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
48 changes: 48 additions & 0 deletions .changeset/scim-admin-tristate-conflict-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@objectstack/spec": minor
"@objectstack/plugin-auth": minor
---

feat(spec,plugin-auth): declare `plugins.admin` tri-state, and refuse an explicit `admin: false` beside effective SCIM at construction time (#13816)

**Behavior change** (maintainer ruling 2026-09-01, director batch #21 on #13816 — the
admin-amplification half split out of #13439 by the 2026-08-31 ruling).

Two halves of one coupling made honest:

- **Declaration.** `AuthPluginConfigSchema.admin` moves from
`z.boolean().default(false)` to tri-state `z.boolean().optional()`, the same
shape #13439 gave `scim` / `sso` / `ssoDomainVerification`. The materialized
`false` could not distinguish "the author said nothing" from "the author
declined the admin surface", and the runtime needs that distinction: absence
means "effective SCIM decides" (SCIM provisioning forces the better-auth
`admin` plugin on because SCIM's `active:false` deprovisioning path runs
through admin ban/unban — ADR-0071), while an explicit value is the author's
own answer. A parse path that materialized `admin: false` onto a silent
document said the opposite of what the runtime does under SCIM.
- **Conflict refusal.** Effective SCIM (explicit `plugins.scim`, or
`OS_SCIM_ENABLED` where the config leaves it unset) beside an explicit
`plugins.admin: false` is now REFUSED loudly — at `AuthManager`
construction, on `applyConfigPatch` (merged result), and at the lazy
better-auth build for env vars that appear after boot. The error names both
keys, the ADR-0071 coupling, and the two ways out: accept the admin plugin,
or disable SCIM. Previously the explicit decline was honoured silently and
SCIM was mounted with its deprovisioning path broken (every `active:false`
ban attempt failing at runtime).

Unchanged, deliberately: an UNSET `admin` under effective SCIM still gets the
admin plugin forced on (the ADR-0071-backed coupling), and without SCIM unset
still means off — the `?? scimEffective` resolution keeps absent semantics
byte-identical. `resolveScimEnabled()` is now the single decision point shared
by the plugin mount, the advertised `/auth/config` features flag and the
conflict refusal, so the three can never disagree.

**Known risk, named:** a deployment already running the contradiction
(`plugins: { scim: true/env-on, admin: false }`) boots today with silently
broken SCIM deprovisioning; after this change it refuses to construct the auth
manager, with the remedy in the message. That loud stop is the ruled behavior.
Whether SCIM's admin dependency can instead be narrowed to the actions it
actually needs (Shape 1) is #14150's measurement; this refusal is the honest
floor pending that reading.

<!-- 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. -->
8 changes: 4 additions & 4 deletions content/docs/references/system/auth-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,9 @@ Advanced / low-level Better-Auth options
| **oidcProvider** | `boolean` | optional (default: `false`) | Enable the OpenID Connect provider plugin (acts as an OIDC IdP) |
| **dynamicClientRegistration** | `boolean` | optional | Allow unauthenticated RFC 7591 Dynamic Client Registration (default: follows OS_MCP_SERVER_ENABLED) |
| **deviceAuthorization** | `boolean` | optional (default: `false`) | Enable RFC 8628 Device Authorization Grant (CLI / TV-style login) |
| **admin** | `boolean` | optional (default: `false`) | Enable platform admin operations (ban/unban, set-password, impersonate, set-role) |
| **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. |
| **phoneNumber** | `boolean` | optional (default: `false`) | Enable phone-number sign-in (phone + password; OTP sign-in/reset when an SMS service is configured) |
| **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. |
| **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. |
| **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. |
| **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. |

Expand Down Expand Up @@ -212,9 +212,9 @@ OIDC / Generic OAuth2 provider configuration for enterprise SSO
| **oidcProvider** | `boolean` | optional (default: `false`) | Enable the OpenID Connect provider plugin (acts as an OIDC IdP) |
| **dynamicClientRegistration** | `boolean` | optional | Allow unauthenticated RFC 7591 Dynamic Client Registration (default: follows OS_MCP_SERVER_ENABLED) |
| **deviceAuthorization** | `boolean` | optional (default: `false`) | Enable RFC 8628 Device Authorization Grant (CLI / TV-style login) |
| **admin** | `boolean` | optional (default: `false`) | Enable platform admin operations (ban/unban, set-password, impersonate, set-role) |
| **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. |
| **phoneNumber** | `boolean` | optional (default: `false`) | Enable phone-number sign-in (phone + password; OTP sign-in/reset when an SMS service is configured) |
| **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. |
| **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. |
| **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. |
| **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. |

Expand Down
154 changes: 154 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,160 @@ describe('AuthManager', () => {
}
});

// #13816 (maintainer ruling 2026-09-01) — `admin` is tri-state, and the
// one incoherent corner is REFUSED at construction: effective SCIM with an
// explicit `plugins.admin: false` contradicts ADR-0071 (SCIM's
// active:false deprovisioning runs through the admin plugin), so the
// manager throws a documented conflict instead of silently honouring the
// decline and mounting SCIM with a broken deprovisioning path.
it('REFUSES construction when plugins.scim=true and plugins.admin=false (documented ADR-0071 conflict)', () => {
const prev = process.env.OS_SCIM_ENABLED;
delete process.env.OS_SCIM_ENABLED;
try {
expect(() => new AuthManager({
secret: 'test-secret-at-least-32-chars-long',
baseUrl: 'http://localhost:3000',
plugins: { scim: true, admin: false },
})).toThrow(/plugins\.admin[\s\S]*ADR-0071[\s\S]*plugins\.scim: false/);
} finally {
if (prev === undefined) delete process.env.OS_SCIM_ENABLED;
else process.env.OS_SCIM_ENABLED = prev;
}
});

it('REFUSES construction when OS_SCIM_ENABLED makes SCIM effective beside plugins.admin=false', () => {
const prev = process.env.OS_SCIM_ENABLED;
process.env.OS_SCIM_ENABLED = 'true';
try {
expect(() => new AuthManager({
secret: 'test-secret-at-least-32-chars-long',
baseUrl: 'http://localhost:3000',
plugins: { admin: false },
})).toThrow(/OS_SCIM_ENABLED[\s\S]*ADR-0071/);
} finally {
if (prev === undefined) delete process.env.OS_SCIM_ENABLED;
else process.env.OS_SCIM_ENABLED = prev;
}
});

it('accepts plugins.admin=false beside plugins.scim=false despite OS_SCIM_ENABLED (declining both is coherent)', async () => {
let capturedConfig: any;
(betterAuth as any).mockImplementation((config: any) => {
capturedConfig = config;
return { handler: vi.fn(), api: {} };
});
const prev = process.env.OS_SCIM_ENABLED;
process.env.OS_SCIM_ENABLED = 'true';
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const manager = new AuthManager({
secret: 'test-secret-at-least-32-chars-long',
baseUrl: 'http://localhost:3000',
plugins: { scim: false, admin: false },
});
await manager.getAuthInstance();
const ids = capturedConfig.plugins.map((p: any) => p.id);
expect(ids).not.toContain('scim');
expect(ids).not.toContain('admin');
expect(manager.getPublicConfig().features.admin).toBe(false);
} finally {
if (prev === undefined) delete process.env.OS_SCIM_ENABLED;
else process.env.OS_SCIM_ENABLED = prev;
warnSpy.mockRestore();
}
});

it('honours an explicit plugins.admin=false when SCIM is not effective (tri-state decline)', async () => {
let capturedConfig: any;
(betterAuth as any).mockImplementation((config: any) => {
capturedConfig = config;
return { handler: vi.fn(), api: {} };
});
const prev = process.env.OS_SCIM_ENABLED;
delete process.env.OS_SCIM_ENABLED;
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const manager = new AuthManager({
secret: 'test-secret-at-least-32-chars-long',
baseUrl: 'http://localhost:3000',
plugins: { admin: false },
});
await manager.getAuthInstance();
expect(capturedConfig.plugins.map((p: any) => p.id)).not.toContain('admin');
expect(manager.getPublicConfig().features.admin).toBe(false);
} finally {
if (prev === undefined) delete process.env.OS_SCIM_ENABLED;
else process.env.OS_SCIM_ENABLED = prev;
warnSpy.mockRestore();
}
});

it('honours an explicit plugins.admin=true without SCIM (tri-state enable)', async () => {
let capturedConfig: any;
(betterAuth as any).mockImplementation((config: any) => {
capturedConfig = config;
return { handler: vi.fn(), api: {} };
});
const prev = process.env.OS_SCIM_ENABLED;
delete process.env.OS_SCIM_ENABLED;
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const manager = new AuthManager({
secret: 'test-secret-at-least-32-chars-long',
baseUrl: 'http://localhost:3000',
plugins: { admin: true },
});
await manager.getAuthInstance();
expect(capturedConfig.plugins.map((p: any) => p.id)).toContain('admin');
expect(manager.getPublicConfig().features.admin).toBe(true);
} finally {
if (prev === undefined) delete process.env.OS_SCIM_ENABLED;
else process.env.OS_SCIM_ENABLED = prev;
warnSpy.mockRestore();
}
});

it('REFUSES an applyConfigPatch that smuggles admin=false into a SCIM-enabled config — the standing config keeps ruling', () => {
const prev = process.env.OS_SCIM_ENABLED;
delete process.env.OS_SCIM_ENABLED;
try {
const manager = new AuthManager({
secret: 'test-secret-at-least-32-chars-long',
baseUrl: 'http://localhost:3000',
plugins: { scim: true },
});
expect(() => manager.applyConfigPatch({ plugins: { admin: false } }))
.toThrow(/ADR-0071/);
// The refused patch never became current: the advertised admin flag
// still reflects the SCIM-forced coupling.
expect(manager.getPublicConfig().features.admin).toBe(true);
} finally {
if (prev === undefined) delete process.env.OS_SCIM_ENABLED;
else process.env.OS_SCIM_ENABLED = prev;
}
});

it('REFUSES the lazy better-auth build when OS_SCIM_ENABLED appears only after construction', async () => {
const prev = process.env.OS_SCIM_ENABLED;
delete process.env.OS_SCIM_ENABLED;
try {
// Coherent at boot: no SCIM anywhere, admin declined.
const manager = new AuthManager({
secret: 'test-secret-at-least-32-chars-long',
baseUrl: 'http://localhost:3000',
plugins: { admin: false },
});
// The env var is actually read at buildPluginList() time — a value
// appearing between construction and the lazy build must not mount
// SCIM with its deprovisioning path silently declined.
process.env.OS_SCIM_ENABLED = 'true';
await expect(manager.getAuthInstance()).rejects.toThrow(/ADR-0071/);
} finally {
if (prev === undefined) delete process.env.OS_SCIM_ENABLED;
else process.env.OS_SCIM_ENABLED = prev;
}
});

it('blocks slug change when the org has active environments', async () => {
let capturedConfig: any;
(betterAuth as any).mockImplementation((config: any) => {
Expand Down
Loading
Loading