Skip to content

Commit c82505b

Browse files
authored
fix: tenant s3 credentials fixes and refactor (#668)
fix: tenant s3 credentials fixes and refactor - migration to use correct cache key in pg_notify cache invalidation - fix s3 credentials count, was always returning 0 (and allowing creation of > 50 credentials) - check for valid uuid on delete credential endpoint (used to throw 500 on insert) - add test coverage - refactor s3 credentials into manager pattern similar to jwks
1 parent c7ea2da commit c82505b

File tree

23 files changed

+882
-223
lines changed

23 files changed

+882
-223
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
CREATE OR REPLACE FUNCTION tenants_s3_credentials_update_notify_trigger ()
2+
RETURNS TRIGGER
3+
AS $$
4+
BEGIN
5+
PERFORM
6+
pg_notify('tenants_s3_credentials_update', '"' || NEW.tenant_id || ':' || NEW.access_key || '"');
7+
RETURN NULL;
8+
END;
9+
$$
10+
LANGUAGE plpgsql;
11+
12+
CREATE OR REPLACE FUNCTION tenants_s3_credentials_delete_notify_trigger ()
13+
RETURNS TRIGGER
14+
AS $$
15+
BEGIN
16+
PERFORM
17+
pg_notify('tenants_s3_credentials_update', '"' || OLD.tenant_id || ':' || OLD.access_key || '"');
18+
RETURN NULL;
19+
END;
20+
$$
21+
LANGUAGE plpgsql;

src/http/error-handler.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export const setErrorHandler = (app: FastifyInstance) => {
6464
// Fastify errors
6565
if ('statusCode' in error) {
6666
const err = error as FastifyError
67-
return reply.status((error as any).statusCode || 500).send({
67+
return reply.status(err.statusCode || 500).send({
6868
statusCode: `${err.statusCode}`,
6969
error: err.name,
7070
message: err.message,

src/http/plugins/db.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ export const migrations = fastifyPlugin(
171171
})
172172

173173
if (dbMigrationStrategy === MultitenantMigrationStrategy.ON_REQUEST) {
174-
const migrationsMutex = createMutexByKey()
174+
const migrationsMutex = createMutexByKey<void>()
175175

176176
fastify.addHook('preHandler', async (request) => {
177177
// migrations are handled via async migrations

src/http/plugins/jwt.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const jwt = fastifyPlugin(
2525
fastify.decorateRequest('jwt', '')
2626
fastify.decorateRequest('jwtPayload', undefined)
2727

28-
fastify.addHook('preHandler', async (request, reply) => {
28+
fastify.addHook('preHandler', async (request) => {
2929
request.jwt = (request.headers.authorization || '').replace(BEARER, '')
3030

3131
if (!request.jwt && request.routeOptions.config.allowInvalidJwt) {
@@ -41,13 +41,14 @@ export const jwt = fastifyPlugin(
4141
request.jwtPayload = payload
4242
request.owner = payload.sub
4343
request.isAuthenticated = true
44-
} catch (err: any) {
44+
} catch (e) {
4545
request.jwtPayload = { role: 'anon' }
4646
request.isAuthenticated = false
4747

4848
if (request.routeOptions.config.allowInvalidJwt) {
4949
return
5050
}
51+
const err = e as Error
5152
throw ERRORS.AccessDenied(err.message, err)
5253
}
5354
})

src/http/plugins/signature-v4.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { FastifyInstance, FastifyRequest } from 'fastify'
22
import fastifyPlugin from 'fastify-plugin'
3-
import { getJwtSecret, getS3CredentialsByAccessKey, getTenantConfig } from '@internal/database'
3+
import { getJwtSecret, getTenantConfig, s3CredentialsManager } from '@internal/database'
44
import { ClientSignature, SignatureV4 } from '@storage/protocols/s3'
55
import { signJWT, verifyJWT } from '@internal/auth'
66
import { ERRORS } from '@internal/errors'
@@ -160,7 +160,7 @@ async function createServerSignature(tenantId: string, clientSignature: ClientSi
160160
}
161161

162162
if (isMultitenant) {
163-
const credential = await getS3CredentialsByAccessKey(
163+
const credential = await s3CredentialsManager.getS3CredentialsByAccessKey(
164164
tenantId,
165165
clientSignature.credentials.accessKey
166166
)

src/http/routes/admin/jwks.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export default async function routes(fastify: FastifyInstance) {
110110
}
111111

112112
const result = await jwksManager.addJwk(params.tenantId, body.jwk, body.kind)
113-
return reply.send(result)
113+
return reply.status(201).send(result)
114114
}
115115
)
116116

src/http/routes/admin/s3.ts

+9-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { FastifyInstance, RequestGenericInterface } from 'fastify'
22
import apiKey from '../../plugins/apikey'
3-
import { createS3Credentials, deleteS3Credential, listS3Credentials } from '@internal/database'
3+
import { s3CredentialsManager } from '@internal/database'
44
import { FromSchema } from 'json-schema-to-ts'
5+
import { isUuid } from '@storage/limits'
6+
import { ERRORS } from '@internal/errors'
57

68
const createCredentialsSchema = {
79
description: 'Create S3 Credentials',
@@ -88,7 +90,7 @@ export default async function routes(fastify: FastifyInstance) {
8890
schema: createCredentialsSchema,
8991
},
9092
async (req, reply) => {
91-
const credentials = await createS3Credentials(req.params.tenantId, {
93+
const credentials = await s3CredentialsManager.createS3Credentials(req.params.tenantId, {
9294
description: req.body.description,
9395
claims: req.body.claims,
9496
})
@@ -106,8 +108,7 @@ export default async function routes(fastify: FastifyInstance) {
106108
'/:tenantId/credentials',
107109
{ schema: listCredentialsSchema },
108110
async (req, reply) => {
109-
const credentials = await listS3Credentials(req.params.tenantId)
110-
111+
const credentials = await s3CredentialsManager.listS3Credentials(req.params.tenantId)
111112
return reply.send(credentials)
112113
}
113114
)
@@ -116,7 +117,10 @@ export default async function routes(fastify: FastifyInstance) {
116117
'/:tenantId/credentials',
117118
{ schema: deleteCredentialsSchema },
118119
async (req, reply) => {
119-
await deleteS3Credential(req.params.tenantId, req.body.id)
120+
if (!isUuid(req.body.id)) {
121+
throw ERRORS.InvalidParameter('id not uuid')
122+
}
123+
await s3CredentialsManager.deleteS3Credential(req.params.tenantId, req.body.id)
120124

121125
return reply.code(204).send()
122126
}

src/internal/concurrency/mutex.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { Semaphore } from '@shopify/semaphore'
22

3-
export function createMutexByKey() {
3+
export function createMutexByKey<T>() {
44
const semaphoreMap = new Map<string, { semaphore: Semaphore; count: number }>()
55

6-
return async (key: string, fn: () => Promise<any>) => {
6+
return async (key: string, fn: () => Promise<T>) => {
77
let entry = semaphoreMap.get(key)
88
if (!entry) {
99
entry = { semaphore: new Semaphore(1), count: 0 }

src/internal/database/jwks-manager/manager.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const TENANTS_JWKS_UPDATE_CHANNEL = 'tenants_jwks_update'
99
const JWK_KIND_STORAGE_URL_SIGNING = 'storage-url-signing-key'
1010
const JWK_KID_SEPARATOR = '_'
1111

12-
const tenantJwksMutex = createMutexByKey()
12+
const tenantJwksMutex = createMutexByKey<JwksConfig>()
1313
const tenantJwksConfigCache = new Map<string, JwksConfig>()
1414

1515
function createJwkKid({ kind, id }: { id: string; kind: string }): string {

src/internal/database/tenant.ts

+12-150
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
1-
import crypto from 'node:crypto'
21
import { getConfig, JwksConfig, JwksConfigKey, JwksConfigKeyOCT } from '../../config'
3-
import { decrypt, encrypt, verifyJWT } from '../auth'
2+
import { decrypt, verifyJWT } from '../auth'
43
import { multitenantKnex } from './multitenant-db'
54
import { JwtPayload } from 'jsonwebtoken'
65
import { PubSubAdapter } from '../pubsub'
76
import { createMutexByKey } from '../concurrency'
8-
import { LRUCache } from 'lru-cache'
9-
import objectSizeOf from 'object-sizeof'
107
import { ERRORS } from '@internal/errors'
118
import { DBMigration } from '@internal/database/migrations'
129
import { JWKSManager } from './jwks-manager'
1310
import { JWKSManagerStoreKnex } from './jwks-manager/store-knex'
11+
import { S3CredentialsManagerStoreKnex } from '../../storage/protocols/s3/credentials-manager/store-knex'
12+
import { S3CredentialsManager } from '../../storage/protocols/s3/credentials-manager'
1413

1514
interface TenantConfig {
1615
anonKey?: string
@@ -51,28 +50,16 @@ export enum TenantMigrationStatus {
5150
FAILED_STALE = 'FAILED_STALE',
5251
}
5352

54-
interface S3Credentials {
55-
accessKey: string
56-
secretKey: string
57-
claims: { role: string; sub?: string; [key: string]: unknown }
58-
}
59-
6053
const { isMultitenant, dbServiceRole, serviceKey, jwtSecret } = getConfig()
6154

6255
const tenantConfigCache = new Map<string, TenantConfig>()
6356

64-
const tenantS3CredentialsCache = new LRUCache<string, S3Credentials>({
65-
maxSize: 1024 * 1024 * 50, // 50MB
66-
ttl: 1000 * 60 * 60, // 1 hour
67-
sizeCalculation: (value) => objectSizeOf(value),
68-
updateAgeOnGet: true,
69-
allowStale: false,
70-
})
71-
72-
const tenantMutex = createMutexByKey()
73-
const s3CredentialsMutex = createMutexByKey()
57+
const tenantMutex = createMutexByKey<TenantConfig>()
7458

7559
export const jwksManager = new JWKSManager(new JWKSManagerStoreKnex(multitenantKnex))
60+
export const s3CredentialsManager = new S3CredentialsManager(
61+
new S3CredentialsManagerStoreKnex(multitenantKnex)
62+
)
7663

7764
const singleTenantServiceKey:
7865
| {
@@ -107,15 +94,15 @@ export async function getTenantConfig(tenantId: string): Promise<TenantConfig> {
10794
}
10895

10996
if (tenantConfigCache.has(tenantId)) {
110-
return tenantConfigCache.get(tenantId) as TenantConfig
97+
return tenantConfigCache.get(tenantId)!
11198
}
11299

113100
return tenantMutex(tenantId, async () => {
114101
if (tenantConfigCache.has(tenantId)) {
115-
return tenantConfigCache.get(tenantId) as TenantConfig
102+
return tenantConfigCache.get(tenantId)!
116103
}
117104

118-
const tenant = await multitenantKnex('tenants').first().where('id', tenantId)
105+
const tenant = await multitenantKnex.table('tenants').first().where('id', tenantId)
119106
if (!tenant) {
120107
throw ERRORS.MissingTenantConfig(tenantId)
121108
}
@@ -173,7 +160,7 @@ export async function getTenantConfig(tenantId: string): Promise<TenantConfig> {
173160
}
174161
tenantConfigCache.set(tenantId, config)
175162

176-
return tenantConfigCache.get(tenantId)
163+
return tenantConfigCache.get(tenantId)!
177164
})
178165
}
179166

@@ -249,7 +236,6 @@ export async function getFeatures(tenantId: string): Promise<Features> {
249236
}
250237

251238
const TENANTS_UPDATE_CHANNEL = 'tenants_update'
252-
const TENANTS_S3_CREDENTIALS_UPDATE_CHANNEL = 'tenants_s3_credentials_update'
253239

254240
/**
255241
* Keeps the in memory config cache up to date
@@ -258,130 +244,6 @@ export async function listenForTenantUpdate(pubSub: PubSubAdapter): Promise<void
258244
await pubSub.subscribe(TENANTS_UPDATE_CHANNEL, (cacheKey) => {
259245
tenantConfigCache.delete(cacheKey)
260246
})
261-
await pubSub.subscribe(TENANTS_S3_CREDENTIALS_UPDATE_CHANNEL, (cacheKey) => {
262-
tenantS3CredentialsCache.delete(cacheKey)
263-
})
264-
247+
await s3CredentialsManager.listenForTenantUpdate(pubSub)
265248
await jwksManager.listenForTenantUpdate(pubSub)
266249
}
267-
268-
/**
269-
* Create S3 Credential for a tenant
270-
* @param tenantId
271-
* @param data
272-
*/
273-
export async function createS3Credentials(
274-
tenantId: string,
275-
data: { description: string; claims?: S3Credentials['claims'] }
276-
) {
277-
const existingCount = await countS3Credentials(tenantId)
278-
279-
if (existingCount >= 50) {
280-
throw ERRORS.MaximumCredentialsLimit()
281-
}
282-
283-
const secretAccessKeyId = crypto.randomBytes(32).toString('hex').slice(0, 32)
284-
const secretAccessKey = crypto.randomBytes(64).toString('hex').slice(0, 64)
285-
286-
if (data.claims) {
287-
delete data.claims.iss
288-
delete data.claims.issuer
289-
delete data.claims.exp
290-
delete data.claims.iat
291-
}
292-
293-
data.claims = {
294-
...(data.claims || {}),
295-
role: data.claims?.role ?? dbServiceRole,
296-
issuer: `supabase.storage.${tenantId}`,
297-
sub: data.claims?.sub,
298-
}
299-
300-
const credentials = await multitenantKnex
301-
.table('tenants_s3_credentials')
302-
.insert({
303-
tenant_id: tenantId,
304-
description: data.description,
305-
access_key: secretAccessKeyId,
306-
secret_key: encrypt(secretAccessKey),
307-
claims: JSON.stringify(data.claims),
308-
})
309-
.returning('id')
310-
311-
return {
312-
id: credentials[0].id,
313-
access_key: secretAccessKeyId,
314-
secret_key: secretAccessKey,
315-
}
316-
}
317-
318-
export async function getS3CredentialsByAccessKey(
319-
tenantId: string,
320-
accessKey: string
321-
): Promise<S3Credentials> {
322-
const cacheKey = `${tenantId}:${accessKey}`
323-
const cachedCredentials = tenantS3CredentialsCache.get(cacheKey)
324-
325-
if (cachedCredentials) {
326-
return cachedCredentials
327-
}
328-
329-
return s3CredentialsMutex(cacheKey, async () => {
330-
const cachedCredentials = tenantS3CredentialsCache.get(cacheKey)
331-
332-
if (cachedCredentials) {
333-
return cachedCredentials
334-
}
335-
336-
const data = await multitenantKnex
337-
.table('tenants_s3_credentials')
338-
.select('access_key', 'secret_key', 'claims')
339-
.where('tenant_id', tenantId)
340-
.where('access_key', accessKey)
341-
.first()
342-
343-
if (!data) {
344-
throw ERRORS.MissingS3Credentials()
345-
}
346-
347-
const secretKey = decrypt(data.secret_key)
348-
349-
tenantS3CredentialsCache.set(cacheKey, {
350-
accessKey: data.access_key,
351-
secretKey: secretKey,
352-
claims: data.claims,
353-
})
354-
355-
return {
356-
accessKey: data.access_key,
357-
secretKey: secretKey,
358-
claims: data.claims,
359-
}
360-
})
361-
}
362-
363-
export function deleteS3Credential(tenantId: string, credentialId: string) {
364-
return multitenantKnex
365-
.table('tenants_s3_credentials')
366-
.where('tenant_id', tenantId)
367-
.where('id', credentialId)
368-
.delete()
369-
.returning('id')
370-
}
371-
372-
export function listS3Credentials(tenantId: string) {
373-
return multitenantKnex
374-
.table('tenants_s3_credentials')
375-
.select('id', 'description', 'access_key', 'created_at')
376-
.where('tenant_id', tenantId)
377-
.orderBy('created_at', 'asc')
378-
}
379-
380-
export async function countS3Credentials(tenantId: string) {
381-
const data = await multitenantKnex
382-
.table('tenants_s3_credentials')
383-
.count<{ count: number }>('id')
384-
.where('tenant_id', tenantId)
385-
386-
return Number(data?.count || 0)
387-
}

src/internal/streams/monitor.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export function monitorStream(dataStream: Readable) {
1212
const byteCounter = createByteCounterStream()
1313
const span = trace.getActiveSpan()
1414

15-
let measures: any[] = []
15+
let measures: object[] = []
1616

1717
// Handle the 'speed' event to collect speed measurements
1818
speedMonitor.on('speed', (bps) => {

0 commit comments

Comments
 (0)