diff --git a/.changeset/adr-0069-last-login-audit.md b/.changeset/adr-0069-last-login-audit.md new file mode 100644 index 0000000000..97b4732e13 --- /dev/null +++ b/.changeset/adr-0069-last-login-audit.md @@ -0,0 +1,21 @@ +--- +"@objectstack/platform-objects": minor +"@objectstack/plugin-auth": minor +--- + +feat(auth): last-login audit fields — sys_user.last_login_at / last_login_ip (ADR-0069 D7) + +Completes the ADR-0069 D7 identity-field set: `sys_user.last_login_at` and +`sys_user.last_login_ip` are stamped on every successful `/sign-in/email` by +`AuthManager.stampLastLogin` (a best-effort after-hook, independent of the +lockout-accounting path so it runs even when lockout is disabled). The IP is +taken from the trusted forwarded headers (`x-forwarded-for` → +`cf-connecting-ip` → `x-real-ip`), the same precedence as the D5 IP allow-list +middleware, and capped to the 45-char column width. Both fields are +system-managed, read-only, and land in the Admin group of `sys_user`. + +The rest of ADR-0069 P1 (password complexity/history/expiry, HIBP, account +lockout, enforced MFA) was already implemented; this fills the one missing D7 +field pair. ADR-0069 status updated Proposed → Accepted (P1/P2 implemented) +with an implementation-status matrix reflecting what is landed vs the remaining +P2 gaps (per-org IP ranges, shared-store rate limiting). diff --git a/docs/adr/0069-enterprise-authentication-hardening.md b/docs/adr/0069-enterprise-authentication-hardening.md index 67f100676d..c783901f3f 100644 --- a/docs/adr/0069-enterprise-authentication-hardening.md +++ b/docs/adr/0069-enterprise-authentication-hardening.md @@ -1,6 +1,6 @@ # ADR-0069: Enterprise authentication hardening — password policy, enforced MFA, SSO, session controls, network gating, and anti-brute-force, all enforcement-wired -**Status**: Proposed (2026-06-24) +**Status**: Accepted — **P1 + P2 implemented** (2026-07-04); P3 partially landed (see Addendum). Original proposal 2026-06-24. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (**the governing constraint** — a security property that isn't enforced at runtime is forbidden; no toggle may be a "false surface"), [ADR-0007](./0007-settings-manifest-and-kv-store.md) (settings manifest + cascade KV store), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (`sys_role` is platform-native, decoupled from better-auth; org scoping), [ADR-0066](./0066-unified-authorization-model.md) (capability/assignment split), [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (`current_user` contract, built-in roles) **Consumers**: `@objectstack/plugin-auth` (better-auth wiring, `bindAuthSettings`/`applyConfigPatch`, auth route middleware), `@objectstack/service-settings` (`auth.manifest.ts`), `@objectstack/platform-objects` (identity objects `sys_user`/`sys_session`/`sys_account`), `@objectstack/rest` (auth request middleware seam), `../objectui` (settings UI rendering) @@ -112,6 +112,8 @@ Legend: **[native]** = a better-auth config/plugin does the enforcing; **[custom Plus admin-facing surface: a `sys_user` list-view filter for locked accounts + an **Unlock** action; a "force password reset" action. +**Implementation status (2026-07-04):** all `sys_user` (incl. `last_login_at`/`last_login_ip`), `sys_session`, `sys_account`, and `sys_organization.require_mfa` fields are landed, plus the `unlock_user` admin action. `last_login_at`/`last_login_ip` are stamped on every successful `/sign-in/email` by `AuthManager.stampLastLogin` (after-hook, independent of lockout config). **Not landed:** a per-record `sys_organization.allowed_ip_ranges` (and optional `sys_user.allowed_ip_ranges` override) — the IP allow-list currently exists only as the **global** `auth.allowed_ip_ranges` setting (D5); per-org/per-user IP scoping is tracked as remaining P2 work. + --- ## Enforcement seam summary (where the code hooks) @@ -132,6 +134,15 @@ Each row in D1-D6 names exactly one of these seams. No setting is introduced wit - **P2 (defense in depth)**: D4 (idle/absolute/concurrent sessions), D5 (IP allowlist), D6 OIDC trust-list UI. - **P3 (federation breadth)**: SAML assessment, broader social providers, per-org overrides UI polish. +### Implementation status (2026-07-04) + +| Phase | Status | Notes | +|---|---|---| +| **P1** (D1/D2/D3 + D7 fields) | ✅ **implemented** | Password complexity/history/expiry (`assertPasswordComplexity`/`assertPasswordNotReused`/`stampPasswordChangedAt`), HIBP (`haveIBeenPwned` plugin), account lockout (`assertAccountNotLocked`/`recordSignInOutcome` + `unlock_user` action), enforced MFA + grace (`computeAuthGate` → `MFA_REQUIRED`, per-org `require_mfa`), rate-limit tuning (`customRules`). All settings in `auth.manifest.ts`, bound via `bindAuthSettings`. Login-audit fields `last_login_at`/`last_login_ip` stamped on sign-in (`stampLastLogin`). | +| **P2** (D4/D5) | 🟡 **mostly implemented** | Session idle/absolute/concurrent (`enforceSessionControls`/`enforceConcurrentCap`) and the **global** IP allow-list (`isClientIpAllowed`, `auth.allowed_ip_ranges`) are landed. **Remaining:** per-org `sys_organization.allowed_ip_ranges` (+ optional `sys_user.allowed_ip_ranges` override); a **shared/Redis rate-limit store** for multi-node (current store is in-memory). | +| **P2/P3** (D6) | 🟡 partial | Generic OIDC RP wired (`genericOAuth`/`sso`); admin OIDC **trust-list settings UI** still env/`sys_sso_provider`-only. | +| **P3** (SAML, broader social) | 🟡 partial | `@better-auth/sso` present (SAML now better-auth-native — see Addendum); broader settings-driven social providers pending. | + ## Out of scope / deferred - **SCIM / directory provisioning & bulk import** — separate ADR (provisioning, not authentication); tracked in #2246. diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 10307b6c35..78278de26e 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -469,6 +469,26 @@ export const SysUser = ObjectSchema.create({ description: 'When enforced MFA first applied to this user (grace-period clock). Backs mfa_required; system-managed.', }), + // ADR-0069 D7 — login audit. Stamped on every successful `/sign-in/email` + // by the auth manager's after-hook (independent of lockout config). Backs + // the admin "last seen" surface + anomaly review; system-managed, read-only. + last_login_at: Field.datetime({ + label: 'Last Login At', + required: false, + readonly: true, + group: 'Admin', + description: 'Timestamp of the last successful sign-in. Stamped by the auth manager; system-managed.', + }), + + last_login_ip: Field.text({ + label: 'Last Login IP', + required: false, + readonly: true, + maxLength: 45, // IPv6 max textual length + group: 'Admin', + description: 'Client IP of the last successful sign-in (from the trusted proxy forwarded header). System-managed.', + }), + ai_access: Field.boolean({ label: 'AI Access', defaultValue: false, diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index b12ebb3c3b..54c2735ebd 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1747,6 +1747,41 @@ describe('AuthManager', () => { expect(engine.update).not.toHaveBeenCalled(); }); + // ── ADR-0069 D7 — last-login audit stamping ────────────────────────── + it('stampLastLogin writes last_login_at (a Date) + last_login_ip on success', async () => { + const engine = makeEngine({ id: 'u1' }); + const m = mgr(engine); + await (m as any).stampLastLogin('u1', '203.0.113.7'); + const patch = engine.update.mock.calls[0][1]; + expect(patch.id).toBe('u1'); + expect(patch.last_login_at instanceof Date).toBe(true); // Date, never epoch-ms (ADR-0074) + expect(patch.last_login_ip).toBe('203.0.113.7'); + }); + + it('stampLastLogin runs even when lockout is disabled (independent of threshold)', async () => { + const engine = makeEngine({ id: 'u1' }); + const m = mgr(engine, { lockoutThreshold: 0 }); + await (m as any).stampLastLogin('u1', undefined); + expect(engine.update).toHaveBeenCalledTimes(1); + const patch = engine.update.mock.calls[0][1]; + expect(patch.last_login_at instanceof Date).toBe(true); + // IP omitted (undetermined) rather than written as null/empty + expect('last_login_ip' in patch).toBe(false); + }); + + it('stampLastLogin caps an oversized IP header to the column width (45)', async () => { + const engine = makeEngine({ id: 'u1' }); + const m = mgr(engine); + await (m as any).stampLastLogin('u1', 'x'.repeat(200)); + expect(engine.update.mock.calls[0][1].last_login_ip).toHaveLength(45); + }); + + it('stampLastLogin never throws when the engine write fails', async () => { + const engine = { ...makeEngine({ id: 'u1' }), update: vi.fn(async () => { throw new Error('db down'); }) }; + const m = mgr(engine); + await expect((m as any).stampLastLogin('u1', '203.0.113.7')).resolves.toBeUndefined(); + }); + it('passes a configured rateLimit through to betterAuth', async () => { let captured: any; (betterAuth as any).mockImplementation((cfg: any) => { captured = cfg; return { handler: vi.fn(), api: {} }; }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index b34d76ddf7..254161c006 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -900,6 +900,21 @@ export class AuthManager { if (succeeded) { const uid = ctx?.context?.returned?.user?.id; if (typeof uid === 'string') await this.enforceConcurrentCap(uid); + // ADR-0069 D7 — login audit: stamp last_login_at/ip on success. + // Independent of lockout config (recordSignInOutcome no-ops when + // lockout is off). IP from the trusted forwarded headers, same + // precedence as the D5 allow-list middleware. + if (typeof uid === 'string') { + const hdr = (k: string): string => + ((ctx?.headers?.get?.(k) ?? ctx?.request?.headers?.get?.(k)) as string) || ''; + const fwd = hdr('x-forwarded-for'); + const ip = + (fwd && fwd.split(',')[0].trim()) || + hdr('cf-connecting-ip') || + hdr('x-real-ip') || + undefined; + await this.stampLastLogin(uid, ip); + } } } return; @@ -2720,6 +2735,27 @@ export class AuthManager { } } + /** + * ADR-0069 D7 — stamp `last_login_at` (+ `last_login_ip` when known) on a + * successful sign-in. Best-effort and always fire-and-forget safe: a login + * audit write must never turn a valid login into an error, and it runs + * unconditionally (unlike lockout accounting, which is gated on a threshold). + */ + private async stampLastLogin(userId: string, ip: string | undefined): Promise { + const engine = this.getDataEngine(); + if (!engine || !userId) return; + try { + const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const patch: Record = { id: userId, last_login_at: new Date() }; + // Cap to the column width (IPv6 textual max 45) — a malformed/oversized + // forwarded header must not blow up the write. + if (ip) patch.last_login_ip = ip.slice(0, 45); + await engine.update('sys_user', patch, { context: SYSTEM_CTX } as any); + } catch { + // Login audit is best-effort — never break the auth response. + } + } + /** * ADR-0069 D2 — clear a user's lockout state (admin "Unlock" action). * Resets `failed_login_count` and `locked_until`. Returns false when no data