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
55 changes: 55 additions & 0 deletions .changeset/walled-owner-email-elevation.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/plugin-security": minor
"@objectstack/plugin-auth": minor
"@objectstack/types": minor
"@objectstack/verify": patch
---

fix(security): walled postures elevate only the env-declared platform owner, never the first registrant (#11184, the framework leg of cloud#1509)

**BREAKING** for walled deployments (`OS_TENANCY_POSTURE=group` or
`isolated`), shipped as `minor` under the repo's launch-window convention for
breaking changes. Single-org deployments are byte-for-byte unchanged.

Measured defect (cloud#1509): on a walled multi-tenant SaaS with
`OS_TENANCY_POSTURE=isolated` and `OS_AUTH_MEMBERSHIP_POLICY=invite-only`, the
FIRST self-registrant received the cross-tenant `admin_full_access` grant
(`platform_admin`, `isPlatformAdmin: true`) and — because the default-org
bootstrap binds "the platform admin" — was merged into the deployment's
Default Organization as its owner. Whoever curls the public sign-up endpoint
first owned the platform.

Per the maintainer ruling of 2026-08-23 (verbatim:
「1509 选择 env 指定 owner 邮箱」):

- **Walled postures: platform admin comes ONLY from the env-declared owner.**
`bootstrapPlatformAdmin` (plugin-security) no longer promotes the oldest
human user when the requested posture is walled; it promotes exactly the
account whose email matches the new `OS_PLATFORM_OWNER_EMAIL` variable
(case-insensitive, matched whenever that account registers — arrival order
is irrelevant). Self-registrants are never promoted and, since the shared
`ensureDefaultOrganization` helper binds only the platform admin, are never
auto-merged into the Default Organization either.
- **Fail-closed startup refusal.** A walled posture with no
`OS_PLATFORM_OWNER_EMAIL` declared refuses to boot from `AuthPlugin.init()`
with a message naming the variable — never a silent fallback to
first-registrant elevation. The elevation site itself also refuses
(`reason: 'walled_owner_email_undeclared'`, logged at `error`) as
defense-in-depth for compositions that reach the bootstrap without
plugin-auth (`os meta resync`, bare embeddings).
- **Single-org posture unchanged.** "First user is owner" stays as ruled
reasonable there; the new variable is never consulted under `single`.
- The requested posture (`resolveTenancyPosture()`) is deliberately the input,
so a walled-requested deployment running degraded
(`OS_ALLOW_DEGRADED_TENANCY=1`) still refuses first-registrant elevation.

Operator action for walled deployments: set `OS_PLATFORM_OWNER_EMAIL` to the
operator account's email address before upgrading. Deployments that already
hold a human platform admin are untouched (the bootstrap remains a no-op once
any human holds the cross-tenant grant); the variable governs installs that
have not yet minted their admin. `@objectstack/types` gains the
`resolvePlatformOwnerEmail()` resolver and the `PLATFORM_OWNER_EMAIL_ENV`
constant; the verify harness declares the owner email (defaulting to its dev
admin) for walled fixtures.

<!-- adr-0087: not-required (no-migration-prescription) nothing authorable is removed, renamed or narrowed: no spec key, no metadata spelling and no stored row changes shape, so there is nothing for `os migrate meta` to rewrite and no ledger entry to make. The prescription above is a deployment-environment requirement (declare an env var before boot), which the ADR-0087 ledger does not carry — the refusal itself names the variable at startup. -->
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,6 +146,11 @@ afterAll(() => {
const SERVE_ENV = {
OS_AUTH_SECRET: 'org-host-resolution-e2e-secret',
OS_TENANCY_POSTURE: 'isolated',
// [#11184] A walled posture refuses to boot unless the platform owner is
// env-declared; these fixtures' subject is the organizations-package
// resolution, so declare one (the refusal itself is pinned in
// plugin-auth's auth-plugin-walled-owner-boot-refusal.test.ts).
OS_PLATFORM_OWNER_EMAIL: 'operator@corp.example',
};

describe('os serve — enterprise organizations resolution (cloud#1013)', () => {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,11 @@ afterAll(() => {
const SERVE_ENV = {
OS_AUTH_SECRET: 'org-mount-failure-e2e-secret',
OS_TENANCY_POSTURE: 'isolated',
// [#11184] A walled posture refuses to boot unless the platform owner is
// env-declared; these fixtures' subject is the organizations-package
// resolution, so declare one (the refusal itself is pinned in
// plugin-auth's auth-plugin-walled-owner-boot-refusal.test.ts).
OS_PLATFORM_OWNER_EMAIL: 'operator@corp.example',
};

const BANNER = 'Press Ctrl+C to stop';
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* AuthPlugin init — the fail-closed clause of #11184 (framework leg of
* cloud#1509; maintainer ruling 2026-08-23, verbatim:
* 「1509 选择 env 指定 owner 邮箱」).
*
* A WALLED tenancy posture (`group` / `isolated`) with no
* `OS_PLATFORM_OWNER_EMAIL` declared must REFUSE STARTUP, naming the
* variable — never boot into a state that either can mint no platform admin
* or tempts a silent fallback to first-registrant elevation. The throw is in
* `init()`, where a failure aborts kernel boot (Phase 1 propagates).
*
* This refusal is a process-boot abort, not an HTTP answer — there is no
* ADR-0112 envelope to carry `code`/`status`. The machine-checkable pin is
* the message: it must name the variable (the remedy) and the posture that
* demanded it, the same shape the ADR-0093 D5 walled fail-fast pins.
*
* Both over-denial directions are pinned as positive controls: a walled boot
* WITH the owner declared initializes, and a `single` boot never consults the
* variable at all.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AuthPlugin } from './auth-plugin';
import type { PluginContext } from '@objectstack/core';

const makeCtx = (): PluginContext =>
({
registerService: vi.fn(),
getService: vi.fn((name: string) => {
if (name === 'manifest') return { register: vi.fn() };
return undefined;
}),
getServices: vi.fn(() => new Map()),
hook: vi.fn(),
trigger: vi.fn(),
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
getKernel: vi.fn(),
}) as unknown as PluginContext;

const OLD_POSTURE = process.env.OS_TENANCY_POSTURE;
const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED;
const OLD_OWNER = process.env.OS_PLATFORM_OWNER_EMAIL;

beforeEach(() => {
delete process.env.OS_TENANCY_POSTURE;
delete process.env.OS_MULTI_ORG_ENABLED;
delete process.env.OS_PLATFORM_OWNER_EMAIL;
});
afterEach(() => {
if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE;
else process.env.OS_TENANCY_POSTURE = OLD_POSTURE;
if (OLD_LEGACY === undefined) delete process.env.OS_MULTI_ORG_ENABLED;
else process.env.OS_MULTI_ORG_ENABLED = OLD_LEGACY;
if (OLD_OWNER === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL;
else process.env.OS_PLATFORM_OWNER_EMAIL = OLD_OWNER;
});

const plugin = () => new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long' });

describe('#11184 — walled posture + undeclared owner email refuses startup', () => {
it("isolated: init rejects, and the message carries the variable (the remedy) and the posture", async () => {
process.env.OS_TENANCY_POSTURE = 'isolated';
const err = await plugin()
.init(makeCtx())
.then(() => null)
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(Error);
const msg = (err as Error).message;
expect(msg).toContain('OS_PLATFORM_OWNER_EMAIL');
expect(msg).toContain("'isolated'");
expect(msg).toContain('Refusing to boot');
// Never silently reverting is the point — the message says so.
expect(msg).toContain('first-registrant elevation');
});

it('group: the other walled posture refuses identically, naming itself', async () => {
process.env.OS_TENANCY_POSTURE = 'group';
const err = await plugin()
.init(makeCtx())
.then(() => null)
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toContain('OS_PLATFORM_OWNER_EMAIL');
expect((err as Error).message).toContain("'group'");
});

it('a blank value is undeclared: whitespace does not satisfy the clause', async () => {
process.env.OS_TENANCY_POSTURE = 'isolated';
process.env.OS_PLATFORM_OWNER_EMAIL = ' ';
const err = await plugin()
.init(makeCtx())
.then(() => null)
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toContain('OS_PLATFORM_OWNER_EMAIL');
});

it('the legacy boolean spelling of a walled posture (OS_MULTI_ORG_ENABLED=true) is covered too', async () => {
process.env.OS_MULTI_ORG_ENABLED = 'true';
const err = await plugin()
.init(makeCtx())
.then(() => null)
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toContain('OS_PLATFORM_OWNER_EMAIL');
});
});

describe('#11184 — over-denial guards (positive controls)', () => {
it('walled + declared owner email initializes and registers auth + tenancy', async () => {
process.env.OS_TENANCY_POSTURE = 'isolated';
process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example';
const ctx = makeCtx();
await plugin().init(ctx);
const registered = (ctx.registerService as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0]);
expect(registered).toContain('auth');
expect(registered).toContain('tenancy');
});

it('single posture boots with no owner email declared — first-user-is-owner stays as ruled', async () => {
const ctx = makeCtx();
await plugin().init(ctx);
const registered = (ctx.registerService as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0]);
expect(registered).toContain('auth');
expect(registered).toContain('tenancy');
});
});
19 changes: 15 additions & 4 deletions packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1097,10 +1097,21 @@ describe('AuthPlugin', () => {

it('multi-org: bootstrap is NOT wired (enterprise organizations package owns it)', async () => {
process.env.OS_MULTI_ORG_ENABLED = 'true';
await boot();
await hookCapture.trigger('kernel:ready');
expect(ql.tables.sys_organization).toHaveLength(0);
expect(ql.tables.sys_member).toHaveLength(0);
// [#11184] A walled posture now declares its platform owner or refuses
// to boot; this fixture's subject is the default-org wiring, so declare
// one (the boot-refusal itself is pinned in
// auth-plugin-walled-owner-boot-refusal.test.ts).
const oldOwner = process.env.OS_PLATFORM_OWNER_EMAIL;
process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example';
try {
await boot();
await hookCapture.trigger('kernel:ready');
expect(ql.tables.sys_organization).toHaveLength(0);
expect(ql.tables.sys_member).toHaveLength(0);
} finally {
if (oldOwner === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL;
else process.env.OS_PLATFORM_OWNER_EMAIL = oldOwner;
}
});

it('autoDefaultOrganization: false opts out', async () => {
Expand Down
29 changes: 27 additions & 2 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,7 +17,7 @@ import {
SystemOverviewDatasets,
} from '@objectstack/platform-objects/apps';
import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages';
import { resolveTenancyPosture } from '@objectstack/types';
import { PLATFORM_OWNER_EMAIL_ENV, resolvePlatformOwnerEmail, resolveTenancyPosture } from '@objectstack/types';
import { postureEnforcesWall, type OrgScopingEntitlement } from '@objectstack/spec/security';
import type { IDataEngine, IEmailService, II18nService, IObjectQLEngine, ISmsService } from '@objectstack/spec/contracts';
import {
Expand DownExpand Up@@ -492,8 +492,33 @@ export class AuthPlugin implements Plugin {
// never probes. `getService` is a cheap registry lookup and org-scoping
// registers AFTER plugin-auth, so the probe is deferred to first read
// (start()/request time).
const requestedPosture = resolveTenancyPosture();
// [#11184 / cloud#1509] Fail-closed clause of the 2026-08-23 ruling
// (「1509 选择 env 指定 owner 邮箱」): a WALLED posture must declare its
// platform owner. Under `group`/`isolated` the "first registrant becomes
// platform admin" bootstrap path is removed (plugin-security's
// `bootstrapPlatformAdmin` grants the cross-tenant `admin_full_access`
// only to the account matching the declared owner email), so a walled
// deployment with no owner declared would otherwise boot into a state
// with NO way to ever mint a platform admin — or, worse, tempt a silent
// fallback to first-registrant elevation, the exact hole cloud#1509
// measured. Refuse startup instead, naming the variable — the same
// fail-fast direction as `resolveTenancyPosture`'s own throw and the
// ADR-0093 D5 degraded-tenancy guard. A throw here aborts kernel boot
// (Phase 1 `init()` failures propagate). The `single` posture never
// consults the variable: "first user is owner" stays as ruled.
if (postureEnforcesWall(requestedPosture) && !resolvePlatformOwnerEmail()) {
throw new Error(
`[auth] tenancy posture '${requestedPosture}' requires ${PLATFORM_OWNER_EMAIL_ENV} to be set. ` +
'Under walled postures the first self-registrant is NOT promoted to platform admin; ' +
'the platform-admin grant goes only to the account whose email matches the declared ' +
'owner. Refusing to boot rather than silently reverting to first-registrant elevation. ' +
`Set ${PLATFORM_OWNER_EMAIL_ENV} to the operator's email address, or set ` +
"OS_TENANCY_POSTURE=single to run single-org.",
);
}
const tenancy: TenancyService = createTenancyService({
requested: resolveTenancyPosture(),
requested: requestedPosture,
probeIsolation: () => {
try {
return !!ctx.getService('org-scoping');
Expand Down
3 changes: 2 additions & 1 deletion packages/plugins/plugin-security/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,8 @@
"@objectstack/formula": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/platform-objects": "workspace:*",
"@objectstack/spec": "workspace:*"
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*"
},
"devDependencies": {
"@objectstack/driver-sql": "workspace:*",
Expand Down
Loading
Loading