1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-23 00:22:19 +01:00

feat: Implement multi token support for client tokens

This adds support for multi project tokens to be created. Backward compatibility is handled at 3 different layers here:

- The API is made backwards compatible though a permissive data type that accepts either a project?: string or projects?: string[] property, validation is done through JOI here, which ensures that projects and project are not set together. In the case of neither, this defaults to the previous default of ALL_PROJECTS
- The service layer method to handle adding tokens has been made tolerant to either of the above case and has been deprecated, a new method supporting only the new structure of using projects has been added
- Existing compatibility for consumers of Unleash as a library should not be affected either, the ApiUser constructor is now tolerant to the the first input and will internally map to the new cleaned structure
This commit is contained in:
sighphyre 2022-04-06 08:11:41 +02:00
parent f64d2cb768
commit e889d8e29c
15 changed files with 410 additions and 54 deletions

View File

@ -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);
@ -198,7 +202,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;

View File

@ -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) => {
toRow(newToken), const [row] = await tx<ITokenInsert>(TABLE).insert(
['created_at'], toRow(newToken),
); ['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.');
} }

View File

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

View 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();
});

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
@ -55,7 +55,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 {

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

View File

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

View File

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

View File

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

View File

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

View File

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