Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: statistics for orphaned tokens #7568

Merged
merged 10 commits into from
Jul 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/lib/__snapshots__/create-config.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ exports[`should create default config 1`] = `
"flagResolver": FlagResolver {
"experiments": {
"adminTokenKillSwitch": false,
"allowOrphanedWildcardTokens": false,
"allowOrphanedWildcardTokens": true,
"anonymiseEventLog": false,
"anonymizeProjectOwners": false,
"automatedActions": false,
Expand Down
70 changes: 69 additions & 1 deletion src/lib/db/api-token-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ const createTokenRowReducer =
if (
!allowOrphanedWildcardTokens &&
!tokenRow.project &&
!tokenRow.secret.startsWith('*:') &&
tokenRow.secret.includes(':') && // Exclude v1 tokens
!tokenRow.secret.startsWith('*:') && // Exclude intentionally wildcard
(tokenRow.type === ApiTokenType.CLIENT ||
tokenRow.type === ApiTokenType.FRONTEND)
) {
Expand Down Expand Up @@ -270,4 +271,71 @@ export class ApiTokenStore implements IApiTokenStore {
this.logger.error('Could not update lastSeen, error: ', err);
}
}

async countDeprecatedTokens(): Promise<{
orphanedTokens: number;
activeOrphanedTokens: number;
legacyTokens: number;
activeLegacyTokens: number;
}> {
const allLegacyCount = this.db<ITokenRow>(`${TABLE} as tokens`)
.where('tokens.secret', 'NOT LIKE', '%:%')
.count()
.first()
.then((res) => Number(res?.count) || 0);

const activeLegacyCount = this.db<ITokenRow>(`${TABLE} as tokens`)
.where('tokens.secret', 'NOT LIKE', '%:%')
.andWhereRaw("tokens.seen_at > NOW() - INTERVAL '3 MONTH'")
.count()
.first()
.then((res) => Number(res?.count) || 0);

const orphanedTokensQuery = this.db<ITokenRow>(`${TABLE} as tokens`)
.leftJoin(
`${API_LINK_TABLE} as token_project_link`,
'tokens.secret',
'token_project_link.secret',
)
.whereNull('token_project_link.project')
.andWhere('tokens.secret', 'NOT LIKE', '*:%') // Exclude intentionally wildcard tokens
.andWhere('tokens.secret', 'LIKE', '%:%') // Exclude legacy tokens
.andWhere((builder) => {
builder
.where('tokens.type', ApiTokenType.CLIENT)
.orWhere('tokens.type', ApiTokenType.FRONTEND);
});

const allOrphanedCount = orphanedTokensQuery
.clone()
.count()
.first()
.then((res) => Number(res?.count) || 0);

const activeOrphanedCount = orphanedTokensQuery
.clone()
.andWhereRaw("tokens.seen_at > NOW() - INTERVAL '3 MONTH'")
.count()
.first()
.then((res) => Number(res?.count) || 0);

const [
orphanedTokens,
activeOrphanedTokens,
legacyTokens,
activeLegacyTokens,
] = await Promise.all([
allOrphanedCount,
activeOrphanedCount,
allLegacyCount,
activeLegacyCount,
]);

return {
orphanedTokens,
activeOrphanedTokens,
legacyTokens,
activeLegacyTokens,
};
}
}
4 changes: 3 additions & 1 deletion src/lib/features/project/project-service.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ beforeAll(async () => {
const config = createTestConfig({
getLogger,
experimental: {
flags: {},
flags: {
cleanApiTokenWhenOrphaned: true,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fun. Killswitch being disabled by default was masking that tests related to other flag don't pass.

},
},
});
eventService = new EventService(stores, config);
Expand Down
34 changes: 34 additions & 0 deletions src/lib/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,26 @@ export default class MetricsMonitor {
labelNames: ['project_id'],
});

const orphanedTokensTotal = createGauge({
name: 'orphaned_api_tokens_total',
help: 'Number of API tokens without a project',
});

const orphanedTokensActive = createGauge({
name: 'orphaned_api_tokens_active',
help: 'Number of API tokens without a project, last seen within 3 months',
});

const legacyTokensTotal = createGauge({
name: 'legacy_api_tokens_total',
help: 'Number of API tokens with v1 format',
});

const legacyTokensActive = createGauge({
name: 'legacy_api_tokens_active',
help: 'Number of API tokens with v1 format, last seen within 3 months',
});

async function collectStaticCounters() {
try {
const stats = await instanceStatsService.getStats();
Expand All @@ -333,6 +353,7 @@ export default class MetricsMonitor {
stageDurationByProject,
largestProjectEnvironments,
largestFeatureEnvironments,
deprecatedTokens,
] = await Promise.all([
stores.featureStrategiesReadModel.getMaxFeatureStrategies(),
stores.featureStrategiesReadModel.getMaxFeatureEnvironmentStrategies(),
Expand All @@ -346,6 +367,7 @@ export default class MetricsMonitor {
stores.largestResourcesReadModel.getLargestFeatureEnvironments(
1,
),
stores.apiTokenStore.countDeprecatedTokens(),
]);

featureFlagsTotal.reset();
Expand Down Expand Up @@ -394,6 +416,18 @@ export default class MetricsMonitor {
apiTokens.labels({ type }).set(value);
}

orphanedTokensTotal.reset();
orphanedTokensTotal.set(deprecatedTokens.orphanedTokens);

orphanedTokensActive.reset();
orphanedTokensActive.set(deprecatedTokens.activeOrphanedTokens);

legacyTokensTotal.reset();
legacyTokensTotal.set(deprecatedTokens.legacyTokens);

legacyTokensActive.reset();
legacyTokensActive.set(deprecatedTokens.activeLegacyTokens);

if (maxEnvironmentStrategies) {
maxFeatureEnvironmentStrategies.reset();
maxFeatureEnvironmentStrategies
Expand Down
2 changes: 1 addition & 1 deletion src/lib/types/experimental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ const flags: IFlags = {
),
allowOrphanedWildcardTokens: parseEnvVarBoolean(
process.env.UNLEASH_ORPHANED_TOKENS_KILL_SWITCH,
false,
true,
),
extendedMetrics: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_EXTENDED_METRICS,
Expand Down
6 changes: 6 additions & 0 deletions src/lib/types/stores/api-token-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,10 @@ export interface IApiTokenStore extends Store<IApiToken, string> {
markSeenAt(secrets: string[]): Promise<void>;
count(): Promise<number>;
countByType(): Promise<Map<string, number>>;
countDeprecatedTokens(): Promise<{
orphanedTokens: number;
activeOrphanedTokens: number;
legacyTokens: number;
activeLegacyTokens: number;
}>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,17 @@ const feature3 = 'f3.p2.token.access';

beforeAll(async () => {
db = await dbInit('feature_api_api_access_client_deletion', getLogger);
app = await setupAppWithAuth(db.stores, {}, db.rawDatabase);
app = await setupAppWithAuth(
db.stores,
{
experimental: {
flags: {
cleanApiTokenWhenOrphaned: true,
},
},
},
db.rawDatabase,
);
apiTokenService = app.services.apiTokenService;

const { featureToggleServiceV2, environmentService } = app.services;
Expand Down
2 changes: 2 additions & 0 deletions src/test/e2e/helpers/database-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ async function resetDatabase(knex) {
knex.table('tag_types').del(),
knex.table('addons').del(),
knex.table('users').del(),
knex.table('api_tokens').del(),
knex.table('api_token_project').del(),
knex
.table('reset_tokens')
.del(),
Expand Down
147 changes: 147 additions & 0 deletions src/test/e2e/stores/api-token-store.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ beforeAll(async () => {
stores = db.stores;
});

afterEach(async () => {
await db.reset();
});

afterAll(async () => {
await db.destroy();
});
Expand All @@ -35,3 +39,146 @@ test('get token returns the token when exists', async () => {
expect(foundToken.tokenName).toBe(newToken.tokenName);
expect(foundToken.type).toBe(newToken.type);
});

describe('count deprecated tokens', () => {
test('should return 0 if there is no legacy or orphaned tokens', async () => {
await stores.projectStore.create({
id: 'test',
name: 'test',
});
await stores.apiTokenStore.insert({
secret: '*:*.be44368985f7fb3237c584ef86f3d6bdada42ddbd63a019d26955178',
Copy link
Contributor

@kwasniew kwasniew Jul 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which of the fields in the token are important. can we emphasize the fields that matter and hide then one that don't? e.g. if only secret counts const orphanedToken = token('*:*.be44368985f7fb3237c584ef86f3d6bdada42ddbd63a019d26955178')

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Almost all fields can count - for example tokens of ADMIN type can't be orphaned, because it doesn't have projects. At the same time it can be in v1 or v2 format. I'm providing a variety of possible correct tokens.

environment: 'default',
type: ApiTokenType.ADMIN,
projects: [],
tokenName: 'admin-token',
});
await stores.apiTokenStore.insert({
secret: 'default:development.be44368985f7fb3237c584ef86f3d6bdada42ddbd63a019d26955178',
environment: 'default',
type: ApiTokenType.CLIENT,
projects: ['default'],
tokenName: 'client-token',
});
await stores.apiTokenStore.insert({
secret: '*:development.be44368985f7fb3237c584ef86f3d6bdada42ddbd63a019d26955178',
environment: 'default',
type: ApiTokenType.CLIENT,
projects: [],
tokenName: 'client-wildcard-token',
});
await stores.apiTokenStore.insert({
secret: '[]:production.3d6bdada42ddbd63a019d26955178be44368985f7fb3237c584ef86f',
environment: 'default',
type: ApiTokenType.FRONTEND,
projects: ['default', 'test'],
tokenName: 'frontend-token',
});

const deprecatedTokens =
await stores.apiTokenStore.countDeprecatedTokens();

expect(deprecatedTokens).toEqual({
activeLegacyTokens: 0,
activeOrphanedTokens: 0,
legacyTokens: 0,
orphanedTokens: 0,
});
});

test('should return 1 for legacy tokens', async () => {
await stores.apiTokenStore.insert({
secret: 'be44368985f7fb3237c584ef86f3d6bdada42ddbd63a019d26955178',
environment: 'default',
type: ApiTokenType.ADMIN,
projects: [],
tokenName: 'admin-test-token',
});

const deprecatedTokens =
await stores.apiTokenStore.countDeprecatedTokens();

expect(deprecatedTokens).toEqual({
activeLegacyTokens: 0,
activeOrphanedTokens: 0,
legacyTokens: 1,
orphanedTokens: 0,
});
});

test('should return 1 for orphaned tokens', async () => {
await stores.apiTokenStore.insert({
secret: 'deleted-project:development.be44368985f7fb3237c584ef86f3d6bdada42ddbd63a019d26955178',
environment: 'default',
type: ApiTokenType.CLIENT,
projects: [],
tokenName: 'admin-test-token',
});

const deprecatedTokens =
await stores.apiTokenStore.countDeprecatedTokens();

expect(deprecatedTokens).toEqual({
activeLegacyTokens: 0,
activeOrphanedTokens: 0,
legacyTokens: 0,
orphanedTokens: 1,
});
});

test('should not count wildcard tokens as orphaned', async () => {
await stores.apiTokenStore.insert({
secret: '*:*.be44368985f7fb3237c584ef86f3d6bdada42ddbd63a019d26955178',
environment: 'default',
type: ApiTokenType.CLIENT,
projects: [],
tokenName: 'client-test-token',
});

const deprecatedTokens =
await stores.apiTokenStore.countDeprecatedTokens();

expect(deprecatedTokens).toEqual({
activeLegacyTokens: 0,
activeOrphanedTokens: 0,
legacyTokens: 0,
orphanedTokens: 0,
});
});

test('should count active tokens based on seen_at', async () => {
const legacyTokenSecret =
'be44368985f7fb3237c584ef86f3d6bdada42ddbd63a019d26955178';
const orphanedTokenSecret =
'[]:production.be44368985f7fb3237c584ef86f3d6bdada42ddbd63a019d26955178';
await stores.apiTokenStore.insert({
secret: legacyTokenSecret,
environment: 'default',
type: ApiTokenType.ADMIN,
projects: [],
tokenName: 'admin-test-token',
});
await stores.apiTokenStore.insert({
secret: orphanedTokenSecret,
environment: 'default',
type: ApiTokenType.FRONTEND,
projects: [],
tokenName: 'frontend-test-token',
});

await stores.apiTokenStore.markSeenAt([
legacyTokenSecret,
orphanedTokenSecret,
]);

const deprecatedTokens =
await stores.apiTokenStore.countDeprecatedTokens();

expect(deprecatedTokens).toEqual({
activeLegacyTokens: 1,
activeOrphanedTokens: 1,
legacyTokens: 1,
orphanedTokens: 1,
});
});
});
14 changes: 14 additions & 0 deletions src/test/fixtures/fake-api-token-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,18 @@ export default class FakeApiTokenStore
t.expiresAt = expiresAt;
return t;
}

async countDeprecatedTokens(): Promise<{
orphanedTokens: number;
activeOrphanedTokens: number;
legacyTokens: number;
activeLegacyTokens: number;
}> {
return {
orphanedTokens: 0,
activeOrphanedTokens: 0,
legacyTokens: 0,
activeLegacyTokens: 0,
};
}
}
Loading