1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-06 00:07:44 +01:00
unleash.unleash/src/lib/db/api-token-store.ts

234 lines
7.0 KiB
TypeScript
Raw Normal View History

2021-03-29 19:58:11 +02:00
import { EventEmitter } from 'events';
2021-05-02 21:11:17 +02:00
import metricsHelper from '../util/metrics-helper';
2021-04-29 10:21:29 +02:00
import { DB_TIME } from '../metric-events';
2021-03-29 19:58:11 +02:00
import { Logger, LogProvider } from '../logger';
import NotFoundError from '../error/notfound-error';
import { IApiTokenStore } from '../types/stores/api-token-store';
import {
ApiTokenType,
IApiToken,
IApiTokenCreate,
isAllProjects,
} from '../types/models/api-token';
import { ALL_PROJECTS } from '../util/constants';
import { Db } from './db';
chore: handle transactions already started at the controller layer (#4953) ## About the changes This PR adds a method to safeguard us from opening a new transaction while inside another transaction, resulting in two isolated transactions that will not be atomic (if one fails, the other might still complete successfully). https://github.com/knex/knex/blob/bbbe4d4637b3838e4a297a457460cd2c76a700d5/lib/knex-builder/make-knex.js#L143C5-L144C88 We're currently opening transactions at the controller layer https://github.com/Unleash/unleash/blob/2746bd151766f8afbbaa2f640e8ebee6f4f98086/src/lib/features/export-import-toggles/export-import-controller.ts#L206-L208 but in some other places, we do it at the store level: https://github.com/Unleash/unleash/blob/2746bd151766f8afbbaa2f640e8ebee6f4f98086/src/lib/db/access-store.ts#L577 ## Alternative We can remove store-level transactions and move them to the controller following this approach: https://github.com/Unleash/unleash/blob/cb034976b93abc799df774858d716a49f645d669/src/lib/services/index.ts#L282-L284 https://github.com/Unleash/unleash/blob/cb034976b93abc799df774858d716a49f645d669/src/lib/features/export-import-toggles/export-import-controller.ts#L206-L208 This option is more expensive because we have to: 1. Write the factory methods that propagate the transaction to the stores (therefore creating the store factory methods as well) 2. Identify the methods for creating the transactions at the store level and backtrack the calls until the controller layer
2023-10-06 13:38:32 +02:00
import { inTransaction } from './transaction';
2021-03-29 19:58:11 +02:00
const TABLE = 'api_tokens';
const API_LINK_TABLE = 'api_token_project';
2021-03-29 19:58:11 +02:00
const ALL = '*';
interface ITokenInsert {
2021-03-29 19:58:11 +02:00
id: number;
secret: string;
username: string;
type: ApiTokenType;
expires_at?: Date;
created_at: Date;
seen_at?: Date;
environment: string;
tokenName?: string;
}
interface ITokenRow extends ITokenInsert {
project: string;
2021-03-29 19:58:11 +02:00
}
const tokenRowReducer = (acc, tokenRow) => {
const { project, ...token } = tokenRow;
if (!acc[tokenRow.secret]) {
acc[tokenRow.secret] = {
secret: token.secret,
tokenName: token.token_name ? token.token_name : token.username,
type: token.type.toLowerCase(),
project: ALL,
projects: [ALL],
environment: token.environment ? token.environment : ALL,
expiresAt: token.expires_at,
createdAt: token.created_at,
alias: token.alias,
seenAt: token.seen_at,
username: token.token_name ? token.token_name : token.username,
};
}
const currentToken = acc[tokenRow.secret];
if (tokenRow.project) {
if (isAllProjects(currentToken.projects)) {
currentToken.projects = [];
}
currentToken.projects.push(tokenRow.project);
currentToken.project = currentToken.projects.join(',');
}
return acc;
};
2021-03-29 19:58:11 +02:00
const toRow = (newToken: IApiTokenCreate) => ({
username: newToken.tokenName ?? newToken.username,
token_name: newToken.tokenName ?? newToken.username,
2021-03-29 19:58:11 +02:00
secret: newToken.secret,
type: newToken.type,
environment:
newToken.environment === ALL ? undefined : newToken.environment,
2021-03-29 19:58:11 +02:00
expires_at: newToken.expiresAt,
alias: newToken.alias || null,
2021-03-29 19:58:11 +02:00
});
const toTokens = (rows: any[]): IApiToken[] => {
const tokens = rows.reduce(tokenRowReducer, {});
return Object.values(tokens);
};
2021-03-29 19:58:11 +02:00
export class ApiTokenStore implements IApiTokenStore {
2021-03-29 19:58:11 +02:00
private logger: Logger;
private timer: Function;
private db: Db;
2021-03-29 19:58:11 +02:00
constructor(db: Db, eventBus: EventEmitter, getLogger: LogProvider) {
2021-03-29 19:58:11 +02:00
this.db = db;
this.logger = getLogger('api-tokens.js');
this.timer = (action: string) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'api-tokens',
action,
});
}
async count(): Promise<number> {
return this.db(TABLE)
.count('*')
.then((res) => Number(res[0].count));
}
async countByType(): Promise<Map<string, number>> {
return this.db(TABLE)
.select('type')
.count('*')
.groupBy('type')
.then((res) => {
const map = new Map<string, number>();
res.forEach((row) => {
map.set(row.type.toString(), Number(row.count));
});
return map;
});
}
2021-03-29 19:58:11 +02:00
async getAll(): Promise<IApiToken[]> {
const stopTimer = this.timer('getAll');
const rows = await this.makeTokenProjectQuery();
2021-03-29 19:58:11 +02:00
stopTimer();
return toTokens(rows);
2021-03-29 19:58:11 +02:00
}
async getAllActive(): Promise<IApiToken[]> {
const stopTimer = this.timer('getAllActive');
const rows = await this.makeTokenProjectQuery()
.where('expires_at', 'IS', null)
.orWhere('expires_at', '>', 'now()');
2021-03-29 19:58:11 +02:00
stopTimer();
return toTokens(rows);
}
private makeTokenProjectQuery() {
return this.db<ITokenRow>(`${TABLE} as tokens`)
.leftJoin(
`${API_LINK_TABLE} as token_project_link`,
'tokens.secret',
'token_project_link.secret',
)
.select(
'tokens.secret',
'username',
'token_name',
'type',
'expires_at',
'created_at',
'alias',
'seen_at',
'environment',
'token_project_link.project',
);
2021-03-29 19:58:11 +02:00
}
async insert(newToken: IApiTokenCreate): Promise<IApiToken> {
chore: handle transactions already started at the controller layer (#4953) ## About the changes This PR adds a method to safeguard us from opening a new transaction while inside another transaction, resulting in two isolated transactions that will not be atomic (if one fails, the other might still complete successfully). https://github.com/knex/knex/blob/bbbe4d4637b3838e4a297a457460cd2c76a700d5/lib/knex-builder/make-knex.js#L143C5-L144C88 We're currently opening transactions at the controller layer https://github.com/Unleash/unleash/blob/2746bd151766f8afbbaa2f640e8ebee6f4f98086/src/lib/features/export-import-toggles/export-import-controller.ts#L206-L208 but in some other places, we do it at the store level: https://github.com/Unleash/unleash/blob/2746bd151766f8afbbaa2f640e8ebee6f4f98086/src/lib/db/access-store.ts#L577 ## Alternative We can remove store-level transactions and move them to the controller following this approach: https://github.com/Unleash/unleash/blob/cb034976b93abc799df774858d716a49f645d669/src/lib/services/index.ts#L282-L284 https://github.com/Unleash/unleash/blob/cb034976b93abc799df774858d716a49f645d669/src/lib/features/export-import-toggles/export-import-controller.ts#L206-L208 This option is more expensive because we have to: 1. Write the factory methods that propagate the transaction to the stores (therefore creating the store factory methods as well) 2. Identify the methods for creating the transactions at the store level and backtrack the calls until the controller layer
2023-10-06 13:38:32 +02:00
const response = await inTransaction(this.db, async (tx) => {
const [row] = await tx<ITokenInsert>(TABLE).insert(
toRow(newToken),
['created_at'],
);
const updateProjectTasks = (newToken.projects || [])
.filter((project) => {
return project !== ALL_PROJECTS;
})
.map((project) => {
return tx.raw(
`INSERT INTO ${API_LINK_TABLE} VALUES (?, ?)`,
[newToken.secret, project],
);
});
await Promise.all(updateProjectTasks);
return {
...newToken,
username: newToken.tokenName,
alias: newToken.alias || null,
project: newToken.projects?.join(',') || '*',
createdAt: row.created_at,
};
});
return response;
2021-03-29 19:58:11 +02:00
}
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<IApiToken> {
const row = await this.makeTokenProjectQuery().where(
'tokens.secret',
key,
);
return toTokens(row)[0];
}
2021-03-29 19:58:11 +02:00
async delete(secret: string): Promise<void> {
return this.db<ITokenRow>(TABLE).where({ secret }).del();
2021-03-29 19:58:11 +02:00
}
async deleteAll(): Promise<void> {
return this.db<ITokenRow>(TABLE).del();
}
2021-03-29 19:58:11 +02:00
async setExpiry(secret: string, expiresAt: Date): Promise<IApiToken> {
const rows = await this.makeTokenProjectQuery()
2021-03-29 19:58:11 +02:00
.update({ expires_at: expiresAt })
.where({ secret })
.returning('*');
if (rows.length > 0) {
return toTokens(rows)[0];
2021-03-29 19:58:11 +02:00
}
throw new NotFoundError('Could not find api-token.');
}
async markSeenAt(secrets: string[]): Promise<void> {
const now = new Date();
try {
await this.db(TABLE)
.whereIn('secret', secrets)
2021-03-29 19:58:11 +02:00
.update({ seen_at: now });
} catch (err) {
this.logger.error('Could not update lastSeen, error: ', err);
}
}
}