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
59 changes: 59 additions & 0 deletions .changeset/adr-0105-group-posture-entitlement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
'@objectstack/plugin-auth': minor
'@objectstack/plugin-security': minor
'@objectstack/spec': minor
'@objectstack/cli': patch
---

Multi-organization operation is an ENTITLEMENT again: the `group` posture no
longer activates without the enterprise runtime (ADR-0105 D12 correction).

The first ADR-0105 wave read 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), and made the weaker isolation the free one, which is not a boundary anyone
would draw on purpose.

The distinction that was missed: **open code is not free activation.** The wall's
implementation has always lived in the open packages — that is equally true of
`isolated`, whose Layer 0 wall sits in `plugin-security` and is gated on a
service the enterprise package registers. Cloud ADR-0016's 铁律
(强制免费、治理收费) guarantees that a deployment RUNNING a multi-org shape is
safe; it is satisfied by REFUSING to run one unwalled, not by giving the posture
away.

## Changes

- **`tenancy-service`**: `group` probes `org-scoping` exactly like `isolated`.
Without it the posture resolves to `single` and reports `degraded`.
- **`os serve`**: the ADR-0093 D5 boot guard keys off the resolved POSTURE
instead of `OS_MULTI_ORG_ENABLED`. Previously `OS_TENANCY_POSTURE=group` skipped
both the enterprise package load AND the fail-fast, silently degrading to an
unwalled deployment — the exact ADR-0049 class that guard exists to close. A
`group` request without the runtime now refuses to boot unless
`OS_ALLOW_DEGRADED_TENANCY=1`.
- **New seam — the runtime declares what it entitles.** `org-scoping` may expose
`supportedPostures` (`OrgScopingEntitlement`, `@objectstack/spec/security`);
the open side honours it and fails closed on anything not listed. Whether
`group` and `isolated` are one commercial tier or two is packaging policy, and
packaging policy belongs to the commercial runtime rather than hard-coded in
open core. Omitting the field entitles every walled posture, so existing
runtimes are unaffected.
- **`organization_id` stamping returns to the enterprise runtime.** The previous
wave moved auto-stamping into the open engine; that removed the closed
package's only load-bearing runtime duty, so a five-line forged `org-scoping`
registration would have produced a fully working multi-org deployment. With
stamping back where it was, a forged registration yields NULL-org rows the wall
hides — a broken deployment, not an unlicensed working one.

**Write-side VALIDATION stays open and is unchanged**, including the
bulk-insert coverage: rejecting a forged `organization_id` is a security
property, not a packaging one. Only filling an ABSENT value moved back.
- Default-organization bootstrap returns to `single`-only; every walled posture
keeps its existing owner (ADR-0081 D1).

## Note for operators

`OS_TENANCY_POSTURE=group` without `@objectstack/organizations` installed now
**refuses to boot** rather than running single-org. This only affects
deployments that adopted `group` between the two waves.
3 changes: 2 additions & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `OS_AUTH_GOOGLE_ENABLED` | boolean | settings default | Settings env override for `auth.google_enabled`. Requires Google OAuth credentials from Settings or env. |
| `GOOGLE_CLIENT_ID` | string | — | Deployment-level Google OAuth client id for the open-source Google login implementation. |
| `GOOGLE_CLIENT_SECRET` | string | — | Deployment-level Google OAuth client secret for the open-source Google login implementation. |
| `OS_MULTI_ORG_ENABLED` | boolean | `false` | When `true`, expose organization creation/switching UI. When `false`, run in single-tenant mode. |
| `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`. |
| `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. |
| `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. |
| `OS_COOKIE_DOMAIN` | string | — | Cookie domain for cross-subdomain session sharing (e.g. `.example.com`). |
| `OS_TRUSTED_ORIGINS` | csv | — | Comma-separated list of origins permitted for auth callbacks / CSRF. |
Expand Down
26 changes: 17 additions & 9 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { bundleRequire } from 'bundle-require';
import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js';
import { resolveDriverType, createStorageDriver, UnsupportedDriverError } from '../utils/storage-driver.js';
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveAllowDegradedTenancy, isMcpServerEnabled, resolveSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types';
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, resolveSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types';
import { PLATFORM_CAPABILITY_TOKENS } from '@objectstack/spec/kernel';
import { missingProviderMessage } from '../utils/capability-preflight.js';
import { resolveObjectStackHome } from '@objectstack/runtime';
Expand Down Expand Up @@ -1588,7 +1588,14 @@ export default class Serve extends Command {
// it, deployments are single-org: the open member-management
// basics (plugin-auth's default-org bootstrap + better-auth
// invitations) still work.
const multiTenant = resolveMultiOrgEnabled();
// [ADR-0105 D1] Key off the resolved POSTURE, not the legacy boolean.
// Both walled postures (`group` and `isolated`) need this package:
// gating on `OS_MULTI_ORG_ENABLED` alone would let
// `OS_TENANCY_POSTURE=group` skip the load AND the fail-fast below,
// silently degrading to an unwalled single-org deployment — the exact
// ADR-0049 class this guard exists to close.
const tenancyPosture = resolveTenancyPosture();
const multiTenant = tenancyPosture !== 'single';
if (multiTenant) {
try {
const organizationsPkg = '@objectstack/organizations';
Expand All @@ -1612,12 +1619,13 @@ export default class Serve extends Command {
if (!resolveAllowDegradedTenancy()) {
console.error(
chalk.red(
'\n ✖ FATAL: OS_MULTI_ORG_ENABLED=true but @objectstack/organizations could not be loaded,\n' +
' so tenant isolation is INACTIVE. Refusing to boot — a deployment that requested\n' +
' multi-tenant isolation must not serve traffic without it (ADR-0093 D5).\n\n' +
`\n ✖ FATAL: tenancy posture '${tenancyPosture}' was requested but ` +
'@objectstack/organizations could not be loaded,\n' +
' so the organization wall is INACTIVE. Refusing to boot — a deployment that requested\n' +
' multi-organization isolation must not serve traffic without it (ADR-0093 D5).\n\n' +
' Fix one of:\n' +
' • install @objectstack/organizations (the enterprise multi-org runtime), or\n' +
' • unset OS_MULTI_ORG_ENABLED to run single-org, or\n' +
"set OS_TENANCY_POSTURE=single (or unset OS_MULTI_ORG_ENABLED) to run single-org, or\n" +
' • set OS_ALLOW_DEGRADED_TENANCY=1 to boot in an explicitly degraded single-org state.\n\n' +
` cause: ${cause}\n`,
),
Expand All @@ -1629,9 +1637,9 @@ export default class Serve extends Command {
// /auth/config and the Setup dashboard so it stays visible.
console.warn(
chalk.yellow(
' ⚠ DEGRADED TENANCY (OS_ALLOW_DEGRADED_TENANCY=1): OS_MULTI_ORG_ENABLED=true but ' +
'@objectstack/organizations is unavailable — booting with tenant isolation INACTIVE. ' +
'Organization boundaries are NOT enforced; wildcard tenant RLS is stripped. (ADR-0093 D5)',
` ⚠ DEGRADED TENANCY (OS_ALLOW_DEGRADED_TENANCY=1): posture '${tenancyPosture}' requested but ` +
'@objectstack/organizations is unavailable — booting with the organization wall INACTIVE. ' +
'Organization boundaries are NOT enforced. (ADR-0093 D5)',
),
);
}
Expand Down
32 changes: 20 additions & 12 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '@objectstack/platform-objects/apps';
import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages';
import { resolveTenancyPosture } from '@objectstack/types';
import { postureEnforcesWall, type OrgScopingEntitlement } from '@objectstack/spec/security';
import {
AuthManager,
resolveOidcProviderEnabled,
Expand Down Expand Up @@ -283,6 +284,18 @@ export class AuthPlugin implements Plugin {
return false;
}
},
// [ADR-0105 D12] Which walled postures the installed multi-org runtime
// entitles. The open side ASKS rather than assuming "package present ⇒
// every posture": whether `group` and `isolated` are one commercial tier
// or two is packaging policy, and that belongs to the commercial runtime.
// A runtime that declares nothing keeps today's behavior (both).
probeEntitledPostures: () => {
try {
return ctx.getService<OrgScopingEntitlement>('org-scoping')?.supportedPostures;
} catch {
return undefined;
}
},
getEngine: () => {
try {
return ctx.getService('objectql');
Expand Down Expand Up @@ -552,18 +565,13 @@ export class AuthPlugin implements Plugin {
await this.maybeSeedDevAdmin(ctx);
});

// ADR-0081 D1 — default-organization bootstrap, owned here for every posture
// the OPEN engine enforces (`single` and, per ADR-0105 D12, `group`).
// `isolated` keeps its existing owner (the enterprise organizations package,
// which runs the same idempotent helper with the seed-ownership step
// injected); one crisp owner per posture.
//
// `group` needs this as much as `single` does: the union wall resolves an
// EMPTY access set for a member of no organization and fails closed, so an
// open group deployment with no first organization could not admit its own
// admin. The bootstrap creates exactly one org and binds the first platform
// admin as owner; every further organization is created deliberately.
if (this.options.autoDefaultOrganization !== false && resolveTenancyPosture() !== 'isolated') {
// ADR-0081 D1 — single-org default-organization bootstrap. Every WALLED
// posture (`group` and `isolated`) keeps its existing owner: the enterprise
// organizations package, which runs the same idempotent helper with the
// seed-ownership step injected. One crisp owner per posture, and the open
// package never bootstraps an organization for a deployment whose
// multi-organization runtime it does not provide.
if (this.options.autoDefaultOrganization !== false && !postureEnforcesWall(resolveTenancyPosture())) {
const runEnsure = async () => {
try {
const ql: any = ctx.getService<any>('objectql');
Expand Down
117 changes: 106 additions & 11 deletions packages/plugins/plugin-auth/src/tenancy-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,25 +61,39 @@ describe('createTenancyService', () => {
expect(t.degraded).toBe(false);
});

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

it('never guesses a default org — membership is explicit', async () => {
it('DEGRADES without the enterprise package — never a silent free multi-org', () => {
const probe = vi.fn(() => false);
const t = createTenancyService({ requested: 'group', probeIsolation: probe });
expect(t.requestedPosture).toBe('group');
expect(t.posture).toBe('single'); // behaves single-org — nothing walls it
// The probe is lazy — org-scoping registers after plugin-auth — so it
// fires on the first read of a derived fact, not at construction.
expect(probe).toHaveBeenCalled();
expect(t.isolationActive).toBe(false);
expect(t.requested).toBe(true);
expect(t.degraded).toBe(true);
});

it('never guesses a default org while active — membership is explicit', async () => {
const engine = makeEngine([{ id: 'org_default', slug: 'default' }]);
const t = createTenancyService({
requested: 'group',
probeIsolation: () => false,
probeIsolation: () => true,
getEngine: () => engine,
});
expect(await t.defaultOrgId()).toBeNull();
Expand Down Expand Up @@ -169,3 +183,84 @@ describe('resolveDefaultOrgId', () => {
expect(await resolveDefaultOrgId({})).toBeNull();
});
});

// ---------------------------------------------------------------------------
// [ADR-0105 D12] Posture ENTITLEMENT is declared by the commercial runtime.
//
// Presence-of-package answers "may this deployment run multi-org at all", not
// "which shapes of it". Whether `group` and `isolated` are one tier or two is a
// packaging decision the enterprise runtime owns, so the open core asks instead
// of assuming — and fails closed on anything not entitled.
// ---------------------------------------------------------------------------
describe('posture entitlement declared by the org-scoping runtime', () => {
const installed = () => true;

it('entitles every walled posture when the runtime declares nothing (back-compat)', () => {
for (const posture of ['group', 'isolated'] as const) {
const t = createTenancyService({
requested: posture,
probeIsolation: installed,
probeEntitledPostures: () => undefined,
});
expect(t.posture, posture).toBe(posture);
expect(t.degraded).toBe(false);
}
});

it('activates a posture the runtime DOES entitle', () => {
const t = createTenancyService({
requested: 'group',
probeIsolation: installed,
probeEntitledPostures: () => ['group'],
});
expect(t.posture).toBe('group');
expect(t.isolationActive).toBe(true);
expect(t.degraded).toBe(false);
});

it('DEGRADES a posture the runtime does not entitle, even though it is installed', () => {
// e.g. a licence covering legal-entity isolation but not the group shape.
const t = createTenancyService({
requested: 'group',
probeIsolation: installed,
probeEntitledPostures: () => ['isolated'],
});
expect(t.requestedPosture).toBe('group');
expect(t.posture).toBe('single');
expect(t.isolationActive).toBe(false);
expect(t.degraded).toBe(true); // → the CLI refuses to boot (ADR-0093 D5)
});

it('fails closed on an EMPTY entitlement set', () => {
const t = createTenancyService({
requested: 'isolated',
probeIsolation: installed,
probeEntitledPostures: () => [],
});
expect(t.isolationActive).toBe(false);
expect(t.degraded).toBe(true);
});

it('fails closed when the entitlement probe throws', () => {
const t = createTenancyService({
requested: 'group',
probeIsolation: installed,
probeEntitledPostures: () => {
throw new Error('licence service unreachable');
},
});
expect(t.isolationActive).toBe(false);
expect(t.degraded).toBe(true);
});

it('never consults entitlement when the runtime is absent (already degraded)', () => {
const entitle = vi.fn(() => ['group'] as const);
const t = createTenancyService({
requested: 'group',
probeIsolation: () => false,
probeEntitledPostures: entitle,
});
expect(t.degraded).toBe(true);
expect(entitle).not.toHaveBeenCalled();
});
});
Loading