@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.
@@ -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 tenantPrefix = `org_${auth.orgId}/tenant_${auth.tenantId}/`;
43
- const userPrefix = parsed.data.prefix;
44
- const effectivePrefix = userPrefix.includes(`org_${auth.orgId}/tenant_${auth.tenantId}`) ? userPrefix : tenantPrefix + userPrefix.replace(/^\//, "");
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 tenantPrefix = `org_${auth.orgId}/tenant_${auth.tenantId}/`\n const userPrefix = parsed.data.prefix\n const effectivePrefix = userPrefix.includes(`org_${auth.orgId}/tenant_${auth.tenantId}`)\n ? userPrefix\n : tenantPrefix + userPrefix.replace(/^\\//, '')\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 ],\n },\n },\n}\n"],
5
- "mappings": "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",
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": "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",
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.6630.1.c8a615e536",
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.6630.1.c8a615e536"
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.6630.1.c8a615e536"
76
+ "@open-mercato/shared": "0.6.7-develop.6645.1.52f0b37813"
77
77
  },
78
78
  "devDependencies": {
79
- "@open-mercato/shared": "0.6.7-develop.6630.1.c8a615e536",
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 tenantPrefix = `org_${auth.orgId}/tenant_${auth.tenantId}/`
55
- const userPrefix = parsed.data.prefix
56
- const effectivePrefix = userPrefix.includes(`org_${auth.orgId}/tenant_${auth.tenantId}`)
57
- ? userPrefix
58
- : tenantPrefix + userPrefix.replace(/^\//, '')
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
  },