@open-mercato/storage-s3 0.6.6-develop.6429.1.2fba7258d1 → 0.6.6-develop.6450.1.b493fb430c
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/.turbo/turbo-build.log +1 -1
- package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js +2 -5
- package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js.map +2 -2
- package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js +2 -5
- package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js.map +2 -2
- package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js +4 -6
- package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js.map +2 -2
- package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js +3 -6
- package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js.map +2 -2
- package/dist/modules/storage_s3/lib/key-scope.js +26 -0
- package/dist/modules/storage_s3/lib/key-scope.js.map +7 -0
- package/package.json +4 -4
- package/src/modules/storage_s3/__tests__/s3Routes.test.ts +122 -0
- package/src/modules/storage_s3/api/delete/storage-providers/s3/delete.ts +2 -6
- package/src/modules/storage_s3/api/get/storage-providers/s3/download.ts +2 -6
- package/src/modules/storage_s3/api/post/storage-providers/s3/signed-url.ts +6 -7
- package/src/modules/storage_s3/api/post/storage-providers/s3/upload.ts +3 -7
- package/src/modules/storage_s3/lib/key-scope.ts +29 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
Found
|
|
1
|
+
Found 19 entry points
|
|
2
2
|
storage-s3 built successfully
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
4
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
|
+
import { isS3KeyAddressableByScope } from "../../../../lib/key-scope.js";
|
|
5
6
|
import { S3StorageDriver } from "../../../../lib/s3-driver.js";
|
|
6
7
|
const metadata = {
|
|
7
8
|
path: "/storage-providers/s3/delete",
|
|
@@ -17,10 +18,6 @@ async function resolveDriver(tenantId, orgId) {
|
|
|
17
18
|
if (!creds) return null;
|
|
18
19
|
return new S3StorageDriver(creds);
|
|
19
20
|
}
|
|
20
|
-
function isKeyScoped(key, orgId, tenantId) {
|
|
21
|
-
const parts = key.split("/");
|
|
22
|
-
return parts.length >= 3 && parts[1] === `org_${orgId}` && parts[2] === `tenant_${tenantId}`;
|
|
23
|
-
}
|
|
24
21
|
async function DELETE(req) {
|
|
25
22
|
const auth = await getAuthFromRequest(req);
|
|
26
23
|
if (!auth?.tenantId || !auth.orgId) {
|
|
@@ -31,7 +28,7 @@ async function DELETE(req) {
|
|
|
31
28
|
if (!parsed.success) {
|
|
32
29
|
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
|
|
33
30
|
}
|
|
34
|
-
if (!
|
|
31
|
+
if (!isS3KeyAddressableByScope(parsed.data.key, auth.orgId, auth.tenantId)) {
|
|
35
32
|
return NextResponse.json({ error: "Access denied: key is not scoped to this tenant." }, { status: 403 });
|
|
36
33
|
}
|
|
37
34
|
const driver = await resolveDriver(auth.tenantId, auth.orgId);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/storage_s3/api/delete/storage-providers/s3/delete.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/delete',\n DELETE: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst requestSchema = z.object({\n key: z.string().min(1),\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)\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,QAAQ,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC7E;AAEA,MAAM,gBAAgB,EAAE,OAAO;AAAA,EAC7B,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC;AACvB,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,KAAK;AAClC;AAEA,
|
|
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 { isS3KeyAddressableByScope } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/delete',\n DELETE: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst requestSchema = z.object({\n key: z.string().min(1),\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)\n}\n\nexport async function DELETE(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 json = await req.json().catch(() => null)\n const parsed = requestSchema.safeParse(json)\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })\n }\n\n if (!isS3KeyAddressableByScope(parsed.data.key, auth.orgId, auth.tenantId)) {\n return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })\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 await driver.delete('', parsed.data.key)\n return new NextResponse(null, { status: 204 })\n}\n\nexport default DELETE\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Delete file from S3',\n methods: {\n DELETE: {\n summary: 'Delete a file from S3 by key',\n description: 'Permanently removes the object at the given key from the configured S3 bucket.',\n requestBody: { contentType: 'application/json', schema: requestSchema },\n responses: [{ status: 204, description: 'File deleted', schema: z.null() }],\n errors: [\n { status: 400, description: 'Invalid payload 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: 'Key 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,iCAAiC;AAC1C,SAAS,uBAAuB;AAEzB,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,QAAQ,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC7E;AAEA,MAAM,gBAAgB,EAAE,OAAO;AAAA,EAC7B,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC;AACvB,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,KAAK;AAClC;AAEA,eAAsB,OAAO,KAAc;AACzC,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,OAAO,MAAM,IAAI,KAAK,EAAE,MAAM,MAAM,IAAI;AAC9C,QAAM,SAAS,cAAc,UAAU,IAAI;AAC3C,MAAI,CAAC,OAAO,SAAS;AACnB,WAAO,aAAa,KAAK,EAAE,OAAO,kBAAkB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACxE;AAEA,MAAI,CAAC,0BAA0B,OAAO,KAAK,KAAK,KAAK,OAAO,KAAK,QAAQ,GAAG;AAC1E,WAAO,aAAa,KAAK,EAAE,OAAO,mDAAmD,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACzG;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,OAAO,OAAO,IAAI,OAAO,KAAK,GAAG;AACvC,SAAO,IAAI,aAAa,MAAM,EAAE,QAAQ,IAAI,CAAC;AAC/C;AAEA,IAAO,iBAAQ;AAER,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,QAAQ;AAAA,MACN,SAAS;AAAA,MACT,aAAa;AAAA,MACb,aAAa,EAAE,aAAa,oBAAoB,QAAQ,cAAc;AAAA,MACtE,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,EAAE,KAAK,EAAE,CAAC;AAAA,MAC1E,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,wCAAwC,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QAC5G,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QACpF,EAAE,QAAQ,KAAK,aAAa,iCAAiC,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,MACvG;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
canRenderInlineAttachment,
|
|
8
8
|
sanitizeUploadedFileName
|
|
9
9
|
} from "@open-mercato/core/modules/attachments/lib/security";
|
|
10
|
+
import { isS3KeyAddressableByScope } from "../../../../lib/key-scope.js";
|
|
10
11
|
import { S3StorageDriver } from "../../../../lib/s3-driver.js";
|
|
11
12
|
const metadata = {
|
|
12
13
|
path: "/storage-providers/s3/download",
|
|
@@ -19,10 +20,6 @@ async function resolveDriver(tenantId, orgId) {
|
|
|
19
20
|
if (!creds) return null;
|
|
20
21
|
return new S3StorageDriver(creds);
|
|
21
22
|
}
|
|
22
|
-
function isKeyScoped(key, orgId, tenantId) {
|
|
23
|
-
const parts = key.split("/");
|
|
24
|
-
return parts.length >= 3 && parts[1] === `org_${orgId}` && parts[2] === `tenant_${tenantId}`;
|
|
25
|
-
}
|
|
26
23
|
async function GET(req) {
|
|
27
24
|
const auth = await getAuthFromRequest(req);
|
|
28
25
|
if (!auth?.tenantId || !auth.orgId) {
|
|
@@ -32,7 +29,7 @@ async function GET(req) {
|
|
|
32
29
|
if (!key) {
|
|
33
30
|
return NextResponse.json({ error: "key query param is required" }, { status: 400 });
|
|
34
31
|
}
|
|
35
|
-
if (!
|
|
32
|
+
if (!isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)) {
|
|
36
33
|
return NextResponse.json({ error: "Access denied: key is not scoped to this tenant." }, { status: 403 });
|
|
37
34
|
}
|
|
38
35
|
const driver = await resolveDriver(auth.tenantId, auth.orgId);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/storage_s3/api/get/storage-providers/s3/download.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 {\n buildAttachmentContentDisposition,\n canRenderInlineAttachment,\n sanitizeUploadedFileName,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/download',\n GET: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\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)\n}\n\
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,uBAAuB;AAEzB,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC1E;AAEA,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,KAAK;AAClC;AAEA,
|
|
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 {\n buildAttachmentContentDisposition,\n canRenderInlineAttachment,\n sanitizeUploadedFileName,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport { isS3KeyAddressableByScope } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/download',\n GET: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\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)\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 key = new URL(req.url).searchParams.get('key')\n if (!key) {\n return NextResponse.json({ error: 'key query param is required' }, { status: 400 })\n }\n\n if (!isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)) {\n return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })\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 let buffer: Buffer\n let contentType: string | undefined\n try {\n const result = await driver.read('', key)\n buffer = result.buffer\n contentType = result.contentType\n } catch {\n return NextResponse.json({ error: 'File not found' }, { status: 404 })\n }\n\n const fileName = sanitizeUploadedFileName(key.split('/').pop() || 'download')\n const renderInline = canRenderInlineAttachment(contentType)\n const responseContentType = renderInline ? (contentType ?? 'application/octet-stream') : 'application/octet-stream'\n\n return new NextResponse(new Uint8Array(buffer), {\n status: 200,\n headers: {\n 'Content-Security-Policy': \"default-src 'none'; sandbox\",\n 'Content-Disposition': buildAttachmentContentDisposition(fileName, renderInline ? 'inline' : 'attachment'),\n 'Content-Length': String(buffer.length),\n 'Content-Type': responseContentType,\n 'X-Content-Type-Options': 'nosniff',\n },\n })\n}\n\nexport default GET\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Download file from S3',\n methods: {\n GET: {\n summary: 'Download a file from S3 by key',\n description: 'Streams the file content for the given S3 key. Requires storage_providers.manage feature.',\n query: z.object({ key: z.string().describe('S3 object key') }),\n responses: [{ status: 200, description: 'File content stream', schema: z.any() }],\n errors: [\n { status: 400, description: 'Missing key 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: 'Key not scoped to this tenant', schema: z.object({ error: z.string() }) },\n { status: 404, description: 'File not found', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
4
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
|
+
import { isS3KeyAddressableByScope, isS3KeyScopedToTenant } from "../../../../lib/key-scope.js";
|
|
5
6
|
import { S3StorageDriver } from "../../../../lib/s3-driver.js";
|
|
6
7
|
const metadata = {
|
|
7
8
|
path: "/storage-providers/s3/signed-url",
|
|
@@ -24,10 +25,6 @@ async function resolveDriver(tenantId, orgId) {
|
|
|
24
25
|
if (!creds) return null;
|
|
25
26
|
return new S3StorageDriver(creds);
|
|
26
27
|
}
|
|
27
|
-
function isKeyScoped(key, orgId, tenantId) {
|
|
28
|
-
const parts = key.split("/");
|
|
29
|
-
return parts.length >= 3 && parts[1] === `org_${orgId}` && parts[2] === `tenant_${tenantId}`;
|
|
30
|
-
}
|
|
31
28
|
async function POST(req) {
|
|
32
29
|
const auth = await getAuthFromRequest(req);
|
|
33
30
|
if (!auth?.tenantId || !auth.orgId) {
|
|
@@ -38,14 +35,15 @@ async function POST(req) {
|
|
|
38
35
|
if (!parsed.success) {
|
|
39
36
|
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
|
|
40
37
|
}
|
|
41
|
-
|
|
38
|
+
const { key, operation, expiresIn, contentType } = parsed.data;
|
|
39
|
+
const canAccessKey = operation === "download" ? isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId) : isS3KeyScopedToTenant(key, auth.orgId, auth.tenantId);
|
|
40
|
+
if (!canAccessKey) {
|
|
42
41
|
return NextResponse.json({ error: "Access denied: key is not scoped to this tenant." }, { status: 403 });
|
|
43
42
|
}
|
|
44
43
|
const driver = await resolveDriver(auth.tenantId, auth.orgId);
|
|
45
44
|
if (!driver) {
|
|
46
45
|
return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
|
|
47
46
|
}
|
|
48
|
-
const { key, operation, expiresIn, contentType } = parsed.data;
|
|
49
47
|
const url = await driver.getSignedUrl(key, operation, expiresIn, contentType);
|
|
50
48
|
const expiresAt = new Date(Date.now() + expiresIn * 1e3).toISOString();
|
|
51
49
|
return NextResponse.json({ url, expiresAt });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/storage_s3/api/post/storage-providers/s3/signed-url.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/signed-url',\n POST: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst requestSchema = z.object({\n key: z.string().min(1),\n operation: z.enum(['upload', 'download']),\n expiresIn: z.number().int().min(60).max(604800).optional().default(3600),\n contentType: z.string().optional(),\n})\n\nconst responseSchema = z.object({\n url: z.string(),\n expiresAt: z.string(),\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)\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,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC3E;AAEA,MAAM,gBAAgB,EAAE,OAAO;AAAA,EAC7B,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EACrB,WAAW,EAAE,KAAK,CAAC,UAAU,UAAU,CAAC;AAAA,EACxC,WAAW,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,IAAI,MAAM,EAAE,SAAS,EAAE,QAAQ,IAAI;AAAA,EACvE,aAAa,EAAE,OAAO,EAAE,SAAS;AACnC,CAAC;AAED,MAAM,iBAAiB,EAAE,OAAO;AAAA,EAC9B,KAAK,EAAE,OAAO;AAAA,EACd,WAAW,EAAE,OAAO;AACtB,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,KAAK;AAClC;AAEA,
|
|
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 { isS3KeyAddressableByScope, isS3KeyScopedToTenant } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/signed-url',\n POST: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst requestSchema = z.object({\n key: z.string().min(1),\n operation: z.enum(['upload', 'download']),\n expiresIn: z.number().int().min(60).max(604800).optional().default(3600),\n contentType: z.string().optional(),\n})\n\nconst responseSchema = z.object({\n url: z.string(),\n expiresAt: z.string(),\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)\n}\n\nexport async function POST(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 json = await req.json().catch(() => null)\n const parsed = requestSchema.safeParse(json)\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })\n }\n\n const { key, operation, expiresIn, contentType } = parsed.data\n const canAccessKey = operation === 'download'\n ? isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)\n : isS3KeyScopedToTenant(key, auth.orgId, auth.tenantId)\n if (!canAccessKey) {\n return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })\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 url = await driver.getSignedUrl(key, operation, expiresIn, contentType)\n const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString()\n\n return NextResponse.json({ url, expiresAt })\n}\n\nexport default POST\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Generate S3 pre-signed URL',\n methods: {\n POST: {\n summary: 'Generate a pre-signed URL for direct browser upload or download',\n description: 'Returns a time-limited URL that allows a browser to directly upload or download a file from S3.',\n requestBody: { contentType: 'application/json', schema: requestSchema },\n responses: [{ status: 200, description: 'Pre-signed URL and expiry', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Invalid payload 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: 'Key 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
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
resolveAttachmentMaxBytes,
|
|
13
13
|
willExceedAttachmentTenantQuota
|
|
14
14
|
} from "@open-mercato/core/modules/attachments/lib/upload-limits";
|
|
15
|
+
import { isS3KeyScopedToTenant } from "../../../../lib/key-scope.js";
|
|
15
16
|
import { S3StorageDriver } from "../../../../lib/s3-driver.js";
|
|
16
17
|
import { randomUUID } from "crypto";
|
|
17
18
|
const metadata = {
|
|
@@ -27,10 +28,6 @@ const responseSchema = z.object({
|
|
|
27
28
|
function sanitizeFileName(name) {
|
|
28
29
|
return name.replace(/[^a-zA-Z0-9._-]/g, "_") || "upload";
|
|
29
30
|
}
|
|
30
|
-
function isKeyScoped(key, orgId, tenantId) {
|
|
31
|
-
const parts = key.split("/");
|
|
32
|
-
return parts.length >= 3 && parts[1] === `org_${orgId}` && parts[2] === `tenant_${tenantId}`;
|
|
33
|
-
}
|
|
34
31
|
async function resolveDriver(tenantId, orgId) {
|
|
35
32
|
const { resolve } = await createRequestContainer();
|
|
36
33
|
const credentialsService = resolve("integrationCredentialsService");
|
|
@@ -44,7 +41,7 @@ async function readTenantStorageUsageBytes(driver, tenantId, orgId) {
|
|
|
44
41
|
do {
|
|
45
42
|
const page = await driver.listObjects("", 1e3, continuationToken);
|
|
46
43
|
for (const file of page.files) {
|
|
47
|
-
if (
|
|
44
|
+
if (isS3KeyScopedToTenant(file.key, orgId, tenantId)) {
|
|
48
45
|
totalBytes += file.size;
|
|
49
46
|
}
|
|
50
47
|
}
|
|
@@ -70,7 +67,7 @@ async function POST(req) {
|
|
|
70
67
|
return NextResponse.json({ error: "file field is required" }, { status: 400 });
|
|
71
68
|
}
|
|
72
69
|
const keyOverride = form.get("key") ? String(form.get("key")) : null;
|
|
73
|
-
if (keyOverride !== null && !
|
|
70
|
+
if (keyOverride !== null && !isS3KeyScopedToTenant(keyOverride, auth.orgId, auth.tenantId)) {
|
|
74
71
|
return NextResponse.json(
|
|
75
72
|
{ error: "Access denied: key override is not scoped to this tenant." },
|
|
76
73
|
{ status: 403 }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/storage_s3/api/post/storage-providers/s3/upload.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 {\n detectAttachmentMimeType,\n hasDangerousExecutableExtension,\n isActiveContentAttachment,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport {\n isMultipartRequestWithinUploadLimit,\n resolveAttachmentMaxBytes,\n willExceedAttachmentTenantQuota,\n} from '@open-mercato/core/modules/attachments/lib/upload-limits'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\nimport { randomUUID } from 'crypto'\n\nexport const metadata = {\n path: '/storage-providers/s3/upload',\n POST: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst responseSchema = z.object({\n key: z.string(),\n bucket: z.string(),\n size: z.number().int(),\n contentType: z.string().optional(),\n})\n\nfunction sanitizeFileName(name: string): string {\n return name.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload'\n}\n\
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,uBAAuB;AAChC,SAAS,kBAAkB;AAEpB,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC3E;AAEA,MAAM,iBAAiB,EAAE,OAAO;AAAA,EAC9B,KAAK,EAAE,OAAO;AAAA,EACd,QAAQ,EAAE,OAAO;AAAA,EACjB,MAAM,EAAE,OAAO,EAAE,IAAI;AAAA,EACrB,aAAa,EAAE,OAAO,EAAE,SAAS;AACnC,CAAC;AAED,SAAS,iBAAiB,MAAsB;AAC9C,SAAO,KAAK,QAAQ,oBAAoB,GAAG,KAAK;AAClD;AAEA,
|
|
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 {\n detectAttachmentMimeType,\n hasDangerousExecutableExtension,\n isActiveContentAttachment,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport {\n isMultipartRequestWithinUploadLimit,\n resolveAttachmentMaxBytes,\n willExceedAttachmentTenantQuota,\n} from '@open-mercato/core/modules/attachments/lib/upload-limits'\nimport { isS3KeyScopedToTenant } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\nimport { randomUUID } from 'crypto'\n\nexport const metadata = {\n path: '/storage-providers/s3/upload',\n POST: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst responseSchema = z.object({\n key: z.string(),\n bucket: z.string(),\n size: z.number().int(),\n contentType: z.string().optional(),\n})\n\nfunction sanitizeFileName(name: string): string {\n return name.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload'\n}\n\nasync function resolveDriver(\n tenantId: string,\n orgId: string,\n): 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)\n}\n\nasync function readTenantStorageUsageBytes(\n driver: S3StorageDriver,\n tenantId: string,\n orgId: string,\n): Promise<number> {\n let totalBytes = 0\n let continuationToken: string | undefined\n\n do {\n const page = await driver.listObjects('', 1000, continuationToken)\n for (const file of page.files) {\n if (isS3KeyScopedToTenant(file.key, orgId, tenantId)) {\n totalBytes += file.size\n }\n }\n continuationToken = page.truncated ? page.nextContinuationToken : undefined\n } while (continuationToken)\n\n return totalBytes\n}\n\nexport async function POST(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 if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {\n return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })\n }\n\n const contentType = req.headers.get('content-type') ?? ''\n if (!contentType.includes('multipart/form-data')) {\n return NextResponse.json({ error: 'Expected multipart/form-data' }, { status: 400 })\n }\n\n const form = await req.formData()\n const file = form.get('file') as File | null\n if (!file) {\n return NextResponse.json({ error: 'file field is required' }, { status: 400 })\n }\n\n const keyOverride = form.get('key') ? String(form.get('key')) : null\n\n if (keyOverride !== null && !isS3KeyScopedToTenant(keyOverride, auth.orgId, auth.tenantId)) {\n return NextResponse.json(\n { error: 'Access denied: key override is not scoped to this tenant.' },\n { status: 403 },\n )\n }\n\n if (hasDangerousExecutableExtension(file.name)) {\n return NextResponse.json({ error: 'Executable file types are not allowed as attachments.' }, { status: 400 })\n }\n\n const effectiveMaxBytes = resolveAttachmentMaxBytes()\n if (file.size > effectiveMaxBytes) {\n return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })\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 buffer = Buffer.from(await file.arrayBuffer())\n const safeName = sanitizeFileName(file.name)\n const trustedMimeType = detectAttachmentMimeType(buffer, safeName, file.type || null)\n if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {\n return NextResponse.json({ error: 'Active content uploads are not allowed.' }, { status: 400 })\n }\n\n const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId)\n if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {\n return NextResponse.json({ error: 'Attachment storage quota exceeded for this tenant.' }, { status: 413 })\n }\n\n const key =\n keyOverride ??\n `uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`\n\n await driver.putObject(key, buffer, trustedMimeType)\n\n return NextResponse.json({\n key,\n bucket: driver.getBucket(),\n size: buffer.length,\n contentType: trustedMimeType,\n })\n}\n\nexport default POST\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Upload file to S3',\n methods: {\n POST: {\n summary: 'Upload a file directly to S3',\n description: 'Uploads a file to the configured S3 bucket. Requires storage_providers.manage feature.',\n requestBody: {\n contentType: 'multipart/form-data',\n schema: z.object({\n file: z.any().describe('File to upload'),\n key: z.string().optional().describe('Optional S3 key override (must be scoped to org/tenant)'),\n contentType: z.string().optional().describe('Optional client-provided content-type hint; the server derives the trusted MIME type.'),\n }),\n },\n responses: [{ status: 200, description: 'Upload result', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Missing file, blocked file type, 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: 'Key override not scoped to this tenant', schema: z.object({ error: z.string() }) },\n { status: 413, description: 'Upload too large or tenant storage quota exceeded', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const SHARED_ORG_SEGMENT = "org_shared";
|
|
2
|
+
const SHARED_TENANT_SEGMENT = "tenant_shared";
|
|
3
|
+
function findScopeSegmentIndex(parts) {
|
|
4
|
+
return parts.findIndex((part, index) => part.startsWith("org_") && parts[index + 1]?.startsWith("tenant_"));
|
|
5
|
+
}
|
|
6
|
+
function hasScopeSegments(key, orgSegment, tenantSegment) {
|
|
7
|
+
const parts = key.split("/");
|
|
8
|
+
const scopeIndex = findScopeSegmentIndex(parts);
|
|
9
|
+
return scopeIndex >= 0 && parts[scopeIndex] === orgSegment && parts[scopeIndex + 1] === tenantSegment;
|
|
10
|
+
}
|
|
11
|
+
function isS3KeyScopedToTenant(key, orgId, tenantId) {
|
|
12
|
+
if (!orgId || !tenantId) return false;
|
|
13
|
+
return hasScopeSegments(key, `org_${orgId}`, `tenant_${tenantId}`);
|
|
14
|
+
}
|
|
15
|
+
function isS3KeyShared(key) {
|
|
16
|
+
return hasScopeSegments(key, SHARED_ORG_SEGMENT, SHARED_TENANT_SEGMENT);
|
|
17
|
+
}
|
|
18
|
+
function isS3KeyAddressableByScope(key, orgId, tenantId) {
|
|
19
|
+
return isS3KeyScopedToTenant(key, orgId, tenantId) || isS3KeyShared(key);
|
|
20
|
+
}
|
|
21
|
+
export {
|
|
22
|
+
isS3KeyAddressableByScope,
|
|
23
|
+
isS3KeyScopedToTenant,
|
|
24
|
+
isS3KeyShared
|
|
25
|
+
};
|
|
26
|
+
//# sourceMappingURL=key-scope.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/storage_s3/lib/key-scope.ts"],
|
|
4
|
+
"sourcesContent": ["const SHARED_ORG_SEGMENT = 'org_shared'\nconst SHARED_TENANT_SEGMENT = 'tenant_shared'\n\nfunction findScopeSegmentIndex(parts: string[]): number {\n return parts.findIndex((part, index) => (\n part.startsWith('org_') && parts[index + 1]?.startsWith('tenant_')\n ))\n}\n\nfunction hasScopeSegments(key: string, orgSegment: string, tenantSegment: string): boolean {\n const parts = key.split('/')\n const scopeIndex = findScopeSegmentIndex(parts)\n return scopeIndex >= 0\n && parts[scopeIndex] === orgSegment\n && parts[scopeIndex + 1] === tenantSegment\n}\n\nexport function isS3KeyScopedToTenant(key: string, orgId: string, tenantId: string): boolean {\n if (!orgId || !tenantId) return false\n return hasScopeSegments(key, `org_${orgId}`, `tenant_${tenantId}`)\n}\n\nexport function isS3KeyShared(key: string): boolean {\n return hasScopeSegments(key, SHARED_ORG_SEGMENT, SHARED_TENANT_SEGMENT)\n}\n\nexport function isS3KeyAddressableByScope(key: string, orgId: string, tenantId: string): boolean {\n return isS3KeyScopedToTenant(key, orgId, tenantId) || isS3KeyShared(key)\n}\n"],
|
|
5
|
+
"mappings": "AAAA,MAAM,qBAAqB;AAC3B,MAAM,wBAAwB;AAE9B,SAAS,sBAAsB,OAAyB;AACtD,SAAO,MAAM,UAAU,CAAC,MAAM,UAC5B,KAAK,WAAW,MAAM,KAAK,MAAM,QAAQ,CAAC,GAAG,WAAW,SAAS,CAClE;AACH;AAEA,SAAS,iBAAiB,KAAa,YAAoB,eAAgC;AACzF,QAAM,QAAQ,IAAI,MAAM,GAAG;AAC3B,QAAM,aAAa,sBAAsB,KAAK;AAC9C,SAAO,cAAc,KAChB,MAAM,UAAU,MAAM,cACtB,MAAM,aAAa,CAAC,MAAM;AACjC;AAEO,SAAS,sBAAsB,KAAa,OAAe,UAA2B;AAC3F,MAAI,CAAC,SAAS,CAAC,SAAU,QAAO;AAChC,SAAO,iBAAiB,KAAK,OAAO,KAAK,IAAI,UAAU,QAAQ,EAAE;AACnE;AAEO,SAAS,cAAc,KAAsB;AAClD,SAAO,iBAAiB,KAAK,oBAAoB,qBAAqB;AACxE;AAEO,SAAS,0BAA0B,KAAa,OAAe,UAA2B;AAC/F,SAAO,sBAAsB,KAAK,OAAO,QAAQ,KAAK,cAAc,GAAG;AACzE;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/storage-s3",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6450.1.b493fb430c",
|
|
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.1075.0",
|
|
71
71
|
"@aws-sdk/s3-request-presigner": "^3.1075.0",
|
|
72
|
-
"@open-mercato/core": "0.6.6-develop.
|
|
72
|
+
"@open-mercato/core": "0.6.6-develop.6450.1.b493fb430c"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"@mikro-orm/postgresql": "^6.6.10",
|
|
76
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
76
|
+
"@open-mercato/shared": "0.6.6-develop.6450.1.b493fb430c"
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
79
|
+
"@open-mercato/shared": "0.6.6-develop.6450.1.b493fb430c",
|
|
80
80
|
"@types/jest": "^30.0.0",
|
|
81
81
|
"esbuild": "^0.28.1",
|
|
82
82
|
"glob": "^13.0.6",
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
const mockGetAuthFromRequest = jest.fn()
|
|
4
|
+
jest.mock('@open-mercato/shared/lib/auth/server', () => ({
|
|
5
|
+
getAuthFromRequest: (...args: unknown[]) => mockGetAuthFromRequest(...args),
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
const mockResolveCredentials = jest.fn()
|
|
9
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
10
|
+
createRequestContainer: jest.fn(async () => ({
|
|
11
|
+
resolve: (name: string) => {
|
|
12
|
+
if (name === 'integrationCredentialsService') {
|
|
13
|
+
return { resolve: mockResolveCredentials }
|
|
14
|
+
}
|
|
15
|
+
throw new Error(`Unexpected dependency: ${name}`)
|
|
16
|
+
},
|
|
17
|
+
})),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
const mockRead = jest.fn()
|
|
21
|
+
const mockDelete = jest.fn()
|
|
22
|
+
const mockGetSignedUrl = jest.fn()
|
|
23
|
+
jest.mock('../lib/s3-driver', () => ({
|
|
24
|
+
S3StorageDriver: jest.fn().mockImplementation(() => ({
|
|
25
|
+
read: (...args: unknown[]) => mockRead(...args),
|
|
26
|
+
delete: (...args: unknown[]) => mockDelete(...args),
|
|
27
|
+
getSignedUrl: (...args: unknown[]) => mockGetSignedUrl(...args),
|
|
28
|
+
})),
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
import { DELETE } from '../api/delete/storage-providers/s3/delete'
|
|
32
|
+
import { GET } from '../api/get/storage-providers/s3/download'
|
|
33
|
+
import { POST } from '../api/post/storage-providers/s3/signed-url'
|
|
34
|
+
|
|
35
|
+
const AUTH = { tenantId: 'tenant-1', orgId: 'org-1' }
|
|
36
|
+
const SHARED_KEY = 'docs/org_shared/tenant_shared/shared.txt'
|
|
37
|
+
const FOREIGN_KEY = 'docs/org_other/tenant_tenant-1/private.txt'
|
|
38
|
+
const NESTED_FOREIGN_KEY = 'docs/org_other/tenant_tenant-1/org_org-1/tenant_tenant-1/private.txt'
|
|
39
|
+
|
|
40
|
+
function jsonRequest(body: unknown): Request {
|
|
41
|
+
return new Request('http://example.test/api/storage-providers/s3', {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: { 'content-type': 'application/json' },
|
|
44
|
+
body: JSON.stringify(body),
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('storage_s3 standalone route key scope', () => {
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
mockGetAuthFromRequest.mockResolvedValue(AUTH)
|
|
51
|
+
mockResolveCredentials.mockResolvedValue({ bucket: 'test-bucket', region: 'eu-central-1' })
|
|
52
|
+
mockRead.mockReset()
|
|
53
|
+
mockDelete.mockReset()
|
|
54
|
+
mockGetSignedUrl.mockReset()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('downloads shared namespace objects for an authorized storage manager', async () => {
|
|
58
|
+
mockRead.mockResolvedValueOnce({
|
|
59
|
+
buffer: Buffer.from('shared-bytes'),
|
|
60
|
+
contentType: 'text/plain',
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const response = await GET(
|
|
64
|
+
new Request(`http://example.test/api/storage-providers/s3/download?key=${encodeURIComponent(SHARED_KEY)}`),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
expect(response.status).toBe(200)
|
|
68
|
+
await expect(response.text()).resolves.toBe('shared-bytes')
|
|
69
|
+
expect(mockRead).toHaveBeenCalledWith('', SHARED_KEY)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('generates signed URLs for shared namespace objects', async () => {
|
|
73
|
+
mockGetSignedUrl.mockResolvedValueOnce('https://s3.example.test/shared?sig=abc')
|
|
74
|
+
|
|
75
|
+
const response = await POST(jsonRequest({
|
|
76
|
+
key: SHARED_KEY,
|
|
77
|
+
operation: 'download',
|
|
78
|
+
expiresIn: 600,
|
|
79
|
+
}))
|
|
80
|
+
|
|
81
|
+
expect(response.status).toBe(200)
|
|
82
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
83
|
+
url: 'https://s3.example.test/shared?sig=abc',
|
|
84
|
+
})
|
|
85
|
+
expect(mockGetSignedUrl).toHaveBeenCalledWith(SHARED_KEY, 'download', 600, undefined)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('keeps shared namespace keys unavailable for signed upload URLs', async () => {
|
|
89
|
+
const response = await POST(jsonRequest({
|
|
90
|
+
key: SHARED_KEY,
|
|
91
|
+
operation: 'upload',
|
|
92
|
+
expiresIn: 600,
|
|
93
|
+
}))
|
|
94
|
+
|
|
95
|
+
expect(response.status).toBe(403)
|
|
96
|
+
expect(mockGetSignedUrl).not.toHaveBeenCalled()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('deletes shared namespace objects for an authorized storage manager', async () => {
|
|
100
|
+
mockDelete.mockResolvedValueOnce(undefined)
|
|
101
|
+
|
|
102
|
+
const response = await DELETE(new Request('http://example.test/api/storage-providers/s3/delete', {
|
|
103
|
+
method: 'DELETE',
|
|
104
|
+
headers: { 'content-type': 'application/json' },
|
|
105
|
+
body: JSON.stringify({ key: SHARED_KEY }),
|
|
106
|
+
}))
|
|
107
|
+
|
|
108
|
+
expect(response.status).toBe(204)
|
|
109
|
+
expect(mockDelete).toHaveBeenCalledWith('', SHARED_KEY)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('keeps rejecting keys scoped to another organization or tenant', async () => {
|
|
113
|
+
for (const key of [FOREIGN_KEY, NESTED_FOREIGN_KEY]) {
|
|
114
|
+
const response = await GET(
|
|
115
|
+
new Request(`http://example.test/api/storage-providers/s3/download?key=${encodeURIComponent(key)}`),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
expect(response.status).toBe(403)
|
|
119
|
+
}
|
|
120
|
+
expect(mockRead).not.toHaveBeenCalled()
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
|
+
import { isS3KeyAddressableByScope } from '../../../../lib/key-scope'
|
|
6
7
|
import { S3StorageDriver } from '../../../../lib/s3-driver'
|
|
7
8
|
|
|
8
9
|
export const metadata = {
|
|
@@ -24,11 +25,6 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
|
|
|
24
25
|
return new S3StorageDriver(creds)
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
function isKeyScoped(key: string, orgId: string, tenantId: string): boolean {
|
|
28
|
-
const parts = key.split('/')
|
|
29
|
-
return parts.length >= 3 && parts[1] === `org_${orgId}` && parts[2] === `tenant_${tenantId}`
|
|
30
|
-
}
|
|
31
|
-
|
|
32
28
|
export async function DELETE(req: Request) {
|
|
33
29
|
const auth = await getAuthFromRequest(req)
|
|
34
30
|
if (!auth?.tenantId || !auth.orgId) {
|
|
@@ -41,7 +37,7 @@ export async function DELETE(req: Request) {
|
|
|
41
37
|
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
if (!
|
|
40
|
+
if (!isS3KeyAddressableByScope(parsed.data.key, auth.orgId, auth.tenantId)) {
|
|
45
41
|
return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })
|
|
46
42
|
}
|
|
47
43
|
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
canRenderInlineAttachment,
|
|
9
9
|
sanitizeUploadedFileName,
|
|
10
10
|
} from '@open-mercato/core/modules/attachments/lib/security'
|
|
11
|
+
import { isS3KeyAddressableByScope } from '../../../../lib/key-scope'
|
|
11
12
|
import { S3StorageDriver } from '../../../../lib/s3-driver'
|
|
12
13
|
|
|
13
14
|
export const metadata = {
|
|
@@ -25,11 +26,6 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
|
|
|
25
26
|
return new S3StorageDriver(creds)
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
function isKeyScoped(key: string, orgId: string, tenantId: string): boolean {
|
|
29
|
-
const parts = key.split('/')
|
|
30
|
-
return parts.length >= 3 && parts[1] === `org_${orgId}` && parts[2] === `tenant_${tenantId}`
|
|
31
|
-
}
|
|
32
|
-
|
|
33
29
|
export async function GET(req: Request) {
|
|
34
30
|
const auth = await getAuthFromRequest(req)
|
|
35
31
|
if (!auth?.tenantId || !auth.orgId) {
|
|
@@ -41,7 +37,7 @@ export async function GET(req: Request) {
|
|
|
41
37
|
return NextResponse.json({ error: 'key query param is required' }, { status: 400 })
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
if (!
|
|
40
|
+
if (!isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)) {
|
|
45
41
|
return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })
|
|
46
42
|
}
|
|
47
43
|
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
|
+
import { isS3KeyAddressableByScope, isS3KeyScopedToTenant } from '../../../../lib/key-scope'
|
|
6
7
|
import { S3StorageDriver } from '../../../../lib/s3-driver'
|
|
7
8
|
|
|
8
9
|
export const metadata = {
|
|
@@ -32,11 +33,6 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
|
|
|
32
33
|
return new S3StorageDriver(creds)
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
function isKeyScoped(key: string, orgId: string, tenantId: string): boolean {
|
|
36
|
-
const parts = key.split('/')
|
|
37
|
-
return parts.length >= 3 && parts[1] === `org_${orgId}` && parts[2] === `tenant_${tenantId}`
|
|
38
|
-
}
|
|
39
|
-
|
|
40
36
|
export async function POST(req: Request) {
|
|
41
37
|
const auth = await getAuthFromRequest(req)
|
|
42
38
|
if (!auth?.tenantId || !auth.orgId) {
|
|
@@ -49,7 +45,11 @@ export async function POST(req: Request) {
|
|
|
49
45
|
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
|
|
50
46
|
}
|
|
51
47
|
|
|
52
|
-
|
|
48
|
+
const { key, operation, expiresIn, contentType } = parsed.data
|
|
49
|
+
const canAccessKey = operation === 'download'
|
|
50
|
+
? isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)
|
|
51
|
+
: isS3KeyScopedToTenant(key, auth.orgId, auth.tenantId)
|
|
52
|
+
if (!canAccessKey) {
|
|
53
53
|
return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })
|
|
54
54
|
}
|
|
55
55
|
|
|
@@ -58,7 +58,6 @@ export async function POST(req: Request) {
|
|
|
58
58
|
return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
const { key, operation, expiresIn, contentType } = parsed.data
|
|
62
61
|
const url = await driver.getSignedUrl(key, operation, expiresIn, contentType)
|
|
63
62
|
const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString()
|
|
64
63
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
resolveAttachmentMaxBytes,
|
|
14
14
|
willExceedAttachmentTenantQuota,
|
|
15
15
|
} from '@open-mercato/core/modules/attachments/lib/upload-limits'
|
|
16
|
+
import { isS3KeyScopedToTenant } from '../../../../lib/key-scope'
|
|
16
17
|
import { S3StorageDriver } from '../../../../lib/s3-driver'
|
|
17
18
|
import { randomUUID } from 'crypto'
|
|
18
19
|
|
|
@@ -32,11 +33,6 @@ function sanitizeFileName(name: string): string {
|
|
|
32
33
|
return name.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload'
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
function isKeyScoped(key: string, orgId: string, tenantId: string): boolean {
|
|
36
|
-
const parts = key.split('/')
|
|
37
|
-
return parts.length >= 3 && parts[1] === `org_${orgId}` && parts[2] === `tenant_${tenantId}`
|
|
38
|
-
}
|
|
39
|
-
|
|
40
36
|
async function resolveDriver(
|
|
41
37
|
tenantId: string,
|
|
42
38
|
orgId: string,
|
|
@@ -61,7 +57,7 @@ async function readTenantStorageUsageBytes(
|
|
|
61
57
|
do {
|
|
62
58
|
const page = await driver.listObjects('', 1000, continuationToken)
|
|
63
59
|
for (const file of page.files) {
|
|
64
|
-
if (
|
|
60
|
+
if (isS3KeyScopedToTenant(file.key, orgId, tenantId)) {
|
|
65
61
|
totalBytes += file.size
|
|
66
62
|
}
|
|
67
63
|
}
|
|
@@ -94,7 +90,7 @@ export async function POST(req: Request) {
|
|
|
94
90
|
|
|
95
91
|
const keyOverride = form.get('key') ? String(form.get('key')) : null
|
|
96
92
|
|
|
97
|
-
if (keyOverride !== null && !
|
|
93
|
+
if (keyOverride !== null && !isS3KeyScopedToTenant(keyOverride, auth.orgId, auth.tenantId)) {
|
|
98
94
|
return NextResponse.json(
|
|
99
95
|
{ error: 'Access denied: key override is not scoped to this tenant.' },
|
|
100
96
|
{ status: 403 },
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const SHARED_ORG_SEGMENT = 'org_shared'
|
|
2
|
+
const SHARED_TENANT_SEGMENT = 'tenant_shared'
|
|
3
|
+
|
|
4
|
+
function findScopeSegmentIndex(parts: string[]): number {
|
|
5
|
+
return parts.findIndex((part, index) => (
|
|
6
|
+
part.startsWith('org_') && parts[index + 1]?.startsWith('tenant_')
|
|
7
|
+
))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function hasScopeSegments(key: string, orgSegment: string, tenantSegment: string): boolean {
|
|
11
|
+
const parts = key.split('/')
|
|
12
|
+
const scopeIndex = findScopeSegmentIndex(parts)
|
|
13
|
+
return scopeIndex >= 0
|
|
14
|
+
&& parts[scopeIndex] === orgSegment
|
|
15
|
+
&& parts[scopeIndex + 1] === tenantSegment
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isS3KeyScopedToTenant(key: string, orgId: string, tenantId: string): boolean {
|
|
19
|
+
if (!orgId || !tenantId) return false
|
|
20
|
+
return hasScopeSegments(key, `org_${orgId}`, `tenant_${tenantId}`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isS3KeyShared(key: string): boolean {
|
|
24
|
+
return hasScopeSegments(key, SHARED_ORG_SEGMENT, SHARED_TENANT_SEGMENT)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isS3KeyAddressableByScope(key: string, orgId: string, tenantId: string): boolean {
|
|
28
|
+
return isS3KeyScopedToTenant(key, orgId, tenantId) || isS3KeyShared(key)
|
|
29
|
+
}
|