Skip to content

fix(plugin-hono-server): current-user endpoints resolve position-bound grants through the canonical resolver (#6334) - #6482

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-6334-hono-current-user-positions
Aug 8, 2026
Merged

fix(plugin-hono-server): current-user endpoints resolve position-bound grants through the canonical resolver (#6334)#6482
os-project-manager merged 2 commits into
mainfrom
claude/issue-6334-hono-current-user-positions

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #6334

What was wrong

On a hono host, /api/v1/auth/me/permissions and /me/apps resolved the caller
through a standalone resolver in packages/plugins/plugin-hono-server/src/current-user-endpoints.ts
(makeExecutionContextResolver) that read sys_member + sys_user_permission_set
and nothing else. It never read sys_user_position / sys_position_permission_set,
so a permission set bound to a position — the ADR-0090 D3 distribution mechanism,
and how the showcase app grants every persona — was invisible to these endpoints:
positions: [], the set missing from permissionSets, its capabilities missing
from systemPermissions.

That is the surface objectui's four useCapabilityGate gates read (ADR-0066 D4),
while the data plane resolves through SecurityPlugin's middleware on the canonical
chain. So the server granted the action and the UI hid the button from a user
who genuinely held the capability — the failure direction the fail-open design names
as the worse one.

Verified against origin/main before implementing (premise holds at eb7613c), and
one thing the issue did not name turned up next to it: the hand-rolled envelope
published membership roles under roles, while ExecutionContext — and every reader
in that same file (execCtx.positions, twice) — calls the field positions
(ADR-0090 D3, "formerly roles"). So the endpoint's positions was always []
and those names never reached resolvePermissionSets either, independently of the
position tables. Both halves are one divergence and both close here.

The fix — direction 1 (the dispatch ruling)

The session lookup, the genuinely transport-specific part, stays where it is. All
grant aggregation now delegates to resolveUserAuthzGrants (packages/core/src/security/resolve-authz-context.ts),
the canonical resolver's userId-driven core, which @objectstack/core exports for
exactly this caller shape: a surface that already knows who the principal is and needs
the same envelope with no HTTP request to resolve it from. The hand-copied table reads
are deleted, not re-implemented.

PM mechanism assumptions, checked before coding:

  • Import path / dependency direction. resolveUserAuthzGrants is re-exported
    from the @objectstack/core root (src/index.ts./security/index.js), and
    plugin-hono-server already depends on @objectstack/core (it imported
    IDataEngine and derivePosture from it in this very file). Core does not depend
    on the plugin — no cycle.
  • Response envelope preserved. positions / permissionSets / systemPermissions
    / tabPermissions keep their names and shapes; the handlers pick fields explicitly,
    so the delegation changes what those fields contain, never the contract
    MePermissionsProvider reads. No forking of the resolver was needed.

Arriving with the delegation, none of it re-implemented: sys_user_position
(null org = global, active-org match, ADR-0091 validity windows), the implicit
everyone audience anchor (ADR-0090 D5), sys_position_permission_set,
mapMembershipRole normalization, the platform-admin derivation and posture rung,
and the ai_seat synthesis.

Two deliberate small decisions inside the file surface:

  • posture is now carried on the returned context. The old comment refused to,
    because the rung was derived locally onto a throw-away object and "only the
    authoritative resolver may set it". grants.posture is that resolver's
    derivation (ADR-0095 D2/D3), so this surface and the dispatcher now feed the shared
    isPerfDisclosurePrincipal predicate the same value instead of two derivations.
  • No seedEmail. AuthSessionApi.getSession declares user: { id?: string }
    and nothing more; reading an undeclared email off it is the dispatcher 多个 domain 调用契约里没有的方法 —— #4087 的同类,只是方向相反(契约缺声明,不是调用点乱编) #4127 shape (tsc
    agreed: TS2339). The resolver reads sys_user.email itself — the row it loads
    anyway for ai_seat — and that column is unique by the auth invariant, so the two
    sources cannot disagree.

Files outside current-user-endpoints.ts + tests + changeset

One, declared: scripts/query-options-erasure-baseline.json loses one line.
pnpm check:query-options-erasure went red as a ratchet DOWN — the 6 erasure
sites it recorded for current-user-endpoints.ts were exactly the as any query
options of the hand-copied table reads this PR deletes — and the gate's own failure
message prescribes the fix (--update and commit the baseline). One line removed,
nothing else in the file changed.

scripts/check-single-authz-resolver.mjs is untouched, as the dispatch required
(#6286 owns it). Nothing there needed touching either: its ALLOW map was re-curated
in #6286 to the query-shaped criterion and never listed this file — which is itself
the hole the issue names, since the criterion requires querying both grant tables
and this copy only ever queried one. Before: matches neither. After: queries neither.
pnpm check:authz-resolver passes unchanged.

Tests

New: packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts
(8 cases, seeded read-only fake engine — find only, so no write-verb dispatch
contract applies).

  1. the issue's repro — a sys_user_positionsys_position_permission_set grant
    surfaces in positions / permissionSets / systemPermissions;
  2. positive control — a direct sys_user_permission_set binding still resolves
    (the issue's own control, and the one path the pre-fix resolver did read);
  3. the implicit everyone position carries its default set (ADR-0090 D5);
  4. sys_member.role projects as the normalized org_member position;
  5. a null-org position row is global;
  6. a position row scoped to another organization is dropped;
  7. position grants outside their ADR-0091 window are dropped (expired and
    not-yet-active);
  8. /me/apps lists an app whose requiredPermissions are held only via the position
    chain, and still filters one that is not.

Cases 6 and 7 each carry a co-present valid grant and assert it surfaced. A
negative asserted alone would have passed in the pre-fix world for the wrong reason —
because the resolver produced nothing at all, not because it judged the invalid row
correctly.

Reverse verification

Direction predicted before running: 7 red, 1 green — every position-dependent case
red, and the direct-binding positive control green, because that is precisely the one
table the deleted code did read. Restoring current-user-endpoints.ts from
origin/main and re-running gives exactly that:

 ×  surfaces a sys_user_position → sys_position_permission_set grant
    → expected [] to include 'ops'
 ✓  still resolves a direct sys_user_permission_set binding (positive control)
 ×  carries the implicit `everyone` position and its default set (ADR-0090 D5)
    → expected [] to include 'everyone'
 ×  projects the normalized org-membership position (sys_member.role)
    → expected [] to include 'org_member'
 ×  treats a null-org position row as global      → expected [] to include 'ops'
 ×  drops a position row scoped to another organization → expected [] to include 'ops'
 ×  drops position grants outside their ADR-0091 validity window
    → expected [] to include 'ops'
 ×  /me/apps ... position-bound set  → expected [] to deeply equal [ 'exports' ]
 Tests  7 failed | 1 passed (8)

Both negatives failed on their co-present-valid half, which is the point: they cannot
go green by producing nothing.

Local runs

pnpm --filter @objectstack/plugin-hono-server test       Test Files 16 passed | Tests 187 passed
pnpm --filter @objectstack/plugin-hono-server typecheck  tsc --noEmit, clean
pnpm lint                                                clean

Every check:* step enumerated from .github/workflows/lint.yml was run one by one
(both jobs' lists): all pass, check:query-options-erasure after the baseline update
above.

Cross-package: the dogfood suites that exercise this endpoint on the real showcase
stack
were run too, since a delegation that newly resolves everyone could in
principle move /me/apps:

test/me-apps-and-everyone-baseline.dogfood.test.ts        6 passed
authz-conformance + showcase-permission-seeding
  + showcase-permission-zoo + two-doors-permission
  + delegation-of-duty + semantic-roles                  40 passed

(The everyone-suggested sets cannot carry systemPermissions — the ADR-0090 D7
lint hard-blocks it — so "no capability-gated app leaks to a plain member" still
holds, now proved rather than argued.)


Generated by Claude Code

claude added 2 commits August 8, 2026 00:55
…tion to resolveUserAuthzGrants (#6334)

The standalone resolver behind /auth/me/permissions and /me/apps read
sys_member + sys_user_permission_set and nothing else, so position-bound
permission sets (sys_user_position -> sys_position_permission_set, the
ADR-0090 D3 distribution mechanism) never reached the response. objectui's
useCapabilityGate surfaces read this endpoint, so the button was hidden from
users who genuinely held the capability while the data plane granted it.

Session lookup stays local; all grant aggregation now delegates to the
canonical resolver's userId-driven core.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx
…ine (#6334)

`AuthSessionApi.getSession` declares `user: { id?: string }` and nothing more,
so reading `email` off it is the #4127 shape. The canonical resolver reads
`sys_user.email` itself (the row it loads anyway for ai_seat), and that column
is unique by the auth invariant — same answer, no contract widening.

The erasure ratchet baseline drops its `current-user-endpoints.ts` entry: the
6 sites it recorded were the `as any` query options of the hand-copied table
reads this PR deletes (ratchet DOWN, as the gate itself instructs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 8, 2026 1:40am

Request Review

@github-actions github-actions Bot added the size/l label Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/getting-started/your-first-project.mdx (via @objectstack/plugin-hono-server)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-hono-server)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-hono-server)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-hono-server)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/plugin-hono-server)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-hono-server)
  • content/docs/releases/v16.mdx (via @objectstack/plugin-hono-server)

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.

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants