@open-mercato/storage-s3 0.6.7-develop.6573.1.28649ddec6 → 0.6.7-develop.6574.1.05dcf72ba8
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/delete/storage-providers/s3/delete.js +1 -1
- 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 +1 -1
- package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js.map +2 -2
- package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js +1 -1
- package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js.map +2 -2
- package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js +1 -1
- 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 +1 -1
- package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js.map +2 -2
- package/dist/modules/storage_s3/di.js +14 -5
- package/dist/modules/storage_s3/di.js.map +2 -2
- package/dist/modules/storage_s3/lib/key-scope.js +77 -6
- package/dist/modules/storage_s3/lib/key-scope.js.map +2 -2
- package/dist/modules/storage_s3/lib/s3-driver.js +43 -9
- package/dist/modules/storage_s3/lib/s3-driver.js.map +2 -2
- package/dist/modules/storage_s3/lib/storage-service.js +22 -7
- package/dist/modules/storage_s3/lib/storage-service.js.map +2 -2
- package/package.json +4 -4
- package/src/modules/storage_s3/__tests__/di.test.ts +42 -0
- package/src/modules/storage_s3/__tests__/s3Driver.test.ts +178 -0
- package/src/modules/storage_s3/__tests__/storageService.test.ts +135 -0
- package/src/modules/storage_s3/api/delete/storage-providers/s3/delete.ts +1 -1
- package/src/modules/storage_s3/api/get/storage-providers/s3/download.ts +1 -1
- package/src/modules/storage_s3/api/get/storage-providers/s3/list.ts +1 -1
- package/src/modules/storage_s3/api/post/storage-providers/s3/signed-url.ts +1 -1
- package/src/modules/storage_s3/api/post/storage-providers/s3/upload.ts +1 -1
- package/src/modules/storage_s3/di.ts +13 -4
- package/src/modules/storage_s3/lib/key-scope.ts +140 -6
- package/src/modules/storage_s3/lib/s3-driver.ts +54 -7
- package/src/modules/storage_s3/lib/storage-service.ts +25 -7
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/storage_s3/lib/s3-driver.ts"],
|
|
4
|
-
"sourcesContent": ["import { randomUUID } from 'crypto'\nimport os from 'os'\nimport path from 'path'\nimport { promises as fs } from 'fs'\nimport {\n S3Client,\n PutObjectCommand,\n GetObjectCommand,\n DeleteObjectCommand,\n ListObjectsV2Command,\n} from '@aws-sdk/client-s3'\nimport { getSignedUrl } from '@aws-sdk/s3-request-presigner'\nimport type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from '@open-mercato/core/modules/attachments/lib/drivers'\n\nexport type S3DriverConfig = {\n bucket: string\n region?: string\n endpoint?: string\n pathPrefix?: string\n forcePathStyle?: boolean\n /**\n * authMode:\n * - access_keys: use explicit credentials (direct values or env prefix)\n * - ambient: let AWS SDK resolve credentials from the default chain (STS/IRSA/instance profile/etc.)\n *\n * Backward compat: missing authMode behaves like access_keys when keys are provided; otherwise ambient.\n */\n authMode?: 'access_keys' | 'ambient'\n /** Resolve access key + secret from env vars: {PREFIX}_ACCESS_KEY_ID / {PREFIX}_SECRET_ACCESS_KEY */\n credentialsEnvPrefix?: string\n /** Direct credentials (used when passed from Integration Marketplace) */\n accessKeyId?: string\n secretAccessKey?: string\n sessionToken?: string\n}\n\nfunction sanitizeFileName(fileName: string): string {\n if (!fileName) return 'file'\n return fileName.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction resolveOrgSegment(orgId: string | null | undefined): string {\n if (typeof orgId === 'string' && orgId.trim().length > 0) return `org_${orgId}`\n return 'org_shared'\n}\n\nfunction resolveTenantSegment(tenantId: string | null | undefined): string {\n if (typeof tenantId === 'string' && tenantId.trim().length > 0) return `tenant_${tenantId}`\n return 'tenant_shared'\n}\n\nasync function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = []\n stream.on('data', (chunk: Buffer) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))\n stream.on('end', () => resolve(Buffer.concat(chunks)))\n stream.on('error', reject)\n })\n}\n\nexport class S3StorageDriver implements StorageDriver {\n readonly key = 's3'\n private readonly client: S3Client\n private readonly bucket: string\n private readonly pathPrefix: string\n\n constructor(config: Record<string, unknown>) {\n const cfg = config as S3DriverConfig\n this.bucket = cfg.bucket\n this.pathPrefix = cfg.pathPrefix ?? ''\n\n const authMode = cfg.authMode\n const shouldUseAccessKeys =\n authMode === 'access_keys'\n || (\n authMode !== 'ambient'\n && ((cfg.accessKeyId && cfg.secretAccessKey) || Boolean(cfg.credentialsEnvPrefix))\n )\n\n let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | undefined\n if (shouldUseAccessKeys) {\n if (cfg.credentialsEnvPrefix) {\n const prefix = cfg.credentialsEnvPrefix\n const accessKeyId = process.env[`${prefix}_ACCESS_KEY_ID`]\n const secretAccessKey = process.env[`${prefix}_SECRET_ACCESS_KEY`]\n const sessionToken = process.env[`${prefix}_SESSION_TOKEN`]\n if (accessKeyId && secretAccessKey) {\n credentials = { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }\n }\n } else if (cfg.accessKeyId && cfg.secretAccessKey) {\n credentials = {\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n ...(cfg.sessionToken ? { sessionToken: cfg.sessionToken } : {}),\n }\n }\n }\n\n this.client = new S3Client({\n region: cfg.region ?? 'us-east-1',\n endpoint: cfg.endpoint,\n forcePathStyle: cfg.forcePathStyle ?? false,\n credentials,\n })\n }\n\n async store(payload: StoreFilePayload): Promise<StoredFile> {\n const orgSegment = resolveOrgSegment(payload.orgId ?? null)\n const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)\n const safeName = sanitizeFileName(payload.fileName || 'file')\n const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)\n const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`\n const key = `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`\n\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: payload.buffer,\n ContentLength: payload.buffer.length,\n }),\n )\n\n return { storagePath: key }\n }\n\n async read(
|
|
5
|
-
"mappings": "AAAA,SAAS,kBAAkB;AAC3B,OAAO,QAAQ;AACf,OAAO,UAAU;AACjB,SAAS,YAAY,UAAU;AAC/B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,oBAAoB;
|
|
4
|
+
"sourcesContent": ["import { randomUUID } from 'crypto'\nimport os from 'os'\nimport path from 'path'\nimport { promises as fs } from 'fs'\nimport {\n S3Client,\n PutObjectCommand,\n GetObjectCommand,\n DeleteObjectCommand,\n ListObjectsV2Command,\n} from '@aws-sdk/client-s3'\nimport { getSignedUrl } from '@aws-sdk/s3-request-presigner'\nimport type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from '@open-mercato/core/modules/attachments/lib/drivers'\nimport {\n assertS3KeyAddressableByTenantScope,\n assertS3KeyScopedToTenant,\n assertS3ListPrefixScopedToTenant,\n filterS3ObjectsToTenant,\n type S3TenantScope,\n} from './key-scope'\n\nexport type S3DriverConfig = {\n bucket: string\n region?: string\n endpoint?: string\n pathPrefix?: string\n forcePathStyle?: boolean\n /**\n * authMode:\n * - access_keys: use explicit credentials (direct values or env prefix)\n * - ambient: let AWS SDK resolve credentials from the default chain (STS/IRSA/instance profile/etc.)\n *\n * Backward compat: missing authMode behaves like access_keys when keys are provided; otherwise ambient.\n */\n authMode?: 'access_keys' | 'ambient'\n /** Resolve access key + secret from env vars: {PREFIX}_ACCESS_KEY_ID / {PREFIX}_SECRET_ACCESS_KEY */\n credentialsEnvPrefix?: string\n /** Direct credentials (used when passed from Integration Marketplace) */\n accessKeyId?: string\n secretAccessKey?: string\n sessionToken?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\nfunction sanitizeFileName(fileName: string): string {\n if (!fileName) return 'file'\n return fileName.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction resolveOrgSegment(orgId: string | null | undefined): string {\n if (typeof orgId === 'string' && orgId.trim().length > 0) return `org_${orgId}`\n return 'org_shared'\n}\n\nfunction resolveTenantSegment(tenantId: string | null | undefined): string {\n if (typeof tenantId === 'string' && tenantId.trim().length > 0) return `tenant_${tenantId}`\n return 'tenant_shared'\n}\n\nasync function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = []\n stream.on('data', (chunk: Buffer) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))\n stream.on('end', () => resolve(Buffer.concat(chunks)))\n stream.on('error', reject)\n })\n}\n\nexport class S3StorageDriver implements StorageDriver {\n readonly key = 's3'\n private readonly client: S3Client\n private readonly bucket: string\n private readonly pathPrefix: string\n private readonly scope: S3TenantScope | null\n\n constructor(config: Record<string, unknown>) {\n const cfg = config as S3DriverConfig\n this.bucket = cfg.bucket\n this.pathPrefix = cfg.pathPrefix ?? ''\n this.scope = cfg.organizationId && cfg.tenantId\n ? { organizationId: cfg.organizationId, tenantId: cfg.tenantId }\n : null\n\n const authMode = cfg.authMode\n const shouldUseAccessKeys =\n authMode === 'access_keys'\n || (\n authMode !== 'ambient'\n && ((cfg.accessKeyId && cfg.secretAccessKey) || Boolean(cfg.credentialsEnvPrefix))\n )\n\n let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | undefined\n if (shouldUseAccessKeys) {\n if (cfg.credentialsEnvPrefix) {\n const prefix = cfg.credentialsEnvPrefix\n const accessKeyId = process.env[`${prefix}_ACCESS_KEY_ID`]\n const secretAccessKey = process.env[`${prefix}_SECRET_ACCESS_KEY`]\n const sessionToken = process.env[`${prefix}_SESSION_TOKEN`]\n if (accessKeyId && secretAccessKey) {\n credentials = { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }\n }\n } else if (cfg.accessKeyId && cfg.secretAccessKey) {\n credentials = {\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n ...(cfg.sessionToken ? { sessionToken: cfg.sessionToken } : {}),\n }\n }\n }\n\n this.client = new S3Client({\n region: cfg.region ?? 'us-east-1',\n endpoint: cfg.endpoint,\n forcePathStyle: cfg.forcePathStyle ?? false,\n credentials,\n })\n }\n\n private assertKeyScoped(storagePath: string, scope?: S3TenantScope | null): void {\n assertS3KeyScopedToTenant(storagePath, scope ?? this.scope, this.pathPrefix)\n }\n\n private assertKeyAddressable(storagePath: string, scope?: S3TenantScope | null): void {\n assertS3KeyAddressableByTenantScope(storagePath, scope ?? this.scope, this.pathPrefix)\n }\n\n private assertPartitionScoped(partitionCode: string, storagePath: string): void {\n if (!partitionCode) return\n const prefix = this.pathPrefix && storagePath.startsWith(this.pathPrefix)\n ? storagePath.slice(this.pathPrefix.length).replace(/^\\/+/, '')\n : storagePath.replace(/^\\/+/, '')\n const firstSegment = prefix.split('/').filter(Boolean)[0]\n if (firstSegment !== partitionCode) {\n throw new Error('S3 key is not scoped to the requested partition')\n }\n }\n\n async store(payload: StoreFilePayload): Promise<StoredFile> {\n const orgSegment = resolveOrgSegment(payload.orgId ?? null)\n const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)\n const safeName = sanitizeFileName(payload.fileName || 'file')\n const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)\n const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`\n const key = `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`\n\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: payload.buffer,\n ContentLength: payload.buffer.length,\n }),\n )\n\n return { storagePath: key }\n }\n\n async read(partitionCode: string, storagePath: string): Promise<ReadFileResult> {\n this.assertPartitionScoped(partitionCode, storagePath)\n this.assertKeyAddressable(storagePath)\n const response = await this.client.send(\n new GetObjectCommand({ Bucket: this.bucket, Key: storagePath }),\n )\n if (!response.Body) {\n throw new Error(`S3 object body is empty for key: ${storagePath}`)\n }\n const buffer = await streamToBuffer(response.Body as NodeJS.ReadableStream)\n return { buffer, contentType: response.ContentType }\n }\n\n async delete(partitionCode: string, storagePath: string): Promise<void> {\n this.assertPartitionScoped(partitionCode, storagePath)\n this.assertKeyAddressable(storagePath)\n try {\n await this.client.send(\n new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }),\n )\n } catch {\n // best-effort removal\n }\n }\n\n async toLocalPath(\n partitionCode: string,\n storagePath: string,\n ): Promise<{ filePath: string; cleanup: () => Promise<void> }> {\n const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 's3-tmp-'))\n const cleanup = async () => {\n await fs.rm(tmpDir, { recursive: true, force: true })\n }\n\n try {\n const fileName = path.basename(storagePath) || 'download'\n const filePath = path.join(tmpDir, fileName)\n const { buffer } = await this.read(partitionCode, storagePath)\n await fs.writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 })\n return { filePath, cleanup }\n } catch (error) {\n await Promise.allSettled([cleanup()])\n throw error\n }\n }\n\n /**\n * Put an object directly at a specific S3 key (for standalone usage).\n */\n async putObject(key: string, buffer: Buffer, contentType?: string): Promise<void> {\n this.assertKeyScoped(key)\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: buffer,\n ContentType: contentType,\n ContentLength: buffer.length,\n }),\n )\n }\n\n getBucket(): string {\n return this.bucket\n }\n\n async listObjects(\n prefix: string,\n maxKeys = 100,\n continuationToken?: string,\n scope?: S3TenantScope | null,\n ): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }> {\n const activeScope = scope ?? this.scope\n assertS3ListPrefixScopedToTenant(prefix, activeScope, this.pathPrefix)\n const response = await this.client.send(\n new ListObjectsV2Command({\n Bucket: this.bucket,\n Prefix: prefix,\n MaxKeys: maxKeys,\n ContinuationToken: continuationToken,\n }),\n )\n const files = (response.Contents ?? []).map((obj) => ({\n key: obj.Key ?? '',\n size: obj.Size ?? 0,\n lastModified: obj.LastModified ?? new Date(0),\n }))\n return {\n files: filterS3ObjectsToTenant(files, activeScope, this.pathPrefix),\n truncated: response.IsTruncated ?? false,\n nextContinuationToken: response.NextContinuationToken,\n }\n }\n\n /**\n * Generate a pre-signed URL for direct browser upload or download.\n */\n async getSignedUrl(\n storagePath: string,\n operation: 'upload' | 'download',\n expiresIn = 3600,\n contentType?: string,\n scope?: S3TenantScope | null,\n ): Promise<string> {\n if (operation === 'upload') {\n this.assertKeyScoped(storagePath, scope)\n } else {\n this.assertKeyAddressable(storagePath, scope)\n }\n if (operation === 'upload') {\n const command = new PutObjectCommand({\n Bucket: this.bucket,\n Key: storagePath,\n ContentType: contentType,\n })\n return getSignedUrl(this.client, command, { expiresIn })\n }\n const command = new GetObjectCommand({ Bucket: this.bucket, Key: storagePath })\n return getSignedUrl(this.client, command, { expiresIn })\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["command"]
|
|
7
7
|
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
2
|
import { S3StorageDriver } from "./s3-driver.js";
|
|
3
|
+
import {
|
|
4
|
+
assertS3KeyAddressableByTenantScope,
|
|
5
|
+
assertS3KeyScopedToTenant,
|
|
6
|
+
assertS3ListPrefixScopedToTenant
|
|
7
|
+
} from "./key-scope.js";
|
|
3
8
|
function sanitizeSegment(value) {
|
|
4
9
|
return value.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
5
10
|
}
|
|
@@ -12,6 +17,7 @@ function buildKey(namespace, scope, fileName) {
|
|
|
12
17
|
}
|
|
13
18
|
function createStorageService(config) {
|
|
14
19
|
const driver = new S3StorageDriver(config);
|
|
20
|
+
const pathPrefix = config.pathPrefix;
|
|
15
21
|
return {
|
|
16
22
|
async upload({ namespace, fileName, buffer, contentType, scope }) {
|
|
17
23
|
const stored = await driver.store({
|
|
@@ -29,20 +35,29 @@ function createStorageService(config) {
|
|
|
29
35
|
contentType
|
|
30
36
|
};
|
|
31
37
|
},
|
|
32
|
-
async download({ key }) {
|
|
38
|
+
async download({ key, scope }) {
|
|
39
|
+
assertS3KeyAddressableByTenantScope(key, scope, pathPrefix);
|
|
33
40
|
return driver.read("", key);
|
|
34
41
|
},
|
|
35
|
-
async delete({ key }) {
|
|
42
|
+
async delete({ key, scope }) {
|
|
43
|
+
assertS3KeyAddressableByTenantScope(key, scope, pathPrefix);
|
|
36
44
|
return driver.delete("", key);
|
|
37
45
|
},
|
|
38
|
-
async getSignedUrl({ key, operation, expiresIn = 3600, contentType }) {
|
|
39
|
-
|
|
46
|
+
async getSignedUrl({ key, operation, expiresIn = 3600, contentType, scope }) {
|
|
47
|
+
if (operation === "upload") {
|
|
48
|
+
assertS3KeyScopedToTenant(key, scope, pathPrefix);
|
|
49
|
+
} else {
|
|
50
|
+
assertS3KeyAddressableByTenantScope(key, scope, pathPrefix);
|
|
51
|
+
}
|
|
52
|
+
const url = await driver.getSignedUrl(key, operation, expiresIn, contentType, scope);
|
|
40
53
|
return { url, expiresAt: new Date(Date.now() + expiresIn * 1e3) };
|
|
41
54
|
},
|
|
42
|
-
async list({ prefix, maxKeys = 100, continuationToken }) {
|
|
43
|
-
|
|
55
|
+
async list({ prefix, maxKeys = 100, continuationToken, scope }) {
|
|
56
|
+
assertS3ListPrefixScopedToTenant(prefix, scope, pathPrefix);
|
|
57
|
+
return driver.listObjects(prefix, maxKeys, continuationToken, scope);
|
|
44
58
|
},
|
|
45
|
-
async toLocalPath({ key }) {
|
|
59
|
+
async toLocalPath({ key, scope }) {
|
|
60
|
+
assertS3KeyAddressableByTenantScope(key, scope, pathPrefix);
|
|
46
61
|
return driver.toLocalPath("", key);
|
|
47
62
|
}
|
|
48
63
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/storage_s3/lib/storage-service.ts"],
|
|
4
|
-
"sourcesContent": ["import { randomUUID } from 'crypto'\nimport { S3StorageDriver } from './s3-driver'\n\ntype TenantScope = {\n tenantId: string | null | undefined\n organizationId: string | null | undefined\n}\n\ntype UploadInput = {\n namespace: string\n fileName: string\n buffer: Buffer\n contentType?: string\n scope: TenantScope\n}\n\ntype StorageResult = {\n key: string\n namespace: string\n fileName: string\n size: number\n contentType?: string\n}\n\ntype DownloadInput = { key: string; scope: TenantScope }\ntype DeleteInput = { key: string; scope: TenantScope }\ntype SignedUrlInput = {\n key: string\n operation: 'upload' | 'download'\n expiresIn?: number\n contentType?: string\n scope: TenantScope\n}\ntype ListInput = {\n prefix: string\n maxKeys?: number\n continuationToken?: string\n scope: TenantScope\n}\n\ntype S3Config = {\n bucket: string\n region?: string\n endpoint?: string\n forcePathStyle?: boolean\n authMode?: 'access_keys' | 'ambient'\n credentialsEnvPrefix?: string\n accessKeyId?: string\n secretAccessKey?: string\n sessionToken?: string\n}\n\nfunction sanitizeSegment(value: string): string {\n return value.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction buildKey(namespace: string, scope: TenantScope, fileName: string): string {\n const orgSeg = scope.organizationId ? `org_${scope.organizationId}` : 'org_shared'\n const tenantSeg = scope.tenantId ? `tenant_${scope.tenantId}` : 'tenant_shared'\n const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)\n const safeName = sanitizeSegment(fileName || 'file')\n return `${sanitizeSegment(namespace)}/${orgSeg}/${tenantSeg}/${Date.now()}_${uniqueSuffix}_${safeName}`\n}\n\nexport interface StorageService {\n upload(input: UploadInput): Promise<StorageResult>\n download(input: DownloadInput): Promise<{ buffer: Buffer; contentType?: string }>\n delete(input: DeleteInput): Promise<void>\n getSignedUrl(input: SignedUrlInput): Promise<{ url: string; expiresAt: Date }>\n list(input: ListInput): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }>\n toLocalPath(input: { key: string; scope: TenantScope }): Promise<{ filePath: string; cleanup: () => Promise<void> }>\n}\n\nexport function createStorageService(config: S3Config): StorageService {\n const driver = new S3StorageDriver(config as Record<string, unknown>)\n\n return {\n async upload({ namespace, fileName, buffer, contentType, scope }): Promise<StorageResult> {\n const stored = await driver.store({\n partitionCode: namespace,\n orgId: scope.organizationId,\n tenantId: scope.tenantId,\n fileName,\n buffer,\n })\n return {\n key: stored.storagePath,\n namespace,\n fileName,\n size: buffer.length,\n contentType,\n }\n },\n\n async download({ key }): Promise<{ buffer: Buffer; contentType?: string }> {\n return driver.read('', key)\n },\n\n async delete({ key }): Promise<void> {\n return driver.delete('', key)\n },\n\n async getSignedUrl({ key, operation, expiresIn = 3600, contentType }): Promise<{ url: string; expiresAt: Date }> {\n const url = await driver.getSignedUrl(key, operation, expiresIn, contentType)\n return { url, expiresAt: new Date(Date.now() + expiresIn * 1000) }\n },\n\n async list({ prefix, maxKeys = 100, continuationToken }): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }> {\n return driver.listObjects(prefix, maxKeys, continuationToken)\n },\n\n async toLocalPath({ key }) {\n return driver.toLocalPath('', key)\n },\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,kBAAkB;AAC3B,SAAS,uBAAuB;
|
|
4
|
+
"sourcesContent": ["import { randomUUID } from 'crypto'\nimport { S3StorageDriver } from './s3-driver'\nimport {\n assertS3KeyAddressableByTenantScope,\n assertS3KeyScopedToTenant,\n assertS3ListPrefixScopedToTenant,\n} from './key-scope'\n\ntype TenantScope = {\n tenantId: string | null | undefined\n organizationId: string | null | undefined\n}\n\ntype UploadInput = {\n namespace: string\n fileName: string\n buffer: Buffer\n contentType?: string\n scope: TenantScope\n}\n\ntype StorageResult = {\n key: string\n namespace: string\n fileName: string\n size: number\n contentType?: string\n}\n\ntype DownloadInput = { key: string; scope: TenantScope }\ntype DeleteInput = { key: string; scope: TenantScope }\ntype SignedUrlInput = {\n key: string\n operation: 'upload' | 'download'\n expiresIn?: number\n contentType?: string\n scope: TenantScope\n}\ntype ListInput = {\n prefix: string\n maxKeys?: number\n continuationToken?: string\n scope: TenantScope\n}\n\ntype S3Config = {\n bucket: string\n region?: string\n endpoint?: string\n forcePathStyle?: boolean\n authMode?: 'access_keys' | 'ambient'\n credentialsEnvPrefix?: string\n accessKeyId?: string\n secretAccessKey?: string\n sessionToken?: string\n pathPrefix?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\nfunction sanitizeSegment(value: string): string {\n return value.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction buildKey(namespace: string, scope: TenantScope, fileName: string): string {\n const orgSeg = scope.organizationId ? `org_${scope.organizationId}` : 'org_shared'\n const tenantSeg = scope.tenantId ? `tenant_${scope.tenantId}` : 'tenant_shared'\n const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)\n const safeName = sanitizeSegment(fileName || 'file')\n return `${sanitizeSegment(namespace)}/${orgSeg}/${tenantSeg}/${Date.now()}_${uniqueSuffix}_${safeName}`\n}\n\nexport interface StorageService {\n upload(input: UploadInput): Promise<StorageResult>\n download(input: DownloadInput): Promise<{ buffer: Buffer; contentType?: string }>\n delete(input: DeleteInput): Promise<void>\n getSignedUrl(input: SignedUrlInput): Promise<{ url: string; expiresAt: Date }>\n list(input: ListInput): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }>\n toLocalPath(input: { key: string; scope: TenantScope }): Promise<{ filePath: string; cleanup: () => Promise<void> }>\n}\n\nexport function createStorageService(config: S3Config): StorageService {\n const driver = new S3StorageDriver(config as Record<string, unknown>)\n const pathPrefix = config.pathPrefix\n\n return {\n async upload({ namespace, fileName, buffer, contentType, scope }): Promise<StorageResult> {\n const stored = await driver.store({\n partitionCode: namespace,\n orgId: scope.organizationId,\n tenantId: scope.tenantId,\n fileName,\n buffer,\n })\n return {\n key: stored.storagePath,\n namespace,\n fileName,\n size: buffer.length,\n contentType,\n }\n },\n\n async download({ key, scope }): Promise<{ buffer: Buffer; contentType?: string }> {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n return driver.read('', key)\n },\n\n async delete({ key, scope }): Promise<void> {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n return driver.delete('', key)\n },\n\n async getSignedUrl({ key, operation, expiresIn = 3600, contentType, scope }): Promise<{ url: string; expiresAt: Date }> {\n if (operation === 'upload') {\n assertS3KeyScopedToTenant(key, scope, pathPrefix)\n } else {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n }\n const url = await driver.getSignedUrl(key, operation, expiresIn, contentType, scope)\n return { url, expiresAt: new Date(Date.now() + expiresIn * 1000) }\n },\n\n async list({ prefix, maxKeys = 100, continuationToken, scope }): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }> {\n assertS3ListPrefixScopedToTenant(prefix, scope, pathPrefix)\n return driver.listObjects(prefix, maxKeys, continuationToken, scope)\n },\n\n async toLocalPath({ key, scope }) {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n return driver.toLocalPath('', key)\n },\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,kBAAkB;AAC3B,SAAS,uBAAuB;AAChC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAsDP,SAAS,gBAAgB,OAAuB;AAC9C,SAAO,MAAM,QAAQ,oBAAoB,GAAG;AAC9C;AAEA,SAAS,SAAS,WAAmB,OAAoB,UAA0B;AACjF,QAAM,SAAS,MAAM,iBAAiB,OAAO,MAAM,cAAc,KAAK;AACtE,QAAM,YAAY,MAAM,WAAW,UAAU,MAAM,QAAQ,KAAK;AAChE,QAAM,eAAe,WAAW,EAAE,QAAQ,MAAM,EAAE,EAAE,MAAM,GAAG,EAAE;AAC/D,QAAM,WAAW,gBAAgB,YAAY,MAAM;AACnD,SAAO,GAAG,gBAAgB,SAAS,CAAC,IAAI,MAAM,IAAI,SAAS,IAAI,KAAK,IAAI,CAAC,IAAI,YAAY,IAAI,QAAQ;AACvG;AAeO,SAAS,qBAAqB,QAAkC;AACrE,QAAM,SAAS,IAAI,gBAAgB,MAAiC;AACpE,QAAM,aAAa,OAAO;AAE1B,SAAO;AAAA,IACL,MAAM,OAAO,EAAE,WAAW,UAAU,QAAQ,aAAa,MAAM,GAA2B;AACxF,YAAM,SAAS,MAAM,OAAO,MAAM;AAAA,QAChC,eAAe;AAAA,QACf,OAAO,MAAM;AAAA,QACb,UAAU,MAAM;AAAA,QAChB;AAAA,QACA;AAAA,MACF,CAAC;AACD,aAAO;AAAA,QACL,KAAK,OAAO;AAAA,QACZ;AAAA,QACA;AAAA,QACA,MAAM,OAAO;AAAA,QACb;AAAA,MACF;AAAA,IACF;AAAA,IAEA,MAAM,SAAS,EAAE,KAAK,MAAM,GAAsD;AAChF,0CAAoC,KAAK,OAAO,UAAU;AAC1D,aAAO,OAAO,KAAK,IAAI,GAAG;AAAA,IAC5B;AAAA,IAEA,MAAM,OAAO,EAAE,KAAK,MAAM,GAAkB;AAC1C,0CAAoC,KAAK,OAAO,UAAU;AAC1D,aAAO,OAAO,OAAO,IAAI,GAAG;AAAA,IAC9B;AAAA,IAEA,MAAM,aAAa,EAAE,KAAK,WAAW,YAAY,MAAM,aAAa,MAAM,GAA8C;AACtH,UAAI,cAAc,UAAU;AAC1B,kCAA0B,KAAK,OAAO,UAAU;AAAA,MAClD,OAAO;AACL,4CAAoC,KAAK,OAAO,UAAU;AAAA,MAC5D;AACA,YAAM,MAAM,MAAM,OAAO,aAAa,KAAK,WAAW,WAAW,aAAa,KAAK;AACnF,aAAO,EAAE,KAAK,WAAW,IAAI,KAAK,KAAK,IAAI,IAAI,YAAY,GAAI,EAAE;AAAA,IACnE;AAAA,IAEA,MAAM,KAAK,EAAE,QAAQ,UAAU,KAAK,mBAAmB,MAAM,GAI1D;AACD,uCAAiC,QAAQ,OAAO,UAAU;AAC1D,aAAO,OAAO,YAAY,QAAQ,SAAS,mBAAmB,KAAK;AAAA,IACrE;AAAA,IAEA,MAAM,YAAY,EAAE,KAAK,MAAM,GAAG;AAChC,0CAAoC,KAAK,OAAO,UAAU;AAC1D,aAAO,OAAO,YAAY,IAAI,GAAG;AAAA,IACnC;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.6574.1.05dcf72ba8",
|
|
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.7-develop.
|
|
72
|
+
"@open-mercato/core": "0.6.7-develop.6574.1.05dcf72ba8"
|
|
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.6574.1.05dcf72ba8"
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
79
|
+
"@open-mercato/shared": "0.6.7-develop.6574.1.05dcf72ba8",
|
|
80
80
|
"@types/jest": "^30.0.0",
|
|
81
81
|
"esbuild": "^0.28.1",
|
|
82
82
|
"glob": "^13.0.6",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
const mockRegisterExternalStorageDriver = jest.fn()
|
|
4
|
+
const mockRegisterExternalCredentialEnhancer = jest.fn()
|
|
5
|
+
|
|
6
|
+
jest.mock('@open-mercato/core/modules/attachments/lib/drivers', () => ({
|
|
7
|
+
registerExternalStorageDriver: (...args: unknown[]) => mockRegisterExternalStorageDriver(...args),
|
|
8
|
+
registerExternalCredentialEnhancer: (...args: unknown[]) => mockRegisterExternalCredentialEnhancer(...args),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
import { register } from '../di'
|
|
12
|
+
|
|
13
|
+
describe('storage_s3 DI registration', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
mockRegisterExternalCredentialEnhancer.mockClear()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('keeps tenant scope on enhanced S3 config when credential resolution fails', async () => {
|
|
19
|
+
const container = {
|
|
20
|
+
register: jest.fn(),
|
|
21
|
+
resolve: jest.fn().mockReturnValue({
|
|
22
|
+
resolve: jest.fn().mockRejectedValue(new Error('credentials unavailable')),
|
|
23
|
+
}),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
register(container as never)
|
|
27
|
+
|
|
28
|
+
const enhancer = mockRegisterExternalCredentialEnhancer.mock.calls.at(-1)?.[1]
|
|
29
|
+
expect(enhancer).toBeDefined()
|
|
30
|
+
|
|
31
|
+
await expect(
|
|
32
|
+
enhancer(
|
|
33
|
+
{ bucket: 'tenant-bucket' },
|
|
34
|
+
{ organizationId: 'org-owned', tenantId: 'tenant-owned' },
|
|
35
|
+
),
|
|
36
|
+
).resolves.toMatchObject({
|
|
37
|
+
bucket: 'tenant-bucket',
|
|
38
|
+
organizationId: 'org-owned',
|
|
39
|
+
tenantId: 'tenant-owned',
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -184,6 +184,85 @@ describe('S3StorageDriver', () => {
|
|
|
184
184
|
expect(cmd.Key).toBe('docs/org_o/tenant_t/img.png')
|
|
185
185
|
expect(cmd.Bucket).toBe('test-bucket')
|
|
186
186
|
})
|
|
187
|
+
|
|
188
|
+
it('rejects cross-tenant keys when the driver is scoped', async () => {
|
|
189
|
+
mockSend.mockResolvedValueOnce({
|
|
190
|
+
Body: makeStream(Buffer.from('secret')),
|
|
191
|
+
ContentType: 'text/plain',
|
|
192
|
+
})
|
|
193
|
+
const driver = new S3StorageDriver({
|
|
194
|
+
...BASE_CONFIG,
|
|
195
|
+
organizationId: 'org-owned',
|
|
196
|
+
tenantId: 'tenant-owned',
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
await expect(
|
|
200
|
+
driver.read('', 'docs/org_org-victim/tenant_tenant-owned/secret.txt'),
|
|
201
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
202
|
+
|
|
203
|
+
expect(mockSend).not.toHaveBeenCalled()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('allows shared namespace keys when the driver is scoped', async () => {
|
|
207
|
+
mockSend.mockResolvedValueOnce({
|
|
208
|
+
Body: makeStream(Buffer.from('shared')),
|
|
209
|
+
ContentType: 'text/plain',
|
|
210
|
+
})
|
|
211
|
+
const driver = new S3StorageDriver({
|
|
212
|
+
...BASE_CONFIG,
|
|
213
|
+
organizationId: 'org-owned',
|
|
214
|
+
tenantId: 'tenant-owned',
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const result = await driver.read('', 'docs/org_shared/tenant_shared/shared.txt')
|
|
218
|
+
|
|
219
|
+
expect(result.buffer.toString()).toBe('shared')
|
|
220
|
+
expect(mockSend).toHaveBeenCalledTimes(1)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('rejects keys outside the requested partition before reading', async () => {
|
|
224
|
+
mockSend.mockResolvedValueOnce({
|
|
225
|
+
Body: makeStream(Buffer.from('secret')),
|
|
226
|
+
ContentType: 'text/plain',
|
|
227
|
+
})
|
|
228
|
+
const driver = new S3StorageDriver(BASE_CONFIG)
|
|
229
|
+
|
|
230
|
+
await expect(
|
|
231
|
+
driver.read('docs', 'exports/org_o/tenant_t/file.txt'),
|
|
232
|
+
).rejects.toThrow('S3 key is not scoped to the requested partition')
|
|
233
|
+
|
|
234
|
+
expect(mockSend).not.toHaveBeenCalled()
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('rejects nested foreign scope segments before reading', async () => {
|
|
238
|
+
const driver = new S3StorageDriver({
|
|
239
|
+
...BASE_CONFIG,
|
|
240
|
+
organizationId: 'org-owned',
|
|
241
|
+
tenantId: 'tenant-owned',
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
await expect(
|
|
245
|
+
driver.read('', 'docs/org_org-victim/tenant_tenant-owned/org_org-owned/tenant_tenant-owned/private.txt'),
|
|
246
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
247
|
+
|
|
248
|
+
expect(mockSend).not.toHaveBeenCalled()
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
describe('putObject()', () => {
|
|
253
|
+
it('rejects cross-tenant keys before directly putting an object when scoped', async () => {
|
|
254
|
+
const driver = new S3StorageDriver({
|
|
255
|
+
...BASE_CONFIG,
|
|
256
|
+
organizationId: 'org-owned',
|
|
257
|
+
tenantId: 'tenant-owned',
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
await expect(
|
|
261
|
+
driver.putObject('docs/org_org-victim/tenant_tenant-owned/file.txt', Buffer.from('secret')),
|
|
262
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
263
|
+
|
|
264
|
+
expect(mockSend).not.toHaveBeenCalled()
|
|
265
|
+
})
|
|
187
266
|
})
|
|
188
267
|
|
|
189
268
|
describe('delete()', () => {
|
|
@@ -202,6 +281,43 @@ describe('S3StorageDriver', () => {
|
|
|
202
281
|
const driver = new S3StorageDriver(BASE_CONFIG)
|
|
203
282
|
await expect(driver.delete('', 'missing.txt')).resolves.not.toThrow()
|
|
204
283
|
})
|
|
284
|
+
|
|
285
|
+
it('rejects cross-tenant keys before issuing DeleteObjectCommand when scoped', async () => {
|
|
286
|
+
const driver = new S3StorageDriver({
|
|
287
|
+
...BASE_CONFIG,
|
|
288
|
+
organizationId: 'org-owned',
|
|
289
|
+
tenantId: 'tenant-owned',
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
await expect(
|
|
293
|
+
driver.delete('', 'docs/org_org-owned/tenant_tenant-victim/file.txt'),
|
|
294
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
295
|
+
|
|
296
|
+
expect(mockSend).not.toHaveBeenCalled()
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('allows deleting shared namespace keys when the driver is scoped', async () => {
|
|
300
|
+
mockSend.mockResolvedValueOnce({})
|
|
301
|
+
const driver = new S3StorageDriver({
|
|
302
|
+
...BASE_CONFIG,
|
|
303
|
+
organizationId: 'org-owned',
|
|
304
|
+
tenantId: 'tenant-owned',
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
await driver.delete('', 'docs/org_shared/tenant_shared/shared.txt')
|
|
308
|
+
|
|
309
|
+
expect(mockSend).toHaveBeenCalledTimes(1)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('rejects keys outside the requested partition before deleting', async () => {
|
|
313
|
+
const driver = new S3StorageDriver(BASE_CONFIG)
|
|
314
|
+
|
|
315
|
+
await expect(
|
|
316
|
+
driver.delete('docs', 'exports/org_o/tenant_t/file.txt'),
|
|
317
|
+
).rejects.toThrow('S3 key is not scoped to the requested partition')
|
|
318
|
+
|
|
319
|
+
expect(mockSend).not.toHaveBeenCalled()
|
|
320
|
+
})
|
|
205
321
|
})
|
|
206
322
|
|
|
207
323
|
describe('toLocalPath()', () => {
|
|
@@ -268,6 +384,46 @@ describe('S3StorageDriver', () => {
|
|
|
268
384
|
|
|
269
385
|
expect(mockGetSignedUrl).toHaveBeenCalledTimes(1)
|
|
270
386
|
})
|
|
387
|
+
|
|
388
|
+
it('rejects cross-tenant keys before presigning when scoped', async () => {
|
|
389
|
+
const driver = new S3StorageDriver({
|
|
390
|
+
...BASE_CONFIG,
|
|
391
|
+
organizationId: 'org-owned',
|
|
392
|
+
tenantId: 'tenant-owned',
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
await expect(
|
|
396
|
+
driver.getSignedUrl('docs/org_org-victim/tenant_tenant-owned/file.pdf', 'download'),
|
|
397
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
398
|
+
|
|
399
|
+
expect(mockGetSignedUrl).not.toHaveBeenCalled()
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('allows download signed URLs for shared namespace keys when scoped', async () => {
|
|
403
|
+
const driver = new S3StorageDriver({
|
|
404
|
+
...BASE_CONFIG,
|
|
405
|
+
organizationId: 'org-owned',
|
|
406
|
+
tenantId: 'tenant-owned',
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
await driver.getSignedUrl('docs/org_shared/tenant_shared/shared.pdf', 'download')
|
|
410
|
+
|
|
411
|
+
expect(mockGetSignedUrl).toHaveBeenCalledTimes(1)
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('rejects upload signed URLs for shared namespace keys when scoped', async () => {
|
|
415
|
+
const driver = new S3StorageDriver({
|
|
416
|
+
...BASE_CONFIG,
|
|
417
|
+
organizationId: 'org-owned',
|
|
418
|
+
tenantId: 'tenant-owned',
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
await expect(
|
|
422
|
+
driver.getSignedUrl('docs/org_shared/tenant_shared/shared.pdf', 'upload'),
|
|
423
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
424
|
+
|
|
425
|
+
expect(mockGetSignedUrl).not.toHaveBeenCalled()
|
|
426
|
+
})
|
|
271
427
|
})
|
|
272
428
|
|
|
273
429
|
describe('listObjects()', () => {
|
|
@@ -295,5 +451,27 @@ describe('S3StorageDriver', () => {
|
|
|
295
451
|
const cmd = mockSend.mock.calls[0][0]
|
|
296
452
|
expect(cmd.ContinuationToken).toBe('tok-123')
|
|
297
453
|
})
|
|
454
|
+
|
|
455
|
+
it('filters cross-tenant objects when the driver is scoped', async () => {
|
|
456
|
+
mockSend.mockResolvedValueOnce({
|
|
457
|
+
Contents: [
|
|
458
|
+
{ Key: 'docs/org_org-owned/tenant_tenant-owned/a.txt', Size: 100, LastModified: new Date('2026-01-01') },
|
|
459
|
+
{ Key: 'docs/org_org-victim/tenant_tenant-owned/b.txt', Size: 200, LastModified: new Date('2026-01-02') },
|
|
460
|
+
{ Key: 'docs/org_org-owned/tenant_tenant-victim/c.txt', Size: 300, LastModified: new Date('2026-01-03') },
|
|
461
|
+
],
|
|
462
|
+
IsTruncated: false,
|
|
463
|
+
})
|
|
464
|
+
const driver = new S3StorageDriver({
|
|
465
|
+
...BASE_CONFIG,
|
|
466
|
+
organizationId: 'org-owned',
|
|
467
|
+
tenantId: 'tenant-owned',
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
const result = await driver.listObjects('', 50)
|
|
471
|
+
|
|
472
|
+
expect(result.files.map((file) => file.key)).toEqual([
|
|
473
|
+
'docs/org_org-owned/tenant_tenant-owned/a.txt',
|
|
474
|
+
])
|
|
475
|
+
})
|
|
298
476
|
})
|
|
299
477
|
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
const mockSend = jest.fn()
|
|
4
|
+
|
|
5
|
+
jest.mock('@aws-sdk/client-s3', () => ({
|
|
6
|
+
S3Client: jest.fn().mockImplementation(() => ({ send: mockSend })),
|
|
7
|
+
PutObjectCommand: jest.fn().mockImplementation((params) => ({ _type: 'PutObject', ...params })),
|
|
8
|
+
GetObjectCommand: jest.fn().mockImplementation((params) => ({ _type: 'GetObject', ...params })),
|
|
9
|
+
DeleteObjectCommand: jest.fn().mockImplementation((params) => ({ _type: 'DeleteObject', ...params })),
|
|
10
|
+
ListObjectsV2Command: jest.fn().mockImplementation((params) => ({ _type: 'ListObjectsV2', ...params })),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
const mockGetSignedUrl = jest.fn().mockResolvedValue('https://presigned.example.com/object?sig=abc')
|
|
14
|
+
jest.mock('@aws-sdk/s3-request-presigner', () => ({
|
|
15
|
+
getSignedUrl: (...args: unknown[]) => mockGetSignedUrl(...args),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
import { createStorageService } from '../lib/storage-service'
|
|
19
|
+
|
|
20
|
+
const BASE_CONFIG = { bucket: 'test-bucket', region: 'eu-central-1' }
|
|
21
|
+
const TENANT_SCOPE = { organizationId: 'org-owned', tenantId: 'tenant-owned' }
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
mockSend.mockReset()
|
|
25
|
+
mockGetSignedUrl.mockReset()
|
|
26
|
+
mockGetSignedUrl.mockResolvedValue('https://presigned.example.com/object?sig=abc')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('createStorageService', () => {
|
|
30
|
+
it('rejects cross-tenant downloads before calling S3', async () => {
|
|
31
|
+
const service = createStorageService(BASE_CONFIG)
|
|
32
|
+
|
|
33
|
+
await expect(
|
|
34
|
+
service.download({
|
|
35
|
+
key: 'docs/org_org-victim/tenant_tenant-owned/file.txt',
|
|
36
|
+
scope: TENANT_SCOPE,
|
|
37
|
+
}),
|
|
38
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
39
|
+
|
|
40
|
+
expect(mockSend).not.toHaveBeenCalled()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('allows shared namespace downloads for the requested scope', async () => {
|
|
44
|
+
mockSend.mockResolvedValueOnce({
|
|
45
|
+
Body: {
|
|
46
|
+
on(event: string, callback: (chunk?: Buffer) => void) {
|
|
47
|
+
if (event === 'data') callback(Buffer.from('shared'))
|
|
48
|
+
if (event === 'end') callback()
|
|
49
|
+
return this
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
ContentType: 'text/plain',
|
|
53
|
+
})
|
|
54
|
+
const service = createStorageService(BASE_CONFIG)
|
|
55
|
+
|
|
56
|
+
const result = await service.download({
|
|
57
|
+
key: 'docs/org_shared/tenant_shared/shared.txt',
|
|
58
|
+
scope: TENANT_SCOPE,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
expect(result.buffer.toString()).toBe('shared')
|
|
62
|
+
expect(mockSend).toHaveBeenCalledTimes(1)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('rejects cross-tenant deletes before calling S3', async () => {
|
|
66
|
+
const service = createStorageService(BASE_CONFIG)
|
|
67
|
+
|
|
68
|
+
await expect(
|
|
69
|
+
service.delete({
|
|
70
|
+
key: 'docs/org_org-victim/tenant_tenant-owned/file.txt',
|
|
71
|
+
scope: TENANT_SCOPE,
|
|
72
|
+
}),
|
|
73
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
74
|
+
|
|
75
|
+
expect(mockSend).not.toHaveBeenCalled()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('rejects cross-tenant local-path downloads before calling S3', async () => {
|
|
79
|
+
const service = createStorageService(BASE_CONFIG)
|
|
80
|
+
|
|
81
|
+
await expect(
|
|
82
|
+
service.toLocalPath({
|
|
83
|
+
key: 'docs/org_org-owned/tenant_tenant-victim/file.txt',
|
|
84
|
+
scope: TENANT_SCOPE,
|
|
85
|
+
}),
|
|
86
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
87
|
+
|
|
88
|
+
expect(mockSend).not.toHaveBeenCalled()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('rejects cross-tenant signed URLs before presigning', async () => {
|
|
92
|
+
const service = createStorageService(BASE_CONFIG)
|
|
93
|
+
|
|
94
|
+
await expect(
|
|
95
|
+
service.getSignedUrl({
|
|
96
|
+
key: 'docs/org_org-owned/tenant_tenant-victim/file.txt',
|
|
97
|
+
operation: 'download',
|
|
98
|
+
scope: TENANT_SCOPE,
|
|
99
|
+
}),
|
|
100
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
101
|
+
|
|
102
|
+
expect(mockGetSignedUrl).not.toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('keeps shared namespace unavailable for upload signed URLs', async () => {
|
|
106
|
+
const service = createStorageService(BASE_CONFIG)
|
|
107
|
+
|
|
108
|
+
await expect(
|
|
109
|
+
service.getSignedUrl({
|
|
110
|
+
key: 'docs/org_shared/tenant_shared/shared.txt',
|
|
111
|
+
operation: 'upload',
|
|
112
|
+
scope: TENANT_SCOPE,
|
|
113
|
+
}),
|
|
114
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
115
|
+
|
|
116
|
+
expect(mockGetSignedUrl).not.toHaveBeenCalled()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('filters list results to the requested tenant scope', async () => {
|
|
120
|
+
mockSend.mockResolvedValueOnce({
|
|
121
|
+
Contents: [
|
|
122
|
+
{ Key: 'docs/org_org-owned/tenant_tenant-owned/a.txt', Size: 100, LastModified: new Date('2026-01-01') },
|
|
123
|
+
{ Key: 'docs/org_org-victim/tenant_tenant-owned/b.txt', Size: 200, LastModified: new Date('2026-01-02') },
|
|
124
|
+
],
|
|
125
|
+
IsTruncated: false,
|
|
126
|
+
})
|
|
127
|
+
const service = createStorageService(BASE_CONFIG)
|
|
128
|
+
|
|
129
|
+
const result = await service.list({ prefix: '', scope: TENANT_SCOPE })
|
|
130
|
+
|
|
131
|
+
expect(result.files.map((file) => file.key)).toEqual([
|
|
132
|
+
'docs/org_org-owned/tenant_tenant-owned/a.txt',
|
|
133
|
+
])
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -22,7 +22,7 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
|
|
|
22
22
|
}
|
|
23
23
|
const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })
|
|
24
24
|
if (!creds) return null
|
|
25
|
-
return new S3StorageDriver(creds)
|
|
25
|
+
return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export async function DELETE(req: Request) {
|
|
@@ -23,7 +23,7 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
|
|
|
23
23
|
}
|
|
24
24
|
const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })
|
|
25
25
|
if (!creds) return null
|
|
26
|
-
return new S3StorageDriver(creds)
|
|
26
|
+
return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export async function GET(req: Request) {
|
|
@@ -35,7 +35,7 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
|
|
|
35
35
|
}
|
|
36
36
|
const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })
|
|
37
37
|
if (!creds) return null
|
|
38
|
-
return new S3StorageDriver(creds)
|
|
38
|
+
return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export async function GET(req: Request) {
|
|
@@ -30,7 +30,7 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
|
|
|
30
30
|
}
|
|
31
31
|
const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })
|
|
32
32
|
if (!creds) return null
|
|
33
|
-
return new S3StorageDriver(creds)
|
|
33
|
+
return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export async function POST(req: Request) {
|
|
@@ -43,7 +43,7 @@ async function resolveDriver(
|
|
|
43
43
|
}
|
|
44
44
|
const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })
|
|
45
45
|
if (!creds) return null
|
|
46
|
-
return new S3StorageDriver(creds)
|
|
46
|
+
return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
async function readTenantStorageUsageBytes(
|