Skip to content

Commit 7797102

Browse files
os-muskclaude
andauthored
feat(platform-objects): sys_organization's data door admits update, column-gated by the ADR-0092 D2 whitelist (#16687)
* feat(platform-objects): admit update on sys_organization's data door, column-gated by the ADR-0092 D2 whitelist (#15873) Ruling (a), decision batch #64, 2026-09-07 — the data door admits `update` and the identity write guard's per-object whitelist does the column gating. `enable.apiMethods` becomes ['get', 'list', 'update'] with the `userActions.edit` affordance the registry reconciler requires; better-auth's own columns (name / slug / logo / metadata) are readonly per ADR-0092 D4 and stay refused/stripped by the guard. create / delete still 405; no bulk (recorded in SINGLE_RECORD_WRITE_ONLY). Pins: declaration (platform-objects), column gate + D4 partition derived from the shipped whitelist (plugin-auth), and the real door with the 405 → 403 transition (dogfood). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * test(dogfood): mint the door test's organization through better-auth under the walled posture The plain showcase boot holds no sys_organization row (system-context read returns []; the admin session has activeOrganizationId null), and organization/create is denied without an organization wall (#5261) — so the fixture boots multiTenant: 'posture-only' and creates the org the way the Setup app does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * test(dogfood),spec,changeset: name the two derived surfaces the update grant moves (#15873 contract-review patch round) Review FAIL was on the accept-set statement, not the code: granting `update` also derives the update-mode import door (API_METHOD_DERIVATION: import = any of create/update) and flips /auth/me/permissions for sys_organization (allowEdit true, apiOperations gains update and import). Both named in the changeset; pinned on the real door in the dogfood file (import: 200, timezone lands, name stripped; better-auth-only row refused per row; insert mode 405 naming create; /me/permissions allowEdit true with update+import and a sibling better-auth table as the clamp control); the SINGLE_RECORD_WRITE_ONLY sentence now says what is true (bulk not granted; the derived import door is, column-clamped per row). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ba5284e commit 7797102

6 files changed

Lines changed: 724 additions & 5 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/platform-objects": minor
3+
---
4+
5+
`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 「同意」).
6+
7+
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.
8+
9+
What widens (Clause ②) — three published surfaces move, all column-clamped by the same guard:
10+
11+
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`).
12+
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`.
13+
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.
14+
15+
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.
16+
17+
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.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #15873 — `sys_organization`'s data door admits `update`, and nothing else on
5+
* the declaration moved (maintainer ruling 2026-09-07, decision batch #64,
6+
* option (a), verbatim 「同意」).
7+
*
8+
* The card: four platform-owned columns (`require_mfa`,
9+
* `parent_organization_id`, `sort_order`, `timezone`) were declared generically
10+
* editable in plugin-auth's `MANAGED_EXTENSION_EDITABLE_FIELDS` — the ADR-0092
11+
* D2 identity write guard's per-object update whitelist — while this object's
12+
* `enable.apiMethods: ['get', 'list']` answered 405 to every PATCH before the
13+
* engine, and the guard, were ever reached. Declared editable, reachable from
14+
* nowhere. The ruling widens the METHOD gate and leaves the COLUMN gate to the
15+
* whitelist that already exists for exactly this.
16+
*
17+
* This file pins the DECLARATION, from source. Three things about it are each
18+
* a way the widening could silently fail to be what was ruled:
19+
*
20+
* 1. the verb set is `update` and only `update` — `create` / `delete` stay
21+
* 405 (organizations are minted and destroyed through better-auth's own
22+
* endpoints), and `bulk` is not granted (the ruling widened one verb; the
23+
* single-record-only choice is recorded in `SINGLE_RECORD_WRITE_ONLY`,
24+
* `@objectstack/spec`'s `api-methods-batch-conformance.test.ts`);
25+
* 2. the verb SURVIVES registration. A `managedBy` object runs through
26+
* `reconcileManagedApiMethods` (objectql registry, ADR-0092 / ADR-0103 D3),
27+
* which strips any write verb the resolved affordances do not grant and
28+
* only warns — so `update` in `apiMethods` with no `userActions.edit` is a
29+
* declaration that serves 405 anyway (the second silent gate #7727 measured
30+
* on `sys_api_key`). The predicate the registry calls is asked here
31+
* directly, with a positive control proving it can still refuse;
32+
* 3. better-auth's own door for its own columns is untouched: the row actions
33+
* still target `organization/update` with `name` / `slug` / `logo`.
34+
*
35+
* The RUNTIME half — that the real door answers 200 for a whitelisted column
36+
* and 403 `PERMISSION_DENIED` (not 405, not 200) for a better-auth column — is
37+
* `organization-update-door.dogfood.test.ts` in `packages/qa/dogfood`; the
38+
* form-facing D4 partition (whitelisted columns writable, everything else
39+
* `readonly`) is `sys-organization-update-door.test.ts` in plugin-auth, which
40+
* derives it from the shipped whitelist rather than re-spelling it.
41+
*/
42+
43+
import { describe, it, expect } from 'vitest';
44+
import { checkManagedApiMethodAffordances } from '@objectstack/spec/data';
45+
import { SysOrganization } from './sys-organization.object';
46+
47+
describe('#15873 — sys_organization.enable.apiMethods admits `update`, and only `update`', () => {
48+
it('declares exactly get / list / update — no create, no delete, no bulk', () => {
49+
expect(SysOrganization.enable?.apiMethods).toEqual(['get', 'list', 'update']);
50+
});
51+
52+
it('opens the generic EDIT affordance alone, so the verb survives `reconcileManagedApiMethods`', () => {
53+
// `managedBy: 'better-auth'` defaults every write affordance to off; the
54+
// one override is `edit`. `create` / `delete` / `import` stay bucket-default.
55+
expect(SysOrganization.managedBy).toBe('better-auth');
56+
expect(SysOrganization.userActions).toEqual({ edit: true });
57+
});
58+
59+
it('the registry predicate keeps every declared verb — `update` is not stripped at registration', () => {
60+
// The SAME predicate objectql's registry calls before it strips a verb
61+
// (`checkManagedApiMethodAffordances` → `reconcileManagedApiMethods`). An
62+
// empty conflict list is "the declaration and the runtime agree".
63+
expect(checkManagedApiMethodAffordances(SysOrganization)).toEqual([]);
64+
});
65+
66+
it('positive control: without `userActions.edit` the same predicate names `update` as stripped', () => {
67+
// A zero-conflict answer above is only a reading if the predicate can
68+
// still refuse this object. Remove the affordance and it must.
69+
const { userActions: _dropped, ...withoutAffordance } = SysOrganization as any;
70+
const conflicts = checkManagedApiMethodAffordances(withoutAffordance);
71+
expect(conflicts.map((c) => c.verb)).toEqual(['update']);
72+
});
73+
});
74+
75+
describe('#15873 — better-auth keeps its own door for its own columns', () => {
76+
const action = (name: string) => (SysOrganization.actions ?? []).find((a: any) => a?.name === name) as any;
77+
78+
it('`update_organization` still targets better-auth `organization/update` with name / slug / logo', () => {
79+
const update = action('update_organization');
80+
expect(update, 'update_organization must stay declared').toBeTruthy();
81+
expect(update.target).toBe('/api/v1/auth/organization/update');
82+
expect(update.bodyShape).toEqual({ wrap: 'data' });
83+
expect((update.params ?? []).map((p: any) => p.field)).toEqual(['name', 'slug', 'logo']);
84+
});
85+
86+
it('`create_organization` still targets better-auth `organization/create` — the data door does not create', () => {
87+
const create = action('create_organization');
88+
expect(create, 'create_organization must stay declared').toBeTruthy();
89+
expect(create.target).toBe('/api/v1/auth/organization/create');
90+
});
91+
});

‎packages/platform-objects/src/identity/sys-organization.object.ts‎

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,27 @@ export const SysOrganization = ObjectSchema.create({
1717
icon: 'building-2',
1818
isSystem: true,
1919
managedBy: 'better-auth',
20+
// [#15873 — maintainer ruling 2026-09-07, option (a), verbatim 「同意」]
21+
// Declares the generic EDIT affordance so `enable.apiMethods` below can
22+
// keep `update`: a `managedBy` object runs through
23+
// `reconcileManagedApiMethods` (objectql registry, ADR-0092 / ADR-0103 D3),
24+
// which strips any write verb the resolved affordances do not grant and only
25+
// warns. Without this line the declaration and the runtime disagree
26+
// silently, one layer deeper than the method gate — the second silent gate
27+
// #7727 measured on `sys_api_key`. `create` / `delete` stay bucket-default
28+
// (off): organizations are created and destroyed through better-auth's own
29+
// endpoints (the row actions below).
30+
//
31+
// Safe to open only because the enforcement it fronts already exists (ADR-0092
32+
// D4's sequencing rule — the affordance never ships ahead of the guard): the
33+
// D2 identity write guard clamps every user-context update on this table to
34+
// the registered column whitelist, `MANAGED_EXTENSION_EDITABLE_FIELDS
35+
// .sys_organization` in plugin-auth — the platform-owned extension columns
36+
// (`require_mfa`, `parent_organization_id`, `sort_order`, `timezone`) and
37+
// nothing else. Per D4's form-rendering constraint, every column outside that
38+
// whitelist is marked `readonly` below, so the edit form cannot offer a write
39+
// the server will refuse or strip.
40+
userActions: { edit: true },
2041
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema,
2142
// but may add overlay row-level config. Use `no-overlay` if you need to
2243
// forbid sys_metadata overlays entirely.
@@ -31,8 +52,12 @@ export const SysOrganization = ObjectSchema.create({
3152
titleFormat: '{name}',
3253
highlightFields: ['name', 'slug'],
3354

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

177202
fields: {
178203
// ── Identity ─────────────────────────────────────────────────
204+
// ADR-0092 D4 — with the generic edit affordance open (#15873), every
205+
// better-auth-owned column is `readonly` so the standard edit form renders
206+
// it non-editable. This is UX only: the server boundary is plugin-auth's
207+
// identity write guard (ADR-0092 D2), which strips these from a
208+
// user-context update regardless — and a mixed payload that carries one
209+
// beside a whitelisted column lands the whitelisted column and drops this
210+
// one, so a form that offered it would report success on an edit that
211+
// never happened. `name` / `slug` / `logo` change through better-auth's
212+
// `organization/update` (the `update_organization` row action, whose
213+
// params are declared on the action, not read off these flags). The
214+
// engine's own static-`readonly` strip exempts system-context writers, so
215+
// better-auth's adapter (which stamps `isSystem`) still writes them.
179216
name: Field.text({
180217
label: 'Name',
181218
required: true,
219+
readonly: true,
182220
searchable: true,
183221
maxLength: 255,
184222
group: 'Identity',
@@ -187,6 +225,7 @@ export const SysOrganization = ObjectSchema.create({
187225
slug: Field.text({
188226
label: 'Slug',
189227
required: false,
228+
readonly: true,
190229
searchable: true,
191230
maxLength: 255,
192231
description: 'URL-friendly identifier',
@@ -197,13 +236,17 @@ export const SysOrganization = ObjectSchema.create({
197236
logo: Field.url({
198237
label: 'Logo',
199238
required: false,
239+
readonly: true,
200240
group: 'Branding',
201241
}),
202242

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

0 commit comments

Comments
 (0)