diff --git a/src/lib/db/api-token-store.ts b/src/lib/db/api-token-store.ts index 47f35f145d..59a38f78a6 100644 --- a/src/lib/db/api-token-store.ts +++ b/src/lib/db/api-token-store.ts @@ -9,12 +9,13 @@ import { ApiTokenType, IApiToken, IApiTokenCreate, - isAllProjects, + isAll, } from '../types/models/api-token'; import { ALL_PROJECTS } from '../util/constants'; const TABLE = 'api_tokens'; -const API_LINK_TABLE = 'api_token_project'; +const API_LINK_TABLE_PROJECT = 'api_token_project'; +const API_LINK_TABLE_ENVIRONMENT = 'api_token_environment'; const ALL = '*'; @@ -26,15 +27,15 @@ interface ITokenInsert { expires_at?: Date; created_at: Date; seen_at?: Date; - environment: string; } interface ITokenRow extends ITokenInsert { project: string; + environment: string; } const tokenRowReducer = (acc, tokenRow) => { - const { project, ...token } = tokenRow; + const { project, environment, ...token } = tokenRow; if (!acc[tokenRow.secret]) { acc[tokenRow.secret] = { secret: token.secret, @@ -42,19 +43,27 @@ const tokenRowReducer = (acc, tokenRow) => { type: token.type, project: ALL, projects: [ALL], - environment: token.environment ? token.environment : ALL, + environment: ALL, + environments: [ALL], expiresAt: token.expires_at, createdAt: token.created_at, }; } const currentToken = acc[tokenRow.secret]; if (tokenRow.project) { - if (isAllProjects(currentToken.projects)) { + if (isAll(currentToken.projects)) { currentToken.projects = []; } currentToken.projects.push(tokenRow.project); currentToken.project = currentToken.projects.join(','); } + if (tokenRow.environment) { + if (isAll(currentToken.environments)) { + currentToken.environments = []; + } + currentToken.environments.push(tokenRow.environment); + currentToken.environment = currentToken.environments.join(','); + } return acc; }; @@ -62,8 +71,9 @@ const toRow = (newToken: IApiTokenCreate) => ({ username: newToken.username, secret: newToken.secret, type: newToken.type, - environment: - newToken.environment === ALL ? undefined : newToken.environment, + environment: isAll(newToken.environments) + ? undefined + : newToken.environments, expires_at: newToken.expiresAt, }); @@ -114,10 +124,15 @@ export class ApiTokenStore implements IApiTokenStore { private makeTokenProjectQuery() { return this.db(`${TABLE} as tokens`) .leftJoin( - `${API_LINK_TABLE} as token_project_link`, + `${API_LINK_TABLE_PROJECT} as token_project_link`, 'tokens.secret', 'token_project_link.secret', ) + .leftJoin( + `${API_LINK_TABLE_ENVIRONMENT} as token_environment_link`, + 'tokens.secret', + 'token_environment_link.secret', + ) .select( 'tokens.secret', 'username', @@ -125,8 +140,8 @@ export class ApiTokenStore implements IApiTokenStore { 'expires_at', 'created_at', 'seen_at', - 'environment', 'token_project_link.project', + 'token_environment_link.environment', ); } @@ -143,14 +158,26 @@ export class ApiTokenStore implements IApiTokenStore { }) .map((project) => { return tx.raw( - `INSERT INTO ${API_LINK_TABLE} VALUES (?, ?)`, + `INSERT INTO ${API_LINK_TABLE_PROJECT} VALUES (?, ?)`, [newToken.secret, project], ); }); await Promise.all(updateProjectTasks); + const updateEnvironmentTasks = (newToken.environments || []) + .filter((environment) => { + return environment !== ALL; + }) + .map((environment) => { + return tx.raw( + `INSERT INTO ${API_LINK_TABLE_ENVIRONMENT} VALUES (?, ?)`, + [newToken.secret, environment], + ); + }); + await Promise.all(updateEnvironmentTasks); return { ...newToken, project: newToken.projects?.join(',') || '*', + environment: newToken.environments?.join(',') || '*', createdAt: row.created_at, }; }); diff --git a/src/lib/routes/client-api/feature.ts b/src/lib/routes/client-api/feature.ts index 04594e682c..24c86d9d6a 100644 --- a/src/lib/routes/client-api/feature.ts +++ b/src/lib/routes/client-api/feature.ts @@ -9,7 +9,7 @@ import { IFeatureToggleQuery, ISegment } from '../../types/model'; import NotFoundError from '../../error/notfound-error'; import { IAuthRequest } from '../unleash-types'; import ApiUser from '../../types/api-user'; -import { ALL, isAllProjects } from '../../types/models/api-token'; +import { isAll } from '../../types/models/api-token'; import { SegmentService } from '../../services/segment-service'; import { FeatureConfigurationClient } from '../../types/stores/feature-strategies-store'; import { ClientSpecService } from '../../services/client-spec-service'; @@ -30,7 +30,7 @@ const version = 2; interface QueryOverride { project?: string[]; - environment?: string; + environment?: string[]; } export default class FeatureController extends Controller { @@ -135,11 +135,11 @@ export default class FeatureController extends Controller { const override: QueryOverride = {}; if (user instanceof ApiUser) { - if (!isAllProjects(user.projects)) { + if (!isAll(user.projects)) { override.project = user.projects; } - if (user.environment !== ALL) { - override.environment = user.environment; + if (!isAll(user.environments)) { + override.environment = user.environments; } } diff --git a/src/lib/routes/client-api/register.ts b/src/lib/routes/client-api/register.ts index 747cd27be5..f3fadc062d 100644 --- a/src/lib/routes/client-api/register.ts +++ b/src/lib/routes/client-api/register.ts @@ -7,7 +7,7 @@ import ClientInstanceService from '../../services/client-metrics/instance-servic import { IAuthRequest, User } from '../../server-impl'; import { IClientApp } from '../../types/model'; import ApiUser from '../../types/api-user'; -import { ALL } from '../../types/models/api-token'; +import { isAll } from '../../types/models/api-token'; import { NONE } from '../../types/permissions'; import { OpenApiService } from '../../services/openapi-service'; import { emptyResponse } from '../../openapi/util/standard-responses'; @@ -51,9 +51,9 @@ export default class RegisterController extends Controller { private static resolveEnvironment(user: User, data: Partial) { if (user instanceof ApiUser) { - if (user.environment !== ALL) { - return user.environment; - } else if (user.environment === ALL && data.environment) { + if (!isAll(user.environments)) { + return user.environments; + } else if (isAll(user.environments) && data.environment) { return data.environment; } } @@ -65,7 +65,7 @@ export default class RegisterController extends Controller { res: Response, ): Promise { const { body: data, ip: clientIp, user } = req; - data.environment = RegisterController.resolveEnvironment(user, data); + data.environments = RegisterController.resolveEnvironment(user, data); await this.clientInstanceService.registerClient(data, clientIp); return res.status(202).end(); } diff --git a/src/lib/schema/api-token-schema.ts b/src/lib/schema/api-token-schema.ts index c6725f67a2..50d38b075c 100644 --- a/src/lib/schema/api-token-schema.ts +++ b/src/lib/schema/api-token-schema.ts @@ -10,7 +10,7 @@ export const createApiToken = joi .string() .lowercase() .required() - .valid(ApiTokenType.ADMIN, ApiTokenType.CLIENT), + .valid(ApiTokenType.ADMIN, ApiTokenType.CLIENT, ApiTokenType.PROXY), expiresAt: joi.date().optional(), project: joi.when('projects', { not: joi.required(), diff --git a/src/lib/services/api-token-service.ts b/src/lib/services/api-token-service.ts index 42680b2806..f28eb013b1 100644 --- a/src/lib/services/api-token-service.ts +++ b/src/lib/services/api-token-service.ts @@ -1,6 +1,6 @@ import crypto from 'crypto'; import { Logger } from '../logger'; -import { ADMIN, CLIENT, PROXY } from '../types/permissions'; +import { ADMIN, PROXY, CLIENT } from '../types/permissions'; import { IUnleashStores } from '../types/stores'; import { IUnleashConfig } from '../types/option'; import ApiUser from '../types/api-user'; @@ -108,7 +108,7 @@ export class ApiTokenService { username: token.username, permissions: resolveTokenPermissions(token.type), projects: token.projects, - environment: token.environment, + environments: token.environments, type: token.type, secret: token.secret, }); @@ -161,12 +161,15 @@ export class ApiTokenService { if (error.code === FOREIGN_KEY_VIOLATION) { let { message } = error; if (error.constraint === 'api_token_project_project_fkey') { - message = `Project=${this.findInvalidProject( + message = `Project=${this.findInvalid( error.detail, newApiToken.projects, )} does not exist`; } else if (error.constraint === 'api_tokens_environment_fkey') { - message = `Environment=${newApiToken.environment} does not exist`; + message = `Environment=${this.findInvalid( + error.detail, + newApiToken.environments, + )} does not exist`; } throw new BadDataError(message); } @@ -174,23 +177,21 @@ export class ApiTokenService { } } - private findInvalidProject(errorDetails, projects) { + private findInvalid(errorDetails, values) { if (!errorDetails) { return 'invalid'; } - let invalidProject = projects.find((project) => { - return errorDetails.includes(`=(${project})`); + let invalid = values.find((value) => { + return errorDetails.includes(`=(${value})`); }); - return invalidProject || 'invalid'; + return invalid || 'invalid'; } - private generateSecretKey({ projects, environment }) { + private generateSecretKey({ projects, environments }) { const randomStr = crypto.randomBytes(28).toString('hex'); - if (projects.length > 1) { - return `[]:${environment}.${randomStr}`; - } else { - return `${projects[0]}:${environment}.${randomStr}`; - } + return `${projects.length > 1 ? '[]' : projects[0]}:${ + environments.length > 1 ? '[]' : environments[0] + }.${randomStr}`; } destroy(): void { diff --git a/src/lib/types/api-user.ts b/src/lib/types/api-user.ts index dfd325feb7..f0c7d63b61 100644 --- a/src/lib/types/api-user.ts +++ b/src/lib/types/api-user.ts @@ -6,7 +6,8 @@ interface IApiUserData { permissions?: string[]; projects?: string[]; project?: string; - environment: string; + environments?: string[]; + environment?: string; type: ApiTokenType; secret: string; } @@ -20,7 +21,7 @@ export default class ApiUser { readonly projects: string[]; - readonly environment: string; + readonly environments: string[]; readonly type: ApiTokenType; @@ -31,6 +32,7 @@ export default class ApiUser { permissions = [CLIENT], projects, project, + environments, environment, type, secret, @@ -40,7 +42,6 @@ export default class ApiUser { } this.username = username; this.permissions = permissions; - this.environment = environment; this.type = type; this.secret = secret; if (projects && projects.length > 0) { @@ -48,5 +49,10 @@ export default class ApiUser { } else { this.projects = [project]; } + if (environments && environments.length > 0) { + this.environments = environments; + } else { + this.environments = [environment]; + } } } diff --git a/src/lib/types/models/api-token.ts b/src/lib/types/models/api-token.ts index 88132e3331..351d399cad 100644 --- a/src/lib/types/models/api-token.ts +++ b/src/lib/types/models/api-token.ts @@ -13,7 +13,8 @@ export interface ILegacyApiTokenCreate { secret: string; username: string; type: ApiTokenType; - environment: string; + environment?: string; + environments?: string[]; project?: string; projects?: string[]; expiresAt?: Date; @@ -23,7 +24,7 @@ export interface IApiTokenCreate { secret: string; username: string; type: ApiTokenType; - environment: string; + environments: string[]; projects: string[]; expiresAt?: Date; } @@ -35,38 +36,48 @@ export interface IApiToken extends IApiTokenCreate { project: string; } -export const isAllProjects = (projects: string[]): boolean => { - return projects && projects.length === 1 && projects[0] === ALL; +export const isAll = (values: string[]): boolean => { + return values && values.length === 1 && values[0] === ALL; }; -export const mapLegacyProjects = ( - project?: string, - projects?: string[], +export const mapLegacy = ( + error: string, + value?: string, + values?: string[], ): string[] => { - let cleanedProjects; - if (project) { - cleanedProjects = [project]; - } else if (projects) { - cleanedProjects = projects; - if (cleanedProjects.includes('*')) { - cleanedProjects = ['*']; + let cleanedValues; + if (value) { + cleanedValues = [value]; + } else if (values) { + cleanedValues = values; + if (cleanedValues.includes('*')) { + cleanedValues = ['*']; } } else { throw new BadDataError( - 'API tokens must either contain a project or projects field', + 'API tokens must either contain an environment or environments field', ); } - return cleanedProjects; + return cleanedValues; }; export const mapLegacyToken = ( token: Omit, ): Omit => { - const cleanedProjects = mapLegacyProjects(token.project, token.projects); + const cleanedProjects = mapLegacy( + 'API tokens must either contain a project or projects field', + token.project, + token.projects, + ); + const cleanedEnvironments = mapLegacy( + 'API tokens must either contain an environment or environments field', + token.environment, + token.environments, + ); return { username: token.username, type: token.type, - environment: token.environment, + environments: cleanedEnvironments, projects: cleanedProjects, expiresAt: token.expiresAt, }; @@ -84,27 +95,27 @@ export const mapLegacyTokenWithSecret = ( export const validateApiToken = ({ type, projects, - environment, + environments, }: Omit): void => { - if (type === ApiTokenType.ADMIN && !isAllProjects(projects)) { + if (type === ApiTokenType.ADMIN && !isAll(projects)) { throw new BadDataError( 'Admin token cannot be scoped to single project', ); } - if (type === ApiTokenType.ADMIN && environment !== ALL) { + if (type === ApiTokenType.ADMIN && !isAll(environments)) { throw new BadDataError( 'Admin token cannot be scoped to single environment', ); } - if (type === ApiTokenType.CLIENT && environment === ALL) { + if (type === ApiTokenType.CLIENT && isAll(environments)) { throw new BadDataError( 'Client token cannot be scoped to all environments', ); } - if (type === ApiTokenType.PROXY && environment === ALL) { + if (type === ApiTokenType.PROXY && isAll(environments)) { throw new BadDataError( 'Proxy token cannot be scoped to all environments', ); @@ -112,24 +123,26 @@ export const validateApiToken = ({ }; export const validateApiTokenEnvironment = ( - { environment }: Pick, - environments: IEnvironment[], + { environments }: Pick, + allEnvironments: IEnvironment[], ): void => { - if (environment === ALL) { + if (isAll(environments)) { return; } - const selectedEnvironment = environments.find( - (env) => env.name === environment, + const foundEnvironments = allEnvironments.filter((environment) => + environments.includes(environment.name), ); - if (!selectedEnvironment) { - throw new BadDataError(`Environment=${environment} does not exist`); + if (foundEnvironments.length !== environments.length) { + throw new BadDataError('One or more environments do not exist'); } - if (!selectedEnvironment.enabled) { + if ( + foundEnvironments.filter((environment) => !environment.enabled).length + ) { throw new BadDataError( - 'Client token cannot be scoped to disabled environments', + 'Token cannot be scoped to disabled environments', ); } }; diff --git a/src/lib/types/permissions.ts b/src/lib/types/permissions.ts index 2bbea67da1..fa82a7c806 100644 --- a/src/lib/types/permissions.ts +++ b/src/lib/types/permissions.ts @@ -1,7 +1,7 @@ //Special export const ADMIN = 'ADMIN'; -export const CLIENT = 'CLIENT'; export const PROXY = 'PROXY'; +export const CLIENT = 'CLIENT'; export const NONE = 'NONE'; export const CREATE_FEATURE = 'CREATE_FEATURE'; diff --git a/src/migrations/20220802092958-add-api-link-table-environments.js b/src/migrations/20220802092958-add-api-link-table-environments.js new file mode 100644 index 0000000000..0aebbc0b5f --- /dev/null +++ b/src/migrations/20220802092958-add-api-link-table-environments.js @@ -0,0 +1,41 @@ +'use strict'; + +exports.up = function (db, cb) { + db.runSql( + ` + CREATE TABLE IF NOT EXISTS api_token_environment + ( + secret text NOT NULL, + environment text NOT NULL, + FOREIGN KEY (secret) REFERENCES api_tokens (secret) ON DELETE CASCADE, + FOREIGN KEY (environment) REFERENCES environments(name) ON DELETE CASCADE + ); + + INSERT INTO api_token_environment SELECT secret, environment FROM api_tokens WHERE environment IS NOT NULL; + + ALTER TABLE api_tokens DROP COLUMN "environment"; + `, + cb, + ); +}; + +//This is a lossy down migration, tokens with multiple environments are discarded +exports.down = function (db, cb) { + db.runSql( + ` + ALTER TABLE api_tokens ADD COLUMN environment VARCHAR REFERENCES ENVIRONMENTS(name) ON DELETE CASCADE; + DELETE FROM api_tokens WHERE secret LIKE '[]%'; + + UPDATE api_tokens + SET environment = subquery.environment + FROM( + SELECT token.secret, link.environment FROM api_tokens AS token LEFT JOIN api_token_environment AS link ON + token.secret = link.secret + ) AS subquery + WHERE api_tokens.environment = subquery.environment; + + DROP TABLE api_token_environment; +`, + cb, + ); +}; diff --git a/src/test/e2e/api/admin/playground.e2e.test.ts b/src/test/e2e/api/admin/playground.e2e.test.ts index 96d48e05d1..d52c9fec2d 100644 --- a/src/test/e2e/api/admin/playground.e2e.test.ts +++ b/src/test/e2e/api/admin/playground.e2e.test.ts @@ -26,7 +26,7 @@ beforeAll(async () => { token = await apiTokenService.createApiTokenWithProjects({ type: ApiTokenType.ADMIN, username: 'tester', - environment: ALL, + environments: [ALL], projects: [ALL], }); }); diff --git a/src/test/e2e/api/admin/state.e2e.test.ts b/src/test/e2e/api/admin/state.e2e.test.ts index 7ccf1856c7..bf6745238c 100644 --- a/src/test/e2e/api/admin/state.e2e.test.ts +++ b/src/test/e2e/api/admin/state.e2e.test.ts @@ -386,7 +386,7 @@ test(`should not delete api_tokens on import when drop-flag is set`, async () => await app.services.apiTokenService.createApiTokenWithProjects({ username: apiTokenName, type: ApiTokenType.CLIENT, - environment: environment, + environments: [environment], projects: [projectId], }); @@ -425,7 +425,7 @@ test(`should clean apitokens for not existing environment after import with drop await app.services.apiTokenService.createApiTokenWithProjects({ username: apiTokenName, type: ApiTokenType.CLIENT, - environment: environment, + environments: [environment], projects: [projectId], }); diff --git a/src/test/e2e/services/api-token-service.e2e.test.ts b/src/test/e2e/services/api-token-service.e2e.test.ts index 8988ada823..39f3c070fd 100644 --- a/src/test/e2e/services/api-token-service.e2e.test.ts +++ b/src/test/e2e/services/api-token-service.e2e.test.ts @@ -225,7 +225,7 @@ test('should not partially create token if projects are invalid', async () => { username: 'default-client', type: ApiTokenType.CLIENT, projects: ['non-existent-project'], - environment: DEFAULT_ENV, + environments: [DEFAULT_ENV], }); } catch (e) {} const allTokens = await apiTokenService.getAllTokens(); diff --git a/src/test/fixtures/fake-api-token-store.ts b/src/test/fixtures/fake-api-token-store.ts index d1d24274ad..b755cbac27 100644 --- a/src/test/fixtures/fake-api-token-store.ts +++ b/src/test/fixtures/fake-api-token-store.ts @@ -51,6 +51,7 @@ export default class FakeApiTokenStore const apiToken = { createdAt: new Date(), project: newToken.projects?.join(',') || '*', + environment: newToken.environments?.join(',') || '*', ...newToken, }; this.tokens.push(apiToken);