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
132 changes: 132 additions & 0 deletions .changeset/adr-0105-group-tenancy-phase-0-1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-security': minor
'@objectstack/plugin-auth': minor
'@objectstack/core': minor
'@objectstack/types': minor
'@objectstack/lint': minor
'@objectstack/platform-objects': minor
'@objectstack/rest': patch
'@objectstack/runtime': patch
'@objectstack/mcp': patch
'@objectstack/plugin-hono-server': patch
'@objectstack/plugin-approvals': patch
'@objectstack/cli': patch
---

ADR-0105 Phase 0 + Phase 1: group tenancy posture; organization scope as a
first-class authorization dimension.

> This release carries BREAKING spec removals (see "Enforce-or-remove" below)
> but is recorded as `minor`: every publishable package is in the Changesets
> lockstep group, so one `major` would promote the whole monorepo. Breaking
> changes ship as `minor` during the launch window — the migration notes below
> are what reach consumers in `CHANGELOG.md`.

## Tenancy is now a spectrum (D1)

`single | group | isolated`, resolved by the `tenancy` service and selected with
the new `OS_TENANCY_POSTURE` env var. Existing deployments are unchanged:
`OS_TENANCY_POSTURE` unset derives the posture from `OS_MULTI_ORG_ENABLED`
(`true` ⇒ `isolated`, else `single`). An unrecognized value throws at boot
rather than silently landing in a posture with no organization wall.

- `single` — no wall (unchanged).
- `group` — **new.** Organizations are membership boundaries over one shared
dataset; Layer 0 becomes `organization_id IN accessible_org_ids` (union / MOAC
semantics). Enforced by the OPEN engine.
- `isolated` — today's `multi`, renamed. Behavior, enterprise `org-scoping`
probe and degraded-boot handling all unchanged.

## Organization scope is a first-class context field (D2)

`ExecutionContext.accessible_org_ids` — every organization the caller holds a
currently-valid membership in (ADR-0091 validity windows) — is resolved once by
`resolveAuthzContext` and carried by every transport. The `group` wall reads it
directly; RLS policies may reference it as
`organization_id IN (current_user.accessible_org_ids)`. An empty or absent set
fails the wall closed.

Only the Layer 0 PREDICATE widens. Composition is untouched: the wall is still
computed independently of the RLS compiler, AND-composed outermost, and
crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object — so
ADR-0095's W1/W2 invariants hold in every posture.

## Two P0 correctness fixes (D3, D4) — behavior changes

**D3 — app-authored org-scoped RLS policies are no longer silently dropped**
(finding F1, framework#3539). `collectRLSPolicies` used to strip any policy whose
`using` contained the substring `current_user.organization_id` when isolation was
inactive, which swallowed app-authored policies as well as the platform's own.
Stripping is now decided by PROVENANCE (identity against the shipped
declaration). **Upgrade impact:** in a deployment with no organization wall, an
app-authored policy referencing the active organization is now RETAINED and
fails closed (zero rows) with a one-time warning, where it previously vanished
and the object read unscoped. `getReadFilter` shared the defect, so analytics and
raw-SQL consumers were affected too. If a policy was only ever meant for
multi-org, delete it or install `@objectstack/organizations`.

**D4 — `viewAllRecords`/`modifyAllRecords` never cross an organization
boundary** (finding F2, framework#3540). Under a wall-less posture nothing
bounded the wildcard superuser bits `organization_admin` carries, so a
deployment that accumulated organizations (personal orgs on signup) made every
owner/admin an environment-wide superuser. `auto-org-admin-grant` now grants a
de-VAMA'd `organization_admin_no_bypass` variant when no wall is enforced, and
revokes the superseded variant whenever the posture changes. **Upgrade impact:**
in `single` posture an org owner/admin keeps full CRUD but loses the blanket
ownership/sharing/RLS bypass. Deliberate deployment-wide visibility remains
available through `admin_full_access` or an explicitly authored permission set —
it just stops being a side effect of a better-auth membership role.

## Engine-owned organization stamping (D5)

Under any wall-enforcing posture the engine stamps `organization_id` from the
caller's active organization on an insert that omits it, and validates every
supplied value against the wall. Idempotent with the enterprise auto-stamp
(neither overwrites a supplied value). This also closes a real hole: the
pre-existing post-image check required a non-array payload, so a BULK insert
could carry a forged `organization_id` per row. One forged row now denies the
whole write.

## Group structure, extension fields and red-line lints (D6, D7)

- `sys_organization` gains `parent_organization_id` and `sort_order` — a
**reporting dimension only**.
- New lint `validateOrgAxisRedLines` (`org-axis-permission-inheritance`,
`org-axis-cross-org-bu-grant`), wired into `os lint` / `os compile` /
`os validate`: an RLS policy or sharing rule that walks the org tree is an
error, as is a business-unit grant on a platform-global object.
- Extension fields on better-auth-managed objects ride the existing ADR-0092
whitelist. A new guard derives better-auth's real field surface from
`getAuthTables()` at the pinned version and fails the build on any name
collision, so a library upgrade cannot silently take ownership of a column.

## Enforce-or-remove (D11) — BREAKING

Both removals are of surface that had **zero runtime consumers**, so no
behavior changes; authoring them is now a no-op instead of a lint warning.

- **`PermissionSet.contextVariables` — REMOVED.** The RLS compiler never read
it. FROM → TO: a set a policy needs as `field IN (current_user.<key>)` is now
supplied by a registered membership resolver (below); a constant belongs in
the policy itself as a literal (`status = 'published'`).
- **`Territory` / `TerritoryModel` / `TerritoryType` (`security/territory.zod.ts`)
— REMOVED.** No runtime object, stack field or resolver existed. FROM → TO:
matrix requirements are served by multi-position × business-unit anchoring; a
generalized dimension-security module will arrive with its own ADR.
- **`ExecutionContext.rlsMembership` — PRODUCTIZED.** The bag the compiler has
merged since ADR-0056 finally has a producer: register an
`IRlsMembershipResolver` (`@objectstack/spec/contracts`) under the
`rls-membership-resolver` service, declaring the keys it owns. Fail-closed by
construction — an unresolved key makes its policies drop out. Kernel-owned
keys (`accessible_org_ids`, `org_user_ids`, …) are reserved and cannot be
overwritten from this seam.

## Edition boundary (D12)

The `group` posture's enforcement primitives ship OPEN — the union wall,
`accessible_org_ids` resolution, D5 stamping/validation, the D3/D4 correctness
fixes and the D6 lints — because the correctness of a wall is never a paid
feature (cloud ADR-0016 铁律「强制免费、治理收费」). `isolated` keeps its existing
enterprise `org-scoping` probe, so the current commercial boundary for
legal-entity isolation is unchanged by this release.
3 changes: 0 additions & 3 deletions content/docs/getting-started/glossary.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,5 @@ A granular permission model (Security Protocol) where access control is applied
### OWD (Organization-Wide Defaults)
The default sharing level (Security Protocol) for records in an object. Determines the baseline access level before sharing rules are applied.

### Territory
A hierarchical access control model (Security Protocol) that grants data access based on geographic or organizational boundaries.

### TCK (Technology Compatibility Kit)
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._
3 changes: 1 addition & 2 deletions content/docs/getting-started/quick-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ Flows, state machines, approvals, and integrations.
| **[Trigger Registry](/docs/references/automation/trigger-registry)** | `trigger-registry.zod.ts` | TriggerRegistry | Event-driven triggers |
| **[Sync](/docs/references/automation/sync)** | `sync.zod.ts` | DataSyncConfig, SyncMode | Bi-directional data sync |

## Security Protocol (4 schemas)
## Security Protocol (3 schemas)

Access control, permissions, and row-level security.

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

## Identity Protocol (4 schemas)

Expand Down
13 changes: 7 additions & 6 deletions content/docs/permissions/permission-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ const salesUserPermission = {
| `systemPermissions` | `string[]` | optional | System-level capabilities |
| `tabPermissions` | `Record<string, TabVisibility>` | optional | Tab/app visibility |
| `rowLevelSecurity` | `RowLevelSecurityPolicy[]` | optional | Row-level security policies |
| `contextVariables` | `Record<string, any>` | optional | **Not implemented** — declared but ignored; the RLS engine reads only the `current_user.*` built-ins (see note below) |

## Object Permissions

Expand Down Expand Up @@ -185,11 +184,13 @@ rowLevelSecurity: [

### Context Variables

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

The built-in context variables available in RLS `using`/`check` clauses include
`current_user.id`, `current_user.organization_id`, and `current_user.positions`
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/permissions-matrix.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ Sharing rules extend access beyond ownership and the depth axis. The declarative
</Callout>

<Callout type="info">
**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.
**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.
</Callout>

### Configuration Example
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ Defines access control and security policies.
| `permission.zod.ts` | `PermissionSchema` | Permission profiles and object-level CRUD access |
| `rls.zod.ts` | `RLSSchema` | Row-level security rules |
| `sharing.zod.ts` | `SharingSchema` | Sharing rules and access grants |
| `territory.zod.ts` | `TerritorySchema` | Territory-based access management |
| `tenancy-posture.ts` | `TenancyPostureSchema` | Which organization wall Layer 0 enforces (`single` / `group` / `isolated`) |

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

Expand Down
1 change: 1 addition & 0 deletions content/docs/references/kernel/execution-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const result = ExecutionContext.parse(data);
| **systemPermissions** | `string[]` | optional | |
| **tabPermissions** | `Record<string, Enum<'visible' \| 'hidden' \| 'default_on' \| 'default_off'>>` | optional | |
| **org_user_ids** | `string[]` | optional | |
| **accessible_org_ids** | `string[]` | optional | |
| **rlsMembership** | `Record<string, string[]>` | optional | |
| **isSystem** | `boolean` | ✅ | |
| **skipTriggers** | `boolean` | optional | |
Expand Down
1 change: 0 additions & 1 deletion content/docs/references/security/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,4 @@ This section contains all protocol schemas for the security layer of ObjectStack
<Card href="/docs/references/security/permission" title="Permission" description="Source: packages/spec/src/security/permission.zod.ts" />
<Card href="/docs/references/security/rls" title="Rls" description="Source: packages/spec/src/security/rls.zod.ts" />
<Card href="/docs/references/security/sharing" title="Sharing" description="Source: packages/spec/src/security/sharing.zod.ts" />
<Card href="/docs/references/security/territory" title="Territory" description="Source: packages/spec/src/security/territory.zod.ts" />
</Cards>
3 changes: 1 addition & 2 deletions content/docs/references/security/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
"misc",
"permission",
"rls",
"sharing",
"territory"
"sharing"
]
}
15 changes: 13 additions & 2 deletions content/docs/references/security/misc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ description: Misc protocol schemas
## TypeScript Usage

```typescript
import { CapabilityDeclaration } from '@objectstack/spec/security';
import type { CapabilityDeclaration } from '@objectstack/spec/security';
import { CapabilityDeclaration, TenancyPosture } from '@objectstack/spec/security';
import type { CapabilityDeclaration, TenancyPosture } from '@objectstack/spec/security';

// Validate data
const result = CapabilityDeclaration.parse(data);
Expand All @@ -36,3 +36,14 @@ const result = CapabilityDeclaration.parse(data);

---

## TenancyPosture

### Allowed Values

* `single`
* `group`
* `isolated`


---

1 change: 0 additions & 1 deletion content/docs/references/security/permission.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,6 @@ const result = AdminScope.parse(data);
| **systemPermissions** | `string[]` | optional | System level capabilities |
| **tabPermissions** | `Record<string, Enum<'visible' \| 'hidden' \| 'default_on' \| 'default_off'>>` | optional | App/tab visibility: visible, hidden, default_on (shown by default), default_off (available but hidden initially) |
| **rowLevelSecurity** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | Row-level security policies (see rls.zod.ts for full spec) |
| **contextVariables** | `Record<string, any>` | optional | Context variables for RLS evaluation |
| **adminScope** | `{ businessUnit: string; includeSubtree: boolean; manageAssignments: boolean; manageBindings: boolean; … }` | optional | [ADR-0090 D12] Scoped delegated-administration grant (BU subtree + assignable-set allowlist) |


Expand Down
93 changes: 0 additions & 93 deletions content/docs/references/security/territory.mdx

This file was deleted.

1 change: 0 additions & 1 deletion content/docs/releases/implementation-status.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,6 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity
| **Permission** | Authorization | ✅ | ✅ Phase-1 | ✅ | ✅ `plugin-security` |
| **Sharing** | Authorization | ✅ | ❌ | ✅ | 📋 Plugin |
| **RLS** | Authorization | ✅ | ✅ Phase-1 (tenant + owner) | ✅ | ✅ `plugin-security` |
| **Territory** | Authorization | ✅ | ❌ | ✅ | 📋 Plugin |

**Notes:**
- All security protocols (identity + permission) are delivered by a single `auth` plugin — matching `CoreServiceName`
Expand Down
Loading