Skip to content

Commit 930fae9

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-17321-conversion-todo-channel
# Conflicts: # packages/metadata-protocol/src/protocol.ts
2 parents 755acf3 + 2dccb7d commit 930fae9

90 files changed

Lines changed: 5288 additions & 388 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/formula": minor
3+
"@objectstack/plugin-security": minor
4+
"@objectstack/plugin-sharing": minor
5+
"@objectstack/lint": minor
6+
"@objectstack/objectql": minor
7+
"@objectstack/spec": patch
8+
---
9+
10+
A row-level or sharing-rule predicate whose comparison is handed something other than one value is refused at the CEL lowering or at the write-check evaluator, instead of admitting writes and reads it was written to refuse (#19886).
11+
12+
**BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing`, `@objectstack/lint` and `@objectstack/objectql` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-one-value-comparand-refused`.
13+
14+
Clause-②: no (narrowing)
15+
16+
**Security fix for RLS write checks and reads.** Each shape below was measured through the real plugin-security on driver-sql and driver-memory:
17+
18+
- `!(record.status in [['closed', 'archived']])` (a list nested in an `in` list) admitted and stored every write the `check` was written to refuse, and a `using` read returned every row on driver-memory.
19+
- `current_user.org_user_ids != 'x'` and `current_user.org_user_ids > 'a'` (a membership set on a comparison with no field) folded to "no restriction": every write admitted, every row read, on every driver.
20+
- `record.status > ['m']` compared the list as the string `'m'` on the write check, while the analytics read scope bound the whole list as one SQL parameter. `record.reviewer_id > current_user` compared the whole caller object as a string and admitted and stored every write; in this release the RLS compiler's comparand faces (#20212) already drop that policy, and this change refuses it at the lowering for every caller of the compiler.
21+
- `record.status != record.tags`, its negation `!(record.status == record.tags)`, and the mirror `record.tags != record.status`, with `tags` a `json` field or a `multiple` lookup, admitted and stored every write.
22+
23+
What changes:
24+
25+
- `@objectstack/formula`: `compileCelToFilter` refuses, with `unsupported`, a list comparand under every comparison (the ordering operators now included, and on the constant-fold branch, whichever side), the `current_user` root or a key resolving to an object under an ordering operator, and an `in` list whose member is itself a list. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports each literal form; a resolved value is refused per request. `matchesFilterCondition` refuses, with `INVALID_FILTER` / 400, an array under `$gt` / `$gte` / `$lt` / `$lte`, an array member of `$in` / `$nin`, and a `{ $field }` comparison (`$eq`, `$ne` or an ordering operator) whose column holds a list or an object on the record being judged, on either side. The message withholds the field, the operator and the value.
26+
- `@objectstack/plugin-security`: the RLS compiler drops a policy the compiler refuses and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403, and `getReadFilter` hands the analytics read scope the deny scope). A `check` comparing a field with a list-holding column is refused 400 and stores nothing.
27+
- `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded.
28+
- `@objectstack/lint`: the literal forms are reported as `rls-predicate-unenforceable`, and an ordering comparison against a membership set through the reference pass.
29+
- `@objectstack/objectql`: a `having` comparison against a `{ $field }` column whose aggregated row holds a list is refused 400 where the row carries the list itself (driver-memory); driver-sql rows carry the stored JSON text and compare as before.
30+
- `@objectstack/spec`: the migration registry carries the entry.
31+
32+
The stage 2a changeset's sentence that `{ $field }` references evaluate as before no longer holds for a column holding a list or an object: that comparison is now refused.
33+
34+
**What to change.** "One of these values" is `record.status in ['open', 'pending']`, and "none of these values" is `!(record.status in ['closed', 'archived'])`, with the list flat. An ordering takes one bound (`record.status > 'm'`); a range is two comparisons joined by `&&`. Compare against one key of the caller (`record.reviewer_id > current_user.id`). A field compared with a `json` or `multiple` field has no pushdown form: compare with a single-valued column, or move the condition into a validation rule or hook. In a raw filter, use `$in` / `$nin` with flat lists and one bound per ordering operator.
35+
36+
Not changed: a field compared with a `json` or `multiple` field still lowers and is not reported at authoring time, because the lowering sees the predicate's text and not the object's field types; driver-memory still answers a `{ $field }` comparison on a read without evaluating the reference.
37+
38+
<!-- adr-0087: registered cel-predicate-one-value-comparand-refused -->
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec): `InlineAction`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed` name the shapes their TSDoc promises instead of being `unknown` (#19920)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING for TypeScript code that annotates with `InlineAction`, `ViewMetadataParsed`, `AssembledViewArtifact` or `AssembledViewArtifactParsed`**: a narrowing of published TYPES, landing in the launch window as `minor` (the lockstep convention: the bump level is not the carrier, this banner and the disposition below are). The runtime accept set does not move at all: no schema, no parse and no export changes, and neither does the declared type of any schema.
10+
11+
Four published type aliases were derived from a schema whose own static type erases to `unknown`, so any value type-checked against them. Each is now derived from the member schema the parse actually runs:
12+
13+
- `InlineAction`: FROM `z.input<typeof InlineActionSchema>` (`unknown`, because the schema is a `z.preprocess` whose input is the preprocess function's `unknown` parameter) TO `z.input<(typeof InlineActionSchema)['out']>`, the input type of the picked action object.
14+
- `ViewMetadataParsed`: FROM `z.infer<typeof ViewMetadataSchema>` (`unknown`, because the union's members are cast to `z.ZodTypeAny` where it is built) TO the union of the OUTPUT types of `VIEW_METADATA_MEMBERS`, the same record `ViewMetadata` reads its input types from. `diagnoseViewMetadata` keeps returning the schema's own parse output as `data`; only that value's static type changes.
15+
- `AssembledViewArtifact` / `AssembledViewArtifactParsed`: FROM `z.input` / `z.infer` of `AssembledViewArtifactSchema` (`unknown`, the same cast) TO the input / output union of the three non-container `VIEW_METADATA_MEMBERS`, the members that schema's union is mapped from. A container body is now a compile error here, as it always was at the schema.
16+
17+
**If your code stops compiling.** A value you annotated with one of these names is not the shape the name describes: correct it, or type a value that is still unvalidated as `unknown` and let the schema's `safeParse` decide. For `InlineAction`, the legacy `type: 'navigation'` and `to` spellings are refused by the type while `InlineActionSchema` still folds them onto `url` / `target`: write `type: 'url'` and `target`.
18+
19+
The types are the members' declared shapes, not the schemas' verdicts. Each schema still accepts some bodies its type refuses (the preprocess folds and strips) and still refuses some bodies its type admits (refinements are not types), so the schema remains the only judge.
20+
21+
`JoinedReportBlock` is not changed by this change, and still resolves to `unknown`.
22+
23+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes moves — no spec key, no export and no stored row changes and every runtime accept set is unchanged, so `objectstack migrate meta` has nothing to reach — and only TypeScript annotations narrow, whose channel is the consumer's compiler. -->

‎.changeset/19953-rls-check-default-composition-text.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Text only. No schema shape, accepted value or runtime behaviour changes.
1616

1717
A policy is applicable when it is not `enabled: false`, its `object` is the written object or `'*'`, its `operation` is the write's own or `'all'`, and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated.
1818

19-
The check runs on the new row of a single-record insert and of a by-id update. An array insert and a `multi: true` update are not post-image checked; those are tracked in #19964 and #19950, and the texts now say so instead of implying every insert and update is checked.
19+
When this text change was written, the check ran on the new row of a single-record insert and of a by-id update only, and the texts said so. The same release extends it: every row of an array insert and of a `multi: true` update is judged (#19964, #19950), and a by-id update is also judged on the row its `beforeUpdate` hooks leave (#19989). The texts now state that every row an insert or an update writes is judged (#19967).
2020

2121
- **`@objectstack/spec`**: the `check` describe and TSDoc state this composition and that scope. The `rowLevelSecurity[].priority` refusal no longer gives "applicable policies OR-combine (most permissive wins)" as its reason, which is not true of the write check, and the file overview limits "OR-combine" to reads. The generated reference pages (`references/security/rls`, `references/security/permission`) are regenerated from the describe.
2222
- **`@objectstack/lint`**: the `rls-predicate-*` findings on a `using` now also say what the dropped `using` does to an insert. On an `insert` or `all` policy, when no applicable policy for the insert declares a `check`, that `using` is also the single-record insert check. If nothing else in that set compiles, every single-record insert the policy governs is refused with `PermissionDeniedError`. The findings on a `check` now say the refusal is a blanket one only when no other applicable policy declares a `check` that compiles.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): the RLS `using` / `check` texts say what the write gate enforces today — every row an insert or an update writes is checked, a check-only `update` policy is legal, and an `insert` policy's `using` is its check when none is declared (#19967)
6+
7+
Clause-②: no
8+
9+
Text only. No schema shape, accepted value or runtime behaviour changes.
10+
11+
- **`RowLevelSecurityPolicySchema.check`**: the describe and TSDoc said the check ran on the new row of a single-record insert or a by-id update, and that an array insert and a `multi: true` update were not post-image checked. Every insert and update shape is now judged: 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 change set as sent.
12+
- **`RowLevelSecurityPolicySchema.using`**: the describe called it a filter for SELECT/UPDATE/DELETE and "optional for INSERT-only policies", and the TSDoc said UPDATE requires it. It now says, per operation, which rows it admits, that it stands in as the check on an insert or update when no applicable policy declares `check` (on an `insert` policy that is its only effect), that a `select` or `delete` policy needs it, and that an `insert`, `update` or `all` policy may declare `check` alone.
13+
- **The "at least one of `using` or `check`" refusal**: its head is unchanged. It no longer says an UPDATE policy must provide `using` or that an INSERT policy must provide `check`. It names what each operation takes.
14+
- **OR-combination**: the schema overview, the `priority` tombstone notes and the `os migrate meta --from 16` prose for the `priority` removal no longer say applicable policies OR-combine with "most permissive wins" without qualification. That holds on reads. On a write, the check is chosen per operation across the applicable policies first, and the chosen predicates then OR-combine.
15+
- **Default deny**: the overview now limits "default deny" to the policies that apply. A caller to whom no policy applies is not restricted by the policies; the tenant wall still applies.
16+
- The generated reference pages (`references/security/rls`, `references/security/permission`) and `docs/protocol-upgrade-guide.md` are regenerated from these sources.

‎.changeset/20156-alternate-door-read-gates.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ What each door answers now:
1313

1414
- `/published` answers exactly what the plain read answers the same caller, for every type: the same refusal, or the same pruned or masked document. That includes the dashboard widget gate: a widget bound to an optional service this deployment does not register is left out of `/published`, as it is from the plain read.
1515
- `/layers`, `?layers=true` and `/diff` refuse a `doc`, `book` or `app` the plain read refuses whole, with the same status and code. For an app, that means one whose `requiredPermissions` the caller lacks (`403`) or an unpublished app to a non-builder (`404`). They mask object fields as the plain read does. `/diff` of a `doc`, `book` or `app` with nothing behind the name answers `404 RESOURCE_NOT_FOUND`, as the plain read does. They do not apply the dashboard widget gate or any other per-deployment gate: they show the stored version, which is what an author edits.
16-
- **One case is unchanged in this release: an app the plain read would serve a caller only in part.** That is an app the caller may open, but with navigation entries withheld from them by `requiredPermissions` or the documentation audience. On `/layers`, `?layers=true` and `/diff`, such a caller still receives the stored app with those entries included, as before. No other caller does: an app the plain read refuses whole is refused on these doors too. What these doors should answer a partial caller is pending a decision, because both ways of matching the plain read restrict authors. Pruning the stored app would delete the withheld entries when Studio's designer saves back what it loaded. Refusing it would lock out any author who lacks one entry's permission, platform admins included.
16+
- For an app the caller may open, `/layers`, `?layers=true` and `/diff` answer by who is asking. A caller who may save the app (the one `PUT /meta/app/:name` admits) receives the full stored version, including the navigation entries that `requiredPermissions` or the documentation audience withhold from them: Studio's designer saves back what it loads, so a pruned load would delete those entries. Every other caller receives the app without the entries withheld from them, left out as the plain read leaves them out, on every layer and on both sides of a diff. The plain read and `/published` prune for every caller, authors included. An app the plain read refuses whole is refused on these doors to an author too.
1717
- `/history` and `/audit` answer the plain read's refusal when the plain read refuses the doc, book or app whole. Otherwise they serve the events, which carry no document body.
1818

19-
Types no per-caller gate judges, such as `view` and `flow`, are unchanged on every door. `dashboard` is unchanged on every door except `/published`. A caller the plain read serves in full gets the same answers as before. A client reading these doors as a caller the plain read restricts, including an integration reading `?layers=true` anonymously, now receives the plain read's answer, except the `app` row of `/layers`, `?layers=true` and `/diff` for a caller the plain read serves only part of the app. To read a gated doc or book through any of these doors, hold the permission set its book names.
19+
Types no per-caller gate judges, such as `view` and `flow`, are unchanged on every door. `dashboard` is unchanged on every door except `/published`. A caller the plain read serves in full gets the same answers as before. A client reading these doors as a caller the plain read restricts, including an integration reading `?layers=true` anonymously, now receives the plain read's answer, except that a caller who may save an app reads it whole on `/layers`, `?layers=true` and `/diff`. To read a gated doc or book through any of these doors, hold the permission set its book names.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
**The stored-version doors of an app answer by who is asking: whoever may save the app reads it whole, everyone else reads it pruned.** `GET /api/v1/meta/app/:name/layers`, the deprecated `?layers=true` and `…/diff` serve the versions Studio's designer loads and saves back. Until now they served an app the caller may open as stored to every such caller, including the navigation entries that `requiredPermissions` or the documentation audience withhold from them, which exposed those entries' names and targets to members the plain read hides them from.
6+
7+
- A caller who may save the app receives the full stored version on these three doors, so a designer that saves back what it loaded keeps every entry. "May save" is exactly what `PUT /meta/app/:name` admits that caller: a system context or `manage_metadata`. `manage_org_presentation` does not save apps, so it does not qualify.
8+
- Every other caller who may open the app receives it without the entries `requiredPermissions` or the documentation audience withhold from them, left out as the plain read leaves them out: on each layer, and in the values on both sides of a diff. A diff keeps all of its entries; only their values are pruned.
9+
- Unchanged: the plain read and `/published` still prune for every caller, authors included. An app the plain read refuses whole (an app-level `requiredPermissions` the caller lacks, or an unpublished app to a caller without Studio or Setup access) is still refused on every door, to an author too. `/history`, `/audit`, and the doc, book and dashboard answers do not change.
10+
11+
A client that reads these doors as a non-author now receives fewer navigation entries. To read an app's full stored version there, read it as a caller the app's save door admits.
12+
13+
For code that runs the shared read gate: `MetaReadGatePolicy.app` is `'gate'` or `'author-exempt'`, and a door that passes `'author-exempt'` supplies the caller's save verdict as `MetaReadGateCaller.mayWriteItem`.

0 commit comments

Comments
 (0)