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,
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<ITokenRow>(`${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,
};
});

View File

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

View File

@ -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<IClientApp>) {
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<void>,
): Promise<void> {
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();
}

View File

@ -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(),

View File

@ -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 {

View File

@ -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];
}
}
}

View File

@ -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<ILegacyApiTokenCreate, '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 {
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<IApiTokenCreate, 'secret'>): 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<IApiTokenCreate, 'environment'>,
environments: IEnvironment[],
{ environments }: Pick<IApiTokenCreate, 'environments'>,
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',
);
}
};

View File

@ -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';

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({
type: ApiTokenType.ADMIN,
username: 'tester',
environment: ALL,
environments: [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({
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],
});

View File

@ -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();

View File

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