@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
@@ -33,16 +33,23 @@ export function register(container: AppContainer) {
33
33
  // Register the credential enhancer via DI so it can access the request-scoped
34
34
  // integrationCredentialsService to inject marketplace credentials at upload time.
35
35
  registerExternalCredentialEnhancer('s3', async (config, scope) => {
36
- if (config.credentialsEnvPrefix || config.accessKeyId || config.authMode) return config
36
+ const scopedConfig = {
37
+ ...config,
38
+ organizationId: scope.organizationId,
39
+ tenantId: scope.tenantId,
40
+ }
41
+ if (config.credentialsEnvPrefix || config.accessKeyId || config.authMode) return scopedConfig
37
42
  try {
38
43
  const credsSvc = container.resolve('integrationCredentialsService') as IntegrationCredentialsService
39
44
  const creds = await credsSvc.resolve('storage_s3', scope)
40
45
  if (!creds) {
41
46
  logger.debug('No marketplace credentials found for scope', { tenantId: scope.tenantId, organizationId: scope.organizationId })
42
- return config
47
+ return scopedConfig
43
48
  }
44
49
  logger.debug('Injecting marketplace credentials into S3 driver config')
45
50
  return {
51
+ organizationId: scope.organizationId,
52
+ tenantId: scope.tenantId,
46
53
  authMode: creds.authMode,
47
54
  bucket: config.bucket ?? (creds.bucket ? String(creds.bucket) : undefined),
48
55
  region: config.region ?? (creds.region ? String(creds.region) : undefined),
@@ -53,8 +60,8 @@ export function register(container: AppContainer) {
53
60
  sessionToken: creds.sessionToken ? String(creds.sessionToken) : undefined,
54
61
  }
55
62
  } catch (err) {
56
- logger.warn('Credential enhancer failed, using partition config as-is', { err })
57
- return config
63
+ logger.warn('Credential enhancer failed, using scoped partition config', { err })
64
+ return scopedConfig
58
65
  }
59
66
  })
60
67
 
@@ -79,6 +86,8 @@ export function register(container: AppContainer) {
79
86
  accessKeyId: creds.accessKeyId ? String(creds.accessKeyId) : undefined,
80
87
  secretAccessKey: creds.secretAccessKey ? String(creds.secretAccessKey) : undefined,
81
88
  sessionToken: creds.sessionToken ? String(creds.sessionToken) : undefined,
89
+ organizationId: scope.organizationId,
90
+ tenantId: scope.tenantId,
82
91
  // Credentials are resolved from the Integration Marketplace (encrypted at rest)
83
92
  // and injected directly rather than via env prefix for the standalone service.
84
93
  } as Parameters<typeof createStorageService>[0])
@@ -1,29 +1,163 @@
1
+ export type S3TenantScope = {
2
+ organizationId?: string | null
3
+ tenantId?: string | null
4
+ }
5
+
6
+ export type S3ScopedObject = {
7
+ key: string
8
+ }
9
+
10
+ type ScopeSegments = {
11
+ orgSegment: string
12
+ tenantSegment: string
13
+ }
14
+
1
15
  const SHARED_ORG_SEGMENT = 'org_shared'
2
16
  const SHARED_TENANT_SEGMENT = 'tenant_shared'
3
17
 
18
+ function resolveSegment(value: string | null | undefined, prefix: 'org' | 'tenant'): string | null {
19
+ if (typeof value !== 'string') return null
20
+ const trimmed = value.trim()
21
+ if (!trimmed) return null
22
+ return `${prefix}_${trimmed}`
23
+ }
24
+
25
+ function resolveScopeSegments(scope: S3TenantScope | null | undefined): ScopeSegments | null {
26
+ const orgSegment = resolveSegment(scope?.organizationId, 'org')
27
+ const tenantSegment = resolveSegment(scope?.tenantId, 'tenant')
28
+ if (!orgSegment || !tenantSegment) return null
29
+ return { orgSegment, tenantSegment }
30
+ }
31
+
32
+ function normalizePath(value: string): string {
33
+ return value.replace(/^\/+/, '')
34
+ }
35
+
36
+ function stripConfiguredPrefix(value: string, pathPrefix?: string): string {
37
+ const normalized = normalizePath(value)
38
+ const normalizedPrefix = normalizePath(pathPrefix ?? '')
39
+ if (!normalizedPrefix) return normalized
40
+ return normalized.startsWith(normalizedPrefix)
41
+ ? normalized.slice(normalizedPrefix.length).replace(/^\/+/, '')
42
+ : normalized
43
+ }
44
+
45
+ function splitS3Path(value: string, pathPrefix?: string): string[] {
46
+ return stripConfiguredPrefix(value, pathPrefix).split('/').filter(Boolean)
47
+ }
48
+
4
49
  function findScopeSegmentIndex(parts: string[]): number {
5
50
  return parts.findIndex((part, index) => (
6
51
  part.startsWith('org_') && parts[index + 1]?.startsWith('tenant_')
7
52
  ))
8
53
  }
9
54
 
10
- function hasScopeSegments(key: string, orgSegment: string, tenantSegment: string): boolean {
11
- const parts = key.split('/')
55
+ function hasScopeSegments(parts: string[], orgSegment: string, tenantSegment: string): boolean {
12
56
  const scopeIndex = findScopeSegmentIndex(parts)
13
57
  return scopeIndex >= 0
14
58
  && parts[scopeIndex] === orgSegment
15
59
  && parts[scopeIndex + 1] === tenantSegment
16
60
  }
17
61
 
18
- export function isS3KeyScopedToTenant(key: string, orgId: string, tenantId: string): boolean {
19
- if (!orgId || !tenantId) return false
20
- return hasScopeSegments(key, `org_${orgId}`, `tenant_${tenantId}`)
62
+ function resolveScopeSegmentsFromArgs(
63
+ scopeOrOrgId: S3TenantScope | string | null | undefined,
64
+ tenantId?: string,
65
+ ): ScopeSegments | null {
66
+ if (typeof scopeOrOrgId === 'string') {
67
+ return resolveScopeSegments({ organizationId: scopeOrOrgId, tenantId })
68
+ }
69
+ return resolveScopeSegments(scopeOrOrgId)
70
+ }
71
+
72
+ export function isS3KeyScopedToTenant(key: string, orgId: string, tenantId: string): boolean
73
+ export function isS3KeyScopedToTenant(
74
+ storagePath: string,
75
+ scope: S3TenantScope | null | undefined,
76
+ pathPrefix?: string,
77
+ ): boolean
78
+ export function isS3KeyScopedToTenant(
79
+ storagePath: string,
80
+ scopeOrOrgId: S3TenantScope | string | null | undefined,
81
+ pathPrefixOrTenantId?: string,
82
+ ): boolean {
83
+ const segments = resolveScopeSegmentsFromArgs(scopeOrOrgId, pathPrefixOrTenantId)
84
+ if (!segments) return typeof scopeOrOrgId === 'string' ? false : true
85
+
86
+ const pathPrefix = typeof scopeOrOrgId === 'string' ? undefined : pathPrefixOrTenantId
87
+ const parts = splitS3Path(storagePath, pathPrefix)
88
+ return hasScopeSegments(parts, segments.orgSegment, segments.tenantSegment)
21
89
  }
22
90
 
23
91
  export function isS3KeyShared(key: string): boolean {
24
- return hasScopeSegments(key, SHARED_ORG_SEGMENT, SHARED_TENANT_SEGMENT)
92
+ return hasScopeSegments(splitS3Path(key), SHARED_ORG_SEGMENT, SHARED_TENANT_SEGMENT)
25
93
  }
26
94
 
27
95
  export function isS3KeyAddressableByScope(key: string, orgId: string, tenantId: string): boolean {
28
96
  return isS3KeyScopedToTenant(key, orgId, tenantId) || isS3KeyShared(key)
29
97
  }
98
+
99
+ export function isS3KeyAddressableByTenantScope(
100
+ storagePath: string,
101
+ scope: S3TenantScope | null | undefined,
102
+ pathPrefix?: string,
103
+ ): boolean {
104
+ if (!resolveScopeSegments(scope)) return true
105
+ const parts = splitS3Path(storagePath, pathPrefix)
106
+ return isS3KeyScopedToTenant(storagePath, scope, pathPrefix)
107
+ || hasScopeSegments(parts, SHARED_ORG_SEGMENT, SHARED_TENANT_SEGMENT)
108
+ }
109
+
110
+ export function assertS3KeyScopedToTenant(
111
+ storagePath: string,
112
+ scope: S3TenantScope | null | undefined,
113
+ pathPrefix?: string,
114
+ ): void {
115
+ if (!isS3KeyScopedToTenant(storagePath, scope, pathPrefix)) {
116
+ throw new Error('S3 key is not scoped to the active tenant')
117
+ }
118
+ }
119
+
120
+ export function assertS3KeyAddressableByTenantScope(
121
+ storagePath: string,
122
+ scope: S3TenantScope | null | undefined,
123
+ pathPrefix?: string,
124
+ ): void {
125
+ if (!isS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix)) {
126
+ throw new Error('S3 key is not scoped to the active tenant')
127
+ }
128
+ }
129
+
130
+ export function assertS3ListPrefixScopedToTenant(
131
+ prefix: string,
132
+ scope: S3TenantScope | null | undefined,
133
+ pathPrefix?: string,
134
+ ): void {
135
+ const segments = resolveScopeSegments(scope)
136
+ if (!segments) return
137
+
138
+ const parts = splitS3Path(prefix, pathPrefix)
139
+ if (parts.length === 0) return
140
+
141
+ if (parts[0]?.startsWith('org_') || parts[1]?.startsWith('tenant_')) {
142
+ if (parts[0] !== segments.orgSegment || parts[1] !== segments.tenantSegment) {
143
+ throw new Error('S3 prefix is not scoped to the active tenant')
144
+ }
145
+ return
146
+ }
147
+
148
+ if (
149
+ (parts[1]?.startsWith('org_') && parts[1] !== segments.orgSegment)
150
+ || (parts[2]?.startsWith('tenant_') && parts[2] !== segments.tenantSegment)
151
+ ) {
152
+ throw new Error('S3 prefix is not scoped to the active tenant')
153
+ }
154
+ }
155
+
156
+ export function filterS3ObjectsToTenant<T extends S3ScopedObject>(
157
+ files: T[],
158
+ scope: S3TenantScope | null | undefined,
159
+ pathPrefix?: string,
160
+ ): T[] {
161
+ if (!resolveScopeSegments(scope)) return files
162
+ return files.filter((file) => isS3KeyScopedToTenant(file.key, scope, pathPrefix))
163
+ }
@@ -11,6 +11,13 @@ import {
11
11
  } from '@aws-sdk/client-s3'
12
12
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
13
13
  import type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from '@open-mercato/core/modules/attachments/lib/drivers'
14
+ import {
15
+ assertS3KeyAddressableByTenantScope,
16
+ assertS3KeyScopedToTenant,
17
+ assertS3ListPrefixScopedToTenant,
18
+ filterS3ObjectsToTenant,
19
+ type S3TenantScope,
20
+ } from './key-scope'
14
21
 
15
22
  export type S3DriverConfig = {
16
23
  bucket: string
@@ -32,6 +39,8 @@ export type S3DriverConfig = {
32
39
  accessKeyId?: string
33
40
  secretAccessKey?: string
34
41
  sessionToken?: string
42
+ organizationId?: string | null
43
+ tenantId?: string | null
35
44
  }
36
45
 
37
46
  function sanitizeFileName(fileName: string): string {
@@ -63,11 +72,15 @@ export class S3StorageDriver implements StorageDriver {
63
72
  private readonly client: S3Client
64
73
  private readonly bucket: string
65
74
  private readonly pathPrefix: string
75
+ private readonly scope: S3TenantScope | null
66
76
 
67
77
  constructor(config: Record<string, unknown>) {
68
78
  const cfg = config as S3DriverConfig
69
79
  this.bucket = cfg.bucket
70
80
  this.pathPrefix = cfg.pathPrefix ?? ''
81
+ this.scope = cfg.organizationId && cfg.tenantId
82
+ ? { organizationId: cfg.organizationId, tenantId: cfg.tenantId }
83
+ : null
71
84
 
72
85
  const authMode = cfg.authMode
73
86
  const shouldUseAccessKeys =
@@ -104,6 +117,25 @@ export class S3StorageDriver implements StorageDriver {
104
117
  })
105
118
  }
106
119
 
120
+ private assertKeyScoped(storagePath: string, scope?: S3TenantScope | null): void {
121
+ assertS3KeyScopedToTenant(storagePath, scope ?? this.scope, this.pathPrefix)
122
+ }
123
+
124
+ private assertKeyAddressable(storagePath: string, scope?: S3TenantScope | null): void {
125
+ assertS3KeyAddressableByTenantScope(storagePath, scope ?? this.scope, this.pathPrefix)
126
+ }
127
+
128
+ private assertPartitionScoped(partitionCode: string, storagePath: string): void {
129
+ if (!partitionCode) return
130
+ const prefix = this.pathPrefix && storagePath.startsWith(this.pathPrefix)
131
+ ? storagePath.slice(this.pathPrefix.length).replace(/^\/+/, '')
132
+ : storagePath.replace(/^\/+/, '')
133
+ const firstSegment = prefix.split('/').filter(Boolean)[0]
134
+ if (firstSegment !== partitionCode) {
135
+ throw new Error('S3 key is not scoped to the requested partition')
136
+ }
137
+ }
138
+
107
139
  async store(payload: StoreFilePayload): Promise<StoredFile> {
108
140
  const orgSegment = resolveOrgSegment(payload.orgId ?? null)
109
141
  const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)
@@ -124,7 +156,9 @@ export class S3StorageDriver implements StorageDriver {
124
156
  return { storagePath: key }
125
157
  }
126
158
 
127
- async read(_partitionCode: string, storagePath: string): Promise<ReadFileResult> {
159
+ async read(partitionCode: string, storagePath: string): Promise<ReadFileResult> {
160
+ this.assertPartitionScoped(partitionCode, storagePath)
161
+ this.assertKeyAddressable(storagePath)
128
162
  const response = await this.client.send(
129
163
  new GetObjectCommand({ Bucket: this.bucket, Key: storagePath }),
130
164
  )
@@ -135,7 +169,9 @@ export class S3StorageDriver implements StorageDriver {
135
169
  return { buffer, contentType: response.ContentType }
136
170
  }
137
171
 
138
- async delete(_partitionCode: string, storagePath: string): Promise<void> {
172
+ async delete(partitionCode: string, storagePath: string): Promise<void> {
173
+ this.assertPartitionScoped(partitionCode, storagePath)
174
+ this.assertKeyAddressable(storagePath)
139
175
  try {
140
176
  await this.client.send(
141
177
  new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }),
@@ -170,6 +206,7 @@ export class S3StorageDriver implements StorageDriver {
170
206
  * Put an object directly at a specific S3 key (for standalone usage).
171
207
  */
172
208
  async putObject(key: string, buffer: Buffer, contentType?: string): Promise<void> {
209
+ this.assertKeyScoped(key)
173
210
  await this.client.send(
174
211
  new PutObjectCommand({
175
212
  Bucket: this.bucket,
@@ -189,11 +226,14 @@ export class S3StorageDriver implements StorageDriver {
189
226
  prefix: string,
190
227
  maxKeys = 100,
191
228
  continuationToken?: string,
229
+ scope?: S3TenantScope | null,
192
230
  ): Promise<{
193
231
  files: Array<{ key: string; size: number; lastModified: Date }>
194
232
  truncated: boolean
195
233
  nextContinuationToken?: string
196
234
  }> {
235
+ const activeScope = scope ?? this.scope
236
+ assertS3ListPrefixScopedToTenant(prefix, activeScope, this.pathPrefix)
197
237
  const response = await this.client.send(
198
238
  new ListObjectsV2Command({
199
239
  Bucket: this.bucket,
@@ -202,12 +242,13 @@ export class S3StorageDriver implements StorageDriver {
202
242
  ContinuationToken: continuationToken,
203
243
  }),
204
244
  )
245
+ const files = (response.Contents ?? []).map((obj) => ({
246
+ key: obj.Key ?? '',
247
+ size: obj.Size ?? 0,
248
+ lastModified: obj.LastModified ?? new Date(0),
249
+ }))
205
250
  return {
206
- files: (response.Contents ?? []).map((obj) => ({
207
- key: obj.Key ?? '',
208
- size: obj.Size ?? 0,
209
- lastModified: obj.LastModified ?? new Date(0),
210
- })),
251
+ files: filterS3ObjectsToTenant(files, activeScope, this.pathPrefix),
211
252
  truncated: response.IsTruncated ?? false,
212
253
  nextContinuationToken: response.NextContinuationToken,
213
254
  }
@@ -221,7 +262,13 @@ export class S3StorageDriver implements StorageDriver {
221
262
  operation: 'upload' | 'download',
222
263
  expiresIn = 3600,
223
264
  contentType?: string,
265
+ scope?: S3TenantScope | null,
224
266
  ): Promise<string> {
267
+ if (operation === 'upload') {
268
+ this.assertKeyScoped(storagePath, scope)
269
+ } else {
270
+ this.assertKeyAddressable(storagePath, scope)
271
+ }
225
272
  if (operation === 'upload') {
226
273
  const command = new PutObjectCommand({
227
274
  Bucket: this.bucket,
@@ -1,5 +1,10 @@
1
1
  import { randomUUID } from 'crypto'
2
2
  import { S3StorageDriver } from './s3-driver'
3
+ import {
4
+ assertS3KeyAddressableByTenantScope,
5
+ assertS3KeyScopedToTenant,
6
+ assertS3ListPrefixScopedToTenant,
7
+ } from './key-scope'
3
8
 
4
9
  type TenantScope = {
5
10
  tenantId: string | null | undefined
@@ -48,6 +53,9 @@ type S3Config = {
48
53
  accessKeyId?: string
49
54
  secretAccessKey?: string
50
55
  sessionToken?: string
56
+ pathPrefix?: string
57
+ organizationId?: string | null
58
+ tenantId?: string | null
51
59
  }
52
60
 
53
61
  function sanitizeSegment(value: string): string {
@@ -77,6 +85,7 @@ export interface StorageService {
77
85
 
78
86
  export function createStorageService(config: S3Config): StorageService {
79
87
  const driver = new S3StorageDriver(config as Record<string, unknown>)
88
+ const pathPrefix = config.pathPrefix
80
89
 
81
90
  return {
82
91
  async upload({ namespace, fileName, buffer, contentType, scope }): Promise<StorageResult> {
@@ -96,28 +105,37 @@ export function createStorageService(config: S3Config): StorageService {
96
105
  }
97
106
  },
98
107
 
99
- async download({ key }): Promise<{ buffer: Buffer; contentType?: string }> {
108
+ async download({ key, scope }): Promise<{ buffer: Buffer; contentType?: string }> {
109
+ assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)
100
110
  return driver.read('', key)
101
111
  },
102
112
 
103
- async delete({ key }): Promise<void> {
113
+ async delete({ key, scope }): Promise<void> {
114
+ assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)
104
115
  return driver.delete('', key)
105
116
  },
106
117
 
107
- async getSignedUrl({ key, operation, expiresIn = 3600, contentType }): Promise<{ url: string; expiresAt: Date }> {
108
- const url = await driver.getSignedUrl(key, operation, expiresIn, contentType)
118
+ async getSignedUrl({ key, operation, expiresIn = 3600, contentType, scope }): Promise<{ url: string; expiresAt: Date }> {
119
+ if (operation === 'upload') {
120
+ assertS3KeyScopedToTenant(key, scope, pathPrefix)
121
+ } else {
122
+ assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)
123
+ }
124
+ const url = await driver.getSignedUrl(key, operation, expiresIn, contentType, scope)
109
125
  return { url, expiresAt: new Date(Date.now() + expiresIn * 1000) }
110
126
  },
111
127
 
112
- async list({ prefix, maxKeys = 100, continuationToken }): Promise<{
128
+ async list({ prefix, maxKeys = 100, continuationToken, scope }): Promise<{
113
129
  files: Array<{ key: string; size: number; lastModified: Date }>
114
130
  truncated: boolean
115
131
  nextContinuationToken?: string
116
132
  }> {
117
- return driver.listObjects(prefix, maxKeys, continuationToken)
133
+ assertS3ListPrefixScopedToTenant(prefix, scope, pathPrefix)
134
+ return driver.listObjects(prefix, maxKeys, continuationToken, scope)
118
135
  },
119
136
 
120
- async toLocalPath({ key }) {
137
+ async toLocalPath({ key, scope }) {
138
+ assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)
121
139
  return driver.toLocalPath('', key)
122
140
  },
123
141
  }