|
| 1 | +--- |
| 2 | +"@objectstack/spec": minor |
| 3 | +"@objectstack/objectql": minor |
| 4 | +"@objectstack/plugin-auth": minor |
| 5 | +"@objectstack/plugin-audit": minor |
| 6 | +"@objectstack/plugin-security": minor |
| 7 | +--- |
| 8 | + |
| 9 | +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 |
| 60 | +blurred by accident. |
0 commit comments