fix(plugin-hono-server): current-user endpoints resolve position-bound grants through the canonical resolver (#6334) - #6482
Merged
os-project-manager merged 2 commits intoAug 8, 2026
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-project-manager
marked this pull request as ready for review
August 8, 2026 02:02
os-project-manager
deleted the
claude/issue-6334-hono-current-user-positions
branch
August 8, 2026 02:18
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6334
What was wrong
On a hono host,
/api/v1/auth/me/permissionsand/me/appsresolved the callerthrough a standalone resolver in
packages/plugins/plugin-hono-server/src/current-user-endpoints.ts(
makeExecutionContextResolver) that readsys_member+sys_user_permission_setand 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 frompermissionSets, its capabilities missingfrom
systemPermissions.That is the surface objectui's four
useCapabilityGategates 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/mainbefore implementing (premise holds ateb7613c), andone thing the issue did not name turned up next to it: the hand-rolled envelope
published membership roles under
roles, whileExecutionContext— and every readerin that same file (
execCtx.positions, twice) — calls the fieldpositions(ADR-0090 D3, "formerly
roles"). So the endpoint'spositionswas always[]and those names never reached
resolvePermissionSetseither, independently of theposition 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/coreexports forexactly 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:
resolveUserAuthzGrantsis re-exportedfrom the
@objectstack/coreroot (src/index.ts→./security/index.js), andplugin-hono-serveralready depends on@objectstack/core(it importedIDataEngineandderivePosturefrom it in this very file). Core does not dependon the plugin — no cycle.
positions/permissionSets/systemPermissions/
tabPermissionskeep their names and shapes; the handlers pick fields explicitly,so the delegation changes what those fields contain, never the contract
MePermissionsProviderreads. 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
everyoneaudience anchor (ADR-0090 D5),sys_position_permission_set,mapMembershipRolenormalization, the platform-admin derivation and posture rung,and the
ai_seatsynthesis.Two deliberate small decisions inside the file surface:
postureis 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.postureis that resolver'sderivation (ADR-0095 D2/D3), so this surface and the dispatcher now feed the shared
isPerfDisclosurePrincipalpredicate the same value instead of two derivations.seedEmail.AuthSessionApi.getSessiondeclaresuser: { id?: string }and nothing more; reading an undeclared
emailoff it is the dispatcher 多个 domain 调用契约里没有的方法 —— #4087 的同类,只是方向相反(契约缺声明,不是调用点乱编) #4127 shape (tscagreed: TS2339). The resolver reads
sys_user.emailitself — the row it loadsanyway for
ai_seat— and that column is unique by the auth invariant, so the twosources cannot disagree.
Files outside
current-user-endpoints.ts+ tests + changesetOne, declared:
scripts/query-options-erasure-baseline.jsonloses one line.pnpm check:query-options-erasurewent red as a ratchet DOWN — the 6 erasuresites it recorded for
current-user-endpoints.tswere exactly theas anyqueryoptions of the hand-copied table reads this PR deletes — and the gate's own failure
message prescribes the fix (
--updateand commit the baseline). One line removed,nothing else in the file changed.
scripts/check-single-authz-resolver.mjsis untouched, as the dispatch required(#6286 owns it). Nothing there needed touching either: its
ALLOWmap was re-curatedin #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-resolverpasses unchanged.Tests
New:
packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts(8 cases, seeded read-only fake engine —
findonly, so no write-verb dispatchcontract applies).
sys_user_position→sys_position_permission_setgrantsurfaces in
positions/permissionSets/systemPermissions;sys_user_permission_setbinding still resolves(the issue's own control, and the one path the pre-fix resolver did read);
everyoneposition carries its default set (ADR-0090 D5);sys_member.roleprojects as the normalizedorg_memberposition;not-yet-active);
/me/appslists an app whoserequiredPermissionsare held only via the positionchain, 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.tsfromorigin/mainand re-running gives exactly that:Both negatives failed on their co-present-valid half, which is the point: they cannot
go green by producing nothing.
Local runs
Every
check:*step enumerated from.github/workflows/lint.ymlwas run one by one(both jobs' lists): all pass,
check:query-options-erasureafter the baseline updateabove.
Cross-package: the dogfood suites that exercise this endpoint on the real showcase
stack were run too, since a delegation that newly resolves
everyonecould inprinciple move
/me/apps:(The
everyone-suggested sets cannot carrysystemPermissions— the ADR-0090 D7lint hard-blocks it — so "no capability-gated app leaks to a plain member" still
holds, now proved rather than argued.)
Generated by Claude Code