mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Add node type policy project scope (no-changelog) (#38061)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
26afe95616
commit
eac5f9eccf
|
|
@ -0,0 +1,24 @@
|
|||
import { GLOBAL_ADMIN_SCOPES, GLOBAL_OWNER_SCOPES } from '@/roles/scopes/global-scopes.ee';
|
||||
import {
|
||||
PERSONAL_PROJECT_OWNER_SCOPES,
|
||||
PROJECT_EDITOR_SCOPES,
|
||||
PROJECT_VIEWER_SCOPES,
|
||||
REGULAR_PROJECT_ADMIN_SCOPES,
|
||||
} from '@/roles/scopes/project-scopes.ee';
|
||||
|
||||
describe('nodeTypePolicy:manage default grants', () => {
|
||||
it('is granted to instance owners and admins', () => {
|
||||
expect(GLOBAL_OWNER_SCOPES).toContain('nodeTypePolicy:manage');
|
||||
expect(GLOBAL_ADMIN_SCOPES).toContain('nodeTypePolicy:manage');
|
||||
});
|
||||
|
||||
it('is granted to project admins, per IAM-1142 (project admins self-govern their own row)', () => {
|
||||
expect(REGULAR_PROJECT_ADMIN_SCOPES).toContain('nodeTypePolicy:manage');
|
||||
});
|
||||
|
||||
it('is not granted to personal project owners, editors, or viewers', () => {
|
||||
expect(PERSONAL_PROJECT_OWNER_SCOPES).not.toContain('nodeTypePolicy:manage');
|
||||
expect(PROJECT_EDITOR_SCOPES).not.toContain('nodeTypePolicy:manage');
|
||||
expect(PROJECT_VIEWER_SCOPES).not.toContain('nodeTypePolicy:manage');
|
||||
});
|
||||
});
|
||||
|
|
@ -53,6 +53,7 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [
|
|||
'folder:list',
|
||||
'folder:move',
|
||||
'sourceControl:push',
|
||||
'nodeTypePolicy:manage',
|
||||
'dataTable:create',
|
||||
'dataTable:delete',
|
||||
'dataTable:read',
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export const scopeInformation: Partial<Record<Scope, ScopeInformation>> = {
|
|||
},
|
||||
'nodeTypePolicy:manage': {
|
||||
displayName: 'Manage Node Type Policies',
|
||||
description: 'Allows managing which node types are available on the instance.',
|
||||
description: 'Allows managing which node types are available, on the instance or in a project.',
|
||||
},
|
||||
'encryptionKey:manage': {
|
||||
displayName: 'Manage Encryption Keys',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { evaluateType } from '../policy-evaluator';
|
||||
import { evaluateComposedType, evaluateType, type ScopePolicy } from '../policy-evaluator';
|
||||
import type { PolicyAttachment } from '../policy-rule.types';
|
||||
|
||||
const attachment = (overrides: Partial<PolicyAttachment>): PolicyAttachment => ({
|
||||
|
|
@ -9,6 +9,12 @@ const attachment = (overrides: Partial<PolicyAttachment>): PolicyAttachment => (
|
|||
...overrides,
|
||||
});
|
||||
|
||||
const scopePolicy = (overrides: Partial<ScopePolicy> = {}): ScopePolicy => ({
|
||||
attachments: [],
|
||||
defaultAction: 'allow',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('evaluateType', () => {
|
||||
it('lets an earlier deny rule take precedence over a later package allow', () => {
|
||||
const attachments = [
|
||||
|
|
@ -206,3 +212,107 @@ describe('evaluateType', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateComposedType', () => {
|
||||
const TYPE = 'n8n-nodes-base.slack';
|
||||
const allowRule = (id: string) =>
|
||||
attachment({ rules: [{ id, action: 'allow', selector: { kind: 'name', value: TYPE } }] });
|
||||
const denyRule = (id: string) =>
|
||||
attachment({ rules: [{ id, action: 'deny', selector: { kind: 'name', value: TYPE } }] });
|
||||
|
||||
it('an instance deny is final: a project allow never overrides it', () => {
|
||||
const instance = scopePolicy({ attachments: [denyRule('instance-deny')] });
|
||||
const project = scopePolicy({ attachments: [allowRule('project-allow')] });
|
||||
|
||||
expect(evaluateComposedType(instance, project, TYPE)).toEqual({
|
||||
action: 'deny',
|
||||
scope: 'instance',
|
||||
matchedRuleId: 'instance-deny',
|
||||
});
|
||||
});
|
||||
|
||||
it('an instance delegate is satisfied by an explicit project allow rule', () => {
|
||||
const instance = scopePolicy({ defaultAction: 'delegate' });
|
||||
const project = scopePolicy({ attachments: [allowRule('project-allow')] });
|
||||
|
||||
expect(evaluateComposedType(instance, project, TYPE)).toEqual({
|
||||
action: 'allow',
|
||||
scope: 'project',
|
||||
matchedRuleId: 'project-allow',
|
||||
});
|
||||
});
|
||||
|
||||
it('an instance delegate stays denied when the project has no policy row at all', () => {
|
||||
const instance = scopePolicy({ defaultAction: 'delegate' });
|
||||
const project = scopePolicy();
|
||||
|
||||
expect(evaluateComposedType(instance, project, TYPE)).toEqual({
|
||||
action: 'deny',
|
||||
scope: 'instance',
|
||||
matchedRuleId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('an instance delegate is not satisfied by a bare project defaultAction of allow', () => {
|
||||
const instance = scopePolicy({ defaultAction: 'delegate' });
|
||||
const project = scopePolicy({ defaultAction: 'allow' });
|
||||
|
||||
expect(evaluateComposedType(instance, project, TYPE)).toEqual({
|
||||
action: 'deny',
|
||||
scope: 'instance',
|
||||
matchedRuleId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('an instance delegate with an explicit project deny rule is attributed to the project', () => {
|
||||
const instance = scopePolicy({ defaultAction: 'delegate' });
|
||||
const project = scopePolicy({ attachments: [denyRule('project-deny')] });
|
||||
|
||||
expect(evaluateComposedType(instance, project, TYPE)).toEqual({
|
||||
action: 'deny',
|
||||
scope: 'project',
|
||||
matchedRuleId: 'project-deny',
|
||||
});
|
||||
});
|
||||
|
||||
it('an instance delegate with a project defaultAction of deny is attributed to the project', () => {
|
||||
const instance = scopePolicy({ defaultAction: 'delegate' });
|
||||
const project = scopePolicy({ defaultAction: 'deny' });
|
||||
|
||||
expect(evaluateComposedType(instance, project, TYPE)).toEqual({
|
||||
action: 'deny',
|
||||
scope: 'project',
|
||||
matchedRuleId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('an instance allow lets a project deny rule restrict further', () => {
|
||||
const instance = scopePolicy({ attachments: [allowRule('instance-allow')] });
|
||||
const project = scopePolicy({ attachments: [denyRule('project-deny')] });
|
||||
|
||||
expect(evaluateComposedType(instance, project, TYPE)).toEqual({
|
||||
action: 'deny',
|
||||
scope: 'project',
|
||||
matchedRuleId: 'project-deny',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows and attributes to the instance when neither scope is configured', () => {
|
||||
expect(evaluateComposedType(scopePolicy(), scopePolicy(), TYPE)).toEqual({
|
||||
action: 'allow',
|
||||
scope: 'instance',
|
||||
matchedRuleId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows and attributes to the project when the project explicitly allows on top of an instance allow', () => {
|
||||
const instance = scopePolicy();
|
||||
const project = scopePolicy({ attachments: [allowRule('project-allow')] });
|
||||
|
||||
expect(evaluateComposedType(instance, project, TYPE)).toEqual({
|
||||
action: 'allow',
|
||||
scope: 'project',
|
||||
matchedRuleId: 'project-allow',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { LICENSE_FEATURES } from '@n8n/constants';
|
||||
import { ControllerRegistryMetadata, ModuleMetadata } from '@n8n/decorators';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
|
|
@ -11,7 +12,13 @@ describe('TypeAvailabilityPoliciesModule', () => {
|
|||
expect(entry).toBeDefined();
|
||||
});
|
||||
|
||||
it('registers the instance controller on init', async () => {
|
||||
it('is gated by the node type policies license feature, so an unlicensed instance skips init', () => {
|
||||
const entry = Container.get(ModuleMetadata).get('type-availability-policies');
|
||||
|
||||
expect(entry?.licenseFlag).toBe(LICENSE_FEATURES.NODE_TYPE_POLICIES);
|
||||
});
|
||||
|
||||
it('registers the instance and project controllers on init', async () => {
|
||||
const module = new TypeAvailabilityPoliciesModule();
|
||||
|
||||
await module.init();
|
||||
|
|
@ -19,11 +26,17 @@ describe('TypeAvailabilityPoliciesModule', () => {
|
|||
const { TypeAvailabilityPolicyInstanceController } = await import(
|
||||
'../type-availability-policy-instance.controller.js'
|
||||
);
|
||||
const metadata = Container.get(ControllerRegistryMetadata).getControllerMetadata(
|
||||
TypeAvailabilityPolicyInstanceController as never,
|
||||
const { TypeAvailabilityPolicyProjectController } = await import(
|
||||
'../type-availability-policy-project.controller.js'
|
||||
);
|
||||
const registry = Container.get(ControllerRegistryMetadata);
|
||||
|
||||
expect(metadata.routes.size).toBeGreaterThan(0);
|
||||
expect(
|
||||
registry.getControllerMetadata(TypeAvailabilityPolicyInstanceController as never).routes.size,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
registry.getControllerMetadata(TypeAvailabilityPolicyProjectController as never).routes.size,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('exposes its entities so the datasource picks them up', async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { LICENSE_FEATURES } from '@n8n/constants';
|
||||
import { ControllerRegistryMetadata } from '@n8n/decorators';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { TypeAvailabilityPolicyProjectController } from '../type-availability-policy-project.controller';
|
||||
|
||||
/**
|
||||
* Every route on this controller must be project-scoped (`@ProjectScope`), never
|
||||
* `@GlobalScope` and never left ungated — a project admin self-governs their own project's
|
||||
* row, per IAM-1142's RFC decision. Every route must also require the node type policies
|
||||
* license feature, same as the instance controller.
|
||||
*/
|
||||
describe('TypeAvailabilityPolicyProjectController route access scopes', () => {
|
||||
const metadata = Container.get(ControllerRegistryMetadata).getControllerMetadata(
|
||||
TypeAvailabilityPolicyProjectController as never,
|
||||
);
|
||||
const routeCases = Array.from(metadata.routes.entries()).map(([handlerName, route]) => ({
|
||||
handlerName,
|
||||
route,
|
||||
}));
|
||||
|
||||
it('registers at least one route', () => {
|
||||
expect(routeCases.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each(routeCases)(
|
||||
'$handlerName is gated by a project-scoped nodeTypePolicy:manage check',
|
||||
({ route }) => {
|
||||
expect(route.accessScope).toBeDefined();
|
||||
expect(route.accessScope?.globalOnly).toBe(false);
|
||||
expect(route.accessScope?.scope).toBe('nodeTypePolicy:manage');
|
||||
},
|
||||
);
|
||||
|
||||
it('is gated by the node type policies license feature', () => {
|
||||
for (const { route } of routeCases) {
|
||||
expect(route.licenseFeature).toBe(LICENSE_FEATURES.NODE_TYPE_POLICIES);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -22,6 +22,14 @@ const RULE: PolicyRule = {
|
|||
selector: { kind: 'name', value: 'n8n-nodes-base.slack' },
|
||||
};
|
||||
|
||||
const DELEGATE_RULE: PolicyRule = {
|
||||
id: 'r-delegate',
|
||||
action: 'delegate',
|
||||
selector: { kind: 'name', value: 'n8n-nodes-base.slack' },
|
||||
};
|
||||
|
||||
const DELEGATE_RULE_AT_PROJECT_SCOPE = 'A rule cannot use action "delegate" at project scope';
|
||||
|
||||
function makeScope(overrides: Partial<TypeAvailabilityPolicyScope> = {}) {
|
||||
return Object.assign(new TypeAvailabilityPolicyScope(), {
|
||||
id: 'scope-1',
|
||||
|
|
@ -106,6 +114,27 @@ describe('TypeAvailabilityPolicyService', () => {
|
|||
});
|
||||
|
||||
describe('setDefaultAction', () => {
|
||||
it('rejects a delegate defaultAction at project scope before opening a transaction', async () => {
|
||||
await expect(
|
||||
service.setDefaultAction(KIND, 'project-1', 'delegate', 0, 'user-1'),
|
||||
).rejects.toThrow('defaultAction cannot be "delegate" at project scope');
|
||||
|
||||
expect(transactionRunner.run).not.toHaveBeenCalled();
|
||||
expect(scopeRepository.findScopeByKindAndProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still allows a delegate defaultAction at instance scope', async () => {
|
||||
scopeRepository.findScopeByKindAndProject.mockResolvedValue(null);
|
||||
scopeRepository.createScopeIfAbsent.mockResolvedValue({
|
||||
scope: makeScope({ defaultAction: 'delegate', version: 1 }),
|
||||
created: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.setDefaultAction(KIND, null, 'delegate', 0, 'user-1'),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('throws ConflictError on a stale version and writes nothing', async () => {
|
||||
scopeRepository.findScopeByKindAndProject.mockResolvedValue(makeScope({ version: 2 }));
|
||||
|
||||
|
|
@ -236,6 +265,55 @@ describe('TypeAvailabilityPolicyService', () => {
|
|||
beforeEach(() => {
|
||||
attachmentRepository.listScopeIdsAttachedToPolicy.mockResolvedValue([]);
|
||||
scopeRepository.lockScopesByIds.mockResolvedValue([]);
|
||||
scopeRepository.containsProjectScope.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it('rejects a delegate rule when the document is attached to a project scope, before reading it', async () => {
|
||||
attachmentRepository.listScopeIdsAttachedToPolicy.mockResolvedValue(['scope-1']);
|
||||
scopeRepository.lockScopesByIds.mockResolvedValue(['scope-1']);
|
||||
scopeRepository.containsProjectScope.mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
service.updatePolicyDocument('policy-1', [DELEGATE_RULE], 1, 'user-2'),
|
||||
).rejects.toThrow(DELEGATE_RULE_AT_PROJECT_SCOPE);
|
||||
|
||||
expect(scopeRepository.containsProjectScope).toHaveBeenCalledWith(['scope-1'], ROOT);
|
||||
expect(policyRepository.findById).not.toHaveBeenCalled();
|
||||
expect(policyRepository.updateRules).not.toHaveBeenCalled();
|
||||
expect(eventService.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still accepts a delegate rule when the document is attached to instance scope only', async () => {
|
||||
const before = makePolicy({ rules: [], version: 1 });
|
||||
policyRepository.findById.mockResolvedValue(before);
|
||||
policyRepository.updateRules.mockResolvedValue(
|
||||
makePolicy({ rules: [DELEGATE_RULE], version: 2 }),
|
||||
);
|
||||
attachmentRepository.listScopeIdsAttachedToPolicy.mockResolvedValue(['scope-1']);
|
||||
scopeRepository.lockScopesByIds.mockResolvedValue(['scope-1']);
|
||||
scopeRepository.containsProjectScope.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
service.updatePolicyDocument(before.id, [DELEGATE_RULE], 1, 'user-2'),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(policyRepository.updateRules).toHaveBeenCalledWith(
|
||||
before.id,
|
||||
[DELEGATE_RULE],
|
||||
'user-2',
|
||||
ROOT,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not look up the scope kinds when the rules carry no delegate', async () => {
|
||||
policyRepository.findById.mockResolvedValue(makePolicy({ rules: [], version: 1 }));
|
||||
policyRepository.updateRules.mockResolvedValue(makePolicy({ rules: [RULE], version: 2 }));
|
||||
attachmentRepository.listScopeIdsAttachedToPolicy.mockResolvedValue(['scope-1']);
|
||||
scopeRepository.lockScopesByIds.mockResolvedValue(['scope-1']);
|
||||
|
||||
await service.updatePolicyDocument('policy-1', [RULE], 1, 'user-2');
|
||||
|
||||
expect(scopeRepository.containsProjectScope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundError when the document does not exist', async () => {
|
||||
|
|
@ -463,9 +541,188 @@ describe('TypeAvailabilityPolicyService', () => {
|
|||
|
||||
expect(result.version).toBe(2);
|
||||
});
|
||||
|
||||
it('rejects attaching a document with a delegate rule to a project scope, and writes nothing', async () => {
|
||||
scopeRepository.findScopeById.mockResolvedValue(makeScope({ projectId: 'project-1' }));
|
||||
policyRepository.findManyByIds.mockResolvedValue([
|
||||
makePolicy({ id: 'p1', rules: [RULE] }),
|
||||
makePolicy({ id: 'p2', rules: [DELEGATE_RULE] }),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.replaceAttachments(
|
||||
'scope-1',
|
||||
[
|
||||
{ policyId: 'p1', priority: 0, isFloor: false },
|
||||
{ policyId: 'p2', priority: 1, isFloor: false },
|
||||
],
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(DELEGATE_RULE_AT_PROJECT_SCOPE);
|
||||
|
||||
// Locked read: the check must hold against a concurrent document edit.
|
||||
expect(policyRepository.findManyByIds).toHaveBeenCalledWith(['p1', 'p2'], ROOT, true);
|
||||
expect(attachmentRepository.replaceAttachmentsForScope).not.toHaveBeenCalled();
|
||||
expect(scopeRepository.bumpVersion).not.toHaveBeenCalled();
|
||||
expect(eventService.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not inspect the documents when attaching to instance scope', async () => {
|
||||
scopeRepository.findScopeById.mockResolvedValue(makeScope({ projectId: null }));
|
||||
attachmentRepository.listAttachmentsForScope.mockResolvedValue([]);
|
||||
|
||||
await service.replaceAttachments(
|
||||
'scope-1',
|
||||
[{ policyId: 'p-delegating', priority: 0, isFloor: false }],
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(policyRepository.findManyByIds).not.toHaveBeenCalled();
|
||||
expect(attachmentRepository.replaceAttachmentsForScope).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setEffectivePolicy', () => {
|
||||
beforeEach(() => {
|
||||
// By default a scope's document is attached to that scope alone.
|
||||
attachmentRepository.listScopeIdsAttachedToPolicy.mockResolvedValue(['scope-1']);
|
||||
});
|
||||
|
||||
it('throws ConflictError when the scope has several attached documents, and writes nothing', async () => {
|
||||
const scope = makeScope({ projectId: 'project-1', defaultAction: 'allow', version: 1 });
|
||||
scopeRepository.findScopeByKindAndProject.mockResolvedValue(scope);
|
||||
attachmentRepository.listAttachmentsForScope.mockResolvedValue([
|
||||
{ policyId: 'policy-1', rules: [], priority: 0, isFloor: false },
|
||||
{ policyId: 'policy-2', rules: [], priority: 1, isFloor: false },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.setEffectivePolicy(
|
||||
KIND,
|
||||
'project-1',
|
||||
{ rules: [RULE], defaultAction: 'deny' },
|
||||
1,
|
||||
'user-2',
|
||||
),
|
||||
).rejects.toThrow(ConflictError);
|
||||
|
||||
expect(scopeRepository.updateDefaultAction).not.toHaveBeenCalled();
|
||||
expect(policyRepository.updateRules).not.toHaveBeenCalled();
|
||||
expect(scopeRepository.bumpVersion).not.toHaveBeenCalled();
|
||||
expect(eventService.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ConflictError when the attached document is shared with another scope, and writes nothing', async () => {
|
||||
const scope = makeScope({ projectId: 'project-1', defaultAction: 'allow', version: 1 });
|
||||
scopeRepository.findScopeByKindAndProject.mockResolvedValue(scope);
|
||||
attachmentRepository.listAttachmentsForScope.mockResolvedValue([
|
||||
{ policyId: 'policy-1', rules: [], priority: 0, isFloor: false },
|
||||
]);
|
||||
attachmentRepository.listScopeIdsAttachedToPolicy.mockResolvedValue([
|
||||
scope.id,
|
||||
'instance-scope',
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.setEffectivePolicy(
|
||||
KIND,
|
||||
'project-1',
|
||||
{ rules: [RULE], defaultAction: 'deny' },
|
||||
1,
|
||||
'user-2',
|
||||
),
|
||||
).rejects.toThrow(ConflictError);
|
||||
|
||||
expect(attachmentRepository.listScopeIdsAttachedToPolicy).toHaveBeenCalledWith(
|
||||
'policy-1',
|
||||
ROOT,
|
||||
);
|
||||
expect(scopeRepository.updateDefaultAction).not.toHaveBeenCalled();
|
||||
expect(policyRepository.updateRules).not.toHaveBeenCalled();
|
||||
expect(scopeRepository.bumpVersion).not.toHaveBeenCalled();
|
||||
expect(eventService.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('locks the existing document before checking which scopes use it', async () => {
|
||||
const scope = makeScope({ defaultAction: 'allow', version: 1 });
|
||||
scopeRepository.findScopeByKindAndProject.mockResolvedValue(scope);
|
||||
attachmentRepository.listAttachmentsForScope.mockResolvedValue([
|
||||
{ policyId: 'policy-1', rules: [], priority: 0, isFloor: false },
|
||||
]);
|
||||
policyRepository.findById.mockResolvedValue(makePolicy({ rules: [], version: 1 }));
|
||||
policyRepository.updateRules.mockResolvedValue(makePolicy({ rules: [RULE], version: 2 }));
|
||||
scopeRepository.findScopeById.mockResolvedValue(makeScope({ version: 2 }));
|
||||
|
||||
await service.setEffectivePolicy(
|
||||
KIND,
|
||||
null,
|
||||
{ rules: [RULE], defaultAction: 'allow' },
|
||||
1,
|
||||
'user-2',
|
||||
);
|
||||
|
||||
expect(policyRepository.findById).toHaveBeenCalledWith('policy-1', ROOT, true);
|
||||
expect(policyRepository.findById.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
attachmentRepository.listScopeIdsAttachedToPolicy.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a delegate defaultAction at project scope before opening a transaction', async () => {
|
||||
await expect(
|
||||
service.setEffectivePolicy(
|
||||
KIND,
|
||||
'project-1',
|
||||
{ rules: [], defaultAction: 'delegate' },
|
||||
0,
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow('defaultAction cannot be "delegate" at project scope');
|
||||
|
||||
expect(transactionRunner.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a delegate rule action at project scope before opening a transaction', async () => {
|
||||
const delegateRule: PolicyRule = {
|
||||
id: 'r1',
|
||||
action: 'delegate',
|
||||
selector: { kind: 'name', value: 'n8n-nodes-base.slack' },
|
||||
};
|
||||
|
||||
await expect(
|
||||
service.setEffectivePolicy(
|
||||
KIND,
|
||||
'project-1',
|
||||
{ rules: [delegateRule], defaultAction: 'allow' },
|
||||
0,
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow('A rule cannot use action "delegate" at project scope');
|
||||
|
||||
expect(transactionRunner.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still allows delegate at instance scope', async () => {
|
||||
scopeRepository.findScopeByKindAndProject.mockResolvedValue(null);
|
||||
scopeRepository.createScopeIfAbsent.mockResolvedValue({
|
||||
scope: makeScope({ defaultAction: 'delegate', version: 1 }),
|
||||
created: true,
|
||||
});
|
||||
policyRepository.createPolicy.mockResolvedValue(makePolicy({ rules: [], version: 1 }));
|
||||
scopeRepository.findScopeById.mockResolvedValue(
|
||||
makeScope({ defaultAction: 'delegate', version: 2 }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.setEffectivePolicy(
|
||||
KIND,
|
||||
null,
|
||||
{ rules: [], defaultAction: 'delegate' },
|
||||
0,
|
||||
'user-1',
|
||||
),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('throws ConflictError on a stale version and writes nothing', async () => {
|
||||
scopeRepository.findScopeByKindAndProject.mockResolvedValue(makeScope({ version: 2 }));
|
||||
|
||||
|
|
@ -659,4 +916,61 @@ describe('TypeAvailabilityPolicyService', () => {
|
|||
expect(result.version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateComposedType', () => {
|
||||
const PROJECT_ID = 'project-1';
|
||||
const TYPE = 'n8n-nodes-base.slack';
|
||||
|
||||
it('reads the instance and project scopes and composes their verdicts', async () => {
|
||||
const instanceScope = makeScope({ projectId: null, defaultAction: 'delegate', version: 1 });
|
||||
const projectScope = makeScope({
|
||||
id: 'scope-2',
|
||||
projectId: PROJECT_ID,
|
||||
defaultAction: 'deny',
|
||||
version: 1,
|
||||
});
|
||||
scopeRepository.findScopeByKindAndProject.mockImplementation(async (_kind, projectId) =>
|
||||
projectId === null ? instanceScope : projectScope,
|
||||
);
|
||||
const projectAllowRule: PolicyRule = {
|
||||
id: 'project-allow',
|
||||
action: 'allow',
|
||||
selector: { kind: 'name', value: TYPE },
|
||||
};
|
||||
attachmentRepository.listAttachmentsForScope.mockImplementation(async (scopeId) =>
|
||||
scopeId === projectScope.id
|
||||
? [{ policyId: 'p1', rules: [projectAllowRule], priority: 0, isFloor: false }]
|
||||
: [],
|
||||
);
|
||||
|
||||
const result = await service.evaluateComposedType(KIND, PROJECT_ID, TYPE);
|
||||
|
||||
expect(scopeRepository.findScopeByKindAndProject).toHaveBeenCalledWith(KIND, null, ROOT);
|
||||
expect(scopeRepository.findScopeByKindAndProject).toHaveBeenCalledWith(
|
||||
KIND,
|
||||
PROJECT_ID,
|
||||
ROOT,
|
||||
);
|
||||
expect(result).toEqual({ action: 'allow', scope: 'project', matchedRuleId: 'project-allow' });
|
||||
});
|
||||
|
||||
it('lets an instance deny win over an unconfigured project', async () => {
|
||||
const denyRule: PolicyRule = {
|
||||
id: 'instance-deny',
|
||||
action: 'deny',
|
||||
selector: { kind: 'name', value: TYPE },
|
||||
};
|
||||
const instanceScope = makeScope({ projectId: null, defaultAction: 'allow', version: 1 });
|
||||
scopeRepository.findScopeByKindAndProject.mockImplementation(async (_kind, projectId) =>
|
||||
projectId === null ? instanceScope : null,
|
||||
);
|
||||
attachmentRepository.listAttachmentsForScope.mockResolvedValue([
|
||||
{ policyId: 'p1', rules: [denyRule], priority: 0, isFloor: false },
|
||||
]);
|
||||
|
||||
const result = await service.evaluateComposedType(KIND, PROJECT_ID, TYPE);
|
||||
|
||||
expect(result).toEqual({ action: 'deny', scope: 'instance', matchedRuleId: 'instance-deny' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -137,6 +137,41 @@ describe('row locks depend on the driver', () => {
|
|||
expect(entityManager.increment).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe('containsProjectScope', () => {
|
||||
it('is false for an empty id list without querying', async () => {
|
||||
expect(await repository.containsProjectScope([], ROOT)).toBe(false);
|
||||
expect(entityManager.count).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts only rows with a non-null projectId', async () => {
|
||||
entityManager.count.mockResolvedValue(0);
|
||||
|
||||
expect(await repository.containsProjectScope(['a', 'b'], ROOT)).toBe(false);
|
||||
|
||||
expect(entityManager.count).toHaveBeenCalledWith(TypeAvailabilityPolicyScope, {
|
||||
where: { id: expect.anything(), projectId: expect.anything() },
|
||||
});
|
||||
});
|
||||
|
||||
it('stops at the first batch that contains a project scope', async () => {
|
||||
entityManager.count.mockResolvedValueOnce(1);
|
||||
const ids = Array.from({ length: 10_001 }, (_, i) => `id-${i}`);
|
||||
|
||||
expect(await repository.containsProjectScope(ids, ROOT)).toBe(true);
|
||||
|
||||
expect(entityManager.count).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('checks every batch when none contains a project scope', async () => {
|
||||
entityManager.count.mockResolvedValue(0);
|
||||
const ids = Array.from({ length: 10_001 }, (_, i) => `id-${i}`);
|
||||
|
||||
expect(await repository.containsProjectScope(ids, ROOT)).toBe(false);
|
||||
|
||||
expect(entityManager.count).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createScopeIfAbsent', () => {
|
||||
const queryBuilder = {
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
|
|
@ -241,7 +276,53 @@ describe('row locks depend on the driver', () => {
|
|||
|
||||
it('returns an empty list without querying for an empty id list', async () => {
|
||||
expect(await repository.findManyByIds([], ROOT)).toEqual([]);
|
||||
expect(entityManager.findBy).not.toHaveBeenCalled();
|
||||
expect(entityManager.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not lock findManyByIds without forUpdate, even on Postgres', async () => {
|
||||
setDriver(entityManager, 'postgres');
|
||||
entityManager.find.mockResolvedValue([]);
|
||||
|
||||
await repository.findManyByIds(['b', 'a'], ROOT);
|
||||
|
||||
expect(entityManager.find).toHaveBeenCalledWith(
|
||||
TypeAvailabilityPolicy,
|
||||
expect.not.objectContaining({ lock: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it('locks findManyByIds in id order on Postgres when forUpdate is requested', async () => {
|
||||
setDriver(entityManager, 'postgres');
|
||||
entityManager.find.mockResolvedValue([]);
|
||||
|
||||
await repository.findManyByIds(['b', 'a'], ROOT, true);
|
||||
|
||||
expect(entityManager.find).toHaveBeenCalledWith(TypeAvailabilityPolicy, {
|
||||
where: { id: expect.anything() },
|
||||
order: { id: 'ASC' },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not lock findManyByIds on SQLite even when forUpdate is requested', async () => {
|
||||
setDriver(entityManager, 'sqlite');
|
||||
entityManager.find.mockResolvedValue([]);
|
||||
|
||||
await repository.findManyByIds(['a'], ROOT, true);
|
||||
|
||||
expect(entityManager.find).toHaveBeenCalledWith(
|
||||
TypeAvailabilityPolicy,
|
||||
expect.not.objectContaining({ lock: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads every batch when more ids are named than fit in one query', async () => {
|
||||
entityManager.find.mockResolvedValue([]);
|
||||
const ids = Array.from({ length: 10_001 }, (_, i) => `id-${i}`);
|
||||
|
||||
await repository.findManyByIds(ids, ROOT, true);
|
||||
|
||||
expect(entityManager.find).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
type OperationContext,
|
||||
} from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { DataSource, In, IsNull, type EntityManager } from '@n8n/typeorm';
|
||||
import { DataSource, In, IsNull, Not, type EntityManager } from '@n8n/typeorm';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import type { PolicyAction } from '../../policy-rule.types';
|
||||
|
|
@ -106,6 +106,23 @@ export class TypeAvailabilityPolicyScopeRepository extends BaseRepository<TypeAv
|
|||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any of the named scopes is a project scope (`projectId IS NOT NULL`). A policy
|
||||
* edit uses this to learn whether its document is attached to a project, where `delegate`
|
||||
* rules are not accepted. Chunked like `lockScopesByIds`, for the same reason.
|
||||
*/
|
||||
async containsProjectScope(ids: string[], ctx: OperationContext): Promise<boolean> {
|
||||
const manager = this.managerFor(ctx);
|
||||
for (const batch of chunkIds(ids)) {
|
||||
const count = await manager.count(TypeAvailabilityPolicyScope, {
|
||||
where: { id: In(batch), projectId: Not(IsNull()) },
|
||||
});
|
||||
if (count > 0) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts the scope row if `(kind, projectId)` has none yet, then reads back whichever row
|
||||
* exists. `created: false` means a concurrent first write got there first.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { BaseRepository, TransactionRunner, type OperationContext } from '@n8n/db';
|
||||
import { BaseRepository, TransactionRunner, chunkIds, type OperationContext } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { DataSource, In } from '@n8n/typeorm';
|
||||
import { DataSource, In, type EntityManager } from '@n8n/typeorm';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
import type { PolicyRule } from '../../policy-rule.types';
|
||||
|
|
@ -28,6 +28,13 @@ export class TypeAvailabilityPolicyRepository extends BaseRepository<TypeAvailab
|
|||
super(TypeAvailabilityPolicy, dataSource.manager, transactionRunner);
|
||||
}
|
||||
|
||||
/** Postgres only — see `TypeAvailabilityPolicyScopeRepository.forUpdateLock`. */
|
||||
private forUpdateLock(manager: EntityManager) {
|
||||
return manager.connection.options.type === 'postgres'
|
||||
? { lock: { mode: 'pessimistic_write' as const } }
|
||||
: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass `forUpdate: true` inside a write transaction that checks `expectedVersion` — see
|
||||
* `TypeAvailabilityPolicyScopeRepository.findScopeByKindAndProject` for why the lock
|
||||
|
|
@ -41,15 +48,39 @@ export class TypeAvailabilityPolicyRepository extends BaseRepository<TypeAvailab
|
|||
const manager = this.managerFor(ctx);
|
||||
return await manager.findOne(TypeAvailabilityPolicy, {
|
||||
where: { id },
|
||||
...(forUpdate && manager.connection.options.type === 'postgres'
|
||||
? { lock: { mode: 'pessimistic_write' as const } }
|
||||
: {}),
|
||||
...(forUpdate ? this.forUpdateLock(manager) : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async findManyByIds(ids: string[], ctx: OperationContext): Promise<TypeAvailabilityPolicy[]> {
|
||||
/**
|
||||
* Unknown ids are ignored. Chunked, since a caller may name many documents at once.
|
||||
*
|
||||
* Pass `forUpdate: true` inside a write transaction whose decision depends on the rules as
|
||||
* they are at commit time — `replaceAttachments` checks them before attaching to a project
|
||||
* scope, and `updatePolicyDocument` holds the same lock while it edits them, so one waits
|
||||
* for the other instead of deciding on rules the other is about to change. Rows are locked
|
||||
* in id order, batch by batch, for the reason `lockScopesByIds` gives; a caller that locks
|
||||
* scopes first and then documents keeps the scope → policy order every write path uses.
|
||||
*/
|
||||
async findManyByIds(
|
||||
ids: string[],
|
||||
ctx: OperationContext,
|
||||
forUpdate = false,
|
||||
): Promise<TypeAvailabilityPolicy[]> {
|
||||
if (ids.length === 0) return [];
|
||||
return await this.managerFor(ctx).findBy(TypeAvailabilityPolicy, { id: In(ids) });
|
||||
|
||||
const manager = this.managerFor(ctx);
|
||||
const found: TypeAvailabilityPolicy[] = [];
|
||||
for (const batch of chunkIds([...ids].sort())) {
|
||||
const rows = await manager.find(TypeAvailabilityPolicy, {
|
||||
where: { id: In(batch) },
|
||||
order: { id: 'ASC' },
|
||||
...(forUpdate ? this.forUpdateLock(manager) : {}),
|
||||
});
|
||||
found.push(...rows);
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Every policy document of one kind, for the document-library listing screen. */
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { policyActionSchema, policyRuleListSchema, policyRuleSchema } from '../policy-rule.schema';
|
||||
import {
|
||||
nonDelegatingPolicyActionSchema,
|
||||
nonDelegatingPolicyRuleListSchema,
|
||||
policyActionSchema,
|
||||
policyRuleListSchema,
|
||||
policyRuleSchema,
|
||||
} from '../policy-rule.schema';
|
||||
|
||||
const nameSelector = { kind: 'name' as const, value: 'n8n-nodes-base.slack' };
|
||||
|
||||
|
|
@ -58,3 +64,44 @@ describe('policyRuleListSchema', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('nonDelegatingPolicyActionSchema', () => {
|
||||
it.each(['allow', 'deny'])('accepts %s', (action) => {
|
||||
expect(nonDelegatingPolicyActionSchema.safeParse(action).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects delegate', () => {
|
||||
expect(nonDelegatingPolicyActionSchema.safeParse('delegate').success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nonDelegatingPolicyRuleListSchema', () => {
|
||||
it('accepts a list of allow/deny rules with unique ids', () => {
|
||||
expect(
|
||||
nonDelegatingPolicyRuleListSchema.safeParse([
|
||||
{ id: 'r1', action: 'deny', selector: nameSelector },
|
||||
{ id: 'r2', action: 'allow', selector: { kind: 'package', value: 'n8n-nodes-base' } },
|
||||
]).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a rule using the delegate action', () => {
|
||||
expect(
|
||||
nonDelegatingPolicyRuleListSchema.safeParse([
|
||||
{ id: 'r1', action: 'delegate', selector: nameSelector },
|
||||
]).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('still rejects duplicate rule ids', () => {
|
||||
const result = nonDelegatingPolicyRuleListSchema.safeParse([
|
||||
{ id: 'r1', action: 'deny', selector: nameSelector },
|
||||
{ id: 'r1', action: 'allow', selector: { kind: 'package', value: 'n8n-nodes-base' } },
|
||||
]);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('Duplicate rule id: r1');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import type { PolicyAction, PolicyRule } from '../policy-rule.types';
|
||||
import type {
|
||||
NonDelegatingPolicyAction,
|
||||
NonDelegatingPolicyRule,
|
||||
PolicyAction,
|
||||
PolicyRule,
|
||||
} from '../policy-rule.types';
|
||||
import { policySelectorSchema } from './policy-selector.schema';
|
||||
|
||||
/**
|
||||
|
|
@ -23,8 +28,7 @@ export const policyRuleSchema = z.object({
|
|||
selector: policySelectorSchema,
|
||||
}) satisfies z.ZodType<PolicyRule>;
|
||||
|
||||
/** An ordered list of rules for one policy document. Rejects duplicate rule ids. */
|
||||
export const policyRuleListSchema = z.array(policyRuleSchema).superRefine((rules, ctx) => {
|
||||
function rejectDuplicateRuleIds(rules: PolicyRule[], ctx: z.RefinementCtx): void {
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const [index, rule] of rules.entries()) {
|
||||
|
|
@ -39,4 +43,24 @@ export const policyRuleListSchema = z.array(policyRuleSchema).superRefine((rules
|
|||
|
||||
seenIds.add(rule.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** An ordered list of rules for one policy document. Rejects duplicate rule ids. */
|
||||
export const policyRuleListSchema = z.array(policyRuleSchema).superRefine(rejectDuplicateRuleIds);
|
||||
|
||||
/**
|
||||
* `delegate` is only meaningful where a narrower scope exists to opt in — instance scope
|
||||
* today. There is no scope narrower than project, so a project-scope write rejects it outright.
|
||||
*/
|
||||
export const nonDelegatingPolicyActionSchema = policyActionSchema.exclude([
|
||||
'delegate',
|
||||
]) satisfies z.ZodType<NonDelegatingPolicyAction>;
|
||||
|
||||
const nonDelegatingPolicyRuleSchema = policyRuleSchema.extend({
|
||||
action: nonDelegatingPolicyActionSchema,
|
||||
}) satisfies z.ZodType<NonDelegatingPolicyRule>;
|
||||
|
||||
/** Same as `policyRuleListSchema`, but for project scope: rejects a `delegate` rule too. */
|
||||
export const nonDelegatingPolicyRuleListSchema = z
|
||||
.array(nonDelegatingPolicyRuleSchema)
|
||||
.superRefine(rejectDuplicateRuleIds);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import { Z } from '@n8n/api-types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
nonDelegatingPolicyActionSchema,
|
||||
nonDelegatingPolicyRuleListSchema,
|
||||
} from './policy-rule.schema';
|
||||
|
||||
/**
|
||||
* Request body for setting a project's composed policy. Same shape as
|
||||
* `PutInstancePolicyDto`, except `delegate` is rejected — there is no scope narrower than
|
||||
* project for it to defer to.
|
||||
*/
|
||||
export class PutProjectPolicyDto extends Z.class({
|
||||
rules: nonDelegatingPolicyRuleListSchema,
|
||||
defaultAction: nonDelegatingPolicyActionSchema,
|
||||
// Optimistic concurrency: callers send the version they last read, so a stale write is rejected.
|
||||
version: z.number().int().nonnegative(),
|
||||
}) {}
|
||||
|
|
@ -5,6 +5,24 @@ import type {
|
|||
PolicyVerdict,
|
||||
} from './policy-rule.types';
|
||||
|
||||
/** One scope's inputs to evaluation: its attached policies' rules and its own default action. */
|
||||
export type ScopePolicy = {
|
||||
readonly attachments: readonly PolicyAttachment[];
|
||||
readonly defaultAction: PolicyAction;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the instance ∩ project composition decided for one type, and which scope decided it.
|
||||
*
|
||||
* `matchedRuleId` carries the same "explicit rule vs. default action" distinction as
|
||||
* `PolicyVerdict`, from whichever scope's verdict is reported.
|
||||
*/
|
||||
export type ComposedVerdict = {
|
||||
readonly action: 'allow' | 'deny';
|
||||
readonly scope: 'instance' | 'project';
|
||||
readonly matchedRuleId: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Package selectors match the segment of the type name before the first dot (a full type
|
||||
* name is always `<packageName>.<nodeName>`), same convention used elsewhere in the
|
||||
|
|
@ -59,3 +77,57 @@ export function evaluateType(
|
|||
|
||||
return { action: defaultAction, matchedRuleId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes an instance scope's verdict with a project scope's, per the finalized RFC:
|
||||
* conjunction with monotonic restriction. A project can only restrict further, never re-allow
|
||||
* an instance `deny`. `delegate` is the sanctioned exception, satisfied only by an explicit
|
||||
* project `allow` rule (`matchedRuleId !== null`) — never by the project's bare `defaultAction`,
|
||||
* since that would let an unconfigured project silently satisfy a delegation it never actually
|
||||
* addressed.
|
||||
*
|
||||
* `scope` names the scope whose verdict decided. A denial under an instance `delegate` is the
|
||||
* project's when the project restricted the type itself (by rule or by a `deny` default), and
|
||||
* the instance's only when the project left the type at its bare `allow` default.
|
||||
*
|
||||
* A project-authored `delegate` is treated as `deny` here (fail-closed): validation rejects it
|
||||
* at write time, so this is defense in depth, not the primary guard.
|
||||
*/
|
||||
export function evaluateComposedType(
|
||||
instance: ScopePolicy,
|
||||
project: ScopePolicy,
|
||||
typeName: string,
|
||||
): ComposedVerdict {
|
||||
const instanceVerdict = evaluateType(instance.attachments, instance.defaultAction, typeName);
|
||||
|
||||
if (instanceVerdict.action === 'deny') {
|
||||
return { action: 'deny', scope: 'instance', matchedRuleId: instanceVerdict.matchedRuleId };
|
||||
}
|
||||
|
||||
const projectVerdict = evaluateType(project.attachments, project.defaultAction, typeName);
|
||||
|
||||
if (instanceVerdict.action === 'delegate') {
|
||||
if (projectVerdict.action === 'allow' && projectVerdict.matchedRuleId !== null) {
|
||||
return { action: 'allow', scope: 'project', matchedRuleId: projectVerdict.matchedRuleId };
|
||||
}
|
||||
// The project restricted the type itself, by rule or by a `deny` default: the denial
|
||||
// is the project's decision, not the unsatisfied delegation's.
|
||||
if (projectVerdict.action !== 'allow') {
|
||||
return { action: 'deny', scope: 'project', matchedRuleId: projectVerdict.matchedRuleId };
|
||||
}
|
||||
// A bare project `allow` default never satisfies a delegation, so the unsatisfied
|
||||
// instance `delegate` is what denies here.
|
||||
return { action: 'deny', scope: 'instance', matchedRuleId: instanceVerdict.matchedRuleId };
|
||||
}
|
||||
|
||||
// instanceVerdict.action === 'allow': the project's own verdict decides.
|
||||
if (projectVerdict.action !== 'allow') {
|
||||
return { action: 'deny', scope: 'project', matchedRuleId: projectVerdict.matchedRuleId };
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'allow',
|
||||
scope: projectVerdict.matchedRuleId !== null ? 'project' : 'instance',
|
||||
matchedRuleId: projectVerdict.matchedRuleId ?? instanceVerdict.matchedRuleId,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,17 @@ export type PolicyRule = {
|
|||
readonly selector: PolicySelector;
|
||||
};
|
||||
|
||||
/**
|
||||
* The actions a project scope accepts. `delegate` defers to a narrower scope, and none exists
|
||||
* below project, so project-scope writes never accept it.
|
||||
*/
|
||||
export type NonDelegatingPolicyAction = Exclude<PolicyAction, 'delegate'>;
|
||||
|
||||
/** A `PolicyRule` as a project scope accepts it. */
|
||||
export type NonDelegatingPolicyRule = Omit<PolicyRule, 'action'> & {
|
||||
readonly action: NonDelegatingPolicyAction;
|
||||
};
|
||||
|
||||
/** One policy document as attached to a scope, with its evaluation-order metadata. */
|
||||
export type PolicyAttachment = {
|
||||
readonly policyId: string;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,21 @@
|
|||
import { LICENSE_FEATURES } from '@n8n/constants';
|
||||
import type { ModuleInterface } from '@n8n/decorators';
|
||||
import { BackendModule } from '@n8n/decorators';
|
||||
|
||||
@BackendModule({ name: 'type-availability-policies' })
|
||||
/**
|
||||
* The module-level `licenseFlag` skips `init()` on an unlicensed instance, so the controllers
|
||||
* are never loaded there. Each route still carries `@Licensed`: modules are initialized once
|
||||
* at startup, so only the per-route check covers a license that changes while running.
|
||||
*/
|
||||
@BackendModule({
|
||||
name: 'type-availability-policies',
|
||||
licenseFlag: LICENSE_FEATURES.NODE_TYPE_POLICIES,
|
||||
})
|
||||
export class TypeAvailabilityPoliciesModule implements ModuleInterface {
|
||||
async init() {
|
||||
// Side-effecting import: registers the controller's routes via `@RestController`.
|
||||
// Side-effecting imports: register the controllers' routes via `@RestController`.
|
||||
await import('./type-availability-policy-instance.controller.js');
|
||||
await import('./type-availability-policy-project.controller.js');
|
||||
}
|
||||
|
||||
async entities() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import { LICENSE_FEATURES } from '@n8n/constants';
|
||||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Body, Get, Licensed, ProjectScope, Put, RestController } from '@n8n/decorators';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { PutProjectPolicyDto } from './dto/put-project-policy.dto';
|
||||
import { TypeAvailabilityPolicyService } from './type-availability-policy.service';
|
||||
|
||||
/**
|
||||
* The one `kind` this REST surface manages — see `TypeAvailabilityPolicyInstanceController`.
|
||||
*/
|
||||
const NODE_TYPES_KIND = 'node-types';
|
||||
|
||||
/**
|
||||
* Project-scope REST surface for a project's own node type availability policy row. Every
|
||||
* route requires `LICENSE_FEATURES.NODE_TYPE_POLICIES` and `nodeTypePolicy:manage`, granted to
|
||||
* project admins so they self-govern their own project's row (per IAM-1142's RFC decision).
|
||||
*
|
||||
* Deliberately narrower than the instance controller: only the composed read/write, mirroring
|
||||
* `GET`/`PUT /instance`. Policy-document CRUD and multi-attachment management stay
|
||||
* instance-only — those act on documents and attachments potentially shared across scopes.
|
||||
*/
|
||||
@RestController('/projects/:projectId/node-type-policies')
|
||||
export class TypeAvailabilityPolicyProjectController {
|
||||
constructor(private readonly service: TypeAvailabilityPolicyService) {}
|
||||
|
||||
@Get('/project')
|
||||
@Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES)
|
||||
@ProjectScope('nodeTypePolicy:manage')
|
||||
async getProjectPolicy(req: AuthenticatedRequest<{ projectId: string }>) {
|
||||
const effective = await this.service.getEffectivePolicy(NODE_TYPES_KIND, req.params.projectId);
|
||||
|
||||
return {
|
||||
scopeId: effective.scopeId,
|
||||
rules: effective.rules,
|
||||
defaultAction: effective.defaultAction,
|
||||
version: effective.version,
|
||||
};
|
||||
}
|
||||
|
||||
@Put('/project')
|
||||
@Licensed(LICENSE_FEATURES.NODE_TYPE_POLICIES)
|
||||
@ProjectScope('nodeTypePolicy:manage')
|
||||
async putProjectPolicy(
|
||||
req: AuthenticatedRequest<{ projectId: string }>,
|
||||
_res: Response,
|
||||
@Body dto: PutProjectPolicyDto,
|
||||
) {
|
||||
const result = await this.service.setEffectivePolicy(
|
||||
NODE_TYPES_KIND,
|
||||
req.params.projectId,
|
||||
{ rules: dto.rules, defaultAction: dto.defaultAction },
|
||||
dto.version,
|
||||
req.user.id,
|
||||
);
|
||||
|
||||
return {
|
||||
scopeId: result.scopeId,
|
||||
rules: result.rules,
|
||||
defaultAction: result.defaultAction,
|
||||
version: result.version,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ import { TypeAvailabilityPolicyScopeRepository } from './database/repositories/t
|
|||
import { TypeAvailabilityPolicyRepository } from './database/repositories/type-availability-policy.repository';
|
||||
import type { TypeAvailabilityPolicy } from './database/entities/type-availability-policy.entity';
|
||||
import type { TypeAvailabilityPolicyScope } from './database/entities/type-availability-policy-scope.entity';
|
||||
import { orderedAttachments } from './policy-evaluator';
|
||||
import { evaluateComposedType, orderedAttachments, type ComposedVerdict } from './policy-evaluator';
|
||||
import type { PolicyAction, PolicyAttachment, PolicyRule } from './policy-rule.types';
|
||||
import { lintRulesForShadowing, type ShadowWarning } from './policy-shadow-lint';
|
||||
|
||||
|
|
@ -55,6 +55,38 @@ function flattenRules(attachments: readonly PolicyAttachment[]): PolicyRule[] {
|
|||
return orderedAttachments(attachments).flatMap((attachment) => [...attachment.rules]);
|
||||
}
|
||||
|
||||
function rulesContainDelegate(rules: readonly PolicyRule[]): boolean {
|
||||
return rules.some((rule) => rule.action === 'delegate');
|
||||
}
|
||||
|
||||
const DELEGATE_RULE_AT_PROJECT_SCOPE = 'A rule cannot use action "delegate" at project scope';
|
||||
|
||||
/**
|
||||
* `delegate` is only satisfiable at instance scope. A project write never accepts it — not in
|
||||
* a rule's `action` (already rejected by `PutProjectPolicyDto`'s schema) and not in
|
||||
* `defaultAction`, which lives on the `policy_scope` row rather than in the policy document, so
|
||||
* the DTO's rule-level check does not cover it on its own. Defense in depth: a future caller
|
||||
* reaching this service directly, not only through the project controller, still can't write an
|
||||
* unsatisfiable `delegate` on a project row.
|
||||
*
|
||||
* The composed write is not the only way rules reach a project row: `replaceAttachments` and
|
||||
* `updatePolicyDocument` guard the same invariant on their own paths.
|
||||
*/
|
||||
function assertNoDelegateAtProjectScope(
|
||||
projectId: string | null,
|
||||
defaultAction: PolicyAction,
|
||||
rules: readonly PolicyRule[] = [],
|
||||
): void {
|
||||
if (projectId === null) return;
|
||||
|
||||
if (defaultAction === 'delegate') {
|
||||
throw new UserError('defaultAction cannot be "delegate" at project scope');
|
||||
}
|
||||
if (rulesContainDelegate(rules)) {
|
||||
throw new UserError(DELEGATE_RULE_AT_PROJECT_SCOPE);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors the DTO-level check in `ReplaceAttachmentsDto`, as a defensive service-level guard. */
|
||||
function assertNoDuplicateAttachmentSlots(attachments: readonly AttachmentInput[]): void {
|
||||
const seenPolicyIds = new Set<string>();
|
||||
|
|
@ -142,6 +174,8 @@ export class TypeAvailabilityPolicyService {
|
|||
expectedVersion: number,
|
||||
updatedBy: string,
|
||||
): Promise<TypeAvailabilityPolicyScope> {
|
||||
assertNoDelegateAtProjectScope(projectId, defaultAction);
|
||||
|
||||
const result = await this.transactionRunner.run({}, async (ctx) => {
|
||||
const scope = await this.scopeRepository.findScopeByKindAndProject(
|
||||
kind,
|
||||
|
|
@ -257,6 +291,15 @@ export class TypeAvailabilityPolicyService {
|
|||
);
|
||||
const lockedScopeIds = await this.scopeRepository.lockScopesByIds(attachedScopeIds, ctx);
|
||||
|
||||
// A document attached to a project scope is part of that project's policy, so the
|
||||
// same `delegate` rejection as a direct project write applies to editing it.
|
||||
if (
|
||||
rulesContainDelegate(rules) &&
|
||||
(await this.scopeRepository.containsProjectScope(lockedScopeIds, ctx))
|
||||
) {
|
||||
throw new UserError(DELEGATE_RULE_AT_PROJECT_SCOPE);
|
||||
}
|
||||
|
||||
const existing = await this.policyRepository.findById(policyId, ctx, true);
|
||||
if (!existing) {
|
||||
throw new NotFoundError(`Policy document not found: ${policyId}`);
|
||||
|
|
@ -382,6 +425,23 @@ export class TypeAvailabilityPolicyService {
|
|||
throw new NotFoundError(`Policy scope not found: ${scopeId}`);
|
||||
}
|
||||
|
||||
// Attaching a document to a project scope makes its rules that project's policy, so
|
||||
// a document carrying `delegate` is rejected the same way a direct project write is.
|
||||
// The documents are read under the same row lock `updatePolicyDocument` takes, so a
|
||||
// concurrent edit cannot slip a `delegate` in between this check and the attach: one
|
||||
// of the two waits, and the loser either sees the new rules here or fails that path's
|
||||
// own "attached while updating" conflict check.
|
||||
if (scope.projectId !== null && attachments.length > 0) {
|
||||
const policies = await this.policyRepository.findManyByIds(
|
||||
attachments.map((a) => a.policyId),
|
||||
ctx,
|
||||
true,
|
||||
);
|
||||
if (policies.some((policy) => rulesContainDelegate(policy.rules))) {
|
||||
throw new UserError(DELEGATE_RULE_AT_PROJECT_SCOPE);
|
||||
}
|
||||
}
|
||||
|
||||
const before = await this.attachmentRepository.listAttachmentsForScope(scopeId, ctx);
|
||||
|
||||
await this.attachmentRepository.replaceAttachmentsForScope(
|
||||
|
|
@ -442,6 +502,8 @@ export class TypeAvailabilityPolicyService {
|
|||
rules: PolicyRule[];
|
||||
warnings: readonly ShadowWarning[];
|
||||
}> {
|
||||
assertNoDelegateAtProjectScope(projectId, input.defaultAction, input.rules);
|
||||
|
||||
const warnings = lintRulesForShadowing(input.rules);
|
||||
|
||||
const result = await this.transactionRunner.run({}, async (ctx) => {
|
||||
|
|
@ -463,6 +525,45 @@ export class TypeAvailabilityPolicyService {
|
|||
? { defaultAction: scope.defaultAction, version: scope.version }
|
||||
: null;
|
||||
|
||||
const existingAttachments = scope
|
||||
? await this.attachmentRepository.listAttachmentsForScope(scope.id, ctx)
|
||||
: [];
|
||||
|
||||
// This write edits the scope's single document in place. It refuses when that would
|
||||
// reach further than the scope itself: several attachments (editing only the first
|
||||
// would silently leave the rest in force), or a document also attached elsewhere
|
||||
// (editing it would change every other scope that uses it — at project scope, that
|
||||
// would let a project admin rewrite the instance policy). Both states can only be
|
||||
// produced through the instance-only attachment management, so the same admin can
|
||||
// undo them there. Checked before any write, so a refusal leaves nothing to roll back.
|
||||
if (existingAttachments.length > 1) {
|
||||
throw new ConflictError(
|
||||
`Cannot replace the policy of a scope with ${existingAttachments.length} attached documents; manage its attachments instead`,
|
||||
);
|
||||
}
|
||||
|
||||
const existingDocumentId = existingAttachments[0]?.policyId ?? null;
|
||||
|
||||
// Lock the document before checking who else uses it: an attach elsewhere key-shares
|
||||
// the document row, so it waits for this transaction (or this one waits for it) and
|
||||
// the check below cannot be overtaken between reading the attachments and the edit.
|
||||
// Scope first, then document — the order every write path keeps.
|
||||
const existingDocument = existingDocumentId
|
||||
? await this.policyRepository.findById(existingDocumentId, ctx, true)
|
||||
: null;
|
||||
|
||||
if (scope && existingDocumentId) {
|
||||
const attachedScopeIds = await this.attachmentRepository.listScopeIdsAttachedToPolicy(
|
||||
existingDocumentId,
|
||||
ctx,
|
||||
);
|
||||
if (attachedScopeIds.some((id) => id !== scope.id)) {
|
||||
throw new ConflictError(
|
||||
'Cannot replace a policy document that is attached to other scopes; manage its attachments instead',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const scopeId = scope
|
||||
? scope.id
|
||||
: (
|
||||
|
|
@ -481,19 +582,15 @@ export class TypeAvailabilityPolicyService {
|
|||
);
|
||||
}
|
||||
|
||||
const existingAttachments = scope
|
||||
? await this.attachmentRepository.listAttachmentsForScope(scopeId, ctx)
|
||||
: [];
|
||||
const existingDocumentId = existingAttachments[0]?.policyId ?? null;
|
||||
|
||||
let documentBefore: { rules: readonly PolicyRule[]; version: number } | null = null;
|
||||
let documentAfter: { rules: readonly PolicyRule[]; version: number };
|
||||
let documentCreated: boolean;
|
||||
let policyId: string;
|
||||
|
||||
if (existingDocumentId) {
|
||||
const before = await this.policyRepository.findById(existingDocumentId, ctx);
|
||||
documentBefore = before ? { rules: before.rules, version: before.version } : null;
|
||||
documentBefore = existingDocument
|
||||
? { rules: existingDocument.rules, version: existingDocument.version }
|
||||
: null;
|
||||
|
||||
const updated = await this.policyRepository.updateRules(
|
||||
existingDocumentId,
|
||||
|
|
@ -582,4 +679,22 @@ export class TypeAvailabilityPolicyService {
|
|||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes the instance and project verdicts for one type, per `evaluateComposedType`. Reads
|
||||
* both scopes' effective policies in parallel — point-in-time snapshots, not one transaction,
|
||||
* which is fine for an evaluation path (unlike a write).
|
||||
*/
|
||||
async evaluateComposedType(
|
||||
kind: string,
|
||||
projectId: string,
|
||||
typeName: string,
|
||||
): Promise<ComposedVerdict> {
|
||||
const [instance, project] = await Promise.all([
|
||||
this.getEffectivePolicy(kind, null),
|
||||
this.getEffectivePolicy(kind, projectId),
|
||||
]);
|
||||
|
||||
return evaluateComposedType(instance, project, typeName);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,394 @@
|
|||
import { createTeamProject, linkUserToProject, testDb } from '@n8n/backend-test-utils';
|
||||
import { LICENSE_FEATURES } from '@n8n/constants';
|
||||
import type { Project, User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { createMember, createOwner } from '@test-integration/db/users';
|
||||
import * as utils from '@test-integration/utils';
|
||||
|
||||
const testServer = utils.setupTestServer({
|
||||
endpointGroups: ['type-availability-policies'],
|
||||
modules: ['type-availability-policies'],
|
||||
enabledFeatures: [LICENSE_FEATURES.NODE_TYPE_POLICIES],
|
||||
});
|
||||
|
||||
let owner: User;
|
||||
let projectAdmin: User;
|
||||
let projectEditor: User;
|
||||
let projectViewer: User;
|
||||
let otherProjectAdmin: User;
|
||||
let project: Project;
|
||||
let otherProject: Project;
|
||||
|
||||
const projectRoute = (projectId: string) => `/projects/${projectId}/node-type-policies/project`;
|
||||
|
||||
const ALLOW_ALL = { rules: [], defaultAction: 'allow', version: 0 };
|
||||
const DENY_RULE = { id: 'r1', action: 'deny', selector: { kind: 'name', value: 'a.b' } };
|
||||
const DELEGATE_RULE = { id: 'r2', action: 'delegate', selector: { kind: 'name', value: 'a.b' } };
|
||||
|
||||
beforeAll(async () => {
|
||||
owner = await createOwner();
|
||||
projectAdmin = await createMember();
|
||||
projectEditor = await createMember();
|
||||
projectViewer = await createMember();
|
||||
otherProjectAdmin = await createMember();
|
||||
|
||||
project = await createTeamProject('Policy project', projectAdmin);
|
||||
otherProject = await createTeamProject('Other project', otherProjectAdmin);
|
||||
await linkUserToProject(projectEditor, project, 'project:editor');
|
||||
await linkUserToProject(projectViewer, project, 'project:viewer');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await testDb.truncate([
|
||||
'TypeAvailabilityPolicyAttachment',
|
||||
'TypeAvailabilityPolicyScope',
|
||||
'TypeAvailabilityPolicy',
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* IAM-1142: a project admin self-governs their own project's row. Every other project role,
|
||||
* and an admin of a different project, is rejected with 403.
|
||||
*/
|
||||
describe('node type availability policy project controller RBAC', () => {
|
||||
test('PUT rejects a project editor with 403', async () => {
|
||||
const response = await testServer
|
||||
.authAgentFor(projectEditor)
|
||||
.put(projectRoute(project.id))
|
||||
.send(ALLOW_ALL);
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
test('GET rejects a project editor with 403', async () => {
|
||||
const response = await testServer.authAgentFor(projectEditor).get(projectRoute(project.id));
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
test('GET rejects a project viewer with 403', async () => {
|
||||
const response = await testServer.authAgentFor(projectViewer).get(projectRoute(project.id));
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
test('GET rejects the admin of a different project with 403', async () => {
|
||||
const response = await testServer.authAgentFor(otherProjectAdmin).get(projectRoute(project.id));
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
test('PUT rejects the admin of a different project with 403 and writes nothing', async () => {
|
||||
const response = await testServer
|
||||
.authAgentFor(otherProjectAdmin)
|
||||
.put(projectRoute(project.id))
|
||||
.send({ ...ALLOW_ALL, defaultAction: 'deny' });
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
const unchanged = await testServer.authAgentFor(projectAdmin).get(projectRoute(project.id));
|
||||
expect(unchanged.body.data.version).toBe(0);
|
||||
});
|
||||
|
||||
test('an unauthenticated caller is rejected before the scope check even runs', async () => {
|
||||
const response = await testServer.authlessAgent.get(projectRoute(project.id));
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
test('the admin of the project is not rejected by the scope check', async () => {
|
||||
const response = await testServer.authAgentFor(projectAdmin).get(projectRoute(project.id));
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('an instance owner without a project role is not rejected by the scope check', async () => {
|
||||
const response = await testServer.authAgentFor(owner).get(projectRoute(project.id));
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('node type availability policy project controller license gating', () => {
|
||||
afterEach(() => {
|
||||
testServer.license.enable(LICENSE_FEATURES.NODE_TYPE_POLICIES);
|
||||
});
|
||||
|
||||
test('rejects a project admin with 403 when the license feature is disabled', async () => {
|
||||
testServer.license.disable(LICENSE_FEATURES.NODE_TYPE_POLICIES);
|
||||
|
||||
const response = await testServer.authAgentFor(projectAdmin).get(projectRoute(project.id));
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('node type availability policy project controller project admin happy path', () => {
|
||||
test('GET on a project that was never configured reports allow-all at version 0', async () => {
|
||||
const response = await testServer.authAgentFor(projectAdmin).get(projectRoute(project.id));
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data).toEqual({
|
||||
scopeId: null,
|
||||
rules: [],
|
||||
defaultAction: 'allow',
|
||||
version: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('PUT persists, bumps version, and fires an audit event scoped to the project', async () => {
|
||||
const eventService = Container.get(EventService);
|
||||
const emitSpy = vi.spyOn(eventService, 'emit');
|
||||
|
||||
const first = await testServer
|
||||
.authAgentFor(projectAdmin)
|
||||
.put(projectRoute(project.id))
|
||||
.send({ rules: [DENY_RULE], defaultAction: 'allow', version: 0 });
|
||||
|
||||
expect(first.statusCode).toBe(200);
|
||||
expect(first.body.data.version).toBeGreaterThan(0);
|
||||
expect(first.body.data.rules).toEqual([DENY_RULE]);
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
'node-type-policy-scope-updated',
|
||||
expect.objectContaining({ updatedBy: projectAdmin.id, projectId: project.id, before: null }),
|
||||
);
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
'node-type-policy-document-created',
|
||||
expect.objectContaining({ updatedBy: projectAdmin.id }),
|
||||
);
|
||||
|
||||
const persisted = await testServer.authAgentFor(projectAdmin).get(projectRoute(project.id));
|
||||
expect(persisted.body.data.rules).toEqual(first.body.data.rules);
|
||||
expect(persisted.body.data.version).toBe(first.body.data.version);
|
||||
|
||||
emitSpy.mockClear();
|
||||
const second = await testServer
|
||||
.authAgentFor(projectAdmin)
|
||||
.put(projectRoute(project.id))
|
||||
.send({ rules: [], defaultAction: 'deny', version: first.body.data.version });
|
||||
|
||||
expect(second.statusCode).toBe(200);
|
||||
expect(second.body.data.version).toBeGreaterThan(first.body.data.version);
|
||||
expect(second.body.data.defaultAction).toBe('deny');
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
'node-type-policy-scope-updated',
|
||||
expect.objectContaining({ updatedBy: projectAdmin.id, projectId: project.id }),
|
||||
);
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
'node-type-policy-document-updated',
|
||||
expect.objectContaining({ updatedBy: projectAdmin.id }),
|
||||
);
|
||||
});
|
||||
|
||||
test('a project write leaves the instance scope and other projects untouched', async () => {
|
||||
const written = await testServer
|
||||
.authAgentFor(projectAdmin)
|
||||
.put(projectRoute(project.id))
|
||||
.send({ rules: [DENY_RULE], defaultAction: 'deny', version: 0 });
|
||||
expect(written.statusCode).toBe(200);
|
||||
|
||||
const instance = await testServer.authAgentFor(owner).get('/node-type-policies/instance');
|
||||
expect(instance.body.data.version).toBe(0);
|
||||
expect(instance.body.data.defaultAction).toBe('allow');
|
||||
|
||||
const other = await testServer
|
||||
.authAgentFor(otherProjectAdmin)
|
||||
.get(projectRoute(otherProject.id));
|
||||
expect(other.body.data.version).toBe(0);
|
||||
expect(other.body.data.rules).toEqual([]);
|
||||
});
|
||||
|
||||
test('PUT with a stale version returns 409', async () => {
|
||||
const first = await testServer
|
||||
.authAgentFor(projectAdmin)
|
||||
.put(projectRoute(project.id))
|
||||
.send(ALLOW_ALL);
|
||||
expect(first.statusCode).toBe(200);
|
||||
|
||||
const stale = await testServer
|
||||
.authAgentFor(projectAdmin)
|
||||
.put(projectRoute(project.id))
|
||||
.send({ ...ALLOW_ALL, defaultAction: 'deny' });
|
||||
|
||||
expect(stale.statusCode).toBe(409);
|
||||
});
|
||||
|
||||
test('PUT with a delegate defaultAction is rejected with 400 and writes nothing', async () => {
|
||||
const response = await testServer
|
||||
.authAgentFor(projectAdmin)
|
||||
.put(projectRoute(project.id))
|
||||
.send({ rules: [], defaultAction: 'delegate', version: 0 });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
|
||||
const unchanged = await testServer.authAgentFor(projectAdmin).get(projectRoute(project.id));
|
||||
expect(unchanged.body.data.version).toBe(0);
|
||||
});
|
||||
|
||||
test('PUT with a delegate rule is rejected with 400 and writes nothing', async () => {
|
||||
const response = await testServer
|
||||
.authAgentFor(projectAdmin)
|
||||
.put(projectRoute(project.id))
|
||||
.send({ rules: [DELEGATE_RULE], defaultAction: 'allow', version: 0 });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
|
||||
const unchanged = await testServer.authAgentFor(projectAdmin).get(projectRoute(project.id));
|
||||
expect(unchanged.body.data.version).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The project write edits the project's single document in place. When instance-only
|
||||
* attachment management has made that document reach further than the project — shared with
|
||||
* another scope, or one of several — the write is refused rather than silently changing
|
||||
* policy the project admin does not govern.
|
||||
*/
|
||||
describe('node type availability policy project controller shared document protection', () => {
|
||||
async function configureProject() {
|
||||
const response = await testServer
|
||||
.authAgentFor(projectAdmin)
|
||||
.put(projectRoute(project.id))
|
||||
.send({ rules: [DENY_RULE], defaultAction: 'allow', version: 0 });
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const documents = await testServer.authAgentFor(owner).get('/node-type-policies/policies');
|
||||
expect(documents.body.data).toHaveLength(1);
|
||||
|
||||
return {
|
||||
scopeId: response.body.data.scopeId as string,
|
||||
documentId: documents.body.data[0].id as string,
|
||||
};
|
||||
}
|
||||
|
||||
test('PUT is rejected with 409 when the project document is also attached to the instance scope', async () => {
|
||||
const { documentId } = await configureProject();
|
||||
|
||||
const instancePut = await testServer
|
||||
.authAgentFor(owner)
|
||||
.put('/node-type-policies/instance')
|
||||
.send(ALLOW_ALL);
|
||||
const instanceScopeId = instancePut.body.data.scopeId;
|
||||
|
||||
const shared = await testServer
|
||||
.authAgentFor(owner)
|
||||
.put(`/node-type-policies/scopes/${instanceScopeId}/attachments`)
|
||||
.send({ attachments: [{ policyId: documentId, priority: 0, isFloor: false }] });
|
||||
expect(shared.statusCode).toBe(200);
|
||||
|
||||
const current = await testServer.authAgentFor(projectAdmin).get(projectRoute(project.id));
|
||||
const response = await testServer
|
||||
.authAgentFor(projectAdmin)
|
||||
.put(projectRoute(project.id))
|
||||
.send({ rules: [], defaultAction: 'allow', version: current.body.data.version });
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
|
||||
const document = await testServer
|
||||
.authAgentFor(owner)
|
||||
.get(`/node-type-policies/policies/${documentId}`);
|
||||
expect(document.body.data.rules).toEqual([DENY_RULE]);
|
||||
});
|
||||
|
||||
test('PUT is rejected with 409 when the project scope has several attached documents', async () => {
|
||||
const { scopeId, documentId } = await configureProject();
|
||||
|
||||
const created = await testServer
|
||||
.authAgentFor(owner)
|
||||
.post('/node-type-policies/policies')
|
||||
.send({ rules: [] });
|
||||
const secondDocumentId = created.body.data.policy.id;
|
||||
|
||||
const attached = await testServer
|
||||
.authAgentFor(owner)
|
||||
.put(`/node-type-policies/scopes/${scopeId}/attachments`)
|
||||
.send({
|
||||
attachments: [
|
||||
{ policyId: documentId, priority: 0, isFloor: false },
|
||||
{ policyId: secondDocumentId, priority: 1, isFloor: false },
|
||||
],
|
||||
});
|
||||
expect(attached.statusCode).toBe(200);
|
||||
|
||||
const current = await testServer.authAgentFor(projectAdmin).get(projectRoute(project.id));
|
||||
const response = await testServer
|
||||
.authAgentFor(projectAdmin)
|
||||
.put(projectRoute(project.id))
|
||||
.send({ rules: [], defaultAction: 'allow', version: current.body.data.version });
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `delegate` is rejected on every path that puts rules on a project row, not only the
|
||||
* project's own composed write.
|
||||
*/
|
||||
describe('node type availability policy project controller delegate rejection via attachments', () => {
|
||||
test('attaching a document with a delegate rule to a project scope is rejected with 400', async () => {
|
||||
const configured = await testServer
|
||||
.authAgentFor(projectAdmin)
|
||||
.put(projectRoute(project.id))
|
||||
.send(ALLOW_ALL);
|
||||
const scopeId = configured.body.data.scopeId;
|
||||
|
||||
const created = await testServer
|
||||
.authAgentFor(owner)
|
||||
.post('/node-type-policies/policies')
|
||||
.send({ rules: [DELEGATE_RULE] });
|
||||
expect(created.statusCode).toBe(200);
|
||||
|
||||
const response = await testServer
|
||||
.authAgentFor(owner)
|
||||
.put(`/node-type-policies/scopes/${scopeId}/attachments`)
|
||||
.send({
|
||||
attachments: [{ policyId: created.body.data.policy.id, priority: 0, isFloor: false }],
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
|
||||
const unchanged = await testServer.authAgentFor(projectAdmin).get(projectRoute(project.id));
|
||||
expect(unchanged.body.data.rules).toEqual([]);
|
||||
});
|
||||
|
||||
test('adding a delegate rule to a document attached to a project scope is rejected with 400', async () => {
|
||||
const configured = await testServer
|
||||
.authAgentFor(projectAdmin)
|
||||
.put(projectRoute(project.id))
|
||||
.send(ALLOW_ALL);
|
||||
expect(configured.statusCode).toBe(200);
|
||||
|
||||
const documents = await testServer.authAgentFor(owner).get('/node-type-policies/policies');
|
||||
const document = documents.body.data[0];
|
||||
|
||||
const response = await testServer
|
||||
.authAgentFor(owner)
|
||||
.patch(`/node-type-policies/policies/${document.id}`)
|
||||
.send({ rules: [DELEGATE_RULE], version: document.version });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
|
||||
const unchanged = await testServer.authAgentFor(projectAdmin).get(projectRoute(project.id));
|
||||
expect(unchanged.body.data.rules).toEqual([]);
|
||||
});
|
||||
|
||||
test('adding a delegate rule to a document attached to the instance scope only still works', async () => {
|
||||
const instancePut = await testServer
|
||||
.authAgentFor(owner)
|
||||
.put('/node-type-policies/instance')
|
||||
.send(ALLOW_ALL);
|
||||
expect(instancePut.statusCode).toBe(200);
|
||||
|
||||
const documents = await testServer.authAgentFor(owner).get('/node-type-policies/policies');
|
||||
const document = documents.body.data[0];
|
||||
|
||||
const response = await testServer
|
||||
.authAgentFor(owner)
|
||||
.patch(`/node-type-policies/policies/${document.id}`)
|
||||
.send({ rules: [DELEGATE_RULE], version: document.version });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
|
@ -170,6 +170,17 @@ describe('type availability policy repositories', () => {
|
|||
expect(found.map((p) => p.id).sort()).toEqual([a.id, b.id].sort());
|
||||
});
|
||||
|
||||
it('finds many by id under a row lock inside a transaction', async () => {
|
||||
const a = await createPolicy();
|
||||
const b = await createPolicy([ALLOW_BASE]);
|
||||
|
||||
const found = await transactionRunner.run(ROOT, async (ctx) => {
|
||||
return await policyRepo.findManyByIds([b.id, a.id], ctx, true);
|
||||
});
|
||||
|
||||
expect(found.map((p) => p.id)).toEqual([a.id, b.id].sort());
|
||||
});
|
||||
|
||||
it('refuses to delete a policy that is still attached', async () => {
|
||||
const policy = await createPolicy();
|
||||
const scope = await createInstanceScope();
|
||||
|
|
@ -288,6 +299,44 @@ describe('type availability policy repositories', () => {
|
|||
expect(scope.updatedBy).toBe('user-1');
|
||||
});
|
||||
|
||||
describe('containsProjectScope', () => {
|
||||
it('is false for an empty id list', async () => {
|
||||
expect(await scopeRepo.containsProjectScope([], ROOT)).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when every named scope is the instance scope', async () => {
|
||||
const instanceScope = await createInstanceScope();
|
||||
|
||||
expect(await scopeRepo.containsProjectScope([instanceScope.id], ROOT)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true when one of the named scopes is a project scope', async () => {
|
||||
const project = await createTeamProject();
|
||||
const instanceScope = await createInstanceScope();
|
||||
const projectScope = await createProjectScope(project.id);
|
||||
|
||||
expect(
|
||||
await scopeRepo.containsProjectScope([instanceScope.id, projectScope.id], ROOT),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores ids that name no scope', async () => {
|
||||
await createInstanceScope();
|
||||
|
||||
expect(await scopeRepo.containsProjectScope(['does-not-exist'], ROOT)).toBe(false);
|
||||
});
|
||||
|
||||
it('finds a project scope that lands in a later batch', async () => {
|
||||
const project = await createTeamProject();
|
||||
const projectScope = await createProjectScope(project.id);
|
||||
// One batch per ID_QUERY_BATCH_SIZE (10,000) ids: the real id is the 10,001st, so
|
||||
// only the second batch can find it.
|
||||
const ids = [...Array.from({ length: 10_000 }, (_, i) => `missing-${i}`), projectScope.id];
|
||||
|
||||
expect(await scopeRepo.containsProjectScope(ids, ROOT)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('locks only the scopes that exist and returns their ids', async () => {
|
||||
const project = await createTeamProject();
|
||||
const instanceScope = await createInstanceScope();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user