Skip to content

Commit f595a7f

Browse files
committed
fix(approvals): refuse cross-org targeting on directory-less approver types
`user`, `field` and `manager` return EARLY in `resolveApproverSpec` — they name a person outright instead of expanding a directory. D9's org resolution (#3824) sat AFTER those returns, so an `organization` declared on one of them never reached the check and was silently INERT. That is precisely the behaviour ADR-0105 D9 rules out and the authoring docs promise against ("`organization` on those is refused at runtime"). The `os lint` rule caught it at author time, but the runtime claim was false — and a flow stored before the lint existed, or assembled programmatically, got no signal. Resolution moves to the top of `resolveApproverSpec`, above every early return, so the refusal reaches all three types. The ordinary path is unchanged and still free: with no `organization` declared the resolver returns the request's organization without reading anything. Why the existing tests missed it: `approver-org-scope.test.ts` calls the resolver DIRECTLY, so it never traverses the early return, and the service-level integration test only covered `position`. Both gaps are closed — the new case opens a real request for each of the three directory-less types and asserts the refusal, plus one that a directory-less approver with NO declaration is untouched, so the guard cannot start costing the ordinary case. Found by cloud's group-posture dogfood on a real `group` boot. The two links that dogfood was written to check both hold: a cross-organization approver does reach her queue through the D2 union wall, and `decide()` does accept her. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL
1 parent 1d4756e commit f595a7f

3 files changed

Lines changed: 75 additions & 6 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
fix(approvals): refuse `organization` on directory-less approver types instead
6+
of silently ignoring it (ADR-0105 D9)
7+
8+
`user`, `field` and `manager` return EARLY in `resolveApproverSpec` — they name
9+
a person outright rather than expanding a directory. D9's org resolution was
10+
placed after those returns, so an `organization` declared on one of them never
11+
reached the check: it was silently INERT.
12+
13+
That is the one behaviour ADR-0105 D9 rules out and the authoring docs
14+
explicitly promise against ("`organization` on those is refused at runtime").
15+
The `os lint` rule caught it at author time, but the runtime claim was false —
16+
and a stored flow that predates the lint, or one assembled programmatically,
17+
got no signal at all.
18+
19+
Resolution now happens at the top of `resolveApproverSpec`, above every early
20+
return, so the refusal reaches all three types. The ordinary path is unchanged
21+
and still costs nothing: with no `organization` declared the resolver returns
22+
the request's organization without reading anything.
23+
24+
Found by cloud's group-posture dogfood driving a real `group` boot — the
25+
resolver's own unit tests could not see it, because they call the resolver
26+
directly and never traverse the early return.

packages/plugins/plugin-approvals/src/approval-service.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,23 @@ export class ApprovalService implements IApprovalService {
784784
{ deprecated: a.type, canonical: type },
785785
);
786786
}
787+
// [ADR-0105 D9] WHERE this approver is looked up — the request's own
788+
// organization unless the spec targets another one in the same group.
789+
//
790+
// Resolved HERE, above the `user` / `field` / `manager` early returns,
791+
// because refusing a declaration on a directory-less type is one of the
792+
// things this resolution DOES (those types name a person outright, so
793+
// `organization` on them cannot narrow anything and an author who wrote it
794+
// misunderstood the field). Resolving it after those returns made the
795+
// refusal unreachable and the declaration silently inert — exactly the
796+
// "ignored, not refused" behaviour ADR-0105 D9 rules out, and what the
797+
// cloud group-posture dogfood caught.
798+
//
799+
// Costs nothing on the overwhelmingly common path: with no `organization`
800+
// declared, the resolver returns the request org without reading anything.
801+
const directoryOrg = await this.directoryOrgFor(a, organizationId);
802+
const crossOrg = directoryOrg !== organizationId;
803+
787804
if (type === 'user') {
788805
return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
789806
}
@@ -798,12 +815,11 @@ export class ApprovalService implements IApprovalService {
798815
}
799816
return out;
800817
}
801-
// [ADR-0105 D9] WHERE this approver is looked up — the request's own
802-
// organization unless the spec targets another one in the same group.
803-
// Resolution failures propagate: they are routing bugs, and this call is
804-
// OUTSIDE the swallowing try below on purpose (see the catch's comment).
805-
const directoryOrg = await this.directoryOrgFor(a, organizationId);
806-
const crossOrg = directoryOrg !== organizationId;
818+
// `directoryOrg` / `crossOrg` were resolved at the TOP of this method, so
819+
// the refusal reaches directory-less types too. Resolution failures
820+
// propagate: they are routing bugs, and that call sits OUTSIDE the
821+
// swallowing try below on purpose (see the catch's comment).
822+
//
807823
// A cross-org slate is filtered to the people who can actually READ the
808824
// request (D2 union); same-org routing is untouched and does no extra read.
809825
const bounded = async (users: string[]): Promise<string[]> => (

packages/plugins/plugin-approvals/src/approver-cross-org.integration.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,33 @@ describe('ADR-0105 D9 — cross-org approver targeting through ApprovalService',
158158
expect(req.pending_approvers).toEqual(['position:cfo']);
159159
});
160160

161+
// Regression: `user` / `field` / `manager` return EARLY in
162+
// `resolveApproverSpec`, before the graph-expansion branch. D9's resolution
163+
// originally sat after those returns, so the declaration on a directory-less
164+
// type was silently INERT rather than refused — the one behaviour ADR-0105 D9
165+
// and the authoring docs both promise it is not. The resolver's own unit test
166+
// could not see it (it calls the resolver directly); only a request opened
167+
// through the service reaches the early return. Caught by cloud's
168+
// group-posture dogfood.
169+
it('refuses `organization` on a directory-less type — the early return must not skip the check', async () => {
170+
for (const spec of [
171+
{ type: 'user', value: 'u_cfo', organization: '$root' },
172+
{ type: 'field', value: 'owner_id', organization: '$root' },
173+
{ type: 'manager', organization: '$root' },
174+
]) {
175+
await expect(
176+
svc.openNodeRequest(openInput([spec]), CTX),
177+
`approver type '${spec.type}' must refuse a cross-org declaration`,
178+
).rejects.toThrow(/VALIDATION_FAILED.*no effect/);
179+
}
180+
});
181+
182+
it('a directory-less approver with NO declaration is untouched by D9', async () => {
183+
// The guard must not cost the ordinary case anything.
184+
const req = await svc.openNodeRequest(openInput([{ type: 'user', value: 'u_plant_mgr' }]), CTX);
185+
expect(req.pending_approvers).toEqual(['u_plant_mgr']);
186+
});
187+
161188
it('refuses a target outside the group — loudly, and no request is created', async () => {
162189
engine._tables['sys_organization'].push({ id: 'o_rival', slug: 'rival-co', parent_organization_id: null });
163190
await expect(

0 commit comments

Comments
 (0)