Skip to content

Commit a47681b

Browse files
committed
feat(auth): identity write guard — enforce managedBy:'better-auth' at the engine (#2816)
ADR-0092 D2/D3/D6 implementation: - identity-write-guard.ts: before{Insert,Update,Delete} hooks (priority 10) registered by plugin-auth at kernel:ready. User-context writes to any object whose schema declares managedBy:'better-auth' are rejected fail-closed (403 PERMISSION_DENIED with a pointer to the dedicated surfaces); better-auth adapter (no context) and isSystem writes bypass. Per-object update whitelist registry is the only opening; engine-stamped lifecycle columns (updated_at/updated_by) pass through but never satisfy the whitelist alone. - sys-user-writable-fields.ts: shared field tiers — profile edit whitelist {name, image} and the import superset {+phone_number, role} (subset-by-construction, ADR-0092 D3); admin-import-users.ts now derives UPDATE_ALLOWED_FIELDS from it. - D6: afterUpdate hook mirrors better-auth's refreshUserSessions — cached {session, user} snapshots in secondary storage are re-written (same TTL, never deleted) with the changed profile fields after a guarded edit. Verified against a live --fresh CRM backend: profile PATCH persists with fresh updated_at; email-only PATCH 403s; role/must_change_password are stripped and not persisted; insert/delete 403; create-user / set-user-password / self-service update-user / import upsert all work unchanged. Call-site sweep: every existing identity-table write already runs under SYSTEM_CTX or the adapter path. Closes #2816 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGAN5VvvBi4YRGwpNT6e21
1 parent a823a95 commit a47681b

7 files changed

Lines changed: 694 additions & 2 deletions

File tree

.changeset/identity-write-guard.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
---
4+
5+
feat(auth): identity write guard — `managedBy: 'better-auth'` is now enforced at the engine (ADR-0092 D2/D3/D6)
6+
7+
Every object whose schema declares `managedBy: 'better-auth'` (`sys_user`,
8+
`sys_member`, `sys_session`, `sys_api_key`, …) is now protected by engine
9+
`beforeInsert` / `beforeUpdate` / `beforeDelete` hooks registered by
10+
plugin-auth: **user-context** writes through the generic data path are
11+
rejected fail-closed with `403 PERMISSION_DENIED`, closing the hole where a
12+
wildcard admin permission set could raw-write any identity column (including
13+
`email` and credential stamps) via the data API. Internal writes are
14+
unaffected — the better-auth adapter, `isSystem` plugin/system contexts, and
15+
the identity import keep working unchanged.
16+
17+
The only opening is a per-object update whitelist
18+
(`registerManagedUpdateWhitelist(object, fields)`): non-whitelisted fields are
19+
stripped from the payload, and a payload that strips to nothing throws. The
20+
first registration ships here: `sys_user → { name, image }` (pure profile
21+
fields), backed by the new shared `SYS_USER_PROFILE_EDIT_FIELDS` /
22+
`SYS_USER_IMPORT_UPDATE_FIELDS` constants — the import upsert's field
23+
discipline is now derived from the same module (subset-by-construction, no
24+
drift).
25+
26+
After a guarded profile edit, an `afterUpdate` companion hook re-writes the
27+
user's cached `{session, user}` snapshots in better-auth's secondary storage
28+
(same TTL, mirror of better-auth's own `refreshUserSessions`) so session
29+
reads stay coherent; it rewrites rather than deletes, and no-ops when no
30+
secondary storage is wired.
31+
32+
Migration note: server-side scripts that previously updated identity tables
33+
with a **user** execution context must either run with a system context
34+
(`{ isSystem: true }`) if they are genuinely internal, or move to the
35+
dedicated auth endpoints (invite / create-user / set-user-password / ban /
36+
better-auth APIs). Flows and automations that wrote non-profile `sys_user`
37+
columns under a user identity are now filtered the same way.

packages/plugins/plugin-auth/src/admin-import-users.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,17 @@ import type {
5757
import { prepareImportRequest, runImport } from '@objectstack/rest';
5858
import { generatePlaceholderEmail, isPlaceholderEmail } from './placeholder-email.js';
5959
import { generateTemporaryPassword, normalizePhoneNumber, isLikelyEmail, type AdminActor, type EndpointResult } from './admin-user-endpoints.js';
60+
import { SYS_USER_IMPORT_UPDATE_FIELDS } from './sys-user-writable-fields.js';
6061

6162
export const IMPORT_USERS_MAX_ROWS = 500;
6263

63-
/** Profile fields an upsert row may modify on an EXISTING user. */
64-
const UPDATE_ALLOWED_FIELDS = new Set(['name', 'phone_number', 'role']);
64+
/**
65+
* Profile fields an upsert row may modify on an EXISTING user — shared with
66+
* the identity write guard's Tier-1 whitelist via sys-user-writable-fields.ts
67+
* (ADR-0092 D3: one file, one derivation; the import surface is a strict
68+
* superset that additionally allows `phone_number` / `role`).
69+
*/
70+
const UPDATE_ALLOWED_FIELDS = SYS_USER_IMPORT_UPDATE_FIELDS;
6571

6672
export interface IdentityImportEngine {
6773
find(objectName: string, query?: any): Promise<any[]>;

packages/plugins/plugin-auth/src/auth-plugin.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ import {
1818
type AuthManagerOptions,
1919
} from './auth-manager.js';
2020
import { ensureDefaultOrganization } from './ensure-default-organization.js';
21+
import {
22+
registerIdentityWriteGuard,
23+
registerManagedUpdateWhitelist,
24+
type SecondaryStorageLike,
25+
} from './identity-write-guard.js';
26+
import { SYS_USER_PROFILE_EDIT_FIELDS } from './sys-user-writable-fields.js';
2127
import { runSetInitialPassword } from './set-initial-password.js';
2228
import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain } from './register-sso-provider.js';
2329
import { runResendVerificationEmail } from './send-verification-email.js';
@@ -125,6 +131,10 @@ export class AuthPlugin implements Plugin {
125131
private options: AuthPluginOptions;
126132
private authManager: AuthManager | null = null;
127133
private configuredSocialProviders: SocialProviderConfig | undefined;
134+
// ADR-0092 D6 — the EFFECTIVE better-auth secondaryStorage (host-supplied or
135+
// the kernel-cache adapter wired in init). The identity write guard's
136+
// session-snapshot refresh reads through this; undefined = refresh no-ops.
137+
private effectiveSecondaryStorage: AuthManagerOptions['secondaryStorage'];
128138

129139
constructor(options: AuthPluginOptions = {}) {
130140
this.options = {
@@ -221,6 +231,9 @@ export class AuthPlugin implements Plugin {
221231

222232
// Initialize auth manager with data engine
223233
this.authManager = new AuthManager(authConfig);
234+
// ADR-0092 D6 — remember the storage better-auth will actually use so the
235+
// identity write guard can keep cached session snapshots coherent.
236+
this.effectiveSecondaryStorage = authConfig.secondaryStorage;
224237

225238
// Register auth service
226239
ctx.registerService('auth', this.authManager);
@@ -535,6 +548,29 @@ export class AuthPlugin implements Plugin {
535548
}
536549
});
537550

551+
// ADR-0092 D2/D6 — generic identity write guard. Every object whose
552+
// schema declares `managedBy: 'better-auth'` gets fail-closed protection
553+
// against USER-CONTEXT writes through the generic data path; the only
554+
// opening is the per-object update whitelist (sys_user → profile fields).
555+
// Internal writes (better-auth adapter, isSystem plugin/system contexts)
556+
// bypass — see identity-write-guard.ts for the full contract.
557+
ctx.hook('kernel:ready', async () => {
558+
try {
559+
const engine: any = ctx.getService<any>('objectql');
560+
if (!engine || typeof engine.registerHook !== 'function') return;
561+
registerManagedUpdateWhitelist(SystemObjectName.USER, SYS_USER_PROFILE_EDIT_FIELDS);
562+
registerIdentityWriteGuard(engine, {
563+
packageId: 'com.objectstack.plugin-auth.identity-write-guard',
564+
logger: ctx.logger,
565+
getSecondaryStorage: () =>
566+
this.effectiveSecondaryStorage as SecondaryStorageLike | undefined,
567+
});
568+
} catch {
569+
// Engine not available (mock mode) — permission-set defaults remain
570+
// the only gate, exactly the pre-guard status quo.
571+
}
572+
});
573+
538574
// Register auth middleware on ObjectQL engine (if available)
539575
try {
540576
const ql = ctx.getService<any>('objectql');

0 commit comments

Comments
 (0)