Commit 917b87e
fix(approvals): isOverrideActor resolves TENANT-admin standing from the ADR-0095 rung, never from a position name (#18252)
Fixes #16166
Clause-②: no
`ApprovalService.isOverrideActor`'s TENANT arm derived override
authority from a built-in identity NAME on `current_user.positions` —
the half PR #16148 deliberately left when it closed the PLATFORM arm of
the same predicate. This lands the tenant half the same way: **read the
rung, never the name.**
## Step 1 was to DRIVE it — the three questions, in order, each with a
reading
The card and the triage comment both required the mechanism be measured
before anything was built, and required each step be reported rather
than concluded from shape. It was, on the tree at `68fea8bc` (the branch
base).
**Q1 — can a `manageAssignments`-only delegate mint an assignment row
spelling `org_owner`?** Measured on both halves, in a throwaway harness
(no permanent file: #15972's landed suite already pins this door).
- The ADR-0090 D12 delegated-admin gate **approves** it, for `org_owner`
and `org_admin` alike — the vacuity #15948 recorded, re-measured here
for the org names rather than carried. Control: the same gate refuses
when the scope does not carry `manageAssignments`.
- The write is **refused anyway**, one layer up, by `plugin-security`'s
`reserved_identity_position` object validation on a real engine over a
real SQL driver — `VALIDATION_FAILED`, for both names. Negative control:
an ordinary position name (`org_manager`) still writes, so the object is
not simply refusing everything.
⇒ **The minting route the card names is gated upstream. That is a
FINDING, not a failure** — and it is exactly the shape the triage
comment reserved that word for. Two neighbouring routes were checked and
are closed too: `sys_member.role` is a closed, write-enforced select
whose HTTP surface is read-only, so the projection cannot be poisoned
from there either.
**Q2 — does such a row, once it exists, move `isOverrideActor`'s
verdict?** Yes. Measured through the REAL `resolveUserAuthzGrants` with
the actor resolved from stored rows, on three doors — `decideNode`,
`recall`, and the console's participant-visibility read. All three
admitted a non-slate, non-submitter actor whose resolved posture was
`MEMBER` and who held no org-administration capability at all. The pin's
premise legs assert exactly that separation before any door is driven,
so the result cannot be an artifact of the actor accidentally holding
standing.
⇒ **Q1 and Q2 together are why the reader still has to be fixed.** The
write-side ruling refused NEW writes and explicitly declined a
migration, so a row predating it resolves into `positions[]` on every
request; and a reader that trusts a name is not an invariant in any
case, which is the whole reason the write door was built.
**Q3 — is any site already gated such that the name-read reaches only a
misreport?** No. The console read here is not an explain panel: it
returns the rows, with `can_override` set.
## The tenant rung, established from the resolver's own source — ⛔ not
copied across
The platform arm's expression is deliberately not reused. ADR-0095 D3
resolves the rung in `derivePosture`
(`packages/core/src/security/posture-ladder.ts`) from held CAPABILITY
grants: `PLATFORM_ADMIN` from the unscoped `admin_full_access` grant,
and `TENANT_ADMIN` from `ORGANIZATION_ADMIN_GRANTS.some(n =>
permissions.includes(n))` **and from nothing else** — the identical
expression the predicate's second arm already spelled.
`packages/spec/src/identity/eval-user.zod.ts` declares those two grants
"the source of truth for the `TENANT_ADMIN` posture rung".
⇒ The question the card left open — whether the
`ORGANIZATION_ADMIN_GRANTS` capability arm is itself a tenant-authority
rung or another weak arm — resolves to **rung**. It and the derived
`posture` are one authority read in two spellings, kept apart only so a
transport that never resolved `posture` still reads the held grant. Both
survive.
The two NAMES are the other thing entirely: ADR-0068 D2 declares them "a
normalized PROJECTION into `current_user.positions`" whose sources of
truth live elsewhere, while the same array also carries ADR-0057 D4
assignment values. A projection is not an authority. Both name arms are
removed, and with them the predicate's last read of `positions[]` on
either rung.
**The whole predicate was read, not just the arm.** `posture ===
'TENANT_ADMIN'` sat first in that OR and protected nothing — an OR is
only as strong as its weakest arm.
## What does not change
The #3424 stuck-approval escape hatch is intact for anyone who actually
holds org-admin standing: a genuine `organization_admin` grant still
overrides, still only within its own organization, and the decision is
still audited as `via_override`. Three CONTROL legs assert that on all
three doors, and they were green before this change as well as after —
they are the floor, not the result.
## Verification
**Reverse verification, both legs taken from COMMITTED states.** The pin
was committed RED first (`bd157492c`), then the fix (`d40b9afd5`):
- pin at `bd157492c` (fix absent): **`Tests 10 failed | 6 passed (16)`**
- pin at `d40b9afd5` (fix present): **`Tests 16 passed (16)`**
The 6 that passed RED are the premise legs and the three controls — i.e.
the harness was already discriminating before the fix, and the 10
failures are the escalation itself, not a broken harness.
**Package suites** (`@objectstack/plugin-approvals`): `pnpm test` →
`Test Files 46 passed (46) · Tests 754 passed (754)`; `pnpm typecheck` →
exit 0, including `check:test-typecheck`.
**Gate families** — derived mechanically from the change set by `node
scripts/pm/dispatch-gates.mjs`, then reconciled with `--ran` carrying
each recorded exit code: **73 derived · 70 run green · 3 NOT MEASURED ·
0 UNRUN.** The three are `check:dual-build-cjs-loads`, `check:i18n` and
`check:type-check-debt`, each **exit 3 = PREREQUISITE NOT MET** (they
read a full-repo build that this container does not hold). ⛔ Exit 3 is
not a pass; those three are CI's.
`check:engine-double-contract` failed first (exit 1) because the new pin
declares its own `update`/`delete` doubles and the ledger had not
learned about the file. Regenerated with `--write` — additive only, **2
rows added, 0 lost** — and the gate is green at `783180eba`.
**Repo-wide lint**: `pnpm lint` (`eslint . --no-inline-config`) run in
full, **exit 0**, 91s, at `783180eba`. No narrowing was used, so no
narrowing evidence is owed.
**Base freshness**: branched from `68fea8bca`; four commits have landed
on `main` since and none touches `plugin-approvals`, `plugin-security`
or the authz resolver, so `main` was not merged in. The merge queue
rebuilds this as merged onto current `main` anyway.
## Clause-②: no — derived from the DELIVERED diff, with controls
Derived by REACHABILITY FROM THE PUBLISHED ENTRY (the barrel's re-export
list plus this package's `exports` / `files`), ⛔ not from the word
`export` and ⛔ not from a bundle grep. The package was built at the
branch base and at HEAD and the two `dist/index.d.ts` were compared:
- **The only delta is TSDoc prose** attached to `private
isOverrideActor;`. That declaration line is byte-identical, and it
carries no signature to widen — nothing was added, removed or narrowed
on the published surface.
- **Positive control** — `ApprovalService`: 43 occurrences in both
builds, so the artifact really is the published surface and the reading
discriminates.
- **Negative control** — `BUILTIN_IDENTITY_ORG_OWNER`: **0** in both
builds. The constants this diff stopped importing were never reachable
from the published entry, so dropping the import moves nothing
published.
- **Negative control** — a helper local to the new test file: **0**, so
the test contributes nothing to the entry.
The behavioural direction is a NARROWING — an authority path removed —
which is the opposite of the widening tell. The changeset is `patch`, on
`@objectstack/plugin-approvals`.
The before/after measurement mutated the service file on disk and
restored it; the restore is evidenced by `git hash-object` equalling the
HEAD blob (`bbae6c8f…`) and by an empty `git diff HEAD`, and `dist/` was
rebuilt at HEAD afterwards so nothing is left standing at the base
build.
## Acceptance notes
Two observations, noted and ⛔ not filed — neither is a reproducible
defect, a contract violation, or a metadata-authoring trap:
- `plugin-security`'s `sys_invitation_org_admin` RLS policy takes a
`positions` domain on the same two built-in names. It is a different
kind of read — a row-visibility WIDENING inside an already org-scoped
select, with its rationale written out at the declaration (the domain
only ever widens, so a principal it does not match fails closed). Its
reach is bounded by Layer 0 and it grants no override authority.
Successor: any future sweep of built-in-name reads.
- The delegated-admin gate's `boundSets.every(...)` is vacuous for a
position that distributes no permission set. That is a real property, it
is already recorded in `plugin-security`'s own landed suite as the
reason the gate cannot be where a reserved name is refused, and it is
not this card's to change. Successor: #15972's write-side lane.
⛔ Per the card's family this body carries no reproduction recipe; the
driving harness lives in the test suite, which is where it belongs.
Authored by Claude Code in session
https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj — recorded here
in prose because this body's trailing footer block is the platform's to
write.
---
_Generated by [Claude Code](https://claude.ai/code)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent fd12471 commit 917b87e
4 files changed
Lines changed: 483 additions & 13 deletions
File tree
- .changeset
- packages/plugins/plugin-approvals/src
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
Lines changed: 32 additions & 13 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
22 | 22 | | |
23 | 23 | | |
24 | 24 | | |
25 | | - | |
26 | | - | |
27 | | - | |
28 | | - | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
29 | 30 | | |
30 | 31 | | |
31 | 32 | | |
32 | | - | |
33 | | - | |
34 | 33 | | |
35 | 34 | | |
36 | 35 | | |
| |||
1367 | 1366 | | |
1368 | 1367 | | |
1369 | 1368 | | |
1370 | | - | |
1371 | | - | |
1372 | | - | |
| 1369 | + | |
| 1370 | + | |
| 1371 | + | |
| 1372 | + | |
| 1373 | + | |
1373 | 1374 | | |
1374 | 1375 | | |
1375 | 1376 | | |
1376 | 1377 | | |
1377 | 1378 | | |
1378 | | - | |
1379 | 1379 | | |
1380 | 1380 | | |
1381 | 1381 | | |
| |||
1402 | 1402 | | |
1403 | 1403 | | |
1404 | 1404 | | |
| 1405 | + | |
| 1406 | + | |
| 1407 | + | |
| 1408 | + | |
| 1409 | + | |
| 1410 | + | |
| 1411 | + | |
| 1412 | + | |
| 1413 | + | |
| 1414 | + | |
| 1415 | + | |
| 1416 | + | |
| 1417 | + | |
| 1418 | + | |
| 1419 | + | |
| 1420 | + | |
| 1421 | + | |
| 1422 | + | |
| 1423 | + | |
| 1424 | + | |
| 1425 | + | |
1405 | 1426 | | |
1406 | | - | |
1407 | | - | |
1408 | | - | |
| 1427 | + | |
1409 | 1428 | | |
1410 | 1429 | | |
1411 | 1430 | | |
| |||
0 commit comments