2021-03-29 19:58:11 +02:00
|
|
|
import crypto from 'crypto';
|
2021-04-22 23:40:52 +02:00
|
|
|
import { Logger } from '../logger';
|
2022-08-18 10:20:51 +02:00
|
|
|
import { ADMIN, CLIENT, FRONTEND } from '../types/permissions';
|
2023-09-27 15:23:05 +02:00
|
|
|
import { IUnleashStores } from '../types/stores';
|
2021-04-22 10:07:10 +02:00
|
|
|
import { IUnleashConfig } from '../types/option';
|
2023-11-03 12:00:24 +01:00
|
|
|
import ApiUser, { IApiUser } from '../types/api-user';
|
2021-08-12 15:04:37 +02:00
|
|
|
import {
|
|
|
|
ApiTokenType,
|
|
|
|
IApiToken,
|
2022-04-06 08:11:41 +02:00
|
|
|
ILegacyApiTokenCreate,
|
2021-09-15 20:28:10 +02:00
|
|
|
IApiTokenCreate,
|
2022-01-05 10:00:59 +01:00
|
|
|
validateApiToken,
|
2022-03-24 11:26:00 +01:00
|
|
|
validateApiTokenEnvironment,
|
2022-04-06 08:11:41 +02:00
|
|
|
mapLegacyToken,
|
|
|
|
mapLegacyTokenWithSecret,
|
2021-09-15 20:28:10 +02:00
|
|
|
} 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';
|
2022-03-24 11:26:00 +01:00
|
|
|
import { IEnvironmentStore } from 'lib/types/stores/environment-store';
|
2022-08-19 10:48:33 +02:00
|
|
|
import { constantTimeCompare } from '../util/constantTimeCompare';
|
2022-11-28 10:56:34 +01:00
|
|
|
import {
|
|
|
|
ApiTokenCreatedEvent,
|
|
|
|
ApiTokenDeletedEvent,
|
|
|
|
ApiTokenUpdatedEvent,
|
|
|
|
} from '../types';
|
|
|
|
import { omitKeys } from '../util';
|
2023-09-27 15:23:05 +02:00
|
|
|
import EventService from './event-service';
|
2021-03-29 19:58:11 +02:00
|
|
|
|
2022-08-16 15:33:33 +02:00
|
|
|
const resolveTokenPermissions = (tokenType: string) => {
|
|
|
|
if (tokenType === ApiTokenType.ADMIN) {
|
|
|
|
return [ADMIN];
|
|
|
|
}
|
|
|
|
|
|
|
|
if (tokenType === ApiTokenType.CLIENT) {
|
|
|
|
return [CLIENT];
|
|
|
|
}
|
|
|
|
|
2022-08-18 10:20:51 +02:00
|
|
|
if (tokenType === ApiTokenType.FRONTEND) {
|
|
|
|
return [FRONTEND];
|
2022-08-16 15:33:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return [];
|
|
|
|
};
|
|
|
|
|
2021-03-29 19:58:11 +02:00
|
|
|
export class ApiTokenService {
|
2021-08-12 15:04:37 +02:00
|
|
|
private store: IApiTokenStore;
|
2021-03-29 19:58:11 +02:00
|
|
|
|
2022-03-24 11:26:00 +01:00
|
|
|
private environmentStore: IEnvironmentStore;
|
|
|
|
|
2021-03-29 19:58:11 +02:00
|
|
|
private logger: Logger;
|
|
|
|
|
|
|
|
private activeTokens: IApiToken[] = [];
|
|
|
|
|
2023-09-27 15:23:05 +02:00
|
|
|
private eventService: EventService;
|
2022-11-28 10:56:34 +01:00
|
|
|
|
2022-11-29 20:46:40 +01:00
|
|
|
private lastSeenSecrets: Set<string> = new Set<string>();
|
|
|
|
|
2021-04-22 10:07:10 +02:00
|
|
|
constructor(
|
2022-03-24 11:26:00 +01:00
|
|
|
{
|
|
|
|
apiTokenStore,
|
|
|
|
environmentStore,
|
2023-09-27 15:23:05 +02:00
|
|
|
}: Pick<IUnleashStores, 'apiTokenStore' | 'environmentStore'>,
|
2022-12-12 15:32:35 +01:00
|
|
|
config: Pick<IUnleashConfig, 'getLogger' | 'authentication'>,
|
2023-09-27 15:23:05 +02:00
|
|
|
eventService: EventService,
|
2021-04-22 10:07:10 +02:00
|
|
|
) {
|
2021-08-12 15:04:37 +02:00
|
|
|
this.store = apiTokenStore;
|
2023-09-27 15:23:05 +02:00
|
|
|
this.eventService = eventService;
|
2022-03-24 11:26:00 +01:00
|
|
|
this.environmentStore = environmentStore;
|
2021-03-29 19:58:11 +02:00
|
|
|
this.logger = config.getLogger('/services/api-token-service.ts');
|
|
|
|
this.fetchActiveTokens();
|
2022-12-12 15:32:35 +01:00
|
|
|
this.updateLastSeen();
|
2022-01-05 10:00:59 +01:00
|
|
|
if (config.authentication.initApiTokens.length > 0) {
|
|
|
|
process.nextTick(async () =>
|
|
|
|
this.initApiTokens(config.authentication.initApiTokens),
|
|
|
|
);
|
|
|
|
}
|
2021-03-29 19:58:11 +02:00
|
|
|
}
|
|
|
|
|
2022-08-18 10:20:51 +02:00
|
|
|
async fetchActiveTokens(): Promise<void> {
|
2021-03-29 19:58:11 +02:00
|
|
|
try {
|
|
|
|
this.activeTokens = await this.getAllActiveTokens();
|
|
|
|
} finally {
|
2023-09-29 14:18:21 +02:00
|
|
|
// biome-ignore lint/correctness/noUnsafeFinally: We ignored this for eslint. Leaving this here for now, server-impl test fails without it
|
2021-03-29 19:58:11 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
feat: Separate api token roles (#4019)
## What
As part of the move to enable custom-root-roles, our permissions model
was found to not be granular enough to allow service accounts to only be
allowed to create read-only tokens (client, frontend), but not be
allowed to create admin tokens to avoid opening up a path for privilege
escalation.
## How
This PR adds 12 new roles, a CRUD set for each of the three token types
(admin, client, frontend). To access the `/api/admin/api-tokens`
endpoints you will still need the existing permission (CREATE_API_TOKEN,
DELETE_API_TOKEN, READ_API_TOKEN, UPDATE_API_TOKEN). Once this PR has
been merged the token type you're modifying will also be checked, so if
you're trying to create a CLIENT api-token, you will need
`CREATE_API_TOKEN` and `CREATE_CLIENT_API_TOKEN` permissions. If the
user performing the create call does not have these two permissions or
the `ADMIN` permission, the creation will be rejected with a `403 -
FORBIDDEN` status.
### Discussion points
The test suite tests all operations using a token with
operation_CLIENT_API_TOKEN permission and verifies that it fails trying
to do any of the operations against FRONTEND and ADMIN tokens. During
development the operation_FRONTEND_API_TOKEN and
operation_ADMIN_API_TOKEN permission has also been tested in the same
way. I wonder if it's worth it to re-add these tests in order to verify
that the permission checker works for all operations, or if this is
enough. Since we're running them using e2e tests, I've removed them for
now, to avoid hogging too much processing time.
2023-06-20 14:21:14 +02:00
|
|
|
async getToken(secret: string): Promise<IApiToken> {
|
|
|
|
return this.store.get(secret);
|
|
|
|
}
|
|
|
|
|
2022-11-29 20:46:40 +01:00
|
|
|
async updateLastSeen(): Promise<void> {
|
|
|
|
if (this.lastSeenSecrets.size > 0) {
|
|
|
|
const toStore = [...this.lastSeenSecrets];
|
|
|
|
this.lastSeenSecrets = new Set<string>();
|
|
|
|
await this.store.markSeenAt(toStore);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-03-29 19:58:11 +02:00
|
|
|
public async getAllTokens(): Promise<IApiToken[]> {
|
|
|
|
return this.store.getAll();
|
|
|
|
}
|
|
|
|
|
|
|
|
public async getAllActiveTokens(): Promise<IApiToken[]> {
|
|
|
|
return this.store.getAllActive();
|
|
|
|
}
|
|
|
|
|
2022-04-06 08:11:41 +02:00
|
|
|
private async initApiTokens(tokens: ILegacyApiTokenCreate[]) {
|
2022-01-05 10:00:59 +01:00
|
|
|
const tokenCount = await this.store.count();
|
|
|
|
if (tokenCount > 0) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
try {
|
2022-04-06 08:11:41 +02:00
|
|
|
const createAll = tokens
|
|
|
|
.map(mapLegacyTokenWithSecret)
|
2022-11-28 10:56:34 +01:00
|
|
|
.map((t) => this.insertNewApiToken(t, 'init-api-tokens'));
|
2022-01-05 10:00:59 +01:00
|
|
|
await Promise.all(createAll);
|
|
|
|
} catch (e) {
|
|
|
|
this.logger.error('Unable to create initial Admin API tokens');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-11-03 12:00:24 +01:00
|
|
|
public getUserForToken(secret: string): IApiUser | undefined {
|
2022-08-18 10:20:51 +02:00
|
|
|
if (!secret) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
|
2022-08-19 10:48:33 +02:00
|
|
|
let token = this.activeTokens.find(
|
|
|
|
(activeToken) =>
|
|
|
|
Boolean(activeToken.secret) &&
|
|
|
|
constantTimeCompare(activeToken.secret, secret),
|
|
|
|
);
|
2022-08-17 10:55:52 +02:00
|
|
|
|
2022-08-19 10:48:33 +02:00
|
|
|
// 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) {
|
2022-08-18 10:20:51 +02:00
|
|
|
token = this.activeTokens.find(
|
2022-08-19 10:48:33 +02:00
|
|
|
(activeToken) =>
|
|
|
|
Boolean(activeToken.alias) &&
|
2023-11-03 12:00:24 +01:00
|
|
|
constantTimeCompare(activeToken.alias!, secret),
|
2022-08-18 10:20:51 +02:00
|
|
|
);
|
2022-08-17 10:55:52 +02:00
|
|
|
}
|
|
|
|
|
2021-03-29 19:58:11 +02:00
|
|
|
if (token) {
|
2022-12-12 15:32:35 +01:00
|
|
|
this.lastSeenSecrets.add(token.secret);
|
2022-11-29 20:46:40 +01:00
|
|
|
|
2021-04-22 23:40:52 +02:00
|
|
|
return new ApiUser({
|
2023-05-04 09:56:00 +02:00
|
|
|
tokenName: token.tokenName,
|
2022-08-16 15:33:33 +02:00
|
|
|
permissions: resolveTokenPermissions(token.type),
|
2022-04-06 08:11:41 +02:00
|
|
|
projects: token.projects,
|
2021-09-15 20:28:10 +02:00
|
|
|
environment: token.environment,
|
|
|
|
type: token.type,
|
2022-08-16 15:33:33 +02:00
|
|
|
secret: token.secret,
|
2021-03-29 19:58:11 +02:00
|
|
|
});
|
|
|
|
}
|
2022-08-19 10:48:33 +02:00
|
|
|
|
2021-03-29 19:58:11 +02:00
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
public async updateExpiry(
|
|
|
|
secret: string,
|
|
|
|
expiresAt: Date,
|
2022-11-28 10:56:34 +01:00
|
|
|
updatedBy: string,
|
2021-03-29 19:58:11 +02:00
|
|
|
): Promise<IApiToken> {
|
2022-11-28 10:56:34 +01:00
|
|
|
const previous = await this.store.get(secret);
|
|
|
|
const token = await this.store.setExpiry(secret, expiresAt);
|
2023-09-27 15:23:05 +02:00
|
|
|
await this.eventService.storeEvent(
|
2022-11-28 10:56:34 +01:00
|
|
|
new ApiTokenUpdatedEvent({
|
|
|
|
createdBy: updatedBy,
|
|
|
|
previousToken: omitKeys(previous, 'secret'),
|
|
|
|
apiToken: omitKeys(token, 'secret'),
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
return token;
|
2021-03-29 19:58:11 +02:00
|
|
|
}
|
|
|
|
|
2022-11-28 10:56:34 +01:00
|
|
|
public async delete(secret: string, deletedBy: string): Promise<void> {
|
|
|
|
if (await this.store.exists(secret)) {
|
|
|
|
const token = await this.store.get(secret);
|
|
|
|
await this.store.delete(secret);
|
2023-09-27 15:23:05 +02:00
|
|
|
await this.eventService.storeEvent(
|
2022-11-28 10:56:34 +01:00
|
|
|
new ApiTokenDeletedEvent({
|
|
|
|
createdBy: deletedBy,
|
|
|
|
apiToken: omitKeys(token, 'secret'),
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
}
|
2021-03-29 19:58:11 +02:00
|
|
|
}
|
|
|
|
|
2022-04-06 08:11:41 +02:00
|
|
|
/**
|
|
|
|
* @deprecated This may be removed in a future release, prefer createApiTokenWithProjects
|
|
|
|
*/
|
2021-09-15 20:28:10 +02:00
|
|
|
public async createApiToken(
|
2022-04-06 08:11:41 +02:00
|
|
|
newToken: Omit<ILegacyApiTokenCreate, 'secret'>,
|
2022-11-28 10:56:34 +01:00
|
|
|
createdBy: string = 'unleash-system',
|
2022-04-06 08:11:41 +02:00
|
|
|
): Promise<IApiToken> {
|
|
|
|
const token = mapLegacyToken(newToken);
|
2022-11-28 10:56:34 +01:00
|
|
|
return this.createApiTokenWithProjects(token, createdBy);
|
2022-04-06 08:11:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
public async createApiTokenWithProjects(
|
2021-09-15 20:28:10 +02:00
|
|
|
newToken: Omit<IApiTokenCreate, 'secret'>,
|
2022-11-28 10:56:34 +01:00
|
|
|
createdBy: string = 'unleash-system',
|
2021-03-29 19:58:11 +02:00
|
|
|
): Promise<IApiToken> {
|
2022-01-05 10:00:59 +01:00
|
|
|
validateApiToken(newToken);
|
2022-03-24 11:26:00 +01:00
|
|
|
const environments = await this.environmentStore.getAll();
|
|
|
|
validateApiTokenEnvironment(newToken, environments);
|
|
|
|
|
2021-09-15 20:28:10 +02:00
|
|
|
const secret = this.generateSecretKey(newToken);
|
|
|
|
const createNewToken = { ...newToken, secret };
|
2022-11-28 10:56:34 +01:00
|
|
|
return this.insertNewApiToken(createNewToken, createdBy);
|
2022-01-05 10:00:59 +01:00
|
|
|
}
|
2021-09-15 20:28:10 +02:00
|
|
|
|
2022-09-12 15:22:23 +02:00
|
|
|
// TODO: Remove this service method after embedded proxy has been released in
|
|
|
|
// 4.16.0
|
|
|
|
public async createMigratedProxyApiToken(
|
|
|
|
newToken: Omit<IApiTokenCreate, 'secret'>,
|
|
|
|
): Promise<IApiToken> {
|
|
|
|
validateApiToken(newToken);
|
|
|
|
|
|
|
|
const secret = this.generateSecretKey(newToken);
|
|
|
|
const createNewToken = { ...newToken, secret };
|
2022-11-28 10:56:34 +01:00
|
|
|
return this.insertNewApiToken(createNewToken, 'system-migration');
|
2022-09-12 15:22:23 +02:00
|
|
|
}
|
|
|
|
|
2022-01-05 10:00:59 +01:00
|
|
|
private async insertNewApiToken(
|
|
|
|
newApiToken: IApiTokenCreate,
|
2022-11-28 10:56:34 +01:00
|
|
|
createdBy: string,
|
2022-01-05 10:00:59 +01:00
|
|
|
): Promise<IApiToken> {
|
2021-09-15 20:28:10 +02:00
|
|
|
try {
|
2022-01-05 10:00:59 +01:00
|
|
|
const token = await this.store.insert(newApiToken);
|
2021-09-15 20:28:10 +02:00
|
|
|
this.activeTokens.push(token);
|
2023-09-27 15:23:05 +02:00
|
|
|
await this.eventService.storeEvent(
|
2022-11-28 10:56:34 +01:00
|
|
|
new ApiTokenCreatedEvent({
|
|
|
|
createdBy,
|
|
|
|
apiToken: omitKeys(token, 'secret'),
|
|
|
|
}),
|
|
|
|
);
|
2021-09-15 20:28:10 +02:00
|
|
|
return token;
|
|
|
|
} catch (error) {
|
|
|
|
if (error.code === FOREIGN_KEY_VIOLATION) {
|
|
|
|
let { message } = error;
|
2022-04-06 08:11:41 +02:00
|
|
|
if (error.constraint === 'api_token_project_project_fkey') {
|
|
|
|
message = `Project=${this.findInvalidProject(
|
|
|
|
error.detail,
|
|
|
|
newApiToken.projects,
|
|
|
|
)} does not exist`;
|
2021-09-15 20:28:10 +02:00
|
|
|
} else if (error.constraint === 'api_tokens_environment_fkey') {
|
2022-01-05 10:00:59 +01:00
|
|
|
message = `Environment=${newApiToken.environment} does not exist`;
|
2021-09-15 20:28:10 +02:00
|
|
|
}
|
|
|
|
throw new BadDataError(message);
|
|
|
|
}
|
|
|
|
throw error;
|
|
|
|
}
|
2021-03-29 19:58:11 +02:00
|
|
|
}
|
|
|
|
|
2022-04-06 08:11:41 +02:00
|
|
|
private findInvalidProject(errorDetails, projects) {
|
|
|
|
if (!errorDetails) {
|
|
|
|
return 'invalid';
|
|
|
|
}
|
2023-09-29 14:18:21 +02:00
|
|
|
const invalidProject = projects.find((project) => {
|
2022-04-06 08:11:41 +02:00
|
|
|
return errorDetails.includes(`=(${project})`);
|
|
|
|
});
|
|
|
|
return invalidProject || 'invalid';
|
|
|
|
}
|
|
|
|
|
|
|
|
private generateSecretKey({ projects, environment }) {
|
2021-09-15 20:28:10 +02:00
|
|
|
const randomStr = crypto.randomBytes(28).toString('hex');
|
2022-04-06 08:11:41 +02:00
|
|
|
if (projects.length > 1) {
|
|
|
|
return `[]:${environment}.${randomStr}`;
|
|
|
|
} else {
|
|
|
|
return `${projects[0]}:${environment}.${randomStr}`;
|
|
|
|
}
|
2021-03-29 19:58:11 +02:00
|
|
|
}
|
|
|
|
}
|