Skip to content

Commit 9cfc312

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20390-forward-conversion-window
2 parents 2c537b7 + 3cf6449 commit 9cfc312

88 files changed

Lines changed: 4885 additions & 996 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: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
'@objectstack/formula': minor
4+
'@objectstack/driver-sql': patch
5+
'@objectstack/lint': patch
6+
'@objectstack/spec': patch
7+
---
8+
9+
fix(security)!: the RLS write check refuses a field-to-field comparison the read refuses — one comparison class, one answer per policy (#20355)
10+
11+
Clause-②: yes (narrowing)
12+
13+
<!-- adr-0087: registered rls-predicate-cross-class-field-comparison-refused -->
14+
15+
**BREAKING** — an accept-set narrowing on the row-level write check, shipped as `minor`
16+
under the launch-window convention (`check-changeset-no-major` refuses `major` until GA;
17+
breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the
18+
level). The hand-migration prescription is registered under protocol major 18 as
19+
`rls-predicate-cross-class-field-comparison-refused`, one ADR-0087 D3 entry for the whole
20+
family: the authoring arm `os validate` gained in #20347 and this write-check arm.
21+
22+
**What changed.** A row-level policy that compares two fields of no shared comparison
23+
class — `record.status != record.amount` (text and a number), `record.status !=
24+
record.photo` (text and a file field), `record.status != record.is_open` (text and a
25+
formula field), `record.status != record.meta` (text and a json field) — already had
26+
every read it scopes refused with `INVALID_FILTER` / 400 on the SQL drivers, because
27+
driver-sql compiles a column-to-column comparison only within one class. The write
28+
check did not know the rule: it compared the two raw values in-process, so an insert
29+
or update the policy's `check` judges (or its `using`, standing in as the check) was
30+
admitted and stored whenever that comparison happened to hold. Measured through
31+
plugin-security and ObjectQL on SQLite, sqlite-wasm and PostgreSQL. The write check
32+
now refuses the comparison too, with the read's envelope, `INVALID_FILTER` / 400, for
33+
every insert (single or array), by-id update and predicate update it judges, and
34+
nothing is stored. The same-class comparisons it always compared are compared as
35+
before. The 400 names no column of the policy; the server log names the policy and
36+
both columns. A comparison against a json or `multiple` field is refused by its
37+
declared type now, where it used to be judged by the value each record held.
38+
39+
**`@objectstack/formula`.** `matchesFilterCondition(record, filter, options?)` takes an
40+
optional third argument: `options.fields`, the object's declared columns (`type` and
41+
`multiple` per field name). Given it, every `{ $field }` comparison between two
42+
declared columns is judged by `crossFieldComparisonVerdict` from
43+
`@objectstack/spec/data` before any record is read, and one the platform defines no
44+
answer for throws `INVALID_FILTER` / 400. Without it the evaluator behaves exactly as
45+
before. Two new exports go with it: `findCrossFieldClassRefusal(filter, fields)`, the
46+
pure judgement, and `crossFieldClassRefusalCarriedBy(error)`, which reads the refused
47+
comparison off the error for a server-side log.
48+
49+
**`@objectstack/driver-sql`.** `crossFieldComparisonClass` reads the same export
50+
(`crossFieldColumnVerdict`) instead of keeping its own copy of the classification, and
51+
layers above it only its internal type aliases. Every read answers as before.
52+
53+
**`@objectstack/lint`.** The `rls-predicate-unenforceable` finding for such a
54+
comparison now states the write answer the runtime gives: the in-process write check
55+
refuses it by the same classification and stores nothing.
56+
57+
**If a policy of yours is refused.** The platform defines no comparison between those
58+
two columns on any path, so the policy never protected a read either. Compare a field
59+
only with a field of the same class — a number with a number, text with text, a
60+
boolean with a boolean, a date with a date, a datetime with a datetime, a time of day
61+
with a time of day — or, if the two columns do hold comparable values, correct the
62+
declaration of the one declared with the wrong type. `os validate` names every such
63+
comparison.
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+
**`GET /api/v1/meta/:type/:name/diff` and `GET /api/v1/meta/:type/:name/history` are now authoring doors: a caller without an authoring capability is refused, as `GET /api/v1/meta/_drafts` refuses.** Before this release, any signed-in caller who could open an item could read its version diff and its change history. Both doors read the metadata version log, which records a draft save exactly as it records a published save. So a member could read an item's unpublished draft through `/diff`, either by naming the draft save's version in `from`/`to` or through the default range once a draft was pending. Through `/history`, the same member could read the draft-save events. This follows the maintainer's ruling on #20378 (letter B, comment 5865708652), which pulls both doors back into the declared contract: draft and preview reads are admin-gated upstream (ADR-0106 D4). It narrows the earlier ruling that let every caller who may open an app read `/diff` pruned, for these two doors only.
6+
7+
Clause-②: no
8+
9+
- **Who may read them:** a system context, or a caller holding `studio.access`, `setup.access` or `manage_metadata`. This is the predicate `/meta/_drafts` and every draft switch already ask, not a second rule.
10+
- **Everyone else:** `403` with code `FORBIDDEN`, in the same nested `error` envelope `/meta/_drafts` answers. The refusal is decided on the caller before the query is parsed and before any item or version is read. So it is the same answer for an item that exists, one that does not, and one that exists only as a draft, and it carries no item name, version or event. The message names the door, not drafts.
11+
- **Unchanged:** callers with an authoring capability read both doors exactly as before, per-caller pruning included: on `/diff`, whoever may save an app reads both sides whole, and any other admitted caller reads them pruned. `/layers` and the deprecated `?layers=true` read the active row, so they keep answering every caller who may open the app with the pruned plain-read answer. `/audit` is unchanged.
12+
13+
A client that read `/diff` or `/history` as a member now receives `403 FORBIDDEN`. To read them, call as a caller holding one of the three capabilities above.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): `diffMetaItem`'s default range labels its to side with the active row's own version, so `GET /meta/:type/:name/diff` with no `from` / `to` names the versions it compares while a draft is pending (#20397)
6+
7+
With no `toVersion`, the to side is the current active `sys_metadata` row. Its body was compared, but `toVersion` came from the newest `sys_metadata_history` row, which is a draft save whenever a draft is pending: every draft save appends a history row. The labels and the bodies then named different rows. Measured on the real REST stack, an app with one active save and two draft saves answered `fromVersion 2 → toVersion 3` over its version-1 body, and a view with one active save and one draft save answered "no changes" labelled `1 → 2` while version 2 differs.
8+
9+
- **Now:** `toVersion` is the active row's own `version`, read in the same read as its body. The default `fromVersion` is still the history version immediately before that label. An item whose active row is version 2 with a draft pending answers `1 → 2`, the same answer as `?from=1&to=2`.
10+
- **No active row** (a draft-only item, or a deleted one): the to side is absent, and both labels are `null` with empty buckets, as the response schema declares for an absent side. Before, a draft-only item was labelled with its newest draft save, and its from side could be an earlier draft save's body. A deleted item was labelled `N-1 → N` up to its tombstone. That deletion is still read by naming its versions (`?from=N-1&to=N`).
11+
- Unchanged: the response shape, explicit `from` / `to` ranges, and the default range of an item with no draft pending.

0 commit comments

Comments
 (0)