@open-mercato/storage-s3 0.6.7-develop.6630.1.c8a615e536 → 0.6.7-develop.6645.1.52f0b37813
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js +30 -4
- package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js.map +2 -2
- package/package.json +4 -4
- package/src/modules/storage_s3/__tests__/s3ListRoute.test.ts +85 -0
- package/src/modules/storage_s3/api/get/storage-providers/s3/list.ts +40 -5
|
@@ -22,6 +22,7 @@ const responseSchema = z.object({
|
|
|
22
22
|
truncated: z.boolean(),
|
|
23
23
|
nextContinuationToken: z.string().optional()
|
|
24
24
|
});
|
|
25
|
+
const DEFAULT_LIST_NAMESPACE = "uploads";
|
|
25
26
|
async function resolveDriver(tenantId, orgId) {
|
|
26
27
|
const { resolve } = await createRequestContainer();
|
|
27
28
|
const credentialsService = resolve("integrationCredentialsService");
|
|
@@ -29,6 +30,26 @@ async function resolveDriver(tenantId, orgId) {
|
|
|
29
30
|
if (!creds) return null;
|
|
30
31
|
return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId });
|
|
31
32
|
}
|
|
33
|
+
function buildTenantPrefix(namespace, tenantId, orgId) {
|
|
34
|
+
return `${namespace}/org_${orgId}/tenant_${tenantId}/`;
|
|
35
|
+
}
|
|
36
|
+
function resolveTenantScopedPrefix(prefix, tenantId, orgId) {
|
|
37
|
+
const normalized = prefix.replace(/^\/+/, "");
|
|
38
|
+
const parts = normalized.split("/");
|
|
39
|
+
const namespace = parts[0] || DEFAULT_LIST_NAMESPACE;
|
|
40
|
+
const tenantPrefix = buildTenantPrefix(namespace, tenantId, orgId);
|
|
41
|
+
if (!normalized || normalized === tenantPrefix.slice(0, -1)) {
|
|
42
|
+
return tenantPrefix;
|
|
43
|
+
}
|
|
44
|
+
if (normalized.startsWith(tenantPrefix)) {
|
|
45
|
+
return normalized;
|
|
46
|
+
}
|
|
47
|
+
if (parts[1]?.startsWith("org_") || parts[2]?.startsWith("tenant_") || normalized.includes(`org_${orgId}/tenant_${tenantId}`)) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const namespaceRelativePrefix = normalized.slice(namespace.length).replace(/^\/+/, "");
|
|
51
|
+
return tenantPrefix + namespaceRelativePrefix;
|
|
52
|
+
}
|
|
32
53
|
async function GET(req) {
|
|
33
54
|
const auth = await getAuthFromRequest(req);
|
|
34
55
|
if (!auth?.tenantId || !auth.orgId) {
|
|
@@ -39,9 +60,13 @@ async function GET(req) {
|
|
|
39
60
|
if (!parsed.success) {
|
|
40
61
|
return NextResponse.json({ error: "Invalid query parameters" }, { status: 400 });
|
|
41
62
|
}
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
63
|
+
const effectivePrefix = resolveTenantScopedPrefix(parsed.data.prefix, auth.tenantId, auth.orgId);
|
|
64
|
+
if (effectivePrefix === null) {
|
|
65
|
+
return NextResponse.json(
|
|
66
|
+
{ error: "Access denied: prefix is not scoped to this tenant." },
|
|
67
|
+
{ status: 403 }
|
|
68
|
+
);
|
|
69
|
+
}
|
|
45
70
|
const driver = await resolveDriver(auth.tenantId, auth.orgId);
|
|
46
71
|
if (!driver) {
|
|
47
72
|
return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
|
|
@@ -73,7 +98,8 @@ const openApi = {
|
|
|
73
98
|
responses: [{ status: 200, description: "File listing", schema: responseSchema }],
|
|
74
99
|
errors: [
|
|
75
100
|
{ status: 400, description: "Invalid params or S3 not configured", schema: z.object({ error: z.string() }) },
|
|
76
|
-
{ status: 401, description: "Unauthorized", schema: z.object({ error: z.string() }) }
|
|
101
|
+
{ status: 401, description: "Unauthorized", schema: z.object({ error: z.string() }) },
|
|
102
|
+
{ status: 403, description: "Prefix not scoped to this tenant", schema: z.object({ error: z.string() }) }
|
|
77
103
|
]
|
|
78
104
|
}
|
|
79
105
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/storage_s3/api/get/storage-providers/s3/list.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/list',\n GET: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst querySchema = z.object({\n prefix: z.string().optional().default(''),\n maxKeys: z.coerce.number().int().min(1).max(1000).optional().default(100),\n continuationToken: z.string().optional(),\n})\n\nconst fileSchema = z.object({\n key: z.string(),\n size: z.number().int(),\n lastModified: z.string(),\n})\n\nconst responseSchema = z.object({\n files: z.array(fileSchema),\n truncated: z.boolean(),\n nextContinuationToken: z.string().optional(),\n})\n\nasync function resolveDriver(tenantId: string, orgId: string): Promise<S3StorageDriver | null> {\n const { resolve } = await createRequestContainer()\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })\n if (!creds) return null\n return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const params = Object.fromEntries(new URL(req.url).searchParams.entries())\n const parsed = querySchema.safeParse(params)\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid query parameters' }, { status: 400 })\n }\n\n // Always scope list operations to the tenant namespace to prevent cross-tenant enumeration.\n const
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,uBAAuB;AAEzB,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC1E;AAEA,MAAM,cAAc,EAAE,OAAO;AAAA,EAC3B,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE;AAAA,EACxC,SAAS,EAAE,OAAO,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAI,EAAE,SAAS,EAAE,QAAQ,GAAG;AAAA,EACxE,mBAAmB,EAAE,OAAO,EAAE,SAAS;AACzC,CAAC;AAED,MAAM,aAAa,EAAE,OAAO;AAAA,EAC1B,KAAK,EAAE,OAAO;AAAA,EACd,MAAM,EAAE,OAAO,EAAE,IAAI;AAAA,EACrB,cAAc,EAAE,OAAO;AACzB,CAAC;AAED,MAAM,iBAAiB,EAAE,OAAO;AAAA,EAC9B,OAAO,EAAE,MAAM,UAAU;AAAA,EACzB,WAAW,EAAE,QAAQ;AAAA,EACrB,uBAAuB,EAAE,OAAO,EAAE,SAAS;AAC7C,CAAC;AAED,eAAe,cAAc,UAAkB,OAAgD;AAC7F,QAAM,EAAE,QAAQ,IAAI,MAAM,uBAAuB;AACjD,QAAM,qBAAqB,QAAQ,+BAA+B;AAGlE,QAAM,QAAQ,MAAM,mBAAmB,QAAQ,cAAc,EAAE,UAAU,gBAAgB,MAAM,CAAC;AAChG,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,IAAI,gBAAgB,EAAE,GAAG,OAAO,gBAAgB,OAAO,SAAS,CAAC;AAC1E;AAEA,eAAsB,IAAI,KAAc;AACtC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,YAAY,CAAC,KAAK,OAAO;AAClC,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AAEA,QAAM,SAAS,OAAO,YAAY,IAAI,IAAI,IAAI,GAAG,EAAE,aAAa,QAAQ,CAAC;AACzE,QAAM,SAAS,YAAY,UAAU,MAAM;AAC3C,MAAI,CAAC,OAAO,SAAS;AACnB,WAAO,aAAa,KAAK,EAAE,OAAO,2BAA2B,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACjF;AAGA,QAAM,
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/list',\n GET: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst querySchema = z.object({\n prefix: z.string().optional().default(''),\n maxKeys: z.coerce.number().int().min(1).max(1000).optional().default(100),\n continuationToken: z.string().optional(),\n})\n\nconst fileSchema = z.object({\n key: z.string(),\n size: z.number().int(),\n lastModified: z.string(),\n})\n\nconst responseSchema = z.object({\n files: z.array(fileSchema),\n truncated: z.boolean(),\n nextContinuationToken: z.string().optional(),\n})\n\nconst DEFAULT_LIST_NAMESPACE = 'uploads'\n\nasync function resolveDriver(tenantId: string, orgId: string): Promise<S3StorageDriver | null> {\n const { resolve } = await createRequestContainer()\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })\n if (!creds) return null\n return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })\n}\n\nfunction buildTenantPrefix(namespace: string, tenantId: string, orgId: string): string {\n return `${namespace}/org_${orgId}/tenant_${tenantId}/`\n}\n\nfunction resolveTenantScopedPrefix(prefix: string, tenantId: string, orgId: string): string | null {\n const normalized = prefix.replace(/^\\/+/, '')\n const parts = normalized.split('/')\n const namespace = parts[0] || DEFAULT_LIST_NAMESPACE\n const tenantPrefix = buildTenantPrefix(namespace, tenantId, orgId)\n\n if (!normalized || normalized === tenantPrefix.slice(0, -1)) {\n return tenantPrefix\n }\n\n if (normalized.startsWith(tenantPrefix)) {\n return normalized\n }\n\n if (\n parts[1]?.startsWith('org_') ||\n parts[2]?.startsWith('tenant_') ||\n normalized.includes(`org_${orgId}/tenant_${tenantId}`)\n ) {\n return null\n }\n\n const namespaceRelativePrefix = normalized.slice(namespace.length).replace(/^\\/+/, '')\n return tenantPrefix + namespaceRelativePrefix\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const params = Object.fromEntries(new URL(req.url).searchParams.entries())\n const parsed = querySchema.safeParse(params)\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid query parameters' }, { status: 400 })\n }\n\n // Always scope list operations to the tenant namespace to prevent cross-tenant enumeration.\n const effectivePrefix = resolveTenantScopedPrefix(parsed.data.prefix, auth.tenantId, auth.orgId)\n if (effectivePrefix === null) {\n return NextResponse.json(\n { error: 'Access denied: prefix is not scoped to this tenant.' },\n { status: 403 },\n )\n }\n\n const driver = await resolveDriver(auth.tenantId, auth.orgId)\n if (!driver) {\n return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })\n }\n\n const result = await driver.listObjects(\n effectivePrefix,\n parsed.data.maxKeys,\n parsed.data.continuationToken,\n )\n\n return NextResponse.json({\n files: result.files.map((f) => ({\n key: f.key,\n size: f.size,\n lastModified: f.lastModified.toISOString(),\n })),\n truncated: result.truncated,\n nextContinuationToken: result.nextContinuationToken,\n })\n}\n\nexport default GET\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'List S3 objects',\n methods: {\n GET: {\n summary: 'List files in S3 by prefix',\n description: 'Returns a paginated list of S3 objects scoped to the authenticated tenant namespace.',\n query: querySchema,\n responses: [{ status: 200, description: 'File listing', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Invalid params or S3 not configured', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Prefix not scoped to this tenant', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,uBAAuB;AAEzB,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC1E;AAEA,MAAM,cAAc,EAAE,OAAO;AAAA,EAC3B,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE;AAAA,EACxC,SAAS,EAAE,OAAO,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAI,EAAE,SAAS,EAAE,QAAQ,GAAG;AAAA,EACxE,mBAAmB,EAAE,OAAO,EAAE,SAAS;AACzC,CAAC;AAED,MAAM,aAAa,EAAE,OAAO;AAAA,EAC1B,KAAK,EAAE,OAAO;AAAA,EACd,MAAM,EAAE,OAAO,EAAE,IAAI;AAAA,EACrB,cAAc,EAAE,OAAO;AACzB,CAAC;AAED,MAAM,iBAAiB,EAAE,OAAO;AAAA,EAC9B,OAAO,EAAE,MAAM,UAAU;AAAA,EACzB,WAAW,EAAE,QAAQ;AAAA,EACrB,uBAAuB,EAAE,OAAO,EAAE,SAAS;AAC7C,CAAC;AAED,MAAM,yBAAyB;AAE/B,eAAe,cAAc,UAAkB,OAAgD;AAC7F,QAAM,EAAE,QAAQ,IAAI,MAAM,uBAAuB;AACjD,QAAM,qBAAqB,QAAQ,+BAA+B;AAGlE,QAAM,QAAQ,MAAM,mBAAmB,QAAQ,cAAc,EAAE,UAAU,gBAAgB,MAAM,CAAC;AAChG,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,IAAI,gBAAgB,EAAE,GAAG,OAAO,gBAAgB,OAAO,SAAS,CAAC;AAC1E;AAEA,SAAS,kBAAkB,WAAmB,UAAkB,OAAuB;AACrF,SAAO,GAAG,SAAS,QAAQ,KAAK,WAAW,QAAQ;AACrD;AAEA,SAAS,0BAA0B,QAAgB,UAAkB,OAA8B;AACjG,QAAM,aAAa,OAAO,QAAQ,QAAQ,EAAE;AAC5C,QAAM,QAAQ,WAAW,MAAM,GAAG;AAClC,QAAM,YAAY,MAAM,CAAC,KAAK;AAC9B,QAAM,eAAe,kBAAkB,WAAW,UAAU,KAAK;AAEjE,MAAI,CAAC,cAAc,eAAe,aAAa,MAAM,GAAG,EAAE,GAAG;AAC3D,WAAO;AAAA,EACT;AAEA,MAAI,WAAW,WAAW,YAAY,GAAG;AACvC,WAAO;AAAA,EACT;AAEA,MACE,MAAM,CAAC,GAAG,WAAW,MAAM,KAC3B,MAAM,CAAC,GAAG,WAAW,SAAS,KAC9B,WAAW,SAAS,OAAO,KAAK,WAAW,QAAQ,EAAE,GACrD;AACA,WAAO;AAAA,EACT;AAEA,QAAM,0BAA0B,WAAW,MAAM,UAAU,MAAM,EAAE,QAAQ,QAAQ,EAAE;AACrF,SAAO,eAAe;AACxB;AAEA,eAAsB,IAAI,KAAc;AACtC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,YAAY,CAAC,KAAK,OAAO;AAClC,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AAEA,QAAM,SAAS,OAAO,YAAY,IAAI,IAAI,IAAI,GAAG,EAAE,aAAa,QAAQ,CAAC;AACzE,QAAM,SAAS,YAAY,UAAU,MAAM;AAC3C,MAAI,CAAC,OAAO,SAAS;AACnB,WAAO,aAAa,KAAK,EAAE,OAAO,2BAA2B,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACjF;AAGA,QAAM,kBAAkB,0BAA0B,OAAO,KAAK,QAAQ,KAAK,UAAU,KAAK,KAAK;AAC/F,MAAI,oBAAoB,MAAM;AAC5B,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,sDAAsD;AAAA,MAC/D,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AAEA,QAAM,SAAS,MAAM,cAAc,KAAK,UAAU,KAAK,KAAK;AAC5D,MAAI,CAAC,QAAQ;AACX,WAAO,aAAa,KAAK,EAAE,OAAO,oCAAoC,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC1F;AAEA,QAAM,SAAS,MAAM,OAAO;AAAA,IAC1B;AAAA,IACA,OAAO,KAAK;AAAA,IACZ,OAAO,KAAK;AAAA,EACd;AAEA,SAAO,aAAa,KAAK;AAAA,IACvB,OAAO,OAAO,MAAM,IAAI,CAAC,OAAO;AAAA,MAC9B,KAAK,EAAE;AAAA,MACP,MAAM,EAAE;AAAA,MACR,cAAc,EAAE,aAAa,YAAY;AAAA,IAC3C,EAAE;AAAA,IACF,WAAW,OAAO;AAAA,IAClB,uBAAuB,OAAO;AAAA,EAChC,CAAC;AACH;AAEA,IAAO,eAAQ;AAER,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,aAAa;AAAA,MACb,OAAO;AAAA,MACP,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,eAAe,CAAC;AAAA,MAChF,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,uCAAuC,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QAC3G,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QACpF,EAAE,QAAQ,KAAK,aAAa,oCAAoC,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,MAC1G;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/storage-s3",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6645.1.52f0b37813",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -69,14 +69,14 @@
|
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@aws-sdk/client-s3": "^3.1085.0",
|
|
71
71
|
"@aws-sdk/s3-request-presigner": "^3.1085.0",
|
|
72
|
-
"@open-mercato/core": "0.6.7-develop.
|
|
72
|
+
"@open-mercato/core": "0.6.7-develop.6645.1.52f0b37813"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"@mikro-orm/postgresql": "^6.6.10",
|
|
76
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
76
|
+
"@open-mercato/shared": "0.6.7-develop.6645.1.52f0b37813"
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
79
|
+
"@open-mercato/shared": "0.6.7-develop.6645.1.52f0b37813",
|
|
80
80
|
"@types/jest": "^30.0.0",
|
|
81
81
|
"esbuild": "^0.28.1",
|
|
82
82
|
"glob": "^13.0.6",
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
const tenantId = 'tenant-1'
|
|
4
|
+
const orgId = 'org-1'
|
|
5
|
+
const listObjectsMock = jest.fn()
|
|
6
|
+
const credentialsResolveMock = jest.fn()
|
|
7
|
+
|
|
8
|
+
jest.mock('@open-mercato/shared/lib/auth/server', () => ({
|
|
9
|
+
getAuthFromRequest: async () => ({ tenantId, orgId, sub: null }),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
13
|
+
createRequestContainer: async () => ({
|
|
14
|
+
resolve: (token: string) => {
|
|
15
|
+
if (token === 'integrationCredentialsService') {
|
|
16
|
+
return { resolve: credentialsResolveMock }
|
|
17
|
+
}
|
|
18
|
+
throw new Error(`Unexpected container token: ${token}`)
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
jest.mock('../lib/s3-driver', () => ({
|
|
24
|
+
S3StorageDriver: jest.fn().mockImplementation(() => ({
|
|
25
|
+
listObjects: listObjectsMock,
|
|
26
|
+
})),
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
import { GET } from '../api/get/storage-providers/s3/list'
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
jest.clearAllMocks()
|
|
33
|
+
credentialsResolveMock.mockResolvedValue({ bucket: 'bucket', region: 'eu-central-1' })
|
|
34
|
+
listObjectsMock.mockResolvedValue({
|
|
35
|
+
files: [],
|
|
36
|
+
truncated: false,
|
|
37
|
+
nextContinuationToken: undefined,
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('storage_s3 list route tenant scoping', () => {
|
|
42
|
+
it('scopes an unqualified namespace prefix to the authenticated tenant', async () => {
|
|
43
|
+
const response = await GET(
|
|
44
|
+
new Request('http://localhost/api/storage-providers/s3/list?prefix=exports/&maxKeys=25&continuationToken=next'),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
expect(response.status).toBe(200)
|
|
48
|
+
expect(listObjectsMock).toHaveBeenCalledWith(
|
|
49
|
+
'exports/org_org-1/tenant_tenant-1/',
|
|
50
|
+
25,
|
|
51
|
+
'next',
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('passes an already scoped tenant prefix through when it is structurally left anchored', async () => {
|
|
56
|
+
const scopedPrefix = 'exports/org_org-1/tenant_tenant-1/reports/'
|
|
57
|
+
const response = await GET(
|
|
58
|
+
new Request(`http://localhost/api/storage-providers/s3/list?prefix=${encodeURIComponent(scopedPrefix)}`),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
expect(response.status).toBe(200)
|
|
62
|
+
expect(listObjectsMock).toHaveBeenCalledWith(scopedPrefix, 100, undefined)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('rejects a prefix scoped to a different tenant before listing S3 objects', async () => {
|
|
66
|
+
const response = await GET(
|
|
67
|
+
new Request('http://localhost/api/storage-providers/s3/list?prefix=exports/org_other/tenant_tenant-1/'),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
expect(response.status).toBe(403)
|
|
71
|
+
await expect(response.json()).resolves.toEqual({
|
|
72
|
+
error: 'Access denied: prefix is not scoped to this tenant.',
|
|
73
|
+
})
|
|
74
|
+
expect(listObjectsMock).not.toHaveBeenCalled()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('rejects a prefix that only contains the tenant scope later in the path', async () => {
|
|
78
|
+
const response = await GET(
|
|
79
|
+
new Request('http://localhost/api/storage-providers/s3/list?prefix=exports/archive/org_org-1/tenant_tenant-1/'),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
expect(response.status).toBe(403)
|
|
83
|
+
expect(listObjectsMock).not.toHaveBeenCalled()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -28,6 +28,8 @@ const responseSchema = z.object({
|
|
|
28
28
|
nextContinuationToken: z.string().optional(),
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
+
const DEFAULT_LIST_NAMESPACE = 'uploads'
|
|
32
|
+
|
|
31
33
|
async function resolveDriver(tenantId: string, orgId: string): Promise<S3StorageDriver | null> {
|
|
32
34
|
const { resolve } = await createRequestContainer()
|
|
33
35
|
const credentialsService = resolve('integrationCredentialsService') as {
|
|
@@ -38,6 +40,36 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
|
|
|
38
40
|
return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
function buildTenantPrefix(namespace: string, tenantId: string, orgId: string): string {
|
|
44
|
+
return `${namespace}/org_${orgId}/tenant_${tenantId}/`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveTenantScopedPrefix(prefix: string, tenantId: string, orgId: string): string | null {
|
|
48
|
+
const normalized = prefix.replace(/^\/+/, '')
|
|
49
|
+
const parts = normalized.split('/')
|
|
50
|
+
const namespace = parts[0] || DEFAULT_LIST_NAMESPACE
|
|
51
|
+
const tenantPrefix = buildTenantPrefix(namespace, tenantId, orgId)
|
|
52
|
+
|
|
53
|
+
if (!normalized || normalized === tenantPrefix.slice(0, -1)) {
|
|
54
|
+
return tenantPrefix
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (normalized.startsWith(tenantPrefix)) {
|
|
58
|
+
return normalized
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (
|
|
62
|
+
parts[1]?.startsWith('org_') ||
|
|
63
|
+
parts[2]?.startsWith('tenant_') ||
|
|
64
|
+
normalized.includes(`org_${orgId}/tenant_${tenantId}`)
|
|
65
|
+
) {
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const namespaceRelativePrefix = normalized.slice(namespace.length).replace(/^\/+/, '')
|
|
70
|
+
return tenantPrefix + namespaceRelativePrefix
|
|
71
|
+
}
|
|
72
|
+
|
|
41
73
|
export async function GET(req: Request) {
|
|
42
74
|
const auth = await getAuthFromRequest(req)
|
|
43
75
|
if (!auth?.tenantId || !auth.orgId) {
|
|
@@ -51,11 +83,13 @@ export async function GET(req: Request) {
|
|
|
51
83
|
}
|
|
52
84
|
|
|
53
85
|
// Always scope list operations to the tenant namespace to prevent cross-tenant enumeration.
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
86
|
+
const effectivePrefix = resolveTenantScopedPrefix(parsed.data.prefix, auth.tenantId, auth.orgId)
|
|
87
|
+
if (effectivePrefix === null) {
|
|
88
|
+
return NextResponse.json(
|
|
89
|
+
{ error: 'Access denied: prefix is not scoped to this tenant.' },
|
|
90
|
+
{ status: 403 },
|
|
91
|
+
)
|
|
92
|
+
}
|
|
59
93
|
|
|
60
94
|
const driver = await resolveDriver(auth.tenantId, auth.orgId)
|
|
61
95
|
if (!driver) {
|
|
@@ -93,6 +127,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
93
127
|
errors: [
|
|
94
128
|
{ status: 400, description: 'Invalid params or S3 not configured', schema: z.object({ error: z.string() }) },
|
|
95
129
|
{ status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },
|
|
130
|
+
{ status: 403, description: 'Prefix not scoped to this tenant', schema: z.object({ error: z.string() }) },
|
|
96
131
|
],
|
|
97
132
|
},
|
|
98
133
|
},
|