mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-24 01:18:01 +02:00
Merge pull request #1476 from Unleash/feat/multi-project-tokens
feat: Implement multi token support for client tokens
This commit is contained in:
commit
c9b44b6546
@ -21,7 +21,11 @@ import { defaultCustomAuthDenyAll } from './default-custom-auth-deny-all';
|
|||||||
import { formatBaseUri } from './util/format-base-uri';
|
import { formatBaseUri } from './util/format-base-uri';
|
||||||
import { minutesToMilliseconds, secondsToMilliseconds } from 'date-fns';
|
import { minutesToMilliseconds, secondsToMilliseconds } from 'date-fns';
|
||||||
import EventEmitter from 'events';
|
import EventEmitter from 'events';
|
||||||
import { ApiTokenType, validateApiToken } from './types/models/api-token';
|
import {
|
||||||
|
ApiTokenType,
|
||||||
|
mapLegacyToken,
|
||||||
|
validateApiToken,
|
||||||
|
} from './types/models/api-token';
|
||||||
|
|
||||||
const safeToUpper = (s: string) => (s ? s.toUpperCase() : s);
|
const safeToUpper = (s: string) => (s ? s.toUpperCase() : s);
|
||||||
|
|
||||||
@ -199,7 +203,7 @@ const loadTokensFromString = (tokenString: String, tokenType: ApiTokenType) => {
|
|||||||
type: tokenType,
|
type: tokenType,
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
};
|
};
|
||||||
validateApiToken(token);
|
validateApiToken(mapLegacyToken(token));
|
||||||
return token;
|
return token;
|
||||||
});
|
});
|
||||||
return tokens;
|
return tokens;
|
||||||
|
@ -9,13 +9,16 @@ import {
|
|||||||
ApiTokenType,
|
ApiTokenType,
|
||||||
IApiToken,
|
IApiToken,
|
||||||
IApiTokenCreate,
|
IApiTokenCreate,
|
||||||
|
isAllProjects,
|
||||||
} from '../types/models/api-token';
|
} from '../types/models/api-token';
|
||||||
|
import { ALL_PROJECTS } from '../../lib/services/access-service';
|
||||||
|
|
||||||
const TABLE = 'api_tokens';
|
const TABLE = 'api_tokens';
|
||||||
|
const API_LINK_TABLE = 'api_token_project';
|
||||||
|
|
||||||
const ALL = '*';
|
const ALL = '*';
|
||||||
|
|
||||||
interface ITokenTable {
|
interface ITokenInsert {
|
||||||
id: number;
|
id: number;
|
||||||
secret: string;
|
secret: string;
|
||||||
username: string;
|
username: string;
|
||||||
@ -24,28 +27,50 @@ interface ITokenTable {
|
|||||||
created_at: Date;
|
created_at: Date;
|
||||||
seen_at?: Date;
|
seen_at?: Date;
|
||||||
environment: string;
|
environment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ITokenRow extends ITokenInsert {
|
||||||
project: string;
|
project: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenRowReducer = (acc, tokenRow) => {
|
||||||
|
const { project, ...token } = tokenRow;
|
||||||
|
if (!acc[tokenRow.secret]) {
|
||||||
|
acc[tokenRow.secret] = {
|
||||||
|
secret: token.secret,
|
||||||
|
username: token.username,
|
||||||
|
type: token.type,
|
||||||
|
project: ALL,
|
||||||
|
projects: [ALL],
|
||||||
|
environment: token.environment ? token.environment : ALL,
|
||||||
|
expiresAt: token.expires_at,
|
||||||
|
createdAt: token.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const currentToken = acc[tokenRow.secret];
|
||||||
|
if (tokenRow.project) {
|
||||||
|
if (isAllProjects(currentToken.projects)) {
|
||||||
|
currentToken.projects = [];
|
||||||
|
}
|
||||||
|
currentToken.projects.push(tokenRow.project);
|
||||||
|
currentToken.project = currentToken.projects.join(',');
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
};
|
||||||
|
|
||||||
const toRow = (newToken: IApiTokenCreate) => ({
|
const toRow = (newToken: IApiTokenCreate) => ({
|
||||||
username: newToken.username,
|
username: newToken.username,
|
||||||
secret: newToken.secret,
|
secret: newToken.secret,
|
||||||
type: newToken.type,
|
type: newToken.type,
|
||||||
project: newToken.project === ALL ? undefined : newToken.project,
|
|
||||||
environment:
|
environment:
|
||||||
newToken.environment === ALL ? undefined : newToken.environment,
|
newToken.environment === ALL ? undefined : newToken.environment,
|
||||||
expires_at: newToken.expiresAt,
|
expires_at: newToken.expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toToken = (row: ITokenTable): IApiToken => ({
|
const toTokens = (rows: any[]): IApiToken[] => {
|
||||||
secret: row.secret,
|
const tokens = rows.reduce(tokenRowReducer, {});
|
||||||
username: row.username,
|
return Object.values(tokens);
|
||||||
type: row.type,
|
};
|
||||||
environment: row.environment ? row.environment : ALL,
|
|
||||||
project: row.project ? row.project : ALL,
|
|
||||||
expiresAt: row.expires_at,
|
|
||||||
createdAt: row.created_at,
|
|
||||||
});
|
|
||||||
|
|
||||||
export class ApiTokenStore implements IApiTokenStore {
|
export class ApiTokenStore implements IApiTokenStore {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
@ -72,26 +97,64 @@ export class ApiTokenStore implements IApiTokenStore {
|
|||||||
|
|
||||||
async getAll(): Promise<IApiToken[]> {
|
async getAll(): Promise<IApiToken[]> {
|
||||||
const stopTimer = this.timer('getAll');
|
const stopTimer = this.timer('getAll');
|
||||||
const rows = await this.db<ITokenTable>(TABLE);
|
const rows = await this.makeTokenProjectQuery();
|
||||||
stopTimer();
|
stopTimer();
|
||||||
return rows.map(toToken);
|
return toTokens(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllActive(): Promise<IApiToken[]> {
|
async getAllActive(): Promise<IApiToken[]> {
|
||||||
const stopTimer = this.timer('getAllActive');
|
const stopTimer = this.timer('getAllActive');
|
||||||
const rows = await this.db<ITokenTable>(TABLE)
|
const rows = await this.makeTokenProjectQuery()
|
||||||
.where('expires_at', 'IS', null)
|
.where('expires_at', 'IS', null)
|
||||||
.orWhere('expires_at', '>', 'now()');
|
.orWhere('expires_at', '>', 'now()');
|
||||||
stopTimer();
|
stopTimer();
|
||||||
return rows.map(toToken);
|
return toTokens(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeTokenProjectQuery() {
|
||||||
|
return this.db<ITokenRow>(`${TABLE} as tokens`)
|
||||||
|
.leftJoin(
|
||||||
|
`${API_LINK_TABLE} as token_project_link`,
|
||||||
|
'tokens.secret',
|
||||||
|
'token_project_link.secret',
|
||||||
|
)
|
||||||
|
.select(
|
||||||
|
'tokens.secret',
|
||||||
|
'username',
|
||||||
|
'type',
|
||||||
|
'expires_at',
|
||||||
|
'created_at',
|
||||||
|
'seen_at',
|
||||||
|
'environment',
|
||||||
|
'token_project_link.project',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async insert(newToken: IApiTokenCreate): Promise<IApiToken> {
|
async insert(newToken: IApiTokenCreate): Promise<IApiToken> {
|
||||||
const [row] = await this.db<ITokenTable>(TABLE).insert(
|
const response = await this.db.transaction(async (tx) => {
|
||||||
|
const [row] = await tx<ITokenInsert>(TABLE).insert(
|
||||||
toRow(newToken),
|
toRow(newToken),
|
||||||
['created_at'],
|
['created_at'],
|
||||||
);
|
);
|
||||||
return { ...newToken, createdAt: row.created_at };
|
|
||||||
|
const updateProjectTasks = (newToken.projects || [])
|
||||||
|
.filter((project) => {
|
||||||
|
return project !== ALL_PROJECTS;
|
||||||
|
})
|
||||||
|
.map((project) => {
|
||||||
|
return tx.raw(
|
||||||
|
`INSERT INTO ${API_LINK_TABLE} VALUES (?, ?)`,
|
||||||
|
[newToken.secret, project],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await Promise.all(updateProjectTasks);
|
||||||
|
return {
|
||||||
|
...newToken,
|
||||||
|
project: newToken.projects?.join(',') || '*',
|
||||||
|
createdAt: row.created_at,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {}
|
destroy(): void {}
|
||||||
@ -106,25 +169,25 @@ export class ApiTokenStore implements IApiTokenStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(key: string): Promise<IApiToken> {
|
async get(key: string): Promise<IApiToken> {
|
||||||
const row = await this.db(TABLE).where('secret', key).first();
|
const row = await this.makeTokenProjectQuery().where('secret', key);
|
||||||
return toToken(row);
|
return toTokens(row)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(secret: string): Promise<void> {
|
async delete(secret: string): Promise<void> {
|
||||||
return this.db<ITokenTable>(TABLE).where({ secret }).del();
|
return this.db<ITokenRow>(TABLE).where({ secret }).del();
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAll(): Promise<void> {
|
async deleteAll(): Promise<void> {
|
||||||
return this.db<ITokenTable>(TABLE).del();
|
return this.db<ITokenRow>(TABLE).del();
|
||||||
}
|
}
|
||||||
|
|
||||||
async setExpiry(secret: string, expiresAt: Date): Promise<IApiToken> {
|
async setExpiry(secret: string, expiresAt: Date): Promise<IApiToken> {
|
||||||
const rows = await this.db<ITokenTable>(TABLE)
|
const rows = await this.makeTokenProjectQuery()
|
||||||
.update({ expires_at: expiresAt })
|
.update({ expires_at: expiresAt })
|
||||||
.where({ secret })
|
.where({ secret })
|
||||||
.returning('*');
|
.returning('*');
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
return toToken(rows[0]);
|
return toTokens(rows)[0];
|
||||||
}
|
}
|
||||||
throw new NotFoundError('Could not find api-token.');
|
throw new NotFoundError('Could not find api-token.');
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,7 @@ import { IFeatureToggleQuery } 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 } from '../../types/models/api-token';
|
import { ALL, isAllProjects } from '../../types/models/api-token';
|
||||||
|
|
||||||
const version = 2;
|
const version = 2;
|
||||||
|
|
||||||
@ -65,8 +65,8 @@ export default class FeatureController extends Controller {
|
|||||||
|
|
||||||
const override: QueryOverride = {};
|
const override: QueryOverride = {};
|
||||||
if (user instanceof ApiUser) {
|
if (user instanceof ApiUser) {
|
||||||
if (user.project !== ALL) {
|
if (!isAllProjects(user.projects)) {
|
||||||
override.project = [user.project];
|
override.project = user.projects;
|
||||||
}
|
}
|
||||||
if (user.environment !== ALL) {
|
if (user.environment !== ALL) {
|
||||||
override.environment = user.environment;
|
override.environment = user.environment;
|
||||||
|
44
src/lib/schema/api-token-schema.test.ts
Normal file
44
src/lib/schema/api-token-schema.test.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { ALL } from '../types/models/api-token';
|
||||||
|
import { createApiToken } from './api-token-schema';
|
||||||
|
|
||||||
|
test('should reject token with projects and project', async () => {
|
||||||
|
expect.assertions(1);
|
||||||
|
try {
|
||||||
|
await createApiToken.validateAsync({
|
||||||
|
username: 'test',
|
||||||
|
type: 'admin',
|
||||||
|
project: 'default',
|
||||||
|
projects: ['default'],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
expect(error.details[0].message).toEqual(
|
||||||
|
'"project" must not exist simultaneously with [projects]',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not have default project set if projects is present', async () => {
|
||||||
|
let token = await createApiToken.validateAsync({
|
||||||
|
username: 'test',
|
||||||
|
type: 'admin',
|
||||||
|
projects: ['default'],
|
||||||
|
});
|
||||||
|
expect(token.project).not.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have project set to default if projects is missing', async () => {
|
||||||
|
let token = await createApiToken.validateAsync({
|
||||||
|
username: 'test',
|
||||||
|
type: 'admin',
|
||||||
|
});
|
||||||
|
expect(token.project).toBe(ALL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not have projects set if project is present', async () => {
|
||||||
|
let token = await createApiToken.validateAsync({
|
||||||
|
username: 'test',
|
||||||
|
type: 'admin',
|
||||||
|
project: 'default',
|
||||||
|
});
|
||||||
|
expect(token.projects).not.toBeDefined();
|
||||||
|
});
|
@ -12,11 +12,16 @@ export const createApiToken = joi
|
|||||||
.required()
|
.required()
|
||||||
.valid(ApiTokenType.ADMIN, ApiTokenType.CLIENT),
|
.valid(ApiTokenType.ADMIN, ApiTokenType.CLIENT),
|
||||||
expiresAt: joi.date().optional(),
|
expiresAt: joi.date().optional(),
|
||||||
project: joi.string().optional().default(ALL),
|
project: joi.when('projects', {
|
||||||
|
not: joi.required(),
|
||||||
|
then: joi.string().optional().default(ALL),
|
||||||
|
}),
|
||||||
|
projects: joi.array().min(0).optional(),
|
||||||
environment: joi.when('type', {
|
environment: joi.when('type', {
|
||||||
is: joi.string().valid(ApiTokenType.CLIENT),
|
is: joi.string().valid(ApiTokenType.CLIENT),
|
||||||
then: joi.string().optional().default(DEFAULT_ENV),
|
then: joi.string().optional().default(DEFAULT_ENV),
|
||||||
otherwise: joi.string().optional().default(ALL),
|
otherwise: joi.string().optional().default(ALL),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
.nand('project', 'projects')
|
||||||
.options({ stripUnknown: true, allowUnknown: false, abortEarly: false });
|
.options({ stripUnknown: true, allowUnknown: false, abortEarly: false });
|
||||||
|
@ -7,9 +7,12 @@ import ApiUser from '../types/api-user';
|
|||||||
import {
|
import {
|
||||||
ApiTokenType,
|
ApiTokenType,
|
||||||
IApiToken,
|
IApiToken,
|
||||||
|
ILegacyApiTokenCreate,
|
||||||
IApiTokenCreate,
|
IApiTokenCreate,
|
||||||
validateApiToken,
|
validateApiToken,
|
||||||
validateApiTokenEnvironment,
|
validateApiTokenEnvironment,
|
||||||
|
mapLegacyToken,
|
||||||
|
mapLegacyTokenWithSecret,
|
||||||
} from '../types/models/api-token';
|
} from '../types/models/api-token';
|
||||||
import { IApiTokenStore } from '../types/stores/api-token-store';
|
import { IApiTokenStore } from '../types/stores/api-token-store';
|
||||||
import { FOREIGN_KEY_VIOLATION } from '../error/db-error';
|
import { FOREIGN_KEY_VIOLATION } from '../error/db-error';
|
||||||
@ -67,13 +70,15 @@ export class ApiTokenService {
|
|||||||
return this.store.getAllActive();
|
return this.store.getAllActive();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initApiTokens(tokens: IApiTokenCreate[]) {
|
private async initApiTokens(tokens: ILegacyApiTokenCreate[]) {
|
||||||
const tokenCount = await this.store.count();
|
const tokenCount = await this.store.count();
|
||||||
if (tokenCount > 0) {
|
if (tokenCount > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const createAll = tokens.map((t) => this.insertNewApiToken(t));
|
const createAll = tokens
|
||||||
|
.map(mapLegacyTokenWithSecret)
|
||||||
|
.map((t) => this.insertNewApiToken(t));
|
||||||
await Promise.all(createAll);
|
await Promise.all(createAll);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error('Unable to create initial Admin API tokens');
|
this.logger.error('Unable to create initial Admin API tokens');
|
||||||
@ -89,7 +94,7 @@ export class ApiTokenService {
|
|||||||
return new ApiUser({
|
return new ApiUser({
|
||||||
username: token.username,
|
username: token.username,
|
||||||
permissions,
|
permissions,
|
||||||
project: token.project,
|
projects: token.projects,
|
||||||
environment: token.environment,
|
environment: token.environment,
|
||||||
type: token.type,
|
type: token.type,
|
||||||
});
|
});
|
||||||
@ -108,7 +113,17 @@ export class ApiTokenService {
|
|||||||
return this.store.delete(secret);
|
return this.store.delete(secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated This may be removed in a future release, prefer createApiTokenWithProjects
|
||||||
|
*/
|
||||||
public async createApiToken(
|
public async createApiToken(
|
||||||
|
newToken: Omit<ILegacyApiTokenCreate, 'secret'>,
|
||||||
|
): Promise<IApiToken> {
|
||||||
|
const token = mapLegacyToken(newToken);
|
||||||
|
return this.createApiTokenWithProjects(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createApiTokenWithProjects(
|
||||||
newToken: Omit<IApiTokenCreate, 'secret'>,
|
newToken: Omit<IApiTokenCreate, 'secret'>,
|
||||||
): Promise<IApiToken> {
|
): Promise<IApiToken> {
|
||||||
validateApiToken(newToken);
|
validateApiToken(newToken);
|
||||||
@ -131,8 +146,11 @@ export class ApiTokenService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === FOREIGN_KEY_VIOLATION) {
|
if (error.code === FOREIGN_KEY_VIOLATION) {
|
||||||
let { message } = error;
|
let { message } = error;
|
||||||
if (error.constraint === 'api_tokens_project_fkey') {
|
if (error.constraint === 'api_token_project_project_fkey') {
|
||||||
message = `Project=${newApiToken.project} does not exist`;
|
message = `Project=${this.findInvalidProject(
|
||||||
|
error.detail,
|
||||||
|
newApiToken.projects,
|
||||||
|
)} 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=${newApiToken.environment} does not exist`;
|
||||||
}
|
}
|
||||||
@ -142,9 +160,23 @@ export class ApiTokenService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateSecretKey({ project, environment }) {
|
private findInvalidProject(errorDetails, projects) {
|
||||||
|
if (!errorDetails) {
|
||||||
|
return 'invalid';
|
||||||
|
}
|
||||||
|
let invalidProject = projects.find((project) => {
|
||||||
|
return errorDetails.includes(`=(${project})`);
|
||||||
|
});
|
||||||
|
return invalidProject || 'invalid';
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateSecretKey({ projects, environment }) {
|
||||||
const randomStr = crypto.randomBytes(28).toString('hex');
|
const randomStr = crypto.randomBytes(28).toString('hex');
|
||||||
return `${project}:${environment}.${randomStr}`;
|
if (projects.length > 1) {
|
||||||
|
return `[]:${environment}.${randomStr}`;
|
||||||
|
} else {
|
||||||
|
return `${projects[0]}:${environment}.${randomStr}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
|
@ -4,7 +4,8 @@ import { CLIENT } from './permissions';
|
|||||||
interface IApiUserData {
|
interface IApiUserData {
|
||||||
username: string;
|
username: string;
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
project: string;
|
projects?: string[];
|
||||||
|
project?: string;
|
||||||
environment: string;
|
environment: string;
|
||||||
type: ApiTokenType;
|
type: ApiTokenType;
|
||||||
}
|
}
|
||||||
@ -16,7 +17,7 @@ export default class ApiUser {
|
|||||||
|
|
||||||
readonly permissions: string[];
|
readonly permissions: string[];
|
||||||
|
|
||||||
readonly project: string;
|
readonly projects: string[];
|
||||||
|
|
||||||
readonly environment: string;
|
readonly environment: string;
|
||||||
|
|
||||||
@ -25,6 +26,7 @@ export default class ApiUser {
|
|||||||
constructor({
|
constructor({
|
||||||
username,
|
username,
|
||||||
permissions = [CLIENT],
|
permissions = [CLIENT],
|
||||||
|
projects,
|
||||||
project,
|
project,
|
||||||
environment,
|
environment,
|
||||||
type,
|
type,
|
||||||
@ -34,8 +36,12 @@ export default class ApiUser {
|
|||||||
}
|
}
|
||||||
this.username = username;
|
this.username = username;
|
||||||
this.permissions = permissions;
|
this.permissions = permissions;
|
||||||
this.project = project;
|
|
||||||
this.environment = environment;
|
this.environment = environment;
|
||||||
this.type = type;
|
this.type = type;
|
||||||
|
if (projects && projects.length > 0) {
|
||||||
|
this.projects = projects;
|
||||||
|
} else {
|
||||||
|
this.projects = [project];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,12 +8,22 @@ export enum ApiTokenType {
|
|||||||
ADMIN = 'admin',
|
ADMIN = 'admin',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ILegacyApiTokenCreate {
|
||||||
|
secret: string;
|
||||||
|
username: string;
|
||||||
|
type: ApiTokenType;
|
||||||
|
environment: string;
|
||||||
|
project?: string;
|
||||||
|
projects?: string[];
|
||||||
|
expiresAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IApiTokenCreate {
|
export interface IApiTokenCreate {
|
||||||
secret: string;
|
secret: string;
|
||||||
username: string;
|
username: string;
|
||||||
type: ApiTokenType;
|
type: ApiTokenType;
|
||||||
environment: string;
|
environment: string;
|
||||||
project: string;
|
projects: string[];
|
||||||
expiresAt?: Date;
|
expiresAt?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,12 +34,58 @@ export interface IApiToken extends IApiTokenCreate {
|
|||||||
project: string;
|
project: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isAllProjects = (projects: string[]): boolean => {
|
||||||
|
return projects && projects.length === 1 && projects[0] === ALL;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapLegacyProjects = (
|
||||||
|
project?: string,
|
||||||
|
projects?: string[],
|
||||||
|
): string[] => {
|
||||||
|
let cleanedProjects;
|
||||||
|
if (project) {
|
||||||
|
cleanedProjects = [project];
|
||||||
|
} else if (projects) {
|
||||||
|
cleanedProjects = projects;
|
||||||
|
if (cleanedProjects.includes('*')) {
|
||||||
|
cleanedProjects = ['*'];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new BadDataError(
|
||||||
|
'API tokens must either contain a project or projects field',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cleanedProjects;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapLegacyToken = (
|
||||||
|
token: Omit<ILegacyApiTokenCreate, 'secret'>,
|
||||||
|
): Omit<IApiTokenCreate, 'secret'> => {
|
||||||
|
const cleanedProjects = mapLegacyProjects(token.project, token.projects);
|
||||||
|
return {
|
||||||
|
username: token.username,
|
||||||
|
type: token.type,
|
||||||
|
environment: token.environment,
|
||||||
|
projects: cleanedProjects,
|
||||||
|
expiresAt: token.expiresAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapLegacyTokenWithSecret = (
|
||||||
|
token: ILegacyApiTokenCreate,
|
||||||
|
): IApiTokenCreate => {
|
||||||
|
return {
|
||||||
|
...mapLegacyToken(token),
|
||||||
|
secret: token.secret,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const validateApiToken = ({
|
export const validateApiToken = ({
|
||||||
type,
|
type,
|
||||||
project,
|
projects,
|
||||||
environment,
|
environment,
|
||||||
}: Omit<IApiTokenCreate, 'secret'>): void => {
|
}: Omit<IApiTokenCreate, 'secret'>): void => {
|
||||||
if (type === ApiTokenType.ADMIN && project !== ALL) {
|
if (type === ApiTokenType.ADMIN && !isAllProjects(projects)) {
|
||||||
throw new BadDataError(
|
throw new BadDataError(
|
||||||
'Admin token cannot be scoped to single project',
|
'Admin token cannot be scoped to single project',
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import EventEmitter from 'events';
|
import EventEmitter from 'events';
|
||||||
import { LogLevel, LogProvider } from '../logger';
|
import { LogLevel, LogProvider } from '../logger';
|
||||||
import { IApiTokenCreate } from './models/api-token';
|
import { ILegacyApiTokenCreate } from './models/api-token';
|
||||||
import { IExperimentalOptions } from '../experimental';
|
import { IExperimentalOptions } from '../experimental';
|
||||||
|
|
||||||
export type EventHook = (eventName: string, data: object) => void;
|
export type EventHook = (eventName: string, data: object) => void;
|
||||||
@ -56,7 +56,7 @@ export interface IAuthOption {
|
|||||||
type: IAuthType;
|
type: IAuthType;
|
||||||
customAuthHandler?: Function;
|
customAuthHandler?: Function;
|
||||||
createAdminUser: boolean;
|
createAdminUser: boolean;
|
||||||
initApiTokens: IApiTokenCreate[];
|
initApiTokens: ILegacyApiTokenCreate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IImportOption {
|
export interface IImportOption {
|
||||||
|
41
src/migrations/20220331085057-add-api-link-table.js
Normal file
41
src/migrations/20220331085057-add-api-link-table.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
exports.up = function (db, cb) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS api_token_project
|
||||||
|
(
|
||||||
|
secret text NOT NULL,
|
||||||
|
project text NOT NULL,
|
||||||
|
FOREIGN KEY (secret) REFERENCES api_tokens (secret) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (project) REFERENCES projects(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO api_token_project SELECT secret, project FROM api_tokens WHERE project IS NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE api_tokens DROP COLUMN "project";
|
||||||
|
`,
|
||||||
|
cb,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
//This is a lossy down migration, tokens with multiple projects are discarded
|
||||||
|
exports.down = function (db, cb) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
ALTER TABLE api_tokens ADD COLUMN project VARCHAR REFERENCES PROJECTS(id) ON DELETE CASCADE;
|
||||||
|
DELETE FROM api_tokens WHERE secret LIKE '[]%';
|
||||||
|
|
||||||
|
UPDATE api_tokens
|
||||||
|
SET project = subquery.project
|
||||||
|
FROM(
|
||||||
|
SELECT token.secret, link.project FROM api_tokens AS token LEFT JOIN api_token_project AS link ON
|
||||||
|
token.secret = link.secret
|
||||||
|
) AS subquery
|
||||||
|
WHERE api_tokens.project = subquery.project;
|
||||||
|
|
||||||
|
DROP TABLE api_token_project;
|
||||||
|
`,
|
||||||
|
cb,
|
||||||
|
);
|
||||||
|
};
|
@ -194,7 +194,7 @@ test('creates new client token: project & environment defaults to "*"', async ()
|
|||||||
expect(res.body.type).toBe('client');
|
expect(res.body.type).toBe('client');
|
||||||
expect(res.body.secret.length > 16).toBe(true);
|
expect(res.body.secret.length > 16).toBe(true);
|
||||||
expect(res.body.environment).toBe(DEFAULT_ENV);
|
expect(res.body.environment).toBe(DEFAULT_ENV);
|
||||||
expect(res.body.project).toBe(ALL);
|
expect(res.body.projects[0]).toBe(ALL);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -213,7 +213,7 @@ test('creates new client token with project & environment set', async () => {
|
|||||||
expect(res.body.type).toBe('client');
|
expect(res.body.type).toBe('client');
|
||||||
expect(res.body.secret.length > 16).toBe(true);
|
expect(res.body.secret.length > 16).toBe(true);
|
||||||
expect(res.body.environment).toBe(DEFAULT_ENV);
|
expect(res.body.environment).toBe(DEFAULT_ENV);
|
||||||
expect(res.body.project).toBe('default');
|
expect(res.body.projects[0]).toBe('default');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -5,10 +5,14 @@ import { createTestConfig } from '../../config/test-config';
|
|||||||
import { ApiTokenType, IApiToken } from '../../../lib/types/models/api-token';
|
import { ApiTokenType, IApiToken } from '../../../lib/types/models/api-token';
|
||||||
import { DEFAULT_ENV } from '../../../lib/util/constants';
|
import { DEFAULT_ENV } from '../../../lib/util/constants';
|
||||||
import { addDays, subDays } from 'date-fns';
|
import { addDays, subDays } from 'date-fns';
|
||||||
|
import ProjectService from '../../../lib/services/project-service';
|
||||||
|
import FeatureToggleService from '../../../lib/services/feature-toggle-service';
|
||||||
|
import { AccessService } from '../../../lib/services/access-service';
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
let stores;
|
let stores;
|
||||||
let apiTokenService: ApiTokenService;
|
let apiTokenService: ApiTokenService;
|
||||||
|
let projectService: ProjectService;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const config = createTestConfig({
|
const config = createTestConfig({
|
||||||
@ -16,7 +20,27 @@ beforeAll(async () => {
|
|||||||
});
|
});
|
||||||
db = await dbInit('api_token_service_serial', getLogger);
|
db = await dbInit('api_token_service_serial', getLogger);
|
||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
// projectStore = stores.projectStore;
|
const accessService = new AccessService(stores, config);
|
||||||
|
const featureToggleService = new FeatureToggleService(stores, config);
|
||||||
|
const project = {
|
||||||
|
id: 'test-project',
|
||||||
|
name: 'Test Project',
|
||||||
|
description: 'Fancy',
|
||||||
|
};
|
||||||
|
const user = await stores.userStore.insert({
|
||||||
|
name: 'Some Name',
|
||||||
|
email: 'test@getunleash.io',
|
||||||
|
});
|
||||||
|
|
||||||
|
projectService = new ProjectService(
|
||||||
|
stores,
|
||||||
|
config,
|
||||||
|
accessService,
|
||||||
|
featureToggleService,
|
||||||
|
);
|
||||||
|
|
||||||
|
await projectService.createProject(project, user);
|
||||||
|
|
||||||
apiTokenService = new ApiTokenService(stores, config);
|
apiTokenService = new ApiTokenService(stores, config);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -128,3 +152,75 @@ test('should only return valid tokens', async () => {
|
|||||||
expect(tokens.length).toBe(1);
|
expect(tokens.length).toBe(1);
|
||||||
expect(activeToken.secret).toBe(tokens[0].secret);
|
expect(activeToken.secret).toBe(tokens[0].secret);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should create client token with project list', async () => {
|
||||||
|
const token = await apiTokenService.createApiToken({
|
||||||
|
username: 'default-client',
|
||||||
|
type: ApiTokenType.CLIENT,
|
||||||
|
projects: ['default', 'test-project'],
|
||||||
|
environment: DEFAULT_ENV,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(token.secret.slice(0, 2)).toEqual('[]');
|
||||||
|
expect(token.projects).toStrictEqual(['default', 'test-project']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should strip all other projects if ALL_PROJECTS is present', async () => {
|
||||||
|
const token = await apiTokenService.createApiToken({
|
||||||
|
username: 'default-client',
|
||||||
|
type: ApiTokenType.CLIENT,
|
||||||
|
projects: ['*', 'default'],
|
||||||
|
environment: DEFAULT_ENV,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(token.projects).toStrictEqual(['*']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return user with multiple projects', async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const tomorrow = addDays(now, 1);
|
||||||
|
|
||||||
|
await apiTokenService.createApiToken({
|
||||||
|
username: 'default-valid',
|
||||||
|
type: ApiTokenType.CLIENT,
|
||||||
|
expiresAt: tomorrow,
|
||||||
|
projects: ['test-project', 'default'],
|
||||||
|
environment: DEFAULT_ENV,
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiTokenService.createApiToken({
|
||||||
|
username: 'default-also-valid',
|
||||||
|
type: ApiTokenType.CLIENT,
|
||||||
|
expiresAt: tomorrow,
|
||||||
|
projects: ['test-project'],
|
||||||
|
environment: DEFAULT_ENV,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokens = await apiTokenService.getAllActiveTokens();
|
||||||
|
const multiProjectUser = await apiTokenService.getUserForToken(
|
||||||
|
tokens[0].secret,
|
||||||
|
);
|
||||||
|
const singleProjectUser = await apiTokenService.getUserForToken(
|
||||||
|
tokens[1].secret,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(multiProjectUser.projects).toStrictEqual([
|
||||||
|
'test-project',
|
||||||
|
'default',
|
||||||
|
]);
|
||||||
|
expect(singleProjectUser.projects).toStrictEqual(['test-project']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not partially create token if projects are invalid', async () => {
|
||||||
|
try {
|
||||||
|
await apiTokenService.createApiTokenWithProjects({
|
||||||
|
username: 'default-client',
|
||||||
|
type: ApiTokenType.CLIENT,
|
||||||
|
projects: ['non-existent-project'],
|
||||||
|
environment: DEFAULT_ENV,
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
const allTokens = await apiTokenService.getAllTokens();
|
||||||
|
|
||||||
|
expect(allTokens.length).toBe(0);
|
||||||
|
});
|
||||||
|
1
src/test/fixtures/fake-api-token-store.ts
vendored
1
src/test/fixtures/fake-api-token-store.ts
vendored
@ -50,6 +50,7 @@ export default class FakeApiTokenStore
|
|||||||
async insert(newToken: IApiTokenCreate): Promise<IApiToken> {
|
async insert(newToken: IApiTokenCreate): Promise<IApiToken> {
|
||||||
const apiToken = {
|
const apiToken = {
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
project: newToken.projects?.join(',') || '*',
|
||||||
...newToken,
|
...newToken,
|
||||||
};
|
};
|
||||||
this.tokens.push(apiToken);
|
this.tokens.push(apiToken);
|
||||||
|
@ -148,13 +148,17 @@ export default class FakeFeatureStrategiesStore
|
|||||||
if (featureQuery.project) {
|
if (featureQuery.project) {
|
||||||
return (
|
return (
|
||||||
toggle.name.startsWith(featureQuery.namePrefix) &&
|
toggle.name.startsWith(featureQuery.namePrefix) &&
|
||||||
featureQuery.project.includes(toggle.project)
|
featureQuery.project.some((project) =>
|
||||||
|
project.includes(toggle.project),
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return toggle.name.startsWith(featureQuery.namePrefix);
|
return toggle.name.startsWith(featureQuery.namePrefix);
|
||||||
}
|
}
|
||||||
if (featureQuery.project) {
|
if (featureQuery.project) {
|
||||||
return featureQuery.project.includes(toggle.project);
|
return featureQuery.project.some((project) =>
|
||||||
|
project.includes(toggle.project),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return toggle.archived === archived;
|
return toggle.archived === archived;
|
||||||
});
|
});
|
||||||
|
@ -19,13 +19,17 @@ export default class FakeFeatureToggleClientStore
|
|||||||
if (featureQuery.project) {
|
if (featureQuery.project) {
|
||||||
return (
|
return (
|
||||||
toggle.name.startsWith(featureQuery.namePrefix) &&
|
toggle.name.startsWith(featureQuery.namePrefix) &&
|
||||||
featureQuery.project.includes(toggle.project)
|
featureQuery.project.some((project) =>
|
||||||
|
project.includes(toggle.project),
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return toggle.name.startsWith(featureQuery.namePrefix);
|
return toggle.name.startsWith(featureQuery.namePrefix);
|
||||||
}
|
}
|
||||||
if (featureQuery.project) {
|
if (featureQuery.project) {
|
||||||
return featureQuery.project.includes(toggle.project);
|
return featureQuery.project.some((project) =>
|
||||||
|
project.includes(toggle.project),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return toggle.archived === archived;
|
return toggle.archived === archived;
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user