diff --git a/.changeset/remove-tenancy-strategy-crosstenant.md b/.changeset/remove-tenancy-strategy-crosstenant.md new file mode 100644 index 0000000000..1551283553 --- /dev/null +++ b/.changeset/remove-tenancy-strategy-crosstenant.md @@ -0,0 +1,37 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: remove `tenancy.strategy` + `tenancy.crossTenantAccess`; tenancy block is now strict (#2763) + +> ⚠️ RELEASE NOTE — breaking by strict semver, shipped as `minor` per the +> launch-window policy (owner decision on PR #2962): the fields had zero +> consumers, behavior is unchanged, and the parse error carries the +> migration. Fold into the v15 release page's "What's new in 15.x" section +> when versioning. + +BREAKING CHANGE: `TenancyConfigSchema` drops its two zero-consumer fields, and +the `tenancy` block is now `.strict()` — an unknown key is a loud parse error +with tombstone guidance instead of a silent zod strip (#1535; precedent +ADR-0056 D8 "compliance-grade config must never merely look live", ADR-0049 +enforce-or-remove). + +The platform has exactly two tenancy modes, and neither needs object-level +strategy config: database-per-tenant isolation is an environment/deployment +choice (each environment carries its own database URL), and shared-database +row isolation is `tenancy.enabled` + `tenancy.tenantField` (both stay, both +live: sql-driver row scoping, security-plugin org scoping). Cross-tenant +visibility is governed by sharing rules / OWD (ADR-0056), +`externalSharingModel` (ADR-0090 D11), and the object access posture — never +by a blanket boolean. + +Migration (delete the keys; nothing read them, so behavior is unchanged): + +- FROM `tenancy: { enabled: false, strategy: 'shared' }` → TO `tenancy: { enabled: false }` +- FROM `tenancy: { enabled: true, strategy: '...', tenantField: 'x', crossTenantAccess: false }` → TO `tenancy: { enabled: true, tenantField: 'x' }` +- Wanted per-tenant databases? Deploy per environment (EnvironmentKernelFactory) — not object metadata. +- Wanted cross-tenant visibility? Use sharing rules / OWD or `externalSharingModel`. + +The compile-time authorWarn for these fields (#2750) and their liveness-ledger +entries are retired with the removal; the schema itself now carries the +prescription. diff --git a/content/docs/data-modeling/objects.mdx b/content/docs/data-modeling/objects.mdx index 1080552db0..a4fe98b9b2 100644 --- a/content/docs/data-modeling/objects.mdx +++ b/content/docs/data-modeling/objects.mdx @@ -108,12 +108,15 @@ enable: { #### Multi-Tenancy +Row-level tenant isolation in a shared database — the tenant field is injected +on write and enforced on read. Database-per-tenant isolation is an +environment/deployment choice (each environment carries its own database URL), +not object metadata. + ```typescript tenancy: { enabled: true, - strategy: 'shared', // 'shared' | 'isolated' | 'hybrid' tenantField: 'tenant_id', - crossTenantAccess: false, } ``` diff --git a/content/docs/protocol/objectql/schema.mdx b/content/docs/protocol/objectql/schema.mdx index 5e06603ae4..75e6419a45 100644 --- a/content/docs/protocol/objectql/schema.mdx +++ b/content/docs/protocol/objectql/schema.mdx @@ -642,8 +642,7 @@ Isolate data by tenant: ```yaml name: customer tenancy: - enabled: true # Automatically filter by the tenant field - strategy: shared # shared (row-level) | isolated (DB per tenant) | hybrid + enabled: true # Automatically filter by the tenant field (row-level isolation) tenantField: tenant_id fields: tenant_id: diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index fe02ea184b..715b35ab26 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -14,8 +14,8 @@ API Operations Enum ## TypeScript Usage ```typescript -import { ApiMethod, Index, ObjectAccessConfig, ObjectCapabilities, ObjectExternalBinding, ObjectFieldGroup, ObjectOwnershipEnum, ObjectRequiredPermissions, PerOperationRequiredPermissions, SoftDeleteConfig, TenancyConfig, VersioningConfig } from '@objectstack/spec/data'; -import type { ApiMethod, Index, ObjectAccessConfig, ObjectCapabilities, ObjectExternalBinding, ObjectFieldGroup, ObjectOwnershipEnum, ObjectRequiredPermissions, PerOperationRequiredPermissions, SoftDeleteConfig, TenancyConfig, VersioningConfig } from '@objectstack/spec/data'; +import { ApiMethod, Index, Lifecycle, LifecycleClass, ObjectAccessConfig, ObjectCapabilities, ObjectExternalBinding, ObjectFieldGroup, ObjectOwnershipEnum, ObjectRequiredPermissions, PerOperationRequiredPermissions, SoftDeleteConfig, TenancyConfig, VersioningConfig } from '@objectstack/spec/data'; +import type { ApiMethod, Index, Lifecycle, LifecycleClass, ObjectAccessConfig, ObjectCapabilities, ObjectExternalBinding, ObjectFieldGroup, ObjectOwnershipEnum, ObjectRequiredPermissions, PerOperationRequiredPermissions, SoftDeleteConfig, TenancyConfig, VersioningConfig } from '@objectstack/spec/data'; // Validate data const result = ApiMethod.parse(data); @@ -58,6 +58,35 @@ const result = ApiMethod.parse(data); | **partial** | `string` | optional | Partial index condition (SQL WHERE clause for conditional indexes) | +--- + +## Lifecycle + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **class** | `Enum<'record' \| 'audit' \| 'telemetry' \| 'transient' \| 'event'>` | ✅ | Persistence contract: record (business truth, permanent) | audit (compliance ledger) | telemetry (high-freq log) | transient (ephemeral state) | event (bus messages). | +| **retention** | `Object` | optional | Age-based retention window enforced by the LifecycleService Reaper. | +| **ttl** | `Object` | optional | Per-row TTL auto-expiry (transient/event classes). | +| **storage** | `Object` | optional | Physical storage strategy for high-frequency telemetry (LifecycleService Rotator). | +| **archive** | `Object` | optional | Cold-store archival (LifecycleService Archiver) — audit-class hot→cold hand-off. | +| **reclaim** | `boolean` | optional | Run driver space reclamation (SQLite incremental_vacuum) after sweeping this object. Default true for non-record classes. | + + +--- + +## LifecycleClass + +### Allowed Values + +* `record` +* `audit` +* `telemetry` +* `transient` +* `event` + + --- ## ObjectAccessConfig @@ -199,9 +228,7 @@ Type: `string[]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | ✅ | Enable multi-tenancy for this object | -| **strategy** | `Enum<'shared' \| 'isolated' \| 'hybrid'>` | ✅ | Tenant isolation strategy: shared (single DB, row-level), isolated (separate DB per tenant), hybrid (mix) | | **tenantField** | `string` | ✅ | Field name for tenant identifier | -| **crossTenantAccess** | `boolean` | ✅ | Allow cross-tenant data access (with explicit permission) | --- diff --git a/content/docs/references/identity/position.mdx b/content/docs/references/identity/position.mdx index e25def0720..71bc6d0190 100644 --- a/content/docs/references/identity/position.mdx +++ b/content/docs/references/identity/position.mdx @@ -84,6 +84,7 @@ const result = Position.parse(data); | **name** | `string` | ✅ | Unique position name (lowercase snake_case) | | **label** | `string` | ✅ | Display label (e.g. VP of Sales) | | **description** | `string` | optional | | +| **delegatable** | `boolean` | ✅ | ADR-0091 D3: holders may self-service delegate this position, time-boxed (default false). | --- diff --git a/content/docs/references/kernel/execution-context.mdx b/content/docs/references/kernel/execution-context.mdx index e30cf50ad8..62901fe4d0 100644 --- a/content/docs/references/kernel/execution-context.mdx +++ b/content/docs/references/kernel/execution-context.mdx @@ -57,6 +57,7 @@ const result = ExecutionContext.parse(data); | **positions** | `string[]` | ✅ | | | **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | | | **audience** | `Enum<'internal' \| 'external'>` | optional | | +| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. | | **onBehalfOf** | `Object` | optional | | | **permissions** | `string[]` | ✅ | | | **systemPermissions** | `string[]` | optional | | @@ -65,6 +66,7 @@ const result = ExecutionContext.parse(data); | **rlsMembership** | `Record` | optional | | | **isSystem** | `boolean` | ✅ | | | **skipTriggers** | `boolean` | optional | | +| **skipAutomations** | `boolean` | optional | | | **oauthScopes** | `string[]` | optional | | | **accessToken** | `string` | optional | | | **transaction** | `any` | optional | | diff --git a/content/docs/references/security/explain.mdx b/content/docs/references/security/explain.mdx index 966c550f19..c9a15c196f 100644 --- a/content/docs/references/security/explain.mdx +++ b/content/docs/references/security/explain.mdx @@ -23,9 +23,31 @@ enforcement, minus the throw. Layer order mirrors the runtime pipeline: -principal → required_permissions → object_crud → fls → owd_baseline → +tenant_isolation → principal → required_permissions → object_crud → fls → -depth → sharing → vama_bypass → rls. +owd_baseline → depth → sharing → vama_bypass → rls. + +[C2 / ADR-0095] Record-grained explanation. The contract carries an optional + +`recordId` on the request and, when present, a per-layer `record` attribution + +plus a top-level `record` verdict on the response — so the sharing / rls / owd + +layers can report the ROW-LEVEL story for one concrete record (which share + +admitted it, which filter excluded it, whether the effective row filter + +matches). Object-level requests (no `recordId`) stay byte-compatible. + +[ADR-0095 D1/D2] The contract also reserves the kernel-chain vocabulary the + +β engine + UI will fill: the always-first tenant wall as `tenant_isolation` + +(Layer 0), a per-layer `kernelTier` marking Layer 0 vs. business RLS + +(Layer 1), and the monotonic posture ladder + +(PLATFORM_ADMIN > TENANT_ADMIN > MEMBER > EXTERNAL) on the resolved principal. **Source:** `packages/spec/src/security/explain.zod.ts` @@ -34,8 +56,8 @@ depth → sharing → vama_bypass → rls. ## TypeScript Usage ```typescript -import { AccessMatrix, AccessMatrixEntry, ExplainDecision, ExplainLayer, ExplainOperation, ExplainRequest } from '@objectstack/spec/security'; -import type { AccessMatrix, AccessMatrixEntry, ExplainDecision, ExplainLayer, ExplainOperation, ExplainRequest } from '@objectstack/spec/security'; +import { AccessMatrix, AccessMatrixEntry, AuthzPosture, ExplainDecision, ExplainLayer, ExplainMatchedRule, ExplainOperation, ExplainRecordAttribution, ExplainRequest } from '@objectstack/spec/security'; +import type { AccessMatrix, AccessMatrixEntry, AuthzPosture, ExplainDecision, ExplainLayer, ExplainMatchedRule, ExplainOperation, ExplainRecordAttribution, ExplainRequest } from '@objectstack/spec/security'; // Validate data const result = AccessMatrix.parse(data); @@ -74,6 +96,20 @@ const result = AccessMatrix.parse(data); | **sharingModel** | `string` | optional | | +--- + +## AuthzPosture + +ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. + +### Allowed Values + +* `PLATFORM_ADMIN` +* `TENANT_ADMIN` +* `MEMBER` +* `EXTERNAL` + + --- ## ExplainDecision @@ -88,6 +124,7 @@ const result = AccessMatrix.parse(data); | **principal** | `Object` | ✅ | | | **layers** | `Object[]` | ✅ | | | **readFilter** | `any` | optional | | +| **record** | `Object` | optional | Row-level verdict for the specific record; set only for record-grained requests. | --- @@ -98,10 +135,28 @@ const result = AccessMatrix.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **layer** | `Enum<'principal' \| 'required_permissions' \| 'object_crud' \| 'fls' \| 'owd_baseline' \| 'depth' \| 'sharing' \| 'vama_bypass' \| 'rls'>` | ✅ | | +| **layer** | `Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| 'fls' \| 'owd_baseline' \| 'depth' \| 'sharing' \| 'vama_bypass' \| 'rls'>` | ✅ | | +| **kernelTier** | `Enum<'layer_0_tenant' \| 'layer_1_business'>` | optional | ADR-0095 kernel layer: layer_0_tenant = the always-first org wall; layer_1_business = business RLS/sharing/ownership. | | **verdict** | `Enum<'grants' \| 'denies' \| 'narrows' \| 'widens' \| 'neutral' \| 'not_applicable'>` | ✅ | | | **detail** | `string` | ✅ | | | **contributors** | `Object[]` | ✅ | | +| **record** | `Object` | optional | Row-level determination for the specific record under explanation; set only for record-grained requests. | + + +--- + +## ExplainMatchedRule + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **kind** | `Enum<'tenant_filter' \| 'owd_baseline' \| 'ownership' \| 'record_share' \| 'sharing_rule' \| 'team' \| 'territory' \| 'rls_policy'>` | ✅ | The row-visibility source kind evaluated for this record at this layer. | +| **name** | `string` | ✅ | Stable identifier of the concrete rule, share, or policy that was evaluated. | +| **grants** | `Enum<'read' \| 'edit' \| 'full'>` | optional | Access level a sharing source grants on the record (mirrors SharingLevel). | +| **via** | `string` | optional | How the rule reached the principal — recipient group/position, ownership, or the matching criteria. | +| **predicate** | `any` | optional | The row predicate this rule contributed, when it is filter-shaped (null = unrestricted). | +| **effect** | `Enum<'admits' \| 'excludes' \| 'neutral'>` | ✅ | The rule's effect on THIS record: admits, excludes, or neutral. | --- @@ -119,6 +174,21 @@ const result = AccessMatrix.parse(data); * `purge` +--- + +## ExplainRecordAttribution + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **outcome** | `Enum<'admitted' \| 'excluded' \| 'not_evaluated'>` | ✅ | This layer's row-level outcome for the record: admitted, excluded, or not_evaluated (skipped/not row-scoped). | +| **rowFilter** | `any` | optional | The effective row predicate this layer contributed for the record set (null = unrestricted, __deny_all__ = zero rows). | +| **matchesRecord** | `boolean` | optional | Whether the specific record satisfies rowFilter — the judgement behind outcome. | +| **rules** | `Object[]` | ✅ | Concrete rules, shares, or policies this layer evaluated against the record, in evaluation order. | +| **detail** | `string` | optional | Human-readable, record-specific explanation of this layer's outcome. | + + --- ## ExplainRequest @@ -129,6 +199,7 @@ const result = AccessMatrix.parse(data); | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | | | **operation** | `Enum<'read' \| 'create' \| 'update' \| 'delete' \| 'transfer' \| 'restore' \| 'purge'>` | ✅ | | +| **recordId** | `string` | optional | Optional id of one concrete record to explain at row granularity; omitted = object-level (pre-C2) request. | | **userId** | `string` | optional | | diff --git a/content/docs/references/security/meta.json b/content/docs/references/security/meta.json index 9d9dcff0cd..c644a9963b 100644 --- a/content/docs/references/security/meta.json +++ b/content/docs/references/security/meta.json @@ -2,6 +2,7 @@ "title": "Security Protocol", "pages": [ "explain", + "misc", "permission", "rls", "sharing", diff --git a/content/docs/references/security/misc.mdx b/content/docs/references/security/misc.mdx new file mode 100644 index 0000000000..b5f2e957ee --- /dev/null +++ b/content/docs/references/security/misc.mdx @@ -0,0 +1,38 @@ +--- +title: Misc +description: Misc protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + + +**Source:** `packages/spec/src/security/misc.zod.ts` + + +## TypeScript Usage + +```typescript +import { CapabilityDeclaration } from '@objectstack/spec/security'; +import type { CapabilityDeclaration } from '@objectstack/spec/security'; + +// Validate data +const result = CapabilityDeclaration.parse(data); +``` + +--- + +## CapabilityDeclaration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Stable capability key referenced by systemPermissions / requiredPermissions | +| **label** | `string` | optional | Human label shown in Setup | +| **description** | `string` | optional | What holding this capability permits | +| **scope** | `Enum<'platform' \| 'org'>` | ✅ | platform = a platform-wide power; org = scoped to an organization | +| **packageId** | `string` | optional | [ADR-0086 D3] Owning package id (author-declared fallback; absent = registry-stamped) | + + +--- + diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 86d676c680..a4dc70fb65 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -48,8 +48,8 @@ to the field name and is used as the request-body key). ## TypeScript Usage ```typescript -import { ActionAi, ActionLocation, ActionParam, ActionType } from '@objectstack/spec/ui'; -import type { ActionAi, ActionLocation, ActionParam, ActionType } from '@objectstack/spec/ui'; +import { ActionAi, ActionLocation, ActionType } from '@objectstack/spec/ui'; +import type { ActionAi, ActionLocation, ActionType } from '@objectstack/spec/ui'; // Validate data const result = ActionAi.parse(data); @@ -86,27 +86,6 @@ const result = ActionAi.parse(data); * `global_nav` ---- - -## ActionParam - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | optional | | -| **field** | `string` | optional | Snake case identifier (lowercase with underscores only) | -| **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) | -| **label** | `string` | optional | Display label (plain string; i18n keys are auto-generated by the framework) | -| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>` | optional | | -| **required** | `boolean` | ✅ | | -| **options** | `Object[]` | optional | | -| **placeholder** | `string` | optional | | -| **helpText** | `string` | optional | | -| **defaultValue** | `any` | optional | | -| **defaultFromRow** | `boolean` | optional | | - - --- ## ActionType diff --git a/packages/cli/src/utils/lint-liveness-properties.test.ts b/packages/cli/src/utils/lint-liveness-properties.test.ts index 6c25d67048..33da4f5d8d 100644 --- a/packages/cli/src/utils/lint-liveness-properties.test.ts +++ b/packages/cli/src/utils/lint-liveness-properties.test.ts @@ -117,11 +117,12 @@ describe('lintLivenessProperties', () => { expect(paths(findings).some((m) => m.includes('`timeout`'))).toBe(true); }); - it('warns on the security-shaped dead props (tenancy.strategy / tool.permissions / permission.contextVariables)', () => { - const tenancy = lintLivenessProperties(objStack({ tenancy: { enabled: true, strategy: 'isolated' } })); - expect(paths(tenancy).some((m) => m.includes('tenancy.strategy'))).toBe(true); - // tenancy.enabled itself is live — must NOT warn. - expect(paths(tenancy).some((m) => m.includes('tenancy.enabled'))).toBe(false); + it('warns on the security-shaped dead props (tool.permissions / permission.contextVariables)', () => { + // 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. + const tenancy = lintLivenessProperties(objStack({ tenancy: { enabled: true, tenantField: 'org_id' } })); + expect(paths(tenancy).some((m) => m.includes('tenancy'))).toBe(false); const tool = lintLivenessProperties({ tools: [{ name: 't1', permissions: ['crm.admin'] }] }); expect(paths(tool).some((m) => m.includes('`permissions`'))).toBe(true); diff --git a/packages/platform-objects/src/identity/sys-sso-provider.object.ts b/packages/platform-objects/src/identity/sys-sso-provider.object.ts index c51446cc3e..4f90ee0536 100644 --- a/packages/platform-objects/src/identity/sys-sso-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-sso-provider.object.ts @@ -45,7 +45,7 @@ export const SysSsoProvider = ObjectSchema.create({ // Together: admins see all env providers; non-admins get 403. better-auth's // own endpoints already read via a system context. (Env-only object — no // control-plane cross-tenant risk.) - tenancy: { enabled: false, strategy: 'shared' }, + tenancy: { enabled: false }, requiredPermissions: ['manage_platform_settings'], // ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema. protection: { diff --git a/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts b/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts index 23816c55a3..8fdb0be751 100644 --- a/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts @@ -231,7 +231,7 @@ describe('SqlDriver tenant scope (organization_id)', () => { { name: 'workspace_item', // Custom tenant column name — not the conventional organization_id. - tenancy: { enabled: true, strategy: 'shared', tenantField: 'workspace_id', crossTenantAccess: false }, + tenancy: { enabled: true, tenantField: 'workspace_id' }, fields: { workspace_id: { type: 'string' }, name: { type: 'string' }, @@ -257,7 +257,7 @@ describe('SqlDriver tenant scope (organization_id)', () => { const platformGlobal = [ { name: 'sys_license', - tenancy: { enabled: false, strategy: 'shared' }, + tenancy: { enabled: false }, fields: { customer: { type: 'string' }, organization_id: { type: 'string' }, // optional owner FK, may be NULL diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts index 19a6aef4ec..f5942ab86e 100644 --- a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts +++ b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts @@ -192,7 +192,7 @@ describe('SqliteWasmDriver tenant scope (organization_id)', () => { { name: 'workspace_item', // Custom tenant column name — not the conventional organization_id. - tenancy: { enabled: true, strategy: 'shared', tenantField: 'workspace_id', crossTenantAccess: false }, + tenancy: { enabled: true, tenantField: 'workspace_id' }, fields: { workspace_id: { type: 'string' }, name: { type: 'string' }, diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 1aae460437..4dc06be488 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -224,7 +224,7 @@ describe('SecurityPlugin', () => { permissionSets: [tenantPolicySet], // Catalog table without organization_id; opts out of tenancy. objectFields: ['id', 'manifest_id', 'visibility', 'owner_org_id'], - schemaExtra: { tenancy: { enabled: false, strategy: 'shared' } }, + schemaExtra: { tenancy: { enabled: false } }, orgScoping: true, }); await plugin.init(harness.ctx); @@ -735,7 +735,7 @@ describe('SecurityPlugin', () => { const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet], objectFields: ['id', 'name'], - schemaExtra: { tenancy: { enabled: false, strategy: 'shared' } }, + schemaExtra: { tenancy: { enabled: false } }, orgScoping: true, }); await plugin.init(harness.ctx); diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index aa9ccaeecd..15414d4468 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -189,13 +189,13 @@ EOF | Type | live | exp | dead | planned | Notes | |---|---|---|---|---|---| -| object | 36 | – | 14 | – | versioning/partitioning/cdc tier dead; ObjectCapabilities fully live post-#2707/#2727; `tenancy.strategy`/`crossTenantAccess` inert (only `enabled`+`tenantField` read) | -| field | 54 | – | 6 | – | near-healthy; dead = referenceFilters/columnName/index/vectorConfig/fileAttachmentConfig/dependencies, all authorWarn'd | +| object | 37 | – | 12 | 1 | versioning/partitioning/cdc tier dead; ObjectCapabilities fully live post-#2707/#2727; `tenancy.strategy`/`crossTenantAccess` REMOVED post-15.0 (#2763) — tenancy block is now strict with tombstone guidance | +| field | 55 | – | 6 | – | near-healthy; dead = referenceFilters/columnName/index/vectorConfig/fileAttachmentConfig/dependencies, all authorWarn'd | | flow | 27 | – | 4 | – | dead = description/template/nodes.outputSchema/errorHandling.fallbackNodeId (engine uses fault edges) | | action | 34 | 1 | 1 | – | `disabled` went LIVE via metadata-admin authoring UI (2026-06 audit missed objectui); only `timeout` dead | | hook | 11 | – | 2 | – | model-healthy; only label/description dead (benign) | | permission | 32 | – | 1 | – | CRUD/FLS/RLS live; `contextVariables` dead (RLS uses current_user.* built-ins only) | -| position | 3 | – | – | – | (role's ADR-0090 successor) fully live | +| position | 4 | – | – | – | (role's ADR-0090 successor) fully live | | agent | 14 | 5 | 1 | – | `tenantId` dead (tenancy comes from request context); autonomy tier experimental | | tool | 9 | 1 | 1 | – | `permissions` dead — tool invocation not permission-gated by it | | skill | 10 | – | – | – | fully live | diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index e0f39d7321..7cf4c284de 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -132,24 +132,10 @@ "status": "live", "evidence": "packages/plugins/driver-sql/src/sql-driver.ts:1081" }, - "strategy": { - "status": "dead", - "evidence": "inert — only tenancy.enabled is read", - "note": "REMOVAL DECIDED (#2763, owner 2026-07-10): the platform has exactly two tenancy modes — per-tenant database (environment-level, zero object config) and shared-DB org row isolation (enabled+tenantField). Object-level isolation strategy has no requirement; remove at the next spec major.", - "authorWarn": true, - "authorHint": "No requirement — removal decided (#2763). Library-level isolation is an environment/deployment choice; row-level isolation is tenancy.enabled + tenantField. This knob changes nothing." - }, "tenantField": { "status": "live", "evidence": "packages/plugins/driver-sql/src/sql-driver.ts", - "note": "row-level tenant scoping (org-scoping plugin path) reads tenancy.tenantField — audit called it inert; corrected." - }, - "crossTenantAccess": { - "status": "dead", - "evidence": "inert", - "note": "REMOVAL DECIDED (#2763, owner 2026-07-10): cross-visibility governance lives in sharing/OWD (ADR-0056), externalSharingModel (ADR-0090 D11) and access posture; a blanket boolean has no requirement. Remove at the next spec major.", - "authorWarn": true, - "authorHint": "No requirement — removal decided (#2763). Setting it true grants nothing; use sharing rules / externalSharingModel for cross-visibility." + "note": "row-level tenant scoping (org-scoping plugin path) reads tenancy.tenantField — audit called it inert; corrected. strategy/crossTenantAccess were REMOVED after spec 15.0 (#2763): zero consumers; tenancy block is now .strict() with tombstone guidance." } } }, diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 4314ade059..a5aa8d4fba 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, LifecycleSchema, type ServiceObject } from './object.zod'; +import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, LifecycleSchema, TenancyConfigSchema, type ServiceObject } from './object.zod'; describe('ObjectCapabilities', () => { it('should apply default values correctly', () => { @@ -1279,7 +1279,7 @@ describe('ADR-0066 — object access posture (D2) + requiredPermissions (D3)', ( it('round-trips access + requiredPermissions on an object', () => { const obj = ObjectSchema.create({ name: 'sys_license', - tenancy: { enabled: false, strategy: 'shared' }, + tenancy: { enabled: false }, access: { default: 'private' }, requiredPermissions: ['manage_licenses'], fields: { signed_token: { type: 'text' } }, @@ -1297,3 +1297,48 @@ describe('ADR-0066 — object access posture (D2) + requiredPermissions (D3)', ( expect(obj.requiredPermissions).toBeUndefined(); }); }); + +describe('TenancyConfigSchema — #2763 strategy/crossTenantAccess removal', () => { + it('accepts the two live knobs and applies the tenantField default', () => { + const result = TenancyConfigSchema.parse({ enabled: true }); + expect(result.enabled).toBe(true); + expect(result.tenantField).toBe('tenant_id'); + expect(TenancyConfigSchema.parse({ enabled: false, tenantField: 'workspace_id' })) + .toEqual({ enabled: false, tenantField: 'workspace_id' }); + }); + + it('rejects the retired `strategy` with a tombstone pointing at the two real modes', () => { + const result = TenancyConfigSchema.safeParse({ enabled: true, strategy: 'isolated' }); + expect(result.success).toBe(false); + const message = result.error!.issues.map((i) => i.message).join('\n'); + expect(message).toContain('removed from @objectstack/spec after v15.0 (#2763)'); + expect(message).toContain('environment/deployment'); + expect(message).toContain('`tenancy.enabled` + `tenancy.tenantField`'); + }); + + it('rejects the retired `crossTenantAccess` with a tombstone pointing at sharing/OWD', () => { + const result = TenancyConfigSchema.safeParse({ enabled: true, crossTenantAccess: true }); + expect(result.success).toBe(false); + const message = result.error!.issues.map((i) => i.message).join('\n'); + expect(message).toContain('crossTenantAccess'); + expect(message).toContain('ADR-0056'); + expect(message).toContain('externalSharingModel'); + }); + + it('rejects arbitrary unknown tenancy keys instead of silently stripping them (#1535)', () => { + const result = TenancyConfigSchema.safeParse({ enabled: true, tenantfield: 'org_id' }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.message).join('\n')) + .toContain('`tenantfield` is not a `tenancy` key'); + }); + + it('rejects a retired key on ObjectSchema.create() (the authoring entrypoint)', () => { + expect(() => + ObjectSchema.create({ + name: 'sys_license', + tenancy: { enabled: false, strategy: 'shared' } as never, + fields: { name: { type: 'text' } }, + }), + ).toThrow(/removed from @objectstack\/spec after v15\.0/); + }); +}); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 4dc770f32d..386beb97af 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -153,24 +153,71 @@ export const SearchConfigSchema = lazySchema(() => z.object({ filters: z.array(z.string()).optional().describe('Default filters for search results'), })); +/** + * Tombstones for RETIRED tenancy keys — same doctrine as the top-level + * `UNKNOWN_KEY_GUIDANCE` map below: a retired key's rejection must carry the + * upgrade prescription, because the parse error is the one channel every + * consumer bumping `@objectstack/spec` is guaranteed to hit. Removed after + * spec 15.0 by owner decision #2763 (enforce-or-remove, ADR-0049; precedent + * ADR-0056 D8 — compliance-grade config must never merely look live). + */ +const TENANCY_RETIRED_KEY_GUIDANCE: Record = { + strategy: + '`tenancy.strategy` was removed from @objectstack/spec after v15.0 (#2763) — it ' + + 'never had a consumer. The platform has exactly two tenancy modes and neither is ' + + 'object-level config: database-per-tenant isolation is an environment/deployment ' + + 'choice (each environment carries its own database URL), and row-level isolation ' + + 'is `tenancy.enabled` + `tenancy.tenantField`. Delete the key.', + crossTenantAccess: + '`tenancy.crossTenantAccess` was removed from @objectstack/spec after v15.0 (#2763) — it ' + + 'never had a consumer; setting it granted nothing. Cross-tenant visibility is ' + + 'governed by sharing rules / OWD (ADR-0056), `externalSharingModel` (ADR-0090 ' + + 'D11), and the object access posture. Delete the key.', +}; + +/** + * Custom zod `error` for the `.strict()` tenancy block (#2763, pattern of + * `strictVisibilityError` / ADR-0089 D3a): an unknown key — a retired + * `strategy`/`crossTenantAccess` or a typo — is a loud, *fixable* parse error + * instead of a silent strip (#1535), and a retired key's error carries its + * upgrade prescription. Every other issue code defers to zod's default. + */ +const strictTenancyError: z.core.$ZodErrorMap = (issue) => { + if (issue.code !== 'unrecognized_keys') return undefined; + const keys = (issue as { keys?: readonly string[] }).keys ?? []; + const lines = keys.map((key) => + TENANCY_RETIRED_KEY_GUIDANCE[key] ?? `\`${key}\` is not a \`tenancy\` key.`, + ); + return ( + `Unrecognized key(s) on \`tenancy\`: ${keys.map((k) => `\`${k}\``).join(', ')}. ` + + 'The two supported tenancy modes are: database-per-tenant = environment-level ' + + 'deployment (no object config); row-level isolation = `tenancy.enabled` + ' + + '`tenancy.tenantField`.\n' + + lines.map((l) => ` • ${l}`).join('\n') + ); +}; + /** * Multi-Tenancy Configuration Schema - * Configures tenant isolation strategy for SaaS applications - * - * @example Shared database with tenant_id isolation + * Row-level tenant isolation for shared-database SaaS applications: the + * tenant field is injected on write and enforced on read (RLS predicate). + * Platform objects declare `enabled: false` to opt out of org row-scoping + * (environment-level objects). Database-per-tenant isolation is NOT object + * metadata — it is an environment/deployment choice. + * + * `.strict()`: unknown keys (incl. the retired `strategy` / + * `crossTenantAccess`, #2763) are rejected with guidance, not stripped (#1535). + * + * @example Shared database with tenant_id row isolation * { * enabled: true, - * strategy: 'shared', - * tenantField: 'tenant_id', - * crossTenantAccess: false + * tenantField: 'tenant_id' * } */ export const TenancyConfigSchema = lazySchema(() => z.object({ enabled: z.boolean().describe('Enable multi-tenancy for this object'), - strategy: z.enum(['shared', 'isolated', 'hybrid']).describe('Tenant isolation strategy: shared (single DB, row-level), isolated (separate DB per tenant), hybrid (mix)'), tenantField: z.string().default('tenant_id').describe('Field name for tenant identifier'), - crossTenantAccess: z.boolean().default(false).describe('Allow cross-tenant data access (with explicit permission)'), -})); +}, { error: strictTenancyError }).strict()); /** * [ADR-0066 D2] Secure-by-default object posture. diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index 7eea84aff3..8517c62aaf 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -615,7 +615,7 @@ OAuth clients). These hit a non-obvious interaction: **Recipe — env-global, admin-only object that admins can fully see:** ```typescript -tenancy: { enabled: false, strategy: 'shared' }, // env IS the tenant; admin viewAllRecords bypass applies +tenancy: { enabled: false }, // env IS the tenant; admin viewAllRecords bypass applies requiredPermissions: ['manage_platform_settings'], // object-level gate → members get 403 ```