Repository navigation
Commit 490d136
fix(plugin-security): every member reads and posts comments, and reads the activity history, of the records they can read (#30222)
<!-- claude-projects-attribution: {"github_login":"os-bill"} -->
_Requested by **Bill** · [project
thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgFMKmBZQVKU38CQ6a3dqyHp)_
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL
## 维护者速读
- **改了什么:** 平台基线 `member_default` 加两行授权:`sys_comment`(读、建、改)和
`sys_activity`(读)。没有删除位、没有行策略、没有导出。
- **为什么改:**
评论和动态默认开在每个对象上,却没有任何出厂权限集授予这两个对象,新应用的普通成员在自己能看的工单上也只看到「没有权限」,不能评论,也看不到谁改了什么。您已「同意
A」。
- **风险与回滚:**
看得到哪些行仍由父记录门决定:成员只能看、只能评论自己能读的记录;外部成员拿不到这个基线,不受影响;`enable.feeds: false`
的对象仍拒评论;动态读取不刷拒绝日志。8 条消融全部转红后还原。回滚即撤本 PR。
- **先看:**
`packages/plugins/plugin-security/src/objects/default-permission-sets.ts`
新增的两行和上方注释,再看 `member-default-feed-grants.integration.test.ts`。
- **两分钟验证:** `pnpm --filter @objectstack/plugin-security exec vitest run
src/member-default-feed-grants.integration.test.ts`,预期 20 条全绿。
- **需要您做的:** 无,席位落地。
Fixes #30108
Clause-②: no — runtime permission behaviour (human-floor
security/permission class, ruled by the maintainer), not a published
accept-set or public-surface widening
Metadata-protocol: none
## Why: the ruling, and its three conditions measured
> 「同意 A」 (maintainer, recorded on #30108 in comment 6633054352). The
platform baseline `member_default` grants `sys_comment` (read, create,
edit) and `sys_activity` (read). The parent-record gates decide which
rows.
| Condition (verbatim from 6633054352) | Measured on this branch |
Ablation that turns it red |
|:--|:--|:--|
| "The parent gates hold for an external member, because
`member_default` also reaches `everyone`." | **Holds; its premise is
corrected.** `member_default` does not reach an external member: a
holder of `org_external` is not given the `everyone` anchor
(`resolveAuthzContext`, step 5b, `@objectstack/core`), and
`humanBaselineFor` (`external-baseline.ts`) resolves `external_default`
in the baseline's place. On the real resolver an external with only its
baseline is refused both objects (`403 PERMISSION_DENIED`), so the grant
widens nothing for externals. An external holding an explicit grant that
names both objects reads and posts on its own ticket only: two other
tickets and a vault record serve no comment and no activity row, a by-id
read finds nothing, and a post to another ticket is refused (`403
RECORD_NOT_ACCESSIBLE`, nothing written). | A6 (external handed the
member baseline): the baseline-only external case goes red. A2, A3, A4:
the explicit-grant external cases go red. |
| "An object with `enable.feeds: false` still refuses on both verbs." |
**Holds.** The switch's gate, `enforceFeedsCapability` (plugin-audit
`audit-writers.ts`), is registered on two verbs: `beforeInsert`, and
since #10170 `beforeUpdate` for a re-thread. Those are also the two
write verbs this grant adds, and both still refuse a member: a comment
create answers `403 FEEDS_DISABLED` and writes nothing; moving the
member's own comment into that object answers `403 FEEDS_DISABLED` and
the row keeps its thread. Measured on a record the member reads and
edits, so the refusal is the switch and not the row scope. Reading is
not one of the switch's verbs, before or after this change: see
Acceptance notes. | A5 (feeds gate off): both cases go red. |
| "The grant does not bring back the per-object refusal log noise #23279
measured." | **Holds.** The console header's feed read (`find` on
`sys_activity`, top 20 by `timestamp` desc, then its `count`) as a
member logs **0** `[Security] Access denied` lines, and serves rows.
Armed twice in the same file: the member's own read of the vault logs
exactly 1 denial, and the same feed read with `canReadObject` withdrawn
from the security service logs denials naming the vault. | A4 (activity
gate off) reds the "rows only about readable records" half; the
`canReadObject` withdrawal is the in-file arm of the log-count half. |
No condition failed, so there is no fork. The grant is exactly the
ruling's: not narrowed, and option B not taken.
## What changes
**`@objectstack/plugin-security` — `member_default`**
(`src/objects/default-permission-sets.ts`) names two more objects:
```ts
sys_comment: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
sys_activity: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false },
```
- **Which rows is not decided here, and no row policy is added.**
Neither object has an owner or user column to scope by, and each row's
parent is a different object. plugin-audit's parent-record gates decide
the rows: `comment-access-hooks.ts` lists, reads and accepts a post only
on a thread whose parent record the caller can read;
`activity-read-visibility.ts` serves an activity row only when its
record is readable, and skips a parent object the caller cannot read
through `canReadObject`, without reading it as the caller.
- **Edit** stays under the wildcard `owner_only_writes` floor (the
`sys_comment_moderation` carve-out is the delete limb only), so a member
edits the comments they wrote and is refused on another member's (`403
PERMISSION_DENIED`).
- **No delete bit** on either (ADR-0090 D5: this set is bound to the
`everyone` anchor). Deleting a comment stays the moderation path,
through a delete bit an ordinary set grants.
- The comment above the inbox pair said "`sys_activity` is deliberately
NOT included … a separate question if it ever matters". It is rewritten
to say why it was left out of that pair (no `user_id` for a `_self`
policy) and that it is granted below on its own ruling, so the file no
longer contradicts itself.
- The new block cites the ruling (6633054352) and names both gates, the
capability switches, the anchor rule and the external member's baseline.
**Tests** (`packages/plugins/plugin-security/src/`):
- `member-default-feed-grants.integration.test.ts` (new, 20 cases): the
real `SecurityPlugin` with the shipped sets, the real `AuditPlugin`, SQL
over in-memory better-sqlite3, and contexts from the real
`resolveAuthzContext` and `assembleExecutionContext` over seeded
identity rows (`sys_member.role` `member` and `external`). The app set
names only the app's own objects, the shape the card measured. Blocks:
(1) a readable record: read, post (author stamped), edit own and refused
on another's, read history, no delete; (2) an unreadable record, both by
row scope (another requester's ticket) and by object (a vault the member
does not read): an unfiltered list serves none of its comments or
history and its `count` agrees, a by-id read finds nothing, a post is
refused and writes nothing; (3) external members, with and without an
explicit grant; (4) `enable.feeds: false`; (5) the feed read's
denial-log count, armed both ways.
- `member-default-explicit-allow.test.ts`: the pin "`sys_activity` is
deliberately NOT granted" is flipped to the new semantics and asserts
it: `sys_comment` read, create and edit and no delete; `sys_activity`
read only; no select- or insert-class row policy of the baseline on
either; the `sys_comment` edit limb is exactly `owner_only_writes`; no
export, view-all or modify-all; `external_default` names neither. The
inbox block keeps its "no `sys_activity_self`" assertion.
- `comment-reaction-own-record-floor.test.ts`: header prose only. Its
"(as an app grants `sys_comment` today)" now says the app grants the
delete bit and the baseline grants read, create and edit.
**Changeset:** `.changeset/30108-member-default-feed-grants.md`, `patch`
for `@objectstack/plugin-security`, both declaration lines.
## Acceptance notes
- **Reading comments on an `enable.feeds: false` object is not gated by
the switch**, before or after this change, and this PR does not change
that. It is by design: the switch closes new comments and re-points, and
a row written before the flag was flipped off stays workable (#10170's
header on `enforceFeedsCapability`); the record page suppresses the
discussion panel for such an object (`content/docs/ui/pages.mdx`). So
such an object holds only comments written before it was turned off, and
a caller who holds `sys_comment` read (whoever an app grants it to
today, every member after this PR) can list those through the API, under
the parent gate like any other row (read from the code: neither
`enforceFeedsCapability` nor `comment-access-hooks.ts` consults the
switch on a read; deliberately not pinned here, so a later decision is
free to change it). The dispatch's suggested route listed "refuses
create and read"; I read the ruling's "both verbs" as the switch's two
registered verbs above. If a read refusal is wanted, it is a change to
the switch in plugin-audit, not to this grant; listed for the seat as an
open question. `enable.activities: false` writes no activity row for its
object (measured: zero rows).
- **External members still get no comments or activity from the
platform.** Measured above; this PR widens nothing for them. A portal
app grants them explicitly, under the same gates.
- **Reactions are not in the ruling.** `member_default` does not name
`sys_comment_reaction`, so an ordinary member reads and posts comments
but the record page does not offer them the reaction control (it needs
create on `sys_comment_reaction`,
`content/docs/frontend/plugins/plugin-detail.mdx`). Listed for the seat;
not changed here.
- The record page and the Console are not touched: the card's own
measurement showed the timeline, comment box and mention delivery
working once these two grants are in a set.
## Verification
Suites and gates ran on the final tree, head `d22f921b`. The ablations
ran on `2086b2d8`, the change before the `main` merge; the merge
(#30165) changed three other plugin-security files
(`delegated-admin-gate.ts`, `security-plugin-authz-middleware.ts`, a
posture test) and nothing in plugin-audit, and the integration file
stays 20/20 on `d22f921b`.
<details><summary>Ablations (8 legs, each restored and proven by blob
hash against HEAD)</summary>
Driven through `scripts/ablation-replace.mjs` (WRAP mode; anchor hit
exactly once, mutation proven on disk, restore proven by `git
hash-object` == the HEAD blob and an empty `git diff HEAD`).
`@objectstack/plugin-audit` resolves to its source in this package's
vitest config, so no build sits between the mutation and the run.
| Leg | Mutation | Red cases |
|:--|:--|:--|
| A1 | the two grant lines removed (the pre-PR baseline) | 18 of 66
across the integration and explicit-allow files: every positive case,
every list/by-id/post case (refused at the object instead), both feeds
cases (refused at the object instead of `FEEDS_DISABLED`), the feed-read
cases |
| A2 | comment read gate's `andIn` skipped | 3: list, by-id read, the
explicit-grant external's comment list |
| A3 | comment create's parent-read check skipped | 2: the member's and
the external's post to an unreadable thread |
| A4 | activity read gate's `andIntoWhere` skipped | 4: list, by-id
read, the external's history, the feed serving vault rows |
| A5 | `enforceFeedsCapability` condition false | 2: create and
re-thread into the feeds-off object |
| A6 | external handed the member baseline | 1: the baseline-only
external reads both objects |
| A7 | `allowDelete: true` on `sys_comment` | 4: the integration
"deletes nothing" case, the new structural pin, and the pre-existing
"the set stays anchor-safe for `everyone`" pin |
| A8 | `sys_comment_moderation` widened from `delete` to `all` | 4: the
"edits … not one somebody else wrote" case, plus three cases the `all`
class reaches through insert and select (observed direction: more red
than the one targeted) |
Full output: project file `server-seat2/30108/ablations-ran.txt`.
</details>
<details><summary>Suites</summary>
Before the `main` merge (head `2086b2d8`): `pnpm --filter
@objectstack/plugin-security test`: Test Files 243 passed (243), Tests
5312 passed, 45 skipped. `pnpm --filter @objectstack/plugin-audit test`:
Test Files 70 passed (70), Tests 1128 passed.
After merging `main` (`474c030b`) into the branch, on head `d22f921b`,
under `scripts/pm/os-verify-lock.sh`: `pnpm install` exit 0; the
package's build closure exit 0; `pnpm --filter
@objectstack/plugin-security test`: Test Files 243 passed (243), Tests
5316 passed, 45 skipped; `pnpm --filter @objectstack/plugin-audit test`:
Test Files 70 passed (70), Tests 1128 passed; the package typecheck exit
0 (the test tsconfig includes all three changed test files).
</details>
<details><summary>Gates</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on head `d22f921b` derived 60 commands
(change set: 5 paths, +607 / -13, under the 3000-line human-merge
threshold). Each ran with its exit code recorded before any pipe.
- 57 exited 0 on the first pass.
- 3 exited 3 (prerequisite not met, nothing measured), and each was
re-run after its prerequisite was met:
- `node scripts/check-plugin-teardown-shape.mjs --self-test`: its
positive-control fixture is pinned to commit `621a4876`, which this
shallow clone lacked. After `git fetch --depth=1 origin` of that commit:
exit 0 (48 cases).
- `pnpm check:dual-build-cjs-loads` and `pnpm check:i18n`: both read
built output. After a full `pnpm build` (119 of 119 tasks): exit 0 each.
- `node scripts/pm/dispatch-gates.mjs --ran` over the final record: "60
derived famil(ies) accounted for — 60 run, 0 NOT-MEASURED", exit 0.
- `pnpm lint` (not derived by the tool, run by the dispatch's
instruction): exit 0.
CI's own families (artifact roster, wide-population families,
path-scheduled jobs, type-check lanes) run on this PR's check runs.
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic
(`packages/spec` not touched).
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes (reading under Gates).
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: n/a.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English (the 维护者速读 block and
the quoted ruling are Chinese by the Communication rule).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Utp6CatT2ajve5PJf1Hhzj)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent 314ad95 commit 490d136
5 files changed
Lines changed: 607 additions & 13 deletions
File tree
- .changeset
- packages/plugins/plugin-security/src
- objects
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
37 | | - | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
38 | 39 | | |
39 | 40 | | |
40 | 41 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
31 | | - | |
| 31 | + | |
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
| |||
267 | 267 | | |
268 | 268 | | |
269 | 269 | | |
270 | | - | |
271 | | - | |
272 | | - | |
273 | | - | |
274 | | - | |
275 | | - | |
276 | | - | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
277 | 274 | | |
278 | 275 | | |
279 | 276 | | |
| |||
291 | 288 | | |
292 | 289 | | |
293 | 290 | | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
294 | 345 | | |
295 | 346 | | |
296 | 347 | | |
| |||
0 commit comments