Skip to content

[finding] SCIM forces the better-auth admin plugin on (pluginConfig.admin ?? scimEffective) and a deployment has no declared way to decline it — split from #13439 by the 2026-08-31 ruling #13816

Description

@huangyiirene

Split out of #13439 by the maintainer's ruling (2026-08-31, director batch #19; the approved recommendation for #13439 was A for the three keys, with the admin amplification split to its own card). #13439 now carries only the three-key half — declare scim / sso / ssoDomainVerification, explicit config outranks env. This card records the amplification half so it does not evaporate with that fix.

The mechanism

packages/plugins/plugin-auth/src/auth-manager.ts:

  • :2426 — const scimEffective = scimFromEnv ?? (pluginConfig as any).scim ?? false;
  • :2439 — admin: pluginConfig.admin ?? scimEffective,
  • :5064 — the advertised-feature surface mirrors the same resolution.

Any deployment where SCIM ends up enabled — including via OS_SCIM_ENABLED alone — gets better-auth's admin plugin mounted as a side effect: impersonate-user, set-user-password, ban-user. A deployment that never asked for the admin surface has the same non-existent means of declining it that #13439 documented for scim itself. This is a larger permission surface than the three keys.

Why it was split rather than ridden on #13439

The coupling is NOT an accident. The comment block above :2426 records it explicitly: SCIM's active:false deprovisioning path runs through the admin plugin (ban), and org-scoped tokens need the organization plugin — see ADR-0071. So "declare admin tri-state and let a host decline it while SCIM is on" is not a config-reading fix: declining admin under SCIM plausibly breaks SCIM deprovisioning semantics. Deciding it needs an ADR-0071 revisit, which would have widened the p1 fix that objectstack-ai/cloud#1451 is parked on.

The question this card carries

Should a host be able to decline the admin surface in a SCIM-enabled deployment — and if so, what happens to SCIM's active:false → ban path? Candidate shapes, neither costed:

  1. scope the admin dependency internally to what SCIM actually needs (ban), instead of mounting the full admin action set;
  2. declare admin tri-state with a documented conflict refusal at construction time when SCIM is on and admin is explicitly false — a loud conflict instead of silent forcing.

Refs: #13439 (ruled sibling), ADR-0071, objectstack-ai/cloud#1451, objectstack-ai/cloud#1265.

⛔ Filed unassigned — recording only, for the finding box to grade.

Activity

  1. os-warren commented on Aug 31, 2026

    @os-warren
    Collaborator

    分诊定级 → domain:spec · needs-user-decision · p1 · security。摘 finding。

    这张卡是我今天上午在 #13439 上提的那一问的答案的另一半。 我当时问「admin 放大项是随 A 同批裁,还是单独立卡」,维护者答(批 #19)单独立卡 —— 就是本卡。⇒ #13439 已转 pm:queue(复核过,标签已正确),本卡承接被拆出来的那一半。

    锚定。 机制在 packages/plugins/plugin-auth/src/auth-manager.ts(domain:services 面),但两个候选形状里的第 2 个要声明 admin 三态 ⇒ 触 packages/spec ⇒ 按唯一所有者规则归 domain:spec。⚠️ 若最终裁定走第 1 个形状(内部收窄 admin 依赖,不动声明面),落点全在 plugin-auth ⇒ 改挂 domain:services。现按「会触 spec 的那个选项」挂,并写明改挂条件。

    保留 needs-user-decision,理由是两条独立成立的人工地板

    1. 权限边界 —— 开了 SCIM 就自动挂上 better-auth 的 admin 插件:impersonate-user / set-user-password / ban-user。一个从未要求过管理面的部署,没有任何声明手段拒绝它。这比 [finding] plugins.scim / plugins.sso are undeclared config keys that read as configuration but cannot ever change the outcome — the env var always outranks them #13439 那三个键是更大的权限面。
    2. ADR 类 —— 卡片说得对且这是它最有价值的判断:这个耦合不是意外。:2426 上方的注释块明确记着 SCIM 的 active:false 停用路径经由 admin 插件(ban),org-scoped token 需要 organization 插件,见 ADR-0071。⇒ 「声明 admin 三态、让 host 在 SCIM 开着时拒绝它」不是一个读配置的修复 —— 拒绝 admin 很可能打断 SCIM 的停用语义。裁它要重审 ADR-0071。

    ⇒ ⛔ 分诊不代裁。

    定级 p1

    不是因为今天有东西坏了 —— 今天的行为是设计如此且有 ADR 背书。判 p1 是因为受影响的是权限面,而缺的是拒绝的手段:一个只想要 SCIM 用户同步的部署,无法说「我不要 impersonate/ban/set-password」。在一个多租户平台上,「无法拒绝一个管理面」本身就是 p1 级的属性,与它是否被滥用无关。

    ⭐ 且它与 #13439 是同一条链上的两环:#13439 落地后,host 终于能显式说 scim: false 了 —— 但只要 SCIM 在任何路径上被打开,admin 仍然自动跟上。⇒ 不裁本卡,#13439 的修复会给出一个「我控制住了 SCIM」的错觉,而权限放大照旧。这一点请在裁决时一并考虑:两张卡合起来才是完整答案。

    需要你答的那一句

    在一个启用了 SCIM 的部署里,host 应不应该能拒绝 admin 面?若能 —— SCIM 的 active:false → ban 路径怎么办?

    卡片给了两个候选形状,都未计价,我按它的原样转述并各补一句代价判断:

    1. 把 admin 依赖内部收窄到 SCIM 真正需要的那部分(ban),而不是挂上完整的 admin 动作集。⇒ 语义上最干净:SCIM 拿到它需要的,部署不必接受它没要的。⚠️ 代价是要在 better-auth 的插件粒度上做裁剪,可能不是配置能表达的,需要读那个插件的实际形状才知道可不可行。
    2. 声明 admin 三态,并在 SCIM 开着而 admin 显式为 false 时于构造期以文档化的冲突拒绝 —— 响亮的冲突,而不是静默的强制。⇒ 代价最小、最快,且符合本仓「声明即强制」的方向;但它把问题交还给部署方(你要么接受 admin,要么关掉 SCIM),没有解决「只想要同步、不想要管理面」这个真实诉求。

    ⇒ 我的读数:若 ADR-0071 的耦合确实不可拆,形状 2 是诚实的下限(至少让作者知道自己在接受什么,而不是无声获得);若形状 1 可行,它才是真的修复。⛔ 但这依赖一次我没做的测量 —— better-auth 的 admin 插件能否按动作粒度挂载。建议裁决前先要这一个读数。

    Refs:#13439(已裁的姊妹卡,pm:queue)· ADR-0071 · objectstack-ai/cloud#1451 · objectstack-ai/cloud#1265。


    Generated by Claude Code

  2. huangyiirene commented on Sep 1, 2026

    @huangyiirene
    CollaboratorAuthor

    裁决:条件裁 —— 形状 2 即刻排队;测量卡 #14150 同笔立,加测 better-auth 升级(维护者 2026-09-01,总监批 #21)

    项目总监席 · session session_01KGtaLpkW1mycWgkbSb3H6t

    维护者逐字:「13816 还要同时评估是否需要升级 better-auth」+ 对呈报推荐「其他同意」。合并为:

    1. 本卡 = 形状 2 实施卡,即刻排队:声明 admin 三态;SCIM 生效而 admin 显式为 false 时,构造期以文档化的冲突响亮拒绝 —— 不再静默强制。落点触 packages/spec(声明面)⇒ 条款②契约复审档位适用。
    2. 测量卡 [measurement] better-auth admin 插件能否按动作粒度挂载(SCIM 只要 ban)——并同时评估是否需要升级 better-auth #14150 同笔立(维护者追加项已并入):(a) 当前钉住的 better-auth 版本上,admin 插件能否按动作粒度挂载(SCIM 只要 ban);(b) 是否需要/值得升级 better-auth —— 新版是否提供更细粒度或解耦手段,升级破坏面与迁移成本读数;(c) 具名分叉:可行 ⇒ 立形状 1 + ADR-0071 修订卡,形状 2 降为过渡态;不可行 ⇒ 形状 2 即终态。
    3. 分诊席的判断被采纳入册:若耦合不可拆,形状 2 是诚实的下限;若形状 1 可行,它才是真修复 —— 但那依赖 [measurement] better-auth admin 插件能否按动作粒度挂载(SCIM 只要 ban)——并同时评估是否需要升级 better-auth #14150 的读数,⛔ 不预设。

    状态转移(同笔)

    needs-user-decision → pm:queue;priority:p1 security domain:spec 不动。#14150 独立排队,两卡可并行(2 不堵 1 的路,先 2 后 1 是加法)。


    Generated by Claude Code

  3. self-assigned this
    on Sep 1, 2026
  4. os-support-ai commented on Sep 1, 2026

    @os-support-ai
    Collaborator

    Claim: session_01Mciyv38maJ6HYVMiaM26T1 (domain:spec execution seat, dispatch batch 5) · branch claude/issue-13816-scim-admin-tristate-conflict. Executing the 2026-09-01 director-batch-#21 ruling (comment 5491056008): Shape 2, immediate — declare admin tri-state on the auth-config surface; when SCIM is effective and admin is explicitly false, refuse loudly at construction time with a documented conflict (no more silent forcing). Measurement card #14150 (action-granularity + better-auth upgrade evaluation) runs in parallel and is NOT this dispatch; ⛔ Shape 1 and any ADR-0071 revision are out of scope here — the ruling records Shape 2 as the honest floor pending #14150's readings. Clause-②: yes (declared-surface widening + a new construction-time refusal) ⇒ contract-review tier; in-seat review before enqueue. Declared file surface: packages/spec/src/system/auth-config.zod.ts (+ regen), packages/plugins/plugin-auth/src/auth-manager.ts (the :2426/:2439/:5064 resolution sites), tests, changeset — the same file family as the landed sibling #13439 → PR #14066, whose three-key precedence shape this extends.


    Generated by Claude Code

  5. claude commented on Sep 1, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 13816,
      "status": "done",
      "branch": "claude/issue-13816-scim-admin-tristate-conflict",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/14246",
      "head": "9238222d3",
      "premise_still_valid": true,
      "summary": "Shape 2 implemented per the 2026-09-01 director-batch-#21 ruling (comment 5491056008). plugins.admin is now tri-state (z.boolean().optional(), the #14066 shape, same file as its scim/sso siblings); effective SCIM beside an explicit admin:false is refused loudly with a documented conflict error naming both keys, the ADR-0071 coupling, and both exits (accept admin, or disable SCIM). The refusal runs at three entry points: AuthManager constructor, applyConfigPatch (merged result), and buildPluginList (env read after boot). The three '?? scimEffective' fallbacks are kept - absent admin under effective SCIM still forces the admin plugin on. New resolveScimEnabled() is the single scim-effective decision point shared by the mount, the advertised features flag, and the assert. The default move is declared in DEFAULT_CHANGES_BY_MAJOR under major 17 (the #4666 ratchet accepted it); authorable-defaults and reference docs regenerated. No wire code added: the refusal is a boot-time throw (assertAudienceConfig class), never an HTTP envelope - check:dispatcher-error-vocabulary green confirms no ledger entry is due.",
      "files": [
        "packages/spec/src/system/auth-config.zod.ts",
        "packages/spec/scripts/lib/default-changes.ts",
        "packages/spec/authorable-defaults/system.json (regen)",
        "content/docs/references/system/auth-config.mdx (regen)",
        "packages/plugins/plugin-auth/src/auth-manager.ts",
        "packages/plugins/plugin-auth/src/auth-manager.test.ts",
        ".changeset/scim-admin-tristate-conflict-refusal.md"
      ],
      "consumer_package_tests": {
        "@objectstack/plugin-auth": "pnpm --filter @objectstack/plugin-auth test at 9238222d3: Test Files 87 passed (87), Tests 1803 passed (1803); includes 7 new pins (4 refusal + 3 acceptance); the six #14066 precedence tests still green",
        "@objectstack/spec": "pnpm --filter @objectstack/spec test at 9238222d3: Test Files 449 passed (449), Tests 12052 passed (12052)",
        "typecheck": "spec exit 0 and plugin-auth exit 0 at 9238222d3; NOTE plugin-auth tsconfig excludes its test files from typecheck with no sibling test-tsconfig, so the edited test file is vitest-exercised only - pre-existing repo-wide gap already tracked by open finding 14062, no new filing"
      },
      "gates": {
        "dispatch_listed_all_at_9238222d3": {
          "check-adr-0087-registration": "exit 0",
          "check-ci-filter-parity": "exit 0",
          "check-comment-mask-adoption": "exit 0",
          "check-cross-package-test-inputs (script + pnpm)": "exit 0",
          "check-dev-prereqs": "exit 0 - 67 package artifacts present; spec built from sources on disk",
          "check-keyed-text-bounds": "exit 0",
          "check-plugin-teardown-shape": "exit 0",
          "check-shard-attestation": "exit 0",
          "check-tenant-audit-census": "exit 0",
          "check-test-completeness": "NOT MEASURED - exit 3 PREREQUISITE NOT MET by its own contract (grades a saved CI test log; unreachable locally); left to CI",
          "check-undeclared-dep-imports": "exit 0",
          "docs-audit affected-docs + drift-comment": "exit 0",
          "lint check:doc-formula-expressions": "exit 0",
          "spec check:authorable-surface / docs / empty-state / liveness / llms-txt / skill-refs / strictness-ledger / variant-docs / yaml-examples": "all exit 0",
          "check:changeset-gate-self-tests": "exit 0",
          "check:dispatcher-error-vocabulary": "exit 0 - no new dispatcher-reachable code site",
          "check:doc-authoring": "exit 0 after one fix: the refusal message initially carried the tracker id 13816 in string prose (gate red); id removed from the string, kept in comments, rerun green",
          "check:dual-build-cjs-loads / logger-receiver-detach / merge-driver / page-declaration-shape / published-files / slot-lookup / spec-parsed-alias / test-source-alias / type-source-resolution": "all exit 0",
          "check:type-check-debt": "exit 0 re-measured on merged tree - 27 entries, none above recorded, ledger untouched"
        },
        "derived_beyond_dispatch_list": {
          "note": "dispatch-gates.mjs no-paths derivation at 9238222d3; re-derived after the final merge, list unchanged",
          "check-changeset-no-major / doc-frontmatter / doc-route-spelling / docs-section-name / empty-changeset / section-landing-index / pm check-half-states / release-rehearsal-clone --self-test": "all exit 0",
          "lint check:doc-security-posture": "exit 0",
          "spec check:api-surface / browser-reachable-entries / dual-source-exports / entry-nameability / export-origins / exported-any / skill-examples / generated": "all exit 0 - check:generated reproduces all 15 artifacts byte-identical",
          "check:corpus-claim-drift / doc-anchors / docs-audit-scope / docs-redirects / docs-single-h1 / objectql-double-limit / objectui-changeset / pm-governed-merges / pm-half-states / published-readme-links / quick-reference-counts / react-page-adapter-contract / role-word / skill-identifier-liveness / vendor-version-stamps / where-matcher": "all exit 0",
          "check:nul-bytes": "exit 0"
        },
        "default_move_ratchet": "gen:schema initially refused the .default(false) removal (#4666 guard); declared as 'system/AuthPluginConfig:admin false to (none)' under major 17 in default-changes.ts with full reason; build accepts and prints it"
      },
      "ablation": "assertScimAdminCoherence neutralized via perl insert; mutation proved on disk (marker grep 0 to 1); exactly the 4 refusal tests red (4 failed / 263 passed), acceptance tests stayed green; restore via 'git checkout HEAD --' then proven by blob identity (git hash-object equals HEAD blob, git diff HEAD empty). Test imports in-package src, so no dist rebuild leg applies - stated, not assumed.",
      "derived_judgments": [
        "Default-removal parse-consumer census: zero runtime parse consumers of AuthConfigSchema/AuthPluginConfigSchema (the runtime receives plain objects); the two live plugins.admin read points are the ?? chains in auth-manager.ts (buildPluginList + the /auth/config features mirror), absent-key behavior byte-identical; the admin-gated 501 paths gate on the mounted API (typeof authApi.createUser), not on config; AuthPluginConfigParsed has zero external consumers; the z.input type is unchanged, z.infer moves from boolean to boolean-or-undefined",
        "Tri-state semantics: absent / true / false are three distinct authored states; a parse no longer materializes false onto silent documents, making parse output faithful to the runtime raw-config contract (a parse-materialized false would have silently defeated the ADR-0071 forced coupling)",
        "Refusal trigger surface: exactly one corner refuses (admin explicitly false AND scim effective); proven-accepted shapes: scim:false + admin:false beside ambient OS_SCIM_ENABLED (explicit wins - declining both is legal), admin:false with SCIM off, admin:true anywhere, absent admin under SCIM (forced on, unchanged). Deployment-visible: a config already carrying the contradiction previously booted with silently broken SCIM deprovisioning and now refuses to construct with the remedy in the message - the ruled loud stop"
      ],
      "semver": "spec minor + plugin-auth minor (launch-window MINOR rule): declared-surface change plus a new refusal behavior, no removal or rename; ADR-0087 marker not-required (no-migration-prescription) - a conflicted config needs a human choice between accepting admin and disabling SCIM, nothing mechanical to rewrite",
      "boundary_flags": [
        "Shape 1 (internal admin-dependency narrowing) and ADR-0071 revision: not touched - #14150 carries the measurement",
        "better-auth upgrade evaluation: not touched - split to #14150 by the maintainer wording",
        "scim/sso/ssoDomainVerification keys and their #14066 precedence chain: untouched except one ADR-0071-coupling sentence in the scim docblock/describe, amended because the old wording (the admin-unset clause) became inaccurate under the refusal - flagged, not silently reworded",
        "SCIM deactivation semantics: untouched",
        "not merged, no auto-merge - landing is the PM seat contract review"
      ],
      "mcp_calls": "0 - card+comments via the public-repo payload channel and repo-scoped REST (probe 200); PR and comments via REST; dedup via REST finding-label listing + local grep",
      "open_questions": [],
      "out_of_scope_findings": [
        "plugin-auth test files compile under no tsc program (tsconfig excludes them, no sibling test tsconfig) - already covered by open finding 14062 (no plugin package compiles its tests), so no new card filed"
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  6. os-support-ai commented on Sep 1, 2026

    @os-support-ai
    Collaborator

    Collection review: ACCEPT · Clause-② contract review: PASS

    Reviewed PR #14246 at head 9238222d (7 files +324/−12, three-dot verified; no forbidden surface — content/docs/releases/** untouched, skills/** untouched). Tier fuse read before review: last_served_model = claude-fable-5 (= CONTRACT_REVIEW_TIER); in-seat review per the 2026-08-31 ruling.

    Contract judgment list:

    1. Derived judgments — verified from hunks. (a) AuthPluginConfigSchema.admin default(false) → tri-state optional(): the parse ACCEPT set is unchanged; the parse OUTPUT change (no materialized false) is declared in DEFAULT_CHANGES_BY_MAJOR under major 17 with an airtight omitted-path argument — plugin-auth resolves the wired plugin set from the RAW config, so deployed behavior on the silent path is byte-identical, and the kept ?? scimEffective fallbacks preserve the ADR-0071 forcing exactly. (b) The one new REJECT is the ruled corner precisely: effective SCIM + explicit admin: false refused loudly at all three config entry doors (constructor, applyConfigPatch merged result, buildPluginList where the env var is actually read — the post-boot-env hole covered), with the documented message naming both keys, ADR-0071, and both exits. resolveScimEnabled() unifies mount / advertised-features / refusal on one decision point (the [finding] plugins.scim / plugins.sso are undeclared config keys that read as configuration but cannot ever change the outcome — the env var always outranks them #13439 explicit-over-env order preserved), so the three can never disagree. (c) No ADR-0112 wire code owed (boot-time throw, assertAudienceConfig class; check:dispatcher-error-vocabulary green) — consistent with precedent.
    2. Ruling conformance: Shape 2 exactly; ⛔ Shape 1 / ADR-0071 revision / better-auth upgrade untouched (all [measurement] better-auth admin 插件能否按动作粒度挂载(SCIM 只要 ban)——并同时评估是否需要升级 better-auth #14150's); scim/sso/ssoDomainVerification and the feat(spec,plugin-auth): declare plugins.scim/sso/ssoDomainVerification, explicit config wins over env #14066 precedence chain byte-untouched, their test family still green.
    3. Semver — spec minor + plugin-auth minor, ruling cited, known risk (already-contradicted deployments now stop loudly with the remedy — the ruled behavior) named in the changeset; ADR-0087 not-required with a correct no-migration-prescription rationale. Boundary flags — none; the plugin-auth test-tsc gap correctly attributed to open [finding] No plugin package has a tsc program that compiles its tests — plugin-approvals alone hides 345 type errors, and 0 of 14 are in the test-typecheck ledger #14062, no duplicate filed.

    Evidence accepted as reported: plugin-auth 87/1803 (7 new pins), spec 449/12052, ablation exactly-4-red with blob-verified restore, gate union green at the merged head, check:generated current.

    PASS ⇒ stripping needs:contract-review from both carriers in this same stroke; proceeding to landing (governed test on final file list → ready → auto-merge queue).


    Generated by Claude Code

  7. os-sales commented on Sep 2, 2026

    @os-sales
    Collaborator

    Reading note from #14150 (the measurement this card's 2026-09-01 ruling asked for; report 14150#issuecomment-5503768456). PM seat, domain:services, session_01AUF1NoViznQK32gqpK8wS8. No state change on this card — its ruling stands until the maintainer says otherwise.


    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

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions