Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/actor-attribution-seam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
"@objectstack/spec": minor
"@objectstack/objectql": minor
"@objectstack/plugin-auth": minor
"@objectstack/plugin-audit": minor
"@objectstack/plugin-security": minor
---

feat(auth,objectql,audit,security,spec): identity-table writes carry the real actor, so `sys_member` history stops saying "system" (#4586)

better-auth owns every write to the identity tables (`sys_member`, `sys_user`,
`sys_invitation`, …) and its ObjectQL adapter runs them `isSystem: true` **on
purpose** — the route already authorized the action under better-auth's own ACL,
and ADR-0092 D2 refuses user-context writes to those tables outright. The
consequence was that the human who clicked *make admin* was known exactly once,
in the hook layer where the session exists, and then discarded: every
`trackHistory` transition on `sys_member` recorded `user_id: null` / "system",
and `sys_user_permission_set.granted_by` was written null by the auto-grant.
"Who made this person an org admin?" had no answer in the platform's own audit
log.

**What changed**

A request-scoped attribution seam, general rather than a `sys_member` special
case:

| Layer | Before | After |
|:--|:--|:--|
| `ExecutionContext` | `userId` / `actor` only | new optional `attributedUserId` — the human CREDITED for a write the system AUTHORIZED |
| `HookContext` | `session`, `user` | new `provenance.attributedUserId`, split off the context beside `session` |
| better-auth ObjectQL adapter | `{ isSystem: true }` | `{ isSystem: true, attributedUserId }` when a request scope is open |
| audit writer | `user_id = session.userId ?? null` | falls back to `provenance.attributedUserId` when the session names nobody |
| `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 |

Outside a request scope nothing changes: writes stay bare `{ isSystem: true }`
and audit rows keep recording `null`. Absence is still never upgraded into a
caller, and never written as a sentinel string (ADR-0118 D1/D2).

**Hard constraint — attribution is not authority**

`attributedUserId` is read by exactly one consumer, the audit writer, and by no
security middleware. It never becomes `ExecutionContext.userId`, so it is never
the subject the engine authorizes as: not RLS `current_user`, not the ownership
stamp, not permission resolution. A context carrying only `attributedUserId`
authorizes exactly like an empty context (ANONYMOUS), and a context carrying it
beside `isSystem: true` authorizes exactly like `isSystem` alone. Re-authorizing
identity writes as the human would re-adjudicate a decision better-auth already
made — the second adjudication track ADR-0095 D3 closed. The constraint is
pinned by tests at three layers: the engine seam
(`packages/objectql/src/engine.test.ts`), the better-auth adapter
(`packages/plugins/plugin-auth/src/auth-actor-attribution.test.ts`), and the
live HTTP route (a plain member still cannot promote themselves).

**For authors and plugin developers**

`attributedUserId` is authorable on `ExecutionContext` and readable as
`ctx.provenance?.attributedUserId` in hooks. Use it to answer *who is
responsible*; keep using `ctx.session` / `ctx.user` to decide *what is
permitted*. The two are separate fields precisely so the distinction cannot be
blurred by accident.
24 changes: 12 additions & 12 deletions content/docs/references/data/data-engine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const result = BaseEngineOptionsSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **context** | `{ userId?: string; actor?: string; email?: string; tenantId?: string; … }` | optional | |
| **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | |


---
Expand All @@ -52,7 +52,7 @@ Options for DataEngine.aggregate operations

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **context** | `{ userId?: string; actor?: string; email?: string; tenantId?: string; … }` | optional | |
| **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | |
| **filter** | `Record<string, any> \| any` | optional | Data Engine query filter conditions |
| **groupBy** | `string[]` | optional | |
| **aggregations** | `{ field: string; method: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; alias?: string }[]` | optional | |
Expand Down Expand Up @@ -94,7 +94,7 @@ Options for DataEngine.count operations

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **context** | `{ userId?: string; actor?: string; email?: string; tenantId?: string; … }` | optional | |
| **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | |
| **filter** | `Record<string, any> \| any` | optional | Data Engine query filter conditions |


Expand All @@ -121,7 +121,7 @@ Options for DataEngine.delete operations

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **context** | `{ userId?: string; actor?: string; email?: string; tenantId?: string; … }` | optional | |
| **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | |
| **filter** | `Record<string, any> \| any` | optional | Data Engine query filter conditions |
| **multi** | `boolean` | optional | |

Expand Down Expand Up @@ -212,7 +212,7 @@ Options for DataEngine.insert operations

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **context** | `{ userId?: string; actor?: string; email?: string; tenantId?: string; … }` | optional | |
| **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | |
| **returning** | `boolean` | optional | |


Expand Down Expand Up @@ -240,7 +240,7 @@ Query options for IDataEngine.find() operations

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **context** | `{ userId?: string; actor?: string; email?: string; tenantId?: string; … }` | optional | |
| **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | |
| **filter** | `Record<string, any> \| any` | optional | Data Engine query filter conditions |
| **select** | `string[]` | optional | |
| **sort** | `Record<string, Enum<'asc' \| 'desc'>> \| Record<string, '1' \| '-1'> \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order definition |
Expand Down Expand Up @@ -428,7 +428,7 @@ Options for DataEngine.update operations

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **context** | `{ userId?: string; actor?: string; email?: string; tenantId?: string; … }` | optional | |
| **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | |
| **filter** | `Record<string, any> \| any` | optional | Data Engine query filter conditions |
| **upsert** | `boolean` | optional | |
| **multi** | `boolean` | optional | |
Expand Down Expand Up @@ -492,7 +492,7 @@ QueryAST-aligned options for DataEngine.aggregate operations

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **context** | `{ userId?: string; actor?: string; email?: string; tenantId?: string; … }` | optional | |
| **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | |
| **where** | `Record<string, any> \| any` | optional | |
| **groupBy** | `string[]` | optional | |
| **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'array_agg' \| 'string_agg'>; field?: string; alias: string; distinct?: boolean; … }[]` | optional | |
Expand All @@ -510,7 +510,7 @@ QueryAST-aligned options for DataEngine.count operations

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **context** | `{ userId?: string; actor?: string; email?: string; tenantId?: string; … }` | optional | |
| **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | |
| **where** | `Record<string, any> \| any` | optional | |


Expand All @@ -524,7 +524,7 @@ QueryAST-aligned options for DataEngine.delete operations

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **context** | `{ userId?: string; actor?: string; email?: string; tenantId?: string; … }` | optional | |
| **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | |
| **where** | `Record<string, any> \| any` | optional | |
| **multi** | `boolean` | optional | |

Expand All @@ -539,7 +539,7 @@ QueryAST-aligned query options for IDataEngine.find() operations

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **context** | `{ userId?: string; actor?: string; email?: string; tenantId?: string; … }` | optional | |
| **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | |
| **where** | `Record<string, any> \| any` | optional | |
| **fields** | `string[]` | optional | |
| **orderBy** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
Expand All @@ -563,7 +563,7 @@ QueryAST-aligned options for DataEngine.update operations

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **context** | `{ userId?: string; actor?: string; email?: string; tenantId?: string; … }` | optional | |
| **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | |
| **where** | `Record<string, any> \| any` | optional | |
| **upsert** | `boolean` | optional | |
| **multi** | `boolean` | optional | |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/hook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const result = HookContextSchema.parse(data);
| **result** | `any` | optional | Operation result (After hooks only) |
| **previous** | `Record<string, any>` | optional | Record state before operation |
| **session** | `{ userId?: string; actor?: string; organizationId?: string; roles?: string[]; … }` | optional | Current session context |
| **provenance** | `{ flowRunId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |
| **provenance** | `{ flowRunId?: string; attributedUserId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |
| **transaction** | `any` | optional | Database transaction handle |
| **ql** | `any` | ✅ | ObjectQL Engine Reference |
| **api** | `any` | optional | Cross-object data access (ScopedContext) |
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/kernel/execution-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const result = ExecutionContextSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **userId** | `string` | optional | |
| **actor** | `string` | optional | |
| **attributedUserId** | `string` | optional | |
| **email** | `string` | optional | |
| **tenantId** | `string` | optional | |
| **timezone** | `string` | optional | |
Expand Down
76 changes: 76 additions & 0 deletions packages/objectql/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,82 @@ describe('ObjectQL Engine', () => {
});
});

/**
* #4586 — the ATTRIBUTED human rides provenance, never the session.
*
* better-auth owns every write to the identity tables and runs them
* `isSystem: true` ON PURPOSE: the route already authorized the action
* under better-auth's own ACL. Threading the real human through so
* `sys_member` history stops saying "system" must therefore change exactly
* one thing — who the write is CREDITED to — and nothing about who it is
* AUTHORIZED as. Re-authorizing as the human would open a second
* adjudication track at the boundary ADR-0095 D3 closed.
*
* These are the pins for that constraint at the engine seam, where the
* context is split into the envelopes hooks and middleware actually read.
*/
describe('attributed actor is attribution, never authorization (#4586)', () => {
beforeEach(async () => {
engine.registerDriver(mockDriver, true);
await engine.init();
vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', fields: {} } as any);
});

const capture = () => {
const seen: { session?: any; provenance?: any; user?: any } = {};
engine.registerHook('beforeInsert', async (ctx: any) => {
seen.session = ctx.session;
seen.provenance = ctx.provenance;
seen.user = ctx.user;
}, { object: 'task' });
return seen;
};

it('a better-auth write surfaces the human on provenance and stays a SYSTEM session', async () => {
const seen = capture();

await engine.insert('task', { title: 'grade change' }, {
context: { isSystem: true, attributedUserId: 'usr_admin' } as any,
});

expect(seen.provenance).toEqual({ attributedUserId: 'usr_admin' });
// The authorization half is untouched: still system, still no caller.
expect(seen.session).toMatchObject({ isSystem: true });
expect(seen.session.userId).toBeUndefined();
// And the attributed human must NOT leak into any channel that a
// hook or middleware reads as "the acting user".
expect(seen.session).not.toHaveProperty('attributedUserId');
expect(seen.user).toBeUndefined();
});

it('attribution ALONE authorizes exactly like no context at all (ADR-0118 D2)', async () => {
// "Absence is never system": a context that names only who to credit
// establishes no principal, so it must not become one. Anything else
// would make forgetting `isSystem` an accidental elevation.
const seen = capture();

await engine.insert('task', { title: 'no authority' }, {
context: { attributedUserId: 'usr_admin' } as any,
});

expect(seen.provenance).toEqual({ attributedUserId: 'usr_admin' });
expect(seen.session).toBeUndefined();
expect(seen.user).toBeUndefined();
});

it('a real caller keeps their own session; the two envelopes never merge', async () => {
const seen = capture();

await engine.insert('task', { title: 'both' }, {
context: { userId: 'u1', attributedUserId: 'usr_admin', flowRunId: 'run_9' } as any,
});

expect(seen.session).toMatchObject({ userId: 'u1' });
expect(seen.user).toMatchObject({ id: 'u1' });
expect(seen.provenance).toEqual({ flowRunId: 'run_9', attributedUserId: 'usr_admin' });
});
});

describe('execution context via the trailing options arg (read methods)', () => {
// Regression: reads took context inside the query while writes took it in
// a trailing options arg — so `find(obj, q, { context })` silently dropped
Expand Down
15 changes: 13 additions & 2 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1181,17 +1181,28 @@ export class ObjectQL implements IObjectQLEngine {
}

/**
* Build the HookContext.provenance envelope — WHAT produced this write.
* Build the HookContext.provenance envelope — WHERE this write came from.
*
* Deliberately separate from {@link buildSession}: provenance is server-
* stamped, evaluated by no security middleware, and can exist with no
* identity beside it. A schedule-triggered flow run resolves no principal
* yet still owns its writes, and that is the case the approvals record lock
* needs to recognize (#3456 / #3712).
*
* `attributedUserId` rides the SAME envelope for the same reason (#4586):
* a better-auth-originated write authorizes as the system, so the human who
* triggered it must reach the audit writer WITHOUT appearing in `session` —
* where every caller-gating hook would read them as the caller. Attribution
* here, authorization in `session`/`isSystem`, never the two mixed.
*/
private buildProvenance(execCtx?: ExecutionContextInput): HookContext['provenance'] {
const flowRunId = (execCtx as any)?.flowRunId;
return flowRunId ? { flowRunId: String(flowRunId) } : undefined;
const attributedUserId = (execCtx as any)?.attributedUserId;
if (!flowRunId && !attributedUserId) return undefined;
return {
...(flowRunId ? { flowRunId: String(flowRunId) } : {}),
...(attributedUserId ? { attributedUserId: String(attributedUserId) } : {}),
};
}

/**
Expand Down
57 changes: 57 additions & 0 deletions packages/plugins/plugin-audit/src/audit-writers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,63 @@ describe('audit writers — actor attribution (ADR-0014 D2, cloud#340)', () => {
expect(audit?.row.actor).toBeNull();
expect(audit?.row.user_id).toBeNull();
});

/**
* [#4586] The `sys_member` case the issue is about: better-auth authorizes
* identity writes as the SYSTEM on purpose, so the session names no caller
* and every grade change used to record as "system". The human arrives on
* PROVENANCE instead — attribution, never authorization.
*/
it('credits the attributed human when the write authorized as the system', async () => {
const { engine, fire, created } = makeEngine(SINGLE_TENANT);
installAuditWriters(engine as any, 'test.audit');
await fire('afterUpdate', {
object: 'sys_member',
input: { id: 'mem-1' },
__previous: { id: 'mem-1', role: 'member' },
result: { id: 'mem-1', role: 'admin' },
// Exactly the envelope `withSystemContext` produces for an
// `organization/update-member-role` call.
session: { isSystem: true },
provenance: { attributedUserId: 'user-admin' },
});
const audit = created.find((c) => c.object === 'sys_audit_log');
expect(audit?.row.action).toBe('update');
// WHO changed the grade — a real sys_user id, so the lookup still joins
// (ADR-0118 D1: an id or null, never a sentinel like 'system').
expect(audit?.row.user_id).toBe('user-admin');
expect(audit?.row.actor).toBe('user-admin');
});

it('a genuinely machine-originated write still records as the system (null)', async () => {
// Boot sync / migration / the kernel:ready backfill: no scope, no actor.
// Absence must stay absence — never upgraded into some ambient user.
const { engine, fire, created } = makeEngine(SINGLE_TENANT);
installAuditWriters(engine as any, 'test.audit');
await fire('afterInsert', {
object: 'sys_member',
input: { id: 'mem-2' },
result: { id: 'mem-2', role: 'member' },
session: { isSystem: true },
});
const audit = created.find((c) => c.object === 'sys_audit_log');
expect(audit?.row.user_id).toBeNull();
expect(audit?.row.actor).toBeNull();
});

it('a real caller outranks attribution — the session subject wins', async () => {
const { engine, fire, created } = makeEngine(SINGLE_TENANT);
installAuditWriters(engine as any, 'test.audit');
await fire('afterInsert', {
object: 'crm_lead',
input: { id: 'lead-3' },
result: { id: 'lead-3', name: 'Gamma' },
session: { userId: 'user-7' },
provenance: { attributedUserId: 'user-admin' },
});
const audit = created.find((c) => c.object === 'sys_audit_log');
expect(audit?.row.user_id).toBe('user-7');
});
});

describe('audit writers — declarative trackHistory activity (ADR-0052 §5b)', () => {
Expand Down
Loading
Loading