Skip to content

Commit 7fb436c

Browse files
authored
fix(authz)!: make the group posture an entitlement again — ADR-0105 D12 correction (#3570)
Follow-up to #3559, which read ADR-0105 D12 as "the `group` wall ships open" and made the posture self-activating — it never probed for @objectstack/organizations. That turned `group` into a free multi-org path around the `isolated` gate (ADR-0081 D2), leaving the WEAKER isolation as the free one, and it opened a silent-degradation hole: `os serve` still gated the enterprise package load on OS_MULTI_ORG_ENABLED, so OS_TENANCY_POSTURE=group skipped both the load and the ADR-0093 D5 fail-fast. The distinction missed: open code is not free activation. The wall's implementation has always lived in open packages — equally true of `isolated`, whose Layer 0 wall sits in plugin-security gated on a service the enterprise package registers. Cloud ADR-0016's 铁律 guarantees a deployment RUNNING a multi-org shape is safe; that is satisfied by REFUSING to run one unwalled, not by giving the posture away. - tenancy-service: `group` probes `org-scoping` exactly like `isolated`; without it the posture resolves to `single` + `degraded`. - serve.ts: the ADR-0093 D5 boot guard keys off the resolved POSTURE, with posture-aware messaging. A `group` request without the runtime now refuses to boot unless OS_ALLOW_DEGRADED_TENANCY=1. - New seam: `org-scoping` may declare `supportedPostures` (`OrgScopingEntitlement`); the open side honours it and fails closed on anything unlisted. Whether `group` and `isolated` are one commercial tier or two is packaging policy, which belongs to the commercial runtime rather than hard-coded in open core. Omitting it entitles every walled posture. - `organization_id` STAMPING returns to the enterprise runtime. #3559 moved it into the open engine, removing the closed package's only load-bearing runtime duty — a five-line forged `org-scoping` registration would then have produced a fully working multi-org deployment. Back where it was, a forged registration yields NULL-org rows the wall hides. Write-side VALIDATION stays open and unchanged, bulk-insert coverage included: rejecting a forged organization_id is a security property, not a packaging one. - Default-org bootstrap returns to `single`-only (ADR-0081 D1). - Documents OS_TENANCY_POSTURE in the environment-variables reference. Operator note: OS_TENANCY_POSTURE=group without the enterprise runtime now refuses to boot rather than silently running single-org.
1 parent 48d5a1c commit 7fb436c

12 files changed

Lines changed: 330 additions & 129 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
'@objectstack/plugin-security': minor
4+
'@objectstack/spec': minor
5+
'@objectstack/cli': patch
6+
---
7+
8+
Multi-organization operation is an ENTITLEMENT again: the `group` posture no
9+
longer activates without the enterprise runtime (ADR-0105 D12 correction).
10+
11+
The first ADR-0105 wave read D12 as "the `group` wall ships open" and made the
12+
posture self-activating — it never probed for `@objectstack/organizations`. That
13+
turned `group` into a free multi-org path around the `isolated` gate (ADR-0081
14+
D2), and made the weaker isolation the free one, which is not a boundary anyone
15+
would draw on purpose.
16+
17+
The distinction that was missed: **open code is not free activation.** The wall's
18+
implementation has always lived in the open packages — that is equally true of
19+
`isolated`, whose Layer 0 wall sits in `plugin-security` and is gated on a
20+
service the enterprise package registers. Cloud ADR-0016's 铁律
21+
(强制免费、治理收费) guarantees that a deployment RUNNING a multi-org shape is
22+
safe; it is satisfied by REFUSING to run one unwalled, not by giving the posture
23+
away.
24+
25+
## Changes
26+
27+
- **`tenancy-service`**: `group` probes `org-scoping` exactly like `isolated`.
28+
Without it the posture resolves to `single` and reports `degraded`.
29+
- **`os serve`**: the ADR-0093 D5 boot guard keys off the resolved POSTURE
30+
instead of `OS_MULTI_ORG_ENABLED`. Previously `OS_TENANCY_POSTURE=group` skipped
31+
both the enterprise package load AND the fail-fast, silently degrading to an
32+
unwalled deployment — the exact ADR-0049 class that guard exists to close. A
33+
`group` request without the runtime now refuses to boot unless
34+
`OS_ALLOW_DEGRADED_TENANCY=1`.
35+
- **New seam — the runtime declares what it entitles.** `org-scoping` may expose
36+
`supportedPostures` (`OrgScopingEntitlement`, `@objectstack/spec/security`);
37+
the open side honours it and fails closed on anything not listed. Whether
38+
`group` and `isolated` are one commercial tier or two is packaging policy, and
39+
packaging policy belongs to the commercial runtime rather than hard-coded in
40+
open core. Omitting the field entitles every walled posture, so existing
41+
runtimes are unaffected.
42+
- **`organization_id` stamping returns to the enterprise runtime.** The previous
43+
wave moved auto-stamping into the open engine; that removed the closed
44+
package's only load-bearing runtime duty, so a five-line forged `org-scoping`
45+
registration would have produced a fully working multi-org deployment. With
46+
stamping back where it was, a forged registration yields NULL-org rows the wall
47+
hides — a broken deployment, not an unlicensed working one.
48+
49+
**Write-side VALIDATION stays open and is unchanged**, including the
50+
bulk-insert coverage: rejecting a forged `organization_id` is a security
51+
property, not a packaging one. Only filling an ABSENT value moved back.
52+
- Default-organization bootstrap returns to `single`-only; every walled posture
53+
keeps its existing owner (ADR-0081 D1).
54+
55+
## Note for operators
56+
57+
`OS_TENANCY_POSTURE=group` without `@objectstack/organizations` installed now
58+
**refuses to boot** rather than running single-org. This only affects
59+
deployments that adopted `group` between the two waves.

content/docs/deployment/environment-variables.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
7979
| `OS_AUTH_GOOGLE_ENABLED` | boolean | settings default | Settings env override for `auth.google_enabled`. Requires Google OAuth credentials from Settings or env. |
8080
| `GOOGLE_CLIENT_ID` | string || Deployment-level Google OAuth client id for the open-source Google login implementation. |
8181
| `GOOGLE_CLIENT_SECRET` | string || Deployment-level Google OAuth client secret for the open-source Google login implementation. |
82-
| `OS_MULTI_ORG_ENABLED` | boolean | `false` | When `true`, expose organization creation/switching UI. When `false`, run in single-tenant mode. |
82+
| `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = <active organization>`, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true``isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. |
83+
| `OS_MULTI_ORG_ENABLED` | boolean | `false` | Superseded by `OS_TENANCY_POSTURE`, and still honoured: `true` selects the `isolated` posture. When `true`, organization creation/switching UI is exposed. |
8384
| `OS_OIDC_PROVIDER_ENABLED` | boolean | tracks MCP | When `true`, expose this instance as an OIDC identity provider. When unset it follows the MCP server surface (`OS_MCP_SERVER_ENABLED`, on by default) — the MCP human-client track is OAuth 2.1, so every MCP-enabled deployment is its own authorization server. |
8485
| `OS_COOKIE_DOMAIN` | string || Cookie domain for cross-subdomain session sharing (e.g. `.example.com`). |
8586
| `OS_TRUSTED_ORIGINS` | csv || Comma-separated list of origins permitted for auth callbacks / CSRF. |

packages/cli/src/commands/serve.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { bundleRequire } from 'bundle-require';
99
import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
1010
import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js';
1111
import { resolveDriverType, createStorageDriver, UnsupportedDriverError } from '../utils/storage-driver.js';
12-
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveAllowDegradedTenancy, isMcpServerEnabled, resolveSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types';
12+
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, resolveSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types';
1313
import { PLATFORM_CAPABILITY_TOKENS } from '@objectstack/spec/kernel';
1414
import { missingProviderMessage } from '../utils/capability-preflight.js';
1515
import { resolveObjectStackHome } from '@objectstack/runtime';
@@ -1588,7 +1588,14 @@ export default class Serve extends Command {
15881588
// it, deployments are single-org: the open member-management
15891589
// basics (plugin-auth's default-org bootstrap + better-auth
15901590
// invitations) still work.
1591-
const multiTenant = resolveMultiOrgEnabled();
1591+
// [ADR-0105 D1] Key off the resolved POSTURE, not the legacy boolean.
1592+
// Both walled postures (`group` and `isolated`) need this package:
1593+
// gating on `OS_MULTI_ORG_ENABLED` alone would let
1594+
// `OS_TENANCY_POSTURE=group` skip the load AND the fail-fast below,
1595+
// silently degrading to an unwalled single-org deployment — the exact
1596+
// ADR-0049 class this guard exists to close.
1597+
const tenancyPosture = resolveTenancyPosture();
1598+
const multiTenant = tenancyPosture !== 'single';
15921599
if (multiTenant) {
15931600
try {
15941601
const organizationsPkg = '@objectstack/organizations';
@@ -1612,12 +1619,13 @@ export default class Serve extends Command {
16121619
if (!resolveAllowDegradedTenancy()) {
16131620
console.error(
16141621
chalk.red(
1615-
'\n ✖ FATAL: OS_MULTI_ORG_ENABLED=true but @objectstack/organizations could not be loaded,\n' +
1616-
' so tenant isolation is INACTIVE. Refusing to boot — a deployment that requested\n' +
1617-
' multi-tenant isolation must not serve traffic without it (ADR-0093 D5).\n\n' +
1622+
`\n ✖ FATAL: tenancy posture '${tenancyPosture}' was requested but ` +
1623+
'@objectstack/organizations could not be loaded,\n' +
1624+
' so the organization wall is INACTIVE. Refusing to boot — a deployment that requested\n' +
1625+
' multi-organization isolation must not serve traffic without it (ADR-0093 D5).\n\n' +
16181626
' Fix one of:\n' +
16191627
' • install @objectstack/organizations (the enterprise multi-org runtime), or\n' +
1620-
' • unset OS_MULTI_ORG_ENABLED to run single-org, or\n' +
1628+
"set OS_TENANCY_POSTURE=single (or unset OS_MULTI_ORG_ENABLED) to run single-org, or\n" +
16211629
' • set OS_ALLOW_DEGRADED_TENANCY=1 to boot in an explicitly degraded single-org state.\n\n' +
16221630
` cause: ${cause}\n`,
16231631
),
@@ -1629,9 +1637,9 @@ export default class Serve extends Command {
16291637
// /auth/config and the Setup dashboard so it stays visible.
16301638
console.warn(
16311639
chalk.yellow(
1632-
' ⚠ DEGRADED TENANCY (OS_ALLOW_DEGRADED_TENANCY=1): OS_MULTI_ORG_ENABLED=true but ' +
1633-
'@objectstack/organizations is unavailable — booting with tenant isolation INACTIVE. ' +
1634-
'Organization boundaries are NOT enforced; wildcard tenant RLS is stripped. (ADR-0093 D5)',
1640+
` ⚠ DEGRADED TENANCY (OS_ALLOW_DEGRADED_TENANCY=1): posture '${tenancyPosture}' requested but ` +
1641+
'@objectstack/organizations is unavailable — booting with the organization wall INACTIVE. ' +
1642+
'Organization boundaries are NOT enforced. (ADR-0093 D5)',
16351643
),
16361644
);
16371645
}

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

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from '@objectstack/platform-objects/apps';
1212
import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages';
1313
import { resolveTenancyPosture } from '@objectstack/types';
14+
import { postureEnforcesWall, type OrgScopingEntitlement } from '@objectstack/spec/security';
1415
import {
1516
AuthManager,
1617
resolveOidcProviderEnabled,
@@ -283,6 +284,18 @@ export class AuthPlugin implements Plugin {
283284
return false;
284285
}
285286
},
287+
// [ADR-0105 D12] Which walled postures the installed multi-org runtime
288+
// entitles. The open side ASKS rather than assuming "package present ⇒
289+
// every posture": whether `group` and `isolated` are one commercial tier
290+
// or two is packaging policy, and that belongs to the commercial runtime.
291+
// A runtime that declares nothing keeps today's behavior (both).
292+
probeEntitledPostures: () => {
293+
try {
294+
return ctx.getService<OrgScopingEntitlement>('org-scoping')?.supportedPostures;
295+
} catch {
296+
return undefined;
297+
}
298+
},
286299
getEngine: () => {
287300
try {
288301
return ctx.getService('objectql');
@@ -552,18 +565,13 @@ export class AuthPlugin implements Plugin {
552565
await this.maybeSeedDevAdmin(ctx);
553566
});
554567

555-
// ADR-0081 D1 — default-organization bootstrap, owned here for every posture
556-
// the OPEN engine enforces (`single` and, per ADR-0105 D12, `group`).
557-
// `isolated` keeps its existing owner (the enterprise organizations package,
558-
// which runs the same idempotent helper with the seed-ownership step
559-
// injected); one crisp owner per posture.
560-
//
561-
// `group` needs this as much as `single` does: the union wall resolves an
562-
// EMPTY access set for a member of no organization and fails closed, so an
563-
// open group deployment with no first organization could not admit its own
564-
// admin. The bootstrap creates exactly one org and binds the first platform
565-
// admin as owner; every further organization is created deliberately.
566-
if (this.options.autoDefaultOrganization !== false && resolveTenancyPosture() !== 'isolated') {
568+
// ADR-0081 D1 — single-org default-organization bootstrap. Every WALLED
569+
// posture (`group` and `isolated`) keeps its existing owner: the enterprise
570+
// organizations package, which runs the same idempotent helper with the
571+
// seed-ownership step injected. One crisp owner per posture, and the open
572+
// package never bootstraps an organization for a deployment whose
573+
// multi-organization runtime it does not provide.
574+
if (this.options.autoDefaultOrganization !== false && !postureEnforcesWall(resolveTenancyPosture())) {
567575
const runEnsure = async () => {
568576
try {
569577
const ql: any = ctx.getService<any>('objectql');

packages/plugins/plugin-auth/src/tenancy-service.test.ts

Lines changed: 106 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,25 +61,39 @@ describe('createTenancyService', () => {
6161
expect(t.degraded).toBe(false);
6262
});
6363

64-
// [ADR-0105 D1/D12] `group` is enforced by the OPEN engine — the union wall,
65-
// `accessible_org_ids`, and write stamping are all open code — so it never
66-
// probes for the enterprise package and can never resolve degraded.
67-
describe('group posture (open-enforced)', () => {
68-
it('is active without the enterprise org-scoping service', () => {
69-
const probe = vi.fn(() => false);
70-
const t = createTenancyService({ requested: 'group', probeIsolation: probe });
64+
// [ADR-0105 D1 / ADR-0081 D2] Multi-organization operation is an ENTITLEMENT.
65+
// The wall's code is open, but activating either walled posture requires the
66+
// enterprise org-scoping runtime — otherwise `group` would be a free multi-org
67+
// back door around the `isolated` gate. The iron rule (cloud ADR-0016) is
68+
// satisfied by refusing to run an unwalled multi-org deployment, not by giving
69+
// the posture away: an unenforceable request resolves to `single` + degraded,
70+
// and the CLI fails fast on that (ADR-0093 D5).
71+
describe('group posture (entitled, like isolated)', () => {
72+
it('is ACTIVE when the enterprise org-scoping service is present', () => {
73+
const t = createTenancyService({ requested: 'group', probeIsolation: () => true });
7174
expect(t.posture).toBe('group');
7275
expect(t.isolationActive).toBe(true);
73-
expect(t.requested).toBe(true);
7476
expect(t.degraded).toBe(false);
75-
expect(probe).not.toHaveBeenCalled();
7677
});
7778

78-
it('never guesses a default org — membership is explicit', async () => {
79+
it('DEGRADES without the enterprise package — never a silent free multi-org', () => {
80+
const probe = vi.fn(() => false);
81+
const t = createTenancyService({ requested: 'group', probeIsolation: probe });
82+
expect(t.requestedPosture).toBe('group');
83+
expect(t.posture).toBe('single'); // behaves single-org — nothing walls it
84+
// The probe is lazy — org-scoping registers after plugin-auth — so it
85+
// fires on the first read of a derived fact, not at construction.
86+
expect(probe).toHaveBeenCalled();
87+
expect(t.isolationActive).toBe(false);
88+
expect(t.requested).toBe(true);
89+
expect(t.degraded).toBe(true);
90+
});
91+
92+
it('never guesses a default org while active — membership is explicit', async () => {
7993
const engine = makeEngine([{ id: 'org_default', slug: 'default' }]);
8094
const t = createTenancyService({
8195
requested: 'group',
82-
probeIsolation: () => false,
96+
probeIsolation: () => true,
8397
getEngine: () => engine,
8498
});
8599
expect(await t.defaultOrgId()).toBeNull();
@@ -169,3 +183,84 @@ describe('resolveDefaultOrgId', () => {
169183
expect(await resolveDefaultOrgId({})).toBeNull();
170184
});
171185
});
186+
187+
// ---------------------------------------------------------------------------
188+
// [ADR-0105 D12] Posture ENTITLEMENT is declared by the commercial runtime.
189+
//
190+
// Presence-of-package answers "may this deployment run multi-org at all", not
191+
// "which shapes of it". Whether `group` and `isolated` are one tier or two is a
192+
// packaging decision the enterprise runtime owns, so the open core asks instead
193+
// of assuming — and fails closed on anything not entitled.
194+
// ---------------------------------------------------------------------------
195+
describe('posture entitlement declared by the org-scoping runtime', () => {
196+
const installed = () => true;
197+
198+
it('entitles every walled posture when the runtime declares nothing (back-compat)', () => {
199+
for (const posture of ['group', 'isolated'] as const) {
200+
const t = createTenancyService({
201+
requested: posture,
202+
probeIsolation: installed,
203+
probeEntitledPostures: () => undefined,
204+
});
205+
expect(t.posture, posture).toBe(posture);
206+
expect(t.degraded).toBe(false);
207+
}
208+
});
209+
210+
it('activates a posture the runtime DOES entitle', () => {
211+
const t = createTenancyService({
212+
requested: 'group',
213+
probeIsolation: installed,
214+
probeEntitledPostures: () => ['group'],
215+
});
216+
expect(t.posture).toBe('group');
217+
expect(t.isolationActive).toBe(true);
218+
expect(t.degraded).toBe(false);
219+
});
220+
221+
it('DEGRADES a posture the runtime does not entitle, even though it is installed', () => {
222+
// e.g. a licence covering legal-entity isolation but not the group shape.
223+
const t = createTenancyService({
224+
requested: 'group',
225+
probeIsolation: installed,
226+
probeEntitledPostures: () => ['isolated'],
227+
});
228+
expect(t.requestedPosture).toBe('group');
229+
expect(t.posture).toBe('single');
230+
expect(t.isolationActive).toBe(false);
231+
expect(t.degraded).toBe(true); // → the CLI refuses to boot (ADR-0093 D5)
232+
});
233+
234+
it('fails closed on an EMPTY entitlement set', () => {
235+
const t = createTenancyService({
236+
requested: 'isolated',
237+
probeIsolation: installed,
238+
probeEntitledPostures: () => [],
239+
});
240+
expect(t.isolationActive).toBe(false);
241+
expect(t.degraded).toBe(true);
242+
});
243+
244+
it('fails closed when the entitlement probe throws', () => {
245+
const t = createTenancyService({
246+
requested: 'group',
247+
probeIsolation: installed,
248+
probeEntitledPostures: () => {
249+
throw new Error('licence service unreachable');
250+
},
251+
});
252+
expect(t.isolationActive).toBe(false);
253+
expect(t.degraded).toBe(true);
254+
});
255+
256+
it('never consults entitlement when the runtime is absent (already degraded)', () => {
257+
const entitle = vi.fn(() => ['group'] as const);
258+
const t = createTenancyService({
259+
requested: 'group',
260+
probeIsolation: () => false,
261+
probeEntitledPostures: entitle,
262+
});
263+
expect(t.degraded).toBe(true);
264+
expect(entitle).not.toHaveBeenCalled();
265+
});
266+
});

0 commit comments

Comments
 (0)