Skip to content

Commit 2e9fceb

Browse files
authored
feat: add tenant specific feature flags (#231)
1 parent a6333ab commit 2e9fceb

File tree

12 files changed

+147
-16
lines changed

12 files changed

+147
-16
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE tenants ADD COLUMN feature_image_transformation boolean DEFAULT false NOT NULL;

src/database/tenant.ts

+30-1
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@ interface TenantConfig {
99
anonKey: string
1010
databaseUrl: string
1111
fileSizeLimit: number
12+
features: Features
1213
jwtSecret: string
1314
serviceKey: string
1415
}
1516

17+
export interface Features {
18+
imageTransformation: {
19+
enabled: boolean
20+
}
21+
}
22+
1623
const { multitenantDatabaseUrl } = getConfig()
1724

1825
const tenantConfigCache = new Map<string, TenantConfig>()
@@ -66,13 +73,26 @@ export async function getTenantConfig(tenantId: string): Promise<TenantConfig> {
6673
`Tenant config for ${tenantId} not found`
6774
)
6875
}
69-
const { anon_key, database_url, file_size_limit, jwt_secret, service_key } = tenant
76+
const {
77+
anon_key,
78+
database_url,
79+
file_size_limit,
80+
jwt_secret,
81+
service_key,
82+
feature_image_transformation,
83+
} = tenant
84+
7085
const config = {
7186
anonKey: decrypt(anon_key),
7287
databaseUrl: decrypt(database_url),
7388
fileSizeLimit: Number(file_size_limit),
7489
jwtSecret: decrypt(jwt_secret),
7590
serviceKey: decrypt(service_key),
91+
features: {
92+
imageTransformation: {
93+
enabled: feature_image_transformation,
94+
},
95+
},
7696
}
7797
await cacheTenantConfigAndRunMigrations(tenantId, config)
7898
return config
@@ -114,6 +134,15 @@ export async function getFileSizeLimit(tenantId: string): Promise<number> {
114134
return fileSizeLimit
115135
}
116136

137+
/**
138+
* Get features flags config for a specific tenant
139+
* @param tenantId
140+
*/
141+
export async function getFeatures(tenantId: string): Promise<Features> {
142+
const { features } = await getTenantConfig(tenantId)
143+
return features
144+
}
145+
117146
const TENANTS_UPDATE_CHANNEL = 'tenants_update'
118147

119148
/**

src/http/plugins/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export * from './log-request'
55
export * from './postgrest'
66
export * from './storage'
77
export * from './tenant-id'
8+
export * from './tenant-feature'

src/http/plugins/tenant-feature.ts

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import fastifyPlugin from 'fastify-plugin'
2+
import { getConfig } from '../../config'
3+
import { Features, getFeatures } from '../../database/tenant'
4+
5+
/**
6+
* Requires a specific feature to be enabled for a given tenant.
7+
*
8+
* This only applies for multi-tenant applications.
9+
* For single-tenant, use environment variables to toggle features
10+
* @param feature
11+
*/
12+
export const requireTenantFeature = (feature: keyof Features) =>
13+
fastifyPlugin(async (fastify) => {
14+
const { isMultitenant } = getConfig()
15+
fastify.addHook('onRequest', async (request, reply) => {
16+
if (!isMultitenant) return
17+
18+
const features = await getFeatures(request.tenantId)
19+
20+
if (!features[feature].enabled) {
21+
reply.status(403).send({
22+
error: 'FeatureNotEnabled',
23+
statusCode: '403',
24+
message: 'feature not enabled for this tenant',
25+
})
26+
}
27+
})
28+
})

src/http/routes/object/getSignedURL.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { FromSchema } from 'json-schema-to-ts'
33
import { createDefaultSchema } from '../../generic-routes'
44
import { AuthenticatedRequest } from '../../request'
55
import { ImageRenderer } from '../../../storage/renderer'
6-
import { getConfig } from '../../../config'
76
import { transformationOptionsSchema } from '../../schemas/transformations'
7+
import { isImageTransformationEnabled } from '../../../storage/limits'
88

99
const getSignedURLParamsSchema = {
1010
type: 'object',
@@ -43,8 +43,6 @@ interface getSignedURLRequestInterface extends AuthenticatedRequest {
4343
Body: FromSchema<typeof getSignedURLBodySchema>
4444
}
4545

46-
const { enableImageTransformation } = getConfig()
47-
4846
export default async function routes(fastify: FastifyInstance) {
4947
const summary = 'Generate a presigned url to retrieve an object'
5048

@@ -66,11 +64,12 @@ export default async function routes(fastify: FastifyInstance) {
6664
const { expiresIn } = request.body
6765

6866
const urlPath = request.url.split('?').shift()
67+
const imageTransformationEnabled = await isImageTransformationEnabled(request.tenantId)
6968

7069
const signedURL = await request.storage
7170
.from(bucketName)
7271
.signObjectUrl(objectName, urlPath as string, expiresIn, {
73-
transformations: enableImageTransformation
72+
transformations: imageTransformationEnabled
7473
? ImageRenderer.applyTransformation(request.body.transform || {}).join(',')
7574
: '',
7675
})

src/http/routes/render/index.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { FastifyInstance } from 'fastify'
22
import renderPublicImage from './renderPublicImage'
33
import renderAuthenticatedImage from './renderAuthenticatedImage'
44
import renderSignedImage from './renderSignedImage'
5-
import { jwt, postgrest, superUserPostgrest, storage } from '../../plugins'
5+
import { jwt, postgrest, superUserPostgrest, storage, requireTenantFeature } from '../../plugins'
66
import { getConfig } from '../../../config'
77

88
const { enableImageTransformation } = getConfig()
@@ -13,6 +13,7 @@ export default async function routes(fastify: FastifyInstance) {
1313
}
1414

1515
fastify.register(async function authorizationContext(fastify) {
16+
fastify.register(requireTenantFeature('imageTransformation'))
1617
fastify.register(jwt)
1718
fastify.register(postgrest)
1819
fastify.register(superUserPostgrest)
@@ -21,6 +22,7 @@ export default async function routes(fastify: FastifyInstance) {
2122
})
2223

2324
fastify.register(async (fastify) => {
25+
fastify.register(requireTenantFeature('imageTransformation'))
2426
fastify.register(superUserPostgrest)
2527
fastify.register(storage)
2628
fastify.register(renderSignedImage)

src/http/routes/tenant/index.ts

+47-5
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ const patchSchema = {
1414
fileSizeLimit: { type: 'number' },
1515
jwtSecret: { type: 'string' },
1616
serviceKey: { type: 'string' },
17+
features: {
18+
type: 'object',
19+
properties: {
20+
imageTransformation: {
21+
type: 'object',
22+
properties: {
23+
enabled: { type: 'boolean' },
24+
},
25+
},
26+
},
27+
},
1728
},
1829
},
1930
} as const
@@ -46,6 +57,7 @@ interface tenantDBInterface {
4657
jwt_secret: string
4758
service_key: string
4859
file_size_limit?: number
60+
feature_image_transformation: boolean
4961
}
5062

5163
export default async function routes(fastify: FastifyInstance) {
@@ -54,13 +66,26 @@ export default async function routes(fastify: FastifyInstance) {
5466
fastify.get('/', async () => {
5567
const tenants = await knex('tenants').select()
5668
return tenants.map(
57-
({ id, anon_key, database_url, file_size_limit, jwt_secret, service_key }) => ({
69+
({
70+
id,
71+
anon_key,
72+
database_url,
73+
file_size_limit,
74+
jwt_secret,
75+
service_key,
76+
feature_image_transformation,
77+
}) => ({
5878
id,
5979
anonKey: decrypt(anon_key),
6080
databaseUrl: decrypt(database_url),
6181
fileSizeLimit: Number(file_size_limit),
6282
jwtSecret: decrypt(jwt_secret),
6383
serviceKey: decrypt(service_key),
84+
features: {
85+
imageTransformation: {
86+
enabled: feature_image_transformation,
87+
},
88+
},
6489
})
6590
)
6691
})
@@ -70,20 +95,34 @@ export default async function routes(fastify: FastifyInstance) {
7095
if (!tenant) {
7196
reply.code(404).send()
7297
} else {
73-
const { anon_key, database_url, file_size_limit, jwt_secret, service_key } = tenant
98+
const {
99+
anon_key,
100+
database_url,
101+
file_size_limit,
102+
jwt_secret,
103+
service_key,
104+
feature_image_transformation,
105+
} = tenant
106+
74107
return {
75108
anonKey: decrypt(anon_key),
76109
databaseUrl: decrypt(database_url),
77110
fileSizeLimit: Number(file_size_limit),
78111
jwtSecret: decrypt(jwt_secret),
79112
serviceKey: decrypt(service_key),
113+
features: {
114+
imageTransformation: {
115+
enabled: feature_image_transformation,
116+
},
117+
},
80118
}
81119
}
82120
})
83121

84122
fastify.post<tenantRequestInterface>('/:tenantId', { schema }, async (request, reply) => {
85-
const { anonKey, databaseUrl, fileSizeLimit, jwtSecret, serviceKey } = request.body
86123
const { tenantId } = request.params
124+
const { anonKey, databaseUrl, fileSizeLimit, jwtSecret, serviceKey, features } = request.body
125+
87126
await runMigrations(tenantId, databaseUrl)
88127
await knex('tenants').insert({
89128
id: tenantId,
@@ -92,6 +131,7 @@ export default async function routes(fastify: FastifyInstance) {
92131
file_size_limit: fileSizeLimit,
93132
jwt_secret: encrypt(jwtSecret),
94133
service_key: encrypt(serviceKey),
134+
feature_image_transformation: features?.imageTransformation?.enabled ?? false,
95135
})
96136
reply.code(201).send()
97137
})
@@ -100,7 +140,7 @@ export default async function routes(fastify: FastifyInstance) {
100140
'/:tenantId',
101141
{ schema: patchSchema },
102142
async (request, reply) => {
103-
const { anonKey, databaseUrl, fileSizeLimit, jwtSecret, serviceKey } = request.body
143+
const { anonKey, databaseUrl, fileSizeLimit, jwtSecret, serviceKey, features } = request.body
104144
const { tenantId } = request.params
105145
if (databaseUrl) {
106146
await runMigrations(tenantId, databaseUrl)
@@ -112,14 +152,15 @@ export default async function routes(fastify: FastifyInstance) {
112152
file_size_limit: fileSizeLimit,
113153
jwt_secret: jwtSecret !== undefined ? encrypt(jwtSecret) : undefined,
114154
service_key: serviceKey !== undefined ? encrypt(serviceKey) : undefined,
155+
feature_image_transformation: features?.imageTransformation?.enabled,
115156
})
116157
.where('id', tenantId)
117158
reply.code(204).send()
118159
}
119160
)
120161

121162
fastify.put<tenantRequestInterface>('/:tenantId', { schema }, async (request, reply) => {
122-
const { anonKey, databaseUrl, fileSizeLimit, jwtSecret, serviceKey } = request.body
163+
const { anonKey, databaseUrl, fileSizeLimit, jwtSecret, serviceKey, features } = request.body
123164
const { tenantId } = request.params
124165
await runMigrations(tenantId, databaseUrl)
125166

@@ -129,6 +170,7 @@ export default async function routes(fastify: FastifyInstance) {
129170
database_url: encrypt(databaseUrl),
130171
jwt_secret: encrypt(jwtSecret),
131172
service_key: encrypt(serviceKey),
173+
feature_image_transformation: features?.imageTransformation?.enabled ?? false,
132174
}
133175

134176
if (fileSizeLimit) {

src/storage/backend/s3.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { StorageBackendError } from '../errors'
2222

2323
/**
2424
* S3Backend
25-
* Interacts with a s3 system with this S3Backend adapter
25+
* Interacts with an s3-compatible file system with this S3Adapter
2626
*/
2727
export class S3Backend implements StorageBackendAdapter {
2828
client: S3Client

src/storage/limits.ts

+16-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { getConfig } from '../config'
2-
import { getFileSizeLimit as getFileSizeLimitForTenant } from '../database/tenant'
2+
import { getFileSizeLimit as getFileSizeLimitForTenant, getFeatures } from '../database/tenant'
33
import { StorageBackendError } from './errors'
44

5-
const { isMultitenant } = getConfig()
5+
const { isMultitenant, enableImageTransformation } = getConfig()
66

77
/**
88
* Get the maximum file size for a specific project
@@ -16,6 +16,20 @@ export async function getFileSizeLimit(tenantId: string): Promise<number> {
1616
return fileSizeLimit
1717
}
1818

19+
/**
20+
* Determines if the image transformation feature is enabled.
21+
* @param tenantId
22+
*/
23+
export async function isImageTransformationEnabled(tenantId: string) {
24+
if (!isMultitenant) {
25+
return enableImageTransformation
26+
}
27+
28+
const { imageTransformation } = await getFeatures(tenantId)
29+
30+
return imageTransformation.enabled
31+
}
32+
1933
/**
2034
* Validates if a given object key or bucket key is valid
2135
* @param key

src/storage/storage.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { StorageBackendAdapter } from './backend'
22
import { Database, FindBucketFilters } from './database'
33
import { StorageBackendError } from './errors'
44
import { ImageRenderer, AssetRenderer, HeadRenderer } from './renderer'
5-
import { mustBeValidBucketName, mustBeValidKey } from './limits'
5+
import { mustBeValidBucketName } from './limits'
66
import { Uploader } from './uploader'
77
import { getConfig } from '../config'
88
import { ObjectStorage } from './object'
@@ -22,7 +22,7 @@ export class Storage {
2222
* @param bucketId
2323
*/
2424
from(bucketId: string) {
25-
mustBeValidKey(bucketId, 'The bucketId name contains invalid characters')
25+
mustBeValidBucketName(bucketId, 'The bucketId name contains invalid characters')
2626

2727
return new ObjectStorage(this.backend, this.db, bucketId)
2828
}

src/test/tenant.test.ts

+10
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ const payload = {
1212
fileSizeLimit: 1,
1313
jwtSecret: 'c',
1414
serviceKey: 'd',
15+
features: {
16+
imageTransformation: {
17+
enabled: true,
18+
},
19+
},
1520
}
1621

1722
const payload2 = {
@@ -20,6 +25,11 @@ const payload2 = {
2025
fileSizeLimit: 2,
2126
jwtSecret: 'g',
2227
serviceKey: 'h',
28+
features: {
29+
imageTransformation: {
30+
enabled: false,
31+
},
32+
},
2333
}
2434

2535
beforeAll(async () => {

src/test/x-forwarded-host.test.ts

+5
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ beforeAll(async () => {
1919
serviceKey: process.env.SERVICE_KEY || '',
2020
jwtSecret: process.env.PGRST_JWT_SECRET || '',
2121
fileSizeLimit: parseInt(process.env.FILE_SIZE_LIMIT || '1000'),
22+
features: {
23+
imageTransformation: {
24+
enabled: true,
25+
},
26+
},
2227
}))
2328

2429
jest

0 commit comments

Comments
 (0)