mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
feat(core): Add KeyManagerService for encryption key lifecycle management (#28533)
This commit is contained in:
parent
657bdf136f
commit
9dd3e59acb
9 changed files with 252 additions and 5 deletions
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export class ModuleRegistry {
|
|||
'otel',
|
||||
'token-exchange',
|
||||
'instance-version-history',
|
||||
'encryption-key-manager',
|
||||
];
|
||||
|
||||
private readonly activeModules: string[] = [];
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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' });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue