backend-app-api: update DatabaseKeyStore to trim expired keys

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-04-05 14:38:57 +02:00
parent bce08790ca
commit 4f2aafb86c
4 changed files with 226 additions and 68 deletions
@@ -32,7 +32,10 @@ exports.up = async function up(knex) {
table.text('key').notNullable().comment('JSON serialized public key');
table.timestamp('expires_at').notNullable();
table
.timestamp('expires_at')
.notNullable()
.comment('The time that the key expires');
},
);
};
@@ -0,0 +1,124 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
TestDatabaseId,
TestDatabases,
mockServices,
} from '@backstage/backend-test-utils';
import { DatabaseKeyStore, TABLE } from './DatabaseKeyStore';
const testKey = {
kid: 'test-key',
kty: 'RSA',
e: 'ABC',
n: 'test',
};
const testKey2 = {
kid: 'test-key-2',
kty: 'RSA',
e: 'XYZ',
n: 'test',
};
describe('DatabaseKeyStore', () => {
const databases = TestDatabases.create();
async function createDatabaseKeyStore(databaseId: TestDatabaseId) {
const knex = await databases.init(databaseId);
const logger = mockServices.logger.mock();
return {
knex,
logger,
keyStore: await DatabaseKeyStore.create({
database: { getClient: async () => knex },
logger,
}),
};
}
describe.each(databases.eachSupportedId())('%p', databaseId => {
it('should insert a key into the database and list it', async () => {
const { keyStore } = await createDatabaseKeyStore(databaseId);
const expiresAt = new Date(Date.now() + 3600_000);
await expect(keyStore.listKeys()).resolves.toEqual({ keys: [] });
await keyStore.addKey({
id: testKey.kid,
key: testKey,
expiresAt,
});
await expect(keyStore.listKeys()).resolves.toEqual({
keys: [{ id: testKey.kid, key: testKey, expiresAt }],
});
});
it('should automatically exclude and remove expired keys when listing', async () => {
const { keyStore, knex, logger } = await createDatabaseKeyStore(
databaseId,
);
const expiresAt = new Date(Date.now() + 3600_000);
await expect(keyStore.listKeys()).resolves.toEqual({ keys: [] });
await keyStore.addKey({
id: testKey.kid,
key: testKey,
expiresAt,
});
await keyStore.addKey({
id: testKey2.kid,
key: testKey2,
expiresAt: new Date(0),
});
await expect(knex(TABLE).select('id')).resolves.toEqual([
{ id: testKey.kid },
{ id: testKey2.kid },
]);
expect(logger.info).not.toHaveBeenCalled();
await expect(keyStore.listKeys()).resolves.toEqual({
keys: [{ id: testKey.kid, key: testKey, expiresAt }],
});
expect(logger.info).toHaveBeenCalledWith(
"Removing expired plugin service keys, 'test-key-2'",
);
await expect(knex(TABLE).select('id')).resolves.toEqual([
{ id: testKey.kid },
]);
});
it('should fail to insert with invalid date', async () => {
const { keyStore } = await createDatabaseKeyStore(databaseId);
await expect(keyStore.listKeys()).resolves.toEqual({ keys: [] });
await expect(
keyStore.addKey({
id: testKey.kid,
key: testKey,
expiresAt: new Date(NaN),
}),
).rejects.toThrow('Failed to format public key expiration date');
});
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { DatabaseService } from '@backstage/backend-plugin-api';
import { DatabaseService, LoggerService } from '@backstage/backend-plugin-api';
import { DateTime } from 'luxon';
import { Knex } from 'knex';
import { JsonObject } from '@backstage/types';
@@ -22,7 +22,8 @@ import { resolvePackagePath } from '@backstage/backend-common';
import { KeyStore } from './types';
const MIGRATIONS_TABLE = 'backstage_backend_public_keys__knex_migrations';
const TABLE = 'backstage_backend_public_keys__keys';
/** @internal */
export const TABLE = 'backstage_backend_public_keys__keys';
type Row = {
id: string;
@@ -30,70 +31,6 @@ type Row = {
expires_at: string | Date; // Needs parsing to handle different DB implementations
};
/** @internal */
export class DatabaseKeyStore implements KeyStore {
private constructor(private readonly client: Knex) {}
static async create(options: { database: DatabaseService }) {
const { database } = options;
console.log(`DEBUG: ###### CREATING STORE`);
const client = await database.getClient();
if (!database.migrations?.skip) {
await applyDatabaseMigrations(client);
}
return new DatabaseKeyStore(client);
}
async addKey(options: {
id: string;
key: JsonObject & { kid: string };
expiresAt: Date;
}) {
console.log(`DEBUG: STORING KEY`, options);
await this.client<Row>(TABLE).insert({
id: options.key.kid,
key: JSON.stringify(options.key),
// TODO: figure out the best way to format this for the DB
expires_at: DateTime.fromJSDate(options.expiresAt).toSQL()!,
});
}
async listKeys() {
const rows = await this.client<Row>(TABLE).select();
// TODO: move over filter/delete the logic from listPublicKeys() in plugins/auth-backend/src/identity/TokenFactory.ts
return {
keys: rows.map(row => ({
id: row.id,
key: JSON.parse(row.key),
expiresAt: parseDate(row.expires_at),
})),
};
}
// async removeKeys(kids: string[]): Promise<void> {
// await this.client(TABLE).delete().whereIn('kid', kids);
// }
}
function parseDate(date: string | Date) {
const parsedDate =
typeof date === 'string'
? DateTime.fromSQL(date, { zone: 'UTC' })
: DateTime.fromJSDate(date);
if (!parsedDate.isValid) {
throw new Error(
`Failed to parse date, reason: ${parsedDate.invalidReason}, explanation: ${parsedDate.invalidExplanation}`,
);
}
return parsedDate.toJSDate();
}
export function applyDatabaseMigrations(knex: Knex): Promise<void> {
const migrationsDir = resolvePackagePath(
'@backstage/backend-app-api',
@@ -105,3 +42,97 @@ export function applyDatabaseMigrations(knex: Knex): Promise<void> {
tableName: MIGRATIONS_TABLE,
});
}
/** @internal */
export class DatabaseKeyStore implements KeyStore {
static async create(options: {
database: DatabaseService;
logger: LoggerService;
}) {
const { database, logger } = options;
const client = await database.getClient();
if (!database.migrations?.skip) {
await applyDatabaseMigrations(client);
}
return new DatabaseKeyStore(client, logger);
}
private constructor(
private readonly client: Knex,
private readonly logger: LoggerService,
) {}
async addKey(options: {
id: string;
key: JsonObject & { kid: string };
expiresAt: Date;
}) {
const expiresAt = DateTime.fromJSDate(options.expiresAt).toSQL();
if (!expiresAt) {
throw new Error('Failed to format public key expiration date');
}
await this.client<Row>(TABLE).insert({
id: options.key.kid,
key: JSON.stringify(options.key),
expires_at: expiresAt,
});
}
async listKeys() {
const rows = await this.client<Row>(TABLE).select();
const keys = rows.map(row => ({
id: row.id,
key: JSON.parse(row.key),
expiresAt: this.#parseDate(row.expires_at),
}));
const validKeys = [];
const expiredKeys = [];
for (const key of keys) {
if (DateTime.fromJSDate(key.expiresAt) < DateTime.local()) {
expiredKeys.push(key);
} else {
validKeys.push(key);
}
}
// Lazily prune expired keys. This may cause duplicate removals if we have concurrent callers, but w/e
if (expiredKeys.length > 0) {
const kids = expiredKeys.map(({ key }) => key.kid);
this.logger.info(
`Removing expired plugin service keys, '${kids.join("', '")}'`,
);
// We don't await this, just let it run in the background
this.client<Row>(TABLE)
.delete()
.whereIn('id', kids)
.catch(error => {
this.logger.error(
'Failed to remove expired plugin service keys',
error,
);
});
}
return { keys: validKeys };
}
#parseDate(date: string | Date) {
const parsedDate =
typeof date === 'string'
? DateTime.fromSQL(date, { zone: 'UTC' })
: DateTime.fromJSDate(date);
if (!parsedDate.isValid) {
throw new Error(
`Failed to parse date, reason: ${parsedDate.invalidReason}, explanation: ${parsedDate.invalidExplanation}`,
);
}
return parsedDate.toJSDate();
}
}
@@ -267,7 +267,7 @@ export const authServiceFactory = createServiceFactory({
),
);
const publicKeyStore = await DatabaseKeyStore.create({ database });
const publicKeyStore = await DatabaseKeyStore.create({ database, logger });
return new DefaultAuthService(
tokenManager,