Skip to content

Commit dce1c8e

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/audit-auth-failures
Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r Co-authored-by: Claude <noreply@anthropic.com>
2 parents 6eb4efa + 74f7815 commit dce1c8e

28 files changed

Lines changed: 1883 additions & 477 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): `security.explain` answers an on-behalf-of caller's by-id update and delete the way the write itself does
6+
7+
For a principal acting on behalf of a user (an OAuth agent), explain's record-grained `update` / `delete` verdict now composes what the by-id PATCH / DELETE composes (ADR-0090 D10):
8+
9+
- record sharing's per-record gate is asked twice, as the write asks it: as the delegated principal at its agent-leg write depth, then as the delegator at the delegator's own write depth;
10+
- the delegator's own row-level write filter is ANDed beside the principal's, as the write's pre-image check ANDs it.
11+
12+
Before, explain asked the gate once at owner-only depth and left the delegator's write filter out, so it could refuse a row the write admits and admit a row the write refuses. Ordinary (non-delegated) callers are unaffected, and so is the delegated read verdict.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/service-storage': minor
3+
'@objectstack/plugin-audit': minor
4+
'@objectstack/spec': minor
5+
---
6+
7+
feat(service-storage, plugin-audit): the attachment and comment gates judge "may edit the parent record" by the parent's own by-id update alone
8+
9+
Clause-②: yes (widening)
10+
11+
A caller who can update the parent record by id can also attach files to it and moderate its comments; a caller who cannot, still cannot. The gates only stop refusing what the CRUD path already admits.
12+
13+
- **What widens.** The `sys_attachment` parent gate (`@objectstack/service-storage`: attaching, detaching or editing another user's attachment, re-pointing one, and `assertMayAttach`) and the `sys_comment` moderation gate (`@objectstack/plugin-audit`: editing or removing another person's comment) now ask one question about the parent record: the security service's record-grained judgement of a by-id `update` of it (`security.explain` for `update` on the parent, admitting only `allowed === true` and `record.visible === true`). They no longer ask record sharing at owner-only depth first, nor the master-detail check: both are part of that judgement already. So a caller whose write depth on the parent object is `unit`, `unit_and_below` or `org` may now attach and moderate wherever that depth lets them update the parent, and so may a caller an app-authored row-level update policy admits.
14+
- **What does not move.** A caller the parent's by-id update refuses is refused, with the same codes as before (`ATTACHMENT_PARENT_ACCESS`, `ATTACHMENT_DELETE_DENIED`, `RECORD_NOT_ACCESSIBLE`, all 403), and a caller who cannot read the parent still gets the not-visible refusal. A `controlled_by_parent` parent is still judged through its master. Uploaders keep their own attachments and authors their own comments. Posting a comment stays a read rule on the parent. A store fault keeps its own status.
15+
- **Kernels that judge no by-id update.** Where the security service serves no `explain` (or there is none), both gates keep exactly the composition they had: record sharing's tri-state edit verdict at owner-only depth, then the master-detail check. With no sharing service, a caller-scoped read of the parent is still asked first.
16+
- **`@objectstack/spec`.** The `ISharingService` contract prose ("Write DEPTH is an input the CALLER supplies") now records that the parent-record gates ask the security service's by-id update judgement instead of this service, and why they never resolve the parent's depth themselves. No type, member or export changes.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/example-showcase': patch
3+
---
4+
5+
fix(showcase): a paid invoice's lines can no longer be added, removed or moved, so its total stays what was paid
6+
7+
Clause-②: no (narrowing) (the showcase refuses two writes it accepted; if the measurement shows the platform half would relax a refusal a faulting rule makes today, the dev stops and reports before building it, and the seat re-declares)
8+
9+
<!-- adr-0087: not-required (unpublished) @objectstack/example-showcase is private: no npm release carries the refused writes, and no author-written key was removed or renamed -->
10+
11+
`readonlyWhen: parent.status == 'paid'` on a line's `product`, `quantity` and `unit_price` locks EDITS of a line under a paid invoice, but it is insert-exempt by design and judges field values, not whether a line exists. So `POST /api/v1/data/showcase_invoice_line` under the paid INV-1003 answered `201`, a `DELETE` of its line `200`, and a `PATCH` moving a line's `invoice` onto or off it `200`, and the paid `total` moved every time.
12+
13+
Three hooks on `showcase_invoice_line` (`src/data/hooks/index.ts`) now refuse each of those writes with a sentence (`400`): `showcase_guard_paid_invoice_line_add` (beforeInsert), `showcase_guard_paid_invoice_line_move` (beforeUpdate) and `showcase_guard_paid_invoice_line_remove` (beforeDelete). Each reads the header invoice under `runAs: 'system'`. A system write passes (the seed loads INV-1003's line under a header that is already paid), and so does the cascade delete of a deleted invoice's lines. A draft or sent invoice's lines add, edit, move and remove as before.

‎content/docs/permissions/attachments-access.mdx‎

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,30 +43,30 @@ independent of the record-level checks below.
4343
Creating a `sys_attachment` requires that the caller may **edit** the parent
4444
record — Salesforce edit-on-parent parity — and "may edit" has one meaning
4545
here: **a by-id update of that parent record, by the same caller, would be
46-
admitted.** The gate asks three questions in order and admits only when all
47-
three do:
48-
49-
1. record sharing (`ISharingService.checkEdit`);
50-
2. the master-detail check, for a `controlled_by_parent` parent
51-
(`ISecurityService.checkControlledByParentWrite`);
52-
3. the security service's judgement of that update — the record-grained verdict
53-
`security.explain` reports for `update` on the parent record (`allowed` and
54-
`record.visible` both).
46+
admitted.** The gate asks exactly that question, of the security service: the
47+
record-grained verdict `security.explain` reports for `update` on the parent
48+
record (`allowed` and `record.visible` both). That verdict is the write path's
49+
own composition — the parent object's grant, its row-level security, its record
50+
sharing at the caller's write depth, the master of a `controlled_by_parent`
51+
parent, and both legs of an on-behalf-of (agent) request — so a caller who can
52+
update the parent record by id can also attach files to it, and a caller who
53+
cannot, still cannot. A caller whose write depth on the parent object is
54+
`unit`, `unit_and_below` or `org` attaches wherever that depth lets them update.
5555

5656
The same rule governs every check that asks about the parent: attaching,
5757
detaching or editing another user's attachment, re-pointing an attachment to
5858
another record (judged on the new parent), and the file-attachments service's
59-
caller-scoped `assertMayAttach`. It never admits more than the parent's update
60-
does, and it can refuse more: record sharing is asked at the narrowest edit
61-
depth for these gates (the ruling recorded on `ISharingService`, "Write DEPTH
62-
is an input the CALLER supplies").
59+
caller-scoped `assertMayAttach`.
6360

6461
When no sharing service is wired, a caller-scoped read of the parent (a
65-
`findOne`) stands in for step 1. A kernel with no security service asks no
66-
update judgement, as the parent's own update meets none there. A store fault in
67-
step 2 or 3 keeps its own status (a `503` stays a `503`); it is never reported
68-
as a refusal. `uploaded_by` is **server-stamped** from the session; a
69-
client-supplied value is ignored.
62+
`findOne`) is asked first. A security service that judges no by-id update (it
63+
serves no `explain`, or there is none) leaves the gate on the composition it
64+
had before: record sharing (`ISharingService.checkEdit`) at the narrowest edit
65+
depth, then, for a `controlled_by_parent` parent, the master-detail check
66+
(`ISecurityService.checkControlledByParentWrite`). A store fault keeps its own
67+
status (a `503` stays a `503`); it is never reported as a refusal.
68+
`uploaded_by` is **server-stamped** from the session; a client-supplied value
69+
is ignored.
7070

7171
| Code | Status | When |
7272
| --- | --- | --- |

‎docs/adr/0047-object-ui-run-modes.md‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,26 @@
2424
> (need named presets on an object → use `listViews`). Enforced by a new
2525
> `ObjectUserFiltersSchema` (element narrowed to `dropdown`/`toggle`, `tabs`/
2626
> `showAllRecords` omitted), which `ObjectListViewSchema` now `.extend`s back in.
27+
>
28+
> **Amendment (2026-10-20) — `filterableFields` is retired in 18.0, with no
29+
> replacement key and no rewrite (#24640: the maintainer ruled 「C」, record
30+
> 6278975049, and pulled it into 18.0 with 「提前」, record 6279033298;
31+
> executed by PR #24721, `4bc3becd5`).** §3.3 item 1 below said
32+
> `filterableFields` "stays as the legacy shorthand; loaders auto-upgrade it to
33+
> `userFilters.fields` entries". That loader was never built: on the tree the
34+
> retirement replaced, the key was declared, offered as a Studio form input and
35+
> checked for field references, and nothing rewrote it into
36+
> `userFilters.fields`; its one renderer, objectui's `ListView`, read it as the
37+
> Filter panel's field whitelist instead. The ruling retires the key with no
38+
> replacement key: the list-view shapes (`ListViewSchema`,
39+
> `ObjectListViewSchema`) refuse it at parse with a `retiredKey()` tombstone
40+
> that names the fix, and the conversion `view-list-filterable-fields-removed`
41+
> drops it from stored views. No conversion rewrites it to
42+
> `userFilters.fields`: that rewrite keeps nothing an author saw — the
43+
> whitelist would vanish and quick-filter chips would appear (record
44+
> 6278907207). A curated end-user filter bar is authored as `userFilters`.
45+
> §3.3 item 1's sentence is struck in place, not deleted; §4 already called
46+
> the alias a one-release courtesy, not a commitment.
2747
**Deciders**: ObjectStack Protocol Architects
2848
**Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (one Zod source per type, org overlay), [ADR-0017](./0017-object-has-many-view.md) (independent view entities, `viewKind`), [ADR-0019](./0019-app-as-consumer-unit.md) (App is the consumer-facing unit — navigation decides what users see), [ADR-0027](./0027-metadata-authoring-lifecycle.md) (draft · publish lifecycle), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (**AI is the long-term author of metadata — the design center this ADR inherits**)
2949
**Consumers**: `@objectstack/spec` (view + page Zod schemas), `@objectstack/objectql` (registry validation/diagnostics), `../objectui` (console `ObjectView` / `PageView`, `plugin-list`), framework templates (`hotcrm`, `app-showcase`), the `objectstack-ui` authoring skill
@@ -151,8 +171,10 @@ have; we do not rely on lint to forbid what the type can simply not express.
151171

152172
Attached as `ListViewSchema.userFilters` (view-level convenience) and
153173
`InterfacePageConfigSchema.userFilters` (the primary home).
154-
`filterableFields` stays as the legacy shorthand; loaders auto-upgrade it
155-
to `userFilters.fields` entries.
174+
~~`filterableFields` stays as the legacy shorthand; loaders auto-upgrade it
175+
to `userFilters.fields` entries.~~ *(Struck by the 2026-10-20 amendment —
176+
`filterableFields` retired in 18.0 by ruling C on #24640, with no
177+
replacement key and no rewrite; see the status block above.)*
156178

157179
2. **Runtime visualization whitelist.**
158180
`UserActionsConfigSchema.visualizations?: z.union([z.boolean(), z.array(ViewTypeEnum)])`

0 commit comments

Comments
 (0)