diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 00174f643b..ca585e2f37 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -6,6 +6,7 @@ Object { "createAdminUser": true, "customAuthHandler": [Function], "enableApiToken": true, + "initApiTokens": Array [], "type": "open-source", }, "db": Object { diff --git a/src/lib/create-config.test.ts b/src/lib/create-config.test.ts index 688cf9439d..afb764e706 100644 --- a/src/lib/create-config.test.ts +++ b/src/lib/create-config.test.ts @@ -1,5 +1,5 @@ -// @ts-nocheck import { createConfig } from './create-config'; +import { ApiTokenType } from './types/models/api-token'; test('should create default config', async () => { const config = createConfig({ @@ -17,3 +17,105 @@ test('should create default config', async () => { expect(config).toMatchSnapshot(); }); + +test('should add initApiToken from options', async () => { + const token = { + environment: '*', + project: '*', + secret: '*:*:some-random-string', + type: ApiTokenType.ADMIN, + username: 'admin', + }; + const config = createConfig({ + db: { + host: 'localhost', + port: 4242, + user: 'unleash', + password: 'password', + database: 'unleash_db', + }, + server: { + port: 4242, + }, + authentication: { + initApiTokens: [token], + }, + }); + + expect(config.authentication.initApiTokens).toHaveLength(1); + expect(config.authentication.initApiTokens[0].environment).toBe( + token.environment, + ); + expect(config.authentication.initApiTokens[0].project).toBe(token.project); + expect(config.authentication.initApiTokens[0].type).toBe( + ApiTokenType.ADMIN, + ); +}); + +test('should add initApiToken from env var', async () => { + process.env.INIT_ADMIN_API_TOKENS = '*:*:some-token1, *:*:some-token2'; + + const config = createConfig({ + db: { + host: 'localhost', + port: 4242, + user: 'unleash', + password: 'password', + database: 'unleash_db', + }, + server: { + port: 4242, + }, + }); + + expect(config.authentication.initApiTokens).toHaveLength(2); + expect(config.authentication.initApiTokens[0].environment).toBe('*'); + expect(config.authentication.initApiTokens[0].project).toBe('*'); + expect(config.authentication.initApiTokens[0].type).toBe( + ApiTokenType.ADMIN, + ); + expect(config.authentication.initApiTokens[1].secret).toBe( + '*:*:some-token2', + ); + + delete process.env.INIT_ADMIN_API_TOKENS; +}); + +test('should validate initApiToken from env var', async () => { + process.env.INIT_ADMIN_API_TOKENS = 'invalidProject:*:some-token1'; + + expect(() => createConfig({})).toThrow( + 'Admin token cannot be scoped to single project', + ); + + delete process.env.INIT_ADMIN_API_TOKENS; +}); + +test('should merge initApiToken from options and env vars', async () => { + process.env.INIT_ADMIN_API_TOKENS = '*:*:some-token1, *:*:some-token2'; + const token = { + environment: '*', + project: '*', + secret: '*:*:some-random-string', + type: ApiTokenType.ADMIN, + username: 'admin', + }; + const config = createConfig({ + db: { + host: 'localhost', + port: 4242, + user: 'unleash', + password: 'password', + database: 'unleash_db', + }, + server: { + port: 4242, + }, + authentication: { + initApiTokens: [token], + }, + }); + + expect(config.authentication.initApiTokens).toHaveLength(3); + delete process.env.INIT_ADMIN_API_TOKENS; +}); diff --git a/src/lib/create-config.ts b/src/lib/create-config.ts index ed9a6d0ef4..d79a696d53 100644 --- a/src/lib/create-config.ts +++ b/src/lib/create-config.ts @@ -21,6 +21,7 @@ import { defaultCustomAuthDenyAll } from './default-custom-auth-deny-all'; import { formatBaseUri } from './util/format-base-uri'; import { minutesToMilliseconds, secondsToMilliseconds } from 'date-fns'; import EventEmitter from 'events'; +import { ApiTokenType, validateApiToken } from './types/models/api-token'; const safeToUpper = (s: string) => (s ? s.toUpperCase() : s); @@ -132,6 +133,7 @@ const defaultAuthentication: IAuthOption = { type: authTypeFromString(process.env.AUTH_TYPE), customAuthHandler: defaultCustomAuthDenyAll, createAdminUser: true, + initApiTokens: [], }; const defaultImport: IImportOption = { @@ -179,6 +181,28 @@ const formatServerOptions = ( }; }; +const loadInitApiTokens = () => { + if (process.env.INIT_ADMIN_API_TOKENS) { + const initApiTokens = process.env.INIT_ADMIN_API_TOKENS.split(/,\s?/); + const tokens = initApiTokens.map((secret) => { + const [project = '*', environment = '*'] = secret.split(':'); + const token = { + createdAt: undefined, + project, + environment, + secret, + type: ApiTokenType.ADMIN, + username: 'admin', + }; + validateApiToken(token); + return token; + }); + return tokens; + } else { + return []; + } +}; + export function createConfig(options: IUnleashOptions): IUnleashConfig { let extraDbOptions = {}; @@ -227,11 +251,14 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig { options.versionCheck, ]); + const initApiTokens = loadInitApiTokens(); + const authentication: IAuthOption = mergeAll([ defaultAuthentication, options.authentication ? removeUndefinedKeys(options.authentication) : options.authentication, + { initApiTokens: initApiTokens }, ]); const importSetting: IImportOption = mergeAll([ diff --git a/src/lib/db/api-token-store.ts b/src/lib/db/api-token-store.ts index cfa8a2c014..04108d4ca1 100644 --- a/src/lib/db/api-token-store.ts +++ b/src/lib/db/api-token-store.ts @@ -64,6 +64,12 @@ export class ApiTokenStore implements IApiTokenStore { }); } + count(): Promise { + return this.db(TABLE) + .count('*') + .then((res) => Number(res[0].count)); + } + async getAll(): Promise { const stopTimer = this.timer('getAll'); const rows = await this.db(TABLE); diff --git a/src/lib/services/api-token-service.test.ts b/src/lib/services/api-token-service.test.ts new file mode 100644 index 0000000000..c2227bbed6 --- /dev/null +++ b/src/lib/services/api-token-service.test.ts @@ -0,0 +1,33 @@ +import { ApiTokenService } from './api-token-service'; +import { createTestConfig } from '../../test/config/test-config'; +import { IUnleashConfig } from '../server-impl'; +import { ApiTokenType } from '../types/models/api-token'; +import FakeApiTokenStore from '../../test/fixtures/fake-api-token-store'; + +test('Should init api token', async () => { + const token = { + environment: '*', + project: '*', + secret: '*:*:some-random-string', + type: ApiTokenType.ADMIN, + username: 'admin', + }; + + const config: IUnleashConfig = createTestConfig({ + authentication: { + initApiTokens: [token], + }, + }); + const apiTokenStore = new FakeApiTokenStore(); + const insertCalled = new Promise((resolve) => { + apiTokenStore.on('insert', resolve); + }); + + new ApiTokenService({ apiTokenStore }, config); + + await insertCalled; + + const tokens = await apiTokenStore.getAll(); + + expect(tokens).toHaveLength(1); +}); diff --git a/src/lib/services/api-token-service.ts b/src/lib/services/api-token-service.ts index 23bc5c0a96..7c1936a58f 100644 --- a/src/lib/services/api-token-service.ts +++ b/src/lib/services/api-token-service.ts @@ -5,10 +5,10 @@ import { IUnleashStores } from '../types/stores'; import { IUnleashConfig } from '../types/option'; import ApiUser from '../types/api-user'; import { - ALL, ApiTokenType, IApiToken, IApiTokenCreate, + validateApiToken, } from '../types/models/api-token'; import { IApiTokenStore } from '../types/stores/api-token-store'; import { FOREIGN_KEY_VIOLATION } from '../error/db-error'; @@ -26,7 +26,7 @@ export class ApiTokenService { constructor( { apiTokenStore }: Pick, - config: Pick, + config: Pick, ) { this.store = apiTokenStore; this.logger = config.getLogger('/services/api-token-service.ts'); @@ -35,6 +35,11 @@ export class ApiTokenService { () => this.fetchActiveTokens(), minutesToMilliseconds(1), ).unref(); + if (config.authentication.initApiTokens.length > 0) { + process.nextTick(async () => + this.initApiTokens(config.authentication.initApiTokens), + ); + } } private async fetchActiveTokens(): Promise { @@ -54,6 +59,19 @@ export class ApiTokenService { return this.store.getAllActive(); } + private async initApiTokens(tokens: IApiTokenCreate[]) { + const tokenCount = await this.store.count(); + if (tokenCount > 0) { + return; + } + try { + const createAll = tokens.map((t) => this.insertNewApiToken(t)); + await Promise.all(createAll); + } catch (e) { + this.logger.error('Unable to create initial Admin API tokens'); + } + } + public getUserForToken(secret: string): ApiUser | undefined { const token = this.activeTokens.find((t) => t.secret === secret); if (token) { @@ -82,45 +100,30 @@ export class ApiTokenService { return this.store.delete(secret); } - private validateNewApiToken({ type, project, environment }) { - if (type === ApiTokenType.ADMIN && project !== ALL) { - throw new BadDataError( - 'Admin token cannot be scoped to single project', - ); - } - - if (type === ApiTokenType.ADMIN && environment !== ALL) { - throw new BadDataError( - 'Admin token cannot be scoped to single environment', - ); - } - - if (type === ApiTokenType.CLIENT && environment === ALL) { - throw new BadDataError( - 'Client token cannot be scoped to all environments', - ); - } - } - public async createApiToken( newToken: Omit, ): Promise { - this.validateNewApiToken(newToken); + validateApiToken(newToken); const secret = this.generateSecretKey(newToken); const createNewToken = { ...newToken, secret }; + return this.insertNewApiToken(createNewToken); + } + private async insertNewApiToken( + newApiToken: IApiTokenCreate, + ): Promise { try { - const token = await this.store.insert(createNewToken); + const token = await this.store.insert(newApiToken); this.activeTokens.push(token); return token; } catch (error) { if (error.code === FOREIGN_KEY_VIOLATION) { let { message } = error; if (error.constraint === 'api_tokens_project_fkey') { - message = `Project=${newToken.project} does not exist`; + message = `Project=${newApiToken.project} does not exist`; } else if (error.constraint === 'api_tokens_environment_fkey') { - message = `Environment=${newToken.environment} does not exist`; + message = `Environment=${newApiToken.environment} does not exist`; } throw new BadDataError(message); } diff --git a/src/lib/types/models/api-token.ts b/src/lib/types/models/api-token.ts index 20075a614f..e69f2a8195 100644 --- a/src/lib/types/models/api-token.ts +++ b/src/lib/types/models/api-token.ts @@ -1,3 +1,5 @@ +import BadDataError from '../../error/bad-data-error'; + export const ALL = '*'; export enum ApiTokenType { @@ -20,3 +22,27 @@ export interface IApiToken extends IApiTokenCreate { environment: string; project: string; } + +export const validateApiToken = ({ + type, + project, + environment, +}: Omit): void => { + if (type === ApiTokenType.ADMIN && project !== ALL) { + throw new BadDataError( + 'Admin token cannot be scoped to single project', + ); + } + + if (type === ApiTokenType.ADMIN && environment !== ALL) { + throw new BadDataError( + 'Admin token cannot be scoped to single environment', + ); + } + + if (type === ApiTokenType.CLIENT && environment === ALL) { + throw new BadDataError( + 'Client token cannot be scoped to all environments', + ); + } +}; diff --git a/src/lib/types/option.ts b/src/lib/types/option.ts index 9b81cba090..a9f1676a93 100644 --- a/src/lib/types/option.ts +++ b/src/lib/types/option.ts @@ -1,5 +1,6 @@ import EventEmitter from 'events'; import { LogLevel, LogProvider } from '../logger'; +import { IApiTokenCreate } from './models/api-token'; export type EventHook = (eventName: string, data: object) => void; @@ -53,6 +54,7 @@ export interface IAuthOption { type: IAuthType; customAuthHandler?: Function; createAdminUser: boolean; + initApiTokens: IApiTokenCreate[]; } export interface IImportOption { diff --git a/src/lib/types/stores/api-token-store.ts b/src/lib/types/stores/api-token-store.ts index b48271423f..516beae3b4 100644 --- a/src/lib/types/stores/api-token-store.ts +++ b/src/lib/types/stores/api-token-store.ts @@ -6,4 +6,5 @@ export interface IApiTokenStore extends Store { insert(newToken: IApiTokenCreate): Promise; setExpiry(secret: string, expiresAt: Date): Promise; markSeenAt(secrets: string[]): Promise; + count(): Promise; } diff --git a/src/server-dev.ts b/src/server-dev.ts index bf1cea9c95..0f6485b106 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -1,6 +1,7 @@ import { start } from './lib/server-impl'; import { createConfig } from './lib/create-config'; import { LogLevel } from './lib/logger'; +import { ApiTokenType } from './lib/types/models/api-token'; process.nextTick(async () => { try { @@ -30,6 +31,17 @@ process.nextTick(async () => { enabled: true, }, }, + authentication: { + initApiTokens: [ + { + environment: '*', + project: '*', + secret: '*:*:964a287e1b728cb5f4f3e0120df92cb5', + type: ApiTokenType.ADMIN, + username: 'some-user', + }, + ], + }, }), ); } catch (error) { diff --git a/src/test/fixtures/fake-api-token-store.ts b/src/test/fixtures/fake-api-token-store.ts index 59e2de84c3..d4fc077e41 100644 --- a/src/test/fixtures/fake-api-token-store.ts +++ b/src/test/fixtures/fake-api-token-store.ts @@ -2,9 +2,13 @@ import { IApiTokenStore } from '../../lib/types/stores/api-token-store'; import { IApiToken, IApiTokenCreate } from '../../lib/types/models/api-token'; import NotFoundError from '../../lib/error/notfound-error'; +import EventEmitter from 'events'; -export default class FakeApiTokenStore implements IApiTokenStore { - tokens: IApiToken[]; +export default class FakeApiTokenStore + extends EventEmitter + implements IApiTokenStore +{ + tokens: IApiToken[] = []; async delete(key: string): Promise { this.tokens.splice( @@ -13,6 +17,10 @@ export default class FakeApiTokenStore implements IApiTokenStore { ); } + async count(): Promise { + return this.tokens.length; + } + async deleteAll(): Promise { this.tokens = []; } @@ -45,6 +53,7 @@ export default class FakeApiTokenStore implements IApiTokenStore { ...newToken, }; this.tokens.push(apiToken); + this.emit('insert'); return apiToken; } diff --git a/website/docs/deploy/configuring-unleash.md b/website/docs/deploy/configuring-unleash.md index 99b7e423af..72aa145d2e 100644 --- a/website/docs/deploy/configuring-unleash.md +++ b/website/docs/deploy/configuring-unleash.md @@ -88,8 +88,8 @@ unleash.start(unleashOptions); - _serverMetrics_ (boolean) - use this option to turn on/off prometheus metrics. - _baseUriPath_ (string) - use to register a base path for all routes on the application. For example `/my/unleash/base` (note the starting /). Defaults to `/`. Can also be configured through the environment variable `BASE_URI_PATH`. - _unleashUrl_ (string) - Used to specify the official URL this instance of Unleash can be accessed at for an end user. Can also be configured through the environment variable `UNLEASH_URL`. - - \_gracefulShutdownEnable: (boolean) - Used to control if Unleash should shutdown gracefully (close connections, stop tasks,). Defaults to true. `GRACEFUL_SHUTDOWN_ENABLE` - - \_gracefulShutdownTimeout: (number) - Used to control the timeout, in milliseconds, for shutdown Unleash gracefully. Will kill all connections regardless if this timeout is exceeded. Defaults to 1000ms `GRACEFUL_SHUTDOWN_TIMEOUT` + - _gracefulShutdownEnable_: (boolean) - Used to control if Unleash should shutdown gracefully (close connections, stop tasks,). Defaults to true. `GRACEFUL_SHUTDOWN_ENABLE` + - _gracefulShutdownTimeout_: (number) - Used to control the timeout, in milliseconds, for shutdown Unleash gracefully. Will kill all connections regardless if this timeout is exceeded. Defaults to 1000ms `GRACEFUL_SHUTDOWN_TIMEOUT` - **preHook** (function) - this is a hook if you need to provide any middlewares to express before `unleash` adds any. Express app instance is injected as first argument. - **preRouterHook** (function) - use this to register custom express middlewares before the `unleash` specific routers are added. - **authentication** - (object) - An object for configuring/implementing custom admin authentication @@ -101,6 +101,18 @@ unleash.start(unleashOptions); - `demo` - Only requires an email to sign-in (was default in v3) - customAuthHandler: (function) - custom express middleware handling authentication. Used when type is set to `custom` - createAdminUser: (boolean) - whether to create an admin user with default password - Defaults to `true` + - initApiTokens: (ApiTokens[]) - Array of API Tokens to create on startup. Will only be applied if there are no existing API tokens in the database already. + Example: + ```ts + [{ + environment: '*', + project: '*', + secret: '*:*:964a287e1b728cb5f4f3e0120df92cb5', + type: ApiTokenType.ADMIN, + username: 'some-user', + }] + ``` + You may also use the environment variable `INIT_ADMIN_API_TOKENS` which takes a comma separated list of API Tokens to initialize. An example value for this variable could be: "`*:*:some-random-string, *:*:some-other-token`". NB: All Admin tokens must target all environments and projects. - **ui** (object) - Set of UI specific overrides. You may set the following keys: `environment`, `slogan`. - **getLogger** (function) - Used to register a [custom log provider](#how-do-i-configure-the-log-output). - **logLevel** (`debug` | `info` | `warn` | `error` | `fatal`) - The lowest level to log at, also configurable using environment variable `LOG_LEVEL`.