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
17 changes: 17 additions & 0 deletions .changeset/organization-update-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@objectstack/platform-objects": minor
---

`sys_organization` admits generic `update` on the data door, column-gated by the ADR-0092 D2 identity write guard (#15873 — maintainer ruling 2026-09-07, decision batch #64, option (a), verbatim 「同意」).

The organization table carries four platform-owned columns better-auth never reads or writes — `require_mfa` (ADR-0069 D3), `parent_organization_id` and `sort_order` (ADR-0105 D6), `timezone` (#14238). plugin-auth declares them generically editable (`MANAGED_EXTENSION_EDITABLE_FIELDS.sys_organization`, the guard's per-object update whitelist), while the object's `enable.apiMethods: ['get', 'list']` answered every `PATCH /api/v1/data/sys_organization/:id` with 405 `OBJECT_API_METHOD_NOT_ALLOWED` before the engine — and the guard — was reached. Declared editable, reachable from no product surface: the columns could be set only by a system-context caller. The ruling answers the card's question — yes, an administrator sets these columns through the product — and refuses the alternative of declaring them system-writable only.

What widens (Clause ②) — three published surfaces move, all column-clamped by the same guard:

1. The data door's accept set. `enable.apiMethods` becomes `['get', 'list', 'update']`, and `userActions: { edit: true }` declares the affordance ADR-0103 D3's `reconcileManagedApiMethods` requires before it lets a `managedBy` object keep a write verb at registration (without it the verb is stripped with a warning and the door keeps answering 405 — the second silent gate #7727 measured on `sys_api_key`). `PATCH /api/v1/data/sys_organization/:id` is admitted; `create` / `delete` still answer 405; `bulk` (`/batch`, the `*Many` routes) is not granted (recorded in `SINGLE_RECORD_WRITE_ONLY`).
2. The derived `import` door. `API_METHOD_DERIVATION` (`@objectstack/spec` `api-derivation.ts`) derives `import` from `any: ['create', 'update']`, so granting `update` admits `POST /api/v1/data/sys_organization/import` (and the async `/import/jobs` route) in `writeMode: 'update'` — one request updates N rows, each row clamped per row by the ADR-0092 D2 guard under the caller's context (a row carrying only better-auth columns is refused `PERMISSION_DENIED`; `treatAsHistorical` does not elevate). Insert-mode and upsert-mode import stay 405 (the conjunct named is `create`). The door's own 405 envelope advertises the derived set in `allowed`.
3. `/auth/me/permissions`. For a principal the permission layer already admits (the seeded platform admin's `admin_full_access` wildcard), `sys_organization.allowEdit` goes `false → true` (`clampManagedObjectWrites` reads `userActions.edit` for the `better-auth` bucket) and `apiOperations` gains `update` and `import` (`annotateEffectiveApiOperations`) — the payload the console renders its edit affordance from. `organization_admin` / `member_default` stay hard-denied on every better-auth table by `managed-object-write-denies.ts`, unchanged.

What does not widen: the column set. The guard clamps every user-context update on this table to the whitelist. A PATCH of a better-auth column sent alone (`name`, `slug`, `logo`, `metadata`) is now refused by the guard's own verdict — 403 `PERMISSION_DENIED` — instead of the method gate's 405; sent beside a whitelisted column it is stripped and the whitelisted column lands. better-auth's own columns keep changing through better-auth's `organization/update` (the `update_organization` row action, unchanged). Per ADR-0092 D4's form-rendering constraint the four better-auth columns are now `readonly: true` on the object, so a standard edit form offers exactly what the guard admits; the engine's static-readonly strip exempts system-context writers, so better-auth's adapter is unaffected.

Not breaking: no key, export or accepted value is removed; every request that succeeded before succeeds unchanged, and the 405 → 403 change applies only to requests that were refused before and are refused still.
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #15873 — `sys_organization`'s data door admits `update`, and nothing else on
* the declaration moved (maintainer ruling 2026-09-07, decision batch #64,
* option (a), verbatim 「同意」).
*
* The card: four platform-owned columns (`require_mfa`,
* `parent_organization_id`, `sort_order`, `timezone`) were declared generically
* editable in plugin-auth's `MANAGED_EXTENSION_EDITABLE_FIELDS` — the ADR-0092
* D2 identity write guard's per-object update whitelist — while this object's
* `enable.apiMethods: ['get', 'list']` answered 405 to every PATCH before the
* engine, and the guard, were ever reached. Declared editable, reachable from
* nowhere. The ruling widens the METHOD gate and leaves the COLUMN gate to the
* whitelist that already exists for exactly this.
*
* This file pins the DECLARATION, from source. Three things about it are each
* a way the widening could silently fail to be what was ruled:
*
* 1. the verb set is `update` and only `update` — `create` / `delete` stay
* 405 (organizations are minted and destroyed through better-auth's own
* endpoints), and `bulk` is not granted (the ruling widened one verb; the
* single-record-only choice is recorded in `SINGLE_RECORD_WRITE_ONLY`,
* `@objectstack/spec`'s `api-methods-batch-conformance.test.ts`);
* 2. the verb SURVIVES registration. A `managedBy` object runs through
* `reconcileManagedApiMethods` (objectql registry, ADR-0092 / ADR-0103 D3),
* which strips any write verb the resolved affordances do not grant and
* only warns — so `update` in `apiMethods` with no `userActions.edit` is a
* declaration that serves 405 anyway (the second silent gate #7727 measured
* on `sys_api_key`). The predicate the registry calls is asked here
* directly, with a positive control proving it can still refuse;
* 3. better-auth's own door for its own columns is untouched: the row actions
* still target `organization/update` with `name` / `slug` / `logo`.
*
* The RUNTIME half — that the real door answers 200 for a whitelisted column
* and 403 `PERMISSION_DENIED` (not 405, not 200) for a better-auth column — is
* `organization-update-door.dogfood.test.ts` in `packages/qa/dogfood`; the
* form-facing D4 partition (whitelisted columns writable, everything else
* `readonly`) is `sys-organization-update-door.test.ts` in plugin-auth, which
* derives it from the shipped whitelist rather than re-spelling it.
*/

import { describe, it, expect } from 'vitest';
import { checkManagedApiMethodAffordances } from '@objectstack/spec/data';
import { SysOrganization } from './sys-organization.object';

describe('#15873 — sys_organization.enable.apiMethods admits `update`, and only `update`', () => {
it('declares exactly get / list / update — no create, no delete, no bulk', () => {
expect(SysOrganization.enable?.apiMethods).toEqual(['get', 'list', 'update']);
});

it('opens the generic EDIT affordance alone, so the verb survives `reconcileManagedApiMethods`', () => {
// `managedBy: 'better-auth'` defaults every write affordance to off; the
// one override is `edit`. `create` / `delete` / `import` stay bucket-default.
expect(SysOrganization.managedBy).toBe('better-auth');
expect(SysOrganization.userActions).toEqual({ edit: true });
});

it('the registry predicate keeps every declared verb — `update` is not stripped at registration', () => {
// The SAME predicate objectql's registry calls before it strips a verb
// (`checkManagedApiMethodAffordances` → `reconcileManagedApiMethods`). An
// empty conflict list is "the declaration and the runtime agree".
expect(checkManagedApiMethodAffordances(SysOrganization)).toEqual([]);
});

it('positive control: without `userActions.edit` the same predicate names `update` as stripped', () => {
// A zero-conflict answer above is only a reading if the predicate can
// still refuse this object. Remove the affordance and it must.
const { userActions: _dropped, ...withoutAffordance } = SysOrganization as any;
const conflicts = checkManagedApiMethodAffordances(withoutAffordance);
expect(conflicts.map((c) => c.verb)).toEqual(['update']);
});
});

describe('#15873 — better-auth keeps its own door for its own columns', () => {
const action = (name: string) => (SysOrganization.actions ?? []).find((a: any) => a?.name === name) as any;

it('`update_organization` still targets better-auth `organization/update` with name / slug / logo', () => {
const update = action('update_organization');
expect(update, 'update_organization must stay declared').toBeTruthy();
expect(update.target).toBe('/api/v1/auth/organization/update');
expect(update.bodyShape).toEqual({ wrap: 'data' });
expect((update.params ?? []).map((p: any) => p.field)).toEqual(['name', 'slug', 'logo']);
});

it('`create_organization` still targets better-auth `organization/create` — the data door does not create', () => {
const create = action('create_organization');
expect(create, 'create_organization must stay declared').toBeTruthy();
expect(create.target).toBe('/api/v1/auth/organization/create');
});
});
67 changes: 62 additions & 5 deletions packages/platform-objects/src/identity/sys-organization.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@ export const SysOrganization = ObjectSchema.create({
icon: 'building-2',
isSystem: true,
managedBy: 'better-auth',
// [#15873 — maintainer ruling 2026-09-07, option (a), verbatim 「同意」]
// Declares the generic EDIT affordance so `enable.apiMethods` below can
// keep `update`: a `managedBy` object runs through
// `reconcileManagedApiMethods` (objectql registry, ADR-0092 / ADR-0103 D3),
// which strips any write verb the resolved affordances do not grant and only
// warns. Without this line the declaration and the runtime disagree
// silently, one layer deeper than the method gate — the second silent gate
// #7727 measured on `sys_api_key`. `create` / `delete` stay bucket-default
// (off): organizations are created and destroyed through better-auth's own
// endpoints (the row actions below).
//
// Safe to open only because the enforcement it fronts already exists (ADR-0092
// D4's sequencing rule — the affordance never ships ahead of the guard): the
// D2 identity write guard clamps every user-context update on this table to
// the registered column whitelist, `MANAGED_EXTENSION_EDITABLE_FIELDS
// .sys_organization` in plugin-auth — the platform-owned extension columns
// (`require_mfa`, `parent_organization_id`, `sort_order`, `timezone`) and
// nothing else. Per D4's form-rendering constraint, every column outside that
// whitelist is marked `readonly` below, so the edit form cannot offer a write
// the server will refuse or strip.
userActions: { edit: true },
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema,
// but may add overlay row-level config. Use `no-overlay` if you need to
// forbid sys_metadata overlays entirely.
Expand All @@ -31,8 +52,12 @@ export const SysOrganization = ObjectSchema.create({
titleFormat: '{name}',
highlightFields: ['name', 'slug'],

// Custom actions — generic CRUD is suppressed (better-auth-managed),
// but admins still need to create new orgs from the Setup app.
// Custom actions — generic create / delete are suppressed (better-auth-
// managed), and better-auth's own columns (`name`, `slug`, `logo`) are
// edited ONLY through `update_organization` below. The generic `update` the
// data door admits since #15873 reaches the platform-owned extension columns
// alone (see `userActions` above and `enable.apiMethods` at the bottom);
// admins still need to create new orgs from the Setup app.
actions: [
{
name: 'create_organization',
Expand Down Expand Up @@ -176,9 +201,22 @@ export const SysOrganization = ObjectSchema.create({

fields: {
// ── Identity ─────────────────────────────────────────────────
// ADR-0092 D4 — with the generic edit affordance open (#15873), every
// better-auth-owned column is `readonly` so the standard edit form renders
// it non-editable. This is UX only: the server boundary is plugin-auth's
// identity write guard (ADR-0092 D2), which strips these from a
// user-context update regardless — and a mixed payload that carries one
// beside a whitelisted column lands the whitelisted column and drops this
// one, so a form that offered it would report success on an edit that
// never happened. `name` / `slug` / `logo` change through better-auth's
// `organization/update` (the `update_organization` row action, whose
// params are declared on the action, not read off these flags). The
// engine's own static-`readonly` strip exempts system-context writers, so
// better-auth's adapter (which stamps `isSystem`) still writes them.
name: Field.text({
label: 'Name',
required: true,
readonly: true,
searchable: true,
maxLength: 255,
group: 'Identity',
Expand All @@ -187,6 +225,7 @@ export const SysOrganization = ObjectSchema.create({
slug: Field.text({
label: 'Slug',
required: false,
readonly: true,
searchable: true,
maxLength: 255,
description: 'URL-friendly identifier',
Expand All @@ -197,13 +236,17 @@ export const SysOrganization = ObjectSchema.create({
logo: Field.url({
label: 'Logo',
required: false,
readonly: true,
group: 'Branding',
}),

// ── Configuration ────────────────────────────────────────────
// better-auth's own `metadata` column (its organization schema declares
// it); not an extension field, so readonly under D4 like the three above.
metadata: Field.textarea({
label: 'Metadata',
required: false,
readonly: true,
description: 'JSON-serialized organization metadata',
group: 'Configuration',
}),
Expand Down Expand Up @@ -333,8 +376,22 @@ export const SysOrganization = ObjectSchema.create({
trackHistory: true,
searchable: true,
apiEnabled: true,
// #1591 — reads only: writes are refused by the identity write guard
// (ADR-0092 D2) and owned by better-auth. HTTP answers 405 before the 403.
apiMethods: ['get', 'list'],
// #1591 closed the generic writes on this table — "refused by the identity
// write guard (ADR-0092 D2) and owned by better-auth; HTTP answers 405
// before the 403" — and that reasoning still holds for better-auth's own
// columns, which the D2 whitelist keeps refusing on this path. It never
// covered the platform-owned extension columns, which better-auth neither
// reads nor writes and which `MANAGED_EXTENSION_EDITABLE_FIELDS` had
// declared editable through the ordinary path while this gate 405'd every
// PATCH before the engine was reached (#15873). Ruled 2026-09-07, option
// (a): the data door admits `update`, and the D2 whitelist does the column
// gating — a user-context PATCH of `name` is now refused by the guard's own
// verdict (403 PERMISSION_DENIED) instead of the method gate's 405, and
// `create` / `delete` stay 405. `update` alone, no `bulk`: the ruling
// widened one verb, and the single-record-only choice is on the record in
// `SINGLE_RECORD_WRITE_ONLY` (`api-methods-batch-conformance.test.ts`,
// @objectstack/spec), whose stale-entry check fails if `bulk` is added
// here without retiring it.
apiMethods: ['get', 'list', 'update'],
},
});
Loading
Loading