1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-31 13:47:02 +02:00

Feat/api key scoping (#941)

Co-authored-by: Christopher Kolstad <chriswk@getunleash.ai>
This commit is contained in:
Ivar Conradi Østhus 2021-09-15 20:28:10 +02:00 committed by GitHub
parent 24b057ab6d
commit c4b697b57d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 716 additions and 156 deletions

View File

@ -4,15 +4,17 @@ import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events'; import { DB_TIME } from '../metric-events';
import { Logger, LogProvider } from '../logger'; import { Logger, LogProvider } from '../logger';
import NotFoundError from '../error/notfound-error'; import NotFoundError from '../error/notfound-error';
import { IApiTokenStore } from '../types/stores/api-token-store';
import { import {
ApiTokenType, ApiTokenType,
IApiToken, IApiToken,
IApiTokenCreate, IApiTokenCreate,
IApiTokenStore, } from '../types/models/api-token';
} from '../types/stores/api-token-store';
const TABLE = 'api_tokens'; const TABLE = 'api_tokens';
const ALL = '*';
interface ITokenTable { interface ITokenTable {
id: number; id: number;
secret: string; secret: string;
@ -21,12 +23,17 @@ interface ITokenTable {
expires_at?: Date; expires_at?: Date;
created_at: Date; created_at: Date;
seen_at?: Date; seen_at?: Date;
environment: string;
project: string;
} }
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:
newToken.environment === ALL ? undefined : newToken.environment,
expires_at: newToken.expiresAt, expires_at: newToken.expiresAt,
}); });
@ -34,6 +41,8 @@ const toToken = (row: ITokenTable): IApiToken => ({
secret: row.secret, secret: row.secret,
username: row.username, username: row.username,
type: row.type, type: row.type,
environment: row.environment ? row.environment : ALL,
project: row.project ? row.project : ALL,
expiresAt: row.expires_at, expiresAt: row.expires_at,
createdAt: row.created_at, createdAt: row.created_at,
}); });

View File

@ -71,7 +71,12 @@ export class ClientMetricsStore
} }
} }
// Insert new client metrics /**
* Insert client metrics. In the future we will isolate "appName" and "environment"
* in separate columns in the database to make it easier to query the data.
*
* @param metrics sent from the client SDK.
*/
async insert(metrics: IClientMetric): Promise<void> { async insert(metrics: IClientMetric): Promise<void> {
const stopTimer = this.startTimer('insert'); const stopTimer = this.startTimer('insert');

View File

@ -3,6 +3,7 @@ import getLogger from '../../test/fixtures/no-logger';
import { CLIENT } from '../types/permissions'; import { CLIENT } from '../types/permissions';
import { createTestConfig } from '../../test/config/test-config'; import { createTestConfig } from '../../test/config/test-config';
import ApiUser from '../types/api-user'; import ApiUser from '../types/api-user';
import { ALL, ApiTokenType } from '../types/models/api-token';
let config: any; let config: any;
@ -55,10 +56,13 @@ test('should not add user if unknown token', async () => {
expect(req.user).toBeFalsy(); expect(req.user).toBeFalsy();
}); });
test('should add user if unknown token', async () => { test('should add user if known token', async () => {
const apiUser = new ApiUser({ const apiUser = new ApiUser({
username: 'default', username: 'default',
permissions: [CLIENT], permissions: [CLIENT],
project: ALL,
environment: ALL,
type: ApiTokenType.CLIENT,
}); });
const apiTokenService = { const apiTokenService = {
getUserForToken: jest.fn().mockReturnValue(apiUser), getUserForToken: jest.fn().mockReturnValue(apiUser),
@ -71,6 +75,7 @@ test('should add user if unknown token', async () => {
const req = { const req = {
header: jest.fn().mockReturnValue('some-known-token'), header: jest.fn().mockReturnValue('some-known-token'),
user: undefined, user: undefined,
path: '/api/client',
}; };
await func(req, undefined, cb); await func(req, undefined, cb);
@ -80,10 +85,47 @@ test('should add user if unknown token', async () => {
expect(req.user).toBe(apiUser); expect(req.user).toBe(apiUser);
}); });
test('should not add user if not /api/client', async () => {
const apiUser = new ApiUser({
username: 'default',
permissions: [CLIENT],
project: ALL,
environment: ALL,
type: ApiTokenType.CLIENT,
});
const apiTokenService = {
getUserForToken: jest.fn().mockReturnValue(apiUser),
};
const func = apiTokenMiddleware(config, { apiTokenService });
const cb = jest.fn();
const res = {
sendStatus: jest.fn(),
};
const req = {
header: jest.fn().mockReturnValue('some-known-token'),
user: undefined,
path: '/api/admin',
};
await func(req, res, cb);
expect(cb).not.toHaveBeenCalled();
expect(req.header).toHaveBeenCalled();
expect(req.user).toBeUndefined();
expect(res.sendStatus).toHaveBeenCalledWith(403);
});
test('should not add user if disabled', async () => { test('should not add user if disabled', async () => {
const apiUser = new ApiUser({ const apiUser = new ApiUser({
username: 'default', username: 'default',
permissions: [CLIENT], permissions: [CLIENT],
project: ALL,
environment: ALL,
type: ApiTokenType.CLIENT,
}); });
const apiTokenService = { const apiTokenService = {
getUserForToken: jest.fn().mockReturnValue(apiUser), getUserForToken: jest.fn().mockReturnValue(apiUser),
@ -136,6 +178,7 @@ test('should call next if apiTokenService throws', async () => {
}); });
test('should call next if apiTokenService throws x2', async () => { test('should call next if apiTokenService throws x2', async () => {
jest.spyOn(global.console, 'error').mockImplementation(() => jest.fn());
const apiTokenService = { const apiTokenService = {
getUserForToken: () => { getUserForToken: () => {
throw new Error('hi there, i am stupid'); throw new Error('hi there, i am stupid');

View File

@ -1,6 +1,11 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { ApiTokenType } from '../types/models/api-token';
import { IUnleashConfig } from '../types/option'; import { IUnleashConfig } from '../types/option';
const isClientApi = ({ path }) => {
return path && path.startsWith('/api/client');
};
const apiAccessMiddleware = ( const apiAccessMiddleware = (
{ {
getLogger, getLogger,
@ -9,14 +14,14 @@ const apiAccessMiddleware = (
{ apiTokenService }: any, { apiTokenService }: any,
): any => { ): any => {
const logger = getLogger('/middleware/api-token.ts'); const logger = getLogger('/middleware/api-token.ts');
logger.info('Enabling api-token middleware'); logger.debug('Enabling api-token middleware');
if (!authentication.enableApiToken) { if (!authentication.enableApiToken) {
return (req, res, next) => next(); return (req, res, next) => next();
} }
return (req, res, next) => { return (req, res, next) => {
if (req.apiUser) { if (req.user) {
return next(); return next();
} }
@ -24,6 +29,9 @@ const apiAccessMiddleware = (
const apiToken = req.header('authorization'); const apiToken = req.header('authorization');
const apiUser = apiTokenService.getUserForToken(apiToken); const apiUser = apiTokenService.getUserForToken(apiToken);
if (apiUser) { if (apiUser) {
if (apiUser.type === ApiTokenType.CLIENT && !isClientApi(req)) {
return res.sendStatus(403);
}
req.user = apiUser; req.user = apiUser;
} }
} catch (error) { } catch (error) {

View File

@ -30,7 +30,7 @@ function demoAuthentication(
next(); next();
}); });
app.use(`${basePath}/api/admin/`, (req, res, next) => { app.use(`${basePath}/api`, (req, res, next) => {
// @ts-ignore // @ts-ignore
if (req.user) { if (req.user) {
return next(); return next();

View File

@ -1,4 +1,5 @@
import { Application, NextFunction, Request, Response } from 'express'; import { Application, NextFunction, Response } from 'express';
import { IAuthRequest } from '../routes/unleash-types';
import AuthenticationRequired from '../types/authentication-required'; import AuthenticationRequired from '../types/authentication-required';
function ossAuthHook(app: Application, baseUriPath: string): void { function ossAuthHook(app: Application, baseUriPath: string): void {
@ -11,14 +12,11 @@ function ossAuthHook(app: Application, baseUriPath: string): void {
app.use( app.use(
`${baseUriPath}/api`, `${baseUriPath}/api`,
async (req: Request, res: Response, next: NextFunction) => { async (req: IAuthRequest, res: Response, next: NextFunction) => {
// @ts-ignore
if (req.session && req.session.user) { if (req.session && req.session.user) {
// @ts-ignore
req.user = req.session.user; req.user = req.session.user;
return next(); return next();
} }
// @ts-ignore
if (req.user) { if (req.user) {
return next(); return next();
} }

View File

@ -6,6 +6,7 @@ import { createTestConfig } from '../../test/config/test-config';
import ApiUser from '../types/api-user'; import ApiUser from '../types/api-user';
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store'; import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
import FakeFeatureToggleStore from '../../test/fixtures/fake-feature-toggle-store'; import FakeFeatureToggleStore from '../../test/fixtures/fake-feature-toggle-store';
import { ApiTokenType } from '../types/models/api-token';
let config: IUnleashConfig; let config: IUnleashConfig;
let featureToggleStore: IFeatureToggleStore; let featureToggleStore: IFeatureToggleStore;
@ -46,6 +47,9 @@ test('should give api-user ADMIN permission', async () => {
user: new ApiUser({ user: new ApiUser({
username: 'api', username: 'api',
permissions: [perms.ADMIN], permissions: [perms.ADMIN],
project: '*',
environment: '*',
type: ApiTokenType.ADMIN,
}), }),
}; };
@ -68,6 +72,9 @@ test('should not give api-user ADMIN permission', async () => {
user: new ApiUser({ user: new ApiUser({
username: 'api', username: 'api',
permissions: [perms.CLIENT], permissions: [perms.CLIENT],
project: '*',
environment: '*',
type: ApiTokenType.CLIENT,
}), }),
}; };

View File

@ -22,8 +22,8 @@ const rbacMiddleware = (
{ featureToggleStore }: Pick<IUnleashStores, 'featureToggleStore'>, { featureToggleStore }: Pick<IUnleashStores, 'featureToggleStore'>,
accessService: PermissionChecker, accessService: PermissionChecker,
): any => { ): any => {
const logger = config.getLogger('/middleware/rbac-middleware.js'); const logger = config.getLogger('/middleware/rbac-middleware.ts');
logger.info('Enabling RBAC'); logger.debug('Enabling RBAC middleware');
return (req, res, next) => { return (req, res, next) => {
req.checkRbac = async (permission: string) => { req.checkRbac = async (permission: string) => {

View File

@ -13,7 +13,8 @@ import { AccessService } from '../../services/access-service';
import { IAuthRequest } from '../unleash-types'; import { IAuthRequest } from '../unleash-types';
import User from '../../types/user'; import User from '../../types/user';
import { IUnleashConfig } from '../../types/option'; import { IUnleashConfig } from '../../types/option';
import { ApiTokenType } from '../../types/stores/api-token-store'; import { ApiTokenType } from '../../types/models/api-token';
import { createApiToken } from '../../schema/api-token-schema';
interface IServices { interface IServices {
apiTokenService: ApiTokenService; apiTokenService: ApiTokenService;
@ -64,41 +65,16 @@ class ApiTokenController extends Controller {
} }
async createApiToken(req: IAuthRequest, res: Response): Promise<any> { async createApiToken(req: IAuthRequest, res: Response): Promise<any> {
const { username, type, expiresAt } = req.body; const createToken = await createApiToken.validateAsync(req.body);
const token = await this.apiTokenService.createApiToken(createToken);
if (!username || !type) { return res.status(201).json(token);
this.logger.error(req.body);
return res.status(400).send();
}
const tokenType =
type.toLowerCase() === 'admin'
? ApiTokenType.ADMIN
: ApiTokenType.CLIENT;
try {
const token = await this.apiTokenService.creteApiToken({
type: tokenType,
username,
expiresAt,
});
return res.status(201).json(token);
} catch (error) {
this.logger.error('error creating api-token', error);
return res.status(500);
}
} }
async deleteApiToken(req: IAuthRequest, res: Response): Promise<void> { async deleteApiToken(req: IAuthRequest, res: Response): Promise<void> {
const { token } = req.params; const { token } = req.params;
try { await this.apiTokenService.delete(token);
await this.apiTokenService.delete(token); res.status(200).end();
res.status(200).end();
} catch (error) {
this.logger.error('error creating api-token', error);
res.status(500);
}
} }
async updateApiToken(req: IAuthRequest, res: Response): Promise<any> { async updateApiToken(req: IAuthRequest, res: Response): Promise<any> {
@ -110,13 +86,8 @@ class ApiTokenController extends Controller {
return res.status(400).send(); return res.status(400).send();
} }
try { await this.apiTokenService.updateExpiry(token, expiresAt);
await this.apiTokenService.updateExpiry(token, expiresAt); return res.status(200).end();
return res.status(200).end();
} catch (error) {
this.logger.error('error creating api-token', error);
return res.status(500);
}
} }
} }

View File

@ -1,5 +1,5 @@
import memoizee from 'memoizee'; import memoizee from 'memoizee';
import { Request, Response } from 'express'; import { Response } from 'express';
import Controller from '../controller'; import Controller from '../controller';
import { IUnleashServices } from '../../types/services'; import { IUnleashServices } from '../../types/services';
import { IUnleashConfig } from '../../types/option'; import { IUnleashConfig } from '../../types/option';
@ -8,9 +8,17 @@ import { Logger } from '../../logger';
import { querySchema } from '../../schema/feature-schema'; import { querySchema } from '../../schema/feature-schema';
import { IFeatureToggleQuery } from '../../types/model'; import { IFeatureToggleQuery } from '../../types/model';
import NotFoundError from '../../error/notfound-error'; import NotFoundError from '../../error/notfound-error';
import { IAuthRequest } from '../unleash-types';
import ApiUser from '../../types/api-user';
import { ALL } from '../../types/models/api-token';
const version = 2; const version = 2;
interface QueryOverride {
project?: string[];
environment?: string;
}
export default class FeatureController extends Controller { export default class FeatureController extends Controller {
private readonly logger: Logger; private readonly logger: Logger;
@ -50,20 +58,34 @@ export default class FeatureController extends Controller {
} }
} }
async getAll(req: Request, res: Response): Promise<void> { private async resolveQuery(
const query = await this.prepQuery(req.query); req: IAuthRequest,
let features; ): Promise<IFeatureToggleQuery> {
if (this.cache) { const { user, query } = req;
features = await this.cachedFeatures(query);
} else { const override: QueryOverride = {};
features = await this.featureToggleServiceV2.getClientFeatures( if (user instanceof ApiUser) {
query, if (user.project !== ALL) {
); override.project = [user.project];
}
if (user.environment !== ALL) {
override.environment = user.environment;
}
} }
res.json({ version, features });
const q = { ...query, ...override };
return this.prepQuery(q);
} }
async prepQuery({ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
private paramToArray(param: any) {
if (!param) {
return param;
}
return Array.isArray(param) ? param : [param];
}
private async prepQuery({
tag, tag,
project, project,
namePrefix, namePrefix,
@ -86,29 +108,25 @@ export default class FeatureController extends Controller {
return query; return query;
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types async getAll(req: IAuthRequest, res: Response): Promise<void> {
paramToArray(param: any) { const featureQuery = await this.resolveQuery(req);
if (!param) { let features;
return param; if (this.cache) {
features = await this.cachedFeatures(featureQuery);
} else {
features = await this.featureToggleServiceV2.getClientFeatures(
featureQuery,
);
} }
return Array.isArray(param) ? param : [param]; res.json({ version, features, query: featureQuery });
} }
async getFeatureToggle( async getFeatureToggle(req: IAuthRequest, res: Response): Promise<void> {
req: Request<
{ featureName: string },
any,
any,
{ environment?: string }
>,
res: Response,
): Promise<void> {
const name = req.params.featureName; const name = req.params.featureName;
const { environment } = req.query; const featureQuery = await this.resolveQuery(req);
const toggles = await this.featureToggleServiceV2.getClientFeatures({ const q = { ...featureQuery, namePrefix: name };
namePrefix: name, const toggles = await this.featureToggleServiceV2.getClientFeatures(q);
environment,
});
const toggle = toggles.find((t) => t.name === name); const toggle = toggles.find((t) => t.name === name);
if (!toggle) { if (!toggle) {
throw new NotFoundError(`Could not find feature toggle ${name}`); throw new NotFoundError(`Could not find feature toggle ${name}`);
@ -116,5 +134,3 @@ export default class FeatureController extends Controller {
res.json(toggle).end(); res.json(toggle).end();
} }
} }
module.exports = FeatureController;

View File

@ -43,7 +43,6 @@ afterEach(() => {
}); });
test('should validate client metrics', () => { test('should validate client metrics', () => {
expect.assertions(0);
return request return request
.post('/api/client/metrics') .post('/api/client/metrics')
.send({ random: 'blush' }) .send({ random: 'blush' })
@ -51,7 +50,6 @@ test('should validate client metrics', () => {
}); });
test('should accept empty client metrics', () => { test('should accept empty client metrics', () => {
expect.assertions(0);
return request return request
.post('/api/client/metrics') .post('/api/client/metrics')
.send({ .send({
@ -67,7 +65,6 @@ test('should accept empty client metrics', () => {
}); });
test('should accept client metrics with yes/no', () => { test('should accept client metrics with yes/no', () => {
expect.assertions(0);
return request return request
.post('/api/client/metrics') .post('/api/client/metrics')
.send({ .send({
@ -88,7 +85,6 @@ test('should accept client metrics with yes/no', () => {
}); });
test('should accept client metrics with variants', () => { test('should accept client metrics with variants', () => {
expect.assertions(0);
return request return request
.post('/api/client/metrics') .post('/api/client/metrics')
.send({ .send({
@ -113,7 +109,6 @@ test('should accept client metrics with variants', () => {
}); });
test('should accept client metrics without yes/no', () => { test('should accept client metrics without yes/no', () => {
expect.assertions(0);
return request return request
.post('/api/client/metrics') .post('/api/client/metrics')
.send({ .send({
@ -133,7 +128,7 @@ test('should accept client metrics without yes/no', () => {
.expect(202); .expect(202);
}); });
test('shema allow empty strings', () => { test('schema allow empty strings', () => {
const data = { const data = {
appName: 'java-test', appName: 'java-test',
instanceId: 'instance y', instanceId: 'instance y',

View File

@ -1,9 +1,12 @@
import { Request, Response } from 'express'; import { Response } from 'express';
import Controller from '../controller'; import Controller from '../controller';
import { IUnleashServices } from '../../types'; import { IUnleashServices } from '../../types';
import { IUnleashConfig } from '../../types/option'; import { IUnleashConfig } from '../../types/option';
import ClientMetricsService from '../../services/client-metrics'; import ClientMetricsService from '../../services/client-metrics';
import { Logger } from '../../logger'; import { Logger } from '../../logger';
import { IAuthRequest } from '../unleash-types';
import ApiUser from '../../types/api-user';
import { ALL } from '../../types/models/api-token';
export default class ClientMetricsController extends Controller { export default class ClientMetricsController extends Controller {
logger: Logger; logger: Logger;
@ -23,13 +26,13 @@ export default class ClientMetricsController extends Controller {
this.post('/', this.registerMetrics); this.post('/', this.registerMetrics);
} }
async registerMetrics( async registerMetrics(req: IAuthRequest, res: Response): Promise<void> {
req: Request<any, any, any, any>, const { body: data, ip: clientIp, user } = req;
res: Response, if (user instanceof ApiUser) {
): Promise<void> { if (user.environment !== ALL) {
const data = req.body; data.environment = user.environment;
const clientIp = req.ip; }
}
await this.metrics.registerClientMetrics(data, clientIp); await this.metrics.registerClientMetrics(data, clientIp);
return res.status(202).end(); return res.status(202).end();
} }

View File

@ -0,0 +1,17 @@
import joi from 'joi';
import { ALL, ApiTokenType } from '../types/models/api-token';
export const createApiToken = joi
.object()
.keys({
username: joi.string().required(),
type: joi
.string()
.lowercase()
.required()
.valid(ApiTokenType.ADMIN, ApiTokenType.CLIENT),
expiresAt: joi.date().optional(),
project: joi.string().optional().default(ALL),
environment: joi.string().optional().default(ALL),
})
.options({ stripUnknown: true, allowUnknown: false, abortEarly: false });

View File

@ -115,9 +115,9 @@ async function start(opts: IUnleashOptions = {}): Promise<IUnleash> {
if (config.db.disableMigration) { if (config.db.disableMigration) {
logger.info('DB migration: disabled'); logger.info('DB migration: disabled');
} else { } else {
logger.info('DB migration: start'); logger.debug('DB migration: start');
await migrateDb(config); await migrateDb(config);
logger.info('DB migration: end'); logger.debug('DB migration: end');
} }
} catch (err) { } catch (err) {
logger.error('Failed to migrate db', err); logger.error('Failed to migrate db', err);

View File

@ -5,19 +5,17 @@ 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,
IApiTokenStore, IApiTokenCreate,
} from '../types/stores/api-token-store'; } from '../types/models/api-token';
import { IApiTokenStore } from '../types/stores/api-token-store';
import { FOREIGN_KEY_VIOLATION } from '../error/db-error';
import BadDataError from '../error/bad-data-error';
const ONE_MINUTE = 60_000; const ONE_MINUTE = 60_000;
interface CreateTokenRequest {
username: string;
type: ApiTokenType;
expiresAt?: Date;
}
export class ApiTokenService { export class ApiTokenService {
private store: IApiTokenStore; private store: IApiTokenStore;
@ -66,6 +64,9 @@ export class ApiTokenService {
return new ApiUser({ return new ApiUser({
username: token.username, username: token.username,
permissions, permissions,
project: token.project,
environment: token.environment,
type: token.type,
}); });
} }
return undefined; return undefined;
@ -82,16 +83,49 @@ export class ApiTokenService {
return this.store.delete(secret); return this.store.delete(secret);
} }
public async creteApiToken( private validateAdminToken({ type, project, environment }) {
creteTokenRequest: CreateTokenRequest, if (type === ApiTokenType.ADMIN && project !== ALL) {
): Promise<IApiToken> { throw new BadDataError(
const secret = this.generateSecretKey(); 'Admin token cannot be scoped to single project',
const createNewToken = { ...creteTokenRequest, secret }; );
return this.store.insert(createNewToken); }
if (type === ApiTokenType.ADMIN && environment !== ALL) {
throw new BadDataError(
'Admin token cannot be scoped to single environment',
);
}
} }
private generateSecretKey() { public async createApiToken(
return crypto.randomBytes(32).toString('hex'); newToken: Omit<IApiTokenCreate, 'secret'>,
): Promise<IApiToken> {
this.validateAdminToken(newToken);
const secret = this.generateSecretKey(newToken);
const createNewToken = { ...newToken, secret };
try {
const token = await this.store.insert(createNewToken);
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`;
} else if (error.constraint === 'api_tokens_environment_fkey') {
message = `Environment=${newToken.environment} does not exist`;
}
throw new BadDataError(message);
}
throw error;
}
}
private generateSecretKey({ project, environment }) {
const randomStr = crypto.randomBytes(28).toString('hex');
return `${project}:${environment}.${randomStr}`;
} }
destroy(): void { destroy(): void {

View File

@ -13,6 +13,7 @@ export const clientMetricsSchema = joi
.object() .object()
.options({ stripUnknown: true }) .options({ stripUnknown: true })
.keys({ .keys({
environment: joi.string().optional(),
appName: joi.string().required(), appName: joi.string().required(),
instanceId: joi.string().required(), instanceId: joi.string().required(),
bucket: joi bucket: joi

View File

@ -1,23 +1,42 @@
import { ApiTokenType } from './models/api-token';
import { CLIENT } from './permissions'; import { CLIENT } from './permissions';
interface IApiUserData { interface IApiUserData {
username: string; username: string;
permissions?: string[]; permissions?: string[];
project: string;
environment: string;
type: ApiTokenType;
} }
export default class ApiUser { export default class ApiUser {
isAPI: boolean = true; readonly isAPI: boolean = true;
username: string; readonly username: string;
permissions: string[]; readonly permissions: string[];
constructor({ username, permissions = [CLIENT] }: IApiUserData) { readonly project: string;
readonly environment: string;
readonly type: ApiTokenType;
constructor({
username,
permissions = [CLIENT],
project,
environment,
type,
}: IApiUserData) {
if (!username) { if (!username) {
throw new TypeError('username is required'); throw new TypeError('username is required');
} }
this.username = username; this.username = username;
this.permissions = permissions; this.permissions = permissions;
this.project = project;
this.environment = environment;
this.type = type;
} }
} }

View File

@ -0,0 +1,22 @@
export const ALL = '*';
export enum ApiTokenType {
CLIENT = 'client',
ADMIN = 'admin',
}
export interface IApiTokenCreate {
secret: string;
username: string;
type: ApiTokenType;
environment: string;
project: string;
expiresAt?: Date;
}
export interface IApiToken extends IApiTokenCreate {
createdAt: Date;
seenAt?: Date;
environment: string;
project: string;
}

View File

@ -1,22 +1,6 @@
import { IApiToken, IApiTokenCreate } from '../models/api-token';
import { Store } from './store'; import { Store } from './store';
export enum ApiTokenType {
CLIENT = 'client',
ADMIN = 'admin',
}
export interface IApiTokenCreate {
secret: string;
username: string;
type: ApiTokenType;
expiresAt?: Date;
}
export interface IApiToken extends IApiTokenCreate {
createdAt: Date;
seenAt?: Date;
}
export interface IApiTokenStore extends Store<IApiToken, string> { export interface IApiTokenStore extends Store<IApiToken, string> {
getAllActive(): Promise<IApiToken[]>; getAllActive(): Promise<IApiToken[]>;
insert(newToken: IApiTokenCreate): Promise<IApiToken>; insert(newToken: IApiTokenCreate): Promise<IApiToken>;

View File

@ -14,7 +14,7 @@ function registerGracefulShutdown(unleash: IUnleash, logger: Logger): void {
} }
}; };
logger.info('Registering graceful shutdown'); logger.debug('Registering graceful shutdown');
process.on('SIGINT', unleashCloser('SIGINT')); process.on('SIGINT', unleashCloser('SIGINT'));
process.on('SIGHUP', unleashCloser('SIGHUP')); process.on('SIGHUP', unleashCloser('SIGHUP'));

View File

@ -0,0 +1,19 @@
exports.up = function (db, cb) {
db.runSql(
`
ALTER TABLE api_tokens ADD COLUMN project VARCHAR REFERENCES PROJECTS(id) ON DELETE CASCADE;;
ALTER TABLE api_tokens ADD COLUMN environment VARCHAR REFERENCES environments(name) ON DELETE CASCADE;;
`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`
ALTER TABLE api_tokens DROP COLUMN project;
ALTER TABLE api_tokens DROP COLUMN environment;
`,
cb,
);
};

View File

@ -1,7 +1,7 @@
import { setupAppWithCustomAuth } from '../../helpers/test-helper'; import { setupAppWithCustomAuth } from '../../helpers/test-helper';
import dbInit from '../../helpers/database-init'; import dbInit from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger'; import getLogger from '../../../fixtures/no-logger';
import { ApiTokenType } from '../../../../lib/types/stores/api-token-store'; import { ApiTokenType } from '../../../../lib/types/models/api-token';
import { RoleName } from '../../../../lib/types/model'; import { RoleName } from '../../../../lib/types/model';
let stores; let stores;

View File

@ -1,7 +1,7 @@
import { setupApp } from '../../helpers/test-helper'; import { setupApp } from '../../helpers/test-helper';
import dbInit from '../../helpers/database-init'; import dbInit from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger'; import getLogger from '../../../fixtures/no-logger';
import { ApiTokenType } from '../../../../lib/types/stores/api-token-store'; import { ALL, ApiTokenType } from '../../../../lib/types/models/api-token';
let db; let db;
let app; let app;
@ -70,6 +70,25 @@ test('creates new admin token', async () => {
}); });
}); });
test('creates new ADMIN token should fix casing', async () => {
expect.assertions(5);
return app.request
.post('/api/admin/api-tokens')
.send({
username: 'default-admin',
type: 'ADMIN',
})
.set('Content-Type', 'application/json')
.expect(201)
.expect((res) => {
expect(res.body.username).toBe('default-admin');
expect(res.body.type).toBe('admin');
expect(res.body.createdAt).toBeTruthy();
expect(res.body.expiresAt).toBeFalsy();
expect(res.body.secret.length > 16).toBe(true);
});
});
test('creates new admin token with expiry', async () => { test('creates new admin token with expiry', async () => {
expect.assertions(1); expect.assertions(1);
const expiresAt = new Date(); const expiresAt = new Date();
@ -170,3 +189,142 @@ test('removes api token', async () => {
expect(res.body.tokens.length).toBe(0); expect(res.body.tokens.length).toBe(0);
}); });
}); });
test('creates new client token: project & environment defaults to "*"', async () => {
return app.request
.post('/api/admin/api-tokens')
.send({
username: 'default-client',
type: 'client',
})
.set('Content-Type', 'application/json')
.expect(201)
.expect((res) => {
expect(res.body.type).toBe('client');
expect(res.body.secret.length > 16).toBe(true);
expect(res.body.environment).toBe(ALL);
expect(res.body.project).toBe(ALL);
});
});
test('creates new client token with project & environment set', async () => {
return app.request
.post('/api/admin/api-tokens')
.send({
username: 'default-client',
type: 'client',
project: 'default',
environment: ':global:',
})
.set('Content-Type', 'application/json')
.expect(201)
.expect((res) => {
expect(res.body.type).toBe('client');
expect(res.body.secret.length > 16).toBe(true);
expect(res.body.environment).toBe(':global:');
expect(res.body.project).toBe('default');
});
});
test('should prefix default token with "*:*."', async () => {
return app.request
.post('/api/admin/api-tokens')
.send({
username: 'default-client',
type: 'client',
})
.set('Content-Type', 'application/json')
.expect(201)
.expect((res) => {
expect(res.body.secret).toMatch(/\*:\*\..*/);
});
});
test('should prefix token with "project:environment."', async () => {
return app.request
.post('/api/admin/api-tokens')
.send({
username: 'default-client',
type: 'client',
project: 'default',
environment: ':global:',
})
.set('Content-Type', 'application/json')
.expect(201)
.expect((res) => {
expect(res.body.secret).toMatch(/default::global:\..*/);
});
});
test('should not create token for invalid projectId', async () => {
return app.request
.post('/api/admin/api-tokens')
.send({
username: 'default-client',
type: 'client',
project: 'bogus-project-something',
})
.set('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body.details[0].message).toMatch(
/bogus-project-something/,
);
});
});
test('should not create token for invalid environment', async () => {
return app.request
.post('/api/admin/api-tokens')
.send({
username: 'default-client',
type: 'client',
environment: 'bogus-environment-something',
})
.set('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body.details[0].message).toMatch(
/bogus-environment-something/,
);
});
});
test('should not create token for invalid project & environment', async () => {
return app.request
.post('/api/admin/api-tokens')
.send({
username: 'default-admin',
type: 'admin',
project: 'bogus-project-something',
environment: 'bogus-environment-something',
})
.set('Content-Type', 'application/json')
.expect(400);
});
test('admin token only supports ALL projects', async () => {
return app.request
.post('/api/admin/api-tokens')
.send({
username: 'default-admin',
type: 'admin',
project: 'default',
environment: '*',
})
.set('Content-Type', 'application/json')
.expect(400);
});
test('admin token only supports ALL environments', async () => {
return app.request
.post('/api/admin/api-tokens')
.send({
username: 'default-admin',
type: 'admin',
project: '*',
environment: ':global:',
})
.set('Content-Type', 'application/json')
.expect(400);
});

View File

@ -0,0 +1,199 @@
import { IUnleashTest, setupAppWithAuth } from '../../helpers/test-helper';
import dbInit, { ITestDb } from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
import { ApiTokenService } from '../../../../lib/services/api-token-service';
import { ApiTokenType } from '../../../../lib/types/models/api-token';
let app: IUnleashTest;
let db: ITestDb;
let apiTokenService: ApiTokenService;
const environment = 'testing';
const project = 'default';
const project2 = 'some';
const username = 'test';
const feature1 = 'f1.token.access';
const feature2 = 'f2.token.access';
const feature3 = 'f3.p2.token.access';
beforeAll(async () => {
db = await dbInit('feature_api_api_access_client', getLogger);
app = await setupAppWithAuth(db.stores);
apiTokenService = app.services.apiTokenService;
const { featureToggleServiceV2, environmentService } = app.services;
const { environmentStore, projectStore } = db.stores;
await environmentStore.create({
name: environment,
displayName: '',
type: 'test',
});
await projectStore.create({
id: project2,
name: 'Test Project 2',
description: '',
});
await environmentService.addEnvironmentToProject(environment, project);
await environmentService.addEnvironmentToProject(environment, project2);
await featureToggleServiceV2.createFeatureToggle(
project,
{
name: feature1,
description: 'the #1 feature',
},
username,
);
await featureToggleServiceV2.createStrategy(
{
name: 'default',
constraints: [],
parameters: {},
},
project,
feature1,
);
await featureToggleServiceV2.createStrategy(
{
name: 'custom-testing',
constraints: [],
parameters: {},
},
project,
feature1,
environment,
);
// create feature 2
await featureToggleServiceV2.createFeatureToggle(
project,
{
name: feature2,
},
username,
);
await featureToggleServiceV2.createStrategy(
{
name: 'default',
constraints: [],
parameters: {},
},
project,
feature2,
environment,
);
// create feature 3
await featureToggleServiceV2.createFeatureToggle(
project2,
{
name: feature3,
},
username,
);
await featureToggleServiceV2.createStrategy(
{
name: 'default',
constraints: [],
parameters: {},
},
project2,
feature3,
environment,
);
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
test('returns feature toggle with :global: config', async () => {
const token = await apiTokenService.createApiToken({
type: ApiTokenType.CLIENT,
username,
environment: ':global:',
project,
});
await app.request
.get('/api/client/features')
.set('Authorization', token.secret)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
const { features } = res.body;
const f1 = features.find((f) => f.name === feature1);
const f2 = features.find((f) => f.name === feature2);
expect(features).toHaveLength(2);
expect(f1.strategies).toHaveLength(1);
expect(f2.strategies).toHaveLength(0);
});
});
test('returns feature toggle with :global: config', async () => {
const token = await apiTokenService.createApiToken({
type: ApiTokenType.CLIENT,
username,
environment,
project,
});
await app.request
.get('/api/client/features')
.set('Authorization', token.secret)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
const { features, query } = res.body;
const f1 = features.find((f) => f.name === feature1);
const f2 = features.find((f) => f.name === feature2);
expect(features).toHaveLength(2);
expect(f1.strategies).toHaveLength(2);
expect(f2.strategies).toHaveLength(1);
expect(query.project[0]).toBe(project);
expect(query.environment).toBe(environment);
});
});
test('returns feature toggle for project2', async () => {
const token = await apiTokenService.createApiToken({
type: ApiTokenType.CLIENT,
username,
environment,
project: project2,
});
await app.request
.get('/api/client/features')
.set('Authorization', token.secret)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
const { features } = res.body;
const f3 = features.find((f) => f.name === feature3);
expect(features).toHaveLength(1);
expect(f3.strategies).toHaveLength(1);
});
});
test('returns feature toggle for all projects', async () => {
const token = await apiTokenService.createApiToken({
type: ApiTokenType.CLIENT,
username,
environment,
project: '*',
});
await app.request
.get('/api/client/features')
.set('Authorization', token.secret)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
const { features } = res.body;
expect(features).toHaveLength(3);
});
});

View File

@ -0,0 +1,45 @@
import { setupAppWithAuth } from '../../helpers/test-helper';
import metricsExample from '../../../examples/client-metrics.json';
import dbInit from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
import { ApiTokenType } from '../../../../lib/types/models/api-token';
let app;
let db;
beforeAll(async () => {
db = await dbInit('metrics_api_client', getLogger);
app = await setupAppWithAuth(db.stores);
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
test('should enrich metrics with environment from api-token', async () => {
const { apiTokenService } = app.services;
const { environmentStore, clientMetricsStore } = db.stores;
await environmentStore.create({
name: 'some',
displayName: '',
type: 'test',
});
const token = await apiTokenService.createApiToken({
type: ApiTokenType.CLIENT,
username: 'test',
environment: 'some',
project: '*',
});
await app.request
.post('/api/client/metrics')
.set('Authorization', token.secret)
.send(metricsExample)
.expect(202);
const all = await clientMetricsStore.getAll();
expect(all[0].metrics.environment).toBe('some');
});

View File

@ -2,10 +2,7 @@ import dbInit from '../helpers/database-init';
import getLogger from '../../fixtures/no-logger'; import getLogger from '../../fixtures/no-logger';
import { ApiTokenService } from '../../../lib/services/api-token-service'; import { ApiTokenService } from '../../../lib/services/api-token-service';
import { createTestConfig } from '../../config/test-config'; import { createTestConfig } from '../../config/test-config';
import { import { ApiTokenType, IApiToken } from '../../../lib/types/models/api-token';
ApiTokenType,
IApiToken,
} from '../../../lib/types/stores/api-token-store';
let db; let db;
let stores; let stores;
@ -42,9 +39,11 @@ test('should have empty list of tokens', async () => {
}); });
test('should create client token', async () => { test('should create client token', async () => {
const token = await apiTokenService.creteApiToken({ const token = await apiTokenService.createApiToken({
username: 'default-client', username: 'default-client',
type: ApiTokenType.CLIENT, type: ApiTokenType.CLIENT,
project: '*',
environment: '*',
}); });
const allTokens = await apiTokenService.getAllTokens(); const allTokens = await apiTokenService.getAllTokens();
@ -56,9 +55,11 @@ test('should create client token', async () => {
}); });
test('should create admin token', async () => { test('should create admin token', async () => {
const token = await apiTokenService.creteApiToken({ const token = await apiTokenService.createApiToken({
username: 'admin', username: 'admin',
type: ApiTokenType.ADMIN, type: ApiTokenType.ADMIN,
project: '*',
environment: '*',
}); });
expect(token.secret.length > 32).toBe(true); expect(token.secret.length > 32).toBe(true);
@ -67,10 +68,12 @@ test('should create admin token', async () => {
test('should set expiry of token', async () => { test('should set expiry of token', async () => {
const time = new Date('2022-01-01'); const time = new Date('2022-01-01');
await apiTokenService.creteApiToken({ await apiTokenService.createApiToken({
username: 'default-client', username: 'default-client',
type: ApiTokenType.CLIENT, type: ApiTokenType.CLIENT,
expiresAt: time, expiresAt: time,
project: '*',
environment: '*',
}); });
const [token] = await apiTokenService.getAllTokens(); const [token] = await apiTokenService.getAllTokens();
@ -82,10 +85,12 @@ test('should update expiry of token', async () => {
const time = new Date('2022-01-01'); const time = new Date('2022-01-01');
const newTime = new Date('2023-01-01'); const newTime = new Date('2023-01-01');
const token = await apiTokenService.creteApiToken({ const token = await apiTokenService.createApiToken({
username: 'default-client', username: 'default-client',
type: ApiTokenType.CLIENT, type: ApiTokenType.CLIENT,
expiresAt: time, expiresAt: time,
project: '*',
environment: '*',
}); });
await apiTokenService.updateExpiry(token.secret, newTime); await apiTokenService.updateExpiry(token.secret, newTime);
@ -99,16 +104,20 @@ test('should only return valid tokens', async () => {
const today = new Date(); const today = new Date();
const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000); const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
await apiTokenService.creteApiToken({ await apiTokenService.createApiToken({
username: 'default-expired', username: 'default-expired',
type: ApiTokenType.CLIENT, type: ApiTokenType.CLIENT,
expiresAt: new Date('2021-01-01'), expiresAt: new Date('2021-01-01'),
project: '*',
environment: '*',
}); });
const activeToken = await apiTokenService.creteApiToken({ const activeToken = await apiTokenService.createApiToken({
username: 'default-valid', username: 'default-valid',
type: ApiTokenType.CLIENT, type: ApiTokenType.CLIENT,
expiresAt: tomorrow, expiresAt: tomorrow,
project: '*',
environment: '*',
}); });
const tokens = await apiTokenService.getAllActiveTokens(); const tokens = await apiTokenService.getAllActiveTokens();

View File

@ -1,8 +1,6 @@
import { import { IApiTokenStore } from '../../lib/types/stores/api-token-store';
IApiToken, import { IApiToken, IApiTokenCreate } from '../../lib/types/models/api-token';
IApiTokenCreate,
IApiTokenStore,
} from '../../lib/types/stores/api-token-store';
import NotFoundError from '../../lib/error/notfound-error'; import NotFoundError from '../../lib/error/notfound-error';
export default class FakeApiTokenStore implements IApiTokenStore { export default class FakeApiTokenStore implements IApiTokenStore {