mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 21:49:36 +02:00
refactor(core): Enforce persistence boundaries in code (no-changelog) (#39170)
This commit is contained in:
parent
9142b47bdf
commit
196ed7f450
|
|
@ -307,8 +307,8 @@ extends one of those layers):
|
|||
- `Cipher` does not expose the legacy or explicit-key methods.
|
||||
- The raw AES classes stay inside `packages/core/src/encryption/`.
|
||||
- **Deployment keys are never deleted** — data encrypted with a key becomes
|
||||
unreadable without it. Deactivate keys instead; the repository's delete
|
||||
surface throws at runtime and the lint rule rejects call sites.
|
||||
unreadable without it. Deactivate keys instead; the repository does not expose
|
||||
deletion and database triggers reject direct deletion.
|
||||
- Inline disables that name these rules, and bare line-form disables, are
|
||||
themselves lint errors. The code-health rule `encryption-boundary` (CI
|
||||
"Static Analysis") is the enforcement layer: it checks that every package
|
||||
|
|
|
|||
1
OWNERS
1
OWNERS
|
|
@ -266,5 +266,4 @@ packages/cli/src/modules/encryption-key-manager/ @n8n-io/iam required
|
|||
packages/@n8n/db/src/entities/deployment-key.ts @n8n-io/iam required
|
||||
packages/@n8n/db/src/repositories/deployment-key.repository.ts @n8n-io/iam required
|
||||
packages/@n8n/eslint-config/src/configs/encryption-boundary.ts @n8n-io/iam required
|
||||
packages/@n8n/eslint-config/src/rules/no-deployment-key-delete.ts @n8n-io/iam required
|
||||
packages/@n8n/eslint-config/src/rules/no-encryption-guardrail-disable.ts @n8n-io/iam required
|
||||
|
|
|
|||
1
docs/generated/postgres-schema/README.md
generated
1
docs/generated/postgres-schema/README.md
generated
|
|
@ -155,6 +155,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
|
|||
| Name | ReturnType | Arguments | Type |
|
||||
| ---- | ------- | ------- | ---- |
|
||||
| public.increment_workflow_version | trigger | | FUNCTION |
|
||||
| public.prevent_deployment_key_delete | trigger | | FUNCTION |
|
||||
| public.uuid_generate_v1 | uuid | | FUNCTION |
|
||||
| public.uuid_generate_v1mc | uuid | | FUNCTION |
|
||||
| public.uuid_generate_v3 | uuid | namespace uuid, name text | FUNCTION |
|
||||
|
|
|
|||
|
|
@ -36,6 +36,13 @@
|
|||
| IDX_deployment_key_signing_jwt_active | CREATE UNIQUE INDEX "IDX_deployment_key_signing_jwt_active" ON public.deployment_key USING btree (type) WHERE (((status)::text = 'active'::text) AND ((type)::text = 'signing.jwt'::text)) |
|
||||
| PK_94bb7aeb5def5a0284a5fe9f9a0 | CREATE UNIQUE INDEX "PK_94bb7aeb5def5a0284a5fe9f9a0" ON public.deployment_key USING btree (id) |
|
||||
|
||||
## Triggers
|
||||
|
||||
| Name | Definition |
|
||||
| ---- | ---------- |
|
||||
| prevent_deployment_key_delete | CREATE TRIGGER prevent_deployment_key_delete BEFORE DELETE ON public.deployment_key FOR EACH STATEMENT EXECUTE FUNCTION prevent_deployment_key_delete() |
|
||||
| prevent_deployment_key_truncate | CREATE TRIGGER prevent_deployment_key_truncate BEFORE TRUNCATE ON public.deployment_key FOR EACH STATEMENT EXECUTE FUNCTION prevent_deployment_key_delete() |
|
||||
|
||||
## Relations
|
||||
|
||||
```mermaid
|
||||
|
|
|
|||
6
docs/generated/sqlite-schema/deployment_key.md
generated
6
docs/generated/sqlite-schema/deployment_key.md
generated
|
|
@ -42,6 +42,12 @@ CREATE TABLE "deployment_key" ("id" varchar(36) PRIMARY KEY NOT NULL, "type" var
|
|||
| IDX_deployment_key_signing_jwt_active | CREATE UNIQUE INDEX "IDX_deployment_key_signing_jwt_active" ON "deployment_key" ("type") WHERE status = 'active' AND type = 'signing.jwt' |
|
||||
| sqlite_autoindex_deployment_key_1 | PRIMARY KEY (id) |
|
||||
|
||||
## Triggers
|
||||
|
||||
| Name | Definition |
|
||||
| ---- | ---------- |
|
||||
| prevent_deployment_key_delete | CREATE TRIGGER "prevent_deployment_key_delete"<br /> BEFORE DELETE ON "deployment_key"<br /> BEGIN<br /> SELECT RAISE(ABORT, 'Deployment keys must not be deleted');<br /> END |
|
||||
|
||||
## Relations
|
||||
|
||||
```mermaid
|
||||
|
|
|
|||
|
|
@ -228,3 +228,36 @@ export async function truncate(entities: EntityName[]) {
|
|||
await connection.getRepository(name).delete({});
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetDeploymentKeys() {
|
||||
const connection = Container.get(Connection);
|
||||
const { type, tablePrefix } = Container.get(GlobalConfig).database;
|
||||
const table = connection.driver.escape(`${tablePrefix}deployment_key`);
|
||||
const deleteTrigger = connection.driver.escape(`${tablePrefix}prevent_deployment_key_delete`);
|
||||
|
||||
if (type === 'postgresdb') {
|
||||
const truncateTrigger = connection.driver.escape(
|
||||
`${tablePrefix}prevent_deployment_key_truncate`,
|
||||
);
|
||||
await connection.transaction(async (tx) => {
|
||||
await tx.query(`ALTER TABLE ${table} DISABLE TRIGGER ${deleteTrigger}`);
|
||||
await tx.query(`ALTER TABLE ${table} DISABLE TRIGGER ${truncateTrigger}`);
|
||||
await tx.query(`DELETE FROM ${table}`);
|
||||
await tx.query(`ALTER TABLE ${table} ENABLE TRIGGER ${deleteTrigger}`);
|
||||
await tx.query(`ALTER TABLE ${table} ENABLE TRIGGER ${truncateTrigger}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await connection.transaction(async (tx) => {
|
||||
await tx.query(`DROP TRIGGER ${deleteTrigger}`);
|
||||
await tx.query(`DELETE FROM ${table}`);
|
||||
await tx.query(`
|
||||
CREATE TRIGGER ${deleteTrigger}
|
||||
BEFORE DELETE ON ${table}
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Deployment keys must not be deleted');
|
||||
END
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { Column, Entity } from '@n8n/typeorm';
|
|||
|
||||
import { WithTimestampsAndStringId } from './abstract-entity';
|
||||
|
||||
export const OAUTH_JWE_PRIVATE_KEY_TYPE = 'jwe.private-key';
|
||||
|
||||
@Entity()
|
||||
export class DeploymentKey extends WithTimestampsAndStringId {
|
||||
@Column({ type: 'varchar', length: 64 })
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
type CredentialDependencyType,
|
||||
} from './credential-dependency-entity';
|
||||
import { CredentialsEntity, type CredentialUsageScope } from './credentials-entity';
|
||||
import { DeploymentKey } from './deployment-key';
|
||||
import { DeploymentKey, OAUTH_JWE_PRIVATE_KEY_TYPE } from './deployment-key';
|
||||
import { EvaluationCollection } from './evaluation-collection.ee';
|
||||
import { EvaluationConfig } from './evaluation-config.ee';
|
||||
import { ExecutionAnnotation } from './execution-annotation.ee';
|
||||
|
|
@ -140,6 +140,7 @@ export {
|
|||
EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE,
|
||||
type CredentialDependencyType,
|
||||
DeploymentKey,
|
||||
OAUTH_JWE_PRIVATE_KEY_TYPE,
|
||||
EvaluationCollection,
|
||||
EvaluationConfig,
|
||||
Folder,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class PreventDeploymentKeyDeletion1790054384570 implements ReversibleMigration {
|
||||
async up({ isSqlite, escape, runQuery }: MigrationContext) {
|
||||
const table = escape.tableName('deployment_key');
|
||||
const deleteTrigger = escape.triggerName('prevent_deployment_key_delete');
|
||||
|
||||
if (isSqlite) {
|
||||
await runQuery(`
|
||||
CREATE TRIGGER ${deleteTrigger}
|
||||
BEFORE DELETE ON ${table}
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Deployment keys must not be deleted');
|
||||
END
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
const truncateTrigger = escape.triggerName('prevent_deployment_key_truncate');
|
||||
const triggerFunction = escape.functionName('prevent_deployment_key_delete');
|
||||
await runQuery(`
|
||||
CREATE OR REPLACE FUNCTION ${triggerFunction}()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'Deployment keys must not be deleted';
|
||||
END;
|
||||
$$
|
||||
`);
|
||||
await runQuery(`
|
||||
CREATE TRIGGER ${deleteTrigger}
|
||||
BEFORE DELETE ON ${table}
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION ${triggerFunction}()
|
||||
`);
|
||||
await runQuery(`
|
||||
CREATE TRIGGER ${truncateTrigger}
|
||||
BEFORE TRUNCATE ON ${table}
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION ${triggerFunction}()
|
||||
`);
|
||||
}
|
||||
|
||||
async down({ isSqlite, escape, runQuery }: MigrationContext) {
|
||||
const deleteTrigger = escape.triggerName('prevent_deployment_key_delete');
|
||||
if (isSqlite) {
|
||||
await runQuery(`DROP TRIGGER IF EXISTS ${deleteTrigger}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const table = escape.tableName('deployment_key');
|
||||
const truncateTrigger = escape.triggerName('prevent_deployment_key_truncate');
|
||||
const triggerFunction = escape.functionName('prevent_deployment_key_delete');
|
||||
await runQuery(`DROP TRIGGER IF EXISTS ${deleteTrigger} ON ${table}`);
|
||||
await runQuery(`DROP TRIGGER IF EXISTS ${truncateTrigger} ON ${table}`);
|
||||
await runQuery(`DROP FUNCTION IF EXISTS ${triggerFunction}()`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +1,21 @@
|
|||
import type { DataSource } from '@n8n/typeorm';
|
||||
import type { Cipher } from 'n8n-core';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { DbLockService } from '../../services/db-lock.service';
|
||||
import { DeploymentKeyRepository } from '../deployment-key.repository';
|
||||
import type { DeploymentKeyRepository } from '../deployment-key.repository';
|
||||
|
||||
describe('DeploymentKeyRepository', () => {
|
||||
describe('delete surface', () => {
|
||||
const repository = new DeploymentKeyRepository(
|
||||
mock<DataSource>(),
|
||||
mock<DbLockService>(),
|
||||
mock<Cipher>(),
|
||||
);
|
||||
|
||||
test.each(['delete', 'remove', 'softDelete', 'softRemove', 'clear'] as const)(
|
||||
'%s() always throws — keys are deactivated, never deleted',
|
||||
async (method) => {
|
||||
await expect(repository[method]()).rejects.toThrow('Deployment keys must never be deleted');
|
||||
},
|
||||
);
|
||||
it('does not expose deletion or TypeORM escape hatches', () => {
|
||||
expectTypeOf<DeploymentKeyRepository>().not.toHaveProperty('delete');
|
||||
expectTypeOf<DeploymentKeyRepository>().not.toHaveProperty('remove');
|
||||
expectTypeOf<DeploymentKeyRepository>().not.toHaveProperty('clear');
|
||||
expectTypeOf<DeploymentKeyRepository>().not.toHaveProperty('manager');
|
||||
expectTypeOf<DeploymentKeyRepository>().not.toHaveProperty('createQueryBuilder');
|
||||
expectTypeOf<DeploymentKeyRepository>().not.toHaveProperty('find');
|
||||
expectTypeOf<DeploymentKeyRepository>().not.toHaveProperty('findOne');
|
||||
expectTypeOf<DeploymentKeyRepository>().not.toHaveProperty('findOneByOrFail');
|
||||
expectTypeOf<DeploymentKeyRepository>().not.toHaveProperty('create');
|
||||
expectTypeOf<DeploymentKeyRepository>().not.toHaveProperty('save');
|
||||
expectTypeOf<DeploymentKeyRepository>().not.toHaveProperty('insert');
|
||||
expectTypeOf<DeploymentKeyRepository>().not.toHaveProperty('update');
|
||||
expectTypeOf<DeploymentKeyRepository>().not.toHaveProperty('insertOrIgnore');
|
||||
expectTypeOf<DeploymentKeyRepository>().not.toHaveProperty('insertAsActive');
|
||||
expectTypeOf<DeploymentKeyRepository>().not.toHaveProperty('promoteToActive');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { DataSource, IsNull, Repository } from '@n8n/typeorm';
|
||||
import { DataSource, IsNull } from '@n8n/typeorm';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import { DeploymentKey } from '../entities/deployment-key';
|
||||
import { BaseRepository } from './base-repository';
|
||||
import { DeploymentKey, OAUTH_JWE_PRIVATE_KEY_TYPE } from '../entities/deployment-key';
|
||||
import { DbLock, DbLockService } from '../services/db-lock.service';
|
||||
import type { OperationContext } from '../services/transaction';
|
||||
import { TransactionRunner } from '../services/transaction';
|
||||
|
||||
/**
|
||||
* Marker for signing-secret rows whose value is wrapped with the instance
|
||||
|
|
@ -25,14 +28,27 @@ export type ListDeploymentKeysOptions = {
|
|||
createdAtTo?: Date;
|
||||
};
|
||||
|
||||
class DeploymentKeyStore extends BaseRepository<DeploymentKey> {
|
||||
constructor(dataSource: DataSource, transactionRunner: TransactionRunner) {
|
||||
super(DeploymentKey, dataSource.manager, transactionRunner);
|
||||
}
|
||||
|
||||
managerForContext(ctx: OperationContext) {
|
||||
return this.managerFor(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class DeploymentKeyRepository extends Repository<DeploymentKey> {
|
||||
export class DeploymentKeyRepository {
|
||||
private readonly store: DeploymentKeyStore;
|
||||
|
||||
constructor(
|
||||
dataSource: DataSource,
|
||||
private readonly transactionRunner: TransactionRunner,
|
||||
private readonly dbLockService: DbLockService,
|
||||
private readonly cipher: Cipher,
|
||||
) {
|
||||
super(DeploymentKey, dataSource.manager);
|
||||
this.store = new DeploymentKeyStore(dataSource, transactionRunner);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -52,7 +68,7 @@ export class DeploymentKeyRepository extends Repository<DeploymentKey> {
|
|||
type: string,
|
||||
{ rewrapLegacy = false }: { rewrapLegacy?: boolean } = {},
|
||||
): Promise<string | null> {
|
||||
const row = await this.findActiveByType(type);
|
||||
const row = await this.store.findOne({ where: { type, status: 'active' } });
|
||||
if (!row) return null;
|
||||
if (row.algorithm === SECRET_WRAP_ALGORITHM) {
|
||||
try {
|
||||
|
|
@ -72,7 +88,7 @@ export class DeploymentKeyRepository extends Repository<DeploymentKey> {
|
|||
);
|
||||
}
|
||||
if (rewrapLegacy) {
|
||||
await this.update(
|
||||
await this.store.update(
|
||||
{ id: row.id, algorithm: IsNull() },
|
||||
{
|
||||
value: this.cipher.encryptDEKWithInstanceKey(row.value),
|
||||
|
|
@ -89,7 +105,7 @@ export class DeploymentKeyRepository extends Repository<DeploymentKey> {
|
|||
* silently ignored; the caller should read the winner's value afterwards.
|
||||
*/
|
||||
async seedSigningSecret(type: string, secret: string): Promise<void> {
|
||||
await this.insertOrIgnore({
|
||||
await this.insertIgnoringConflict({
|
||||
type,
|
||||
value: this.cipher.encryptDEKWithInstanceKey(secret),
|
||||
status: 'active',
|
||||
|
|
@ -103,8 +119,8 @@ export class DeploymentKeyRepository extends Repository<DeploymentKey> {
|
|||
* starting concurrently cannot create duplicate rows.
|
||||
*/
|
||||
async seedLegacyCbcKey(encryptedValue: string): Promise<void> {
|
||||
await this.dbLockService.withLock(DbLock.DATA_ENCRYPTION_KEY_SEED, async (tx) => {
|
||||
const repo = tx.getRepository(DeploymentKey);
|
||||
await this.dbLockService.withLockContext(DbLock.DATA_ENCRYPTION_KEY_SEED, async (ctx) => {
|
||||
const repo = this.store.managerForContext(ctx).getRepository(DeploymentKey);
|
||||
const existing = await repo.findOne({
|
||||
where: { type: 'data_encryption', algorithm: 'aes-256-cbc' },
|
||||
});
|
||||
|
|
@ -122,16 +138,49 @@ export class DeploymentKeyRepository extends Repository<DeploymentKey> {
|
|||
});
|
||||
}
|
||||
|
||||
async findActiveByType(type: string): Promise<DeploymentKey | null> {
|
||||
return await this.findOne({ where: { type, status: 'active' } });
|
||||
async findActiveIdentifier(type: string): Promise<DeploymentKey | null> {
|
||||
return await this.store.findOne({ where: { type, status: 'active' } });
|
||||
}
|
||||
|
||||
async findAllByType(type: string): Promise<DeploymentKey[]> {
|
||||
return await this.find({ where: { type } });
|
||||
async seedActiveIdentifier(type: string, value: string): Promise<void> {
|
||||
await this.insertIgnoringConflict({ type, value, status: 'active', algorithm: null });
|
||||
}
|
||||
|
||||
async findDataEncryptionKeys(): Promise<DeploymentKey[]> {
|
||||
return await this.find({ where: { type: 'data_encryption' } });
|
||||
return await this.store.find({ where: { type: 'data_encryption' } });
|
||||
}
|
||||
|
||||
async findActiveDataEncryptionKeys(): Promise<DeploymentKey[]> {
|
||||
return await this.store.find({ where: { type: 'data_encryption', status: 'active' } });
|
||||
}
|
||||
|
||||
async findDataEncryptionKeyById(id: string): Promise<DeploymentKey | null> {
|
||||
return await this.store.findOne({ where: { id, type: 'data_encryption' } });
|
||||
}
|
||||
|
||||
async findDataEncryptionKeyByAlgorithm(algorithm: string): Promise<DeploymentKey | null> {
|
||||
return await this.store.findOne({ where: { type: 'data_encryption', algorithm } });
|
||||
}
|
||||
|
||||
async findActiveDataEncryptionKeyByAlgorithm(algorithm: string): Promise<DeploymentKey | null> {
|
||||
return await this.store.findOne({
|
||||
where: { type: 'data_encryption', algorithm, status: 'active' },
|
||||
});
|
||||
}
|
||||
|
||||
async seedActiveDataEncryptionKey(value: string, algorithm: string): Promise<void> {
|
||||
await this.insertIgnoringConflict({
|
||||
type: 'data_encryption',
|
||||
value,
|
||||
algorithm,
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
|
||||
async insertInactiveDataEncryptionKey(value: string, algorithm: string): Promise<DeploymentKey> {
|
||||
return await this.store.save(
|
||||
this.store.create({ type: 'data_encryption', value, algorithm, status: 'inactive' }),
|
||||
);
|
||||
}
|
||||
|
||||
async rewrapLegacyDataEncryptionValue(
|
||||
|
|
@ -139,13 +188,16 @@ export class DeploymentKeyRepository extends Repository<DeploymentKey> {
|
|||
oldValue: string,
|
||||
wrappedValue: string,
|
||||
): Promise<void> {
|
||||
await this.update({ id, type: 'data_encryption', value: oldValue }, { value: wrappedValue });
|
||||
await this.store.update(
|
||||
{ id, type: 'data_encryption', value: oldValue },
|
||||
{ value: wrappedValue },
|
||||
);
|
||||
}
|
||||
|
||||
async findAndCountForList(
|
||||
opts: ListDeploymentKeysOptions,
|
||||
): Promise<{ items: DeploymentKey[]; count: number }> {
|
||||
const qb = this.createQueryBuilder('deploymentKey');
|
||||
const qb = this.store.createQueryBuilder('deploymentKey');
|
||||
|
||||
if (opts.type) {
|
||||
qb.andWhere('deploymentKey.type = :type', { type: opts.type });
|
||||
|
|
@ -181,16 +233,25 @@ export class DeploymentKeyRepository extends Repository<DeploymentKey> {
|
|||
* On a unique-index conflict (concurrent multi-main startup), the insert
|
||||
* is silently ignored. The caller should read the winner's value afterwards.
|
||||
*/
|
||||
async insertOrIgnore(
|
||||
private async insertIgnoringConflict(
|
||||
entityData: Pick<DeploymentKey, 'type' | 'value' | 'status' | 'algorithm'>,
|
||||
): Promise<void> {
|
||||
const entity = this.create(entityData);
|
||||
await this.createQueryBuilder().insert().values(entity).orIgnore().execute();
|
||||
const entity = this.store.create(entityData);
|
||||
await this.store.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) => {
|
||||
async insertAndActivateDataEncryptionKey(
|
||||
value: string,
|
||||
algorithm: string,
|
||||
): Promise<DeploymentKey> {
|
||||
const entity = this.store.create({
|
||||
type: 'data_encryption',
|
||||
value,
|
||||
algorithm,
|
||||
status: 'active',
|
||||
});
|
||||
return await this.transactionRunner.run({}, async (ctx) => {
|
||||
const tx = this.store.managerForContext(ctx);
|
||||
await tx.update(
|
||||
DeploymentKey,
|
||||
{ type: entity.type, status: 'active' },
|
||||
|
|
@ -200,42 +261,40 @@ export class DeploymentKeyRepository extends Repository<DeploymentKey> {
|
|||
});
|
||||
}
|
||||
|
||||
/** 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' });
|
||||
async activateDataEncryptionKey(id: string): Promise<void> {
|
||||
await this.transactionRunner.run({}, async (ctx) => {
|
||||
const tx = this.store.managerForContext(ctx);
|
||||
const target = await tx.findOne(DeploymentKey, {
|
||||
where: { id, type: 'data_encryption' },
|
||||
});
|
||||
if (!target) throw new UnexpectedError(`Data encryption key '${id}' not found`);
|
||||
|
||||
await tx.update(
|
||||
DeploymentKey,
|
||||
{ type: 'data_encryption', status: 'active' },
|
||||
{ status: 'inactive' },
|
||||
);
|
||||
await tx.update(DeploymentKey, { id, type: 'data_encryption' }, { status: 'active' });
|
||||
});
|
||||
}
|
||||
|
||||
// Deployment keys must never be deleted: data encrypted with a key becomes
|
||||
// unreadable without it. Keys are deactivated instead (`markInactive` /
|
||||
// `promoteToActive`). These parameterless shadows close the inherited
|
||||
// TypeORM delete surface twice over — calls with arguments no longer
|
||||
// type-check, and any call throws at runtime. Call sites are additionally
|
||||
// rejected in CI by `n8n-local-rules/no-deployment-key-delete`.
|
||||
|
||||
async delete(): Promise<never> {
|
||||
throw new UnexpectedError('Deployment keys must never be deleted — deactivate them instead');
|
||||
async deactivateDataEncryptionKey(id: string): Promise<void> {
|
||||
await this.store.update({ id, type: 'data_encryption' }, { status: 'inactive' });
|
||||
}
|
||||
|
||||
async remove(): Promise<never> {
|
||||
throw new UnexpectedError('Deployment keys must never be deleted — deactivate them instead');
|
||||
async findActiveOAuthJweKey(algorithm: string): Promise<DeploymentKey | null> {
|
||||
return await this.store.findOne({
|
||||
where: { type: OAUTH_JWE_PRIVATE_KEY_TYPE, algorithm, status: 'active' },
|
||||
});
|
||||
}
|
||||
|
||||
async softDelete(): Promise<never> {
|
||||
throw new UnexpectedError('Deployment keys must never be deleted — deactivate them instead');
|
||||
}
|
||||
|
||||
async softRemove(): Promise<never> {
|
||||
throw new UnexpectedError('Deployment keys must never be deleted — deactivate them instead');
|
||||
}
|
||||
|
||||
async clear(): Promise<never> {
|
||||
throw new UnexpectedError('Deployment keys must never be deleted — deactivate them instead');
|
||||
async insertActiveOAuthJweKey(id: string, value: string, algorithm: string): Promise<void> {
|
||||
await this.store.insert({
|
||||
id,
|
||||
type: OAUTH_JWE_PRIVATE_KEY_TYPE,
|
||||
value,
|
||||
algorithm,
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
type WorkflowContentWriteCapability = { available: boolean };
|
||||
|
||||
const workflowContentWriteContext = new AsyncLocalStorage<WorkflowContentWriteCapability>();
|
||||
|
||||
export const runWorkflowContentWrite = async <T>(write: () => Promise<T>): Promise<T> =>
|
||||
await workflowContentWriteContext.run({ available: true }, write);
|
||||
|
||||
export const consumeWorkflowContentWrite = () => {
|
||||
const capability = workflowContentWriteContext.getStore();
|
||||
if (!capability?.available) return false;
|
||||
capability.available = false;
|
||||
return true;
|
||||
};
|
||||
|
|
@ -19,6 +19,7 @@ import type { ActivityProjectScope } from './activity-event.repository';
|
|||
import { BaseRepository } from './base-repository';
|
||||
import { FolderRepository } from './folder.repository';
|
||||
import { SharedWorkflowRepository } from './shared-workflow.repository';
|
||||
import { runWorkflowContentWrite } from './workflow-content-write-context';
|
||||
import { WorkflowHistoryRepository } from './workflow-history.repository';
|
||||
import {
|
||||
WebhookEntity,
|
||||
|
|
@ -305,7 +306,9 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
ctx: OperationContext,
|
||||
) {
|
||||
assertClearedFor(ctx.policyCleared, 'workflowSave', { type: 'workflow', id });
|
||||
await this.managerFor(ctx).update(WorkflowEntity, id, content);
|
||||
await runWorkflowContentWrite(
|
||||
async () => await this.managerFor(ctx).update(WorkflowEntity, id, content),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -321,13 +324,20 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
projectId: string,
|
||||
ctx: OperationContext = {},
|
||||
): Promise<WorkflowEntity> {
|
||||
return await this.runInTransaction(ctx, async (em) => {
|
||||
const saved = await em.save(workflow);
|
||||
await em.save(
|
||||
em.create(SharedWorkflow, { role: 'workflow:owner', projectId, workflowId: saved.id }),
|
||||
);
|
||||
return saved;
|
||||
});
|
||||
return await runWorkflowContentWrite(
|
||||
async () =>
|
||||
await this.runInTransaction(ctx, async (em) => {
|
||||
const saved = await em.save(workflow);
|
||||
await em.save(
|
||||
em.create(SharedWorkflow, {
|
||||
role: 'workflow:owner',
|
||||
projectId,
|
||||
workflowId: saved.id,
|
||||
}),
|
||||
);
|
||||
return saved;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -342,7 +352,7 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
*/
|
||||
async createContent(workflow: WorkflowEntity, ctx: OperationContext): Promise<WorkflowEntity> {
|
||||
assertClearedFor(ctx.policyCleared, 'workflowSave', workflowContentSubject(workflow));
|
||||
return await this.managerFor(ctx).save(workflow);
|
||||
return await runWorkflowContentWrite(async () => await this.managerFor(ctx).save(workflow));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -363,7 +373,9 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
workflowSubject({ id: content.id ?? null, nodes: content.nodes }),
|
||||
);
|
||||
|
||||
const result = await this.managerFor(ctx).upsert(WorkflowEntity, content, ['id']);
|
||||
const result = await runWorkflowContentWrite(
|
||||
async () => await this.managerFor(ctx).upsert(WorkflowEntity, content, ['id']),
|
||||
);
|
||||
const id = result.identifiers.at(0)?.id;
|
||||
|
||||
if (typeof id !== 'string') {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import type { InsertEvent, UpdateEvent } from '@n8n/typeorm';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { WorkflowEntitySubscriber } from '../workflow-entity-subscriber';
|
||||
import type { WorkflowEntity } from '../../entities';
|
||||
import { runWorkflowContentWrite } from '../../repositories/workflow-content-write-context';
|
||||
|
||||
type UpdatedColumn = UpdateEvent<WorkflowEntity>['updatedColumns'][number];
|
||||
|
||||
describe('WorkflowEntitySubscriber', () => {
|
||||
const subscriber = new WorkflowEntitySubscriber(true);
|
||||
|
||||
it('rejects inserts outside a cleared repository write', () => {
|
||||
expect(() => subscriber.beforeInsert(mock<InsertEvent<WorkflowEntity>>())).toThrow(
|
||||
'Workflow content writes must use a policy-cleared repository method',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects node updates outside a cleared repository write', () => {
|
||||
expect(() =>
|
||||
subscriber.beforeUpdate(
|
||||
mock<UpdateEvent<WorkflowEntity>>({
|
||||
entity: { nodes: [] },
|
||||
updatedColumns: [],
|
||||
updatedRelations: [],
|
||||
}),
|
||||
),
|
||||
).toThrow('Workflow content writes must use a policy-cleared repository method');
|
||||
});
|
||||
|
||||
it('allows a full entity save when only metadata changed', () => {
|
||||
expect(() =>
|
||||
subscriber.beforeUpdate(
|
||||
mock<UpdateEvent<WorkflowEntity>>({
|
||||
entity: { active: false, nodes: [] },
|
||||
updatedColumns: [mock<UpdatedColumn>({ propertyName: 'active' })],
|
||||
updatedRelations: [],
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a full entity save when nodes changed', () => {
|
||||
expect(() =>
|
||||
subscriber.beforeUpdate(
|
||||
mock<UpdateEvent<WorkflowEntity>>({
|
||||
entity: { nodes: [] },
|
||||
updatedColumns: [mock<UpdatedColumn>({ propertyName: 'nodes' })],
|
||||
updatedRelations: [],
|
||||
}),
|
||||
),
|
||||
).toThrow('Workflow content writes must use a policy-cleared repository method');
|
||||
});
|
||||
|
||||
it('allows content writes inside the repository context', async () => {
|
||||
await expect(
|
||||
runWorkflowContentWrite(async () => {
|
||||
subscriber.beforeInsert(mock<InsertEvent<WorkflowEntity>>());
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
runWorkflowContentWrite(async () => {
|
||||
subscriber.beforeUpdate(
|
||||
mock<UpdateEvent<WorkflowEntity>>({
|
||||
entity: { nodes: [] },
|
||||
updatedColumns: [],
|
||||
updatedRelations: [],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('consumes the scoped write capability', async () => {
|
||||
await expect(
|
||||
runWorkflowContentWrite(async () => {
|
||||
subscriber.beforeInsert(mock<InsertEvent<WorkflowEntity>>());
|
||||
subscriber.beforeInsert(mock<InsertEvent<WorkflowEntity>>());
|
||||
}),
|
||||
).rejects.toThrow('Workflow content writes must use a policy-cleared repository method');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { UserSubscriber } from './user-subscriber';
|
||||
import { WorkflowEntitySubscriber } from './workflow-entity-subscriber';
|
||||
|
||||
export const subscribers = {
|
||||
UserSubscriber,
|
||||
WorkflowEntitySubscriber,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import type { EntitySubscriberInterface, InsertEvent, UpdateEvent } from '@n8n/typeorm';
|
||||
import { EventSubscriber } from '@n8n/typeorm';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import { WorkflowEntity } from '../entities';
|
||||
import { consumeWorkflowContentWrite } from '../repositories/workflow-content-write-context';
|
||||
|
||||
@EventSubscriber()
|
||||
export class WorkflowEntitySubscriber implements EntitySubscriberInterface<WorkflowEntity> {
|
||||
constructor(private readonly enforce = process.env.VITEST !== 'true') {}
|
||||
|
||||
listenTo() {
|
||||
return WorkflowEntity;
|
||||
}
|
||||
|
||||
beforeInsert(_event: InsertEvent<WorkflowEntity>) {
|
||||
this.assertContentWriteAllowed();
|
||||
}
|
||||
|
||||
beforeUpdate(event: UpdateEvent<WorkflowEntity>) {
|
||||
const hasTrackedChanges =
|
||||
(event.updatedColumns?.length ?? 0) > 0 || (event.updatedRelations?.length ?? 0) > 0;
|
||||
const changesNodes = hasTrackedChanges
|
||||
? (event.updatedColumns?.some((column) => column.propertyName === 'nodes') ?? false)
|
||||
: event.entity && Object.prototype.hasOwnProperty.call(event.entity, 'nodes');
|
||||
|
||||
if (changesNodes) {
|
||||
this.assertContentWriteAllowed();
|
||||
}
|
||||
}
|
||||
|
||||
private assertContentWriteAllowed() {
|
||||
if (this.enforce && !consumeWorkflowContentWrite()) {
|
||||
throw new UnexpectedError(
|
||||
'Workflow content writes must use a policy-cleared repository method',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,8 +11,6 @@ import tseslint from 'typescript-eslint';
|
|||
* `encryption-boundary` verifies that coverage in CI and rejects package-level
|
||||
* downgrades and inline directives that would silence these rules:
|
||||
*
|
||||
* - `no-deployment-key-delete`: data encrypted with a key becomes unreadable
|
||||
* without it — keys are deactivated, never deleted.
|
||||
* - `no-encryption-guardrail-disable`: in-editor feedback that the rules
|
||||
* above cannot be silenced inline; widening the boundary happens here,
|
||||
* under security ownership (see OWNERS).
|
||||
|
|
@ -26,7 +24,6 @@ import tseslint from 'typescript-eslint';
|
|||
*/
|
||||
export const encryptionBoundaryConfig = tseslint.config({
|
||||
rules: {
|
||||
'n8n-local-rules/no-deployment-key-delete': 'error',
|
||||
'n8n-local-rules/no-encryption-guardrail-disable': 'error',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,20 +13,7 @@ export const functionalGuardrailsConfig = tseslint.config({
|
|||
parserOptions: { projectService: true },
|
||||
},
|
||||
rules: {
|
||||
'n8n-local-rules/no-deployment-key-delete': 'error',
|
||||
'n8n-local-rules/no-unsealed-workflow-entity-write': 'error',
|
||||
'n8n-local-rules/no-encryption-guardrail-disable': 'error',
|
||||
'n8n-local-rules/no-guardrail-disable': [
|
||||
'error',
|
||||
{
|
||||
guarded: [
|
||||
{
|
||||
rule: 'no-unsealed-workflow-entity-write',
|
||||
message: 'Route the write through a token-gated `WorkflowRepository` method.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export const localRulesPlugin = {
|
|||
'n8n-local-rules': plugin,
|
||||
},
|
||||
rules: {
|
||||
// Seals WorkflowEntity node writes to the token-gated repository methods.
|
||||
'n8n-local-rules/no-unsealed-workflow-entity-write': 'error',
|
||||
'n8n-local-rules/no-uncaught-json-parse': 'error',
|
||||
'n8n-local-rules/no-json-parse-json-stringify': 'error',
|
||||
'n8n-local-rules/no-unneeded-backticks': 'error',
|
||||
|
|
@ -31,8 +33,6 @@ export const localRulesPlugin = {
|
|||
'n8n-local-rules/no-dynamic-regexp': 'warn',
|
||||
'n8n-local-rules/no-restricted-sleep-definition': 'error',
|
||||
'n8n-local-rules/no-restricted-sleep-import': 'error',
|
||||
// Seals WorkflowEntity node writes to the token-gated repository methods, in every package.
|
||||
'n8n-local-rules/no-unsealed-workflow-entity-write': 'error',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import { RequirePublicApiControllerRule } from './require-public-api-controller.
|
|||
import { NoUnsealedWorkflowEntityWriteRule } from './no-unsealed-workflow-entity-write.js';
|
||||
import { RequireEscapedQueryValuesRule } from './require-escaped-query-values.js';
|
||||
import { NoOnLeaderTakeoverRule } from './no-on-leader-takeover.js';
|
||||
import { NoDeploymentKeyDeleteRule } from './no-deployment-key-delete.js';
|
||||
import { NoEncryptionGuardrailDisableRule } from './no-encryption-guardrail-disable.js';
|
||||
|
||||
export const rules = {
|
||||
|
|
@ -65,6 +64,5 @@ export const rules = {
|
|||
'no-unsealed-workflow-entity-write': NoUnsealedWorkflowEntityWriteRule,
|
||||
'require-escaped-query-values': RequireEscapedQueryValuesRule,
|
||||
'no-on-leader-takeover': NoOnLeaderTakeoverRule,
|
||||
'no-deployment-key-delete': NoDeploymentKeyDeleteRule,
|
||||
'no-encryption-guardrail-disable': NoEncryptionGuardrailDisableRule,
|
||||
} satisfies Record<string, AnyRuleModule>;
|
||||
|
|
|
|||
|
|
@ -1,247 +0,0 @@
|
|||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
import path from 'node:path';
|
||||
import { NoDeploymentKeyDeleteRule } from './no-deployment-key-delete.js';
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: ['*.ts'],
|
||||
},
|
||||
tsconfigRootDir: path.join(import.meta.dirname, 'fixtures'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Minimal stand-in mirroring the repository from @n8n/db. */
|
||||
const repositoryClass = `
|
||||
class DeploymentKeyRepository {
|
||||
async delete(criteria: unknown): Promise<unknown> { return {}; }
|
||||
async remove(entity: unknown): Promise<unknown> { return {}; }
|
||||
async softDelete(criteria: unknown): Promise<unknown> { return {}; }
|
||||
async clear(): Promise<void> {}
|
||||
async update(id: string, patch: unknown): Promise<unknown> { return {}; }
|
||||
createQueryBuilder(alias?: string): { delete(): { execute(): Promise<unknown> } } { throw new Error(); }
|
||||
}
|
||||
`;
|
||||
|
||||
ruleTester.run('no-deployment-key-delete', NoDeploymentKeyDeleteRule, {
|
||||
valid: [
|
||||
// Deactivation is the sanctioned lifecycle operation
|
||||
{
|
||||
code: `${repositoryClass}
|
||||
declare const deploymentKeyRepository: DeploymentKeyRepository;
|
||||
void deploymentKeyRepository.update('id', { status: 'inactive' });`,
|
||||
filename: 'service.ts',
|
||||
},
|
||||
// delete on unrelated repositories stays allowed
|
||||
{
|
||||
code: `class WebhookRepository {
|
||||
async delete(criteria: unknown): Promise<unknown> { return {}; }
|
||||
}
|
||||
declare const webhookRepository: WebhookRepository;
|
||||
void webhookRepository.delete({});`,
|
||||
filename: 'service.ts',
|
||||
},
|
||||
// Plain collections are untouched
|
||||
{
|
||||
code: `const seen = new Map<string, string>();
|
||||
seen.delete('x');
|
||||
new Set<string>().clear();`,
|
||||
filename: 'service.ts',
|
||||
},
|
||||
// Test files may clean up rows
|
||||
{
|
||||
code: `${repositoryClass}
|
||||
declare const deploymentKeyRepository: DeploymentKeyRepository;
|
||||
void deploymentKeyRepository.delete({});`,
|
||||
filename: 'cleanup.test.ts',
|
||||
languageOptions: { parserOptions: { projectService: false } },
|
||||
},
|
||||
// The repository itself legitimately reaches the entity via the manager
|
||||
{
|
||||
code: `declare class DeploymentKey {}
|
||||
declare const tx: { getRepository(entity: unknown): unknown };
|
||||
tx.getRepository(DeploymentKey);`,
|
||||
filename: `${path.sep}repo${path.sep}packages${path.sep}@n8n${path.sep}db${path.sep}src${path.sep}repositories${path.sep}deployment-key.repository.ts`,
|
||||
languageOptions: { parserOptions: { projectService: false } },
|
||||
},
|
||||
// Reaching other entities through the manager stays allowed
|
||||
{
|
||||
code: `declare class WebhookEntity {}
|
||||
declare const tx: { getRepository(entity: unknown): unknown };
|
||||
tx.getRepository(WebhookEntity);`,
|
||||
filename: 'service.ts',
|
||||
},
|
||||
// Collections that merely hold entities are not a delete surface
|
||||
{
|
||||
code: `class DeploymentKey { id: string = ''; }
|
||||
declare const cache: Map<string, DeploymentKey>;
|
||||
cache.delete('x');`,
|
||||
filename: 'service.ts',
|
||||
},
|
||||
// The repository builds its own use-case queries
|
||||
{
|
||||
code: `${repositoryClass}
|
||||
class Extended extends DeploymentKeyRepository {
|
||||
list() { return this.createQueryBuilder('deploymentKey'); }
|
||||
}`,
|
||||
filename: `${path.sep}repo${path.sep}packages${path.sep}@n8n${path.sep}db${path.sep}src${path.sep}repositories${path.sep}deployment-key.repository.ts`,
|
||||
languageOptions: { parserOptions: { projectService: false } },
|
||||
},
|
||||
// Query builders on unrelated repositories stay allowed
|
||||
{
|
||||
code: `class WebhookRepository {
|
||||
createQueryBuilder(alias?: string): unknown { return {}; }
|
||||
}
|
||||
declare const webhookRepository: WebhookRepository;
|
||||
void webhookRepository.createQueryBuilder();`,
|
||||
filename: 'service.ts',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: `${repositoryClass}
|
||||
declare const deploymentKeyRepository: DeploymentKeyRepository;
|
||||
void deploymentKeyRepository.delete({});`,
|
||||
filename: 'service.ts',
|
||||
errors: [{ messageId: 'noDelete' }],
|
||||
},
|
||||
{
|
||||
code: `${repositoryClass}
|
||||
declare const deploymentKeyRepository: DeploymentKeyRepository;
|
||||
void deploymentKeyRepository.softDelete({});`,
|
||||
filename: 'service.ts',
|
||||
errors: [{ messageId: 'noDelete' }],
|
||||
},
|
||||
{
|
||||
code: `${repositoryClass}
|
||||
class Holder {
|
||||
constructor(private readonly repository: DeploymentKeyRepository) {}
|
||||
async run() { await this.repository.clear(); }
|
||||
}`,
|
||||
filename: 'service.ts',
|
||||
errors: [{ messageId: 'noDelete' }],
|
||||
},
|
||||
// Entity-manager form is caught syntactically
|
||||
{
|
||||
code: `declare class DeploymentKey {}
|
||||
declare const tx: { delete(entity: unknown, criteria: unknown): Promise<unknown> };
|
||||
void tx.delete(DeploymentKey, { id: '1' });`,
|
||||
filename: 'service.ts',
|
||||
errors: [{ messageId: 'noDelete' }],
|
||||
},
|
||||
{
|
||||
code: `declare class DeploymentKey {}
|
||||
declare const manager: { remove(entity: unknown, entities: unknown): Promise<unknown> };
|
||||
void manager.remove(DeploymentKey, []);`,
|
||||
filename: 'service.ts',
|
||||
errors: [{ messageId: 'noDelete' }],
|
||||
},
|
||||
// A typed entity instance is caught even without naming the class
|
||||
{
|
||||
code: `class DeploymentKey { id: string = ''; }
|
||||
declare const manager: { remove(entity: unknown): Promise<unknown> };
|
||||
declare const key: DeploymentKey;
|
||||
void manager.remove(key);`,
|
||||
filename: 'service.ts',
|
||||
errors: [{ messageId: 'noDelete' }],
|
||||
},
|
||||
// … including arrays of entities
|
||||
{
|
||||
code: `class DeploymentKey { id: string = ''; }
|
||||
declare const manager: { softRemove(entities: unknown): Promise<unknown> };
|
||||
declare const keys: DeploymentKey[];
|
||||
void manager.softRemove(keys);`,
|
||||
filename: 'service.ts',
|
||||
errors: [{ messageId: 'noDelete' }],
|
||||
},
|
||||
// Alternative surfaces over the entity bypass the repository lockdown
|
||||
{
|
||||
code: `declare class DeploymentKey {}
|
||||
declare const dataSource: { getRepository(entity: unknown): { delete(c: unknown): Promise<unknown> } };
|
||||
void dataSource.getRepository(DeploymentKey).delete({});`,
|
||||
filename: 'service.ts',
|
||||
errors: [{ messageId: 'noAlternativeSurface', data: { method: 'getRepository' } }],
|
||||
},
|
||||
{
|
||||
code: `declare class DeploymentKey {}
|
||||
declare const qb: { delete(): { from(entity: unknown): { execute(): Promise<unknown> } } };
|
||||
void qb.delete().from(DeploymentKey).execute();`,
|
||||
filename: 'service.ts',
|
||||
errors: [{ messageId: 'noAlternativeSurface', data: { method: 'from' } }],
|
||||
},
|
||||
{
|
||||
code: `declare class DeploymentKey {}
|
||||
declare const manager: { createQueryBuilder(entity: unknown, alias: string): unknown };
|
||||
void manager.createQueryBuilder(DeploymentKey, 'dk');`,
|
||||
filename: 'service.ts',
|
||||
errors: [{ messageId: 'noAlternativeSurface', data: { method: 'createQueryBuilder' } }],
|
||||
},
|
||||
// A receiver whose name gives no hint is still caught by its type
|
||||
{
|
||||
code: `${repositoryClass}
|
||||
declare const repo: DeploymentKeyRepository;
|
||||
void repo.delete({});`,
|
||||
filename: 'service.ts',
|
||||
errors: [{ messageId: 'noDelete' }],
|
||||
},
|
||||
// The generic TypeORM repository instantiated for the entity
|
||||
{
|
||||
code: `class DeploymentKey { id: string = ''; }
|
||||
class Repository<T> {
|
||||
async delete(criteria: unknown): Promise<T | undefined> { return undefined; }
|
||||
}
|
||||
declare const repo: Repository<DeploymentKey>;
|
||||
void repo.delete({});`,
|
||||
filename: 'service.ts',
|
||||
errors: [{ messageId: 'noDelete' }],
|
||||
},
|
||||
// Entity instances arriving through any expression shape
|
||||
{
|
||||
code: `class DeploymentKey { id: string = ''; }
|
||||
declare function loadKey(): Promise<DeploymentKey>;
|
||||
declare const manager: { remove(entity: unknown): Promise<unknown> };
|
||||
export const run = async () => await manager.remove(await loadKey());`,
|
||||
filename: 'service.ts',
|
||||
errors: [{ messageId: 'noDelete' }],
|
||||
},
|
||||
// String entity targets: class name or table name
|
||||
{
|
||||
code: `declare const tx: { getRepository(entity: unknown): unknown };
|
||||
tx.getRepository('DeploymentKey');`,
|
||||
filename: 'service.ts',
|
||||
errors: [{ messageId: 'noAlternativeSurface', data: { method: 'getRepository' } }],
|
||||
},
|
||||
{
|
||||
code: `declare const qb: { delete(): { from(entity: unknown): { execute(): Promise<unknown> } } };
|
||||
void qb.delete().from('deployment_key').execute();`,
|
||||
filename: 'service.ts',
|
||||
errors: [{ messageId: 'noAlternativeSurface', data: { method: 'from' } }],
|
||||
},
|
||||
// A no-substitution template literal is just another string spelling
|
||||
{
|
||||
code: 'declare const tx: { getRepository(entity: unknown): unknown };\ntx.getRepository(`deployment_key`);',
|
||||
filename: 'service.ts',
|
||||
errors: [{ messageId: 'noAlternativeSurface', data: { method: 'getRepository' } }],
|
||||
},
|
||||
// A repository-bound query builder names no entity — caught by receiver type
|
||||
{
|
||||
code: `${repositoryClass}
|
||||
declare const repo: DeploymentKeyRepository;
|
||||
void repo.createQueryBuilder().delete().execute();`,
|
||||
filename: 'service.ts',
|
||||
errors: [{ messageId: 'noRepositoryQueryBuilder' }],
|
||||
},
|
||||
{
|
||||
code: `class DeploymentKey { id: string = ''; }
|
||||
class Repository<T> {
|
||||
createQueryBuilder(alias?: string): T | undefined { return undefined; }
|
||||
}
|
||||
declare const repo: Repository<DeploymentKey>;
|
||||
void repo.createQueryBuilder('dk');`,
|
||||
filename: 'service.ts',
|
||||
errors: [{ messageId: 'noRepositoryQueryBuilder' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
|
||||
import type * as ts from 'typescript';
|
||||
|
||||
const DELETE_METHODS = new Set(['delete', 'remove', 'softDelete', 'softRemove', 'clear']);
|
||||
|
||||
/** Methods that hand out an alternative surface over the entity. */
|
||||
const ACCESS_METHODS = new Set(['getRepository', 'createQueryBuilder', 'from']);
|
||||
|
||||
/** The entity's class name and its table name, as entity targets. */
|
||||
const ENTITY_NAMES = new Set(['DeploymentKey', 'deployment_key']);
|
||||
|
||||
/** Argument shapes that can never carry an entity-instance type. */
|
||||
const NON_ENTITY_ARGUMENTS = new Set<TSESTree.AST_NODE_TYPES>([
|
||||
TSESTree.AST_NODE_TYPES.Literal,
|
||||
TSESTree.AST_NODE_TYPES.TemplateLiteral,
|
||||
TSESTree.AST_NODE_TYPES.ObjectExpression,
|
||||
]);
|
||||
|
||||
/** Test files may clean up rows for setup/teardown. */
|
||||
const TEST_FILE_PATTERN = /(\.(test|spec)\.ts$)|([\\/]__tests__[\\/])|([\\/]test[\\/])/;
|
||||
|
||||
/** The repository itself legitimately uses `tx.getRepository(DeploymentKey)`. */
|
||||
const OWN_REPOSITORY_FILE = /[\\/]deployment-key\.repository\.ts$/;
|
||||
|
||||
export const NoDeploymentKeyDeleteRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow deleting deployment keys, and disallow reaching the deployment_key entity through surfaces that would allow it (entity-manager calls, query builders, `getRepository`). Old ciphertext stays decryptable only while every key row survives — keys are deactivated, never deleted.',
|
||||
},
|
||||
messages: {
|
||||
noDelete:
|
||||
'Never delete deployment keys: data encrypted with a key becomes unreadable without it. Deactivate the key instead (`markInactive`).',
|
||||
noAlternativeSurface:
|
||||
'Do not reach the deployment_key entity through `{{method}}` — it bypasses the delete lockdown. Go through `DeploymentKeyRepository`.',
|
||||
noRepositoryQueryBuilder:
|
||||
'Do not build queries on the deployment-key repository from outside it — `createQueryBuilder().delete()` bypasses the delete lockdown. Add a use-case method to `DeploymentKeyRepository` instead.',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
if (TEST_FILE_PATTERN.test(context.filename)) return {};
|
||||
const isOwnRepositoryFile = OWN_REPOSITORY_FILE.test(context.filename);
|
||||
|
||||
/** Identifier `DeploymentKey`, or the entity/table name as a string target. */
|
||||
const namesEntity = (node: TSESTree.Node | undefined): boolean => {
|
||||
if (node === undefined) return false;
|
||||
if (node.type === TSESTree.AST_NODE_TYPES.Identifier) {
|
||||
return node.name === 'DeploymentKey';
|
||||
}
|
||||
if (node.type === TSESTree.AST_NODE_TYPES.Literal) {
|
||||
return typeof node.value === 'string' && ENTITY_NAMES.has(node.value);
|
||||
}
|
||||
// A no-substitution template literal is just another string spelling.
|
||||
return (
|
||||
node.type === TSESTree.AST_NODE_TYPES.TemplateLiteral &&
|
||||
node.expressions.length === 0 &&
|
||||
node.quasis.length === 1 &&
|
||||
ENTITY_NAMES.has(node.quasis[0].value.cooked ?? '')
|
||||
);
|
||||
};
|
||||
|
||||
/** True when the type of `node` is (an array of) the DeploymentKey entity. */
|
||||
const isDeploymentKeyTyped = (node: TSESTree.Node): boolean => {
|
||||
const services = ESLintUtils.getParserServices(context);
|
||||
const type = services.getTypeAtLocation(node);
|
||||
const symbolName = (type.getSymbol() ?? type.aliasSymbol)?.getName();
|
||||
if (symbolName === 'DeploymentKey') return true;
|
||||
const elementSymbol = type.getNumberIndexType?.()?.getSymbol();
|
||||
return elementSymbol?.getName() === 'DeploymentKey';
|
||||
};
|
||||
|
||||
/**
|
||||
* The concrete repository by name, or TypeORM's generic `Repository`
|
||||
* instantiated for the entity (`Repository<DeploymentKey>`, e.g. from
|
||||
* `getRepository(...)` results stored behind an annotation).
|
||||
*/
|
||||
const isDeploymentKeyRepository = (type: ts.Type): boolean => {
|
||||
const symbolName = (type.getSymbol() ?? type.aliasSymbol)?.getName();
|
||||
if (symbolName === 'DeploymentKeyRepository') return true;
|
||||
if (symbolName !== 'Repository') return false;
|
||||
const typeArguments = (type as ts.TypeReference).typeArguments ?? [];
|
||||
return typeArguments.some((arg) => arg.getSymbol()?.getName() === 'DeploymentKey');
|
||||
};
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
const callee =
|
||||
node.callee.type === TSESTree.AST_NODE_TYPES.ChainExpression
|
||||
? node.callee.expression
|
||||
: node.callee;
|
||||
if (callee.type !== TSESTree.AST_NODE_TYPES.MemberExpression) return;
|
||||
|
||||
const { property } = callee;
|
||||
let method: string | null = null;
|
||||
if (!callee.computed && property.type === TSESTree.AST_NODE_TYPES.Identifier) {
|
||||
method = property.name;
|
||||
} else if (
|
||||
callee.computed &&
|
||||
property.type === TSESTree.AST_NODE_TYPES.Literal &&
|
||||
typeof property.value === 'string'
|
||||
) {
|
||||
method = property.value;
|
||||
}
|
||||
if (method === null) return;
|
||||
|
||||
const [firstArg] = node.arguments;
|
||||
|
||||
// Alternative surfaces over the entity bypass the repository's
|
||||
// delete lockdown: tx.getRepository(DeploymentKey).delete(…),
|
||||
// qb.delete().from(DeploymentKey), createQueryBuilder(DeploymentKey, …)
|
||||
// — by identifier or by entity/table name string.
|
||||
if (ACCESS_METHODS.has(method)) {
|
||||
// The repository builds its own use-case queries.
|
||||
if (isOwnRepositoryFile) return;
|
||||
if (namesEntity(firstArg)) {
|
||||
context.report({ node: property, messageId: 'noAlternativeSurface', data: { method } });
|
||||
return;
|
||||
}
|
||||
// A repository is already bound to its entity, so
|
||||
// `repository.createQueryBuilder()` names no entity at all —
|
||||
// catch it by the receiver's type instead.
|
||||
if (method === 'createQueryBuilder') {
|
||||
const services = ESLintUtils.getParserServices(context);
|
||||
if (isDeploymentKeyRepository(services.getTypeAtLocation(callee.object))) {
|
||||
context.report({ node: property, messageId: 'noRepositoryQueryBuilder' });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!DELETE_METHODS.has(method)) return;
|
||||
|
||||
// Entity-manager form: tx.delete(DeploymentKey, …) / tx.delete('deployment_key', …).
|
||||
if (namesEntity(firstArg)) {
|
||||
context.report({ node: property, messageId: 'noDelete' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Typed entity instances in any argument position, whatever the
|
||||
// expression shape (identifier, member, call result, await, array).
|
||||
for (const argument of node.arguments) {
|
||||
if (NON_ENTITY_ARGUMENTS.has(argument.type)) continue;
|
||||
const target =
|
||||
argument.type === TSESTree.AST_NODE_TYPES.SpreadElement ? argument.argument : argument;
|
||||
if (isDeploymentKeyTyped(target)) {
|
||||
context.report({ node: property, messageId: 'noDelete' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Repository form: the concrete repository or Repository<DeploymentKey>.
|
||||
const services = ESLintUtils.getParserServices(context);
|
||||
const receiverType = services.getTypeAtLocation(callee.object);
|
||||
if (!isDeploymentKeyRepository(receiverType)) return;
|
||||
|
||||
context.report({ node: property, messageId: 'noDelete' });
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
import { NoDeploymentKeyDeleteRule } from './no-deployment-key-delete.js';
|
||||
import { NoEncryptionGuardrailDisableRule } from './no-encryption-guardrail-disable.js';
|
||||
|
||||
// Register the guarded rules so ESLint can resolve the disable directives under
|
||||
|
|
@ -12,7 +11,6 @@ const ruleTester = new RuleTester({
|
|||
plugins: {
|
||||
'n8n-local-rules': {
|
||||
rules: {
|
||||
'no-deployment-key-delete': NoDeploymentKeyDeleteRule,
|
||||
'no-encryption-guardrail-disable': NoEncryptionGuardrailDisableRule,
|
||||
},
|
||||
},
|
||||
|
|
@ -23,7 +21,7 @@ ruleTester.run('no-encryption-guardrail-disable', NoEncryptionGuardrailDisableRu
|
|||
valid: [
|
||||
// A plain comment mentioning a rule name is not a disable directive
|
||||
{
|
||||
code: '// enforced by n8n-local-rules/no-deployment-key-delete\nconst a = 1;',
|
||||
code: '// enforced by n8n-local-rules/no-encryption-guardrail-disable\nconst a = 1;',
|
||||
},
|
||||
// Disabling unrelated rules is fine
|
||||
{
|
||||
|
|
@ -31,14 +29,10 @@ ruleTester.run('no-encryption-guardrail-disable', NoEncryptionGuardrailDisableRu
|
|||
},
|
||||
// A guarded rule mentioned only in the `--` explanation is not disabled
|
||||
{
|
||||
code: '// eslint-disable-next-line no-console -- keep parity with no-deployment-key-delete docs\nconsole.log("x");',
|
||||
code: '// eslint-disable-next-line no-console -- keep parity with encryption guardrail docs\nconsole.log("x");',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: '/* eslint-disable n8n-local-rules/no-deployment-key-delete */\nconst a = 1;',
|
||||
errors: [{ messageId: 'noDisable', data: { rule: 'no-deployment-key-delete' } }],
|
||||
},
|
||||
// Blanket line-form disables silence every rule on the target line
|
||||
{
|
||||
code: '// eslint-disable-next-line\nconst a = 1;',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { ESLintUtils } from '@typescript-eslint/utils';
|
||||
|
||||
const TARGET_RULES = [
|
||||
'no-deployment-key-delete',
|
||||
// A `-next-line` directive naming this rule cannot suppress the report
|
||||
// this rule emits at the directive's own line.
|
||||
'no-encryption-guardrail-disable',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
import { NoGuardrailDisableRule } from './no-guardrail-disable.js';
|
||||
import { MisplacedN8nTypeormImportRule } from './misplaced-n8n-typeorm-import.js';
|
||||
import { NoUnsealedWorkflowEntityWriteRule } from './no-unsealed-workflow-entity-write.js';
|
||||
import { MisplacedN8nTypeormImportRule } from './misplaced-n8n-typeorm-import.js';
|
||||
|
||||
// Register the guarded rules so ESLint can resolve the disable directives under test (an
|
||||
// unknown rule name in a directive would emit an extra "rule not found" error). The `-audit`
|
||||
|
|
@ -75,6 +75,11 @@ ruleTester.run('no-guardrail-disable', NoGuardrailDisableRule, {
|
|||
options,
|
||||
errors: [typeormHit],
|
||||
},
|
||||
{
|
||||
code: '// oxlint-disable-next-line n8n-local-rules/no-unsealed-workflow-entity-write\nexport const x = 1;',
|
||||
options,
|
||||
errors: [sealHit],
|
||||
},
|
||||
{
|
||||
code: '/* eslint-disable n8n-local-rules/no-unsealed-workflow-entity-write */',
|
||||
options,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ESLintUtils } from '@typescript-eslint/utils';
|
||||
|
||||
/** Captures the directive kind, then an optional rule list and an optional `-- reason`. */
|
||||
const DISABLE_DIRECTIVE = /^eslint-disable(-next-line|-line)?(?![\w-])([\s\S]*)$/;
|
||||
const DISABLE_DIRECTIVE = /^(?:eslint|oxlint)-disable(-next-line|-line)?(?![\w-])([\s\S]*)$/;
|
||||
const PLUGIN_PREFIX = 'n8n-local-rules/';
|
||||
const SELF = 'no-guardrail-disable';
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ export const NoGuardrailDisableRule = ESLintUtils.RuleCreator.withoutDocs({
|
|||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow inline eslint-disable of the configured guardrail rules.',
|
||||
description: 'Disallow inline disable directives for the configured guardrail rules.',
|
||||
},
|
||||
messages: {
|
||||
noDisable: 'Do not disable `{{rule}}` inline. {{message}}',
|
||||
|
|
|
|||
|
|
@ -1,281 +1,60 @@
|
|||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
import path from 'node:path';
|
||||
|
||||
import { NoUnsealedWorkflowEntityWriteRule } from './no-unsealed-workflow-entity-write.js';
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: ['*.ts'],
|
||||
},
|
||||
tsconfigRootDir: path.join(import.meta.dirname, 'fixtures'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Minimal stand-ins for the TypeORM and `@n8n/db` shapes the rule resolves through. */
|
||||
const dbTypes = `
|
||||
class WorkflowEntity { id: string; name: string; nodes: unknown[]; active: boolean; activeVersionId: string | null; isArchived: boolean; settings: object; }
|
||||
class CredentialsEntity { id: string; data: string; }
|
||||
class SharedWorkflow { workflowId: string; projectId: string; workflow: WorkflowEntity; }
|
||||
class WorkflowHistory { versionId: string; nodes: unknown[]; }
|
||||
type DeepPartial<T> = { [K in keyof T]?: T[K] };
|
||||
declare class UpdateQueryBuilder<T> { set(values: DeepPartial<T>): this; where(q: string, p?: object): this; execute(): Promise<unknown>; }
|
||||
declare class InsertQueryBuilder<T> { into<E>(target: new () => E): InsertQueryBuilder<E>; values(v: DeepPartial<T>): this; execute(): Promise<unknown>; }
|
||||
declare class SelectQueryBuilder<T> {
|
||||
update(): UpdateQueryBuilder<T>;
|
||||
update<E>(target: new () => E, values?: DeepPartial<E>): UpdateQueryBuilder<E>;
|
||||
insert(): InsertQueryBuilder<T>;
|
||||
}
|
||||
declare class EntityManager {
|
||||
save<T>(entity: T): Promise<T>;
|
||||
save<T>(target: new () => T, entity: DeepPartial<T>): Promise<T>;
|
||||
insert<T>(target: new () => T, entity: DeepPartial<T>): Promise<unknown>;
|
||||
upsert<T>(target: new () => T, entity: DeepPartial<T>, conflict: string[]): Promise<unknown>;
|
||||
update<T>(target: new () => T, criteria: unknown, partial: DeepPartial<T>): Promise<unknown>;
|
||||
createQueryBuilder(): SelectQueryBuilder<unknown>;
|
||||
getRepository<T>(target: new () => T): Repository<T>;
|
||||
query(sql: string, params?: unknown[]): Promise<unknown>;
|
||||
}
|
||||
declare class Repository<T> {
|
||||
manager: EntityManager;
|
||||
create(partial: DeepPartial<T>): T;
|
||||
save(entity: T | T[]): Promise<T>;
|
||||
insert(entity: DeepPartial<T>): Promise<unknown>;
|
||||
upsert(entity: DeepPartial<T>, conflict: string[]): Promise<unknown>;
|
||||
update(criteria: unknown, partial: DeepPartial<T>): Promise<unknown>;
|
||||
delete(criteria: unknown): Promise<unknown>;
|
||||
createQueryBuilder(alias?: string): SelectQueryBuilder<T>;
|
||||
}
|
||||
declare class WorkflowRepository extends Repository<WorkflowEntity> {
|
||||
updateContent(id: string, content: DeepPartial<WorkflowEntity>, ctx: object): Promise<void>;
|
||||
createContent(workflow: WorkflowEntity, ctx: object): Promise<WorkflowEntity>;
|
||||
upsertImportedContent(content: DeepPartial<WorkflowEntity>, ctx: object): Promise<string>;
|
||||
}
|
||||
declare class SharedWorkflowRepository extends Repository<SharedWorkflow> {}
|
||||
declare class CredentialsRepository extends Repository<CredentialsEntity> {}
|
||||
declare class WorkflowHistoryRepository extends Repository<WorkflowHistory> {}
|
||||
declare const Container: { get<T>(c: new (...args: never[]) => T): T };
|
||||
declare const manager: EntityManager;
|
||||
declare const repo: WorkflowRepository;
|
||||
declare const wf: WorkflowEntity;
|
||||
declare const cred: CredentialsEntity;
|
||||
declare const shared: SharedWorkflow;
|
||||
declare const history: WorkflowHistory;
|
||||
declare const id: string;
|
||||
declare const ctx: object;
|
||||
`;
|
||||
|
||||
const typed = (code: string) => ({
|
||||
name: code,
|
||||
code: `${dbTypes}\n${code}`,
|
||||
filename: 'foo.service.ts',
|
||||
});
|
||||
|
||||
/** The syntactic floor: no program, so only names and inline literals are inspected. */
|
||||
const untyped = (code: string, filename: string) => ({
|
||||
name: code,
|
||||
code,
|
||||
filename,
|
||||
languageOptions: { parserOptions: { projectService: false } },
|
||||
});
|
||||
|
||||
const businessLogic = `${path.sep}repo${path.sep}packages${path.sep}cli${path.sep}src${path.sep}services${path.sep}foo.service.ts`;
|
||||
const dbService = `${path.sep}repo${path.sep}packages${path.sep}@n8n${path.sep}db${path.sep}src${path.sep}services${path.sep}foo.service.ts`;
|
||||
const sealedRepository = `${path.sep}repo${path.sep}packages${path.sep}@n8n${path.sep}db${path.sep}src${path.sep}repositories${path.sep}workflow.repository.ts`;
|
||||
const migration = `${path.sep}repo${path.sep}packages${path.sep}@n8n${path.sep}db${path.sep}src${path.sep}migrations${path.sep}1-foo.ts`;
|
||||
const testUtils = `${path.sep}repo${path.sep}packages${path.sep}@n8n${path.sep}backend-test-utils${path.sep}src${path.sep}db${path.sep}workflows.ts`;
|
||||
const integrationTest = `${path.sep}repo${path.sep}packages${path.sep}cli${path.sep}test${path.sep}integration${path.sep}setup.ts`;
|
||||
const testFolderInRuntimeTree = `${path.sep}repo${path.sep}packages${path.sep}cli${path.sep}src${path.sep}test${path.sep}foo.service.ts`;
|
||||
|
||||
const unsealed = [{ messageId: 'unsealedWrite' as const }];
|
||||
const opaque = [{ messageId: 'opaquePayload' as const }];
|
||||
const ruleTester = new RuleTester();
|
||||
const errors = [{ messageId: 'unsealedWrite' as const }];
|
||||
|
||||
ruleTester.run('no-unsealed-workflow-entity-write', NoUnsealedWorkflowEntityWriteRule, {
|
||||
valid: [
|
||||
// The sanctioned, token-gated write methods, also through an alias.
|
||||
typed('repo.updateContent(id, { nodes: [] }, ctx);'),
|
||||
typed('repo.createContent(wf, ctx);'),
|
||||
typed('repo.upsertImportedContent({ id, nodes: [] }, ctx);'),
|
||||
typed('const r = repo; r.updateContent(id, { nodes: [] }, ctx);'),
|
||||
// Non-node partial updates: inline, hoisted with an inferred type, spread, or narrowed.
|
||||
typed('repo.update(id, { active: true });'),
|
||||
typed('repo.update({ id }, { isArchived: false });'),
|
||||
typed('const rollback = { active: false, activeVersionId: null }; repo.update(id, rollback);'),
|
||||
typed(
|
||||
'const rollback = { active: false }; repo.update(id, { ...rollback, isArchived: true });',
|
||||
),
|
||||
typed(
|
||||
"declare const p: Pick<WorkflowEntity, 'active' | 'activeVersionId'>; repo.update(id, p);",
|
||||
),
|
||||
typed('manager.update(WorkflowEntity, { id }, { settings: {} });'),
|
||||
typed('manager.update(WorkflowEntity, { id }, { active: false });'),
|
||||
typed(
|
||||
"repo.createQueryBuilder().update().set({ active: true }).where('id = :id', { id }).execute();",
|
||||
),
|
||||
// Existence changes and entity construction are out of the node seal's scope.
|
||||
typed('repo.delete(id);'),
|
||||
typed('repo.create({ id, nodes: [] });'),
|
||||
// Other entities, including one whose relation points at WorkflowEntity.
|
||||
typed('manager.save(cred);'),
|
||||
typed('manager.save(shared);'),
|
||||
typed('manager.save(history);'),
|
||||
typed('Container.get(SharedWorkflowRepository).save(shared);'),
|
||||
typed('Container.get(CredentialsRepository).save(cred);'),
|
||||
typed('Container.get(WorkflowHistoryRepository).insert({ versionId: id, nodes: [] });'),
|
||||
typed(
|
||||
'declare const sharedRepo: SharedWorkflowRepository; sharedRepo.insert({ workflowId: id, projectId: id });',
|
||||
),
|
||||
typed(
|
||||
"manager.createQueryBuilder().insert().into(CredentialsEntity).values({ data: '' }).execute();",
|
||||
),
|
||||
// Raw SQL that does not write workflow_entity.
|
||||
typed("manager.query('UPDATE credentials_entity SET data = $1 WHERE id = $2', ['', id]);"),
|
||||
typed("manager.query('SELECT nodes FROM workflow_entity WHERE id = $1', [id]);"),
|
||||
typed("manager.query('DELETE FROM workflow_entity WHERE id = $1', [id]);"),
|
||||
// Test files are exempt even with type information.
|
||||
{ ...typed('repo.save(wf);'), filename: 'foo.test.ts' },
|
||||
// The floor: untyped shapes that were never flagged stay unflagged.
|
||||
untyped('this.workflowRepository.update(id, { active: true });', businessLogic),
|
||||
untyped('trx.update(WorkflowEntity, { id: workflowId }, { settings });', businessLogic),
|
||||
untyped('this.sharedWorkflowRepository.save(sw);', businessLogic),
|
||||
untyped('this.sharedWorkflowRepo.save(sw);', businessLogic),
|
||||
untyped('this.userRepository.save(user);', businessLogic),
|
||||
untyped('manager.save<CredentialsEntity>(cred);', businessLogic),
|
||||
// Exempt paths: the sealed repository, migrations, test utilities, integration tests.
|
||||
untyped('manager.save<WorkflowEntity>(wf);', sealedRepository),
|
||||
untyped("await queryRunner.query('UPDATE workflow_entity SET nodes = ?');", migration),
|
||||
untyped('await Container.get(WorkflowRepository).save(newWorkflow(attributes));', testUtils),
|
||||
untyped('await workflowRepository.save(workflow);', integrationTest),
|
||||
'workflowRepository.update(id, { active: false });',
|
||||
'workflowRepository.update(id);',
|
||||
'workflowRepository.updateContent(id, { nodes: [] }, ctx);',
|
||||
"manager.query('DELETE FROM workflow_entity WHERE id = ?');",
|
||||
"manager.query('SELECT nodes FROM workflow_entity WHERE id = ?');",
|
||||
{ code: 'workflowRepository.save(workflow);', filename: '/src/example.test.ts' },
|
||||
{
|
||||
code: 'workflowRepository.save(workflow);',
|
||||
filename: '/repositories/workflow.repository.ts',
|
||||
},
|
||||
{
|
||||
code: 'workflowRepository.save(workflow);',
|
||||
filename: '/packages/@n8n/backend-test-utils/src/db/workflows.ts',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
// Hoisted payloads: inferred, factory-built, spread, declared wide, or untyped.
|
||||
{ code: 'workflowRepository.save(workflow);', errors },
|
||||
{ code: 'this.workflowRepository.save(workflow);', errors },
|
||||
{ code: 'this.workflowRepo.update(id, { nodes: [] });', errors },
|
||||
{ code: "workflowRepository['save'](workflow);", errors },
|
||||
{ code: 'workflowsRepo.insert(workflow);', errors },
|
||||
{ code: 'Container.get(WorkflowRepository).save(workflow);', errors },
|
||||
{ code: 'dataSource.getRepository(WorkflowEntity).save(workflow);', errors },
|
||||
{ code: 'manager.getRepository(WorkflowEntity).insert(workflow);', errors },
|
||||
{ code: "manager.getRepository(WorkflowEntity).upsert(workflow, ['id']);", errors },
|
||||
{ code: 'manager.getRepository(WorkflowEntity).update(id, { nodes: [] });', errors },
|
||||
{ code: 'workflowRepository?.save(workflow);', errors },
|
||||
{ code: 'workflowRepo.insert(workflow);', errors },
|
||||
{ code: 'workflowRepository.update(id, { nodes: [] });', errors },
|
||||
{ code: "workflowRepository.update(id, { 'nodes': [] });", errors },
|
||||
{ code: 'manager.save(WorkflowEntity, workflow);', errors },
|
||||
{ code: "manager.upsert(WorkflowEntity, workflow, ['id']);", errors },
|
||||
{ code: 'manager.update(WorkflowEntity, id, { nodes: [] });', errors },
|
||||
{ code: 'manager.update<WorkflowEntity>(id, { nodes: [] });', errors },
|
||||
{ code: 'queryBuilder.insert().into(WorkflowEntity);', errors },
|
||||
{
|
||||
...typed("const content = { name: 'x', nodes: [] }; repo.update(id, content);"),
|
||||
errors: unsealed,
|
||||
code: 'manager.createQueryBuilder().update(WorkflowEntity).set({ nodes: [] });',
|
||||
errors,
|
||||
},
|
||||
{
|
||||
...typed('declare function build(): { nodes: unknown[] }; repo.update(id, build());'),
|
||||
errors: unsealed,
|
||||
code: 'workflowRepository.createQueryBuilder().update().set({ nodes: [] });',
|
||||
errors,
|
||||
},
|
||||
{
|
||||
...typed('const base = { nodes: [] }; repo.update(id, { ...base, active: true });'),
|
||||
errors: unsealed,
|
||||
},
|
||||
{
|
||||
...typed('declare const payload: DeepPartial<WorkflowEntity>; repo.update(id, payload);'),
|
||||
errors: opaque,
|
||||
},
|
||||
{ ...typed('declare const payload: any; repo.update(id, payload);'), errors: opaque },
|
||||
{ ...typed('declare const payload: unknown; repo.update(id, payload);'), errors: opaque },
|
||||
{
|
||||
...typed('declare const extra: any; repo.update(id, { ...extra, active: true });'),
|
||||
errors: opaque,
|
||||
},
|
||||
{
|
||||
...typed('const content = { nodes: [] }; manager.update(WorkflowEntity, { id }, content);'),
|
||||
errors: unsealed,
|
||||
},
|
||||
// Receivers resolved by type, whatever their name.
|
||||
{
|
||||
...typed(`class S {
|
||||
constructor(private readonly repository: WorkflowRepository) {}
|
||||
run() { const r = this.repository; return r.save(wf); }
|
||||
}`),
|
||||
errors: unsealed,
|
||||
},
|
||||
{ ...typed('const { manager: m } = repo; m.save(wf);'), errors: unsealed },
|
||||
{
|
||||
...typed(
|
||||
'function persist(r: Repository<WorkflowEntity>) { return r.insert({ nodes: [] }); }',
|
||||
),
|
||||
errors: unsealed,
|
||||
},
|
||||
{ ...typed('Container.get(WorkflowRepository).save(wf);'), errors: unsealed },
|
||||
{ ...typed('manager.getRepository(WorkflowEntity).save(wf);'), errors: unsealed },
|
||||
{
|
||||
...typed(
|
||||
"declare class Foo extends Repository<WorkflowEntity> {} declare const foo: Foo; foo.upsert({ nodes: [] }, ['id']);",
|
||||
),
|
||||
errors: unsealed,
|
||||
},
|
||||
{ ...typed('repo.manager.save(wf);'), errors: unsealed },
|
||||
// Entities resolved by type.
|
||||
{ ...typed('manager.save(wf);'), errors: unsealed },
|
||||
{ ...typed('manager.save([wf]);'), errors: unsealed },
|
||||
{
|
||||
...typed('declare const w: WorkflowEntity | CredentialsEntity; manager.save(w);'),
|
||||
errors: unsealed,
|
||||
},
|
||||
{ ...typed('manager.save(WorkflowEntity, { nodes: [] });'), errors: unsealed },
|
||||
{ ...typed("manager.upsert(WorkflowEntity, { nodes: [] }, ['id']);"), errors: unsealed },
|
||||
// Query builders.
|
||||
{
|
||||
...typed('repo.createQueryBuilder().update().set({ nodes: [] }).execute();'),
|
||||
errors: unsealed,
|
||||
},
|
||||
{
|
||||
...typed('repo.createQueryBuilder().update().where("id = :id", { id }).execute();'),
|
||||
errors: unsealed,
|
||||
},
|
||||
{
|
||||
...typed('manager.createQueryBuilder().update(WorkflowEntity).set({ nodes: [] }).execute();'),
|
||||
errors: unsealed,
|
||||
},
|
||||
{
|
||||
...typed('manager.createQueryBuilder().update(WorkflowEntity, { nodes: [] }).execute();'),
|
||||
errors: unsealed,
|
||||
},
|
||||
{
|
||||
...typed(
|
||||
'manager.createQueryBuilder().insert().into(WorkflowEntity).values({ nodes: [] }).execute();',
|
||||
),
|
||||
errors: unsealed,
|
||||
},
|
||||
{
|
||||
...typed('repo.createQueryBuilder().insert().values({ nodes: [] }).execute();'),
|
||||
errors: unsealed,
|
||||
},
|
||||
// Raw SQL.
|
||||
{
|
||||
...typed("manager.query('UPDATE workflow_entity SET nodes = $1 WHERE id = $2', [[], id]);"),
|
||||
errors: unsealed,
|
||||
},
|
||||
{
|
||||
...typed(
|
||||
'manager.query(`INSERT INTO "workflow_entity" (id, nodes) VALUES ($1, $2)`, [id, []]);',
|
||||
),
|
||||
errors: unsealed,
|
||||
},
|
||||
// Optional chaining and computed keys.
|
||||
{ ...typed('repo?.save(wf);'), errors: unsealed },
|
||||
{ ...typed("repo['save'](wf);"), errors: unsealed },
|
||||
// The floor: untyped shapes that were always flagged.
|
||||
{ ...untyped('manager.save<WorkflowEntity>(wf);', businessLogic), errors: unsealed },
|
||||
{ ...untyped("tx.upsert(WorkflowEntity, wf, ['id']);", businessLogic), errors: unsealed },
|
||||
{ ...untyped('transactionManager.save(WorkflowEntity, wf);', businessLogic), errors: unsealed },
|
||||
{ ...untyped('this.workflowRepository.save(wf);', businessLogic), errors: unsealed },
|
||||
{
|
||||
...untyped('this.workflowRepository.update(id, { nodes: [] });', businessLogic),
|
||||
errors: unsealed,
|
||||
},
|
||||
{
|
||||
...untyped("this.workflowRepository.update(id, { 'nodes': [] });", businessLogic),
|
||||
errors: unsealed,
|
||||
},
|
||||
{
|
||||
...untyped("this.workflowRepository.update(id, { ['nodes']: [] });", businessLogic),
|
||||
errors: unsealed,
|
||||
},
|
||||
{
|
||||
...untyped('this.workflowRepo.save(this.workflowRepo.create({ id, nodes }));', businessLogic),
|
||||
errors: unsealed,
|
||||
},
|
||||
{ ...untyped('this.workflowsRepository.insert(wf);', businessLogic), errors: unsealed },
|
||||
{ ...untyped('workflowRepo.update(id, { nodes: [] });', businessLogic), errors: unsealed },
|
||||
// Only the sealed repository and migrations are exempt inside @n8n/db.
|
||||
{ ...untyped('manager.save<WorkflowEntity>(wf);', dbService), errors: unsealed },
|
||||
// A runtime folder that happens to be named `test` is not a test path.
|
||||
{ ...untyped('this.workflowRepository.save(wf);', testFolderInRuntimeTree), errors: unsealed },
|
||||
{ code: "manager.query('UPDATE workflow_entity SET nodes = ?');", errors },
|
||||
{ code: 'manager.query(`INSERT INTO workflow_entity (nodes) VALUES (?)`);', errors },
|
||||
{ code: 'manager.query(`UPDATE ${prefix}workflow_entity SET nodes = ?`);', errors },
|
||||
{ code: 'manager.query(`UPDATE\\x20workflow_entity SET nodes = ?`);', errors },
|
||||
{ code: "manager.query('UPDATE public.workflow_entity SET nodes = ?');", errors },
|
||||
{ code: 'manager.query(`UPDATE "public"."workflow_entity" SET nodes = ?`);', errors },
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,260 +1,203 @@
|
|||
import { AST_NODE_TYPES, ESLintUtils, type TSESTree } from '@typescript-eslint/utils';
|
||||
import ts from 'typescript';
|
||||
import { ESLintUtils } from '@typescript-eslint/utils';
|
||||
|
||||
// Node-content writes only. `delete`/`remove`/`softDelete`/`recover` change a row's existence,
|
||||
// not its node content, so the node seal does not govern them.
|
||||
const ENTITY_WRITE_METHODS = new Set(['save', 'insert', 'upsert']);
|
||||
const CHECKED_METHODS = new Set([...ENTITY_WRITE_METHODS, 'update', 'into', 'query']);
|
||||
|
||||
/** Tests, fixtures and test utilities write `WorkflowEntity` for setup; the seal governs runtime code. */
|
||||
const NON_RUNTIME_FILE =
|
||||
/(\.test\.ts$|\.spec\.ts$|[\\/]__tests__[\\/]|[\\/]packages[\\/](@n8n[\\/])?[^\\/]+[\\/]test[\\/]|[\\/]backend-test-utils[\\/])/;
|
||||
/(\.(test|spec)\.ts$)|([\\/]__tests__[\\/])|([\\/]test[\\/])|([\\/]migrations[\\/])|([\\/]backend-test-utils[\\/])/;
|
||||
const WORKFLOW_REPOSITORY_FILE = /[\\/]repositories[\\/]workflow\.repository\.ts$/;
|
||||
const WRITE_METHODS = new Set(['save', 'insert', 'upsert']);
|
||||
const RAW_WORKFLOW_WRITE =
|
||||
/\b(?:UPDATE|INSERT\s+INTO)\s+(?:(?:"?\w+"?)\s*\.\s*)?["'`]?\w*workflow_entity["'`]?(?=\s|\(|$)/i;
|
||||
const WORKFLOW_REPOSITORY_NAME = /^(?:workflow|workflows)Repo(?:sitory)?$/i;
|
||||
|
||||
/** Only the sealed repository and migrations may write `WorkflowEntity` directly. */
|
||||
const PERSISTENCE_FILE =
|
||||
/([\\/]@n8n[\\/]db[\\/]src[\\/]repositories[\\/]workflow\.repository\.ts$|[\\/]migrations[\\/])/;
|
||||
|
||||
const RAW_SQL_WORKFLOW_WRITE = /\b(update|insert\s+into)\s+"?workflow_entity"?\b/i;
|
||||
|
||||
// Matches the repository under any injected name (`workflowRepo`, `workflowsRepository`, …),
|
||||
// while a sibling like `sharedWorkflowRepository` stays out because the match is anchored.
|
||||
const WORKFLOW_REPO_RECEIVER = /^workflows?Repo/;
|
||||
|
||||
const WORKFLOW_TYPE_SYMBOLS = new Set(['WorkflowEntity', 'WorkflowRepository']);
|
||||
|
||||
type TypeServices = {
|
||||
checker: ts.TypeChecker;
|
||||
typeOf: (node: TSESTree.Node) => ts.Type;
|
||||
type SyntaxNode = {
|
||||
type: string;
|
||||
name?: string;
|
||||
value?: unknown;
|
||||
computed?: boolean;
|
||||
property?: SyntaxNode;
|
||||
object?: SyntaxNode;
|
||||
expression?: SyntaxNode;
|
||||
callee?: SyntaxNode;
|
||||
arguments?: SyntaxNode[];
|
||||
properties?: Array<{
|
||||
type: string;
|
||||
computed?: boolean;
|
||||
key?: SyntaxNode;
|
||||
}>;
|
||||
};
|
||||
|
||||
type PayloadVerdict = 'nodes' | 'opaque' | 'clean';
|
||||
const unwrapChain = (node: SyntaxNode): SyntaxNode =>
|
||||
node.type === 'ChainExpression' && node.expression ? node.expression : node;
|
||||
|
||||
const isWorkflowEntityIdentifier = (node: TSESTree.Node | undefined) =>
|
||||
node?.type === AST_NODE_TYPES.Identifier && node.name === 'WorkflowEntity';
|
||||
|
||||
const memberName = (member: TSESTree.MemberExpression): string | undefined => {
|
||||
if (!member.computed && member.property.type === AST_NODE_TYPES.Identifier) {
|
||||
return member.property.name;
|
||||
}
|
||||
const memberName = (node: SyntaxNode) => {
|
||||
if (!node.property) return undefined;
|
||||
if (!node.computed && node.property.type === 'Identifier') return node.property.name;
|
||||
if (
|
||||
member.computed &&
|
||||
member.property.type === AST_NODE_TYPES.Literal &&
|
||||
typeof member.property.value === 'string'
|
||||
node.computed &&
|
||||
node.property.type === 'Literal' &&
|
||||
typeof node.property.value === 'string'
|
||||
) {
|
||||
return member.property.value;
|
||||
return node.property.value;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const receiverName = (node: TSESTree.Node): string | undefined => {
|
||||
if (node.type === AST_NODE_TYPES.Identifier) return node.name;
|
||||
if (node.type === AST_NODE_TYPES.MemberExpression) return memberName(node);
|
||||
return undefined;
|
||||
const targetsWorkflowEntity = (node: SyntaxNode | undefined) =>
|
||||
node !== undefined &&
|
||||
((node.type === 'Identifier' && node.name === 'WorkflowEntity') ||
|
||||
(node.type === 'Literal' &&
|
||||
(node.value === 'WorkflowEntity' || node.value === 'workflow_entity')));
|
||||
|
||||
const targetsWorkflowRepositoryClass = (node: SyntaxNode | undefined) =>
|
||||
node?.type === 'Identifier' && node.name === 'WorkflowRepository';
|
||||
|
||||
const isWorkflowRepository = (node: SyntaxNode): boolean => {
|
||||
const target = unwrapChain(node);
|
||||
if (target.type === 'Identifier') return WORKFLOW_REPOSITORY_NAME.test(target.name ?? '');
|
||||
if (target.type === 'MemberExpression') {
|
||||
return WORKFLOW_REPOSITORY_NAME.test(memberName(target) ?? '');
|
||||
}
|
||||
if (target.type !== 'CallExpression' || !target.callee) return false;
|
||||
const callee = unwrapChain(target.callee);
|
||||
return (
|
||||
callee.type === 'MemberExpression' &&
|
||||
((memberName(callee) === 'get' && targetsWorkflowRepositoryClass(target.arguments?.[0])) ||
|
||||
(memberName(callee) === 'getRepository' && targetsWorkflowEntity(target.arguments?.[0])))
|
||||
);
|
||||
};
|
||||
|
||||
const isNodesKey = (key: TSESTree.Node) =>
|
||||
(key.type === AST_NODE_TYPES.Identifier && key.name === 'nodes') ||
|
||||
(key.type === AST_NODE_TYPES.Literal && key.value === 'nodes');
|
||||
|
||||
const stringValue = (node: TSESTree.Node | undefined): string | undefined => {
|
||||
if (node?.type === AST_NODE_TYPES.Literal && typeof node.value === 'string') return node.value;
|
||||
if (node?.type === AST_NODE_TYPES.TemplateLiteral) {
|
||||
return node.quasis.map((q) => q.value.cooked ?? '').join(' ');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isTypeReference = (type: ts.Type): type is ts.TypeReference =>
|
||||
'target' in type && typeof type.target === 'object';
|
||||
|
||||
/**
|
||||
* True for the entity itself, `WorkflowRepository`, or any generic over the entity
|
||||
* (`Repository<WorkflowEntity>`, a query builder, a subclass of either).
|
||||
*/
|
||||
const refersToWorkflowEntity = (
|
||||
type: ts.Type,
|
||||
checker: ts.TypeChecker,
|
||||
seen = new Set<ts.Type>(),
|
||||
): boolean => {
|
||||
if (seen.has(type)) return false;
|
||||
seen.add(type);
|
||||
|
||||
if (type.isUnionOrIntersection()) {
|
||||
return type.types.some((t) => refersToWorkflowEntity(t, checker, seen));
|
||||
}
|
||||
|
||||
const name = (type.getSymbol() ?? type.aliasSymbol)?.getName();
|
||||
if (name !== undefined && WORKFLOW_TYPE_SYMBOLS.has(name)) return true;
|
||||
|
||||
const typeArguments = [
|
||||
...(type.aliasTypeArguments ?? []),
|
||||
...(isTypeReference(type) ? checker.getTypeArguments(type) : []),
|
||||
];
|
||||
if (typeArguments.some((t) => refersToWorkflowEntity(t, checker, seen))) return true;
|
||||
|
||||
const target = isTypeReference(type) ? type.target : type;
|
||||
if (target.isClassOrInterface()) {
|
||||
return checker.getBaseTypes(target).some((t) => refersToWorkflowEntity(t, checker, seen));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// `any`/`unknown` could carry `nodes`, and an optional `nodes` (a wide partial type) might; the
|
||||
// seal treats both as opaque rather than clean.
|
||||
const payloadTypeVerdict = (type: ts.Type): PayloadVerdict => {
|
||||
if (type.isUnionOrIntersection()) {
|
||||
const verdicts = type.types.map(payloadTypeVerdict);
|
||||
if (verdicts.includes('nodes')) return 'nodes';
|
||||
return verdicts.includes('opaque') ? 'opaque' : 'clean';
|
||||
}
|
||||
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return 'opaque';
|
||||
const nodes = type.getProperty('nodes');
|
||||
if (nodes === undefined) return 'clean';
|
||||
return nodes.flags & ts.SymbolFlags.Optional ? 'opaque' : 'nodes';
|
||||
};
|
||||
|
||||
const payloadVerdict = (payload: TSESTree.Node, services: TypeServices | null): PayloadVerdict => {
|
||||
if (payload.type === AST_NODE_TYPES.ObjectExpression) {
|
||||
let verdict: PayloadVerdict = 'clean';
|
||||
for (const property of payload.properties) {
|
||||
if (property.type === AST_NODE_TYPES.Property && isNodesKey(property.key)) return 'nodes';
|
||||
if (property.type === AST_NODE_TYPES.SpreadElement && services) {
|
||||
const spread = payloadTypeVerdict(services.typeOf(property.argument));
|
||||
if (spread === 'nodes') return 'nodes';
|
||||
if (spread === 'opaque') verdict = 'opaque';
|
||||
}
|
||||
const hasNodes = (node: SyntaxNode) =>
|
||||
node.type === 'ObjectExpression' &&
|
||||
(node.properties ?? []).some((property) => {
|
||||
if (property.type === 'SpreadElement' || !property.key) return false;
|
||||
if (!property.computed && property.key.type === 'Identifier') {
|
||||
return property.key.name === 'nodes';
|
||||
}
|
||||
return verdict;
|
||||
}
|
||||
// Without type information a hoisted payload cannot be inspected; the syntactic floor
|
||||
// keeps its historical behaviour and lets it through.
|
||||
if (!services) return 'clean';
|
||||
return payloadTypeVerdict(services.typeOf(payload));
|
||||
return property.key.type === 'Literal' && property.key.value === 'nodes';
|
||||
});
|
||||
|
||||
const isWorkflowQueryBuilder = (node: SyntaxNode): boolean => {
|
||||
const call = unwrapChain(node);
|
||||
if (call.type !== 'CallExpression' || !call.callee) return false;
|
||||
const callee = unwrapChain(call.callee);
|
||||
if (callee.type !== 'MemberExpression' || !callee.object) return false;
|
||||
const method = memberName(callee);
|
||||
if (method === 'createQueryBuilder') return isWorkflowRepository(callee.object);
|
||||
if (method !== 'update') return false;
|
||||
return (
|
||||
targetsWorkflowEntity(call.arguments?.[0]) ||
|
||||
(call.arguments?.length === 0 && isWorkflowQueryBuilder(callee.object))
|
||||
);
|
||||
};
|
||||
|
||||
/** Finds the `.set(values)` chained after a query-builder `.update(...)`. */
|
||||
const chainedSetPayload = (call: TSESTree.CallExpression): TSESTree.Node | undefined => {
|
||||
let current: TSESTree.Node = call;
|
||||
while (
|
||||
current.parent?.type === AST_NODE_TYPES.MemberExpression &&
|
||||
current.parent.object === current
|
||||
) {
|
||||
const member: TSESTree.MemberExpression = current.parent;
|
||||
const outer: TSESTree.Node | undefined = member.parent;
|
||||
if (outer?.type !== AST_NODE_TYPES.CallExpression || outer.callee !== member) return undefined;
|
||||
if (memberName(member) === 'set') return outer.arguments[0];
|
||||
current = outer;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Seals `WorkflowEntity` node writes to the token-gated `WorkflowRepository` methods.
|
||||
* Type-aware where a program is available, with the syntactic name/literal checks as a floor.
|
||||
* Ceiling: SQL built from non-literal strings and `any`-typed receivers; the runtime
|
||||
* `assertClearedFor` gate in the repository is the enforcing half.
|
||||
*/
|
||||
export const NoUnsealedWorkflowEntityWriteRule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Seal WorkflowEntity writes behind a policy-cleared repository method.',
|
||||
description: 'Route workflow content writes through policy-cleared repository methods.',
|
||||
},
|
||||
messages: {
|
||||
unsealedWrite:
|
||||
'This writes `WorkflowEntity` outside the sealed repository path. Route it through a token-gated `WorkflowRepository` method (e.g. `updateContent`).',
|
||||
opaquePayload:
|
||||
'The payload type of this `WorkflowEntity` update may contain `nodes`, so the seal cannot rule out a node write. Narrow the payload type (e.g. `Pick<WorkflowEntity, "active">`) or route it through `WorkflowRepository.updateContent`.',
|
||||
'Route WorkflowEntity content writes through a policy-cleared repository method.',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
if (PERSISTENCE_FILE.test(context.filename) || NON_RUNTIME_FILE.test(context.filename)) {
|
||||
if (
|
||||
NON_RUNTIME_FILE.test(context.filename) ||
|
||||
WORKFLOW_REPOSITORY_FILE.test(context.filename)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let services: TypeServices | null | undefined;
|
||||
const typeServices = (): TypeServices | null => {
|
||||
if (services !== undefined) return services;
|
||||
const parserServices = ESLintUtils.getParserServices(context, true);
|
||||
services = parserServices.program
|
||||
? {
|
||||
checker: parserServices.program.getTypeChecker(),
|
||||
typeOf: (node) => parserServices.getTypeAtLocation(node),
|
||||
}
|
||||
: null;
|
||||
return services;
|
||||
};
|
||||
|
||||
const refersToWorkflow = (node: TSESTree.Node | undefined): boolean => {
|
||||
if (!node) return false;
|
||||
if (isWorkflowEntityIdentifier(node)) return true;
|
||||
const typed = typeServices();
|
||||
return typed !== null && refersToWorkflowEntity(typed.typeOf(node), typed.checker);
|
||||
};
|
||||
|
||||
return {
|
||||
CallExpression(node: TSESTree.CallExpression) {
|
||||
const callee =
|
||||
node.callee.type === AST_NODE_TYPES.ChainExpression
|
||||
? node.callee.expression
|
||||
: node.callee;
|
||||
if (callee.type !== AST_NODE_TYPES.MemberExpression) return;
|
||||
CallExpression(node) {
|
||||
const callee = unwrapChain(node.callee as never);
|
||||
if (callee.type !== 'MemberExpression' || !callee.object) return;
|
||||
const method = memberName(callee);
|
||||
if (method === undefined || !CHECKED_METHODS.has(method)) return;
|
||||
if (!method) return;
|
||||
|
||||
if (method === 'query') {
|
||||
const sql = stringValue(node.arguments[0]);
|
||||
if (sql !== undefined && RAW_SQL_WORKFLOW_WRITE.test(sql)) {
|
||||
context.report({ node, messageId: 'unsealedWrite' });
|
||||
if (WRITE_METHODS.has(method) && isWorkflowRepository(callee.object)) {
|
||||
context.report({ node, messageId: 'unsealedWrite' });
|
||||
return;
|
||||
}
|
||||
|
||||
const secondArgument = node.arguments[1];
|
||||
if (
|
||||
method === 'update' &&
|
||||
isWorkflowRepository(callee.object) &&
|
||||
secondArgument !== undefined &&
|
||||
secondArgument.type !== 'SpreadElement' &&
|
||||
hasNodes(secondArgument as never)
|
||||
) {
|
||||
context.report({ node, messageId: 'unsealedWrite' });
|
||||
return;
|
||||
}
|
||||
|
||||
const callPrefix = context.sourceCode.getText(node).split('(', 1)[0];
|
||||
const hasWorkflowTypeArgument = /<[^>]*\bWorkflowEntity\b[^>]*>/.test(callPrefix);
|
||||
const explicitWorkflowTarget = targetsWorkflowEntity(node.arguments[0] as never);
|
||||
const updatePayload = explicitWorkflowTarget ? node.arguments[2] : node.arguments[1];
|
||||
if (
|
||||
method === 'update' &&
|
||||
(explicitWorkflowTarget || hasWorkflowTypeArgument) &&
|
||||
updatePayload !== undefined &&
|
||||
updatePayload.type !== 'SpreadElement' &&
|
||||
hasNodes(updatePayload as never)
|
||||
) {
|
||||
context.report({ node, messageId: 'unsealedWrite' });
|
||||
return;
|
||||
}
|
||||
|
||||
const queryBuilderText = callee.object
|
||||
? context.sourceCode.getText(callee.object as never)
|
||||
: '';
|
||||
if (
|
||||
method === 'set' &&
|
||||
node.arguments[0]?.type !== 'SpreadElement' &&
|
||||
hasNodes(node.arguments[0] as never) &&
|
||||
(isWorkflowQueryBuilder(callee.object) ||
|
||||
/update\s*<[^>]*\bWorkflowEntity\b[^>]*>/.test(queryBuilderText))
|
||||
) {
|
||||
context.report({ node, messageId: 'unsealedWrite' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
['save', 'insert', 'upsert'].includes(method) &&
|
||||
node.arguments[0]?.type !== 'SpreadElement' &&
|
||||
targetsWorkflowEntity(node.arguments[0] as never)
|
||||
) {
|
||||
context.report({ node, messageId: 'unsealedWrite' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
method === 'into' &&
|
||||
node.arguments[0]?.type !== 'SpreadElement' &&
|
||||
targetsWorkflowEntity(node.arguments[0] as never)
|
||||
) {
|
||||
context.report({ node, messageId: 'unsealedWrite' });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const argument of node.arguments) {
|
||||
if (
|
||||
argument.type === 'Literal' &&
|
||||
typeof argument.value === 'string' &&
|
||||
RAW_WORKFLOW_WRITE.test(argument.value)
|
||||
) {
|
||||
context.report({ node: argument, messageId: 'unsealedWrite' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'into') {
|
||||
if (refersToWorkflow(node.arguments[0])) {
|
||||
context.report({ node, messageId: 'unsealedWrite' });
|
||||
if (
|
||||
argument.type === 'TemplateLiteral' &&
|
||||
RAW_WORKFLOW_WRITE.test(
|
||||
argument.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join(''),
|
||||
)
|
||||
) {
|
||||
context.report({ node: argument, messageId: 'unsealedWrite' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const typeArg = node.typeArguments?.params?.[0];
|
||||
const genericIsWorkflowEntity =
|
||||
typeArg?.type === AST_NODE_TYPES.TSTypeReference &&
|
||||
typeArg.typeName.type === AST_NODE_TYPES.Identifier &&
|
||||
typeArg.typeName.name === 'WorkflowEntity';
|
||||
const receiver = receiverName(callee.object);
|
||||
const onWorkflowRepoByName =
|
||||
receiver !== undefined && WORKFLOW_REPO_RECEIVER.test(receiver);
|
||||
|
||||
// Full-entity writes always carry `nodes`.
|
||||
if (ENTITY_WRITE_METHODS.has(method)) {
|
||||
const targetsWorkflow =
|
||||
genericIsWorkflowEntity ||
|
||||
onWorkflowRepoByName ||
|
||||
refersToWorkflow(callee.object) ||
|
||||
node.arguments.some((arg) => refersToWorkflow(arg));
|
||||
if (targetsWorkflow) context.report({ node, messageId: 'unsealedWrite' });
|
||||
return;
|
||||
}
|
||||
|
||||
// `update`: the payload is the last argument, or a chained `.set()` on a query builder.
|
||||
const payload =
|
||||
node.arguments.length >= 2 ? node.arguments.at(-1) : chainedSetPayload(node);
|
||||
const entityArgs = node.arguments.filter((arg) => arg !== payload);
|
||||
const targetsWorkflow =
|
||||
genericIsWorkflowEntity ||
|
||||
onWorkflowRepoByName ||
|
||||
refersToWorkflow(callee.object) ||
|
||||
entityArgs.some((arg) => refersToWorkflow(arg));
|
||||
if (!targetsWorkflow) return;
|
||||
|
||||
// A settings-/active-only update is out of the node seal's scope. A query builder with
|
||||
// no `.set()` in reach is treated as a node write.
|
||||
const verdict = payload ? payloadVerdict(payload, typeServices()) : 'nodes';
|
||||
if (verdict === 'nodes') context.report({ node, messageId: 'unsealedWrite' });
|
||||
if (verdict === 'opaque') context.report({ node, messageId: 'opaquePayload' });
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@ export const baseConfig = defineConfig({
|
|||
'n8n-local-rules/no-restricted-sleep-definition': 'error',
|
||||
'n8n-local-rules/no-restricted-sleep-import': 'error',
|
||||
'n8n-local-rules/no-type-only-import-in-di': 'error',
|
||||
'n8n-local-rules/no-unsealed-workflow-entity-write': 'error',
|
||||
'n8n-local-rules/no-unneeded-backticks': 'error',
|
||||
'n8n-local-rules/no-unused-param-in-catch-clause': 'error',
|
||||
'n8n-local-rules/no-useless-catch-throw': 'error',
|
||||
|
|
|
|||
|
|
@ -45,8 +45,7 @@ export default defineConfig(
|
|||
rules: {
|
||||
'n8n-local-rules/no-dynamic-import-template': 'error',
|
||||
'n8n-local-rules/misplaced-n8n-typeorm-import': 'error',
|
||||
// Ratchets: the allowlists below only shrink, so an inline disable is the one way to add a
|
||||
// violation. `no-unsealed-workflow-entity-write` (on for every package via the plugin) has none.
|
||||
// Ratchets: the allowlists below only shrink, so an inline disable is the one way to add a violation.
|
||||
'n8n-local-rules/no-guardrail-disable': [
|
||||
'error',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ mockInstance(AuthRolesService);
|
|||
mockInstance(BinaryDataRepository);
|
||||
|
||||
const deploymentKeyRepository = mockInstance(DeploymentKeyRepository);
|
||||
deploymentKeyRepository.findActiveByType.mockResolvedValue(null);
|
||||
deploymentKeyRepository.insertOrIgnore.mockResolvedValue(undefined);
|
||||
deploymentKeyRepository.findActiveIdentifier.mockResolvedValue(null);
|
||||
deploymentKeyRepository.seedActiveIdentifier.mockResolvedValue(undefined);
|
||||
|
||||
test('should start a task runner', async () => {
|
||||
// arrange
|
||||
|
|
|
|||
|
|
@ -64,8 +64,8 @@ mockInstance(AuthRolesService);
|
|||
mockInstance(BinaryDataRepository);
|
||||
|
||||
const deploymentKeyRepository = mockInstance(DeploymentKeyRepository);
|
||||
deploymentKeyRepository.findActiveByType.mockResolvedValue(null);
|
||||
deploymentKeyRepository.insertOrIgnore.mockResolvedValue(undefined);
|
||||
deploymentKeyRepository.findActiveIdentifier.mockResolvedValue(null);
|
||||
deploymentKeyRepository.seedActiveIdentifier.mockResolvedValue(undefined);
|
||||
|
||||
// BaseCommand.init() wires the encryption key provider; this command-init test
|
||||
// does not exercise encryption, so keep the bootstrap a no-op.
|
||||
|
|
@ -133,8 +133,10 @@ test('should start a task runner', async () => {
|
|||
test('should not seed the instance identity and should tolerate deployment key read errors', async () => {
|
||||
// arrange
|
||||
|
||||
deploymentKeyRepository.insertOrIgnore.mockClear();
|
||||
deploymentKeyRepository.findActiveByType.mockRejectedValueOnce(new Error('permission denied'));
|
||||
deploymentKeyRepository.seedActiveIdentifier.mockClear();
|
||||
deploymentKeyRepository.findActiveIdentifier.mockRejectedValueOnce(
|
||||
new Error('permission denied'),
|
||||
);
|
||||
|
||||
const cmd = new ReadOnlyCommand();
|
||||
|
||||
|
|
@ -144,7 +146,7 @@ test('should not seed the instance identity and should tolerate deployment key r
|
|||
|
||||
// assert
|
||||
|
||||
expect(deploymentKeyRepository.insertOrIgnore).not.toHaveBeenCalled();
|
||||
expect(deploymentKeyRepository.seedActiveIdentifier).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should not init the expression engine for commands that do not need it', async () => {
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ const roleCacheService = mockInstance(RoleCacheService);
|
|||
roleCacheService.refreshCache.mockResolvedValue(undefined);
|
||||
|
||||
const deploymentKeyRepository = mockInstance(DeploymentKeyRepository);
|
||||
deploymentKeyRepository.findActiveByType.mockResolvedValue(null);
|
||||
deploymentKeyRepository.insertOrIgnore.mockResolvedValue(undefined);
|
||||
deploymentKeyRepository.findActiveIdentifier.mockResolvedValue(null);
|
||||
deploymentKeyRepository.seedActiveIdentifier.mockResolvedValue(undefined);
|
||||
|
||||
const loadNodesAndCredentials = mockInstance(LoadNodesAndCredentials);
|
||||
loadNodesAndCredentials.init.mockResolvedValue(undefined);
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ dbConnection.init.mockResolvedValue(undefined);
|
|||
dbConnection.migrate.mockResolvedValue(undefined);
|
||||
|
||||
const deploymentKeyRepository = mockInstance(DeploymentKeyRepository);
|
||||
deploymentKeyRepository.findActiveByType.mockResolvedValue(null);
|
||||
deploymentKeyRepository.insertOrIgnore.mockResolvedValue(undefined);
|
||||
deploymentKeyRepository.findActiveIdentifier.mockResolvedValue(null);
|
||||
deploymentKeyRepository.seedActiveIdentifier.mockResolvedValue(undefined);
|
||||
|
||||
mockInstance(RedisClientService);
|
||||
mockInstance(PubSubRegistry);
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ dbConnection.init.mockResolvedValue(undefined);
|
|||
dbConnection.migrate.mockResolvedValue(undefined);
|
||||
|
||||
const deploymentKeyRepository = mockInstance(DeploymentKeyRepository);
|
||||
deploymentKeyRepository.findActiveByType.mockResolvedValue(null);
|
||||
deploymentKeyRepository.insertOrIgnore.mockResolvedValue(undefined);
|
||||
deploymentKeyRepository.findActiveIdentifier.mockResolvedValue(null);
|
||||
deploymentKeyRepository.seedActiveIdentifier.mockResolvedValue(undefined);
|
||||
|
||||
mockInstance(RedisClientService);
|
||||
mockInstance(PubSubRegistry);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { mockInstance, testDb } from '@n8n/backend-test-utils';
|
||||
import { DeploymentKeyRepository } from '@n8n/db';
|
||||
import { DeploymentKey, DeploymentKeyRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { DataSource, type Repository } from '@n8n/typeorm';
|
||||
import { Cipher, InstanceSettings } from 'n8n-core';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
|
|
@ -8,6 +9,7 @@ import { EncryptionBootstrapService } from '../encryption-bootstrap.service';
|
|||
import { KeyManagerService } from '../key-manager.service';
|
||||
|
||||
const INSTANCE_ENCRYPTION_KEY = 'legacy-encryption-key';
|
||||
let keyStore: Repository<DeploymentKey>;
|
||||
|
||||
beforeAll(async () => {
|
||||
mockInstance(InstanceSettings, {
|
||||
|
|
@ -17,10 +19,11 @@ beforeAll(async () => {
|
|||
canSeedDeploymentState: true,
|
||||
});
|
||||
await testDb.init();
|
||||
keyStore = Container.get(DataSource).getRepository(DeploymentKey);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['DeploymentKey']);
|
||||
await testDb.resetDeploymentKeys();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
@ -31,7 +34,7 @@ describe('EncryptionBootstrapService (integration)', () => {
|
|||
it('creates an inactive CBC key seeded from the instance encryption key', async () => {
|
||||
await Container.get(EncryptionBootstrapService).run();
|
||||
|
||||
const rows = await Container.get(DeploymentKeyRepository).find({
|
||||
const rows = await keyStore.find({
|
||||
where: { type: 'data_encryption', algorithm: 'aes-256-cbc' },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
|
|
@ -45,7 +48,7 @@ describe('EncryptionBootstrapService (integration)', () => {
|
|||
it('creates an active GCM key', async () => {
|
||||
await Container.get(EncryptionBootstrapService).run();
|
||||
|
||||
const rows = await Container.get(DeploymentKeyRepository).find({
|
||||
const rows = await keyStore.find({
|
||||
where: { type: 'data_encryption', algorithm: 'aes-256-gcm', status: 'active' },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
|
|
@ -55,7 +58,7 @@ describe('EncryptionBootstrapService (integration)', () => {
|
|||
await Container.get(EncryptionBootstrapService).run();
|
||||
await Container.get(EncryptionBootstrapService).run();
|
||||
|
||||
const all = await Container.get(DeploymentKeyRepository).find({
|
||||
const all = await keyStore.find({
|
||||
where: { type: 'data_encryption' },
|
||||
});
|
||||
const cbcKeys = all.filter((k) => k.algorithm === 'aes-256-cbc');
|
||||
|
|
@ -69,7 +72,7 @@ describe('EncryptionBootstrapService (integration)', () => {
|
|||
|
||||
await Promise.all([...Array(5)].map(async () => await service.run()));
|
||||
|
||||
const all = await Container.get(DeploymentKeyRepository).find({
|
||||
const all = await keyStore.find({
|
||||
where: { type: 'data_encryption' },
|
||||
});
|
||||
const cbcKeys = all.filter((k) => k.algorithm === 'aes-256-cbc');
|
||||
|
|
@ -87,12 +90,27 @@ describe('EncryptionBootstrapService (integration)', () => {
|
|||
[...Array(5)].map(async (_, i) => await repository.seedLegacyCbcKey(`value-${i}`)),
|
||||
);
|
||||
|
||||
const rows = await repository.find({
|
||||
const rows = await keyStore.find({
|
||||
where: { type: 'data_encryption', algorithm: 'aes-256-cbc' },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps the active key when the activation target does not exist', async () => {
|
||||
await Container.get(EncryptionBootstrapService).run();
|
||||
const repository = Container.get(DeploymentKeyRepository);
|
||||
const activeBefore = await keyStore.findOne({
|
||||
where: { type: 'data_encryption', status: 'active' },
|
||||
});
|
||||
|
||||
await expect(repository.activateDataEncryptionKey('missing')).rejects.toThrow('not found');
|
||||
|
||||
const activeAfter = await keyStore.findOne({
|
||||
where: { type: 'data_encryption', status: 'active' },
|
||||
});
|
||||
expect(activeAfter?.id).toBe(activeBefore?.id);
|
||||
});
|
||||
|
||||
describe('end-to-end write path (real key store, real cipher)', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.N8N_ENV_FEAT_ENCRYPTION_KEY_ROTATION;
|
||||
|
|
@ -117,8 +135,9 @@ describe('EncryptionBootstrapService (integration)', () => {
|
|||
|
||||
const encrypted = await cipher.encryptV2('e2e-on');
|
||||
|
||||
const active =
|
||||
await Container.get(DeploymentKeyRepository).findActiveByType('data_encryption');
|
||||
const active = await keyStore.findOne({
|
||||
where: { type: 'data_encryption', status: 'active' },
|
||||
});
|
||||
expect(encrypted.startsWith(`${active!.id}:`)).toBe(true);
|
||||
expect(await cipher.decryptV2(encrypted)).toBe('e2e-on');
|
||||
} finally {
|
||||
|
|
@ -129,14 +148,18 @@ describe('EncryptionBootstrapService (integration)', () => {
|
|||
|
||||
describe('legacy DEK repair', () => {
|
||||
const seed = async (value: string) => {
|
||||
const repo = Container.get(DeploymentKeyRepository);
|
||||
await repo.save(
|
||||
repo.create({ type: 'data_encryption', value, algorithm: 'aes-256-gcm', status: 'active' }),
|
||||
await keyStore.save(
|
||||
keyStore.create({
|
||||
type: 'data_encryption',
|
||||
value,
|
||||
algorithm: 'aes-256-gcm',
|
||||
status: 'active',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onlyRow = async () => {
|
||||
const rows = await Container.get(DeploymentKeyRepository).find({
|
||||
const rows = await keyStore.find({
|
||||
where: { type: 'data_encryption' },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
|
|
@ -146,9 +169,8 @@ describe('EncryptionBootstrapService (integration)', () => {
|
|||
// Seeds an inactive row and returns it — the partial unique index allows
|
||||
// only one active row per type, so a multi-row test must stay inactive.
|
||||
const seedRow = async (value: string) => {
|
||||
const repo = Container.get(DeploymentKeyRepository);
|
||||
return await repo.save(
|
||||
repo.create({
|
||||
return await keyStore.save(
|
||||
keyStore.create({
|
||||
type: 'data_encryption',
|
||||
value,
|
||||
algorithm: 'aes-256-gcm',
|
||||
|
|
@ -178,8 +200,6 @@ describe('EncryptionBootstrapService (integration)', () => {
|
|||
|
||||
it('repairs every legacy row in a mixed set and keeps going past a skipped row', async () => {
|
||||
const cipher = Container.get(Cipher);
|
||||
const repo = Container.get(DeploymentKeyRepository);
|
||||
|
||||
const rawA = randomBytes(32).toString('hex');
|
||||
const rawB = randomBytes(32).toString('hex');
|
||||
const rawC = randomBytes(32).toString('hex');
|
||||
|
|
@ -190,7 +210,7 @@ describe('EncryptionBootstrapService (integration)', () => {
|
|||
|
||||
await Container.get(KeyManagerService).repairLegacyDataEncryptionKeys();
|
||||
|
||||
const rows = await repo.find({ where: { type: 'data_encryption' } });
|
||||
const rows = await keyStore.find({ where: { type: 'data_encryption' } });
|
||||
const byId = (id: string) => rows.find((r) => r.id === id)!;
|
||||
|
||||
// Both legacy rows are now GCM-readable and recover their exact keys.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { mockInstance, testDb } from '@n8n/backend-test-utils';
|
||||
import { DeploymentKeyRepository } from '@n8n/db';
|
||||
import { DeploymentKey } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { DataSource, type Repository } from '@n8n/typeorm';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
|
||||
import { KeyManagerService } from '@/encryption/key-manager.service';
|
||||
|
||||
let keyStore: Repository<DeploymentKey>;
|
||||
|
||||
beforeAll(async () => {
|
||||
mockInstance(InstanceSettings, {
|
||||
encryptionKey: 'get-key-by-id-type-scope-instance-key',
|
||||
|
|
@ -13,6 +16,7 @@ beforeAll(async () => {
|
|||
canSeedDeploymentState: true,
|
||||
});
|
||||
await testDb.init();
|
||||
keyStore = Container.get(DataSource).getRepository(DeploymentKey);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
@ -23,9 +27,8 @@ afterAll(async () => {
|
|||
// lookup, even though `id` is the table's only primary key across all types.
|
||||
describe('getKeyById() type scoping (integration)', () => {
|
||||
it('does not resolve a jwe.private-key row', async () => {
|
||||
const repo = Container.get(DeploymentKeyRepository);
|
||||
const jweRow = await repo.save(
|
||||
repo.create({
|
||||
const jweRow = await keyStore.save(
|
||||
keyStore.create({
|
||||
type: 'jwe.private-key',
|
||||
value: 'jwe-secret',
|
||||
algorithm: 'RSA-OAEP-256',
|
||||
|
|
@ -39,9 +42,8 @@ describe('getKeyById() type scoping (integration)', () => {
|
|||
});
|
||||
|
||||
it('does not resolve a signing.hmac row', async () => {
|
||||
const repo = Container.get(DeploymentKeyRepository);
|
||||
const hmacRow = await repo.save(
|
||||
repo.create({
|
||||
const hmacRow = await keyStore.save(
|
||||
keyStore.create({
|
||||
type: 'signing.hmac',
|
||||
value: 'hmac-secret',
|
||||
algorithm: null,
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ describe('KeyManagerService', () => {
|
|||
it('returns the active key as a prefixed descriptor', async () => {
|
||||
const { service, repo } = makeFreshService();
|
||||
const key = makeKey();
|
||||
repo.find.mockResolvedValue([key]);
|
||||
repo.findActiveDataEncryptionKeys.mockResolvedValue([key]);
|
||||
|
||||
const result = await service.getActiveKey();
|
||||
|
||||
|
|
@ -74,17 +74,20 @@ describe('KeyManagerService', () => {
|
|||
|
||||
it('throws NotFoundError when no active key exists, without memoizing the absence', async () => {
|
||||
const { service, repo } = makeFreshService();
|
||||
repo.find.mockResolvedValue([]);
|
||||
repo.findActiveDataEncryptionKeys.mockResolvedValue([]);
|
||||
|
||||
await expect(service.getActiveKey()).rejects.toThrow(NotFoundError);
|
||||
// The absence is re-checked, not memoized.
|
||||
await expect(service.getActiveKey()).rejects.toThrow(NotFoundError);
|
||||
expect(repo.find).toHaveBeenCalledTimes(2);
|
||||
expect(repo.findActiveDataEncryptionKeys).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws when multiple active keys exist (invariant violation)', async () => {
|
||||
const { service, repo } = makeFreshService();
|
||||
repo.find.mockResolvedValue([makeKey({ id: 'key-1' }), makeKey({ id: 'key-2' })]);
|
||||
repo.findActiveDataEncryptionKeys.mockResolvedValue([
|
||||
makeKey({ id: 'key-1' }),
|
||||
makeKey({ id: 'key-2' }),
|
||||
]);
|
||||
|
||||
await expect(service.getActiveKey()).rejects.toThrow(
|
||||
'Encryption key invariant violated: multiple active keys found',
|
||||
|
|
@ -114,7 +117,7 @@ describe('KeyManagerService', () => {
|
|||
format: 'no-prefix',
|
||||
});
|
||||
expect(cipher.encryptDEKWithInstanceKey).toHaveBeenCalledWith('test-instance-key');
|
||||
expect(repository.find).not.toHaveBeenCalled();
|
||||
expect(repository.findActiveDataEncryptionKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('memoizes the legacy descriptor', async () => {
|
||||
|
|
@ -133,7 +136,7 @@ describe('KeyManagerService', () => {
|
|||
it('returns KeyInfo when key exists', async () => {
|
||||
const { service, repo } = makeFreshService();
|
||||
const key = makeKey();
|
||||
repo.findOne.mockResolvedValue(key);
|
||||
repo.findDataEncryptionKeyById.mockResolvedValue(key);
|
||||
|
||||
const result = await service.getKeyById('key-1');
|
||||
|
||||
|
|
@ -143,14 +146,12 @@ describe('KeyManagerService', () => {
|
|||
algorithm: key.algorithm,
|
||||
format: 'prefixed',
|
||||
});
|
||||
expect(repo.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 'key-1', type: 'data_encryption' },
|
||||
});
|
||||
expect(repo.findDataEncryptionKeyById).toHaveBeenCalledWith('key-1');
|
||||
});
|
||||
|
||||
it('returns null when key not found', async () => {
|
||||
const { service, repo } = makeFreshService();
|
||||
repo.findOne.mockResolvedValue(null);
|
||||
repo.findDataEncryptionKeyById.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getKeyById('missing');
|
||||
|
||||
|
|
@ -159,31 +160,28 @@ describe('KeyManagerService', () => {
|
|||
|
||||
it('serves repeated lookups from the LRU without another database read', async () => {
|
||||
const { service, repo } = makeFreshService();
|
||||
repo.findOne.mockResolvedValue(makeKey());
|
||||
repo.findDataEncryptionKeyById.mockResolvedValue(makeKey());
|
||||
|
||||
const first = await service.getKeyById('key-1');
|
||||
const second = await service.getKeyById('key-1');
|
||||
|
||||
expect(repo.findOne).toHaveBeenCalledTimes(1);
|
||||
expect(repo.findDataEncryptionKeyById).toHaveBeenCalledTimes(1);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it('does not cache misses, so an unknown id cannot evict real keys', async () => {
|
||||
const { service, repo } = makeFreshService();
|
||||
repo.findOne.mockResolvedValue(null);
|
||||
repo.findDataEncryptionKeyById.mockResolvedValue(null);
|
||||
|
||||
await service.getKeyById('missing');
|
||||
await service.getKeyById('missing');
|
||||
|
||||
expect(repo.findOne).toHaveBeenCalledTimes(2);
|
||||
expect(repo.findDataEncryptionKeyById).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('caches exactly up to capacity without evicting', async () => {
|
||||
const { service, repo } = makeFreshService();
|
||||
repo.findOne.mockImplementation(async (opts) => {
|
||||
const where = (opts as { where: { id: string } }).where;
|
||||
return makeKey({ id: where.id });
|
||||
});
|
||||
repo.findDataEncryptionKeyById.mockImplementation(async (id) => makeKey({ id }));
|
||||
|
||||
// Fill the LRU to exactly its capacity of 10 — no eviction yet.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
|
|
@ -192,31 +190,28 @@ describe('KeyManagerService', () => {
|
|||
for (let i = 0; i < 10; i++) {
|
||||
await service.getKeyById(`key-${i}`);
|
||||
}
|
||||
expect(repo.findOne).toHaveBeenCalledTimes(10);
|
||||
expect(repo.findDataEncryptionKeyById).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
|
||||
it('evicts the LEAST RECENTLY USED entry beyond capacity, not the oldest inserted', async () => {
|
||||
const { service, repo } = makeFreshService();
|
||||
repo.findOne.mockImplementation(async (opts) => {
|
||||
const where = (opts as { where: { id: string } }).where;
|
||||
return makeKey({ id: where.id });
|
||||
});
|
||||
repo.findDataEncryptionKeyById.mockImplementation(async (id) => makeKey({ id }));
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await service.getKeyById(`key-${i}`);
|
||||
}
|
||||
// Touch the oldest-inserted entry so key-1 becomes least recently used.
|
||||
await service.getKeyById('key-0');
|
||||
expect(repo.findOne).toHaveBeenCalledTimes(10);
|
||||
expect(repo.findDataEncryptionKeyById).toHaveBeenCalledTimes(10);
|
||||
|
||||
// The 11th distinct id evicts key-1 (LRU), not key-0 (refreshed).
|
||||
await service.getKeyById('key-10');
|
||||
expect(repo.findOne).toHaveBeenCalledTimes(11);
|
||||
expect(repo.findDataEncryptionKeyById).toHaveBeenCalledTimes(11);
|
||||
|
||||
await service.getKeyById('key-0');
|
||||
expect(repo.findOne).toHaveBeenCalledTimes(11);
|
||||
expect(repo.findDataEncryptionKeyById).toHaveBeenCalledTimes(11);
|
||||
await service.getKeyById('key-1');
|
||||
expect(repo.findOne).toHaveBeenCalledTimes(12);
|
||||
expect(repo.findDataEncryptionKeyById).toHaveBeenCalledTimes(12);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -233,18 +228,18 @@ describe('KeyManagerService', () => {
|
|||
vi.useFakeTimers();
|
||||
try {
|
||||
const { service, repo } = makeFreshService();
|
||||
repo.find.mockResolvedValue([makeKey()]);
|
||||
repo.findActiveDataEncryptionKeys.mockResolvedValue([makeKey()]);
|
||||
|
||||
const first = await service.getActiveKey();
|
||||
const second = await service.getActiveKey();
|
||||
expect(second).toBe(first);
|
||||
expect(repo.find).toHaveBeenCalledTimes(1);
|
||||
expect(repo.findActiveDataEncryptionKeys).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Past the memo TTL the database view is re-read — this is also the
|
||||
// propagation window for a rotation done on another instance.
|
||||
vi.advanceTimersByTime(6000);
|
||||
await service.getActiveKey();
|
||||
expect(repo.find).toHaveBeenCalledTimes(2);
|
||||
expect(repo.findActiveDataEncryptionKeys).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
|
@ -255,7 +250,7 @@ describe('KeyManagerService', () => {
|
|||
it('returns the stored KeyInfo for the aes-256-cbc key when the store serves it', async () => {
|
||||
const { service, repo, cipherMock } = makeFreshService();
|
||||
const key = makeKey({ algorithm: 'aes-256-cbc' });
|
||||
repo.findOne.mockResolvedValue(key);
|
||||
repo.findDataEncryptionKeyByAlgorithm.mockResolvedValue(key);
|
||||
|
||||
const result = await service.getLegacyKey();
|
||||
|
||||
|
|
@ -265,26 +260,26 @@ describe('KeyManagerService', () => {
|
|||
algorithm: 'aes-256-cbc',
|
||||
format: 'no-prefix',
|
||||
});
|
||||
expect(repo.findOne).toHaveBeenCalledWith({
|
||||
where: { type: 'data_encryption', algorithm: 'aes-256-cbc' },
|
||||
});
|
||||
expect(repo.findDataEncryptionKeyByAlgorithm).toHaveBeenCalledWith('aes-256-cbc');
|
||||
expect(cipherMock.encryptDEKWithInstanceKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('memoizes the stored legacy key — the row is immutable once seeded', async () => {
|
||||
const { service, repo } = makeFreshService();
|
||||
repo.findOne.mockResolvedValue(makeKey({ algorithm: 'aes-256-cbc' }));
|
||||
repo.findDataEncryptionKeyByAlgorithm.mockResolvedValue(
|
||||
makeKey({ algorithm: 'aes-256-cbc' }),
|
||||
);
|
||||
|
||||
const first = await service.getLegacyKey();
|
||||
const second = await service.getLegacyKey();
|
||||
|
||||
expect(repo.findOne).toHaveBeenCalledTimes(1);
|
||||
expect(repo.findDataEncryptionKeyByAlgorithm).toHaveBeenCalledTimes(1);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it('falls back to the instance key when the legacy key is not seeded', async () => {
|
||||
const { service, repo, cipherMock } = makeFreshService();
|
||||
repo.findOne.mockResolvedValue(null);
|
||||
repo.findDataEncryptionKeyByAlgorithm.mockResolvedValue(null);
|
||||
cipherMock.encryptDEKWithInstanceKey.mockReturnValue('wrapped-instance-key');
|
||||
|
||||
const result = await service.getLegacyKey();
|
||||
|
|
@ -296,7 +291,7 @@ describe('KeyManagerService', () => {
|
|||
|
||||
it('falls back to the instance key when the store lookup fails', async () => {
|
||||
const { service, repo, cipherMock } = makeFreshService();
|
||||
repo.findOne.mockRejectedValue(new Error('connection refused'));
|
||||
repo.findDataEncryptionKeyByAlgorithm.mockRejectedValue(new Error('connection refused'));
|
||||
cipherMock.encryptDEKWithInstanceKey.mockReturnValue('wrapped-instance-key');
|
||||
|
||||
const result = await service.getLegacyKey();
|
||||
|
|
@ -310,7 +305,7 @@ describe('KeyManagerService', () => {
|
|||
// instance key exactly as bootstrap would.
|
||||
it('wraps the instance key only once and reuses the result', async () => {
|
||||
const { service, repo, cipherMock } = makeFreshService();
|
||||
repo.findOne.mockResolvedValue(null);
|
||||
repo.findDataEncryptionKeyByAlgorithm.mockResolvedValue(null);
|
||||
cipherMock.encryptDEKWithInstanceKey.mockReturnValue('wrapped-instance-key');
|
||||
|
||||
const first = await service.getLegacyKey();
|
||||
|
|
@ -330,20 +325,18 @@ describe('KeyManagerService', () => {
|
|||
|
||||
describe('bootstrapLegacyCbcKey()', () => {
|
||||
it('is a no-op when a CBC key already exists', async () => {
|
||||
repository.findOne.mockResolvedValue(
|
||||
repository.findDataEncryptionKeyByAlgorithm.mockResolvedValue(
|
||||
makeKey({ algorithm: 'aes-256-cbc', status: 'inactive' }),
|
||||
);
|
||||
|
||||
await Container.get(KeyManagerService).bootstrapLegacyCbcKey('instance-key');
|
||||
|
||||
expect(repository.findOne).toHaveBeenCalledWith({
|
||||
where: { type: 'data_encryption', algorithm: 'aes-256-cbc' },
|
||||
});
|
||||
expect(repository.findDataEncryptionKeyByAlgorithm).toHaveBeenCalledWith('aes-256-cbc');
|
||||
expect(repository.seedLegacyCbcKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('encrypts the instance key and seeds it when no CBC key exists', async () => {
|
||||
repository.findOne.mockResolvedValue(null);
|
||||
repository.findDataEncryptionKeyByAlgorithm.mockResolvedValue(null);
|
||||
cipher.encryptDEKWithInstanceKey.mockReturnValue('encrypted-instance-key');
|
||||
|
||||
await Container.get(KeyManagerService).bootstrapLegacyCbcKey('instance-key');
|
||||
|
|
@ -352,24 +345,24 @@ describe('KeyManagerService', () => {
|
|||
// The repository seeds inside a DbLock critical section, which keeps
|
||||
// concurrent startups from creating duplicate rows.
|
||||
expect(repository.seedLegacyCbcKey).toHaveBeenCalledWith('encrypted-instance-key');
|
||||
expect(repository.save).not.toHaveBeenCalled();
|
||||
expect(repository.insertInactiveDataEncryptionKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bootstrapGcmKey()', () => {
|
||||
it('is a no-op when an active GCM key already exists', async () => {
|
||||
repository.findOne.mockResolvedValue(makeKey({ algorithm: 'aes-256-gcm', status: 'active' }));
|
||||
repository.findActiveDataEncryptionKeyByAlgorithm.mockResolvedValue(
|
||||
makeKey({ algorithm: 'aes-256-gcm', status: 'active' }),
|
||||
);
|
||||
|
||||
await Container.get(KeyManagerService).bootstrapGcmKey();
|
||||
|
||||
expect(repository.findOne).toHaveBeenCalledWith({
|
||||
where: { type: 'data_encryption', algorithm: 'aes-256-gcm', status: 'active' },
|
||||
});
|
||||
expect(repository.insertAsActive).not.toHaveBeenCalled();
|
||||
expect(repository.findActiveDataEncryptionKeyByAlgorithm).toHaveBeenCalledWith('aes-256-gcm');
|
||||
expect(repository.seedActiveDataEncryptionKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('generates a 64-char hex key and inserts as active when no active GCM key exists', async () => {
|
||||
repository.findOne.mockResolvedValue(null);
|
||||
repository.findActiveDataEncryptionKeyByAlgorithm.mockResolvedValue(null);
|
||||
cipher.encryptDEKWithInstanceKey.mockReturnValue('encrypted-gcm-key');
|
||||
|
||||
await Container.get(KeyManagerService).bootstrapGcmKey();
|
||||
|
|
@ -378,54 +371,43 @@ describe('KeyManagerService', () => {
|
|||
const [rawKey] = cipher.encryptDEKWithInstanceKey.mock.calls[0];
|
||||
expect(typeof rawKey).toBe('string');
|
||||
expect(rawKey).toHaveLength(64);
|
||||
expect(repository.insertOrIgnore).toHaveBeenCalledWith({
|
||||
type: 'data_encryption',
|
||||
value: 'encrypted-gcm-key',
|
||||
algorithm: 'aes-256-gcm',
|
||||
status: 'active',
|
||||
});
|
||||
expect(repository.seedActiveDataEncryptionKey).toHaveBeenCalledWith(
|
||||
'encrypted-gcm-key',
|
||||
'aes-256-gcm',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addKey()', () => {
|
||||
it('encrypts the value and 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);
|
||||
repository.insertInactiveDataEncryptionKey.mockResolvedValue(saved);
|
||||
cipher.encryptDEKWithInstanceKey.mockReturnValue('encrypted-base64');
|
||||
|
||||
const result = await Container.get(KeyManagerService).addKey('secret', 'aes-256-gcm');
|
||||
|
||||
expect(cipher.encryptDEKWithInstanceKey).toHaveBeenCalledWith('secret');
|
||||
expect(repository.insertAsActive).not.toHaveBeenCalled();
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'inactive',
|
||||
type: 'data_encryption',
|
||||
value: 'encrypted-base64',
|
||||
}),
|
||||
expect(repository.insertAndActivateDataEncryptionKey).not.toHaveBeenCalled();
|
||||
expect(repository.insertInactiveDataEncryptionKey).toHaveBeenCalledWith(
|
||||
'encrypted-base64',
|
||||
'aes-256-gcm',
|
||||
);
|
||||
expect(result).toBe(saved);
|
||||
});
|
||||
|
||||
it('encrypts the value and delegates to insertAsActive when setAsActive=true', async () => {
|
||||
it('encrypts the value and inserts it as active when setAsActive=true', async () => {
|
||||
const saved = makeKey({ id: 'new-key', status: 'active' });
|
||||
repository.create.mockReturnValue(saved);
|
||||
repository.insertAsActive.mockResolvedValue(saved);
|
||||
repository.insertAndActivateDataEncryptionKey.mockResolvedValue(saved);
|
||||
cipher.encryptDEKWithInstanceKey.mockReturnValue('encrypted-base64');
|
||||
|
||||
const result = await Container.get(KeyManagerService).addKey('secret', 'aes-256-gcm', true);
|
||||
|
||||
expect(cipher.encryptDEKWithInstanceKey).toHaveBeenCalledWith('secret');
|
||||
expect(repository.save).not.toHaveBeenCalled();
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'data_encryption',
|
||||
value: 'encrypted-base64',
|
||||
algorithm: 'aes-256-gcm',
|
||||
}),
|
||||
expect(repository.insertInactiveDataEncryptionKey).not.toHaveBeenCalled();
|
||||
expect(repository.insertAndActivateDataEncryptionKey).toHaveBeenCalledWith(
|
||||
'encrypted-base64',
|
||||
'aes-256-gcm',
|
||||
);
|
||||
expect(repository.insertAsActive).toHaveBeenCalledWith(saved);
|
||||
expect(result).toBe(saved);
|
||||
});
|
||||
|
||||
|
|
@ -441,8 +423,7 @@ describe('KeyManagerService', () => {
|
|||
it('switches the very next write to the new key after the insert commits', async () => {
|
||||
const { service, repo, cipherMock } = makeFreshService();
|
||||
const saved = makeKey({ id: 'new-key', status: 'active' });
|
||||
repo.create.mockReturnValue(saved);
|
||||
repo.insertAsActive.mockResolvedValue(saved);
|
||||
repo.insertAndActivateDataEncryptionKey.mockResolvedValue(saved);
|
||||
cipherMock.encryptDEKWithInstanceKey.mockReturnValue('encrypted-base64');
|
||||
|
||||
await service.addKey('secret', 'aes-256-gcm', true);
|
||||
|
|
@ -450,19 +431,18 @@ describe('KeyManagerService', () => {
|
|||
|
||||
expect(active.id).toBe('new-key');
|
||||
// Served from the memo the commit refreshed — no database read.
|
||||
expect(repo.find).not.toHaveBeenCalled();
|
||||
expect(repo.findActiveDataEncryptionKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not update the memo when the insert fails', async () => {
|
||||
const { service, repo, cipherMock } = makeFreshService();
|
||||
repo.create.mockReturnValue(makeKey({ id: 'new-key', status: 'active' }));
|
||||
repo.insertAsActive.mockRejectedValue(new Error('db down'));
|
||||
repo.insertAndActivateDataEncryptionKey.mockRejectedValue(new Error('db down'));
|
||||
cipherMock.encryptDEKWithInstanceKey.mockReturnValue('encrypted-base64');
|
||||
|
||||
await expect(service.addKey('secret', 'aes-256-gcm', true)).rejects.toThrow('db down');
|
||||
|
||||
// The next write must re-read the store, not trust a failed rotation.
|
||||
repo.find.mockResolvedValue([makeKey({ id: 'still-active' })]);
|
||||
repo.findActiveDataEncryptionKeys.mockResolvedValue([makeKey({ id: 'still-active' })]);
|
||||
const active = await service.getActiveKey();
|
||||
expect(active.id).toBe('still-active');
|
||||
});
|
||||
|
|
@ -470,13 +450,12 @@ describe('KeyManagerService', () => {
|
|||
it('does not touch the memo for an inactive key insert', async () => {
|
||||
const { service, repo, cipherMock } = makeFreshService();
|
||||
const saved = makeKey({ id: 'spare-key', status: 'inactive' });
|
||||
repo.create.mockReturnValue(saved);
|
||||
repo.save.mockResolvedValue(saved);
|
||||
repo.insertInactiveDataEncryptionKey.mockResolvedValue(saved);
|
||||
cipherMock.encryptDEKWithInstanceKey.mockReturnValue('encrypted-base64');
|
||||
|
||||
await service.addKey('secret', 'aes-256-gcm');
|
||||
|
||||
repo.find.mockResolvedValue([makeKey({ id: 'the-active-one' })]);
|
||||
repo.findActiveDataEncryptionKeys.mockResolvedValue([makeKey({ id: 'the-active-one' })]);
|
||||
const active = await service.getActiveKey();
|
||||
expect(active.id).toBe('the-active-one');
|
||||
});
|
||||
|
|
@ -486,8 +465,7 @@ describe('KeyManagerService', () => {
|
|||
describe('rotateKey()', () => {
|
||||
it('generates a 64-char hex key and inserts it as active with aes-256-gcm', async () => {
|
||||
const saved = makeKey({ id: 'rotated', status: 'active', algorithm: 'aes-256-gcm' });
|
||||
repository.create.mockReturnValue(saved);
|
||||
repository.insertAsActive.mockResolvedValue(saved);
|
||||
repository.insertAndActivateDataEncryptionKey.mockResolvedValue(saved);
|
||||
cipher.encryptDEKWithInstanceKey.mockReturnValue('encrypted-base64');
|
||||
|
||||
const result = await Container.get(KeyManagerService).rotateKey();
|
||||
|
|
@ -497,21 +475,16 @@ describe('KeyManagerService', () => {
|
|||
expect(typeof rawKey).toBe('string');
|
||||
expect(rawKey.length).toBe(64);
|
||||
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'data_encryption',
|
||||
value: 'encrypted-base64',
|
||||
algorithm: 'aes-256-gcm',
|
||||
}),
|
||||
expect(repository.insertAndActivateDataEncryptionKey).toHaveBeenCalledWith(
|
||||
'encrypted-base64',
|
||||
'aes-256-gcm',
|
||||
);
|
||||
expect(repository.insertAsActive).toHaveBeenCalledWith(saved);
|
||||
expect(result).toBe(saved);
|
||||
});
|
||||
|
||||
it('generates a fresh key value on each call', async () => {
|
||||
const saved = makeKey();
|
||||
repository.create.mockReturnValue(saved);
|
||||
repository.insertAsActive.mockResolvedValue(saved);
|
||||
repository.insertAndActivateDataEncryptionKey.mockResolvedValue(saved);
|
||||
cipher.encryptDEKWithInstanceKey.mockImplementation(
|
||||
(data: string | object) => `enc:${String(data)}`,
|
||||
);
|
||||
|
|
@ -610,33 +583,33 @@ describe('KeyManagerService', () => {
|
|||
delete process.env.N8N_ENV_FEAT_ENCRYPTION_KEY_ROTATION;
|
||||
});
|
||||
|
||||
it('delegates to promoteToActive and invalidates the memo', async () => {
|
||||
it('activates the key and invalidates the memo', async () => {
|
||||
const { service, repo } = makeFreshService();
|
||||
repo.find.mockResolvedValue([makeKey({ id: 'old-active' })]);
|
||||
repo.findActiveDataEncryptionKeys.mockResolvedValue([makeKey({ id: 'old-active' })]);
|
||||
await service.getActiveKey(); // primes the memo
|
||||
repo.promoteToActive.mockResolvedValue(undefined);
|
||||
repo.activateDataEncryptionKey.mockResolvedValue(undefined);
|
||||
|
||||
await service.setActiveKey('target');
|
||||
|
||||
expect(repo.promoteToActive).toHaveBeenCalledWith('target', 'data_encryption');
|
||||
expect(repo.activateDataEncryptionKey).toHaveBeenCalledWith('target');
|
||||
// The memo was dropped: the next write re-reads the store.
|
||||
repo.find.mockResolvedValue([makeKey({ id: 'target' })]);
|
||||
repo.findActiveDataEncryptionKeys.mockResolvedValue([makeKey({ id: 'target' })]);
|
||||
const active = await service.getActiveKey();
|
||||
expect(active.id).toBe('target');
|
||||
expect(repo.find).toHaveBeenCalledTimes(2);
|
||||
expect(repo.findActiveDataEncryptionKeys).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps the memo when the promotion fails', async () => {
|
||||
const { service, repo } = makeFreshService();
|
||||
repo.find.mockResolvedValue([makeKey({ id: 'old-active' })]);
|
||||
repo.findActiveDataEncryptionKeys.mockResolvedValue([makeKey({ id: 'old-active' })]);
|
||||
await service.getActiveKey();
|
||||
repo.promoteToActive.mockRejectedValue(new Error('not found'));
|
||||
repo.activateDataEncryptionKey.mockRejectedValue(new Error('not found'));
|
||||
|
||||
await expect(service.setActiveKey('ghost')).rejects.toThrow('not found');
|
||||
|
||||
const active = await service.getActiveKey();
|
||||
expect(active.id).toBe('old-active');
|
||||
expect(repo.find).toHaveBeenCalledTimes(1);
|
||||
expect(repo.findActiveDataEncryptionKeys).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -651,41 +624,37 @@ describe('KeyManagerService', () => {
|
|||
|
||||
it('sets status to inactive and invalidates the memo', async () => {
|
||||
const { service, repo } = makeFreshService();
|
||||
repo.find.mockResolvedValue([makeKey({ id: 'old-active' })]);
|
||||
repo.findActiveDataEncryptionKeys.mockResolvedValue([makeKey({ id: 'old-active' })]);
|
||||
await service.getActiveKey(); // primes the memo
|
||||
|
||||
await service.markInactive('old-active');
|
||||
|
||||
expect(repo.update).toHaveBeenCalledWith('old-active', { status: 'inactive' });
|
||||
expect(repo.deactivateDataEncryptionKey).toHaveBeenCalledWith('old-active');
|
||||
// The memo was dropped: the next write re-reads the store.
|
||||
repo.find.mockResolvedValue([]);
|
||||
repo.findActiveDataEncryptionKeys.mockResolvedValue([]);
|
||||
await expect(service.getActiveKey()).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('keeps the memo when the status update fails', async () => {
|
||||
const { service, repo } = makeFreshService();
|
||||
repo.find.mockResolvedValue([makeKey({ id: 'old-active' })]);
|
||||
repo.findActiveDataEncryptionKeys.mockResolvedValue([makeKey({ id: 'old-active' })]);
|
||||
await service.getActiveKey();
|
||||
repo.update.mockRejectedValue(new Error('db down'));
|
||||
repo.deactivateDataEncryptionKey.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(service.markInactive('old-active')).rejects.toThrow('db down');
|
||||
|
||||
const active = await service.getActiveKey();
|
||||
expect(active.id).toBe('old-active');
|
||||
expect(repo.find).toHaveBeenCalledTimes(1);
|
||||
expect(repo.findActiveDataEncryptionKeys).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never deletes the key row', async () => {
|
||||
const { service, repo } = makeFreshService();
|
||||
repo.find.mockResolvedValue([makeKey({ id: 'old-active' })]);
|
||||
repo.findActiveDataEncryptionKeys.mockResolvedValue([makeKey({ id: 'old-active' })]);
|
||||
|
||||
await service.markInactive('old-active');
|
||||
|
||||
expect(repo.delete).not.toHaveBeenCalled();
|
||||
expect(repo.remove).not.toHaveBeenCalled();
|
||||
expect(repo.softDelete).not.toHaveBeenCalled();
|
||||
expect(repo.softRemove).not.toHaveBeenCalled();
|
||||
expect(repo.update).toHaveBeenCalledWith('old-active', { status: 'inactive' });
|
||||
expect(repo.deactivateDataEncryptionKey).toHaveBeenCalledWith('old-active');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -82,9 +82,7 @@ export class KeyManagerService implements IEncryptionKeyProvider {
|
|||
return this.activeKeyMemo.info;
|
||||
}
|
||||
|
||||
const activeKeys = await this.deploymentKeyRepository.find({
|
||||
where: { type: 'data_encryption', status: 'active' },
|
||||
});
|
||||
const activeKeys = await this.deploymentKeyRepository.findActiveDataEncryptionKeys();
|
||||
if (activeKeys.length === 0) {
|
||||
throw new NotFoundError('No active encryption key found');
|
||||
}
|
||||
|
|
@ -116,9 +114,7 @@ export class KeyManagerService implements IEncryptionKeyProvider {
|
|||
return cached;
|
||||
}
|
||||
|
||||
const key = await this.deploymentKeyRepository.findOne({
|
||||
where: { id, type: 'data_encryption' },
|
||||
});
|
||||
const key = await this.deploymentKeyRepository.findDataEncryptionKeyById(id);
|
||||
if (!key) return null;
|
||||
const keyInfo: KeyInfo = {
|
||||
id: key.id,
|
||||
|
|
@ -217,9 +213,8 @@ export class KeyManagerService implements IEncryptionKeyProvider {
|
|||
if (this.cachedStoredLegacyKey) return this.cachedStoredLegacyKey;
|
||||
|
||||
try {
|
||||
const key = await this.deploymentKeyRepository.findOne({
|
||||
where: { type: 'data_encryption', algorithm: 'aes-256-cbc' },
|
||||
});
|
||||
const key =
|
||||
await this.deploymentKeyRepository.findDataEncryptionKeyByAlgorithm('aes-256-cbc');
|
||||
if (key) {
|
||||
this.cachedStoredLegacyKey = {
|
||||
id: key.id,
|
||||
|
|
@ -251,9 +246,8 @@ export class KeyManagerService implements IEncryptionKeyProvider {
|
|||
* skip the lock on every startup after the first.
|
||||
*/
|
||||
async bootstrapLegacyCbcKey(instanceEncryptionKey: string): Promise<void> {
|
||||
const existing = await this.deploymentKeyRepository.findOne({
|
||||
where: { type: 'data_encryption', algorithm: 'aes-256-cbc' },
|
||||
});
|
||||
const existing =
|
||||
await this.deploymentKeyRepository.findDataEncryptionKeyByAlgorithm('aes-256-cbc');
|
||||
if (existing) return;
|
||||
|
||||
const encryptedValue = this.cipher.encryptDEKWithInstanceKey(instanceEncryptionKey);
|
||||
|
|
@ -282,19 +276,13 @@ export class KeyManagerService implements IEncryptionKeyProvider {
|
|||
* (type, status='active') serializes inserts, and losers are silently ignored.
|
||||
*/
|
||||
async bootstrapGcmKey(): Promise<void> {
|
||||
const existing = await this.deploymentKeyRepository.findOne({
|
||||
where: { type: 'data_encryption', algorithm: 'aes-256-gcm', status: 'active' },
|
||||
});
|
||||
const existing =
|
||||
await this.deploymentKeyRepository.findActiveDataEncryptionKeyByAlgorithm('aes-256-gcm');
|
||||
if (existing) return;
|
||||
|
||||
const rawKey = randomBytes(32).toString('hex');
|
||||
const encryptedValue = this.cipher.encryptDEKWithInstanceKey(rawKey);
|
||||
await this.deploymentKeyRepository.insertOrIgnore({
|
||||
type: 'data_encryption',
|
||||
value: encryptedValue,
|
||||
algorithm: 'aes-256-gcm',
|
||||
status: 'active',
|
||||
});
|
||||
await this.deploymentKeyRepository.seedActiveDataEncryptionKey(encryptedValue, 'aes-256-gcm');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -347,24 +335,16 @@ export class KeyManagerService implements IEncryptionKeyProvider {
|
|||
const encryptedValue = this.cipher.encryptDEKWithInstanceKey(plaintextValue);
|
||||
|
||||
if (!setAsActive) {
|
||||
const entity = this.deploymentKeyRepository.create({
|
||||
type: 'data_encryption',
|
||||
value: encryptedValue,
|
||||
return await this.deploymentKeyRepository.insertInactiveDataEncryptionKey(
|
||||
encryptedValue,
|
||||
algorithm,
|
||||
status: 'inactive',
|
||||
});
|
||||
return await this.deploymentKeyRepository.save(entity);
|
||||
);
|
||||
}
|
||||
|
||||
const entity = Object.assign(
|
||||
this.deploymentKeyRepository.create({
|
||||
type: 'data_encryption',
|
||||
value: encryptedValue,
|
||||
algorithm,
|
||||
}),
|
||||
{ status: 'active' as const },
|
||||
const saved = await this.deploymentKeyRepository.insertAndActivateDataEncryptionKey(
|
||||
encryptedValue,
|
||||
algorithm,
|
||||
);
|
||||
const saved = await this.deploymentKeyRepository.insertAsActive(entity);
|
||||
// Update the memo only after the commit — otherwise this instance could
|
||||
// encrypt with a key that never landed in the database. Local writes
|
||||
// switch immediately; other instances follow within the memo window.
|
||||
|
|
@ -381,17 +361,16 @@ export class KeyManagerService implements IEncryptionKeyProvider {
|
|||
|
||||
/** Atomically deactivates the current active key and promotes the given key. */
|
||||
async setActiveKey(id: string): Promise<void> {
|
||||
await this.deploymentKeyRepository.promoteToActive(id, 'data_encryption');
|
||||
await this.deploymentKeyRepository.activateDataEncryptionKey(id);
|
||||
this.activeKeyMemo = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitions key to 'inactive'. Never deletes: DeploymentKeyRepository's
|
||||
* delete/remove/softDelete/softRemove/clear all throw, so a deactivated
|
||||
* key's value stays intact and readable for any ciphertext still using it.
|
||||
* Transitions a key to inactive. The repository exposes no delete operation,
|
||||
* and the database rejects direct deletion.
|
||||
*/
|
||||
async markInactive(id: string): Promise<void> {
|
||||
await this.deploymentKeyRepository.update(id, { status: 'inactive' });
|
||||
await this.deploymentKeyRepository.deactivateDataEncryptionKey(id);
|
||||
// The active key may be gone now: force the next write to re-read the store.
|
||||
this.activeKeyMemo = undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { mockInstance, testDb } from '@n8n/backend-test-utils';
|
||||
import { DeploymentKeyRepository } from '@n8n/db';
|
||||
import { DeploymentKey } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { DataSource, type Repository } from '@n8n/typeorm';
|
||||
import type { CryptoKey } from 'jose';
|
||||
import { CompactEncrypt, compactDecrypt, exportJWK, generateKeyPair, importJWK } from 'jose';
|
||||
import { Cipher, InstanceSettings } from 'n8n-core';
|
||||
|
|
@ -14,16 +15,19 @@ import {
|
|||
JWE_PRIVATE_KEY_TYPE,
|
||||
} from '../oauth-jwe.constants';
|
||||
|
||||
let keyStore: Repository<DeploymentKey>;
|
||||
|
||||
beforeAll(async () => {
|
||||
mockInstance(InstanceSettings, {
|
||||
encryptionKey: 'oauth-jwe-test-encryption-key',
|
||||
n8nFolder: '/tmp/n8n-test',
|
||||
});
|
||||
await testDb.init();
|
||||
keyStore = Container.get(DataSource).getRepository(DeploymentKey);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['DeploymentKey']);
|
||||
await testDb.resetDeploymentKeys();
|
||||
await Container.get(CacheService).delete(JWE_KEY_CACHE_KEY);
|
||||
});
|
||||
|
||||
|
|
@ -35,7 +39,7 @@ describe('OAuthJweKeyService (integration)', () => {
|
|||
it('persists exactly one active private-key row per algorithm on first initialize', async () => {
|
||||
await Container.get(OAuthJweKeyService).initialize();
|
||||
|
||||
const rows = await Container.get(DeploymentKeyRepository).find({
|
||||
const rows = await keyStore.find({
|
||||
where: { type: JWE_PRIVATE_KEY_TYPE, status: 'active' },
|
||||
});
|
||||
|
||||
|
|
@ -56,7 +60,7 @@ describe('OAuthJweKeyService (integration)', () => {
|
|||
await Container.get(CacheService).delete(JWE_KEY_CACHE_KEY);
|
||||
await service.initialize();
|
||||
|
||||
const rows = await Container.get(DeploymentKeyRepository).find({
|
||||
const rows = await keyStore.find({
|
||||
where: { type: JWE_PRIVATE_KEY_TYPE, status: 'active' },
|
||||
});
|
||||
expect(rows).toHaveLength(JWE_KEY_ALGORITHMS.length);
|
||||
|
|
@ -80,7 +84,7 @@ describe('OAuthJweKeyService (integration)', () => {
|
|||
it('stores new private JWKs in the DEK-style wrap', async () => {
|
||||
await Container.get(OAuthJweKeyService).initialize();
|
||||
|
||||
const rows = await Container.get(DeploymentKeyRepository).find({
|
||||
const rows = await keyStore.find({
|
||||
where: { type: JWE_PRIVATE_KEY_TYPE, status: 'active' },
|
||||
});
|
||||
const cipher = Container.get(Cipher);
|
||||
|
|
@ -94,7 +98,6 @@ describe('OAuthJweKeyService (integration)', () => {
|
|||
it('reads a row in the earlier wrap format and leaves its stored wrap in place', async () => {
|
||||
const algorithm = JWE_KEY_ALGORITHMS[0];
|
||||
const cipher = Container.get(Cipher);
|
||||
const repository = Container.get(DeploymentKeyRepository);
|
||||
|
||||
// A row as earlier releases persisted it.
|
||||
const id = 'legacy-format-row-1';
|
||||
|
|
@ -102,7 +105,7 @@ describe('OAuthJweKeyService (integration)', () => {
|
|||
const privateJwk = { ...(await exportJWK(privateKey)), kid: id, alg: algorithm, use: 'enc' };
|
||||
const legacyValue = cipher.encryptWithInstanceKey(JSON.stringify(privateJwk));
|
||||
expect(legacyValue.startsWith('U2FsdGVkX1')).toBe(true);
|
||||
await repository.insert({
|
||||
await keyStore.insert({
|
||||
id,
|
||||
type: JWE_PRIVATE_KEY_TYPE,
|
||||
value: legacyValue,
|
||||
|
|
@ -119,13 +122,13 @@ describe('OAuthJweKeyService (integration)', () => {
|
|||
|
||||
// The stored value keeps its wrap byte for byte: older instances in a
|
||||
// rolling deployment can only read this format, and the cache is shared.
|
||||
const row = await repository.findOne({ where: { id } });
|
||||
const row = await keyStore.findOne({ where: { id } });
|
||||
expect(row!.value).toBe(legacyValue);
|
||||
|
||||
// A cold re-read leaves it untouched too.
|
||||
await Container.get(CacheService).delete(JWE_KEY_CACHE_KEY);
|
||||
await service.initialize();
|
||||
const rowAfter = await repository.findOne({ where: { id } });
|
||||
const rowAfter = await keyStore.findOne({ where: { id } });
|
||||
expect(rowAfter!.value).toBe(legacyValue);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -64,44 +64,40 @@ describe('OAuthJweKeyService', () => {
|
|||
|
||||
describe('initialize / loadOrGenerate', () => {
|
||||
it('reuses an existing active row without inserting', async () => {
|
||||
repository.findOne.mockResolvedValue(makeRow({ value: JSON.stringify(privateJwkFixture) }));
|
||||
repository.findActiveOAuthJweKey.mockResolvedValue(
|
||||
makeRow({ value: JSON.stringify(privateJwkFixture) }),
|
||||
);
|
||||
cipher.decryptDEKWithInstanceKey.mockReturnValue(JSON.stringify(privateJwkFixture));
|
||||
|
||||
await Container.get(OAuthJweKeyService).initialize();
|
||||
|
||||
expect(repository.insert).not.toHaveBeenCalled();
|
||||
expect(repository.insertActiveOAuthJweKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('generates and persists a new key pair when none exists', async () => {
|
||||
let row: DeploymentKey | null = null;
|
||||
repository.findOne.mockImplementation(async () => row);
|
||||
repository.insert.mockImplementation(async (entity) => {
|
||||
repository.findActiveOAuthJweKey.mockImplementation(async () => row);
|
||||
repository.insertActiveOAuthJweKey.mockImplementation(async (id, value, algorithm) => {
|
||||
row = makeRow({
|
||||
value: (entity as DeploymentKey).value,
|
||||
algorithm: (entity as DeploymentKey).algorithm,
|
||||
id,
|
||||
value,
|
||||
algorithm,
|
||||
});
|
||||
cipher.decryptDEKWithInstanceKey.mockImplementation((value: string) =>
|
||||
value === row?.value ? JSON.stringify(privateJwkFixture) : '',
|
||||
);
|
||||
return { identifiers: [{ id: 'row-1' }], generatedMaps: [], raw: [] };
|
||||
});
|
||||
|
||||
await Container.get(OAuthJweKeyService).initialize();
|
||||
|
||||
expect(repository.insert).toHaveBeenCalledTimes(1);
|
||||
const [inserted] = repository.insert.mock.calls[0];
|
||||
expect(inserted).toMatchObject({
|
||||
type: JWE_PRIVATE_KEY_TYPE,
|
||||
algorithm: ALGORITHM,
|
||||
status: 'active',
|
||||
});
|
||||
expect((inserted as DeploymentKey).id).toEqual(expect.any(String));
|
||||
expect(repository.insertActiveOAuthJweKey).toHaveBeenCalledTimes(1);
|
||||
const [id, value, algorithm] = repository.insertActiveOAuthJweKey.mock.calls[0];
|
||||
expect(id).toEqual(expect.any(String));
|
||||
expect(value).toEqual(expect.any(String));
|
||||
expect(algorithm).toBe(ALGORITHM);
|
||||
expect(cipher.encryptDEKWithInstanceKey).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws when the persisted private JWK has no kid', async () => {
|
||||
const { kid: _kid, ...kidless } = privateJwkFixture;
|
||||
repository.findOne.mockResolvedValue(makeRow({ value: 'enc-row' }));
|
||||
repository.findActiveOAuthJweKey.mockResolvedValue(makeRow({ value: 'enc-row' }));
|
||||
cipher.decryptDEKWithInstanceKey.mockReturnValue(JSON.stringify(kidless));
|
||||
|
||||
await expect(Container.get(OAuthJweKeyService).initialize()).rejects.toThrow(
|
||||
|
|
@ -110,7 +106,9 @@ describe('OAuthJweKeyService', () => {
|
|||
});
|
||||
|
||||
it('throws when the persisted JWK kid does not match the row id', async () => {
|
||||
repository.findOne.mockResolvedValue(makeRow({ id: 'different-id', value: 'enc-row' }));
|
||||
repository.findActiveOAuthJweKey.mockResolvedValue(
|
||||
makeRow({ id: 'different-id', value: 'enc-row' }),
|
||||
);
|
||||
cipher.decryptDEKWithInstanceKey.mockReturnValue(JSON.stringify(privateJwkFixture));
|
||||
|
||||
await expect(Container.get(OAuthJweKeyService).initialize()).rejects.toThrow(
|
||||
|
|
@ -119,8 +117,8 @@ describe('OAuthJweKeyService', () => {
|
|||
});
|
||||
|
||||
it('throws when the post-generate re-read returns null', async () => {
|
||||
repository.findOne.mockResolvedValue(null);
|
||||
repository.insert.mockResolvedValue({ identifiers: [], generatedMaps: [], raw: [] });
|
||||
repository.findActiveOAuthJweKey.mockResolvedValue(null);
|
||||
repository.insertActiveOAuthJweKey.mockResolvedValue(undefined);
|
||||
|
||||
await expect(Container.get(OAuthJweKeyService).initialize()).rejects.toThrow(
|
||||
`OAuth JWE key for algorithm "${ALGORITHM}" not found after generation`,
|
||||
|
|
@ -131,8 +129,8 @@ describe('OAuthJweKeyService', () => {
|
|||
describe('race handling', () => {
|
||||
it('swallows a postgres unique-constraint violation and re-reads the winner', async () => {
|
||||
let row: DeploymentKey | null = null;
|
||||
repository.findOne.mockImplementation(async () => row);
|
||||
repository.insert.mockImplementation(async () => {
|
||||
repository.findActiveOAuthJweKey.mockImplementation(async () => row);
|
||||
repository.insertActiveOAuthJweKey.mockImplementation(async () => {
|
||||
row = makeRow({ value: 'winner-row' });
|
||||
cipher.decryptDEKWithInstanceKey.mockImplementation((value: string) =>
|
||||
value === 'winner-row' ? JSON.stringify(privateJwkFixture) : '',
|
||||
|
|
@ -145,8 +143,8 @@ describe('OAuthJweKeyService', () => {
|
|||
|
||||
it('swallows a sqlite unique-constraint violation', async () => {
|
||||
let row: DeploymentKey | null = null;
|
||||
repository.findOne.mockImplementation(async () => row);
|
||||
repository.insert.mockImplementation(async () => {
|
||||
repository.findActiveOAuthJweKey.mockImplementation(async () => row);
|
||||
repository.insertActiveOAuthJweKey.mockImplementation(async () => {
|
||||
row = makeRow({ value: 'winner-row' });
|
||||
cipher.decryptDEKWithInstanceKey.mockImplementation((value: string) =>
|
||||
value === 'winner-row' ? JSON.stringify(privateJwkFixture) : '',
|
||||
|
|
@ -158,9 +156,9 @@ describe('OAuthJweKeyService', () => {
|
|||
});
|
||||
|
||||
it('re-throws non-unique-constraint errors unchanged', async () => {
|
||||
repository.findOne.mockResolvedValue(null);
|
||||
repository.findActiveOAuthJweKey.mockResolvedValue(null);
|
||||
const connectionError = makeUniqueViolation('ECONNREFUSED');
|
||||
repository.insert.mockRejectedValue(connectionError);
|
||||
repository.insertActiveOAuthJweKey.mockRejectedValue(connectionError);
|
||||
|
||||
await expect(Container.get(OAuthJweKeyService).initialize()).rejects.toBe(connectionError);
|
||||
});
|
||||
|
|
@ -168,7 +166,7 @@ describe('OAuthJweKeyService', () => {
|
|||
|
||||
describe('getKeyPair / getPublicJwk', () => {
|
||||
beforeEach(() => {
|
||||
repository.findOne.mockResolvedValue(makeRow({ value: 'enc-active' }));
|
||||
repository.findActiveOAuthJweKey.mockResolvedValue(makeRow({ value: 'enc-active' }));
|
||||
cipher.decryptDEKWithInstanceKey.mockReturnValue(JSON.stringify(privateJwkFixture));
|
||||
});
|
||||
|
||||
|
|
@ -221,12 +219,12 @@ describe('OAuthJweKeyService', () => {
|
|||
const legacyValue = 'U2FsdGVkX1-legacy-wrapped';
|
||||
|
||||
beforeEach(() => {
|
||||
repository.findOne.mockResolvedValue(makeRow({ value: legacyValue }));
|
||||
repository.findActiveOAuthJweKey.mockResolvedValue(makeRow({ value: legacyValue }));
|
||||
cipher.decryptWithInstanceKey.mockReturnValue(JSON.stringify(privateJwkFixture));
|
||||
});
|
||||
|
||||
it('reads a row in the earlier wrap format and leaves the stored value untouched', async () => {
|
||||
repository.findOne.mockResolvedValue(makeRow({ value: legacyValue }));
|
||||
repository.findActiveOAuthJweKey.mockResolvedValue(makeRow({ value: legacyValue }));
|
||||
|
||||
const pair = await Container.get(OAuthJweKeyService).getKeyPair();
|
||||
|
||||
|
|
@ -234,15 +232,15 @@ describe('OAuthJweKeyService', () => {
|
|||
expect(cipher.decryptWithInstanceKey).toHaveBeenCalledWith(legacyValue);
|
||||
// Older instances in a rolling deployment can only read this format,
|
||||
// and the cache is shared — the stored row must not be rewritten.
|
||||
expect(repository.update).not.toHaveBeenCalled();
|
||||
expect(repository.insertActiveOAuthJweKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not touch a row already in the DEK-style wrap', async () => {
|
||||
repository.findOne.mockResolvedValue(makeRow({ value: 'enc-active' }));
|
||||
repository.findActiveOAuthJweKey.mockResolvedValue(makeRow({ value: 'enc-active' }));
|
||||
|
||||
await Container.get(OAuthJweKeyService).getKeyPair();
|
||||
|
||||
expect(repository.update).not.toHaveBeenCalled();
|
||||
expect(repository.insertActiveOAuthJweKey).not.toHaveBeenCalled();
|
||||
expect(cipher.decryptWithInstanceKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -260,8 +258,8 @@ describe('OAuthJweKeyService', () => {
|
|||
|
||||
await Container.get(OAuthJweKeyService).getKeyPair();
|
||||
|
||||
expect(repository.findOne).not.toHaveBeenCalled();
|
||||
expect(repository.insert).not.toHaveBeenCalled();
|
||||
expect(repository.findActiveOAuthJweKey).not.toHaveBeenCalled();
|
||||
expect(repository.insertActiveOAuthJweKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import {
|
|||
JWE_KEY_ALGORITHMS,
|
||||
JWE_KEY_CACHE_KEY,
|
||||
JWE_KEY_USE,
|
||||
JWE_PRIVATE_KEY_TYPE,
|
||||
type JweKeyAlgorithm,
|
||||
} from './oauth-jwe.constants';
|
||||
|
||||
|
|
@ -181,13 +180,7 @@ export class OAuthJweKeyService {
|
|||
}
|
||||
|
||||
private async readActiveEntry(algorithm: JweKeyAlgorithm): Promise<OAuthJweKeyEntry | null> {
|
||||
const privateRow = await this.deploymentKeyRepository.findOne({
|
||||
where: {
|
||||
type: JWE_PRIVATE_KEY_TYPE,
|
||||
algorithm,
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
const privateRow = await this.deploymentKeyRepository.findActiveOAuthJweKey(algorithm);
|
||||
if (!privateRow) return null;
|
||||
|
||||
const decryptedPrivate = this.unwrapPrivateJwk(privateRow.value);
|
||||
|
|
@ -228,13 +221,7 @@ export class OAuthJweKeyService {
|
|||
const encryptedPrivate = this.cipher.encryptDEKWithInstanceKey(JSON.stringify(privateJwk));
|
||||
|
||||
try {
|
||||
await this.deploymentKeyRepository.insert({
|
||||
id,
|
||||
type: JWE_PRIVATE_KEY_TYPE,
|
||||
value: encryptedPrivate,
|
||||
algorithm,
|
||||
status: 'active',
|
||||
});
|
||||
await this.deploymentKeyRepository.insertActiveOAuthJweKey(id, encryptedPrivate, algorithm);
|
||||
|
||||
this.logger.info('Generated new instance OAuth JWE key pair', { algorithm, kid: id });
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export const JWE_PRIVATE_KEY_TYPE = 'jwe.private-key';
|
||||
export { OAUTH_JWE_PRIVATE_KEY_TYPE as JWE_PRIVATE_KEY_TYPE } from '@n8n/db';
|
||||
|
||||
/**
|
||||
* RSA `alg` values accepted in the public JWKS for `kty: 'RSA'`. Drives the
|
||||
|
|
|
|||
|
|
@ -197,11 +197,11 @@ checks that the token exists, was minted for this point, and binds to this
|
|||
subject. Only `PolicyEnforcementService` may import the minter; a lint rule
|
||||
enforces that.
|
||||
|
||||
A second lint rule, `no-unsealed-workflow-entity-write`, flags direct
|
||||
`save`/`insert`/`update`/`upsert` calls on `WorkflowEntity` in runtime code. It is
|
||||
syntactic. It catches `save({ id, nodes })` and `update(id, { nodes })`, not a
|
||||
payload built off-site or an aliased receiver. The runtime check is the enforcing
|
||||
half.
|
||||
The workflow entity subscriber rejects inserts and node-bearing updates outside
|
||||
the repository's scoped write context. Policy-cleared repository methods open
|
||||
that context after they validate the matching token. Standalone node execution
|
||||
is the documented exception: it opens the context without a save token, then
|
||||
enforces the policy before the temporary workflow starts.
|
||||
|
||||
## Add a check
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { mockInstance, testDb } from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { DeploymentKeyRepository } from '@n8n/db';
|
||||
import { DeploymentKey, DeploymentKeyRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { DataSource, type Repository } from '@n8n/typeorm';
|
||||
import jsonwebtoken from 'jsonwebtoken';
|
||||
import { Cipher, InstanceSettings } from 'n8n-core';
|
||||
|
||||
|
|
@ -27,21 +28,23 @@ afterAll(async () => {
|
|||
// real database and the real cipher.
|
||||
describe('deployment signing secrets (integration)', () => {
|
||||
let repo: DeploymentKeyRepository;
|
||||
let keyStore: Repository<DeploymentKey>;
|
||||
let cipher: Cipher;
|
||||
|
||||
beforeAll(() => {
|
||||
repo = Container.get(DeploymentKeyRepository);
|
||||
keyStore = Container.get(DataSource).getRepository(DeploymentKey);
|
||||
cipher = Container.get(Cipher);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['DeploymentKey']);
|
||||
await testDb.resetDeploymentKeys();
|
||||
});
|
||||
|
||||
it('seeds a secret in wrapped form and reads it back in usable form', async () => {
|
||||
await repo.seedSigningSecret('signing.hmac', 'the-hmac-secret');
|
||||
|
||||
const row = await repo.findActiveByType('signing.hmac');
|
||||
const row = await keyStore.findOne({ where: { type: 'signing.hmac', status: 'active' } });
|
||||
expect(row).not.toBeNull();
|
||||
expect(row!.algorithm).toBe('aes-256-gcm');
|
||||
expect(row!.value).not.toContain('the-hmac-secret');
|
||||
|
|
@ -51,33 +54,37 @@ describe('deployment signing secrets (integration)', () => {
|
|||
});
|
||||
|
||||
it('returns a row in the original stored form as-is without rewriting it', async () => {
|
||||
await repo.insertOrIgnore({
|
||||
type: 'signing.jwt',
|
||||
value: 'stored-plain-secret',
|
||||
status: 'active',
|
||||
algorithm: null,
|
||||
});
|
||||
await keyStore.save(
|
||||
keyStore.create({
|
||||
type: 'signing.jwt',
|
||||
value: 'stored-plain-secret',
|
||||
status: 'active',
|
||||
algorithm: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(repo.findActiveSigningSecret('signing.jwt')).resolves.toBe('stored-plain-secret');
|
||||
|
||||
const row = await repo.findActiveByType('signing.jwt');
|
||||
const row = await keyStore.findOne({ where: { type: 'signing.jwt', status: 'active' } });
|
||||
expect(row!.algorithm).toBeNull();
|
||||
expect(row!.value).toBe('stored-plain-secret');
|
||||
});
|
||||
|
||||
it('upgrades a row in the original stored form when rewrapLegacy is set', async () => {
|
||||
await repo.insertOrIgnore({
|
||||
type: 'signing.jwt',
|
||||
value: 'stored-plain-secret',
|
||||
status: 'active',
|
||||
algorithm: null,
|
||||
});
|
||||
await keyStore.save(
|
||||
keyStore.create({
|
||||
type: 'signing.jwt',
|
||||
value: 'stored-plain-secret',
|
||||
status: 'active',
|
||||
algorithm: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(repo.findActiveSigningSecret('signing.jwt', { rewrapLegacy: true })).resolves.toBe(
|
||||
'stored-plain-secret',
|
||||
);
|
||||
|
||||
const row = await repo.findActiveByType('signing.jwt');
|
||||
const row = await keyStore.findOne({ where: { type: 'signing.jwt', status: 'active' } });
|
||||
expect(row!.algorithm).toBe('aes-256-gcm');
|
||||
expect(row!.value).not.toBe('stored-plain-secret');
|
||||
|
||||
|
|
@ -88,12 +95,14 @@ describe('deployment signing secrets (integration)', () => {
|
|||
});
|
||||
|
||||
it('throws on a storage format this version cannot read', async () => {
|
||||
await repo.insertOrIgnore({
|
||||
type: 'signing.jwt',
|
||||
value: 'value-in-some-future-format',
|
||||
status: 'active',
|
||||
algorithm: 'aes-256-cbc',
|
||||
});
|
||||
await keyStore.save(
|
||||
keyStore.create({
|
||||
type: 'signing.jwt',
|
||||
value: 'value-in-some-future-format',
|
||||
status: 'active',
|
||||
algorithm: 'aes-256-cbc',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(repo.findActiveSigningSecret('signing.jwt')).rejects.toThrow(
|
||||
"unsupported storage format 'aes-256-cbc'",
|
||||
|
|
@ -102,9 +111,11 @@ describe('deployment signing secrets (integration)', () => {
|
|||
|
||||
it('throws a usable error when a wrapped value cannot be read', async () => {
|
||||
await repo.seedSigningSecret('signing.binary_data', 'the-binary-secret');
|
||||
const row = await repo.findActiveByType('signing.binary_data');
|
||||
const row = await keyStore.findOne({
|
||||
where: { type: 'signing.binary_data', status: 'active' },
|
||||
});
|
||||
// Simulate a value written under a different instance encryption key.
|
||||
await repo.update({ id: row!.id }, { value: `${row!.value.slice(0, -4)}AAAA` });
|
||||
await keyStore.update({ id: row!.id }, { value: `${row!.value.slice(0, -4)}AAAA` });
|
||||
|
||||
await expect(repo.findActiveSigningSecret('signing.binary_data')).rejects.toThrow(
|
||||
'cannot be read with this instance encryption key',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { testDb } from '@n8n/backend-test-utils';
|
||||
import type { DeploymentKey, User } from '@n8n/db';
|
||||
import { DeploymentKeyRepository } from '@n8n/db';
|
||||
import { DeploymentKey, type User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { DataSource, type Repository } from '@n8n/typeorm';
|
||||
|
||||
import { createMember, createOwner } from './shared/db/users';
|
||||
import type { SuperAgentTest } from './shared/types';
|
||||
|
|
@ -9,18 +9,19 @@ import * as utils from './shared/utils/';
|
|||
|
||||
const testServer = utils.setupTestServer({ endpointGroups: ['encryption-keys'] });
|
||||
|
||||
let deploymentKeyRepository: DeploymentKeyRepository;
|
||||
let keyStore: Repository<DeploymentKey>;
|
||||
let owner: User;
|
||||
let ownerAgent: SuperAgentTest;
|
||||
let member: User;
|
||||
let memberAgent: SuperAgentTest;
|
||||
|
||||
beforeAll(() => {
|
||||
deploymentKeyRepository = Container.get(DeploymentKeyRepository);
|
||||
keyStore = Container.get(DataSource).getRepository(DeploymentKey);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['User', 'DeploymentKey']);
|
||||
await testDb.truncate(['User']);
|
||||
await testDb.resetDeploymentKeys();
|
||||
owner = await createOwner();
|
||||
ownerAgent = testServer.authAgentFor(owner);
|
||||
member = await createMember();
|
||||
|
|
@ -38,8 +39,8 @@ const seedKey = async (
|
|||
}> = {},
|
||||
) => {
|
||||
const { createdAt, updatedAt, ...rest } = overrides;
|
||||
const saved = await deploymentKeyRepository.save(
|
||||
deploymentKeyRepository.create({
|
||||
const saved = await keyStore.save(
|
||||
keyStore.create({
|
||||
type: 'data_encryption',
|
||||
value: 'seed-value',
|
||||
algorithm: 'aes-256-cbc',
|
||||
|
|
@ -48,11 +49,11 @@ const seedKey = async (
|
|||
}),
|
||||
);
|
||||
if (createdAt || updatedAt) {
|
||||
await deploymentKeyRepository.update(saved.id, {
|
||||
await keyStore.update(saved.id, {
|
||||
...(createdAt ? { createdAt } : {}),
|
||||
...(updatedAt ? { updatedAt } : {}),
|
||||
});
|
||||
return await deploymentKeyRepository.findOneByOrFail({ id: saved.id });
|
||||
return await keyStore.findOneByOrFail({ id: saved.id });
|
||||
}
|
||||
return saved;
|
||||
};
|
||||
|
|
@ -317,7 +318,7 @@ describe('POST /encryption/keys', () => {
|
|||
});
|
||||
expect(response.body.data).not.toHaveProperty('value');
|
||||
|
||||
const rows = await deploymentKeyRepository.find({ where: { type: 'data_encryption' } });
|
||||
const rows = await keyStore.find({ where: { type: 'data_encryption' } });
|
||||
expect(rows).toHaveLength(2);
|
||||
|
||||
const active = rows.filter((r: DeploymentKey) => r.status === 'active');
|
||||
|
|
@ -327,7 +328,7 @@ describe('POST /encryption/keys', () => {
|
|||
expect(active[0].value.length).toBeGreaterThan(0);
|
||||
expect(JSON.stringify(response.body)).not.toContain(active[0].value);
|
||||
|
||||
const reloadedPrevious = await deploymentKeyRepository.findOneByOrFail({
|
||||
const reloadedPrevious = await keyStore.findOneByOrFail({
|
||||
id: previousActive.id,
|
||||
});
|
||||
expect(reloadedPrevious.status).toBe('inactive');
|
||||
|
|
@ -344,7 +345,7 @@ describe('POST /encryption/keys', () => {
|
|||
test('returns 403 for a non-owner user', async () => {
|
||||
await memberAgent.post('/encryption/keys').send({ type: 'data_encryption' }).expect(403);
|
||||
|
||||
const rows = await deploymentKeyRepository.find();
|
||||
const rows = await keyStore.find();
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
import {
|
||||
createTestMigrationContext,
|
||||
initDbUpToMigration,
|
||||
runSingleMigration,
|
||||
undoLastSingleMigration,
|
||||
type TestMigrationContext,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import { DbConnection } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { DataSource } from '@n8n/typeorm';
|
||||
|
||||
const MIGRATION_NAME = 'PreventDeploymentKeyDeletion1790054384570';
|
||||
|
||||
describe('PreventDeploymentKeyDeletion migration', () => {
|
||||
let dataSource: DataSource;
|
||||
|
||||
async function withContext<T>(fn: (context: TestMigrationContext) => Promise<T>): Promise<T> {
|
||||
const context = createTestMigrationContext(dataSource);
|
||||
try {
|
||||
return await fn(context);
|
||||
} finally {
|
||||
await context.queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const dbConnection = Container.get(DbConnection);
|
||||
await dbConnection.init();
|
||||
dataSource = Container.get(DataSource);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await withContext(async (context) => await context.queryRunner.clearDatabase());
|
||||
await initDbUpToMigration(MIGRATION_NAME);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Container.get(DbConnection).close();
|
||||
});
|
||||
|
||||
async function insertKey(context: TestMigrationContext) {
|
||||
const table = context.escape.tableName('deployment_key');
|
||||
await context.runQuery(
|
||||
`INSERT INTO ${table} (id, type, value, algorithm, status, "createdAt", "updatedAt")
|
||||
VALUES (:id, :type, :value, :algorithm, :status, :createdAt, :updatedAt)`,
|
||||
{
|
||||
id: 'key-1',
|
||||
type: 'data_encryption',
|
||||
value: 'encrypted-value',
|
||||
algorithm: 'aes-256-gcm',
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
it('rejects deletion and keeps the key readable', async () => {
|
||||
await withContext(insertKey);
|
||||
await runSingleMigration(MIGRATION_NAME);
|
||||
|
||||
await withContext(async (context) => {
|
||||
const table = context.escape.tableName('deployment_key');
|
||||
await expect(context.runQuery(`DELETE FROM ${table}`)).rejects.toThrow(
|
||||
'Deployment keys must not be deleted',
|
||||
);
|
||||
await expect(
|
||||
context.runQuery(`UPDATE ${table} SET status = 'inactive' WHERE id = 'key-1'`),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
const rows = await context.runQuery<Array<{ id: string; status: string }>>(
|
||||
`SELECT id, status FROM ${table}`,
|
||||
);
|
||||
expect(rows).toEqual([{ id: 'key-1', status: 'inactive' }]);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects truncation on Postgres', async () => {
|
||||
if (dataSource.options.type !== 'postgres') return;
|
||||
await withContext(insertKey);
|
||||
await runSingleMigration(MIGRATION_NAME);
|
||||
|
||||
await withContext(async (context) => {
|
||||
const table = context.escape.tableName('deployment_key');
|
||||
await expect(context.runQuery(`TRUNCATE TABLE ${table}`)).rejects.toThrow(
|
||||
'Deployment keys must not be deleted',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('allows deletion after the migration is reverted', async () => {
|
||||
await withContext(insertKey);
|
||||
await runSingleMigration(MIGRATION_NAME);
|
||||
await undoLastSingleMigration();
|
||||
|
||||
await withContext(async (context) => {
|
||||
const table = context.escape.tableName('deployment_key');
|
||||
await expect(context.runQuery(`DELETE FROM ${table}`)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -224,8 +224,8 @@ describe('InstanceSettings', () => {
|
|||
|
||||
describe('initialize', () => {
|
||||
const mockRepo = {
|
||||
findActiveByType: vi.fn(),
|
||||
insertOrIgnore: vi.fn(),
|
||||
findActiveIdentifier: vi.fn(),
|
||||
seedActiveIdentifier: vi.fn(),
|
||||
findActiveSigningSecret: vi.fn(),
|
||||
seedSigningSecret: vi.fn(),
|
||||
};
|
||||
|
|
@ -240,8 +240,8 @@ describe('InstanceSettings', () => {
|
|||
settings = createInstanceSettings({ encryptionKey: 'test_key' });
|
||||
|
||||
// Default: no DB rows, inserts succeed
|
||||
mockRepo.findActiveByType.mockResolvedValue(null);
|
||||
mockRepo.insertOrIgnore.mockResolvedValue(undefined);
|
||||
mockRepo.findActiveIdentifier.mockResolvedValue(null);
|
||||
mockRepo.seedActiveIdentifier.mockResolvedValue(undefined);
|
||||
mockRepo.findActiveSigningSecret.mockResolvedValue(null);
|
||||
mockRepo.seedSigningSecret.mockResolvedValue(undefined);
|
||||
});
|
||||
|
|
@ -253,22 +253,24 @@ describe('InstanceSettings', () => {
|
|||
await settings.initialize(mockRepo);
|
||||
|
||||
expect(settings.instanceId).toEqual('env-pinned-id');
|
||||
expect(mockRepo.findActiveByType).not.toHaveBeenCalledWith('instance.id');
|
||||
expect(mockRepo.insertOrIgnore).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'instance.id' }),
|
||||
expect(mockRepo.findActiveIdentifier).not.toHaveBeenCalledWith('instance.id');
|
||||
expect(mockRepo.seedActiveIdentifier).not.toHaveBeenCalledWith(
|
||||
'instance.id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use the value from the active DB row when one exists', async () => {
|
||||
mockRepo.findActiveByType.mockImplementation(async (type: string) =>
|
||||
mockRepo.findActiveIdentifier.mockImplementation(async (type: string) =>
|
||||
type === 'instance.id' ? { value: 'db-stored-id' } : null,
|
||||
);
|
||||
|
||||
await settings.initialize(mockRepo);
|
||||
|
||||
expect(settings.instanceId).toEqual('db-stored-id');
|
||||
expect(mockRepo.insertOrIgnore).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'instance.id' }),
|
||||
expect(mockRepo.seedActiveIdentifier).not.toHaveBeenCalledWith(
|
||||
'instance.id',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -277,12 +279,7 @@ describe('InstanceSettings', () => {
|
|||
|
||||
await settings.initialize(mockRepo);
|
||||
|
||||
expect(mockRepo.insertOrIgnore).toHaveBeenCalledWith({
|
||||
type: 'instance.id',
|
||||
value: derivedId,
|
||||
status: 'active',
|
||||
algorithm: null,
|
||||
});
|
||||
expect(mockRepo.seedActiveIdentifier).toHaveBeenCalledWith('instance.id', derivedId);
|
||||
expect(settings.instanceId).toEqual(derivedId);
|
||||
});
|
||||
|
||||
|
|
@ -298,11 +295,10 @@ describe('InstanceSettings', () => {
|
|||
});
|
||||
|
||||
it('should use the winner row when a concurrent insert is ignored', async () => {
|
||||
mockRepo.insertOrIgnore.mockImplementation(async (entity: { type: string }) => {
|
||||
// Simulate conflict only for instance.id
|
||||
if (entity.type === 'instance.id') return undefined;
|
||||
mockRepo.seedActiveIdentifier.mockImplementation(async (type: string) => {
|
||||
if (type === 'instance.id') return undefined;
|
||||
});
|
||||
mockRepo.findActiveByType.mockImplementation(async (type: string) =>
|
||||
mockRepo.findActiveIdentifier.mockImplementation(async (type: string) =>
|
||||
type === 'instance.id' ? { value: 'winner-id' } : null,
|
||||
);
|
||||
|
||||
|
|
@ -365,7 +361,7 @@ describe('InstanceSettings', () => {
|
|||
|
||||
await settings.initialize(mockRepo, { canSeed: false });
|
||||
|
||||
expect(mockRepo.insertOrIgnore).not.toHaveBeenCalled();
|
||||
expect(mockRepo.seedActiveIdentifier).not.toHaveBeenCalled();
|
||||
expect(mockRepo.seedSigningSecret).not.toHaveBeenCalled();
|
||||
expect(settings.instanceId).toEqual(derivedId);
|
||||
expect(settings.hmacSignatureSecret).toEqual(derivedHmac);
|
||||
|
|
@ -402,7 +398,7 @@ describe('InstanceSettings', () => {
|
|||
|
||||
expect(settings.instanceId).toEqual('env-pinned-id');
|
||||
expect(settings.hmacSignatureSecret).toEqual('db-stored-hmac');
|
||||
expect(mockRepo.insertOrIgnore).not.toHaveBeenCalled();
|
||||
expect(mockRepo.seedActiveIdentifier).not.toHaveBeenCalled();
|
||||
expect(mockRepo.seedSigningSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,13 +34,8 @@ type Settings = ReadOnlySettings & WritableSettings;
|
|||
* package dependency: `@n8n/db` depends on `n8n-core` at runtime.
|
||||
*/
|
||||
export type DeploymentStateRepo = {
|
||||
findActiveByType(type: string): Promise<{ value: string } | null>;
|
||||
insertOrIgnore(entity: {
|
||||
type: string;
|
||||
value: string;
|
||||
status: string;
|
||||
algorithm: null;
|
||||
}): Promise<void>;
|
||||
findActiveIdentifier(type: string): Promise<{ value: string } | null>;
|
||||
seedActiveIdentifier(type: string, value: string): Promise<void>;
|
||||
findActiveSigningSecret(type: string, opts?: { rewrapLegacy?: boolean }): Promise<string | null>;
|
||||
seedSigningSecret(type: string, secret: string): Promise<void>;
|
||||
};
|
||||
|
|
@ -165,14 +160,14 @@ export class InstanceSettings {
|
|||
set(envValue);
|
||||
return;
|
||||
}
|
||||
const existing = await repo.findActiveByType(type);
|
||||
const existing = await repo.findActiveIdentifier(type);
|
||||
if (existing) {
|
||||
set(existing.value);
|
||||
return;
|
||||
}
|
||||
if (!canSeed) return;
|
||||
await repo.insertOrIgnore({ type, value: get(), status: 'active', algorithm: null });
|
||||
const winner = await repo.findActiveByType(type);
|
||||
await repo.seedActiveIdentifier(type, get());
|
||||
const winner = await repo.findActiveIdentifier(type);
|
||||
if (winner) set(winner.value);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ describe('EncryptionBoundaryRule', () => {
|
|||
const downgraded = `${BOUNDARY_CONFIG.trimEnd()}
|
||||
export const extra = {
|
||||
rules: {
|
||||
'n8n-local-rules/no-deployment-key-delete': 'off',
|
||||
'n8n-local-rules/no-encryption-guardrail-disable': ['warn'],
|
||||
'n8n-local-rules/no-uncaught-json-parse': 'off',
|
||||
},
|
||||
};
|
||||
|
|
@ -130,8 +130,7 @@ export const extra = {
|
|||
const violations = await analyze();
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0]).toContain('eslint.config.mjs:7');
|
||||
expect(violations[0]).toContain('no-deployment-key-delete');
|
||||
expect(violations[0]).toContain('no-encryption-guardrail-disable');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -182,7 +181,7 @@ export const extra = {
|
|||
write(
|
||||
'packages/a/src/index.ts',
|
||||
[
|
||||
'// oxlint-disable-next-line n8n-local-rules/no-deployment-key-delete',
|
||||
'// oxlint-disable-next-line n8n-local-rules/no-encryption-guardrail-disable',
|
||||
'export const a = 1; // oxlint-disable-line no-console',
|
||||
'/* oxlint-disable */',
|
||||
'/* oxlint n8n-local-rules/no-encryption-guardrail-disable: "off" */',
|
||||
|
|
@ -193,7 +192,7 @@ export const extra = {
|
|||
const violations = await analyze();
|
||||
|
||||
expect(violations).toHaveLength(3);
|
||||
expect(violations[0]).toContain('no-deployment-key-delete');
|
||||
expect(violations[0]).toContain('no-encryption-guardrail-disable');
|
||||
expect(violations[1]).toContain('`oxlint-disable` directive silences every lint rule');
|
||||
expect(violations[2]).toContain('inline `oxlint` configuration comment');
|
||||
});
|
||||
|
|
@ -215,7 +214,7 @@ export const extra = {
|
|||
write(
|
||||
'packages/a/src/index.ts',
|
||||
[
|
||||
'// eslint-disable-next-line n8n-local-rules/no-deployment-key-delete',
|
||||
'// eslint-disable-next-line n8n-local-rules/no-encryption-guardrail-disable',
|
||||
'export const a = 1; // eslint-disable-line no-console',
|
||||
'/* eslint n8n-local-rules/no-encryption-guardrail-disable: "off" */',
|
||||
'',
|
||||
|
|
@ -225,7 +224,7 @@ export const extra = {
|
|||
const violations = await analyze();
|
||||
|
||||
expect(violations).toHaveLength(2);
|
||||
expect(violations[0]).toContain('no-deployment-key-delete');
|
||||
expect(violations[0]).toContain('no-encryption-guardrail-disable');
|
||||
expect(violations[1]).toContain('inline `eslint` configuration comment');
|
||||
});
|
||||
|
||||
|
|
@ -246,7 +245,7 @@ export const extra = {
|
|||
it('does not read the explanation after -- as a rule list', async () => {
|
||||
write(
|
||||
'packages/a/src/index.ts',
|
||||
'// eslint-disable-next-line no-console -- unrelated to no-deployment-key-delete\nexport const a = 1;\n',
|
||||
'// eslint-disable-next-line no-console -- unrelated to encryption guardrails\nexport const a = 1;\n',
|
||||
);
|
||||
|
||||
expect(await analyze()).toEqual([]);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
const DEFAULT_TRIGGER_DEPENDENCIES = ['n8n-core', '@n8n/db'];
|
||||
|
||||
/** The rules `encryptionBoundaryConfig` enables, without the plugin prefix. */
|
||||
const DEFAULT_GUARDED_RULES = ['no-deployment-key-delete', 'no-encryption-guardrail-disable'];
|
||||
const DEFAULT_GUARDED_RULES = ['no-encryption-guardrail-disable'];
|
||||
|
||||
const BOUNDARY_CONFIG_PATH = 'packages/@n8n/eslint-config/src/configs/encryption-boundary.ts';
|
||||
|
||||
|
|
|
|||
198
scripts/lint-parity/OXLINT_MIGRATION_BLOCKERS.md
Normal file
198
scripts/lint-parity/OXLINT_MIGRATION_BLOCKERS.md
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# Oxlint migration blockers
|
||||
|
||||
Last reviewed: 2026-09-18
|
||||
Oxlint version: 1.78.0
|
||||
|
||||
This file is the working backlog for the ESLint to Oxlint migration. It focuses on rules that block an Oxlint-only lint command for many packages. `oxlint-gap.json` is the machine-readable source of truth for shared-layer parity.
|
||||
|
||||
## Completion criteria
|
||||
|
||||
A blocker is complete when all of these conditions are true:
|
||||
|
||||
1. Oxlint enforces equivalent behavior, or we intentionally retire the rule.
|
||||
2. We remove the rule from `oxlint-gap.json` if it is present there.
|
||||
3. `node scripts/lint-parity/oxlint-parity.mjs --pkg packages/<package> --eslint /tmp/eslint-snapshot.json` passes.
|
||||
4. The affected packages no longer need an ESLint pass for that rule.
|
||||
5. We test fixer behavior when the rule has a fixer.
|
||||
|
||||
## Majority-package blockers
|
||||
|
||||
These rules come from the shared base or backend layers. Resolve these first because they affect the largest number of packages.
|
||||
|
||||
| Priority | Rule | Blocker | Suggested next action |
|
||||
| --- | --- | --- | --- |
|
||||
| P0 | `import-x/no-extraneous-dependencies` | Oxlint reserves the `import-x` plugin name. The JS plugin bridge has not been validated with an alias. | Test `eslint-plugin-import-x` under an alias. If that is unstable, implement a native package-manifest check. |
|
||||
| P1 | `no-restricted-syntax` | Oxlint has no AST-selector rule. The base layer uses it for the raw-enum restriction. | Add a purpose-built syntax rule for each active selector. Start with the raw-enum rule. |
|
||||
| P1 | `typescript/consistent-type-imports` | Oxlint does not honor the `emitDecoratorMetadata` exception. Its fixer can remove imports that DI needs at runtime. | Wait for upstream support or add a tested shim that skips decorated files. Do not enable the current fixer. |
|
||||
| P1 | `typescript/prefer-optional-chain` | The native nursery rule changes guarded undeclared globals incorrectly. | Re-test after the rule leaves nursery or after the upstream fixer issue is resolved. |
|
||||
| P2 | `import/export` | A native rule exists, but it is still in the nursery category. | Run parity samples and enable it after it becomes stable. |
|
||||
| P3 | `no-octal` | Oxlint has no rule. The parser already rejects relevant octal syntax in module and strict-mode code. | Confirm parser coverage and retire the explicit rule if no supported source form remains. |
|
||||
|
||||
## Package-level typed blocker
|
||||
|
||||
### `@typescript-eslint/naming-convention`
|
||||
|
||||
Oxlint has no native equivalent. The shared base disables this rule, but multiple package configs enable it at `error`. This blocks those packages from reaching full parity even after the shared blockers are complete.
|
||||
|
||||
Possible approaches:
|
||||
|
||||
1. Retire rules that only enforce style preferences.
|
||||
2. Replace important selectors with small syntax-only custom rules.
|
||||
3. Use existing Oxlint rules for specific conventions, such as filename or identifier restrictions.
|
||||
4. Keep a narrow ESLint pass only in packages whose convention protects an API contract.
|
||||
|
||||
Audit every package configuration before conversion. Do not copy the full generic naming rule into a new custom implementation.
|
||||
|
||||
## Frontend package blockers
|
||||
|
||||
Oxlint can lint the script section of a Vue SFC, but it does not expose the Vue template AST. An Oxlint-only frontend package would lose the following 59 rules:
|
||||
|
||||
```text
|
||||
vue/block-order
|
||||
vue/comment-directive
|
||||
vue/component-name-in-template-casing
|
||||
vue/jsx-uses-vars
|
||||
vue/no-child-content
|
||||
vue/no-deprecated-dollar-listeners-api
|
||||
vue/no-deprecated-dollar-scopedslots-api
|
||||
vue/no-deprecated-filter
|
||||
vue/no-deprecated-functional-template
|
||||
vue/no-deprecated-html-element-is
|
||||
vue/no-deprecated-inline-template
|
||||
vue/no-deprecated-router-link-tag-prop
|
||||
vue/no-deprecated-scope-attribute
|
||||
vue/no-deprecated-slot-attribute
|
||||
vue/no-deprecated-slot-scope-attribute
|
||||
vue/no-deprecated-v-bind-sync
|
||||
vue/no-deprecated-v-is
|
||||
vue/no-deprecated-v-on-native-modifier
|
||||
vue/no-deprecated-v-on-number-modifiers
|
||||
vue/no-dupe-v-else-if
|
||||
vue/no-duplicate-attributes
|
||||
vue/no-multiple-template-root
|
||||
vue/no-parsing-error
|
||||
vue/no-ref-as-operand
|
||||
vue/no-template-key
|
||||
vue/no-textarea-mustache
|
||||
vue/no-undef-components
|
||||
vue/no-unused-components
|
||||
vue/no-unused-vars
|
||||
vue/no-use-computed-property-like-method
|
||||
vue/no-use-v-if-with-v-for
|
||||
vue/no-useless-template-attributes
|
||||
vue/no-v-for-template-key-on-child
|
||||
vue/no-v-html
|
||||
vue/require-component-is
|
||||
vue/require-macro-variable-name
|
||||
vue/require-toggle-inside-transition
|
||||
vue/require-v-for-key
|
||||
vue/require-valid-default-prop
|
||||
vue/use-v-on-exact
|
||||
vue/v-slot-style
|
||||
vue/valid-attribute-name
|
||||
vue/valid-template-root
|
||||
vue/valid-v-bind
|
||||
vue/valid-v-cloak
|
||||
vue/valid-v-else
|
||||
vue/valid-v-else-if
|
||||
vue/valid-v-for
|
||||
vue/valid-v-html
|
||||
vue/valid-v-if
|
||||
vue/valid-v-is
|
||||
vue/valid-v-memo
|
||||
vue/valid-v-model
|
||||
vue/valid-v-on
|
||||
vue/valid-v-once
|
||||
vue/valid-v-pre
|
||||
vue/valid-v-show
|
||||
vue/valid-v-slot
|
||||
vue/valid-v-text
|
||||
```
|
||||
|
||||
The design-system rule `@n8n/design-system/require-teleported-tooltip-in-dropdown` has the same blocker. It uses `vue-eslint-parser` template services, not TypeScript type information.
|
||||
|
||||
Suggested direction:
|
||||
|
||||
1. Keep ESLint for `.vue` files until Oxlint exposes a template AST or another template-aware linter replaces it.
|
||||
2. Run Oxlint for `.ts`, `.tsx`, and the SFC script section.
|
||||
3. Do not rewrite 60 template rules as source-text checks.
|
||||
|
||||
## Node package blockers
|
||||
|
||||
The `n8n-nodes-base` plugin contributes 94 enforced rules:
|
||||
|
||||
- 12 credential rules in `packages/@n8n/eslint-config/src/configs/nodes.ts`.
|
||||
- 82 node rules in the same config.
|
||||
|
||||
The blocker is not known type awareness. The plugin needs a compatibility test through Oxlint's JavaScript plugin bridge. Its file-scoped rule tables also need an Oxlint translation.
|
||||
|
||||
Use the rule tables in `packages/@n8n/eslint-config/src/configs/nodes.ts` as the canonical list. They change more often than this document and must not be duplicated here.
|
||||
|
||||
Suggested spike:
|
||||
|
||||
1. Load `eslint-plugin-n8n-nodes-base` through `jsPlugins` in an isolated node package.
|
||||
2. Run all 12 credential rules against representative valid and invalid files.
|
||||
3. Run the 82 node rules against representative valid and invalid files.
|
||||
4. Compare diagnostics, locations, options, and fixes with ESLint.
|
||||
5. Add the working file-scoped tables to `@n8n/oxlint-config/nodes`.
|
||||
|
||||
## Rules that are not blockers
|
||||
|
||||
Oxlint has native implementations for almost all configured type-aware `@typescript-eslint` rules. This includes promise analysis, unsafe-operation checks, type constituent checks, and template-expression checks.
|
||||
|
||||
Do not keep ESLint only because a rule is type-aware. Check whether Oxlint already provides the rule in the `typescript` namespace.
|
||||
|
||||
The main exceptions are:
|
||||
|
||||
- `@typescript-eslint/naming-convention`, which has no native equivalent.
|
||||
- `@typescript-eslint/consistent-type-imports`, which diverges for decorated files.
|
||||
- `@typescript-eslint/prefer-optional-chain`, which has a known semantic divergence.
|
||||
|
||||
## Work order
|
||||
|
||||
1. Resolve or isolate `import-x/no-extraneous-dependencies`.
|
||||
2. Replace active `no-restricted-syntax` selectors with focused rules.
|
||||
3. Resolve decorator-safe `consistent-type-imports` behavior.
|
||||
4. Audit and reduce `naming-convention` package overrides.
|
||||
5. Re-test the two nursery rules.
|
||||
6. Run a JS-plugin bridge spike for `n8n-nodes-base`.
|
||||
7. Keep a template-only ESLint pass for Vue until native template support exists.
|
||||
|
||||
## Validation commands
|
||||
|
||||
Create an ESLint snapshot and run the shared parity check for each converted package:
|
||||
|
||||
```sh
|
||||
node scripts/lint-parity/snapshot.mjs --out /tmp/eslint-snapshot.json --only packages/<package>
|
||||
node scripts/lint-parity/oxlint-parity.mjs --pkg packages/<package> --eslint /tmp/eslint-snapshot.json
|
||||
```
|
||||
|
||||
Inspect package rule downgrades and overrides:
|
||||
|
||||
```sh
|
||||
node scripts/lint-parity/majority.mjs
|
||||
```
|
||||
|
||||
Before changing the config, create the baseline snapshot. After the change, create the second snapshot and compare them:
|
||||
|
||||
```sh
|
||||
pnpm turbo run build --filter=@n8n/eslint-config
|
||||
node scripts/lint-parity/snapshot.mjs --out /tmp/lint-before.json
|
||||
# Change the config.
|
||||
node scripts/lint-parity/snapshot.mjs --out /tmp/lint-after.json
|
||||
node scripts/lint-parity/diff.mjs /tmp/lint-before.json /tmp/lint-after.json
|
||||
```
|
||||
|
||||
Run lint from each changed package. Use both linters when the package still has a compatibility pass.
|
||||
|
||||
## Source references
|
||||
|
||||
- Machine-readable gaps: `scripts/lint-parity/oxlint-gap.json`
|
||||
- Parity checker: `scripts/lint-parity/oxlint-parity.mjs`
|
||||
- ESLint base: `packages/@n8n/eslint-config/src/configs/base.ts`
|
||||
- Oxlint base: `packages/@n8n/oxlint-config/src/configs/base.ts`
|
||||
- ESLint frontend: `packages/@n8n/eslint-config/src/configs/frontend.ts`
|
||||
- Oxlint frontend: `packages/@n8n/oxlint-config/src/configs/frontend.ts`
|
||||
- ESLint nodes: `packages/@n8n/eslint-config/src/configs/nodes.ts`
|
||||
- Oxlint nodes: `packages/@n8n/oxlint-config/src/configs/nodes.ts`
|
||||
- Functional guardrails: `packages/@n8n/eslint-config/src/configs/functional-guardrails.ts`
|
||||
|
|
@ -120,13 +120,6 @@
|
|||
"after": "error",
|
||||
"reason": "package moved from base to the backend layer, which carries the network boundary"
|
||||
},
|
||||
{
|
||||
"rule": "n8n-local-rules/no-deployment-key-delete",
|
||||
"pkg": "packages/",
|
||||
"before": "off",
|
||||
"after": "error",
|
||||
"reason": "package moved from base to the backend layer, which carries the encryption boundary"
|
||||
},
|
||||
{
|
||||
"rule": "n8n-local-rules/no-encryption-guardrail-disable",
|
||||
"pkg": "packages/",
|
||||
|
|
@ -149,13 +142,6 @@
|
|||
"after": "off",
|
||||
"reason": "runtime-agnostic library moved to base: no Node runtime, no HTTP imports"
|
||||
},
|
||||
{
|
||||
"rule": "n8n-local-rules/no-deployment-key-delete",
|
||||
"pkg": "packages/@n8n/utils",
|
||||
"before": "error",
|
||||
"after": "off",
|
||||
"reason": "runtime-agnostic library moved to base"
|
||||
},
|
||||
{
|
||||
"rule": "n8n-local-rules/no-encryption-guardrail-disable",
|
||||
"pkg": "packages/@n8n/utils",
|
||||
|
|
@ -170,13 +156,6 @@
|
|||
"after": "off",
|
||||
"reason": "pure library moved to base: no Node APIs"
|
||||
},
|
||||
{
|
||||
"rule": "n8n-local-rules/no-deployment-key-delete",
|
||||
"pkg": "packages/@n8n/json-schema-to-zod",
|
||||
"before": "error",
|
||||
"after": "off",
|
||||
"reason": "pure library moved to base"
|
||||
},
|
||||
{
|
||||
"rule": "n8n-local-rules/no-encryption-guardrail-disable",
|
||||
"pkg": "packages/@n8n/json-schema-to-zod",
|
||||
|
|
@ -191,13 +170,6 @@
|
|||
"after": "off",
|
||||
"reason": "yjs-only library moved to base: no Node APIs"
|
||||
},
|
||||
{
|
||||
"rule": "n8n-local-rules/no-deployment-key-delete",
|
||||
"pkg": "packages/@n8n/crdt",
|
||||
"before": "error",
|
||||
"after": "off",
|
||||
"reason": "yjs-only library moved to base"
|
||||
},
|
||||
{
|
||||
"rule": "n8n-local-rules/no-encryption-guardrail-disable",
|
||||
"pkg": "packages/@n8n/crdt",
|
||||
|
|
|
|||
|
|
@ -50,20 +50,6 @@
|
|||
"replacement": "Wait for the upstream emitDecoratorMetadata option, or use an approved shim that suppresses decorated files.",
|
||||
"upstream": "https://github.com/oxc-project/oxc/issues/13609"
|
||||
},
|
||||
"n8n-local-rules/no-unsealed-workflow-entity-write": {
|
||||
"layer": "base",
|
||||
"native": null,
|
||||
"reason": "Needs type information. oxlint gives jsPlugin rules a frozen empty `parserServices`, so `ESLintUtils.getParserServices` throws and the rule reports `Error running JS plugin` on any file that calls a checked method. It is in `localRulesPlugin.configs.recommended`, so this is a base-layer gap, not a backend-only one.",
|
||||
"impact": "The WorkflowEntity node-write seal is unenforced in migrated packages. None of the three spike packages depends on @n8n/db, but this blocks any package that does.",
|
||||
"replacement": "Move this type-aware functional guardrail to @n8n/code-health in the follow-up PR."
|
||||
},
|
||||
"n8n-local-rules/no-deployment-key-delete": {
|
||||
"layer": "backend",
|
||||
"native": null,
|
||||
"reason": "Needs type information. Oxlint gives jsPlugin rules an empty parserServices object, so the rule cannot identify DeploymentKey repository receivers.",
|
||||
"impact": "Deployment-key deletion is not guarded by an Oxlint-only package.",
|
||||
"replacement": "Move this type-aware functional guardrail to @n8n/code-health in the follow-up PR."
|
||||
},
|
||||
"frontend/vue-template-rules": {
|
||||
"layer": "frontend",
|
||||
"native": null,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user