Repository navigation
sys_member grade changes lose the true actor — thread attribution through the better-auth seam (validity windows deliberately NOT added) #4586
Description
> 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:
-
Hop 1 — the grade change loses the true actor.
sys_memberistrackHistory: true, so role transitions are already captured as history rows. But every writer is a better-auth path running underisSystem, 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.tsalready resolves the issuer's membership on that same seam). It is discarded before the write reaches ObjectQL. -
Hop 2 — the capability grant discards provenance it has a column for.
auto-org-admin-grant.ts:244writesgranted_by: nullinto a column that exists. Soexplaincannot 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.
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
rolestring (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_admingrade (carries no authority by construction) + anadminScopepermission-set grant with an ADR-0091 window, capped by the 30-day delegation ceiling implemented inDelegatedAdminGate. That is the incident-window tool. Fullowner/adminis 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, onlyaccessible_org_idsappliesisGrantActivetoday — 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_bystamp. 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
[-]`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)[/+]os-zhuang commentedon Aug 2, 2026
Body rewritten (revision 1) after verifying the code — the issue got smaller and sharper:
sys_memberalready hastrackHistory: true(transitions are recorded; only the true actor is lost — every writer runsisSystem), andgranted_byalready exists on the grant table (auto-org-admin-grant.ts:244writesnullinto it). No new tables, no new audit machinery — the gap is attribution at the better-auth seam, the one place the session actor exists.sys_memberis 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 storedrolestring) — 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+ windowedadminScope, 30-day ceiling). Temporary membership is a separate product question (ADR-0093 territory), not an audit fix.granted_byon the auto-grant; test at the real routes, not the declaration.Generated by Claude Code
os-zhuang commentedon Aug 2, 2026
🔒 CLAIM — pm-dispatch round 6
branch:
claude/issue-4586-actor-attribution-seamworktree:
objectstack-issue-4586按正文 revision-1 的 W1(推荐的通用形状)+ W2 + W3 实施;硬约束(线程化的 actor 只作归因、绝不成为写入的授权主体)须有 pin 测试守住。"Deliberately NOT doing"(有效期窗口)一节的边界照办,不得顺手实现。
Generated by Claude Code
feat(auth): thread the real better-auth actor into identity writes fo…
test(dogfood): identify the demotion history row by what it records, …
docs(changeset): add the changeset for the #4586 actor-attribution seam
merge: reconcile the rebased local branch with its pushed head (#4586)
os-zhuang commentedon Aug 2, 2026
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
feat(auth): 把 better-auth 的真实操作人接到身份表写入上,作为归因 (objectstack-ai#4586) (o…
feat(audit): write login/logout rows to sys_audit_log and attribute t…