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
21 changes: 21 additions & 0 deletions .changeset/adr-0069-last-login-audit.md
Original file line number Diff line number Diff line change
@@ -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).
13 changes: 12 additions & 1 deletion docs/adr/0069-enterprise-authentication-hardening.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} }; });
Expand Down
36 changes: 36 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> {
const engine = this.getDataEngine();
if (!engine || !userId) return;
try {
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
const patch: Record<string, unknown> = { 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
Expand Down