Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/22464-security-service-controlled-by-parent-write.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/spec': minor
---

`ISecurityService` declares an optional, feature-detected member that answers the existing ADR-0055 master-detail write check: `checkControlledByParentWrite(object, recordId, context)`.

Clause-②: yes (widening)

- **What it answers.** It answers what the master-detail write check decides for an update of one record by the caller. A by-id write to a `controlled_by_parent` record requires edit access to its master, and the write path checks each master up the chain: the object-level `update` grant, the master's write row-level security, and record sharing. The answer equals what a by-id update of the same record gets from that check, the ADR-0090 D10 delegator leg included. It adds no verdict: it exposes the one the write path already reaches, so a gate on a record's attachments or comments can judge the record as its own update is judged.
- **What it resolves with.** `ControlledByParentWriteOutcome` is a discriminated union. `allow` means the check does not refuse. `deny` names the leg that refused (`ControlledByParentWriteDenialLeg`: `object_permission`, `row_level_security`, `record_sharing` or `master_chain`). `not_applicable` means the object is not `controlled_by_parent`. `unresolvable` names why the check reached no verdict (`ControlledByParentWriteUnresolvedReason`: a missing `master_detail` relation, a record that does not exist, or an empty master reference). A store fault is not an outcome: the member rejects with the engine's own error, so the fault keeps its declared status (a datasource outage is `503`).
- **Optional, and absence is typed.** A security service without the member still satisfies the contract, and an unguarded call does not compile. Absence means no master check is composed for a consumer to ask, which is not a policy that admits.

Nothing an author writes changes, and no runtime behaviour changes. `@objectstack/plugin-security` does not serve the member yet. An implementation typed as `ISecurityService` that serves this name must serve it under the declared signature.
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const DECLARED_MEMBERS = {
resolveWriteScope: 'required',
describeDelegationNarrowing: 'optional',
checkAuthoredRowWrite: 'optional',
checkControlledByParentWrite: 'optional',
explain: 'required',
describeDelegableScope: 'required',
listAudienceBindingSuggestions: 'required',
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/api-surface/contracts.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@
"ConnectorSourcePullRequest (interface)",
"ConnectorSourcePullResult (interface)",
"ConnectorSourcePullSummary (interface)",
"ControlledByParentWriteDenialLeg (type)",
"ControlledByParentWriteOutcome (type)",
"ControlledByParentWriteUnresolvedReason (type)",
"CoreServiceContract (type)",
"CoreServiceContracts (interface)",
"CounterIncrOptions (interface)",
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/export-origins/contracts.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@
"ConnectorSourcePullRequest": "src/contracts/automation-service.ts#ConnectorSourcePullRequest (interface)",
"ConnectorSourcePullResult": "src/contracts/automation-service.ts#ConnectorSourcePullResult (interface)",
"ConnectorSourcePullSummary": "src/contracts/automation-service.ts#ConnectorSourcePullSummary (interface)",
"ControlledByParentWriteDenialLeg": "src/contracts/security-service.ts#ControlledByParentWriteDenialLeg (type)",
"ControlledByParentWriteOutcome": "src/contracts/security-service.ts#ControlledByParentWriteOutcome (type)",
"ControlledByParentWriteUnresolvedReason": "src/contracts/security-service.ts#ControlledByParentWriteUnresolvedReason (type)",
"CoreServiceContract": "src/contracts/core-service-contracts.ts#CoreServiceContract (type)",
"CoreServiceContracts": "src/contracts/core-service-contracts.ts#CoreServiceContracts (interface)",
"CounterIncrOptions": "src/contracts/cluster-service.ts#CounterIncrOptions (interface)",
Expand Down
133 changes: 133 additions & 0 deletions packages/spec/src/contracts/security-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import type {
AuthoredRowWriteVerdict,
AuthoredRowWriteOperation,
OwnershipFloorAlternate,
ControlledByParentWriteOutcome,
ControlledByParentWriteDenialLeg,
ControlledByParentWriteUnresolvedReason,
} from './security-service';

/**
Expand Down Expand Up @@ -642,4 +645,134 @@ describe('Security Service Contract', () => {
};
expect(notOneLimb.operation).toBe('all');
});

it('checkControlledByParentWrite is OPTIONAL — absence is typed, and it means no master check, not allow', async () => {
// A security service without the member still satisfies the contract, and
// a consumer cannot reach the master check without handling the absent
// case first.
const withoutIt: ISecurityService = makeService();
expect(typeof withoutIt.checkControlledByParentWrite).toBe('undefined');

// The unguarded call does not compile. Never invoked: its only job is to
// make the COMPILER prove the point.
const mustNotCompileWithoutAGuard = () =>
// @ts-expect-error possibly undefined — a consumer must feature-detect first
withoutIt.checkControlledByParentWrite('order_line', 'r1', { userId: 'u1' });
expect(typeof mustNotCompileWithoutAGuard).toBe('function');

// The shape a gate writes. Absence proceeds, because the parent's own
// update meets no master check there either, and it is reported as its own
// state: nothing measured the master, so it is never relabelled `allow`.
const gate = async (svc: ISecurityService) => {
if (typeof svc.checkControlledByParentWrite !== 'function') return 'no-master-check';
const answer = await svc.checkControlledByParentWrite('order_line', 'r1', { userId: 'u1' });
return answer.outcome === 'allow' || answer.outcome === 'not_applicable' ? 'proceed' : 'refuse';
};
await expect(gate(withoutIt)).resolves.toBe('no-master-check');

const seen: unknown[] = [];
const withIt = makeService({
checkControlledByParentWrite: async (object, recordId, context) => {
seen.push([object, recordId, context]);
return { outcome: 'deny', leg: 'record_sharing' };
},
});
await expect(gate(withIt)).resolves.toBe('refuse');
expect(seen).toEqual([['order_line', 'r1', { userId: 'u1' }]]);
await expect(gate(makeService({ checkControlledByParentWrite: async () => ({ outcome: 'allow' }) })))
.resolves.toBe('proceed');
});

it('ControlledByParentWriteOutcome: deny names its leg, unresolvable its reason, and neither arm can omit it (compile-time)', () => {
const everyOutcome: ControlledByParentWriteOutcome[] = [
{ outcome: 'allow' },
{ outcome: 'deny', leg: 'object_permission' },
{ outcome: 'not_applicable' },
{ outcome: 'unresolvable', reason: 'record_not_found' },
];
expect(everyOutcome).toHaveLength(4);

// A refusal with no leg, and a non-verdict with no reason, are not
// outcomes: the union holds each field to its arm.
// @ts-expect-error a `deny` must name the leg that refused
const denyWithoutLeg: ControlledByParentWriteOutcome = { outcome: 'deny' };
// @ts-expect-error an `unresolvable` must name why no verdict was reached
const unresolvableWithoutReason: ControlledByParentWriteOutcome = { outcome: 'unresolvable' };
expect([denyWithoutLeg, unresolvableWithoutReason]).toHaveLength(2);

// `abstain` is the sharing verdict's state, not this check's: the object
// either has no master check (`not_applicable`) or the check answers.
// @ts-expect-error `abstain` is not an outcome of the master-detail check
const notAnOutcome: ControlledByParentWriteOutcome = { outcome: 'abstain' };
expect(notAnOutcome.outcome).toBe('abstain');

// Both vocabularies are closed, so a consumer branches on a value and
// never parses the refusal's prose.
const everyLeg: ControlledByParentWriteDenialLeg[] = [
'object_permission',
'row_level_security',
'record_sharing',
'master_chain',
];
const everyReason: ControlledByParentWriteUnresolvedReason[] = [
'master_detail_relation_missing',
'record_not_found',
'master_reference_missing',
];
expect(everyLeg).toHaveLength(4);
expect(everyReason).toHaveLength(3);
// A store fault is a REJECTION with its own status, never a reason here.
// @ts-expect-error a store fault rejects; it is not a reason the check gives
const faultIsNotAReason: ControlledByParentWriteUnresolvedReason = 'store_fault';
expect(faultIsNotAReason).toBe('store_fault');

// An exhaustive consumer: every arm is handled, and a fifth arm added to
// the union would stop this switch compiling at the `never` line.
const decide = (answer: ControlledByParentWriteOutcome): 'proceed' | 'refuse' => {
switch (answer.outcome) {
case 'allow':
case 'not_applicable':
return 'proceed';
case 'deny':
case 'unresolvable':
return 'refuse';
default: {
const unhandled: never = answer;
return unhandled;
}
}
};
expect(everyOutcome.map(decide)).toEqual(['proceed', 'refuse', 'proceed', 'refuse']);
});

it('checkControlledByParentWrite: a store fault REJECTS with its own status, and a gate propagates it', async () => {
// The check lets a read fault propagate rather than report it as an
// access answer, so the member rejects with the engine's own error. A gate
// that propagates the rejection keeps the declared 503; one that caught it
// into `deny` would answer an outage as a refusal, and one that caught it
// into `allow` would fail open.
const outage = Object.assign(new Error('datasource unavailable'), {
code: 'ERR_DATASOURCE_UNAVAILABLE',
status: 503,
});
const failing = makeService({
checkControlledByParentWrite: async () => {
throw outage;
},
});
const gate = async (svc: ISecurityService) => {
if (typeof svc.checkControlledByParentWrite !== 'function') return 'no-master-check';
const answer = await svc.checkControlledByParentWrite('order_line', 'r1', { userId: 'u1' });
return answer.outcome === 'allow' || answer.outcome === 'not_applicable' ? 'proceed' : 'refuse';
};
await expect(gate(failing)).rejects.toMatchObject({ code: 'ERR_DATASOURCE_UNAVAILABLE', status: 503 });

// A non-verdict is an outcome, and the gate refuses on it: a record whose
// access IS derived from a master and whose check reached no verdict is
// not a record nothing refuses.
const missingRow = makeService({
checkControlledByParentWrite: async () => ({ outcome: 'unresolvable', reason: 'record_not_found' }),
});
await expect(gate(missingRow)).resolves.toBe('refuse');
});
});
Loading
Loading