Skip to content

Commit a545a04

Browse files
fix(runtime, metadata-protocol): the permission-set clone door copies a set that grants on an object this deployment does not install, and member_default's feed grants re-land (#30310)
<!-- 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 ## 维护者速读 - **改了什么:** 两件事放在一个 PR 里:一是把 #30108 重新落地(`member_default` 授予 `sys_comment` 读建改、`sys_activity` 读,与 #30222 逐字节相同);二是修权限集克隆门:副本从源集合继承来的授权键交给保存门,保存门的未知对象拒收不再审这些键。 - **为什么改:** #30222 合入后 main 红了。没装审计插件的部署里,这两个对象没有注册,克隆 `member_default` 会被 422 拒收。上个 PR #30262 先回退止血,这个 PR 把功能和修复一起带回来。 - **风险与回滚:** 拒收门只放过「副本从源集合原样继承」的键;副本新加的未知对象、`/meta` 保存、草稿发布,照拒不误。`saveMetaItem` 的请求多了一个可选键,在 metadata-protocol 的公开类型里可达,所以申报 Clause-② yes (widening)。回滚即撤本 PR。 - **先看:** `packages/metadata-protocol/src/protocol-save-door-refusals.ts` 新增的过滤,再看 `packages/runtime/src/domains/permission-set-clone.ts` 第 7 步保存。 - **两分钟验证:** 在 `packages/qa/dogfood` 跑 `pnpm exec vitest run test/permission-set-clone-door.dogfood.test.ts`(依赖先构建),预期 11 条全绿。 - **需要您做的:** 无,席位按 Clause-② yes 安排契约复审后落地。 Fixes #30255 Fixes #30108 Clause-②: yes (widening) — `ObjectStackProtocolImplementation.saveMetaItem`'s request gains one optional key, `inheritedGrantObjects`, reachable from `@objectstack/metadata-protocol`'s `.` entry types Metadata-protocol: none ## Why main went red, measured Run 41961254209 (`797f9257`), `Dogfood Regression Gate (2/3)`, case `permission-set-clone-door.dogfood.test.ts` "a platform set (the lock-and-clone rule's commonest source) copies into its shipping package's pair": `422 INVALID_METADATA` with two findings, `objects.sys_comment` and `objects.sys_activity`, each "no package registers an object of that name and none is saved here". - `@objectstack/verify`'s `bootStack` constructs no `AuditPlugin` (fact 3 of `packages/cli/src/commands/serve-audit-registration.contract.test.ts`), and the showcase's `requires` does not name `audit`. So neither object is registered on that boot. `sys_inbox_message` and `sys_notification_receipt`, which `member_default` already granted, pass because the showcase requires `messaging`. - The save door's unknown-grant refusal (#28990) judges every grant of a NEW item, and a clone is a new item. Its docblock assumed that platform objects are always registered. That does not hold for an optional platform plugin. - Not dogfood-only: any deployment that does not install `@objectstack/plugin-audit` could not clone `member_default` once #30222 landed. ## What changes **The clone door is told which grants it inherits** (`packages/runtime/src/domains/permission-set-clone.ts`). Step 7's `saveMetaItem` call passes `inheritedGrantObjects: inheritedGrantObjectsOf(clone)`, which is the keys of the copy's `objects` map. `clonePermissionSetDefinition` carries every one of them unchanged from the source. **The refusal leaves inherited grants to the source's save** (`@objectstack/metadata-protocol`): - `protocol-door-requests.ts`: `SaveMetaItemRequest` gains `inheritedGrantObjects?: readonly string[]`, documented as server-set only. - `protocol-save.ts`: the save path forwards it to the refusal. Draft and publish mode are both covered, and a `migrate-stored` save is still exempt as before. - `protocol.ts` and `protocol-save-door-refusals.ts`: `unknownGrantObjectSaveRefusal` takes it as a trailing parameter and drops those keys from the unknown list, after the existing "the set's current definition already grants on it" step. This is that rule applied to a copy: the grant was introduced by the source and is judged where the source introduced it. The method's docblock gains the bullet. - Unchanged: an unknown object the save does not say it inherits is refused exactly as before. That covers a `/meta` save, a re-save that adds a grant, a copy that adds one, and a draft's publish (`promoteDraftForPublish` and the restore doors pass no inherited set). No HTTP door forwards the key: `PUT /meta` builds its request field by field. **#30108 re-landed:** reverting `119c2db1` (PR #30262, the revert) restores `member_default`'s two grants, their tests and their changeset. `git diff 490d136 HEAD` over those five paths is empty apart from the changeset's one added bullet, which says the grant now lands with this fix. #30108's measurements are in PR #30222's body. **Changesets:** `.changeset/30108-member-default-feed-grants.md` (restored, `@objectstack/plugin-security` patch, with one bullet added that names this fix) and `.changeset/30255-clone-inherits-held-grants.md` (`@objectstack/metadata-protocol` and `@objectstack/runtime` patch, both declaration lines). ## Clause-②, measured `@objectstack/metadata-protocol`'s `exports` map has one entry, `.`, typed by `./dist/index.d.ts` and `./dist/index.d.cts`. Built on this tree, `dist/index.d.ts` declares `SaveMetaItemRequest` (a non-exported alias) with `inheritedGrantObjects?: readonly string[]`, and the exported class `ObjectStackProtocolImplementation`'s `saveMetaItem(request: SaveMetaItemRequest)` references it. So the key is reachable from the entry's type graph: a widening of the published surface. Nothing a metadata author writes changes, so `Metadata-protocol: none`. ## Verification On head `591198e4` (branch off `main` `ef2ad31c`), under `scripts/pm/os-verify-lock.sh`: - `pnpm install` exit 0, then the dogfood dependency closure build (`pnpm --filter '@objectstack/dogfood^...' build`) exit 0. - `packages/qa/dogfood`, run with `vitest run`: `permission-set-clone-door.dogfood.test.ts`, `permission-unknown-grant-object-refusal.dogfood.test.ts`, `comments-permission-matrix.dogfood.test.ts`, `me-apps-and-everyone-baseline.dogfood.test.ts`. Result: Test Files 4 passed (4), Tests 34 passed (34). This includes the case that was red on `main`, and the #28990 refusal's own dogfood file. - `@objectstack/plugin-security`: `member-default-feed-grants.integration.test.ts`, `member-default-explicit-allow.test.ts`, `comment-reaction-own-record-floor.test.ts`. Result: Test Files 3 passed (3), Tests 70 passed (70). - `pnpm --filter @objectstack/metadata-protocol test`: Test Files 312 passed, 4 skipped; Tests 22356 passed, 26 skipped. - `pnpm --filter @objectstack/runtime test`: Test Files 541 passed (541); Tests 8620 passed, 19 skipped. - `typecheck` exit 0 for `@objectstack/metadata-protocol`, `@objectstack/runtime` and `@objectstack/plugin-security`. - The derived gates (`dispatch-gates --commands`, 65 on this change set) and `pnpm lint`: their readings are in the seat hand-off. CI does not run the dogfood shards on a pull request or in the merge queue. The runs above are this PR's dogfood evidence. <details><summary>Ablations (4 legs, each restored and proven by blob hash against HEAD)</summary> Driven through `scripts/ablation-replace.mjs` (WRAP mode, under `os-verify-lock`), on head `591198e4`. | Leg | Mutation | Red cases | |:--|:--|:--| | F1 | the refusal ignores `inherited` | 1: the new metadata-protocol case (the copy is refused on its inherited grant) | | F2 | the save path drops `request.inheritedGrantObjects` | 1: the same case | | F3 | the clone door omits `inheritedGrantObjects` | 2: the two clone-door pins that assert the save request's exact key set and the key's value | | F4 | `inherited` widened to every grant of the item | 1: the new case's second half (a grant the copy adds is still refused) | The dogfood half of F3 is `main`'s own red: with the grants landed and no fix, the clone-door case answered 422 (runs 41960290148 and 41961254209). Full output: project file `server-seat2/30255/ablations-ran.txt`. </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`'s reading is in the seat hand-off. - [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 changesets. - [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 is Chinese by the Communication rule). --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CatT2ajve5PJf1Hhzj)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8c9f48f commit a545a04

13 files changed

Lines changed: 709 additions & 24 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): every member reads and posts comments, and reads the activity history, of the records they can read — the platform baseline `member_default` now grants `sys_comment` (read, create, edit) and `sys_activity` (read)
6+
7+
Clause-②: no — runtime permission behaviour (human-floor security/permission class, ruled by the maintainer), not a published accept-set or public-surface widening
8+
Metadata-protocol: none
9+
10+
Comments (`enable.feeds`) and the activity timeline (`enable.activities`) are on by default on every object, but no shipped permission set named `sys_comment` or `sys_activity`. A member of an app whose permission sets name only the app's own objects — what `create-objectstack` generates — was refused both (`403` on `GET /api/v1/data/sys_comment` and `GET /api/v1/data/sys_activity`), on records they can read: the record page showed "no permission" for comments and activity, and offered no comment box. An app author no longer needs to grant the two objects by hand.
11+
12+
- **Which rows is unchanged.** The grant opens the two objects; plugin-audit's parent-record gates still decide the rows, so a member reads and posts comments, and reads the history, of exactly the records they can read, and nothing of any other record — a record another user owns under the app's row scope, or a record of an object the member does not read. A member edits the comments they wrote. The grant carries no delete bit (deleting a comment stays the moderation path, through a delete bit an app grants), no export and no view-all or modify-all.
13+
- **`enable.feeds: false` still closes comments** on that object: creating a comment there, or moving one into it, answers `403 FEEDS_DISABLED`. `enable.activities: false` still writes no activity row for the object.
14+
- **External members are not reached.** A holder of the `external` organization role does not hold the `everyone` anchor and resolves `external_default` in place of `member_default`, so a portal user still gets comments and activity only through an explicit grant, under the same two gates.
15+
- **No log noise.** A member's activity read skips the parent objects they cannot read without reading them, so the console header's feed logs no `[Security] Access denied` line per skipped object.
16+
- An app that already grants the two objects to its members keeps working unchanged: permission sets merge most-permissively, so its own lines (a delete bit included) still apply beside the baseline's.
17+
- **Lands with the permission-set clone door fix** (`.changeset/30255-clone-inherits-held-grants.md`). This grant first landed alone and was reverted: on a deployment that does not install `@objectstack/plugin-audit`, neither object is registered, and cloning `member_default` answered `422 INVALID_METADATA`. The clone door now copies such a set, so the grant and the fix ship together.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(runtime, metadata-protocol): the permission-set clone door copies a platform set whose grants name an object this deployment does not install — `member_default` copies into its package's customization pair again on a boot without `@objectstack/plugin-audit`
7+
8+
Clause-②: yes (widening) — `ObjectStackProtocolImplementation.saveMetaItem`'s request gains one optional key, `inheritedGrantObjects`, reachable from `@objectstack/metadata-protocol`'s `.` entry types
9+
Metadata-protocol: none
10+
11+
- **What failed:** cloning a set through `POST /api/v1/security/permission-sets/:name/clone` answered `422 INVALID_METADATA` when the source grants on an object no package registers here. The commonest source is the platform baseline `member_default`, which grants `sys_comment` and `sys_activity`. `@objectstack/plugin-audit` registers both objects, and it is an optional package: a deployment that does not install it could not clone `member_default` at all.
12+
- **The rule it now follows:** the save door's unknown-grant refusal (#28990) already leaves alone a grant the set itself already holds, because that grant is not the current write's. A clone's grants are the source's, copied verbatim, so the same holds for a copy. The clone door now tells the save which grant keys the copy inherits (`inheritedGrantObjects`), and the refusal does not judge those.
13+
- **Still refused:** an unknown object that the save does not say it inherits is this write's grant, and is refused exactly as before. That covers a `/meta` save, a re-save that adds a grant, and a draft's publish. No HTTP door forwards the new key.

‎packages/metadata-protocol/src/protocol-door-requests.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,17 @@ import type { MetadataWriteFace } from './protocol-write-face.js';
1818
*/
1919

2020
/** The request of {@link ObjectStackProtocolImplementation.saveMetaItem}. */
21-
type SaveMetaItemRequest = { type: string, name: string, item?: any, parentVersion?: string | null, storedParentVersion?: string | null, actor?: string, force?: boolean, mode?: 'draft' | 'publish', packageId?: string | null, source?: string, writeFace?: MetadataWriteFace, surface?: MetadataAuthoringSurface, client?: MetadataAuthoringClient };
21+
type SaveMetaItemRequest = {
22+
type: string, name: string, item?: any, parentVersion?: string | null, storedParentVersion?: string | null, actor?: string, force?: boolean, mode?: 'draft' | 'publish', packageId?: string | null, source?: string, writeFace?: MetadataWriteFace, surface?: MetadataAuthoringSurface, client?: MetadataAuthoringClient,
23+
/**
24+
* [#30255] The grant keys of a `permission` item that this save copies
25+
* verbatim from a set that already holds them — the permission-set clone
26+
* door's copy. Not this write's grants, so the unknown-grant refusal does
27+
* not judge them, exactly as it does not judge a re-save's grants the set
28+
* already holds. Set by server code only; no HTTP door forwards it.
29+
*/
30+
inheritedGrantObjects?: readonly string[],
31+
};
2232

2333
/** The request of {@link ObjectStackProtocolImplementation.publishMetaItem}. */
2434
type PublishMetaItemRequest = {

‎packages/metadata-protocol/src/protocol-save-door-refusals.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,7 @@ export async function unknownGrantObjectSaveRefusal(
579579
pending?: 'drafts' | ReadonlySet<string>,
580580
writeFace?: MetadataWriteFace,
581581
door: 'save' | 'publish' = 'save',
582+
inherited?: readonly string[],
582583
): Promise<(Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] }) | undefined> {
583584
if (singularType !== 'permission') return undefined;
584585
if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
@@ -625,6 +626,14 @@ export async function unknownGrantObjectSaveRefusal(
625626
: new Set<string>();
626627
unknown = unknown.filter((key) => !held.has(key));
627628
if (unknown.length === 0) return undefined;
629+
// [#30255] A grant this save copies from a set that already holds it (the
630+
// clone door's copy) is the source's, not this save's — the rule above,
631+
// for a copy: judged where the source introduced it.
632+
if (inherited && inherited.length > 0) {
633+
const copied = new Set(inherited);
634+
unknown = unknown.filter((key) => !copied.has(key));
635+
if (unknown.length === 0) return undefined;
636+
}
628637
// [#29279] What was looked for, as this door looked: a save also counts an
629638
// object saved here; a draft's publish counts only what goes live with it.
630639
const looked = door === 'publish'

‎packages/metadata-protocol/src/protocol-save.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1575,10 +1575,13 @@ export class ProtocolSaveFacet {
15751575
// already holds and is not judged: a draft-only set has no active row
15761576
// to hold its grants, so judging it refused the pass's own rewrite of
15771577
// a draft whose object draft was gone; the draft is judged at its
1578-
// publish. See {@link unknownGrantObjectSaveRefusal}.
1578+
// publish. [#30255] Grants the item copies from a set that already
1579+
// holds them (`inheritedGrantObjects`, the clone door's copy) are not
1580+
// this save's either. See {@link unknownGrantObjectSaveRefusal}.
15791581
if (writeSource !== 'migrate-stored') {
15801582
const grantRefusal = await host.unknownGrantObjectSaveRefusal(
15811583
singularType, request.item, request.name, mode === 'draft' ? 'drafts' : undefined, request.writeFace,
1584+
undefined, request.inheritedGrantObjects,
15821585
);
15831586
if (grantRefusal) throw grantRefusal;
15841587
}

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10306,7 +10306,16 @@ export class ObjectStackProtocolImplementation implements
1030610306
* a set), since the batch promotes as one unit in no type order;
1030710307
* - the set's current definition already grants on it: the grant is not
1030810308
* this write's. A re-save or a restore that keeps it is not judged, and
10309-
* neither is an object that has gone since the grant was made.
10309+
* neither is an object that has gone since the grant was made;
10310+
* - [#30255] the save copies it verbatim from a set that already holds it
10311+
* (`inherited`, the request's `inheritedGrantObjects`: the permission-set
10312+
* clone door's copy). The grant is the source's, not this write's — the
10313+
* rule above, for a copy. A platform set may grant on an object of an
10314+
* optional platform plugin this deployment does not install
10315+
* (`member_default` on `sys_comment` / `sys_activity`, which
10316+
* `@objectstack/plugin-audit` registers): the grant applies to nothing
10317+
* here, in the source and in the copy alike, and the copy is what the
10318+
* clone door promises.
1031010319
*
1031110320
* Asked in draft and in publish mode by {@link saveMetaItem} after the
1031210321
* schema gate (except a `migrate-stored` re-save, the platform healing
@@ -10327,14 +10336,15 @@ export class ObjectStackProtocolImplementation implements
1032710336
pending?: 'drafts' | ReadonlySet<string>,
1032810337
writeFace?: MetadataWriteFace,
1032910338
door?: 'save' | 'publish',
10339+
inherited?: readonly string[],
1033010340
): Promise<(Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] }) | undefined> {
1033110341
const readEngine = () => this.engine;
1033210342
const host: ThisParameterType<typeof unknownGrantObjectSaveRefusal> = {
1033310343
get engine() { return readEngine(); },
1033410344
listDrafts: (...a) => this.listDrafts(...a),
1033510345
readMetaItem: (...a) => this.readMetaItem(...a),
1033610346
};
10337-
return unknownGrantObjectSaveRefusal.call(host, singularType, item, name, pending, writeFace, door);
10347+
return unknownGrantObjectSaveRefusal.call(host, singularType, item, name, pending, writeFace, door, inherited);
1033810348
}
1033910349

1034010350
/**

‎packages/metadata-protocol/src/protocol.unknown-grant-object-refusal.test.ts‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@
2222
* - controls, each saved, published and promoted: a registered object, a
2323
* platform object (an RBAC link table), the `'*'` wildcard, an object
2424
* stored in this environment, an object drafted in the same package and
25-
* published in the same batch (promoted after the set), and a re-save
26-
* that keeps a grant the set already holds; a registry that cannot look
27-
* an object up refuses nothing.
25+
* published in the same batch (promoted after the set), a re-save
26+
* that keeps a grant the set already holds, and [#30255] a copy whose
27+
* grants a set already holds (`inheritedGrantObjects`, the permission-set
28+
* clone door's copy); a registry that cannot look an object up refuses
29+
* nothing.
2830
*
2931
* Each refusal is asserted by its code, its status, its path and the first
3032
* sentence of its finding, spelled out here.
@@ -363,6 +365,25 @@ describe('[#28990] controls — a grant on an object that exists is not refused'
363365
expectRefusal(err, 'qa_old', ['qa_typo']);
364366
});
365367

368+
it('[#30255] a copy that inherits a grant on an object not registered here (the clone door): saved; a grant the copy adds is still refused', async () => {
369+
const { protocol, rows } = makeSession({ objects: ['qa_widget'] });
370+
371+
const result = await protocol.saveMetaItem({
372+
type: 'permission', name: 'qa_copy', item: set('qa_copy', ['qa_widget', 'qa_plugin_object']),
373+
inheritedGrantObjects: ['qa_widget', 'qa_plugin_object'],
374+
});
375+
376+
expect(result.success).toBe(true);
377+
expect(stored(rows, 'permission', 'qa_copy')).toEqual(['active|-|qa_widget,qa_plugin_object']);
378+
// …while an unknown object the copy does not inherit is this save's grant, and refused.
379+
const err = await refusalOf(protocol.saveMetaItem({
380+
type: 'permission', name: 'qa_copy2', item: set('qa_copy2', ['qa_plugin_object', 'qa_typo']),
381+
inheritedGrantObjects: ['qa_plugin_object'],
382+
}));
383+
expectRefusal(err, 'qa_copy2', ['qa_typo']);
384+
expect(stored(rows, 'permission', 'qa_copy2')).toEqual([]);
385+
});
386+
366387
it('a registry that cannot look an object up refuses nothing', async () => {
367388
const { protocol, rows } = makeSession({ looksUpObjects: false });
368389
expect((await protocol.saveMetaItem({ type: 'permission', name: 'qa_probe', item: set('qa_probe', ['qa_no_such_object']) })).success).toBe(true);

‎packages/plugins/plugin-security/src/comment-reaction-own-record-floor.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@
3333
* ## Who grants the bits
3434
*
3535
* The object-level create / read / delete bits come from an ordinary
36-
* position-distributed set an application ships (as an app grants
37-
* `sys_comment` today), never from the `everyone`-bound baseline, which
36+
* position-distributed set an application ships (as an app grants the
37+
* `sys_comment` delete bit; the baseline grants comments' read, create and
38+
* edit, never delete), never from the `everyone`-bound baseline, which
3839
* ADR-0090 D5 keeps free of delete bits. Which shipped set — if any — carries
3940
* them is an open decision on #22566; the floor pinned here holds whatever set
4041
* hands the delete bit out.

‎packages/plugins/plugin-security/src/member-default-explicit-allow.test.ts‎

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { PermissionSetSchema } from '@objectstack/spec/security';
2828
import type { PermissionSet } from '@objectstack/spec/security';
2929
import { PermissionEvaluator } from './permission-evaluator.js';
3030
import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js';
31-
import { defaultPermissionSets, BETTER_AUTH_MANAGED_OBJECTS } from './objects/default-permission-sets.js';
31+
import { defaultPermissionSets, BETTER_AUTH_MANAGED_OBJECTS, EXTERNAL_DEFAULT_PERMISSION_SET } from './objects/default-permission-sets.js';
3232

3333
const evaluator = new PermissionEvaluator();
3434
const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!;
@@ -267,13 +267,10 @@ describe('[#7344] the personal inbox is readable by a member, scoped to their ow
267267
expect(rls.compileFilter(policiesFor(object, 'select') as any, {} as any)).toEqual(RLS_DENY_FILTER);
268268
});
269269

270-
it('`sys_activity` is deliberately NOT granted — the ruling excludes it', () => {
271-
// Named as an explicit negative so a future "while we are here" sweep has to
272-
// argue with the ruling rather than quietly widen past it. It is not a
273-
// per-user-scoped shape; a separate question if it ever matters.
274-
for (const { operation } of AXES) {
275-
expect(allows(operation, [MEMBER_DEFAULT], 'sys_activity'), `sys_activity ${operation}`).toBe(false);
276-
}
270+
it('`sys_activity` rides on its own ruling, not on this one: no `_self` policy scopes it to the caller', () => {
271+
// The inbox ruling left it out because it has no `user_id` to scope by. Its
272+
// grant (the `[#30108]` block below) is scoped by its parent-record gate, so
273+
// no inbox-shaped `_self` carve-out may appear for it.
277274
const names = (MEMBER_DEFAULT.rowLevelSecurity ?? []).map((p: any) => p.name);
278275
expect(names).not.toContain('sys_activity_self');
279276
});
@@ -291,6 +288,60 @@ describe('[#7344] the personal inbox is readable by a member, scoped to their ow
291288
});
292289
});
293290

291+
// [#30108 — maintainer ruling 6633054352, 「同意 A」] A record's comments and its
292+
// activity timeline, for every member who can read the record. The baseline
293+
// opens the two OBJECTS and nothing more; which rows is plugin-audit's
294+
// parent-record gates' answer, driven on a real stack in
295+
// `member-default-feed-grants.integration.test.ts`. What is pinned here is the
296+
// shape of the opening: the part that would quietly widen if it moved.
297+
const FEED_OBJECTS = ['sys_comment', 'sys_activity'] as const;
298+
299+
describe('[#30108] the baseline opens comments and activity, and leaves the rows to the parent gates', () => {
300+
it('sys_comment: a member reads, posts and edits — and holds no delete bit', () => {
301+
expect(allows('find', [MEMBER_DEFAULT], 'sys_comment'), 'read').toBe(true);
302+
expect(allows('insert', [MEMBER_DEFAULT], 'sys_comment'), 'create').toBe(true);
303+
expect(allows('update', [MEMBER_DEFAULT], 'sys_comment'), 'edit').toBe(true);
304+
expect(allows('delete', [MEMBER_DEFAULT], 'sys_comment'), 'delete').toBe(false);
305+
});
306+
307+
it('sys_activity: a member reads — the audit mirror is its only writer', () => {
308+
expect(allows('find', [MEMBER_DEFAULT], 'sys_activity'), 'read').toBe(true);
309+
for (const operation of ['insert', 'update', 'delete']) {
310+
expect(allows(operation, [MEMBER_DEFAULT], 'sys_activity'), operation).toBe(false);
311+
}
312+
});
313+
314+
it.each(FEED_OBJECTS)('%s: no read or create row policy of the baseline stands in for the parent gate', (object) => {
315+
// A select- or insert-class policy here would answer the gate's question with
316+
// a column predicate neither object can answer (no owner or user column, and
317+
// a different parent object on every row).
318+
expect(policiesFor(object, 'select').map((p: any) => p.name), 'select').toEqual([]);
319+
expect(policiesFor(object, 'insert').map((p: any) => p.name), 'insert').toEqual([]);
320+
});
321+
322+
it('sys_comment: an edit stays under the ownership floor — a member edits the comments they wrote', () => {
323+
// The moderation carve-out is the DELETE limb only; the edit limb is the
324+
// wildcard floor, so a member's edit reaches their own comments alone.
325+
expect(policiesFor('sys_comment', 'update').map((p: any) => p.name)).toEqual(['owner_only_writes']);
326+
});
327+
328+
it.each(FEED_OBJECTS)('%s: no export, view-all or modify-all rides along', (object) => {
329+
const perm = (MEMBER_DEFAULT.objects as any)[object];
330+
expect(perm, `${object} is named`).toBeTruthy();
331+
expect(perm.allowDelete ?? false).toBe(false);
332+
expect(perm.allowExport ?? false).toBe(false);
333+
expect(perm.viewAllRecords ?? false).toBe(false);
334+
expect(perm.modifyAllRecords ?? false).toBe(false);
335+
});
336+
337+
it.each(FEED_OBJECTS)('%s: the external baseline does not name it — the grant reaches no external member', (object) => {
338+
const external = defaultPermissionSets.find((p) => p.name === EXTERNAL_DEFAULT_PERMISSION_SET)!;
339+
expect(external, `${EXTERNAL_DEFAULT_PERMISSION_SET} is shipped`).toBeTruthy();
340+
expect(allows('find', [external], object)).toBe(false);
341+
expect(Object.keys(external.objects ?? {})).not.toContain(object);
342+
});
343+
});
344+
294345
describe('[#5491] the admin sets keep their wildcards (this is a BASELINE change only)', () => {
295346
it.each(['admin_full_access', 'organization_admin', 'viewer_readonly'])(
296347
'%s still carries a `*` entry',

0 commit comments

Comments
 (0)