1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-19 17:52:45 +02:00

feat: incorporate backend as a valid api token type replacing client (#10500)

This PR deprecates `CLIENT` api token type in favor of `BACKEND` but
both will continue working.

Also replaces:
- `INIT_CLIENT_API_TOKENS` with `INIT_BACKEND_API_TOKENS`. The former is
kept for backward compatibility.
This commit is contained in:
Gastón Fournier 2025-08-21 05:43:54 -07:00 committed by GitHub
parent 02ee94c38f
commit 92480554dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 431 additions and 358 deletions

View File

@ -28,7 +28,7 @@ services:
LOG_LEVEL: "warn"
INIT_FRONTEND_API_TOKENS: "default:development.unleash-insecure-frontend-api-token"
# The default API token is insecure and should not be used in production.
INIT_CLIENT_API_TOKENS: "default:development.unleash-insecure-api-token"
INIT_BACKEND_API_TOKENS: "default:development.unleash-insecure-api-token"
depends_on:
db:
condition: service_healthy

View File

@ -28,7 +28,7 @@ services:
LOG_LEVEL: "warn"
INIT_FRONTEND_API_TOKENS: "default:development.unleash-insecure-frontend-api-token"
# The default API token is insecure and should not be used in production.
INIT_CLIENT_API_TOKENS: "default:development.unleash-insecure-api-token"
INIT_BACKEND_API_TOKENS: "default:development.unleash-insecure-api-token"
depends_on:
db:
condition: service_healthy

View File

@ -1,6 +1,12 @@
import { createConfig, resolveIsOss } from './create-config.js';
import { ApiTokenType } from './types/model.js';
beforeEach(() => {
delete process.env.INIT_BACKEND_API_TOKENS;
delete process.env.INIT_ADMIN_API_TOKENS;
delete process.env.INIT_CLIENT_API_TOKENS;
delete process.env.ENABLED_ENVIRONMENTS;
});
test('should create default config', async () => {
const config = createConfig({
db: {
@ -65,7 +71,7 @@ test('should add initApiToken for client token from options', async () => {
environment: 'development',
projects: ['default'],
secret: 'default:development.some-random-string',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
tokenName: 'admin',
};
const config = createConfig({
@ -92,7 +98,7 @@ test('should add initApiToken for client token from options', async () => {
token.projects,
);
expect(config.authentication.initApiTokens[0].type).toBe(
ApiTokenType.CLIENT,
ApiTokenType.BACKEND,
);
});
@ -123,8 +129,6 @@ test('should add initApiToken for admin token from env var', async () => {
expect(config.authentication.initApiTokens[1].secret).toBe(
'*:*.some-token2',
);
delete process.env.INIT_ADMIN_API_TOKENS;
});
test('should validate initApiToken for admin token from env var', async () => {
@ -133,23 +137,19 @@ test('should validate initApiToken for admin token from env var', async () => {
expect(() => createConfig({})).toThrow(
'Admin token cannot be scoped to single project',
);
delete process.env.INIT_ADMIN_API_TOKENS;
});
test('should validate initApiToken for client token from env var', async () => {
process.env.INIT_CLIENT_API_TOKENS = '*:*:some-token1';
process.env.INIT_BACKEND_API_TOKENS = '*:*:some-token1';
expect(() => createConfig({})).toThrow(
'Client token cannot be scoped to all environments',
);
delete process.env.INIT_CLIENT_API_TOKENS;
});
test('should merge initApiToken from options and env vars', async () => {
process.env.INIT_ADMIN_API_TOKENS = '*:*.some-token1, *:*.some-token2';
process.env.INIT_CLIENT_API_TOKENS = 'default:development.some-token1';
process.env.INIT_BACKEND_API_TOKENS = 'default:development.some-token1';
const token = {
environment: '*',
projects: ['*'],
@ -174,12 +174,12 @@ test('should merge initApiToken from options and env vars', async () => {
});
expect(config.authentication.initApiTokens).toHaveLength(4);
delete process.env.INIT_CLIENT_API_TOKENS;
delete process.env.INIT_ADMIN_API_TOKENS;
});
test('should add initApiToken for client token from env var', async () => {
process.env.INIT_CLIENT_API_TOKENS =
test.each([ApiTokenType.BACKEND, ApiTokenType.CLIENT])(
'should add initApiToken for %s token from env var',
async (tokenType) => {
process.env[`INIT_${tokenType.toUpperCase()}_API_TOKENS`] =
'default:development.some-token1, default:development.some-token2';
const config = createConfig({
@ -203,14 +203,13 @@ test('should add initApiToken for client token from env var', async () => {
'default',
]);
expect(config.authentication.initApiTokens[0].type).toBe(
ApiTokenType.CLIENT,
ApiTokenType.BACKEND,
);
expect(config.authentication.initApiTokens[0].secret).toBe(
'default:development.some-token1',
);
delete process.env.INIT_CLIENT_API_TOKENS;
});
},
);
test('should handle cases where no env var specified for tokens', async () => {
const token = {
@ -265,7 +264,6 @@ test('should load environment overrides from env var', async () => {
expect(config.environmentEnableOverrides).toHaveLength(2);
expect(config.environmentEnableOverrides).toContain('production');
delete process.env.ENABLED_ENVIRONMENTS;
});
test('should yield an empty list when no environment overrides are specified', async () => {
@ -510,7 +508,7 @@ test('Config with enterpriseVersion set and not pro environment should set isEnt
test('create config should be idempotent in terms of tokens', async () => {
// two admin tokens
process.env.INIT_ADMIN_API_TOKENS = '*:*.some-token1, *:*.some-token2';
process.env.INIT_CLIENT_API_TOKENS = 'default:development.some-token1';
process.env.INIT_BACKEND_API_TOKENS = 'default:development.some-token1';
process.env.INIT_FRONTEND_API_TOKENS = 'frontend:development.some-token1';
const token = {
environment: '*',
@ -538,9 +536,6 @@ test('create config should be idempotent in terms of tokens', async () => {
createConfig(config).authentication.initApiTokens.length,
);
expect(config.authentication.initApiTokens).toHaveLength(5);
delete process.env.INIT_ADMIN_API_TOKENS;
delete process.env.INIT_CLIENT_API_TOKENS;
delete process.env.INIT_FRONTEND_API_TOKENS;
});
describe('isOSS', () => {

View File

@ -436,8 +436,10 @@ const loadInitApiTokens = () => {
ApiTokenType.ADMIN,
),
...loadTokensFromString(
process.env.INIT_BACKEND_API_TOKENS ??
// INIT_CLIENT_API_TOKENS is deprecated in favor of INIT_BACKEND_API_TOKENS
process.env.INIT_CLIENT_API_TOKENS,
ApiTokenType.CLIENT,
ApiTokenType.BACKEND,
),
...loadTokensFromString(
process.env.INIT_FRONTEND_API_TOKENS,

View File

@ -284,7 +284,8 @@ export class ApiTokenStore implements IApiTokenStore {
.andWhere('tokens.secret', 'LIKE', '%:%') // Exclude legacy tokens
.andWhere((builder) => {
builder
.where('tokens.type', ApiTokenType.CLIENT)
.where('tokens.type', ApiTokenType.BACKEND)
.orWhere('tokens.type', ApiTokenType.CLIENT)
.orWhere('tokens.type', ApiTokenType.FRONTEND);
});

View File

@ -513,6 +513,13 @@ describe('bulk metrics', () => {
environment: 'development',
projects: ['*'],
});
const backendToken =
await authed.services.apiTokenService.createApiTokenWithProjects({
tokenName: 'backend-bulk-metrics-test',
type: ApiTokenType.BACKEND,
environment: 'development',
projects: ['*'],
});
const frontendToken =
await authed.services.apiTokenService.createApiTokenWithProjects({
tokenName: 'frontend-bulk-metrics-test',
@ -530,6 +537,11 @@ describe('bulk metrics', () => {
.set('Authorization', frontendToken.secret)
.send({ applications: [], metrics: [] })
.expect(403);
await authed.request
.post('/api/client/metrics/bulk')
.set('Authorization', backendToken.secret)
.send({ applications: [], metrics: [] })
.expect(202);
await authed.request
.post('/api/client/metrics/bulk')
.set('Authorization', clientToken.secret)

View File

@ -90,7 +90,7 @@ test('should add user if known token', async () => {
permissions: [CLIENT],
project: ALL,
environment: ALL,
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
secret: 'a',
});
const apiTokenService = {
@ -114,7 +114,9 @@ test('should add user if known token', async () => {
expect(req.user).toBe(apiUser);
});
test('should not add user if not /api/client', async () => {
test.each([ApiTokenType.CLIENT, ApiTokenType.BACKEND])(
'should not add user if not /api/client with token type %s',
async (type) => {
expect.assertions(5);
const apiUser = new ApiUser({
@ -122,7 +124,7 @@ test('should not add user if not /api/client', async () => {
permissions: [CLIENT],
project: ALL,
environment: ALL,
type: ApiTokenType.CLIENT,
type,
secret: 'a',
});
@ -153,7 +155,8 @@ test('should not add user if not /api/client', async () => {
expect(cb).not.toHaveBeenCalled();
expect(req.header).toHaveBeenCalled();
expect(req.user).toBeUndefined();
});
},
);
test('should not add user if disabled', async () => {
const apiUser = new ApiUser({
@ -161,7 +164,7 @@ test('should not add user if disabled', async () => {
permissions: [CLIENT],
project: ALL,
environment: ALL,
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
secret: 'a',
});
const apiTokenService = {
@ -252,7 +255,7 @@ test('should add user if client token and /edge/metrics', async () => {
permissions: [CLIENT],
project: ALL,
environment: ALL,
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
secret: 'a',
});
const apiTokenService = {

View File

@ -57,11 +57,12 @@ const apiAccessMiddleware = (
const apiUser = apiToken
? await apiTokenService.getUserForToken(apiToken)
: undefined;
const { CLIENT, FRONTEND } = ApiTokenType;
const { CLIENT, BACKEND, FRONTEND } = ApiTokenType;
if (apiUser) {
if (
(apiUser.type === CLIENT &&
((apiUser.type === CLIENT ||
apiUser.type === BACKEND) &&
!isClientApi(req) &&
!isEdgeMetricsApi(req)) ||
(apiUser.type === FRONTEND && !isProxyApi(req))

View File

@ -134,7 +134,9 @@ describe('ADMIN tokens should have user id -1337 when only passed through rbac-m
});
});
test('should not give api-user ADMIN permission', async () => {
test.each([ApiTokenType.BACKEND, ApiTokenType.CLIENT, ApiTokenType.FRONTEND])(
'should not give api-user ADMIN permission to token %s',
async (tokenType) => {
const accessService = {
hasPermission: vi.fn(),
} as PermissionChecker;
@ -148,11 +150,11 @@ test('should not give api-user ADMIN permission', async () => {
const cb = vi.fn();
const req: any = {
user: new ApiUser({
tokenName: 'api',
tokenName: `api_${tokenType}`,
permissions: [perms.CLIENT],
project: '*',
environment: '*',
type: ApiTokenType.CLIENT,
type: tokenType,
secret: 'a',
}),
};
@ -163,7 +165,8 @@ test('should not give api-user ADMIN permission', async () => {
expect(hasAccess).toBe(false);
expect(accessService.hasPermission).toHaveBeenCalledTimes(0);
});
},
);
test('should not allow user to miss userId', async () => {
vi.spyOn(global.console, 'error').mockImplementation(() => vi.fn());

View File

@ -14,13 +14,16 @@ const defaultData: ApiTokenSchema = {
project: '',
};
test('apiTokenSchema', () => {
const data: ApiTokenSchema = { ...defaultData };
test.each([ApiTokenType.CLIENT, ApiTokenType.BACKEND, ApiTokenType.FRONTEND])(
'apiTokenSchema %s',
(tokenType) => {
const data: ApiTokenSchema = { ...defaultData, type: tokenType };
expect(
validateSchema('#/components/schemas/apiTokenSchema', data),
).toBeUndefined();
});
},
);
test('apiTokenSchema empty', () => {
expect(

View File

@ -20,8 +20,8 @@ const clientFrontendSchema = {
type: {
type: 'string',
pattern:
'^([Cc][Ll][Ii][Ee][Nn][Tt]|[Ff][Rr][Oo][Nn][Tt][Ee][Nn][Dd])$',
description: `A client or frontend token. Must be one of the strings "client" or "frontend" (not case sensitive).`,
'^([Cc][Ll][Ii][Ee][Nn][Tt]|[Bb][Aa][Cc][Kk][Ee][Nn][Dd]|[Ff][Rr][Oo][Nn][Tt][Ee][Nn][Dd])$',
description: `A client or frontend token. Must be one of the strings "client" (deprecated), "backend" (preferred over "client") or "frontend" (not case sensitive).`,
example: 'frontend',
},
environment: {

View File

@ -10,8 +10,8 @@ export const createProjectApiTokenSchema = {
type: {
type: 'string',
pattern:
'^([Cc][Ll][Ii][Ee][Nn][Tt]|[Ff][Rr][Oo][Nn][Tt][Ee][Nn][Dd])$',
description: `A client or frontend token. Must be one of the strings "client" or "frontend" (not case sensitive).`,
'^([Cc][Ll][Ii][Ee][Nn][Tt]|[Bb][Aa][Cc][Kk][Ee][Nn][Dd]|[Ff][Rr][Oo][Nn][Tt][Ee][Nn][Dd])$',
description: `A client or frontend token. Must be one of the strings "client" (deprecated), "backend" (preferred over "client") or "frontend" (not case sensitive).`,
example: 'frontend',
},
environment: {

View File

@ -57,6 +57,7 @@ export const tokenTypeToCreatePermission: (tokenType: ApiTokenType) => string =
case ApiTokenType.ADMIN:
return ADMIN;
case ApiTokenType.CLIENT:
case ApiTokenType.BACKEND:
return CREATE_CLIENT_API_TOKEN;
case ApiTokenType.FRONTEND:
return CREATE_FRONTEND_API_TOKEN;
@ -82,7 +83,7 @@ const permissionToTokenType: (permission: string) => ApiTokenType | undefined =
UPDATE_CLIENT_API_TOKEN,
].includes(permission)
) {
return ApiTokenType.CLIENT;
return ApiTokenType.BACKEND;
} else if (ADMIN === permission) {
return ApiTokenType.ADMIN;
} else {
@ -97,6 +98,7 @@ const tokenTypeToUpdatePermission: (tokenType: ApiTokenType) => string = (
case ApiTokenType.ADMIN:
return ADMIN;
case ApiTokenType.CLIENT:
case ApiTokenType.BACKEND:
return UPDATE_CLIENT_API_TOKEN;
case ApiTokenType.FRONTEND:
return UPDATE_FRONTEND_API_TOKEN;
@ -110,6 +112,7 @@ const tokenTypeToDeletePermission: (tokenType: ApiTokenType) => string = (
case ApiTokenType.ADMIN:
return ADMIN;
case ApiTokenType.CLIENT:
case ApiTokenType.BACKEND:
return DELETE_CLIENT_API_TOKEN;
case ApiTokenType.FRONTEND:
return DELETE_FRONTEND_API_TOKEN;

View File

@ -10,7 +10,11 @@ export const createApiToken = joi
.string()
.lowercase()
.required()
.valid(ApiTokenType.CLIENT, ApiTokenType.FRONTEND),
.valid(
ApiTokenType.CLIENT,
ApiTokenType.BACKEND,
ApiTokenType.FRONTEND,
),
expiresAt: joi.date().optional(),
projects: joi.array().min(1).optional().default([ALL]),
environment: joi.string().optional().default('development'),

View File

@ -10,10 +10,20 @@ export const createProjectApiToken = joi
.string()
.lowercase()
.required()
.valid(ApiTokenType.CLIENT, ApiTokenType.FRONTEND),
.valid(
ApiTokenType.CLIENT,
ApiTokenType.BACKEND,
ApiTokenType.FRONTEND,
),
expiresAt: joi.date().optional(),
environment: joi.when('type', {
is: joi.string().valid(ApiTokenType.CLIENT, ApiTokenType.FRONTEND),
is: joi
.string()
.valid(
ApiTokenType.CLIENT,
ApiTokenType.BACKEND,
ApiTokenType.FRONTEND,
),
then: joi.string().optional().default(DEFAULT_ENV),
}),
})

View File

@ -45,7 +45,12 @@ test('Should allow you to create tokens up to and including the limit', async ()
}
});
test.each([ApiTokenType.ADMIN, ApiTokenType.CLIENT, ApiTokenType.FRONTEND])(
test.each([
ApiTokenType.ADMIN,
ApiTokenType.CLIENT,
ApiTokenType.BACKEND,
ApiTokenType.FRONTEND,
])(
"Should prevent you from creating %s tokens when you're already at the limit",
async (tokenType) => {
const limit = 1;
@ -58,7 +63,7 @@ test.each([ApiTokenType.ADMIN, ApiTokenType.CLIENT, ApiTokenType.FRONTEND])(
await service.createApiTokenWithProjects(
{
tokenName: 'token-1',
tokenName: `token-1-${tokenType}`,
type: ApiTokenType.CLIENT,
environment: 'production',
projects: ['*'],

View File

@ -5,9 +5,9 @@ import type { IUnleashStores } from '../types/stores.js';
import type { IUnleashConfig } from '../types/option.js';
import ApiUser, { type IApiUser } from '../types/api-user.js';
import {
ALL,
resolveValidProjects,
validateApiToken,
validateApiTokenEnvironment,
} from '../types/models/api-token.js';
import type { IApiTokenStore } from '../types/stores/api-token-store.js';
import { FOREIGN_KEY_VIOLATION } from '../error/db-error.js';
@ -40,7 +40,10 @@ const resolveTokenPermissions = (tokenType: string) => {
return [ADMIN];
}
if (tokenType === ApiTokenType.CLIENT) {
if (
tokenType === ApiTokenType.BACKEND ||
tokenType === ApiTokenType.CLIENT
) {
return [CLIENT];
}
@ -295,9 +298,7 @@ export class ApiTokenService {
auditUser: IAuditUser,
): Promise<IApiToken> {
validateApiToken(newToken);
const environments = await this.environmentStore.getAll();
validateApiTokenEnvironment(newToken, environments);
await this.validateApiTokenEnvironment(newToken);
await this.validateApiTokenLimit();
const secret = this.generateSecretKey(newToken);
@ -305,6 +306,19 @@ export class ApiTokenService {
return this.insertNewApiToken(createNewToken, auditUser);
}
private async validateApiTokenEnvironment({
environment,
}: Pick<IApiTokenCreate, 'environment'>): Promise<void> {
if (environment === ALL) {
return;
}
const exists = await this.environmentStore.exists(environment);
if (!exists) {
throw new BadDataError(`Environment=${environment} does not exist`);
}
}
private async validateApiTokenLimit() {
const currentTokenCount = await this.store.count();
const limit = this.resourceLimits.apiTokens;

View File

@ -197,9 +197,11 @@ export interface IFeatureDependency {
export type IStrategyVariant = Omit<IVariant, 'overrides'>;
export enum ApiTokenType {
/** @deprecated: Use BACKEND instead */
CLIENT = 'client',
ADMIN = 'admin',
FRONTEND = 'frontend',
BACKEND = 'backend',
}
export interface IApiTokenCreate {

View File

@ -1,5 +1,5 @@
import BadDataError from '../../error/bad-data-error.js';
import type { IApiTokenCreate, IEnvironment } from '../model.js';
import type { IApiTokenCreate } from '../model.js';
import { ApiTokenType } from '../model.js';
export const ALL = '*';
@ -33,7 +33,10 @@ export const validateApiToken = ({
);
}
if (type === ApiTokenType.CLIENT && environment === ALL) {
if (
(type === ApiTokenType.BACKEND || type === ApiTokenType.CLIENT) &&
environment === ALL
) {
throw new BadDataError(
'Client token cannot be scoped to all environments',
);
@ -45,20 +48,3 @@ export const validateApiToken = ({
);
}
};
export const validateApiTokenEnvironment = (
{ environment }: Pick<IApiTokenCreate, 'environment'>,
environments: IEnvironment[],
): void => {
if (environment === ALL) {
return;
}
const selectedEnvironment = environments.find(
(env) => env.name === environment,
);
if (!selectedEnvironment) {
throw new BadDataError(`Environment=${environment} does not exist`);
}
};

View File

@ -1,6 +1,6 @@
// Special
export const ADMIN = 'ADMIN';
export const CLIENT = 'CLIENT';
export const CLIENT = 'CLIENT'; // TODO data migration needed to change to BACKEND
export const FRONTEND = 'FRONTEND';
export const NONE = 'NONE';

View File

@ -40,16 +40,6 @@ afterEach(async () => {
await stores.apiTokenStore.deleteAll();
});
const getLastEvent = async () => {
const events = await db.stores.eventStore.getEvents();
return events.reduce((last, current) => {
if (current.id > last.id) {
return current;
}
return last;
});
};
test('editor users should only get client or frontend tokens', async () => {
expect.assertions(3);
@ -77,7 +67,7 @@ test('editor users should only get client or frontend tokens', async () => {
projects: [],
tokenName: 'test',
secret: '*:environment.1234',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
});
await stores.apiTokenStore.insert({
@ -102,7 +92,7 @@ test('editor users should only get client or frontend tokens', async () => {
.expect(200)
.expect((res) => {
expect(res.body.tokens.length).toBe(2);
expect(res.body.tokens[0].type).toBe(ApiTokenType.CLIENT);
expect(res.body.tokens[0].type).toBe(ApiTokenType.BACKEND);
expect(res.body.tokens[1].type).toBe(ApiTokenType.FRONTEND);
});
@ -136,7 +126,7 @@ test('viewer users should not be allowed to fetch tokens', async () => {
projects: [],
tokenName: 'test',
secret: '*:environment.1234',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
});
await stores.apiTokenStore.insert({
@ -155,8 +145,10 @@ test('viewer users should not be allowed to fetch tokens', async () => {
await destroy();
});
test('A role with only CREATE_PROJECT_API_TOKEN can create project tokens', async () => {
expect.assertions(0);
test.each(['client', 'backend'])(
'A role with only CREATE_PROJECT_API_TOKEN can create project %s token',
async (type) => {
expect.assertions(1);
const preHook = (
app,
@ -172,15 +164,15 @@ test('A role with only CREATE_PROJECT_API_TOKEN can create project tokens', asyn
))!;
const user = await userService.createUser(
{
email: 'powerpuffgirls_viewer@example.com',
email: `powerpuffgirls_viewer_${type}@example.com`,
rootRole: role.id,
},
SYSTEM_USER_AUDIT,
);
const createClientApiTokenRole = await accessService.createRole(
{
name: 'project_client_token_creator',
description: 'Can create client tokens',
name: `project_client_${type}_token_creator`,
description: `Can create ${type} tokens`,
permissions: [{ name: CREATE_PROJECT_API_TOKEN }],
type: 'root-custom',
createdByUserId: SYSTEM_USER_ID,
@ -204,17 +196,19 @@ test('A role with only CREATE_PROJECT_API_TOKEN can create project tokens', asyn
db.rawDatabase,
);
await request
const { body, status } = await request
.post('/api/admin/projects/default/api-tokens')
.send({
tokenName: 'client-token-maker',
type: 'client',
tokenName: `${type}-token-maker`,
type,
projects: ['default'],
})
.set('Content-Type', 'application/json')
.expect(201);
.set('Content-Type', 'application/json');
console.log(`Response: ${JSON.stringify(body)}`);
expect(status).toBe(201);
await destroy();
});
},
);
describe('Fine grained API token permissions', () => {
describe('A role with access to CREATE_CLIENT_API_TOKEN', () => {
@ -468,7 +462,7 @@ describe('Fine grained API token permissions', () => {
projects: [],
tokenName: 'client',
secret: '*:environment.client_secret_1234',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
});
await stores.apiTokenStore.insert({
@ -491,7 +485,7 @@ describe('Fine grained API token permissions', () => {
.expect(200)
.expect((res) => {
expect(res.body.tokens).toHaveLength(1);
expect(res.body.tokens[0].type).toBe(ApiTokenType.CLIENT);
expect(res.body.tokens[0].type).toBe(ApiTokenType.BACKEND);
});
await destroy();
});
@ -527,7 +521,7 @@ describe('Fine grained API token permissions', () => {
projects: [],
tokenName: 'client',
secret: '*:environment.client_secret_4321',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
});
await stores.apiTokenStore.insert({
@ -585,7 +579,7 @@ describe('Fine grained API token permissions', () => {
projects: [],
tokenName: 'client',
secret: '*:environment.client_secret_4321',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
});
await stores.apiTokenStore.insert({
environment: '',

View File

@ -48,18 +48,18 @@ test('returns empty list of tokens', async () => {
});
});
test('creates new client token', async () => {
return app.request
test.each(['client', 'backend'])('creates new %s token', async (type) => {
await app.request
.post('/api/admin/api-tokens')
.send({
tokenName: 'default-client',
type: 'client',
type,
})
.set('Content-Type', 'application/json')
.expect(201)
.expect((res) => {
expect(res.body.tokenName).toBe('default-client');
expect(res.body.type).toBe('client');
expect(res.body.type).toBe(type);
expect(res.body.createdAt).toBeTruthy();
expect(res.body.secret.length > 16).toBe(true);
});
@ -72,7 +72,7 @@ test('update client token with expiry', async () => {
projects: ['*'],
tokenName: 'test_token',
secret: tokenSecret,
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
environment: 'development',
});
@ -94,7 +94,9 @@ test('update client token with expiry', async () => {
});
});
test('creates a lot of client tokens', async () => {
test.each(['client', 'backend'])(
'creates a lot of backend tokens from type %s',
async (type) => {
const requests: any[] = [];
for (let i = 0; i < 10; i++) {
@ -103,7 +105,7 @@ test('creates a lot of client tokens', async () => {
.post('/api/admin/api-tokens')
.send({
tokenName: 'default-client',
type: 'client',
type,
})
.set('Content-Type', 'application/json')
.expect(201),
@ -117,7 +119,7 @@ test('creates a lot of client tokens', async () => {
.expect(200)
.expect((res) => {
expect(res.body.tokens.length).toBe(10);
expect(res.body.tokens[2].type).toBe('client');
expect(res.body.tokens[2].type).toBe(type);
});
await app.request
.get('/api/admin/api-tokens/default-client')
@ -125,9 +127,10 @@ test('creates a lot of client tokens', async () => {
.expect(200)
.expect((res) => {
expect(res.body.tokens.length).toBe(10);
expect(res.body.tokens[2].type).toBe('client');
});
expect(res.body.tokens[2].type).toBe(type);
});
},
);
test('removes api token', async () => {
const tokenSecret = '*:environment.random-secret';
@ -137,7 +140,7 @@ test('removes api token', async () => {
projects: ['*'],
tokenName: 'testtoken',
secret: tokenSecret,
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
});
await app.request
@ -154,41 +157,47 @@ test('removes api token', async () => {
});
});
test('creates new client token: project & environment defaults to "*"', async () => {
return app.request
test.each(['client', 'backend'])(
'creates new %s token: project & environment defaults to "*"',
async (type) => {
await app.request
.post('/api/admin/api-tokens')
.send({
tokenName: 'default-client',
type: 'client',
type,
})
.set('Content-Type', 'application/json')
.expect(201)
.expect((res) => {
expect(res.body.type).toBe('client');
expect(res.body.type).toBe(type);
expect(res.body.secret.length > 16).toBe(true);
expect(res.body.environment).toBe(DEFAULT_ENV);
expect(res.body.projects[0]).toBe(ALL);
});
});
},
);
test('creates new client token with project & environment set', async () => {
return app.request
test.each(['client', 'backend'])(
'creates new %s token with project & environment set',
async (type) => {
await app.request
.post('/api/admin/api-tokens')
.send({
tokenName: 'default-client',
type: 'client',
type,
projects: ['default'],
environment: DEFAULT_ENV,
})
.set('Content-Type', 'application/json')
.expect(201)
.expect((res) => {
expect(res.body.type).toBe('client');
expect(res.body.type).toBe(type);
expect(res.body.secret.length > 16).toBe(true);
expect(res.body.environment).toBe(DEFAULT_ENV);
expect(res.body.projects[0]).toBe('default');
});
});
},
);
test('should prefix default token with "*:*."', async () => {
return app.request
@ -324,3 +333,26 @@ test('Deleting non-existing token should yield 200', async () => {
.delete('/api/admin/api-tokens/random-non-existing-token')
.expect(200);
});
test('having an existing client token in the db of type client still works', async () => {
await app.services.apiTokenService.createApiTokenWithProjects({
tokenName: 'default-client',
type: ApiTokenType.CLIENT,
environment: DEFAULT_ENV,
projects: ['*'],
});
const { body } = await app.request
.get('/api/admin/api-tokens')
.expect('Content-Type', /json/)
.expect(200);
const { tokens } = body;
expect(tokens.length).toBe(1);
expect(tokens[0]).toMatchObject({
tokenName: 'default-client',
type: ApiTokenType.CLIENT,
environment: DEFAULT_ENV,
projects: ['*'],
});
});

View File

@ -63,7 +63,7 @@ beforeAll(async () => {
defaultToken =
await app.services.apiTokenService.createApiTokenWithProjects({
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
projects: ['default'],
environment: DEFAULT_ENV,
tokenName: 'tester',

View File

@ -77,7 +77,7 @@ test('api tokens are serialized correctly', async () => {
});
await app.services.apiTokenService.createApiTokenWithProjects({
tokenName: 'client',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
environment: DEFAULT_ENV,
projects: ['*'],
});
@ -88,7 +88,7 @@ test('api tokens are serialized correctly', async () => {
.expect(200);
expect(body).toMatchObject({
apiTokens: { client: 1, admin: 1, frontend: 1 },
apiTokens: { backend: 1, admin: 1, frontend: 1 },
});
const { text: csv } = await app.request
@ -96,7 +96,7 @@ test('api tokens are serialized correctly', async () => {
.expect('Content-Type', /text\/csv/)
.expect(200);
expect(csv).toMatch(/{""client"":1,""admin"":1,""frontend"":1}/);
expect(csv).toMatch(/{""admin"":1,""frontend"":1,""backend"":1}/);
});
test('should return instance statistics with correct number of projects', async () => {

View File

@ -153,7 +153,7 @@ test('should save multiple projects from token', async () => {
const multiProjectToken =
await app.services.apiTokenService.createApiTokenWithProjects({
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
projects: ['default', 'mainProject'],
environment: DEFAULT_ENV,
tokenName: 'tester',

View File

@ -50,7 +50,7 @@ test('Returns list of tokens', async () => {
await db.stores.apiTokenStore.insert({
tokenName: 'test',
secret: tokenSecret,
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
environment: DEFAULT_ENV,
projects: ['default'],
});
@ -87,21 +87,23 @@ test('fails to create new client token when given wrong project', async () => {
.expect(404);
});
test('creates new client token', async () => {
return app.request
test.each(['client', 'frontend', 'backend'])(
'creates new %s token',
async (type) => {
const { body, status } = await app.request
.post('/api/admin/projects/default/api-tokens')
.send({
tokenName: 'default-client',
type: 'client',
tokenName: `default-${type}`,
type,
projects: ['default'],
environment: DEFAULT_ENV,
})
.set('Content-Type', 'application/json')
.expect(201)
.expect((res) => {
expect(res.body.tokenName).toBe('default-client');
});
});
.set('Content-Type', 'application/json');
console.log(body);
expect(status).toBe(201);
expect(body.tokenName).toBe(`default-${type}`);
},
);
test('Deletes existing tokens', async () => {
const tokenSecret = 'random-secret';
@ -109,7 +111,7 @@ test('Deletes existing tokens', async () => {
await db.stores.apiTokenStore.insert({
tokenName: 'test',
secret: tokenSecret,
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
environment: DEFAULT_ENV,
projects: ['default'],
});
@ -142,7 +144,7 @@ test('Returns Bad Request when deleting tokens with more than one project', asyn
await db.stores.apiTokenStore.insert({
tokenName: 'test',
secret: tokenSecret,
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
environment: DEFAULT_ENV,
projects: ['default', 'other'],
});

View File

@ -67,7 +67,7 @@ test('Access with API token is granted', async () => {
environment: DEFAULT_ENV,
projects: ['default'],
tokenName: 'test',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
},
);
await app.request

View File

@ -61,7 +61,7 @@ beforeAll(async () => {
const token = await app.services.apiTokenService.createApiTokenWithProjects(
{
tokenName: 'test',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
environment: DEFAULT_ENV,
projects: ['default'],
},

View File

@ -126,7 +126,7 @@ afterAll(async () => {
test('returns feature flag with "default" config', async () => {
const token = await apiTokenService.createApiTokenWithProjects({
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
tokenName,
environment: DEFAULT_ENV,
projects: [project],
@ -148,7 +148,7 @@ test('returns feature flag with "default" config', async () => {
test('returns feature flag with testing environment config', async () => {
const token = await apiTokenService.createApiTokenWithProjects({
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
tokenName: tokenName,
environment,
projects: [project],
@ -174,7 +174,7 @@ test('returns feature flag with testing environment config', async () => {
test('returns feature flag for project2', async () => {
const token = await apiTokenService.createApiTokenWithProjects({
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
tokenName: tokenName,
environment,
projects: [project2],
@ -194,7 +194,7 @@ test('returns feature flag for project2', async () => {
test('returns feature flag for all projects', async () => {
const token = await apiTokenService.createApiTokenWithProjects({
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
tokenName: tokenName,
environment,
projects: ['*'],

View File

@ -134,7 +134,7 @@ afterAll(async () => {
test('doesnt return feature flags if project deleted', async () => {
const token = await apiTokenService.createApiTokenWithProjects({
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
tokenName: deletionTokenName,
environment,
projects: [deletionProject],

View File

@ -32,7 +32,7 @@ test('should enrich metrics with environment from api-token', async () => {
});
const token = await apiTokenService.createApiTokenWithProjects({
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
tokenName: 'test',
environment: 'some',
projects: ['*'],

View File

@ -20,7 +20,7 @@ beforeAll(async () => {
app = await setupAppWithAuth(db.stores, {}, db.rawDatabase);
defaultToken =
await app.services.apiTokenService.createApiTokenWithProjects({
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
projects: ['default'],
environment: DEFAULT_ENV,
tokenName: 'tester',
@ -75,7 +75,7 @@ test('should pick up environment from token', async () => {
await db.stores.environmentStore.create({ name: 'test', type: 'test' });
const token = await app.services.apiTokenService.createApiTokenWithProjects(
{
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
projects: ['default'],
environment,
tokenName: 'tester',
@ -132,7 +132,7 @@ test('should set lastSeen for toggles with metrics both for toggle and toggle en
const token = await app.services.apiTokenService.createApiTokenWithProjects(
{
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
projects: ['default'],
environment: DEFAULT_ENV,
tokenName: 'tester',

View File

@ -70,7 +70,7 @@ test('should have empty list of tokens', async () => {
test('should create client token', async () => {
const token = await apiTokenService.createApiTokenWithProjects({
tokenName: 'default-client',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
projects: ['*'],
environment: DEFAULT_ENV,
});
@ -78,7 +78,7 @@ test('should create client token', async () => {
expect(allTokens.length).toBe(1);
expect(token.secret.length > 32).toBe(true);
expect(token.type).toBe(ApiTokenType.CLIENT);
expect(token.type).toBe(ApiTokenType.BACKEND);
expect(token.tokenName).toBe('default-client');
expect(allTokens[0].secret).toBe(token.secret);
});
@ -99,7 +99,7 @@ test('should set expiry of token', async () => {
const time = new Date('2022-01-01');
await apiTokenService.createApiTokenWithProjects({
tokenName: 'default-client',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
expiresAt: time,
projects: ['*'],
environment: DEFAULT_ENV,
@ -117,7 +117,7 @@ test('should update expiry of token', async () => {
const token = await apiTokenService.createApiTokenWithProjects(
{
tokenName: 'default-client',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
expiresAt: time,
projects: ['*'],
environment: DEFAULT_ENV,
@ -135,7 +135,7 @@ test('should update expiry of token', async () => {
test('should create client token with project list', async () => {
const token = await apiTokenService.createApiTokenWithProjects({
tokenName: 'default-client',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
projects: ['default', 'test-project'],
environment: DEFAULT_ENV,
});
@ -147,7 +147,7 @@ test('should create client token with project list', async () => {
test('should strip all other projects if ALL_PROJECTS is present', async () => {
const token = await apiTokenService.createApiTokenWithProjects({
tokenName: 'default-client',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
projects: ['*', 'default'],
environment: DEFAULT_ENV,
});
@ -162,7 +162,7 @@ test('should return user with multiple projects', async () => {
const { secret: secret1 } =
await apiTokenService.createApiTokenWithProjects({
tokenName: 'default-valid',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
expiresAt: tomorrow,
projects: ['test-project', 'default'],
environment: DEFAULT_ENV,
@ -171,7 +171,7 @@ test('should return user with multiple projects', async () => {
const { secret: secret2 } =
await apiTokenService.createApiTokenWithProjects({
tokenName: 'default-also-valid',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
expiresAt: tomorrow,
projects: ['test-project'],
environment: DEFAULT_ENV,
@ -191,7 +191,7 @@ test('should not partially create token if projects are invalid', async () => {
try {
await apiTokenService.createApiTokenWithProjects({
tokenName: 'default-client',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
projects: ['non-existent-project'],
environment: DEFAULT_ENV,
});

View File

@ -69,7 +69,7 @@ test('should only return valid tokens', async () => {
const expiredToken = await stores.apiTokenStore.insert({
tokenName: 'expired',
secret: '*:environment.expired-secret',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
expiresAt: yesterday,
projects: ['*'],
environment: DEFAULT_ENV,
@ -78,7 +78,7 @@ test('should only return valid tokens', async () => {
const activeToken = await stores.apiTokenStore.insert({
tokenName: 'default-valid',
secret: '*:environment.valid-secret',
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
expiresAt: tomorrow,
projects: ['*'],
environment: DEFAULT_ENV,

View File

@ -57,14 +57,14 @@ describe('count deprecated tokens', () => {
await stores.apiTokenStore.insert({
secret: 'default:development.be44368985f7fb3237c584ef86f3d6bdada42ddbd63a019d26955178',
environment: DEFAULT_ENV,
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
projects: ['default'],
tokenName: 'client-token',
});
await stores.apiTokenStore.insert({
secret: '*:development.be44368985f7fb3237c584ef86f3d6bdada42ddbd63a019d26955178',
environment: DEFAULT_ENV,
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
projects: [],
tokenName: 'client-wildcard-token',
});
@ -111,7 +111,7 @@ describe('count deprecated tokens', () => {
await stores.apiTokenStore.insert({
secret: 'deleted-project:development.be44368985f7fb3237c584ef86f3d6bdada42ddbd63a019d26955178',
environment: DEFAULT_ENV,
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
projects: [],
tokenName: 'admin-test-token',
});
@ -131,7 +131,7 @@ describe('count deprecated tokens', () => {
await stores.apiTokenStore.insert({
secret: '*:*.be44368985f7fb3237c584ef86f3d6bdada42ddbd63a019d26955178',
environment: DEFAULT_ENV,
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
projects: [],
tokenName: 'client-test-token',
});
@ -195,14 +195,14 @@ describe('count project tokens', () => {
await store.insert({
secret: `default:default.${randomId()}`,
environment: DEFAULT_ENV,
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
projects: ['default'],
tokenName: 'token1',
});
await store.insert({
secret: `*:*.${randomId()}`,
environment: DEFAULT_ENV,
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
projects: ['*'],
tokenName: 'token2',
});
@ -210,7 +210,7 @@ describe('count project tokens', () => {
await store.insert({
secret: `${project.id}:default.${randomId()}`,
environment: DEFAULT_ENV,
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
projects: [project.id],
tokenName: 'token3',
});
@ -218,7 +218,7 @@ describe('count project tokens', () => {
await store.insert({
secret: `[]:default.${randomId()}`,
environment: DEFAULT_ENV,
type: ApiTokenType.CLIENT,
type: ApiTokenType.BACKEND,
projects: [project.id, 'default'],
tokenName: 'token4',
});

View File

@ -185,7 +185,8 @@ If emails fail to send or contain errors:
| :--------------------------------- | :-------------- | :--------------------------------------------------------------------------------------------------------- |
| `UNLEASH_DEFAULT_ADMIN_USERNAME` | `admin` | Sets the username for the initial admin user created on first startup. |
| `UNLEASH_DEFAULT_ADMIN_PASSWORD` | `unleash4all` | Sets the password for the initial admin user. **Change this for any non-local setup.** |
| `INIT_CLIENT_API_TOKENS` | N/A | Comma-separated list of [Client tokens](/reference/api-tokens-and-client-keys#backend-tokens) to create on first startup (if no tokens exist in the database). |
| `INIT_CLIENT_API_TOKENS` | N/A | Deprecated, use `INIT_BACKEND_API_TOKENS` instead. |
| `INIT_BACKEND_API_TOKENS` | N/A | Comma-separated list of [Backend tokens](/reference/api-tokens-and-client-keys#backend-tokens) to create on first startup (if no tokens exist in the database). |
| `INIT_FRONTEND_API_TOKENS` | N/A | Comma-separated list of [Frontend tokens](/reference/api-tokens-and-client-keys#frontend-tokens) to create on first startup (if no tokens exist in the database). |
### Server behavior