Skip to content

hono current-user 端点的独立解析器不读 sys_user_position —— 岗位绑定的能力到不了 /me/permissions,UI 能力门看不见岗位授予 #6334

Description

@baozhoutao

一句话

hono 宿主上,/api/v1/auth/me/permissions 的独立上下文解析器(packages/plugins/plugin-hono-server/src/current-user-endpoints.ts 的 makeExecutionContextResolver)只查 sys_member + sys_user_permission_set,不查 sys_user_position / sys_position_permission_set —— 岗位绑定的权限集(及其 systemPermissions 能力)对该端点不可见,而 objectui 的四面能力门(useCapabilityGate → MePermissionsProvider)整个读的就是它。

真机复现(2026-08-07,worktree @ main 95c4227 + #6332,objectstack dev --seed-admin --fresh)

showcase 里 ops 岗位 ↔ showcase_ops 权限集绑定行(ppsb_showcase_ops)存在,showcase_ops.systemPermissions = ['setup.access', 'showcase.export_data']:

  1. 给 admin 插 sys_user_position { user_id, position: 'ops', organization_id: <活动租户> }(行确认写入,valid_from/valid_until 均空)→ 重新登录后 /me/permissions 仍然:positions: [],systemPermissions 里没有 showcase.export_data。UI 上门在 showcase.export_data 的批量按钮对该用户保持隐藏。
  2. 改插 sys_user_permission_set { user_id, permission_set_id: <showcase_ops> }(用户直绑,该解析器读的表)→ /me/permissions 立即出现 showcase_ops 与 showcase.export_data,UI 门立刻放行。

同时可见:showcase 播种的 usp_showcase_admin_exec/finance/legal/manager 四行岗位授予同样不反映在 positions/permissionSets 里;everyone → showcase_member_default(isDefault 自动绑定)也没有出现在 permissionSets。

为什么要紧

  • 声明 ≠ 生效的又一格:ADR-0090 说分发 = position,showcase 的人设授权全走岗位绑定;但在 hono 宿主上这些授予对 UI 能力门(工具栏 / 行内 kebab / 记录页头 / 批量条,ADR-0066 D4)全部不可见 —— 按钮对确实持有能力的用户隐藏,是 fail-open 设计里明确点名的「更糟的失败方向」。
  • 数据面 CRUD 走 SecurityPlugin 中间件(canonical 解析链,读岗位),所以服务端放行、UI 却藏按钮,两边答案不一致。
  • 标准解析器 resolveUserAuthzGrants(packages/core/src/security/resolve-authz-context.ts)明确处理 sys_user_position(null org = 全局、租户匹配、有效期窗口、everyone 隐式岗位)。该 hono 副本自述「duplicates the canonical resolveAuthzContext by design; the duplication is tracked by scripts/check-single-authz-resolver.mjs」—— 但该 guard 防的是新增副本与两个 DELEGATOR 的失联,并不断言这个被豁免副本的行为等价;sys_user_position 的缺失正是从这个缝里漏掉的(与当年 REST 副本静默丢 sys_user_role 同型)。

建议方向(二选一,倾向 1)

  1. 让 current-user-endpoints 的 resolveCtx 委托共享的 resolveUserAuthzGrants(它就是为「已知 userId 的非 HTTP 面构建同一信封」而导出的),删掉手抄的表读取;
  2. 若刻意保持自包含,至少补上 sys_user_position → sys_position_permission_set 链(含 null-org=全局、租户匹配、isGrantActive 有效期窗口、隐式 everyone),并在 check-single-authz-resolver 里为「豁免副本必须与 canonical 读同一组表」补一条机械断言。

发现于 #6257 / PR #6332 的 showcase UI 实测过程(正向对照被迫改用用户直绑);仅记录,未认领。

Activity

  1. claude commented on Aug 7, 2026

    @claude
    Contributor

    Triage: pm:queue + domain:cli + target:v17.

    Domain, anchored to the landing site rather than the title. The word "permissions" reads as identity, but the fix lands in packages/plugins/plugin-hono-server/src/current-user-endpoints.ts (makeExecutionContextResolver), and plugin-hono-server is in the domain:cli package family. Under direction 1 the diff is a delegation to packages/core/src/security/resolve-authz-context.ts, which it consumes but does not modify — so it stays domain:cli. Direction 2 would additionally touch scripts/check-single-authz-resolver.mjs (domain:devx); if the dev takes that route, the gate assertion is better filed as its own card than carried as a rider — see the collision note below.

    Premises verified on origin/main: resolveUserAuthzGrants in packages/core/src/security/resolve-authz-context.ts reads sys_user_position (:317) and sys_user_permission_set (:341), i.e. the canonical resolver does traverse positions. The reproduction is first-hand against a --fresh boot with a stated baseline and — importantly — a positive control: switching the same user to a direct sys_user_permission_set binding makes the capability appear immediately. That control is what makes this a resolver gap rather than a seeding or login-cache artifact.

    Release board (target:v17): criterion ① — a defect a user hits today on a shipped surface. A user who genuinely holds a capability via a position binding has the button hidden across all four useCapabilityGate surfaces (ADR-0066 D4), while the data-plane middleware grants the same action. Server says yes, UI says no. As the body notes, hiding from an entitled user is the failure direction the fail-open design explicitly names as the worse one, and ADR-0090 makes position binding the distribution mechanism — so this is not an edge configuration, it is the documented path.

    ⚠️ Read this together with #6286 — same seam, opposite halves, and the pairing is the actual story. #6286 measures that check-single-authz-resolver's check (1) is structurally incapable of failing, because its heuristic still keys on the pre-ADR-0090 D3 name sys_user_role (0 hits repo-wide outside one "formerly" comment). This card is a live instance of exactly the drift that gate exists to prevent, escaping through the other hole the body identifies: the gate polices new, unregistered copies, and never asserts that a registered, exempted copy reads the same tables as the canonical resolver. So the family needs all three: the stale criterion (#6286), the behavioural-equivalence assertion for exempted copies (direction 2 here, or its own card), and this defect. The body's own comparison is apt — the original REST copy silently dropping sys_user_role is the same shape, one rename later.

    Collision note for whoever claims this (SKILL note 8). #6286 is queued to domain:devx and lands in scripts/check-single-authz-resolver.mjs; direction 2 here would touch the same file. Two lanes, one shared gate script — if this card goes direction 2, declare that file and coordinate, or split the gate assertion out. Direction 1 (delegate to resolveUserAuthzGrants, delete the hand-copied table reads) avoids the overlap entirely, is the body's own preference, and removes the divergence rather than re-implementing it — this seat concurs, without binding the dev.

    Dedup: all 463 open issues and PRs across the three repos paginated (the search API 403s for this seat). No duplicate. Cross-links: #6286 (the gate's dead criterion, as above), #6257 / PR #6332 (the discovery site; the forced switch to direct binding as a positive control is what surfaced this), ADR-0090 D3 (the rename), ADR-0066 D4 (the four gated surfaces).

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    Collaborator

    Claim: PM loop round 1 (domain:cli seat, 2026-08-08 shift)
    Session: session_017uFVNMmTxLpmfQYiuKM1Yx
    Branch: claude/issue-6334-hono-current-user-positions
    Worktree: objectstack-issue-6334
    Domain: domain:cli
    File surface: packages/plugins/plugin-hono-server/src/current-user-endpoints.ts + its tests + one .changeset/*.md. Explicitly EXCLUDED: scripts/check-single-authz-resolver.mjs — direction 1 (delegate to the canonical resolver) is the dispatch ruling, avoiding the collision with #6286 (domain:devx) that triage flagged. packages/core/src/security/resolve-authz-context.ts is consumed, not modified. (Stop on breach; explain in the report.)
    Serial constraints cleared: no in-flight claims in this lane; no open PR touches current-user-endpoints.ts; #6286 lands in scripts/ and stays disjoint under direction 1.


    Generated by Claude Code

  3. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    Collaborator

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

    What shipped — direction 1, as ruled. The session lookup (the genuinely transport-specific part) stays local; all grant aggregation now delegates to resolveUserAuthzGrants, and the hand-copied table reads are deleted rather than re-implemented (+83 / −155). Arriving with the delegation instead of being hand-written: sys_user_position with null-org-as-global and active-org matching, ADR-0091 validity windows, the implicit everyone anchor (ADR-0090 D5), sys_position_permission_set, mapMembershipRole normalization, the platform-admin derivation, and the ai_seat synthesis.

    Both PM mechanism assumptions were checked before coding rather than taken on faith — import path and dependency direction (no cycle; plugin-hono-server already depended on @objectstack/core in this very file), and envelope preservation (positions / permissionSets / systemPermissions / tabPermissions keep their names and shapes, so MePermissionsProvider reads the same contract with different — correct — contents).

    A second defect was found next to the reported one, and it is the more interesting half. The hand-rolled envelope published membership names under roles, while every reader in the file reads positions (ADR-0090 D3, "formerly roles"). I verified this independently on origin/main rather than taking the report's word for it:

    $ git grep -n "\.positions" origin/main -- 'packages/plugins/plugin-hono-server/**/*.ts'
    current-user-endpoints.ts:794    positions: execCtx.positions ?? [],
    current-user-endpoints.ts:806    ...(execCtx.positions ?? []),
    current-user-endpoints.ts:908    positions: execCtx.positions ?? [],
    current-user-endpoints.ts:995    ...((execCtx as any).positions ?? []),
    
    $ git grep -n "\.roles\b" origin/main -- 'packages/plugins/plugin-hono-server/**/*.ts'
    (no output — and "roles" appears 5× in that same file, so the scanner and path are sound)
    

    Four consumers, zero producers on positions; one producer, zero consumers on roles. That is the declared ≠ enforced / no-producer shape exactly, and it means the endpoint's positions was always [] and those names never reached resolvePermissionSets either — independently of the position tables this card was filed about. Two bugs, one root cause, closed by the same delegation. Neither types nor lint could have caught it: both sides were internally consistent.

    Tests. Eight cases against a seeded read-only fake engine (find only, so no write-verb dispatch contract applies). The discipline worth naming: every negative case carries a co-present valid grant and asserts it surfaced — a wrong-org row and an out-of-window row are each paired with a good row. Without that, both negatives would have passed in the pre-fix world for the wrong reason, since the old resolver produced nothing at all. Reverse verification predicted 7 red / 1 green before running — the one green being the direct-binding positive control, which is the single table the deleted code did read — and restoring the old file produced exactly that split. Cross-package coverage on the real showcase stack was run too (me-apps-and-everyone-baseline 6 passed; authz-conformance + showcase-permission-seeding + permission-zoo + two-doors + delegation-of-duty + semantic-roles, 40 passed), which is the right instinct given a delegation that newly resolves everyone could in principle widen /me/apps.

    Two in-file judgement calls, both reviewed and accepted:

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

    On the file-surface breach — the dev was right and my carve-out named the wrong file. The claim comment declared current-user-endpoints.ts + tests + changeset, and the dispatch pre-authorised exactly one out-of-surface touch: a stale entry in scripts/check-single-authz-resolver.mjs. What actually went red was check:query-options-erasure, as a ratchet DOWN — the six as any query-option sites it recorded for this file were precisely the hand-copied table reads being deleted — and the fix is the one the gate's own failure message prescribes. The diff is a single deleted line. That is the same shape my carve-out anticipated (a registry entry invalidated by the deletion) landing in a different file than I guessed, so the right response is to accept it and correct the dispatch, not to bounce the PR. Recording it here so the next reader inherits the corrected premise: a deletion of this kind can invalidate more than one baseline, and the carve-out should be written by shape rather than by filename. scripts/check-single-authz-resolver.mjs is confirmed untouched (absent from the changed-file list), so the #6286 collision the triage seat flagged did not occur.

    Verification (read from GitHub, not from the report): 4 changed files — changeset (patch), the new test, current-user-endpoints.ts, and the one-line baseline; no content/docs/releases/ edit. CI: 24 check runs, zero failures, 2 skipped by path filter; ESLint success and TypeScript Type Check success read as job conclusions, plus Test Core 1–3/3, Dogfood Regression Gate 1–3/3, Temporal Conformance and Check Changeset all success.

    Note for the family: this card was the live instance of the drift #6286 describes from the other side. The gate polices new, unregistered copies and never asserted that a registered, exempted copy reads the same tables as the canonical resolver. Direction 1 removes this copy's divergence entirely, so the hole is now one card narrower — but #6286 still owns closing it in the gate.

    Marking ready and adding to the merge queue.


    Generated by Claude Code

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

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions