Skip to content

Commit df9d07e

Browse files
fix(lint): judge an object's defaultRowScope in the RLS predicate and org-axis rules (#24511)
Fixes #24452 Clause-②: no ## What changed `@objectstack/lint` now judges an object's default row scope (`ObjectSchema.defaultRowScope`) the way it judges a permission set's row policy. - **`validateRlsPredicateEnforceability`** (`packages/lint/src/validate-rls-predicate-enforceability.ts`). The per-policy body of the walker is moved into one function, `judgePolicy(graph, policy, site, options)`. The walker calls it for each `permissions[].rowLevelSecurity[]` entry, as before, and for each `objects[].defaultRowScope`, with the declaring object as the target. There is no second lowering implementation: the default goes through the same `isSupportedRlsExpression` verdict, the same shape explanation (`rls-predicate-unenforceable` / `-unparseable` / `-over-budget`), the same reference pass (`-unknown-field`, `-unknown-user-variable`, the type and comparand faults, the list-holding and cross-class arms) and the same engine read-scope pass. Severity is `error` for both sources. - Finding path: `objects[N].defaultRowScope.using` / `.check`. - `where`: `default row scope "NAME" of object "OBJECT"`. No part of a default's finding mentions a permission set (pinned). - The permission-set leg is byte-for-byte unchanged: same `where`, `path`, message and hint (every existing pin passes unedited). - **`validateOrgAxisRedLines` rule ①** (`validate-org-axis-red-lines.ts`) also judges a default whose `using` / `check` reads `parent_organization_id`, at `objects[N].defaultRowScope.CLAUSE`, with the same message and hint. The reason is the rule's own: once the security plugin applies the default, it is a Layer-1 policy like any other, so walking the org parent there builds the same second hierarchy. The file's `## Scope` table and its "object-level RLS" note now name the one object-level policy key that is declared. - **`os explain`** (`rule-explanations.ts`). The shared `RLS_DROPPED_USING` paragraph (used by all five `rls-predicate-*` entries) now covers both sources. For a permission set's policy, the object disappears for every holder of the set that carries it. For an object's `defaultRowScope`, which is applied only when the caller's sets carry no row policy for the object and operation, the default is always the only applicable policy. So a dropped default makes the object disappear for every caller it applies to, and a caller whose set carries its own row policy for the object is not affected. The `rls-predicate-unenforceable` entry's first paragraph also says it judges both places a row policy is declared. No other entry of that file is touched. - Changeset `.changeset/24452-default-row-scope-lint.md`, `@objectstack/lint` patch: a new authoring-time finding on a shape that was silent before. ## Why the severity is the same (measured on the reader, not assumed) PR #24451 (the runtime half, merged as `94e60c00f` and merged into this branch) makes `collectRLSPolicies` hand the default to the same admission, the same `getApplicablePolicies` filter and the same `RLSCompiler` an authored policy reaches. A non-lowering `using` is dropped with the same WARN and fails closed to `RLS_DENY_FILTER`, and #24451 pins that on the same predicate in both forms. The one runtime difference is that a default is never OR-ed with a sibling, because it is applied only to an empty collection. That makes the "this policy vanishes from the OR" branch impossible for a default, and the explanation says so. It is no reason for a different severity: the lint's bar for `error` is that no reading of the metadata behaves as written, and that holds for a default whose predicate cannot lower. **Ordering.** #24451 landed while this PR was in progress, and this branch has it merged, so the explanation's account of a dropped default is what `main` does now. ## Pins New file `packages/lint/src/validate-rls-predicate-enforceability.default-row-scope.test.ts`. Every object fixture passes `ObjectSchema.safeParse` first. 1. A default whose `using` does not lower is reported (`rls-predicate-unenforceable`, `error`, `objects[0].defaultRowScope.using`, the `where` above), and no part of the finding mentions a permission set. 2. A default `check` that does not lower is reported at `.check`, with the write consequence ("admits no write"). 3. The name-keyed map shape of `objects` is read too. 4. CONTROL: a lowering default (`select` and `all`), and an object with no default, report nothing. 5. Parity, five predicates (outside the pushdown subset, not CEL, an undeclared column, an unresolved `current_user` key, a list-holding key under `==`). The same predicate as a permission-set policy and as a default earns the same rule, severity, message and hint. Only `path` differs. 6. Both sources in one stack are reported, permission sets first. 7. The engine judge is handed a `select` default's `using` with the declaring object (`lead`, `{ operation: 'find' }`), and its refusal is reported at the default. CONTROL: an `update` default is not handed to it. 8. `runAuthoringRules('validate', …)` (the CLI registry door) reports the default under `rls-predicate-unenforceable`. 9. `os explain` for `-unenforceable`, `-unknown-field` and `-unknown-user-variable` carries the both-source wording. `validate-org-axis-red-lines.test.ts`: - new cases: a default `using` that walks the org parent is flagged (array form; `where` and `path` asserted; no "permission set" in it), a default `check` is flagged (map form), and CONTROL: a membership-scoped default is silent; - the structural meta-guards are extended, not loosened. The declared-key guard now expects `object` to read `defaultRowScope` / `name` / `systemFields` / `tenancy` (all on `ObjectSchema.shape`) and adds a `scope` receiver checked against `DefaultRowScopeSchema.shape`. The clause-list guard checks both clause loops against their schemas. The reachability guard expects four `findings.push` sites and has a spec-valid default-row-scope fixture reaching the new one. **Pin sweep.** I searched the whole repo for `rls-predicate-unenforceable` / `RLS_PREDICATE_UNENFORCEABLE` and for the reworded sentence (`disappears for every holder of the permission set`, `vanishes from the OR`). The two lint pins on the old sentence (`validate-rls-predicate-enforceability.test.ts`, the unknown-field and unknown-user-variable cases) still match, because the new text keeps that clause for the permission-set source. The new both-source assertions are in pin 9 above. The consumer pins (`packages/cli/test/rls-policy-authoring-admission.test.ts`, `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`) assert permission-set paths only, and they pass unedited. No object in the tree declares `defaultRowScope` outside tests (`git grep` over `examples/` and every non-test source), so no linted example changes verdict. **Ablation.** Run once on `ed2d826c13`, the first commit, after it was committed and before any merge (the line it mutates is unchanged since), then reverted, through `node scripts/ablation-replace.mjs` under a `trap` restore: - the change: `const scope = obj.defaultRowScope;` became `const scope = undefined as unknown;`; - the anchor went from 1 to 0, and the blob from `bbb07af6453f` to `7dbfe87353be`; - the new pin file then gave **11 failed / 3 passed**. The 3 passing are exactly the controls (lowering default, non-read-scope default, explain wording); - the restore put the blob back at `bbb07af6453f`, equal to `HEAD`, with `git diff HEAD` empty; - the observed direction was red, as expected. The tests import the rule from source (`./validate-rls-predicate-enforceability.js`, no `exports` hop), so no `dist` rebuild was involved. ## Tests and gates Every reading below is on the final head `0db86b5479`. That head carries `origin/main` `45bec43cd` merged in (with #24451 and #24474), and every run went through `scripts/pm/os-verify-lock.sh`. Each exit code below is the command's own, captured before any pipe. - `pnpm --filter @objectstack/lint test`: **146 files, 6896 passed**, exit 0. `pnpm --filter @objectstack/lint typecheck`: exit 0. That run includes `check:test-typecheck`, so the new test file compiles under `tsconfig.test.json` with the debt ledger unchanged. - Consumers whose verdicts this rule feeds, run against the rebuilt `@objectstack/lint` `dist` (the closure was rebuilt by turbo on this head): - `packages/cli/test/rls-policy-authoring-admission.test.ts`: 39 passed; - `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`: 145 passed. - No example or app in the tree declares `defaultRowScope`, so no linted example changes verdict. - `@objectstack/spec`: - `test:repo`: 70 files, **1162 passed**, exit 0; - `src/data/object-default-row-scope.test.ts` and `src/system/metadata-form-zod-reconciliation.test.ts`: 86 passed; - `check:generated`: the only stale artifact was `content/docs/references/**`, from the describe edit, regenerated with `--fix` (only that one) and committed in `0db86b5479`. - Derived gate families: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives **110** commands for this diff (10 paths, merge base `45bec43cd`). All 110 were run on this head and every one exited 0. `--ran` reconciles: **110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN**. - The four roster gates the derivation marks as keeping their roster under one of this diff's directories also exit 0: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. - Declared to CI, not run locally: the whole-repo `pnpm lint` and the workspace type-check lanes. ### Surface beyond the claim (and why) The claim's file surface is `packages/lint` plus the changeset. Measurement put one more file in this PR's way. `ObjectSchema.defaultRowScope`'s describe (`packages/spec/src/data/object.zod.ts`, written by #24451) told authors: "Authoring checks do not judge this predicate, so keep it inside the lowerable subset". This PR makes that sentence false. The edit is two sentences. The describe now says `os validate` / `os build` / `os lint` judge the predicate, and that saving the object through the server does not run that check. The TSDoc says the same. With it come the three reference pages `gen:docs` regenerates and a `@objectstack/spec` patch line in the changeset. No schema shape, export or authorable key moved. ## Acceptance notes - **The runtime publish gate does not run these rules on an `object` write.** `validateRlsPredicateEnforceability` is registered `runtimeTypes: ['permission']` (`packages/lint/src/authoring-rules.ts`), and `validateOrgAxisRedLines` is `CLI_ONLY`. So a Studio, REST `/meta` or MCP `object` save with a non-lowering `defaultRowScope` is not refused at that door by this PR. The CLI commands (`os validate` / `build` / `lint`) do judge it. The rule's positional `objects[N]` path is what the gate's per-write locating keys on (`isLocatedOnAnotherEntry`), so a `permission` write is not blamed for an object's default. Widening `runtimeTypes` is outside this card's claim surface (`authoring-rules.ts`). It needs a measured reading of the door, and the documentation table in `content/docs/deployment/validating-metadata.mdx` that says this rule "reads `permissions[]`" would change with it. Not measured here, so not filed. Carrier: none. - **#24451's edit to `validate-org-axis-red-lines.test.ts`** (title and docblock) merged into this branch without conflict. - **Serial note.** PR #24474 (#23566), which edits other entries of `rule-explanations.ts`, has landed (`4bd7215a1`), and this branch merges `main` after it. The merge was clean. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 913fd6d commit df9d07e

10 files changed

Lines changed: 590 additions & 133 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/lint": patch
3+
"@objectstack/spec": patch
4+
---
5+
6+
fix(lint): the RLS predicate and org-axis rules judge an object's `defaultRowScope`
7+
8+
Clause-②: no
9+
10+
- **What changed.** `validateRlsPredicateEnforceability` now judges `objects[].defaultRowScope.using` and `.check` with the same passes it runs on a permission set's `rowLevelSecurity[]` policy, with the declaring object as the target. A default whose predicate does not lower, does not parse, overruns a parse bound, names a column the object does not declare, reads a `current_user` key nothing resolves, or (for a `select` / `all` read scope) is refused by the engine's filter admission is reported under the same id, at the same `error` severity, at `objects[N].defaultRowScope.<clause>`. `validateOrgAxisRedLines` rule ① (`org-axis-permission-inheritance`) also judges a default that reads `parent_organization_id`.
11+
- **A new authoring-time finding on a shape that was silent.** Until now these rules walked `permissions[].rowLevelSecurity[]` only, so `os validate`, `os build` and `os lint` passed such a default. The security plugin's row-policy collection hands an object's default to the same compiler an authored policy reaches, so a predicate that does not lower is dropped at request time and the object reads as zero rows for every caller the default applies to. A stack that declares such a default now fails these commands; the fix is the same rewrite the finding prescribes for a permission-set policy. A default that lowers, and an object with no default, report nothing new.
12+
- **How the finding reads.** `where` names the default and its object (`default row scope "NAME" of object "OBJECT"`), never a permission set. `os explain` for the RLS predicate ids now says what a dropped `using` does for both sources: a permission set's policy denies the holders of that set, and a default, applied only when the caller's sets carry no row policy for the object and operation, denies every caller it applies to.
13+
- **The key's own text.** `ObjectSchema.defaultRowScope`'s description said authoring checks do not judge its predicate; it now says `os validate`, `os build` and `os lint` judge it as they judge a permission set's policy, and that saving the object through the server does not run that check.

‎content/docs/references/api/metadata.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -969,7 +969,7 @@ Metadata query with filtering, sorting, and pagination
969969
| **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. |
970970
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules |
971971
| **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). |
972-
| **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 security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. Authoring checks do not judge this predicate, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. |
972+
| **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 security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. |
973973
| **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. |
974974
| **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) |
975975
| **actions** | `{ name: string; label: string \| Record<string, string>; description?: string \| Record<string, string>; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) |

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ const result = ApiMethod.parse(data);
173173
| **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. |
174174
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules |
175175
| **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). |
176-
| **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 security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. Authoring checks do not judge this predicate, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. |
176+
| **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 security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. |
177177
| **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. |
178178
| **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) |
179179
| **actions** | `{ name: string; label: string \| Record<string, string>; description?: string \| Record<string, string>; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) |

0 commit comments

Comments
 (0)