diff --git a/.changeset/adr-0105-group-tenancy-phase-0-1.md b/.changeset/adr-0105-group-tenancy-phase-0-1.md new file mode 100644 index 0000000000..7f2b8dffdb --- /dev/null +++ b/.changeset/adr-0105-group-tenancy-phase-0-1.md @@ -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.)` 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. diff --git a/content/docs/getting-started/glossary.mdx b/content/docs/getting-started/glossary.mdx index 7219a4ccdf..6077d8dfac 100644 --- a/content/docs/getting-started/glossary.mdx +++ b/content/docs/getting-started/glossary.mdx @@ -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._ diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index 61293896d9..dd0a03e601 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -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. @@ -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) diff --git a/content/docs/permissions/permission-metadata.mdx b/content/docs/permissions/permission-metadata.mdx index 3497c4957d..75bdbedfc2 100644 --- a/content/docs/permissions/permission-metadata.mdx +++ b/content/docs/permissions/permission-metadata.mdx @@ -67,7 +67,6 @@ const salesUserPermission = { | `systemPermissions` | `string[]` | optional | System-level capabilities | | `tabPermissions` | `Record` | optional | Tab/app visibility | | `rowLevelSecurity` | `RowLevelSecurityPolicy[]` | optional | Row-level security policies | -| `contextVariables` | `Record` | optional | **Not implemented** — declared but ignored; the RLS engine reads only the `current_user.*` built-ins (see note below) | ## Object Permissions @@ -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.)`); 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` diff --git a/content/docs/permissions/permissions-matrix.mdx b/content/docs/permissions/permissions-matrix.mdx index b48882136c..e2b9fa01ac 100644 --- a/content/docs/permissions/permissions-matrix.mdx +++ b/content/docs/permissions/permissions-matrix.mdx @@ -151,7 +151,7 @@ Sharing rules extend access beyond ownership and the depth axis. The declarative -**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. ### Configuration Example diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 88e268717b..52e1172934 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -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) diff --git a/content/docs/references/kernel/execution-context.mdx b/content/docs/references/kernel/execution-context.mdx index e43e89dd18..ebe6e4378f 100644 --- a/content/docs/references/kernel/execution-context.mdx +++ b/content/docs/references/kernel/execution-context.mdx @@ -63,6 +63,7 @@ const result = ExecutionContext.parse(data); | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | | **org_user_ids** | `string[]` | optional | | +| **accessible_org_ids** | `string[]` | optional | | | **rlsMembership** | `Record` | optional | | | **isSystem** | `boolean` | ✅ | | | **skipTriggers** | `boolean` | optional | | diff --git a/content/docs/references/security/index.mdx b/content/docs/references/security/index.mdx index 2885a067a9..16562cf020 100644 --- a/content/docs/references/security/index.mdx +++ b/content/docs/references/security/index.mdx @@ -10,5 +10,4 @@ This section contains all protocol schemas for the security layer of ObjectStack - diff --git a/content/docs/references/security/meta.json b/content/docs/references/security/meta.json index c644a9963b..098724fd6c 100644 --- a/content/docs/references/security/meta.json +++ b/content/docs/references/security/meta.json @@ -5,7 +5,6 @@ "misc", "permission", "rls", - "sharing", - "territory" + "sharing" ] } \ No newline at end of file diff --git a/content/docs/references/security/misc.mdx b/content/docs/references/security/misc.mdx index b5f2e957ee..af3e3fd36d 100644 --- a/content/docs/references/security/misc.mdx +++ b/content/docs/references/security/misc.mdx @@ -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); @@ -36,3 +36,14 @@ const result = CapabilityDeclaration.parse(data); --- +## TenancyPosture + +### Allowed Values + +* `single` +* `group` +* `isolated` + + +--- + diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index 9acb07b910..08e32e1344 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -133,7 +133,6 @@ const result = AdminScope.parse(data); | **systemPermissions** | `string[]` | optional | System level capabilities | | **tabPermissions** | `Record>` | 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` | 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) | diff --git a/content/docs/references/security/territory.mdx b/content/docs/references/security/territory.mdx deleted file mode 100644 index 081b4adc16..0000000000 --- a/content/docs/references/security/territory.mdx +++ /dev/null @@ -1,93 +0,0 @@ ---- -title: Territory -description: Territory protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -Territory Management Protocol - -Defines a matrix reporting structure that exists parallel to the - -business-unit hierarchy (ADR-0090 D3 — the org tree is `sys_business_unit`). - -USE CASE: - -- Enterprise Sales Teams (Geo-based: "EMEA", "APAC") - -- Industry Verticals (Industry-based: "Healthcare", "Financial") - -- Strategic Accounts (Account-based: "Strategic Accounts") - -DIFFERENCE FROM THE BUSINESS-UNIT TREE: - -- Business unit: Hierarchy of PEOPLE (org structure). Stable. HR-driven. - -- Territory: Hierarchy of ACCOUNTS/REVENUE (Who owns which market). Flexible. Sales-driven. - -- One User can be assigned to MANY Territories (Matrix). - -- One User belongs to one primary business unit (Tree). - - -**Source:** `packages/spec/src/security/territory.zod.ts` - - -## TypeScript Usage - -```typescript -import { Territory, TerritoryModel, TerritoryType } from '@objectstack/spec/security'; -import type { Territory, TerritoryModel, TerritoryType } from '@objectstack/spec/security'; - -// Validate data -const result = Territory.parse(data); -``` - ---- - -## Territory - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Territory unique name (lowercase snake_case) | -| **label** | `string` | ✅ | Territory Label (e.g. "West Coast") | -| **modelId** | `string` | ✅ | Belongs to which Territory Model | -| **parent** | `string` | optional | Parent Territory | -| **type** | `Enum<'geography' \| 'industry' \| 'named_account' \| 'product_line'>` | ✅ | | -| **assignmentRule** | `string` | optional | Criteria based assignment rule | -| **assignedUsers** | `string[]` | optional | | -| **accountAccess** | `Enum<'read' \| 'edit'>` | ✅ | | -| **opportunityAccess** | `Enum<'read' \| 'edit'>` | ✅ | | -| **caseAccess** | `Enum<'read' \| 'edit'>` | ✅ | | - - ---- - -## TerritoryModel - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Model Name (e.g. FY24 Planning) | -| **state** | `Enum<'planning' \| 'active' \| 'archived'>` | ✅ | | -| **startDate** | `string` | optional | | -| **endDate** | `string` | optional | | - - ---- - -## TerritoryType - -### Allowed Values - -* `geography` -* `industry` -* `named_account` -* `product_line` - - ---- - diff --git a/content/docs/releases/implementation-status.mdx b/content/docs/releases/implementation-status.mdx index 77016c08f7..706d665905 100644 --- a/content/docs/releases/implementation-status.mdx +++ b/content/docs/releases/implementation-status.mdx @@ -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` diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 68cc2319cf..17cca927fd 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -13,7 +13,7 @@ import { validateVisibilityPredicates } from '@objectstack/lint'; import { validateWidgetBindings } from '@objectstack/lint'; import { validateDashboardActionRefs } from '@objectstack/lint'; import { validateResponsiveStyles } from '@objectstack/lint'; -import { validateSecurityPosture, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint'; +import { validateSecurityPosture, validateOrgAxisRedLines, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint'; import { validateReadonlyFlowWrites } from '@objectstack/lint'; import { lintFlowPatterns } from '../utils/lint-flow-patterns.js'; import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js'; @@ -426,7 +426,14 @@ export default class Compile extends Command { // Errors GATE the build (per ADR-0049 this is not advisory // security); `info` findings are printed dimmed and never fatal. if (!flags.json) printStep('Checking security posture (ADR-0090 D7)...'); - const securityFindings = validateSecurityPosture(result.data as Record); + const securityFindings = [ + ...validateSecurityPosture(result.data as Record), + // [ADR-0105 D6] Organization-axis red lines: no permission inheritance + // along the org tree, and business-unit trees stay org-internal. Same + // finding shape, same gate — an `error` here blocks exactly as a + // security-posture error does. + ...validateOrgAxisRedLines(result.data as Record), + ]; const securityErrors = securityFindings.filter((f) => f.severity === 'error'); const securityAdvisories = securityFindings.filter((f) => f.severity !== 'error'); if (securityErrors.length > 0) { diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 669ff842a4..3917482e7a 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -9,7 +9,7 @@ import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { computeI18nCoverage, type CoverageIssue } from '../utils/i18n-coverage.js'; import { lintDataModel } from '../lint/data-model-rules.js'; import { validateWidgetBindings } from '@objectstack/lint'; -import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture, validateApprovalApprovers, validateSeedReplaySafety, validateSeedStateMachine } from '@objectstack/lint'; +import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture, validateOrgAxisRedLines, validateApprovalApprovers, validateSeedReplaySafety, validateSeedStateMachine } from '@objectstack/lint'; import { collectAndLintDocs } from '../utils/collect-docs.js'; import { scoreMetadata } from '../lint/score.js'; import { runMetadataEval } from '../lint/metadata-eval.js'; @@ -425,6 +425,23 @@ export function lintConfig(config: any): LintIssue[] { }); } + // ── Organization-axis red lines (ADR-0105 D6) ── + // The org tree (`parent_organization_id`) is a REPORTING dimension. An RLS + // policy or sharing rule that walks it builds a second permission hierarchy — + // the dual-hierarchy mistake ADR-0057 D5 retired — and cannot widen Layer 0 + // anyway, so it grants nothing it appears to. Business-unit grants on + // platform-global objects are the other half: no org column to scope against + // means the grant spans every organization. + for (const t of validateOrgAxisRedLines(config)) { + issues.push({ + severity: t.severity, + rule: t.rule, + message: `${t.where}: ${t.message}`, + path: t.path, + fix: t.hint, + }); + } + // ── Approval-node approvers (ADR-0090 D3 fallout) ── // `{ type: 'role' }` resolves against the better-auth org-membership tier // (owner/admin/member), NOT positions — a position name authored there diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index bead03cf13..b3de1f5efb 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -17,7 +17,7 @@ import { validateResponsiveStyles } from '@objectstack/lint'; import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint'; import { validateCapabilityReferences } from '@objectstack/lint'; import { validateVisibilityPredicates } from '@objectstack/lint'; -import { validateSecurityPosture } from '@objectstack/lint'; +import { validateSecurityPosture, validateOrgAxisRedLines } from '@objectstack/lint'; import { validateFlowTriggerReadiness } from '@objectstack/lint'; import { validateFlowTemplatePaths } from '@objectstack/lint'; import { validateReadonlyFlowWrites } from '@objectstack/lint'; @@ -486,7 +486,14 @@ export default class Validate extends Command { // breaking this command's contract of being the artifact-free run of // the same gates. Errors gate; advisories print dimmed. if (!flags.json) printStep('Checking security posture (ADR-0090 D7)...'); - const securityFindings = validateSecurityPosture(result.data as Record); + const securityFindings = [ + ...validateSecurityPosture(result.data as Record), + // [ADR-0105 D6] Organization-axis red lines: no permission inheritance + // along the org tree, and business-unit trees stay org-internal. Same + // finding shape, same gate — an `error` here blocks exactly as a + // security-posture error does. + ...validateOrgAxisRedLines(result.data as Record), + ]; const securityErrors = securityFindings.filter((f) => f.severity === 'error'); const securityAdvisories = securityFindings.filter((f) => f.severity !== 'error'); if (securityErrors.length > 0) { diff --git a/packages/cli/src/utils/lint-liveness-properties.test.ts b/packages/cli/src/utils/lint-liveness-properties.test.ts index 34dbbbeecc..f9a5f7cddc 100644 --- a/packages/cli/src/utils/lint-liveness-properties.test.ts +++ b/packages/cli/src/utils/lint-liveness-properties.test.ts @@ -7,8 +7,7 @@ import { lintLivenessProperties } from './lint-liveness-properties.js'; * These run against the REAL ledgers shipped by `@objectstack/spec` (the same * files the gate enforces), so they double as a contract test: if an * `authorWarn` annotation is removed from a still-dead prop (e.g. tool - * `permissions`, permission `contextVariables`, flow `nodes.outputSchema`), - * the matching assertion fails. + * `permissions`, flow `nodes.outputSchema`), the matching assertion fails. */ const objStack = (obj: Record) => ({ objects: [{ name: 'widget', ...obj }] }); @@ -99,7 +98,7 @@ describe('lintLivenessProperties', () => { expect(paths(findings).some((m) => m.includes('`undoable`'))).toBe(true); }); - it('warns on the security-shaped dead props (tool.permissions / permission.contextVariables)', () => { + it('warns on the security-shaped dead props (tool.permissions)', () => { // tenancy.strategy/crossTenantAccess left this list after spec 15.0 (#2763): // the schema now REJECTS them (strict tenancy block), so the ledger entries // are gone and the live tenancy knobs must not warn. @@ -109,8 +108,11 @@ describe('lintLivenessProperties', () => { const tool = lintLivenessProperties({ tools: [{ name: 't1', permissions: ['crm.admin'] }] }); expect(paths(tool).some((m) => m.includes('`permissions`'))).toBe(true); + // permission.contextVariables left this list with ADR-0105 D11: the prop was + // REMOVED outright (enforce-or-remove), so its ledger entry is gone and the + // lint no longer has anything to warn about. const perm = lintLivenessProperties({ permissions: [{ name: 'p1', contextVariables: { region: 'emea' } }] }); - expect(paths(perm).some((m) => m.includes('contextVariables'))).toBe(true); + expect(paths(perm).some((m) => m.includes('contextVariables'))).toBe(false); }); it('stays silent on clean flat-collection items', () => { diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 9a2e26cc3c..a155caa65c 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -29,7 +29,7 @@ import { mapMembershipRole, BUILTIN_IDENTITY_PLATFORM_ADMIN, ADMIN_FULL_ACCESS, - ORGANIZATION_ADMIN, + ORGANIZATION_ADMIN_GRANTS, } from '@objectstack/spec'; import type { AuthzPosture } from '@objectstack/spec/security'; @@ -49,6 +49,14 @@ export interface ResolvedAuthzContext { tabPermissions?: Record; /** Fellow-org user IDs for RLS scoping of identity tables (`id IN (...)`). */ org_user_ids: string[]; + /** + * [ADR-0105 D2] Every organization this principal currently holds a VALID + * membership in — the caller's org access set, and the read reach of the + * `group` tenancy posture (Layer 0 becomes `organization_id IN (...)`). + * Resolved here, once, so no surface re-derives it; empty for an anonymous or + * membership-less principal, which fails the group wall closed. + */ + accessible_org_ids: string[]; /** * [ADR-0095 D2/D3] The monotonic posture rung this principal resolves to, * DERIVED once here from held capability grants (never a better-auth role): @@ -102,6 +110,7 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise; posture?: AuthzPosture; /** The user's unique email (`sys_user`), for `current_user.email` owner RLS. */ @@ -222,6 +234,7 @@ export async function resolveUserAuthzGrants( permissions: Array.isArray(opts.seedPermissions) ? [...opts.seedPermissions] : [], systemPermissions: [], org_user_ids: [userId], + accessible_org_ids: [], }; if (opts.seedEmail) grants.email = opts.seedEmail; if (!ql || typeof ql.find !== 'function') return grants; @@ -248,18 +261,48 @@ export async function resolveUserAuthzGrants( if (u?.email) grants.email = String(u.email); } - // 3. Organization-administration roles via sys_member (better-auth), normalized - // to the canonical built-in names (owner→org_owner, admin→org_admin, …). - // [ADR-0095 D3] This is the ONE PROVISIONING boundary where a better-auth - // role is read: it is projected into `positions` here, and separately drives - // the `organization_admin` capability grant (auto-org-admin-grant.ts). No - // enforcement code path reads the raw role — posture/adjudication run off - // the resulting capability grants, so the #2836 dual-track cannot recur. - const memberWhere: any = tenantId - ? { user_id: userId, organization_id: tenantId } - : { user_id: userId }; - const members = await tryFind(ql, 'sys_member', memberWhere, 50); + // Single clock for every validity-window check in this resolution + // (ADR-0091 D2 — a grant row outside [valid_from, valid_until) does not + // resolve, fail-closed, with no background job involved). + const nowMs = opts.nowMs ?? Date.now(); + + // 3. Memberships via sys_member (better-auth). ONE read serves two purposes, + // so the two facts can never disagree about what the user belongs to: + // + // (a) [ADR-0095 D3] Org-administration roles for the ACTIVE organization, + // normalized to the canonical built-in names (owner→org_owner, + // admin→org_admin, …). This is the ONE PROVISIONING boundary where a + // better-auth role is read: it is projected into `positions` here, and + // separately drives the `organization_admin` capability grant + // (auto-org-admin-grant.ts). No enforcement code path reads the raw + // role — posture/adjudication run off the resulting capability grants, + // so the #2836 dual-track cannot recur. + // + // (b) [ADR-0105 D2] `accessible_org_ids` — EVERY organization the user + // currently belongs to, regardless of which one is active. This is the + // `group` posture's read reach (Layer 0 becomes `organization_id IN + // (...)`), so it must span the whole membership set, not the active + // org. Rows outside their ADR-0091 validity window do not resolve; the + // columns are absent on `sys_member` today, and `isGrantActive` treats + // an absent bound as unbounded, so this is a no-op until they exist and + // correct the moment they do. + const members = await tryFind(ql, 'sys_member', { user_id: userId }, 200); + const accessibleOrgIds = new Set(); for (const m of members) { + if (!isGrantActive(m, nowMs)) continue; + const org = m.organization_id ?? m.organizationId; + if (typeof org === 'string' && org) accessibleOrgIds.add(org); + } + grants.accessible_org_ids = Array.from(accessibleOrgIds); + + // Positions come from the ACTIVE org's membership only (unchanged): a role + // held in one organization must not grant its capabilities while the caller + // operates in another. With no active org, every membership contributes — + // exactly the pre-D2 behavior of the org-less read. + const activeMembers = tenantId + ? members.filter((m) => (m.organization_id ?? m.organizationId) === tenantId) + : members; + for (const m of activeMembers) { if (m.role && typeof m.role === 'string') { for (const raw of m.role.split(',').map((s: string) => s.trim()).filter(Boolean)) { const r = mapMembershipRole(raw); @@ -268,11 +311,6 @@ export async function resolveUserAuthzGrants( } } - // Single clock for every validity-window check in this resolution - // (ADR-0091 D2 — a grant row outside [valid_from, valid_until) does not - // resolve, fail-closed, with no background job involved). - const nowMs = opts.nowMs ?? Date.now(); - // 4. [ADR-0057 D4] Platform-owned RBAC role assignments (sys_user_position) — the // source of truth for custom roles, decoupled from sys_member.role. // `organization_id = null` = global (cross-tenant); else match active org. @@ -391,7 +429,9 @@ export async function resolveUserAuthzGrants( // tier. `EXTERNAL` is never derived (no external principal type yet). grants.posture = derivePosture({ isPlatformAdmin: hasPlatformAdminGrant, - isTenantAdmin: grants.permissions.includes(ORGANIZATION_ADMIN), + // [ADR-0105 D4] Either org-admin capability set resolves the rung — the + // wall-less variant differs only by withholding the superuser bits. + isTenantAdmin: ORGANIZATION_ADMIN_GRANTS.some((n: string) => grants.permissions.includes(n)), }); // 7. [ADR-0024] Env-side AI seat: synthesize the `ai_seat` capability from the diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 6c737b5761..9f874d2427 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -161,6 +161,13 @@ export { } from './validate-security-posture.js'; export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js'; +export { + validateOrgAxisRedLines, + ORG_AXIS_PERMISSION_INHERITANCE, + ORG_AXIS_CROSS_ORG_BU_GRANT, +} from './validate-org-axis-red-lines.js'; +export type { OrgAxisFinding, OrgAxisSeverity } from './validate-org-axis-red-lines.js'; + export { validateDashboardActionRefs, DASHBOARD_ACTION_TARGET_UNDEFINED, diff --git a/packages/lint/src/validate-org-axis-red-lines.test.ts b/packages/lint/src/validate-org-axis-red-lines.test.ts new file mode 100644 index 0000000000..8045d0dbe2 --- /dev/null +++ b/packages/lint/src/validate-org-axis-red-lines.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +import { + validateOrgAxisRedLines, + ORG_AXIS_PERMISSION_INHERITANCE, + ORG_AXIS_CROSS_ORG_BU_GRANT, +} from './validate-org-axis-red-lines.js'; + +const rules = (stack: unknown) => validateOrgAxisRedLines(stack).map((f) => f.rule); + +describe('validateOrgAxisRedLines — ① no permission inheritance on the org axis', () => { + it('flags an RLS `using` on a permission set that walks the org parent', () => { + const findings = validateOrgAxisRedLines({ + permissions: [ + { + name: 'group_hq_reader', + rowLevelSecurity: [ + { + name: 'child_orgs', + object: 'work_order', + using: 'organization_id IN (current_user.parent_organization_id)', + }, + ], + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: ORG_AXIS_PERMISSION_INHERITANCE, + path: 'permissions[0].rowLevelSecurity[0].using', + }); + // The fix-it must point at membership, the mechanism that actually works. + expect(findings[0].hint).toMatch(/accessible_org_ids/); + }); + + it('flags a `check` clause too (write-side inheritance is the same defect)', () => { + expect( + rules({ + permissions: [ + { + name: 'p', + rowLevelSecurity: [{ name: 'r', check: "parent_organization_id = 'org_hq'" }], + }, + ], + }), + ).toEqual([ORG_AXIS_PERMISSION_INHERITANCE]); + }); + + it('flags an object-authored RLS policy', () => { + const findings = validateOrgAxisRedLines({ + objects: [ + { + name: 'work_order', + rowLevelSecurity: [{ name: 'rollup', using: 'parent_organization_id = current_user.organization_id' }], + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('objects[0].rowLevelSecurity[0].using'); + }); + + it('flags a sharing rule whose criteria walk the org parent', () => { + expect( + rules({ + sharingRules: [ + { name: 'hq_rollup', object: 'work_order', criteria: { parent_organization_id: 'org_hq' } }, + ], + }), + ).toEqual([ORG_AXIS_PERMISSION_INHERITANCE]); + }); + + it('stays silent on membership-based and business-unit scoping (the sanctioned paths)', () => { + expect( + rules({ + permissions: [ + { + name: 'plant_reader', + rowLevelSecurity: [ + // ADR-0105 D2 — the engine's own union wall vocabulary. + { name: 'my_orgs', using: 'organization_id IN (current_user.accessible_org_ids)' }, + // Intra-org hierarchy — the business-unit tree, not the org tree. + { name: 'my_unit', using: 'business_unit_id IN (current_user.unit_ids)' }, + { name: 'mine', using: 'owner_id = current_user.id' }, + ], + }, + ], + sharingRules: [ + { name: 'plant_team', object: 'work_order', sharedTo: { type: 'business_unit', id: 'bu_plant_a' } }, + ], + objects: [{ name: 'work_order' }], + }), + ).toEqual([]); + }); +}); + +describe('validateOrgAxisRedLines — ② business-unit trees stay org-internal', () => { + const platformGlobalStack = (tenancy: unknown) => ({ + objects: [{ name: 'material_catalog', tenancy }], + sharingRules: [ + { + name: 'catalog_to_plant', + object: 'material_catalog', + sharedTo: { type: 'business_unit', id: 'bu_plant_a' }, + }, + ], + }); + + it('flags a business-unit grant on a `tenancy.enabled: false` object', () => { + const findings = validateOrgAxisRedLines(platformGlobalStack({ enabled: false })); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: ORG_AXIS_CROSS_ORG_BU_GRANT, + path: 'sharingRules[0].sharedTo', + }); + expect(findings[0].message).toMatch(/spans EVERY organization/); + }); + + it('flags the `systemFields.tenant: false` spelling of the same opt-out', () => { + expect( + rules({ + objects: [{ name: 'material_catalog', systemFields: { tenant: false } }], + sharingRules: [ + { name: 'r', object: 'material_catalog', sharedTo: { type: 'business_unit', id: 'bu' } }, + ], + }), + ).toEqual([ORG_AXIS_CROSS_ORG_BU_GRANT]); + }); + + it('allows a business-unit grant on an ORG-SCOPED object (the normal case)', () => { + expect(rules(platformGlobalStack({ enabled: true }))).toEqual([]); + expect(rules(platformGlobalStack(undefined))).toEqual([]); + }); + + it('allows a non-BU audience on a platform-global object', () => { + expect( + rules({ + objects: [{ name: 'material_catalog', tenancy: { enabled: false } }], + sharingRules: [ + { name: 'r', object: 'material_catalog', sharedTo: { type: 'position', name: 'buyer' } }, + ], + }), + ).toEqual([]); + }); +}); + +describe('validateOrgAxisRedLines — input tolerance', () => { + it('returns no findings for empty / malformed input instead of throwing', () => { + expect(validateOrgAxisRedLines(undefined)).toEqual([]); + expect(validateOrgAxisRedLines({})).toEqual([]); + expect(validateOrgAxisRedLines({ permissions: 'nonsense', objects: 42 })).toEqual([]); + }); + + it('accepts the name-keyed map shape as well as arrays', () => { + expect( + rules({ + permissions: { + group_hq: { rowLevelSecurity: [{ name: 'r', using: 'parent_organization_id = 1' }] }, + }, + }), + ).toEqual([ORG_AXIS_PERMISSION_INHERITANCE]); + }); +}); diff --git a/packages/lint/src/validate-org-axis-red-lines.ts b/packages/lint/src/validate-org-axis-red-lines.ts new file mode 100644 index 0000000000..0c18688cf1 --- /dev/null +++ b/packages/lint/src/validate-org-axis-red-lines.ts @@ -0,0 +1,195 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0105 D6] The two organization-axis red lines, enforced at authoring time. + * + * ADR-0105 gives organizations a reporting/grouping dimension + * (`sys_organization.parent_organization_id`, sibling ordering). That dimension + * is load-bearing for consolidated reporting — and dangerous, because it LOOKS + * like a permission hierarchy. Two lines keep it from becoming one: + * + * | Rule | Red line | + * |-----------------------------------------|-----------------------------------| + * | org-axis-permission-inheritance (error) | D6 ①: no inheritance along the org tree | + * | org-axis-cross-org-bu-grant (error) | D6 ②: business-unit trees stay org-internal | + * + * **① No permission inheritance along the org axis.** Cross-organization + * visibility comes from membership union (`accessible_org_ids`, ADR-0105 D2) — + * the engine's own Layer 0 wall — never from walking a parent reference. An RLS + * policy or sharing rule that reads `parent_organization_id` builds a SECOND + * permission hierarchy beside the business-unit tree: exactly the dual-hierarchy + * mistake ADR-0057 D5 retired and ADR-0090 D3 finalized for positions. It also + * silently outranks the wall it sits behind, since a Layer-1 policy cannot widen + * Layer 0 (W1) — so the author gets a rule that appears to grant access and + * does not. Fail at authoring, not in a support ticket. + * + * **② Business-unit trees remain org-internal.** `sys_business_unit` is + * org-scoped and every BU mechanism (`unit_and_subordinates` sharing, + * `adminScope` delegation, depth scopes) resolves within ONE organization. A + * business-unit sharing rule on a PLATFORM-GLOBAL object (`tenancy.enabled: + * false`) has no organization column to scope against, so the grant spans every + * organization in the database — a cross-org BU grant by construction, and the + * "cross-org BU mega-tree" the ADR rejected, arrived at by accident. + * + * Both are `error`, per ADR-0049 discipline: each mirrors a real enforcement + * property (the Layer 0 wall's independence; the org-predicated BU resolver), + * so the lint moves the failure from silent-wrong-answer to author-time fix-it. + * + * Pure `(stack) => Finding[]`; accepts the NORMALIZED stack input. + */ + +export const ORG_AXIS_PERMISSION_INHERITANCE = 'org-axis-permission-inheritance'; +export const ORG_AXIS_CROSS_ORG_BU_GRANT = 'org-axis-cross-org-bu-grant'; + +export type OrgAxisSeverity = 'error' | 'warning'; + +export interface OrgAxisFinding { + severity: OrgAxisSeverity; + /** Diagnostic rule id (`org-axis-*`). */ + rule: string; + /** Human-readable location, e.g. `permission set "plant_reader"`. */ + where: string; + /** Config path, e.g. `permissions[2].rowLevelSecurity[0].using`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +type AnyRec = Record; + +/** The org-axis grouping reference. Reporting only — never an authorization input. */ +const ORG_PARENT_FIELD = 'parent_organization_id'; + +/** Coerce a collection (array or name-keyed map) to an array of records. */ +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v as AnyRec[]; + if (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); + } + return []; +} + +function str(v: unknown): string { + return typeof v === 'string' ? v : ''; +} + +/** True iff the object opted out of tenancy — platform-global, no org column. */ +function isTenancyDisabled(object: AnyRec): boolean { + const tenancy = object.tenancy as AnyRec | undefined; + if (tenancy && typeof tenancy === 'object' && tenancy.enabled === false) return true; + const systemFields = object.systemFields as AnyRec | undefined; + if (systemFields && typeof systemFields === 'object' && systemFields.tenant === false) return true; + return false; +} + +const INHERITANCE_HINT = + `Remove the ${ORG_PARENT_FIELD} reference. Cross-organization visibility comes from MEMBERSHIP: ` + + `under the \`group\` tenancy posture the engine's Layer 0 wall is ` + + `\`organization_id IN accessible_org_ids\`, so a user who should see several organizations is made ` + + `a member of them (ADR-0105 D2). A Layer-1 policy cannot widen Layer 0 anyway, so this rule would ` + + `not grant the access it appears to. For a hierarchy INSIDE one organization, use the business-unit ` + + `tree (\`unit_and_subordinates\` sharing, or a depth scope anchored on \`sys_user_position\`).`; + +/** + * Lint an ObjectStack config for the ADR-0105 D6 organization-axis red lines. + */ +export function validateOrgAxisRedLines(stack: unknown): OrgAxisFinding[] { + const findings: OrgAxisFinding[] = []; + const cfg = (stack ?? {}) as AnyRec; + + // ── ① No permission inheritance along the org axis ──────────────────────── + // + // RLS policies may live on a permission set (`rowLevelSecurity`) or be + // authored per object; both reach the same compiler, so both are checked. + const permissionSets = asArray(cfg.permissions ?? cfg.permissionSets); + permissionSets.forEach((ps, psIndex) => { + asArray(ps.rowLevelSecurity).forEach((policy, pIndex) => { + for (const clause of ['using', 'check'] as const) { + if (!str(policy[clause]).includes(ORG_PARENT_FIELD)) continue; + findings.push({ + severity: 'error', + rule: ORG_AXIS_PERMISSION_INHERITANCE, + where: `permission set "${str(ps.name) || psIndex}" policy "${str(policy.name) || pIndex}"`, + path: `permissions[${psIndex}].rowLevelSecurity[${pIndex}].${clause}`, + message: + `RLS ${clause} reads \`${ORG_PARENT_FIELD}\`, which builds a permission hierarchy along the ` + + `organization axis. ADR-0105 D6 forbids it: the org tree is a REPORTING dimension only.`, + hint: INHERITANCE_HINT, + }); + } + }); + }); + + const objects = asArray(cfg.objects); + objects.forEach((object, oIndex) => { + const objectName = str(object.name) || String(oIndex); + + asArray(object.rowLevelSecurity ?? object.rls).forEach((policy, pIndex) => { + for (const clause of ['using', 'check'] as const) { + if (!str(policy[clause]).includes(ORG_PARENT_FIELD)) continue; + findings.push({ + severity: 'error', + rule: ORG_AXIS_PERMISSION_INHERITANCE, + where: `object "${objectName}" policy "${str(policy.name) || pIndex}"`, + path: `objects[${oIndex}].rowLevelSecurity[${pIndex}].${clause}`, + message: + `RLS ${clause} reads \`${ORG_PARENT_FIELD}\`, which builds a permission hierarchy along the ` + + `organization axis. ADR-0105 D6 forbids it: the org tree is a REPORTING dimension only.`, + hint: INHERITANCE_HINT, + }); + } + }); + }); + + // Sharing rules — criteria and recipient may both reach for the org parent. + asArray(cfg.sharingRules ?? cfg.sharing).forEach((rule, rIndex) => { + const criteria = JSON.stringify(rule.criteria ?? rule.filter ?? ''); + const sharedTo = JSON.stringify(rule.sharedTo ?? rule.recipient ?? ''); + if (criteria.includes(ORG_PARENT_FIELD) || sharedTo.includes(ORG_PARENT_FIELD)) { + findings.push({ + severity: 'error', + rule: ORG_AXIS_PERMISSION_INHERITANCE, + where: `sharing rule "${str(rule.name) || rIndex}"`, + path: `sharingRules[${rIndex}]`, + message: + `Sharing rule reads \`${ORG_PARENT_FIELD}\`, granting access by walking the organization ` + + `tree. ADR-0105 D6 forbids permission inheritance along the org axis.`, + hint: INHERITANCE_HINT, + }); + } + }); + + // ── ② Business-unit trees remain org-internal ───────────────────────────── + // + // A `business_unit` recipient on a platform-global object has no organization + // column to scope against, so the grant reaches every organization's rows. + const tenancyDisabledObjects = new Set( + objects.filter((o) => isTenancyDisabled(o)).map((o) => str(o.name)).filter(Boolean), + ); + asArray(cfg.sharingRules ?? cfg.sharing).forEach((rule, rIndex) => { + const target = str(rule.object ?? rule.objectName); + if (!target || !tenancyDisabledObjects.has(target)) return; + const sharedTo = (rule.sharedTo ?? rule.recipient) as AnyRec | undefined; + const recipientType = str(sharedTo?.type); + if (recipientType !== 'business_unit') return; + findings.push({ + severity: 'error', + rule: ORG_AXIS_CROSS_ORG_BU_GRANT, + where: `sharing rule "${str(rule.name) || rIndex}" on object "${target}"`, + path: `sharingRules[${rIndex}].sharedTo`, + message: + `A business-unit sharing rule targets "${target}", which opted out of tenancy ` + + `(\`tenancy.enabled: false\`). Platform-global objects carry no organization column, so this ` + + `grant spans EVERY organization — a cross-organization business-unit grant, which ADR-0105 D6 ` + + `forbids (BU trees are org-internal).`, + hint: + `Either scope the object to organizations (drop \`tenancy.enabled: false\` so Layer 0 walls it), ` + + `or share it to a position / permission-set audience instead of a business unit. A ` + + `platform-global catalog that everyone should read wants an OWD of \`public_read\`, not a BU grant.`, + }); + }); + + return findings; +} diff --git a/packages/mcp/src/plugin.ts b/packages/mcp/src/plugin.ts index 112da1c5b6..ad0e67aa4c 100644 --- a/packages/mcp/src/plugin.ts +++ b/packages/mcp/src/plugin.ts @@ -38,6 +38,10 @@ async function resolveStdioExecutionContext( if (authz.email) ec.email = authz.email; if (authz.posture) ec.posture = authz.posture; (ec as unknown as { org_user_ids?: string[] }).org_user_ids = authz.org_user_ids; + // [ADR-0105 D2] The caller's org access set — the `group` posture's Layer 0 + // wall reads it directly, so every transport must carry it. + (ec as unknown as { accessible_org_ids?: string[] }).accessible_org_ids = + authz.accessible_org_ids; return ec; } diff --git a/packages/metadata/vitest.config.ts b/packages/metadata/vitest.config.ts index d753a92b14..10915ef3c0 100644 --- a/packages/metadata/vitest.config.ts +++ b/packages/metadata/vitest.config.ts @@ -15,6 +15,8 @@ export default defineConfig({ '@objectstack/spec/kernel': path.resolve(__dirname, '../spec/src/kernel/index.ts'), '@objectstack/spec/system': path.resolve(__dirname, '../spec/src/system/index.ts'), '@objectstack/spec/shared': path.resolve(__dirname, '../spec/src/shared/index.ts'), + // [ADR-0105 D1] Reached transitively via `@objectstack/types` (tenancy posture). + '@objectstack/spec/security': path.resolve(__dirname, '../spec/src/security/index.ts'), '@objectstack/spec': path.resolve(__dirname, '../spec/src/index.ts'), '@objectstack/types': path.resolve(__dirname, '../types/src/index.ts'), }, diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 365195eef9..26e1073b58 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1515,10 +1515,6 @@ export const enMetadataForms: NonNullable = { rowLevelSecurity: { label: "Row Level Security", helpText: "Array of RLS policies (see rls.zod.ts)" - }, - contextVariables: { - label: "Context Variables", - helpText: "Custom variables referenced in RLS predicates" } } }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 6a2c0d1b54..ca592f2ba9 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1515,10 +1515,6 @@ export const esESMetadataForms: NonNullable = rowLevelSecurity: { label: "Seguridad a nivel de fila", helpText: "Array de políticas RLS (ver rls.zod.ts)" - }, - contextVariables: { - label: "Variables de contexto", - helpText: "Variables personalizadas referenciadas en predicados RLS" } } }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 5ecee91613..605f3a7575 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1515,10 +1515,6 @@ export const jaJPMetadataForms: NonNullable = rowLevelSecurity: { label: "行レベルセキュリティ", helpText: "RLS ポリシーの配列(rls.zod.ts 参照)" - }, - contextVariables: { - label: "コンテキスト変数", - helpText: "RLS 述語で参照するカスタム変数" } } }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 20d269a38b..94bdb4f935 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1515,10 +1515,6 @@ export const zhCNMetadataForms: NonNullable = rowLevelSecurity: { label: "行级安全", helpText: "基于记录条件的访问规则" - }, - contextVariables: { - label: "上下文变量", - helpText: "可在规则中引用的变量" } } }, diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 91b1628469..74802b2304 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -215,6 +215,41 @@ export const SysOrganization = ObjectSchema.create({ description: 'When true, every member of this organization must enroll an authenticator app to access data.', }), + // ── Group structure (ADR-0105 D6) ──────────────────────────── + // + // ⛔ REPORTING DIMENSION ONLY. These fields describe how organizations roll + // up for consolidated reporting, navigation and ordering. They are NOT an + // authorization axis, and two red lines keep them that way: + // + // 1. **No permission inheritance along the org axis.** A parent + // organization confers NOTHING on its children. Cross-org visibility + // comes from membership union (`accessible_org_ids`, ADR-0105 D2) — + // never from walking this tree. Re-adding a second permission + // hierarchy is the mistake ADR-0057 D5 retired and ADR-0090 D3 + // finalized for positions; it stays retired for organizations. + // 2. **Business-unit trees remain org-internal.** `sys_business_unit` is + // org-scoped, and every BU mechanism (`unit_and_subordinates` sharing, + // `adminScope` delegation, depth scopes) operates within ONE + // organization. There is no cross-org tree. + // + // Both are lint-enforced (`validateOrgAxisRedLines` in @objectstack/lint): + // an RLS policy, sharing rule or scope that reads `parent_organization_id` + // is a build error, not a runtime surprise. + parent_organization_id: Field.lookup('sys_organization', { + label: 'Parent Organization', + required: false, + group: 'Group Structure', + description: + 'Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference (ADR-0105 D6).', + }), + + sort_order: Field.number({ + label: 'Sort Order', + required: false, + group: 'Group Structure', + description: 'Display order among sibling organizations. Presentation only.', + }), + // ── System ─────────────────────────────────────────────────── id: Field.text({ label: 'Organization ID', diff --git a/packages/plugins/driver-memory/vitest.config.ts b/packages/plugins/driver-memory/vitest.config.ts index fcded82ed9..21bd9ebbb5 100644 --- a/packages/plugins/driver-memory/vitest.config.ts +++ b/packages/plugins/driver-memory/vitest.config.ts @@ -16,6 +16,8 @@ export default defineConfig({ '@objectstack/spec/data': path.resolve(__dirname, '../../spec/src/data/index.ts'), '@objectstack/spec/kernel': path.resolve(__dirname, '../../spec/src/kernel/index.ts'), '@objectstack/spec/system': path.resolve(__dirname, '../../spec/src/system/index.ts'), + // [ADR-0105 D1] Reached transitively via `@objectstack/types` (tenancy posture). + '@objectstack/spec/security': path.resolve(__dirname, '../../spec/src/security/index.ts'), '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), }, }, diff --git a/packages/plugins/driver-sql/vitest.config.ts b/packages/plugins/driver-sql/vitest.config.ts index 4b49e52df0..d8263a3634 100644 --- a/packages/plugins/driver-sql/vitest.config.ts +++ b/packages/plugins/driver-sql/vitest.config.ts @@ -14,6 +14,8 @@ export default defineConfig({ '@objectstack/spec/data': path.resolve(__dirname, '../../spec/src/data/index.ts'), '@objectstack/spec/shared': path.resolve(__dirname, '../../spec/src/shared/index.ts'), '@objectstack/spec/system': path.resolve(__dirname, '../../spec/src/system/index.ts'), + // [ADR-0105 D1] Reached transitively via `@objectstack/types` (tenancy posture). + '@objectstack/spec/security': path.resolve(__dirname, '../../spec/src/security/index.ts'), '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), }, }, diff --git a/packages/plugins/embedder-openai/vitest.config.ts b/packages/plugins/embedder-openai/vitest.config.ts index 1ca74cee61..4db09eb32e 100644 --- a/packages/plugins/embedder-openai/vitest.config.ts +++ b/packages/plugins/embedder-openai/vitest.config.ts @@ -12,6 +12,8 @@ export default defineConfig({ alias: { '@objectstack/spec/contracts': path.resolve(__dirname, '../../spec/src/contracts/index.ts'), '@objectstack/spec/shared': path.resolve(__dirname, '../../spec/src/shared/index.ts'), + // [ADR-0105 D1] Reached transitively via `@objectstack/types` (tenancy posture). + '@objectstack/spec/security': path.resolve(__dirname, '../../spec/src/security/index.ts'), '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), }, }, diff --git a/packages/plugins/knowledge-memory/vitest.config.ts b/packages/plugins/knowledge-memory/vitest.config.ts index 7b90fe8229..7737503420 100644 --- a/packages/plugins/knowledge-memory/vitest.config.ts +++ b/packages/plugins/knowledge-memory/vitest.config.ts @@ -17,6 +17,8 @@ export default defineConfig({ '@objectstack/spec/data': path.resolve(__dirname, '../../spec/src/data/index.ts'), '@objectstack/spec/kernel': path.resolve(__dirname, '../../spec/src/kernel/index.ts'), '@objectstack/spec/system': path.resolve(__dirname, '../../spec/src/system/index.ts'), + // [ADR-0105 D1] Reached transitively via `@objectstack/types` (tenancy posture). + '@objectstack/spec/security': path.resolve(__dirname, '../../spec/src/security/index.ts'), '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), }, }, diff --git a/packages/plugins/knowledge-ragflow/vitest.config.ts b/packages/plugins/knowledge-ragflow/vitest.config.ts index 7b90fe8229..7737503420 100644 --- a/packages/plugins/knowledge-ragflow/vitest.config.ts +++ b/packages/plugins/knowledge-ragflow/vitest.config.ts @@ -17,6 +17,8 @@ export default defineConfig({ '@objectstack/spec/data': path.resolve(__dirname, '../../spec/src/data/index.ts'), '@objectstack/spec/kernel': path.resolve(__dirname, '../../spec/src/kernel/index.ts'), '@objectstack/spec/system': path.resolve(__dirname, '../../spec/src/system/index.ts'), + // [ADR-0105 D1] Reached transitively via `@objectstack/types` (tenancy posture). + '@objectstack/spec/security': path.resolve(__dirname, '../../spec/src/security/index.ts'), '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), }, }, diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index eec7558359..93752f2314 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -9,7 +9,7 @@ import { import { ExpressionEngine, collectCelRootIdentifiers } from '@objectstack/formula'; import { ADMIN_FULL_ACCESS, - ORGANIZATION_ADMIN, + ORGANIZATION_ADMIN_GRANTS, BUILTIN_IDENTITY_PLATFORM_ADMIN, BUILTIN_IDENTITY_ORG_OWNER, BUILTIN_IDENTITY_ORG_ADMIN, @@ -381,7 +381,7 @@ export class ApprovalService implements IApprovalService { || positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN); if (isPlatformAdmin) return true; const isTenantAdmin = posture === 'TENANT_ADMIN' - || perms.includes(ORGANIZATION_ADMIN) + || ORGANIZATION_ADMIN_GRANTS.some((n) => perms.includes(n)) || positions.includes(BUILTIN_IDENTITY_ORG_OWNER) || positions.includes(BUILTIN_IDENTITY_ORG_ADMIN); if (!isTenantAdmin) return false; diff --git a/packages/plugins/plugin-audit/vitest.config.ts b/packages/plugins/plugin-audit/vitest.config.ts index 9da54a92fb..e12f1473a8 100644 --- a/packages/plugins/plugin-audit/vitest.config.ts +++ b/packages/plugins/plugin-audit/vitest.config.ts @@ -23,6 +23,8 @@ export default defineConfig({ '@objectstack/spec/system': path.resolve(__dirname, '../../spec/src/system/index.ts'), '@objectstack/spec/api': path.resolve(__dirname, '../../spec/src/api/index.ts'), '@objectstack/spec/kernel': path.resolve(__dirname, '../../spec/src/kernel/index.ts'), + // [ADR-0105 D1] Reached transitively via `@objectstack/types` (tenancy posture). + '@objectstack/spec/security': path.resolve(__dirname, '../../spec/src/security/index.ts'), '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), '@objectstack/types': path.resolve(__dirname, '../../types/src/index.ts'), }, diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 86f7b6d1e9..3aaae8a19e 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1508,6 +1508,10 @@ describe('AuthManager', () => { phoneNumber: false, phoneNumberOtp: false, multiOrgEnabled: false, + // [ADR-0105 D1] Which of `single` | `group` | `isolated` is in force. + // A vanilla deployment (no tenancy service wired, no env override) is + // `single`, matching `multiOrgEnabled: false`. + tenancyPosture: 'single', degradedTenancy: false, privacyUrl: 'https://objectstack.ai/privacy', termsUrl: 'https://objectstack.ai/terms', diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 3134ea9d2a..26933764d7 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -2735,13 +2735,15 @@ export class AuthManager { const pluginConfig: Partial = this.config.plugins ?? {}; // Multi-org capability (UI org-switcher, "create org" action, etc.). // `OS_MULTI_ORG_ENABLED` (default `'false'` → single-org / per-env runtime). - // ADR-0093 D4 — the `tenancy` service is the single source of truth. Prefer - // it; fall back to the raw env flag only when it isn't wired (e.g. a lean - // embedding). `multiOrgEnabled` now reflects ACTUAL capability - // (`mode === 'multi'`), so a degraded deployment (requested but no isolation) + // ADR-0093 D4 / ADR-0105 D1 — the `tenancy` service is the single source of + // truth. Prefer it; fall back to the raw env flag only when it isn't wired + // (e.g. a lean embedding). `multiOrgEnabled` reflects ACTUAL capability — + // any posture that enforces an organization wall (`group` or `isolated`) — + // so a degraded deployment (requested but no isolation resolves to `single`) // reports `false` and the org-management UI hides instead of rendering broken. const tenancy = this.config.getTenancy?.(); - const multiOrgEnabled = tenancy ? tenancy.mode === 'multi' : resolveMultiOrgEnabled(); + const tenancyPosture = tenancy?.posture ?? (resolveMultiOrgEnabled() ? 'isolated' : 'single'); + const multiOrgEnabled = tenancyPosture !== 'single'; const degradedTenancy = tenancy?.degraded ?? false; // Legal links shown beneath the login / register cards. Defaults to @@ -2772,6 +2774,11 @@ export class AuthManager { magicLink: pluginConfig.magicLink ?? false, organization: pluginConfig.organization ?? true, multiOrgEnabled, + // ADR-0105 D1 — WHICH posture is in force. `multiOrgEnabled` stays the + // capability gate; this tells the console how to render org context: under + // `group` the org switcher picks the WRITE target while reads span every + // organization the member belongs to ("all my organizations" views). + tenancyPosture, // ADR-0093 D5 — brand the degraded state everywhere an operator looks. // True iff multi-org was requested but tenant isolation is inactive // (booted only because OS_ALLOW_DEGRADED_TENANCY=1). The console can diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 8e6e51878a..054038822c 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -10,7 +10,7 @@ import { SystemOverviewDatasets, } from '@objectstack/platform-objects/apps'; import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages'; -import { resolveMultiOrgEnabled } from '@objectstack/types'; +import { resolveTenancyPosture } from '@objectstack/types'; import { AuthManager, resolveOidcProviderEnabled, @@ -26,6 +26,7 @@ import { type SecondaryStorageLike, } from './identity-write-guard.js'; import { SYS_USER_PROFILE_EDIT_FIELDS } from './sys-user-writable-fields.js'; +import { MANAGED_EXTENSION_EDITABLE_FIELDS } from './managed-extension-fields.js'; import { runSetInitialPassword } from './set-initial-password.js'; import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain } from './register-sso-provider.js'; import { runResendVerificationEmail } from './send-verification-email.js'; @@ -263,16 +264,18 @@ export class AuthPlugin implements Plugin { // Register auth service ctx.registerService('auth', this.authManager); - // ADR-0093 D4 — register the `tenancy` service (single source of truth for - // tenancy mode). Registered AFTER `auth` so `auth` stays the plugin's first - // service registration (consumers and tests rely on that ordering). Baseline - // derives `isolationActive` from the presence of the `org-scoping` service - // (registered by @objectstack/organizations when installed), so the - // enterprise package needs no change to light it up. `getService` is a cheap - // registry lookup and org-scoping registers AFTER plugin-auth, so the probe - // is deferred to first read (start()/request time). + // ADR-0093 D4 / ADR-0105 D1 — register the `tenancy` service (single source + // of truth for the tenancy POSTURE). Registered AFTER `auth` so `auth` stays + // the plugin's first service registration (consumers and tests rely on that + // ordering). The `isolated` posture derives `isolationActive` from the + // presence of the `org-scoping` service (registered by + // @objectstack/organizations when installed), so the enterprise package + // needs no change to light it up; `group` is enforced by the open engine and + // 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 tenancy: TenancyService = createTenancyService({ - requested: resolveMultiOrgEnabled(), + requested: resolveTenancyPosture(), probeIsolation: () => { try { return !!ctx.getService('org-scoping'); @@ -549,11 +552,18 @@ export class AuthPlugin implements Plugin { await this.maybeSeedDevAdmin(ctx); }); - // ADR-0081 D1 — single-org default-organization bootstrap. Multi-org - // keeps its existing owner (the enterprise organizations package, which - // runs the same idempotent helper with the seed-ownership step injected); - // one crisp owner per mode. - if (this.options.autoDefaultOrganization !== false && !resolveMultiOrgEnabled()) { + // 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') { const runEnsure = async () => { try { const ql: any = ctx.getService('objectql'); @@ -697,6 +707,16 @@ export class AuthPlugin implements Plugin { const engine: any = ctx.getService('objectql'); if (!engine || typeof engine.registerHook !== 'function') return; registerManagedUpdateWhitelist(SystemObjectName.USER, SYS_USER_PROFILE_EDIT_FIELDS); + // [ADR-0105 D7] Extension fields ObjectStack adds to better-auth-managed + // tables ride the SAME whitelist — no second mechanism. better-auth + // neither reads nor writes them, so they are editable through the + // ordinary path under normal FLS / requiredPermissions; its own protocol + // fields stay rejected. `managed-extension-fields.test.ts` proves the two + // populations are disjoint at the pinned better-auth version. + for (const [object, fields] of Object.entries(MANAGED_EXTENSION_EDITABLE_FIELDS)) { + if (object === SystemObjectName.USER) continue; // sys_user tiering above + registerManagedUpdateWhitelist(object, fields); + } registerIdentityWriteGuard(engine, { packageId: 'com.objectstack.plugin-auth.identity-write-guard', logger: ctx.logger, diff --git a/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts b/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts new file mode 100644 index 0000000000..931feb8e48 --- /dev/null +++ b/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0105 D7] Collision guard for extension fields on better-auth-managed + * objects. + * + * Extending a better-auth table is established practice (`sys_user.manager_id`, + * `sys_organization.require_mfa`, and now the D6 group-structure fields). The + * hazard is a NAME COLLISION: if a better-auth upgrade introduces a field with + * the same name, ownership of that column silently changes hands — our writes + * start clobbering better-auth state, or better-auth's start clobbering ours, + * with no error anywhere and no test failing. + * + * So the guard derives better-auth's REAL field surface from `getAuthTables()` + * at the pinned version, rather than trusting a hand-maintained list, and fails + * on any overlap. The upgrade that would cause the collision is the moment + * someone finds out. + */ + +import { describe, it, expect } from 'vitest'; +import { getAuthTables } from 'better-auth/db'; +import { organization } from 'better-auth/plugins'; + +import { + MANAGED_EXTENSION_FIELDS, + MANAGED_EXTENSION_EDITABLE_FIELDS, + managedExtensionFields, + managedExtensionEditableFields, +} from './managed-extension-fields.js'; + +/** better-auth model name → the ObjectStack object it materializes as. */ +const MODEL_TO_OBJECT: Record = { + user: 'sys_user', + session: 'sys_session', + account: 'sys_account', + verification: 'sys_verification', + organization: 'sys_organization', + member: 'sys_member', + invitation: 'sys_invitation', + team: 'sys_team', + teamMember: 'sys_team_member', +}; + +/** better-auth authors fields in camelCase; ObjectStack columns are snake_case. */ +function toSnakeCase(name: string): string { + return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); +} + +/** + * Every field better-auth owns, per ObjectStack object name, in BOTH spellings + * — comparing only one would let `parentOrganizationId` slip past a check on + * `parent_organization_id`. + */ +function betterAuthFieldsByObject(): Record> { + const tables = getAuthTables({ plugins: [organization()] } as never); + const out: Record> = {}; + for (const [model, table] of Object.entries(tables ?? {})) { + const object = MODEL_TO_OBJECT[model]; + if (!object) continue; + const names = new Set(); + for (const field of Object.keys((table as { fields?: object }).fields ?? {})) { + names.add(field); + names.add(toSnakeCase(field)); + } + // better-auth always owns the primary key, whatever the table. + names.add('id'); + out[object] = names; + } + return out; +} + +describe('managed extension fields (ADR-0105 D7)', () => { + const byObject = betterAuthFieldsByObject(); + + it('derives a non-empty better-auth surface (the guard must not pass vacuously)', () => { + expect(Object.keys(byObject).length).toBeGreaterThan(0); + expect(byObject.sys_organization?.size ?? 0).toBeGreaterThan(0); + expect(byObject.sys_user?.size ?? 0).toBeGreaterThan(0); + }); + + it('no declared extension field collides with better-auth\'s own schema', () => { + const collisions: string[] = []; + for (const [object, fields] of Object.entries(MANAGED_EXTENSION_FIELDS)) { + const owned = byObject[object]; + if (!owned) continue; + for (const field of fields) { + if (owned.has(field)) collisions.push(`${object}.${field}`); + } + } + expect( + collisions, + `these extension fields collide with better-auth's own schema at the pinned version: ` + + `${collisions.join(', ')} — better-auth now owns those columns, so ObjectStack must rename ` + + `its field (and migrate) or drop the extension. Silently sharing a column means one side ` + + `clobbers the other with no error.`, + ).toEqual([]); + }); + + it('editable extension fields are a SUBSET of declared extension fields', () => { + // Listing a field as ours must not be what makes it editable — the two + // tiers are separate decisions (ADR-0092 D1). + for (const [object, editable] of Object.entries(MANAGED_EXTENSION_EDITABLE_FIELDS)) { + const declared = managedExtensionFields(object); + for (const field of editable) { + expect(declared.has(field), `${object}.${field} is editable but not declared`).toBe(true); + } + } + }); + + it('the ADR-0105 D6 group-structure fields are declared and editable', () => { + expect(managedExtensionFields('sys_organization')).toContain('parent_organization_id'); + expect(managedExtensionEditableFields('sys_organization')).toContain('parent_organization_id'); + expect(managedExtensionEditableFields('sys_organization')).toContain('sort_order'); + }); + + it('admin-surface-only sys_user fields are declared but NOT generically editable', () => { + // `manager_id` / `ai_access` drive authorization and AI seating; + // `primary_business_unit_id` is a projection plugin-sharing maintains. + for (const field of ['manager_id', 'ai_access', 'primary_business_unit_id']) { + expect(managedExtensionFields('sys_user')).toContain(field); + expect(managedExtensionEditableFields('sys_user')).not.toContain(field); + } + }); + + it('returns empty sets for an object with no extensions', () => { + expect(managedExtensionFields('sys_session').size).toBe(0); + expect(managedExtensionEditableFields('sys_session').size).toBe(0); + }); +}); diff --git a/packages/plugins/plugin-auth/src/managed-extension-fields.ts b/packages/plugins/plugin-auth/src/managed-extension-fields.ts new file mode 100644 index 0000000000..f2a4420cc6 --- /dev/null +++ b/packages/plugins/plugin-auth/src/managed-extension-fields.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0105 D7 / ADR-0092 D2] Extension fields ObjectStack adds to + * better-auth-managed objects, and which of them a generic write surface may + * edit. + * + * ## Two field populations on one table + * + * A `managedBy: 'better-auth'` object carries two kinds of column: + * + * - **Protocol fields** — better-auth's own (`email`, `slug`, `sys_member.role`, + * …). Mutations must flow through better-auth's endpoints so hashing, token + * signing, invitation flows and membership invariants all fire. The identity + * write guard rejects direct writes to them, full stop. + * - **Extension fields** — columns ObjectStack (or an app) adds for platform + * concerns better-auth knows nothing about: `sys_user.manager_id`, + * `sys_organization.require_mfa`, and the ADR-0105 D6 group-structure + * fields. better-auth never reads or writes these, so they can be edited + * through the ordinary path under normal FLS / `requiredPermissions`. + * + * ADR-0092 made the *guard* registry-driven with a per-object update whitelist; + * `sys_user → {name, image}` was its first and only entry. This module is the + * home for the rest, so "which fields on a managed table are ours, and which of + * those are editable" is answered in ONE place instead of per call site. + * + * ## The collision rule (D7) + * + * An extension field name must NEVER collide with better-auth's own schema + * surface for that table. A collision would silently transfer ownership of a + * column on a library upgrade — our writes would start clobbering better-auth + * state, or vice versa, with no error anywhere. `managed-extension-fields.test.ts` + * derives better-auth's real field surface from `getAuthTables()` at the PINNED + * version and fails the build on any overlap, so the upgrade that would cause + * this is the moment someone finds out. + */ + +/** Object name → the extension fields ObjectStack declares on it. */ +export const MANAGED_EXTENSION_FIELDS: Readonly>> = { + sys_user: new Set([ + 'manager_id', + 'primary_business_unit_id', + 'ai_access', + 'phone_number', + 'source', + ]), + sys_organization: new Set([ + // ADR-0069 D3 — per-org MFA tightening above the global floor. + 'require_mfa', + // ADR-0105 D6 — group structure. Reporting dimension ONLY: no permission + // inheritance walks this reference (lint-enforced). + 'parent_organization_id', + 'sort_order', + ]), +}; + +/** + * Object name → the extension fields a generic write surface (edit form, data + * API) may update, subject to the usual FLS / `requiredPermissions` checks. + * + * A subset of {@link MANAGED_EXTENSION_FIELDS} by construction: listing a field + * as ours does NOT make it editable. `manager_id` / `primary_business_unit_id` + * / `ai_access` stay admin-surface-only (ADR-0092 D1's tier table), and + * `primary_business_unit_id` is a projection plugin-sharing maintains — a hand + * edit would be overwritten on the next reconcile. + */ +export const MANAGED_EXTENSION_EDITABLE_FIELDS: Readonly>> = { + sys_organization: new Set([ + 'require_mfa', + 'parent_organization_id', + 'sort_order', + ]), +}; + +/** The extension fields declared on `object`, or an empty set. */ +export function managedExtensionFields(object: string): ReadonlySet { + return MANAGED_EXTENSION_FIELDS[object] ?? new Set(); +} + +/** The generically-editable extension fields on `object`, or an empty set. */ +export function managedExtensionEditableFields(object: string): ReadonlySet { + return MANAGED_EXTENSION_EDITABLE_FIELDS[object] ?? new Set(); +} diff --git a/packages/plugins/plugin-auth/src/tenancy-service.test.ts b/packages/plugins/plugin-auth/src/tenancy-service.test.ts index c576a44fa0..300dd8f626 100644 --- a/packages/plugins/plugin-auth/src/tenancy-service.test.ts +++ b/packages/plugins/plugin-auth/src/tenancy-service.test.ts @@ -16,24 +16,26 @@ function makeEngine(orgs: Array<{ id: string; slug?: string }>) { } describe('createTenancyService', () => { - it('single mode: not requested, isolation off', () => { - const t = createTenancyService({ requested: false, probeIsolation: () => false }); - expect(t.mode).toBe('single'); + it('single posture: no wall requested, isolation off', () => { + const t = createTenancyService({ requested: 'single', probeIsolation: () => false }); + expect(t.posture).toBe('single'); + expect(t.requestedPosture).toBe('single'); expect(t.isolationActive).toBe(false); expect(t.requested).toBe(false); expect(t.degraded).toBe(false); }); - it('multi mode: requested and isolation active', () => { - const t = createTenancyService({ requested: true, probeIsolation: () => true }); - expect(t.mode).toBe('multi'); + it('isolated posture: requested and isolation active', () => { + const t = createTenancyService({ requested: 'isolated', probeIsolation: () => true }); + expect(t.posture).toBe('isolated'); expect(t.isolationActive).toBe(true); expect(t.degraded).toBe(false); }); - it('degraded: requested but isolation NOT active', () => { - const t = createTenancyService({ requested: true, probeIsolation: () => false }); - expect(t.mode).toBe('single'); // behaves single-org-like — nothing isolates + it('degraded: isolated requested but isolation NOT active', () => { + const t = createTenancyService({ requested: 'isolated', probeIsolation: () => false }); + expect(t.posture).toBe('single'); // behaves single-org-like — nothing isolates + expect(t.requestedPosture).toBe('isolated'); expect(t.isolationActive).toBe(false); expect(t.requested).toBe(true); expect(t.degraded).toBe(true); @@ -41,7 +43,7 @@ describe('createTenancyService', () => { it('a throwing probe is treated as isolation off (fail-closed to single)', () => { const t = createTenancyService({ - requested: true, + requested: 'isolated', probeIsolation: () => { throw new Error('registry exploded'); }, @@ -52,15 +54,57 @@ describe('createTenancyService', () => { it('re-reads the probe each access (org-scoping may register after construction)', () => { let active = false; - const t = createTenancyService({ requested: true, probeIsolation: () => active }); - expect(t.mode).toBe('single'); + const t = createTenancyService({ requested: 'isolated', probeIsolation: () => active }); + expect(t.posture).toBe('single'); active = true; // org-scoping registers later - expect(t.mode).toBe('multi'); + expect(t.posture).toBe('isolated'); 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 }); + 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 () => { + const engine = makeEngine([{ id: 'org_default', slug: 'default' }]); + const t = createTenancyService({ + requested: 'group', + probeIsolation: () => false, + getEngine: () => engine, + }); + expect(await t.defaultOrgId()).toBeNull(); + }); + }); + + // The legacy boolean shape stays accepted so an embedding that passes + // `resolveMultiOrgEnabled()` keeps working: true ⇒ isolated, false ⇒ single. + describe('legacy boolean `requested`', () => { + it('true maps to the isolated posture', () => { + const t = createTenancyService({ requested: true, probeIsolation: () => true }); + expect(t.requestedPosture).toBe('isolated'); + expect(t.posture).toBe('isolated'); + }); + + it('false maps to the single posture', () => { + const t = createTenancyService({ requested: false, probeIsolation: () => true }); + expect(t.requestedPosture).toBe('single'); + expect(t.posture).toBe('single'); + expect(t.isolationActive).toBe(false); + }); + }); + describe('defaultOrgId', () => { - it('multi mode never guesses — returns null', async () => { + it('isolated posture never guesses — returns null', async () => { const engine = makeEngine([{ id: 'org_a' }, { id: 'org_b' }]); const t = createTenancyService({ requested: true, diff --git a/packages/plugins/plugin-auth/src/tenancy-service.ts b/packages/plugins/plugin-auth/src/tenancy-service.ts index cfda9a9a7f..0488d77951 100644 --- a/packages/plugins/plugin-auth/src/tenancy-service.ts +++ b/packages/plugins/plugin-auth/src/tenancy-service.ts @@ -1,8 +1,9 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * `tenancy` service — the single source of truth for "what tenancy mode is this - * deployment in?" (ADR-0093 D4). + * `tenancy` service — the single source of truth for "what tenancy posture is + * this deployment in?" (ADR-0093 D4, widened to the three-posture spectrum by + * ADR-0105 D1). * * Before this service, the same fact was re-derived from four independent * signals that could disagree: the `OS_MULTI_ORG_ENABLED` env flag, the @@ -15,53 +16,82 @@ * queryable, so consumers stop re-deriving and the degraded state stops being * silent. * + * ## The three postures (ADR-0105 D1) + * + * - `single` — no wall. One logical tenant; sub-units are business units in one + * business-unit tree. + * - `group` — wall = `organization_id IN accessible_org_ids`. Organizations are + * membership/invitation boundaries over one shared dataset, with union (MOAC) + * read access. **Enforced by the OPEN engine**: the Layer 0 union wall, the + * `accessible_org_ids` resolution, and the D5 write stamping/validation all + * ship open, because the correctness of a wall is never a paid feature + * (cloud ADR-0016 铁律「强制免费、治理收费」). Managing organizations at + * scale stays commercial. + * - `isolated` — wall = `organization_id = activeOrganizationId`. The hard + * legal-entity wall, formerly spelled `multi`. Its machinery still comes from + * the enterprise `@objectstack/organizations` package, so this posture keeps + * the historical `org-scoping` probe and can still resolve DEGRADED. + * * Registered by plugin-auth (the open-core home, alongside the default-org - * bootstrap). The baseline implementation derives `isolationActive` from the - * presence of the `org-scoping` service — the exact signal SecurityPlugin - * probes today — so the enterprise package needs no change to light it up: - * installing `@objectstack/organizations` registers `org-scoping`, and this - * service reports `mode: 'multi'` / `isolationActive: true` as a result. + * bootstrap). */ -export type TenancyMode = 'single' | 'multi'; +import { + postureEnforcesWall, + type TenancyPosture, +} from '@objectstack/spec/security'; + +export type { TenancyPosture }; export interface TenancyService { /** - * Resolved tenancy mode. `multi` iff org-scoping (auto-stamp + tenant RLS) is - * actually active; otherwise `single`. A *degraded* deployment (multi-org - * requested, isolation absent) reports `single` — it behaves single-org-like - * because nothing isolates its data. + * The posture actually IN FORCE. Equals {@link requestedPosture} whenever the + * request can be enforced; an `isolated` request that cannot be (the + * enterprise package is absent) resolves to `single` — the deployment behaves + * single-org-like because nothing isolates its data — and sets + * {@link degraded}. */ - readonly mode: TenancyMode; - /** True iff org-scoping (auto-stamp + tenant RLS) is actually wired. */ + readonly posture: TenancyPosture; + /** What the operator asked for (`OS_TENANCY_POSTURE` / `OS_MULTI_ORG_ENABLED`). */ + readonly requestedPosture: TenancyPosture; + /** True iff an organization wall is actually enforced (posture !== `single`). */ readonly isolationActive: boolean; - /** What the operator asked for (`OS_MULTI_ORG_ENABLED`). */ + /** True iff a wall-enforcing posture was requested. */ readonly requested: boolean; /** - * `requested && !isolationActive` — multi-org was asked for but cannot be + * `requested && !isolationActive` — a wall was asked for but cannot be * enforced. Boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1` (serve.ts, * ADR-0093 D5); when it boots anyway, this flag brands the deployment * everywhere an operator looks (`/auth/config`, Setup dashboard). + * + * Only reachable for `isolated` — `group` is enforced by the open engine, so + * it has no missing dependency to degrade against. */ readonly degraded: boolean; /** - * The default organization id to bind new users to in `single` mode - * (ADR-0093 D3). Returns `null` in `multi` mode — the framework never guesses - * a target org there; invite / add-member / SSO JIT own membership. Also - * `null` in single mode until an org exists (e.g. before the default-org + * The default organization id to bind new users to when no wall is enforced + * (ADR-0093 D3). Returns `null` under any walled posture — the framework + * never guesses a target org there; invite / add-member / SSO JIT own + * membership. Also `null` before an org exists (e.g. before the default-org * bootstrap runs). Positive resolutions are memoized (the id is stable). */ defaultOrgId(): Promise; } export interface TenancyServiceDeps { - /** `OS_MULTI_ORG_ENABLED` — what the operator asked for. */ - requested: boolean; /** - * Whether org-scoping is actually wired. Called lazily (never at - * construction — the org-scoping provider registers after plugin-auth) and + * The requested posture (`resolveTenancyPosture()`). Accepts the legacy + * boolean shape too: `true` ⇒ `isolated`, `false` ⇒ `single`. + */ + requested: TenancyPosture | boolean; + /** + * Whether the enterprise org-scoping machinery is wired. Called lazily (never + * at construction — the org-scoping provider registers after plugin-auth) and * cheap (a service-registry lookup); consumers that read it hot should cache * the result themselves, as SecurityPlugin does at `start()`. + * + * Consulted ONLY for the `isolated` posture. `group` is enforced by the open + * engine and never probes. */ probeIsolation: () => boolean; /** ObjectQL engine accessor, for {@link TenancyService.defaultOrgId}. */ @@ -100,10 +130,26 @@ export async function resolveDefaultOrgId(engine: any): Promise { return null; } +function normalizeRequested(requested: TenancyPosture | boolean): TenancyPosture { + if (typeof requested === 'boolean') return requested ? 'isolated' : 'single'; + return requested; +} + export function createTenancyService(deps: TenancyServiceDeps): TenancyService { let cachedDefaultOrgId: string | null = null; + const requestedPosture = normalizeRequested(deps.requested); + /** + * Can the REQUESTED posture actually be enforced? + * + * - `single` — nothing to enforce. + * - `group` — always: the union wall, `accessible_org_ids`, and write + * stamping/validation are all open-engine code (ADR-0105 D12). + * - `isolated` — only with the enterprise org-scoping machinery. + */ const isolationActive = (): boolean => { + if (requestedPosture === 'single') return false; + if (requestedPosture === 'group') return true; try { return !!deps.probeIsolation(); } catch { @@ -112,20 +158,25 @@ export function createTenancyService(deps: TenancyServiceDeps): TenancyService { }; return { + get requestedPosture(): TenancyPosture { + return requestedPosture; + }, get requested(): boolean { - return deps.requested; + return postureEnforcesWall(requestedPosture); }, get isolationActive(): boolean { return isolationActive(); }, - get mode(): TenancyMode { - return isolationActive() ? 'multi' : 'single'; + get posture(): TenancyPosture { + // A wall that cannot be enforced is not a wall: report the posture the + // deployment actually BEHAVES as, and let `degraded` carry the discrepancy. + return isolationActive() ? requestedPosture : 'single'; }, get degraded(): boolean { - return deps.requested && !isolationActive(); + return postureEnforcesWall(requestedPosture) && !isolationActive(); }, async defaultOrgId(): Promise { - // Multi-org: the framework never guesses a target org. + // Any walled posture: the framework never guesses a target org. if (isolationActive()) return null; if (cachedDefaultOrgId) return cachedDefaultOrgId; const resolved = await resolveDefaultOrgId(deps.getEngine?.()); diff --git a/packages/plugins/plugin-dev/vitest.config.ts b/packages/plugins/plugin-dev/vitest.config.ts index cf26fa6f78..9225bcf19b 100644 --- a/packages/plugins/plugin-dev/vitest.config.ts +++ b/packages/plugins/plugin-dev/vitest.config.ts @@ -22,6 +22,8 @@ export default defineConfig({ '@objectstack/spec/data': path.resolve(__dirname, '../../spec/src/data/index.ts'), '@objectstack/spec/kernel': path.resolve(__dirname, '../../spec/src/kernel/index.ts'), '@objectstack/spec/system': path.resolve(__dirname, '../../spec/src/system/index.ts'), + // [ADR-0105 D1] Reached transitively via `@objectstack/types` (tenancy posture). + '@objectstack/spec/security': path.resolve(__dirname, '../../spec/src/security/index.ts'), '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), }, }, diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 07f593ea82..ffa1ba17ac 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -8,7 +8,7 @@ import { import { RestServerConfig, } from '@objectstack/spec/api'; -import { ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN } from '@objectstack/spec'; +import { ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN_GRANTS } from '@objectstack/spec'; import { resolveEffectiveApiMethods, effectiveOperationsArray, @@ -919,6 +919,31 @@ export class HonoServerPlugin implements Plugin { /* fall back to self-only */ } } + // [ADR-0105 D2] The caller's org access set — the `group` + // posture's Layer 0 wall is `organization_id IN (...)`, so a + // context without it fails every read closed on this surface. + // Resolved from the user's OWN memberships (all organizations, + // not the active one). This standalone resolver duplicates the + // canonical `resolveAuthzContext` by design (see the posture + // note below); the duplication is tracked by + // `scripts/check-single-authz-resolver.mjs`. + let accessibleOrgIds: string[] = []; + try { + const ql = getObjectQL(); + const sysCtx = { context: { isSystem: true } }; + const myMemberships = await ql?.find?.( + 'sys_member', + { where: { user_id: userId }, limit: 200, ...sysCtx } as any, + ).catch(() => []); + const orgIds = new Set(); + for (const m of (myMemberships ?? []) as any[]) { + const oid = m.organization_id ?? m.organizationId; + if (typeof oid === 'string' && oid.length > 0) orgIds.add(oid); + } + accessibleOrgIds = Array.from(orgIds); + } catch { + /* no memberships resolvable → empty set → fails closed */ + } // Env-side AI-seat marker (simple model). The single-org env // DB has no permission-set/org dimension for this — the seat is // the boolean `sys_user.ai_access`. Read it with a GUARDED system @@ -957,7 +982,7 @@ export class HonoServerPlugin implements Plugin { // set it. A no-op when perf-tuning is off (no ambient gate). const disclosurePosture = derivePosture({ isPlatformAdmin: permissions.includes(ADMIN_FULL_ACCESS), - isTenantAdmin: permissions.includes(ORGANIZATION_ADMIN), + isTenantAdmin: ORGANIZATION_ADMIN_GRANTS.some((n) => permissions.includes(n)), }); if (isPerfDisclosurePrincipal({ isSystem: false, posture: disclosurePosture } as ExecutionContext)) { allowPerfDisclosure(); @@ -969,6 +994,7 @@ export class HonoServerPlugin implements Plugin { permissions, isSystem: false, org_user_ids: orgUserIds, + accessible_org_ids: accessibleOrgIds, } as any; } catch { return undefined; diff --git a/packages/plugins/plugin-hono-server/vitest.config.ts b/packages/plugins/plugin-hono-server/vitest.config.ts index 154db32466..b60801e244 100644 --- a/packages/plugins/plugin-hono-server/vitest.config.ts +++ b/packages/plugins/plugin-hono-server/vitest.config.ts @@ -16,6 +16,11 @@ export default defineConfig({ '@objectstack/spec/contracts': path.resolve(__dirname, '../../spec/src/contracts/index.ts'), '@objectstack/spec/data': path.resolve(__dirname, '../../spec/src/data/index.ts'), '@objectstack/spec/kernel': path.resolve(__dirname, '../../spec/src/kernel/index.ts'), + // Reached transitively: `@objectstack/types` resolves the tenancy posture + // (ADR-0105 D1) from this subpath. Without the entry the bare + // `@objectstack/spec` alias below wins by prefix and yields the + // nonsensical `spec/src/index.ts/security`. + '@objectstack/spec/security': path.resolve(__dirname, '../../spec/src/security/index.ts'), '@objectstack/spec/system': path.resolve(__dirname, '../../spec/src/system/index.ts'), '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), }, diff --git a/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts b/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts index 1f3b0050b4..6e299767ea 100644 --- a/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts +++ b/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts @@ -360,10 +360,17 @@ describe('authz Layer-0 matrix gate — ADR-0095 D1 (post-extraction)', () => { it('member inserting the SAME-tenant organization_id is allowed (row keeps it)', async () => { expect(await insertOrg(OBJECTS.task, ROLES.member, 'org-1')).toBe('org-1'); }); - it('member inserting with NO organization_id passes the wall (auto-stamp territory)', async () => { - // plugin-security does not stamp; an absent value is the organizations - // plugin's job. The Layer 0 check must NOT deny it (ordering-independent). - expect(await insertOrg(OBJECTS.task, ROLES.member, undefined)).toBe('<>'); + it('member inserting with NO organization_id is STAMPED with the active org', async () => { + // [ADR-0105 D5] The engine now owns the stamp. It used to belong solely to + // the enterprise organizations plugin, which left the open `group` posture + // writing NULL-org rows that its own wall then hid. Idempotent w.r.t. that + // plugin: neither overwrites a supplied value, so ordering stays irrelevant. + expect(await insertOrg(OBJECTS.task, ROLES.member, undefined)).toBe('org-1'); + }); + it('member with NO active org inserting without organization_id is left ABSENT (never invented)', async () => { + // [ADR-0105 D5] Stamping a tenant the caller does not have would smuggle a + // row past the very wall the caller already fails closed against. + expect(await insertOrg(OBJECTS.task, ROLES.no_org_member, undefined)).toBe('<>'); }); it('member with NO active org supplying ANY organization_id is fail-closed DENIED', async () => { expect(await insertOrg(OBJECTS.task, ROLES.no_org_member, 'org-1')).toBe('CRUD_DENY:PermissionDeniedError'); diff --git a/packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts b/packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts index ca07e3ae2a..aa8cd8ed95 100644 --- a/packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts +++ b/packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts @@ -23,6 +23,11 @@ function makeStub(seed: { const matches = (row: any, where: any) => { for (const [k, v] of Object.entries(where ?? {})) { + // `$in` — the ADR-0105 D4 backfill sweeps BOTH org-admin variants in one read. + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) { + if (!(v as any).$in.includes(row[k])) return false; + continue; + } if (row[k] !== v) return false; } return true; @@ -48,13 +53,20 @@ function makeStub(seed: { } const ORG_ADMIN_SET = { id: 'ps_org_admin', name: 'organization_admin' }; +// [ADR-0105 D4] The wall-less variant a `single`-posture deployment grants instead. +const ORG_ADMIN_NO_BYPASS_SET = { id: 'ps_org_admin_nb', name: 'organization_admin_no_bypass' }; + +// Every pre-ADR-0105 case in this file exercised the WALLED behavior (the only +// behavior that existed), so they pin `isolated` explicitly. The wall-less +// posture — which grants the de-VAMA'd variant — gets its own describe below. +const WALLED = { posture: 'isolated' } as const; describe('reconcileOrgAdminGrant', () => { let stub: ReturnType; beforeEach(() => { stub = makeStub({ - sys_permission_set: [ORG_ADMIN_SET], + sys_permission_set: [ORG_ADMIN_SET, ORG_ADMIN_NO_BYPASS_SET], sys_member: [], sys_user_permission_set: [], }); @@ -62,7 +74,7 @@ describe('reconcileOrgAdminGrant', () => { it('grants when membership role is "owner"', async () => { stub.tables.sys_member = [{ id: 'm1', user_id: 'u1', organization_id: 'o1', role: 'owner' }]; - const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1'); + const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1', WALLED); expect(res.action).toBe('granted'); expect(stub.tables.sys_user_permission_set).toHaveLength(1); const row = stub.tables.sys_user_permission_set[0]; @@ -72,7 +84,7 @@ describe('reconcileOrgAdminGrant', () => { it('grants when membership role is "admin"', async () => { stub.tables.sys_member = [{ id: 'm1', user_id: 'u1', organization_id: 'o1', role: 'admin' }]; - const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1'); + const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1', WALLED); expect(res.action).toBe('granted'); }); @@ -80,13 +92,13 @@ describe('reconcileOrgAdminGrant', () => { stub.tables.sys_member = [ { id: 'm1', user_id: 'u1', organization_id: 'o1', role: 'owner,admin' }, ]; - const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1'); + const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1', WALLED); expect(res.action).toBe('granted'); }); it('does NOT grant when role is just "member"', async () => { stub.tables.sys_member = [{ id: 'm1', user_id: 'u1', organization_id: 'o1', role: 'member' }]; - const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1'); + const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1', WALLED); expect(res.action).toBe('noop'); expect(stub.tables.sys_user_permission_set).toHaveLength(0); }); @@ -101,7 +113,7 @@ describe('reconcileOrgAdminGrant', () => { permission_set_id: 'ps_org_admin', }, ]; - const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1'); + const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1', WALLED); expect(res.action).toBe('revoked'); expect(stub.tables.sys_user_permission_set).toHaveLength(0); }); @@ -115,21 +127,21 @@ describe('reconcileOrgAdminGrant', () => { permission_set_id: 'ps_org_admin', }, ]; - const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1'); + const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1', WALLED); expect(res.action).toBe('revoked'); }); it('is idempotent — re-running keeps exactly one grant row', async () => { stub.tables.sys_member = [{ id: 'm1', user_id: 'u1', organization_id: 'o1', role: 'owner' }]; - await reconcileOrgAdminGrant(stub, 'u1', 'o1'); - const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1'); + await reconcileOrgAdminGrant(stub, 'u1', 'o1', WALLED); + const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1', WALLED); expect(res.action).toBe('noop'); expect(stub.tables.sys_user_permission_set).toHaveLength(1); }); it('only grants org-scoped (organization_id is set, not null)', async () => { stub.tables.sys_member = [{ id: 'm1', user_id: 'u1', organization_id: 'o1', role: 'owner' }]; - await reconcileOrgAdminGrant(stub, 'u1', 'o1'); + await reconcileOrgAdminGrant(stub, 'u1', 'o1', WALLED); const grant = stub.tables.sys_user_permission_set[0]; expect(grant.organization_id).toBe('o1'); expect(grant.organization_id).not.toBeNull(); @@ -138,7 +150,7 @@ describe('reconcileOrgAdminGrant', () => { it('skips cleanly when the permission set is not seeded', async () => { stub.tables.sys_permission_set = []; stub.tables.sys_member = [{ id: 'm1', user_id: 'u1', organization_id: 'o1', role: 'owner' }]; - const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1'); + const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1', WALLED); expect(res.action).toBe('skipped'); expect(res.reason).toBe('permission_set_missing'); }); @@ -147,7 +159,7 @@ describe('reconcileOrgAdminGrant', () => { describe('backfillOrgAdminGrants', () => { it('grants for every owner/admin membership and revokes orphans', async () => { const stub = makeStub({ - sys_permission_set: [ORG_ADMIN_SET], + sys_permission_set: [ORG_ADMIN_SET, ORG_ADMIN_NO_BYPASS_SET], sys_member: [ { id: 'm1', user_id: 'u1', organization_id: 'o1', role: 'owner' }, { id: 'm2', user_id: 'u2', organization_id: 'o1', role: 'admin' }, @@ -164,7 +176,7 @@ describe('backfillOrgAdminGrants', () => { ], }); - const summary = await backfillOrgAdminGrants(stub); + const summary = await backfillOrgAdminGrants(stub, WALLED); expect(summary.scanned).toBe(3); expect(summary.granted).toBe(2); expect(summary.revoked).toBe(1); @@ -175,3 +187,86 @@ describe('backfillOrgAdminGrants', () => { expect(grantedUsers).toEqual(['u1', 'u2']); }); }); + +// --------------------------------------------------------------------------- +// [ADR-0105 D4] Posture-selected org-admin variant. +// +// `organization_admin` carries wildcard viewAllRecords/modifyAllRecords, which +// is safe ONLY because Layer 0 bounds it to the caller's organization scope. +// A wall-less posture has no such bound — finding F2: with personal orgs on +// signup, every owner/admin would become an environment-wide superuser — so the +// auto-grant hands out the de-VAMA'd variant there instead. +// --------------------------------------------------------------------------- +describe('[ADR-0105 D4] posture selects the org-admin variant', () => { + const seedBoth = () => + makeStub({ + sys_permission_set: [ORG_ADMIN_SET, ORG_ADMIN_NO_BYPASS_SET], + sys_member: [{ id: 'm1', user_id: 'u1', organization_id: 'o1', role: 'owner' }], + sys_user_permission_set: [], + }); + + it('grants the de-VAMA\'d variant under the wall-less `single` posture', async () => { + const stub = seedBoth(); + const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1', { posture: 'single' }); + expect(res.action).toBe('granted'); + expect(stub.tables.sys_user_permission_set).toHaveLength(1); + expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin_nb'); + }); + + it('grants the full set under `isolated` (the wall bounds the superuser bits)', async () => { + const stub = seedBoth(); + const res = await reconcileOrgAdminGrant(stub, 'u1', 'o1', { posture: 'isolated' }); + expect(res.action).toBe('granted'); + expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin'); + }); + + it('grants the full set under `group` (the union wall bounds them too)', async () => { + const stub = seedBoth(); + await reconcileOrgAdminGrant(stub, 'u1', 'o1', { posture: 'group' }); + expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin'); + }); + + it('defaults to the de-VAMA\'d variant when no posture is supplied (fail safe)', async () => { + const stub = seedBoth(); + await reconcileOrgAdminGrant(stub, 'u1', 'o1'); + expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin_nb'); + }); + + // The F2 close-out: a deployment that drops its wall must not leave the + // unbounded grant in force. Reconciling converges on exactly one row. + it('revokes the superseded variant when the posture changes', async () => { + const stub = seedBoth(); + await reconcileOrgAdminGrant(stub, 'u1', 'o1', { posture: 'isolated' }); + expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin'); + + await reconcileOrgAdminGrant(stub, 'u1', 'o1', { posture: 'single' }); + expect(stub.tables.sys_user_permission_set).toHaveLength(1); + expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin_nb'); + + // ...and back again. + await reconcileOrgAdminGrant(stub, 'u1', 'o1', { posture: 'isolated' }); + expect(stub.tables.sys_user_permission_set).toHaveLength(1); + expect(stub.tables.sys_user_permission_set[0].permission_set_id).toBe('ps_org_admin'); + }); + + it('backfill converges every pair onto the posture\'s variant', async () => { + const stub = makeStub({ + sys_permission_set: [ORG_ADMIN_SET, ORG_ADMIN_NO_BYPASS_SET], + sys_member: [ + { id: 'm1', user_id: 'u1', organization_id: 'o1', role: 'owner' }, + { id: 'm2', user_id: 'u2', organization_id: 'o1', role: 'admin' }, + ], + // Pre-existing unbounded grants from a previously-walled boot. + sys_user_permission_set: [ + { id: 'ups1', user_id: 'u1', organization_id: 'o1', permission_set_id: 'ps_org_admin' }, + { id: 'ups2', user_id: 'u2', organization_id: 'o1', permission_set_id: 'ps_org_admin' }, + ], + }); + + await backfillOrgAdminGrants(stub, { posture: 'single' }); + + const grants = stub.tables.sys_user_permission_set; + expect(grants).toHaveLength(2); + expect(grants.every((g) => g.permission_set_id === 'ps_org_admin_nb')).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-security/src/auto-org-admin-grant.ts b/packages/plugins/plugin-security/src/auto-org-admin-grant.ts index c6862b1ffb..3ecd73bced 100644 --- a/packages/plugins/plugin-security/src/auto-org-admin-grant.ts +++ b/packages/plugins/plugin-security/src/auto-org-admin-grant.ts @@ -36,12 +36,31 @@ * `admin_full_access`. */ -import { ORGANIZATION_ADMIN } from '@objectstack/spec'; +import { ORGANIZATION_ADMIN, ORGANIZATION_ADMIN_NO_BYPASS } from '@objectstack/spec'; +import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; const SYSTEM_CTX = { isSystem: true } as const; -// [ADR-0095 D3] Single source of truth for the org-admin capability grant name — -// the same constant the TENANT_ADMIN posture rung derives from. -const PERMISSION_SET_NAME = ORGANIZATION_ADMIN; + +/** + * [ADR-0105 D4] Which org-admin capability set this posture may auto-grant. + * + * `organization_admin` carries wildcard `viewAllRecords`/`modifyAllRecords`. + * That is safe ONLY because Layer 0 bounds it to the caller's organization + * scope. Under a wall-less posture nothing bounds it, and a deployment that + * accumulates organizations turns every owner/admin into an environment-wide + * superuser (finding F2) — so the auto-grant hands out the de-VAMA'd variant + * there instead. Deliberate blanket visibility remains available through + * `admin_full_access` or an explicitly authored set; it just stops being a side + * effect of a better-auth membership role. + */ +export function orgAdminSetNameForPosture(posture: TenancyPosture): string { + return postureEnforcesWall(posture) ? ORGANIZATION_ADMIN : ORGANIZATION_ADMIN_NO_BYPASS; +} + +/** The variant NOT granted under `posture` — reconciled away so a posture change converges. */ +function supersededOrgAdminSetName(posture: TenancyPosture): string { + return postureEnforcesWall(posture) ? ORGANIZATION_ADMIN_NO_BYPASS : ORGANIZATION_ADMIN; +} interface MaybeLogger { info?: (message: string, meta?: Record) => void; @@ -104,15 +123,20 @@ function isAdminRole(raw: unknown): boolean { * across calls per ObjectQL instance via a WeakMap so repeated * reconciliations do not re-query. */ -const permissionSetIdCache = new WeakMap(); +const permissionSetIdCache = new WeakMap>(); -async function resolvePermissionSetId(ql: any): Promise { - const cached = permissionSetIdCache.get(ql); +async function resolvePermissionSetId(ql: any, name: string): Promise { + let perQl = permissionSetIdCache.get(ql); + if (!perQl) { + perQl = new Map(); + permissionSetIdCache.set(ql, perQl); + } + const cached = perQl.get(name); if (cached) return cached; - const rows = await tryFind(ql, 'sys_permission_set', { name: PERMISSION_SET_NAME }, 1); + const rows = await tryFind(ql, 'sys_permission_set', { name }, 1); const id = rows[0]?.id; if (typeof id === 'string' && id.length > 0) { - permissionSetIdCache.set(ql, id); + perQl.set(name, id); return id; } return null; @@ -134,7 +158,7 @@ export async function reconcileOrgAdminGrant( ql: any, userId: string, orgId: string, - options: { logger?: MaybeLogger } = {}, + options: { logger?: MaybeLogger; posture?: TenancyPosture } = {}, ): Promise<{ action: 'granted' | 'revoked' | 'noop' | 'skipped'; reason?: string; @@ -147,10 +171,17 @@ export async function reconcileOrgAdminGrant( return { action: 'skipped', reason: 'missing_keys' }; } - const permSetId = await resolvePermissionSetId(ql); + // [ADR-0105 D4] The posture decides WHICH org-admin set is granted. Default + // `single` (the wall-less, conservative choice) when a caller does not supply + // one: an unknown posture must not hand out unbounded superuser bits. + const posture: TenancyPosture = options.posture ?? 'single'; + const grantSetName = orgAdminSetNameForPosture(posture); + const supersededSetName = supersededOrgAdminSetName(posture); + + const permSetId = await resolvePermissionSetId(ql, grantSetName); if (!permSetId) { - // organization_admin permission set isn't seeded yet (boot ordering) - // — caller can retry later (e.g. via kernel:ready backfill). + // The permission set isn't seeded yet (boot ordering) — caller can retry + // later (e.g. via kernel:ready backfill). return { action: 'skipped', reason: 'permission_set_missing' }; } @@ -166,6 +197,29 @@ export async function reconcileOrgAdminGrant( ); const shouldGrant = memberships.some((m: any) => isAdminRole(m?.role)); + // 1b. [ADR-0105 D4] Revoke the OTHER variant for this pair, always. A posture + // change (or a downgrade after F2) must converge on exactly one org-admin + // grant; leaving the superseded row would keep the old bits in force. + const supersededSetId = await resolvePermissionSetId(ql, supersededSetName); + if (supersededSetId) { + const stale = await tryFind( + ql, + 'sys_user_permission_set', + { user_id: userId, organization_id: orgId, permission_set_id: supersededSetId }, + 5, + ); + for (const row of stale) { + if (row?.id && (await tryDelete(ql, 'sys_user_permission_set', String(row.id)))) { + logger?.info?.('[security] revoked superseded org-admin grant', { + userId, + orgId, + set: supersededSetName, + posture, + }); + } + } + } + // 2. Look at existing grants for this exact pair. const existingGrants = await tryFind( ql, @@ -190,7 +244,12 @@ export async function reconcileOrgAdminGrant( granted_by: null, }); if (created) { - logger?.info?.('[security] granted organization_admin', { userId, orgId }); + logger?.info?.('[security] granted org-admin capability', { + userId, + orgId, + set: grantSetName, + posture, + }); return { action: 'granted' }; } return { action: 'skipped', reason: 'insert_failed' }; @@ -207,7 +266,12 @@ export async function reconcileOrgAdminGrant( } } if (removed > 0) { - logger?.info?.('[security] revoked organization_admin', { userId, orgId, removed }); + logger?.info?.('[security] revoked org-admin capability', { + userId, + orgId, + set: grantSetName, + removed, + }); return { action: 'revoked' }; } return { action: 'skipped', reason: 'delete_failed' }; @@ -221,18 +285,23 @@ export async function reconcileOrgAdminGrant( */ export async function backfillOrgAdminGrants( ql: any, - options: { logger?: MaybeLogger; limit?: number } = {}, + options: { logger?: MaybeLogger; limit?: number; posture?: TenancyPosture } = {}, ): Promise<{ scanned: number; granted: number; revoked: number; skipped: number }> { const logger = options.logger; const limit = options.limit ?? 5000; + const posture: TenancyPosture = options.posture ?? 'single'; const summary = { scanned: 0, granted: 0, revoked: 0, skipped: 0 }; if (!ql || typeof ql.find !== 'function') return summary; - const permSetId = await resolvePermissionSetId(ql); + const permSetId = await resolvePermissionSetId(ql, orgAdminSetNameForPosture(posture)); if (!permSetId) { - logger?.debug?.('[security] organization_admin backfill skipped — permission set missing'); + logger?.debug?.('[security] org-admin backfill skipped — permission set missing'); return summary; } + // [ADR-0105 D4] The orphan sweep below must see BOTH variants: a boot that + // changed posture leaves grants of the superseded set behind, and those are + // exactly the rows whose bits must stop applying. + const supersededId = await resolvePermissionSetId(ql, supersededOrgAdminSetName(posture)); const members = await tryFind(ql, 'sys_member', {}, limit); // De-duplicate by (user_id, organization_id) pair — a user with two @@ -246,7 +315,7 @@ export async function backfillOrgAdminGrants( if (seen.has(key)) continue; seen.add(key); summary.scanned += 1; - const res = await reconcileOrgAdminGrant(ql, userId, orgId, { logger }); + const res = await reconcileOrgAdminGrant(ql, userId, orgId, { logger, posture }); if (res.action === 'granted') summary.granted += 1; else if (res.action === 'revoked') summary.revoked += 1; else if (res.action === 'skipped') summary.skipped += 1; @@ -255,10 +324,11 @@ export async function backfillOrgAdminGrants( // Also revoke any organization_admin grant pointing at a (user, org) // pair with NO membership row left (orphaned grants from deletes // that fired before this hook existed). + const grantSetIds = [permSetId, supersededId].filter(Boolean) as string[]; const allGrants = await tryFind( ql, 'sys_user_permission_set', - { permission_set_id: permSetId }, + { permission_set_id: { $in: grantSetIds } }, limit, ); for (const g of allGrants) { @@ -267,11 +337,11 @@ export async function backfillOrgAdminGrants( if (!userId || !orgId) continue; const key = `${userId}|${orgId}`; if (seen.has(key)) continue; - const res = await reconcileOrgAdminGrant(ql, userId, orgId, { logger }); + const res = await reconcileOrgAdminGrant(ql, userId, orgId, { logger, posture }); if (res.action === 'revoked') summary.revoked += 1; } - logger?.info?.('[security] organization_admin backfill complete', summary); + logger?.info?.('[security] org-admin grant backfill complete', { ...summary, posture }); return summary; } diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index 1444812a1f..ead2de3b5d 100644 --- a/packages/plugins/plugin-security/src/explain-engine.test.ts +++ b/packages/plugins/plugin-security/src/explain-engine.test.ts @@ -402,6 +402,11 @@ describe('buildContextForUser', () => { userId: 'u2', positions: ['hr_specialist', 'everyone'], permissions: ['payroll_reader'], + // [ADR-0105 D2] The DELEGATOR's own org access set, resolved here rather + // than inherited — a delegated read is bounded by the delegator's own + // memberships. This fixture's `ql` serves no `sys_member` rows, so it is + // empty, which fails the `group` wall closed. + accessible_org_ids: [], expiredGrants: [], delegatedPositions: [], hasPlatformAdminGrant: false, diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index 0b99c36aff..9ec2511fa2 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -21,7 +21,7 @@ import { isGrantActive, isGrantExpired, derivePosture as deriveAdminPosture } from '@objectstack/core'; import { matchesFilterCondition } from '@objectstack/formula'; -import { BUILTIN_IDENTITY_PLATFORM_ADMIN, ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN } from '@objectstack/spec'; +import { BUILTIN_IDENTITY_PLATFORM_ADMIN, ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN_GRANTS } from '@objectstack/spec'; import type { PermissionSet } from '@objectstack/spec/security'; import type { AuthzPosture, @@ -98,7 +98,7 @@ function derivePosture(context: any): AuthzPosture { return deriveAdminPosture({ isPlatformAdmin: context?.hasPlatformAdminGrant === true || positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN), - isTenantAdmin: permissions.includes(ORGANIZATION_ADMIN), + isTenantAdmin: ORGANIZATION_ADMIN_GRANTS.some((n) => permissions.includes(n)), }); } @@ -274,7 +274,24 @@ export async function buildContextForUser(ql: any, userId: string, nowMs: number } catch { /* ignore */ } // [ADR-0090 D5] Authenticated principals implicitly hold the everyone anchor. if (!positions.includes('everyone')) positions.push('everyone'); - return { userId, positions, permissions, expiredGrants, delegatedPositions, hasPlatformAdminGrant }; + // [ADR-0105 D2] The user's OWN org access set. Resolved here rather than + // inherited from the live principal: under the `group` posture this is the + // Layer 0 read reach, and a delegated read must be bounded by the DELEGATOR's + // memberships. Absent → empty → the group wall denies (fail-closed), which is + // the safe direction for an unresolvable delegator. + const accessible_org_ids: string[] = []; + try { + const rows = await ql.find('sys_member', { where: { user_id: userId }, limit: 200, context: SYSTEM_CTX }); + for (const m of Array.isArray(rows) ? rows : []) { + if (!isGrantActive(m, nowMs)) continue; + const org = (m as any)?.organization_id ?? (m as any)?.organizationId; + if (typeof org === 'string' && org && !accessible_org_ids.includes(org)) { + accessible_org_ids.push(org); + } + } + } catch { /* table unavailable → empty set → fails closed under `group` */ } + + return { userId, positions, permissions, accessible_org_ids, expiredGrants, delegatedPositions, hasPlatformAdminGrant }; } /** @@ -308,6 +325,10 @@ export type DelegatorResolution = * its delegator are, by construction, in the same org, so `tenantId` / * `org_user_ids` carry over — delegator-side RLS that substitutes them then * compiles faithfully instead of collapsing to the deny sentinel. + * `accessible_org_ids` (ADR-0105 D2) is the exception: it is resolved from + * the DELEGATOR's own memberships by `buildContextForUser`, never inherited, + * because inheriting it would widen a delegated read past the organizations + * the delegator actually belongs to. * - **Person-specific membership bags (`rlsMembership`) are left unresolved** * for the first cut. Absent → the RLS compiler's fail-closed substitution * NARROWS the delegator's row set, never widens it — safe by construction. @@ -342,6 +363,10 @@ export async function resolveDelegatorContext( // Inherit tenant-scoped substitution bags from the live principal (same org). if (context?.tenantId != null) dctx.tenantId = context.tenantId; if (context?.org_user_ids != null) dctx.org_user_ids = context.org_user_ids; + // [ADR-0105 D2] The delegator's own org access set is NOT inherited — it is + // the delegator's membership that bounds a delegated read, and + // `buildContextForUser` resolves it for them. Inheriting the live principal's + // set would widen the delegator past their own memberships. if (user.email != null && user.email !== '') dctx.email = user.email; return { kind: 'resolved', context: dctx }; } diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index f04c86adba..8eefd79090 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; +import { ORGANIZATION_ADMIN, ORGANIZATION_ADMIN_NO_BYPASS } from '@objectstack/spec'; import { MCP_AGENT_PERMISSION_SET_READ, MCP_AGENT_PERMISSION_SET_WRITE, @@ -96,7 +97,7 @@ const denyWritesOnManagedObjects = (): Record = { ...(base.objects ?? {}) }; + const { viewAllRecords: _v, modifyAllRecords: _m, ...wildcardWithoutBypass } = + (objects['*'] ?? {}) as Record; + objects['*'] = wildcardWithoutBypass; + return PermissionSetSchema.parse({ + ...base, + name: ORGANIZATION_ADMIN_NO_BYPASS, + label: 'Organization Administrator (no record bypass)', + description: + 'Organization administration WITHOUT blanket record visibility. Granted instead of ' + + '`organization_admin` when the tenancy posture enforces no organization wall, where nothing ' + + 'would bound the superuser bits (ADR-0105 D4 / finding F2). Ownership, sharing and business ' + + 'RLS still apply; grant `admin_full_access` (or an explicit set carrying the bits) when a ' + + 'deployment-wide data superuser is genuinely intended.', + objects, + }); +} + +export const defaultPermissionSets: PermissionSet[] = (() => { + const orgAdmin = baseDefaultPermissionSets.find((ps) => ps.name === ORGANIZATION_ADMIN); + if (!orgAdmin) return baseDefaultPermissionSets; + // Placed directly after its parent so the seeded row order stays stable and + // the two variants read as a pair. + const idx = baseDefaultPermissionSets.indexOf(orgAdmin); + return [ + ...baseDefaultPermissionSets.slice(0, idx + 1), + deriveWallLessOrgAdmin(orgAdmin), + ...baseDefaultPermissionSets.slice(idx + 1), + ]; +})(); diff --git a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts index 6292be2f96..c3f3a000e7 100644 --- a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts +++ b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts @@ -20,6 +20,11 @@ describe('default permission sets', () => { 'mcp_agent_restricted', 'member_default', 'organization_admin', + // [ADR-0105 D4] The wall-less variant of `organization_admin`, DERIVED + // from it by dropping the wildcard viewAllRecords/modifyAllRecords bits. + // Granted by `auto-org-admin-grant` when the posture enforces no + // organization wall, where nothing would bound them (finding F2). + 'organization_admin_no_bypass', 'viewer_readonly', ]); }); diff --git a/packages/plugins/plugin-security/src/platform-tenant-policies.test.ts b/packages/plugins/plugin-security/src/platform-tenant-policies.test.ts new file mode 100644 index 0000000000..d30a1267ac --- /dev/null +++ b/packages/plugins/plugin-security/src/platform-tenant-policies.test.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0105 D3] Provenance, not pattern-matching. + * + * Finding F1: `collectRLSPolicies` used to drop ANY policy whose `using` + * contained the substring `current_user.organization_id` when org isolation was + * inactive. The token is part of the public RLS grammar, so the match swallowed + * app-authored policies too — a declared security policy silently unenforced, + * exactly the ADR-0049 class of defect. + * + * These tests pin the replacement contract from both sides: the platform's OWN + * shipped policies are still recognized (so single-tenant deployments keep + * paying nothing for them), and anything else is not. + */ + +import { describe, it, expect } from 'vitest'; + +import { + isPlatformTenantPolicy, + isAuthoredTenantPolicy, + platformTenantPolicyCount, + TENANT_CONTEXT_TOKEN, +} from './platform-tenant-policies.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +/** Every tenant-scoped policy the platform actually ships, from the live declaration. */ +const shippedTenantPolicies = defaultPermissionSets + .flatMap((ps) => ps.rowLevelSecurity ?? []) + .filter((p) => typeof p.using === 'string' && p.using.includes(TENANT_CONTEXT_TOKEN)); + +describe('platform tenant-policy provenance (ADR-0105 D3)', () => { + it('recognizes every tenant-scoped policy the platform ships', () => { + // Guards against the provenance set going empty (which would silently make + // single-tenant deployments start compiling the platform's own policies). + expect(shippedTenantPolicies.length).toBeGreaterThan(0); + for (const policy of shippedTenantPolicies) { + expect(isPlatformTenantPolicy(policy), `${policy.object}.${policy.name}`).toBe(true); + expect(isAuthoredTenantPolicy(policy)).toBe(false); + } + // The provenance set is keyed by IDENTITY, so a policy shipped verbatim in + // several permission sets (the `_self` identity carve-outs appear in + // organization_admin / member_default / viewer_readonly) counts once. + expect(platformTenantPolicyCount()).toBeGreaterThan(0); + expect(platformTenantPolicyCount()).toBeLessThanOrEqual(shippedTenantPolicies.length); + }); + + it('does NOT recognize an app-authored policy that merely uses the same token', () => { + // The F1 repro: this is precisely what the old substring match swallowed. + const authored = { + name: 'invoice_org_scope', + object: 'invoice', + operation: 'all' as const, + using: 'organization_id == current_user.organization_id', + }; + expect(isPlatformTenantPolicy(authored)).toBe(false); + expect(isAuthoredTenantPolicy(authored)).toBe(true); + }); + + it('does NOT recognize a shipped policy NAME re-used on a different object', () => { + // Name alone is not provenance — an app may legitimately reuse a name. + const shipped = shippedTenantPolicies[0]!; + const impostor = { ...shipped, object: 'some_app_object' }; + expect(isPlatformTenantPolicy(impostor)).toBe(false); + expect(isAuthoredTenantPolicy(impostor)).toBe(true); + }); + + it('does NOT recognize a shipped policy whose predicate was edited', () => { + // An admin who broadened a shipped policy owns the result: it is theirs now, + // and it must survive collection so the change actually takes effect. + const shipped = shippedTenantPolicies[0]!; + const edited = { ...shipped, using: `${shipped.using} /* widened */` }; + expect(isPlatformTenantPolicy(edited)).toBe(false); + expect(isAuthoredTenantPolicy(edited)).toBe(true); + }); + + it('treats a policy with no tenant token as neither platform nor authored-tenant', () => { + const ownerPolicy = { + name: 'owner_only', + object: 'task', + operation: 'all' as const, + using: 'owner_id == current_user.id', + }; + expect(isPlatformTenantPolicy(ownerPolicy)).toBe(false); + expect(isAuthoredTenantPolicy(ownerPolicy)).toBe(false); + }); +}); diff --git a/packages/plugins/plugin-security/src/platform-tenant-policies.ts b/packages/plugins/plugin-security/src/platform-tenant-policies.ts new file mode 100644 index 0000000000..573e3e121d --- /dev/null +++ b/packages/plugins/plugin-security/src/platform-tenant-policies.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0105 D3] Provenance for the platform's OWN tenant-scoped RLS policies. + * + * `collectRLSPolicies` drops the platform's tenant policies when org isolation + * is inactive — there is no meaningful active organization to compare against, + * so the policy would match zero rows (or be dropped by the field-existence + * safety net) for no benefit. + * + * That strip used to be a SUBSTRING match on `current_user.organization_id` + * (ADR-0105 finding F1). The token is part of the public RLS grammar, so the + * match also swallowed **app-authored** policies — a declared security policy + * silently unenforced, exactly the ADR-0049 class of defect. An authored policy + * must always reach the compiler and fail closed there (an unavailable context + * variable makes the policy drop out, never fail open), where the operator can + * see it, instead of vanishing during collection. + * + * The fix is provenance, not pattern-matching: a policy is stripped only when it + * is IDENTICALLY one the platform ships in {@link defaultPermissionSets}. The + * identity key is `(object, name, using)` — an app policy can only collide by + * being byte-identical to a shipped one on the same object, in which case + * stripping it is the same decision the platform made for its own copy. + * + * This deliberately does NOT introduce an authorable "strip me" flag on the RLS + * schema: provenance is a fact about who shipped the policy, and letting + * metadata claim it would hand authors the very footgun F1 was. + */ + +import type { RowLevelSecurityPolicy } from '@objectstack/spec/security'; + +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +/** + * The context token that makes a policy tenant-scoped. Used ONLY to select + * which shipped policies enter the provenance set (and to describe an authored + * policy in the operator warning) — never as a matcher against authored input. + */ +export const TENANT_CONTEXT_TOKEN = 'current_user.organization_id'; + +/** `\u0000` cannot appear in an object/policy name or a `using` expression. */ +function provenanceKey(policy: Pick): string { + return `${policy.object ?? ''}\u0000${policy.name ?? ''}\u0000${policy.using ?? ''}`; +} + +/** + * Identity keys of every tenant-scoped policy the platform itself ships. Built + * once from the compiled declaration — the same constant `bootstrapPlatformAdmin` + * seeds `sys_permission_set` rows from, so a policy read back from the database + * matches its shipped original. + */ +const PLATFORM_TENANT_POLICY_KEYS: ReadonlySet = (() => { + const keys = new Set(); + for (const ps of defaultPermissionSets) { + for (const policy of ps.rowLevelSecurity ?? []) { + if (typeof policy.using === 'string' && policy.using.includes(TENANT_CONTEXT_TOKEN)) { + keys.add(provenanceKey(policy)); + } + } + } + return keys; +})(); + +/** + * True iff this policy is one the PLATFORM ships for tenant scoping — the only + * policies `collectRLSPolicies` may strip when isolation is inactive. + */ +export function isPlatformTenantPolicy( + policy: Pick, +): boolean { + return PLATFORM_TENANT_POLICY_KEYS.has(provenanceKey(policy)); +} + +/** + * True iff this policy is tenant-scoped but NOT platform-shipped — i.e. an + * app-authored policy that will now survive collection and fail closed at + * compile time. Callers surface this to the operator once per policy so the + * "matches zero rows" outcome is explained rather than mysterious. + */ +export function isAuthoredTenantPolicy( + policy: Pick, +): boolean { + return ( + typeof policy.using === 'string' && + policy.using.includes(TENANT_CONTEXT_TOKEN) && + !isPlatformTenantPolicy(policy) + ); +} + +/** Test/diagnostic accessor — the number of shipped tenant policies recognized. */ +export function platformTenantPolicyCount(): number { + return PLATFORM_TENANT_POLICY_KEYS.size; +} diff --git a/packages/plugins/plugin-security/src/rls-membership-resolver.test.ts b/packages/plugins/plugin-security/src/rls-membership-resolver.test.ts new file mode 100644 index 0000000000..d7381c87c5 --- /dev/null +++ b/packages/plugins/plugin-security/src/rls-membership-resolver.test.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0105 D11] The `rls-membership-resolver` seam has a PRODUCER. + * + * `ExecutionContext.rlsMembership` and the compiler's merge of it have existed + * since ADR-0056, but nothing in production ever populated the bag — a declared + * capability with no producer, which is the ADR-0049 defect. These tests pin the + * producer end-to-end: a registered resolver's sets reach the compiled filter, + * and every failure mode narrows access rather than widening it. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { PermissionSet } from '@objectstack/spec/security'; + +import { SecurityPlugin } from './security-plugin.js'; +import { RLS_DENY_FILTER } from './rls-compiler.js'; + +/** Policy shape the seam exists for: set membership with no subquery support. */ +const territorySet: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true } }, + rowLevelSecurity: [ + { + name: 'account_territory_scope', + object: 'task', + operation: 'select', + using: 'assigned_to_id IN (current_user.territory_user_ids)', + }, + ], +} as any; + +function makeCtx(resolver?: unknown) { + const schema: any = { + name: 'task', + fields: { id: { name: 'id' }, assigned_to_id: { name: 'assigned_to_id' } }, + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: { registerMiddleware: () => {}, getSchema: () => schema, findOne: async () => null }, + metadata: { get: async () => schema, list: async () => [territorySet] }, + }; + if (resolver) services['rls-membership-resolver'] = resolver; + return { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + } as any; +} + +async function readFilter(resolver: unknown, context: Record) { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const ctx = makeCtx(resolver); + await plugin.init(ctx); + await plugin.start(ctx); + return { filter: await plugin.getReadFilter('task', context as any), ctx }; +} + +const caller = { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }; + +describe('rls-membership-resolver (ADR-0105 D11)', () => { + it('stages a registered resolver\'s set into the compiled filter', async () => { + const resolver = { + keys: ['territory_user_ids'], + resolve: vi.fn(async () => ({ territory_user_ids: ['u2', 'u3'] })), + }; + const { filter } = await readFilter(resolver, { ...caller }); + expect(filter).toEqual({ assigned_to_id: { $in: ['u2', 'u3'] } }); + expect(resolver.resolve).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'u1', tenantId: 'org-1' }), + ); + }); + + it('passes the caller\'s org access set to the resolver (ADR-0105 D2)', async () => { + const resolver = { + keys: ['territory_user_ids'], + resolve: vi.fn(async () => ({ territory_user_ids: ['u2'] })), + }; + await readFilter(resolver, { ...caller, accessible_org_ids: ['org-1', 'org-2'] }); + expect(resolver.resolve).toHaveBeenCalledWith( + expect.objectContaining({ accessible_org_ids: ['org-1', 'org-2'] }), + ); + }); + + it('fails CLOSED when no resolver is registered (the pre-D11 state)', async () => { + const { filter } = await readFilter(undefined, { ...caller }); + // The key never resolves → the only policy drops out → deny sentinel. + expect(filter).toEqual(RLS_DENY_FILTER); + }); + + it('fails CLOSED when the resolver throws', async () => { + const resolver = { + keys: ['territory_user_ids'], + resolve: vi.fn(async () => { + throw new Error('territory service down'); + }), + }; + const { filter, ctx } = await readFilter(resolver, { ...caller }); + expect(filter).toEqual(RLS_DENY_FILTER); + expect(ctx.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('rls-membership-resolver threw'), + expect.anything(), + ); + }); + + it('fails CLOSED when the resolver returns an EMPTY set', async () => { + const resolver = { keys: ['territory_user_ids'], resolve: async () => ({ territory_user_ids: [] }) }; + const { filter } = await readFilter(resolver, { ...caller }); + expect(filter).toEqual(RLS_DENY_FILTER); + }); + + it('refuses to let a resolver overwrite a RESERVED kernel key', async () => { + // An app that could redefine `accessible_org_ids` could redefine the group + // posture's wall — so the reserved keys are not writable from this seam. + const resolver = { + keys: ['territory_user_ids'], + resolve: async () => ({ + territory_user_ids: ['u2'], + accessible_org_ids: ['org-victim'], + }), + }; + const context: Record = { ...caller, accessible_org_ids: ['org-1'] }; + const { filter, ctx } = await readFilter(resolver, context); + expect(filter).toEqual({ assigned_to_id: { $in: ['u2'] } }); + expect((context.rlsMembership as any)?.accessible_org_ids).toBeUndefined(); + expect(context.accessible_org_ids).toEqual(['org-1']); + expect(ctx.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('RESERVED context key'), + { key: 'accessible_org_ids' }, + ); + }); + + it('resolves once per request context, not once per object touched', async () => { + const resolver = { + keys: ['territory_user_ids'], + resolve: vi.fn(async () => ({ territory_user_ids: ['u2'] })), + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const ctx = makeCtx(resolver); + await plugin.init(ctx); + await plugin.start(ctx); + const context = { ...caller }; + await plugin.getReadFilter('task', context as any); + await plugin.getReadFilter('task', context as any); + expect(resolver.resolve).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 5657b9db61..da38084dc7 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -453,15 +453,40 @@ describe('SecurityPlugin', () => { await harness.run(opCtx); // must not throw }); - it("a write that doesn't supply organization_id is untouched (auto-stamp's job)", async () => { + it('[ADR-0105 D5] a write that omits organization_id is STAMPED with the active org', async () => { const harness = await boot(); const opCtx: any = { object: 'task', operation: 'insert', data: { name: 'A' }, context: memberCtx(), }; - await harness.run(opCtx); // must not throw — no cross-tenant check on an absent value - // SecurityPlugin never stamps organization_id (that's plugin-org-scoping's job). - expect(opCtx.data.organization_id).toBeUndefined(); + await harness.run(opCtx); // must not throw — the stamped value satisfies the wall + // The engine owns the stamp under any wall-enforcing posture; the enterprise + // organizations plugin remains free to run too (neither overwrites a value). + expect(opCtx.data.organization_id).toBe('org-1'); + }); + + it('[ADR-0105 D5] a BULK insert stamps every row that omits organization_id', async () => { + const harness = await boot(); + const opCtx: any = { + object: 'task', operation: 'insert', + data: [{ name: 'A' }, { name: 'B', organization_id: 'org-1' }], + context: memberCtx(), + }; + await harness.run(opCtx); + expect(opCtx.data.map((r: any) => r.organization_id)).toEqual(['org-1', 'org-1']); + }); + + it('[ADR-0105 D5] a BULK insert with ONE forged row is denied entirely', async () => { + // The pre-D5 check required a non-array payload, so an array of rows could + // carry a forged organization_id past the wall — the #2937 defect one call + // site down. No partial landing: one bad row denies the write. + const harness = await boot(); + const opCtx: any = { + object: 'task', operation: 'insert', + data: [{ name: 'A', organization_id: 'org-1' }, { name: 'B', organization_id: 'org-2' }], + context: memberCtx(), + }; + await expect(harness.run(opCtx)).rejects.toThrow(/another tenant|organization scope/i); }); it('a system-context write bypasses the wall (import / migration / SYSTEM_CTX)', async () => { @@ -474,7 +499,14 @@ describe('SecurityPlugin', () => { }); }); - it('without org-scoping plugin — strips tenant_isolation RLS so find applies no tenant where', async () => { + // [ADR-0105 D3] Stripping is decided by PROVENANCE, not by pattern-matching + // the `current_user.organization_id` token. `tenantPolicySet` above is an + // APP-AUTHORED fixture, so with isolation off it is now RETAINED and fails + // closed at compile time — it is no longer silently dropped (finding F1, the + // ADR-0049 "declared but unenforced" class). Only the platform's own shipped + // tenant policies are stripped; that half is covered in + // `platform-tenant-policies.test.ts`. + it('without org-scoping — an APP-AUTHORED tenant policy is RETAINED (never silently dropped)', async () => { const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet] }); await plugin.init(harness.ctx); @@ -484,7 +516,28 @@ describe('SecurityPlugin', () => { context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }, }; await harness.run(opCtx); - expect(opCtx.ast.where).toBeUndefined(); + // Layer 0 is inert (no wall), so this filter is the AUTHORED policy alone. + expect(opCtx.ast.where).toEqual({ organization_id: 'org-1' }); + // ...and the operator is told why, once. + expect(harness.ctx.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('ADR-0105'), + expect.objectContaining({ object: 'task', policy: 'tenant_isolation' }), + ); + }); + + it('without org-scoping — a retained authored tenant policy FAILS CLOSED with no active org', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet] }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const opCtx: any = { + object: 'task', operation: 'find', ast: { where: undefined }, + context: { userId: 'u1', positions: [], permissions: [] }, + }; + await harness.run(opCtx); + // The context variable is unavailable → the policy drops out at compile + // time → deny sentinel. Zero rows, loudly — never fail-open. + expect(opCtx.ast.where).toEqual(RLS_DENY_FILTER); }); it('with org-scoping plugin — applies tenant_isolation RLS to find', async () => { @@ -1125,13 +1178,16 @@ describe('SecurityPlugin', () => { expect(filter).toEqual({ $and: [{ organization_id: 'org-1' }, { organization_id: 'org-1' }] }); }); - it('returns undefined (no scope) when tenant_isolation is stripped (org-scoping off)', async () => { + it('[ADR-0105 D3] keeps an APP-AUTHORED tenant policy in scope even with org-scoping off', async () => { + // Parity with the find path: getReadFilter reads the same collection, so an + // authored policy must reach analytics/raw-SQL consumers too. Returning + // `undefined` here (the pre-D3 behavior) handed them an UNSCOPED read. const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet] }); await plugin.init(harness.ctx); await plugin.start(harness.ctx); const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }); - expect(filter).toBeUndefined(); + expect(filter).toEqual({ organization_id: 'org-1' }); }); it('fail-closed: wildcard org policy on an object missing the column → deny sentinel', async () => { diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 0058cbcd46..ee2072fed9 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -37,6 +37,18 @@ import { normalizeManagedByVocab } from './normalize-managed-by.js'; import { bootstrapDeclaredCapabilities } from './bootstrap-declared-capabilities.js'; import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; import { computeTenantLayer0Filter, andComposeLayers } from './tenant-layer.js'; +import { isPlatformTenantPolicy, isAuthoredTenantPolicy } from './platform-tenant-policies.js'; +import { + normalizeTenancyPosture, + postureEnforcesWall, + postureStampsOrganization, + type TenancyPosture, +} from '@objectstack/spec/security'; +import { + RLS_MEMBERSHIP_RESOLVER_SERVICE, + RESERVED_RLS_MEMBERSHIP_KEYS, + type IRlsMembershipResolver, +} from '@objectstack/spec/contracts'; import { matchesFilterCondition } from '@objectstack/formula'; import { FieldMasker } from './field-masker.js'; import { assertReadableQueryFields } from './predicate-guard.js'; @@ -246,11 +258,13 @@ export interface SecurityPluginOptions { * `@objectstack/organizations` package** (auto-stamps * `organization_id` on insert, per-org seed replay, default-org * bootstrap). When that plugin is installed, SecurityPlugin detects - * it via `getService('org-scoping')` and keeps the wildcard - * `current_user.organization_id` RLS policies that ship with the - * default permission sets. Without it, those policies are stripped so - * single-tenant deployments don't pay the field-existence safety-net - * cost on every find. + * it via `getService('org-scoping')` and keeps the tenant-scoped RLS + * policies that ship with the default permission sets. Without it, + * *those shipped policies* are stripped so single-tenant deployments + * don't pay the field-existence safety-net cost on every find — + * app-authored policies are never stripped (ADR-0105 D3; they are + * retained and fail closed at compile time, see + * `platform-tenant-policies.ts`). * * Dependencies: * - objectql service (ObjectQL engine with middleware support) @@ -276,13 +290,35 @@ export class SecurityPlugin implements Plugin { private readonly fallbackPermissionSet: string | null; /** * Runtime probe — set in `start()` from - * `ctx.getService('org-scoping')`. When `false`, wildcard RLS - * policies that reference `current_user.organization_id` are - * stripped from the per-request policy set (saves the - * field-existence safety net cost on every find in single-tenant - * deployments). When `true`, the policies apply normally. + * `ctx.getService('org-scoping')`. When `false`, the PLATFORM's own + * tenant-scoped RLS policies are stripped from the per-request + * policy set (saves the field-existence safety net cost on every + * find in single-tenant deployments); app-authored policies are + * retained and fail closed (ADR-0105 D3). When `true`, every policy + * applies normally. + */ + private tenancyPosture: TenancyPosture = 'single'; + /** + * [ADR-0105 D11] The registered membership resolver, probed at `start()`. + * `null` when none is installed — the overwhelmingly common case, and the + * reason {@link stageRlsMembership} costs nothing when unused. */ - private orgScopingEnabled = false; + private rlsMembershipResolver: IRlsMembershipResolver | null = null; + /** + * [ADR-0105 D1] Back-compat view of {@link tenancyPosture}: "is an + * organization wall enforced at all?" — the exact question the pre-spectrum + * boolean answered. Layer 0 reads the posture itself; this stays for the + * strip decision and the boot log, which only care wall / no wall. + */ + private get orgScopingEnabled(): boolean { + return postureEnforcesWall(this.tenancyPosture); + } + /** + * [ADR-0105 D3] `object|policy` keys already reported by + * {@link warnAuthoredTenantPolicyOnce}, so a retained authored tenant policy + * is explained once per boot instead of on every find. + */ + private readonly warnedAuthoredTenantPolicies = new Set(); /** * Per-object field-name cache. Populated lazily from the metadata * service / ObjectQL registry on first access per object. Schemas are @@ -454,32 +490,51 @@ export class SecurityPlugin implements Plugin { }); } - // Probe whether org-scoping (auto-stamp + tenant RLS) is active. We capture - // the boolean once at start time (plugin DI graph is static after start) - // and let `collectRLSPolicies` consult it on every request. + // Resolve the tenancy POSTURE once at start time (the plugin DI graph is + // static after start); Layer 0 and `collectRLSPolicies` consult it on every + // request. // - // ADR-0093 D4 — prefer the `tenancy` service, the single source of truth. - // It derives `isolationActive` from the very same `org-scoping` presence - // probe, so this is behavior-identical while centralizing the fact. Fall - // back to probing `org-scoping` directly when the tenancy service isn't - // wired (e.g. an embedding without plugin-auth), preserving prior behavior. + // ADR-0093 D4 / ADR-0105 D1 — prefer the `tenancy` service, the single + // source of truth for which of `single` | `group` | `isolated` is in force + // (a requested-but-unenforceable wall already resolves to `single` there). + // Fall back to probing `org-scoping` directly when the tenancy service + // isn't wired (e.g. an embedding without plugin-auth): its presence means + // the historical `isolated` posture, preserving prior behavior exactly. try { - const tenancy = ctx.getService<{ isolationActive?: boolean }>('tenancy'); - this.orgScopingEnabled = !!tenancy?.isolationActive; + const tenancy = ctx.getService<{ posture?: TenancyPosture; isolationActive?: boolean }>('tenancy'); + this.tenancyPosture = + normalizeTenancyPosture(tenancy?.posture) ?? (tenancy?.isolationActive ? 'isolated' : 'single'); } catch { try { - this.orgScopingEnabled = !!ctx.getService('org-scoping'); + this.tenancyPosture = ctx.getService('org-scoping') ? 'isolated' : 'single'; } catch { - this.orgScopingEnabled = false; + this.tenancyPosture = 'single'; } } + // [ADR-0105 D11] Optional app/plugin membership resolver for the §7.3.1 + // `IN (current_user.)` sets. Absent is the norm — probe once. + try { + this.rlsMembershipResolver = + ctx.getService(RLS_MEMBERSHIP_RESOLVER_SERVICE) ?? null; + } catch { + this.rlsMembershipResolver = null; + } + if (this.rlsMembershipResolver) { + ctx.logger.info('[security] rls-membership-resolver registered', { + keys: this.rlsMembershipResolver.keys, + }); + } + if (this.orgScopingEnabled) { ctx.logger.info( - '[security] org-scoping plugin detected — wildcard `organization_id` RLS policies will apply', + `[security] tenancy posture '${this.tenancyPosture}' — the Layer 0 organization wall is ACTIVE ` + + `(${this.tenancyPosture === 'group' + ? 'organization_id IN accessible_org_ids — union access across the caller\'s memberships' + : 'organization_id = active organization'})`, ); } else { ctx.logger.info( - '[security] org-scoping plugin not present — wildcard `organization_id` RLS policies will be stripped (single-tenant mode)', + "[security] tenancy posture 'single' — Layer 0 is inert; the platform's own tenant-scoped RLS policies are stripped (app-authored ones are retained and fail closed, ADR-0105 D3)", ); } @@ -1363,16 +1418,58 @@ export class SecurityPlugin implements Plugin { // (import engine, migrations, plugin SYSTEM_CTX) are unaffected. A true // platform admin on a posture-permitting object is exempt via Layer 0 (same // rule as reads/insert). + // + // [ADR-0105 D5] Two changes here, both closing gaps in the above: + // + // • STAMPING moved into the engine. `organization_id` used to be filled + // only by the enterprise organizations plugin, so the `group` posture — + // which the open engine now enforces — would have written NULL-org rows + // that its own wall then hides. Under any wall-enforcing posture the + // engine stamps the caller's ACTIVE organization onto an insert that + // carries no value, then lets the check below validate it like any + // other. Idempotent w.r.t. the enterprise auto-stamp: whoever runs + // first sets it, and neither overwrites a supplied value. + // • BULK inserts are now covered. The check previously required a + // non-array payload, so an `insert` of an ARRAY of rows could carry a + // forged `organization_id` per row and never meet the wall — the same + // defect #2937 closed for the single-row shape, one call-site down + // (AGENTS.md #10: a `case` label is not enforcement, check the call site). if ( (opCtx.operation === 'insert' || opCtx.operation === 'update') && opCtx.data && typeof opCtx.data === 'object' && - !Array.isArray(opCtx.data) && !!opCtx.context?.userId ) { - const data = opCtx.data as Record; - const suppliedOrg = data.organization_id; - if (suppliedOrg != null && suppliedOrg !== '') { + const writeRows: Record[] = ( + Array.isArray(opCtx.data) ? (opCtx.data as unknown[]) : [opCtx.data as unknown] + ).filter((r: unknown): r is Record => + !!r && typeof r === 'object' && !Array.isArray(r), + ); + + if ( + opCtx.operation === 'insert' && + postureStampsOrganization(this.tenancyPosture) && + writeRows.some((r) => r.organization_id == null || r.organization_id === '') && + (await this.isTenantScopedObject(opCtx.object)) + ) { + const activeOrg = opCtx.context?.tenantId; + if (activeOrg != null && activeOrg !== '') { + for (const row of writeRows) { + if (row.organization_id == null || row.organization_id === '') { + row.organization_id = activeOrg; + } + } + } + // No active organization under a walled posture: leave the value + // absent. The row would be unreachable behind its own wall, and the + // read-side Layer 0 already fails such a context closed — inventing a + // tenant here is the one thing that could smuggle a row past it. + } + + const suppliedRows = writeRows.filter( + (r) => r.organization_id != null && r.organization_id !== '', + ); + if (suppliedRows.length > 0) { const tenantCheck = await this.computeWriteTenantCheckFilter( permissionSets, opCtx.object, @@ -1386,17 +1483,19 @@ export class SecurityPlugin implements Plugin { ? await this.computeWriteTenantCheckFilter(delegatorSets, opCtx.object, opCtx.operation, delegatorContext) : null; const tenantParts = [tenantCheck, delTenantCheck].filter(Boolean) as Record[]; + // EVERY supplied row must clear the wall — one forged row in a bulk + // payload denies the whole write (fail closed, no partial landing). if ( tenantParts.length > 0 && - !tenantParts.every((f) => matchesFilterCondition(data as any, f as any)) + !suppliedRows.every((row) => tenantParts.every((f) => matchesFilterCondition(row as any, f as any))) ) { this.logger.warn?.( `[Security] Layer 0 tenant CHECK FAILED on ${opCtx.operation} '${opCtx.object}' — write denied ` + - `(fail-closed); a supplied organization_id does not match the active organization`, + `(fail-closed); a supplied organization_id is outside the caller's organization scope`, ); throw new PermissionDeniedError( `[Security] Access denied: the ${opCtx.operation} would place '${opCtx.object}' in another tenant ` + - `(organization_id does not match the active organization)`, + `(organization_id is outside the caller's organization scope)`, { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets }, ); } @@ -1842,7 +1941,7 @@ export class SecurityPlugin implements Plugin { const pairs = extractMemberPairs(opCtx); for (const { userId, orgId } of pairs) { try { - await reconcileOrgAdminGrant(ql, userId, orgId, { logger: ctx.logger }); + await reconcileOrgAdminGrant(ql, userId, orgId, { logger: ctx.logger, posture: this.tenancyPosture }); } catch (e) { ctx.logger.warn?.('[security] org_admin reconcile failed', { userId, @@ -1858,7 +1957,7 @@ export class SecurityPlugin implements Plugin { // missing rows and revokes orphaned ones, never duplicates. const runOrgAdminBackfill = async () => { try { - await backfillOrgAdminGrants(ql, { logger: ctx.logger }); + await backfillOrgAdminGrants(ql, { logger: ctx.logger, posture: this.tenancyPosture }); } catch (e) { ctx.logger.warn?.('[security] organization_admin backfill failed', { error: (e as Error).message, @@ -2515,6 +2614,12 @@ export class SecurityPlugin implements Plugin { const objectFields = await this.getObjectFieldNames(this.metadata, object, this.ql); const tenancyDisabled = this.tenancyDisabledCache.get(object) === true || meta.tenancyDisabled; + // [ADR-0105 D11] Stage app-resolved membership sets before Layer 1 compiles, + // so `field IN (current_user.)` predicates can resolve. Lazy (only when + // a resolver is registered) and memoized per request — resolution is I/O and + // the same context is reused across every object touched by one request. + await this.stageRlsMembership(context); + // ── Layer 1: business RLS (ownership / unit depth / sharing / _self carve-outs). // The wildcard tenant policy has LEFT this layer (retired from the seeds), so // this superuser short-circuit now governs BUSINESS RLS only — it can no @@ -2552,8 +2657,11 @@ export class SecurityPlugin implements Plugin { // via extractTargetField's `=`-only shape match), so a `tenancy.enabled:false` // global object correctly contributes nothing (ADR-0095 delta c). const layer0 = computeTenantLayer0Filter({ - isolationActive: this.orgScopingEnabled, + tenancyPosture: this.tenancyPosture, organizationId: context?.tenantId, + // [ADR-0105 D2] The `group` wall's predicate. Resolved by + // `resolveAuthzContext` and carried on the context — never re-derived here. + accessibleOrgIds: context?.accessible_org_ids, objectHasOrgIdField: objectFields ? objectFields.has('organization_id') : undefined, tenancyDisabled, posturePermitsCrossTenant: posturePermits, @@ -2605,6 +2713,81 @@ export class SecurityPlugin implements Plugin { * applied to the write post-image (that path is governed by explicit `check` * clauses via {@link computeWriteCheckFilter}). */ + /** + * [ADR-0105 D11] Populate `context.rlsMembership` from the registered + * membership resolver, once per request. + * + * The RLS compiler has merged this bag since ADR-0056, but nothing in + * production ever filled it — a declared capability with no producer, the + * ADR-0049 defect. This is the producer: apps and plugins register an + * {@link IRlsMembershipResolver} under `rls-membership-resolver` and own the + * keys they declare. + * + * Fail-closed by construction: a throwing or partial resolver leaves keys + * unset, which makes the policies referencing them drop out — narrowing + * access, never widening it. Reserved kernel keys can never be overwritten, + * so an app cannot redefine the org wall's own vocabulary. + */ + private async stageRlsMembership(context: any): Promise { + if (!this.rlsMembershipResolver || !context || typeof context !== 'object') return; + // One resolution per request: the same context object is threaded through + // every object the operation touches. + if (context.__rlsMembershipStaged) return; + context.__rlsMembershipStaged = true; + + let resolved: Record = {}; + try { + resolved = (await this.rlsMembershipResolver.resolve({ + userId: context.userId, + tenantId: context.tenantId, + accessible_org_ids: context.accessible_org_ids, + positions: context.positions, + permissions: context.permissions, + })) ?? {}; + } catch (e) { + this.logger.warn?.( + '[security/ADR-0105] rls-membership-resolver threw — its keys stay unresolved and the ' + + 'policies referencing them will fail closed', + { error: (e as Error)?.message }, + ); + return; + } + + const bag: Record = { ...(context.rlsMembership ?? {}) }; + for (const [key, value] of Object.entries(resolved)) { + if (RESERVED_RLS_MEMBERSHIP_KEYS.includes(key)) { + this.logger.warn?.( + '[security/ADR-0105] rls-membership-resolver tried to supply a RESERVED context key — ignored', + { key }, + ); + continue; + } + if (!Array.isArray(value)) continue; + // An already-staged key wins: a caller-supplied bag is closer to the + // request than a generic resolver. + if (bag[key] === undefined) bag[key] = value.filter((v) => typeof v === 'string'); + } + if (Object.keys(bag).length > 0) context.rlsMembership = bag; + } + + /** + * [ADR-0105 D5] Is this object subject to the organization wall — i.e. does it + * carry an `organization_id` column and NOT opt out of tenancy? + * + * Uses the SAME two facts Layer 0 decides with (field set + tenancy posture), + * so the write-side stamp can never disagree with the read-side wall about + * what counts as a tenant object. An unresolvable field set (boot window) + * answers `false`: stamping is an additive convenience, and guessing a column + * onto an object that may not have one would fail the insert outright — the + * #2937 post-image check still guards any value that IS supplied. + */ + private async isTenantScopedObject(object: string): Promise { + const meta = await this.getObjectSecurityMeta(object); + if (this.tenancyDisabledCache.get(object) === true || meta.tenancyDisabled) return false; + const objectFields = await this.getObjectFieldNames(this.metadata, object, this.ql); + return objectFields ? objectFields.has('organization_id') : false; + } + private async computeWriteTenantCheckFilter( permissionSets: PermissionSet[], object: string, @@ -2772,23 +2955,22 @@ export class SecurityPlugin implements Plugin { for (const ps of permissionSets) { if (ps.rowLevelSecurity) { for (const policy of ps.rowLevelSecurity) { - // When the org-scoping plugin is NOT installed, strip any - // policy that filters on `current_user.organization_id` — - // there is no meaningful tenant to compare against, so the - // policy would either drop every row (when the field exists - // on the object) or be dropped by the field-existence safety - // net. Either way it's pure overhead. Substring match is - // sufficient: every wildcard tenant policy in the default - // permission sets uses exactly this token. Install - // `@objectstack/organizations` to enable the - // multi-tenant behavior. - if ( - !this.orgScopingEnabled && - policy.using && - policy.using.includes('current_user.organization_id') - ) { + // [ADR-0105 D3] When org isolation is NOT active, strip the tenant + // policies the PLATFORM itself ships — there is no meaningful active + // organization to compare against, so they would match zero rows (or + // be dropped by the field-existence safety net) for no benefit. + // + // Provenance, not pattern-matching (finding F1): the former substring + // test on `current_user.organization_id` also swallowed app-authored + // policies, silently unenforcing a declared security property. An + // authored policy now always reaches the compiler and fails closed + // there, where the operator can see it. + if (!this.orgScopingEnabled && isPlatformTenantPolicy(policy)) { continue; } + if (!this.orgScopingEnabled && isAuthoredTenantPolicy(policy)) { + this.warnAuthoredTenantPolicyOnce(policy, objectName); + } allPolicies.push(policy); } } @@ -2797,6 +2979,29 @@ export class SecurityPlugin implements Plugin { return this.rlsCompiler.getApplicablePolicies(objectName, operation, allPolicies, heldPositions); } + /** + * [ADR-0105 D3] Explain, once per policy, why an app-authored tenant-scoped + * policy will match zero rows: it references the active organization but this + * deployment has no org isolation, so `current_user.organization_id` resolves + * to nothing and the policy fails closed at compile time. The policy is NOT + * dropped — a declared security property stays declared (ADR-0049); this is + * the operator-visible half of that contract. + */ + private warnAuthoredTenantPolicyOnce( + policy: RowLevelSecurityPolicy, + objectName: string, + ): void { + const key = `${objectName} ${policy.name ?? ''}`; + if (this.warnedAuthoredTenantPolicies.has(key)) return; + this.warnedAuthoredTenantPolicies.add(key); + this.logger.warn?.( + '[security/ADR-0105] authored RLS policy references the active organization but org isolation ' + + 'is inactive — it is retained and will fail closed (zero rows). Install ' + + '@objectstack/organizations (or drop the policy) if this is not intended.', + { object: objectName, policy: policy.name, using: policy.using }, + ); + } + /** * [ADR-0066 D2/D3] Resolve and cache the object's security posture: whether it * is `private` (access.default), platform-global (tenancy disabled), and its diff --git a/packages/plugins/plugin-security/src/tenant-layer.test.ts b/packages/plugins/plugin-security/src/tenant-layer.test.ts new file mode 100644 index 0000000000..85c103f6be --- /dev/null +++ b/packages/plugins/plugin-security/src/tenant-layer.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Layer 0 — the organization wall — across all three tenancy postures + * (ADR-0095 D1, widened by ADR-0105 D1/D2). + * + * The invariants under test are the ones the whole authorization kernel rests + * on: the wall is computed independently of business RLS, composed FIRST, and + * crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object. + * ADR-0105 widens only the PREDICATE (equality → set membership under `group`); + * every composition rule must be unchanged, in every posture. + */ + +import { describe, it, expect } from 'vitest'; + +import { computeTenantLayer0Filter, andComposeLayers } from './tenant-layer.js'; +import { RLS_DENY_FILTER } from './rls-compiler.js'; + +/** A plain tenant business object with a member caller — the common case. */ +const base = { + organizationId: 'org-a', + accessibleOrgIds: ['org-a', 'org-b'], + objectHasOrgIdField: true, + tenancyDisabled: false, + posturePermitsCrossTenant: false, + isPlatformAdmin: false, +} as const; + +describe('computeTenantLayer0Filter — posture spectrum (ADR-0105 D1/D2)', () => { + describe('single', () => { + it('is inert — no wall, whatever the context carries', () => { + expect(computeTenantLayer0Filter({ ...base, tenancyPosture: 'single' })).toBeNull(); + }); + + it('stays inert even with no active org and no memberships', () => { + expect( + computeTenantLayer0Filter({ + ...base, + tenancyPosture: 'single', + organizationId: undefined, + accessibleOrgIds: [], + }), + ).toBeNull(); + }); + }); + + describe('isolated (the hard wall — behavior unchanged)', () => { + it('scopes to the ACTIVE organization, ignoring the wider membership set', () => { + expect(computeTenantLayer0Filter({ ...base, tenancyPosture: 'isolated' })).toEqual({ + organization_id: 'org-a', + }); + }); + + it('fails closed with no active organization', () => { + expect( + computeTenantLayer0Filter({ ...base, tenancyPosture: 'isolated', organizationId: undefined }), + ).toEqual(RLS_DENY_FILTER); + }); + }); + + describe('group (union / MOAC access)', () => { + it('scopes to the caller\'s whole membership set, not the active org', () => { + // The load-bearing difference: a group-HQ analyst reads every plant they + // belong to on one screen, with no context switching (Oracle MOAC, the + // pattern the ADR adopts over one-org-at-a-time responsibility switching). + expect(computeTenantLayer0Filter({ ...base, tenancyPosture: 'group' })).toEqual({ + organization_id: { $in: ['org-a', 'org-b'] }, + }); + }); + + it('does not widen past membership even when an org is active elsewhere', () => { + expect( + computeTenantLayer0Filter({ + ...base, + tenancyPosture: 'group', + organizationId: 'org-z', + accessibleOrgIds: ['org-a'], + }), + ).toEqual({ organization_id: { $in: ['org-a'] } }); + }); + + it('fails closed on an EMPTY access set (no membership, no reach)', () => { + expect( + computeTenantLayer0Filter({ ...base, tenancyPosture: 'group', accessibleOrgIds: [] }), + ).toEqual(RLS_DENY_FILTER); + }); + + it('fails closed when the access set is ABSENT (context never resolved it)', () => { + // A transport that forgets to carry `accessible_org_ids` must deny, not + // fall back to the active org — silently degrading to equality would make + // the wall depend on which surface the request arrived through. + expect( + computeTenantLayer0Filter({ ...base, tenancyPosture: 'group', accessibleOrgIds: undefined }), + ).toEqual(RLS_DENY_FILTER); + }); + + it('copies the id set (a later mutation cannot reach the compiled filter)', () => { + const orgIds = ['org-a']; + const filter = computeTenantLayer0Filter({ + ...base, + tenancyPosture: 'group', + accessibleOrgIds: orgIds, + }); + orgIds.push('org-victim'); + expect(filter).toEqual({ organization_id: { $in: ['org-a'] } }); + }); + }); + + // W1/W2 (ADR-0095): the wall is not weakenable by business RLS, and the + // superuser bit alone never crosses it. ADR-0105 D4 extends the SAME rule to + // `group` — only a true PLATFORM_ADMIN on a posture-permitting object exits. + describe('cross-wall exemption holds identically in group and isolated', () => { + for (const tenancyPosture of ['group', 'isolated'] as const) { + it(`${tenancyPosture}: a platform admin crosses on a posture-permitting object`, () => { + expect( + computeTenantLayer0Filter({ + ...base, + tenancyPosture, + isPlatformAdmin: true, + posturePermitsCrossTenant: true, + }), + ).toBeNull(); + }); + + it(`${tenancyPosture}: a TENANT admin (superuser bit, no platform rung) stays walled`, () => { + // `isPlatformAdmin` already folds in the platform-exclusive capability + // probe, so a tenant org_admin arrives here as false — finding F2 / #2937. + const filter = computeTenantLayer0Filter({ + ...base, + tenancyPosture, + isPlatformAdmin: false, + posturePermitsCrossTenant: true, + }); + expect(filter).not.toBeNull(); + }); + + it(`${tenancyPosture}: a platform admin stays walled on a PUBLIC business object`, () => { + const filter = computeTenantLayer0Filter({ + ...base, + tenancyPosture, + isPlatformAdmin: true, + posturePermitsCrossTenant: false, + }); + expect(filter).not.toBeNull(); + }); + + it(`${tenancyPosture}: contributes nothing on a tenancy-disabled object`, () => { + expect( + computeTenantLayer0Filter({ ...base, tenancyPosture, tenancyDisabled: true }), + ).toBeNull(); + }); + + it(`${tenancyPosture}: contributes nothing when the object has no organization_id`, () => { + expect( + computeTenantLayer0Filter({ ...base, tenancyPosture, objectHasOrgIdField: false }), + ).toBeNull(); + }); + + it(`${tenancyPosture}: assumes a tenant object when the field set is unresolved`, () => { + const filter = computeTenantLayer0Filter({ + ...base, + tenancyPosture, + objectHasOrgIdField: undefined, + }); + expect(filter).not.toBeNull(); + }); + } + }); +}); + +describe('andComposeLayers — Layer 0 is always outermost', () => { + it('places the group union wall FIRST so a Layer 1 disjunction cannot widen it', () => { + const layer0 = computeTenantLayer0Filter({ ...base, tenancyPosture: 'group' })!; + const layer1 = { $or: [{ owner_id: 'u1' }, { public: true }] }; + expect(andComposeLayers(layer0, layer1)).toEqual({ $and: [layer0, layer1] }); + }); + + it('passes either side through when the other contributes nothing', () => { + expect(andComposeLayers(null, { owner_id: 'u1' })).toEqual({ owner_id: 'u1' }); + expect(andComposeLayers({ organization_id: 'org-a' }, null)).toEqual({ organization_id: 'org-a' }); + expect(andComposeLayers(null, null)).toBeNull(); + }); +}); diff --git a/packages/plugins/plugin-security/src/tenant-layer.ts b/packages/plugins/plugin-security/src/tenant-layer.ts index 5a07a4286c..002e91a7b8 100644 --- a/packages/plugins/plugin-security/src/tenant-layer.ts +++ b/packages/plugins/plugin-security/src/tenant-layer.ts @@ -1,5 +1,11 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { + postureEnforcesWall, + postureUsesUnionScope, + type TenancyPosture, +} from '@objectstack/spec/security'; + import { RLS_DENY_FILTER } from './rls-compiler.js'; /** @@ -25,12 +31,28 @@ import { RLS_DENY_FILTER } from './rls-compiler.js'; /** Inputs the Layer 0 decision needs — all cheap, already-loaded facts. */ export interface TenantLayer0Input { /** - * True iff multi-org isolation is actually active (ADR-0093 `tenancy.mode === - * 'multi'` / `tenancy.isolationActive`). In `single` mode Layer 0 is inert. + * [ADR-0105 D1] The tenancy posture actually IN FORCE (`tenancy.posture`) — + * the single fact that decides what this layer enforces: + * + * - `single` → inert (no wall) + * - `group` → `organization_id IN accessibleOrgIds` (union / MOAC) + * - `isolated` → `organization_id = organizationId` (the hard wall) + * + * A degraded deployment (a wall requested but not enforceable) already + * resolves to `single` at the service, so this layer never sees a posture it + * cannot honour. */ - isolationActive: boolean; + tenancyPosture: TenancyPosture; /** The resolved authz context's active organization id (`ExecutionContext.tenantId`). */ organizationId?: string; + /** + * [ADR-0105 D2] The caller's org access set + * (`ExecutionContext.accessible_org_ids`) — every organization they hold a + * currently-valid membership in. Read ONLY under the `group` posture, where + * it is the wall. An empty/absent set there denies (fail closed): a principal + * with no membership has no group data reach. + */ + accessibleOrgIds?: readonly string[]; /** * Whether the object carries an `organization_id` column. `undefined` = the * schema/field-set could not be resolved yet (boot); treated as "assume tenant @@ -74,17 +96,27 @@ export interface TenantLayer0Input { /** * Compute the Layer 0 (tenant) filter to AND onto a read/write. * - * - `null` → Layer 0 contributes nothing (single mode; non-tenant object; or an - * exempt platform admin). The caller applies only Layer 1. - * - `{ organization_id }` → the tenant wall, AND-composed unconditionally. - * - {@link RLS_DENY_FILTER} → multi mode on a tenant object but the context has - * no active organization → fail closed (zero rows / write denied). + * - `null` → Layer 0 contributes nothing (`single` posture; non-tenant object; + * or an exempt platform admin). The caller applies only Layer 1. + * - `{ organization_id: }` → the `isolated` wall, AND-composed unconditionally. + * - `{ organization_id: { $in: [...] } }` → the `group` union wall (ADR-0105 D2). + * - {@link RLS_DENY_FILTER} → a walled posture on a tenant object, but the + * context carries no organization scope to enforce with (no active org under + * `isolated`, empty access set under `group`) → fail closed (zero rows / + * write denied). + * + * Only the PREDICATE widens between `isolated` and `group`; the composition + * rules do not. Layer 0 is still computed independently of the RLS compiler, + * still AND-composed first, and still crossable only by a true `PLATFORM_ADMIN` + * — so ADR-0095's W1 (business RLS cannot weaken the wall) and W2 (the + * superuser bypass cannot cross it) hold in every posture (ADR-0105 D2/D4). */ export function computeTenantLayer0Filter( input: TenantLayer0Input, ): Record | null { - // `single` mode / isolation absent → parity with today's policy stripping. - if (!input.isolationActive) return null; + // `single` posture (incl. a degraded deployment, which resolves to `single`) + // → parity with today's policy stripping. + if (!postureEnforcesWall(input.tenancyPosture)) return null; // Not a tenant object: platform-global (tenancy disabled) or simply carries no // `organization_id` column (e.g. better-auth identity tables like `sys_user`). @@ -99,7 +131,18 @@ export function computeTenantLayer0Filter( // bypass no longer implies Layer 0's. if (input.isPlatformAdmin && input.posturePermitsCrossTenant) return null; - // Enforce the wall. Missing active org in multi mode → fail closed. + // [ADR-0105 D2] `group`: union access over the caller's memberships (MOAC). + // The ACTIVE organization no longer bounds reads here — membership does — so + // a group-HQ analyst sees every plant they belong to on one screen without + // context switching, and a plant admin still sees only their own plants. + // An empty access set is the fail-closed case: no membership, no reach. + if (postureUsesUnionScope(input.tenancyPosture)) { + const orgIds = input.accessibleOrgIds; + if (!orgIds || orgIds.length === 0) return { ...RLS_DENY_FILTER }; + return { organization_id: { $in: [...orgIds] } }; + } + + // `isolated`: the hard wall. Missing active org → fail closed. if (!input.organizationId) return { ...RLS_DENY_FILTER }; return { organization_id: input.organizationId }; } diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index dbe33b9b3c..4b277bc9dc 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -55,6 +55,25 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ { id: 'multi-tenant-exemption-posture', summary: 'Layer 0 cross-tenant exemption requires the PLATFORM_ADMIN posture (Finding 2 — org_admin does not cross the wall)', state: 'enforced', enforcement: 'plugin-security/security-plugin.ts computeLayeredRlsFilter reads the carried ctx.posture rung (ADR-0099 D1 / #2956) to gate the tenant-layer.ts Layer 0 exemption — PLATFORM_ADMIN crosses, everything below is walled; the hasPlatformAdminPosture capability probe is the resolver-less fallback; the superuser bit (viewAllRecords/modifyAllRecords) governs only the Layer 1 business-RLS short-circuit', note: 'An organization_admin holds the superuser bit via its `*` wildcard, so it used to also get the Layer 0 exemption and read/write EVERY tenant\'s rows on private tenant objects. Crossing now requires the carried PLATFORM_ADMIN rung (ADR-0099 P1), which derives only from an unscoped admin_full_access grant — org_admin resolves to TENANT_ADMIN and a scoped grant / piecemeal platform capability to MEMBER, so all are walled to their own org (SECURITY NARROWING; a true platform admin still crosses, the better-auth carve-out is untouched). Before P1 the gate keyed on a platform-exclusive capability probe, which a scoped admin_full_access grant or a piecemeal studio.access grant could satisfy — the divergence class the equivalence gate pinned and P1 closed (invariant I1: TENANT_ADMIN never crosses). Unit-proven in plugin-security/authz-matrix-gate.test.ts ([Finding 2 / #2937] platform-posture exemption + "ADR-0099 P1 — Layer 0 exemption reads the carried rung").' }, + // ── ADR-0105 — group tenancy posture; organization scope as a first-class + // authorization dimension. Phase 0 (F1/F2 correctness) + Phase 1 (the `group` + // union wall). Multi-org is not in the open-core dogfood boot, so these are + // unit-proven at the plugin-security layer, like the #2937 row above. ── + { id: 'tenancy-posture-spectrum', summary: 'three tenancy postures — single | group | isolated (ADR-0105 D1/D2)', state: 'enforced', + enforcement: 'plugin-auth/tenancy-service.ts resolves the posture in force; plugin-security/tenant-layer.ts computeTenantLayer0Filter switches the Layer 0 predicate on it — inert (single), `organization_id IN accessible_org_ids` (group, MOAC union), `organization_id = activeOrganizationId` (isolated); empty/absent scope → RLS_DENY_FILTER', + note: 'Only the PREDICATE widens; composition is untouched — Layer 0 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 W1/W2 hold in every posture. `group` is enforced by the OPEN engine (cloud ADR-0016 铁律: the wall\'s correctness is never paid); `isolated` keeps its enterprise org-scoping probe and can still resolve degraded. Unit-proven in plugin-security/tenant-layer.test.ts and plugin-auth/tenancy-service.test.ts.' }, + { id: 'accessible-org-ids', summary: 'accessible_org_ids — core-resolved org access set (ADR-0105 D2)', state: 'enforced', + enforcement: 'core/security/resolve-authz-context.ts resolveUserAuthzGrants reads every sys_member row for the user under ADR-0091 validity windows; carried on ExecutionContext by every transport (rest-server, runtime resolve-execution-context, mcp, hono) and read directly by the Layer 0 group wall', + note: 'ONE read serves both the active-org position projection and the full membership set, so the two facts cannot disagree. A transport that fails to carry the set denies under `group` rather than falling back to the active org — the wall must not depend on which surface the request arrived through. Delegated (on-behalf-of) reads resolve the DELEGATOR\'s own set (explain-engine buildContextForUser), never inherit the live principal\'s.' }, + { id: 'org-stamp-write', summary: 'engine-owned organization_id stamping + bulk write validation (ADR-0105 D5)', state: 'enforced', + enforcement: 'plugin-security/security-plugin.ts step 3.7 — under any wall-enforcing posture an insert that omits organization_id is stamped from ctx.tenantId; every SUPPLIED value (single row AND bulk array) must satisfy the Layer 0 filter or the whole write is denied', + note: 'Stamping used to live solely in the enterprise organizations plugin, which left the open `group` posture writing NULL-org rows its own wall then hid; idempotent w.r.t. that plugin (neither overwrites a supplied value). The bulk path was a genuine hole: the pre-D5 check required a non-array payload, so an INSERT of an ARRAY could carry a forged organization_id per row — the #2937 defect one call site down. No tenant is ever invented: a caller with no active org leaves the value absent rather than smuggling a row past the wall. Unit-proven in plugin-security/security-plugin.test.ts + authz-matrix-gate.test.ts.' }, + { id: 'authored-rls-policy-survival', summary: 'app-authored org-scoped RLS policies are never silently stripped (ADR-0105 D3 / F1)', state: 'enforced', + enforcement: 'plugin-security/platform-tenant-policies.ts — collectRLSPolicies strips by PROVENANCE (identity against the shipped declaration), not by substring-matching `current_user.organization_id`; an authored policy is retained, warned about once, and fails closed at compile time', + note: 'The substring match dropped ANY policy using the token, including app-authored ones — a declared security policy silently unenforced, the ADR-0049 class. getReadFilter shared the defect, so analytics/raw-SQL consumers got an UNSCOPED read. Unit-proven in plugin-security/platform-tenant-policies.test.ts + security-plugin.test.ts.' }, + { id: 'vama-bounded-by-org-scope', summary: 'viewAllRecords/modifyAllRecords never cross an organization boundary, in any posture (ADR-0105 D4 / F2)', state: 'enforced', + enforcement: 'Layer 0 owns the wall in `group` exactly as in `isolated` (the superuser bit governs only the Layer 1 short-circuit); plugin-security/auto-org-admin-grant.ts grants the de-VAMA\'d `organization_admin_no_bypass` variant under a wall-less posture and revokes the superseded variant on any posture change', + note: 'organization_admin carries wildcard viewAllRecords/modifyAllRecords, safe only because Layer 0 bounds it. Wall-less, nothing did: with personal-org creation on signup, every owner/admin was effectively an environment-wide superuser. Deliberate blanket visibility remains available via admin_full_access or an explicitly authored set — it just stops being a side effect of a better-auth membership role. The variant is DERIVED from organization_admin (deriveWallLessOrgAdmin), never a second literal, so the two cannot drift. Unit-proven in plugin-security/auto-org-admin-grant.test.ts + objects/rbac-objects.test.ts.' }, { id: 'anonymous-deny', summary: 'secure-by-default anonymous posture (capability)', state: 'enforced', enforcement: 'rest/rest-server.ts enforceAuth (requireAuth)', proof: 'showcase-anonymous-deny.dogfood.test.ts' }, // ── #2567 — the anonymous-deny posture is UNIFORM across HTTP surfaces, not diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 78f87a2acb..49714d9701 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1362,6 +1362,10 @@ export class RestServer { ...(authz.posture ? { posture: authz.posture } : {}), isSystem: false, org_user_ids: authz.org_user_ids, + // [ADR-0105 D2] The caller's org access set — the `group` + // posture's Layer 0 wall reads it directly, so it must reach + // enforcement on every transport, not just this one. + accessible_org_ids: authz.accessible_org_ids, ...(authGate ? { authGate } : {}), ...(localization.timezone ? { timezone: localization.timezone } : {}), ...(localization.locale ? { locale: localization.locale } : {}), diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index ee389858ab..43e101eda1 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -208,6 +208,9 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise)`. The compiler has merged that + * bag since day one — but nothing in production ever populated it. A declared + * capability with no producer is the ADR-0049 defect ("declared but + * unenforced"), and ADR-0105 D11 resolves it the enforce-or-remove way: keep the + * seam, and give it a real contract so plugins and apps can fill it. + * + * ## Contract + * + * A resolver DECLARES the keys it owns and RESOLVES them for one request. The + * declaration is what makes the seam auditable: an authoring lint can tell a + * policy referencing an unowned key from a typo, and an operator can see which + * plugin supplies which key. + * + * ## Fail-closed by construction + * + * A resolver that throws, omits a key, or returns an empty array makes every + * policy referencing that key DROP OUT — and a policy that drops out narrows + * access (fail closed), never widens it. Callers must therefore never treat a + * resolution failure as "no restriction": the compiler's existing + * missing-variable path already yields zero rows, which is the safe answer. + * + * ## What does NOT belong here + * + * `accessible_org_ids` (ADR-0105 D2) is CORE-resolved, not an app resolver: it + * is the `group` posture's Layer 0 wall, and a wall an app could redefine would + * not be a wall. The same goes for `org_user_ids` — the one well-known + * membership set, resolved by `resolveAuthzContext`. This seam is for + * app-shaped sets: the accounts in a rep's territories, the records a case team + * can touch, the cost centres a controller reviews. + */ + +/** Kernel service name a membership resolver registers under. */ +export const RLS_MEMBERSHIP_RESOLVER_SERVICE = 'rls-membership-resolver'; + +/** The request facts a resolver may key off. A subset of `ExecutionContext`. */ +export interface RlsMembershipContext { + /** The acting principal. Absent for an anonymous request (resolvers should return `{}`). */ + userId?: string; + /** Active organization id (`ExecutionContext.tenantId`). */ + tenantId?: string; + /** Organizations the caller holds a valid membership in (ADR-0105 D2). */ + accessible_org_ids?: string[]; + /** Positions held for this request. */ + positions?: string[]; + /** Permission-set names resolved for this request. */ + permissions?: string[]; +} + +/** + * Resolves pre-computed membership sets for RLS `IN (current_user.)` + * predicates. Registered under {@link RLS_MEMBERSHIP_RESOLVER_SERVICE}. + */ +export interface IRlsMembershipResolver { + /** + * The `current_user.*` keys this resolver owns. Declared up front so the seam + * is auditable and a policy referencing an unowned key is distinguishable + * from a typo. Keys are `snake_case` and must not collide with the named + * context fields (`id`, `organization_id`, `positions`, `org_user_ids`, + * `accessible_org_ids`, `email`) — the compiler never lets a membership key + * clobber those. + */ + readonly keys: readonly string[]; + + /** + * Resolve this request's sets. Returning a subset of {@link keys} is fine — + * an unresolved key simply makes its policies drop out (fail closed). + * + * MUST NOT throw for an ordinary miss; a throw is treated as "resolved + * nothing", with the same fail-closed outcome. + */ + resolve(context: RlsMembershipContext): Promise>; +} + +/** + * Context keys a membership resolver may never supply — they are resolved by + * the kernel and carry authorization meaning an app must not be able to redefine. + */ +export const RESERVED_RLS_MEMBERSHIP_KEYS: readonly string[] = [ + 'id', + 'organization_id', + 'positions', + 'org_user_ids', + 'accessible_org_ids', + 'email', +] as const; diff --git a/packages/spec/src/identity/eval-user.zod.ts b/packages/spec/src/identity/eval-user.zod.ts index 6e87602fcc..7a1656e1dd 100644 --- a/packages/spec/src/identity/eval-user.zod.ts +++ b/packages/spec/src/identity/eval-user.zod.ts @@ -69,6 +69,31 @@ export const ADMIN_FULL_ACCESS = 'admin_full_access'; */ export const ORGANIZATION_ADMIN = 'organization_admin'; +/** + * [ADR-0105 D4] The wall-less variant of {@link ORGANIZATION_ADMIN}: identical + * administration rights, WITHOUT the `viewAllRecords`/`modifyAllRecords` + * superuser bits. + * + * `organization_admin` is safe because Layer 0 bounds its superuser bits to the + * caller's organization scope. Under the `single` posture there is no wall to + * bound them — and a wall-less deployment that accumulates organizations (the + * personal-org-on-signup shape) would make every owner/admin an + * ENVIRONMENT-WIDE superuser (ADR-0105 finding F2). So the auto-grant hands out + * THIS set instead whenever no wall is enforced: an org admin still administers + * their organization, but blanket record visibility must be granted + * deliberately (`admin_full_access`, or an explicit set carrying the bits) — + * never as a side effect of a better-auth membership role. + * + * It resolves the `TENANT_ADMIN` rung exactly like `organization_admin` does. + */ +export const ORGANIZATION_ADMIN_NO_BYPASS = 'organization_admin_no_bypass'; + +/** Both org-admin capability grants — either one resolves the `TENANT_ADMIN` rung. */ +export const ORGANIZATION_ADMIN_GRANTS: readonly string[] = [ + ORGANIZATION_ADMIN, + ORGANIZATION_ADMIN_NO_BYPASS, +] as const; + /** Human-readable metadata for the built-in identity names (seeded into `sys_position`; AI grounding). */ export const BUILTIN_IDENTITY_METADATA: Record = { [BUILTIN_IDENTITY_PLATFORM_ADMIN]: { label: 'Platform Admin', description: 'Platform operator (SaaS admin). NOT a tenant user role.' }, diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 1ec352e6fa..9795b29cbb 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -173,5 +173,7 @@ export { BUILTIN_IDENTITY_ORG_MEMBER, ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN, + ORGANIZATION_ADMIN_NO_BYPASS, + ORGANIZATION_ADMIN_GRANTS, } from './identity/eval-user.zod'; export type { EvalUser, EvalUserInput, BuiltinIdentityName } from './identity/eval-user.zod'; diff --git a/packages/spec/src/kernel/execution-context.zod.ts b/packages/spec/src/kernel/execution-context.zod.ts index d6e970d0df..655b738a89 100644 --- a/packages/spec/src/kernel/execution-context.zod.ts +++ b/packages/spec/src/kernel/execution-context.zod.ts @@ -145,6 +145,31 @@ export const ExecutionContextSchema = lazySchema(() => z.object({ */ org_user_ids: z.array(z.string()).optional(), + /** + * [ADR-0105 D2] Every organization this principal currently holds a valid + * membership in — the caller's **org access set**, resolved once by + * `resolveAuthzContext` from `sys_member` under ADR-0091 validity windows. + * + * This is the read reach of the `group` tenancy posture, where the Layer 0 + * wall is `organization_id IN accessible_org_ids` (MOAC semantics: union + * access across the member's organizations, no context switching). It is a + * FIRST-CLASS authorization dimension, not a convenience: the wall compiler + * reads it directly, and RLS policies may reference it as + * `organization_id IN (current_user.accessible_org_ids)`. + * + * Relationship to {@link tenantId}: `tenantId` remains the ACTIVE + * organization — the default write target and the UI's current context. In + * `isolated` posture it also bounds reads (the wall is equality); in `group` + * posture it does not — membership does. In `single` posture the set is + * resolved but no wall consumes it. + * + * An authenticated principal with no valid membership resolves to an EMPTY + * set, which fails the group wall closed (zero rows) — never fail-open. + * + * @example ['org_plant_a', 'org_plant_b', 'org_group_hq'] + */ + accessible_org_ids: z.array(z.string()).optional(), + /** * Pre-resolved dynamic-membership arrays for RLS (§7.3.1). The runtime * resolves set-membership that would otherwise need a subquery — team diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts index d3551f4cef..abf5dffcdf 100644 --- a/packages/spec/src/kernel/public-auth-features.ts +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -148,8 +148,9 @@ export const PUBLIC_AUTH_FEATURES = { 'sys_organization.actions.change_slug', ], notes: - 'Reflects ACTUAL multi-tenancy capability (tenancy.mode === "multi", ' + - 'ADR-0093 D4), not just the requested mode.', + 'Reflects ACTUAL multi-tenancy capability (the tenancy posture enforces ' + + 'an organization wall — `group` or `isolated`, ADR-0093 D4 / ADR-0105 D1), ' + + 'not just the requested posture.', }, degradedTenancy: { surface: 'status', @@ -251,11 +252,19 @@ export const PUBLIC_AUTH_FEATURE_NAMES = Object.keys(PUBLIC_AUTH_FEATURES) as [ ]; /** - * Non-boolean keys `getPublicConfig()` may conditionally spread into - * `features` (legal-link URLs). Exempt from flag classification; the drift - * guard asserts no OTHER non-boolean key sneaks in. + * Non-boolean keys `getPublicConfig()` may spread into `features` (legal-link + * URLs; the tenancy posture). Exempt from FLAG classification — a flag is a + * boolean capability gate, and these are values — but the drift guard still + * asserts no OTHER non-boolean key sneaks in. + * + * `tenancyPosture` (ADR-0105 D1) reports WHICH of `single` | `group` | + * `isolated` is in force. It gates nothing: `multiOrgEnabled` remains the + * boolean capability gate ("is an organization wall enforced at all?"), while + * this tells the console how to render org context — under `group` the org + * switcher picks the WRITE target and reads span every organization the member + * belongs to. */ -export const PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS = ['termsUrl', 'privacyUrl'] as const; +export const PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS = ['termsUrl', 'privacyUrl', 'tenancyPosture'] as const; /** * The canonical CEL gate for a flag, per its default semantics: diff --git a/packages/spec/src/security/index.ts b/packages/spec/src/security/index.ts index 8a0c6297d2..bb76c1c1b2 100644 --- a/packages/spec/src/security/index.ts +++ b/packages/spec/src/security/index.ts @@ -6,8 +6,12 @@ * Fine-grained Access Control * - Permission Sets (CRUD + Field-Level Security) * - Sharing Rules (Record Ownership) - * - Territory Management (Geographic/Hierarchical) * - Row-Level Security (RLS - PostgreSQL-style) + * + * [ADR-0105 D11] Territory Management was removed (enforce-or-remove, ADR-0049): + * the schemas had no runtime object, stack field, or resolver. Matrix + * requirements are served today by multi-position × business-unit anchoring; a + * generalized dimension-security module will arrive with its own ADR. */ export * from './permission.zod'; @@ -17,5 +21,5 @@ export * from './high-privilege'; export * from './public-form'; export * from './explain.zod'; export * from './sharing.zod'; -export * from './territory.zod'; export * from './rls.zod'; +export * from './tenancy-posture'; diff --git a/packages/spec/src/security/permission.form.ts b/packages/spec/src/security/permission.form.ts index d0f04bbb26..1986ed530e 100644 --- a/packages/spec/src/security/permission.form.ts +++ b/packages/spec/src/security/permission.form.ts @@ -47,12 +47,11 @@ export const permissionForm = defineForm({ }, { label: 'Tab & Row-Level Security', - description: 'Tab visibility, RLS policies, and custom context variables for predicate evaluation.', + description: 'Tab visibility and RLS policies.', columns: 1, fields: [ { field: 'tabPermissions', widget: 'json', helpText: '{ "app_crm": "visible", "app_admin": "hidden" }' }, { field: 'rowLevelSecurity', widget: 'json', helpText: 'Array of RLS policies (see rls.zod.ts)' }, - { field: 'contextVariables', widget: 'json', helpText: 'Custom variables referenced in RLS predicates' }, ], }, ], diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index eba1727799..b1ed4a3a2a 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -284,28 +284,16 @@ export const PermissionSetSchema = lazySchema(() => z.object({ .describe('Row-level security policies (see rls.zod.ts for full spec)'), /** - * Context-Based Access Control Variables - * - * Custom context variables that can be referenced in RLS rules. - * These variables are evaluated at runtime based on the user's session. - * - * Common context variables: - * - `current_user.id` - Current user ID - * - `current_user.organization_id` - Active organization id - * - `current_user.department` - User's department - * - `current_user.positions` - Held positions (ADR-0090 D3) - * - `current_user.region` - User's geographic region - * - * @example Custom context - * ```typescript - * contextVariables: { - * allowed_regions: ['US', 'EU'], - * access_level: 2, - * custom_attribute: 'value' - * } - * ``` + * [ADR-0105 D11] `contextVariables` was REMOVED (enforce-or-remove, ADR-0049): + * it was authorable but had zero runtime consumers — the RLS compiler resolves + * only the `current_user.*` built-ins plus the runtime-staged + * `ExecutionContext.rlsMembership` sets, never a value declared here. + * + * Migration: a custom set that a policy needs as `field IN (current_user.)` + * is staged by a registered membership resolver (see + * {@link ExecutionContextSchema.rlsMembership}); a constant is written as a + * literal in the policy's `using` (`status = 'published'`). */ - contextVariables: z.record(z.string(), z.unknown()).optional().describe('Context variables for RLS evaluation'), /** * [ADR-0090 D12] Delegated-administration scope carried by this set. diff --git a/packages/spec/src/security/tenancy-posture.ts b/packages/spec/src/security/tenancy-posture.ts new file mode 100644 index 0000000000..faa69b7cb7 --- /dev/null +++ b/packages/spec/src/security/tenancy-posture.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0105 D1] Tenancy posture — the deployment's organization-scope shape. + * + * Tenancy is a SPECTRUM, not a boolean. The posture is the single fact that + * decides what the Layer 0 authorization wall enforces, so it lives in the + * protocol rather than being re-derived per package (the exact drift ADR-0093 + * retired for the two-valued `mode`). + * + * | Posture | Layer 0 wall | Shape | + * |------------|-------------------------------------------|-------| + * | `single` | none (inert) | one logical tenant; factories modeled as business units in one tree | + * | `group` | `organization_id IN accessible_org_ids` | organizations are membership/invitation boundaries over one shared dataset; union (MOAC) read access | + * | `isolated` | `organization_id = activeOrganizationId` | legal-entity / sovereignty isolation — the hard wall | + * + * `isolated` is the posture formerly called `multi`; existing configuration + * maps to it unchanged. `group` is the middle that group-shaped customers + * (multi-plant manufacturing, multi-branch retail, holding structures) need: + * group-wide visibility and cross-org workflow are inherent to the shape, and + * a hard per-org wall makes both an architecture problem. + * + * The wall's CORRECTNESS is never a paid feature (cloud ADR-0016 铁律): every + * posture is enforced by the open engine. What stays commercial is managing + * organizations at scale, not being safe. + */ + +import { z } from 'zod'; + +/** The three tenancy postures. */ +export const TenancyPostureSchema = z.enum(['single', 'group', 'isolated']); +export type TenancyPosture = z.infer; + +/** Every posture, in ascending isolation order. */ +export const TENANCY_POSTURES: readonly TenancyPosture[] = ['single', 'group', 'isolated'] as const; + +/** + * Does this posture enforce an organization wall at Layer 0? + * + * `false` for `single` only. A wall-less posture has NO engine-enforced org + * boundary, which is why ADR-0105 D4 forbids auto-granting unbounded + * `viewAllRecords`/`modifyAllRecords` there — nothing would contain it. + */ +export function postureEnforcesWall(posture: TenancyPosture): boolean { + return posture !== 'single'; +} + +/** + * Does this posture stamp and validate `organization_id` on write (ADR-0105 D5)? + * True exactly when a wall is enforced — the write side must be the twin of the + * read side or a forged/absent value would slip under the wall. + */ +export function postureStampsOrganization(posture: TenancyPosture): boolean { + return postureEnforcesWall(posture); +} + +/** + * Does this posture grant READ reach across the caller's whole membership set + * (union / MOAC semantics) rather than only the active organization? + * + * `group` only. In `isolated` the active organization bounds reads; in `single` + * there is no wall to widen. + */ +export function postureUsesUnionScope(posture: TenancyPosture): boolean { + return posture === 'group'; +} + +/** + * Normalize a stored/env-supplied posture value, accepting the legacy `multi` + * spelling as `isolated` (ADR-0093 → ADR-0105 rename). Returns `undefined` for + * anything unrecognized so callers can fall back deliberately rather than + * silently landing in a weaker posture. + */ +export function normalizeTenancyPosture(value: unknown): TenancyPosture | undefined { + const raw = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (raw === 'multi') return 'isolated'; + const parsed = TenancyPostureSchema.safeParse(raw); + return parsed.success ? parsed.data : undefined; +} diff --git a/packages/spec/src/security/territory.test.ts b/packages/spec/src/security/territory.test.ts deleted file mode 100644 index e695f7a9a6..0000000000 --- a/packages/spec/src/security/territory.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - TerritorySchema, - TerritoryModelSchema, - TerritoryType, - type Territory, - type TerritoryModel, -} from './territory.zod'; - -describe('TerritoryType', () => { - it('should accept valid territory types', () => { - const validTypes = ['geography', 'industry', 'named_account', 'product_line']; - - validTypes.forEach(type => { - expect(() => TerritoryType.parse(type)).not.toThrow(); - }); - }); - - it('should reject invalid territory types', () => { - expect(() => TerritoryType.parse('customer')).toThrow(); - expect(() => TerritoryType.parse('region')).toThrow(); - expect(() => TerritoryType.parse('')).toThrow(); - }); -}); - -describe('TerritoryModelSchema', () => { - it('should accept valid minimal territory model', () => { - const model: TerritoryModel = { - name: 'FY2024 Planning', - }; - - expect(() => TerritoryModelSchema.parse(model)).not.toThrow(); - }); - - it('should apply default state', () => { - const model = TerritoryModelSchema.parse({ - name: 'FY2024', - }); - - expect(model.state).toBe('planning'); - }); - - it('should accept model with all fields', () => { - const model = TerritoryModelSchema.parse({ - name: 'FY2024 Planning', - state: 'active', - startDate: '2024-01-01', - endDate: '2024-12-31', - }); - - expect(model.state).toBe('active'); - expect(model.startDate).toBe('2024-01-01'); - expect(model.endDate).toBe('2024-12-31'); - }); - - it('should accept different states', () => { - const states: Array = ['planning', 'active', 'archived']; - - states.forEach(state => { - const model = TerritoryModelSchema.parse({ - name: 'Test Model', - state, - }); - expect(model.state).toBe(state); - }); - }); - - it('should reject invalid state', () => { - expect(() => TerritoryModelSchema.parse({ - name: 'Test Model', - state: 'inactive', - })).toThrow(); - }); - - it('should handle fiscal year planning model', () => { - const model = TerritoryModelSchema.parse({ - name: 'FY2024 Planning', - state: 'planning', - startDate: '2024-01-01', - endDate: '2024-12-31', - }); - - expect(model.name).toBe('FY2024 Planning'); - }); - - it('should handle active territory model', () => { - const model = TerritoryModelSchema.parse({ - name: 'Current Territory', - state: 'active', - }); - - expect(model.state).toBe('active'); - }); -}); - -describe('TerritorySchema', () => { - it('should accept valid minimal territory', () => { - const territory: Territory = { - name: 'west_coast', - label: 'West Coast', - modelId: 'fy2024', - }; - - expect(() => TerritorySchema.parse(territory)).not.toThrow(); - }); - - it('should validate territory name format (snake_case)', () => { - expect(() => TerritorySchema.parse({ - name: 'valid_territory_name', - label: 'Valid Territory', - modelId: 'model1', - })).not.toThrow(); - - expect(() => TerritorySchema.parse({ - name: 'InvalidTerritory', - label: 'Invalid', - modelId: 'model1', - })).toThrow(); - - expect(() => TerritorySchema.parse({ - name: 'invalid-territory', - label: 'Invalid', - modelId: 'model1', - })).toThrow(); - }); - - it('should apply default values', () => { - const territory = TerritorySchema.parse({ - name: 'test_territory', - label: 'Test Territory', - modelId: 'model1', - }); - - expect(territory.type).toBe('geography'); - expect(territory.accountAccess).toBe('read'); - expect(territory.opportunityAccess).toBe('read'); - expect(territory.caseAccess).toBe('read'); - }); - - it('should accept parent territory', () => { - const territory = TerritorySchema.parse({ - name: 'northern_california', - label: 'Northern California', - modelId: 'fy2024', - parent: 'west_coast', - }); - - expect(territory.parent).toBe('west_coast'); - }); - - it('should accept different territory types', () => { - const types: Array = ['geography', 'industry', 'named_account', 'product_line']; - - types.forEach(type => { - const territory = TerritorySchema.parse({ - name: 'test_territory', - label: 'Test', - modelId: 'model1', - type, - }); - expect(territory.type).toBe(type); - }); - }); - - it('should accept assignment rule', () => { - const territory = TerritorySchema.parse({ - name: 'california', - label: 'California', - modelId: 'fy2024', - assignmentRule: "BillingCountry = 'US' AND BillingState = 'CA'", - }); - - expect(territory.assignmentRule).toBe("BillingCountry = 'US' AND BillingState = 'CA'"); - }); - - it('should accept assigned users', () => { - const territory = TerritorySchema.parse({ - name: 'west_coast', - label: 'West Coast', - modelId: 'fy2024', - assignedUsers: ['user1', 'user2', 'user3'], - }); - - expect(territory.assignedUsers).toEqual(['user1', 'user2', 'user3']); - }); - - it('should accept access levels', () => { - const territory = TerritorySchema.parse({ - name: 'emea', - label: 'EMEA', - modelId: 'fy2024', - accountAccess: 'edit', - opportunityAccess: 'edit', - caseAccess: 'read', - }); - - expect(territory.accountAccess).toBe('edit'); - expect(territory.opportunityAccess).toBe('edit'); - expect(territory.caseAccess).toBe('read'); - }); - - it('should reject invalid access levels', () => { - expect(() => TerritorySchema.parse({ - name: 'test', - label: 'Test', - modelId: 'model1', - accountAccess: 'write', - })).toThrow(); - }); - - it('should handle geographic territory', () => { - const territory = TerritorySchema.parse({ - name: 'north_america', - label: 'North America', - modelId: 'fy2024', - type: 'geography', - assignmentRule: "BillingCountry IN ('US', 'CA', 'MX')", - assignedUsers: ['sales_manager_1'], - }); - - expect(territory.type).toBe('geography'); - }); - - it('should handle industry vertical territory', () => { - const territory = TerritorySchema.parse({ - name: 'healthcare', - label: 'Healthcare', - modelId: 'fy2024', - type: 'industry', - assignmentRule: "Industry = 'Healthcare'", - assignedUsers: ['industry_specialist_1', 'industry_specialist_2'], - }); - - expect(territory.type).toBe('industry'); - }); - - it('should handle named account territory', () => { - const territory = TerritorySchema.parse({ - name: 'strategic_accounts', - label: 'Strategic Accounts', - modelId: 'fy2024', - type: 'named_account', - assignmentRule: "AccountType = 'Strategic'", - assignedUsers: ['strategic_account_manager'], - accountAccess: 'edit', - opportunityAccess: 'edit', - }); - - expect(territory.type).toBe('named_account'); - expect(territory.accountAccess).toBe('edit'); - }); - - it('should handle product line territory', () => { - const territory = TerritorySchema.parse({ - name: 'cloud_products', - label: 'Cloud Products', - modelId: 'fy2024', - type: 'product_line', - assignedUsers: ['product_specialist_1'], - }); - - expect(territory.type).toBe('product_line'); - }); - - it('should handle hierarchical territories', () => { - const parent = TerritorySchema.parse({ - name: 'americas', - label: 'Americas', - modelId: 'fy2024', - }); - - const child = TerritorySchema.parse({ - name: 'north_america', - label: 'North America', - modelId: 'fy2024', - parent: 'americas', - }); - - expect(child.parent).toBe('americas'); - }); - - it('should handle territory with multiple users', () => { - const territory = TerritorySchema.parse({ - name: 'enterprise_accounts', - label: 'Enterprise Accounts', - modelId: 'fy2024', - assignedUsers: ['rep1', 'rep2', 'rep3', 'manager1'], - }); - - expect(territory.assignedUsers).toHaveLength(4); - }); - - it('should reject territory without required fields', () => { - expect(() => TerritorySchema.parse({ - label: 'Test', - modelId: 'model1', - })).toThrow(); - - expect(() => TerritorySchema.parse({ - name: 'test', - modelId: 'model1', - })).toThrow(); - - expect(() => TerritorySchema.parse({ - name: 'test', - label: 'Test', - })).toThrow(); - }); -}); diff --git a/packages/spec/src/security/territory.zod.ts b/packages/spec/src/security/territory.zod.ts deleted file mode 100644 index ef36cbb3d7..0000000000 --- a/packages/spec/src/security/territory.zod.ts +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; - -/** - * Territory Management Protocol - * Defines a matrix reporting structure that exists parallel to the - * business-unit hierarchy (ADR-0090 D3 — the org tree is `sys_business_unit`). - * - * USE CASE: - * - Enterprise Sales Teams (Geo-based: "EMEA", "APAC") - * - Industry Verticals (Industry-based: "Healthcare", "Financial") - * - Strategic Accounts (Account-based: "Strategic Accounts") - * - * DIFFERENCE FROM THE BUSINESS-UNIT TREE: - * - Business unit: Hierarchy of PEOPLE (org structure). Stable. HR-driven. - * - Territory: Hierarchy of ACCOUNTS/REVENUE (Who owns which market). Flexible. Sales-driven. - * - One User can be assigned to MANY Territories (Matrix). - * - One User belongs to one primary business unit (Tree). - */ - -import { lazySchema } from '../shared/lazy-schema'; -export const TerritoryType = z.enum([ - 'geography', // Region/Country/City - 'industry', // Vertical - 'named_account', // Key Accounts - 'product_line' // Product Specialty -]); - -/** - * Territory Model Schema - * A container for a version of territory planning. - * (e.g. "Fiscal Year 2024 Planning" vs "Fiscal Year 2025 Planning") - */ -export const TerritoryModelSchema = lazySchema(() => z.object({ - name: z.string().describe('Model Name (e.g. FY24 Planning)'), - state: z.enum(['planning', 'active', 'archived']).default('planning'), - startDate: z.string().optional(), - endDate: z.string().optional(), -})); - -/** - * Territory Node Schema - * A single node in the territory tree. - * - * **NAMING CONVENTION:** - * Territory names are machine identifiers and must be lowercase snake_case. - * - * @example Good territory names - * - 'west_coast' - * - 'emea_region' - * - 'healthcare_vertical' - * - 'strategic_accounts' - * - * @example Bad territory names (will be rejected) - * - 'WestCoast' (PascalCase) - * - 'West Coast' (spaces) - */ -export const TerritorySchema = lazySchema(() => z.object({ - /** Identity */ - name: SnakeCaseIdentifierSchema.describe('Territory unique name (lowercase snake_case)'), - label: z.string().describe('Territory Label (e.g. "West Coast")'), - - /** Structure */ - modelId: z.string().describe('Belongs to which Territory Model'), - parent: z.string().optional().describe('Parent Territory'), - type: TerritoryType.default('geography'), - - /** - * Assignment Rules (The "Magic") - * How do accounts automatically fall into this territory? - * e.g. "BillingCountry = 'US' AND BillingState = 'CA'" - */ - assignmentRule: z.string().optional().describe('Criteria based assignment rule'), - - /** - * User Assignment - * Users assigned to work this territory. - */ - assignedUsers: z.array(z.string()).optional(), - - /** Access Level */ - accountAccess: z.enum(['read', 'edit']).default('read'), - opportunityAccess: z.enum(['read', 'edit']).default('read'), - caseAccess: z.enum(['read', 'edit']).default('read'), -})); - -export type Territory = z.infer; -export type TerritoryModel = z.infer; diff --git a/packages/types/src/env.ts b/packages/types/src/env.ts index f317ef7faf..ed97d054ee 100644 --- a/packages/types/src/env.ts +++ b/packages/types/src/env.ts @@ -17,6 +17,12 @@ * operators to rename it. */ +import { + normalizeTenancyPosture, + TENANCY_POSTURES, + type TenancyPosture, +} from '@objectstack/spec/security'; + const _warnedKeys = new Set(); /** @@ -103,6 +109,46 @@ export function resolveMultiOrgEnabled(): boolean { return String(raw ?? 'false').toLowerCase() !== 'false'; } +/** + * [ADR-0105 D1] Resolve the deployment's REQUESTED tenancy posture — + * `single` | `group` | `isolated`. + * + * `OS_TENANCY_POSTURE` is the canonical knob and generalizes the boolean + * `OS_MULTI_ORG_ENABLED` it supersedes: + * + * - set → that posture (the legacy spelling `multi` normalizes to `isolated`) + * - unset → derived from `OS_MULTI_ORG_ENABLED`: `true` ⇒ `isolated`, else `single` + * + * so every existing deployment keeps its current posture with no config change. + * + * An unrecognized value THROWS rather than falling back. A typo'd posture that + * quietly resolved to `single` would silently remove the organization wall — + * the deployment-layer form of the "declared but unenforced" defect ADR-0049 + * forbids, and the same reasoning behind ADR-0093 D5's refusal to boot into + * undeclared degradation. + * + * This resolves what the operator ASKED FOR. Whether the posture is actually + * enforced is the `tenancy` service's answer (`isolationActive` / `degraded`). + */ +export function resolveTenancyPosture(): TenancyPosture { + // Read through `globalThis` like `readEnvWithDeprecation` does — this package + // targets non-Node runtimes too, where a bare `process` reference throws. + const raw = (globalThis as { process?: { env?: Record } }) + .process?.env?.OS_TENANCY_POSTURE; + if (raw != null && String(raw).trim() !== '') { + const posture = normalizeTenancyPosture(raw); + if (!posture) { + throw new Error( + `Invalid OS_TENANCY_POSTURE=${JSON.stringify(String(raw))}. ` + + `Expected one of: ${TENANCY_POSTURES.join(', ')} (or the legacy alias 'multi' = 'isolated'). ` + + 'Refusing to boot rather than silently falling back to a posture with no organization wall.', + ); + } + return posture; + } + return resolveMultiOrgEnabled() ? 'isolated' : 'single'; +} + /** * Escape hatch for the degraded-tenancy boot guard (ADR-0093 D5). *