mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Send a log streaming audit event when a policy blocks an action (#39581)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
53b84d2baa
commit
55fd9daa08
|
|
@ -262,10 +262,13 @@ describe('ActiveWorkflowManager', () => {
|
|||
// Registration fails here (no real node types); the check runs before it.
|
||||
await activeWorkflowManager.add('wf-1', 'activate').catch(() => {});
|
||||
|
||||
expect(policyEnforcementService.enforceWorkflowPublish).toHaveBeenCalledExactlyOnceWith({
|
||||
workflow: { id: 'wf-1', name: 'My workflow', nodes: VERSION_NODES },
|
||||
projectId: 'project-1',
|
||||
});
|
||||
expect(policyEnforcementService.enforceWorkflowPublish).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
workflow: { id: 'wf-1', name: 'My workflow', nodes: VERSION_NODES },
|
||||
projectId: 'project-1',
|
||||
},
|
||||
{ kind: 'system', reason: 'activation' },
|
||||
);
|
||||
});
|
||||
|
||||
test('registers nothing and records an activation error when policy blocks', async () => {
|
||||
|
|
|
|||
|
|
@ -3049,7 +3049,11 @@ describe('CredentialsHelper', () => {
|
|||
source: null,
|
||||
} as IExecuteData;
|
||||
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ projectId: 'proj-1' });
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
projectId: 'proj-1',
|
||||
executionId: 'exec-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
await helper.getDecrypted(
|
||||
additionalData,
|
||||
|
|
@ -3060,16 +3064,69 @@ describe('CredentialsHelper', () => {
|
|||
true,
|
||||
);
|
||||
|
||||
expect(policyEnforcementService.enforceCredentialDecrypt).toHaveBeenCalledExactlyOnceWith({
|
||||
credentialType: 'testApi',
|
||||
credentialId: 'cred-policy',
|
||||
consumer: { nodeType: 'n8n-nodes-base.slack' },
|
||||
projectId: 'proj-1',
|
||||
expect(policyEnforcementService.enforceCredentialDecrypt).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
credentialType: 'testApi',
|
||||
credentialId: 'cred-policy',
|
||||
consumer: { nodeType: 'n8n-nodes-base.slack' },
|
||||
projectId: 'proj-1',
|
||||
},
|
||||
{ kind: 'system', reason: 'execution', executionId: 'exec-1' },
|
||||
);
|
||||
});
|
||||
|
||||
test('names the user when the decrypt is outside a run, e.g. an OAuth flow', async () => {
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
projectId: undefined,
|
||||
executionId: undefined,
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
await helper.getDecrypted(
|
||||
additionalData,
|
||||
nodeCredentials,
|
||||
'testApi',
|
||||
'internal',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(policyEnforcementService.enforceCredentialDecrypt).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.anything(),
|
||||
{ kind: 'user', user: { id: 'user-1' } },
|
||||
);
|
||||
});
|
||||
|
||||
test('uses the actor the caller names over the derived one', async () => {
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
projectId: undefined,
|
||||
executionId: undefined,
|
||||
userId: undefined,
|
||||
});
|
||||
|
||||
await helper.getDecrypted(
|
||||
additionalData,
|
||||
nodeCredentials,
|
||||
'testApi',
|
||||
'internal',
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
{ actor: { kind: 'system', reason: 'log-streaming' } },
|
||||
);
|
||||
|
||||
expect(policyEnforcementService.enforceCredentialDecrypt).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.anything(),
|
||||
{ kind: 'system', reason: 'log-streaming' },
|
||||
);
|
||||
});
|
||||
|
||||
test('passes a null consumer when no node is asking, e.g. a credential test', async () => {
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ projectId: undefined });
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
projectId: undefined,
|
||||
executionId: undefined,
|
||||
userId: undefined,
|
||||
});
|
||||
|
||||
await helper.getDecrypted(
|
||||
additionalData,
|
||||
|
|
@ -3082,6 +3139,7 @@ describe('CredentialsHelper', () => {
|
|||
|
||||
expect(policyEnforcementService.enforceCredentialDecrypt).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ consumer: null, projectId: null }),
|
||||
{ kind: 'system', reason: 'execution' },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'
|
|||
import { ExternalHooks } from '@/external-hooks';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import { enforceWorkflowPublishPolicy } from '@/policy/enforce-workflow-publish';
|
||||
import type { PolicyActor } from '@/policy/policy-enforcement-backend';
|
||||
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||
import { isPolicyRefusal } from '@/policy/policy-violation.error';
|
||||
import { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
|
|
@ -540,7 +541,10 @@ export class ActiveWorkflowManager {
|
|||
workflowId: WorkflowId,
|
||||
activationMode: WorkflowActivateMode,
|
||||
existingWorkflow?: WorkflowEntity,
|
||||
{ shouldPublish } = { shouldPublish: true },
|
||||
{
|
||||
shouldPublish = true,
|
||||
actor = { kind: 'system', reason: 'activation' },
|
||||
}: { shouldPublish?: boolean; actor?: PolicyActor } = {},
|
||||
) {
|
||||
const added = { webhooks: false, triggersAndPollers: false };
|
||||
|
||||
|
|
@ -601,11 +605,12 @@ export class ActiveWorkflowManager {
|
|||
|
||||
// Trigger and poller nodes run code at registration, so this gates startup
|
||||
// and leadership change too, not just the activate button.
|
||||
await enforceWorkflowPublishPolicy(this.policyEnforcementService, this.ownershipService, {
|
||||
id: dbWorkflow.id,
|
||||
name: dbWorkflow.name,
|
||||
nodes,
|
||||
});
|
||||
await enforceWorkflowPublishPolicy(
|
||||
this.policyEnforcementService,
|
||||
this.ownershipService,
|
||||
{ id: dbWorkflow.id, name: dbWorkflow.name, nodes },
|
||||
actor,
|
||||
);
|
||||
|
||||
workflow = new Workflow({
|
||||
id: dbWorkflow.id,
|
||||
|
|
|
|||
|
|
@ -273,11 +273,14 @@ export class ImportCredentialsCommand extends BaseCommand<z.infer<typeof flagsSc
|
|||
|
||||
let cleared: PolicyCleared<'contentImport'>;
|
||||
try {
|
||||
cleared = await Container.get(PolicyEnforcementService).enforceContentImport({
|
||||
credential: { id: credential.id ?? null, type },
|
||||
projectId: credential.usageScope === 'instance' ? null : landingProjectId,
|
||||
transport: 'cli',
|
||||
});
|
||||
cleared = await Container.get(PolicyEnforcementService).enforceContentImport(
|
||||
{
|
||||
credential: { id: credential.id ?? null, type },
|
||||
projectId: credential.usageScope === 'instance' ? null : landingProjectId,
|
||||
transport: 'cli',
|
||||
},
|
||||
{ kind: 'system', reason: 'cli-import' },
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof PolicyViolationError)) throw error;
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { CredentialTypes } from '@/credential-types';
|
|||
import { CredentialsOverwrites } from '@/credentials-overwrites';
|
||||
import { DCR_MANAGED_CREDENTIAL_FIELDS, OAUTH_PINNED_FIELDS } from '@/oauth/dcr-managed-fields';
|
||||
import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
|
||||
import type { PolicyActor } from '@/policy/policy-enforcement-backend';
|
||||
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||
import { AiGatewayService } from '@/services/ai-gateway.service';
|
||||
|
||||
|
|
@ -100,6 +101,13 @@ const { nodeTypes: mockNodeTypes } = createMockNodeTypes();
|
|||
|
||||
const INVALID_JSON_VALUE = Symbol('invalidJsonValue');
|
||||
|
||||
/** A run names no user: the starter is not known reliably on every path, so a run never guesses. */
|
||||
function decryptActor({ executionId, userId }: IWorkflowExecuteAdditionalData): PolicyActor {
|
||||
if (executionId) return { kind: 'system', reason: 'execution', executionId };
|
||||
if (userId) return { kind: 'user', user: { id: userId } };
|
||||
return { kind: 'system', reason: 'execution' };
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class CredentialsHelper extends ICredentialsHelper {
|
||||
constructor(
|
||||
|
|
@ -556,7 +564,7 @@ export class CredentialsHelper extends ICredentialsHelper {
|
|||
executeData?: IExecuteData,
|
||||
raw?: boolean,
|
||||
expressionResolveValues?: ICredentialsExpressionResolveValues,
|
||||
options?: IGetDecryptedCredentialsOptions,
|
||||
options?: IGetDecryptedCredentialsOptions & { actor?: PolicyActor },
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
// Sub-nodes, such as a chat model connected to a chain or agent, inherit executeData.node
|
||||
// from their parent. Prefer expressionResolveValues.node when present: it is always
|
||||
|
|
@ -578,12 +586,15 @@ export class CredentialsHelper extends ICredentialsHelper {
|
|||
const credentialsEntity = await this.getCredentialsEntity(nodeCredentials, type);
|
||||
|
||||
// Validate against the executing project's policy before any decryption happens.
|
||||
await this.policyEnforcementService.enforceCredentialDecrypt({
|
||||
credentialType: type,
|
||||
credentialId: credentialsEntity.id,
|
||||
consumer: consumerNode ? { nodeType: consumerNode.type } : null,
|
||||
projectId: additionalData.projectId ?? null,
|
||||
});
|
||||
await this.policyEnforcementService.enforceCredentialDecrypt(
|
||||
{
|
||||
credentialType: type,
|
||||
credentialId: credentialsEntity.id,
|
||||
consumer: consumerNode ? { nodeType: consumerNode.type } : null,
|
||||
projectId: additionalData.projectId ?? null,
|
||||
},
|
||||
options?.actor ?? decryptActor(additionalData),
|
||||
);
|
||||
|
||||
const credentials = new Credentials(
|
||||
{ id: credentialsEntity.id, name: credentialsEntity.name },
|
||||
|
|
|
|||
|
|
@ -387,6 +387,7 @@ describe('CredentialsController', () => {
|
|||
expect.objectContaining({
|
||||
isGlobal: true,
|
||||
}),
|
||||
{ kind: 'user', user: ownerReq.user },
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -570,6 +571,7 @@ describe('CredentialsController', () => {
|
|||
expect(updateSpy).toHaveBeenCalledWith(
|
||||
credentialId,
|
||||
expect.objectContaining({ description: prepared }),
|
||||
{ kind: 'user', user: req.user },
|
||||
expect.anything(),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -686,6 +688,7 @@ describe('CredentialsController', () => {
|
|||
expect.objectContaining({
|
||||
isGlobal: false,
|
||||
}),
|
||||
{ kind: 'user', user: ownerReq.user },
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -773,6 +776,7 @@ describe('CredentialsController', () => {
|
|||
expect(updateSpy).toHaveBeenCalledWith(
|
||||
credentialId,
|
||||
expect.any(Object),
|
||||
{ kind: 'user', user: ownerReq.user },
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -820,6 +824,7 @@ describe('CredentialsController', () => {
|
|||
expect.objectContaining({
|
||||
isResolvable: true,
|
||||
}),
|
||||
{ kind: 'user', user: ownerReq.user },
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -897,6 +902,7 @@ describe('CredentialsController', () => {
|
|||
expect.objectContaining({
|
||||
isResolvable: true, // Should keep the existing value
|
||||
}),
|
||||
{ kind: 'user', user: ownerReq.user },
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -1040,10 +1046,16 @@ describe('CredentialsController', () => {
|
|||
await credentialsController.updateCredentials(ownerReq);
|
||||
|
||||
expect(getChangedSharedFieldsSpy).toHaveBeenCalled();
|
||||
expect(updateSpy).toHaveBeenCalledWith(credentialId, expect.any(Object), expect.any(Object), {
|
||||
deleteUserEntries: true,
|
||||
user: ownerReq.user,
|
||||
});
|
||||
expect(updateSpy).toHaveBeenCalledWith(
|
||||
credentialId,
|
||||
expect.any(Object),
|
||||
{ kind: 'user', user: ownerReq.user },
|
||||
expect.any(Object),
|
||||
{
|
||||
deleteUserEntries: true,
|
||||
user: ownerReq.user,
|
||||
},
|
||||
);
|
||||
expect(emitSpy).toHaveBeenCalledWith('private-credential-connections-cleared', {
|
||||
user: ownerReq.user,
|
||||
credentialType: privateCredential.type,
|
||||
|
|
@ -1070,10 +1082,16 @@ describe('CredentialsController', () => {
|
|||
|
||||
await credentialsController.updateCredentials(ownerReq);
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledWith(credentialId, expect.any(Object), expect.any(Object), {
|
||||
deleteUserEntries: false,
|
||||
user: ownerReq.user,
|
||||
});
|
||||
expect(updateSpy).toHaveBeenCalledWith(
|
||||
credentialId,
|
||||
expect.any(Object),
|
||||
{ kind: 'user', user: ownerReq.user },
|
||||
expect.any(Object),
|
||||
{
|
||||
deleteUserEntries: false,
|
||||
user: ownerReq.user,
|
||||
},
|
||||
);
|
||||
const emittedEventNames = emitSpy.mock.calls.map((call) => call[0]);
|
||||
expect(emittedEventNames).not.toContain('private-credential-connections-cleared');
|
||||
});
|
||||
|
|
@ -1098,10 +1116,16 @@ describe('CredentialsController', () => {
|
|||
await credentialsController.updateCredentials(ownerReq);
|
||||
|
||||
expect(getChangedSharedFieldsSpy).not.toHaveBeenCalled();
|
||||
expect(updateSpy).toHaveBeenCalledWith(credentialId, expect.any(Object), expect.any(Object), {
|
||||
deleteUserEntries: true,
|
||||
user: ownerReq.user,
|
||||
});
|
||||
expect(updateSpy).toHaveBeenCalledWith(
|
||||
credentialId,
|
||||
expect.any(Object),
|
||||
{ kind: 'user', user: ownerReq.user },
|
||||
expect.any(Object),
|
||||
{
|
||||
deleteUserEntries: true,
|
||||
user: ownerReq.user,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should not emit toggle events when resolvable state is unchanged', async () => {
|
||||
|
|
@ -1175,7 +1199,7 @@ describe('CredentialsController', () => {
|
|||
req.user,
|
||||
['credential:update'],
|
||||
);
|
||||
expect(clearSpy).toHaveBeenCalledWith(credential);
|
||||
expect(clearSpy).toHaveBeenCalledWith(credential, { kind: 'user', user: req.user });
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import { CredentialsService } from '@/credentials/credentials.service';
|
|||
import type { InstanceCredentialUseRegistry } from '@/credentials/instance-credential-use.registry';
|
||||
import * as validation from '@/credentials/validation';
|
||||
import type { CredentialsHelper } from '@/credentials-helper';
|
||||
import type { PolicyActor } from '@/policy/policy-enforcement-backend';
|
||||
import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||
import { PolicyViolationError } from '@/policy/policy-violation.error';
|
||||
import { CredentialNotFoundError } from '@/errors/credential-not-found.error';
|
||||
|
|
@ -69,6 +70,7 @@ import type { RoleService } from '@/services/role.service';
|
|||
import { mockExistingCredential } from './credentials.test-data';
|
||||
|
||||
const ownerUser = mock<User>({ id: 'owner-id', role: GLOBAL_OWNER_ROLE });
|
||||
const ownerActor: PolicyActor = { kind: 'user', user: ownerUser };
|
||||
const memberUser = mock<User>({ id: 'member-id', role: GLOBAL_MEMBER_ROLE });
|
||||
/** A custom instance role that can see every credential but not use one. */
|
||||
const viewOnlyUser = mock<User>({
|
||||
|
|
@ -239,13 +241,18 @@ describe('CredentialsService', () => {
|
|||
.mockResolvedValue(mock<ICredentialsDb>());
|
||||
const updateSpy = vi.spyOn(service, 'update').mockResolvedValue(mock<CredentialsEntity>());
|
||||
|
||||
await service.clearOauthTokenData(credential);
|
||||
await service.clearOauthTokenData(credential, ownerActor);
|
||||
|
||||
const passedData = createEncryptedDataSpy.mock.calls[0][0].data;
|
||||
expect(passedData).not.toHaveProperty('oauthTokenData');
|
||||
expect(passedData).not.toHaveProperty('accountIdentifier');
|
||||
expect(passedData).toHaveProperty('clientId', 'abc');
|
||||
expect(updateSpy).toHaveBeenCalledWith(credential.id, expect.anything(), passedData);
|
||||
expect(updateSpy).toHaveBeenCalledWith(
|
||||
credential.id,
|
||||
expect.anything(),
|
||||
ownerActor,
|
||||
passedData,
|
||||
);
|
||||
});
|
||||
|
||||
it('aborts without persisting when the credential cannot be decrypted', async () => {
|
||||
|
|
@ -256,7 +263,9 @@ describe('CredentialsService', () => {
|
|||
const updateSpy = vi.spyOn(service, 'update');
|
||||
|
||||
// Must propagate the failure rather than overwrite the credential with empty data.
|
||||
await expect(service.clearOauthTokenData(credential)).rejects.toThrow(decryptionError);
|
||||
await expect(service.clearOauthTokenData(credential, ownerActor)).rejects.toThrow(
|
||||
decryptionError,
|
||||
);
|
||||
expect(updateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1540,7 +1549,7 @@ describe('CredentialsService', () => {
|
|||
return [];
|
||||
});
|
||||
|
||||
const update = service.update(credential.id, encrypted, undefined, {
|
||||
const update = service.update(credential.id, encrypted, ownerActor, undefined, {
|
||||
instanceCredential: credential,
|
||||
});
|
||||
await vi.waitFor(() => expect(dbLockService.withLock).toHaveBeenCalledOnce());
|
||||
|
|
@ -1569,7 +1578,7 @@ describe('CredentialsService', () => {
|
|||
data: 'encrypted',
|
||||
});
|
||||
|
||||
await expect(service.update(credential.id, encrypted)).resolves.toBe(credential);
|
||||
await expect(service.update(credential.id, encrypted, ownerActor)).resolves.toBe(credential);
|
||||
|
||||
expect(repositoryTransaction).toHaveBeenCalledExactlyOnceWith(
|
||||
{ policyCleared: cleared },
|
||||
|
|
@ -1594,20 +1603,23 @@ describe('CredentialsService', () => {
|
|||
setRepositoryTransaction(transactionManager);
|
||||
const encrypted = mock<ICredentialsDb>({ id: credential.id, type: 'slackApi' });
|
||||
|
||||
await service.update(credential.id, encrypted);
|
||||
await service.update(credential.id, encrypted, ownerActor);
|
||||
|
||||
expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith({
|
||||
credential: { id: credential.id, type: 'slackApi' },
|
||||
storedCredential: { id: credential.id, type: 'githubApi' },
|
||||
projectId: 'project-1',
|
||||
});
|
||||
expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
credential: { id: credential.id, type: 'slackApi' },
|
||||
storedCredential: { id: credential.id, type: 'githubApi' },
|
||||
projectId: 'project-1',
|
||||
},
|
||||
ownerActor,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null without checking policy when the credential does not exist', async () => {
|
||||
credentialsRepository.findOneBy.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('missing', mock<ICredentialsDb>({ id: 'missing' })),
|
||||
service.update('missing', mock<ICredentialsDb>({ id: 'missing' }), ownerActor),
|
||||
).resolves.toBeNull();
|
||||
|
||||
expect(policyEnforcementService.enforceCredentialSave).not.toHaveBeenCalled();
|
||||
|
|
@ -1623,7 +1635,11 @@ describe('CredentialsService', () => {
|
|||
);
|
||||
|
||||
await expect(
|
||||
service.update('project-credential', mock<ICredentialsDb>({ id: 'project-credential' })),
|
||||
service.update(
|
||||
'project-credential',
|
||||
mock<ICredentialsDb>({ id: 'project-credential' }),
|
||||
ownerActor,
|
||||
),
|
||||
).rejects.toThrow(PolicyViolationError);
|
||||
|
||||
expect(credentialsRepository.runInTransaction).not.toHaveBeenCalled();
|
||||
|
|
@ -1644,7 +1660,7 @@ describe('CredentialsService', () => {
|
|||
new Map([[credential.id, { accountIdentifier: 'me@gmail.com' }]]),
|
||||
);
|
||||
|
||||
const result = await service.update(credential.id, encrypted, undefined, {
|
||||
const result = await service.update(credential.id, encrypted, ownerActor, undefined, {
|
||||
user: ownerUser,
|
||||
});
|
||||
|
||||
|
|
@ -1668,7 +1684,7 @@ describe('CredentialsService', () => {
|
|||
setRepositoryTransaction(transactionManager);
|
||||
const encrypted = mock<ICredentialsDb>({ id: credential.id });
|
||||
|
||||
await service.update(credential.id, encrypted);
|
||||
await service.update(credential.id, encrypted, ownerActor);
|
||||
|
||||
expect(connectionStatusProxy.findMyConnections).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -1725,11 +1741,14 @@ describe('CredentialsService', () => {
|
|||
|
||||
await service.updateInstanceCredential(ownerUser, 'instance-credential', payload, ctx);
|
||||
|
||||
expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith({
|
||||
credential: { id: 'instance-credential', type: 'openAiApi' },
|
||||
storedCredential: { id: 'instance-credential', type: 'openAiApi' },
|
||||
projectId: null,
|
||||
});
|
||||
expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
credential: { id: 'instance-credential', type: 'openAiApi' },
|
||||
storedCredential: { id: 'instance-credential', type: 'openAiApi' },
|
||||
projectId: null,
|
||||
},
|
||||
{ kind: 'user', user: ownerUser },
|
||||
);
|
||||
});
|
||||
|
||||
it('writes nothing when policy refuses the update', async () => {
|
||||
|
|
@ -1873,11 +1892,14 @@ describe('CredentialsService', () => {
|
|||
it('gates the create on a policy clearance for its type, with no project', async () => {
|
||||
await service.createInstanceCredential(dto, ownerUser, {});
|
||||
|
||||
expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith({
|
||||
credential: { id: null, type: 'apiKey' },
|
||||
storedCredential: null,
|
||||
projectId: null,
|
||||
});
|
||||
expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
credential: { id: null, type: 'apiKey' },
|
||||
storedCredential: null,
|
||||
projectId: null,
|
||||
},
|
||||
{ kind: 'user', user: ownerUser },
|
||||
);
|
||||
expect(credentialsRepository.saveInstanceCredential).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'apiKey', usageScope: 'instance' }),
|
||||
{ policyCleared: cleared },
|
||||
|
|
@ -3479,11 +3501,14 @@ describe('CredentialsService', () => {
|
|||
ownerUser,
|
||||
);
|
||||
|
||||
expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith({
|
||||
credential: { id: null, type: credentialData.type },
|
||||
storedCredential: null,
|
||||
projectId: 'project-1',
|
||||
});
|
||||
expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
credential: { id: null, type: credentialData.type },
|
||||
storedCredential: null,
|
||||
projectId: 'project-1',
|
||||
},
|
||||
{ kind: 'user', user: ownerUser },
|
||||
);
|
||||
expect(credentialsRepository.runInTransaction).toHaveBeenCalledWith(
|
||||
{ policyCleared: cleared },
|
||||
expect.any(Function),
|
||||
|
|
@ -3773,11 +3798,14 @@ describe('CredentialsService', () => {
|
|||
[],
|
||||
{ policyCleared: cleared },
|
||||
);
|
||||
expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith({
|
||||
credential: { id: null, type: 'githubApi' },
|
||||
storedCredential: null,
|
||||
projectId: 'project-1',
|
||||
});
|
||||
expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
credential: { id: null, type: 'githubApi' },
|
||||
storedCredential: null,
|
||||
projectId: 'project-1',
|
||||
},
|
||||
{ kind: 'user', user: ownerUser },
|
||||
);
|
||||
expect(projectService.getProjectWithScope).toHaveBeenCalledWith(ownerUser, 'project-1', [
|
||||
'credential:create',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -378,6 +378,7 @@ export class CredentialsController {
|
|||
const responseData = await this.credentialsService.update(
|
||||
credentialId,
|
||||
newCredentialData,
|
||||
{ kind: 'user', user: req.user },
|
||||
body.data
|
||||
? (preparedCredentialData.data as unknown as ICredentialDataDecryptedObject)
|
||||
: undefined,
|
||||
|
|
@ -468,7 +469,7 @@ export class CredentialsController {
|
|||
throw new BadRequestError('Only OAuth credentials can be disconnected');
|
||||
}
|
||||
|
||||
await this.credentialsService.clearOauthTokenData(credential);
|
||||
await this.credentialsService.clearOauthTokenData(credential, { kind: 'user', user: req.user });
|
||||
|
||||
this.logger.debug('Credential OAuth token cleared', { credentialId });
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ import { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee
|
|||
import { DCR_MANAGED_CREDENTIAL_FIELDS } from '@/oauth/dcr-managed-fields';
|
||||
import { validateOAuthUrl } from '@/oauth/validate-oauth-url';
|
||||
import { userHasScopes } from '@/permissions.ee/check-access';
|
||||
import type { PolicyActor } from '@/policy/policy-enforcement-backend';
|
||||
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||
import type { CredentialRequest, ListQuery } from '@/requests';
|
||||
import { CredentialsTester } from '@/services/credentials-tester.service';
|
||||
|
|
@ -949,7 +950,7 @@ export class CredentialsService {
|
|||
* deliberately carries `oauthTokenData` forward, so a "Disconnect" action on a
|
||||
* fixed OAuth credential needs this dedicated path.
|
||||
*/
|
||||
async clearOauthTokenData(credential: CredentialsEntity): Promise<void> {
|
||||
async clearOauthTokenData(credential: CredentialsEntity, actor: PolicyActor): Promise<void> {
|
||||
// Decrypt via the core credential so a decryption failure aborts the
|
||||
// disconnect. `this.decrypt` swallows CredentialDataError and returns `{}`,
|
||||
// which would otherwise overwrite the whole credential with empty data.
|
||||
|
|
@ -964,7 +965,7 @@ export class CredentialsService {
|
|||
data: decryptedData,
|
||||
});
|
||||
|
||||
await this.update(credential.id, newCredentialData, decryptedData);
|
||||
await this.update(credential.id, newCredentialData, actor, decryptedData);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -996,6 +997,7 @@ export class CredentialsService {
|
|||
async update(
|
||||
credentialId: string,
|
||||
newCredentialData: ICredentialsDb,
|
||||
actor: PolicyActor,
|
||||
decryptedCredentialData?: ICredentialDataDecryptedObject,
|
||||
options?: UpdateOptions,
|
||||
) {
|
||||
|
|
@ -1003,7 +1005,7 @@ export class CredentialsService {
|
|||
await this.externalHooks.run('credentials.update', [newCredentialData]);
|
||||
await this.credentialDescriptions.stripIfDisabled(newCredentialData);
|
||||
|
||||
const cleared = await this.enforceCredentialUpdate(credentialId, newCredentialData.type);
|
||||
const cleared = await this.enforceCredentialUpdate(credentialId, newCredentialData.type, actor);
|
||||
if (cleared === null) return null;
|
||||
|
||||
const persist = async (transactionManager: EntityManager, ctx: OperationContext) => {
|
||||
|
|
@ -1134,11 +1136,14 @@ export class CredentialsService {
|
|||
);
|
||||
await this.validateInstanceCredentialUpdate(credential, hookedData, undefined, ctx);
|
||||
// The type cannot change here, so the check sees the same stored and new type.
|
||||
const cleared = await this.policyEnforcementService.enforceCredentialSave({
|
||||
credential: { id: credential.id, type: credential.type },
|
||||
storedCredential: { id: credential.id, type: credential.type },
|
||||
projectId: null,
|
||||
});
|
||||
const cleared = await this.policyEnforcementService.enforceCredentialSave(
|
||||
{
|
||||
credential: { id: credential.id, type: credential.type },
|
||||
storedCredential: { id: credential.id, type: credential.type },
|
||||
projectId: null,
|
||||
},
|
||||
{ kind: 'user', user },
|
||||
);
|
||||
const updated = await this.credentialsRepository.updateInstanceCredential(
|
||||
credential.id,
|
||||
encrypted,
|
||||
|
|
@ -1198,7 +1203,7 @@ export class CredentialsService {
|
|||
|
||||
// Gate the save on policy before persisting, so the author learns about a blocked type
|
||||
// while setting the credential up rather than at run time. No stored credential: this one is new.
|
||||
const cleared = await this.enforceCredentialCreate(newCredential.type, project.id);
|
||||
const cleared = await this.enforceCredentialCreate(newCredential.type, project.id, user);
|
||||
|
||||
const result = await this.credentialsRepository.runInTransaction(
|
||||
{ policyCleared: cleared },
|
||||
|
|
@ -1245,26 +1250,29 @@ export class CredentialsService {
|
|||
private async enforceCredentialUpdate(
|
||||
credentialId: string,
|
||||
newType: string | undefined,
|
||||
actor: PolicyActor,
|
||||
): Promise<PolicyCleared<'credentialSave'> | null> {
|
||||
const stored = await this.credentialsRepository.findOneBy({ id: credentialId });
|
||||
if (!stored) return null;
|
||||
const owningProject =
|
||||
await this.sharedCredentialsRepository.findCredentialOwningProject(credentialId);
|
||||
|
||||
return await this.policyEnforcementService.enforceCredentialSave({
|
||||
credential: { id: credentialId, type: newType ?? stored.type },
|
||||
storedCredential: { id: credentialId, type: stored.type },
|
||||
projectId: owningProject?.id ?? null,
|
||||
});
|
||||
return await this.policyEnforcementService.enforceCredentialSave(
|
||||
{
|
||||
credential: { id: credentialId, type: newType ?? stored.type },
|
||||
storedCredential: { id: credentialId, type: stored.type },
|
||||
projectId: owningProject?.id ?? null,
|
||||
},
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
/** A create binds to its type: the row has no committed id yet, whatever the payload claims. */
|
||||
private async enforceCredentialCreate(type: string, projectId: string | null) {
|
||||
return await this.policyEnforcementService.enforceCredentialSave({
|
||||
credential: { id: null, type },
|
||||
storedCredential: null,
|
||||
projectId,
|
||||
});
|
||||
private async enforceCredentialCreate(type: string, projectId: string | null, user: User) {
|
||||
return await this.policyEnforcementService.enforceCredentialSave(
|
||||
{ credential: { id: null, type }, storedCredential: null, projectId },
|
||||
{ kind: 'user', user },
|
||||
);
|
||||
}
|
||||
|
||||
private async insertProjectCredential(
|
||||
|
|
@ -1293,7 +1301,7 @@ export class CredentialsService {
|
|||
"You don't have the permissions to save the credential in this project.",
|
||||
);
|
||||
}
|
||||
const cleared = await this.enforceCredentialCreate(newCredential.type, project.id);
|
||||
const cleared = await this.enforceCredentialCreate(newCredential.type, project.id, user);
|
||||
const saved = await this.transactionRunner.run({ policyCleared: cleared }, async (ctx) => {
|
||||
return await this.credentialsRepository.insertProjectCredentialWithOwner(
|
||||
newCredential,
|
||||
|
|
@ -2239,7 +2247,7 @@ export class CredentialsService {
|
|||
});
|
||||
|
||||
// After the hooks ran: the type checked has to be the type written.
|
||||
const cleared = await this.enforceCredentialCreate(credentialEntity.type, null);
|
||||
const cleared = await this.enforceCredentialCreate(credentialEntity.type, null, user);
|
||||
const savedCredential = await this.credentialsRepository.saveInstanceCredential(
|
||||
credentialEntity,
|
||||
{ ...ctx, policyCleared: cleared },
|
||||
|
|
|
|||
|
|
@ -11,14 +11,16 @@ import type { AbstractEventPayload } from './abstract-event-payload';
|
|||
// --------------------------------------
|
||||
export interface EventPayloadAudit extends AbstractEventPayload {
|
||||
msg?: JsonValue;
|
||||
userId?: string;
|
||||
/** `null` when the event names no user. */
|
||||
userId?: string | null;
|
||||
userEmail?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
credentialName?: string;
|
||||
credentialType?: string;
|
||||
credentialId?: string;
|
||||
workflowId?: string;
|
||||
credentialId?: string | null;
|
||||
/** `null` when the event has no workflow row, such as a create that was blocked. */
|
||||
workflowId?: string | null;
|
||||
workflowName?: string;
|
||||
projectId?: string | null;
|
||||
projectName?: string;
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ export const eventNamesAudit = [
|
|||
'n8n.audit.node-type-policy.document.updated',
|
||||
'n8n.audit.node-type-policy.document.deleted',
|
||||
'n8n.audit.node-type-policy.attachments.updated',
|
||||
'n8n.audit.policy.decision.blocked',
|
||||
] as const;
|
||||
|
||||
// Instance MCP server events. Kept as their own list and message class because the payload
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { EventService } from '@n8n/backend-services';
|
||||
import { GLOBAL_OWNER_ROLE, type IWorkflowDb } from '@n8n/db';
|
||||
import { GLOBAL_OWNER_ROLE, type IWorkflowDb, type User, type UserRepository } from '@n8n/db';
|
||||
import type { InstanceSettings } from 'n8n-core';
|
||||
import type { INode, IRun, IWorkflowBase, IWorkflowExecutionDataProcess } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
|
@ -13,7 +13,8 @@ describe('LogStreamingEventRelay', () => {
|
|||
const eventService = new EventService();
|
||||
const hostId = 'host-xyz';
|
||||
const instanceSettings = mock<InstanceSettings>({ hostId });
|
||||
new LogStreamingEventRelay(eventService, eventBus, instanceSettings).init();
|
||||
const userRepository = mock<UserRepository>();
|
||||
new LogStreamingEventRelay(eventService, eventBus, instanceSettings, userRepository).init();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -3027,6 +3028,164 @@ describe('LogStreamingEventRelay', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('policy enforcement events', () => {
|
||||
const blocked: RelayEventMap['policy-decision-blocked'] = {
|
||||
point: 'workflowSave',
|
||||
outcome: 'violation',
|
||||
durationMs: 12,
|
||||
checkIds: ['node-types'],
|
||||
violations: [
|
||||
{
|
||||
checkId: 'node-types',
|
||||
kind: 'node-type-unavailable',
|
||||
subject: 'n8n-nodes-base.slack',
|
||||
subjectType: null,
|
||||
scope: null,
|
||||
matchedRuleId: null,
|
||||
},
|
||||
],
|
||||
policyVersions: [{ scope: 'instance', version: 4 }],
|
||||
workflowId: 'wf-1',
|
||||
workflowName: 'My workflow',
|
||||
projectId: 'proj-1',
|
||||
actorType: 'user',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
email: 'alice@example.com',
|
||||
firstName: 'Alice',
|
||||
lastName: 'Admin',
|
||||
role: { slug: 'global:admin' },
|
||||
},
|
||||
};
|
||||
|
||||
it('should log on `policy-decision-blocked` event, with the user redactable', () => {
|
||||
eventService.emit('policy-decision-blocked', { ...blocked });
|
||||
|
||||
expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({
|
||||
eventName: 'n8n.audit.policy.decision.blocked',
|
||||
payload: {
|
||||
userId: 'user-1',
|
||||
_email: 'alice@example.com',
|
||||
_firstName: 'Alice',
|
||||
_lastName: 'Admin',
|
||||
globalRole: 'global:admin',
|
||||
point: 'workflowSave',
|
||||
outcome: 'violation',
|
||||
durationMs: 12,
|
||||
checkIds: ['node-types'],
|
||||
violations: [
|
||||
{
|
||||
checkId: 'node-types',
|
||||
kind: 'node-type-unavailable',
|
||||
subject: 'n8n-nodes-base.slack',
|
||||
subjectType: null,
|
||||
scope: null,
|
||||
matchedRuleId: null,
|
||||
},
|
||||
],
|
||||
policyVersions: [{ scope: 'instance', version: 4 }],
|
||||
workflowId: 'wf-1',
|
||||
workflowName: 'My workflow',
|
||||
projectId: 'proj-1',
|
||||
actorType: 'user',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send a null user id and the reason when no user asked for the action', () => {
|
||||
eventService.emit('policy-decision-blocked', {
|
||||
...blocked,
|
||||
point: 'workflowStart',
|
||||
outcome: 'checkFailure',
|
||||
correlationIds: ['corr-1'],
|
||||
policyVersions: undefined,
|
||||
actorType: 'system',
|
||||
user: null,
|
||||
systemReason: 'execution',
|
||||
executionId: 'exec-1',
|
||||
});
|
||||
|
||||
expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({
|
||||
eventName: 'n8n.audit.policy.decision.blocked',
|
||||
payload: expect.objectContaining({
|
||||
userId: null,
|
||||
actorType: 'system',
|
||||
systemReason: 'execution',
|
||||
executionId: 'exec-1',
|
||||
point: 'workflowStart',
|
||||
outcome: 'checkFailure',
|
||||
correlationIds: ['corr-1'],
|
||||
policyVersions: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should not send an event when a destination credential was blocked', () => {
|
||||
eventService.emit('policy-decision-blocked', {
|
||||
...blocked,
|
||||
point: 'credentialDecrypt',
|
||||
actorType: 'system',
|
||||
user: null,
|
||||
systemReason: 'log-streaming',
|
||||
});
|
||||
|
||||
expect(eventBus.sendAuditEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should read the user fields when the host passed only the user id', async () => {
|
||||
userRepository.findByIdWithRole.mockResolvedValue(
|
||||
mock<User>({
|
||||
id: 'user-1',
|
||||
email: 'alice@example.com',
|
||||
firstName: 'Alice',
|
||||
lastName: 'Admin',
|
||||
role: { slug: 'global:admin' },
|
||||
}),
|
||||
);
|
||||
|
||||
eventService.emit('policy-decision-blocked', { ...blocked, user: { id: 'user-1' } });
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({
|
||||
eventName: 'n8n.audit.policy.decision.blocked',
|
||||
payload: expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
_email: 'alice@example.com',
|
||||
_firstName: 'Alice',
|
||||
_lastName: 'Admin',
|
||||
globalRole: 'global:admin',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(userRepository.findByIdWithRole).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no longer exists', () => userRepository.findByIdWithRole.mockResolvedValue(null)],
|
||||
[
|
||||
'cannot be read',
|
||||
() => userRepository.findByIdWithRole.mockRejectedValue(new Error('db down')),
|
||||
],
|
||||
])('should still send the event with the user id when the user %s', async (_, arrange) => {
|
||||
arrange();
|
||||
|
||||
eventService.emit('policy-decision-blocked', { ...blocked, user: { id: 'user-1' } });
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({
|
||||
eventName: 'n8n.audit.policy.decision.blocked',
|
||||
payload: expect.objectContaining({ userId: 'user-1', actorType: 'user' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not read the user when the host passed a full user', () => {
|
||||
eventService.emit('policy-decision-blocked', { ...blocked });
|
||||
|
||||
expect(userRepository.findByIdWithRole).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflow review events', () => {
|
||||
const reviewer = {
|
||||
id: 'user123',
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ import type {
|
|||
} from '@/modules/n8n-packages/n8n-packages.types';
|
||||
import type { TokenExchangeFailureReason } from '@/modules/token-exchange/token-exchange.types';
|
||||
import type { AdminCredentialSelection as InstanceAiCredentialSelection } from '@/modules/instance-ai/instance-ai-settings.service';
|
||||
import type {
|
||||
AuditedActor,
|
||||
PolicyDecisionAudit,
|
||||
} from '@/modules/policy-infrastructure/policy-decision-audit';
|
||||
import type {
|
||||
PolicyAction,
|
||||
PolicyAttachment,
|
||||
|
|
@ -32,6 +36,7 @@ import type {
|
|||
} from '@/modules/type-availability-policies/policy-rule.types';
|
||||
import type { TracingContext } from '@/modules/otel/tracing-context';
|
||||
import type { McpCallerAuth } from '@/services/oauth-token-verifier-proxy.service';
|
||||
import type { UserLike } from '@/types/user-like.types';
|
||||
|
||||
import type { AiEventMap } from './ai.event-map';
|
||||
|
||||
|
|
@ -43,15 +48,7 @@ export type WorkflowActionSource =
|
|||
| 'import'
|
||||
| 'review-approval';
|
||||
|
||||
export type UserLike = {
|
||||
id: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
role?: {
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
export type { UserLike };
|
||||
|
||||
/**
|
||||
* Which write path produced a policy document event. A composed save emits a document event
|
||||
|
|
@ -1402,4 +1399,10 @@ export type RelayEventMap = {
|
|||
};
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Policy enforcement
|
||||
|
||||
'policy-decision-blocked': PolicyDecisionAudit & AuditedActor;
|
||||
|
||||
// #endregion
|
||||
} & AiEventMap;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { EventService } from '@n8n/backend-services';
|
||||
import { UserRepository } from '@n8n/db';
|
||||
import { Redactable } from '@n8n/decorators';
|
||||
import { Service } from '@n8n/di';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
|
|
@ -68,6 +69,7 @@ export class LogStreamingEventRelay extends EventRelay {
|
|||
readonly eventService: EventService,
|
||||
private readonly eventBus: MessageEventBus,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly userRepository: UserRepository,
|
||||
) {
|
||||
super(eventService);
|
||||
}
|
||||
|
|
@ -124,6 +126,7 @@ export class LogStreamingEventRelay extends EventRelay {
|
|||
'node-type-policy-document-deleted': (event) => this.nodeTypePolicyDocumentDeleted(event),
|
||||
'node-type-policy-attachments-updated': (event) =>
|
||||
this.nodeTypePolicyAttachmentsUpdated(event),
|
||||
'policy-decision-blocked': (event) => this.policyDecisionBlocked(event),
|
||||
'external-secrets-provider-settings-saved': (event) =>
|
||||
this.externalSecretsProviderSettingsSaved(event),
|
||||
'external-secrets-provider-reloaded': (event) => this.externalSecretsProviderReloaded(event),
|
||||
|
|
@ -909,6 +912,50 @@ export class LogStreamingEventRelay extends EventRelay {
|
|||
|
||||
// #endregion
|
||||
|
||||
// #region Policy enforcement
|
||||
|
||||
/** Hosts that know only the user id pass `{ id }`, so read the rest to keep the fields uniform. */
|
||||
private policyDecisionBlocked(event: RelayEventMap['policy-decision-blocked']) {
|
||||
// The event would go to the destination whose credential was blocked, and block again.
|
||||
if (event.actorType === 'system' && event.systemReason === 'log-streaming') return;
|
||||
|
||||
if (event.actorType === 'system' || event.user.email !== undefined) {
|
||||
this.sendPolicyDecisionBlocked(event);
|
||||
return;
|
||||
}
|
||||
|
||||
void this.findAuditedUser(event.user).then((user) =>
|
||||
this.sendPolicyDecisionBlocked({ ...event, user }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Falls back to the id alone, so a failed lookup never drops the event. */
|
||||
private async findAuditedUser(user: UserLike): Promise<UserLike> {
|
||||
try {
|
||||
return (await this.userRepository.findByIdWithRole(user.id)) ?? user;
|
||||
} catch {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@Redactable()
|
||||
private sendPolicyDecisionBlocked({
|
||||
user,
|
||||
policyVersions,
|
||||
...rest
|
||||
}: RelayEventMap['policy-decision-blocked']) {
|
||||
void this.eventBus.sendAuditEvent({
|
||||
eventName: 'n8n.audit.policy.decision.blocked',
|
||||
payload: {
|
||||
...(user ?? { userId: null }),
|
||||
...rest,
|
||||
policyVersions: policyVersions?.map((version) => ({ ...version })) ?? [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region External Secrets Provider
|
||||
|
||||
private externalSecretsProviderSettingsSaved(
|
||||
|
|
|
|||
|
|
@ -677,6 +677,7 @@ describe('Slack setup services', () => {
|
|||
type: 'slackManagerOAuth2Api',
|
||||
data: 're-encrypted',
|
||||
},
|
||||
{ kind: 'user', user },
|
||||
rawData,
|
||||
);
|
||||
} else {
|
||||
|
|
@ -854,6 +855,7 @@ describe('Slack setup services', () => {
|
|||
expect(credentialsService.update).toHaveBeenCalledWith(
|
||||
'manager',
|
||||
{ data: 'encrypted' },
|
||||
{ kind: 'system', reason: 'integration' },
|
||||
expect.objectContaining({
|
||||
oauthTokenData: expect.objectContaining({
|
||||
authed_user: expect.objectContaining({
|
||||
|
|
|
|||
|
|
@ -251,7 +251,12 @@ export class SlackManagedSetupService {
|
|||
type: manager.credential.type,
|
||||
data: manager.rawData,
|
||||
});
|
||||
await this.credentialsService.update(options.credentialId, encrypted, manager.rawData);
|
||||
await this.credentialsService.update(
|
||||
options.credentialId,
|
||||
encrypted,
|
||||
{ kind: 'user', user: options.user },
|
||||
manager.rawData,
|
||||
);
|
||||
}
|
||||
|
||||
async installApp(
|
||||
|
|
@ -980,7 +985,12 @@ export class SlackManagedSetupService {
|
|||
type: manager.credential.type,
|
||||
data: updatedData,
|
||||
});
|
||||
await this.credentialsService.update(manager.credential.id, encrypted, updatedData);
|
||||
await this.credentialsService.update(
|
||||
manager.credential.id,
|
||||
encrypted,
|
||||
{ kind: 'system', reason: 'integration' },
|
||||
updatedData,
|
||||
);
|
||||
manager.oauthTokenData = oauthTokenData;
|
||||
manager.accessToken = refreshedAccessToken;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ describe('ChatHubWorkflowService', () => {
|
|||
|
||||
expect(policyEnforcementService.enforceWorkflowSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ storedWorkflow: null, projectId: 'project-789' }),
|
||||
{ kind: 'user', user: { id: 'user-123' } },
|
||||
);
|
||||
expect(workflowRepository.createContent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
|
@ -174,6 +175,7 @@ describe('ChatHubWorkflowService', () => {
|
|||
|
||||
expect(policyEnforcementService.enforceWorkflowSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ projectId: 'project-789' }),
|
||||
{ kind: 'user', user: { id: 'user-123' } },
|
||||
);
|
||||
expect(workflowRepository.createContent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
|
@ -207,6 +209,7 @@ describe('ChatHubWorkflowService', () => {
|
|||
workflow: expect.objectContaining({ id: 'workflow-1' }),
|
||||
projectId: 'project-1',
|
||||
}),
|
||||
{ kind: 'user', user: expect.objectContaining({ id: 'user-1' }) },
|
||||
);
|
||||
expect(workflowRepository.createContent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { BadRequestError } from '@n8n/errors';
|
||||
import type { UserLike } from '@/types/user-like.types';
|
||||
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
|
||||
|
|
@ -161,7 +162,7 @@ export class ChatHubWorkflowService {
|
|||
executionOrder: 'v1',
|
||||
};
|
||||
|
||||
const cleared = await this.enforceChatWorkflowSave(newWorkflow, projectId);
|
||||
const cleared = await this.enforceChatWorkflowSave(newWorkflow, projectId, { id: userId });
|
||||
|
||||
return await this.workflowRepository.runInTransaction(
|
||||
{ ...ctx, policyCleared: cleared },
|
||||
|
|
@ -189,12 +190,19 @@ export class ChatHubWorkflowService {
|
|||
* Chat workflows are system-generated but policed like any other: the nodes are real, so a
|
||||
* blocked node type has to block the run rather than reach the engine.
|
||||
*/
|
||||
private async enforceChatWorkflowSave(workflow: WorkflowEntity, projectId: string) {
|
||||
return await this.policyEnforcementService.enforceWorkflowSave({
|
||||
workflow: { id: workflow.id ?? null, name: workflow.name, nodes: workflow.nodes },
|
||||
storedWorkflow: null,
|
||||
projectId,
|
||||
});
|
||||
private async enforceChatWorkflowSave(
|
||||
workflow: WorkflowEntity,
|
||||
projectId: string,
|
||||
user: UserLike,
|
||||
) {
|
||||
return await this.policyEnforcementService.enforceWorkflowSave(
|
||||
{
|
||||
workflow: { id: workflow.id ?? null, name: workflow.name, nodes: workflow.nodes },
|
||||
storedWorkflow: null,
|
||||
projectId,
|
||||
},
|
||||
{ kind: 'user', user },
|
||||
);
|
||||
}
|
||||
|
||||
async createTitleGenerationWorkflow(
|
||||
|
|
@ -241,7 +249,7 @@ export class ChatHubWorkflowService {
|
|||
saveDataSuccessExecution: 'all',
|
||||
};
|
||||
|
||||
const cleared = await this.enforceChatWorkflowSave(newWorkflow, projectId);
|
||||
const cleared = await this.enforceChatWorkflowSave(newWorkflow, projectId, { id: userId });
|
||||
|
||||
return await this.workflowRepository.runInTransaction(
|
||||
{ ...ctx, policyCleared: cleared },
|
||||
|
|
@ -1889,7 +1897,7 @@ You can update the most recent document using the commands described above, or c
|
|||
executionOrder: 'v1',
|
||||
};
|
||||
|
||||
const cleared = await this.enforceChatWorkflowSave(newWorkflow, projectId);
|
||||
const cleared = await this.enforceChatWorkflowSave(newWorkflow, projectId, user);
|
||||
|
||||
return await this.workflowRepository.runInTransaction(
|
||||
{ ...ctx, policyCleared: cleared },
|
||||
|
|
|
|||
|
|
@ -3778,11 +3778,14 @@ describe('createWorkflowAdapter', () => {
|
|||
|
||||
await adapter.createFromWorkflowJSON(minimalWorkflowJSON);
|
||||
|
||||
expect(mockPolicyEnforcementService.enforceWorkflowSave).toHaveBeenCalledWith({
|
||||
workflow: { id: null, name: minimalWorkflowJSON.name, nodes: [] },
|
||||
storedWorkflow: null,
|
||||
projectId: 'team-project-id',
|
||||
});
|
||||
expect(mockPolicyEnforcementService.enforceWorkflowSave).toHaveBeenCalledWith(
|
||||
{
|
||||
workflow: { id: null, name: minimalWorkflowJSON.name, nodes: [] },
|
||||
storedWorkflow: null,
|
||||
projectId: 'team-project-id',
|
||||
},
|
||||
{ kind: 'user', user: expect.objectContaining({ id: 'user-1' }) },
|
||||
);
|
||||
expect(mockWorkflowRepository.runInTransaction).toHaveBeenCalledWith(
|
||||
{ policyCleared: cleared },
|
||||
expect.any(Function),
|
||||
|
|
|
|||
|
|
@ -1123,6 +1123,7 @@ describe('InstanceAiController', () => {
|
|||
expect(evalThreadRestore.restoreWorkflows).toHaveBeenCalledWith(
|
||||
[seedWorkflow],
|
||||
'project-1',
|
||||
expect.objectContaining({ id: USER_ID }),
|
||||
expect.any(Map),
|
||||
undefined,
|
||||
expect.any(Map),
|
||||
|
|
@ -1166,6 +1167,7 @@ describe('InstanceAiController', () => {
|
|||
expect(evalThreadRestore.restoreWorkflows).toHaveBeenCalledWith(
|
||||
[seedWorkflow],
|
||||
'project-1',
|
||||
expect.objectContaining({ id: USER_ID }),
|
||||
idMap,
|
||||
undefined,
|
||||
expect.any(Map),
|
||||
|
|
@ -1185,6 +1187,7 @@ describe('InstanceAiController', () => {
|
|||
expect(evalThreadRestore.restoreWorkflows).toHaveBeenCalledWith(
|
||||
[seedWorkflow],
|
||||
'project-1',
|
||||
expect.objectContaining({ id: USER_ID }),
|
||||
expect.any(Map),
|
||||
new Set(['cred-1', 'cred-2']),
|
||||
expect.any(Map),
|
||||
|
|
@ -1295,6 +1298,7 @@ describe('InstanceAiController', () => {
|
|||
expect(evalThreadRestore.restoreWorkflows).toHaveBeenCalledWith(
|
||||
[placedWorkflow],
|
||||
'project-1',
|
||||
expect.objectContaining({ id: USER_ID }),
|
||||
expect.any(Map),
|
||||
undefined,
|
||||
folderIdMap,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { LicenseState } from '@n8n/backend-common';
|
||||
import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils';
|
||||
import type { Project } from '@n8n/db';
|
||||
import type { Project, User } from '@n8n/db';
|
||||
import {
|
||||
CredentialsRepository,
|
||||
SharedWorkflowRepository,
|
||||
|
|
@ -250,7 +250,11 @@ describe('EvalThreadRestoreService.restoreWorkflows (policy seal)', () => {
|
|||
});
|
||||
|
||||
it('persists the seed workflow at its id and makes the project its owner', async () => {
|
||||
const created = await service.restoreWorkflows([seed('wf-seeded-1', 'Allowed')], project.id);
|
||||
const created = await service.restoreWorkflows(
|
||||
[seed('wf-seeded-1', 'Allowed')],
|
||||
project.id,
|
||||
mock<User>(),
|
||||
);
|
||||
|
||||
expect(created).toEqual(['wf-seeded-1']);
|
||||
const stored = await workflowRepository.findById('wf-seeded-1');
|
||||
|
|
@ -264,6 +268,7 @@ describe('EvalThreadRestoreService.restoreWorkflows (policy seal)', () => {
|
|||
const restore = service.restoreWorkflows(
|
||||
[seed('wf-seeded-ok', 'Allowed'), seed('wf-seeded-blocked', 'Blocked')],
|
||||
project.id,
|
||||
mock<User>(),
|
||||
);
|
||||
|
||||
await expect(restore).rejects.toThrow(PolicyViolationError);
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ describe('EvalThreadRestoreService', () => {
|
|||
await service.restoreWorkflows(
|
||||
[{ id: 'wf-original', name: 'Daily digest', nodes: [makeNode()], connections: {} }],
|
||||
'project-1',
|
||||
evalUser,
|
||||
);
|
||||
|
||||
expect(workflowRepo.createContent).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -104,14 +105,18 @@ describe('EvalThreadRestoreService', () => {
|
|||
await service.restoreWorkflows(
|
||||
[{ id: 'wf-1', name: 'Daily digest', nodes: [makeNode()], connections: {} }],
|
||||
'project-1',
|
||||
evalUser,
|
||||
);
|
||||
|
||||
const saved = workflowRepo.create.mock.calls[0][0];
|
||||
expect(policyEnforcementService.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith({
|
||||
workflow: { id: null, name: 'Daily digest', nodes: saved.nodes },
|
||||
storedWorkflow: null,
|
||||
projectId: 'project-1',
|
||||
});
|
||||
expect(policyEnforcementService.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
workflow: { id: null, name: 'Daily digest', nodes: saved.nodes },
|
||||
storedWorkflow: null,
|
||||
projectId: 'project-1',
|
||||
},
|
||||
{ kind: 'user', user: evalUser },
|
||||
);
|
||||
expect(workflowRepo.runInTransaction).toHaveBeenCalledExactlyOnceWith(
|
||||
{ policyCleared: cleared },
|
||||
expect.any(Function),
|
||||
|
|
@ -131,6 +136,7 @@ describe('EvalThreadRestoreService', () => {
|
|||
await service.restoreWorkflows(
|
||||
[{ id: 'wf-1', name: 'wf', nodes: [node], connections: {} }],
|
||||
'project-1',
|
||||
evalUser,
|
||||
);
|
||||
|
||||
expect(credentialsRepo.findByNameAndTypeInProject).toHaveBeenCalledExactlyOnceWith(
|
||||
|
|
@ -160,6 +166,7 @@ describe('EvalThreadRestoreService', () => {
|
|||
await service.restoreWorkflows(
|
||||
[{ id: 'wf-1', name: 'wf', nodes: [node], connections: {} }],
|
||||
'project-1',
|
||||
evalUser,
|
||||
);
|
||||
|
||||
const saved = workflowRepo.create.mock.calls[0][0];
|
||||
|
|
@ -178,6 +185,7 @@ describe('EvalThreadRestoreService', () => {
|
|||
await service.restoreWorkflows(
|
||||
[{ id: 'wf-1', name: 'wf', nodes: [node], connections: {} }],
|
||||
'project-1',
|
||||
evalUser,
|
||||
new Map(),
|
||||
new Set(['cred-this-thread']),
|
||||
);
|
||||
|
|
@ -198,6 +206,7 @@ describe('EvalThreadRestoreService', () => {
|
|||
await service.restoreWorkflows(
|
||||
[{ id: 'wf-1', name: 'wf', nodes: [node], connections: {} }],
|
||||
'project-1',
|
||||
evalUser,
|
||||
);
|
||||
|
||||
expect(workflowRepo.create.mock.calls[0][0].nodes?.[0]).not.toHaveProperty('credentials');
|
||||
|
|
@ -210,6 +219,7 @@ describe('EvalThreadRestoreService', () => {
|
|||
service.restoreWorkflows(
|
||||
[{ id: 'wf-live', name: 'wf', nodes: [node], connections: {}, published: true }],
|
||||
'project-1',
|
||||
evalUser,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Seed workflow wf-live is published, but its slackApi credential "Slak" matched 0 project credentials (need exactly 1)',
|
||||
|
|
@ -228,6 +238,7 @@ describe('EvalThreadRestoreService', () => {
|
|||
service.restoreWorkflows(
|
||||
[{ id: 'wf-live', name: 'wf', nodes: [node], connections: {}, published: true }],
|
||||
'project-1',
|
||||
evalUser,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Seed workflow wf-live is published, but its slackApi credential "Slack" matched 2 project credentials (need exactly 1)',
|
||||
|
|
@ -243,6 +254,7 @@ describe('EvalThreadRestoreService', () => {
|
|||
service.restoreWorkflows(
|
||||
[{ id: 'wf-live', name: 'wf', nodes: [node], connections: {}, published: true }],
|
||||
'project-1',
|
||||
evalUser,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Seed workflow wf-live is published, but its slackApi credential reference has no name',
|
||||
|
|
@ -339,16 +351,20 @@ describe('EvalThreadRestoreService', () => {
|
|||
const created = await service.restoreWorkflows(
|
||||
[{ id: 'wf-1', name: 'wf', nodes: [makeNode()], connections: {} }],
|
||||
'project-1',
|
||||
evalUser,
|
||||
);
|
||||
|
||||
expect(workflowRepo.findByIds).toHaveBeenCalledWith(['wf-1'], {
|
||||
fields: ['id', 'name', 'nodes'],
|
||||
});
|
||||
expect(policyEnforcementService.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith({
|
||||
workflow: { id: 'wf-1', name: 'wf', nodes: expect.any(Array) },
|
||||
storedWorkflow: { id: 'wf-1', name: 'Old name', nodes: storedNodes },
|
||||
projectId: 'project-1',
|
||||
});
|
||||
expect(policyEnforcementService.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
workflow: { id: 'wf-1', name: 'wf', nodes: expect.any(Array) },
|
||||
storedWorkflow: { id: 'wf-1', name: 'Old name', nodes: storedNodes },
|
||||
projectId: 'project-1',
|
||||
},
|
||||
{ kind: 'user', user: evalUser },
|
||||
);
|
||||
expect(workflowRepo.updateContent).toHaveBeenCalledExactlyOnceWith(
|
||||
'wf-1',
|
||||
expect.objectContaining({ name: 'wf', active: false, versionId: expect.any(String) }),
|
||||
|
|
@ -368,6 +384,7 @@ describe('EvalThreadRestoreService', () => {
|
|||
service.restoreWorkflows(
|
||||
[{ id: 'wf-1', name: 'wf', nodes: [makeNode()], connections: {} }],
|
||||
'project-1',
|
||||
evalUser,
|
||||
),
|
||||
).rejects.toThrow(BadRequestError);
|
||||
expect(policyEnforcementService.enforceWorkflowSave).not.toHaveBeenCalled();
|
||||
|
|
@ -379,6 +396,7 @@ describe('EvalThreadRestoreService', () => {
|
|||
service.restoreWorkflows(
|
||||
[{ id: 'wf-1', name: 'wf', nodes: [{ name: 'no type' }], connections: {} }],
|
||||
'project-1',
|
||||
evalUser,
|
||||
),
|
||||
).rejects.toThrow(BadRequestError);
|
||||
expect(policyEnforcementService.enforceWorkflowSave).not.toHaveBeenCalled();
|
||||
|
|
@ -405,6 +423,7 @@ describe('EvalThreadRestoreService', () => {
|
|||
{ id: 'wf-blocked', name: 'Blocked', nodes: [makeNode()], connections: {} },
|
||||
],
|
||||
'project-1',
|
||||
evalUser,
|
||||
);
|
||||
|
||||
await expect(restore).rejects.toThrow(PolicyViolationError);
|
||||
|
|
@ -427,6 +446,7 @@ describe('EvalThreadRestoreService', () => {
|
|||
service.restoreWorkflows(
|
||||
[{ id: 'wf-1', name: 'wf', nodes: [makeNode()], connections: {} }],
|
||||
'project-1',
|
||||
evalUser,
|
||||
),
|
||||
).rejects.toBe(failure);
|
||||
expect(workflowRepo.createContent).not.toHaveBeenCalled();
|
||||
|
|
@ -574,6 +594,7 @@ describe('EvalThreadRestoreService', () => {
|
|||
await service.restoreWorkflows(
|
||||
[{ id: 'wf-1', name: 'wf', nodes: [node], connections: {} }],
|
||||
'project-1',
|
||||
evalUser,
|
||||
new Map([['dt-old', 'dt-new']]),
|
||||
);
|
||||
|
||||
|
|
@ -847,6 +868,7 @@ describe('EvalThreadRestoreService', () => {
|
|||
{ id: 'wf-1', name: 'Rooted', nodes: [makeNode()], connections: {} },
|
||||
],
|
||||
'project-1',
|
||||
evalUser,
|
||||
new Map(),
|
||||
undefined,
|
||||
new Map([['odwFolder0001', 'real-odw']]),
|
||||
|
|
@ -981,6 +1003,7 @@ describe('EvalThreadRestoreService', () => {
|
|||
},
|
||||
],
|
||||
'project-1',
|
||||
evalUser,
|
||||
new Map(),
|
||||
undefined,
|
||||
new Map([['odwFolder0001', 'real-odw']]),
|
||||
|
|
@ -994,6 +1017,7 @@ describe('EvalThreadRestoreService', () => {
|
|||
await service.restoreWorkflows(
|
||||
[{ id: 'wf-1', name: 'Root', nodes: [makeNode()], connections: {} }],
|
||||
'project-1',
|
||||
evalUser,
|
||||
new Map(),
|
||||
undefined,
|
||||
new Map([['odwFolder0001', 'real-odw']]),
|
||||
|
|
@ -1016,6 +1040,7 @@ describe('EvalThreadRestoreService', () => {
|
|||
},
|
||||
],
|
||||
'project-1',
|
||||
evalUser,
|
||||
),
|
||||
).rejects.toThrow(BadRequestError);
|
||||
|
||||
|
|
|
|||
|
|
@ -356,6 +356,7 @@ export class EvalThreadRestoreService {
|
|||
async restoreWorkflows(
|
||||
workflows: InstanceAiEvalSeedWorkflow[],
|
||||
projectId: string,
|
||||
user: User,
|
||||
dataTableIdMap: Map<string, string> = new Map(),
|
||||
allowedCredentialIds?: Set<string>,
|
||||
folderIdMap: Map<string, string> = new Map(),
|
||||
|
|
@ -366,6 +367,7 @@ export class EvalThreadRestoreService {
|
|||
const isNew = await this.createWorkflowPinnedToId(
|
||||
workflow,
|
||||
projectId,
|
||||
user,
|
||||
dataTableIdMap,
|
||||
allowedCredentialIds,
|
||||
folderIdMap,
|
||||
|
|
@ -469,6 +471,7 @@ export class EvalThreadRestoreService {
|
|||
private async createWorkflowPinnedToId(
|
||||
workflow: InstanceAiEvalSeedWorkflow,
|
||||
projectId: string,
|
||||
user: User,
|
||||
dataTableIdMap: Map<string, string>,
|
||||
allowedCredentialIds: Set<string> | undefined,
|
||||
folderIdMap: Map<string, string>,
|
||||
|
|
@ -538,7 +541,7 @@ export class EvalThreadRestoreService {
|
|||
versionId: randomUUID(),
|
||||
parentFolder: parentFolderId === null ? null : { id: parentFolderId },
|
||||
});
|
||||
const cleared = await this.enforceSeedWorkflowSave(workflow, entity, stored, projectId);
|
||||
const cleared = await this.enforceSeedWorkflowSave(workflow, entity, stored, projectId, user);
|
||||
|
||||
await this.workflowRepo.runInTransaction({ policyCleared: cleared }, async (em, ctx) => {
|
||||
if (stored) {
|
||||
|
|
@ -614,13 +617,17 @@ export class EvalThreadRestoreService {
|
|||
entity: WorkflowEntity,
|
||||
stored: PolicedWorkflow | null,
|
||||
projectId: string,
|
||||
user: User,
|
||||
): Promise<PolicyCleared<'workflowSave'>> {
|
||||
try {
|
||||
return await this.policyEnforcementService.enforceWorkflowSave({
|
||||
workflow: { id: stored?.id ?? null, name: entity.name, nodes: entity.nodes },
|
||||
storedWorkflow: stored,
|
||||
projectId,
|
||||
});
|
||||
return await this.policyEnforcementService.enforceWorkflowSave(
|
||||
{
|
||||
workflow: { id: stored?.id ?? null, name: entity.name, nodes: entity.nodes },
|
||||
storedWorkflow: stored,
|
||||
projectId,
|
||||
},
|
||||
{ kind: 'user', user },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof PolicyViolationError && hasViolations(error.violations)) {
|
||||
throw new PolicyViolationError(
|
||||
|
|
|
|||
|
|
@ -1670,11 +1670,14 @@ export class InstanceAiAdapterService {
|
|||
|
||||
// The shell has no nodes, so the real content check is the `update()` below.
|
||||
// This call is still needed: `createContent` refuses to write without a clearance.
|
||||
const cleared = await policyEnforcementService.enforceWorkflowSave({
|
||||
workflow: { id: null, name: newWorkflow.name, nodes: newWorkflow.nodes },
|
||||
storedWorkflow: null,
|
||||
projectId,
|
||||
});
|
||||
const cleared = await policyEnforcementService.enforceWorkflowSave(
|
||||
{
|
||||
workflow: { id: null, name: newWorkflow.name, nodes: newWorkflow.nodes },
|
||||
storedWorkflow: null,
|
||||
projectId,
|
||||
},
|
||||
{ kind: 'user', user },
|
||||
);
|
||||
|
||||
const saved = await workflowRepository.runInTransaction(
|
||||
{ policyCleared: cleared },
|
||||
|
|
|
|||
|
|
@ -1300,6 +1300,7 @@ export class InstanceAiController {
|
|||
createdWorkflowIds = await this.evalThreadRestore.restoreWorkflows(
|
||||
workflows,
|
||||
projectId,
|
||||
req.user,
|
||||
idMap,
|
||||
allowedCredentialIds ? new Set(allowedCredentialIds) : undefined,
|
||||
folderIdMap,
|
||||
|
|
|
|||
|
|
@ -156,6 +156,8 @@ describe('MessageEventBusDestinationWebhook', () => {
|
|||
'internal',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
{ actor: { kind: 'system', reason: 'log-streaming' } },
|
||||
);
|
||||
expect(credentialsHelper.authenticate).toHaveBeenCalledWith(
|
||||
decrypted,
|
||||
|
|
|
|||
|
|
@ -179,6 +179,8 @@ export class MessageEventBusDestinationWebhook
|
|||
'internal',
|
||||
undefined,
|
||||
raw,
|
||||
undefined,
|
||||
{ actor: { kind: 'system', reason: 'log-streaming' } },
|
||||
);
|
||||
return credentialsDecrypted;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { mock } from 'vitest-mock-extended';
|
|||
|
||||
import type { PolicyCleared } from '@n8n/decorators';
|
||||
|
||||
import type { PolicyActor } from '@/policy/policy-enforcement-backend';
|
||||
import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||
import { PolicyViolationError } from '@/policy/policy-violation.error';
|
||||
|
||||
|
|
@ -58,6 +59,7 @@ describe('contentImportTransport', () => {
|
|||
describe('ContentImportPolicyGate', () => {
|
||||
const policyEnforcementService = mock<PolicyEnforcementService>();
|
||||
const gate = new ContentImportPolicyGate(policyEnforcementService, mock<Logger>());
|
||||
const actor: PolicyActor = { kind: 'user', user: { id: 'user-1' } };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -66,7 +68,9 @@ describe('ContentImportPolicyGate', () => {
|
|||
it('admits nothing when no check is registered', async () => {
|
||||
policyEnforcementService.hasChecksFor.mockReturnValue(false);
|
||||
|
||||
expect(await gate.refusedWorkflows([createItem('W1')], 'project-1', 'package')).toEqual([]);
|
||||
expect(await gate.refusedWorkflows([createItem('W1')], 'project-1', 'package', actor)).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(policyEnforcementService.hasChecksFor).toHaveBeenCalledTimes(1);
|
||||
expect(policyEnforcementService.enforceContentImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -76,7 +80,12 @@ describe('ContentImportPolicyGate', () => {
|
|||
policyEnforcementService.enforceContentImport.mockResolvedValue(cleared);
|
||||
|
||||
expect(
|
||||
await gate.refusedWorkflows([createItem('W1'), createItem('W2')], 'project-1', 'package'),
|
||||
await gate.refusedWorkflows(
|
||||
[createItem('W1'), createItem('W2')],
|
||||
'project-1',
|
||||
'package',
|
||||
actor,
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(policyEnforcementService.enforceContentImport).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
|
@ -85,7 +94,7 @@ describe('ContentImportPolicyGate', () => {
|
|||
policyEnforcementService.hasChecksFor.mockReturnValue(true);
|
||||
policyEnforcementService.enforceContentImport.mockResolvedValue(cleared);
|
||||
|
||||
await gate.refusedWorkflows([skipItem('W1'), createItem('W2')], 'project-1', 'package');
|
||||
await gate.refusedWorkflows([skipItem('W1'), createItem('W2')], 'project-1', 'package', actor);
|
||||
|
||||
expect(policyEnforcementService.enforceContentImport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
|
@ -98,18 +107,27 @@ describe('ContentImportPolicyGate', () => {
|
|||
[createItem('W1', 'new-id'), updateItem('W2', 'existing-id')],
|
||||
'project-1',
|
||||
'git-connection',
|
||||
actor,
|
||||
);
|
||||
|
||||
expect(policyEnforcementService.enforceContentImport).toHaveBeenNthCalledWith(1, {
|
||||
workflow: { id: 'new-id', name: 'W1', nodes: [] },
|
||||
projectId: 'project-1',
|
||||
transport: 'git-connection',
|
||||
});
|
||||
expect(policyEnforcementService.enforceContentImport).toHaveBeenNthCalledWith(2, {
|
||||
workflow: { id: 'existing-id', name: 'W2', nodes: [] },
|
||||
projectId: 'project-1',
|
||||
transport: 'git-connection',
|
||||
});
|
||||
expect(policyEnforcementService.enforceContentImport).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{
|
||||
workflow: { id: 'new-id', name: 'W1', nodes: [] },
|
||||
projectId: 'project-1',
|
||||
transport: 'git-connection',
|
||||
},
|
||||
actor,
|
||||
);
|
||||
expect(policyEnforcementService.enforceContentImport).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
workflow: { id: 'existing-id', name: 'W2', nodes: [] },
|
||||
projectId: 'project-1',
|
||||
transport: 'git-connection',
|
||||
},
|
||||
actor,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports every refusal, not just the first', async () => {
|
||||
|
|
@ -122,6 +140,7 @@ describe('ContentImportPolicyGate', () => {
|
|||
[createItem('W1'), createItem('W2')],
|
||||
'project-1',
|
||||
'package',
|
||||
actor,
|
||||
);
|
||||
|
||||
expect(refused).toEqual([
|
||||
|
|
@ -140,6 +159,7 @@ describe('ContentImportPolicyGate', () => {
|
|||
[createItem('W1'), createItem('W2')],
|
||||
'project-1',
|
||||
'package',
|
||||
actor,
|
||||
);
|
||||
|
||||
expect(refused).toHaveLength(1);
|
||||
|
|
@ -151,8 +171,8 @@ describe('ContentImportPolicyGate', () => {
|
|||
const checkFailure = new Error('check exploded');
|
||||
policyEnforcementService.enforceContentImport.mockRejectedValue(checkFailure);
|
||||
|
||||
await expect(gate.refusedWorkflows([createItem('W1')], 'project-1', 'package')).rejects.toBe(
|
||||
checkFailure,
|
||||
);
|
||||
await expect(
|
||||
gate.refusedWorkflows([createItem('W1')], 'project-1', 'package', actor),
|
||||
).rejects.toBe(checkFailure);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Logger } from '@n8n/backend-common';
|
|||
import type { ContentImportTransport } from '@n8n/decorators';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import type { PolicyActor } from '@/policy/policy-enforcement-backend';
|
||||
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||
import { PolicyViolationError } from '@/policy/policy-violation.error';
|
||||
|
||||
|
|
@ -36,6 +37,7 @@ export class ContentImportPolicyGate {
|
|||
items: WorkflowPlanItem[],
|
||||
projectId: string,
|
||||
transport: ContentImportTransport,
|
||||
actor: PolicyActor,
|
||||
): Promise<BlockingIssue[]> {
|
||||
// Skips the whole loop rather than paying a per-workflow guard: a package can hold
|
||||
// hundreds, and having no policy at all is the common case.
|
||||
|
|
@ -50,15 +52,18 @@ export class ContentImportPolicyGate {
|
|||
// The clearance is discarded: these writes still go through `WorkflowCreationService`
|
||||
// and `WorkflowService`, which mint their own `workflowSave` one. Threading this token
|
||||
// into them instead is its own change.
|
||||
await this.policyEnforcementService.enforceContentImport({
|
||||
workflow: {
|
||||
id: item.action === 'create' ? item.decidedId : item.existing.id,
|
||||
name: item.entity.name,
|
||||
nodes: item.entity.nodes,
|
||||
await this.policyEnforcementService.enforceContentImport(
|
||||
{
|
||||
workflow: {
|
||||
id: item.action === 'create' ? item.decidedId : item.existing.id,
|
||||
name: item.entity.name,
|
||||
nodes: item.entity.nodes,
|
||||
},
|
||||
projectId,
|
||||
transport,
|
||||
},
|
||||
projectId,
|
||||
transport,
|
||||
});
|
||||
actor,
|
||||
);
|
||||
} catch (error) {
|
||||
// Every refusal is collected, so the caller reports the whole package's worth at once.
|
||||
// A check that broke is not scoped to one workflow, so it fails the import outright
|
||||
|
|
|
|||
|
|
@ -257,6 +257,7 @@ export class ImportOrchestrator {
|
|||
workflowPlan.items,
|
||||
context.projectId,
|
||||
contentImportTransport(input.importSource),
|
||||
{ kind: 'user', user: context.user },
|
||||
);
|
||||
|
||||
const blockingIssues = this.collectBlockingIssues({
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ flowchart LR
|
|||
end
|
||||
|
||||
subgraph module["policy-infrastructure module (default, disable to opt out)"]
|
||||
pds["PolicyDecisionService<br/>deadline per check · all checks must pass<br/>crash or timeout = fail closed<br/>one audit line per veto"]
|
||||
pds["PolicyDecisionService<br/>deadline per check · all checks must pass<br/>crash or timeout = fail closed<br/>one audit line + one log streaming event per veto"]
|
||||
registry["PolicyCheckMetadata<br/>registry in @n8n/decorators"]
|
||||
checks["@PolicyCheck() classes<br/>onWorkflowSave · onWorkflowPublish · …"]
|
||||
end
|
||||
|
|
@ -199,6 +199,35 @@ Two logging facts to know before relying on this:
|
|||
Policy _mutation_ audit — who changed a policy — is a different surface, owned by the
|
||||
feature that has a policy to mutate, on the existing audit-event infrastructure.
|
||||
|
||||
## The log streaming event
|
||||
|
||||
The same emit site also sends `n8n.audit.policy.decision.blocked` to log streaming, so a
|
||||
SIEM sees every block but one kind (see below). The payload is the audit line plus the actor. It does not depend on
|
||||
the log format or on `N8N_LOG_SCOPES`.
|
||||
|
||||
- **The host names the actor.** Every `enforce*` call takes a `PolicyActor` as its second
|
||||
argument. Checks never see it. It is a user when the host is a request with an
|
||||
authenticated user. Otherwise it is the system with a reason: `execution`, `cli-import`,
|
||||
`activation`, `publication`, `integration` or `log-streaming`.
|
||||
- **A run never names a user.** Not every execution path knows reliably who started the
|
||||
run, so a block inside a run is `execution` and carries the `executionId`. Use the
|
||||
execution to find who started it. This includes manual runs and sub-workflows.
|
||||
- **The payload says which.** `actorType` is `user` or `system`. A system actor adds
|
||||
`systemReason` and has `userId: null`.
|
||||
- **`userId` always means the accountable human.** A future agent actor adds its own
|
||||
fields and fills `userId` with the user it acts for, so SIEM rules on `userId` keep
|
||||
their meaning.
|
||||
- **The user fields are the same on every block.** A host that knows only the user id
|
||||
passes `{ id }`, and the relay reads the rest of the user before it sends the event. The
|
||||
fields are redactable, the same as on every other `n8n.audit.*` event.
|
||||
- **A block on a destination credential sends no event.** A webhook destination decrypts
|
||||
its credential on every delivery. If a policy blocks it, the event would go to the same
|
||||
destination and block again. So the relay drops `log-streaming` blocks. The audit line
|
||||
still records them.
|
||||
- **Violations have fixed keys.** A field a check left out is `null`, not missing.
|
||||
- **Workers and webhook processes send it too.** The log streaming module runs on
|
||||
every instance type, so an event from a worker goes straight to the destinations.
|
||||
|
||||
## The seal
|
||||
|
||||
A cleared write needs a `PolicyCleared` token minted by the enforcement point.
|
||||
|
|
@ -250,7 +279,7 @@ registry is read on every decision, so load order cannot hide a check.
|
|||
| File | Role |
|
||||
| -------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `policy-infrastructure.module.ts` | Registers `PolicyDecisionService` into the enforcement point and loads the lifecycle handler |
|
||||
| `policy-decision.service.ts` | Runs the checks with deadlines, combines their results, and emits the audit line |
|
||||
| `policy-decision.service.ts` | Runs the checks with deadlines, combines their results, and emits the audit line and event |
|
||||
| `policy-decision-audit.ts` | The audit line's shape and how it reads a target off each context |
|
||||
| `policy-lifecycle-handler.ts` | The `workflowStart` host, one hook for every way an execution starts |
|
||||
| `policy-check-failed.error.ts` | The 503 for a check that did not answer |
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { EventService } from '@n8n/backend-services';
|
||||
import { mockLogger } from '@n8n/backend-test-utils';
|
||||
import {
|
||||
type CredentialDecryptContext,
|
||||
|
|
@ -16,6 +17,7 @@ import { OperationalError } from 'n8n-workflow';
|
|||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { classifyHttpError } from '@/errors/http-error-classifier';
|
||||
import type { PolicyActor } from '@/policy/policy-enforcement-backend';
|
||||
import { serializeInternalRestError } from '@/errors/http-error-serializers';
|
||||
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||
import { PolicyViolationError } from '@/policy/policy-violation.error';
|
||||
|
|
@ -198,7 +200,7 @@ class StartOnlyCheck implements RegisteredPolicyCheck {
|
|||
const serviceWith = (...classes: PolicyCheckClass[]) => {
|
||||
const metadata = mock<PolicyCheckMetadata>({ getClasses: () => classes });
|
||||
|
||||
return new PolicyDecisionService(mockLogger(), metadata);
|
||||
return new PolicyDecisionService(mockLogger(), metadata, mock<EventService>());
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -208,23 +210,33 @@ const serviceWith = (...classes: PolicyCheckClass[]) => {
|
|||
const auditedServiceWith = (...classes: PolicyCheckClass[]) => {
|
||||
const logger = mockLogger();
|
||||
const metadata = mock<PolicyCheckMetadata>({ getClasses: () => classes });
|
||||
const eventService = mock<EventService>();
|
||||
|
||||
return {
|
||||
service: new PolicyDecisionService(logger, metadata),
|
||||
service: new PolicyDecisionService(logger, metadata, eventService),
|
||||
audit: vi.mocked(logger.scoped('policy').warn),
|
||||
emit: eventService.emit,
|
||||
};
|
||||
};
|
||||
|
||||
const alice = { id: 'user-1', email: 'alice@example.com', role: { slug: 'global:member' } };
|
||||
const asAlice: PolicyActor = { kind: 'user', user: alice };
|
||||
const unattended: PolicyActor = { kind: 'system', reason: 'execution' };
|
||||
|
||||
describe('PolicyDecisionService', () => {
|
||||
describe('enforce', () => {
|
||||
it('allows when no checks are registered', async () => {
|
||||
const decision = await serviceWith().enforce('workflowSave', saveContext);
|
||||
const decision = await serviceWith().enforce('workflowSave', saveContext, unattended);
|
||||
|
||||
expect(decision).toEqual({ violations: [] });
|
||||
});
|
||||
|
||||
it('allows when every registered check is silent', async () => {
|
||||
const decision = await serviceWith(SilentCheck).enforce('workflowSave', saveContext);
|
||||
const decision = await serviceWith(SilentCheck).enforce(
|
||||
'workflowSave',
|
||||
saveContext,
|
||||
unattended,
|
||||
);
|
||||
|
||||
expect(decision).toEqual({ violations: [] });
|
||||
});
|
||||
|
|
@ -233,6 +245,7 @@ describe('PolicyDecisionService', () => {
|
|||
const decision = await serviceWith(SlackCheck, CodeCheck).enforce(
|
||||
'workflowSave',
|
||||
saveContext,
|
||||
unattended,
|
||||
);
|
||||
|
||||
expect(decision.violations).toEqual([slackBlocked, codeBlocked]);
|
||||
|
|
@ -242,6 +255,7 @@ describe('PolicyDecisionService', () => {
|
|||
const decision = await serviceWith(SlackCheck, CodeCheck).enforce(
|
||||
'workflowSave',
|
||||
saveContext,
|
||||
unattended,
|
||||
);
|
||||
|
||||
expect(decision.policyVersions).toEqual([{ scope: 'instance', version: 4 }]);
|
||||
|
|
@ -249,7 +263,7 @@ describe('PolicyDecisionService', () => {
|
|||
|
||||
it('blocks when a check throws, without leaking why', async () => {
|
||||
const error = await serviceWith(SilentCheck, BrokenCheck)
|
||||
.enforce('workflowSave', saveContext)
|
||||
.enforce('workflowSave', saveContext, unattended)
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(PolicyCheckFailedError);
|
||||
|
|
@ -265,7 +279,7 @@ describe('PolicyDecisionService', () => {
|
|||
|
||||
try {
|
||||
const pending = serviceWith(HangingCheck)
|
||||
.enforce('workflowStart', startContext)
|
||||
.enforce('workflowStart', startContext, unattended)
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
|
@ -281,7 +295,7 @@ describe('PolicyDecisionService', () => {
|
|||
|
||||
try {
|
||||
const pending = serviceWith(AbortAwareCheck)
|
||||
.enforce('workflowStart', startContext)
|
||||
.enforce('workflowStart', startContext, unattended)
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
|
@ -296,7 +310,7 @@ describe('PolicyDecisionService', () => {
|
|||
it('only runs checks that implement the point', async () => {
|
||||
const service = serviceWith(StartOnlyCheck);
|
||||
|
||||
await service.enforce('workflowSave', saveContext);
|
||||
await service.enforce('workflowSave', saveContext, unattended);
|
||||
|
||||
expect(Container.get(StartOnlyCheck).onWorkflowStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -319,7 +333,9 @@ describe('PolicyDecisionService', () => {
|
|||
const service = serviceWith(SilentCheck);
|
||||
|
||||
expect(service.hasChecksFor('workflowSave')).toBe(true);
|
||||
expect((await service.enforce('workflowSave', saveContext)).violations).toEqual([]);
|
||||
expect((await service.enforce('workflowSave', saveContext, unattended)).violations).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(service.hasChecksFor('workflowStart')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -355,7 +371,7 @@ describe('PolicyDecisionService', () => {
|
|||
it('writes one line naming the point, the objecting check and the violation', async () => {
|
||||
const { service, audit } = auditedServiceWith(SlackCheck);
|
||||
|
||||
await service.enforce('workflowSave', updateContext);
|
||||
await service.enforce('workflowSave', updateContext, unattended);
|
||||
|
||||
expect(audit).toHaveBeenCalledTimes(1);
|
||||
expect(audit).toHaveBeenCalledWith('Policy blocked workflowSave', {
|
||||
|
|
@ -374,7 +390,7 @@ describe('PolicyDecisionService', () => {
|
|||
it('keeps the violation message out of the line', async () => {
|
||||
const { service, audit } = auditedServiceWith(SlackCheck);
|
||||
|
||||
await service.enforce('workflowSave', saveContext);
|
||||
await service.enforce('workflowSave', saveContext, unattended);
|
||||
|
||||
expect(JSON.stringify(audit.mock.calls[0][1])).not.toContain('is not available');
|
||||
});
|
||||
|
|
@ -382,7 +398,7 @@ describe('PolicyDecisionService', () => {
|
|||
it('writes one line for the decision, not one per objecting check', async () => {
|
||||
const { service, audit } = auditedServiceWith(SilentCheck, SlackCheck, CodeCheck);
|
||||
|
||||
await service.enforce('workflowSave', saveContext);
|
||||
await service.enforce('workflowSave', saveContext, unattended);
|
||||
|
||||
expect(audit).toHaveBeenCalledTimes(1);
|
||||
expect(audit.mock.calls[0][1]).toMatchObject({
|
||||
|
|
@ -394,7 +410,7 @@ describe('PolicyDecisionService', () => {
|
|||
it('names the workflow when a create has no id to name', async () => {
|
||||
const { service, audit } = auditedServiceWith(SlackCheck);
|
||||
|
||||
await service.enforce('workflowSave', createContext);
|
||||
await service.enforce('workflowSave', createContext, unattended);
|
||||
|
||||
expect(audit.mock.calls[0][1]).toMatchObject({
|
||||
workflowId: null,
|
||||
|
|
@ -405,7 +421,7 @@ describe('PolicyDecisionService', () => {
|
|||
it('does not record a create id the client supplied, which the seal discards too', async () => {
|
||||
const { service, audit } = auditedServiceWith(SlackCheck);
|
||||
|
||||
await service.enforce('workflowSave', createWithClaimedIdContext);
|
||||
await service.enforce('workflowSave', createWithClaimedIdContext, unattended);
|
||||
|
||||
expect(audit.mock.calls[0][1]).toMatchObject({
|
||||
workflowId: null,
|
||||
|
|
@ -417,7 +433,7 @@ describe('PolicyDecisionService', () => {
|
|||
it('records the project a transfer moves into', async () => {
|
||||
const { service, audit } = auditedServiceWith(OtherPointsCheck);
|
||||
|
||||
await service.enforce('workflowTransfer', transferContext);
|
||||
await service.enforce('workflowTransfer', transferContext, unattended);
|
||||
|
||||
expect(audit.mock.calls[0][1]).toMatchObject({ projectId: 'proj-2' });
|
||||
});
|
||||
|
|
@ -425,7 +441,7 @@ describe('PolicyDecisionService', () => {
|
|||
it('records a credential create by type, with no id', async () => {
|
||||
const { service, audit } = auditedServiceWith(OtherPointsCheck);
|
||||
|
||||
await service.enforce('credentialSave', credentialCreateContext);
|
||||
await service.enforce('credentialSave', credentialCreateContext, unattended);
|
||||
|
||||
expect(audit.mock.calls[0][1]).toMatchObject({
|
||||
credentialId: null,
|
||||
|
|
@ -438,7 +454,7 @@ describe('PolicyDecisionService', () => {
|
|||
it('records a credential update by its stored id', async () => {
|
||||
const { service, audit } = auditedServiceWith(OtherPointsCheck);
|
||||
|
||||
await service.enforce('credentialSave', credentialUpdateContext);
|
||||
await service.enforce('credentialSave', credentialUpdateContext, unattended);
|
||||
|
||||
expect(audit.mock.calls[0][1]).toMatchObject({
|
||||
credentialId: 'cred-1',
|
||||
|
|
@ -450,7 +466,7 @@ describe('PolicyDecisionService', () => {
|
|||
it('records the credential and the node asking for it', async () => {
|
||||
const { service, audit } = auditedServiceWith(OtherPointsCheck);
|
||||
|
||||
await service.enforce('credentialDecrypt', decryptContext);
|
||||
await service.enforce('credentialDecrypt', decryptContext, unattended);
|
||||
|
||||
expect(audit.mock.calls[0][1]).toMatchObject({
|
||||
credentialId: 'cred-1',
|
||||
|
|
@ -465,7 +481,7 @@ describe('PolicyDecisionService', () => {
|
|||
const { service, audit } = auditedServiceWith(SilentCheck, BrokenCheck);
|
||||
|
||||
const error = (await service
|
||||
.enforce('workflowSave', saveContext)
|
||||
.enforce('workflowSave', saveContext, unattended)
|
||||
.catch((e: unknown) => e)) as PolicyCheckFailedError;
|
||||
|
||||
expect(audit).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -483,7 +499,7 @@ describe('PolicyDecisionService', () => {
|
|||
it('keeps what the answered checks said on a check failure line', async () => {
|
||||
const { service, audit } = auditedServiceWith(SlackCheck, BrokenCheck);
|
||||
|
||||
await service.enforce('workflowSave', saveContext).catch(() => {});
|
||||
await service.enforce('workflowSave', saveContext, unattended).catch(() => {});
|
||||
|
||||
expect(audit.mock.calls[0][1]).toMatchObject({
|
||||
outcome: 'checkFailure',
|
||||
|
|
@ -495,7 +511,7 @@ describe('PolicyDecisionService', () => {
|
|||
it('stays silent when every check is silent', async () => {
|
||||
const { service, audit } = auditedServiceWith(SilentCheck);
|
||||
|
||||
await service.enforce('workflowSave', saveContext);
|
||||
await service.enforce('workflowSave', saveContext, unattended);
|
||||
|
||||
expect(audit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -503,7 +519,7 @@ describe('PolicyDecisionService', () => {
|
|||
it('stays silent when no check is registered', async () => {
|
||||
const { service, audit } = auditedServiceWith();
|
||||
|
||||
await service.enforce('workflowSave', saveContext);
|
||||
await service.enforce('workflowSave', saveContext, unattended);
|
||||
|
||||
expect(audit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -530,7 +546,7 @@ describe('PolicyDecisionService', () => {
|
|||
const proxy = new PolicyEnforcementService();
|
||||
proxy.setImplementation(service);
|
||||
|
||||
await expect(proxy.enforceWorkflowSave(saveContext)).rejects.toBeInstanceOf(
|
||||
await expect(proxy.enforceWorkflowSave(saveContext, unattended)).rejects.toBeInstanceOf(
|
||||
PolicyViolationError,
|
||||
);
|
||||
expect(audit).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -541,17 +557,99 @@ describe('PolicyDecisionService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('log streaming event', () => {
|
||||
it('emits one event with the point, checks, violations, versions and user', async () => {
|
||||
const { service, emit } = auditedServiceWith(SlackCheck);
|
||||
const proxy = new PolicyEnforcementService();
|
||||
proxy.setImplementation(service);
|
||||
|
||||
await expect(proxy.enforceWorkflowSave(updateContext, asAlice)).rejects.toBeInstanceOf(
|
||||
PolicyViolationError,
|
||||
);
|
||||
|
||||
expect(emit).toHaveBeenCalledTimes(1);
|
||||
expect(emit).toHaveBeenCalledWith('policy-decision-blocked', {
|
||||
point: 'workflowSave',
|
||||
outcome: 'violation',
|
||||
durationMs: expect.any(Number),
|
||||
checkIds: ['node-types'],
|
||||
violations: [slackAudited],
|
||||
policyVersions: [{ scope: 'instance', version: 4 }],
|
||||
workflowId: 'wf-1',
|
||||
workflowName: 'My workflow',
|
||||
projectId: 'proj-1',
|
||||
actorType: 'user',
|
||||
user: alice,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits a check failure with its correlation ids and no check internals', async () => {
|
||||
const { service, emit } = auditedServiceWith(BrokenCheck);
|
||||
|
||||
const error = (await service
|
||||
.enforce('workflowSave', saveContext, asAlice)
|
||||
.catch((e: unknown) => e)) as PolicyCheckFailedError;
|
||||
|
||||
expect(emit).toHaveBeenCalledTimes(1);
|
||||
const [, payload] = emit.mock.calls[0];
|
||||
expect(payload).toMatchObject({
|
||||
outcome: 'checkFailure',
|
||||
correlationIds: error.meta.correlationIds,
|
||||
});
|
||||
expect(JSON.stringify(payload)).not.toContain('policy store');
|
||||
});
|
||||
|
||||
it('sends a null user and the reason for a system actor', async () => {
|
||||
const { service, emit } = auditedServiceWith(SlackCheck);
|
||||
|
||||
await service.enforce('workflowSave', saveContext, unattended);
|
||||
|
||||
expect(emit).toHaveBeenCalledWith(
|
||||
'policy-decision-blocked',
|
||||
expect.objectContaining({ actorType: 'system', user: null, systemReason: 'execution' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('still writes the decision line alongside the event', async () => {
|
||||
const { service, audit, emit } = auditedServiceWith(SlackCheck);
|
||||
|
||||
await service.enforce('workflowSave', saveContext, asAlice);
|
||||
|
||||
expect(audit).toHaveBeenCalledTimes(1);
|
||||
expect(emit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('emits nothing for an allowed action', async () => {
|
||||
const { service, emit } = auditedServiceWith(SilentCheck);
|
||||
|
||||
await service.enforce('workflowSave', saveContext, asAlice);
|
||||
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits nothing for an evaluate, even one that finds violations or breaks', async () => {
|
||||
const { service, emit } = auditedServiceWith(SlackCheck, BrokenCheck);
|
||||
|
||||
const decision = await service.evaluate('workflowSave', saveContext);
|
||||
|
||||
expect(decision.violations).toEqual([slackBlocked]);
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('check resolution', () => {
|
||||
it('reads the registry per decision, so a check registered later still runs', async () => {
|
||||
const classes: PolicyCheckClass[] = [];
|
||||
const metadata = mock<PolicyCheckMetadata>({ getClasses: () => classes });
|
||||
const service = new PolicyDecisionService(mockLogger(), metadata);
|
||||
const service = new PolicyDecisionService(mockLogger(), metadata, mock<EventService>());
|
||||
|
||||
expect(await service.enforce('workflowSave', saveContext)).toEqual({ violations: [] });
|
||||
expect(await service.enforce('workflowSave', saveContext, unattended)).toEqual({
|
||||
violations: [],
|
||||
});
|
||||
|
||||
classes.push(SlackCheck);
|
||||
|
||||
const decision = await service.enforce('workflowSave', saveContext);
|
||||
const decision = await service.enforce('workflowSave', saveContext, unattended);
|
||||
expect(decision.violations).toEqual([slackBlocked]);
|
||||
});
|
||||
});
|
||||
|
|
@ -561,7 +659,9 @@ describe('PolicyDecisionService', () => {
|
|||
const proxy = new PolicyEnforcementService();
|
||||
proxy.setImplementation(serviceWith(SlackCheck));
|
||||
|
||||
const error = await proxy.enforceWorkflowSave(saveContext).catch((e: unknown) => e);
|
||||
const error = await proxy
|
||||
.enforceWorkflowSave(saveContext, unattended)
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(PolicyViolationError);
|
||||
expect((error as PolicyViolationError).violations).toEqual([slackBlocked]);
|
||||
|
|
@ -571,7 +671,7 @@ describe('PolicyDecisionService', () => {
|
|||
const proxy = new PolicyEnforcementService();
|
||||
proxy.setImplementation(serviceWith(SilentCheck));
|
||||
|
||||
const token = await proxy.enforceWorkflowSave(saveContext);
|
||||
const token = await proxy.enforceWorkflowSave(saveContext, unattended);
|
||||
|
||||
expect(token.subject).toEqual(workflowContentSubject(saveContext.workflow));
|
||||
expect(token.subject.id).not.toBe('wf-1');
|
||||
|
|
@ -581,10 +681,10 @@ describe('PolicyDecisionService', () => {
|
|||
const proxy = new PolicyEnforcementService();
|
||||
proxy.setImplementation(serviceWith(SilentCheck));
|
||||
|
||||
const token = await proxy.enforceWorkflowSave({
|
||||
...saveContext,
|
||||
storedWorkflow: saveContext.workflow,
|
||||
});
|
||||
const token = await proxy.enforceWorkflowSave(
|
||||
{ ...saveContext, storedWorkflow: saveContext.workflow },
|
||||
unattended,
|
||||
);
|
||||
|
||||
expect(token.subject).toEqual({ type: 'workflow', id: 'wf-1' });
|
||||
});
|
||||
|
|
@ -593,7 +693,7 @@ describe('PolicyDecisionService', () => {
|
|||
const proxy = new PolicyEnforcementService();
|
||||
proxy.setImplementation(serviceWith(BrokenCheck));
|
||||
|
||||
await expect(proxy.enforceWorkflowSave(saveContext)).rejects.toBeInstanceOf(
|
||||
await expect(proxy.enforceWorkflowSave(saveContext, unattended)).rejects.toBeInstanceOf(
|
||||
PolicyCheckFailedError,
|
||||
);
|
||||
});
|
||||
|
|
@ -602,7 +702,9 @@ describe('PolicyDecisionService', () => {
|
|||
const proxy = new PolicyEnforcementService();
|
||||
proxy.setImplementation(serviceWith(BrokenCheck));
|
||||
|
||||
const error = await proxy.enforceWorkflowSave(saveContext).catch((e: unknown) => e);
|
||||
const error = await proxy
|
||||
.enforceWorkflowSave(saveContext, unattended)
|
||||
.catch((e: unknown) => e);
|
||||
const { status, body } = serializeInternalRestError(classifyHttpError(error as Error));
|
||||
|
||||
expect(status).toBe(503);
|
||||
|
|
|
|||
|
|
@ -55,9 +55,19 @@ describe('PolicyLifecycleHandler', () => {
|
|||
it('enforces the workflow start point with the workflow and its owning project', async () => {
|
||||
await handler.onWorkflowExecuteBefore(beforeContext());
|
||||
|
||||
expect(policyEnforcementService.enforceWorkflowStart).toHaveBeenCalledExactlyOnceWith({
|
||||
workflow,
|
||||
projectId: 'proj-1',
|
||||
expect(policyEnforcementService.enforceWorkflowStart).toHaveBeenCalledExactlyOnceWith(
|
||||
{ workflow, projectId: 'proj-1' },
|
||||
{ kind: 'system', reason: 'execution', executionId: 'exec-1' },
|
||||
);
|
||||
});
|
||||
|
||||
it('names the run, not a user, even for a manual run', async () => {
|
||||
await handler.onWorkflowExecuteBefore(beforeContext({ mode: 'manual' }));
|
||||
|
||||
expect(policyEnforcementService.enforceWorkflowStart).toHaveBeenCalledWith(expect.anything(), {
|
||||
kind: 'system',
|
||||
reason: 'execution',
|
||||
executionId: 'exec-1',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,16 +7,25 @@ import type {
|
|||
PolicyVersionRef,
|
||||
} from '@n8n/decorators';
|
||||
|
||||
import type { PolicyContext } from '@/policy/policy-enforcement-backend';
|
||||
import type { UserLike } from '@/types/user-like.types';
|
||||
import type {
|
||||
PolicyActor,
|
||||
PolicyContext,
|
||||
PolicySystemReason,
|
||||
} from '@/policy/policy-enforcement-backend';
|
||||
|
||||
/** Every context, as one union — a generic `PolicyContext<Point>` narrows against none. */
|
||||
type AnyPolicyContext = PolicyContext<EnforcementPoint>;
|
||||
|
||||
/** The violation as the audit line records it. No `message`: free text saying what the fields say. */
|
||||
type AuditedViolation = Pick<
|
||||
PolicyViolation,
|
||||
'checkId' | 'kind' | 'subject' | 'subjectType' | 'scope' | 'matchedRuleId'
|
||||
>;
|
||||
type OptionalAuditedKey = 'subject' | 'subjectType' | 'scope' | 'matchedRuleId';
|
||||
|
||||
/**
|
||||
* The violation as the audit line records it. Every key is present, `null` when the check left
|
||||
* it out, so a SIEM can map fixed fields. No `message`: free text saying what the fields say.
|
||||
*/
|
||||
type AuditedViolation = Pick<PolicyViolation, 'checkId' | 'kind'> & {
|
||||
[Key in OptionalAuditedKey]-?: Exclude<PolicyViolation[Key], undefined> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* One decision-audit line.
|
||||
|
|
@ -53,20 +62,13 @@ export type PolicyDecisionAudit = {
|
|||
projectId: string | null;
|
||||
};
|
||||
|
||||
const auditedViolation = ({
|
||||
checkId,
|
||||
kind,
|
||||
subject,
|
||||
subjectType,
|
||||
scope,
|
||||
matchedRuleId,
|
||||
}: PolicyViolation): AuditedViolation => ({
|
||||
checkId,
|
||||
kind,
|
||||
subject,
|
||||
subjectType,
|
||||
scope,
|
||||
matchedRuleId,
|
||||
const auditedViolation = (violation: PolicyViolation): AuditedViolation => ({
|
||||
checkId: violation.checkId,
|
||||
kind: violation.kind,
|
||||
subject: violation.subject ?? null,
|
||||
subjectType: violation.subjectType ?? null,
|
||||
scope: violation.scope ?? null,
|
||||
matchedRuleId: violation.matchedRuleId ?? null,
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -157,3 +159,22 @@ export function decisionAudit({
|
|||
...targetOf(context),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The actor as the log streaming event records it. `user` is always the accountable human, or
|
||||
* `null` when there is none, and stays a field of its own so the relay can redact it.
|
||||
*/
|
||||
export type AuditedActor =
|
||||
| { actorType: 'user'; user: UserLike }
|
||||
| { actorType: 'system'; user: null; systemReason: PolicySystemReason; executionId?: string };
|
||||
|
||||
export function auditedActor(actor: PolicyActor): AuditedActor {
|
||||
if (actor.kind === 'user') return { actorType: 'user', user: actor.user };
|
||||
|
||||
return {
|
||||
actorType: 'system',
|
||||
user: null,
|
||||
systemReason: actor.reason,
|
||||
...(actor.executionId !== undefined && { executionId: actor.executionId }),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Logger } from '@n8n/backend-common';
|
||||
import { EventService } from '@n8n/backend-services';
|
||||
import type {
|
||||
EnforcementPoint,
|
||||
PolicyCheckFailure,
|
||||
|
|
@ -11,11 +12,15 @@ import { Container, Service } from '@n8n/di';
|
|||
import { OperationalError } from 'n8n-workflow';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { PolicyContext, PolicyEnforcementBackend } from '@/policy/policy-enforcement-backend';
|
||||
import type {
|
||||
PolicyActor,
|
||||
PolicyContext,
|
||||
PolicyEnforcementBackend,
|
||||
} from '@/policy/policy-enforcement-backend';
|
||||
|
||||
import { PolicyCheckFailedError } from './policy-check-failed.error';
|
||||
import type { DecisionAuditInput } from './policy-decision-audit';
|
||||
import { decisionAudit } from './policy-decision-audit';
|
||||
import { auditedActor, decisionAudit } from './policy-decision-audit';
|
||||
|
||||
/**
|
||||
* How long one check gets. Tight on the two points that sit inside a running execution: a wedged
|
||||
|
|
@ -106,14 +111,16 @@ function decisionFrom(results: PolicyCheckResult[]): PolicyDecision {
|
|||
* Checks are combined by conjunction: all of them have to pass and every violation is reported,
|
||||
* so a user fixing a workflow sees the whole list rather than one at a time.
|
||||
*
|
||||
* Either way of blocking writes one audit line from here, the single emit site for enforcement
|
||||
* observability. `evaluate` stays silent, so previews never pollute the trail.
|
||||
* Either way of blocking writes one audit line and one log streaming event from here, the single
|
||||
* emit site for enforcement observability. `evaluate` stays silent, so previews never pollute
|
||||
* the trail.
|
||||
*/
|
||||
@Service()
|
||||
export class PolicyDecisionService implements PolicyEnforcementBackend {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly checkMetadata: PolicyCheckMetadata,
|
||||
private readonly eventService: EventService,
|
||||
) {
|
||||
this.logger = this.logger.scoped('policy');
|
||||
}
|
||||
|
|
@ -121,6 +128,7 @@ export class PolicyDecisionService implements PolicyEnforcementBackend {
|
|||
async enforce<Point extends EnforcementPoint>(
|
||||
point: Point,
|
||||
context: PolicyContext<Point>,
|
||||
actor: PolicyActor,
|
||||
): Promise<PolicyDecision> {
|
||||
const startedAt = Date.now();
|
||||
const { checkIds, results, failures } = await this.runChecks(point, context);
|
||||
|
|
@ -129,7 +137,7 @@ export class PolicyDecisionService implements PolicyEnforcementBackend {
|
|||
const decision = decisionFrom(results);
|
||||
|
||||
if (failures.length > 0) {
|
||||
this.audit({ point, context, decision, durationMs, checkIds, failures });
|
||||
this.audit({ point, context, decision, durationMs, checkIds, failures }, actor);
|
||||
|
||||
throw new PolicyCheckFailedError(
|
||||
point,
|
||||
|
|
@ -139,7 +147,7 @@ export class PolicyDecisionService implements PolicyEnforcementBackend {
|
|||
|
||||
// The proxy throws on any violation, so this is the veto — and the only place it is logged.
|
||||
if (decision.violations.length > 0) {
|
||||
this.audit({ point, context, decision, durationMs, checkIds });
|
||||
this.audit({ point, context, decision, durationMs, checkIds }, actor);
|
||||
}
|
||||
|
||||
return decision;
|
||||
|
|
@ -162,13 +170,13 @@ export class PolicyDecisionService implements PolicyEnforcementBackend {
|
|||
|
||||
/**
|
||||
* The one emit site for enforcement observability, so every policy feature gets the same
|
||||
* line without building one.
|
||||
* line and log streaming event without building them.
|
||||
*
|
||||
* `warn`, not `info`: a blocked action must survive an operator quietening logs. The
|
||||
* structured half only reaches the console under `N8N_LOG_FORMAT=json` — the text format
|
||||
* prints the message alone — so the message names the point on its own.
|
||||
*/
|
||||
private audit(input: DecisionAuditInput) {
|
||||
private audit(input: DecisionAuditInput, actor: PolicyActor) {
|
||||
const line = decisionAudit(input);
|
||||
const message =
|
||||
line.outcome === 'violation'
|
||||
|
|
@ -176,6 +184,7 @@ export class PolicyDecisionService implements PolicyEnforcementBackend {
|
|||
: `Policy could not be verified for ${line.point}, so it was blocked`;
|
||||
|
||||
this.logger.warn(message, line);
|
||||
this.eventService.emit('policy-decision-blocked', { ...line, ...auditedActor(actor) });
|
||||
}
|
||||
|
||||
private async runChecks<Point extends EnforcementPoint>(
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@ export class PolicyLifecycleHandler {
|
|||
// lookup fails the run. The other pre-execution gates let this throw too.
|
||||
const project = await this.ownershipService.getWorkflowProjectCached(ctx.workflow.id);
|
||||
|
||||
await this.policyEnforcementService.enforceWorkflowStart({
|
||||
workflow: ctx.workflow,
|
||||
projectId: project.id,
|
||||
});
|
||||
await this.policyEnforcementService.enforceWorkflowStart(
|
||||
{ workflow: ctx.workflow, projectId: project.id },
|
||||
{ kind: 'system', reason: 'execution', executionId: ctx.executionId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1663,15 +1663,18 @@ describe('SourceControlImportService', () => {
|
|||
|
||||
await service.importWorkflowFromWorkFolder(candidates, mockUserId);
|
||||
|
||||
expect(policyEnforcementService.enforceContentImport).toHaveBeenCalledWith({
|
||||
workflow: {
|
||||
id: mockWorkflowData.id,
|
||||
name: mockWorkflowData.name,
|
||||
nodes: mockWorkflowData.nodes,
|
||||
expect(policyEnforcementService.enforceContentImport).toHaveBeenCalledWith(
|
||||
{
|
||||
workflow: {
|
||||
id: mockWorkflowData.id,
|
||||
name: mockWorkflowData.name,
|
||||
nodes: mockWorkflowData.nodes,
|
||||
},
|
||||
projectId: 'personal-project-id-123',
|
||||
transport: 'source-control',
|
||||
},
|
||||
projectId: 'personal-project-id-123',
|
||||
transport: 'source-control',
|
||||
});
|
||||
{ kind: 'user', user: { id: mockUserId } },
|
||||
);
|
||||
});
|
||||
|
||||
// The clearance is checked at the write, so enforcing after the upsert would seal
|
||||
|
|
@ -2830,11 +2833,14 @@ describe('SourceControlImportService', () => {
|
|||
|
||||
await service.importCredentialsFromWorkFolder(candidates, mockUserId);
|
||||
|
||||
expect(policyEnforcementService.enforceContentImport).toHaveBeenCalledWith({
|
||||
credential: { id: mockCredentialData.id, type: mockCredentialData.type },
|
||||
projectId: mockPersonalProject.id,
|
||||
transport: 'source-control',
|
||||
});
|
||||
expect(policyEnforcementService.enforceContentImport).toHaveBeenCalledWith(
|
||||
{
|
||||
credential: { id: mockCredentialData.id, type: mockCredentialData.type },
|
||||
projectId: mockPersonalProject.id,
|
||||
transport: 'source-control',
|
||||
},
|
||||
{ kind: 'user', user: { id: mockUserId } },
|
||||
);
|
||||
});
|
||||
|
||||
// The clearance is checked at the write, so enforcing after the upsert would seal
|
||||
|
|
|
|||
|
|
@ -863,11 +863,14 @@ export class SourceControlImportService {
|
|||
// skip after that point would leave it stopped with nothing imported in its place.
|
||||
let cleared: PolicyCleared<'contentImport'>;
|
||||
try {
|
||||
cleared = await this.policyEnforcementService.enforceContentImport({
|
||||
workflow: { id, name: importedWorkflow.name, nodes },
|
||||
projectId: targetOwnerProject.id,
|
||||
transport: 'source-control',
|
||||
});
|
||||
cleared = await this.policyEnforcementService.enforceContentImport(
|
||||
{
|
||||
workflow: { id, name: importedWorkflow.name, nodes },
|
||||
projectId: targetOwnerProject.id,
|
||||
transport: 'source-control',
|
||||
},
|
||||
{ kind: 'user', user: { id: userId } },
|
||||
);
|
||||
} catch (error) {
|
||||
// A blocked workflow is skipped, not fatal — the rest of the pull still lands. A check
|
||||
// that broke is not scoped to one workflow, so it fails the pull rather than silently
|
||||
|
|
@ -1116,11 +1119,14 @@ export class SourceControlImportService {
|
|||
// its stored data can't be read.
|
||||
let cleared: PolicyCleared<'contentImport'>;
|
||||
try {
|
||||
cleared = await this.policyEnforcementService.enforceContentImport({
|
||||
credential: { id: credential.id ?? null, type },
|
||||
projectId: targetOwnerProject.id,
|
||||
transport: 'source-control',
|
||||
});
|
||||
cleared = await this.policyEnforcementService.enforceContentImport(
|
||||
{
|
||||
credential: { id: credential.id ?? null, type },
|
||||
projectId: targetOwnerProject.id,
|
||||
transport: 'source-control',
|
||||
},
|
||||
{ kind: 'user', user: { id: userId } },
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof PolicyViolationError)) throw error;
|
||||
|
||||
|
|
|
|||
|
|
@ -632,8 +632,8 @@ describe('auto-close on source-control pull', () => {
|
|||
mock<PolicyEnforcementService>({
|
||||
// The repository verifies the token, so it has to be a real one. With no backend
|
||||
// registered the real service clears everything, which is what a default pull does.
|
||||
enforceContentImport: async (context) =>
|
||||
await Container.get(PolicyEnforcementService).enforceContentImport(context),
|
||||
enforceContentImport: async (context, actor) =>
|
||||
await Container.get(PolicyEnforcementService).enforceContentImport(context, actor),
|
||||
}), // policyEnforcementService
|
||||
mock(), // dataTableSizeValidator
|
||||
Container.get(WorkflowPublishedVersionRepository),
|
||||
|
|
|
|||
|
|
@ -1223,6 +1223,8 @@ describe('OauthService', () => {
|
|||
// Flow state read from cache and consumed (replay protection)
|
||||
expect(result[4]).toEqual({ csrfSecret: 'csrf-secret', codeVerifier: 'code-verifier' });
|
||||
expect(cacheService.delete).toHaveBeenCalledWith(`oauth:flow:${stateToken}`);
|
||||
// Names the user on a policy block while the credential is decrypted.
|
||||
expect(WorkflowExecuteAdditionalData.getBase).toHaveBeenCalledWith({ userId: 'user-id' });
|
||||
});
|
||||
|
||||
it('should reject the callback when the flow state is missing (replay / unknown state)', async () => {
|
||||
|
|
@ -1437,6 +1439,8 @@ describe('OauthService', () => {
|
|||
|
||||
// Should succeed despite no user because origin is dynamic-credential
|
||||
expect(result[0]).toEqual(mockCredential);
|
||||
// The starter comes from the state, since the callback carries no user.
|
||||
expect(WorkflowExecuteAdditionalData.getBase).toHaveBeenCalledWith({ userId: 'user-id' });
|
||||
expect(result[1]).toEqual(mockDecryptedData);
|
||||
expect(result[2]).toEqual(mockOAuthCredentials);
|
||||
expect(result[3]).toMatchObject({
|
||||
|
|
@ -2035,6 +2039,7 @@ describe('OauthService', () => {
|
|||
});
|
||||
|
||||
expect(authUri).toContain('https://example.domain/oauth2/auth');
|
||||
expect(service.getOAuthCredentials).toHaveBeenCalledWith(credential, 'user-id');
|
||||
// CSRF/PKCE state must not be persisted to the credential; it lives in the cache.
|
||||
expect(service.encryptAndSaveData).not.toHaveBeenCalled();
|
||||
expect(cacheService.set).toHaveBeenCalledWith(
|
||||
|
|
|
|||
|
|
@ -111,6 +111,10 @@ export const skipAuthOnOAuthCallback = shouldSkipAuthOnOAuthCallback();
|
|||
|
||||
export { OauthVersion, type OAuth1CredentialData, type CreateCsrfStateData, type CsrfState };
|
||||
|
||||
/** The user who started the flow. An externally started dynamic-credential flow has none. */
|
||||
const csrfUserId = (csrfData: CreateCsrfStateData) =>
|
||||
typeof csrfData.userId === 'string' ? csrfData.userId : undefined;
|
||||
|
||||
export class InvalidTargetError extends BadRequestError {
|
||||
constructor(message: string) {
|
||||
super(message, undefined, 'invalid_target');
|
||||
|
|
@ -345,8 +349,9 @@ export class OauthService {
|
|||
};
|
||||
}
|
||||
|
||||
protected async getAdditionalData() {
|
||||
return await WorkflowExecuteAdditionalData.getBase();
|
||||
/** `userId` names the user on a policy block while the credential is decrypted. */
|
||||
protected async getAdditionalData(userId?: string) {
|
||||
return await WorkflowExecuteAdditionalData.getBase({ userId });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -609,7 +614,8 @@ export class OauthService {
|
|||
throw new NotFoundError(RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL);
|
||||
}
|
||||
|
||||
const additionalData = await this.getAdditionalData();
|
||||
// The state holds the verified starter; the callback request may carry no user at all.
|
||||
const additionalData = await this.getAdditionalData(csrfUserId(state));
|
||||
const decryptedDataOriginal = await this.getDecryptedDataForCallback(
|
||||
credential,
|
||||
additionalData,
|
||||
|
|
@ -661,8 +667,8 @@ export class OauthService {
|
|||
return causes.length ? causes.join(': ') : undefined;
|
||||
}
|
||||
|
||||
async getOAuthCredentials<T>(credential: CredentialsEntity): Promise<T> {
|
||||
const additionalData = await this.getAdditionalData();
|
||||
async getOAuthCredentials<T>(credential: CredentialsEntity, userId?: string): Promise<T> {
|
||||
const additionalData = await this.getAdditionalData(userId);
|
||||
const decryptedDataOriginal = await this.getDecryptedDataForAuthUri(credential, additionalData);
|
||||
|
||||
// At some point in the past we saved hidden scopes to credentials (but shouldn't)
|
||||
|
|
@ -1013,7 +1019,7 @@ export class OauthService {
|
|||
this.applyBrowserBindingIfEnabled(csrfData, req, res);
|
||||
|
||||
const oauthCredentials: OAuth2CredentialData =
|
||||
await this.getOAuthCredentials<OAuth2CredentialData>(credential);
|
||||
await this.getOAuthCredentials<OAuth2CredentialData>(credential, csrfUserId(csrfData));
|
||||
|
||||
const toUpdate: ICredentialDataDecryptedObject = {};
|
||||
const toDelete: string[] = [];
|
||||
|
|
@ -1252,7 +1258,7 @@ export class OauthService {
|
|||
this.applyBrowserBindingIfEnabled(csrfData, req, res);
|
||||
|
||||
const oauthCredentials: OAuth1CredentialData =
|
||||
await this.getOAuthCredentials<OAuth1CredentialData>(credential);
|
||||
await this.getOAuthCredentials<OAuth1CredentialData>(credential, csrfUserId(csrfData));
|
||||
|
||||
this.validateOAuthUrlOrThrow(oauthCredentials.authUrl ?? '');
|
||||
this.validateOAuthUrlOrThrow(oauthCredentials.requestTokenUrl ?? '');
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ import { mock } from 'vitest-mock-extended';
|
|||
import type { OwnershipService } from '@/services/ownership.service';
|
||||
|
||||
import { enforceWorkflowPublishPolicy } from '../enforce-workflow-publish';
|
||||
import type { PolicyActor } from '../policy-enforcement-backend';
|
||||
import type { PolicyEnforcementService } from '../policy-enforcement.service';
|
||||
|
||||
describe('enforceWorkflowPublishPolicy', () => {
|
||||
const policyEnforcementService = mock<PolicyEnforcementService>();
|
||||
const ownershipService = mock<OwnershipService>();
|
||||
const workflow = { id: 'workflow-1', name: 'Test Workflow', nodes: [] };
|
||||
const actor: PolicyActor = { kind: 'user', user: { id: 'user-1' } };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -20,23 +22,26 @@ describe('enforceWorkflowPublishPolicy', () => {
|
|||
it('skips the project lookup and the check when nothing is registered for workflowPublish', async () => {
|
||||
policyEnforcementService.hasChecksFor.mockReturnValue(false);
|
||||
|
||||
await enforceWorkflowPublishPolicy(policyEnforcementService, ownershipService, workflow);
|
||||
await enforceWorkflowPublishPolicy(policyEnforcementService, ownershipService, workflow, actor);
|
||||
|
||||
expect(policyEnforcementService.hasChecksFor).toHaveBeenCalledWith('workflowPublish');
|
||||
expect(ownershipService.getWorkflowProjectCached).not.toHaveBeenCalled();
|
||||
expect(policyEnforcementService.enforceWorkflowPublish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enforces with the owning project when a check is registered', async () => {
|
||||
it('enforces with the owning project and the actor when a check is registered', async () => {
|
||||
policyEnforcementService.hasChecksFor.mockReturnValue(true);
|
||||
|
||||
await enforceWorkflowPublishPolicy(policyEnforcementService, ownershipService, workflow);
|
||||
await enforceWorkflowPublishPolicy(policyEnforcementService, ownershipService, workflow, actor);
|
||||
|
||||
expect(ownershipService.getWorkflowProjectCached).toHaveBeenCalledWith('workflow-1');
|
||||
expect(policyEnforcementService.enforceWorkflowPublish).toHaveBeenCalledWith({
|
||||
workflow,
|
||||
projectId: 'project-1',
|
||||
});
|
||||
expect(policyEnforcementService.enforceWorkflowPublish).toHaveBeenCalledWith(
|
||||
{
|
||||
workflow,
|
||||
projectId: 'project-1',
|
||||
},
|
||||
actor,
|
||||
);
|
||||
});
|
||||
|
||||
// An unevaluated project rule is not a passed one, so the lookup is unguarded.
|
||||
|
|
@ -45,7 +50,7 @@ describe('enforceWorkflowPublishPolicy', () => {
|
|||
ownershipService.getWorkflowProjectCached.mockRejectedValue(new Error('no owner row'));
|
||||
|
||||
await expect(
|
||||
enforceWorkflowPublishPolicy(policyEnforcementService, ownershipService, workflow),
|
||||
enforceWorkflowPublishPolicy(policyEnforcementService, ownershipService, workflow, actor),
|
||||
).rejects.toThrow('no owner row');
|
||||
|
||||
expect(policyEnforcementService.enforceWorkflowPublish).not.toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { PolicedWorkflow, PolicyDecision, PolicyViolation } from '@n8n/deco
|
|||
import { UnexpectedError } from 'n8n-workflow';
|
||||
import { mock, type MockProxy } from 'vitest-mock-extended';
|
||||
|
||||
import type { PolicyEnforcementBackend } from '../policy-enforcement-backend';
|
||||
import type { PolicyActor, PolicyEnforcementBackend } from '../policy-enforcement-backend';
|
||||
import { PolicyEnforcementService } from '../policy-enforcement.service';
|
||||
import { PolicyViolationError } from '../policy-violation.error';
|
||||
|
||||
|
|
@ -19,39 +19,60 @@ const cleared: PolicyDecision = { violations: [] };
|
|||
/** One call per point, so a new point can't be left out of the no-op guarantee. */
|
||||
const enforceCalls = (service: PolicyEnforcementService) => ({
|
||||
workflowSave: async () =>
|
||||
await service.enforceWorkflowSave({
|
||||
workflow: savedWorkflow,
|
||||
storedWorkflow: null,
|
||||
projectId: 'proj-1',
|
||||
}),
|
||||
await service.enforceWorkflowSave(
|
||||
{
|
||||
workflow: savedWorkflow,
|
||||
storedWorkflow: null,
|
||||
projectId: 'proj-1',
|
||||
},
|
||||
{ kind: 'system', reason: 'execution' },
|
||||
),
|
||||
workflowPublish: async () =>
|
||||
await service.enforceWorkflowPublish({ workflow: savedWorkflow, projectId: 'proj-1' }),
|
||||
await service.enforceWorkflowPublish(
|
||||
{ workflow: savedWorkflow, projectId: 'proj-1' },
|
||||
{ kind: 'system', reason: 'execution' },
|
||||
),
|
||||
workflowStart: async () =>
|
||||
await service.enforceWorkflowStart({ workflow: savedWorkflow, projectId: 'proj-1' }),
|
||||
await service.enforceWorkflowStart(
|
||||
{ workflow: savedWorkflow, projectId: 'proj-1' },
|
||||
{ kind: 'system', reason: 'execution' },
|
||||
),
|
||||
workflowTransfer: async () =>
|
||||
await service.enforceWorkflowTransfer({
|
||||
workflow: savedWorkflow,
|
||||
targetProjectId: 'proj-2',
|
||||
}),
|
||||
await service.enforceWorkflowTransfer(
|
||||
{
|
||||
workflow: savedWorkflow,
|
||||
targetProjectId: 'proj-2',
|
||||
},
|
||||
{ kind: 'system', reason: 'execution' },
|
||||
),
|
||||
credentialSave: async () =>
|
||||
await service.enforceCredentialSave({
|
||||
credential: { id: null, type: 'slackApi' },
|
||||
storedCredential: null,
|
||||
projectId: 'proj-1',
|
||||
}),
|
||||
await service.enforceCredentialSave(
|
||||
{
|
||||
credential: { id: null, type: 'slackApi' },
|
||||
storedCredential: null,
|
||||
projectId: 'proj-1',
|
||||
},
|
||||
{ kind: 'system', reason: 'execution' },
|
||||
),
|
||||
credentialDecrypt: async () =>
|
||||
await service.enforceCredentialDecrypt({
|
||||
credentialType: 'slackApi',
|
||||
credentialId: 'cred-1',
|
||||
consumer: null,
|
||||
projectId: 'proj-1',
|
||||
}),
|
||||
await service.enforceCredentialDecrypt(
|
||||
{
|
||||
credentialType: 'slackApi',
|
||||
credentialId: 'cred-1',
|
||||
consumer: null,
|
||||
projectId: 'proj-1',
|
||||
},
|
||||
{ kind: 'system', reason: 'execution' },
|
||||
),
|
||||
contentImport: async () =>
|
||||
await service.enforceContentImport({
|
||||
workflow: savedWorkflow,
|
||||
projectId: 'proj-1',
|
||||
transport: 'cli',
|
||||
}),
|
||||
await service.enforceContentImport(
|
||||
{
|
||||
workflow: savedWorkflow,
|
||||
projectId: 'proj-1',
|
||||
transport: 'cli',
|
||||
},
|
||||
{ kind: 'system', reason: 'execution' },
|
||||
),
|
||||
});
|
||||
|
||||
const evaluateCalls = (service: PolicyEnforcementService) => ({
|
||||
|
|
@ -166,14 +187,17 @@ describe('PolicyEnforcementService', () => {
|
|||
backend.enforce.mockResolvedValue({ violations: [violation, second] });
|
||||
|
||||
const error = await service
|
||||
.enforceWorkflowSave({ workflow: savedWorkflow, storedWorkflow: null, projectId: null })
|
||||
.enforceWorkflowSave(
|
||||
{ workflow: savedWorkflow, storedWorkflow: null, projectId: null },
|
||||
{ kind: 'system', reason: 'execution' },
|
||||
)
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(PolicyViolationError);
|
||||
expect((error as PolicyViolationError).violations).toEqual([violation, second]);
|
||||
});
|
||||
|
||||
it('passes the point and context through and mints the decision it got back', async () => {
|
||||
it('passes the point, context and actor through and mints the decision it got back', async () => {
|
||||
const decision: PolicyDecision = {
|
||||
violations: [],
|
||||
policyVersions: [{ scope: 'instance', version: 7 }],
|
||||
|
|
@ -181,9 +205,11 @@ describe('PolicyEnforcementService', () => {
|
|||
backend.enforce.mockResolvedValue(decision);
|
||||
const context = { workflow: savedWorkflow, storedWorkflow: null, projectId: 'proj-1' };
|
||||
|
||||
const token = await service.enforceWorkflowSave(context);
|
||||
const actor: PolicyActor = { kind: 'user', user: { id: 'user-1' } };
|
||||
|
||||
expect(backend.enforce).toHaveBeenCalledWith('workflowSave', context);
|
||||
const token = await service.enforceWorkflowSave(context, actor);
|
||||
|
||||
expect(backend.enforce).toHaveBeenCalledWith('workflowSave', context, actor);
|
||||
expect(token.decision).toBe(decision);
|
||||
expect(token.policyVersions).toEqual([{ scope: 'instance', version: 7 }]);
|
||||
});
|
||||
|
|
@ -205,10 +231,13 @@ describe('PolicyEnforcementService', () => {
|
|||
const service = new PolicyEnforcementService();
|
||||
|
||||
it('binds a saved workflow to its id', async () => {
|
||||
const token = await service.enforceWorkflowStart({
|
||||
workflow: savedWorkflow,
|
||||
projectId: null,
|
||||
});
|
||||
const token = await service.enforceWorkflowStart(
|
||||
{
|
||||
workflow: savedWorkflow,
|
||||
projectId: null,
|
||||
},
|
||||
{ kind: 'system', reason: 'execution' },
|
||||
);
|
||||
|
||||
expect(token.subject).toEqual({ type: 'workflow', id: 'wf-1' });
|
||||
});
|
||||
|
|
@ -216,11 +245,14 @@ describe('PolicyEnforcementService', () => {
|
|||
it('binds an unsaved workflow to a hash of its nodes', async () => {
|
||||
const unsaved: PolicedWorkflow = { id: null, name: 'New', nodes: [] };
|
||||
|
||||
const token = await service.enforceWorkflowSave({
|
||||
workflow: unsaved,
|
||||
storedWorkflow: null,
|
||||
projectId: null,
|
||||
});
|
||||
const token = await service.enforceWorkflowSave(
|
||||
{
|
||||
workflow: unsaved,
|
||||
storedWorkflow: null,
|
||||
projectId: null,
|
||||
},
|
||||
{ kind: 'system', reason: 'execution' },
|
||||
);
|
||||
|
||||
expect(token.subject.type).toBe('workflow');
|
||||
expect(token.subject.id).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
|
@ -231,11 +263,14 @@ describe('PolicyEnforcementService', () => {
|
|||
it('binds a create with a supplied id to a hash of its nodes', async () => {
|
||||
const withClientId: PolicedWorkflow = { id: 'wf-client', name: 'New', nodes: [] };
|
||||
|
||||
const token = await service.enforceWorkflowSave({
|
||||
workflow: withClientId,
|
||||
storedWorkflow: null,
|
||||
projectId: null,
|
||||
});
|
||||
const token = await service.enforceWorkflowSave(
|
||||
{
|
||||
workflow: withClientId,
|
||||
storedWorkflow: null,
|
||||
projectId: null,
|
||||
},
|
||||
{ kind: 'system', reason: 'execution' },
|
||||
);
|
||||
|
||||
expect(token.subject.id).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(token.subject.id).not.toBe('wf-client');
|
||||
|
|
@ -243,11 +278,14 @@ describe('PolicyEnforcementService', () => {
|
|||
|
||||
it('gives two unsaved workflows with different nodes different subjects', async () => {
|
||||
const enforce = async (nodes: PolicedWorkflow['nodes']) =>
|
||||
await service.enforceWorkflowSave({
|
||||
workflow: { id: null, name: 'New', nodes },
|
||||
storedWorkflow: null,
|
||||
projectId: null,
|
||||
});
|
||||
await service.enforceWorkflowSave(
|
||||
{
|
||||
workflow: { id: null, name: 'New', nodes },
|
||||
storedWorkflow: null,
|
||||
projectId: null,
|
||||
},
|
||||
{ kind: 'system', reason: 'execution' },
|
||||
);
|
||||
|
||||
const empty = await enforce([]);
|
||||
const withNode = await enforce([mock<PolicedWorkflow['nodes'][number]>({ type: 'slack' })]);
|
||||
|
|
@ -256,33 +294,42 @@ describe('PolicyEnforcementService', () => {
|
|||
});
|
||||
|
||||
it('binds a credential create to a hash of its type', async () => {
|
||||
const token = await service.enforceCredentialSave({
|
||||
credential: { id: null, type: 'slackApi' },
|
||||
storedCredential: null,
|
||||
projectId: null,
|
||||
});
|
||||
const token = await service.enforceCredentialSave(
|
||||
{
|
||||
credential: { id: null, type: 'slackApi' },
|
||||
storedCredential: null,
|
||||
projectId: null,
|
||||
},
|
||||
{ kind: 'system', reason: 'execution' },
|
||||
);
|
||||
|
||||
expect(token.subject.type).toBe('credential');
|
||||
expect(token.subject.id).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('binds a credential update to the row id', async () => {
|
||||
const token = await service.enforceCredentialSave({
|
||||
credential: { id: 'cred-1', type: 'slackApi' },
|
||||
storedCredential: { id: 'cred-1', type: 'slackApi' },
|
||||
projectId: null,
|
||||
});
|
||||
const token = await service.enforceCredentialSave(
|
||||
{
|
||||
credential: { id: 'cred-1', type: 'slackApi' },
|
||||
storedCredential: { id: 'cred-1', type: 'slackApi' },
|
||||
projectId: null,
|
||||
},
|
||||
{ kind: 'system', reason: 'execution' },
|
||||
);
|
||||
|
||||
expect(token.subject).toEqual({ type: 'credential', id: 'cred-1' });
|
||||
});
|
||||
|
||||
it('binds a credential decrypt to the credential', async () => {
|
||||
const token = await service.enforceCredentialDecrypt({
|
||||
credentialType: 'slackApi',
|
||||
credentialId: 'cred-1',
|
||||
consumer: { nodeType: 'n8n-nodes-base.slack' },
|
||||
projectId: null,
|
||||
});
|
||||
const token = await service.enforceCredentialDecrypt(
|
||||
{
|
||||
credentialType: 'slackApi',
|
||||
credentialId: 'cred-1',
|
||||
consumer: { nodeType: 'n8n-nodes-base.slack' },
|
||||
projectId: null,
|
||||
},
|
||||
{ kind: 'system', reason: 'execution' },
|
||||
);
|
||||
|
||||
expect(token.subject).toEqual({ type: 'credential', id: 'cred-1' });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { PolicedWorkflow } from '@n8n/decorators';
|
|||
|
||||
import type { OwnershipService } from '@/services/ownership.service';
|
||||
|
||||
import type { PolicyActor } from './policy-enforcement-backend';
|
||||
import type { PolicyEnforcementService } from './policy-enforcement.service';
|
||||
|
||||
/**
|
||||
|
|
@ -20,10 +21,11 @@ export async function enforceWorkflowPublishPolicy(
|
|||
policyEnforcementService: PolicyEnforcementService,
|
||||
ownershipService: OwnershipService,
|
||||
workflow: PolicedWorkflow & { id: string },
|
||||
actor: PolicyActor,
|
||||
): Promise<void> {
|
||||
if (!policyEnforcementService.hasChecksFor('workflowPublish')) return;
|
||||
|
||||
const project = await ownershipService.getWorkflowProjectCached(workflow.id);
|
||||
|
||||
await policyEnforcementService.enforceWorkflowPublish({ workflow, projectId: project.id });
|
||||
await policyEnforcementService.enforceWorkflowPublish({ workflow, projectId: project.id }, actor);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import type {
|
|||
WorkflowTransferContext,
|
||||
} from '@n8n/decorators';
|
||||
|
||||
import type { UserLike } from '@/types/user-like.types';
|
||||
|
||||
/** Which context each point is called with, mirroring `RegisteredPolicyCheck`. */
|
||||
type PolicyContexts = {
|
||||
workflowSave: WorkflowSaveContext;
|
||||
|
|
@ -23,6 +25,29 @@ type PolicyContexts = {
|
|||
|
||||
export type PolicyContext<Point extends EnforcementPoint> = PolicyContexts[Point];
|
||||
|
||||
/** Why the actor is not a user. */
|
||||
export type PolicySystemReason =
|
||||
/** Inside a run. Runs are not attributed to the user who started them. */
|
||||
| 'execution'
|
||||
| 'cli-import'
|
||||
/** Trigger registration that no request asked for directly, such as startup or a retry. */
|
||||
| 'activation'
|
||||
/** The publication outbox. The publish request itself was checked with its user. */
|
||||
| 'publication'
|
||||
/** An integration that refreshes its own credential. */
|
||||
| 'integration'
|
||||
/** A log streaming destination that reads its own credential. */
|
||||
| 'log-streaming';
|
||||
|
||||
/**
|
||||
* Who asked for the policed action, named on the block audit event and never shown to checks.
|
||||
* An agent lands later as an additive `{ kind: 'agent'; agentId: string; onBehalfOf: UserLike |
|
||||
* null }`, with `onBehalfOf` as the audited user, so `userId` keeps meaning the accountable human.
|
||||
*/
|
||||
export type PolicyActor =
|
||||
| { kind: 'user'; user: UserLike }
|
||||
| { kind: 'system'; reason: PolicySystemReason; executionId?: string };
|
||||
|
||||
/**
|
||||
* What the policy infrastructure module registers into the proxy.
|
||||
*
|
||||
|
|
@ -34,6 +59,7 @@ export interface PolicyEnforcementBackend {
|
|||
enforce<Point extends EnforcementPoint>(
|
||||
point: Point,
|
||||
context: PolicyContext<Point>,
|
||||
actor: PolicyActor,
|
||||
): Promise<PolicyDecision>;
|
||||
|
||||
evaluate<Point extends EnforcementPoint>(
|
||||
|
|
|
|||
|
|
@ -21,7 +21,11 @@ import { mintPolicyCleared } from '@n8n/decorators/policy-internal';
|
|||
import { Service } from '@n8n/di';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import type { PolicyContext, PolicyEnforcementBackend } from './policy-enforcement-backend';
|
||||
import type {
|
||||
PolicyActor,
|
||||
PolicyContext,
|
||||
PolicyEnforcementBackend,
|
||||
} from './policy-enforcement-backend';
|
||||
import { hasViolations, PolicyViolationError } from './policy-violation.error';
|
||||
|
||||
/** Fresh each time — `violations` is mutable. */
|
||||
|
|
@ -57,14 +61,17 @@ export class PolicyEnforcementService {
|
|||
return this.implementation?.hasChecksFor(point) ?? false;
|
||||
}
|
||||
|
||||
async enforceWorkflowSave(context: WorkflowSaveContext): Promise<PolicyCleared<'workflowSave'>> {
|
||||
async enforceWorkflowSave(
|
||||
context: WorkflowSaveContext,
|
||||
actor: PolicyActor,
|
||||
): Promise<PolicyCleared<'workflowSave'>> {
|
||||
// A create has no committed id to bind to, so it binds to its content — even when a client
|
||||
// supplied an id, which is no proof of what was checked. An update binds to the row id.
|
||||
const subject =
|
||||
context.storedWorkflow === null
|
||||
? workflowContentSubject(context.workflow)
|
||||
: workflowSubject(context.workflow);
|
||||
return await this.enforce('workflowSave', context, subject);
|
||||
return await this.enforce('workflowSave', context, actor, subject);
|
||||
}
|
||||
|
||||
async evaluateWorkflowSave(context: WorkflowSaveContext): Promise<PolicyDecision> {
|
||||
|
|
@ -73,8 +80,9 @@ export class PolicyEnforcementService {
|
|||
|
||||
async enforceWorkflowPublish(
|
||||
context: WorkflowPublishContext,
|
||||
actor: PolicyActor,
|
||||
): Promise<PolicyCleared<'workflowPublish'>> {
|
||||
return await this.enforce('workflowPublish', context, workflowSubject(context.workflow));
|
||||
return await this.enforce('workflowPublish', context, actor, workflowSubject(context.workflow));
|
||||
}
|
||||
|
||||
async evaluateWorkflowPublish(context: WorkflowPublishContext): Promise<PolicyDecision> {
|
||||
|
|
@ -83,8 +91,9 @@ export class PolicyEnforcementService {
|
|||
|
||||
async enforceWorkflowStart(
|
||||
context: WorkflowStartContext,
|
||||
actor: PolicyActor,
|
||||
): Promise<PolicyCleared<'workflowStart'>> {
|
||||
return await this.enforce('workflowStart', context, workflowSubject(context.workflow));
|
||||
return await this.enforce('workflowStart', context, actor, workflowSubject(context.workflow));
|
||||
}
|
||||
|
||||
async evaluateWorkflowStart(context: WorkflowStartContext): Promise<PolicyDecision> {
|
||||
|
|
@ -93,8 +102,14 @@ export class PolicyEnforcementService {
|
|||
|
||||
async enforceWorkflowTransfer(
|
||||
context: WorkflowTransferContext,
|
||||
actor: PolicyActor,
|
||||
): Promise<PolicyCleared<'workflowTransfer'>> {
|
||||
return await this.enforce('workflowTransfer', context, workflowSubject(context.workflow));
|
||||
return await this.enforce(
|
||||
'workflowTransfer',
|
||||
context,
|
||||
actor,
|
||||
workflowSubject(context.workflow),
|
||||
);
|
||||
}
|
||||
|
||||
async evaluateWorkflowTransfer(context: WorkflowTransferContext): Promise<PolicyDecision> {
|
||||
|
|
@ -103,13 +118,14 @@ export class PolicyEnforcementService {
|
|||
|
||||
async enforceCredentialSave(
|
||||
context: CredentialSaveContext,
|
||||
actor: PolicyActor,
|
||||
): Promise<PolicyCleared<'credentialSave'>> {
|
||||
// Same rule as a workflow save: a create binds to its content, an update to the row id.
|
||||
const subject =
|
||||
context.storedCredential === null
|
||||
? credentialContentSubject(context.credential)
|
||||
: credentialSubject(context.credential);
|
||||
return await this.enforce('credentialSave', context, subject);
|
||||
return await this.enforce('credentialSave', context, actor, subject);
|
||||
}
|
||||
|
||||
async evaluateCredentialSave(context: CredentialSaveContext): Promise<PolicyDecision> {
|
||||
|
|
@ -118,8 +134,9 @@ export class PolicyEnforcementService {
|
|||
|
||||
async enforceCredentialDecrypt(
|
||||
context: CredentialDecryptContext,
|
||||
actor: PolicyActor,
|
||||
): Promise<PolicyCleared<'credentialDecrypt'>> {
|
||||
return await this.enforce('credentialDecrypt', context, {
|
||||
return await this.enforce('credentialDecrypt', context, actor, {
|
||||
type: 'credential',
|
||||
id: context.credentialId,
|
||||
});
|
||||
|
|
@ -131,12 +148,13 @@ export class PolicyEnforcementService {
|
|||
|
||||
async enforceContentImport(
|
||||
context: ContentImportContext,
|
||||
actor: PolicyActor,
|
||||
): Promise<PolicyCleared<'contentImport'>> {
|
||||
const subject =
|
||||
'workflow' in context
|
||||
? workflowSubject(context.workflow)
|
||||
: credentialSubject(context.credential);
|
||||
return await this.enforce('contentImport', context, subject);
|
||||
return await this.enforce('contentImport', context, actor, subject);
|
||||
}
|
||||
|
||||
async evaluateContentImport(context: ContentImportContext): Promise<PolicyDecision> {
|
||||
|
|
@ -146,10 +164,11 @@ export class PolicyEnforcementService {
|
|||
private async enforce<Point extends EnforcementPoint>(
|
||||
point: Point,
|
||||
context: PolicyContext<Point>,
|
||||
actor: PolicyActor,
|
||||
subject: PolicySubject,
|
||||
): Promise<PolicyCleared<Point>> {
|
||||
const decision = this.implementation
|
||||
? await this.implementation.enforce(point, context)
|
||||
? await this.implementation.enforce(point, context, actor)
|
||||
: emptyDecision();
|
||||
|
||||
if (hasViolations(decision.violations)) {
|
||||
|
|
|
|||
|
|
@ -325,6 +325,7 @@ export class CredentialsPublicController {
|
|||
const updatedCredential = await this.credentialsService.update(
|
||||
credentialId,
|
||||
updatePayload,
|
||||
{ kind: 'user', user: req.user },
|
||||
decryptedDataForDeps,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -173,11 +173,14 @@ export class ImportService {
|
|||
|
||||
let cleared: PolicyCleared<'contentImport'>;
|
||||
try {
|
||||
cleared = await this.policyEnforcementService.enforceContentImport({
|
||||
workflow: { id: workflow.id ?? null, name: workflow.name, nodes: workflow.nodes },
|
||||
projectId: policyProjectId,
|
||||
transport: 'cli',
|
||||
});
|
||||
cleared = await this.policyEnforcementService.enforceContentImport(
|
||||
{
|
||||
workflow: { id: workflow.id ?? null, name: workflow.name, nodes: workflow.nodes },
|
||||
projectId: policyProjectId,
|
||||
transport: 'cli',
|
||||
},
|
||||
{ kind: 'system', reason: 'cli-import' },
|
||||
);
|
||||
} catch (error) {
|
||||
// A blocked workflow is skipped, not fatal — the operator gets the rest of the batch.
|
||||
// A check that broke is not scoped to one workflow, so it fails the whole import
|
||||
|
|
|
|||
10
packages/cli/src/types/user-like.types.ts
Normal file
10
packages/cli/src/types/user-like.types.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/** The user fields an event or a policy actor carries. Only `id` is guaranteed. */
|
||||
export type UserLike = {
|
||||
id: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
role?: {
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
|
|
@ -526,11 +526,14 @@ describe('WorkflowCreationService', () => {
|
|||
projectId: 'project-1',
|
||||
});
|
||||
|
||||
expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith({
|
||||
workflow: { id: null, name: 'My workflow', nodes: [] },
|
||||
storedWorkflow: null,
|
||||
projectId: 'project-1',
|
||||
});
|
||||
expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
workflow: { id: null, name: 'My workflow', nodes: [] },
|
||||
storedWorkflow: null,
|
||||
projectId: 'project-1',
|
||||
},
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the user's personal project when no project is given", async () => {
|
||||
|
|
@ -543,6 +546,7 @@ describe('WorkflowCreationService', () => {
|
|||
|
||||
expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ projectId: 'personal-project' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -492,10 +492,13 @@ describe('EnterpriseWorkflowService', () => {
|
|||
|
||||
await service.transferWorkflow(user, 'wf-1', 'proj-dest');
|
||||
|
||||
expect(policyEnforcementService.enforceWorkflowTransfer).toHaveBeenCalledExactlyOnceWith({
|
||||
workflow,
|
||||
targetProjectId: destinationProject.id,
|
||||
});
|
||||
expect(policyEnforcementService.enforceWorkflowTransfer).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
workflow,
|
||||
targetProjectId: destinationProject.id,
|
||||
},
|
||||
{ kind: 'user', user },
|
||||
);
|
||||
});
|
||||
|
||||
it('proceeds with the transfer unchanged when the policy check clears', async () => {
|
||||
|
|
|
|||
|
|
@ -1883,7 +1883,7 @@ describe('WorkflowService', () => {
|
|||
arrangeSuccessfulActivation(workflow);
|
||||
workflowHistoryServiceMock.getVersion.mockResolvedValue(versionToActivate);
|
||||
|
||||
await workflowService.activateWorkflow(mock<User>(), WORKFLOW_ID, {
|
||||
await workflowService.activateWorkflow(mock<User>({ id: 'user-1' }), WORKFLOW_ID, {
|
||||
versionId: TARGET_VERSION_ID,
|
||||
});
|
||||
|
||||
|
|
@ -1896,6 +1896,7 @@ describe('WorkflowService', () => {
|
|||
},
|
||||
projectId: 'project-1',
|
||||
},
|
||||
{ kind: 'user', user: expect.objectContaining({ id: 'user-1' }) },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1913,7 +1914,7 @@ describe('WorkflowService', () => {
|
|||
candidate.nodes.push({ name: 'Injected by hook' } as INode);
|
||||
});
|
||||
|
||||
await workflowService.activateWorkflow(mock<User>(), WORKFLOW_ID, {
|
||||
await workflowService.activateWorkflow(mock<User>({ id: 'user-1' }), WORKFLOW_ID, {
|
||||
versionId: TARGET_VERSION_ID,
|
||||
});
|
||||
|
||||
|
|
@ -1921,6 +1922,7 @@ describe('WorkflowService', () => {
|
|||
expect.objectContaining({
|
||||
workflow: expect.objectContaining({ nodes: originalNodes }),
|
||||
}),
|
||||
{ kind: 'user', user: expect.objectContaining({ id: 'user-1' }) },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1937,7 +1939,7 @@ describe('WorkflowService', () => {
|
|||
candidate.nodes = [{ name: 'Rewritten by hook' } as INode];
|
||||
});
|
||||
|
||||
await workflowService.activateWorkflow(mock<User>(), WORKFLOW_ID, {
|
||||
await workflowService.activateWorkflow(mock<User>({ id: 'user-1' }), WORKFLOW_ID, {
|
||||
versionId: TARGET_VERSION_ID,
|
||||
});
|
||||
|
||||
|
|
@ -1945,6 +1947,7 @@ describe('WorkflowService', () => {
|
|||
expect.objectContaining({
|
||||
workflow: expect.objectContaining({ nodes: versionToActivate.nodes }),
|
||||
}),
|
||||
{ kind: 'user', user: expect.objectContaining({ id: 'user-1' }) },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -2740,11 +2743,14 @@ describe('WorkflowService', () => {
|
|||
WORKFLOW_ID,
|
||||
);
|
||||
|
||||
expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith({
|
||||
workflow: { id: WORKFLOW_ID, name: 'New name', nodes: submittedNodes },
|
||||
storedWorkflow: { id: WORKFLOW_ID, name: 'Stored name', nodes: storedNodes },
|
||||
projectId: 'project-1',
|
||||
});
|
||||
expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
workflow: { id: WORKFLOW_ID, name: 'New name', nodes: submittedNodes },
|
||||
storedWorkflow: { id: WORKFLOW_ID, name: 'Stored name', nodes: storedNodes },
|
||||
projectId: 'project-1',
|
||||
},
|
||||
{ kind: 'user', user: expect.objectContaining({ id: 'user-1' }) },
|
||||
);
|
||||
});
|
||||
|
||||
// A partial update (e.g. renaming only) omits `nodes` entirely. The check still needs
|
||||
|
|
@ -2756,11 +2762,14 @@ describe('WorkflowService', () => {
|
|||
WORKFLOW_ID,
|
||||
);
|
||||
|
||||
expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith({
|
||||
workflow: { id: WORKFLOW_ID, name: 'Stored name', nodes: storedNodes },
|
||||
storedWorkflow: { id: WORKFLOW_ID, name: 'Stored name', nodes: storedNodes },
|
||||
projectId: 'project-1',
|
||||
});
|
||||
expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
workflow: { id: WORKFLOW_ID, name: 'Stored name', nodes: storedNodes },
|
||||
storedWorkflow: { id: WORKFLOW_ID, name: 'Stored name', nodes: storedNodes },
|
||||
projectId: 'project-1',
|
||||
},
|
||||
{ kind: 'user', user: expect.objectContaining({ id: 'user-1' }) },
|
||||
);
|
||||
});
|
||||
|
||||
it('updates the workflow unchanged when the check clears', async () => {
|
||||
|
|
|
|||
|
|
@ -301,14 +301,17 @@ describe('WorkflowPublicationApplier', () => {
|
|||
|
||||
await applier.apply(makeRecord(), abort);
|
||||
|
||||
expect(policyEnforcementService.enforceWorkflowPublish).toHaveBeenCalledExactlyOnceWith({
|
||||
workflow: {
|
||||
id: 'wf-1',
|
||||
name: 'My workflow',
|
||||
nodes: versionWithNodes.nodes,
|
||||
expect(policyEnforcementService.enforceWorkflowPublish).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
workflow: {
|
||||
id: 'wf-1',
|
||||
name: 'My workflow',
|
||||
nodes: versionWithNodes.nodes,
|
||||
},
|
||||
projectId: 'project-1',
|
||||
},
|
||||
projectId: 'project-1',
|
||||
});
|
||||
{ kind: 'system', reason: 'publication' },
|
||||
);
|
||||
});
|
||||
|
||||
// The no-change branch still advances the published version, which running
|
||||
|
|
|
|||
|
|
@ -443,11 +443,12 @@ export class WorkflowPublicationApplier {
|
|||
newVersion: WorkflowHistory,
|
||||
): Promise<PublicationResult | null> {
|
||||
try {
|
||||
await enforceWorkflowPublishPolicy(this.policyEnforcementService, this.ownershipService, {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
nodes: newVersion.nodes,
|
||||
});
|
||||
await enforceWorkflowPublishPolicy(
|
||||
this.policyEnforcementService,
|
||||
this.ownershipService,
|
||||
{ id: workflow.id, name: workflow.name, nodes: newVersion.nodes },
|
||||
{ kind: 'system', reason: 'publication' },
|
||||
);
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -296,11 +296,14 @@ export class WorkflowCreationService {
|
|||
// while editing rather than at runtime. No stored workflow: this one is new.
|
||||
// The clearance binds to the node hash while the row has no id, so nothing below
|
||||
// may touch `newWorkflow.nodes` — the sealed write would reject it.
|
||||
const cleared = await this.policyEnforcementService.enforceWorkflowSave({
|
||||
workflow: { id: newWorkflow.id ?? null, name: newWorkflow.name, nodes: newWorkflow.nodes },
|
||||
storedWorkflow: null,
|
||||
projectId: effectiveProjectId,
|
||||
});
|
||||
const cleared = await this.policyEnforcementService.enforceWorkflowSave(
|
||||
{
|
||||
workflow: { id: newWorkflow.id ?? null, name: newWorkflow.name, nodes: newWorkflow.nodes },
|
||||
storedWorkflow: null,
|
||||
projectId: effectiveProjectId,
|
||||
},
|
||||
{ kind: 'user', user },
|
||||
);
|
||||
|
||||
const floor = batchContext?.redactionFloor ?? (await this.readActiveRedactionFloor());
|
||||
|
||||
|
|
|
|||
|
|
@ -520,10 +520,10 @@ export class EnterpriseWorkflowService {
|
|||
}
|
||||
|
||||
// 6. validate against the destination project's policy
|
||||
await this.policyEnforcementService.enforceWorkflowTransfer({
|
||||
workflow,
|
||||
targetProjectId: destinationProject.id,
|
||||
});
|
||||
await this.policyEnforcementService.enforceWorkflowTransfer(
|
||||
{ workflow, targetProjectId: destinationProject.id },
|
||||
{ kind: 'user', user },
|
||||
);
|
||||
|
||||
const wasActive = this.isActiveWorkflow(workflow);
|
||||
|
||||
|
|
@ -679,7 +679,9 @@ export class EnterpriseWorkflowService {
|
|||
|
||||
private async attemptWorkflowReactivation(workflowId: string, versionId: string, userId: string) {
|
||||
try {
|
||||
await this.activeWorkflowManager.add(workflowId, 'update');
|
||||
await this.activeWorkflowManager.add(workflowId, 'update', undefined, {
|
||||
actor: { kind: 'user', user: { id: userId } },
|
||||
});
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -612,15 +612,18 @@ export class WorkflowService {
|
|||
// Gate the save on policy before persisting, so the author learns about a violation
|
||||
// while editing rather than at runtime. Carries the stored workflow alongside the
|
||||
// submitted one so a check can restrict its verdict to what this save adds.
|
||||
const cleared = await this.policyEnforcementService.enforceWorkflowSave({
|
||||
workflow: {
|
||||
id: workflow.id,
|
||||
name: workflowUpdateData.name ?? workflow.name,
|
||||
nodes: workflowUpdateData.nodes ?? workflow.nodes,
|
||||
const cleared = await this.policyEnforcementService.enforceWorkflowSave(
|
||||
{
|
||||
workflow: {
|
||||
id: workflow.id,
|
||||
name: workflowUpdateData.name ?? workflow.name,
|
||||
nodes: workflowUpdateData.nodes ?? workflow.nodes,
|
||||
},
|
||||
storedWorkflow: { id: workflow.id, name: workflow.name, nodes: workflow.nodes },
|
||||
projectId: ownerProject.id,
|
||||
},
|
||||
storedWorkflow: { id: workflow.id, name: workflow.name, nodes: workflow.nodes },
|
||||
projectId: ownerProject.id,
|
||||
});
|
||||
{ kind: 'user', user },
|
||||
);
|
||||
|
||||
const fieldsToUpdate = [
|
||||
'name',
|
||||
|
|
@ -793,7 +796,9 @@ export class WorkflowService {
|
|||
): Promise<void> {
|
||||
let didPublish = false;
|
||||
try {
|
||||
await this.activeWorkflowManager.add(workflowId, mode);
|
||||
await this.activeWorkflowManager.add(workflowId, mode, undefined, {
|
||||
actor: { kind: 'user', user },
|
||||
});
|
||||
didPublish = true;
|
||||
} catch (error) {
|
||||
// Activation failed partway through. It may already have registered triggers
|
||||
|
|
@ -1030,11 +1035,12 @@ export class WorkflowService {
|
|||
|
||||
// Polices what gets registered — the version row, not the hook's candidate.
|
||||
// Enforced on a same-version republish too.
|
||||
await enforceWorkflowPublishPolicy(this.policyEnforcementService, this.ownershipService, {
|
||||
id: workflowId,
|
||||
name: workflow.name,
|
||||
nodes: nodesToPublish,
|
||||
});
|
||||
await enforceWorkflowPublishPolicy(
|
||||
this.policyEnforcementService,
|
||||
this.ownershipService,
|
||||
{ id: workflowId, name: workflow.name, nodes: nodesToPublish },
|
||||
{ kind: 'user', user },
|
||||
);
|
||||
|
||||
// re-applying the already-published version (e.g. a settings-only update)
|
||||
// publishes no new version, so the review gate must not block it.
|
||||
|
|
|
|||
|
|
@ -150,6 +150,10 @@ describe('init()', () => {
|
|||
await activeWorkflowManager.init();
|
||||
|
||||
expect(enforceSpy).toHaveBeenCalledTimes(2);
|
||||
expect(enforceSpy).toHaveBeenCalledWith(expect.anything(), {
|
||||
kind: 'system',
|
||||
reason: 'activation',
|
||||
});
|
||||
expect(activeWorkflowManager.allActiveInMemory()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -855,11 +855,11 @@ describe('content-import policy', () => {
|
|||
const service = Container.get(PolicyEnforcementService);
|
||||
// The prototype method, not `service.enforceContentImport`: that is the spy itself.
|
||||
const clearance = PolicyEnforcementService.prototype.enforceContentImport;
|
||||
enforceContentImport.mockImplementation(async (context: ContentImportContext) => {
|
||||
enforceContentImport.mockImplementation(async (context: ContentImportContext, actor) => {
|
||||
if ('credential' in context && context.credential.type === blockedType) {
|
||||
throw new PolicyViolationError([violation]);
|
||||
}
|
||||
return await clearance.call(service, context);
|
||||
return await clearance.call(service, context, actor);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -893,11 +893,14 @@ describe('content-import policy', () => {
|
|||
|
||||
await command.run([`--input=${inputPath}`]);
|
||||
|
||||
expect(enforceContentImport).toHaveBeenCalledWith({
|
||||
credential: { id: 'blocked', type: 'slackApi' },
|
||||
projectId: ownerProject.id,
|
||||
transport: 'cli',
|
||||
});
|
||||
expect(enforceContentImport).toHaveBeenCalledWith(
|
||||
{
|
||||
credential: { id: 'blocked', type: 'slackApi' },
|
||||
projectId: ownerProject.id,
|
||||
transport: 'cli',
|
||||
},
|
||||
{ kind: 'system', reason: 'cli-import' },
|
||||
);
|
||||
const after = {
|
||||
credentials: await getAllCredentials(),
|
||||
sharings: await getAllSharedCredentials(),
|
||||
|
|
@ -921,11 +924,14 @@ describe('content-import policy', () => {
|
|||
// Flagless re-import: the batch target is the owner's project, but the row stays the member's.
|
||||
await command.run([`--input=${inputPath}`]);
|
||||
|
||||
expect(enforceContentImport).toHaveBeenCalledWith({
|
||||
credential: { id: 'existing', type: 'aws' },
|
||||
projectId: memberProject.id,
|
||||
transport: 'cli',
|
||||
});
|
||||
expect(enforceContentImport).toHaveBeenCalledWith(
|
||||
{
|
||||
credential: { id: 'existing', type: 'aws' },
|
||||
projectId: memberProject.id,
|
||||
transport: 'cli',
|
||||
},
|
||||
{ kind: 'system', reason: 'cli-import' },
|
||||
);
|
||||
});
|
||||
|
||||
test('fails the import when the policy layer errors', async () => {
|
||||
|
|
|
|||
|
|
@ -525,7 +525,9 @@ describe('--activeState flag', () => {
|
|||
expect(inactiveWorkflow).toMatchObject({ active: false, activeVersionId: null });
|
||||
|
||||
const activeWorkflowManager = Container.get(ActiveWorkflowManager);
|
||||
expect(activeWorkflowManager.add).toHaveBeenCalledWith('998', 'activate');
|
||||
expect(activeWorkflowManager.add).toHaveBeenCalledWith('998', 'activate', undefined, {
|
||||
actor: expect.objectContaining({ kind: 'user' }),
|
||||
});
|
||||
expect(activeWorkflowManager.add).not.toHaveBeenCalledWith('999', expect.anything());
|
||||
});
|
||||
|
||||
|
|
@ -558,7 +560,9 @@ describe('--activeState flag', () => {
|
|||
|
||||
const activeWorkflowManager = Container.get(ActiveWorkflowManager);
|
||||
expect(activeWorkflowManager.remove).toHaveBeenCalledWith('998');
|
||||
expect(activeWorkflowManager.add).toHaveBeenLastCalledWith('998', 'activate');
|
||||
expect(activeWorkflowManager.add).toHaveBeenLastCalledWith('998', 'activate', undefined, {
|
||||
actor: expect.objectContaining({ kind: 'user' }),
|
||||
});
|
||||
|
||||
const publishHistoryRepo = Container.get(WorkflowPublishHistoryRepository);
|
||||
expect(publishHistoryRepo.addRecord).toHaveBeenCalledTimes(3);
|
||||
|
|
|
|||
|
|
@ -88,11 +88,14 @@ describe('DbLockService', () => {
|
|||
const settingsKey = `test.atomic-settings.${randomUUID()}`;
|
||||
|
||||
// The credential writes are sealed; with no check registered, enforcement clears.
|
||||
const policyCleared = await Container.get(PolicyEnforcementService).enforceCredentialSave({
|
||||
credential: { id: null, type: 'openAiApi' },
|
||||
storedCredential: null,
|
||||
projectId: null,
|
||||
});
|
||||
const policyCleared = await Container.get(PolicyEnforcementService).enforceCredentialSave(
|
||||
{
|
||||
credential: { id: null, type: 'openAiApi' },
|
||||
storedCredential: null,
|
||||
projectId: null,
|
||||
},
|
||||
{ kind: 'system', reason: 'integration' },
|
||||
);
|
||||
|
||||
await expect(
|
||||
dbLockService.withLockContext(DbLock.TEST, async (ctx) => {
|
||||
|
|
@ -130,11 +133,14 @@ describe('DbLockService', () => {
|
|||
}),
|
||||
);
|
||||
const settingsKey = `test.atomic-settings.${randomUUID()}`;
|
||||
const policyCleared = await Container.get(PolicyEnforcementService).enforceCredentialSave({
|
||||
credential: { id: credential.id, type: credential.type },
|
||||
storedCredential: { id: credential.id, type: credential.type },
|
||||
projectId: null,
|
||||
});
|
||||
const policyCleared = await Container.get(PolicyEnforcementService).enforceCredentialSave(
|
||||
{
|
||||
credential: { id: credential.id, type: credential.type },
|
||||
storedCredential: { id: credential.id, type: credential.type },
|
||||
projectId: null,
|
||||
},
|
||||
{ kind: 'system', reason: 'integration' },
|
||||
);
|
||||
|
||||
await expect(
|
||||
dbLockService.withLockContext(DbLock.TEST, async (ctx) => {
|
||||
|
|
|
|||
|
|
@ -112,8 +112,8 @@ describe('SourceControlImportService', () => {
|
|||
// The repository verifies the token, so it has to be a real one. With no backend
|
||||
// registered the real service clears everything, which is what a default pull does.
|
||||
mockPolicyEnforcementService.enforceContentImport.mockImplementation(
|
||||
async (context) =>
|
||||
await Container.get(PolicyEnforcementService).enforceContentImport(context),
|
||||
async (context, actor) =>
|
||||
await Container.get(PolicyEnforcementService).enforceContentImport(context, actor),
|
||||
);
|
||||
service = new SourceControlImportService(
|
||||
mock(),
|
||||
|
|
@ -2097,11 +2097,14 @@ describe('SourceControlImportService', () => {
|
|||
);
|
||||
|
||||
expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledTimes(1);
|
||||
expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith({
|
||||
workflow: { id: workflow.id, name: workflow.name, nodes: workflow.nodes },
|
||||
projectId: importingUserProject.id,
|
||||
transport: 'source-control',
|
||||
});
|
||||
expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith(
|
||||
{
|
||||
workflow: { id: workflow.id, name: workflow.name, nodes: workflow.nodes },
|
||||
projectId: importingUserProject.id,
|
||||
transport: 'source-control',
|
||||
},
|
||||
{ kind: 'user', user: { id: importingUser.id } },
|
||||
);
|
||||
});
|
||||
|
||||
it('skips a blocked workflow, attaches the reason, and persists nothing', async () => {
|
||||
|
|
|
|||
|
|
@ -68,8 +68,8 @@ describe('ImportService', () => {
|
|||
// The repository verifies the token, so it has to be a real one. With no backend
|
||||
// registered the real service clears everything, which is what a default import does.
|
||||
mockPolicyEnforcementService.enforceContentImport.mockImplementation(
|
||||
async (context) =>
|
||||
await Container.get(PolicyEnforcementService).enforceContentImport(context),
|
||||
async (context, actor) =>
|
||||
await Container.get(PolicyEnforcementService).enforceContentImport(context, actor),
|
||||
);
|
||||
|
||||
importService = new ImportService(
|
||||
|
|
@ -495,7 +495,10 @@ describe('ImportService', () => {
|
|||
|
||||
describe('content-import policy', () => {
|
||||
const clearance = async (context: ContentImportContext) =>
|
||||
await Container.get(PolicyEnforcementService).enforceContentImport(context);
|
||||
await Container.get(PolicyEnforcementService).enforceContentImport(context, {
|
||||
kind: 'system',
|
||||
reason: 'cli-import',
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockPolicyEnforcementService.enforceContentImport.mockClear();
|
||||
|
|
@ -510,16 +513,22 @@ describe('ImportService', () => {
|
|||
await importService.importWorkflows([first, second], ownerPersonalProject.id, owner.id, {});
|
||||
|
||||
expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledTimes(2);
|
||||
expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith({
|
||||
workflow: { id: first.id, name: first.name, nodes: first.nodes },
|
||||
projectId: ownerPersonalProject.id,
|
||||
transport: 'cli',
|
||||
});
|
||||
expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith({
|
||||
workflow: { id: second.id, name: second.name, nodes: second.nodes },
|
||||
projectId: ownerPersonalProject.id,
|
||||
transport: 'cli',
|
||||
});
|
||||
expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith(
|
||||
{
|
||||
workflow: { id: first.id, name: first.name, nodes: first.nodes },
|
||||
projectId: ownerPersonalProject.id,
|
||||
transport: 'cli',
|
||||
},
|
||||
{ kind: 'system', reason: 'cli-import' },
|
||||
);
|
||||
expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith(
|
||||
{
|
||||
workflow: { id: second.id, name: second.name, nodes: second.nodes },
|
||||
projectId: ownerPersonalProject.id,
|
||||
transport: 'cli',
|
||||
},
|
||||
{ kind: 'system', reason: 'cli-import' },
|
||||
);
|
||||
});
|
||||
|
||||
test('skips a blocked workflow, reports it, and still imports the rest of the batch', async () => {
|
||||
|
|
@ -591,15 +600,18 @@ describe('ImportService', () => {
|
|||
{},
|
||||
);
|
||||
|
||||
expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith({
|
||||
workflow: {
|
||||
id: workflowToReimport.id,
|
||||
name: workflowToReimport.name,
|
||||
nodes: workflowToReimport.nodes,
|
||||
expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith(
|
||||
{
|
||||
workflow: {
|
||||
id: workflowToReimport.id,
|
||||
name: workflowToReimport.name,
|
||||
nodes: workflowToReimport.nodes,
|
||||
},
|
||||
projectId: memberPersonalProject.id,
|
||||
transport: 'cli',
|
||||
},
|
||||
projectId: memberPersonalProject.id,
|
||||
transport: 'cli',
|
||||
});
|
||||
{ kind: 'system', reason: 'cli-import' },
|
||||
);
|
||||
});
|
||||
|
||||
// A check that cannot answer is an infrastructure fault, not a property of one workflow.
|
||||
|
|
|
|||
|
|
@ -173,11 +173,14 @@ describe('InstanceAiSettingsService (integration)', () => {
|
|||
);
|
||||
|
||||
// A real clearance, so the row is protected by its usage scope alone, not by the seal.
|
||||
const policyCleared = await Container.get(PolicyEnforcementService).enforceCredentialSave({
|
||||
credential: { id: workflowCredential.id, type: workflowCredential.type },
|
||||
storedCredential: { id: workflowCredential.id, type: workflowCredential.type },
|
||||
projectId: null,
|
||||
});
|
||||
const policyCleared = await Container.get(PolicyEnforcementService).enforceCredentialSave(
|
||||
{
|
||||
credential: { id: workflowCredential.id, type: workflowCredential.type },
|
||||
storedCredential: { id: workflowCredential.id, type: workflowCredential.type },
|
||||
projectId: null,
|
||||
},
|
||||
{ kind: 'system', reason: 'integration' },
|
||||
);
|
||||
|
||||
const updated = await credentialsRepository.updateInstanceCredential(
|
||||
workflowCredential.id,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { PolicyCheck, PolicyCheckMetadata } from '@n8n/decorators';
|
|||
import { Container } from '@n8n/di';
|
||||
|
||||
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
|
||||
import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay';
|
||||
|
||||
import { cleanupRolesAndScopes } from '../shared/db/roles';
|
||||
import { createOwnerWithApiKey } from '../shared/db/users';
|
||||
|
|
@ -68,6 +70,12 @@ let owner: User;
|
|||
let editorAgent: SuperAgentTest;
|
||||
let publicApiAgent: SuperAgentTest;
|
||||
let workflowRepository: WorkflowRepository;
|
||||
const eventBus = mockInstance(MessageEventBus);
|
||||
|
||||
const blockEvents = () =>
|
||||
eventBus.sendAuditEvent.mock.calls
|
||||
.map(([options]) => options)
|
||||
.filter((options) => options.eventName === 'n8n.audit.policy.decision.blocked');
|
||||
|
||||
const truncate = async () =>
|
||||
await testDb.truncate([
|
||||
|
|
@ -86,6 +94,7 @@ const truncate = async () =>
|
|||
beforeAll(async () => {
|
||||
await utils.initNodeTypes();
|
||||
workflowRepository = Container.get(WorkflowRepository);
|
||||
Container.get(LogStreamingEventRelay).init();
|
||||
|
||||
// The public API's OpenAPI validator compiles the whole spec on the first authenticated
|
||||
// request that reaches it (~3s). Spend it here, under the 30s hook timeout, so it can't
|
||||
|
|
@ -99,6 +108,7 @@ beforeAll(async () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
mode = 'allow';
|
||||
eventBus.sendAuditEvent.mockClear();
|
||||
await truncate();
|
||||
await cleanupRolesAndScopes();
|
||||
|
||||
|
|
@ -128,6 +138,31 @@ describe('with a check that denies every save', () => {
|
|||
await expect(workflowRepository.count()).resolves.toBe(0);
|
||||
});
|
||||
|
||||
test('editor create sends one audit event naming the point, violation and user', async () => {
|
||||
await editorAgent.post('/workflows').send(editorPayload);
|
||||
|
||||
expect(blockEvents()).toHaveLength(1);
|
||||
expect(blockEvents()[0].payload).toMatchObject({
|
||||
point: 'workflowSave',
|
||||
outcome: 'violation',
|
||||
checkIds: expect.arrayContaining([CHECK_ID]),
|
||||
violations: [
|
||||
{
|
||||
checkId: CHECK_ID,
|
||||
kind: DENIAL.kind,
|
||||
subject: DENIAL.subject,
|
||||
subjectType: DENIAL.subjectType,
|
||||
scope: null,
|
||||
matchedRuleId: null,
|
||||
},
|
||||
],
|
||||
policyVersions: [],
|
||||
actorType: 'user',
|
||||
userId: owner.id,
|
||||
_email: owner.email,
|
||||
});
|
||||
});
|
||||
|
||||
test('editor update fails with 403 and the violations', async () => {
|
||||
const workflow = await createWorkflow({ name: 'Stored name' }, owner);
|
||||
|
||||
|
|
@ -219,4 +254,12 @@ test('a check that breaks blocks the save without leaking violations', async ()
|
|||
const { meta } = response.body as { meta: Record<string, unknown> };
|
||||
expect(meta.violations).toBeUndefined();
|
||||
await expect(workflowRepository.count()).resolves.toBe(0);
|
||||
|
||||
expect(blockEvents()).toHaveLength(1);
|
||||
expect(blockEvents()[0].payload).toMatchObject({
|
||||
outcome: 'checkFailure',
|
||||
correlationIds: meta.correlationIds,
|
||||
userId: owner.id,
|
||||
});
|
||||
expect(JSON.stringify(blockEvents()[0].payload)).not.toContain('failed on purpose');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -372,14 +372,17 @@ describe('activateWorkflow()', () => {
|
|||
|
||||
const updatedWorkflow = await workflowService.activateWorkflow(owner, workflow.id);
|
||||
|
||||
expect(enforceSpy).toHaveBeenCalledExactlyOnceWith({
|
||||
workflow: {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
nodes: expect.any(Array),
|
||||
expect(enforceSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
workflow: {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
nodes: expect.any(Array),
|
||||
},
|
||||
projectId: expect.any(String),
|
||||
},
|
||||
projectId: expect.any(String),
|
||||
});
|
||||
{ kind: 'user', user: expect.objectContaining({ id: owner.id }) },
|
||||
);
|
||||
expect(updatedWorkflow.activeVersionId).toBe(workflow.versionId);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1859,7 +1859,9 @@ describe('PUT /:workflowId/transfer', () => {
|
|||
expect(response.body).toEqual({});
|
||||
|
||||
expect(activeWorkflowManager.remove).toHaveBeenCalledWith(workflow.id);
|
||||
expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update');
|
||||
expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update', undefined, {
|
||||
actor: { kind: 'user', user: { id: member.id } },
|
||||
});
|
||||
});
|
||||
|
||||
test('should move workflow to project root if `destinationParentFolderId` is not provided', async () => {
|
||||
|
|
@ -1989,7 +1991,9 @@ describe('PUT /:workflowId/transfer', () => {
|
|||
});
|
||||
|
||||
expect(activeWorkflowManager.remove).toHaveBeenCalledWith(workflow.id);
|
||||
expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update');
|
||||
expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update', undefined, {
|
||||
actor: { kind: 'user', user: { id: member.id } },
|
||||
});
|
||||
|
||||
const workflowFromDB = await workflowRepository.findOneByOrFail({ id: workflow.id });
|
||||
expect(workflowFromDB.active).toBe(false);
|
||||
|
|
|
|||
|
|
@ -3914,7 +3914,9 @@ describe('PATCH /workflows/:workflowId', () => {
|
|||
expect(response.statusCode).toBe(200);
|
||||
|
||||
expect(activeWorkflowManagerLike.remove).toHaveBeenCalledWith(workflow.id);
|
||||
expect(activeWorkflowManagerLike.add).toHaveBeenCalledWith(workflow.id, 'update');
|
||||
expect(activeWorkflowManagerLike.add).toHaveBeenCalledWith(workflow.id, 'update', undefined, {
|
||||
actor: expect.objectContaining({ kind: 'user' }),
|
||||
});
|
||||
});
|
||||
|
||||
test('should not reactivate when settings unchanged', async () => {
|
||||
|
|
@ -4287,7 +4289,9 @@ describe('POST /workflows/:workflowId/activate', () => {
|
|||
.send({ versionId: newVersionId });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
|
||||
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate', undefined, {
|
||||
actor: expect.objectContaining({ kind: 'user' }),
|
||||
});
|
||||
|
||||
const { data } = response.body;
|
||||
expect(data.id).toBe(workflow.id);
|
||||
|
|
@ -4422,7 +4426,9 @@ describe('POST /workflows/:workflowId/activate', () => {
|
|||
.send({ versionId: workflow.versionId, name: newVersionName });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
|
||||
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate', undefined, {
|
||||
actor: expect.objectContaining({ kind: 'user' }),
|
||||
});
|
||||
|
||||
const { data } = response.body;
|
||||
expect(data.activeVersionId).toBe(workflow.versionId);
|
||||
|
|
@ -4443,7 +4449,9 @@ describe('POST /workflows/:workflowId/activate', () => {
|
|||
.send({ versionId: workflow.versionId, description: newDescription });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
|
||||
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate', undefined, {
|
||||
actor: expect.objectContaining({ kind: 'user' }),
|
||||
});
|
||||
|
||||
const { data } = response.body;
|
||||
expect(data.activeVersionId).toBe(workflow.versionId);
|
||||
|
|
@ -4467,7 +4475,9 @@ describe('POST /workflows/:workflowId/activate', () => {
|
|||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
|
||||
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate', undefined, {
|
||||
actor: expect.objectContaining({ kind: 'user' }),
|
||||
});
|
||||
|
||||
const { data } = response.body;
|
||||
expect(data.activeVersionId).toBe(workflow.versionId);
|
||||
|
|
@ -4592,7 +4602,9 @@ describe('POST /workflows/:workflowId/activate', () => {
|
|||
|
||||
// First remove active version
|
||||
expect(activeWorkflowManagerLike.remove).toBeCalledWith(workflow.id);
|
||||
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'update');
|
||||
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'update', undefined, {
|
||||
actor: expect.objectContaining({ kind: 'user' }),
|
||||
});
|
||||
});
|
||||
|
||||
test('should call active workflow manager with activate mode if workflow is not active', async () => {
|
||||
|
|
@ -4604,7 +4616,9 @@ describe('POST /workflows/:workflowId/activate', () => {
|
|||
.send({ versionId: workflow.versionId });
|
||||
|
||||
expect(activeWorkflowManagerLike.remove).not.toBeCalledWith(workflow.id);
|
||||
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
|
||||
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate', undefined, {
|
||||
actor: expect.objectContaining({ kind: 'user' }),
|
||||
});
|
||||
expect(addRecordSpy).toBeCalledWith({
|
||||
event: 'activated',
|
||||
userId: owner.id,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user