Skip to content

Commit 08f93bc

Browse files
os-zhuangclaude
andauthored
fix(auth): organization/create 判权威的 OS_TENANCY_POSTURE,不再判被降级的 OS_MULTI_ORG_ENABLED (#5233) (#5267)
* fix(auth): gate organization/create on OS_TENANCY_POSTURE, not the demoted OS_MULTI_ORG_ENABLED (#5233) ADR-0105 D1 made `OS_TENANCY_POSTURE` the canonical tenancy knob and demoted `OS_MULTI_ORG_ENABLED` to a back-compat INPUT of `resolveTenancyPosture()`. Two sites in AuthManager kept reading the demoted boolean directly, so a deployment configured the documented way — posture only, legacy boolean unset — mounted the entire organization wall and still answered `403 Creating additional organizations is disabled on this deployment.` - `organizationHooks.beforeCreateOrganization` now judges `postureEnforcesWall(resolveTenancyPosture())`, the same knob serve.ts's ADR-0093 D5 boot guard keys on. Intent unchanged (single-org still refuses); only the knob is corrected, so the gate reads the REQUESTED posture exactly as the old boolean did. - `/auth/config`'s `features.multiOrgEnabled` keeps preferring the `tenancy` service, but its no-service fallback now resolves the posture instead of the demoted boolean. `resolveMultiOrgEnabled()`'s doc comment — which still instructed both of those sites to call it, written before the demotion — now says the opposite. Its code semantics, and resolveTenancyPosture()'s fallback chain, are untouched. New `org-create-posture-gate.test.ts` drives the real better-auth pipeline (sign-up + POST /organization/create through AuthManager.handleRequest) across posture-only, legacy-only, group, single and degraded deployments, and asserts `/auth/config` predicts the route's answer in each. Ten of its cases fail against the pre-fix source. Fixes #5233 Refs cloud#1012, cloud#1020, #5261, #5262 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t * test(plugin-auth): pin org-create posture gate's engine double to ObjectQL delete dispatch The #5233 fake engine's delete() accepted call shapes ObjectQLEngine.delete refuses, so check:engine-double-contract flagged it as an unpinned double. Route it through assertEngineDeleteDispatch from '@objectstack/objectql' — the same in-package pattern as auth-manager.jwt-eddsa-fallback.test.ts and session-of-record.test.ts (#4550) — rather than taking a baseline entry. The devDependency was already present from #5044. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1b9a53b commit 08f93bc

4 files changed

Lines changed: 534 additions & 29 deletions

File tree

.changeset/gentle-buttons-shave.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/types': patch
4+
---
5+
6+
fix(auth): `organization/create` gates on the authoritative `OS_TENANCY_POSTURE`, not the demoted `OS_MULTI_ORG_ENABLED` (#5233)
7+
8+
A deployment configured the documented way — `OS_TENANCY_POSTURE=isolated` (or
9+
`group`), legacy boolean unset — mounted the entire organization wall and still
10+
answered `403 Creating additional organizations is disabled on this deployment.`
11+
to `POST /api/v1/auth/organization/create`. Org-less users had no way to create
12+
their workspace, so the guided "Create your workspace" path was a dead end.
13+
14+
ADR-0105 D1 made `OS_TENANCY_POSTURE` the canonical knob and demoted
15+
`OS_MULTI_ORG_ENABLED` to a back-compat *input* of `resolveTenancyPosture()`.
16+
Two sites in `AuthManager` kept reading the demoted boolean directly, so both
17+
reported "single-org" on a deployment that had asked for a wall and got one:
18+
19+
- `organizationHooks.beforeCreateOrganization` — the 403 above. It now judges
20+
`postureEnforcesWall(resolveTenancyPosture())`, matching the knob `serve.ts`'s
21+
own ADR-0093 D5 boot guard keys on. Intent is unchanged (single-org still
22+
refuses); only the knob is corrected.
23+
- `/auth/config`'s `features.multiOrgEnabled` — its no-tenancy-service fallback
24+
read the same boolean. It now falls back to the resolved posture, so a lean
25+
embedding advertises the capability its own gate allows.
26+
27+
**No configuration change is needed anywhere.** Deployments that set only
28+
`OS_MULTI_ORG_ENABLED=true` keep working unchanged — `resolveTenancyPosture()`
29+
falls back to it — and the `OS_TENANCY_POSTURE=isolated` + `OS_MULTI_ORG_ENABLED=true`
30+
workaround people used to unblock themselves stays valid. Deployments that set
31+
only `OS_TENANCY_POSTURE` can now drop the redundant boolean.
32+
33+
`resolveMultiOrgEnabled()`'s doc comment in `@objectstack/types` — which still
34+
instructed "the auth manager's `/auth/config` feature flag and org-create guard
35+
… MUST call this", written before the demotion — now says the opposite: ask the
36+
posture, and never gate on this boolean. Its behaviour is unchanged.

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

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@ import type {
1212
} from '@objectstack/spec/system';
1313
import type { IDataEngine } from '@objectstack/core';
1414
import type { IEmailService, ISmsService } from '@objectstack/spec/contracts';
15-
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveOrgLimit, isMcpServerEnabled } from '@objectstack/types';
15+
import { readEnvWithDeprecation, resolveTenancyPosture, resolveOrgLimit, isMcpServerEnabled } from '@objectstack/types';
1616
import {
1717
mapMembershipRole,
1818
BUILTIN_IDENTITY_PLATFORM_ADMIN,
1919
MEMBERSHIP_ROLE_DELEGATED_ADMIN,
2020
} from '@objectstack/spec';
21+
import { postureEnforcesWall } from '@objectstack/spec/security';
2122
import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
2223
import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js';
2324
import { runWithAuthActorScope, setAuthActorResolver } from './auth-actor-attribution.js';
@@ -1891,12 +1892,20 @@ export class AuthManager {
18911892
// never seed `sys_environment`) keep working: any lookup error
18921893
// is treated as "no envs to protect".
18931894
organizationHooks: {
1894-
// Gate fresh organization creation behind the multi-org flag.
1895-
// The plugin itself is always installed (so list/update/invite endpoints
1896-
// keep responding); only the `create` operation is denied when the
1897-
// deployment is provisioned in single-org mode. Resolution order:
1898-
// `OS_MULTI_ORG_ENABLED` (default `'false'` → single-org /
1899-
// per-env runtime).
1895+
// Gate fresh organization creation behind the deployment's TENANCY
1896+
// POSTURE. The plugin itself is always installed (so list/update/invite
1897+
// endpoints keep responding); only the `create` operation is denied,
1898+
// and only where no organization wall is enforced — creating an
1899+
// organization there would mint a boundary nothing keeps (ADR-0049 at
1900+
// the deployment layer).
1901+
//
1902+
// [#5233] The judge is the REQUESTED tenancy posture
1903+
// (`multiOrgPostureRequested()`), never the `OS_MULTI_ORG_ENABLED`
1904+
// boolean ADR-0105 D1 demoted — that one reads `false` on a
1905+
// deployment configured with only the authoritative
1906+
// `OS_TENANCY_POSTURE=isolated`, so the whole organization wall
1907+
// mounted and every org-less user's guided "create your workspace"
1908+
// path still 403'd. Same defect shape as cloud#1020.
19001909
beforeCreateOrganization: async ({ organization }: any = {}) => {
19011910
// [ADR-0120 D3] `'__global__'` is the platform's name for the
19021911
// NULL-organization bucket: the autonumber sequence table keys
@@ -1913,7 +1922,7 @@ export class AuthManager {
19131922
'(ADR-0120 D3) and cannot be used as an organization id or slug.',
19141923
});
19151924
}
1916-
if (!resolveMultiOrgEnabled()) {
1925+
if (!this.multiOrgPostureRequested()) {
19171926
const { APIError } = await import('better-auth/api');
19181927
throw new APIError('FORBIDDEN', {
19191928
message:
@@ -3185,6 +3194,34 @@ export class AuthManager {
31853194
return readSsoOnlyEnv() ?? (this.config.ssoOnlyMode ?? false);
31863195
}
31873196

3197+
/**
3198+
* [ADR-0105 D1 / #5233] Does this deployment ASK for a multi-organization
3199+
* posture? The `beforeCreateOrganization` gate's judge.
3200+
*
3201+
* ⛔ Never `resolveMultiOrgEnabled()`. ADR-0105 D1 DEMOTED that boolean to a
3202+
* back-compat INPUT of `resolveTenancyPosture()`, so it reads `false` on a
3203+
* deployment configured with only the authoritative `OS_TENANCY_POSTURE` —
3204+
* the exact inversion of the declared contract. It shipped twice: cloud#1020
3205+
* (the EE licence gate) and #5233, where a fully walled
3206+
* `OS_TENANCY_POSTURE=isolated` deployment 403'd `organization/create`, so
3207+
* every org-less user's guided "create your workspace" path dead-ended while
3208+
* `/auth/config` advertised the capability as present.
3209+
*
3210+
* REQUESTED, not effective — the same fact `serve.ts`'s ADR-0093 D5 boot
3211+
* guard keys on (`resolveTenancyPosture() !== 'single'`), and the same fact
3212+
* the old boolean expressed, so this corrects the KNOB and nothing else.
3213+
* Whether a requested wall is actually ENFORCED is the `tenancy` service's
3214+
* separate answer (`degraded`), which `/auth/config` reports and this gate
3215+
* deliberately does not consult; #5261 carries that question.
3216+
*
3217+
* Read live on every call — never cached. The posture is process-level
3218+
* config, and freezing it at plugin-build time would make the gate unable to
3219+
* see anything a later boot phase (or a test) established.
3220+
*/
3221+
private multiOrgPostureRequested(): boolean {
3222+
return postureEnforcesWall(resolveTenancyPosture());
3223+
}
3224+
31883225
getPublicConfig() {
31893226
// Extract social providers info (without sensitive data)
31903227
const socialProviders = [];
@@ -3244,16 +3281,23 @@ export class AuthManager {
32443281
// Extract enabled features
32453282
const pluginConfig: Partial<AuthPluginConfig> = this.config.plugins ?? {};
32463283
// Multi-org capability (UI org-switcher, "create org" action, etc.).
3247-
// `OS_MULTI_ORG_ENABLED` (default `'false'` → single-org / per-env runtime).
32483284
// ADR-0093 D4 / ADR-0105 D1 — the `tenancy` service is the single source of
3249-
// truth. Prefer it; fall back to the raw env flag only when it isn't wired
3250-
// (e.g. a lean embedding). `multiOrgEnabled` reflects ACTUAL capability —
3251-
// any posture that enforces an organization wall (`group` or `isolated`) —
3252-
// so a degraded deployment (requested but no isolation resolves to `single`)
3253-
// reports `false` and the org-management UI hides instead of rendering broken.
3285+
// truth. Prefer it; fall back to the resolved POSTURE only when it isn't
3286+
// wired (e.g. a lean embedding). `multiOrgEnabled` reflects ACTUAL
3287+
// capability — any posture that enforces an organization wall (`group` or
3288+
// `isolated`) — so a degraded deployment (requested but no isolation
3289+
// resolves to `single`) reports `false` and the org-management UI hides
3290+
// instead of rendering broken.
3291+
//
3292+
// [#5233] That fallback used to read `resolveMultiOrgEnabled()`, the
3293+
// boolean ADR-0105 D1 demoted, which reports `false` on a deployment that
3294+
// sets only the authoritative `OS_TENANCY_POSTURE` — the same stale
3295+
// contract that broke the org-create gate, one site over. It reads the
3296+
// posture now, so an unwired-tenancy embedding advertises the capability
3297+
// its gate actually allows.
32543298
const tenancy = this.config.getTenancy?.();
3255-
const tenancyPosture = tenancy?.posture ?? (resolveMultiOrgEnabled() ? 'isolated' : 'single');
3256-
const multiOrgEnabled = tenancyPosture !== 'single';
3299+
const tenancyPosture = tenancy?.posture ?? resolveTenancyPosture();
3300+
const multiOrgEnabled = postureEnforcesWall(tenancyPosture);
32573301
const degradedTenancy = tenancy?.degraded ?? false;
32583302

32593303
// Legal links shown beneath the login / register cards. Defaults to

0 commit comments

Comments
 (0)