UNPKG

unleash-server

Version:

Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.

259 lines • 10.7 kB
import crypto from 'crypto'; import { ADMIN, CLIENT, FRONTEND } from '../types/permissions.js'; import ApiUser from '../types/api-user.js'; import { resolveValidProjects, validateApiToken, validateApiTokenEnvironment, } from '../types/models/api-token.js'; import { FOREIGN_KEY_VIOLATION } from '../error/db-error.js'; import BadDataError from '../error/bad-data-error.js'; import { constantTimeCompare } from '../util/constantTimeCompare.js'; import { ADMIN_TOKEN_USER, ApiTokenCreatedEvent, ApiTokenDeletedEvent, ApiTokenType, ApiTokenUpdatedEvent, SYSTEM_USER_AUDIT, } from '../types/index.js'; import { omitKeys } from '../util/index.js'; import { addMinutes, isPast } from 'date-fns'; import metricsHelper from '../util/metrics-helper.js'; import { FUNCTION_TIME } from '../metric-events.js'; import { throwExceedsLimitError } from '../error/exceeds-limit-error.js'; const resolveTokenPermissions = (tokenType) => { if (tokenType === ApiTokenType.ADMIN) { return [ADMIN]; } if (tokenType === ApiTokenType.CLIENT) { return [CLIENT]; } if (tokenType === ApiTokenType.FRONTEND) { return [FRONTEND]; } return []; }; export class ApiTokenService { constructor({ apiTokenStore, environmentStore, }, config, eventService) { this.activeTokens = []; this.queryAfter = new Map(); this.lastSeenSecrets = new Set(); this.store = apiTokenStore; this.eventService = eventService; this.environmentStore = environmentStore; this.flagResolver = config.flagResolver; this.logger = config.getLogger('/services/api-token-service.ts'); this.resourceLimits = config.resourceLimits; if (!this.flagResolver.isEnabled('useMemoizedActiveTokens')) { // This is probably not needed because the scheduler will run it this.fetchActiveTokens(); } this.updateLastSeen(); this.timer = (functionName) => metricsHelper.wrapTimer(config.eventBus, FUNCTION_TIME, { className: 'ApiTokenService', functionName, }); this.eventBus = config.eventBus; } /** * Called by a scheduler without jitter to refresh all active tokens */ async fetchActiveTokens() { try { this.activeTokens = await this.store.getAllActive(); } catch (e) { this.logger.warn('Failed to fetch active tokens', e); } } async getToken(secret) { return this.store.get(secret); } async getTokenWithCache(secret) { if (!secret) { return undefined; } let token = this.activeTokens.find((activeToken) => Boolean(activeToken.secret) && constantTimeCompare(activeToken.secret, secret)); // If the token is not found, try to find it in the legacy format with alias. // This allows us to support the old format of tokens migrating to the embedded proxy. if (!token) { token = this.activeTokens.find((activeToken) => Boolean(activeToken.alias) && constantTimeCompare(activeToken.alias, secret)); } const nextAllowedQuery = this.queryAfter.get(secret) ?? 0; if (!token) { if (isPast(nextAllowedQuery)) { if (this.queryAfter.size > 1000) { // establish a max limit for queryAfter size to prevent memory leak this.queryAfter.clear(); } const stopCacheTimer = this.timer('getTokenWithCache.query'); token = await this.store.get(secret); if (token) { if (token?.expiresAt && isPast(token.expiresAt)) { this.logger.info('Token has expired'); // prevent querying the same invalid secret multiple times. Expire after 5 minutes this.queryAfter.set(secret, addMinutes(new Date(), 5)); token = undefined; } else { this.activeTokens.push(token); } } else { // prevent querying the same invalid secret multiple times. Expire after 5 minutes this.queryAfter.set(secret, addMinutes(new Date(), 5)); } stopCacheTimer(); } else { this.logger.info(`Not allowed to query this token until: ${this.queryAfter.get(secret)}`); } } return token; } async updateLastSeen() { if (this.lastSeenSecrets.size > 0) { const toStore = [...this.lastSeenSecrets]; this.lastSeenSecrets = new Set(); await this.store.markSeenAt(toStore); } } async getAllTokens() { return this.store.getAll(); } async initApiTokens(tokens) { const tokenCount = await this.store.count(); if (tokenCount > 0) { this.logger.debug('Not creating initial API tokens because tokens exist in the database'); return; } try { const createAll = tokens.map((t) => this.insertNewApiToken(t, SYSTEM_USER_AUDIT)); await Promise.all(createAll); this.logger.info(`Created initial API tokens: ${tokens.map((t) => `(name: ${t.tokenName}, type: ${t.type})`).join(', ')}`); } catch (e) { this.logger.warn(`Unable to create initial API tokens from: ${tokens.map((t) => `(name: ${t.tokenName}, type: ${t.type})`).join(', ')}`, e); } } async getUserForToken(secret) { const token = await this.getTokenWithCache(secret); if (token) { this.lastSeenSecrets.add(token.secret); const apiUser = new ApiUser({ tokenName: token.tokenName, permissions: resolveTokenPermissions(token.type), projects: token.projects, environment: token.environment, type: token.type, secret: token.secret, }); apiUser.internalAdminTokenUserId = token.type === ApiTokenType.ADMIN ? ADMIN_TOKEN_USER.id : undefined; return apiUser; } return undefined; } async updateExpiry(secret, expiresAt, auditUser) { const previous = (await this.store.get(secret)); const token = (await this.store.setExpiry(secret, expiresAt)); await this.eventService.storeEvent(new ApiTokenUpdatedEvent({ auditUser, previousToken: omitKeys(previous, 'secret'), apiToken: omitKeys(token, 'secret'), })); return token; } async delete(secret, auditUser) { if (await this.store.exists(secret)) { const token = (await this.store.get(secret)); await this.store.delete(secret); await this.eventService.storeEvent(new ApiTokenDeletedEvent({ auditUser, apiToken: omitKeys(token, 'secret'), })); } } /** * @param newToken * @param createdBy should be IApiUser or IUser. Still supports optional or string for backward compatibility * @param createdByUserId still supported for backward compatibility */ async createApiTokenWithProjects(newToken, auditUser = SYSTEM_USER_AUDIT) { return this.internalCreateApiTokenWithProjects({ ...newToken, projects: resolveValidProjects(newToken.projects), }, auditUser); } async internalCreateApiTokenWithProjects(newToken, auditUser) { validateApiToken(newToken); const environments = await this.environmentStore.getAll(); validateApiTokenEnvironment(newToken, environments); await this.validateApiTokenLimit(); const secret = this.generateSecretKey(newToken); const createNewToken = { ...newToken, secret }; return this.insertNewApiToken(createNewToken, auditUser); } async validateApiTokenLimit() { const currentTokenCount = await this.store.count(); const limit = this.resourceLimits.apiTokens; if (currentTokenCount >= limit) { throwExceedsLimitError(this.eventBus, { resource: 'api token', limit, }); } } // TODO: Remove this service method after embedded proxy has been released in // 4.16.0 async createMigratedProxyApiToken(newToken) { validateApiToken(newToken); const secret = this.generateSecretKey(newToken); const createNewToken = { ...newToken, secret }; return this.insertNewApiToken(createNewToken, SYSTEM_USER_AUDIT); } normalizeTokenType(token) { const { type, ...rest } = token; return { ...rest, type: type.toLowerCase(), }; } async insertNewApiToken(newApiToken, auditUser) { try { const token = await this.store.insert(this.normalizeTokenType(newApiToken)); this.activeTokens.push(token); await this.eventService.storeEvent(new ApiTokenCreatedEvent({ auditUser, apiToken: omitKeys(token, 'secret'), })); return token; } catch (error) { if (error.code === FOREIGN_KEY_VIOLATION) { let { message } = error; if (error.constraint === 'api_token_project_project_fkey') { message = `Project=${this.findInvalidProject(error.detail, newApiToken.projects)} does not exist`; } else if (error.constraint === 'api_tokens_environment_fkey') { message = `Environment=${newApiToken.environment} does not exist`; } throw new BadDataError(message); } throw error; } } findInvalidProject(errorDetails, projects) { if (!errorDetails) { return 'invalid'; } const invalidProject = projects.find((project) => { return errorDetails.includes(`=(${project})`); }); return invalidProject || 'invalid'; } generateSecretKey({ projects, environment }) { const randomStr = crypto.randomBytes(28).toString('hex'); if (projects.length > 1) { return `[]:${environment}.${randomStr}`; } else { return `${projects[0]}:${environment}.${randomStr}`; } } } //# sourceMappingURL=api-token-service.js.map