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
48 changes: 48 additions & 0 deletions .changeset/audit-login-logout-writers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@objectstack/plugin-audit": minor
"@objectstack/plugin-auth": minor
---

feat(audit): sign-in and sign-out are recorded in `sys_audit_log`, with the actor and the tenant (#8144)

`sys_audit_log.action` declares `login` and `logout`, the shipped `auth_events`
list view filters on them, and two System Overview dashboard widgets chart them —
but **nothing in the platform ever wrote either row**. The audit writers subscribe
to the ObjectQL CRUD lifecycle, so `create`/`update`/`delete`/`restore` were the
only actions that could ever materialize. On a fresh boot, signing in and then
querying `GET /api/v1/data/sys_audit_log?$filter={"action":"login"}` returned
**total 0**, and the `auth_events` view was empty by construction.

The whole trace a sign-in left behind was one **unattributed** `update sys_user`
row (`user_id` null) diffing `last_login_at` — a compliance ledger recording that
somebody, unknown, had signed in.

Both halves are fixed:

- **`login` on every session creation.** The writer is wired to better-auth's
`session.create` database hook rather than to the `/sign-in/email` endpoint, so
it covers every way a session is minted — email sign-in, sign-up auto-sign-in,
SSO, OAuth callback, magic link, email OTP, passkey. The row carries the actor
(`user_id`), the tenant (`tenant_id` + the RLS `organization_id`), the session
it is about, and the client fingerprint better-auth recorded (IP, user agent).
An impersonation session keeps the subject on `user_id` and names the
impersonating admin on `actor`, so it cannot be misread as a self-service login.
- **`logout` on sign-out.** Scoped to `POST /sign-out` deliberately: a session row
is also deleted by admin revokes, `/revoke-session`, bans, user erasure and
better-auth's own collection of expired rows, and recording any of those as
`logout` would name an action the user never took. Those revocations already
carry their own cause on the ADR-0069 D4 session tombstone.
- **The `last_login_at` write is now attributed.** It goes out through the
platform's existing attribution channel (`ExecutionContext.attributedUserId`),
so the row names the person who signed in. It is attribution only — the write
still authorizes as the system, and nothing about who may touch `sys_user`
changes. The row is kept rather than suppressed: a login from a new address is
exactly what a compliance ledger is read for.

The audit plugin now registers the `audit` service, the ledger's write ingress
for events that are not CRUD. `@objectstack/plugin-auth` resolves it lazily and
takes no dependency on the audit package — a deployment without the audit plugin
installed writes no auth rows, exactly as before.

No API, schema or enum changes: `login`/`logout` were already declared members of
the `action` enum, and `sys_audit_log` remains `get`/`list`-only over HTTP.
33 changes: 33 additions & 0 deletions packages/plugins/plugin-audit/src/audit-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { SysAuditLog, SysActivity, SysComment } from './objects/index.js';
// @objectstack/service-storage for the same ownership reason (ADR-0052 §3: a
// file↔record link belongs with storage, not the compliance ledger).
import { installAuditWriters, type AuditI18nSurface, type MessagingEmitSurface } from './audit-writers.js';
import { createAuthEventAuditSink } from './auth-event-audit.js';
import { installCommentAccessHooks, installCommentReadVisibility } from './comment-access-hooks.js';

/**
Expand All @@ -30,6 +31,13 @@ export class AuditPlugin implements Plugin {
type = 'standard';
version = '1.0.0';
dependencies = ['com.objectstack.engine.objectql'];
/**
* [#8144] The `audit` slot — the ledger's WRITE ingress for events that are
* not CRUD (`login`/`logout` today). Declared here because `init()` registers
* it unconditionally, which is what ADR-0116 / `plugin-order.ts` reads this
* field to mean.
*/
providesServices = ['audit'];

async init(ctx: PluginContext): Promise<void> {
// Register audit system objects via the manifest service.
Expand All @@ -56,6 +64,31 @@ export class AuditPlugin implements Plugin {
],
});

// [#8144] The non-CRUD write ingress. Registered in init() — plugin-auth
// resolves it lazily and calls it from better-auth's session lifecycle
// hooks, i.e. at request time, so the engine is resolved per call rather
// than captured: the service exists from init() while `objectql` only
// resolves at kernel:ready, and every caller arrives long after both.
ctx.registerService(
'audit',
createAuthEventAuditSink({
getEngine: () => {
try {
return ctx.getService<IDataEngine>('objectql');
} catch {
// Same fallback alias `start()` uses below — some kernels register
// the engine as `data`.
try {
return ctx.getService<IDataEngine>('data');
} catch {
return undefined;
}
}
},
logger: ctx.logger,
}),
);

// ADR-0029 D8 — contribute this plugin's object translations to the i18n
// service on kernel:ready (the i18n plugin may register after this one).
if (typeof (ctx as any).hook === 'function') {
Expand Down
82 changes: 52 additions & 30 deletions packages/plugins/plugin-audit/src/audit-writers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,54 @@ const NOISE_FIELDS = new Set<string>([
'created_by',
]);

/**
* "Does this object's REGISTERED schema declare this field?", memoized per
* object.
*
* Extracted to module scope (#8144) so the CRUD writer below and the auth-event
* writer (`auth-event-audit.ts`) ask the question ONE way. Both stamp the same
* two conditional columns on the same table, and a second hand-rolled probe
* would answer differently on the day one of them is fixed.
*
* Why the probe exists at all: the SchemaRegistry auto-injects
* `organization_id` only in multi-tenant mode (`applySystemFields({
* multiTenant })`), so on single-tenant stacks the `sys_audit_log` /
* `sys_activity` tables have no such column. Unconditionally stamping it there
* made every audit INSERT fail with "table sys_audit_log has no column named
* organization_id" — and the error was swallowed, so audit logging was silently
* non-functional. Resolve the field set lazily from the engine schema and cache
* it; object schemas are static after registration.
*
* Best-effort in both directions: an engine with no `getSchema` (an in-memory
* test double) reports every field absent, which skips the stamp rather than
* failing the write.
*/
export function createFieldPresenceProbe(
engine: unknown,
): (objectName: string, field: string) => boolean {
const fieldSetCache = new Map<string, Set<string> | null>();
return (objectName: string, field: string): boolean => {
let set = fieldSetCache.get(objectName);
if (set === undefined) {
set = null;
try {
const schema: any =
typeof (engine as any)?.getSchema === 'function' ? (engine as any).getSchema(objectName) : null;
const fields = schema?.fields;
if (fields && typeof fields === 'object' && !Array.isArray(fields)) {
set = new Set<string>(Object.keys(fields));
} else if (Array.isArray(fields)) {
set = new Set<string>(fields.map((f: any) => f?.name).filter(Boolean));
}
} catch {
/* ignore — best-effort; absence just means we skip the stamp */
}
fieldSetCache.set(objectName, set);
}
return set != null && set.has(field);
};
}

/** Action name produced from a HookContext.event string. */
function actionFor(event: string): 'create' | 'update' | 'delete' | null {
if (event === 'afterInsert') return 'create';
Expand Down Expand Up @@ -777,36 +825,10 @@ export function installAuditWriters(
engine.unregisterHooksByPackage(packageId);
}

// Whether a given object's *registered* schema declares a field. The
// SchemaRegistry auto-injects `organization_id` only in multi-tenant mode
// (`applySystemFields({ multiTenant })`), so on single-tenant stacks the
// `sys_audit_log` / `sys_activity` tables have no `organization_id` column.
// Unconditionally stamping it there made every audit INSERT fail with
// "table sys_audit_log has no column named organization_id" (the error was
// swallowed, so audit logging was silently non-functional). Resolve the
// field set lazily from the engine schema and cache it — object schemas are
// static after registration.
const fieldSetCache = new Map<string, Set<string> | null>();
const objectHasField = (objectName: string, field: string): boolean => {
let set = fieldSetCache.get(objectName);
if (set === undefined) {
set = null;
try {
const schema: any =
typeof (engine as any).getSchema === 'function' ? (engine as any).getSchema(objectName) : null;
const fields = schema?.fields;
if (fields && typeof fields === 'object' && !Array.isArray(fields)) {
set = new Set<string>(Object.keys(fields));
} else if (Array.isArray(fields)) {
set = new Set<string>(fields.map((f: any) => f?.name).filter(Boolean));
}
} catch {
/* ignore — best-effort; absence just means we skip the stamp */
}
fieldSetCache.set(objectName, set);
}
return set != null && set.has(field);
};
// Whether a given object's *registered* schema declares a field — see
// `createFieldPresenceProbe` for why the conditional stamp exists. Shared
// with the auth-event writer so both stamp on the same answer.
const objectHasField = createFieldPresenceProbe(engine);

// Cached full field-definition map per object (for ADR-0052 §5b trackHistory
// rendering — needs labels/options, not just field names).
Expand Down
Loading
Loading