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/org-create-effective-posture-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
"@objectstack/plugin-auth": major
"@objectstack/verify": minor
---

BREAKING(auth): `organization/create` 改判**实际生效的** tenancy posture —— 没有组织墙的部署不再能创建组织 (#5261)

`POST /api/v1/auth/organization/create` 的闸门此前判的是操作者**请求的** posture
(`postureEnforcesWall(resolveTenancyPosture())`,一次纯 env 读)。现在判 `tenancy` 服务给出的
**生效** posture —— `tenancy?.posture ?? resolveTenancyPosture()`,与 `/auth/config` 的
`features.multiOrgEnabled` 是**同一次求值**。

## 为什么

两个站点此前只在一种形状下分叉,而那种形状恰恰是最不该放行的一种 —— ADR-0093 D5 **降级态**:
请求了 `isolated`/`group`,但企业包 `@objectstack/organizations` 缺席,于是 `tenancy.posture`
解析为 `single` 且 `degraded=true`。此时:

- 闸门读「请求」→ **放行**;
- `/auth/config` 读「生效」→ `multiOrgEnabled=false`,console 把「创建组织」入口**藏起来**。

结果是 UI 没有按钮而 API 打得通,并且建出来的每一个组织都是**没有任何引擎强制的租户边界** ——
声明了但没强制,ADR-0049 最讨厌的那一类,只不过发生在部署层。改判生效 posture 之后两者同解、
永不分叉:**没有墙,就没有组织**,无论这个部署是从未要过墙,还是要了没拿到。

## 破坏性影响(有意为之)

**没有安装企业包 `@objectstack/organizations` 的部署将完全无法创建组织**,任何 env 组合都不行 ——
`OS_TENANCY_POSTURE=isolated`、`OS_MULTI_ORG_ENABLED=true`、两个一起设,都不再能把闸门说通。
这是一次实打实的能力收缩,不是 knob 纠正,所以搭 v17 主版本车。

| 部署形状 | 改前 | 改后 |
|---|---|---|
| 有企业包,posture `isolated` / `group`(墙真的立着) | 200 | **200**(不变) |
| **请求了墙但企业包缺席(D5 降级态)** | 200 | **403** ⚠️ |
| `single` / 两个 knob 都不设 | 403 | 403(不变) |
| 未注册 `tenancy` 服务的精简嵌入(回落 env 解析) | 按 env | 按 env(不变) |

`serve.ts` 本来就在降级态**默认拒绝启动**(要 `OS_ALLOW_DEGRADED_TENANCY=1` 才走),所以这条收缩
命中的是一个已经需要显式选择才能到达的形状:从此那里的 org-create 路由也一并拒绝,而不是半通不通。
cloud 控制面与任何装了企业包的部署不受影响。

**迁移**:需要多组织能力的部署安装并声明 `@objectstack/organizations`(ADR-0081 D2)。仅靠 env
声明一个墙、而没有实现它的运行时,不再被当作多组织部署对待。

## `@objectstack/verify`(minor,新增)

`BootOptions.multiTenant` 增加 `'posture-only'` 取值:注册一个内置的 `org-scoping` 服务替身,
让 `tenancy` 服务解析出真实、**非降级**的 `isolated` posture,从而打开受 posture 把守的路由 ——
供那些「组织墙是**前置条件**而非被测对象」的 fixture 使用(#3624 的 `org-create-default-team`
dogfood 就是为它而建:那条回归此前靠「boot 后翻 env、闸门 live 读」开路,本次收缩把这个绕法关死了)。

⛔ 它**不做任何租户隔离**:不 stamp `organization_id`,不 scope 任何查询 —— 它让部署的
**posture** 为真,不是让**墙**为真。跨租户隔离的唯一诚实证明仍然是 `multiTenant: true` +
真实的企业包,这也是那些 gate 在本仓继续 skip 而不是假装通过的原因。
83 changes: 63 additions & 20 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
BUILTIN_IDENTITY_PLATFORM_ADMIN,
MEMBERSHIP_ROLE_DELEGATED_ADMIN,
} from '@objectstack/spec';
import { postureEnforcesWall } from '@objectstack/spec/security';
import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security';
import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js';
import { runWithAuthActorScope, setAuthActorResolver } from './auth-actor-attribution.js';
Expand Down Expand Up @@ -1899,13 +1899,19 @@ export class AuthManager {
// organization there would mint a boundary nothing keeps (ADR-0049 at
// the deployment layer).
//
// [#5233] The judge is the REQUESTED tenancy posture
// (`multiOrgPostureRequested()`), never the `OS_MULTI_ORG_ENABLED`
// boolean ADR-0105 D1 demoted — that one reads `false` on a
// deployment configured with only the authoritative
// [#5233] The judge is the tenancy POSTURE, never the
// `OS_MULTI_ORG_ENABLED` boolean ADR-0105 D1 demoted — that one reads
// `false` on a deployment configured with only the authoritative
// `OS_TENANCY_POSTURE=isolated`, so the whole organization wall
// mounted and every org-less user's guided "create your workspace"
// path still 403'd. Same defect shape as cloud#1020.
//
// [#5261] And the judge is the EFFECTIVE posture
// (`multiOrgPostureEffective()` — the `tenancy` service's answer),
// not the requested one, so this gate and `/auth/config`'s
// `features.multiOrgEnabled` are literally the same derivation. On the
// ADR-0093 D5 degraded shape they used to disagree: the console hid
// the button while the route minted organizations no engine walls.
beforeCreateOrganization: async ({ organization }: any = {}) => {
// [ADR-0120 D3] `'__global__'` is the platform's name for the
// NULL-organization bucket: the autonumber sequence table keys
Expand All @@ -1922,7 +1928,7 @@ export class AuthManager {
'(ADR-0120 D3) and cannot be used as an organization id or slug.',
});
}
if (!this.multiOrgPostureRequested()) {
if (!this.multiOrgPostureEffective()) {
const { APIError } = await import('better-auth/api');
throw new APIError('FORBIDDEN', {
message:
Expand Down Expand Up @@ -3195,8 +3201,16 @@ export class AuthManager {
}

/**
* [ADR-0105 D1 / #5233] Does this deployment ASK for a multi-organization
* posture? The `beforeCreateOrganization` gate's judge.
* The tenancy posture ACTUALLY IN FORCE on this deployment — the one fact
* both `organization/create`'s gate and `/auth/config`'s
* `features.multiOrgEnabled` are derived from, so the two can never disagree.
*
* The `tenancy` service is authoritative because it is the only thing that
* knows whether a requested wall is actually STANDING: it probes the
* enterprise `org-scoping` runtime and resolves an unenforceable request down
* to `single` + `degraded` (ADR-0093 D5 / ADR-0105 D1). Only a lean embedding
* that never registered the service falls back to `resolveTenancyPosture()`,
* the operator's REQUEST read from env.
*
* ⛔ Never `resolveMultiOrgEnabled()`. ADR-0105 D1 DEMOTED that boolean to a
* back-compat INPUT of `resolveTenancyPosture()`, so it reads `false` on a
Expand All @@ -3207,19 +3221,43 @@ export class AuthManager {
* every org-less user's guided "create your workspace" path dead-ended while
* `/auth/config` advertised the capability as present.
*
* REQUESTED, not effective — the same fact `serve.ts`'s ADR-0093 D5 boot
* guard keys on (`resolveTenancyPosture() !== 'single'`), and the same fact
* the old boolean expressed, so this corrects the KNOB and nothing else.
* Whether a requested wall is actually ENFORCED is the `tenancy` service's
* separate answer (`degraded`), which `/auth/config` reports and this gate
* deliberately does not consult; #5261 carries that question.
* [#5261] EFFECTIVE, not requested — this is a deliberate, BREAKING capability
* contraction over what #5233 shipped. The gate used to judge
* `postureEnforcesWall(resolveTenancyPosture())`, the operator's request, which
* came apart from `/auth/config` on exactly one deployment shape: ADR-0093 D5
* degradation (a wall was asked for, the enterprise `@objectstack/organizations`
* runtime is absent, so nothing isolates anything). There the console hid the
* "Create organization" action while the API happily minted organizations whose
* boundary NO engine enforces — a declared-but-unenforced security property,
* ADR-0049's canonical failure, at the deployment layer. Reading the effective
* posture retires that shape: a deployment without an organization wall cannot
* mint an organization, whether it never asked for a wall or asked and did not
* get one.
*
* Read live on every call — never cached. The posture is process-level
* config, and freezing it at plugin-build time would make the gate unable to
* see anything a later boot phase (or a test) established.
* Consequence, accepted knowingly: a deployment running WITHOUT the enterprise
* runtime can no longer create organizations at all, no matter which env knobs
* it sets. `serve.ts` already refuses to boot that shape unless the operator
* passes `OS_ALLOW_DEGRADED_TENANCY=1`; from here on, the org-create route is
* refused there too rather than half-working.
*
* Read live on every call — never cached. The posture is process-level config
* and the service's own probe is lazy, so freezing either at plugin-build time
* would make the gate unable to see a provider that registers later in the
* same boot (the recorded-verdict defect AGENTS.md's startup-registry rule
* names).
*/
private multiOrgPostureEffective(): boolean {
return postureEnforcesWall(this.effectiveTenancyPosture());
}

/**
* The effective posture itself. Single derivation, two readers — the gate
* above and `getPublicConfig()`'s `features.multiOrgEnabled` /
* `features.tenancyPosture` — because #5233 was only hard to see thanks to
* those two sites answering from different facts.
*/
private multiOrgPostureRequested(): boolean {
return postureEnforcesWall(resolveTenancyPosture());
private effectiveTenancyPosture(): TenancyPosture {
return this.config.getTenancy?.()?.posture ?? resolveTenancyPosture();
}

getPublicConfig() {
Expand Down Expand Up @@ -3295,8 +3333,13 @@ export class AuthManager {
// contract that broke the org-create gate, one site over. It reads the
// posture now, so an unwired-tenancy embedding advertises the capability
// its gate actually allows.
//
// [#5261] `effectiveTenancyPosture()` is now literally the SAME call the
// `organization/create` gate makes, so this flag cannot advertise a
// capability the route refuses (or hide one it allows) on any deployment
// shape — including ADR-0093 D5 degradation, where the two used to split.
const tenancy = this.config.getTenancy?.();
const tenancyPosture = tenancy?.posture ?? resolveTenancyPosture();
const tenancyPosture = this.effectiveTenancyPosture();
const multiOrgEnabled = postureEnforcesWall(tenancyPosture);
const degradedTenancy = tenancy?.degraded ?? false;

Expand Down
Loading
Loading