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
37 changes: 37 additions & 0 deletions .changeset/identity-write-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@objectstack/plugin-auth": minor
---

feat(auth): identity write guard — `managedBy: 'better-auth'` is now enforced at the engine (ADR-0092 D2/D3/D6)

Every object whose schema declares `managedBy: 'better-auth'` (`sys_user`,
`sys_member`, `sys_session`, `sys_api_key`, …) is now protected by engine
`beforeInsert` / `beforeUpdate` / `beforeDelete` hooks registered by
plugin-auth: **user-context** writes through the generic data path are
rejected fail-closed with `403 PERMISSION_DENIED`, closing the hole where a
wildcard admin permission set could raw-write any identity column (including
`email` and credential stamps) via the data API. Internal writes are
unaffected — the better-auth adapter, `isSystem` plugin/system contexts, and
the identity import keep working unchanged.

The only opening is a per-object update whitelist
(`registerManagedUpdateWhitelist(object, fields)`): non-whitelisted fields are
stripped from the payload, and a payload that strips to nothing throws. The
first registration ships here: `sys_user → { name, image }` (pure profile
fields), backed by the new shared `SYS_USER_PROFILE_EDIT_FIELDS` /
`SYS_USER_IMPORT_UPDATE_FIELDS` constants — the import upsert's field
discipline is now derived from the same module (subset-by-construction, no
drift).

After a guarded profile edit, an `afterUpdate` companion hook re-writes the
user's cached `{session, user}` snapshots in better-auth's secondary storage
(same TTL, mirror of better-auth's own `refreshUserSessions`) so session
reads stay coherent; it rewrites rather than deletes, and no-ops when no
secondary storage is wired.

Migration note: server-side scripts that previously updated identity tables
with a **user** execution context must either run with a system context
(`{ isSystem: true }`) if they are genuinely internal, or move to the
dedicated auth endpoints (invite / create-user / set-user-password / ban /
better-auth APIs). Flows and automations that wrote non-profile `sys_user`
columns under a user identity are now filtered the same way.
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,20 @@
/**
* ADR-0057 — org-scoped identity objects must be creatable in SINGLE-TENANT.
*
* Single-tenant deployments have no `sys_organization` row and no auto-stamp
* (OrgScopingPlugin is multi-tenant-only), so a `required` `organization_id`
* made sys_business_unit / sys_team uncreatable (VALIDATION_FAILED). The field
* is now optional; this proves the create path works with no org.
* Single-tenant deployments have no auto-stamp (OrgScopingPlugin is
* multi-tenant-only), so a `required` `organization_id` made
* sys_business_unit / sys_team uncreatable (VALIDATION_FAILED). The field is
* now optional; this proves the create path works single-tenant.
*
* ADR-0092 update: `sys_team` is `managedBy: 'better-auth'`, so its generic
* data-API insert is now REJECTED fail-closed for user contexts by the
* identity write guard. The canonical user surfaces are better-auth's team
* endpoints (see sys_team.actions), which the schema itself gates to
* multi-org mode — single-org hides every team-mutation affordance. The
* ADR-0057 property (optional `organization_id`) therefore matters for the
* writers that remain legitimate single-tenant: SYSTEM-context writes.
* sys_business_unit is plugin-security's table (not better-auth-managed) and
* keeps the generic create path.
*/

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

it('creates a sys_team with no organization_id', async () => {
const res = await stack.apiAs(token, 'POST', '/data/sys_team', { name: 'Tiger Team' });
expect(res.status).toBe(201);
it('sys_team: generic insert is guarded for users; org_id stays optional for system writes', async () => {
// ADR-0092 D2 — sys_team is managedBy:'better-auth', so a USER-context
// insert through the generic data API is rejected fail-closed (the
// canonical surfaces are better-auth's team endpoints, which the schema
// gates to multi-org mode — in single-org the affordances are hidden).
const direct = await stack.apiAs(token, 'POST', '/data/sys_team', { name: 'Tiger Team' });
expect(direct.status).toBe(403);
const denied: any = await direct.json();
expect(denied.code).toBe('PERMISSION_DENIED');

// ADR-0057's actual regression target — `organization_id` is OPTIONAL at
// the schema level, so a single-tenant (no org row) write does not die
// with VALIDATION_FAILED. System-context writes (org-structure sync,
// seeding) are the writers that remain legitimate post-ADR-0092.
const ql = await stack.kernel.getServiceAsync<any>('objectql');
const row = await ql.insert(
'sys_team',
{ name: 'Tiger Team' },
{ context: { isSystem: true } },
);
expect(row?.id).toBeTruthy();
expect(row?.organization_id ?? null).toBeNull();
});
});
10 changes: 8 additions & 2 deletions packages/plugins/plugin-auth/src/admin-import-users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,17 @@ import type {
import { prepareImportRequest, runImport } from '@objectstack/rest';
import { generatePlaceholderEmail, isPlaceholderEmail } from './placeholder-email.js';
import { generateTemporaryPassword, normalizePhoneNumber, isLikelyEmail, type AdminActor, type EndpointResult } from './admin-user-endpoints.js';
import { SYS_USER_IMPORT_UPDATE_FIELDS } from './sys-user-writable-fields.js';

export const IMPORT_USERS_MAX_ROWS = 500;

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

export interface IdentityImportEngine {
find(objectName: string, query?: any): Promise<any[]>;
Expand Down
36 changes: 36 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import {
type AuthManagerOptions,
} from './auth-manager.js';
import { ensureDefaultOrganization } from './ensure-default-organization.js';
import {
registerIdentityWriteGuard,
registerManagedUpdateWhitelist,
type SecondaryStorageLike,
} from './identity-write-guard.js';
import { SYS_USER_PROFILE_EDIT_FIELDS } from './sys-user-writable-fields.js';
import { runSetInitialPassword } from './set-initial-password.js';
import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain } from './register-sso-provider.js';
import { runResendVerificationEmail } from './send-verification-email.js';
Expand Down Expand Up @@ -125,6 +131,10 @@ export class AuthPlugin implements Plugin {
private options: AuthPluginOptions;
private authManager: AuthManager | null = null;
private configuredSocialProviders: SocialProviderConfig | undefined;
// ADR-0092 D6 — the EFFECTIVE better-auth secondaryStorage (host-supplied or
// the kernel-cache adapter wired in init). The identity write guard's
// session-snapshot refresh reads through this; undefined = refresh no-ops.
private effectiveSecondaryStorage: AuthManagerOptions['secondaryStorage'];

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

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

// Register auth service
ctx.registerService('auth', this.authManager);
Expand Down Expand Up @@ -535,6 +548,29 @@ export class AuthPlugin implements Plugin {
}
});

// ADR-0092 D2/D6 — generic identity write guard. Every object whose
// schema declares `managedBy: 'better-auth'` gets fail-closed protection
// against USER-CONTEXT writes through the generic data path; the only
// opening is the per-object update whitelist (sys_user → profile fields).
// Internal writes (better-auth adapter, isSystem plugin/system contexts)
// bypass — see identity-write-guard.ts for the full contract.
ctx.hook('kernel:ready', async () => {
try {
const engine: any = ctx.getService<any>('objectql');
if (!engine || typeof engine.registerHook !== 'function') return;
registerManagedUpdateWhitelist(SystemObjectName.USER, SYS_USER_PROFILE_EDIT_FIELDS);
registerIdentityWriteGuard(engine, {
packageId: 'com.objectstack.plugin-auth.identity-write-guard',
logger: ctx.logger,
getSecondaryStorage: () =>
this.effectiveSecondaryStorage as SecondaryStorageLike | undefined,
});
} catch {
// Engine not available (mock mode) — permission-set defaults remain
// the only gate, exactly the pre-guard status quo.
}
});

// Register auth middleware on ObjectQL engine (if available)
try {
const ql = ctx.getService<any>('objectql');
Expand Down
Loading