Skip to content

Commit a7e18de

Browse files
claude[bot]claude
andauthored
feat(spec,plugin-auth): declare plugins.admin tri-state; refuse explicit admin:false beside effective SCIM at construction (ADR-0071 conflict) (#14246)
* feat(spec,plugin-auth): declare plugins.admin tri-state; refuse explicit admin:false beside effective SCIM (ADR-0071 conflict) Part of #13816 — WIP before artifact regeneration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1 * chore(spec): regen auth-config docs + authorable-defaults for the admin tri-state; declare the default move; add changeset Part of #13816. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1 * fix(plugin-auth): drop tracker id from the conflict-refusal message (doc-authoring gate); keep it in comments Part of #13816. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4b2cbf7 commit a7e18de

7 files changed

Lines changed: 324 additions & 12 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-auth": minor
4+
---
5+
6+
feat(spec,plugin-auth): declare `plugins.admin` tri-state, and refuse an explicit `admin: false` beside effective SCIM at construction time (#13816)
7+
8+
**Behavior change** (maintainer ruling 2026-09-01, director batch #21 on #13816 — the
9+
admin-amplification half split out of #13439 by the 2026-08-31 ruling).
10+
11+
Two halves of one coupling made honest:
12+
13+
- **Declaration.** `AuthPluginConfigSchema.admin` moves from
14+
`z.boolean().default(false)` to tri-state `z.boolean().optional()`, the same
15+
shape #13439 gave `scim` / `sso` / `ssoDomainVerification`. The materialized
16+
`false` could not distinguish "the author said nothing" from "the author
17+
declined the admin surface", and the runtime needs that distinction: absence
18+
means "effective SCIM decides" (SCIM provisioning forces the better-auth
19+
`admin` plugin on because SCIM's `active:false` deprovisioning path runs
20+
through admin ban/unban — ADR-0071), while an explicit value is the author's
21+
own answer. A parse path that materialized `admin: false` onto a silent
22+
document said the opposite of what the runtime does under SCIM.
23+
- **Conflict refusal.** Effective SCIM (explicit `plugins.scim`, or
24+
`OS_SCIM_ENABLED` where the config leaves it unset) beside an explicit
25+
`plugins.admin: false` is now REFUSED loudly — at `AuthManager`
26+
construction, on `applyConfigPatch` (merged result), and at the lazy
27+
better-auth build for env vars that appear after boot. The error names both
28+
keys, the ADR-0071 coupling, and the two ways out: accept the admin plugin,
29+
or disable SCIM. Previously the explicit decline was honoured silently and
30+
SCIM was mounted with its deprovisioning path broken (every `active:false`
31+
ban attempt failing at runtime).
32+
33+
Unchanged, deliberately: an UNSET `admin` under effective SCIM still gets the
34+
admin plugin forced on (the ADR-0071-backed coupling), and without SCIM unset
35+
still means off — the `?? scimEffective` resolution keeps absent semantics
36+
byte-identical. `resolveScimEnabled()` is now the single decision point shared
37+
by the plugin mount, the advertised `/auth/config` features flag and the
38+
conflict refusal, so the three can never disagree.
39+
40+
**Known risk, named:** a deployment already running the contradiction
41+
(`plugins: { scim: true/env-on, admin: false }`) boots today with silently
42+
broken SCIM deprovisioning; after this change it refuses to construct the auth
43+
manager, with the remedy in the message. That loud stop is the ruled behavior.
44+
Whether SCIM's admin dependency can instead be narrowed to the actions it
45+
actually needs (Shape 1) is #14150's measurement; this refusal is the honest
46+
floor pending that reading.
47+
48+
<!-- adr-0087: not-required (no-migration-prescription) The key is neither removed nor renamed — `admin` stays authorable with the same type; only its materialized default is dropped (declared in DEFAULT_CHANGES_BY_MAJOR under major 17) and one incoherent corner (explicit false beside effective SCIM) becomes a loud construction-time refusal whose message carries the remedy. Nothing mechanical for `objectstack migrate meta` to rewrite: a conflicted config needs a human decision between accepting the admin surface and disabling SCIM. -->

content/docs/references/system/auth-config.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,9 @@ Advanced / low-level Better-Auth options
102102
| **oidcProvider** | `boolean` | optional (default: `false`) | Enable the OpenID Connect provider plugin (acts as an OIDC IdP) |
103103
| **dynamicClientRegistration** | `boolean` | optional | Allow unauthenticated RFC 7591 Dynamic Client Registration (default: follows OS_MCP_SERVER_ENABLED) |
104104
| **deviceAuthorization** | `boolean` | optional (default: `false`) | Enable RFC 8628 Device Authorization Grant (CLI / TV-style login) |
105-
| **admin** | `boolean` | optional (default: `false`) | Enable platform admin operations (ban/unban, set-password, impersonate, set-role) |
105+
| **admin** | `boolean` | optional | Enable platform admin operations (ban/unban, set-password, impersonate, set-role). Unset: forced on by effective SCIM (ADR-0071), otherwise off. An explicit false beside effective SCIM is a conflict refused at construction time. |
106106
| **phoneNumber** | `boolean` | optional (default: `false`) | Enable phone-number sign-in (phone + password; OTP sign-in/reset when an SMS service is configured) |
107-
| **scim** | `boolean` | optional | Enable the SCIM 2.0 provisioning surface. Unset: OS_SCIM_ENABLED decides (absent = off); an explicit value wins over the env var. Effective SCIM forces the admin plugin on unless admin is set. |
107+
| **scim** | `boolean` | optional | Enable the SCIM 2.0 provisioning surface. Unset: OS_SCIM_ENABLED decides (absent = off); an explicit value wins over the env var. Effective SCIM forces the admin plugin on when admin is unset; an explicit admin: false beside effective SCIM is refused at construction time. |
108108
| **sso** | `boolean` | optional | Enable enterprise SSO (domain-routed OIDC/SAML sign-in). Unset: OS_SSO_ENABLED decides (absent = off); an explicit value wins over the env var. |
109109
| **ssoDomainVerification** | `boolean` | optional | Enable DNS domain-verification for SSO providers (requires sso). Unset: OS_SSO_DOMAIN_VERIFICATION decides (absent = off); an explicit value wins over the env var. |
110110

@@ -212,9 +212,9 @@ OIDC / Generic OAuth2 provider configuration for enterprise SSO
212212
| **oidcProvider** | `boolean` | optional (default: `false`) | Enable the OpenID Connect provider plugin (acts as an OIDC IdP) |
213213
| **dynamicClientRegistration** | `boolean` | optional | Allow unauthenticated RFC 7591 Dynamic Client Registration (default: follows OS_MCP_SERVER_ENABLED) |
214214
| **deviceAuthorization** | `boolean` | optional (default: `false`) | Enable RFC 8628 Device Authorization Grant (CLI / TV-style login) |
215-
| **admin** | `boolean` | optional (default: `false`) | Enable platform admin operations (ban/unban, set-password, impersonate, set-role) |
215+
| **admin** | `boolean` | optional | Enable platform admin operations (ban/unban, set-password, impersonate, set-role). Unset: forced on by effective SCIM (ADR-0071), otherwise off. An explicit false beside effective SCIM is a conflict refused at construction time. |
216216
| **phoneNumber** | `boolean` | optional (default: `false`) | Enable phone-number sign-in (phone + password; OTP sign-in/reset when an SMS service is configured) |
217-
| **scim** | `boolean` | optional | Enable the SCIM 2.0 provisioning surface. Unset: OS_SCIM_ENABLED decides (absent = off); an explicit value wins over the env var. Effective SCIM forces the admin plugin on unless admin is set. |
217+
| **scim** | `boolean` | optional | Enable the SCIM 2.0 provisioning surface. Unset: OS_SCIM_ENABLED decides (absent = off); an explicit value wins over the env var. Effective SCIM forces the admin plugin on when admin is unset; an explicit admin: false beside effective SCIM is refused at construction time. |
218218
| **sso** | `boolean` | optional | Enable enterprise SSO (domain-routed OIDC/SAML sign-in). Unset: OS_SSO_ENABLED decides (absent = off); an explicit value wins over the env var. |
219219
| **ssoDomainVerification** | `boolean` | optional | Enable DNS domain-verification for SSO providers (requires sso). Unset: OS_SSO_DOMAIN_VERIFICATION decides (absent = off); an explicit value wins over the env var. |
220220

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

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,160 @@ describe('AuthManager', () => {
554554
}
555555
});
556556

557+
// #13816 (maintainer ruling 2026-09-01) — `admin` is tri-state, and the
558+
// one incoherent corner is REFUSED at construction: effective SCIM with an
559+
// explicit `plugins.admin: false` contradicts ADR-0071 (SCIM's
560+
// active:false deprovisioning runs through the admin plugin), so the
561+
// manager throws a documented conflict instead of silently honouring the
562+
// decline and mounting SCIM with a broken deprovisioning path.
563+
it('REFUSES construction when plugins.scim=true and plugins.admin=false (documented ADR-0071 conflict)', () => {
564+
const prev = process.env.OS_SCIM_ENABLED;
565+
delete process.env.OS_SCIM_ENABLED;
566+
try {
567+
expect(() => new AuthManager({
568+
secret: 'test-secret-at-least-32-chars-long',
569+
baseUrl: 'http://localhost:3000',
570+
plugins: { scim: true, admin: false },
571+
})).toThrow(/plugins\.admin[\s\S]*ADR-0071[\s\S]*plugins\.scim: false/);
572+
} finally {
573+
if (prev === undefined) delete process.env.OS_SCIM_ENABLED;
574+
else process.env.OS_SCIM_ENABLED = prev;
575+
}
576+
});
577+
578+
it('REFUSES construction when OS_SCIM_ENABLED makes SCIM effective beside plugins.admin=false', () => {
579+
const prev = process.env.OS_SCIM_ENABLED;
580+
process.env.OS_SCIM_ENABLED = 'true';
581+
try {
582+
expect(() => new AuthManager({
583+
secret: 'test-secret-at-least-32-chars-long',
584+
baseUrl: 'http://localhost:3000',
585+
plugins: { admin: false },
586+
})).toThrow(/OS_SCIM_ENABLED[\s\S]*ADR-0071/);
587+
} finally {
588+
if (prev === undefined) delete process.env.OS_SCIM_ENABLED;
589+
else process.env.OS_SCIM_ENABLED = prev;
590+
}
591+
});
592+
593+
it('accepts plugins.admin=false beside plugins.scim=false despite OS_SCIM_ENABLED (declining both is coherent)', async () => {
594+
let capturedConfig: any;
595+
(betterAuth as any).mockImplementation((config: any) => {
596+
capturedConfig = config;
597+
return { handler: vi.fn(), api: {} };
598+
});
599+
const prev = process.env.OS_SCIM_ENABLED;
600+
process.env.OS_SCIM_ENABLED = 'true';
601+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
602+
try {
603+
const manager = new AuthManager({
604+
secret: 'test-secret-at-least-32-chars-long',
605+
baseUrl: 'http://localhost:3000',
606+
plugins: { scim: false, admin: false },
607+
});
608+
await manager.getAuthInstance();
609+
const ids = capturedConfig.plugins.map((p: any) => p.id);
610+
expect(ids).not.toContain('scim');
611+
expect(ids).not.toContain('admin');
612+
expect(manager.getPublicConfig().features.admin).toBe(false);
613+
} finally {
614+
if (prev === undefined) delete process.env.OS_SCIM_ENABLED;
615+
else process.env.OS_SCIM_ENABLED = prev;
616+
warnSpy.mockRestore();
617+
}
618+
});
619+
620+
it('honours an explicit plugins.admin=false when SCIM is not effective (tri-state decline)', async () => {
621+
let capturedConfig: any;
622+
(betterAuth as any).mockImplementation((config: any) => {
623+
capturedConfig = config;
624+
return { handler: vi.fn(), api: {} };
625+
});
626+
const prev = process.env.OS_SCIM_ENABLED;
627+
delete process.env.OS_SCIM_ENABLED;
628+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
629+
try {
630+
const manager = new AuthManager({
631+
secret: 'test-secret-at-least-32-chars-long',
632+
baseUrl: 'http://localhost:3000',
633+
plugins: { admin: false },
634+
});
635+
await manager.getAuthInstance();
636+
expect(capturedConfig.plugins.map((p: any) => p.id)).not.toContain('admin');
637+
expect(manager.getPublicConfig().features.admin).toBe(false);
638+
} finally {
639+
if (prev === undefined) delete process.env.OS_SCIM_ENABLED;
640+
else process.env.OS_SCIM_ENABLED = prev;
641+
warnSpy.mockRestore();
642+
}
643+
});
644+
645+
it('honours an explicit plugins.admin=true without SCIM (tri-state enable)', async () => {
646+
let capturedConfig: any;
647+
(betterAuth as any).mockImplementation((config: any) => {
648+
capturedConfig = config;
649+
return { handler: vi.fn(), api: {} };
650+
});
651+
const prev = process.env.OS_SCIM_ENABLED;
652+
delete process.env.OS_SCIM_ENABLED;
653+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
654+
try {
655+
const manager = new AuthManager({
656+
secret: 'test-secret-at-least-32-chars-long',
657+
baseUrl: 'http://localhost:3000',
658+
plugins: { admin: true },
659+
});
660+
await manager.getAuthInstance();
661+
expect(capturedConfig.plugins.map((p: any) => p.id)).toContain('admin');
662+
expect(manager.getPublicConfig().features.admin).toBe(true);
663+
} finally {
664+
if (prev === undefined) delete process.env.OS_SCIM_ENABLED;
665+
else process.env.OS_SCIM_ENABLED = prev;
666+
warnSpy.mockRestore();
667+
}
668+
});
669+
670+
it('REFUSES an applyConfigPatch that smuggles admin=false into a SCIM-enabled config — the standing config keeps ruling', () => {
671+
const prev = process.env.OS_SCIM_ENABLED;
672+
delete process.env.OS_SCIM_ENABLED;
673+
try {
674+
const manager = new AuthManager({
675+
secret: 'test-secret-at-least-32-chars-long',
676+
baseUrl: 'http://localhost:3000',
677+
plugins: { scim: true },
678+
});
679+
expect(() => manager.applyConfigPatch({ plugins: { admin: false } }))
680+
.toThrow(/ADR-0071/);
681+
// The refused patch never became current: the advertised admin flag
682+
// still reflects the SCIM-forced coupling.
683+
expect(manager.getPublicConfig().features.admin).toBe(true);
684+
} finally {
685+
if (prev === undefined) delete process.env.OS_SCIM_ENABLED;
686+
else process.env.OS_SCIM_ENABLED = prev;
687+
}
688+
});
689+
690+
it('REFUSES the lazy better-auth build when OS_SCIM_ENABLED appears only after construction', async () => {
691+
const prev = process.env.OS_SCIM_ENABLED;
692+
delete process.env.OS_SCIM_ENABLED;
693+
try {
694+
// Coherent at boot: no SCIM anywhere, admin declined.
695+
const manager = new AuthManager({
696+
secret: 'test-secret-at-least-32-chars-long',
697+
baseUrl: 'http://localhost:3000',
698+
plugins: { admin: false },
699+
});
700+
// The env var is actually read at buildPluginList() time — a value
701+
// appearing between construction and the lazy build must not mount
702+
// SCIM with its deprovisioning path silently declined.
703+
process.env.OS_SCIM_ENABLED = 'true';
704+
await expect(manager.getAuthInstance()).rejects.toThrow(/ADR-0071/);
705+
} finally {
706+
if (prev === undefined) delete process.env.OS_SCIM_ENABLED;
707+
else process.env.OS_SCIM_ENABLED = prev;
708+
}
709+
});
710+
557711
it('blocks slug change when the org has active environments', async () => {
558712
let capturedConfig: any;
559713
(betterAuth as any).mockImplementation((config: any) => {

0 commit comments

Comments
 (0)