Skip to content

sys_member grade changes lose the true actor — thread attribution through the better-auth seam (validity windows deliberately NOT added) #4586

Closed

Description

@os-zhuang

> Revision 1 (2026-08-02): narrowed after verifying the code. Two facts shrink this issue: sys_member already has trackHistory: true (the transitions ARE recorded — the missing piece is only who), and granted_by already exists on the capability-grant table (auto-org-admin-grant.ts:244 deliberately writes null into it). And one half of the original "suggested shape" is reversed: adding ADR-0091 validity columns to sys_member turns out to be the wrong move, for a reason worth recording — see "Deliberately not doing". Original framing preserved in History.

Split out of #3723 (closed by #3802 / ADR-0108). Everything else in that investigation landed; this is the one substantive item left.

The actual gap: attribution, not machinery

The elevation chain is sys_member.role (admin grade) → auto-org-admin-grant.ts → sys_user_permission_set(organization_admin, org-scoped) → isTenantAdmin(). Both hops lose provenance:

  1. Hop 1 — the grade change loses the true actor. sys_member is trackHistory: true, so role transitions are already captured as history rows. But every writer is a better-auth path running under isSystem, so history records "system" as the actor. The human who clicked make admin is known exactly once — in the better-auth hook layer, where the session actor exists (invitation-role-cap.ts already resolves the issuer's membership on that same seam). It is discarded before the write reaches ObjectQL.

  2. Hop 2 — the capability grant discards provenance it has a column for. auto-org-admin-grant.ts:244 writes granted_by: null into a column that exists. So explain cannot answer "why is X a tenant admin" end to end, even though every table involved could carry the answer.

Suggested work

W1 — thread the true actor from the better-auth seam into the write context, so trackHistory captures it. Two shapes:

  • General (recommended): better-auth-originated ObjectQL writes carry the real actor for ATTRIBUTION. Fixes actor history for every better-auth-managed table in one move, not just sys_member.
  • Targeted: emit explicit grade-transition audit events from the hook layer only. Smaller blast radius; the next table with the same problem does it again.

⚠️ The one hard constraint either way: the threaded actor must be attribution-only — it must NOT become the authorization subject of the write. better-auth writes are isSystem on purpose (the route already authorized the action under better-auth's ACL); re-authorizing them as the human actor would create a second adjudication track at exactly the boundary ADR-0095 D3 closed. Attribution and authorization stay separate fields of the context, and a test should pin that.

W2 — stamp granted_by on the auto-grant row: a machine-provenance marker plus the sys_member row that triggered it (and the W1 actor when present). One-liner class of change; makes explain answer the whole chain.

W3 — cover it at the call sites, not the declaration (the #3106 lesson: a case label is not enforcement). Tests drive the real better-auth routes — invite-accept, update-member-role, reconciler bind, demotion revoke — and assert the history row / grant row carries the actor.

Deliberately NOT doing: validity windows on sys_member

The original filing suggested adding the ADR-0091 valid_from / valid_until columns. Rejected on the merits, and the reasoning is the record here:

  • A platform-side window on the grade would be dual-track adjudication. better-auth's own route ACL reads the stored role string (update-member-role, invitation permissions). If the platform computes "admin until Friday, member after", there are two answers to is X an admin — the platform's and better-auth's — which is the ADR-0092 D4/D5: sys_user 编辑对"同时持有 organization_admin 的平台管理员"被权限合成挡住(explicit deny 胜 wildcard allow) #2836 class reopened at the boundary ADR-0095 D3 just cleaned. The stored string must remain the single truth.
  • The alternative (revert the role at expiry via a job) is banned — ADR-0049: a security property enforced by a cleanup job is not enforced.
  • Windowed temporary authority already exists, governed: delegated_admin grade (carries no authority by construction) + an adminScope permission-set grant with an ADR-0091 window, capped by the 30-day delegation ceiling implemented in DelegatedAdminGate. That is the incident-window tool. Full owner/admin is a standing appointment, not a temp elevation mechanism.
  • Temporary membership (a contractor whose belonging lapses) is a different thing — a product feature in ADR-0093 lifecycle territory, not an audit-parity fix. Note the read path is only PARTIALLY prepared for it: of the three membership-consuming loops in resolve-authz-context, only accessible_org_ids applies isGrantActive today — the grade-projection and fellow-org (org_user_ids) loops do not, so building the feature requires closing those two plus copying the window onto the auto-granted capability row and gating the better-auth actor seam. Windowing MEMBERSHIP does not hit the dual-track wall the way windowing the GRADE does — an expired plain member diverges only on presentation (better-auth still lists the org) while Layer 0 already denies all data — but it is real scoped work. Decided 2026-08-02 (founder): not now — demand-gated. Revisit when an enterprise/compliance need lands; it then gets its own design (ADR-0093 addendum), starting from the analysis above.

Not in scope

Narrowing the resolve-authz-context.ts projection union — with the vocabulary closed (ADR-0108) it can only see the four framework names; correct as it stands.

History

  • Original filing (2026-07-28): framed as "no audit trail and no expiry", suggesting ADR-0091 columns + a granted_by stamp. Half right: the audit machinery already existed (trackHistory), the expiry half is architecturally wrong on this table, and the real gap is actor attribution at the better-auth seam.

Related: #3723 · #3697 · #3767 (sys_member write governance) · ADR-0049 · ADR-0091 D3 · ADR-0093 · ADR-0095 D3 · ADR-0108.

Activity

  1. changed the title [-]`sys_member` grants org-admin standing with no audit trail and no expiry — the last asymmetry from #3723[/-] [+]`sys_member` grade changes lose the true actor — thread attribution through the better-auth seam (validity windows deliberately NOT added)[/+] on Aug 2, 2026
  2. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    Body rewritten (revision 1) after verifying the code — the issue got smaller and sharper:

    • Two facts shrink it. sys_member already has trackHistory: true (transitions are recorded; only the true actor is lost — every writer runs isSystem), and granted_by already exists on the grant table (auto-org-admin-grant.ts:244 writes null into it). No new tables, no new audit machinery — the gap is attribution at the better-auth seam, the one place the session actor exists.
    • One half of the original suggestion is reversed. Adding ADR-0091 validity columns to sys_member is now recorded as deliberately not doing: a platform-side window on the grade would give two answers to "is X an admin" (better-auth's route ACL reads the stored role string) — the ADR-0092 D4/D5: sys_user 编辑对"同时持有 organization_admin 的平台管理员"被权限合成挡住(explicit deny 胜 wildcard allow) #2836 dual-track class reopened at the boundary ADR-0095 D3 closed — and job-based demotion is banned by ADR-0049. Windowed temporary authority already exists governed (delegated_admin + windowed adminScope, 30-day ceiling). Temporary membership is a separate product question (ADR-0093 territory), not an audit fix.
    • Work is now W1–W3: thread the actor for attribution (general form recommended — fixes every better-auth-managed table's history at once), with the hard constraint that the threaded actor is attribution-only and never becomes the write's authorization subject; stamp granted_by on the auto-grant; test at the real routes, not the declaration.

    Generated by Claude Code

  3. self-assigned this
    on Aug 2, 2026
  4. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    🔒 CLAIM — pm-dispatch round 6
    branch: claude/issue-4586-actor-attribution-seam
    worktree: objectstack-issue-4586
    按正文 revision-1 的 W1(推荐的通用形状)+ W2 + W3 实施;硬约束(线程化的 actor 只作归因、绝不成为写入的授权主体)须有 pin 测试守住。"Deliberately NOT doing"(有效期窗口)一节的边界照办,不得顺手实现。


    Generated by Claude Code

  5. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    PM 复核:ACCEPT ✅(PR #4656,CI 22 项全绿——21 成功 + 1 skipped,已转正并挂自动合并)

    关键的一点做对了:dogfood 失败时没有把断言改宽松了事,而是查清了哪一侧错。结论是测试的选行逻辑错了两次——用 rows[rows.length-1] 当"最新",既忽略了 find 无序,又忽略了审计行是在接口返回后异步落库的(于是降级用例实际断言到了上一个用例的晋升行)。实现侧没有缺口:audit-writers.ts 的 diff() 本来就同时存了 old_value 与 new_value 两半。改法是换成 waitForHistoryMatching(按"这一行记录了什么"来认行),并补正向 pin钉住 old/new 值对与晋升行各自带自己的 actor——比原来更强,不是更弱。

    其余核过:


    范围外发现 #4640 已提级为 security / priority:p1,请维护者优先看——它是这次批次里最值钱的产出,而且是一个当前 main 上的活口子:

    auto-org-admin-grant 唯一的删除通道用了三参数 ql.delete(object, id, ctx),而引擎签名是两参数 (object, options),于是 options 收到字符串、rejectUnknownEngineOptions 抛错、tryDelete 的 catch 吞掉返回 false。后果:降级 / 移除成员之后,organization_admin 那行权限集留在原地,被降级的人仍然是 tenant admin;ADR-0105 D4 的 superseded-variant 收敛与 kernel:ready 孤儿清扫也一并失效。

    单测一直全绿的原因正是 #4550 那个模式:stub 自己实现的就是三参数形状,"revoke 生效"这件事只在测试替身里成立过。修法除了改调用点,必须把 stub 对齐真引擎签名,否则修完仍然测不出来。


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions