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
18 changes: 18 additions & 0 deletions .changeset/30924-ip-allow-list-refusal-audit-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/plugin-auth': patch
'@objectstack/plugin-audit': patch
---

feat(plugin-auth, plugin-audit): a sign-in the IP allow-list refuses leaves one `login_failed` row in the audit log, bounded per client address

Clause-②: yes (widening)
Metadata-protocol: none

A sign-in refused by the ADR-0069 D5 IP allow-list used to leave nothing in `sys_audit_log`: the deployment's list answered `403 IP_NOT_ALLOWED` with only a once-per-process operator line, and an organization's own list refused the session silently. Both now write a `login_failed` row with `metadata.reason: "IP_NOT_ALLOWED"`, `metadata.rule: "ip_allow_list"`, `metadata.endpoint` and `metadata.attempts`, and the client address and user agent in `ip_address` / `user_agent`:

- **The deployment's list** refuses before any account is resolved. Its row names the account when the request's `email` resolves to one (the password sign-in's own account lookup), and is written with no account otherwise. Only `/sign-in/*` endpoints are recorded; a refused session read or sign-out is not a sign-in. The row is written off the response path, so the refusal's status, body and timing do not depend on whether the address names an account.
- **An organization's own list** refuses when the session is created. Its row names the account and the organization (`tenant_id`). A refused switch of the active organization is not a sign-in and writes no row.

The rows are bounded so a scanner cannot flood the table: per client address, in a 10-minute window from its first refusal, only the 1st, 10th and 100th refusal write a row (`attempts` 1, 10, 100); across all addresses, at most 1000 rows per 10-minute window, with one operator `warn` line in a window once the cap drops a row. The bound is held in each server process, so N replicas write up to N times as many rows. No row carries the submitted identifier or the password.

`@objectstack/plugin-audit`'s `AuthSessionAuditEvent.userId` and `@objectstack/plugin-auth`'s `AuthEventAuditSurface` event `userId` are now optional: a `login_failed` with no `userId` is written with `user_id` and `record_id` null (a row about no record, read under the ledger's own grant, platform administrators only when it names no organization). Any other event without a `userId` still writes nothing. `AuthManager.resolveRefusedSignInAccountId(email)` is new: the account a refused sign-in names, used only for that row. A sink registered on the `audit` service slot now receives `login_failed` events with no `userId`, so a custom sink must accept one. The exported `signInFailureEventFor` now answers `null` for `reason: 'IP_NOT_ALLOWED'`, because the door that refused the sign-in writes that row itself.
46 changes: 34 additions & 12 deletions content/docs/kernel/runtime-services/audit-service.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The slot's entire surface is one method:
```ts
services.audit.recordAuthEvent(event: {
action: 'login' | 'logout' | 'login_failed' | 'account_locked';
userId: string;
userId?: string;
sessionId?: string;
organizationId?: string;
ipAddress?: string;
Expand All @@ -46,11 +46,17 @@ services.audit.recordAuthEvent(event: {
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
- `login_failed` / `account_locked` are **account events**: a refused sign-in — a
password sign-in that named an existing account, or any `/sign-in/*` request the IP
allow-list refused (ADR-0069 D5) — 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. On an account event it is the account the sign-in named.
- **`userId`** must be a real `sys_user` id when given — it lands on the strict `user_id`
lookup. On an account event it is the account the sign-in named. Every event names one
except a `login_failed` refused before any account could be resolved: the deployment's
IP allow-list refuses ahead of better-auth, and the identifier the request carried may
name no account. That row is written with `user_id` and `record_id` null — a row about
no record, read under the ledger's own grant — and still carries the client address and
the rule. Any other event without a `userId` writes nothing.
- **`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
Expand All @@ -67,9 +73,23 @@ services.audit.recordAuthEvent(event: {
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`). 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.
endpoint, the refusal's machine code (`reason`, e.g. `INVALID_EMAIL_OR_PASSWORD`,
`ACCOUNT_LOCKED` or `IP_NOT_ALLOWED`) and, for `account_locked`, the lock's `locked_until`
and `threshold`. It never carries the attempted credential.
- A sign-in an authentication **policy** holds or refuses also names the policy, in
`rule`. A sign-in held by enforced MFA (ADR-0069 D3) is not refused — the session is
minted — so its own `login` row carries `reason: 'MFA_REQUIRED'` and
`rule: 'mfa_required'`. A sign-in the IP allow-list refuses (ADR-0069 D5), by the
deployment's list or by the organization's own list when the session is created, is a
`login_failed` with `reason: 'IP_NOT_ALLOWED'`, `rule: 'ip_allow_list'` and
`attempts`, below; the organization's refusal also names the organization. A refused
switch of the active organization is not a sign-in and writes no row.
- IP allow-list rows are **bounded** so a scanner cannot flood the ledger: per client
address, in a 10-minute window from its first refusal, only the 1st, 10th and 100th
refusal write a row, carrying that count as `attempts`; across all addresses, at most
1000 rows per 10-minute window, with one operator `warn` line in a window once the cap
drops a row. The bound is held in each server process, so N replicas write up to N
times as many rows.

## Notes

Expand All @@ -80,8 +100,9 @@ services.audit.recordAuthEvent(event: {
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
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.
and from the sign-in outcomes, so neither package depends on the other. A password
sign-in whose identifier resolves to no account produces no call; an IP allow-list
refusal produces one with no `userId`.

## Typical Errors

Expand All @@ -98,8 +119,9 @@ caller's side:
`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 call **silently no-ops** when no data engine resolves, or when `userId` is absent on
any event but `login_failed`. A caller that passes such an event records nothing and is
told nothing.
- The most common cause of a failing insert is a datasource split rather than a broken
write: `sys_audit_log` carries an ADR-0057 `audit` lifecycle class, which routes it to a
dedicated `telemetry` datasource whenever one is registered — `os dev` provisions one by
Expand Down
51 changes: 51 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 @@ -620,3 +620,54 @@ describe('auth-event audit — reported once per CAUSE, not once per process (#1
expect(warns[1].message).toMatch(/ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED/);
});
});

/**
* [#30924] A sign-in the IP allow-list refuses is refused before any account
* is resolved, and the identifier it carried may name no account at all. Its
* `login_failed` row is still worth writing — the address and the rule are
* the record an enforcement report reads — so a `login_failed` is the one
* event that may name no subject. Every other event still needs one.
*/
describe('[#30924] a login_failed with no resolvable account is still a row', () => {
it('a subject-less login_failed lands about no record, credits nobody, and keeps the address and the rule', async () => {
const { engine } = await boot();
const sink = createAuthEventAuditSink({ getEngine: () => engine as any });

await sink.recordAuthEvent({
action: 'login_failed',
ipAddress: '198.51.100.9',
userAgent: 'scanner/1.0',
context: { endpoint: '/sign-in/email', reason: 'IP_NOT_ALLOWED', rule: 'ip_allow_list', attempts: 1 },
});

const rows = await ledgerRows(engine, { action: 'login_failed' });
expect(rows).toHaveLength(1);
const [row] = rows;
expect(row.user_id ?? null).toBeNull();
expect(row.object_name).toBe('sys_user');
expect(row.record_id ?? null).toBeNull();
expect(row.actor ?? null).toBeNull();
expect(row.tenant_id ?? null).toBeNull();
expect(row.ip_address).toBe('198.51.100.9');
expect(row.user_agent).toBe('scanner/1.0');
expect(JSON.parse(String(row.metadata))).toEqual({
endpoint: '/sign-in/email',
reason: 'IP_NOT_ALLOWED',
rule: 'ip_allow_list',
attempts: 1,
});
});

it('every other event still needs its subject: no row', async () => {
const { engine } = await boot();
const sink = createAuthEventAuditSink({ getEngine: () => engine as any });

await sink.recordAuthEvent({ action: 'login', sessionId: 'ses_1' });
await sink.recordAuthEvent({ action: 'logout', sessionId: 'ses_1' });
await sink.recordAuthEvent({ action: 'account_locked', context: { threshold: 3 } });
await sink.recordAuthEvent({ action: 'login_failed', userId: '' });

expect(await ledgerRows(engine)).toHaveLength(1);
expect((await ledgerRows(engine, { action: 'login_failed' }))[0].user_id ?? null).toBeNull();
});
});
35 changes: 29 additions & 6 deletions packages/plugins/plugin-audit/src/auth-event-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@
* credited with it, and recording the account holder there would claim
* they made the attempt.
*
* A `login_failed` whose sign-in was refused before any account resolved
* (the IP allow-list, #30924) names no account: `user_id` and `record_id` are
* null, so it is a row about no record, read under the ledger's own grant, and
* still carries the client address and the refusing rule.
*
* ⛔ An account event never carries the attempted credential, in any form.
* Its `context` is the endpoint, the refusal's machine code and, for a
* lockout, the lock's expiry and threshold — nothing the caller typed.
Expand Down Expand Up @@ -130,8 +135,14 @@ export interface AuthSessionAuditEvent {
* The event's subject — a real `sys_user` id (the `user_id` lookup): the
* session's owner for a session event, the account the sign-in named for an
* account event.
*
* [#30924] Optional for one event only: a `login_failed` whose sign-in was
* refused before any account could be resolved (the IP allow-list refuses
* ahead of better-auth, and the identifier may name no account) is still
* written, about no record and attributed to nobody. Every other event
* without a subject writes no row.
*/
userId: string;
userId?: string;
/**
* The `sys_session` row a SESSION event is about, when known. An account
* event names its account instead (module note), so this is not read for
Expand Down Expand Up @@ -206,6 +217,15 @@ export interface AuthEventAuditSinkOptions {
logger?: AuthEventAuditLogger;
}

/**
* [#30924] Whether an event of `action` is written with no subject: only a
* refused sign-in (`login_failed`), whose refusal may come before any account
* is resolved. A session needs its owner and a lockout its account.
*/
function mayNameNoSubject(action: unknown): boolean {
return action === 'login_failed';
}

/** The object a session event's row names as its target. */
const SESSION_OBJECT = 'sys_session';

Expand Down Expand Up @@ -354,7 +374,9 @@ export function createAuthEventAuditSink(opts: AuthEventAuditSinkOptions): AuthE
return {
async recordAuthEvent(event: AuthSessionAuditEvent): Promise<void> {
const engine = getEngine();
if (!engine || !event?.userId) return;
if (!engine || !event) return;
const subject = typeof event.userId === 'string' && event.userId.length > 0 ? event.userId : undefined;
if (!subject && !mayNameNoSubject(event.action)) return;
if (probe === undefined || probedEngine !== engine) {
probe = createFieldPresenceProbe(engine);
probedEngine = engine;
Expand All @@ -367,13 +389,14 @@ export function createAuthEventAuditSink(opts: AuthEventAuditSinkOptions): AuthE
action: event.action,
// The whole point of the card: the event names its actor. `user_id` is
// a strict `sys_user` lookup, and a session always has a real subject.
// On an account event it is the account the sign-in named.
user_id: event.userId,
// On an account event it is the account the sign-in named, and null
// for a refused sign-in that named none (#30924).
user_id: subject ?? null,
// The record this event is about, so the row is navigable from the
// ledger to it (and back) rather than being a bare verb: the session
// for a session event, the account for an account event.
object_name: targetObjectFor(event.action),
record_id: accountEvent ? event.userId : (event.sessionId ?? null),
record_id: accountEvent ? (subject ?? null) : (event.sessionId ?? null),
// No before/after state: an auth event is not a field diff. Recording
// `{}` here would be a claim about a record that never changed.
old_value: null,
Expand All @@ -393,7 +416,7 @@ export function createAuthEventAuditSink(opts: AuthEventAuditSinkOptions): AuthE
// is, and recording the impersonated user as the sole principal there
// would be a WRONG record rather than a vague one. An account event
// authenticated nobody, so it credits no principal (module note).
row.actor = event.actor ?? (accountEvent ? null : event.userId);
row.actor = event.actor ?? (accountEvent ? null : subject);
}

try {
Expand Down
30 changes: 26 additions & 4 deletions packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ import {
recordUnlinkTombstone,
} from './implicit-account-linking.js';
import { withSystemReadContext } from './objectql-adapter.js';
import { emitAuthSessionAuditEvent, loginEventFor, logoutEventFor } from './auth-session-audit.js';
import { emitAuthSessionAuditEvent, loginEventFor, logoutEventFor, signInClientFingerprint } from './auth-session-audit.js';
import { ipAllowListRefusalEventFor, ipRefusalAuditBoundFor } from './ip-allow-list-refusal-audit.js';
import { sessionPolicyHoldFor, withSessionPolicyHold } from './session-policy-hold.js';
import { holdPrincipalSessionExpiry } from './principal-session-mint.js';
import { canonicalClientAddress } from './client-address-canonical.js';
Expand Down Expand Up @@ -314,11 +315,32 @@ export function composeDatabaseHooks(
// established address is in the request state, exactly the address the
// deployment's list judged at that door. A programmatic `auth.api.*` call
// carries no request and is not judged, as the door never judged it.
const refuseUnadmittedScoping = async (userId: unknown, organizationId: unknown, ctx: any) => {
//
// [#30924] A refused CREATION is a refused sign-in, and leaves its
// `login_failed` row (`ip-allow-list-refusal-audit.ts`), counted by the same
// per-address bound as the deployment gate's rows. A refused SWITCH is not a
// sign-in and writes none: only the creation hands `creating` in.
const refuseUnadmittedScoping = async (userId: unknown, organizationId: unknown, ctx: any, creating?: any) => {
if (!ctx?.request || typeof userId !== 'string' || !userId) return;
if (typeof organizationId !== 'string' || !organizationId) return;
const verdict = await this.judgeSessionScoping(userId, organizationId, await readRequestClientAddress());
const address = await readRequestClientAddress();
const verdict = await this.judgeSessionScoping(userId, organizationId, address);
if (verdict === 'admitted') return;
const sink = creating ? this.config.getAuditSink?.() : undefined;
if (sink) {
const fingerprint = signInClientFingerprint(ctx, address);
const attempts = ipRefusalAuditBoundFor(sink).admit(fingerprint.ipAddress, (line) => this.config.logger?.warn?.(line));
if (attempts !== undefined) {
await emitAuthSessionAuditEvent(sink, ipAllowListRefusalEventFor({
path: typeof ctx?.path === 'string' ? ctx.path : '',
attempts,
userId,
organizationId,
impersonatedBy: typeof creating?.impersonatedBy === 'string' ? creating.impersonatedBy : undefined,
fingerprint,
}));
}
}
const { APIError } = await import('better-auth/api');
throw new APIError('FORBIDDEN', { code: SESSION_SCOPING_REFUSAL.code, message: SESSION_SCOPING_REFUSAL.message });
};
Expand All @@ -329,7 +351,7 @@ export function composeDatabaseHooks(
const final = result && typeof result === 'object' && 'data' in result
? { ...captured, ...(result as any).data }
: captured;
await refuseUnadmittedScoping(final?.userId, final?.activeOrganizationId, ctx);
await refuseUnadmittedScoping(final?.userId, final?.activeOrganizationId, ctx, final);
if (result && typeof result === 'object' && 'data' in result) return { data: final };
return captured === session ? result : { data: captured };
};
Expand Down
16 changes: 16 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6153,6 +6153,22 @@ export class AuthManager {
return resolveSignInAccountId.call(host, email);
}

/**
* [#30924] The id of the account a sign-in refused by the ADR-0069 D5 IP
* allow-list names, for its `login_failed` row only
* (`ip-allow-list-refusal-audit.ts`), or `undefined` — the password
* sign-in's own account question ({@link findSignInAccount}). The gate
* refuses before better-auth resolves any account, so this is the only way
* that row can name one. It decides nothing: the refusal is already
* answered. Throws what the engine throws; the caller writes the row with no
* subject then.
*/
public async resolveRefusedSignInAccountId(email: string): Promise<string | undefined> {
const engine = this.getDataEngine();
if (!engine) return undefined;
return (await this.findSignInAccount(engine, email, []))?.id;
}

/**
* ADR-0069 D2 — the ONE answer to "which account does this password sign-in
* name", read by the lock gate ({@link assertAccountNotLocked}), the counter
Expand Down
Loading
Loading