Repository navigation
fix(plugin-security): every member reads and posts comments, and reads the activity history, of the records they can read - #30222
Conversation
…records a member can read The platform baseline names sys_comment (read, create, edit) and sys_activity (read). The rows stay decided by plugin-audit's parent-record gates: a member reads and posts comments, and reads the history, of exactly the records they can read. No delete bit, no row policy, no export. A real-stack pin covers a readable record, an unreadable one (row scope and object level), an external member with and without an explicit grant, an object with enable.feeds false, and the console feed read's access-denied log count. Claude-Session: https://claude.ai/code/session_01Utp6CatT2ajve5PJf1Hhzj Co-authored-by: Claude <noreply@anthropic.com>
…-24iijl Claude-Session: https://claude.ai/code/session_01Utp6CatT2ajve5PJf1Hhzj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
|
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL Contract reviewServed-tier: Inputs: card #30108 (body and all four comments, the ruling 6633054352 included), PR #30222 (body, file list, ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
Implemented-by: VERDICT: PASS |
|
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL 维护者速读
席位核对 — ACCEPT · domain:server#2 · head
|
Requested by Bill · project thread
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL
维护者速读
member_default加两行授权:sys_comment(读、建、改)和sys_activity(读)。没有删除位、没有行策略、没有导出。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
member_defaultalso reacheseveryone."member_defaultdoes not reach an external member: a holder oforg_externalis not given theeveryoneanchor (resolveAuthzContext, step 5b,@objectstack/core), andhumanBaselineFor(external-baseline.ts) resolvesexternal_defaultin 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).enable.feeds: falsestill refuses on both verbs."enforceFeedsCapability(plugin-auditaudit-writers.ts), is registered on two verbs:beforeInsert, and since #10170beforeUpdatefor a re-thread. Those are also the two write verbs this grant adds, and both still refuse a member: a comment create answers403 FEEDS_DISABLEDand writes nothing; moving the member's own comment into that object answers403 FEEDS_DISABLEDand 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.findonsys_activity, top 20 bytimestampdesc, then itscount) as a member logs 0[Security] Access deniedlines, 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 withcanReadObjectwithdrawn from the security service logs denials naming the vault.canReadObjectwithdrawal 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:comment-access-hooks.tslists, reads and accepts a post only on a thread whose parent record the caller can read;activity-read-visibility.tsserves an activity row only when its record is readable, and skips a parent object the caller cannot read throughcanReadObject, without reading it as the caller.owner_only_writesfloor (thesys_comment_moderationcarve-out is the delete limb only), so a member edits the comments they wrote and is refused on another member's (403 PERMISSION_DENIED).everyoneanchor). Deleting a comment stays the moderation path, through a delete bit an ordinary set grants.sys_activityis deliberately NOT included … a separate question if it ever matters". It is rewritten to say why it was left out of that pair (nouser_idfor a_selfpolicy) and that it is granted below on its own ruling, so the file no longer contradicts itself.Tests (
packages/plugins/plugin-security/src/):member-default-feed-grants.integration.test.ts(new, 20 cases): the realSecurityPluginwith the shipped sets, the realAuditPlugin, SQL over in-memory better-sqlite3, and contexts from the realresolveAuthzContextandassembleExecutionContextover seeded identity rows (sys_member.rolememberandexternal). 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 itscountagrees, 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_activityis deliberately NOT granted" is flipped to the new semantics and asserts it:sys_commentread, create and edit and no delete;sys_activityread only; no select- or insert-class row policy of the baseline on either; thesys_commentedit limb is exactlyowner_only_writes; no export, view-all or modify-all;external_defaultnames neither. The inbox block keeps its "nosys_activity_self" assertion.comment-reaction-own-record-floor.test.ts: header prose only. Its "(as an app grantssys_commenttoday)" now says the app grants the delete bit and the baseline grants read, create and edit.Changeset:
.changeset/30108-member-default-feed-grants.md,patchfor@objectstack/plugin-security, both declaration lines.Acceptance notes
enable.feeds: falseobject 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 (enforceFilesCapability/enforceFeedsCapabilityarebeforeInsert-only — a re-point via update dodges the capability opt-in on the NEW parent object #10170's header onenforceFeedsCapability); 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 holdssys_commentread (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: neitherenforceFeedsCapabilitynorcomment-access-hooks.tsconsults 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: falsewrites no activity row for its object (measured: zero rows).member_defaultdoes not namesys_comment_reaction, so an ordinary member reads and posts comments but the record page does not offer them the reaction control (it needs create onsys_comment_reaction,content/docs/frontend/plugins/plugin-detail.mdx). Listed for the seat; not changed here.Verification
Suites and gates ran on the final tree, head
d22f921b. The ablations ran on2086b2d8, the change before themainmerge; 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 ond22f921b.Ablations (8 legs, each restored and proven by blob hash against HEAD)
Driven through
scripts/ablation-replace.mjs(WRAP mode; anchor hit exactly once, mutation proven on disk, restore proven bygit hash-object== the HEAD blob and an emptygit diff HEAD).@objectstack/plugin-auditresolves to its source in this package's vitest config, so no build sits between the mutation and the run.FEEDS_DISABLED), the feed-read casesandInskippedandIntoWhereskippedenforceFeedsCapabilitycondition falseallowDelete: trueonsys_commenteveryone" pinsys_comment_moderationwidened fromdeletetoallallclass reaches through insert and select (observed direction: more red than the one targeted)Full output: project file
server-seat2/30108/ablations-ran.txt.Suites
Before the
mainmerge (head2086b2d8):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 headd22f921b, underscripts/pm/os-verify-lock.sh:pnpm installexit 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).Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon headd22f921bderived 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.node scripts/check-plugin-teardown-shape.mjs --self-test: its positive-control fixture is pinned to commit621a4876, which this shallow clone lacked. Aftergit fetch --depth=1 originof that commit: exit 0 (48 cases).pnpm check:dual-build-cjs-loadsandpnpm check:i18n: both read built output. After a fullpnpm build(119 of 119 tasks): exit 0 each.node scripts/pm/dispatch-gates.mjs --ranover 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.
Human Minimum
packages/specholds no runtime logic (packages/specnot touched).pnpm lintpasses (reading under Gates).main, one pull request.packages/spectouched: n/a.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code