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
14 changes: 14 additions & 0 deletions .changeset/audit-auth-account-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/plugin-audit': minor
'@objectstack/plugin-auth': patch
---

fix(plugin-audit,plugin-auth): a refused sign-in and an account lockout each write a `sys_audit_log` row, as a successful sign-in already does

Clause-②: no

- **Two new ledger actions, each with its writer.** `sys_audit_log.action` gains `login_failed` and `account_locked`. A refused `/sign-in/email` that names an existing account writes one `login_failed` row. The refused sign-in that stamps the account lockout (`lockoutThreshold`, ADR-0069 D2) also writes one `account_locked` row. A sign-in refused while the account is locked writes `login_failed` with the reason `ACCOUNT_LOCKED`. A successful sign-in still writes `login`, unchanged.
- **What each row records.** `user_id` is the account the sign-in named, and the row names `sys_user` and that account's id as its target. The outcome is the action, plus the refusal's machine code in `metadata` (`INVALID_EMAIL_OR_PASSWORD`, `ACCOUNT_LOCKED`, …), and the lock's `locked_until` and `threshold` on an `account_locked` row. `created_at` is the time. `ip_address` and `user_agent` are resolved by the rule better-auth applies to a session row, which is where a `login` row's values come from. `actor` is null, because a refused attempt authenticated nobody. A row never carries the attempted credential, and an identifier that resolves to no account writes no row.
- **Where they show.** Setup → Audit Log's *Auth* preset (the `auth_events` list view) now lists `login`, `logout`, `login_failed` and `account_locked`. Each row is about the account it names, so the ledger's record read gate judges it by that `sys_user` record, as for any row about a user. It carries no `tenant_id`: a refused attempt has no session to take an organization from, so it is a deployment-level row under the ledger's organization scope.
- **Recorded with or without a lockout.** A refused sign-in writes its row whether or not `lockoutThreshold` is set.
- **`@objectstack/plugin-audit` types.** `AuthSessionAuditEvent.action` widens to `AuthEventAuditAction` (`'login' | 'logout' | 'login_failed' | 'account_locked'`). `AuthEventAuditAction` and `AuthAccountAuditAction` are new exported types, and `AuthSessionAuditAction` is unchanged.
47 changes: 32 additions & 15 deletions content/docs/kernel/runtime-services/audit-service.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: services.audit — the audit log service API
navTitle: services.audit
description: Write ingress for audit events that are not CRUD — today, auth session events.
description: Write ingress for audit events that are not CRUD — today, auth session events and sign-in outcome events on an account.
---

- **Stability:** `experimental`
Expand All @@ -10,7 +10,8 @@ description: Write ingress for audit events that are not CRUD — today, auth se

`services.audit` is the kernel service slot that `@objectstack/plugin-audit` registers
during `init()`. It is the **write ingress for audit events that are not CRUD** — today,
auth session events — and every call appends one row to `sys_audit_log`.
auth session events and sign-in outcome events on an account — and every call appends one
row to `sys_audit_log`.

Record-level `create` / `update` / `delete` rows do **not** travel through this slot. The
same plugin writes those from ObjectQL lifecycle hooks, with no service call involved, so
Expand All @@ -27,7 +28,7 @@ The slot's entire surface is one method:

```ts
services.audit.recordAuthEvent(event: {
action: 'login' | 'logout';
action: 'login' | 'logout' | 'login_failed' | 'account_locked';
userId: string;
sessionId?: string;
organizationId?: string;
Expand All @@ -43,18 +44,32 @@ services.audit.recordAuthEvent(event: {
validates nothing in either direction — a misspelled action would be accepted silently.
This union is the only structural protection an author gets, which is why the caller
hands over an **event** (what happened) rather than a row (what to store).
- `login` / `logout` are **session events**: a sign-in that minted a session, and a
sign-out.
- `login_failed` / `account_locked` are **account events**: a refused password sign-in
that named an existing account, and the refused sign-in that stamped the account
lockout (`lockoutThreshold`, ADR-0069 D2). They exist without any session.
- **`userId`** is required and must be a real `sys_user` id — it lands on the strict
`user_id` lookup.
- **`sessionId`** lands on `record_id`, with `object_name` fixed to `sys_session`, so the
ledger row is navigable to the session it describes.
`user_id` lookup. On an account event it is the account the sign-in named.
- **`sessionId`** lands on `record_id`, with `object_name` set to `sys_session`, so the
ledger row is navigable to the session it describes. An account event names its account
instead — `object_name` `sys_user` and `record_id` the account's id — and does not read
`sessionId`.
- **`organizationId`** is the session's active organization. It stamps the row's tenant
columns, and an unstamped row is one that non-administrator members can never see
through the security layer's predicate.
- **`ipAddress`** / **`userAgent`** are the client fingerprint by better-auth's own rule:
the session row's values for a session event, and the same resolution applied to the
sign-in request for an account event.
- **`actor`** is the principal that *caused* the event when that is not the subject — an
administrator's user id on an impersonation session. Omitted, the subject is recorded as
having acted for themselves.
having acted for themselves on a session event; an account event records no principal,
because a refused attempt authenticated nobody.
- **`context`** is free-form and is serialized into `metadata`, e.g. the endpoint that
produced the event (`/sign-in/email`, `/sign-out`).
produced the event (`/sign-in/email`, `/sign-out`). On an account event it is the
endpoint, the refusal's machine code (`reason`, e.g. `INVALID_EMAIL_OR_PASSWORD` or
`ACCOUNT_LOCKED`) and, for `account_locked`, the lock's `locked_until` and `threshold`.
It never carries the attempted credential.

## Notes

Expand All @@ -64,8 +79,9 @@ services.audit.recordAuthEvent(event: {
`kernel:ready`. The sink therefore resolves its engine per call, so it is safe to hold a
reference from the moment the plugin initializes.
- `@objectstack/plugin-auth` is the only in-repo consumer. It resolves the slot lazily
through a locally-declared structural surface and calls it from the session lifecycle,
so neither package depends on the other.
through a locally-declared structural surface and calls it from the session lifecycle
and from the password sign-in outcome, so neither package depends on the other. A
sign-in identifier that resolves to no account produces no call.

## Typical Errors

Expand All @@ -76,11 +92,12 @@ caller's side:

- A failed ledger insert is reported at `error` level **once per cause** — a cause is the
error's `code` plus the object the row is about, so every uncoded failure collapses into
one bucket and the object is always `sys_session` on this seam — and the row is simply
lost. Nothing retries it. A repeat of an **already-reported** cause drops to `debug`; a
**new** cause gets its own `error` line, once. The sign-in itself succeeded and returned
200, so the shipped `auth_events` list view and the system-overview widgets keep showing
an empty, healthy-looking screen.
one bucket per object, and the object is `sys_session` for a session event and
`sys_user` for an account event — and the row is simply lost. Nothing retries it. A
repeat of an **already-reported** cause drops to `debug`; a **new** cause gets its own
`error` line, once. The sign-in was answered exactly as it would have been without the
ledger, so the shipped `auth_events` list view and the system-overview widgets keep
showing an empty, healthy-looking screen.
- The call **silently no-ops** when no data engine resolves, or when `userId` is absent.
A caller that passes an event without a subject records nothing and is told nothing.
- The most common cause of a failing insert is a datasource split rather than a broken
Expand Down
29 changes: 23 additions & 6 deletions packages/plugins/plugin-audit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,25 @@ these are the only values that are ever written:
| `delete` | `installAuditWriters` (this package) | `afterDelete` |
| `login` | `createAuthEventAuditSink` (this package), called by `@objectstack/plugin-auth` | session start |
| `logout` | `createAuthEventAuditSink` (this package), called by `@objectstack/plugin-auth` | session end |
| `login_failed` | `createAuthEventAuditSink` (this package), called by `@objectstack/plugin-auth` | a refused password sign-in (`/sign-in/email`) that names an existing account |
| `account_locked` | `createAuthEventAuditSink` (this package), called by `@objectstack/plugin-auth` | the refused sign-in that stamps the account lockout (`lockoutThreshold`, ADR-0069 D2) |
| `config_change` | `@objectstack/service-settings` | every successful settings write |
| `import` | `@objectstack/plugin-auth` admin user import | one run-level row, with `record_id: null` |
| `platform_admin_standing_change` | `@objectstack/plugin-security` | a change of who holds platform-administrator standing, plus a first-boot baseline |

Action values are snake_case. There is no namespaced `domain:action` spelling — the enum
above is the complete accepted set, and a value outside it is not a form this object takes.

**Session events and account events.** A `login` / `logout` row is about a session: it names
`sys_session` and the session id, and `actor` is the subject (or the administrator who
started an impersonation session). A `login_failed` / `account_locked` row is about an
account and exists without any session: `user_id` is the account the sign-in named, the row
names `sys_user` and that account's id, and `actor` is null, because a refused attempt
authenticated nobody. Its `metadata` carries the endpoint and the refusal's machine code
(`INVALID_EMAIL_OR_PASSWORD`, `ACCOUNT_LOCKED`, …), plus the lock's expiry and threshold on
an `account_locked` row. It never carries the attempted credential, and an identifier that
resolves to no account writes no row.

## `sys_audit_log` fields

These are the columns the object declares. Every field is `readonly: true`; rows are
Expand All @@ -123,7 +136,7 @@ written only by internal system hooks running under `sudo()`, never through UI f
|---|---|---|
| `id` | text | Audit log entry id |
| `created_at` | datetime | When the action occurred (`NOW()` default) |
| `action` | select | One of the eight values above |
| `action` | select | One of the eleven values above |
| `user_id` | lookup → `sys_user` | Null for non-user / service actions |
| `actor` | text | Principal label: a user id, `svc:<name>`, or null. Attributes service-token writes that `user_id` structurally cannot hold |
| `object_name` | text | Target object, e.g. `sys_user` |
Expand All @@ -144,7 +157,9 @@ without the secret itself reaching the ledger.
**`ip_address` / `user_agent` are populated on auth events only.** Neither the
record-level writer nor the record-view writer stamps them: a `create` / `update` /
`delete` / `read` row records who and what, not from where. Do not read a null client
fingerprint on such a row as "the request had none".
fingerprint on such a row as "the request had none". On every auth event they are
resolved by better-auth's own rule: a session event copies the session row's values, and
an account event applies the same resolution to the sign-in request.

**`old_value` / `new_value` are null on every `read` row**, deliberately and not as an
omission — see [Record-view auditing](#record-view-auditing--the-read-action).
Expand Down Expand Up @@ -336,7 +351,7 @@ the standard object API or `services.data`, and through the shipped list views:
|---|---|
| `recent` | Everything, newest first |
| `writes_only` | `create` / `update` / `delete` |
| `auth_events` | `login` / `logout` |
| `auth_events` | `login` / `logout` / `login_failed` / `account_locked` |
| `record_views` | `read` — who opened which record |
| `config_changes` | `config_change` / `import` |
| `all_events` | Everything, larger page size |
Expand All @@ -355,9 +370,10 @@ interface AuthEventAuditSink {
}
```

`AuthSessionAuditEvent.action` is the closed union `'login' | 'logout'`. `@objectstack/plugin-auth`
resolves this slot lazily and calls it from the session lifecycle. It is not a query API:
to read audit history, query the `sys_audit_log` object.
`AuthSessionAuditEvent.action` is the closed union `'login' | 'logout' | 'login_failed' |
'account_locked'` (`AuthEventAuditAction`). `@objectstack/plugin-auth` resolves this slot
lazily and calls it from the session lifecycle and from the password sign-in outcome. It is
not a query API: to read audit history, query the `sys_audit_log` object.

## Retention and archival

Expand Down Expand Up @@ -430,6 +446,7 @@ export type {
// Auth-event ingress (the `audit` service slot)
export { createAuthEventAuditSink };
export type {
AuthAccountAuditAction, AuthEventAuditAction,
AuthEventAuditLogger, AuthEventAuditSink, AuthEventAuditSinkOptions,
AuthSessionAuditAction, AuthSessionAuditEvent,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@
* - A record-write row's snapshot that is not a JSON object cannot be judged
* key by key, so it is dropped whole (fail closed).
* - A row of any other action is served as written. Measured per writer on
* the tree this landed on: `read`, `login`, `logout` and `import` rows
* write no snapshot; a `config_change` row's snapshot is a DIGEST of the
* the tree this landed on: `read`, `login`, `logout`, `login_failed`,
* `account_locked` and `import` rows write no snapshot; a `config_change` row's snapshot is a DIGEST of the
* setting, never its value (`service-settings`' `config-change-audit.ts`);
* a `platform_admin_standing_change` row's is the deployment's
* administrator roster (`plugin-security`). None is a parent record's field
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@
* - **Rows about a record** — they name `object_name` + `record_id`: the CRUD
* mirror's `create` / `update` / `delete` (`audit-writers.ts`), record-view
* `read` rows (`read-audit.ts`), plugin-auth's administrative `create` /
* `update` on a `sys_user`, and the auth-event sink's `login` / `logout`,
* which name the session (`auth-event-audit.ts`). Judged by the record gate;
* `update` on a `sys_user`, the auth-event sink's `login` / `logout`,
* which name the session, and its `login_failed` / `account_locked`, which
* name the account's `sys_user` row (`auth-event-audit.ts`). Judged by the
* record gate;
* the session is in the audit writer's `AUDIT_EXCLUDED_OBJECTS`, so the gate
* names it ({@link LEDGER_NAMED_PARENTS}) to keep judging it.
* A record that no longer exists is read by no caller, so its rows are
Expand Down
81 changes: 81 additions & 0 deletions packages/plugins/plugin-audit/src/auth-event-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,87 @@ describe('[#8144] createAuthEventAuditSink writes a login row that names its act
* The helpers are `audit-writers.ts`'s, imported rather than re-spelled: a
* second copy of this key is how the defect got here in the first place.
*/
describe('a refused sign-in and a lockout each land as a ledger row about the account', () => {
it('login_failed names the account as user and target, credits no principal, and keeps outcome and client', async () => {
const { engine } = await boot();
const sink = createAuthEventAuditSink({ getEngine: () => engine as any });

await sink.recordAuthEvent({
action: 'login_failed',
userId: 'usr_1',
ipAddress: '203.0.113.7',
userAgent: 'Mozilla/5.0 (probe)',
context: { endpoint: '/sign-in/email', reason: 'INVALID_EMAIL_OR_PASSWORD' },
});

const rows = await ledgerRows(engine, { action: 'login_failed' });
expect(rows).toHaveLength(1);
const [row] = rows;
// The account attempted: the subject lookup, and the record the row is about.
expect(row.user_id).toBe('usr_1');
expect(row.object_name).toBe('sys_user');
expect(row.record_id).toBe('usr_1');
// A refused attempt authenticated nobody, so no principal is credited with
// it — and in particular not the account holder.
expect(row.actor ?? null).toBeNull();
// The outcome and the client.
expect(JSON.parse(String(row.metadata))).toEqual({
endpoint: '/sign-in/email',
reason: 'INVALID_EMAIL_OR_PASSWORD',
});
expect(row.ip_address).toBe('203.0.113.7');
expect(row.user_agent).toBe('Mozilla/5.0 (probe)');
// No session, no organization context, no field diff.
expect(row.tenant_id ?? null).toBeNull();
expect(row.old_value ?? null).toBeNull();
expect(row.new_value ?? null).toBeNull();
});

it('account_locked is its own row about the account, carrying the lock', async () => {
const { engine } = await boot();
const sink = createAuthEventAuditSink({ getEngine: () => engine as any });

await sink.recordAuthEvent({
action: 'login_failed',
userId: 'usr_1',
context: { endpoint: '/sign-in/email', reason: 'INVALID_EMAIL_OR_PASSWORD' },
});
await sink.recordAuthEvent({
action: 'account_locked',
userId: 'usr_1',
ipAddress: '203.0.113.7',
context: { endpoint: '/sign-in/email', locked_until: '2026-10-20T12:15:00.000Z', threshold: 3 },
});

expect(await ledgerRows(engine, { action: 'login_failed' })).toHaveLength(1);
const locks = await ledgerRows(engine, { action: 'account_locked' });
expect(locks).toHaveLength(1);
expect(locks[0].user_id).toBe('usr_1');
expect(locks[0].object_name).toBe('sys_user');
expect(locks[0].record_id).toBe('usr_1');
expect(locks[0].actor ?? null).toBeNull();
expect(locks[0].ip_address).toBe('203.0.113.7');
expect(JSON.parse(String(locks[0].metadata))).toEqual({
endpoint: '/sign-in/email',
locked_until: '2026-10-20T12:15:00.000Z',
threshold: 3,
});
});

it('a session event still names its session and credits its subject', async () => {
// The account target must not leak onto the session class.
const { engine } = await boot();
const sink = createAuthEventAuditSink({ getEngine: () => engine as any });

await sink.recordAuthEvent({ action: 'login', userId: 'usr_1', sessionId: 'ses_1' });

const [row] = await ledgerRows(engine, { action: 'login' });
expect(row.object_name).toBe('sys_session');
expect(row.record_id).toBe('ses_1');
expect(row.actor).toBe('usr_1');
});
});

describe('auth-event audit — reported once per CAUSE, not once per process (#17452)', () => {
interface LogLine {
level: string;
Expand Down
Loading
Loading