@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.
- 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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
57
|
-
return
|
|
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(
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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(
|
|
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(
|
|
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: (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|