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:
parent
449167b17d
commit
b13df231fa
@ -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,
|
||||
};
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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(),
|
||||
|
@ -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 {
|
||||
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
);
|
||||
};
|
@ -26,7 +26,7 @@ beforeAll(async () => {
|
||||
token = await apiTokenService.createApiTokenWithProjects({
|
||||
type: ApiTokenType.ADMIN,
|
||||
username: 'tester',
|
||||
environment: ALL,
|
||||
environments: [ALL],
|
||||
projects: [ALL],
|
||||
});
|
||||
});
|
||||
|
@ -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],
|
||||
});
|
||||
|
||||
|
@ -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();
|
||||
|
1
src/test/fixtures/fake-api-token-store.ts
vendored
1
src/test/fixtures/fake-api-token-store.ts
vendored
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user