diff --git a/.changeset/24338-object-default-row-scope.md b/.changeset/24338-object-default-row-scope.md new file mode 100644 index 00000000000..be0c78014dc --- /dev/null +++ b/.changeset/24338-object-default-row-scope.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-core': patch +--- + +feat(spec): an object can declare its default row scope, `defaultRowScope`, the row-level security policy shape without `object` + +Clause-②: yes (widening) + +`ObjectSchema` gains one optional key, `defaultRowScope`: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object`, which the declaring object implies. The shape is exported as `DefaultRowScopeSchema` from `@objectstack/spec/security` (types `DefaultRowScope` and `DefaultRowScopeParsed`). + +- **What it parses.** Every member of `RowLevelSecurityPolicySchema` except `object`, the same member schemas: `name`, `label`, `description`, `operation`, `using`, `check`, `positions` and `enabled` (default `true`). The policy's two rules hold as written: at least one of `using` / `check`, and no `check` on a `select` or `delete` policy. +- **What it refuses.** `object` is refused by name, with a prescription to delete it: the target is implied. Any other unknown key is refused too, the policy's retired `priority` and `tags` included. +- **What it does today: nothing.** No server reads the key in this release, so declaring it filters no rows. The describe states the contract the security plugin's row-policy collection is to meet once it reads the key: the default is to apply when no permission set the caller holds carries a row policy for the object; a policy an authored set carries is to be used as authored; a superuser-bypass principal is to be unaffected; an object without the key keeps its behaviour. No object declares the key yet; the platform's identity objects are to declare it for their own tables. Authoring the key raises the liveness lint's `liveness-planned-property` warning until then. Restrict rows with a permission set's `rowLevelSecurity` policy in the meantime. +- **`composeStacks` with `objectConflict: 'merge'`** now refuses two stacks that declare a different `defaultRowScope` on the same object, the way it refuses every other fixed-shape config object (`access`, `enable`, `publicSharing`, …). The refusal message's list of those objects names `defaultRowScope` too. Nothing composed before is affected, because no stack declared the key. +- **`@objectstack/metadata-core`: the field-level-security projection classifies the new key.** When an object document is served to a caller who cannot read some of its fields, `defaultRowScope` is treated like a validation rule: the whole policy is dropped from the served document when its `using`, `check` or prose names a field that caller cannot read, and served unchanged otherwise. No behaviour moves until an object declares the key, and no object declares it yet. +- Additive: every object that parsed before still parses, and its parsed output is unchanged. diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 73c1d7986ba..30c8a1efa91 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -969,6 +969,7 @@ Metadata query with filtering, sorting, and pagination | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | +| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The contract the security plugin's row-policy collection, the one step every read, write and explain passes through, is to meet once it reads the key: when no permission set the caller holds carries a row policy for this object, this policy is to apply; a policy an authored set carries for the object is to be used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is to be unaffected; an object that declares none keeps the behaviour it has without the key. The platform's identity objects are to declare it for their own tables. Not applied yet: no server reads this key in this release, so declaring it filters no rows. Until the collection applies it, restrict rows with a permission set's `rowLevelSecurity` policy. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 61038958a89..1dd558fa6f7 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -173,6 +173,7 @@ const result = ApiMethod.parse(data); | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | +| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The contract the security plugin's row-policy collection, the one step every read, write and explain passes through, is to meet once it reads the key: when no permission set the caller holds carries a row policy for this object, this policy is to apply; a policy an authored set carries for the object is to be used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is to be unaffected; an object that declares none keeps the behaviour it has without the key. The platform's identity objects are to declare it for their own tables. Not applied yet: no server reads this key in this release, so declaring it filters no rows. Until the collection applies it, restrict rows with a permission set's `rowLevelSecurity` policy. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | @@ -434,6 +435,19 @@ const result = ApiMethod.parse(data); | **clone** | `boolean` | optional (default: `true`) | Allow record deep cloning | | **approvalsVisibleToReaders** | `boolean` | optional (default: `false`) | Opt-in: true lets a caller who can read a record of this object see that record's approval requests and full action history (comments and attachments included), read-only, on the approvals API and the generic data API alike, on a read that names the record. No approval action is offered. Default false: only the request's participants (submitter, approvers, past actors) and administrators see it | +### Nested Shape: `Object.defaultRowScope` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Policy unique identifier (snake_case) | +| **label** | `string` | optional | Human-readable policy label | +| **description** | `string` | optional | Policy description and business justification | +| **operation** | `Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>` | ✅ | Database operation this policy applies to | +| **using** | `string` | optional | Row predicate, authored in canonical CEL (ADR-0058 D1): the rows a `select` policy lets a caller read, and the existing rows an `update` or `delete` policy lets a caller change or remove. On an insert or an update, when no applicable policy for that operation declares `check`, each applicable policy's `using` also stands in as its check on every row written (see `check`); on an `insert` policy that is its only effect. It enforces when the predicate lowers to an ObjectQL filter: a field compared against a literal or a `current_user.*` context value using `==`, `!=`, `<`, `<=`, `>` or `>=`; `in` against a `current_user.*` array or an inline literal list (e.g. status in ['draft', 'pending']); these combined with `&&` / `\|\|`; or the bare allow-all `true`. Anything that does not lower fails closed — the policy matches zero rows. The legacy SQL-ish spellings are still accepted through a transitional bridge that rewrites `=` to `==` and `IN` to `in` (deprecated under ADR-0058 D1); SQL `AND` / `OR` / `NOT IN` / `IS NULL` / `LIKE` are NOT bridged and fail closed. Needed on a `select` or `delete` policy (a `check` there is refused); optional on an `insert`, `update` or `all` policy that declares `check`. | +| **check** | `string` | optional | Validation condition judged on every row an insert or an update writes (enforced at application level): each row of an insert, an array insert included, as its `beforeInsert` hooks leave it, and each row an update changes, by id or `multi: true`, as the prior row merged with the final payload after its `beforeUpdate` hooks. One failing row refuses the whole write. A by-id update is also judged, before its hooks, on the prior row merged with the change set as sent. The default to `using` is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares `check`, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares `check` does each applicable policy's `using` stand in as its check (OR-combined). Applicable = not `enabled: false`, `object` matches or is '*', `operation` matches or is 'all', and the caller holds one of its `positions` when it lists any. Refused on a policy whose `operation` is `select` or `delete`, which write no new row to check: limit the rows such a policy admits with `using`, and declare the `check` on an `insert`, `update` or `all` policy. | +| **positions** | `string[]` | optional | Positions this policy applies to (omit for all) | +| **enabled** | `boolean` | optional (default: `true`) | Whether this policy is active | + ### Nested Shape: `Object.publicSharing` | Property | Type | Required | Description | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index e418ad24497..c4b60801099 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1539 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1540 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -29,12 +29,12 @@ counts are sums of the rows they head. Regenerate with | [Kernel Protocol](/docs/references/kernel) | 30 | 161 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [Marketplace Protocol](/docs/references/marketplace) | 4 | 30 | The package & marketplace format — package identity and versions, listing, publish, review, search, install, template manifests. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | -| [Security Protocol](/docs/references/security) | 5 | 30 | Permission sets, row-level security, sharing rules, tenancy posture. | +| [Security Protocol](/docs/references/security) | 5 | 31 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 284 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 169 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **197** | **1539** | 14 protocol modules | +| **Total** | **197** | **1540** | 14 protocol modules | --- @@ -266,7 +266,7 @@ Declarative test suites — scenarios, steps, actions and assertions. ## Security Protocol -**Source:** `packages/spec/src/security/` · **Import:** `@objectstack/spec/security` · **5 pages, 30 schemas** +**Source:** `packages/spec/src/security/` · **Import:** `@objectstack/spec/security` · **5 pages, 31 schemas** Permission sets, row-level security, sharing rules, tenancy posture. @@ -275,7 +275,7 @@ Permission sets, row-level security, sharing rules, tenancy posture. | [`explain.zod.ts`](/docs/references/security/explain) | `AccessMatrix`, `AccessMatrixEntry`, `AuthzPosture`, `ExplainDecision`, `ExplainLayer`, `ExplainMatchedRule`, `ExplainOperation`, `ExplainRecordAttribution`, `ExplainRequest` | | [`misc`](/docs/references/security/misc) *(no single source file)* | `CapabilityDeclaration`, `OrgScopingEntitlement`, `PlatformGlobalObjects`, `TenancyPosture`, `TenantLayer0Verdict` | | [`permission.zod.ts`](/docs/references/security/permission) | `AdminScope`, `EffectiveObjectPermission`, `FieldPermission`, `ObjectAccessScope`, `ObjectPermission`, `PermissionSet` | -| [`rls.zod.ts`](/docs/references/security/rls) | `RLSEvaluationResult`, `RLSOperation`, `RLSUserContext`, `RowLevelSecurityPolicy` | +| [`rls.zod.ts`](/docs/references/security/rls) | `DefaultRowScope`, `RLSEvaluationResult`, `RLSOperation`, `RLSUserContext`, `RowLevelSecurityPolicy` | | [`sharing.zod.ts`](/docs/references/security/sharing) | `CriteriaSharingRule`, `OWDModel`, `ShareRecipientType`, `SharingLevel`, `SharingRule`, `SharingRuleType` | --- diff --git a/content/docs/references/security/rls.mdx b/content/docs/references/security/rls.mdx index f7e2dbaef10..afcf60e7716 100644 --- a/content/docs/references/security/rls.mdx +++ b/content/docs/references/security/rls.mdx @@ -111,13 +111,31 @@ See also: https://help.salesforce.com/s/articleView?id=sf.security_sharing_rules ## TypeScript Usage ```typescript -import { RLSEvaluationResultSchema, RLSOperation, RLSUserContextSchema, RowLevelSecurityPolicySchema } from '@objectstack/spec/security'; -import type { RLSEvaluationResult, RLSOperation, RLSUserContext, RowLevelSecurityPolicy } from '@objectstack/spec/security'; +import { DefaultRowScopeSchema, RLSEvaluationResultSchema, RLSOperation, RLSUserContextSchema, RowLevelSecurityPolicySchema } from '@objectstack/spec/security'; +import type { DefaultRowScope, RLSEvaluationResult, RLSOperation, RLSUserContext, RowLevelSecurityPolicy } from '@objectstack/spec/security'; // Validate data -const result = RLSEvaluationResultSchema.parse(data); +const result = DefaultRowScopeSchema.parse(data); ``` +--- + +## DefaultRowScope + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Policy unique identifier (snake_case) | +| **label** | `string` | optional | Human-readable policy label | +| **description** | `string` | optional | Policy description and business justification | +| **operation** | `Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>` | ✅ | Database operation this policy applies to | +| **using** | `string` | optional | Row predicate, authored in canonical CEL (ADR-0058 D1): the rows a `select` policy lets a caller read, and the existing rows an `update` or `delete` policy lets a caller change or remove. On an insert or an update, when no applicable policy for that operation declares `check`, each applicable policy's `using` also stands in as its check on every row written (see `check`); on an `insert` policy that is its only effect. It enforces when the predicate lowers to an ObjectQL filter: a field compared against a literal or a `current_user.*` context value using `==`, `!=`, `<`, `<=`, `>` or `>=`; `in` against a `current_user.*` array or an inline literal list (e.g. status in ['draft', 'pending']); these combined with `&&` / `\|\|`; or the bare allow-all `true`. Anything that does not lower fails closed — the policy matches zero rows. The legacy SQL-ish spellings are still accepted through a transitional bridge that rewrites `=` to `==` and `IN` to `in` (deprecated under ADR-0058 D1); SQL `AND` / `OR` / `NOT IN` / `IS NULL` / `LIKE` are NOT bridged and fail closed. Needed on a `select` or `delete` policy (a `check` there is refused); optional on an `insert`, `update` or `all` policy that declares `check`. | +| **check** | `string` | optional | Validation condition judged on every row an insert or an update writes (enforced at application level): each row of an insert, an array insert included, as its `beforeInsert` hooks leave it, and each row an update changes, by id or `multi: true`, as the prior row merged with the final payload after its `beforeUpdate` hooks. One failing row refuses the whole write. A by-id update is also judged, before its hooks, on the prior row merged with the change set as sent. The default to `using` is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares `check`, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares `check` does each applicable policy's `using` stand in as its check (OR-combined). Applicable = not `enabled: false`, `object` matches or is '*', `operation` matches or is 'all', and the caller holds one of its `positions` when it lists any. Refused on a policy whose `operation` is `select` or `delete`, which write no new row to check: limit the rows such a policy admits with `using`, and declare the `check` on an `insert`, `update` or `all` policy. | +| **positions** | `string[]` | optional | Positions this policy applies to (omit for all) | +| **enabled** | `boolean` | optional (default: `true`) | Whether this policy is active | + + --- ## RLSEvaluationResult diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index c143b19eb25..4ae29fd1a3d 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -355,6 +355,7 @@ Create a new object | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | +| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The contract the security plugin's row-policy collection, the one step every read, write and explain passes through, is to meet once it reads the key: when no permission set the caller holds carries a row policy for this object, this policy is to apply; a policy an authored set carries for the object is to be used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is to be unaffected; an object that declares none keeps the behaviour it has without the key. The platform's identity objects are to declare it for their own tables. Not applied yet: no server reads this key in this release, so declaring it filters no rows. Until the collection applies it, restrict rows with a permission set's `rowLevelSecurity` policy. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | @@ -648,6 +649,7 @@ Create a new object | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | +| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The contract the security plugin's row-policy collection, the one step every read, write and explain passes through, is to meet once it reads the key: when no permission set the caller holds carries a row policy for this object, this policy is to apply; a policy an authored set carries for the object is to be used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is to be unaffected; an object that declares none keeps the behaviour it has without the key. The platform's identity objects are to declare it for their own tables. Not applied yet: no server reads this key in this release, so declaring it filters no rows. Until the collection applies it, restrict rows with a permission set's `rowLevelSecurity` policy. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/security.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/security.md index fee42f41873..0eb270d67e0 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/security.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/security.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `security/` | 20 | 7 | 0 | 0 | 13 | +| `security/` | 21 | 8 | 0 | 0 | 13 | ## `security/` — sites @@ -33,9 +33,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit |---|---| | `explain.zod.ts` | 11 | | `permission.zod.ts` | 4 | -| `rls.zod.ts` | 3 | +| `rls.zod.ts` | 4 | | `sharing.zod.ts` | 2 | -| **total** | **20** | +| **total** | **21** | ## `security/` — open @@ -43,13 +43,13 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**13 strip of 20**, in 2 file(s). +**13 strip of 21**, in 2 file(s). | File | Strip | Sites | |---|---|---| | `explain.zod.ts` | 11 | 11 | -| `rls.zod.ts` | 2 | 3 | -| **total** | **13** | **20** | +| `rls.zod.ts` | 2 | 4 | +| **total** | **13** | **21** | | Bucket | Sites | |---|---| diff --git a/packages/metadata-core/src/object-schema-fls-references.test.ts b/packages/metadata-core/src/object-schema-fls-references.test.ts index 20d7fa0b87f..ce2aa412d99 100644 --- a/packages/metadata-core/src/object-schema-fls-references.test.ts +++ b/packages/metadata-core/src/object-schema-fls-references.test.ts @@ -115,6 +115,29 @@ describe('[ADR-0106 D1] references to a denied field — object level', () => { expect(served).not.toHaveProperty('activityMilestones'); }); + it('drops the default row scope whose predicate reads the denied field WHOLE, and keeps one that does not (#24338)', () => { + // One row policy (`defaultRowScope`), a rule entry in all but arity: deleting + // only its `using` would serve a policy that reads as restricting nothing. + const scope = (using: string) => ({ name: 'thing_self', operation: 'select', using }); + const denied = mask({ + name: 'thing', + fields: fieldsWith(), + defaultRowScope: scope('secret_score > 0 && owner_id == current_user.id'), + }); + expect(denied).not.toHaveProperty('defaultRowScope'); + + const readable = scope('title != null && owner_id == current_user.id'); + const served = mask({ name: 'thing', fields: fieldsWith(), defaultRowScope: readable }); + expect(served.defaultRowScope).toEqual(readable); + + // The leg that tells the classification from the unclassified fail-safe, which + // drops on a mention too: a denied field named like one of the policy's own + // schema words (`operation`) costs the scope nothing, because in a classified + // position a key is a schema word, never a field reference. + const wordy = mask({ name: 'thing', fields: { ...fieldsWith(), operation: { type: 'text' } }, defaultRowScope: readable }); + expect(wordy.defaultRowScope).toEqual(readable); + }); + it('scrubs the nested policy blocks: tenancy, lifecycle, publicSharing, external, userActions', () => { const served = mask({ name: 'thing', diff --git a/packages/metadata-core/src/object-schema-fls-references.ts b/packages/metadata-core/src/object-schema-fls-references.ts index def5e480468..79fa4d6296c 100644 --- a/packages/metadata-core/src/object-schema-fls-references.ts +++ b/packages/metadata-core/src/object-schema-fls-references.ts @@ -359,6 +359,9 @@ function arrayOf(element: Scrub): Scrub { */ const ruleEntries: Scrub = arrayOf((entry, denied) => (mentionsDenied(entry, denied, 'include', 'classified') ? REMOVE : entry)); +/** ONE rule entry outside an array, dropped whole on the same test {@link ruleEntries} applies per entry. */ +const ruleEntry: Scrub = (entry, denied) => (mentionsDenied(entry, denied, 'include', 'classified') ? REMOVE : entry); + /** An object block scrubbed key by key; an unclassified key goes the {@link unclassified} way. */ function block(table: Readonly>): Scrub { return (value, denied, scope) => { @@ -745,6 +748,11 @@ export const OBJECT_REFERENCE_POSITIONS: Readonly> = { validations: ruleEntries, indexes: ruleEntries, activityMilestones: ruleEntries, + // [#24338] The object's default row scope: ONE row-level security policy whose + // `using` / `check` are CEL over this object's record, so a rule entry in all + // but arity, dropped whole when it mentions a denied field. It is server + // policy the caller cannot evaluate, and its text describes the field. + defaultRowScope: ruleEntry, // Blocks. userActions: block({ create: crudFlag, import: crudFlag, edit: crudFlag, delete: crudFlag, exportCsv: keep }), external: block({ diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index 85571f9c629..06903a2e4ba 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -309,7 +309,8 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no * 25 → 26 when it gained `record` (#24265, the row an app page binds as * `record`). `object` * moved 44 → 45 the same way when it gained `attachedOnRead` (#22386, the - * blocks a service attaches per caller on read). `field` moved 75 → 77 the + * blocks a service attaches per caller on read), and 45 → 46 when it gained + * `defaultRowScope` (#24338, the object's default row scope). `field` moved 75 → 77 the * same way when it gained `dueLike` and `settledWhen` (#22227, a date's * deadline and when it is settled), 77 → 78 when it gained * `conditionalFormatting` (#22228, a field's cell formatting rules), and @@ -324,7 +325,7 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no */ const CARD_PROPERTY_COUNTS: Record = { agent: 26, app: 30, dashboard: 22, dataset: 16, field: 80, flow: 23, - hook: 22, object: 45, page: 26, position: 13, report: 21, skill: 17, tool: 14, + hook: 22, object: 46, page: 26, position: 13, report: 21, skill: 17, tool: 14, }; it.each(Object.entries(CARD_PROPERTY_COUNTS))( diff --git a/packages/spec/api-surface/security.json b/packages/spec/api-surface/security.json index b3c8a5a33bd..2af530e3567 100644 --- a/packages/spec/api-surface/security.json +++ b/packages/spec/api-surface/security.json @@ -19,6 +19,9 @@ "CriteriaSharingRule (type)", "CriteriaSharingRuleParsed (type)", "CriteriaSharingRuleSchema (const)", + "DefaultRowScope (type)", + "DefaultRowScopeParsed (type)", + "DefaultRowScopeSchema (const)", "EXPLAIN_BATCH_MAX_RECORD_IDS (const)", "EffectiveObjectPermission (type)", "EffectiveObjectPermissionSchema (const)", diff --git a/packages/spec/authorable-defaults/security.json b/packages/spec/authorable-defaults/security.json index b0e3f80f575..0b0fa9d8688 100644 --- a/packages/spec/authorable-defaults/security.json +++ b/packages/spec/authorable-defaults/security.json @@ -10,6 +10,7 @@ "security/CapabilityDeclaration:scope = \"platform\"", "security/CriteriaSharingRule:accessLevel = \"read\"", "security/CriteriaSharingRule:active = true", + "security/DefaultRowScope:enabled = true", "security/EffectiveObjectPermission:allowCreate = false", "security/EffectiveObjectPermission:allowDelete = false", "security/EffectiveObjectPermission:allowEdit = false", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index bd091ac034f..d8fb766a88b 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -662,6 +662,7 @@ "data/Object:activityMilestones", "data/Object:attachedOnRead", "data/Object:datasource", + "data/Object:defaultRowScope", "data/Object:description", "data/Object:displayNameField", "data/Object:editMode", diff --git a/packages/spec/authorable-surface/security.json b/packages/spec/authorable-surface/security.json index 9f8aaab8c40..e8967ae383c 100644 --- a/packages/spec/authorable-surface/security.json +++ b/packages/spec/authorable-surface/security.json @@ -49,6 +49,14 @@ "security/CriteriaSharingRule:object", "security/CriteriaSharingRule:sharedWith", "security/CriteriaSharingRule:type", + "security/DefaultRowScope:check", + "security/DefaultRowScope:description", + "security/DefaultRowScope:enabled", + "security/DefaultRowScope:label", + "security/DefaultRowScope:name", + "security/DefaultRowScope:operation", + "security/DefaultRowScope:positions", + "security/DefaultRowScope:using", "security/EffectiveObjectPermission:allowCreate", "security/EffectiveObjectPermission:allowDelete", "security/EffectiveObjectPermission:allowEdit", diff --git a/packages/spec/declaration-map/security.json b/packages/spec/declaration-map/security.json index 15b1b91e2af..d77d757d0af 100644 --- a/packages/spec/declaration-map/security.json +++ b/packages/spec/declaration-map/security.json @@ -14,6 +14,8 @@ "CapabilityDeclarationSchema": "security/CapabilityDeclaration", "CriteriaSharingRule": "security/CriteriaSharingRule", "CriteriaSharingRuleSchema": "security/CriteriaSharingRule", + "DefaultRowScope": "security/DefaultRowScope", + "DefaultRowScopeSchema": "security/DefaultRowScope", "EffectiveObjectPermission": "security/EffectiveObjectPermission", "EffectiveObjectPermissionSchema": "security/EffectiveObjectPermission", "ExplainDecision": "security/ExplainDecision", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index fc3370b3426..cf82a851456 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 239, - "droppedRefinementSites": 771, + "publishedSchemasWithDroppedRefinements": 240, + "droppedRefinementSites": 781, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -96,6 +96,7 @@ "manifest.navigationContributions.element.items.element.lazy.options[0]", "manifest.objectExtensions.element", "manifest.objects.element", + "manifest.objects.element.defaultRowScope", "manifest.objects.element.fieldGroups", "manifest.objects.element.fields.out.valueType", "manifest.objects.element.fields.out.valueType.inlineColumns.element", @@ -212,6 +213,7 @@ "data.options[1].manifest.jobs.element.schedule.options[0].timezone", "data.options[1].manifest.objectExtensions.element", "data.options[1].manifest.objects.element", + "data.options[1].manifest.objects.element.defaultRowScope", "data.options[1].manifest.objects.element.fieldGroups", "data.options[1].manifest.objects.element.fields.out.valueType", "data.options[1].manifest.objects.element.fields.out.valueType.inlineColumns.element", @@ -319,6 +321,7 @@ "options[1].manifest.jobs.element.schedule.options[0].timezone", "options[1].manifest.objectExtensions.element", "options[1].manifest.objects.element", + "options[1].manifest.objects.element.defaultRowScope", "options[1].manifest.objects.element.fieldGroups", "options[1].manifest.objects.element.fields.out.valueType", "options[1].manifest.objects.element.fields.out.valueType.inlineColumns.element", @@ -381,6 +384,7 @@ "data.packages.element.options[1].manifest.jobs.element.schedule.options[0].timezone", "data.packages.element.options[1].manifest.objectExtensions.element", "data.packages.element.options[1].manifest.objects.element", + "data.packages.element.options[1].manifest.objects.element.defaultRowScope", "data.packages.element.options[1].manifest.objects.element.fieldGroups", "data.packages.element.options[1].manifest.objects.element.fields.out.valueType", "data.packages.element.options[1].manifest.objects.element.fields.out.valueType.inlineColumns.element", @@ -427,6 +431,7 @@ "data.actions.element.in.ai.outputSchema", "data.actions.element.in.onSuccess", "data.actions.element.in.params.element.in", + "data.defaultRowScope", "data.fieldGroups", "data.fields.out.valueType", "data.fields.out.valueType.inlineColumns.element", @@ -880,6 +885,7 @@ "actions.element.in.ai.outputSchema", "actions.element.in.onSuccess", "actions.element.in.params.element.in", + "defaultRowScope", "fieldGroups", "fields.out.valueType", "fields.out.valueType.inlineColumns.element", @@ -1098,6 +1104,11 @@ "sharedWith" ] }, + "security/DefaultRowScope": { + "sites": [ + "" + ] + }, "security/ExplainRequest": { "sites": [ "" @@ -1161,6 +1172,7 @@ "operations.element.options[3].object.actions.element.in.ai.outputSchema", "operations.element.options[3].object.actions.element.in.onSuccess", "operations.element.options[3].object.actions.element.in.params.element.in", + "operations.element.options[3].object.defaultRowScope", "operations.element.options[3].object.fieldGroups", "operations.element.options[3].object.fields.out.valueType", "operations.element.options[3].object.lifecycle", @@ -1181,6 +1193,7 @@ "object.actions.element.in.ai.outputSchema", "object.actions.element.in.onSuccess", "object.actions.element.in.params.element.in", + "object.defaultRowScope", "object.fieldGroups", "object.fields.out.valueType", "object.fields.out.valueType.inlineColumns.element", @@ -1227,6 +1240,7 @@ "options[3].object.actions.element.in.ai.outputSchema", "options[3].object.actions.element.in.onSuccess", "options[3].object.actions.element.in.params.element.in", + "options[3].object.defaultRowScope", "options[3].object.fieldGroups", "options[3].object.fields.out.valueType", "options[3].object.lifecycle", diff --git a/packages/spec/export-origins/security.json b/packages/spec/export-origins/security.json index 4e97ed9a343..3d9dc6851d5 100644 --- a/packages/spec/export-origins/security.json +++ b/packages/spec/export-origins/security.json @@ -19,6 +19,9 @@ "CriteriaSharingRule": "src/security/sharing.zod.ts#CriteriaSharingRule (type)", "CriteriaSharingRuleParsed": "src/security/sharing.zod.ts#CriteriaSharingRuleParsed (type)", "CriteriaSharingRuleSchema": "src/security/sharing.zod.ts#CriteriaSharingRuleSchema (const)", + "DefaultRowScope": "src/security/rls.zod.ts#DefaultRowScope (type)", + "DefaultRowScopeParsed": "src/security/rls.zod.ts#DefaultRowScopeParsed (type)", + "DefaultRowScopeSchema": "src/security/rls.zod.ts#DefaultRowScopeSchema (const)", "EXPLAIN_BATCH_MAX_RECORD_IDS": "src/security/explain.zod.ts#EXPLAIN_BATCH_MAX_RECORD_IDS (const)", "EffectiveObjectPermission": "src/security/permission.zod.ts#EffectiveObjectPermission (type)", "EffectiveObjectPermissionSchema": "src/security/permission.zod.ts#EffectiveObjectPermissionSchema (const)", diff --git a/packages/spec/json-schema.manifest/security.json b/packages/spec/json-schema.manifest/security.json index 6a7f6a05519..938e371612f 100644 --- a/packages/spec/json-schema.manifest/security.json +++ b/packages/spec/json-schema.manifest/security.json @@ -8,6 +8,7 @@ "security/AuthzPosture", "security/CapabilityDeclaration", "security/CriteriaSharingRule", + "security/DefaultRowScope", "security/EffectiveObjectPermission", "security/ExplainDecision", "security/ExplainLayer", diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index 0c63f12041b..5be046dabce 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -158,6 +158,64 @@ "evidence": "packages/plugins/plugin-sharing/src/sharing-service.ts#effectiveSharingModel (`schema?.sharingModel ?? schema?.security?.sharingModel`, collapsed onto the three behaviours the service enforces; `controlled_by_parent` folds to the master-derived branch and an unrecognised stored value fails CLOSED to `private`)", "note": "ADR-0055 high-risk class (sharing): the `controlled_by_parent` value derives a detail object's access from its master — the security layer injects `masterFK IN (accessible master ids)` on reads and requires master edit-access on by-id writes. The proof asserts a member who cannot read the master can neither read nor by-id-write the detail, and is not over-blocked on a master they own. 2026-08-28: RE-ANCHORED (commit 93ea19bca) and REPOINTED — `sharing-service.ts:54` had rotted onto a blank line between an interface and a `makeShareId` docblock. Re-closed by hand against c459da6bc." }, + "defaultRowScope": { + "status": "planned", + "verifiedAt": "2026-10-18", + "evidence": "packages/spec/src/data/object.zod.ts#ObjectSchemaBase (declares the key, optional, typed as the default-row-scope shape); packages/spec/src/security/rls.zod.ts#DefaultRowScopeSchema (the shape: RowLevelSecurityPolicySchema's own members without `object`, with the same two rules, refineRowLevelSecurityPolicy)", + "authorWarn": true, + "authorHint": "Not applied yet: no server reads `defaultRowScope` in this release, so it filters no rows. Until the security plugin's row-policy collection applies it, restrict rows with a permission set's `rowLevelSecurity` policy.", + "note": "PLANNED, the reader pending — #24164 ruling E′ item 1 (spec half, #24338). The one reader is to be plugin-security's `collectRLSPolicies` (packages/plugins/plugin-security/src/security-plugin.ts), carried by objectstack-ai/objectstack#24340: measured before the key existed, `git grep defaultRowScope` over the whole tree at origin/main b2e78701f7 returns zero hits (control: `sharingModel` under packages/plugins hits), and objectui at the `.objectui-sha` pin 0707b4ae37 returns zero too (same control hits), so this declaration adds no reader and an object declaring the key is filtered exactly as before. The writers are to be the platform identity objects (packages/platform-objects), carried by objectstack-ai/objectstack#24341, which is blocked on this key alone, so its declarations can land before the reader does; until #24340 lands they too filter nothing. Flip to `live` on the commit that lands #24340, citing `collectRLSPolicies` as the evidence and its explain source as the second read point, and drop `authorWarn` / `authorHint` in that same edit: a warned `live` row reds this gate. Drilled into `children` rather than deferred to `permission/rowLevelSecurity`: every member is RowLevelSecurityPolicySchema's own instance, but the key sets differ (`object`, `priority` and `tags` are left out here), so the gate's equality check would refuse the deferral; each child is `planned` for the same reason as the container, and only the container carries `authorWarn`, so authoring the key warns once. Flip the children with the container.", + "children": { + "name": { + "status": "planned", + "verifiedAt": "2026-10-18", + "evidence": "packages/spec/src/security/rls.zod.ts#DefaultRowScopeSchema (declares `name`: RowLevelSecurityPolicySchema's own member instance, the policy name, snake_case; the explain source a reader names)", + "note": "Planned with its container: no server reads `defaultRowScope` until the security plugin's `collectRLSPolicies` does." + }, + "label": { + "status": "planned", + "verifiedAt": "2026-10-18", + "evidence": "packages/spec/src/security/rls.zod.ts#DefaultRowScopeSchema (declares `label`: RowLevelSecurityPolicySchema's own member instance, the human-readable policy label)", + "note": "Planned with its container: no server reads `defaultRowScope` until the security plugin's `collectRLSPolicies` does." + }, + "description": { + "status": "planned", + "verifiedAt": "2026-10-18", + "evidence": "packages/spec/src/security/rls.zod.ts#DefaultRowScopeSchema (declares `description`: RowLevelSecurityPolicySchema's own member instance, the policy description)", + "note": "Planned with its container: no server reads `defaultRowScope` until the security plugin's `collectRLSPolicies` does." + }, + "operation": { + "status": "planned", + "verifiedAt": "2026-10-18", + "evidence": "packages/spec/src/security/rls.zod.ts#DefaultRowScopeSchema (declares `operation`: RowLevelSecurityPolicySchema's own member instance, which operations the scope applies to)", + "note": "Planned with its container: no server reads `defaultRowScope` until the security plugin's `collectRLSPolicies` does." + }, + "using": { + "status": "planned", + "verifiedAt": "2026-10-18", + "evidence": "packages/spec/src/security/rls.zod.ts#DefaultRowScopeSchema (declares `using`: RowLevelSecurityPolicySchema's own member instance, the row predicate)", + "note": "Planned with its container: no server reads `defaultRowScope` until the security plugin's `collectRLSPolicies` does." + }, + "check": { + "status": "planned", + "verifiedAt": "2026-10-18", + "evidence": "packages/spec/src/security/rls.zod.ts#DefaultRowScopeSchema (declares `check`: RowLevelSecurityPolicySchema's own member instance, the write-image check, refused on a `select` / `delete` scope)", + "note": "Planned with its container: no server reads `defaultRowScope` until the security plugin's `collectRLSPolicies` does." + }, + "positions": { + "status": "planned", + "verifiedAt": "2026-10-18", + "evidence": "packages/spec/src/security/rls.zod.ts#DefaultRowScopeSchema (declares `positions`: RowLevelSecurityPolicySchema's own member instance, the positions the scope applies to)", + "note": "Planned with its container: no server reads `defaultRowScope` until the security plugin's `collectRLSPolicies` does." + }, + "enabled": { + "status": "planned", + "verifiedAt": "2026-10-18", + "evidence": "packages/spec/src/security/rls.zod.ts#DefaultRowScopeSchema (declares `enabled`: RowLevelSecurityPolicySchema's own member instance, whether the scope is active (default true))", + "note": "Planned with its container: no server reads `defaultRowScope` until the security plugin's `collectRLSPolicies` does." + } + } + }, "publicSharing": { "status": "live", "verifiedAt": "2026-08-28", diff --git a/packages/spec/liveness/state-counts/object.md b/packages/spec/liveness/state-counts/object.md index 7f04dd1a6d3..73b536971df 100644 --- a/packages/spec/liveness/state-counts/object.md +++ b/packages/spec/liveness/state-counts/object.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `object` | 53 | 0 | 0 | 1 | 0 | 54 | +| `object` | 53 | 0 | 0 | 1 | 8 | 62 | diff --git a/packages/spec/src/compose-stacks-merge-collection-refusal.test.ts b/packages/spec/src/compose-stacks-merge-collection-refusal.test.ts index 3bea93cf0dd..90dd14e1fa9 100644 --- a/packages/spec/src/compose-stacks-merge-collection-refusal.test.ts +++ b/packages/spec/src/compose-stacks-merge-collection-refusal.test.ts @@ -84,6 +84,7 @@ const CONFIG_OBJECT_KEYS_IN_SHAPE_ORDER = [ 'access', 'lifecycle', 'enable', + 'defaultRowScope', 'publicSharing', 'protection', ] as const; diff --git a/packages/spec/src/compose-stacks-merge-config-object-refusal.test.ts b/packages/spec/src/compose-stacks-merge-config-object-refusal.test.ts index fa3ffcf4664..c0b35033e75 100644 --- a/packages/spec/src/compose-stacks-merge-config-object-refusal.test.ts +++ b/packages/spec/src/compose-stacks-merge-config-object-refusal.test.ts @@ -72,7 +72,11 @@ function refusal(fn: () => unknown): Envelope | null { const shared = (out: ObjectStackDefinition) => (out.objects ?? []).find((o) => o.name === 'shared') as Record | undefined; -/** The ruling's eight, in the object shape's declaration order. */ +/** + * The ruling's eight, in the object shape's declaration order, plus each fixed-shape + * config object the shape has gained since: `defaultRowScope` (#24338), refused under + * `'merge'` by the unedited composer, which is the ARRIVAL leg below in production. + */ const CONFIG_OBJECT_KEYS_IN_SHAPE_ORDER = [ 'userActions', 'external', @@ -80,6 +84,7 @@ const CONFIG_OBJECT_KEYS_IN_SHAPE_ORDER = [ 'access', 'lifecycle', 'enable', + 'defaultRowScope', 'publicSharing', 'protection', ] as const; @@ -270,7 +275,7 @@ function refusedConfigObjectsOf(compose: typeof composeStacks, define: typeof de describe('DERIVATION — the refused config-object set equals the shape walk', () => { const derived = configObjectKeysOf(shapeOf(ObjectSchema)); - it("the shape walk yields the ruling's eight, in shape order (a new config object lands here as a one-line diff)", () => { + it("the shape walk yields the ruling's eight plus the keys added since, in shape order (a new config object lands here as a one-line diff)", () => { expect(derived).toEqual([...CONFIG_OBJECT_KEYS_IN_SHAPE_ORDER]); }); diff --git a/packages/spec/src/data/object-default-row-scope.test.ts b/packages/spec/src/data/object-default-row-scope.test.ts new file mode 100644 index 00000000000..a0202efdefc --- /dev/null +++ b/packages/spec/src/data/object-default-row-scope.test.ts @@ -0,0 +1,142 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `ObjectSchema.defaultRowScope` — the object's default row scope, the + * permission-set row policy shape without `object` (#24164 ruling E′, item 1). + * + * What is pinned, on the authoring door (`ObjectSchema`) and on the metadata + * write door (the `object` registry binding): + * + * - a policy WITHOUT `object` parses, defaults applied (`enabled: true`); + * - a policy CARRYING `object` is refused, by name, at the key's own path; + * - an unknown key is still refused (the shape is closed); + * - control: an object that does not declare the key parses as before and its + * parsed output carries no `defaultRowScope`. + * + * And, because "minus `object`" must keep every other rule of the policy + * shape: both cross-field rules still hold (at least one of `using` / `check`; + * no `check` on a `select` policy), the members are the policy schema's own + * instances, and the input type is the policy type without `object` and its two + * value-less tombstones (`priority`, `tags`), which are refused here as well. + * + * No runtime reads the key until the security plugin's collection step does + * (#24340), so nothing here asserts a filter: this is the contract's shape. + */ + +import { describe, expect, it } from 'vitest'; + +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { + DefaultRowScopeSchema, + RowLevelSecurityPolicySchema, + type DefaultRowScope, + type RowLevelSecurityPolicy, +} from '../security/rls.zod'; +import { ObjectSchema } from './object.zod'; + +const OBJECT = { name: 'support_case', fields: { subject: { type: 'text' } } } as const; + +/** A `_self` scope, in the shape the shipped sets' identity-table policies take, minus `object`. */ +const SCOPE = { + name: 'support_case_self', + operation: 'all', + using: 'owner_id == current_user.id', +} as const; + +const withScope = (scope: Record) => ({ ...OBJECT, defaultRowScope: scope }); + +/** The one issue a refused parse raised — asserted to be exactly one. */ +function onlyIssue(result: ReturnType) { + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable'); + expect(result.error.issues).toHaveLength(1); + return result.error.issues[0]!; +} + +// ── compile-time half: the input type IS the policy type without `object` ── +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Assert = T; + +describe('ObjectSchema.defaultRowScope — the RLS policy shape without `object`', () => { + it('parses a policy without `object`, defaults applied', () => { + const r = ObjectSchema.safeParse(withScope(SCOPE)); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data.defaultRowScope).toEqual({ ...SCOPE, enabled: true }); + }); + + it('refuses a policy carrying `object`, by name, at the key — with the implied-object guidance', () => { + const issue = onlyIssue(ObjectSchema.safeParse(withScope({ ...SCOPE, object: 'support_case' }))); + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.path).toEqual(['defaultRowScope']); + expect((issue as { keys?: string[] }).keys).toEqual(['object']); + // The prescription names the fix: the key is implied, delete it. + expect(issue.message).toContain('`object` is implied on a default row scope'); + }); + + it('still refuses an unknown key — the shape is closed', () => { + const issue = onlyIssue(ObjectSchema.safeParse(withScope({ ...SCOPE, appliesTo: 'members' }))); + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.path).toEqual(['defaultRowScope']); + expect((issue as { keys?: string[] }).keys).toEqual(['appliesTo']); + }); + + it('control: an object without the key parses as before, and its output carries no default', () => { + const r = ObjectSchema.safeParse(OBJECT); + expect(r.success).toBe(true); + if (!r.success) return; + expect('defaultRowScope' in r.data).toBe(false); + }); + + it('the metadata write door (the `object` registry binding) applies the same shape', () => { + const door = getMetadataTypeSchema('object'); + expect(door).toBeDefined(); + expect(door!.safeParse(withScope(SCOPE)).success).toBe(true); + const refused = door!.safeParse(withScope({ ...SCOPE, object: 'support_case' })); + expect(refused.success).toBe(false); + if (refused.success) return; + expect(refused.error.issues.map((i) => [i.code, i.path])).toEqual([['unrecognized_keys', ['defaultRowScope']]]); + }); +}); + +describe('ObjectSchema.defaultRowScope — every rule of the policy shape that does not need `object` holds', () => { + it('refuses a scope with neither `using` nor `check`', () => { + const issue = onlyIssue(ObjectSchema.safeParse(withScope({ name: 'no_clause', operation: 'select' }))); + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual(['defaultRowScope']); + }); + + it('refuses a `check` on a `select` scope, at the `check` path', () => { + const issue = onlyIssue( + ObjectSchema.safeParse(withScope({ ...SCOPE, operation: 'select', check: 'owner_id == current_user.id' })), + ); + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual(['defaultRowScope', 'check']); + }); + + it('its input type is the policy type without `object` and the two tombstones (compile-time; tsconfig.test.json)', () => { + // `priority` / `tags` are `retiredKey()` tombstones on the policy: they accept no + // value, so leaving them out changes nothing either schema accepts. + const scopeIsPolicyMinusObject: Assert< + Equal> + > = true; + expect(scopeIsPolicyMinusObject).toBe(true); + }); + + it('its members are the policy schema\'s own member instances: `object` and the two tombstones left out', () => { + const policy = RowLevelSecurityPolicySchema.shape as Record; + const scope = DefaultRowScopeSchema.shape as Record; + const leftOut = ['object', 'priority', 'tags']; + expect(Object.keys(scope)).toEqual(Object.keys(policy).filter((k) => !leftOut.includes(k))); + for (const key of Object.keys(scope)) expect(scope[key], key).toBe(policy[key]); + }); + + it('a tombstoned policy key is refused here too, as an unknown key', () => { + for (const key of ['priority', 'tags']) { + const issue = onlyIssue(ObjectSchema.safeParse(withScope({ ...SCOPE, [key]: key === 'tags' ? ['a'] : 1 }))); + expect(issue.code, key).toBe('unrecognized_keys'); + expect(issue.path, key).toEqual(['defaultRowScope']); + expect((issue as { keys?: string[] }).keys, key).toEqual([key]); + } + }); +}); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 5ee9ab17df9..edb6930a733 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -30,6 +30,9 @@ import { isMaskedOnReadFieldType } from './masked-field-types'; // refuse one set. Public: re-exported from `@objectstack/spec/data`, with the // field predicate `isControlledByParentMasterField` it counts. import { controlledByParentMasterRefusal } from './controlled-by-parent-master'; +// The object's default row scope: the RLS policy shape without `object`. No +// cycle: `security/rls.zod` imports only zod and `shared/`. +import { DefaultRowScopeSchema } from '../security/rls.zod'; export const ApiMethod = z.enum([ 'get', 'list', // Read 'create', 'update', 'delete', // Write @@ -2966,6 +2969,41 @@ const ObjectSchemaBase = strictObject( */ sharingModel: z.enum(['private', 'public_read', 'public_read_write', 'controlled_by_parent']).optional().describe('Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) | public_read (everyone reads, owner writes) | public_read_write (everyone reads+writes) | controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1).'), + /** + * The object's default row scope (#24164 ruling E′, item 1): one row-level + * security policy, the permission-set `rowLevelSecurity` entry shape without + * `object` ({@link DefaultRowScopeSchema}). Layer 1 silence on an object, no + * held set carrying a row policy for it, then means this policy rather than + * no filter. + * + * The describe states the CONTRACT, not shipped behaviour (Prime Directive + * #10, the claim as narrow as the enforcement): its one reader is to be + * plugin-security's `collectRLSPolicies`, the collection every read, write + * and explain door uses (#24340), and until that lands no server reads the + * key; its writers are to be the platform identity objects (#24341), and no + * object declares it yet. The liveness row is `planned` with an author + * warning until the reader lands. + * + * The ruling's semantics, which the reader is to meet: when no held set + * carries a row policy for this object, the default applies; an authored set + * policy is used as authored; a superuser-bypass principal is untouched (the + * bypass is decided before the collection); an object with no default keeps + * today's behaviour. + */ + defaultRowScope: DefaultRowScopeSchema.optional().describe( + "The object's default row scope: one row-level security policy, written like a permission set's " + + '`rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is ' + + 'refused). The contract the security plugin\'s row-policy collection, the one step every read, write ' + + 'and explain passes through, is to meet once it reads the key: when no permission set the caller ' + + 'holds carries a row policy for this object, this policy is to apply; a policy an authored set ' + + 'carries for the object is to be used as authored, with this default not added to it; a principal ' + + 'whose superuser bypass skips row policies is to be unaffected; an object that declares none keeps ' + + 'the behaviour it has without the key. The platform\'s identity objects are to declare it for their ' + + 'own tables. Not applied yet: no server reads this key in this release, so declaring it filters no ' + + 'rows. Until the collection applies it, restrict rows with a permission set\'s `rowLevelSecurity` ' + + 'policy.', + ), + /** * RETIRED (#23268, ADR-0049 enforce-or-remove; ruling of record 6159300530, * letter A). ADR-0090 D11 declared a second, external org-wide default for diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index d065797e736..12caf499ed1 100644 --- a/packages/spec/src/security/rls.zod.ts +++ b/packages/spec/src/security/rls.zod.ts @@ -136,6 +136,100 @@ const RLS_POLICY_TAGS_RETIRED = + 'by its `name` and its `object`; say why it exists in `description`. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; +/** + * The curated near-miss answers of a row policy's unknown keys, shared by both + * carriers of the policy shape: {@link RowLevelSecurityPolicySchema} and + * {@link DefaultRowScopeSchema}. One table, so the two surfaces cannot answer + * the same slip differently. Declared above its first eager reader, for the + * TDZ reason {@link RLS_POLICY_TAGS_RETIRED} gives. + * + * The suggestion pool is `Object.keys(shape)` minus anything that accepts + * nothing (#5593). `priority` and `tags` are exactly that case and the + * exclusion is deliberate: each is a {@link retiredKey} tombstone, declared so + * its rejection carries the upgrade prescription, never offered as a rename + * target. The hand-transcribed list this replaced had to state the same + * exclusion in prose and be trusted to keep it. + */ +const RLS_POLICY_ALIASES: Readonly> = { + // ADR-0090 D3 renamed the pre-D3 `roles` vocabulary to `positions`. + roles: 'positions', + role: 'positions', + // PostgreSQL spells the write-side clause `WITH CHECK`. + withcheck: 'check', + // The read-side clause under other names an author reaches for first. + condition: 'using', + filter: 'using', + where: 'using', +}; + +/** + * The policy members the two rules below read. Neither reads `object`, which + * is why a carrier that implies the object ({@link DefaultRowScopeSchema}) + * takes both rules unchanged. + */ +interface RowLevelSecurityPolicyClauses { + operation: z.infer; + using?: string; + check?: string; +} + +/** + * The cross-field rules of a row policy, named so both carriers of the policy + * shape run the same function: {@link RowLevelSecurityPolicySchema} and + * {@link DefaultRowScopeSchema}. Named rather than inherited because zod 4 + * refuses `.omit()` / `.pick()` on an object schema that carries a refinement + * (`.omit() cannot be used on object schemas containing refinements`), so the + * object-implied shape cannot be cut from the refined policy schema and keep + * its rules; it is rebuilt from the same members and given the same rules. + */ +function refineRowLevelSecurityPolicy(data: RowLevelSecurityPolicyClauses, ctx: z.RefinementCtx): void { + // Ensure at least one of USING or CHECK is provided + if (!data.using && !data.check) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'At least one of "using" or "check" must be specified. A `select` or `delete` policy takes "using": ' + + 'the rows it lets a caller read or delete. An `insert` policy takes "check", which judges each row ' + + 'an insert writes; a "using" alone is that check when no applicable insert policy declares one. ' + + 'An `update` or `all` policy takes either or both: "using" limits the existing rows it admits, and ' + + '"check" judges each row written.', + }); + } + + // [ADR-0049 · ADR-0058 D4] `check` judges the post-image of a write — the + // new row of an insert, the changed row of an update. A `select` or `delete` + // policy has no post-image, and the runtime's write gate never collects one + // (plugin-security `writeCheckPolicies` is handed only the policies whose + // `operation` is the write's own or `all`). A `check` there was stored and + // never evaluated, so an author who wrote one on a `delete` policy believed + // it guarded deletes. Declared ≠ enforced ⇒ refused here, at the key's own + // path, naming the operation and what to write instead. "Declares" is the + // runtime's test (a non-blank string): a blank `check` declares nothing and + // is left to the rule above. + if ( + (data.operation === 'select' || data.operation === 'delete') && + typeof data.check === 'string' && + data.check.trim() !== '' + ) { + const verb = data.operation === 'select' ? 'read' : 'delete'; + const act = data.operation === 'select' ? 'a read' : 'a delete'; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['check'], + message: + `\`check\` is never evaluated on a \`${data.operation}\` policy: it validates the new row an insert ` + + `or an update writes, and ${act} writes none. Remove \`check\` from this policy. To limit which ` + + `rows the policy lets a caller ${verb}, write that predicate as \`using\`; to validate rows as ` + + 'they are written, declare the `check` on a policy whose `operation` is `insert`, `update` or `all`.', + }); + } + + // With both rules above, a non-blank `using` is the only predicate a + // `select` or `delete` policy can carry. An `insert`, `update` or `all` + // policy may carry either clause or both; what each does per operation is + // on the `using` and `check` docs. +} + /** * Row-Level Security Policy Schema * @@ -239,23 +333,9 @@ const RLS_POLICY_TAGS_RETIRED = export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject( { surface: 'this RLS policy', - // The suggestion pool is `Object.keys(shape)` minus anything that accepts - // nothing (#5593). `priority` and `tags` are exactly that case and the - // exclusion is deliberate: each is a {@link retiredKey} tombstone, declared so its - // rejection carries the upgrade prescription, never offered as a rename - // target. The hand-transcribed list this replaced had to state the same - // exclusion in prose and be trusted to keep it. - aliases: { - // ADR-0090 D3 renamed the pre-D3 `roles` vocabulary to `positions`. - roles: 'positions', - role: 'positions', - // PostgreSQL spells the write-side clause `WITH CHECK`. - withcheck: 'check', - // The read-side clause under other names an author reaches for first. - condition: 'using', - filter: 'using', - where: 'using', - }, + // Shared with the object-implied carrier; the table's docblock carries + // why the two tombstones are not in the suggestion pool. + aliases: RLS_POLICY_ALIASES, history: 'Until this shape was closed, these were dropped silently — the policy still parsed, so a ' + 'row-level restriction the author wrote was never compiled into the filter.', @@ -570,53 +650,67 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject( * of an unknown-key verdict with no upgrade text. */ tags: retiredKey(RLS_POLICY_TAGS_RETIRED), -}).superRefine((data, ctx) => { - // Ensure at least one of USING or CHECK is provided - if (!data.using && !data.check) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - 'At least one of "using" or "check" must be specified. A `select` or `delete` policy takes "using": ' + - 'the rows it lets a caller read or delete. An `insert` policy takes "check", which judges each row ' + - 'an insert writes; a "using" alone is that check when no applicable insert policy declares one. ' + - 'An `update` or `all` policy takes either or both: "using" limits the existing rows it admits, and ' + - '"check" judges each row written.', - }); - } +}).superRefine(refineRowLevelSecurityPolicy)); - // [ADR-0049 · ADR-0058 D4] `check` judges the post-image of a write — the - // new row of an insert, the changed row of an update. A `select` or `delete` - // policy has no post-image, and the runtime's write gate never collects one - // (plugin-security `writeCheckPolicies` is handed only the policies whose - // `operation` is the write's own or `all`). A `check` there was stored and - // never evaluated, so an author who wrote one on a `delete` policy believed - // it guarded deletes. Declared ≠ enforced ⇒ refused here, at the key's own - // path, naming the operation and what to write instead. "Declares" is the - // runtime's test (a non-blank string): a blank `check` declares nothing and - // is left to the rule above. - if ( - (data.operation === 'select' || data.operation === 'delete') && - typeof data.check === 'string' && - data.check.trim() !== '' - ) { - const verb = data.operation === 'select' ? 'read' : 'delete'; - const act = data.operation === 'select' ? 'a read' : 'a delete'; - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['check'], - message: - `\`check\` is never evaluated on a \`${data.operation}\` policy: it validates the new row an insert ` + - `or an update writes, and ${act} writes none. Remove \`check\` from this policy. To limit which ` + - `rows the policy lets a caller ${verb}, write that predicate as \`using\`; to validate rows as ` + - 'they are written, declare the `check` on a policy whose `operation` is `insert`, `update` or `all`.', - }); - } +/** + * What an author who writes `object` on a default row scope is told. Hoisted + * above its eager reader, for the TDZ reason {@link RLS_POLICY_TAGS_RETIRED} + * gives. + */ +const DEFAULT_ROW_SCOPE_OBJECT_GUIDANCE = + '`object` is implied on a default row scope: the scope belongs to the object that declares it, so it ' + + 'names no target. Delete the key. A policy for some other object is written in a permission set\'s ' + + '`rowLevelSecurity`, where `object` names its target.'; - // With both rules above, a non-blank `using` is the only predicate a - // `select` or `delete` policy can carry. An `insert`, `update` or `all` - // policy may carry either clause or both; what each does per operation is - // on the `using` and `check` docs. -})); +/** + * An object's default row scope: {@link RowLevelSecurityPolicySchema} without + * `object`, which the declaring object implies. It is the value of + * `ObjectSchema.defaultRowScope` (`data/object.zod.ts`), whose describe states + * the contract the security plugin's row-policy collection is to meet. + * + * ## Why it is rebuilt, not cut + * + * The obvious spelling, `RowLevelSecurityPolicySchema.omit({ object: true })`, + * throws in zod 4 (`.omit() cannot be used on object schemas containing + * refinements`). So this schema is built from the same three parts as the + * policy schema, each shared by reference rather than copied: + * + * - **the members**: the policy schema's own `shape` minus `object`, so every + * member and its describe are the same schema instances. The two + * `retiredKey()` tombstones (`priority`, `tags`) are left out with it: they + * accept no value, so leaving them out changes nothing this schema accepts, + * and their prescriptions are upgrade text for `rowLevelSecurity[]`, a path + * this surface never had. Either key is refused here as an unknown key; + * - **the rules**: {@link refineRowLevelSecurityPolicy}, which reads + * `operation`, `using` and `check` and never `object`, so both rules hold as + * written: at least one of `using` / `check`, and no `check` on a `select` or + * `delete` policy; + * - **the near-miss table**: {@link RLS_POLICY_ALIASES}. + * + * What differs is the surface name, the `object` guidance, the history + * sentence and the two absent tombstones. `object` is refused as an unknown key, with the guidance above, so + * a policy copied from a permission set fails loudly instead of parsing with a + * target the runtime would never read. + * + * Reader and writers (#24164 ruling E′): the reader is to be the security + * plugin's `collectRLSPolicies` (#24340); the writers are to be the platform + * identity objects (#24341). Neither exists yet: until the reader lands, no + * server reads the key, and until the writers land, no object declares it. + */ +export const DefaultRowScopeSchema = lazySchema(() => { + const { object: _implied, priority: _priority, tags: _tags, ...members } = RowLevelSecurityPolicySchema.shape; + return strictObject( + { + surface: "this object's default row scope", + aliases: RLS_POLICY_ALIASES, + guidance: { object: DEFAULT_ROW_SCOPE_OBJECT_GUIDANCE }, + history: + 'A key this scope does not declare is refused rather than dropped: a dropped member of a row ' + + 'scope would be a row restriction its author believes is in force.', + }, + members, + ).superRefine(refineRowLevelSecurityPolicy); +}); // RLSAuditEventSchema / RLSAuditConfigSchema / RLSConfigSchema were REMOVED // per ADR-0056 D8 "design+enforce or remove": the global RLSConfig @@ -731,6 +825,10 @@ export const RLSEvaluationResultSchema = lazySchema(() => z.object({ export type RowLevelSecurityPolicy = z.input; /** Post-parse shape of {@link RowLevelSecurityPolicy} — defaults applied, transforms run (ADR-0122). */ export type RowLevelSecurityPolicyParsed = z.infer; +/** An object's default row scope — {@link RowLevelSecurityPolicy} without `object`. */ +export type DefaultRowScope = z.input; +/** Post-parse shape of {@link DefaultRowScope} — defaults applied (ADR-0122). */ +export type DefaultRowScopeParsed = z.infer; export type RLSUserContext = z.input; export type RLSEvaluationResult = z.input; diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 60ddbc75f58..f46ba8a82c2 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -528,6 +528,13 @@ const LEDGER: ReadonlyArray = [ key: 'attachedOnRead', why: "not a form's to offer, by ruling (ruling record 6070963704, letter A): the blocks a service attaches to the rows it serves, computed per caller and never stored, which the ruling says no form reads; a block is declared in code beside the service that attaches it (plugin-approvals' `viewer` on `sys_approval_request`), because nothing authored in a designer can make a service attach a block, and its one reader is the shared build validator", }, + { + kind: 'omit', + type: 'object', + path: ROOT_PATH, + key: 'defaultRowScope', + why: "not offered while nothing applies it (#24164 ruling E′, item 1): no server reads the object's default row scope until the security plugin's `collectRLSPolicies` does (#24340), so a form row would let an author declare a row restriction that filters nothing, the declared-not-enforced trap Prime Directive #10 names; its writers are to be the platform identity objects, declared in code (#24341), and none declares it yet. Revisit when the reader lands: a row policy is a composite of a select, two predicate strings and a positions list, a form-face addition rather than a reconciliation", + }, { kind: 'omit', type: 'app',