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

PublicSignupTokens (#2053)

* PublicSignupTokens

* bug fix

* bug fixes and test

* bug fixes and test

* bug fixes and test

* Add feature flag

* tests

* tests

* Update 20220908093515-add-public-signup-tokens.js

Bug Fix

* task: use swc instead of ts-jest (#2042)

* Add a counter for total number of environments (#1964)

* add groupId to gradual rollout template (#2045)

* add groupId to gradual rollout template

* FMT

* Improve tabs UI on smaller devices (#2014)

* Improve tabs UI on smaller devices

* Improve tabs UI on smaller devices

* bug fix

* add proper scrollable tabs

* removed centered from Tabs (conflicts with scrollable)

* PR comments

* 4.15.0-beta.10

* Fix broken doc links (#2046)

## What

This PR fixes some broken links that have been hanging around in the
docs for what seems like a very long time.

## Why

As discovered by the link check in #1912, there are a fair few broken
links in the docs. Everyone hates broken links because it makes it
harder to understand what they were supposed to be pointing at.

## How

There are 3 types of links that have been fixed:
- Links that should have been internal but were absolute. E.g.
  `https://docs.getunleash.io/path/article` that should have been
  `./article.md`
- External links that have changed, such as Slack's API description
- GitHub links to files that either no longer exist or that have been
  moved. These links generally pointed to `master`/`main`, meaning
  they are subject to change. They have been replaced with permalinks
  pointing to specific commits.

-----

* docs: fix slack api doc link

* docs: update links in migration guide

* docs: fix broken link to ancient feature schema validation

* docs: update links to v3 auth hooks

* docs: update broken link in the go sdk article

* Fix: use permalink for GitHub link

* docs: fix wrong google auth link

* 4.15.0

* 4.15.1

* docs: update link for symfony sdk (#2048)



The doc link appears to have pointed at an address that is no longer reachable. Instead, let's point to the equivalent GitHub link

Relates to and closes #2047

* docs: test broken links in website (#1912)

The action triggers manually as a first step to test this functionality. In the near future, we might schedule it

* Schedule link checker action (#2050)

Runs at 12:30 UTC on Mon, Tue, Wed, Thu and Fri

* fix: add env and project labels to feature updated metrics. (#2043)

* Revert workflow (#2051)

* update snapshot

* PR comments

* Added Events and tests

* Throw error if token not found

Co-authored-by: Christopher Kolstad <chriswk@getunleash.ai>
Co-authored-by: Thomas Heartman <thomas@getunleash.ai>
Co-authored-by: Gastón Fournier <gaston@getunleash.ai>
Co-authored-by: Ivar Conradi Østhus <ivarconr@gmail.com>
Co-authored-by: sjaanus <sellinjaanus@gmail.com>
This commit is contained in:
andreas-unleash 2022-09-14 15:29:12 +03:00 committed by GitHub
parent ce3db75133
commit 6778d347cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1531 additions and 18 deletions

View File

@ -30,6 +30,7 @@ import UserSplashStore from './user-splash-store';
import RoleStore from './role-store';
import SegmentStore from './segment-store';
import GroupStore from './group-store';
import { PublicSignupTokenStore } from './public-signup-token-store';
export const createStores = (
config: IUnleashConfig,
@ -84,6 +85,11 @@ export const createStores = (
roleStore: new RoleStore(db, eventBus, getLogger),
segmentStore: new SegmentStore(db, eventBus, getLogger),
groupStore: new GroupStore(db),
publicSignupTokenStore: new PublicSignupTokenStore(
db,
eventBus,
getLogger,
),
};
};

View File

@ -0,0 +1,197 @@
import { EventEmitter } from 'events';
import { Knex } from 'knex';
import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events';
import { Logger, LogProvider } from '../logger';
import NotFoundError from '../error/notfound-error';
import { PublicSignupTokenSchema } from '../openapi/spec/public-signup-token-schema';
import { IPublicSignupTokenStore } from '../types/stores/public-signup-token-store';
import { UserSchema } from '../openapi/spec/user-schema';
import { IPublicSignupTokenCreate } from '../types/models/public-signup-token';
const TABLE = 'public_signup_tokens';
const TOKEN_USERS_TABLE = 'public_signup_tokens_user';
interface ITokenInsert {
secret: string;
name: string;
expires_at: Date;
created_at: Date;
created_by?: string;
role_id: number;
}
interface ITokenRow extends ITokenInsert {
users: UserSchema[];
}
interface ITokenUserRow {
secret: string;
user_id: number;
created_at: Date;
}
const tokenRowReducer = (acc, tokenRow) => {
const { userId, name, ...token } = tokenRow;
if (!acc[tokenRow.secret]) {
acc[tokenRow.secret] = {
secret: token.secret,
name: token.name,
expiresAt: token.expires_at,
createdAt: token.created_at,
createdBy: token.created_by,
roleId: token.role_id,
users: [],
};
}
const currentToken = acc[tokenRow.secret];
if (userId) {
currentToken.users.push({ userId, name });
}
return acc;
};
const toRow = (newToken: IPublicSignupTokenCreate) => {
if (!newToken) return;
return {
secret: newToken.secret,
name: newToken.name,
expires_at: newToken.expiresAt,
created_by: newToken.createdBy || null,
role_id: newToken.roleId,
};
};
const toTokens = (rows: any[]): PublicSignupTokenSchema[] => {
const tokens = rows.reduce(tokenRowReducer, {});
return Object.values(tokens);
};
export class PublicSignupTokenStore implements IPublicSignupTokenStore {
private logger: Logger;
private timer: Function;
private db: Knex;
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('public-signup-tokens.js');
this.timer = (action: string) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'public-signup-tokens',
action,
});
}
count(): Promise<number> {
return this.db(TABLE)
.count('*')
.then((res) => Number(res[0].count));
}
private makeTokenUsersQuery() {
return this.db<ITokenRow>(`${TABLE} as tokens`)
.leftJoin(
`${TOKEN_USERS_TABLE} as token_project_users`,
'tokens.secret',
'token_project_users.secret',
)
.leftJoin(`users`, 'token_project_users.user_id', 'users.id')
.select(
'tokens.secret',
'tokens.name',
'tokens.expires_at',
'tokens.created_at',
'tokens.created_by',
'tokens.role_id',
'token_project_users.user_id',
'users.name',
);
}
async getAll(): Promise<PublicSignupTokenSchema[]> {
const stopTimer = this.timer('getAll');
const rows = await this.makeTokenUsersQuery();
stopTimer();
return toTokens(rows);
}
async getAllActive(): Promise<PublicSignupTokenSchema[]> {
const stopTimer = this.timer('getAllActive');
const rows = await this.makeTokenUsersQuery()
.where('expires_at', 'IS', null)
.orWhere('expires_at', '>', 'now()');
stopTimer();
return toTokens(rows);
}
async addTokenUser(secret: string, userId: number): Promise<void> {
await this.db<ITokenUserRow>(TOKEN_USERS_TABLE).insert(
{ user_id: userId, secret },
['created_at'],
);
}
async insert(
newToken: IPublicSignupTokenCreate,
): Promise<PublicSignupTokenSchema> {
const response = await this.db<ITokenRow>(TABLE).insert(
toRow(newToken),
['created_at'],
);
return toTokens([response])[0];
}
async isValid(secret: string): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE secret = ? AND expires_at::date > ?) AS valid`,
[secret, new Date()],
);
const { valid } = result.rows[0];
return valid;
}
destroy(): void {}
async exists(secret: string): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE secret = ?) AS present`,
[secret],
);
const { present } = result.rows[0];
return present;
}
async get(key: string): Promise<PublicSignupTokenSchema> {
const row = await this.makeTokenUsersQuery()
.where('secret', key)
.first();
if (!row)
throw new NotFoundError('Could not find a token with that key');
return toTokens([row])[0];
}
async delete(secret: string): Promise<void> {
return this.db<ITokenInsert>(TABLE).where({ secret }).del();
}
async deleteAll(): Promise<void> {
return this.db<ITokenInsert>(TABLE).del();
}
async setExpiry(
secret: string,
expiresAt: Date,
): Promise<PublicSignupTokenSchema> {
const rows = await this.makeTokenUsersQuery()
.update({ expires_at: expiresAt })
.where('secret', secret)
.returning('*');
if (rows.length > 0) {
return toTokens(rows)[0];
}
throw new NotFoundError('Could not find public signup token.');
}
}

View File

@ -113,6 +113,10 @@ import { proxyMetricsSchema } from './spec/proxy-metrics-schema';
import { setUiConfigSchema } from './spec/set-ui-config-schema';
import { edgeTokenSchema } from './spec/edge-token-schema';
import { validateEdgeTokensSchema } from './spec/validate-edge-tokens-schema';
import { publicSignupTokenCreateSchema } from './spec/public-signup-token-create-schema';
import { publicSignupTokenSchema } from './spec/public-signup-token-schema';
import { publicSignupTokensSchema } from './spec/public-signup-tokens-schema';
import { publicSignupTokenUpdateSchema } from './spec/public-signup-token-update-schema';
// All schemas in `openapi/spec` should be listed here.
export const schemas = {
@ -140,6 +144,7 @@ export const schemas = {
createUserSchema,
dateSchema,
emailSchema,
edgeTokenSchema,
environmentSchema,
environmentsSchema,
eventSchema,
@ -182,6 +187,14 @@ export const schemas = {
playgroundRequestSchema,
playgroundResponseSchema,
projectEnvironmentSchema,
publicSignupTokenCreateSchema,
publicSignupTokenUpdateSchema,
publicSignupTokensSchema,
publicSignupTokenSchema,
proxyClientSchema,
proxyFeaturesSchema,
proxyFeatureSchema,
proxyMetricsSchema,
projectSchema,
projectsSchema,
resetPasswordSchema,
@ -219,11 +232,6 @@ export const schemas = {
variantSchema,
variantsSchema,
versionSchema,
proxyClientSchema,
proxyFeaturesSchema,
proxyFeatureSchema,
proxyMetricsSchema,
edgeTokenSchema,
validateEdgeTokensSchema,
};

View File

@ -0,0 +1,22 @@
import { validateSchema } from '../validate';
import { PublicSignupTokenSchema } from './public-signup-token-schema';
test('publicSignupTokenSchema', () => {
const data: PublicSignupTokenSchema = {
name: 'Default',
secret: 'some-secret',
expiresAt: new Date().toISOString(),
users: [],
role: { name: 'Viewer ', type: 'type', id: 1 },
createdAt: new Date().toISOString(),
createdBy: 'someone',
};
expect(
validateSchema('#/components/schemas/publicSignupTokenSchema', {}),
).not.toBeUndefined();
expect(
validateSchema('#/components/schemas/publicSignupTokenSchema', data),
).toBeUndefined();
});

View File

@ -0,0 +1,22 @@
import { FromSchema } from 'json-schema-to-ts';
export const publicSignupTokenCreateSchema = {
$id: '#/components/schemas/publicSignupTokenCreateSchema',
type: 'object',
additionalProperties: false,
required: ['name', 'expiresAt'],
properties: {
name: {
type: 'string',
},
expiresAt: {
type: 'string',
format: 'date-time',
},
},
components: {},
} as const;
export type PublicSignupTokenCreateSchema = FromSchema<
typeof publicSignupTokenCreateSchema
>;

View File

@ -0,0 +1,50 @@
import { FromSchema } from 'json-schema-to-ts';
import { userSchema } from './user-schema';
import { roleSchema } from './role-schema';
export const publicSignupTokenSchema = {
$id: '#/components/schemas/publicSignupTokenSchema',
type: 'object',
additionalProperties: false,
required: ['secret', 'name', 'expiresAt', 'createdAt', 'createdBy', 'role'],
properties: {
secret: {
type: 'string',
},
name: {
type: 'string',
},
expiresAt: {
type: 'string',
format: 'date-time',
},
createdAt: {
type: 'string',
format: 'date-time',
},
createdBy: {
type: 'string',
nullable: true,
},
users: {
type: 'array',
items: {
$ref: '#/components/schemas/userSchema',
},
nullable: true,
},
role: {
$ref: '#/components/schemas/roleSchema',
},
},
components: {
schemas: {
userSchema,
roleSchema,
},
},
} as const;
export type PublicSignupTokenSchema = FromSchema<
typeof publicSignupTokenSchema
>;

View File

@ -0,0 +1,19 @@
import { FromSchema } from 'json-schema-to-ts';
export const publicSignupTokenUpdateSchema = {
$id: '#/components/schemas/publicSignupTokenUpdateSchema',
type: 'object',
additionalProperties: false,
required: ['expiresAt'],
properties: {
expiresAt: {
type: 'string',
format: 'date-time',
},
},
components: {},
} as const;
export type PublicSignupTokenUpdateSchema = FromSchema<
typeof publicSignupTokenUpdateSchema
>;

View File

@ -0,0 +1,30 @@
import { FromSchema } from 'json-schema-to-ts';
import { userSchema } from './user-schema';
import { roleSchema } from './role-schema';
import { publicSignupTokenSchema } from './public-signup-token-schema';
export const publicSignupTokensSchema = {
$id: '#/components/schemas/publicSignupTokensSchema',
type: 'object',
additionalProperties: false,
required: ['tokens'],
properties: {
tokens: {
type: 'array',
items: {
$ref: '#/components/schemas/publicSignupTokenSchema',
},
},
},
components: {
schemas: {
publicSignupTokenSchema,
userSchema,
roleSchema,
},
},
} as const;
export type PublicSignupTokensSchema = FromSchema<
typeof publicSignupTokensSchema
>;

View File

@ -61,6 +61,11 @@ const OPENAPI_TAGS = [
description:
'Create, update, and delete [Unleash projects](https://docs.getunleash.io/user_guide/projects).',
},
{
name: 'Public signup tokens',
description:
'Create, update, and delete [Unleash Public Signup tokens](https://docs.getunleash.io/reference/public-signup-tokens).',
},
{
name: 'Strategies',
description:

View File

@ -1,6 +1,5 @@
import Controller from '../controller';
import { IUnleashServices } from '../../types/services';
import { IUnleashConfig } from '../../types/option';
import { IUnleashServices, IUnleashConfig } from '../../types';
import FeatureController from './feature';
import { FeatureTypeController } from './feature-type';
import ArchiveController from './archive';
@ -24,6 +23,8 @@ import UserSplashController from './user-splash';
import ProjectApi from './project';
import { EnvironmentsController } from './environments';
import ConstraintsController from './constraints';
import { PublicSignupController } from './public-signup';
import { conditionalMiddleware } from '../../middleware/conditional-middleware';
class AdminApi extends Controller {
constructor(config: IUnleashConfig, services: IUnleashServices) {
@ -101,6 +102,13 @@ class AdminApi extends Controller {
'/constraints',
new ConstraintsController(config, services).router,
);
this.app.use(
'/invite-link',
conditionalMiddleware(
() => config.flagResolver.isEnabled('publicSignup'),
new PublicSignupController(config, services).router,
),
);
}
}

View File

@ -0,0 +1,211 @@
import createStores from '../../../test/fixtures/store';
import { createTestConfig } from '../../../test/config/test-config';
import { createServices } from '../../services';
import getApp from '../../app';
import supertest from 'supertest';
import permissions from '../../../test/fixtures/permissions';
import { RoleName, RoleType } from '../../types/model';
import { CreateUserSchema } from '../../openapi/spec/create-user-schema';
describe('Public Signup API', () => {
async function getSetup() {
const stores = createStores();
const perms = permissions();
const config = createTestConfig({
preRouterHook: perms.hook,
});
config.flagResolver = {
isEnabled: jest.fn().mockResolvedValue(true),
getAll: jest.fn(),
};
stores.accessStore = {
...stores.accessStore,
addUserToRole: jest.fn(),
removeRolesOfTypeForUser: jest.fn(),
};
const services = createServices(stores, config);
const app = await getApp(config, stores, services);
await stores.roleStore.create({
name: RoleName.VIEWER,
roleType: RoleType.ROOT,
description: '',
});
return {
request: supertest(app),
stores,
perms,
destroy: () => {
services.versionService.destroy();
services.clientInstanceService.destroy();
services.publicSignupTokenService.destroy();
},
};
}
let stores;
let request;
let destroy;
beforeEach(async () => {
const setup = await getSetup();
stores = setup.stores;
request = setup.request;
destroy = setup.destroy;
});
afterEach(() => {
destroy();
});
const expireAt = (addDays: number = 7): Date => {
let now = new Date();
now.setDate(now.getDate() + addDays);
return now;
};
const createBody = () => ({
name: 'some-name',
expiresAt: expireAt(),
});
test('should create a token', async () => {
expect.assertions(4);
const appName = '123!23';
stores.clientApplicationsStore.upsert({ appName });
stores.roleStore.create({ name: RoleName.VIEWER });
const bodyCreate = createBody();
return request
.post('/api/admin/invite-link/tokens')
.send(bodyCreate)
.expect(201)
.expect(async (res) => {
const token = res.body;
expect(token.name).toBe(bodyCreate.name);
expect(token.secret).not.toBeNull();
expect(token.expiresAt).toBe(
bodyCreate.expiresAt.toISOString(),
);
const eventCount = await stores.eventStore.count();
expect(eventCount).toBe(1); //PUBLIC_SIGNUP_TOKEN_CREATED
});
});
test('should get All', async () => {
expect.assertions(1);
const appName = '123!23';
stores.clientApplicationsStore.upsert({ appName });
stores.publicSignupTokenStore.create({
name: 'some-name',
expiresAt: expireAt(),
});
return request
.get('/api/admin/invite-link/tokens')
.expect(200)
.expect((res) => {
const { tokens } = res.body;
expect(tokens[0].name).toBe('some-name');
});
});
test('should get token', async () => {
expect.assertions(1);
const appName = '123!23';
stores.clientApplicationsStore.upsert({ appName });
stores.publicSignupTokenStore.create({
name: 'some-name',
expiresAt: expireAt(),
});
return request
.get('/api/admin/invite-link/tokens/some-secret')
.expect(200)
.expect((res) => {
const token = res.body;
expect(token.name).toBe('some-name');
});
});
test('should expire token', async () => {
expect.assertions(1);
const appName = '123!23';
stores.clientApplicationsStore.upsert({ appName });
stores.publicSignupTokenStore.create({
name: 'some-name',
expiresAt: expireAt(),
});
return request
.delete('/api/admin/invite-link/tokens/some-secret')
.expect(200)
.expect(async () => {
const eventCount = await stores.eventStore.count();
expect(eventCount).toBe(1); // PUBLIC_SIGNUP_TOKEN_MANUALLY_EXPIRED
});
});
test('should create user and add to token', async () => {
expect.assertions(3);
const appName = '123!23';
stores.clientApplicationsStore.upsert({ appName });
stores.publicSignupTokenStore.create({
name: 'some-name',
expiresAt: expireAt(),
});
const user: CreateUserSchema = {
username: 'some-username',
email: 'someEmail@example.com',
name: 'some-name',
password: null,
rootRole: 1,
sendEmail: false,
};
return request
.post('/api/admin/invite-link/tokens/some-secret/signup')
.send(user)
.expect(201)
.expect(async (res) => {
const count = await stores.userStore.count();
expect(count).toBe(1);
const eventCount = await stores.eventStore.count();
expect(eventCount).toBe(2); //USER_CREATED && PUBLIC_SIGNUP_TOKEN_USER_ADDED
expect(res.body.username).toBe(user.username);
});
});
test('should return 200 if token is valid', async () => {
const appName = '123!23';
stores.clientApplicationsStore.upsert({ appName });
stores.publicSignupTokenStore.create({
name: 'some-name',
expiresAt: expireAt(),
});
return request
.post('/api/admin/invite-link/tokens/some-secret/validate')
.expect(200);
});
test('should return 401 if token is invalid', async () => {
const appName = '123!23';
stores.clientApplicationsStore.upsert({ appName });
return request
.post('/api/admin/invite-link/tokens/some-invalid-secret/validate')
.expect(401);
});
});

View File

@ -0,0 +1,289 @@
import { Response } from 'express';
import Controller from '../controller';
import { ADMIN, NONE } from '../../types/permissions';
import { Logger } from '../../logger';
import { AccessService } from '../../services/access-service';
import { IAuthRequest } from '../unleash-types';
import { IUnleashConfig, IUnleashServices } from '../../types';
import { OpenApiService } from '../../services/openapi-service';
import { createRequestSchema } from '../../openapi/util/create-request-schema';
import { createResponseSchema } from '../../openapi/util/create-response-schema';
import { serializeDates } from '../../types/serialize-dates';
import { emptyResponse } from '../../openapi/util/standard-responses';
import { PublicSignupTokenService } from '../../services/public-signup-token-service';
import UserService from '../../services/user-service';
import {
publicSignupTokenSchema,
PublicSignupTokenSchema,
} from '../../openapi/spec/public-signup-token-schema';
import {
publicSignupTokensSchema,
PublicSignupTokensSchema,
} from '../../openapi/spec/public-signup-tokens-schema';
import { PublicSignupTokenCreateSchema } from '../../openapi/spec/public-signup-token-create-schema';
import { PublicSignupTokenUpdateSchema } from '../../openapi/spec/public-signup-token-update-schema';
import { CreateUserSchema } from '../../openapi/spec/create-user-schema';
import { UserSchema, userSchema } from '../../openapi/spec/user-schema';
interface TokenParam {
token: string;
}
export class PublicSignupController extends Controller {
private publicSignupTokenService: PublicSignupTokenService;
private userService: UserService;
private accessService: AccessService;
private openApiService: OpenApiService;
private logger: Logger;
constructor(
config: IUnleashConfig,
{
publicSignupTokenService,
accessService,
userService,
openApiService,
}: Pick<
IUnleashServices,
| 'publicSignupTokenService'
| 'accessService'
| 'userService'
| 'openApiService'
>,
) {
super(config);
this.publicSignupTokenService = publicSignupTokenService;
this.accessService = accessService;
this.userService = userService;
this.openApiService = openApiService;
this.logger = config.getLogger('public-signup-controller.js');
this.route({
method: 'get',
path: '/tokens',
handler: this.getAllPublicSignupTokens,
permission: ADMIN,
middleware: [
openApiService.validPath({
tags: ['Public signup tokens'],
operationId: 'getAllPublicSignupTokens',
responses: {
200: createResponseSchema('publicSignupTokenSchema'),
},
}),
],
});
this.route({
method: 'post',
path: '/tokens',
handler: this.createPublicSignupToken,
permission: ADMIN,
middleware: [
openApiService.validPath({
tags: ['Public signup tokens'],
operationId: 'createApiToken',
requestBody: createRequestSchema(
'publicSignupTokenCreateSchema',
),
responses: {
201: createResponseSchema('publicSignupTokenSchema'),
},
}),
],
});
this.route({
method: 'post',
path: '/tokens/:token/signup',
handler: this.addTokenUser,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['Public signup tokens'],
operationId: 'addPublicSignupTokenUser',
requestBody: createRequestSchema('createUserSchema'),
responses: {
200: createResponseSchema('userSchema'),
},
}),
],
});
this.route({
method: 'get',
path: '/tokens/:token',
handler: this.getPublicSignupToken,
permission: ADMIN,
middleware: [
openApiService.validPath({
tags: ['Public signup tokens'],
operationId: 'getPublicSignupToken',
responses: {
200: createResponseSchema('publicSignupTokenSchema'),
},
}),
],
});
this.route({
method: 'put',
path: '/tokens/:token',
handler: this.updatePublicSignupToken,
permission: ADMIN,
middleware: [
openApiService.validPath({
tags: ['Public signup tokens'],
operationId: 'updatePublicSignupToken',
requestBody: createRequestSchema(
'publicSignupTokenUpdateSchema',
),
responses: {
200: emptyResponse,
},
}),
],
});
this.route({
method: 'delete',
path: '/tokens/:token',
handler: this.deletePublicSignupToken,
acceptAnyContentType: true,
permission: ADMIN,
middleware: [
openApiService.validPath({
tags: ['API tokens'],
operationId: 'deletePublicSignupToken',
responses: {
200: emptyResponse,
},
}),
],
});
this.route({
method: 'post',
path: '/tokens/:token/validate',
handler: this.validate,
acceptAnyContentType: true,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['API tokens'],
operationId: 'validateSignupToken',
responses: {
200: emptyResponse,
401: emptyResponse,
},
}),
],
});
}
async getAllPublicSignupTokens(
req: IAuthRequest,
res: Response<PublicSignupTokensSchema>,
): Promise<void> {
const tokens = await this.publicSignupTokenService.getAllActiveTokens();
this.openApiService.respondWithValidation(
200,
res,
publicSignupTokensSchema.$id,
{ tokens: serializeDates(tokens) },
);
}
async getPublicSignupToken(
req: IAuthRequest<TokenParam>,
res: Response<PublicSignupTokenSchema>,
): Promise<void> {
const { token } = req.params;
const result = await this.publicSignupTokenService.get(token);
this.openApiService.respondWithValidation(
200,
res,
publicSignupTokenSchema.$id,
serializeDates(result),
);
}
async validate(
req: IAuthRequest<TokenParam, void, CreateUserSchema>,
res: Response,
): Promise<void> {
const { token } = req.params;
const valid = await this.publicSignupTokenService.validate(token);
if (valid) return res.status(200).end();
else return res.status(401).end();
}
async addTokenUser(
req: IAuthRequest<TokenParam, void, CreateUserSchema>,
res: Response<UserSchema>,
): Promise<void> {
const { token } = req.params;
const user = await this.publicSignupTokenService.addTokenUser(
token,
req.body,
);
this.openApiService.respondWithValidation(
201,
res,
userSchema.$id,
serializeDates(user),
);
}
async createPublicSignupToken(
req: IAuthRequest<void, void, PublicSignupTokenCreateSchema>,
res: Response<PublicSignupTokenSchema>,
): Promise<void> {
const token =
await this.publicSignupTokenService.createNewPublicSignupToken(
req.body,
req.user.name,
);
this.openApiService.respondWithValidation(
201,
res,
publicSignupTokensSchema.$id,
serializeDates(token),
);
}
async updatePublicSignupToken(
req: IAuthRequest<TokenParam, void, PublicSignupTokenUpdateSchema>,
res: Response,
): Promise<any> {
const { token } = req.params;
const { expiresAt } = req.body;
if (!expiresAt) {
this.logger.error(req.body);
return res.status(400).send();
}
await this.publicSignupTokenService.setExpiry(
token,
new Date(expiresAt),
);
return res.status(200).end();
}
async deletePublicSignupToken(
req: IAuthRequest<TokenParam>,
res: Response,
): Promise<void> {
const { token } = req.params;
await this.publicSignupTokenService.delete(token, req.user.name);
res.status(200).end();
}
}

View File

@ -35,6 +35,7 @@ import { PlaygroundService } from './playground-service';
import { GroupService } from './group-service';
import { ProxyService } from './proxy-service';
import EdgeService from './edge-service';
import { PublicSignupTokenService } from './public-signup-token-service';
export const createServices = (
stores: IUnleashStores,
config: IUnleashConfig,
@ -101,6 +102,12 @@ export const createServices = (
const edgeService = new EdgeService(stores, config);
const publicSignupTokenService = new PublicSignupTokenService(
stores,
config,
userService,
);
return {
accessService,
addonService,
@ -136,6 +143,7 @@ export const createServices = (
groupService,
proxyService,
edgeService,
publicSignupTokenService,
};
};

View File

@ -0,0 +1,151 @@
import crypto from 'crypto';
import { Logger } from '../logger';
import { IUnleashConfig, IUnleashStores } from '../types';
import { minutesToMilliseconds } from 'date-fns';
import { IPublicSignupTokenStore } from '../types/stores/public-signup-token-store';
import { PublicSignupTokenSchema } from '../openapi/spec/public-signup-token-schema';
import { IRoleStore } from '../types/stores/role-store';
import { IPublicSignupTokenCreate } from '../types/models/public-signup-token';
import { PublicSignupTokenCreateSchema } from '../openapi/spec/public-signup-token-create-schema';
import { RoleName } from '../types/model';
import { IEventStore } from '../types/stores/event-store';
import {
PublicSignupTokenCreatedEvent,
PublicSignupTokenManuallyExpiredEvent,
PublicSignupTokenUserAddedEvent,
} from '../types/events';
import UserService, { ICreateUser } from './user-service';
import { IUser } from '../types/user';
export class PublicSignupTokenService {
private store: IPublicSignupTokenStore;
private roleStore: IRoleStore;
private eventStore: IEventStore;
private userService: UserService;
private logger: Logger;
private timer: NodeJS.Timeout;
private activeTokens: PublicSignupTokenSchema[] = [];
constructor(
{
publicSignupTokenStore,
roleStore,
eventStore,
}: Pick<
IUnleashStores,
'publicSignupTokenStore' | 'roleStore' | 'eventStore'
>,
config: Pick<IUnleashConfig, 'getLogger' | 'authentication'>,
userService: UserService,
) {
this.store = publicSignupTokenStore;
this.userService = userService;
this.roleStore = roleStore;
this.eventStore = eventStore;
this.logger = config.getLogger(
'/services/public-signup-token-service.ts',
);
this.fetchActiveTokens();
this.timer = setInterval(
() => this.fetchActiveTokens(),
minutesToMilliseconds(1),
).unref();
}
async fetchActiveTokens(): Promise<void> {
this.activeTokens = await this.getAllActiveTokens();
}
public async get(secret: string): Promise<PublicSignupTokenSchema> {
return this.store.get(secret);
}
public async getAllTokens(): Promise<PublicSignupTokenSchema[]> {
return this.store.getAll();
}
public async getAllActiveTokens(): Promise<PublicSignupTokenSchema[]> {
return this.store.getAllActive();
}
public async validate(secret: string): Promise<boolean> {
return this.store.isValid(secret);
}
public async setExpiry(
secret: string,
expireAt: Date,
): Promise<PublicSignupTokenSchema> {
return this.store.setExpiry(secret, expireAt);
}
public async addTokenUser(
secret: string,
createUser: ICreateUser,
): Promise<IUser> {
const user = await this.userService.createUser(createUser);
await this.store.addTokenUser(secret, user.id);
await this.eventStore.store(
new PublicSignupTokenUserAddedEvent({
createdBy: 'userId',
data: { secret, userId: user.id },
}),
);
return user;
}
public async delete(secret: string, expiredBy: string): Promise<void> {
await this.expireToken(secret);
await this.eventStore.store(
new PublicSignupTokenManuallyExpiredEvent({
createdBy: expiredBy,
data: { secret },
}),
);
}
private async expireToken(
secret: string,
): Promise<PublicSignupTokenSchema> {
return this.store.setExpiry(secret, new Date());
}
public async createNewPublicSignupToken(
tokenCreate: PublicSignupTokenCreateSchema,
createdBy: string,
): Promise<PublicSignupTokenSchema> {
const viewerRole = await this.roleStore.getRoleByName(RoleName.VIEWER);
const newToken: IPublicSignupTokenCreate = {
name: tokenCreate.name,
expiresAt: new Date(tokenCreate.expiresAt),
secret: this.generateSecretKey(),
roleId: viewerRole ? viewerRole.id : -1,
createdBy: createdBy,
};
const token = await this.store.insert(newToken);
this.activeTokens.push(token);
await this.eventStore.store(
new PublicSignupTokenCreatedEvent({
createdBy: createdBy,
data: newToken,
}),
);
return token;
}
private generateSecretKey(): string {
return crypto.randomBytes(32).toString('hex');
}
destroy(): void {
clearInterval(this.timer);
this.timer = null;
}
}

View File

@ -76,6 +76,11 @@ export const SETTING_DELETED = 'setting-deleted';
export const CLIENT_METRICS = 'client-metrics';
export const CLIENT_REGISTER = 'client-register';
export const PUBLIC_SIGNUP_TOKEN_CREATED = 'public-signup-token-created';
export const PUBLIC_SIGNUP_TOKEN_USER_ADDED = 'public-signup-token-user-added';
export const PUBLIC_SIGNUP_TOKEN_MANUALLY_EXPIRED =
'public-signup-token-manually-expired';
export interface IBaseEvent {
type: string;
createdBy: string;
@ -531,3 +536,30 @@ export class SettingUpdatedEvent extends BaseEvent {
this.data = eventData.data;
}
}
export class PublicSignupTokenCreatedEvent extends BaseEvent {
readonly data: any;
constructor(eventData: { createdBy: string; data: any }) {
super(PUBLIC_SIGNUP_TOKEN_CREATED, eventData.createdBy);
this.data = eventData.data;
}
}
export class PublicSignupTokenManuallyExpiredEvent extends BaseEvent {
readonly data: any;
constructor(eventData: { createdBy: string; data: any }) {
super(PUBLIC_SIGNUP_TOKEN_MANUALLY_EXPIRED, eventData.createdBy);
this.data = eventData.data;
}
}
export class PublicSignupTokenUserAddedEvent extends BaseEvent {
readonly data: any;
constructor(eventData: { createdBy: string; data: any }) {
super(PUBLIC_SIGNUP_TOKEN_USER_ADDED, eventData.createdBy);
this.data = eventData.data;
}
}

View File

@ -0,0 +1,13 @@
import User from '../user';
export interface IPublicSignupTokenCreate {
name: string;
expiresAt: Date;
roleId: number;
secret: string;
createdBy: string;
}
export interface IPublicSignupToken extends IPublicSignupTokenCreate {
users: User[];
}

View File

@ -115,6 +115,7 @@ export interface IUnleashOptions {
disableLegacyFeaturesApi?: boolean;
inlineSegmentConstraints?: boolean;
clientFeatureCaching?: Partial<IClientCachingOption>;
flagResolver?: IFlagResolver;
}
export interface IEmailOption {

View File

@ -31,6 +31,7 @@ import { PlaygroundService } from 'lib/services/playground-service';
import { GroupService } from '../services/group-service';
import { ProxyService } from '../services/proxy-service';
import EdgeService from '../services/edge-service';
import { PublicSignupTokenService } from '../services/public-signup-token-service';
export interface IUnleashServices {
accessService: AccessService;
@ -42,6 +43,7 @@ export interface IUnleashServices {
emailService: EmailService;
environmentService: EnvironmentService;
eventService: EventService;
edgeService: EdgeService;
featureTagService: FeatureTagService;
featureToggleService: FeatureToggleService;
featureToggleServiceV2: FeatureToggleService; // deprecated
@ -50,6 +52,9 @@ export interface IUnleashServices {
healthService: HealthService;
projectHealthService: ProjectHealthService;
projectService: ProjectService;
playgroundService: PlaygroundService;
proxyService: ProxyService;
publicSignupTokenService: PublicSignupTokenService;
resetTokenService: ResetTokenService;
sessionService: SessionService;
settingService: SettingService;
@ -64,7 +69,4 @@ export interface IUnleashServices {
segmentService: SegmentService;
openApiService: OpenApiService;
clientSpecService: ClientSpecService;
playgroundService: PlaygroundService;
proxyService: ProxyService;
edgeService: EdgeService;
}

View File

@ -26,6 +26,7 @@ import { IUserSplashStore } from './stores/user-splash-store';
import { IRoleStore } from './stores/role-store';
import { ISegmentStore } from './stores/segment-store';
import { IGroupStore } from './stores/group-store';
import { IPublicSignupTokenStore } from './stores/public-signup-token-store';
export interface IUnleashStores {
accessStore: IAccessStore;
@ -56,4 +57,5 @@ export interface IUnleashStores {
userSplashStore: IUserSplashStore;
roleStore: IRoleStore;
segmentStore: ISegmentStore;
publicSignupTokenStore: IPublicSignupTokenStore;
}

View File

@ -0,0 +1,19 @@
import { Store } from './store';
import { PublicSignupTokenSchema } from '../../openapi/spec/public-signup-token-schema';
import { IPublicSignupTokenCreate } from '../models/public-signup-token';
export interface IPublicSignupTokenStore
extends Store<PublicSignupTokenSchema, string> {
getAllActive(): Promise<PublicSignupTokenSchema[]>;
insert(
newToken: IPublicSignupTokenCreate,
): Promise<PublicSignupTokenSchema>;
addTokenUser(secret: string, userId: number): Promise<void>;
isValid(secret): Promise<boolean>;
setExpiry(
secret: string,
expiresAt: Date,
): Promise<PublicSignupTokenSchema>;
delete(secret: string): Promise<void>;
count(): Promise<number>;
}

View File

@ -0,0 +1,37 @@
'use strict';
exports.up = function (db, callback) {
db.runSql(
`
create table IF NOT EXISTS public_signup_tokens
(
secret text primary key,
name text,
expires_at timestamp with time zone not null,
created_at timestamp with time zone not null default now(),
created_by text,
role_id integer not null references roles (id) ON DELETE CASCADE
);
create table IF NOT EXISTS public_signup_tokens_user
(
secret text not null references public_signup_tokens (secret) on DELETE CASCADE,
user_id integer not null references users (id) ON DELETE CASCADE,
created_at timestamp with time zone not null default now(),
primary key (secret, user_id)
);
`,
callback,
);
};
exports.down = function (db, callback) {
db.runSql(
`
DROP TABLE public_signup_tokens;
DROP TABLE public_signup_tokens_user;
DROP TABLE public_signup_tokens_role;
`,
callback,
);
};

View File

@ -2353,6 +2353,93 @@ exports[`should serve the OpenAPI spec 1`] = `
],
"type": "object",
},
"publicSignupTokenCreateSchema": {
"additionalProperties": false,
"properties": {
"expiresAt": {
"format": "date-time",
"type": "string",
},
"name": {
"type": "string",
},
},
"required": [
"name",
"expiresAt",
],
"type": "object",
},
"publicSignupTokenSchema": {
"additionalProperties": false,
"properties": {
"createdAt": {
"format": "date-time",
"type": "string",
},
"createdBy": {
"nullable": true,
"type": "string",
},
"expiresAt": {
"format": "date-time",
"type": "string",
},
"name": {
"type": "string",
},
"role": {
"$ref": "#/components/schemas/roleSchema",
},
"secret": {
"type": "string",
},
"users": {
"items": {
"$ref": "#/components/schemas/userSchema",
},
"nullable": true,
"type": "array",
},
},
"required": [
"secret",
"name",
"expiresAt",
"createdAt",
"createdBy",
"role",
],
"type": "object",
},
"publicSignupTokenUpdateSchema": {
"additionalProperties": false,
"properties": {
"expiresAt": {
"format": "date-time",
"type": "string",
},
},
"required": [
"expiresAt",
],
"type": "object",
},
"publicSignupTokensSchema": {
"additionalProperties": false,
"properties": {
"tokens": {
"items": {
"$ref": "#/components/schemas/publicSignupTokenSchema",
},
"type": "array",
},
},
"required": [
"tokens",
],
"type": "object",
},
"resetPasswordSchema": {
"additionalProperties": false,
"properties": {
@ -4346,6 +4433,205 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
"/api/admin/invite-link/tokens": {
"get": {
"operationId": "getAllPublicSignupTokens",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/publicSignupTokenSchema",
},
},
},
"description": "publicSignupTokenSchema",
},
},
"tags": [
"Public signup tokens",
],
},
"post": {
"operationId": "createApiToken",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/publicSignupTokenCreateSchema",
},
},
},
"description": "publicSignupTokenCreateSchema",
"required": true,
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/publicSignupTokenSchema",
},
},
},
"description": "publicSignupTokenSchema",
},
},
"tags": [
"Public signup tokens",
],
},
},
"/api/admin/invite-link/tokens/{token}": {
"delete": {
"operationId": "deletePublicSignupToken",
"parameters": [
{
"in": "path",
"name": "token",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"description": "This response has no body.",
},
},
"tags": [
"API tokens",
],
},
"get": {
"operationId": "getPublicSignupToken",
"parameters": [
{
"in": "path",
"name": "token",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/publicSignupTokenSchema",
},
},
},
"description": "publicSignupTokenSchema",
},
},
"tags": [
"Public signup tokens",
],
},
"put": {
"operationId": "updatePublicSignupToken",
"parameters": [
{
"in": "path",
"name": "token",
"required": true,
"schema": {
"type": "string",
},
},
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/publicSignupTokenUpdateSchema",
},
},
},
"description": "publicSignupTokenUpdateSchema",
"required": true,
},
"responses": {
"200": {
"description": "This response has no body.",
},
},
"tags": [
"Public signup tokens",
],
},
},
"/api/admin/invite-link/tokens/{token}/signup": {
"post": {
"operationId": "addPublicSignupTokenUser",
"parameters": [
{
"in": "path",
"name": "token",
"required": true,
"schema": {
"type": "string",
},
},
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/createUserSchema",
},
},
},
"description": "createUserSchema",
"required": true,
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/userSchema",
},
},
},
"description": "userSchema",
},
},
"tags": [
"Public signup tokens",
],
},
},
"/api/admin/invite-link/tokens/{token}/validate": {
"post": {
"operationId": "validateSignupToken",
"parameters": [
{
"in": "path",
"name": "token",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"description": "This response has no body.",
},
"404": {
"description": "This response has no body.",
},
},
"tags": [
"API tokens",
],
},
},
"/api/admin/metrics/applications": {
"get": {
"operationId": "getApplications",
@ -7028,6 +7314,10 @@ If the provided project does not exist, the list of events will be empty.",
"description": "Create, update, and delete [Unleash projects](https://docs.getunleash.io/user_guide/projects).",
"name": "Projects",
},
{
"description": "Create, update, and delete [Unleash Public Signup tokens](https://docs.getunleash.io/reference/public-signup-tokens).",
"name": "Public signup tokens",
},
{
"description": "Create, update, delete, manage [custom strategies](https://docs.getunleash.io/advanced/custom_activation_strategy).",
"name": "Strategies",

View File

@ -0,0 +1,85 @@
import { IPublicSignupTokenStore } from '../../lib/types/stores/public-signup-token-store';
import { PublicSignupTokenSchema } from '../../lib/openapi/spec/public-signup-token-schema';
import { IPublicSignupTokenCreate } from '../../lib/types/models/public-signup-token';
export default class FakePublicSignupStore implements IPublicSignupTokenStore {
tokens: PublicSignupTokenSchema[] = [];
async addTokenUser(secret: string, userId: number): Promise<void> {
this.get(secret).then((token) => token.users.push({ id: userId }));
return Promise.resolve();
}
async get(secret: string): Promise<PublicSignupTokenSchema> {
const token = this.tokens.find((t) => t.secret === secret);
return Promise.resolve(token);
}
async isValid(secret: string): Promise<boolean> {
const token = this.tokens.find((t) => t.secret === secret);
return Promise.resolve(token && new Date(token.expiresAt) > new Date());
}
async count(): Promise<number> {
return Promise.resolve(0);
}
// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars
async delete(secret: string): Promise<void> {
return Promise.resolve(undefined);
}
async getAllActive(): Promise<PublicSignupTokenSchema[]> {
return Promise.resolve(this.tokens);
}
async create(
newToken: IPublicSignupTokenCreate,
): Promise<PublicSignupTokenSchema> {
return this.insert(newToken);
}
async insert(
newToken: IPublicSignupTokenCreate,
): Promise<PublicSignupTokenSchema> {
const token = {
secret: 'some-secret',
expiresAt: newToken.expiresAt.toISOString(),
createdAt: new Date().toISOString(),
users: [],
name: newToken.name,
role: {
name: 'Viewer',
type: '',
id: 1,
},
createdBy: null,
};
this.tokens.push(token);
return Promise.resolve(token);
}
async setExpiry(
secret: string,
expiresAt: Date,
): Promise<PublicSignupTokenSchema> {
const token = await this.get(secret);
token.expiresAt = expiresAt.toISOString();
return Promise.resolve(token);
}
async deleteAll(): Promise<void> {
return Promise.resolve(undefined);
}
destroy(): void {}
async exists(key: string): Promise<boolean> {
return this.tokens.some((t) => t.secret === key);
}
// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars
async getAll(query?: Object): Promise<PublicSignupTokenSchema[]> {
return Promise.resolve([]);
}
}

View File

@ -8,6 +8,8 @@ import {
} from 'lib/types/stores/role-store';
export default class FakeRoleStore implements IRoleStore {
roles: ICustomRole[] = [];
getGroupRolesForProject(projectId: string): Promise<IRole[]> {
throw new Error('Method not implemented.');
}
@ -16,12 +18,14 @@ export default class FakeRoleStore implements IRoleStore {
throw new Error('Method not implemented.');
}
getAll(): Promise<ICustomRole[]> {
throw new Error('Method not implemented.');
async getAll(): Promise<ICustomRole[]> {
return this.roles;
}
create(role: ICustomRoleInsert): Promise<ICustomRole> {
throw new Error('Method not implemented.');
async create(role: ICustomRoleInsert): Promise<ICustomRole> {
const roleCreated = { ...role, id: 1, type: 'some-type' };
this.roles.push(roleCreated);
return Promise.resolve(roleCreated);
}
update(role: ICustomRoleUpdate): Promise<ICustomRole> {
@ -36,8 +40,8 @@ export default class FakeRoleStore implements IRoleStore {
throw new Error('Method not implemented.');
}
getRoleByName(name: string): Promise<IRole> {
throw new Error('Method not implemented.');
async getRoleByName(name: string): Promise<IRole> {
return this.roles.find((r) => (r.name = name));
}
getRolesForProject(projectId: string): Promise<IRole[]> {
@ -52,8 +56,8 @@ export default class FakeRoleStore implements IRoleStore {
throw new Error('Method not implemented.');
}
getRootRoles(): Promise<IRole[]> {
throw new Error('Method not implemented.');
async getRootRoles(): Promise<IRole[]> {
return this.roles;
}
getRootRoleForAllUsers(): Promise<IUserRole[]> {

View File

@ -27,6 +27,7 @@ import FakeUserSplashStore from './fake-user-splash-store';
import FakeRoleStore from './fake-role-store';
import FakeSegmentStore from './fake-segment-store';
import FakeGroupStore from './fake-group-store';
import FakePublicSignupStore from './fake-public-signup-store';
const createStores: () => IUnleashStores = () => {
const db = {
@ -65,6 +66,7 @@ const createStores: () => IUnleashStores = () => {
roleStore: new FakeRoleStore(),
segmentStore: new FakeSegmentStore(),
groupStore: new FakeGroupStore(),
publicSignupTokenStore: new FakePublicSignupStore(),
};
};