unleash-server
Version:
Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.
259 lines • 10.7 kB
JavaScript
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