mirror of
https://github.com/Unleash/unleash.git
synced 2024-10-18 20:09:08 +02:00
e889d8e29c
This adds support for multi project tokens to be created. Backward compatibility is handled at 3 different layers here: - The API is made backwards compatible though a permissive data type that accepts either a project?: string or projects?: string[] property, validation is done through JOI here, which ensures that projects and project are not set together. In the case of neither, this defaults to the previous default of ALL_PROJECTS - The service layer method to handle adding tokens has been made tolerant to either of the above case and has been deprecated, a new method supporting only the new structure of using projects has been added - Existing compatibility for consumers of Unleash as a library should not be affected either, the ApiUser constructor is now tolerant to the the first input and will internally map to the new cleaned structure
187 lines
5.9 KiB
TypeScript
187 lines
5.9 KiB
TypeScript
import crypto from 'crypto';
|
|
import { Logger } from '../logger';
|
|
import { ADMIN, CLIENT } from '../types/permissions';
|
|
import { IUnleashStores } from '../types/stores';
|
|
import { IUnleashConfig } from '../types/option';
|
|
import ApiUser from '../types/api-user';
|
|
import {
|
|
ApiTokenType,
|
|
IApiToken,
|
|
ILegacyApiTokenCreate,
|
|
IApiTokenCreate,
|
|
validateApiToken,
|
|
validateApiTokenEnvironment,
|
|
mapLegacyToken,
|
|
mapLegacyTokenWithSecret,
|
|
} from '../types/models/api-token';
|
|
import { IApiTokenStore } from '../types/stores/api-token-store';
|
|
import { FOREIGN_KEY_VIOLATION } from '../error/db-error';
|
|
import BadDataError from '../error/bad-data-error';
|
|
import { minutesToMilliseconds } from 'date-fns';
|
|
import { IEnvironmentStore } from 'lib/types/stores/environment-store';
|
|
|
|
export class ApiTokenService {
|
|
private store: IApiTokenStore;
|
|
|
|
private environmentStore: IEnvironmentStore;
|
|
|
|
private logger: Logger;
|
|
|
|
private timer: NodeJS.Timeout;
|
|
|
|
private activeTokens: IApiToken[] = [];
|
|
|
|
constructor(
|
|
{
|
|
apiTokenStore,
|
|
environmentStore,
|
|
}: Pick<IUnleashStores, 'apiTokenStore' | 'environmentStore'>,
|
|
config: Pick<IUnleashConfig, 'getLogger' | 'authentication'>,
|
|
) {
|
|
this.store = apiTokenStore;
|
|
this.environmentStore = environmentStore;
|
|
this.logger = config.getLogger('/services/api-token-service.ts');
|
|
this.fetchActiveTokens();
|
|
this.timer = setInterval(
|
|
() => this.fetchActiveTokens(),
|
|
minutesToMilliseconds(1),
|
|
).unref();
|
|
if (config.authentication.initApiTokens.length > 0) {
|
|
process.nextTick(async () =>
|
|
this.initApiTokens(config.authentication.initApiTokens),
|
|
);
|
|
}
|
|
}
|
|
|
|
private async fetchActiveTokens(): Promise<void> {
|
|
try {
|
|
this.activeTokens = await this.getAllActiveTokens();
|
|
} finally {
|
|
// eslint-disable-next-line no-unsafe-finally
|
|
return;
|
|
}
|
|
}
|
|
|
|
public async getAllTokens(): Promise<IApiToken[]> {
|
|
return this.store.getAll();
|
|
}
|
|
|
|
public async getAllActiveTokens(): Promise<IApiToken[]> {
|
|
return this.store.getAllActive();
|
|
}
|
|
|
|
private async initApiTokens(tokens: ILegacyApiTokenCreate[]) {
|
|
const tokenCount = await this.store.count();
|
|
if (tokenCount > 0) {
|
|
return;
|
|
}
|
|
try {
|
|
const createAll = tokens
|
|
.map(mapLegacyTokenWithSecret)
|
|
.map((t) => this.insertNewApiToken(t));
|
|
await Promise.all(createAll);
|
|
} catch (e) {
|
|
this.logger.error('Unable to create initial Admin API tokens');
|
|
}
|
|
}
|
|
|
|
public getUserForToken(secret: string): ApiUser | undefined {
|
|
const token = this.activeTokens.find((t) => t.secret === secret);
|
|
if (token) {
|
|
const permissions =
|
|
token.type === ApiTokenType.ADMIN ? [ADMIN] : [CLIENT];
|
|
|
|
return new ApiUser({
|
|
username: token.username,
|
|
permissions,
|
|
projects: token.projects,
|
|
environment: token.environment,
|
|
type: token.type,
|
|
});
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
public async updateExpiry(
|
|
secret: string,
|
|
expiresAt: Date,
|
|
): Promise<IApiToken> {
|
|
return this.store.setExpiry(secret, expiresAt);
|
|
}
|
|
|
|
public async delete(secret: string): Promise<void> {
|
|
return this.store.delete(secret);
|
|
}
|
|
|
|
/**
|
|
* @deprecated This may be removed in a future release, prefer createApiTokenWithProjects
|
|
*/
|
|
public async createApiToken(
|
|
newToken: Omit<ILegacyApiTokenCreate, 'secret'>,
|
|
): Promise<IApiToken> {
|
|
const token = mapLegacyToken(newToken);
|
|
return this.createApiTokenWithProjects(token);
|
|
}
|
|
|
|
public async createApiTokenWithProjects(
|
|
newToken: Omit<IApiTokenCreate, 'secret'>,
|
|
): Promise<IApiToken> {
|
|
validateApiToken(newToken);
|
|
|
|
const environments = await this.environmentStore.getAll();
|
|
validateApiTokenEnvironment(newToken, environments);
|
|
|
|
const secret = this.generateSecretKey(newToken);
|
|
const createNewToken = { ...newToken, secret };
|
|
return this.insertNewApiToken(createNewToken);
|
|
}
|
|
|
|
private async insertNewApiToken(
|
|
newApiToken: IApiTokenCreate,
|
|
): Promise<IApiToken> {
|
|
try {
|
|
const token = await this.store.insert(newApiToken);
|
|
this.activeTokens.push(token);
|
|
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;
|
|
}
|
|
}
|
|
|
|
private findInvalidProject(errorDetails, projects) {
|
|
if (!errorDetails) {
|
|
return 'invalid';
|
|
}
|
|
let invalidProject = projects.find((project) => {
|
|
return errorDetails.includes(`=(${project})`);
|
|
});
|
|
return invalidProject || 'invalid';
|
|
}
|
|
|
|
private generateSecretKey({ projects, environment }) {
|
|
const randomStr = crypto.randomBytes(28).toString('hex');
|
|
if (projects.length > 1) {
|
|
return `[]:${environment}.${randomStr}`;
|
|
} else {
|
|
return `${projects[0]}:${environment}.${randomStr}`;
|
|
}
|
|
}
|
|
|
|
destroy(): void {
|
|
clearInterval(this.timer);
|
|
this.timer = null;
|
|
}
|
|
}
|