Skip to content

sys_organization's platform-owned columns are engine-writable but no product surface reaches them — the data door answers 405 (apiMethods: ['get', 'list']) and better-auth's update endpoint does not carry them #15873

Description

@zhuangjianguo

Found while landing #14238's sys_organization.timezone — the root default of the business-unit timezone chain, which the ruling describes as a value an administrator sets. The column follows the precedent every earlier platform-owned column on that object follows (require_mfa, parent_organization_id, sort_order): registered in plugin-auth's MANAGED_EXTENSION_EDITABLE_FIELDS so the ADR-0092 D2 identity write guard admits it. This card is about where that precedent leads. Unassigned — for triage; it may be a documented posture rather than a defect, and that is the decision.

The reading

Measured on origin/main at 7b6825477:

  • packages/platform-objects/src/identity/sys-organization.object.ts — enable.apiMethods: ['get', 'list'] (its own comment: managedBy is not enforced: generic CRUD bypasses better-auth on sys_team (data-integrity / security) #1591, reads only, writes refused by the identity write guard and owned by better-auth; "HTTP answers 405 before the 403").
  • packages/rest/src/rest-server.ts:1699 — a non-empty enable.apiMethods whitelist rejects unlisted operations with 405 on the REST data surface, the external API boundary. So PATCH /api/v1/data/sys_organization/ID never reaches the engine.
  • packages/plugins/plugin-auth/src/managed-extension-fields.ts — MANAGED_EXTENSION_EDITABLE_FIELDS.sys_organization = require_mfa, parent_organization_id, sort_order (and timezone once No platform object carries a timezone, so every app that computes a date boundary has to invent one — and each will invent it differently #14238's PR lands), registered at kernel:ready as the guard's per-object update whitelist (auth-plugin.ts, the D7 loop). This opens the columns on the engine path for user-context callers — a path the data door does not let a user-context caller reach.
  • The object's update_organization row action targets better-auth's organization/update (bodyShape: { wrap: 'data' }) with params name, slug, logo only; the extension fields are deliberately not better-auth additionalFields (the ai_access / locale notes in auth-manager.ts), so that endpoint does not carry them either.
  • Writers of require_mfa outside plugin-auth's own read site (auth-manager.ts): git grep -n require_mfa -- packages/rest/src packages/runtime/src packages/apps packages/qa = 0.

So the four columns are settable only by a system-context caller — a plugin, a flow, a CLI seed, SQL. They are declared generically editable and reachable from no product surface, which is the declared-but-unusable shape ADR-0049 / ADR-0078 keep out unless it is the intended posture.

Not measured

Whether the Setup app (objectui side) renders an organization edit form for these columns at all; if it does, it meets the 405 above. Whether any dogfood test exercises a user-context write to sys_organization (none found by the grep above).

Fix space (a decision, not a prescription)

  • (a) admit update in sys_organization.apiMethods and let the D2 whitelist do the column gating — the whitelist already exists for exactly this, and better-auth's own columns stay stripped;
  • (b) a dedicated admin route for the platform-owned columns;
  • (c) declare the posture: the columns are system-writable only, and drop them from MANAGED_EXTENSION_EDITABLE_FIELDS so the declaration matches the reach.

The #14238 seat did not touch apiMethods: the method gate on an identity table is an ADR-0092 / #1591 decision, outside ruling A's scope.

Published surface (measured)

sys_organization's apiMethods is in the built packages/platform-objects/dist/identity/index.js / index.mjs; content/docs/** was not measured for this observation.

Activity

  1. os-zhuang commented on Sep 6, 2026

    @os-zhuang
    Contributor

    分诊:domain:services + domain:engine / enhancement + finding / needs-user-decision / priority:p3

    ⛔ needs-user-decision 不与 pm:* 并存(先例 #15854 / #15617 / #15542)。
    ✅ 双 lane:三支修法分属两个包(见下)。

    落点核实(origin/main,本轮实读)

    packages/platform-objects/src/identity/sys-organization.object.ts:337
        apiMethods: ['get', 'list'],
    
    packages/plugins/plugin-auth/src/managed-extension-fields.ts:87
      sys_organization: new Set([
    packages/plugins/plugin-auth/src/managed-extension-fields.ts:88
        // ADR-0069 D3 — per-org MFA tightening above the global floor.
    

    ⇒ 两侧都成立:数据门只开 get / list(写在 REST 边界被 405 挡掉,rest-server.ts:1699),而引擎路径上 MANAGED_EXTENSION_EDITABLE_FIELDS.sys_organization 把这几列开给了 user-context 调用方。

    ⭐ 而 managed-extension-fields.ts:18-22 的档头把这个设计意图写得很清楚:

    sys_organization.require_mfa, and the ADR-0105 D6 group-structure fields. better-auth never reads or writes these, so they can be edited through the ordinary path under normal FLS / requiredPermissions. … A field is an extension field because better-auth does not write it, not …

    ⇒ 档头声称「可以走普通路径编辑」,而普通路径(REST 数据门)在这个对象上被 405 挡死。 这正是卡片指认的那条断裂:声明说可编辑,可达性说不可达。

    ⚠️ 本席未复核卡片的两条辅助读数:update_organization 行动作只带 name / slug / logo;以及 git grep require_mfa -- packages/rest/src packages/runtime/src packages/apps packages/qa = 0。⛔ 不要把上面的核对当成对它们的复现继承下去。


    为什么是决定箱

    卡片自己把它定性得很准,本席复核后同意:

    Unassigned — for triage; it may be a documented posture rather than a defect, and that is the decision.

    ⇒ 「四列只能由 system-context 调用方设置(插件、flow、CLI seed、SQL)」这件事,既可能是有意的姿态,也可能是一次遗漏——而两者的处置完全相反。⛔ 且它触及 ADR-0092 / #1591 对身份表方法门的既有决定,卡片明说 #14238 的席位刻意没碰 apiMethods,因为那在裁决 A 的范围之外。

    ⛔ 本席不裁决(本会话 claude-opus-5,CONTRACT_REVIEW_TIER 硬门要求 fable)。

    四facet

    ① 事实(已核) sys_organization 的 enable.apiMethods = ['get','list'](其自身注释:#1591,只读,写由身份写守卫拒绝、归 better-auth 所有,「HTTP answers 405 before the 403」);rest-server.ts:1699 的非空白名单以 405 拒绝未列操作 ⇒ PATCH /api/v1/data/sys_organization/ID 永不到达引擎。同时 MANAGED_EXTENSION_EDITABLE_FIELDS.sys_organization 在 kernel:ready 注册为守卫的按对象更新白名单,把 require_mfa / parent_organization_id / sort_order(以及 #14238 落地后的 timezone)开在引擎路径上。better-auth 的 organization/update 只带 name / slug / logo。⇒ 四列只有 system-context 可写。

    ② 分叉(卡片列出,本席原样转达并标注 lane)

    • (a) 在 sys_organization.apiMethods 里放开 update,让 D2 白名单做列级把关——白名单正是为此存在的,better-auth 自己的列仍被剥离。⇒ 落 packages/platform-objects ⇒ domain:engine。
    • (b) 为这些平台自有列开一条专用管理路由。⇒ 落 packages/rest 或 packages/plugins/plugin-auth ⇒ domain:cli / domain:services。
    • (c) 声明姿态:这些列只允许系统写,并把它们从 MANAGED_EXTENSION_EDITABLE_FIELDS 里删掉,让声明与可达性一致。⇒ 落 packages/plugins/plugin-auth ⇒ domain:services。

    ③ 各支的代价

    ④ 需要裁决者提供的东西
    一句话:sys_organization 上的平台自有列,是否应当由管理员通过产品界面设置?
    答「是」⇒ (a) 或 (b);答「否,只允许系统写」⇒ (c),并请一并确认 #14238 对 timezone 的描述如何修正。

    定级理由(其余)

    • enhancement 而非 bug:main 上没有东西是假的——405 是按声明发生的,白名单也是按声明注册的;错的是两个声明合起来给出一个不可达的能力。任一修法都是改变已发布的可达面 ⇒ Feature 侧,人工地板,与决定箱相符。
    • p3:无运行期故障、无数据风险、无安全泄漏(方向是更严:能力宣称有而实际没有)。⛔ 不降更低:它正是 ADR-0049 / ADR-0078 明确要挡在外面的「declared-but-unusable」形状,除非它是有意的姿态——而那正是本卡要问的。

    ⚠️ 卡片明说未测的两项,本席原样转达为取卡前置

    1. Setup app(objectui 侧)是否真的为这些列渲染了一个组织编辑表单——若渲染了,它会撞上那个 405。⇒ 这是最可能已经有用户在踩的路径,且它会把本卡从「声明不一致」变成「一个界面按钮永远失败」。⛔ 本席不跨仓测。
    2. 是否有任何 dogfood 测试跑过对 sys_organization 的 user-context 写(卡片的 grep 没找到)。

    已发布面(卡片已测):sys_organization 的 apiMethods 在构建出的 packages/platform-objects/dist/identity/index.js / index.mjs 里;content/docs/** 未被测量——⭐ 卡片明说了这一点,⇒ ⛔ 不要把「文档没说」当成读数。

    Refs:#14238(本卡的来源)· ADR-0092 D2(身份写守卫)· #1591(方法门的既有决定)· ADR-0069 D3 · ADR-0105 D6 · ADR-0049 / ADR-0078(declared-but-unusable)。


    ⛔ 本席为 triage 席位:不认领、不派单、不写码、不合并、不裁决 decision-box。


    Generated by Claude Code

  2. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    Contributor

    Ruling recorded — option (a): administrators set the platform-owned columns through the product; the data door admits update and the D2 whitelist gates the columns (director seat, decision batch #64, 2026-09-07)

    Maintainer reply, verbatim: 「同意」 (all five batch #64 recommendations adopted).

    Answer to the card's question. Yes — require_mfa, parent_organization_id, sort_order and timezone on sys_organization are values an administrator sets through the product surface (this confirms #14238's description of timezone). Option (c) — declaring them system-writable only — is refused: it would make the product unable to set an organisation's timezone or MFA posture.

    Execution notes.

    • packages/platform-objects/src/identity/sys-organization.object.ts: enable.apiMethods gains update. The ADR-0092 D2 identity write guard with MANAGED_EXTENSION_EDITABLE_FIELDS.sys_organization is the column gate; better-auth's own columns (name, slug, logo, and everything else not on the whitelist) stay stripped/refused on that path and continue to go through better-auth's organization/update. Update the managedBy is not enforced: generic CRUD bypasses better-auth on sys_team (data-integrity / security) #1591 comment: its "writes owned by better-auth" reasoning holds for better-auth's columns, which the whitelist still protects; it never covered the platform-owned extension columns.
    • Verify the refusal ordering after the change: an attempt to write name through the data door must be refused by the guard (403 with the D2 code), not silently ignored.
    • Precondition reading, recorded here before landing: does the objectui Setup app already render an organisation edit form for these columns? If yes, it starts working; if no, a follow-up objectui card adds it (the director will not file it blind).
    • Clause-② yes (a published method gate on an identity table widens): landing PR carries needs:contract-review; changeset states the change.

    Labels: needs-user-decision → pm:queue; lane domain:engine (the platform-objects change), domain:services dropped. Ledger on #12708 (batch #64).


    Generated by Claude Code

  3. hotlong commented on Sep 7, 2026

    @hotlong
    Contributor

    State restored to the recorded ruling — needs-user-decision → pm:queue (director seat, summon #17, session_01XesLUWmuhjuRwmU618AZ1M, 2026-09-07T14:1xZ)

    This card was ruled at 02:52Z today: option (a) — administrators set the platform-owned columns through the product; the data door admits update and the D2 whitelist gates the columns (director seat, decision batch #64, comment 5564369547; maintainer reply, verbatim: 「同意」). That ruling explicitly refused option (c). The 12:08Z comment (5570393087) re-presented A/B/C to the maintainer recommending (c) and moved the card back into the decision inbox; it does not cite the ruling and the ruling was not overturned by the maintainer, so it is a duplicate presentation of a decided question, not a reopening. A recorded ruling stands until the maintainer overturns it — the director's ledger on objectstack#12708 is the authority for its existence.

    Executed now: needs-user-decision → pm:queue in one label write (read back), assignee clear. The domain:engine lane dispatches under ruling (a) — the answer to the card's question is "yes, through the product", and #14238's description of timezone stands. If the maintainer wants (c) after all, that is a new ruling to record here, not a re-presentation.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions