1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-10-28 19:06:12 +01:00

feat: add resource limits for API tokens (#7510)

This PR adds the back end for API token resource limits. 

It adds the limit to the schema and checks the limit in the service.

## Discussion points

The PAT service uses a different service and different store entirely,
so I have not included testing any edge cases where PATs are included.
However, that could be seen as "knowing too much". We could add tests
that check both of the stores in tandem, but I think it's overkill for
now.
This commit is contained in:
Thomas Heartman 2024-07-02 14:41:31 +02:00 committed by GitHub
parent 0a2f7e5a61
commit b2522f9199
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 133 additions and 1 deletions

View File

@ -196,6 +196,7 @@ exports[`should create default config 1`] = `
"actionSetFilterValues": 25,
"actionSetFilters": 5,
"actionSetsPerProject": 5,
"apiTokens": 2000,
"constraintValues": 250,
"environments": 50,
"featureEnvironmentStrategies": 30,

View File

@ -661,6 +661,10 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
process.env.UNLEASH_ENVIRONMENTS_LIMIT,
50,
),
apiTokens: Math.max(
0,
parseEnvVarNumber(process.env.UNLEASH_API_TOKENS_LIMIT, 2000),
),
projects: parseEnvVarNumber(process.env.UNLEASH_PROJECTS_LIMIT, 500),
};

View File

@ -83,6 +83,13 @@ export const resourceLimitsSchema = {
example: 50,
description: 'The maximum number of environments allowed.',
},
apiTokens: {
type: 'integer',
minimum: 0,
example: 2000,
description:
'The maximum number of SDK and admin API tokens you can have at the same time. This limit applies only to server-side and client-side SDK tokens and to admin tokens. Personal access tokens are not subject to this limit. The limit applies to the total number of tokens across all projects in your organization.',
},
projects: {
type: 'integer',
minimum: 1,

View File

@ -0,0 +1,99 @@
import { ApiTokenService } from './api-token-service';
import { createTestConfig } from '../../test/config/test-config';
import type { IUnleashConfig } from '../server-impl';
import { ApiTokenType } from '../types/models/api-token';
import FakeApiTokenStore from '../../test/fixtures/fake-api-token-store';
import FakeEnvironmentStore from '../features/project-environments/fake-environment-store';
import { createFakeEventsService } from '../../lib/features';
import { ExceedsLimitError } from '../error/exceeds-limit-error';
const createServiceWithLimit = (limit: number) => {
const config: IUnleashConfig = createTestConfig({
experimental: {
flags: {
resourceLimits: true,
},
},
});
const apiTokenStore = new FakeApiTokenStore();
const environmentStore = new FakeEnvironmentStore();
environmentStore.create({
name: 'production',
type: 'production',
enabled: true,
protected: true,
sortOrder: 1,
});
const eventService = createFakeEventsService(config);
config.resourceLimits.apiTokens = limit;
const service = new ApiTokenService(
{ apiTokenStore, environmentStore },
config,
eventService,
);
return service;
};
test('Should allow you to create tokens up to and including the limit', async () => {
const limit = 3;
const service = createServiceWithLimit(limit);
for (let i = 0; i < limit; i++) {
await service.createApiTokenWithProjects(
{
tokenName: `token-${i}`,
type: ApiTokenType.CLIENT,
environment: 'production',
projects: ['*'],
},
{
id: 1,
username: 'audit user',
ip: '127.0.0.1',
},
);
}
});
test.each([ApiTokenType.ADMIN, ApiTokenType.CLIENT, ApiTokenType.FRONTEND])(
"Should prevent you from creating %s tokens when you're already at the limit",
async (tokenType) => {
const limit = 1;
const service = createServiceWithLimit(limit);
const auditUser = {
id: 1,
username: 'audit user',
ip: '127.0.0.1',
};
await service.createApiTokenWithProjects(
{
tokenName: 'token-1',
type: ApiTokenType.CLIENT,
environment: 'production',
projects: ['*'],
},
auditUser,
);
const environment =
tokenType === ApiTokenType.ADMIN ? '*' : 'production';
await expect(
service.createApiTokenWithProjects(
{
tokenName: 'exceeds-limit',
type: tokenType,
environment,
projects: ['*'],
},
auditUser,
),
).rejects.toThrow(ExceedsLimitError);
},
);

View File

@ -33,6 +33,8 @@ import type EventService from '../features/events/event-service';
import { addMinutes, isPast } from 'date-fns';
import metricsHelper from '../util/metrics-helper';
import { FUNCTION_TIME } from '../metric-events';
import type { ResourceLimitsSchema } from '../openapi';
import { ExceedsLimitError } from '../error/exceeds-limit-error';
const resolveTokenPermissions = (tokenType: string) => {
if (tokenType === ApiTokenType.ADMIN) {
@ -69,6 +71,8 @@ export class ApiTokenService {
private timer: Function;
private resourceLimits: ResourceLimitsSchema;
constructor(
{
apiTokenStore,
@ -76,7 +80,11 @@ export class ApiTokenService {
}: Pick<IUnleashStores, 'apiTokenStore' | 'environmentStore'>,
config: Pick<
IUnleashConfig,
'getLogger' | 'authentication' | 'flagResolver' | 'eventBus'
| 'getLogger'
| 'authentication'
| 'flagResolver'
| 'eventBus'
| 'resourceLimits'
>,
eventService: EventService,
) {
@ -85,6 +93,7 @@ export class ApiTokenService {
this.environmentStore = environmentStore;
this.flagResolver = config.flagResolver;
this.logger = config.getLogger('/services/api-token-service.ts');
this.resourceLimits = config.resourceLimits;
if (!this.flagResolver.isEnabled('useMemoizedActiveTokens')) {
// This is probably not needed because the scheduler will run it
this.fetchActiveTokens();
@ -286,11 +295,23 @@ export class ApiTokenService {
const environments = await this.environmentStore.getAll();
validateApiTokenEnvironment(newToken, environments);
await this.validateApiTokenLimit();
const secret = this.generateSecretKey(newToken);
const createNewToken = { ...newToken, secret };
return this.insertNewApiToken(createNewToken, auditUser);
}
private async validateApiTokenLimit() {
if (this.flagResolver.isEnabled('resourceLimits')) {
const currentTokenCount = await this.store.count();
const limit = this.resourceLimits.apiTokens;
if (currentTokenCount >= limit) {
throw new ExceedsLimitError('api token', limit);
}
}
}
// TODO: Remove this service method after embedded proxy has been released in
// 4.16.0
public async createMigratedProxyApiToken(