Skip to content

feat(security): let a delegated_admin read the invitations it issued (#8240) - #8425

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-8240-delegated-admin-own-invitations
Aug 13, 2026
Merged

feat(security): let a delegated_admin read the invitations it issued (#8240)#8425
os-zhuang merged 3 commits into
mainfrom
claude/issue-8240-delegated-admin-own-invitations

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Fixes#8240

Implements the maintainer's option C ruling of 2026-08-13: a delegated_admin sees the invitations they themselves issued, not the whole ledger.

The gap

delegated_admin is the one grade that reaches /organization/invite-member without being an org admin (ADR-0105 D8 / #3697). #8095 narrowed the sys_invitation ledger to an admin admission domained to org_owner / org_admin plus an addressee carve-out — and delegated_admin normalizes to neither of those identities, nor does it receive organization_admin from the auto-grant. So the issuing principal could create invitations it then could not list, with no second path back (better-auth's own list-invitations route is owner/admin gated too).

The change — one row-scope policy

member_default gains sys_invitation_issuer, a sibling of the addressee carve-out already sitting beside it:

name: 'sys_invitation_issuer'
object: 'sys_invitation'
operation: 'select'
using: 'inviter_id == current_user.id'
positions: [MEMBERSHIP_ROLE_DELEGATED_ADMIN]

Same set, same declarative mechanism, same select — differing only in which end of the invitation it keys on. Owner/admin visibility is untouched; their admission still comes from sys_invitation_org_admin, and a test asserts that set is byte-identical to before.

The positions domain is not decoration. The predicate looks self-domaining (only owner/admin/delegated_admin can ever be an inviter_id, and the first two already read everything), but without the domain a principal demoted out of an administrative grade keeps a permanent window onto what it issued while it held one — wider than what was ruled. The domain only ever widens: a principal it does not match keeps the addressee scope and fails closed.

Measured four-persona matrix

Real GET /api/v1/data/sys_invitation responses, stock showcase boot, ledger of three invitations from two distinct inviters (two by the org-owner grade, one by the delegate).

personabeforeafterrows seen
owner200, 3200, 3all three — unchanged
member200, 0200, 0none — unchanged
invitee200, 1200, 1their own addressed row — unchanged
delegated_admin200, 0200, 1only the row it issued

The "before" column is measured, not recalled: ablation M1 below removes the policy and the delegate drops to zero, which reproduces the card's original probe exactly.

Anti-vacuity — predict-then-mutate ablation

Predictions were written down before any edit. The trap this card names is that a pin asking only "can the delegate see a row" passes under the ruled option and under the rejected option B, so the fixture carries a second inviter and asserts the negative case.

mutationpredictedobservedevidence
M1 — delete the policyREDREDexpected [] to have a length of 1 but got +0
M2 — widen predicate to option B's id != nullREDREDexpected [ ... ] to have a length of 1 but got 3
M3 — drop the positions domainGREEN (indistinguishable)GREENall 7 HTTP assertions pass
M4 — point the domain at a bogus nameREDREDdelegate drops to zero rows

M2 is the one that matters: under option B's shape the delegate read 3 rows — the whole ledger — and the negative assertions fire. A single-inviter fixture could not have told the two apart.

M3 came out green exactly as predicted, and that is why the positions domain also carries a structural pin in default-permission-sets.test.ts rather than relying on the HTTP matrix. Verified complementary: with M3 applied, the unit pin goes red (expected undefined to deeply equal [ 'delegated_admin' ]) while all seven HTTP assertions stay green. The two layers catch different things, measured rather than asserted.

ADR-0105 D8 checklist — no rewording needed, and the step stops being vacuous

The ruling predicted the D8 checklist steps become correct as written and asked for verification rather than assumption. Confirmed by measurement; docs/qa/platform-checklist/areas/identity-auth.json is not edited by this PR.

One finding beyond the prediction: the item's second step ("read sys_invitation via the API and verify NO row was left behind" by the refused admin-role attempt) was not merely broken before — it was vacuously passing. The delegate read zero rows unconditionally, so the step returned "no row" whether or not a row had actually been left behind. With the issuer scope in place, a leaked row would carry the delegate's own inviter_id and therefore land inside the granted scope, so the step is load-bearing for the first time. A test now drives that exact sequence.

Tests

  • packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts — 7 passed. Four personas over real HTTP, plus a by-id probe for the delegate in both directions (refused for a row it did not issue, 200 for the one it did — so the refusal is the row scope talking, not the object being closed).
  • @objectstack/plugin-security — 51 files, 1033 tests passed.
  • @objectstack/dogfood full suite — 107 files, 750 tests passed.
  • typecheck clean on both packages.
  • Gates run locally: check:nul-bytes, check:engine-double-contract (193 pinned / 2 exempt — this PR declares no engine double), check:error-code-casing, check:agent-model-declared. No ratchet, ledger or baseline touched.

Generated by Claude Code

…8240)
#8095 narrowed the sys_invitation ledger to owner/admin plus a row-scope
carve-out for the addressee. delegated_admin -- the one grade that reaches
/organization/invite-member without being an org admin (ADR-0105 D8) --
normalizes to neither org_owner nor org_admin, so it read zero of the ledger,
including the invitations it had just issued itself.
Maintainer ruling 2026-08-13, option C: one additional row-scope policy,
sys_invitation_issuer (inviter_id == current_user.id) in member_default,
domained to the delegated_admin grade. Owner/admin visibility unchanged; the
ledger's audience is not widened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
@vercel

vercelBot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 13, 2026 1:17pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security.

12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx(via @objectstack/plugin-security)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via @objectstack/plugin-security)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/access-recipes.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/explain.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/plugin-security)
  • content/docs/plugins/index.mdx(via @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx(via @objectstack/plugin-security)
  • content/docs/ui/audience-based-interfaces.mdx(via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx(via @objectstack/plugin-security)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-security)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 13, 2026
…cy roster
rbac-objects.test.ts enumerates member_default's RLS policy names
exhaustively; the #8240 issuer carve-out has to appear there too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
… issuer scope
The D8 item's 'read sys_invitation as the delegated_admin and verify no row
was left behind' step could not fail before this card: the delegate read zero
rows unconditionally. With the issuer carve-out a leaked row from a refused
attempt would carry the delegate's own inviter_id and land inside the scope,
so the step is now load-bearing. Measured, not assumed -- the ruling asked for
verification rather than a checklist reword, and no checklist edit was needed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
@os-zhuang
os-zhuang marked this pull request as ready for review August 13, 2026 13:31
@os-zhuang
os-zhuang added this pull request to the merge queueAug 13, 2026
Merged via the queue into main with commit 89e9808Aug 13, 2026
28 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8240-delegated-admin-own-invitations branch August 13, 2026 13:40
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

delegated_admin can issue invitations but can no longer read them via the data API — the #8095 narrowing lands on owner/admin only

2 participants

@os-zhuang@claude