Skip to content

delegated-admin gate: scope violations answer PERMISSION_DENIED, so a client cannot tell them from an opaque denial (own registered code) #26189

Closed

Description

@objectstack-fleet

Filing gate ②: a maintainer ruling, carried out as child 3 of the nine that #26059's ruling (Q3) names. ⛔ Not a claim.

When a delegated admin acts outside their scope, the server knows exactly why. Examples: "business unit … is outside the delegated subtree", or "position 'pos_mgr' distributes permission set 'ps_mgr', which is not in the scope's allowlist". But it throws that reason as a plain PERMISSION_DENIED 403. A client cannot tell it from a denial whose reason must stay hidden, so the console shows only the fixed "You don't have permission to save this record." This card is the server half: the gate answers its own code. The UI half is child 4 and waits on this one.

Part of #26059
Ruling-ref: 6416233738 (#26059 (comment))
Ruling: batch #396 item 1, letter Q1 是 / Q2 不做 / Q3 分拆 / Q4 框架. This card is Q3 child 3 (server, p2): item 3, server half. Brief 6415795834, item-by-item reading 6415971852 (item 3).
Filed by domain:ui seat 2 (domain:ui#2) · os-steve · session session_01SMps7wq7rBTk53e8KTSEup, first claimant of #26059. Domain, grade and route are triage's on first touch.

Business reason. A delegated admin (a regional manager who assigns positions in their own subtree) who hits the boundary sees only "you don't have permission". They cannot tell whether they picked the wrong unit, the wrong position, or lack the right altogether. These reasons describe the caller's own scope, so they are safe to show. They just cannot be told apart on the wire today.

Reader who acts. The domain:server execution seat, dispatch step: one os-dev, one PR on packages/plugins/plugin-security/src/delegated-admin-gate.ts plus its ledger row in packages/spec/src/api/error-code-ledger.zod.ts, with pins.

Duplicate check. A local scan of the 1,962 newest issues (all states, #22168–#26181) for DELEGATION_SCOPE_VIOLATION|delegated.?admin.{0,80}(code|reason|toast|message) found 4 hits, all closed and none this. #24082 / #23945 gave the invitation role-cap refusals their own codes, a precedent rather than a duplicate. #23323 and #23957 are docs / invite-dialog cards.

Ruled scope (ruling Q3 item 3)

Anchors (re-read on origin/main 83a26c38)

  • packages/plugins/plugin-security/src/delegated-admin-gate.ts:
    • :403–503 holds: the gate's entry refusals, each a PermissionDeniedError (throws at :403, :415, :446, :480, :487, :503).
    • :1136–1179 holds: the scope-violation reasons. One more reason, "target user … is outside the delegated subtree", sits at :1185.
    • Those reasons are thrown at :1157 (sys_user_position) and :1193 (sys_user_permission_set).
  • packages/plugins/plugin-security/src/errors.ts:41–44: PermissionDeniedError, with code = 'PERMISSION_DENIED' and status 403.
  • packages/spec/src/api/error-code-ledger.zod.ts:
  • docs/adr/0112-error-code-vocabulary-and-ledger.md: the ledger ADR.

Note for the dev (found while verifying, not a new rule). The :403–503 block also holds refusals that are not about the caller's delegated scope:

  • the audience anchor (:403)
  • principal-less writes (:415)
  • sys_member being tenant-only (:446)
  • plain CRUD with no scope (:487)
  • filter writes (:503)

The ruling's test decides which ones move: a reason that describes the caller's own scope may take the new code, and anything else keeps PERMISSION_DENIED. Name the split in the PR.

Expected / acceptance

  • A delegated-admin write outside the caller's scope (unit outside the subtree, set not in the allowlist, target user outside the subtree) answers 403 with the new code and the gate's reason as the message.
  • Every other refusal of the gate, and every other PERMISSION_DENIED in the platform, is unchanged on the wire.
  • The code has its ADR-0112 ledger row and passes the ledger's admission / synonym gate.
  • Pins cover the new code on each scope-violation path and PERMISSION_DENIED on a non-scope refusal.

Activity

  1. added
    enhancementNew feature or request
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    domain:serverDomain lane — seat card indexed by label:pm:seat
    on Oct 26, 2026
  2. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade — as filed · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-26T15:59Z

    ⛔ Not a claim, ⛔ not a dispatch.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 8 · card thread
    Session: session_01R1q2xdtYgU6FKrZ7LgjPCC (the seat); the card thread is session_01MZUz5qaFxUqiNqA3LqEVzd
    Account: os-project-manager (the seat's linked user; the card's assignee)
    Branch: claude/project-thread-okhr87 (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
    Worktree: the card thread's own container checkout
    Domain: domain:server
    Seat: domain:server#1
    Ruling-ref: 6416233738 (#26059, maintainer ruling Q1 是 / Q2 不做 / Q3 分拆 / Q4 框架; this card is Q3 child 3) and triage first grade (2026-10-26T15:59Z). A delegated-admin refusal that describes the caller's own scope answers its own 403 code with the gate's reason, registered in the ADR-0112 ledger as its own meaning. Every other PERMISSION_DENIED is unchanged on the wire. Ruling #24914 B′ stands.
    File surface: packages/plugins/plugin-security/src/{errors.ts,delegated-admin-gate.ts} and their tests; packages/plugins/plugin-auth/src/auth-manager.ts (isInvitationPlacementGateRefusal, about :1398, a one-line addition so an out-of-scope invitation stays 403 INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE) and its test; packages/spec/src/api/error-code-ledger.zod.ts, one row, and the ledger test. The new code's message must not open with [Security] Access denied, which mapDataError, isPermissionDeniedError and security-routes.ts would re-flatten; pin all three. Changesets: patch on spec, plugin-security and plugin-auth. ⛔ No explain-engine.ts, no ui/. Any other packages/spec/** change: stop and explain in the report
    Container & model: S, mode:thread, default model and effort (set at thread open)
    Clause-②: no
    Metadata-protocol: none
    Responsibility: a delegated admin's scope violation answers an opaque PERMISSION_DENIED, so the console cannot tell it apart (delegated-admin-gate.ts scope-violation throws)
    Thread-read: none
    Premise: holds (read 2026-10-26T18:20Z at origin/main 21371552b0, which contains #26064: card open, unassigned, pm:queue, no claim; the scope-violation reasons still throw PermissionDeniedError)
    Serial constraints cleared: #26064 merged (it is on 21371552b0); PR #26250 adds a row to the same ledger, append-only, so whichever lands second merges main and keeps both; #26188 (in parallel) stays off this surface; the dev re-measures before pr_create

  4. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 26189,
    "status": "done",
    "branch": "claude/project-thread-okhr87",
    "pr": "#26320",
    "session": "session_01MZUz5qaFxUqiNqA3LqEVzd",
    "premise_still_valid": true,
    "summary": "The delegated-admin gate's three scope reasons (unit outside the subtree, new row or pre-image; set not in the allowlist, assignment or direct grant; target user outside the subtree) now throw DelegationScopeViolationError: 403, code DELEGATION_SCOPE_VIOLATION, message '[Security] Outside your delegated scope: ...' carrying the gate's reason. Every other refusal keeps PERMISSION_DENIED (split table per throw site in the PR body). The message avoids the '[Security] Access denied' matcher prefix, which mapDataError would re-flatten (shown by ablation). plugin-auth's isInvitationPlacementGateRefusal accepts the new code so an out-of-scope invitation stays 403 INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE (red first: 503). One ADR-0112 ledger row under @objectstack/plugin-security, no waiver; #26250's DUPLICATE_MATCH_KEY row kept (main merged); docs regenerated by gen:docs.",
    "tests": "Head b563cd4. Red first on e05e0de + tests: gate [#26189] 5 scope cells red ('expected [ PERMISSION_DENIED, 403, 403 ] to deeply equal [ DELEGATION_SCOPE_VIOLATION, ...]'), 10 non-scope controls green; plugin-auth stand-in red 'expected 503 to be 403'; ledger red ZodError invalid_value. Green: plugin-security 227 files / 4941 passed / 45 skipped / 0 failed; plugin-auth 198 / 3780 passed / 10 skipped / 0 failed; error-code-ledger.test.ts 24 passed; dogfood door files (showcase-permission-zoo, delegated-admin-invite) 17 passed; typecheck plugin-security, plugin-auth, spec, dogfood exit 0; eslint --no-inline-config on touched sources exit 0. Ablation (scripts/ablation-replace.mjs): the new class's message reopened with '[Security] Access denied:'; ablation-dist-preflight exit 0; showcase-permission-zoo 2 failed / 9 passed (both door pins answer 403 PERMISSION_DENIED, control green); restored blob == HEAD, git diff HEAD empty, rebuilt, --absent preflight exit 0. A first ablation attempt failed the DTS build and measured nothing.",
    "gates": "Local, at b563cd4: dispatch-gates --commands 119 derived, all run, exit codes captured before any pipe; --ran: 119 derived, 119 run, 0 NOT-MEASURED. Two needed a container step and were re-run green: check-plugin-teardown-shape --self-test (git fetch of pinned fixture 621a487, then exit 0) and check:type-check-debt (queue-timeout while my dogfood held the lock, re-run under the lock exit 0, 26 raw tsc errors, none above ledger). spec check:generated: content/docs/references stale after the ledger row, regenerated with --fix and committed. CI check-runs: in_progress at report time; the seat reads them.",
    "dogfood_battery": "b563cd4c4 · os-verify-lock VERDICT command-exit 0 (held 1225s) · Test Files 297 passed / 3 skipped (300) · Tests 2528 passed / 18 skipped (2546) · no failures",
    "mcp_calls": "5 — read-only: issue_read get #26189, issue_read get_comments #26189 (x2), pull_request_read get #26064, issue_read get_comments #26059. No MCP writes.",
    "api_writes": "2 — fleet-write relay POST /repos/objectstack-ai/objectstack/dispatches for pr_create (#26320, run 40399885401, read-back identical, assignee os-project-manager); this os-dev-report comment via post-stamped. git push is not a REST write.",
    "open_questions": [
    {
    "question": "Two refusals concern the caller's own scope but stay PERMISSION_DENIED under the ruling's literal list: a scope without manageAssignments, and adminScope containment ('would hand out an adminScope not strictly contained by yours'). Move them to DELEGATION_SCOPE_VIOLATION too?",
    "options": [
    "A keep them PERMISSION_DENIED, as built (the ruling lists three reasons and says every other refusal keeps PERMISSION_DENIED)",
    "B move both: each describes the caller's own adminScope, and the message names only the caller's own scope"
    ],
    "recommendation": "A, no decision needed to land: the ruling's list is explicit, and the console (child 4, #26190) gains nothing it needs from B today. Revisit only if #26190 measures an admin confused by either message."
    }
    ],
    "out_of_scope_findings": [
    "carrier: #26190 (UI half, Blocked-by this card) · noted, not filed: the console's isPermissionError (ui/packages/react/src/utils/error-message.ts:202) treats any 403 as the fixed line, so the new code changes nothing in the UI until #26190."
    ],
    "deviations": [
    "Clause-②: the claim wrote 'no'; measured 'yes (widening)' (the ledger header: registering a code widens the published face). Changeset and PR body declare it, with the BREAKING wire-answer banner and ADR-0087 not-required (no-migration-prescription) marker; level patch (Metadata-protocol: none). PR title carries '!', so the full dogfood ran.",
    "File surface: besides the claim's files, packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts (the order's 'pin it at a real door': its two delegated-admin refusals now assert the code, plus a PERMISSION_DENIED control), plugin-security delegable-scope-no-identity-positions.posture.test.ts, delegated-admin-own-name-set.posture.test.ts, delegated-admin-gate-catalog.test.ts, errors.test.ts (pins flipped or extended), and content/docs/references/api/{automation-api,contract,error-code-ledger}.mdx (gen:docs output). No other packages/spec change.",
    "security-routes.ts: the explain route's catch cannot receive this throw (explain-access swallows the gate's throw with a catch that answers false), so it is pinned at the predicate level: the unit cells assert all three limbs it reads (code, name, message prefix).",
    "DelegationScopeViolationError is not exported from plugin-security's index (smallest surface; clients read the code).",
    "The session's default 'request review from os-project-manager' step was not done: it needs a GitHub MCP write, which the order forbids."
    ]
    }


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed

    PR #26320 merged at 2026-10-26T21:46Z by the merge queue as 495fa07c2a on main (Fixes #26189; ACCEPT 6427652251).

    Reading Probe Result
    Queue membership git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-26320-*' 0 refs
    origin/main by content git cat-file -e origin/main:packages/plugins/plugin-security/src/delegated-admin-gate.ts present
    Ancestry git merge-base --is-ancestor 495fa07c2a origin/main holds

    State: this card closed by Fixes (completed); pm:dispatched removed.


    Generated by Claude Code

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

Metadata

Metadata

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsdomain:serverDomain lane — seat card indexed by label:pm:seatenhancementNew feature or requestpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions