feat(core): Add KeyManagerService for encryption key lifecycle management (#28533)

This commit is contained in:
Stephen Wright 2026-04-20 13:39:46 +01:00 committed by GitHub
parent 657bdf136f
commit 9dd3e59acb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 252 additions and 5 deletions

View file

@ -42,6 +42,7 @@ describe('eligibleModules', () => {
'otel',
'token-exchange',
'instance-version-history',
'encryption-key-manager',
]);
});
@ -70,6 +71,7 @@ describe('eligibleModules', () => {
'otel',
'token-exchange',
'instance-version-history',
'encryption-key-manager',
'instance-ai',
]);
});

View file

@ -52,6 +52,7 @@ export class ModuleRegistry {
'otel',
'token-exchange',
'instance-version-history',
'encryption-key-manager',
];
private readonly activeModules: string[] = [];

View file

@ -26,6 +26,7 @@ export const MODULE_NAMES = [
'otel',
'token-exchange',
'instance-version-history',
'encryption-key-manager',
] as const;
export type ModuleName = (typeof MODULE_NAMES)[number];

View file

@ -1,6 +1,6 @@
import { Column, Entity } from '@n8n/typeorm';
import { datetimeColumnType, WithTimestampsAndStringId } from './abstract-entity';
import { WithTimestampsAndStringId } from './abstract-entity';
@Entity()
export class DeploymentKey extends WithTimestampsAndStringId {
@ -15,7 +15,4 @@ export class DeploymentKey extends WithTimestampsAndStringId {
@Column({ type: 'varchar', length: 20 })
status: string;
@Column({ type: datetimeColumnType, nullable: true })
deprecatedAt: Date | null;
}

View file

@ -8,7 +8,6 @@ export class CreateDeploymentKeyTable1777000000000 implements ReversibleMigratio
column('value').text.notNull,
column('algorithm').varchar(20),
column('status').varchar(20).notNull,
column('deprecatedAt').timestamp(),
).withTimestamps;
await createIndex(

View file

@ -28,4 +28,28 @@ export class DeploymentKeyRepository extends Repository<DeploymentKey> {
const entity = this.create(entityData);
await this.createQueryBuilder().insert().values(entity).orIgnore().execute();
}
/** Atomically deactivates any existing active key of the same type, then saves the given entity as active. */
async insertAsActive(entity: DeploymentKey & { status: 'active' }): Promise<DeploymentKey> {
return await this.manager.transaction(async (tx) => {
await tx.update(
DeploymentKey,
{ type: entity.type, status: 'active' },
{ status: 'inactive' },
);
return await tx.save(DeploymentKey, entity);
});
}
/** Atomically deactivates any existing active key of the given type, then sets the target key as active. */
async promoteToActive(id: string, type: string): Promise<void> {
await this.manager.transaction(async (tx) => {
const target = await tx.findOne(DeploymentKey, { where: { id, type } });
if (!target) {
throw new Error(`Deployment key '${id}' of type '${type}' not found`);
}
await tx.update(DeploymentKey, { type, status: 'active' }, { status: 'inactive' });
await tx.update(DeploymentKey, { id, type }, { status: 'active' });
});
}
}

View file

@ -0,0 +1,138 @@
import { mockInstance } from '@n8n/backend-test-utils';
import type { DeploymentKey } from '@n8n/db';
import { DeploymentKeyRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { KeyManagerService } from '@/modules/encryption-key-manager/key-manager.service';
const makeKey = (overrides: Partial<DeploymentKey> = {}): DeploymentKey =>
({
id: 'key-1',
type: 'data_encryption',
value: 'secret',
algorithm: 'aes-256-gcm',
status: 'active',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
}) as DeploymentKey;
describe('KeyManagerService', () => {
const repository = mockInstance(DeploymentKeyRepository);
beforeEach(() => {
jest.clearAllMocks();
});
describe('getActiveKey()', () => {
it('returns KeyInfo when one active key exists', async () => {
const key = makeKey();
repository.find.mockResolvedValue([key]);
const result = await Container.get(KeyManagerService).getActiveKey();
expect(result).toEqual({ id: key.id, value: key.value, algorithm: key.algorithm });
});
it('throws NotFoundError when no active key exists', async () => {
repository.find.mockResolvedValue([]);
await expect(Container.get(KeyManagerService).getActiveKey()).rejects.toThrow(NotFoundError);
});
it('throws when multiple active keys exist (invariant violation)', async () => {
repository.find.mockResolvedValue([makeKey({ id: 'key-1' }), makeKey({ id: 'key-2' })]);
await expect(Container.get(KeyManagerService).getActiveKey()).rejects.toThrow(
'Encryption key invariant violated: multiple active keys found',
);
});
});
describe('getKeyById()', () => {
it('returns KeyInfo when key exists', async () => {
const key = makeKey();
repository.findOne.mockResolvedValue(key);
const result = await Container.get(KeyManagerService).getKeyById('key-1');
expect(result).toEqual({ id: key.id, value: key.value, algorithm: key.algorithm });
expect(repository.findOne).toHaveBeenCalledWith({ where: { id: 'key-1' } });
});
it('returns null when key not found', async () => {
repository.findOne.mockResolvedValue(null);
const result = await Container.get(KeyManagerService).getKeyById('missing');
expect(result).toBeNull();
});
});
describe('getLegacyKey()', () => {
it('returns KeyInfo for the aes-256-cbc key', async () => {
const key = makeKey({ algorithm: 'aes-256-cbc' });
repository.findOne.mockResolvedValue(key);
const result = await Container.get(KeyManagerService).getLegacyKey();
expect(result).toEqual({ id: key.id, value: key.value, algorithm: 'aes-256-cbc' });
expect(repository.findOne).toHaveBeenCalledWith({
where: { type: 'data_encryption', algorithm: 'aes-256-cbc' },
});
});
it('throws NotFoundError when no CBC key exists', async () => {
repository.findOne.mockResolvedValue(null);
await expect(Container.get(KeyManagerService).getLegacyKey()).rejects.toThrow(NotFoundError);
});
});
describe('addKey()', () => {
it('inserts as inactive when setAsActive is not set', async () => {
const saved = makeKey({ id: 'new-key', status: 'inactive' });
repository.create.mockReturnValue(saved);
repository.save.mockResolvedValue(saved);
const result = await Container.get(KeyManagerService).addKey('secret', 'aes-256-gcm');
expect(repository.insertAsActive).not.toHaveBeenCalled();
expect(repository.create).toHaveBeenCalledWith(
expect.objectContaining({ status: 'inactive', type: 'data_encryption' }),
);
expect(result).toEqual({ id: 'new-key' });
});
it('delegates to insertAsActive when setAsActive=true', async () => {
const saved = makeKey({ id: 'new-key', status: 'active' });
repository.create.mockReturnValue(saved);
repository.insertAsActive.mockResolvedValue(saved);
const result = await Container.get(KeyManagerService).addKey('secret', 'aes-256-gcm', true);
expect(repository.save).not.toHaveBeenCalled();
expect(repository.insertAsActive).toHaveBeenCalledWith(saved);
expect(result).toEqual({ id: 'new-key' });
});
});
describe('setActiveKey()', () => {
it('delegates to promoteToActive', async () => {
repository.promoteToActive.mockResolvedValue(undefined);
await Container.get(KeyManagerService).setActiveKey('target');
expect(repository.promoteToActive).toHaveBeenCalledWith('target', 'data_encryption');
});
});
describe('markInactive()', () => {
it('sets status to inactive', async () => {
await Container.get(KeyManagerService).markInactive('key-1');
expect(repository.update).toHaveBeenCalledWith('key-1', { status: 'inactive' });
});
});
});

View file

@ -0,0 +1,9 @@
import type { ModuleInterface } from '@n8n/decorators';
import { BackendModule } from '@n8n/decorators';
@BackendModule({ name: 'encryption-key-manager', instanceTypes: ['main'] })
export class EncryptionKeyManagerModule implements ModuleInterface {
async init() {
await import('./key-manager.service');
}
}

View file

@ -0,0 +1,76 @@
import { Service } from '@n8n/di';
import { DeploymentKeyRepository } from '@n8n/db';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
type KeyInfo = { id: string; value: string; algorithm: string };
@Service()
export class KeyManagerService {
constructor(private readonly deploymentKeyRepository: DeploymentKeyRepository) {}
/** Returns the current active encryption key. Throws if none exists or if multiple are found. */
async getActiveKey(): Promise<KeyInfo> {
const activeKeys = await this.deploymentKeyRepository.find({
where: { type: 'data_encryption', status: 'active' },
});
if (activeKeys.length === 0) {
throw new NotFoundError('No active encryption key found');
}
if (activeKeys.length > 1) {
throw new Error('Encryption key invariant violated: multiple active keys found');
}
const key = activeKeys[0];
return { id: key.id, value: key.value, algorithm: key.algorithm! };
}
/** Returns a key by id, or null if not found. */
async getKeyById(id: string): Promise<KeyInfo | null> {
const key = await this.deploymentKeyRepository.findOne({ where: { id } });
if (!key) return null;
return { id: key.id, value: key.value, algorithm: key.algorithm! };
}
/** Returns the legacy CBC key (used to decrypt rows with NULL encryptionKeyId). */
async getLegacyKey(): Promise<KeyInfo> {
const key = await this.deploymentKeyRepository.findOne({
where: { type: 'data_encryption', algorithm: 'aes-256-cbc' },
});
if (!key) {
throw new NotFoundError('No legacy aes-256-cbc encryption key found');
}
return { id: key.id, value: key.value, algorithm: key.algorithm! };
}
/** Inserts a new encryption key. If setAsActive, atomically deactivates the current key first. */
async addKey(value: string, algorithm: string, setAsActive = false): Promise<{ id: string }> {
if (!setAsActive) {
const entity = this.deploymentKeyRepository.create({
type: 'data_encryption',
value,
algorithm,
status: 'inactive',
});
const saved = await this.deploymentKeyRepository.save(entity);
return { id: saved.id };
}
const entity = Object.assign(
this.deploymentKeyRepository.create({ type: 'data_encryption', value, algorithm }),
{ status: 'active' as const },
);
const saved = await this.deploymentKeyRepository.insertAsActive(entity);
return { id: saved.id };
}
/** Atomically deactivates the current active key and promotes the given key. */
async setActiveKey(id: string): Promise<void> {
await this.deploymentKeyRepository.promoteToActive(id, 'data_encryption');
}
/** Transitions key to 'inactive'. Usage count guard to be added in T13. */
async markInactive(id: string): Promise<void> {
// TODO: T13 will add usage check — throw ConflictError if usage count > 0
await this.deploymentKeyRepository.update(id, { status: 'inactive' });
}
}