Skip to content

Commit 1bfae40

Browse files
committed
feat(authz)!: group tenancy posture + org scope as a first-class dimension — ADR-0105 Phase 0/1 (#3541)
Implements ADR-0105 Phase 0 (correctness) and Phase 1 (group posture MVP). Kernel: - D1: tenancy becomes a spectrum — `single | group | isolated`, selected by `OS_TENANCY_POSTURE` and resolved by the `tenancy` service. Unset derives from `OS_MULTI_ORG_ENABLED`, so existing deployments are unchanged; an unrecognized value throws at boot rather than silently dropping the wall. - D2: `ExecutionContext.accessible_org_ids` — the caller's org access set, resolved once by `resolveAuthzContext` under ADR-0091 validity windows and carried by every transport. The `group` Layer 0 wall is `organization_id IN accessible_org_ids` (MOAC union); empty/absent fails closed. Only the predicate widens — Layer 0 stays independent, AND-composed first, and crossable only by a true PLATFORM_ADMIN, so ADR-0095 W1/W2 hold in every posture. - D5: the engine now stamps `organization_id` on insert under any walled posture and validates every supplied value — including BULK array payloads, which the pre-existing post-image check skipped entirely (#2937 one call site down). P0 correctness (fixes #3539, #3540): - D3: `collectRLSPolicies` strips by PROVENANCE, not by substring-matching `current_user.organization_id`. App-authored policies are retained, warned about once, and fail closed instead of vanishing (F1 / ADR-0049 class). `getReadFilter` shared the defect, so analytics reads were unscoped. - D4: `viewAllRecords`/`modifyAllRecords` never cross an org boundary. Wall-less postures get a de-VAMA'd `organization_admin_no_bypass` variant — DERIVED from `organization_admin`, never a second literal — and the superseded variant is revoked whenever the posture changes (F2 / personal-org amplification). Model + product: - D6: `sys_organization.parent_organization_id` / `sort_order` as a reporting dimension, with both red lines lint-enforced (`validateOrgAxisRedLines`, wired into os lint/compile/validate): no permission inheritance along the org axis, and no business-unit grants on platform-global objects. - D7: extension fields on better-auth-managed objects ride the ADR-0092 whitelist; a new guard derives better-auth's real field surface from `getAuthTables()` at the pinned version and fails on any name collision. Enforce-or-remove (BREAKING, no runtime behavior change — all three had zero consumers): - `PermissionSet.contextVariables` removed; `security/territory.zod.ts` removed. - `ExecutionContext.rlsMembership` productized: register an `IRlsMembershipResolver` under `rls-membership-resolver`. Kernel-owned keys are reserved; an unresolved key makes its policies drop out (fail closed). Edition boundary (D12): the `group` wall and its write-side twin ship OPEN — the correctness of a wall is never paid (cloud ADR-0016). `isolated` keeps its enterprise `org-scoping` probe, so the commercial boundary for legal-entity isolation is unchanged by this PR. Adds 5 ADR-0105 rows to the dogfood authz conformance ledger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL
1 parent d1cabaa commit 1bfae40

82 files changed

Lines changed: 2657 additions & 785 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
---
2+
'@objectstack/spec': major
3+
'@objectstack/plugin-security': minor
4+
'@objectstack/plugin-auth': minor
5+
'@objectstack/core': minor
6+
'@objectstack/types': minor
7+
'@objectstack/lint': minor
8+
'@objectstack/platform-objects': minor
9+
'@objectstack/rest': patch
10+
'@objectstack/runtime': patch
11+
'@objectstack/mcp': patch
12+
'@objectstack/plugin-hono-server': patch
13+
'@objectstack/plugin-approvals': patch
14+
'@objectstack/cli': patch
15+
---
16+
17+
ADR-0105 Phase 0 + Phase 1: group tenancy posture; organization scope as a
18+
first-class authorization dimension.
19+
20+
## Tenancy is now a spectrum (D1)
21+
22+
`single | group | isolated`, resolved by the `tenancy` service and selected with
23+
the new `OS_TENANCY_POSTURE` env var. Existing deployments are unchanged:
24+
`OS_TENANCY_POSTURE` unset derives the posture from `OS_MULTI_ORG_ENABLED`
25+
(`true``isolated`, else `single`). An unrecognized value throws at boot
26+
rather than silently landing in a posture with no organization wall.
27+
28+
- `single` — no wall (unchanged).
29+
- `group`**new.** Organizations are membership boundaries over one shared
30+
dataset; Layer 0 becomes `organization_id IN accessible_org_ids` (union / MOAC
31+
semantics). Enforced by the OPEN engine.
32+
- `isolated` — today's `multi`, renamed. Behavior, enterprise `org-scoping`
33+
probe and degraded-boot handling all unchanged.
34+
35+
## Organization scope is a first-class context field (D2)
36+
37+
`ExecutionContext.accessible_org_ids` — every organization the caller holds a
38+
currently-valid membership in (ADR-0091 validity windows) — is resolved once by
39+
`resolveAuthzContext` and carried by every transport. The `group` wall reads it
40+
directly; RLS policies may reference it as
41+
`organization_id IN (current_user.accessible_org_ids)`. An empty or absent set
42+
fails the wall closed.
43+
44+
Only the Layer 0 PREDICATE widens. Composition is untouched: the wall is still
45+
computed independently of the RLS compiler, AND-composed outermost, and
46+
crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object — so
47+
ADR-0095's W1/W2 invariants hold in every posture.
48+
49+
## Two P0 correctness fixes (D3, D4) — behavior changes
50+
51+
**D3 — app-authored org-scoped RLS policies are no longer silently dropped**
52+
(finding F1, framework#3539). `collectRLSPolicies` used to strip any policy whose
53+
`using` contained the substring `current_user.organization_id` when isolation was
54+
inactive, which swallowed app-authored policies as well as the platform's own.
55+
Stripping is now decided by PROVENANCE (identity against the shipped
56+
declaration). **Upgrade impact:** in a deployment with no organization wall, an
57+
app-authored policy referencing the active organization is now RETAINED and
58+
fails closed (zero rows) with a one-time warning, where it previously vanished
59+
and the object read unscoped. `getReadFilter` shared the defect, so analytics and
60+
raw-SQL consumers were affected too. If a policy was only ever meant for
61+
multi-org, delete it or install `@objectstack/organizations`.
62+
63+
**D4 — `viewAllRecords`/`modifyAllRecords` never cross an organization
64+
boundary** (finding F2, framework#3540). Under a wall-less posture nothing
65+
bounded the wildcard superuser bits `organization_admin` carries, so a
66+
deployment that accumulated organizations (personal orgs on signup) made every
67+
owner/admin an environment-wide superuser. `auto-org-admin-grant` now grants a
68+
de-VAMA'd `organization_admin_no_bypass` variant when no wall is enforced, and
69+
revokes the superseded variant whenever the posture changes. **Upgrade impact:**
70+
in `single` posture an org owner/admin keeps full CRUD but loses the blanket
71+
ownership/sharing/RLS bypass. Deliberate deployment-wide visibility remains
72+
available through `admin_full_access` or an explicitly authored permission set —
73+
it just stops being a side effect of a better-auth membership role.
74+
75+
## Engine-owned organization stamping (D5)
76+
77+
Under any wall-enforcing posture the engine stamps `organization_id` from the
78+
caller's active organization on an insert that omits it, and validates every
79+
supplied value against the wall. Idempotent with the enterprise auto-stamp
80+
(neither overwrites a supplied value). This also closes a real hole: the
81+
pre-existing post-image check required a non-array payload, so a BULK insert
82+
could carry a forged `organization_id` per row. One forged row now denies the
83+
whole write.
84+
85+
## Group structure, extension fields and red-line lints (D6, D7)
86+
87+
- `sys_organization` gains `parent_organization_id` and `sort_order` — a
88+
**reporting dimension only**.
89+
- New lint `validateOrgAxisRedLines` (`org-axis-permission-inheritance`,
90+
`org-axis-cross-org-bu-grant`), wired into `os lint` / `os compile` /
91+
`os validate`: an RLS policy or sharing rule that walks the org tree is an
92+
error, as is a business-unit grant on a platform-global object.
93+
- Extension fields on better-auth-managed objects ride the existing ADR-0092
94+
whitelist. A new guard derives better-auth's real field surface from
95+
`getAuthTables()` at the pinned version and fails the build on any name
96+
collision, so a library upgrade cannot silently take ownership of a column.
97+
98+
## Enforce-or-remove (D11) — BREAKING
99+
100+
Both removals are of surface that had **zero runtime consumers**, so no
101+
behavior changes; authoring them is now a no-op instead of a lint warning.
102+
103+
- **`PermissionSet.contextVariables` — REMOVED.** The RLS compiler never read
104+
it. FROM → TO: a set a policy needs as `field IN (current_user.<key>)` is now
105+
supplied by a registered membership resolver (below); a constant belongs in
106+
the policy itself as a literal (`status = 'published'`).
107+
- **`Territory` / `TerritoryModel` / `TerritoryType` (`security/territory.zod.ts`)
108+
— REMOVED.** No runtime object, stack field or resolver existed. FROM → TO:
109+
matrix requirements are served by multi-position × business-unit anchoring; a
110+
generalized dimension-security module will arrive with its own ADR.
111+
- **`ExecutionContext.rlsMembership` — PRODUCTIZED.** The bag the compiler has
112+
merged since ADR-0056 finally has a producer: register an
113+
`IRlsMembershipResolver` (`@objectstack/spec/contracts`) under the
114+
`rls-membership-resolver` service, declaring the keys it owns. Fail-closed by
115+
construction — an unresolved key makes its policies drop out. Kernel-owned
116+
keys (`accessible_org_ids`, `org_user_ids`, …) are reserved and cannot be
117+
overwritten from this seam.
118+
119+
## Edition boundary (D12)
120+
121+
The `group` posture's enforcement primitives ship OPEN — the union wall,
122+
`accessible_org_ids` resolution, D5 stamping/validation, the D3/D4 correctness
123+
fixes and the D6 lints — because the correctness of a wall is never a paid
124+
feature (cloud ADR-0016 铁律「强制免费、治理收费」). `isolated` keeps its existing
125+
enterprise `org-scoping` probe, so the current commercial boundary for
126+
legal-entity isolation is unchanged by this release.

content/docs/getting-started/glossary.mdx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,5 @@ A granular permission model (Security Protocol) where access control is applied
153153
### OWD (Organization-Wide Defaults)
154154
The default sharing level (Security Protocol) for records in an object. Determines the baseline access level before sharing rules are applied.
155155

156-
### Territory
157-
A hierarchical access control model (Security Protocol) that grants data access based on geographic or organizational boundaries.
158-
159156
### TCK (Technology Compatibility Kit)
160157
A **planned** suite of tests that would validate whether a Driver or Renderer complies with the official ObjectStack Protocol, certifying a passing driver as compatible. _Not yet implemented — there is no TCK program or certification mechanism in the codebase today._

content/docs/getting-started/quick-reference.mdx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ Flows, state machines, approvals, and integrations.
162162
| **[Trigger Registry](/docs/references/automation/trigger-registry)** | `trigger-registry.zod.ts` | TriggerRegistry | Event-driven triggers |
163163
| **[Sync](/docs/references/automation/sync)** | `sync.zod.ts` | DataSyncConfig, SyncMode | Bi-directional data sync |
164164

165-
## Security Protocol (4 schemas)
165+
## Security Protocol (3 schemas)
166166

167167
Access control, permissions, and row-level security.
168168

@@ -171,7 +171,6 @@ Access control, permissions, and row-level security.
171171
| **[Permission](/docs/references/security/permission)** | `permission.zod.ts` | ObjectPermission | Object-level permissions |
172172
| **[RLS](/docs/references/security/rls)** | `rls.zod.ts` | RowLevelSecurityPolicy | Row-level security filters |
173173
| **[Sharing](/docs/references/security/sharing)** | `sharing.zod.ts` | SharingRule | Record sharing rules |
174-
| **[Territory](/docs/references/security/territory)** | `territory.zod.ts` | Territory | Territory-based access |
175174

176175
## Identity Protocol (4 schemas)
177176

content/docs/permissions/permission-metadata.mdx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ const salesUserPermission = {
6767
| `systemPermissions` | `string[]` | optional | System-level capabilities |
6868
| `tabPermissions` | `Record<string, TabVisibility>` | optional | Tab/app visibility |
6969
| `rowLevelSecurity` | `RowLevelSecurityPolicy[]` | optional | Row-level security policies |
70-
| `contextVariables` | `Record<string, any>` | optional | **Not implemented** — declared but ignored; the RLS engine reads only the `current_user.*` built-ins (see note below) |
7170

7271
## Object Permissions
7372

@@ -185,11 +184,13 @@ rowLevelSecurity: [
185184

186185
### Context Variables
187186

188-
**Note:** The `contextVariables` field is **not yet implemented** — the RLS
189-
compiler never reads it, so custom context values are **not** injected into
190-
row-level conditions. Conditions can only reference the built-in `current_user.*`
191-
variables described below; authoring `contextVariables` raises a build-time
192-
liveness warning.
187+
**Note:** A permission set cannot declare its own context values. The
188+
`contextVariables` field was removed in ADR-0105 D11 (enforce-or-remove) because
189+
the RLS compiler never read it. Conditions reference the built-in
190+
`current_user.*` variables described below, plus any set a registered membership
191+
resolver stages into `ExecutionContext.rlsMembership` (referenced as
192+
`field IN (current_user.<key>)`); a constant belongs in the policy itself as a
193+
literal (`status = 'published'`).
193194

194195
The built-in context variables available in RLS `using`/`check` clauses include
195196
`current_user.id`, `current_user.organization_id`, and `current_user.positions`

content/docs/permissions/permissions-matrix.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ Sharing rules extend access beyond ownership and the depth axis. The declarative
151151
</Callout>
152152

153153
<Callout type="info">
154-
**Beyond declarative rules:** Two other sharing mechanisms exist but are **not** `SharingRule` types. **Manual sharing** is a runtime grant — `sys_record_share` rows created with `source: 'manual'` (see `packages/plugins/plugin-sharing/src/sharing-service.ts`). **Territories** are a separate matrix model (`TerritorySchema` in `packages/spec/src/security/territory.zod.ts`) with their own account/opportunity/case access levels, parallel to the business-unit model. Owner/criteria rules are re-evaluated on insert/update via internal sharing rule hooks.
154+
**Beyond declarative rules:** Two other sharing mechanisms exist but are **not** `SharingRule` types. **Manual sharing** is a runtime grant — `sys_record_share` rows created with `source: 'manual'` (see `packages/plugins/plugin-sharing/src/sharing-service.ts`). **Matrix / territory-shaped access** is served today by multi-position assignment anchored on business units, plus pre-resolved membership sets: a registered `rls-membership-resolver` (ADR-0105 D11) stages e.g. `territory_account_ids` into `ExecutionContext.rlsMembership`, and a policy references it as `account_id IN (current_user.territory_account_ids)`. The aspirational `TerritorySchema` was removed in ADR-0105 D11 — it had no runtime object, stack field or resolver; a generalized dimension-security module will arrive with its own ADR. Owner/criteria rules are re-evaluated on insert/update via internal sharing rule hooks.
155155
</Callout>
156156

157157
### Configuration Example

content/docs/references/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ Defines access control and security policies.
264264
| `permission.zod.ts` | `PermissionSchema` | Permission profiles and object-level CRUD access |
265265
| `rls.zod.ts` | `RLSSchema` | Row-level security rules |
266266
| `sharing.zod.ts` | `SharingSchema` | Sharing rules and access grants |
267-
| `territory.zod.ts` | `TerritorySchema` | Territory-based access management |
267+
| `tenancy-posture.ts` | `TenancyPostureSchema` | Which organization wall Layer 0 enforces (`single` / `group` / `isolated`) |
268268

269269
**Learn more:** [Security Protocol Reference](/docs/references/security)
270270

content/docs/references/kernel/execution-context.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const result = ExecutionContext.parse(data);
6363
| **systemPermissions** | `string[]` | optional | |
6464
| **tabPermissions** | `Record<string, Enum<'visible' \| 'hidden' \| 'default_on' \| 'default_off'>>` | optional | |
6565
| **org_user_ids** | `string[]` | optional | |
66+
| **accessible_org_ids** | `string[]` | optional | |
6667
| **rlsMembership** | `Record<string, string[]>` | optional | |
6768
| **isSystem** | `boolean` || |
6869
| **skipTriggers** | `boolean` | optional | |

content/docs/references/security/index.mdx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,4 @@ This section contains all protocol schemas for the security layer of ObjectStack
1010
<Card href="/docs/references/security/permission" title="Permission" description="Source: packages/spec/src/security/permission.zod.ts" />
1111
<Card href="/docs/references/security/rls" title="Rls" description="Source: packages/spec/src/security/rls.zod.ts" />
1212
<Card href="/docs/references/security/sharing" title="Sharing" description="Source: packages/spec/src/security/sharing.zod.ts" />
13-
<Card href="/docs/references/security/territory" title="Territory" description="Source: packages/spec/src/security/territory.zod.ts" />
1413
</Cards>

content/docs/references/security/meta.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
"misc",
66
"permission",
77
"rls",
8-
"sharing",
9-
"territory"
8+
"sharing"
109
]
1110
}

content/docs/references/security/misc.mdx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ description: Misc protocol schemas
1212
## TypeScript Usage
1313

1414
```typescript
15-
import { CapabilityDeclaration } from '@objectstack/spec/security';
16-
import type { CapabilityDeclaration } from '@objectstack/spec/security';
15+
import { CapabilityDeclaration, TenancyPosture } from '@objectstack/spec/security';
16+
import type { CapabilityDeclaration, TenancyPosture } from '@objectstack/spec/security';
1717

1818
// Validate data
1919
const result = CapabilityDeclaration.parse(data);
@@ -36,3 +36,14 @@ const result = CapabilityDeclaration.parse(data);
3636

3737
---
3838

39+
## TenancyPosture
40+
41+
### Allowed Values
42+
43+
* `single`
44+
* `group`
45+
* `isolated`
46+
47+
48+
---
49+

0 commit comments

Comments
 (0)