feat: rotate an API key (#30801)

* feat: rotate an API key

* fix: test response

* chore: create new api key scope

* chore: code review
This commit is contained in:
Brandon Wees
2026-08-18 16:07:39 -04:00
committed by GitHub
parent c8c9d703ef
commit 93d2d30fe0
13 changed files with 264 additions and 3 deletions
+2
View File
@@ -22,6 +22,7 @@ import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
@@ -443,6 +444,7 @@ const newRealRepository = <T extends BaseServiceDeps[number]>(key: T, db: Kysely
case AlbumRepository:
case AlbumUserRepository:
case ActivityRepository:
case ApiKeyRepository:
case AssetRepository:
case AssetEditRepository:
case AssetJobRepository:
@@ -0,0 +1,68 @@
import { Kysely } from 'kysely';
import { Permission } from 'src/enum';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { DB } from 'src/schema';
import { ApiKeyService } from 'src/services/api-key.service';
import { newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(ApiKeyService, {
database: db || defaultDatabase,
real: [ApiKeyRepository, CryptoRepository],
mock: [LoggingRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(ApiKeyService.name, () => {
describe('rotate', () => {
it('should not rotate an api key of another user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { apiKey } = await sut.create(factory.auth({ user }), { permissions: [Permission.All] });
await expect(sut.rotate(factory.auth({ user: otherUser }), apiKey.id)).rejects.toThrow('API Key not found');
});
it('should not rotate a key with permissions the caller does not have', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { apiKey } = await sut.create(factory.auth({ user }), { permissions: [Permission.All] });
const auth = factory.auth({ user, apiKey: { permissions: [Permission.ApiKeyRotate] } });
await expect(sut.rotate(auth, apiKey.id)).rejects.toThrow(
'Cannot rotate an API Key with permissions you do not have',
);
});
it('should replace the secret of an api key', async () => {
const { sut, ctx } = setup();
const apiKeyRepo = ctx.get(ApiKeyRepository);
const crypto = ctx.get(CryptoRepository);
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { apiKey, secret } = await sut.create(auth, { permissions: [Permission.All] });
const rotated = await sut.rotate(auth, apiKey.id);
expect(rotated.secret).not.toEqual(secret);
expect(rotated.apiKey).toEqual(
expect.objectContaining({ id: apiKey.id, name: apiKey.name, permissions: [Permission.All] }),
);
await expect(apiKeyRepo.getKey(crypto.hashSha256(secret))).resolves.toBeUndefined();
await expect(apiKeyRepo.getKey(crypto.hashSha256(rotated.secret))).resolves.toEqual(
expect.objectContaining({ id: apiKey.id }),
);
});
});
});