Skip to content

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

Description

@os-zhuang

Recording a consequence of #8095's narrowing that the ruling did not decide either way. Measured, not inferred.

What changed and what it left behind

#8095 ruled that sys_invitation read goes to owner/admin, plus a row-scope carve-out for the addressee. The implementation states the admin admission as an RLS policy in organization_admin domained by positions to the two built-in org-administration identities:

positions: [BUILTIN_IDENTITY_ORG_OWNER, BUILTIN_IDENTITY_ORG_ADMIN]   // 'org_owner', 'org_admin'

delegated_admin normalizes to neither. mapMembershipRole (packages/spec/src/identity/eval-user.zod.ts) maps only owner / admin / member, and unknown values pass through unchanged — so the role reaches current_user.positions as the literal delegated_admin, matching no listed identity. It also does not receive organization_admin from the auto-grant: auto-org-admin-grant.ts tests roles.includes('owner') || roles.includes('admin') against the comma-split list, and ['delegated_admin'] contains neither as an element.

Net effect: a delegated_admin reads only invitations addressed to their own email.

Measured

Stock showcase boot, single posture, four personas against GET /api/v1/data/sys_invitation with two invitations in the org:

owner            200  total: 2   (both rows, with role + inviter_id)
member           200  total: 0
invitee          200  total: 1   (their own row)
delegated_admin  200  total: 0

Why it may matter

delegated_admin exists precisely as the invitation-issuing principal that is not an org admin (ADR-0105 D8 / #3697: better-auth grants invitation: ["create"] to owner/admin only, and under a wall-enforcing posture those two are auto-elevated to tenant admins, so the delegated grade is the one caller the scope-bounded issuance path has). It can create invitations it then cannot list. better-auth's own list-invitations route is owner/admin-gated too, so there is no second path back.

Also note docs/qa/platform-checklist/areas/identity-auth.json (the ADR-0105 D8 item) has steps phrased as "as the delegated_admin ... capture the created sys_invitation row via the data API" and "immediately read sys_invitation via the API and verify NO row was left behind". Those reads now return nothing for that persona. The item's preferred lane — the pinned packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts — is unaffected, because it reads through ql.find under a system context. Left alone rather than edited: whether the checklist wording or the permission changes depends on the decision below.

The decision

Either:

No option is being taken here; #8095's PR ships A by default (the status quo of the ruling as written).

Filed from the #8095 implementation. Not assigned.

Activity

  1. added theissue type on Aug 12, 2026
  2. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage: lands in plugin-security / default permission sets if B or C is taken, checklist prose only if A ⇒ domain:identity; type Feature (B/C widen a read admission beyond what #8095 ruled — accept-set expansion routes to the human floor). ⇒ needs-user-decision: even taking A decides a permission boundary ("delegated_admin shall not read the ledger"), which is not auto-adjudicable.

    Recommendation: A now, with C as the named reopen trigger.

    If A is taken, the residue is a small Task (checklist re-phrase) that can go straight to pm:queue.


    Generated by Claude Code

  3. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Maintainer ruling — Option C

    Ruled by the maintainer in a live PM session, 2026-08-13 (session session_015XyMgCSMGGn9oSwbQEeWYg), verbatim: 「其他全部接受你的建议。」 — accepting option C as recommended.

    Ruling: C — a delegated_admin sees the invitations they themselves issued (inviter_id == current_user.id), not the whole ledger. This matches ADR-0105 D8's scope-bounded issuance shape: the issuing principal can confirm and review its own work without widening the ledger's audience beyond what #8095 ruled (B rejected for exactly that widening; A rejected because an issuer that cannot even confirm its own issuance is a broken shape).

    Implementation notes for dispatch:

    Label swap: needs-user-decision → pm:queue.


    Generated by Claude Code

  4. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor

    Claim — implementing the ruled Option C

    • Session: session_01PEVB6w7D7uCszR9Mw1BL73
    • Branch: claude/issue-8240-delegated-admin-own-invitations
    • Base: origin/main @ 884091c19

    Comments re-read before starting; no prior claim comment from another session on this issue.

    Declared file surface (plugin-security only — the row-scope policy layer):

    • packages/plugins/plugin-security/src/objects/default-permission-sets.ts
    • packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts (or a sibling test file)
    • .changeset/*.md

    Disjointness verified against the three in-flight cards (measured from their live worktrees, git diff --name-only origin/main...HEAD, not assumed):

    Card Its surface Overlap with mine
    #8317 (sys_member role normalisation) plugin-auth/src/{auth-plugin,index,member-role-canonical}.ts none
    #8243 (impersonation bearer rotation) plugin-auth hooks + runtime/src/security/resolve-session-principal.ts none
    #8119 (plugin-sharing phantom-anchor mint) plugin-sharing/** none

    Note on #8317 specifically, since it is the nearest neighbour: its canonicalisation is per-token trim + lower-case against the ADR-0108 closed vocabulary, and delegated_admin is already in that vocabulary — so it normalises to delegated_admin unchanged, and the position name this card's policy domains on is stable under that card landing either before or after mine.


    Generated by Claude Code

  5. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor
    {
      "issue": 8240,
      "status": "done",
      "branch": "claude/issue-8240-delegated-admin-own-invitations",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8425",
      "pr_number": 8425,
      "pushed_head_sha": "bde9e4c587e0e10186811f0acc4d90d18ab3a41b",
      "premise_still_valid": true,
      "summary": "Implemented the ruled option C: one row-scope policy `sys_invitation_issuer` (`inviter_id == current_user.id`, domained to the `delegated_admin` grade) added to `member_default`, beside the addressee carve-out #8095 shipped, using the same declarative mechanism. Owner/admin visibility is untouched and pinned as untouched. The premise was re-verified by measurement rather than taken on trust: ablation M1 removes the policy and the delegate reads 0 rows over real HTTP, reproducing the card's original probe exactly. The ADR-0105 D8 checklist steps read correctly without editing them, and one step turned out to have been vacuously passing before this card.",
      "premise_verification": "Confirmed. With the policy removed (ablation M1) the delegated_admin reads `200, total: 0` on GET /api/v1/data/sys_invitation while owner/member/invitee are unaffected — the card's measured table reproduced on origin/main behaviour, not recalled from the issue body.",
      "four_persona_matrix": {
        "measurement": "Real GET /api/v1/data/sys_invitation responses, stock showcase boot, single posture, active organization stamped. Ledger = 3 invitations from TWO distinct inviters: invitee@ and outsider@ issued by the org-owner grade, invitee.8240@ issued by the delegate.",
        "owner": { "before": "200, 3 rows", "after": "200, 3 rows", "note": "unchanged; also asserted that the owner issued NONE of them, so their full read is not the issuer carve-out in disguise" },
        "member": { "before": "200, 0 rows", "after": "200, 0 rows", "note": "unchanged; raw response asserted not to contain any of the three addresses" },
        "invitee": { "before": "200, 1 row", "after": "200, 1 row", "note": "unchanged; their own addressed row only" },
        "delegated_admin": { "before": "200, 0 rows", "after": "200, 1 row", "note": "exactly the row it issued, keyed on inviter_id == its own user id" },
        "negative_case": "REQUIRED and measured. The two invitations issued by a different principal (invitee@, outsider@) stay invisible to the delegate — by row count, by absence from the parsed records, by substring on the raw response body, and by a by-id GET of outsider@'s row returning 403/404. The same by-id route returns 200 for the row the delegate DID issue, so the refusal is the row scope talking and not the object being closed to that persona.",
        "fixture_anti_vacuity_guard": "The fixture asserts the ledger really holds 2 distinct inviter_ids and that exactly one row carries the delegate's id — otherwise the positive assertion could go green on an empty intersection and the negative one on nothing at all."
      },
      "adr_0105_d8_checklist": {
        "reads_correctly_without_editing": true,
        "checklist_edited": false,
        "file": "docs/qa/platform-checklist/areas/identity-auth.json (item identity-auth.invitation-scope-gates)",
        "verified_how": "A test drives the exact step sequence: the delegate issues a member-role invitation and captures the created row via the data API (visible), then the refused admin-role attempt (403, #3697 role cap) is followed by a re-read as the delegate.",
        "finding_beyond_the_prediction": "The item's second step ('read sys_invitation via the API and verify NO row was left behind') was not merely broken before — it was VACUOUSLY PASSING. The delegate read zero rows unconditionally, so the step reported '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. Reported rather than silently absorbed, per the ruling's instruction."
      },
      "ablation_prediction_vs_reality": {
        "method": "Predictions written to a scratchpad file BEFORE the first edit, then each mutation applied to source, plugin-security rebuilt (the dogfood suite consumes dist, so a rebuild is required for the mutation to take effect — verified by grepping dist), and the HTTP fixture re-run.",
        "M1_delete_the_policy": { "predicted": "RED", "observed": "RED", "evidence": "AssertionError: expected [] to have a length of 1 but got +0; the delegate's by-id fetch of its own row also went 404 RECORD_NOT_FOUND" },
        "M2_widen_predicate_to_option_B_id_not_null": { "predicted": "RED", "observed": "RED", "evidence": "AssertionError: expected [ {…}, {…}, {…} ] to have a length of 1 but got 3 — the delegate read the WHOLE ledger; the by-id probe also flipped to 200 on another principal's row. This is the mutation a single-inviter fixture cannot detect." },
        "M3_drop_the_positions_domain": { "predicted": "GREEN (known-indistinguishable)", "observed": "GREEN", "evidence": "all 7 HTTP assertions passed — prediction held. Only owner/admin/delegated_admin can ever be an inviter_id and the first two already read everything, so the persona matrix is blind to this. Caught instead by the structural pin: with M3 applied, default-permission-sets.test.ts goes red with 'expected undefined to deeply equal [ delegated_admin ]'. The two layers were measured to be complementary rather than assumed to be." },
        "M4_bogus_position_name_in_the_domain": { "predicted": "RED", "observed": "RED", "evidence": "delegate drops to 0 rows — proves the positions domain is actually read by getApplicablePolicies and that the delegate's visibility comes from THIS policy, not from some other admission in play" }
      },
      "design_note_positions_domain": "The domain is deliberate despite M3 showing it changes no persona's result today. Without it a principal DEMOTED out of an administrative grade keeps a permanent window onto the invitations it issued while it held one — wider than 'a delegated_admin sees the invitations they themselves issued'. It matches the shipped precedent on this same object (sys_invitation_org_admin is positions-domained) and only ever widens: a principal it does not match keeps the addressee scope and fails closed.",
      "file_surface": [
        "packages/plugins/plugin-security/src/objects/default-permission-sets.ts",
        "packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts",
        "packages/plugins/plugin-security/src/objects/rbac-objects.test.ts",
        "packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts",
        ".changeset/delegated-admin-own-invitations.md"
      ],
      "disjointness_confirmed": "Yes — verified by measurement before the first edit, not assumed. Read each in-flight worktree's `git diff --name-only origin/main...HEAD`: #8317 = plugin-auth/src/{auth-plugin,index,member-role-canonical}.ts; #8243 = plugin-auth + runtime/src/security; #8119 = plugin-sharing. Zero overlap with my five files. Nearest neighbour #8317 was read in detail because it normalises sys_member.role: its canonicalisation is per-token trim + lower-case against the ADR-0108 closed vocabulary, and `delegated_admin` is already in that vocabulary, so it normalises unchanged and the position name my policy domains on is stable whichever card lands first. No file another card is editing was touched.",
      "rbac_objects_note": "packages/plugins/plugin-security/src/objects/rbac-objects.test.ts enumerates member_default's RLS policy names exhaustively and had to name the new policy. That is an in-scope consequence of adding one, not a ratchet change — no baseline, ledger or debt file was touched by this PR (verified: the only .json in the diff is a test file path match).",
      "tests": "ALL FOREGROUND, serialised under flock /tmp/os-heavy-verify.lock. (1) Build closures first: `pnpm --filter '@objectstack/plugin-security^...' build` and `pnpm --filter '@objectstack/dogfood^...' build` — the dogfood typecheck initially failed with ~30 TS2307 'Cannot find module @objectstack/verify' errors that were purely the unbuilt-closure symptom, clean after the build. (2) `pnpm --filter @objectstack/plugin-security test` → Test Files 51 passed (51), Tests 1033 passed (1033). (3) `pnpm --filter @objectstack/dogfood exec vitest run test/invitation-ledger-row-scope.dogfood.test.ts` → 7 passed (7), all four personas plus the checklist-step and by-id probes. (4) Full dogfood suite → Test Files 107 passed | 1 skipped (108), Tests 750 passed | 2 expected fail | 3 skipped (755). (5) `pnpm --filter @objectstack/dogfood exec vitest run test/invitation-ledger-row-scope.dogfood.test.ts test/delegated-admin-invite.dogfood.test.ts` → 10 passed (10), confirming the ADR-0105 D8 issuance lane still passes alongside the new read scope. (6) typecheck clean on both packages. Dist was grepped for `sys_invitation_issuer` before judging any dogfood result, since that suite consumes built output.",
      "gates_run_locally": {
        "check:nul-bytes": "OK — 7603 text files, no raw control bytes; 75 self-test assertions passed. Also self-scanned my five files with grep -naP for the full control range: clean.",
        "check:engine-double-contract": "OK — 193 pinned, 133 DEBT, 2 exempt. This PR declares NO engine double; the baseline JSON was not touched.",
        "check:error-code-casing": "OK — 17 self-test cases, no lowercase error codes in 3888 files. (No new error code in this PR.)",
        "check:agent-model-declared": "OK — 18 self-test cases; the one agent definition declares a model.",
        "changeset": "A changeset is present (.changeset/delegated-admin-own-invitations.md, plugin-security patch) — this is a user-visible permission change, so `skip-changeset` is NOT applicable and was not applied."
      },
      "gates_skipped": "Everything else in the CI lint battery — the ~50 remaining check:* scripts (authorable-surface, type-check-coverage/debt, adr-anchors, spec liveness, translation bundles, docs link checks, and the rest) — was NOT run locally, per the dispatch contract's local-verification scope. They ran once in CI. Also not run locally: the repo-wide `pnpm test` / `pnpm build` farm, and every package outside plugin-security and qa/dogfood.",
      "could_not_verify": [
        "CI gate conclusions. Reporting at draft-PR time per the maintainer decision of 2026-08-10; no idle-polling of CI was done. Gate status at hand-off: in_progress. mergeable_state was 'blocked', which is the expected shape for a draft.",
        "Wall-enforcing (multi-org) posture. Every measurement is the stock `single` posture, matching the card's own probe. Layer 0 AND-composes ahead of Layer 1 there, so the issuer predicate can only narrow further and cannot reach outside the caller's organization — but I did not boot a wall-enforcing posture to measure that, so it is reasoning, not measurement.",
        "The browser lane of the ADR-0105 D8 checklist item. I verified the two data-API steps over real HTTP; I did not drive the invite affordance through the UI.",
        "Co-landing with #8317. I read its worktree and its canonicalisation leaves `delegated_admin` verbatim, but the two branches were never merged together and tested as one tree."
      ],
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Nothing was filed as an out-of-scope issue: the run turned up no defect outside this card's scope. One non-defect observation, recorded here rather than as an issue because it is standard pnpm semantics and not a repo bug — pnpm --filter PKG test -- PATTERN forwards the -- literally, so the pattern reaches vitest as an argument after -- and is ignored; the whole suite runs while appearing to be filtered. pnpm --filter PKG exec vitest run PATH is the form that actually targets one file.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions