@open-mercato/storage-s3 0.6.7-develop.6573.1.28649ddec6 → 0.6.7-develop.6576.1.c4bc47711b

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.
Files changed (31) hide show
  1. package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js +1 -1
  2. package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js.map +2 -2
  3. package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js +1 -1
  4. package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js.map +2 -2
  5. package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js +1 -1
  6. package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js.map +2 -2
  7. package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js +1 -1
  8. package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js.map +2 -2
  9. package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js +1 -1
  10. package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js.map +2 -2
  11. package/dist/modules/storage_s3/di.js +14 -5
  12. package/dist/modules/storage_s3/di.js.map +2 -2
  13. package/dist/modules/storage_s3/lib/key-scope.js +77 -6
  14. package/dist/modules/storage_s3/lib/key-scope.js.map +2 -2
  15. package/dist/modules/storage_s3/lib/s3-driver.js +43 -9
  16. package/dist/modules/storage_s3/lib/s3-driver.js.map +2 -2
  17. package/dist/modules/storage_s3/lib/storage-service.js +22 -7
  18. package/dist/modules/storage_s3/lib/storage-service.js.map +2 -2
  19. package/package.json +4 -4
  20. package/src/modules/storage_s3/__tests__/di.test.ts +42 -0
  21. package/src/modules/storage_s3/__tests__/s3Driver.test.ts +178 -0
  22. package/src/modules/storage_s3/__tests__/storageService.test.ts +135 -0
  23. package/src/modules/storage_s3/api/delete/storage-providers/s3/delete.ts +1 -1
  24. package/src/modules/storage_s3/api/get/storage-providers/s3/download.ts +1 -1
  25. package/src/modules/storage_s3/api/get/storage-providers/s3/list.ts +1 -1
  26. package/src/modules/storage_s3/api/post/storage-providers/s3/signed-url.ts +1 -1
  27. package/src/modules/storage_s3/api/post/storage-providers/s3/upload.ts +1 -1
  28. package/src/modules/storage_s3/di.ts +13 -4
  29. package/src/modules/storage_s3/lib/key-scope.ts +140 -6
  30. package/src/modules/storage_s3/lib/s3-driver.ts +54 -7
  31. 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(_partitionCode: string, storagePath: string): Promise<ReadFileResult> {\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 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 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 ): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }> {\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 return {\n files: (response.Contents ?? []).map((obj) => ({\n key: obj.Key ?? '',\n size: obj.Size ?? 0,\n lastModified: obj.LastModified ?? new Date(0),\n })),\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 ): Promise<string> {\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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",
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
- const url = await driver.getSignedUrl(key, operation, expiresIn, contentType);
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
- return driver.listObjects(prefix, maxKeys, continuationToken);
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;AAmDhC,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;AAEpE,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,IAAI,GAAsD;AACzE,aAAO,OAAO,KAAK,IAAI,GAAG;AAAA,IAC5B;AAAA,IAEA,MAAM,OAAO,EAAE,IAAI,GAAkB;AACnC,aAAO,OAAO,OAAO,IAAI,GAAG;AAAA,IAC9B;AAAA,IAEA,MAAM,aAAa,EAAE,KAAK,WAAW,YAAY,MAAM,YAAY,GAA8C;AAC/G,YAAM,MAAM,MAAM,OAAO,aAAa,KAAK,WAAW,WAAW,WAAW;AAC5E,aAAO,EAAE,KAAK,WAAW,IAAI,KAAK,KAAK,IAAI,IAAI,YAAY,GAAI,EAAE;AAAA,IACnE;AAAA,IAEA,MAAM,KAAK,EAAE,QAAQ,UAAU,KAAK,kBAAkB,GAInD;AACD,aAAO,OAAO,YAAY,QAAQ,SAAS,iBAAiB;AAAA,IAC9D;AAAA,IAEA,MAAM,YAAY,EAAE,IAAI,GAAG;AACzB,aAAO,OAAO,YAAY,IAAI,GAAG;AAAA,IACnC;AAAA,EACF;AACF;",
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.6573.1.28649ddec6",
3
+ "version": "0.6.7-develop.6576.1.c4bc47711b",
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.6573.1.28649ddec6"
72
+ "@open-mercato/core": "0.6.7-develop.6576.1.c4bc47711b"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "@mikro-orm/postgresql": "^6.6.10",
76
- "@open-mercato/shared": "0.6.7-develop.6573.1.28649ddec6"
76
+ "@open-mercato/shared": "0.6.7-develop.6576.1.c4bc47711b"
77
77
  },
78
78
  "devDependencies": {
79
- "@open-mercato/shared": "0.6.7-develop.6573.1.28649ddec6",
79
+ "@open-mercato/shared": "0.6.7-develop.6576.1.c4bc47711b",
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(