You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
* feat(auth): thread the real better-auth actor into identity writes for attribution (#4586)
better-auth owns every write to the identity tables and its ObjectQL adapter
runs them `isSystem: true` on purpose — the route already authorized the
action under better-auth's own ACL. The human who clicked *make admin* was
known exactly once, in the hook layer, then discarded, so every
`trackHistory` transition on `sys_member` recorded "system" as its actor.
W1 — a general seam, not a `sys_member` special case: a request-scoped
attribution store opened at `AuthManager.handleRequest`, filled lazily from
better-auth's global before-hook, surfaced as
`ExecutionContext.attributedUserId` → `HookContext.provenance.attributedUserId`
and read by the audit writer.
W2 — `auto-org-admin-grant` stamps the attributed human into the `granted_by`
column it always wrote null into, plus a machine-provenance `reason` naming
the writer and the triggering `sys_member` row.
W3 — covered at the real routes (invite-accept, update-member-role, the
reconciler bind, demotion) in a dogfood test over the live HTTP stack.
ATTRIBUTION ONLY: the threaded actor never becomes the authorization subject.
It rides `provenance`, which no security middleware reads; `isSystem` stays
the unconditional authorization half. Re-authorizing as the human would open
the second adjudication track ADR-0095 D3 closed — pinned by tests at the
engine seam, the adapter, and the live route.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5
* feat(auth): thread the real better-auth actor into identity writes for attribution (#4586)
better-auth owns every write to the identity tables and its ObjectQL adapter
runs them `isSystem: true` on purpose — the route already authorized the
action under better-auth's own ACL. The human who clicked *make admin* was
known exactly once, in the hook layer, then discarded, so every
`trackHistory` transition on `sys_member` recorded "system" as its actor.
W1 — a general seam, not a `sys_member` special case: a request-scoped
attribution store opened at `AuthManager.handleRequest`, filled lazily from
better-auth's global before-hook, surfaced as
`ExecutionContext.attributedUserId` → `HookContext.provenance.attributedUserId`
and read by the audit writer.
W2 — `auto-org-admin-grant` stamps the attributed human into the `granted_by`
column it always wrote null into, plus a machine-provenance `reason` naming
the writer and the triggering `sys_member` row.
W3 — covered at the real routes (invite-accept, update-member-role, the
reconciler bind, demotion) in a dogfood test over the live HTTP stack.
ATTRIBUTION ONLY: the threaded actor never becomes the authorization subject.
It rides `provenance`, which no security middleware reads; `isSystem` stays
the unconditional authorization half. Re-authorizing as the human would open
the second adjudication track ADR-0095 D3 closed — pinned by tests at the
engine seam, the adapter, and the live route.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5
* test(dogfood): identify the demotion history row by what it records, not its position (#4586)
The Dogfood Regression Gate failed on this file's own new assertion:
expected '{"role":"admin"}' to contain 'member'
Not a defect in what `trackHistory` stores — the audit writer's `update` leg
writes a CHANGED-FIELDS DIFF, `old_value` the before-state and `new_value` the
after-state (`diff()` in `audit-writers.ts` fills both halves), so a demotion
really is recorded as `{"role":"admin"}` → `{"role":"member"}`. The audit
question "who changed X from member to admin" was already answerable; #4586
adds WHO to a row that already knew WHAT.
The defect was the TEST's row SELECTION. It took `rows[rows.length - 1]` as
"the newest row", which is wrong twice over:
1. `find` without an explicit sort is unordered, so the last element is not
the newest anything;
2. the audit row lands ASYNCHRONOUSLY after the endpoint returns, so at the
moment the demotion test polled, the only `update` row present was the
PROMOTION from the earlier test — and it asserted the demotion's
expectation against it.
Locally green, red in CI, because `packages/qa/dogfood` sits outside the
`--filter` scope the change was verified under.
`waitForHistoryMatching` now waits for the row that says the thing under test,
so both hazards are gone. The demotion case additionally pins the pair
(`old_value` admin → `new_value` member) and asserts the promotion survives as
a DISTINCT row keeping its own actor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5
* docs(changeset): add the changeset for the #4586 actor-attribution seam
The Check Changeset gate was red: a user-visible behaviour change (identity-table
writes now carry the true actor; `granted_by` / `reason` stop being null) with no
`.changeset/*.md`. Minor across the five published packages whose surface moves —
`spec` gains an authorable `ExecutionContext.attributedUserId` and the
`HookContext.provenance` envelope, so this is additive API, not a pure fix.
The body states the hard constraint the reviewer must be able to find later:
the threaded actor is attribution ONLY and never the authorization subject.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(auth,objectql,audit,security,spec): identity-table writes carry the real actor, so `sys_member` history stops saying "system" (#4586)
10
+
11
+
better-auth owns every write to the identity tables (`sys_member`, `sys_user`,
12
+
`sys_invitation`, …) and its ObjectQL adapter runs them `isSystem: true`**on
13
+
purpose** — the route already authorized the action under better-auth's own ACL,
14
+
and ADR-0092 D2 refuses user-context writes to those tables outright. The
15
+
consequence was that the human who clicked *make admin* was known exactly once,
16
+
in the hook layer where the session exists, and then discarded: every
17
+
`trackHistory` transition on `sys_member` recorded `user_id: null` / "system",
18
+
and `sys_user_permission_set.granted_by` was written null by the auto-grant.
19
+
"Who made this person an org admin?" had no answer in the platform's own audit
20
+
log.
21
+
22
+
**What changed**
23
+
24
+
A request-scoped attribution seam, general rather than a `sys_member` special
25
+
case:
26
+
27
+
| Layer | Before | After |
28
+
|:--|:--|:--|
29
+
|`ExecutionContext`|`userId` / `actor` only | new optional `attributedUserId` — the human CREDITED for a write the system AUTHORIZED |
30
+
|`HookContext`|`session`, `user`| new `provenance.attributedUserId`, split off the context beside `session`|
31
+
| better-auth ObjectQL adapter |`{ isSystem: true }`|`{ isSystem: true, attributedUserId }` when a request scope is open |
32
+
| audit writer |`user_id = session.userId ?? null`| falls back to `provenance.attributedUserId` when the session names nobody |
33
+
|`auto-org-admin-grant`|`granted_by: null`, no `reason`| the attributed human in `granted_by`, plus a machine-provenance `reason` naming the writer and the triggering `sys_member` row |
34
+
35
+
Outside a request scope nothing changes: writes stay bare `{ isSystem: true }`
36
+
and audit rows keep recording `null`. Absence is still never upgraded into a
37
+
caller, and never written as a sentinel string (ADR-0118 D1/D2).
38
+
39
+
**Hard constraint — attribution is not authority**
40
+
41
+
`attributedUserId` is read by exactly one consumer, the audit writer, and by no
42
+
security middleware. It never becomes `ExecutionContext.userId`, so it is never
43
+
the subject the engine authorizes as: not RLS `current_user`, not the ownership
44
+
stamp, not permission resolution. A context carrying only `attributedUserId`
45
+
authorizes exactly like an empty context (ANONYMOUS), and a context carrying it
46
+
beside `isSystem: true` authorizes exactly like `isSystem` alone. Re-authorizing
47
+
identity writes as the human would re-adjudicate a decision better-auth already
48
+
made — the second adjudication track ADR-0095 D3 closed. The constraint is
49
+
pinned by tests at three layers: the engine seam
50
+
(`packages/objectql/src/engine.test.ts`), the better-auth adapter
51
+
(`packages/plugins/plugin-auth/src/auth-actor-attribution.test.ts`), and the
52
+
live HTTP route (a plain member still cannot promote themselves).
53
+
54
+
**For authors and plugin developers**
55
+
56
+
`attributedUserId` is authorable on `ExecutionContext` and readable as
57
+
`ctx.provenance?.attributedUserId` in hooks. Use it to answer *who is
58
+
responsible*; keep using `ctx.session` / `ctx.user` to decide *what is
59
+
permitted*. The two are separate fields precisely so the distinction cannot be
0 commit comments