1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-28 00:17:12 +01:00

feat: Add init api tokens option (#1181)

Adds support for initializing a fresh Unleash instance with predefined API tokens. 

Co-authored-by: sighphyre <liquidwicked64@gmail.com>
Co-authored-by: Juraj Malenica <juraj.malenica@mindsmiths.com>
Co-authored-by: Ivar Conradi Østhus <ivarconr@gmail.com>
This commit is contained in:
Juraj Malenica 2022-01-05 10:00:59 +01:00 committed by GitHub
parent e814a5fd9a
commit e757c00840
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 265 additions and 31 deletions

View File

@ -6,6 +6,7 @@ Object {
"createAdminUser": true, "createAdminUser": true,
"customAuthHandler": [Function], "customAuthHandler": [Function],
"enableApiToken": true, "enableApiToken": true,
"initApiTokens": Array [],
"type": "open-source", "type": "open-source",
}, },
"db": Object { "db": Object {

View File

@ -1,5 +1,5 @@
// @ts-nocheck
import { createConfig } from './create-config'; import { createConfig } from './create-config';
import { ApiTokenType } from './types/models/api-token';
test('should create default config', async () => { test('should create default config', async () => {
const config = createConfig({ const config = createConfig({
@ -17,3 +17,105 @@ test('should create default config', async () => {
expect(config).toMatchSnapshot(); 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;
});

View File

@ -21,6 +21,7 @@ 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';
const safeToUpper = (s: string) => (s ? s.toUpperCase() : s); const safeToUpper = (s: string) => (s ? s.toUpperCase() : s);
@ -132,6 +133,7 @@ const defaultAuthentication: IAuthOption = {
type: authTypeFromString(process.env.AUTH_TYPE), type: authTypeFromString(process.env.AUTH_TYPE),
customAuthHandler: defaultCustomAuthDenyAll, customAuthHandler: defaultCustomAuthDenyAll,
createAdminUser: true, createAdminUser: true,
initApiTokens: [],
}; };
const defaultImport: IImportOption = { 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 { export function createConfig(options: IUnleashOptions): IUnleashConfig {
let extraDbOptions = {}; let extraDbOptions = {};
@ -227,11 +251,14 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
options.versionCheck, options.versionCheck,
]); ]);
const initApiTokens = loadInitApiTokens();
const authentication: IAuthOption = mergeAll([ const authentication: IAuthOption = mergeAll([
defaultAuthentication, defaultAuthentication,
options.authentication options.authentication
? removeUndefinedKeys(options.authentication) ? removeUndefinedKeys(options.authentication)
: options.authentication, : options.authentication,
{ initApiTokens: initApiTokens },
]); ]);
const importSetting: IImportOption = mergeAll([ const importSetting: IImportOption = mergeAll([

View File

@ -64,6 +64,12 @@ export class ApiTokenStore implements IApiTokenStore {
}); });
} }
count(): Promise<number> {
return this.db(TABLE)
.count('*')
.then((res) => Number(res[0].count));
}
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.db<ITokenTable>(TABLE);

View File

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

View File

@ -5,10 +5,10 @@ import { IUnleashStores } from '../types/stores';
import { IUnleashConfig } from '../types/option'; import { IUnleashConfig } from '../types/option';
import ApiUser from '../types/api-user'; import ApiUser from '../types/api-user';
import { import {
ALL,
ApiTokenType, ApiTokenType,
IApiToken, IApiToken,
IApiTokenCreate, IApiTokenCreate,
validateApiToken,
} 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';
@ -26,7 +26,7 @@ export class ApiTokenService {
constructor( constructor(
{ apiTokenStore }: Pick<IUnleashStores, 'apiTokenStore'>, { apiTokenStore }: Pick<IUnleashStores, 'apiTokenStore'>,
config: Pick<IUnleashConfig, 'getLogger'>, config: Pick<IUnleashConfig, 'getLogger' | 'authentication'>,
) { ) {
this.store = apiTokenStore; this.store = apiTokenStore;
this.logger = config.getLogger('/services/api-token-service.ts'); this.logger = config.getLogger('/services/api-token-service.ts');
@ -35,6 +35,11 @@ export class ApiTokenService {
() => this.fetchActiveTokens(), () => this.fetchActiveTokens(),
minutesToMilliseconds(1), minutesToMilliseconds(1),
).unref(); ).unref();
if (config.authentication.initApiTokens.length > 0) {
process.nextTick(async () =>
this.initApiTokens(config.authentication.initApiTokens),
);
}
} }
private async fetchActiveTokens(): Promise<void> { private async fetchActiveTokens(): Promise<void> {
@ -54,6 +59,19 @@ export class ApiTokenService {
return this.store.getAllActive(); 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 { public getUserForToken(secret: string): ApiUser | undefined {
const token = this.activeTokens.find((t) => t.secret === secret); const token = this.activeTokens.find((t) => t.secret === secret);
if (token) { if (token) {
@ -82,45 +100,30 @@ export class ApiTokenService {
return this.store.delete(secret); 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( public async createApiToken(
newToken: Omit<IApiTokenCreate, 'secret'>, newToken: Omit<IApiTokenCreate, 'secret'>,
): Promise<IApiToken> { ): Promise<IApiToken> {
this.validateNewApiToken(newToken); validateApiToken(newToken);
const secret = this.generateSecretKey(newToken); const secret = this.generateSecretKey(newToken);
const createNewToken = { ...newToken, secret }; const createNewToken = { ...newToken, secret };
return this.insertNewApiToken(createNewToken);
}
private async insertNewApiToken(
newApiToken: IApiTokenCreate,
): Promise<IApiToken> {
try { try {
const token = await this.store.insert(createNewToken); const token = await this.store.insert(newApiToken);
this.activeTokens.push(token); this.activeTokens.push(token);
return token; return token;
} 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_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') { } 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); throw new BadDataError(message);
} }

View File

@ -1,3 +1,5 @@
import BadDataError from '../../error/bad-data-error';
export const ALL = '*'; export const ALL = '*';
export enum ApiTokenType { export enum ApiTokenType {
@ -20,3 +22,27 @@ export interface IApiToken extends IApiTokenCreate {
environment: string; environment: string;
project: string; project: string;
} }
export const validateApiToken = ({
type,
project,
environment,
}: Omit<IApiTokenCreate, 'secret'>): 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',
);
}
};

View File

@ -1,5 +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';
export type EventHook = (eventName: string, data: object) => void; export type EventHook = (eventName: string, data: object) => void;
@ -53,6 +54,7 @@ export interface IAuthOption {
type: IAuthType; type: IAuthType;
customAuthHandler?: Function; customAuthHandler?: Function;
createAdminUser: boolean; createAdminUser: boolean;
initApiTokens: IApiTokenCreate[];
} }
export interface IImportOption { export interface IImportOption {

View File

@ -6,4 +6,5 @@ export interface IApiTokenStore extends Store<IApiToken, string> {
insert(newToken: IApiTokenCreate): Promise<IApiToken>; insert(newToken: IApiTokenCreate): Promise<IApiToken>;
setExpiry(secret: string, expiresAt: Date): Promise<IApiToken>; setExpiry(secret: string, expiresAt: Date): Promise<IApiToken>;
markSeenAt(secrets: string[]): Promise<void>; markSeenAt(secrets: string[]): Promise<void>;
count(): Promise<number>;
} }

View File

@ -1,6 +1,7 @@
import { start } from './lib/server-impl'; import { start } from './lib/server-impl';
import { createConfig } from './lib/create-config'; import { createConfig } from './lib/create-config';
import { LogLevel } from './lib/logger'; import { LogLevel } from './lib/logger';
import { ApiTokenType } from './lib/types/models/api-token';
process.nextTick(async () => { process.nextTick(async () => {
try { try {
@ -30,6 +31,17 @@ process.nextTick(async () => {
enabled: true, enabled: true,
}, },
}, },
authentication: {
initApiTokens: [
{
environment: '*',
project: '*',
secret: '*:*:964a287e1b728cb5f4f3e0120df92cb5',
type: ApiTokenType.ADMIN,
username: 'some-user',
},
],
},
}), }),
); );
} catch (error) { } catch (error) {

View File

@ -2,9 +2,13 @@ import { IApiTokenStore } from '../../lib/types/stores/api-token-store';
import { IApiToken, IApiTokenCreate } from '../../lib/types/models/api-token'; import { IApiToken, IApiTokenCreate } from '../../lib/types/models/api-token';
import NotFoundError from '../../lib/error/notfound-error'; import NotFoundError from '../../lib/error/notfound-error';
import EventEmitter from 'events';
export default class FakeApiTokenStore implements IApiTokenStore { export default class FakeApiTokenStore
tokens: IApiToken[]; extends EventEmitter
implements IApiTokenStore
{
tokens: IApiToken[] = [];
async delete(key: string): Promise<void> { async delete(key: string): Promise<void> {
this.tokens.splice( this.tokens.splice(
@ -13,6 +17,10 @@ export default class FakeApiTokenStore implements IApiTokenStore {
); );
} }
async count(): Promise<number> {
return this.tokens.length;
}
async deleteAll(): Promise<void> { async deleteAll(): Promise<void> {
this.tokens = []; this.tokens = [];
} }
@ -45,6 +53,7 @@ export default class FakeApiTokenStore implements IApiTokenStore {
...newToken, ...newToken,
}; };
this.tokens.push(apiToken); this.tokens.push(apiToken);
this.emit('insert');
return apiToken; return apiToken;
} }

View File

@ -88,8 +88,8 @@ unleash.start(unleashOptions);
- _serverMetrics_ (boolean) - use this option to turn on/off prometheus metrics. - _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`. - _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`. - _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` - _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` - _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. - **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. - **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 - **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) - `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` - 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` - 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`. - **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). - **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`. - **logLevel** (`debug` | `info` | `warn` | `error` | `fatal`) - The lowest level to log at, also configurable using environment variable `LOG_LEVEL`.