1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-05 17:53:12 +02:00

feat: add proxy token

This commit is contained in:
Tymoteusz Czech 2022-08-16 15:48:41 +02:00
parent 449167b17d
commit b13df231fa
No known key found for this signature in database
GPG Key ID: 133555230D88D75F
13 changed files with 165 additions and 76 deletions

View File

@ -9,12 +9,13 @@ import {
ApiTokenType, ApiTokenType,
IApiToken, IApiToken,
IApiTokenCreate, IApiTokenCreate,
isAllProjects, isAll,
} from '../types/models/api-token'; } from '../types/models/api-token';
import { ALL_PROJECTS } from '../util/constants'; import { ALL_PROJECTS } from '../util/constants';
const TABLE = 'api_tokens'; 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 = '*'; const ALL = '*';
@ -26,15 +27,15 @@ interface ITokenInsert {
expires_at?: Date; expires_at?: Date;
created_at: Date; created_at: Date;
seen_at?: Date; seen_at?: Date;
environment: string;
} }
interface ITokenRow extends ITokenInsert { interface ITokenRow extends ITokenInsert {
project: string; project: string;
environment: string;
} }
const tokenRowReducer = (acc, tokenRow) => { const tokenRowReducer = (acc, tokenRow) => {
const { project, ...token } = tokenRow; const { project, environment, ...token } = tokenRow;
if (!acc[tokenRow.secret]) { if (!acc[tokenRow.secret]) {
acc[tokenRow.secret] = { acc[tokenRow.secret] = {
secret: token.secret, secret: token.secret,
@ -42,19 +43,27 @@ const tokenRowReducer = (acc, tokenRow) => {
type: token.type, type: token.type,
project: ALL, project: ALL,
projects: [ALL], projects: [ALL],
environment: token.environment ? token.environment : ALL, environment: ALL,
environments: [ALL],
expiresAt: token.expires_at, expiresAt: token.expires_at,
createdAt: token.created_at, createdAt: token.created_at,
}; };
} }
const currentToken = acc[tokenRow.secret]; const currentToken = acc[tokenRow.secret];
if (tokenRow.project) { if (tokenRow.project) {
if (isAllProjects(currentToken.projects)) { if (isAll(currentToken.projects)) {
currentToken.projects = []; currentToken.projects = [];
} }
currentToken.projects.push(tokenRow.project); currentToken.projects.push(tokenRow.project);
currentToken.project = currentToken.projects.join(','); 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; return acc;
}; };
@ -62,8 +71,9 @@ const toRow = (newToken: IApiTokenCreate) => ({
username: newToken.username, username: newToken.username,
secret: newToken.secret, secret: newToken.secret,
type: newToken.type, type: newToken.type,
environment: environment: isAll(newToken.environments)
newToken.environment === ALL ? undefined : newToken.environment, ? undefined
: newToken.environments,
expires_at: newToken.expiresAt, expires_at: newToken.expiresAt,
}); });
@ -114,10 +124,15 @@ export class ApiTokenStore implements IApiTokenStore {
private makeTokenProjectQuery() { private makeTokenProjectQuery() {
return this.db<ITokenRow>(`${TABLE} as tokens`) return this.db<ITokenRow>(`${TABLE} as tokens`)
.leftJoin( .leftJoin(
`${API_LINK_TABLE} as token_project_link`, `${API_LINK_TABLE_PROJECT} as token_project_link`,
'tokens.secret', 'tokens.secret',
'token_project_link.secret', 'token_project_link.secret',
) )
.leftJoin(
`${API_LINK_TABLE_ENVIRONMENT} as token_environment_link`,
'tokens.secret',
'token_environment_link.secret',
)
.select( .select(
'tokens.secret', 'tokens.secret',
'username', 'username',
@ -125,8 +140,8 @@ export class ApiTokenStore implements IApiTokenStore {
'expires_at', 'expires_at',
'created_at', 'created_at',
'seen_at', 'seen_at',
'environment',
'token_project_link.project', 'token_project_link.project',
'token_environment_link.environment',
); );
} }
@ -143,14 +158,26 @@ export class ApiTokenStore implements IApiTokenStore {
}) })
.map((project) => { .map((project) => {
return tx.raw( return tx.raw(
`INSERT INTO ${API_LINK_TABLE} VALUES (?, ?)`, `INSERT INTO ${API_LINK_TABLE_PROJECT} VALUES (?, ?)`,
[newToken.secret, project], [newToken.secret, project],
); );
}); });
await Promise.all(updateProjectTasks); 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 { return {
...newToken, ...newToken,
project: newToken.projects?.join(',') || '*', project: newToken.projects?.join(',') || '*',
environment: newToken.environments?.join(',') || '*',
createdAt: row.created_at, createdAt: row.created_at,
}; };
}); });

View File

@ -9,7 +9,7 @@ import { IFeatureToggleQuery, ISegment } from '../../types/model';
import NotFoundError from '../../error/notfound-error'; import NotFoundError from '../../error/notfound-error';
import { IAuthRequest } from '../unleash-types'; import { IAuthRequest } from '../unleash-types';
import ApiUser from '../../types/api-user'; 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 { SegmentService } from '../../services/segment-service';
import { FeatureConfigurationClient } from '../../types/stores/feature-strategies-store'; import { FeatureConfigurationClient } from '../../types/stores/feature-strategies-store';
import { ClientSpecService } from '../../services/client-spec-service'; import { ClientSpecService } from '../../services/client-spec-service';
@ -30,7 +30,7 @@ const version = 2;
interface QueryOverride { interface QueryOverride {
project?: string[]; project?: string[];
environment?: string; environment?: string[];
} }
export default class FeatureController extends Controller { export default class FeatureController extends Controller {
@ -135,11 +135,11 @@ export default class FeatureController extends Controller {
const override: QueryOverride = {}; const override: QueryOverride = {};
if (user instanceof ApiUser) { if (user instanceof ApiUser) {
if (!isAllProjects(user.projects)) { if (!isAll(user.projects)) {
override.project = user.projects; override.project = user.projects;
} }
if (user.environment !== ALL) { if (!isAll(user.environments)) {
override.environment = user.environment; override.environment = user.environments;
} }
} }

View File

@ -7,7 +7,7 @@ import ClientInstanceService from '../../services/client-metrics/instance-servic
import { IAuthRequest, User } from '../../server-impl'; import { IAuthRequest, User } from '../../server-impl';
import { IClientApp } from '../../types/model'; import { IClientApp } from '../../types/model';
import ApiUser from '../../types/api-user'; 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 { NONE } from '../../types/permissions';
import { OpenApiService } from '../../services/openapi-service'; import { OpenApiService } from '../../services/openapi-service';
import { emptyResponse } from '../../openapi/util/standard-responses'; import { emptyResponse } from '../../openapi/util/standard-responses';
@ -51,9 +51,9 @@ export default class RegisterController extends Controller {
private static resolveEnvironment(user: User, data: Partial<IClientApp>) { private static resolveEnvironment(user: User, data: Partial<IClientApp>) {
if (user instanceof ApiUser) { if (user instanceof ApiUser) {
if (user.environment !== ALL) { if (!isAll(user.environments)) {
return user.environment; return user.environments;
} else if (user.environment === ALL && data.environment) { } else if (isAll(user.environments) && data.environment) {
return data.environment; return data.environment;
} }
} }
@ -65,7 +65,7 @@ export default class RegisterController extends Controller {
res: Response<void>, res: Response<void>,
): Promise<void> { ): Promise<void> {
const { body: data, ip: clientIp, user } = req; 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); await this.clientInstanceService.registerClient(data, clientIp);
return res.status(202).end(); return res.status(202).end();
} }

View File

@ -10,7 +10,7 @@ export const createApiToken = joi
.string() .string()
.lowercase() .lowercase()
.required() .required()
.valid(ApiTokenType.ADMIN, ApiTokenType.CLIENT), .valid(ApiTokenType.ADMIN, ApiTokenType.CLIENT, ApiTokenType.PROXY),
expiresAt: joi.date().optional(), expiresAt: joi.date().optional(),
project: joi.when('projects', { project: joi.when('projects', {
not: joi.required(), not: joi.required(),

View File

@ -1,6 +1,6 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { Logger } from '../logger'; import { Logger } from '../logger';
import { ADMIN, CLIENT, PROXY } from '../types/permissions'; import { ADMIN, PROXY, CLIENT } from '../types/permissions';
import { IUnleashStores } from '../types/stores'; import { IUnleashStores } from '../types/stores';
import { IUnleashConfig } from '../types/option'; import { IUnleashConfig } from '../types/option';
import ApiUser from '../types/api-user'; import ApiUser from '../types/api-user';
@ -108,7 +108,7 @@ export class ApiTokenService {
username: token.username, username: token.username,
permissions: resolveTokenPermissions(token.type), permissions: resolveTokenPermissions(token.type),
projects: token.projects, projects: token.projects,
environment: token.environment, environments: token.environments,
type: token.type, type: token.type,
secret: token.secret, secret: token.secret,
}); });
@ -161,12 +161,15 @@ export class ApiTokenService {
if (error.code === FOREIGN_KEY_VIOLATION) { if (error.code === FOREIGN_KEY_VIOLATION) {
let { message } = error; let { message } = error;
if (error.constraint === 'api_token_project_project_fkey') { if (error.constraint === 'api_token_project_project_fkey') {
message = `Project=${this.findInvalidProject( message = `Project=${this.findInvalid(
error.detail, error.detail,
newApiToken.projects, newApiToken.projects,
)} does not exist`; )} does not exist`;
} else if (error.constraint === 'api_tokens_environment_fkey') { } 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); throw new BadDataError(message);
} }
@ -174,23 +177,21 @@ export class ApiTokenService {
} }
} }
private findInvalidProject(errorDetails, projects) { private findInvalid(errorDetails, values) {
if (!errorDetails) { if (!errorDetails) {
return 'invalid'; return 'invalid';
} }
let invalidProject = projects.find((project) => { let invalid = values.find((value) => {
return errorDetails.includes(`=(${project})`); 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'); const randomStr = crypto.randomBytes(28).toString('hex');
if (projects.length > 1) { return `${projects.length > 1 ? '[]' : projects[0]}:${
return `[]:${environment}.${randomStr}`; environments.length > 1 ? '[]' : environments[0]
} else { }.${randomStr}`;
return `${projects[0]}:${environment}.${randomStr}`;
}
} }
destroy(): void { destroy(): void {

View File

@ -6,7 +6,8 @@ interface IApiUserData {
permissions?: string[]; permissions?: string[];
projects?: string[]; projects?: string[];
project?: string; project?: string;
environment: string; environments?: string[];
environment?: string;
type: ApiTokenType; type: ApiTokenType;
secret: string; secret: string;
} }
@ -20,7 +21,7 @@ export default class ApiUser {
readonly projects: string[]; readonly projects: string[];
readonly environment: string; readonly environments: string[];
readonly type: ApiTokenType; readonly type: ApiTokenType;
@ -31,6 +32,7 @@ export default class ApiUser {
permissions = [CLIENT], permissions = [CLIENT],
projects, projects,
project, project,
environments,
environment, environment,
type, type,
secret, secret,
@ -40,7 +42,6 @@ export default class ApiUser {
} }
this.username = username; this.username = username;
this.permissions = permissions; this.permissions = permissions;
this.environment = environment;
this.type = type; this.type = type;
this.secret = secret; this.secret = secret;
if (projects && projects.length > 0) { if (projects && projects.length > 0) {
@ -48,5 +49,10 @@ export default class ApiUser {
} else { } else {
this.projects = [project]; this.projects = [project];
} }
if (environments && environments.length > 0) {
this.environments = environments;
} else {
this.environments = [environment];
}
} }
} }

View File

@ -13,7 +13,8 @@ export interface ILegacyApiTokenCreate {
secret: string; secret: string;
username: string; username: string;
type: ApiTokenType; type: ApiTokenType;
environment: string; environment?: string;
environments?: string[];
project?: string; project?: string;
projects?: string[]; projects?: string[];
expiresAt?: Date; expiresAt?: Date;
@ -23,7 +24,7 @@ export interface IApiTokenCreate {
secret: string; secret: string;
username: string; username: string;
type: ApiTokenType; type: ApiTokenType;
environment: string; environments: string[];
projects: string[]; projects: string[];
expiresAt?: Date; expiresAt?: Date;
} }
@ -35,38 +36,48 @@ export interface IApiToken extends IApiTokenCreate {
project: string; project: string;
} }
export const isAllProjects = (projects: string[]): boolean => { export const isAll = (values: string[]): boolean => {
return projects && projects.length === 1 && projects[0] === ALL; return values && values.length === 1 && values[0] === ALL;
}; };
export const mapLegacyProjects = ( export const mapLegacy = (
project?: string, error: string,
projects?: string[], value?: string,
values?: string[],
): string[] => { ): string[] => {
let cleanedProjects; let cleanedValues;
if (project) { if (value) {
cleanedProjects = [project]; cleanedValues = [value];
} else if (projects) { } else if (values) {
cleanedProjects = projects; cleanedValues = values;
if (cleanedProjects.includes('*')) { if (cleanedValues.includes('*')) {
cleanedProjects = ['*']; cleanedValues = ['*'];
} }
} else { } else {
throw new BadDataError( 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 = ( export const mapLegacyToken = (
token: Omit<ILegacyApiTokenCreate, 'secret'>, token: Omit<ILegacyApiTokenCreate, 'secret'>,
): Omit<IApiTokenCreate, 'secret'> => { ): Omit<IApiTokenCreate, 'secret'> => {
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 { return {
username: token.username, username: token.username,
type: token.type, type: token.type,
environment: token.environment, environments: cleanedEnvironments,
projects: cleanedProjects, projects: cleanedProjects,
expiresAt: token.expiresAt, expiresAt: token.expiresAt,
}; };
@ -84,27 +95,27 @@ export const mapLegacyTokenWithSecret = (
export const validateApiToken = ({ export const validateApiToken = ({
type, type,
projects, projects,
environment, environments,
}: Omit<IApiTokenCreate, 'secret'>): void => { }: Omit<IApiTokenCreate, 'secret'>): void => {
if (type === ApiTokenType.ADMIN && !isAllProjects(projects)) { if (type === ApiTokenType.ADMIN && !isAll(projects)) {
throw new BadDataError( throw new BadDataError(
'Admin token cannot be scoped to single project', 'Admin token cannot be scoped to single project',
); );
} }
if (type === ApiTokenType.ADMIN && environment !== ALL) { if (type === ApiTokenType.ADMIN && !isAll(environments)) {
throw new BadDataError( throw new BadDataError(
'Admin token cannot be scoped to single environment', 'Admin token cannot be scoped to single environment',
); );
} }
if (type === ApiTokenType.CLIENT && environment === ALL) { if (type === ApiTokenType.CLIENT && isAll(environments)) {
throw new BadDataError( throw new BadDataError(
'Client token cannot be scoped to all environments', 'Client token cannot be scoped to all environments',
); );
} }
if (type === ApiTokenType.PROXY && environment === ALL) { if (type === ApiTokenType.PROXY && isAll(environments)) {
throw new BadDataError( throw new BadDataError(
'Proxy token cannot be scoped to all environments', 'Proxy token cannot be scoped to all environments',
); );
@ -112,24 +123,26 @@ export const validateApiToken = ({
}; };
export const validateApiTokenEnvironment = ( export const validateApiTokenEnvironment = (
{ environment }: Pick<IApiTokenCreate, 'environment'>, { environments }: Pick<IApiTokenCreate, 'environments'>,
environments: IEnvironment[], allEnvironments: IEnvironment[],
): void => { ): void => {
if (environment === ALL) { if (isAll(environments)) {
return; return;
} }
const selectedEnvironment = environments.find( const foundEnvironments = allEnvironments.filter((environment) =>
(env) => env.name === environment, environments.includes(environment.name),
); );
if (!selectedEnvironment) { if (foundEnvironments.length !== environments.length) {
throw new BadDataError(`Environment=${environment} does not exist`); throw new BadDataError('One or more environments do not exist');
} }
if (!selectedEnvironment.enabled) { if (
foundEnvironments.filter((environment) => !environment.enabled).length
) {
throw new BadDataError( throw new BadDataError(
'Client token cannot be scoped to disabled environments', 'Token cannot be scoped to disabled environments',
); );
} }
}; };

View File

@ -1,7 +1,7 @@
//Special //Special
export const ADMIN = 'ADMIN'; export const ADMIN = 'ADMIN';
export const CLIENT = 'CLIENT';
export const PROXY = 'PROXY'; export const PROXY = 'PROXY';
export const CLIENT = 'CLIENT';
export const NONE = 'NONE'; export const NONE = 'NONE';
export const CREATE_FEATURE = 'CREATE_FEATURE'; export const CREATE_FEATURE = 'CREATE_FEATURE';

View File

@ -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,
);
};

View File

@ -26,7 +26,7 @@ beforeAll(async () => {
token = await apiTokenService.createApiTokenWithProjects({ token = await apiTokenService.createApiTokenWithProjects({
type: ApiTokenType.ADMIN, type: ApiTokenType.ADMIN,
username: 'tester', username: 'tester',
environment: ALL, environments: [ALL],
projects: [ALL], projects: [ALL],
}); });
}); });

View File

@ -386,7 +386,7 @@ test(`should not delete api_tokens on import when drop-flag is set`, async () =>
await app.services.apiTokenService.createApiTokenWithProjects({ await app.services.apiTokenService.createApiTokenWithProjects({
username: apiTokenName, username: apiTokenName,
type: ApiTokenType.CLIENT, type: ApiTokenType.CLIENT,
environment: environment, environments: [environment],
projects: [projectId], projects: [projectId],
}); });
@ -425,7 +425,7 @@ test(`should clean apitokens for not existing environment after import with drop
await app.services.apiTokenService.createApiTokenWithProjects({ await app.services.apiTokenService.createApiTokenWithProjects({
username: apiTokenName, username: apiTokenName,
type: ApiTokenType.CLIENT, type: ApiTokenType.CLIENT,
environment: environment, environments: [environment],
projects: [projectId], projects: [projectId],
}); });

View File

@ -225,7 +225,7 @@ test('should not partially create token if projects are invalid', async () => {
username: 'default-client', username: 'default-client',
type: ApiTokenType.CLIENT, type: ApiTokenType.CLIENT,
projects: ['non-existent-project'], projects: ['non-existent-project'],
environment: DEFAULT_ENV, environments: [DEFAULT_ENV],
}); });
} catch (e) {} } catch (e) {}
const allTokens = await apiTokenService.getAllTokens(); const allTokens = await apiTokenService.getAllTokens();

View File

@ -51,6 +51,7 @@ export default class FakeApiTokenStore
const apiToken = { const apiToken = {
createdAt: new Date(), createdAt: new Date(),
project: newToken.projects?.join(',') || '*', project: newToken.projects?.join(',') || '*',
environment: newToken.environments?.join(',') || '*',
...newToken, ...newToken,
}; };
this.tokens.push(apiToken); this.tokens.push(apiToken);