Skip to content

Commit a348394

Browse files
authored
feat(auth): identity write guard — enforce managedBy:'better-auth' at the engine (#2828)
ADR-0092 D2/D3/D6 (closes #2816): engine before{Insert,Update,Delete} hooks reject user-context writes to every managedBy:'better-auth' table fail-closed (403 PERMISSION_DENIED); per-object update whitelist is the only opening (sys_user → {name, image}); import upsert derives its field list from the same shared module; afterUpdate hook keeps better-auth secondary-storage session snapshots coherent (rewrite, never delete). Adapter and isSystem writes bypass. Dogfood sys_team test realigned to the canonical better-auth surface with the guard 403 as coverage.
1 parent ac46e52 commit a348394

8 files changed

Lines changed: 730 additions & 9 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/dogfood/test/single-tenant-identity-create.dogfood.test.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,20 @@
33
/**
44
* ADR-0057 — org-scoped identity objects must be creatable in SINGLE-TENANT.
55
*
6-
* Single-tenant deployments have no `sys_organization` row and no auto-stamp
7-
* (OrgScopingPlugin is multi-tenant-only), so a `required` `organization_id`
8-
* made sys_business_unit / sys_team uncreatable (VALIDATION_FAILED). The field
9-
* is now optional; this proves the create path works with no org.
6+
* Single-tenant deployments have no auto-stamp (OrgScopingPlugin is
7+
* multi-tenant-only), so a `required` `organization_id` made
8+
* sys_business_unit / sys_team uncreatable (VALIDATION_FAILED). The field is
9+
* now optional; this proves the create path works single-tenant.
10+
*
11+
* ADR-0092 update: `sys_team` is `managedBy: 'better-auth'`, so its generic
12+
* data-API insert is now REJECTED fail-closed for user contexts by the
13+
* identity write guard. The canonical user surfaces are better-auth's team
14+
* endpoints (see sys_team.actions), which the schema itself gates to
15+
* multi-org mode — single-org hides every team-mutation affordance. The
16+
* ADR-0057 property (optional `organization_id`) therefore matters for the
17+
* writers that remain legitimate single-tenant: SYSTEM-context writes.
18+
* sys_business_unit is plugin-security's table (not better-auth-managed) and
19+
* keeps the generic create path.
1020
*/
1121

1222
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -31,8 +41,27 @@ describe('ADR-0057: org-scoped identity creatable single-tenant', () => {
3141
expect(body.record?.organization_id ?? null).toBeNull();
3242
});
3343

34-
it('creates a sys_team with no organization_id', async () => {
35-
const res = await stack.apiAs(token, 'POST', '/data/sys_team', { name: 'Tiger Team' });
36-
expect(res.status).toBe(201);
44+
it('sys_team: generic insert is guarded for users; org_id stays optional for system writes', async () => {
45+
// ADR-0092 D2 — sys_team is managedBy:'better-auth', so a USER-context
46+
// insert through the generic data API is rejected fail-closed (the
47+
// canonical surfaces are better-auth's team endpoints, which the schema
48+
// gates to multi-org mode — in single-org the affordances are hidden).
49+
const direct = await stack.apiAs(token, 'POST', '/data/sys_team', { name: 'Tiger Team' });
50+
expect(direct.status).toBe(403);
51+
const denied: any = await direct.json();
52+
expect(denied.code).toBe('PERMISSION_DENIED');
53+
54+
// ADR-0057's actual regression target — `organization_id` is OPTIONAL at
55+
// the schema level, so a single-tenant (no org row) write does not die
56+
// with VALIDATION_FAILED. System-context writes (org-structure sync,
57+
// seeding) are the writers that remain legitimate post-ADR-0092.
58+
const ql = await stack.kernel.getServiceAsync<any>('objectql');
59+
const row = await ql.insert(
60+
'sys_team',
61+
{ name: 'Tiger Team' },
62+
{ context: { isSystem: true } },
63+
);
64+
expect(row?.id).toBeTruthy();
65+
expect(row?.organization_id ?? null).toBeNull();
3766
});
3867
});

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);
@@ -571,6 +584,29 @@ export class AuthPlugin implements Plugin {
571584
}
572585
});
573586

587+
// ADR-0092 D2/D6 — generic identity write guard. Every object whose
588+
// schema declares `managedBy: 'better-auth'` gets fail-closed protection
589+
// against USER-CONTEXT writes through the generic data path; the only
590+
// opening is the per-object update whitelist (sys_user → profile fields).
591+
// Internal writes (better-auth adapter, isSystem plugin/system contexts)
592+
// bypass — see identity-write-guard.ts for the full contract.
593+
ctx.hook('kernel:ready', async () => {
594+
try {
595+
const engine: any = ctx.getService<any>('objectql');
596+
if (!engine || typeof engine.registerHook !== 'function') return;
597+
registerManagedUpdateWhitelist(SystemObjectName.USER, SYS_USER_PROFILE_EDIT_FIELDS);
598+
registerIdentityWriteGuard(engine, {
599+
packageId: 'com.objectstack.plugin-auth.identity-write-guard',
600+
logger: ctx.logger,
601+
getSecondaryStorage: () =>
602+
this.effectiveSecondaryStorage as SecondaryStorageLike | undefined,
603+
});
604+
} catch {
605+
// Engine not available (mock mode) — permission-set defaults remain
606+
// the only gate, exactly the pre-guard status quo.
607+
}
608+
});
609+
574610
// Register auth middleware on ObjectQL engine (if available)
575611
try {
576612
const ql = ctx.getService<any>('objectql');

0 commit comments

Comments
 (0)