Skip to content

Commit 6f57888

Browse files
fix(plugin-audit)!: an engine read of sys_activity returns only the rows whose parent record the caller can read (#21069)
Part of #20833 Clause-②: no (narrowing) — an engine read of one platform object returns fewer rows to a caller that is not system context. No schema, accept set, export or stored shape moves: `src/index.ts`, `package.json` and `src/objects/**` are unchanged, and none of the new module-level names appears in the built `dist/index.d.ts` / `dist/index.d.mts` (positive control: `installCommentReadVisibility` does). ## What this changes `AuditPlugin` mounts a read middleware on `sys_activity`, beside the one `sys_comment` already has. On `find`, `findOne`, `count` and `aggregate` it pre-scans the parent pairs (`object_name`, `record_id`) the query would touch under system context, asks which of those parent records the caller can read, and ANDs one branch per parent object into the query. A row is returned only when the caller can read the record it is about. - **One readability answer, shared.** The comment gate's per-object, caller-scoped engine read of the parent is extracted from `comment-access-hooks.ts` into `resolveReadableParentIds`, and both gates call it. The parent object's own sharing, RLS and object-level CRUD decide. Nothing derives readability a second way. - **Batched.** One parent read per parent object per request, never one per row. A pin counts them. - **Fails closed**, with the same row classes the comment gate uses for threads: a row about a record the caller cannot read, about a record that no longer exists, naming no record or an object the engine does not know, or naming `sys_activity` itself. - **Scan window.** The pre-scan keeps the comment gate's 2,000-row bound and its fail-closed warning. It also carries the caller's own `orderBy`, so on a larger table the window it scans is the one the caller pages through, such as a feed sorted by `timestamp`. - System-context and context-less reads are not narrowed. ## Per door, by class A real org-bound boot (`bootStack`, `orgContext: true`) with plugin-audit's CRUD mirror writing the rows. The member holds object-level `sys_activity` read through one explicit permission set. The admin is the seeded admin. "Parent readable" means the same caller's read of the parent record through the data door. | door | caller | parent readable by the caller | before | after | |:--|:--|:--|:--|:--| | list `GET /data/sys_activity` | member | no | rows returned | absent | | list | member | yes | returned | returned | | list | member | record no longer exists | returned | absent | | list `total` | member | (any) | every row (43) | the rows returned (38) | | by id `GET /data/sys_activity/:id` | member | no | 200 | 404 | | by id | member | yes | 200 | 200 | | filtered query `POST /data/sys_activity/query` (`where`) | member | no | rows returned | none | | grouped count `POST /data/sys_activity/query` (`groupBy`) | member | no | group present | group absent | | list, by id, filtered query, grouped count | admin | yes (every existing record) | returned | returned | | list, grouped count | admin | record no longer exists | returned | absent | | export `GET /data/sys_activity/export` | member, admin | (any) | 403 | 403 (the object's API methods, not this gate) | Measured counts in that boot: member list 43 rows before, 38 after. Admin list 43 before, 41 after. The admin's two missing rows are both about one record deleted before the read. ## Pins (committed red, before the fix) - `packages/plugins/plugin-audit/src/activity-read-visibility.integration.test.ts`, commit 97fdaa4. It uses a real kernel, the real `AuditPlugin`, the real CRUD mirror and a real SQLite driver, and it covers find, findOne, count, aggregate, a scoped query, the batching count, the fail-closed classes and an admin. At the pin commit: 8 failed, 4 passed of 12. After the fix: 12 of 12. - `packages/qa/dogfood/test/activity-parent-read-gate.dogfood.test.ts`, commit 2e261af. It covers the public data doors above. Each returned row is checked by opening its parent through the same door as the same member. Against the pre-fix build: 4 failed, 2 passed of 6. After the fix: 6 of 6. - The fix commit 2afc6a0 adds `activity-read-visibility.test.ts` for the fail-closed branches, the scan bound, the order pass-through and the envelope keys. It also adds a mounting assertion to `audit-plugin.test.ts`. ## Ablation: gate off, red, restore (from the committed fix 2afc6a0) The mutation deletes the one `installActivityReadVisibility(...)` call in `audit-plugin.ts` through `scripts/ablation-replace.mjs`: anchor count 1 → 0, blob `952d943e` → `84a26115`. A trap restores it by absolute path. - **Leg A (plugin read path, resolved from source):** the integration pin plus `audit-plugin.test.ts` gave 10 failed, 12 passed of 22. The restore was proven: blob equals HEAD `952d943e` and `git diff HEAD` is empty. Re-run: 22 of 22. - **Leg B (dogfood door pin, resolved from `dist/`):** after the rebuild, the JS was emitted. The DTS step refused the now-unused import (TS6133), and the suite consumes the JS. `ablation-dist-preflight --absent` proved the call gone from all 4 built files. The dogfood pin then gave 4 failed, 2 passed of 6. The restore was proven, the rebuild exited 0, and the preflight proved the call present again. Re-run: 6 of 6. ## Verification At HEAD 22cc56d, run as one locked script. Each exit code was captured before any pipe. - `pnpm --filter @objectstack/plugin-audit test`: 28 files, 392 tests passed. `pnpm --filter @objectstack/plugin-audit typecheck`: exit 0, test layer included (`check:test-typecheck` OK, 0 errors). - Dogfood, at the fix commit's build: `activity-parent-read-gate.dogfood.test.ts` passed 6 of 6, and `comments-permission-matrix.dogfood.test.ts` passed 10 of 10. The comment gate is the one whose readability answer is now shared. - Gate families derived by `dispatch-gates --commands` (95): 91 ran green. 4 are NOT MEASURED, each refusing on its own PREREQUISITE NOT MET (exit 3): `check:dual-build-cjs-loads` and `check:type-check-debt` need a whole-repo build, `check:i18n` needs the built CLI, and `spec check:skill-examples` needs the built client SDK. `dispatch-gates --ran`: 95 accounted for, 0 unrun. - Lint, a declared narrowing: `eslint --no-inline-config --format json` over the 7 changed TypeScript files reports 7 files linted, with 0 errors and 0 warnings. An ignored file would report a warning, so none of them falls outside the config's population. `eslint.config.mjs` sets no `parserOptions.project` and no cross-file import rule, so this diff cannot move a verdict on an untouched file. The whole-repo `pnpm lint` runs in CI. ## Acceptance notes - **The card stays open, so this PR says `Part of`.** One read door of this object answers outside the engine's middleware chain, so an engine middleware does not reach it. That door was measured on the same boot and reported to the dispatching seat. It is not described here, and closing it is outside this package. - **Admins.** An admin reads every row about a record that exists, exactly as before. Rows about a record that no longer exists are now excluded for every caller, admins included. The comment gate does the same with a deleted record's thread. The deletion itself stays in `sys_audit_log`, which this gate does not touch. Whether admins should keep those rows is raised with the seat. The security service gives this object no per-object read-bypass answer to key on, measured on `/auth/me/permissions` for the seeded admin. - **Surface addition:** `content/docs/permissions/system-context.mdx`, row 45. `check-system-context-census` requires every `isSystem` read site to carry an anchor, and the new middleware's early return is one. The declared counts were regenerated by `gen:system-context-census`. - Not measured: whether a caller who can read a parent record, but not every field of it, sees the before and after values an activity row records. That is field-level and is not this record-level gate's question. - Not measured: `GET /search` returned no `sys_activity` hits for either principal, before or after, in this boot. That reading says nothing about the gate. - Not measured: a boot with more than one organization. - Not measured, whole-repo build prerequisites (CI runs them): `check:dual-build-cjs-loads`, `check:type-check-debt`, `check:i18n` (built CLI), and `pnpm --filter @objectstack/dogfood typecheck`. Its 40 errors locally are all TS2307 for unbuilt packages plus one downstream TS7006, and none is in the added file. - Main moved after the branch point. The four gate inputs it changed are not on this PR's paths. The gates ran against this branch's copies. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dff98c1 commit 6f57888

9 files changed

Lines changed: 886 additions & 31 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/plugin-audit': minor
3+
---
4+
5+
fix(plugin-audit)!: an engine read of `sys_activity` returns only the rows whose parent record the caller can read, the same way an engine read of `sys_comment` is narrowed
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a narrowing of what a READ returns, on one platform object, decided at request time: an activity row is served to a caller exactly when the record it names (object_name, record_id) is one that caller can read. No authorable key, spelling, value domain, export or stored metadata shape moves: the sys_activity object definition is byte-identical, every query shape parses as before, the package barrel exports nothing new and nothing less, and no stored row is rewritten. There is therefore nothing for an author to convert and nothing for `objectstack migrate meta` to reach. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a read-visibility rule and this diff adds none (not registered / already-registered); and the change is runtime behaviour, not a published TypeScript declaration (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING**: this narrows what an engine read of `sys_activity` returns to a caller that is not system context. It ships as `minor` under the repo's launch-window convention for narrowings.
12+
13+
**What changes.** `AuditPlugin` now mounts a read gate on `sys_activity`, beside the one `sys_comment` already has. It is an engine middleware, so it narrows what passes through the engine: `find`, `findOne`, `count` and `aggregate`, which on the generic data doors are the list, its `total`, the by-id read and both query shapes. There a row is returned only when the caller can read the record it is about. That answer is the one the comment gate asks: the caller's own engine read of the parent record, so the parent object's sharing, RLS and object-level permissions decide. Parent reads are batched, one per parent object.
14+
15+
**Rows that are left out**, failing closed exactly as the comment gate does for its threads:
16+
17+
- a row about a record the caller cannot read;
18+
- a row about a record that no longer exists;
19+
- a row that names no record, or names an object the engine does not know;
20+
- a row that names `sys_activity` itself.
21+
22+
**Unchanged.** A caller who can read every record (an admin) keeps every row about a record that exists. System-context reads, including the audit writer's own, are not narrowed. The object, its fields and what the CRUD mirror writes are unchanged, and nothing stored is rewritten.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
1010
service self-write, a migration.
1111

1212
This page is **the authority** for what that flag actually does. It exists
13-
because the flag is not one concept: it is a single boolean read at **107
13+
because the flag is not one concept: it is a single boolean read at **108
1414
distinct sites across 19 packages**, and knowing three of those behaviours gives
1515
no hint that the other hundred-and-four exist. Every documented app-side bug
1616
traced to `isSystem` had the same shape — the metadata was complete and correct,
@@ -138,7 +138,7 @@ that silently does not happen.
138138

139139
### 3. Sharing (`plugin-sharing`)
140140

141-
The largest single consumer — **17 of the 107 sites**.
141+
The largest single consumer — **17 of the 108 sites**.
142142

143143
| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
144144
|:--|:---|:---|:---|
@@ -161,7 +161,7 @@ The largest single consumer — **17 of the 107 sites**.
161161
| 42 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `packages/plugins/plugin-approvals/src/lifecycle-hooks.ts#bindDelegationWriteGuard` |
162162
| 43 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `packages/plugins/plugin-approvals/src/approval-service.ts#isOverrideActor`, `#resolveActor`, `#sendBack`, `#resubmit`, `#reassign`, `#remind`, `#requestInfo`, `#comment` |
163163
| 44 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `packages/services/service-storage/src/attachment-access-hooks.ts#installAttachmentAccessHooks`, `#installAttachmentReadVisibility` |
164-
| 45 | Comment access hooks return early (insert + update + delete, and the read AST) | plugin-audit | Lose: comment visibility scoping | `packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks`, `#installCommentReadVisibility` |
164+
| 45 | Comment access hooks return early (insert + update + delete, and the read AST), and so does the activity read gate (its read AST) | plugin-audit | Lose: comment visibility scoping, and the narrowing of `sys_activity` to rows whose parent record the caller can read | `packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks`, `#installCommentReadVisibility`, `packages/plugins/plugin-audit/src/activity-read-visibility.ts#installActivityReadVisibility` |
165165
| 46 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | `packages/services/service-knowledge/src/knowledge-service.ts#applyPermissionFilter` |
166166

167167
### 5. Actions, metadata plane, provenance, the organization wall
@@ -281,7 +281,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
281281
independent decisions, and a seed loader plausibly wants the first two but not
282282
the third. The concept is nevertheless **staying as one boolean**:
283283

284-
- **Shipped semantics.** `isSystem` is a published contract with 107 read sites
284+
- **Shipped semantics.** `isSystem` is a published contract with 108 read sites
285285
in 19 packages. Splitting it is a breaking contract change across all of them.
286286
(The ruling was taken when the census read 80 sites in 18 packages; the count
287287
has grown, which strengthens rather than weakens the argument.)
@@ -355,16 +355,16 @@ still holds equal to the census on every pull request:
355355
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
356356
| — parsed as a declaration | 22 | ✅ |
357357
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
358-
| — parsed as a property **read** | 113 | ✅ |
358+
| — parsed as a property **read** | 114 | ✅ |
359359
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
360360
| — the remainder: text inside comments and string literals | 358 | — |
361361
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
362-
| Of those reads: reads of `ExecutionContext.isSystem` | **107** | ✅ |
363-
| — behaviour-bearing (rows 1–61 above) | 104 | ✅ |
362+
| Of those reads: reads of `ExecutionContext.isSystem` | **108** | ✅ |
363+
| — behaviour-bearing (rows 1–61 above) | 105 | ✅ |
364364
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
365365
| Packages containing at least one elevation read | **19** | ✅ |
366-
| Files containing at least one elevation read | 45 | ✅ |
367-
| — the distinct symbols those reads live in — what this page anchors | 90 | ✅ |
366+
| Files containing at least one elevation read | 46 | ✅ |
367+
| — the distinct symbols those reads live in — what this page anchors | 91 | ✅ |
368368
| — of those files, the ones holding more than one read in one symbol | 8 | ✅ |
369369

370370
The six rows marked — are a **dated decomposition, not a live claim**: they were
@@ -428,7 +428,7 @@ same resolver, and the same registration shape, that holds `docs/adr/**`.
428428
Renaming a symbol is now a loud red instead of a silent misdirection.
429429

430430
⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor
431-
cannot say WHICH read inside a function it means, and **8** of the **45**
431+
cannot say WHICH read inside a function it means, and **8** of the **46**
432432
anchored files hold more than one read inside a single symbol. So the population
433433
check runs per file at symbol granularity: every file the census finds a read in
434434
must be anchored, and the set of symbols this page cites into that file must
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `sys_activity` read visibility, on the plugin's READ PATH.
5+
*
6+
* A caller holding object-level `sys_activity` read gets back only the
7+
* activity rows whose parent record (`object_name`, `record_id`) it can read,
8+
* the same way `installCommentReadVisibility` narrows `sys_comment`. An admin
9+
* is the caller who can read every record, so it keeps every row about a
10+
* record that exists.
11+
*
12+
* ## Why a real engine, a real driver and the real plugin
13+
*
14+
* The narrowing is a WHERE the gate ANDs into the caller's query. Whether that
15+
* WHERE selects the right rows is a question about the Filter Protocol as a
16+
* driver executes it (`$or` over two columns, `$in`, `count`, `aggregate`), not
17+
* about the shape of an object, so the rows below are written by the real
18+
* audit writer (the CRUD mirror is the platform's own producer of these rows),
19+
* stored by a real SQLite driver and read back through the real engine
20+
* middleware chain. The gate is mounted by `AuditPlugin` itself at
21+
* `kernel:ready`, so a gate that stops being mounted fails here exactly like a
22+
* gate that was never written.
23+
*
24+
* ## The one stand-in, and why it is the smallest honest one
25+
*
26+
* PARENT READABILITY. In a deployment the parent object's own sharing, RLS and
27+
* object-level CRUD decide whether a caller can read a record; the gate only
28+
* ever asks the engine, under the caller's context. Here one engine middleware
29+
* on the owned parent object plays that part: a member reads the ledger rows
30+
* it owns, the admin reads them all. The gate cannot tell it apart from
31+
* plugin-security because it asks the same question the same way.
32+
*/
33+
34+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
35+
import { ObjectKernel } from '@objectstack/core';
36+
import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql';
37+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
38+
39+
import { AuditPlugin } from './audit-plugin.js';
40+
41+
const ACTIVITY = 'sys_activity';
42+
/** A parent object whose rows each caller reads only when it owns them. */
43+
const OWNED = 'act_ledger';
44+
/** A parent object every caller reads. */
45+
const OPEN = 'act_board';
46+
const HARNESS_PACKAGE = 'com.objectstack.audit.test.activity-read';
47+
48+
const SYS = { isSystem: true } as const;
49+
const MEMBER = { userId: 'u_member', tenantId: 'org_1', positions: ['org_member'] };
50+
const OTHER = { userId: 'u_other', tenantId: 'org_1', positions: ['org_member'] };
51+
const ADMIN = { userId: 'u_admin', tenantId: 'org_1', positions: ['org_admin'] };
52+
53+
const ownedObject = {
54+
name: OWNED,
55+
label: 'Ledger',
56+
fields: {
57+
name: { name: 'name', label: 'Name', type: 'text' as const },
58+
owner: { name: 'owner', label: 'Owner', type: 'text' as const },
59+
},
60+
};
61+
const openObject = {
62+
name: OPEN,
63+
label: 'Board',
64+
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
65+
};
66+
67+
const READ_OPS = new Set(['find', 'findOne', 'count', 'aggregate']);
68+
69+
/** Parent-object read operations issued under a caller (non-system) context. */
70+
const parentProbes: Array<{ object: string; operation: string; userId?: string }> = [];
71+
72+
type Row = Record<string, any>;
73+
const pair = (r: Row) => `${r.object_name ?? 'NULL'}/${r.record_id ?? 'NULL'}`;
74+
const pairs = (rows: Row[]) => rows.map(pair).sort();
75+
76+
describe('sys_activity read visibility — the plugin read path', () => {
77+
let kernel: ObjectKernel;
78+
let engine: ObjectQL;
79+
const ids: Record<string, string> = {};
80+
let allRows: Row[] = [];
81+
82+
beforeAll(async () => {
83+
kernel = new ObjectKernel({ logger: { level: 'silent' } });
84+
await kernel.use(new ObjectQLPlugin());
85+
await kernel.use(new AuditPlugin());
86+
await kernel.bootstrap();
87+
88+
engine = kernel.getService<ObjectQL>('objectql');
89+
const driver = new SqliteWasmDriver({ filename: ':memory:' });
90+
await driver.connect();
91+
engine.registerDriver(driver, true);
92+
engine.registry.registerObject(ownedObject as any, HARNESS_PACKAGE);
93+
engine.registry.registerObject(openObject as any, HARNESS_PACKAGE);
94+
await engine.syncSchemas();
95+
96+
// Parent readability: a non-admin caller reads the ledger rows it owns.
97+
engine.registerMiddleware(async (op: any, next: () => Promise<void>) => {
98+
if (READ_OPS.has(op.operation) && op.context && !op.context.isSystem) {
99+
parentProbes.push({ object: op.object, operation: op.operation, userId: op.context.userId });
100+
if (op.context.userId !== ADMIN.userId && op.ast) {
101+
const mine = { owner: op.context.userId ?? '__nobody__' };
102+
op.ast.where = op.ast.where ? { $and: [op.ast.where, mine] } : mine;
103+
}
104+
}
105+
return next();
106+
}, { object: OWNED });
107+
engine.registerMiddleware(async (op: any, next: () => Promise<void>) => {
108+
if (READ_OPS.has(op.operation) && op.context && !op.context.isSystem) {
109+
parentProbes.push({ object: op.object, operation: op.operation, userId: op.context.userId });
110+
}
111+
return next();
112+
}, { object: OPEN });
113+
114+
// Every row below is written by the real audit writer's CRUD mirror.
115+
ids.mine = (await engine.insert(OWNED, { name: 'mine', owner: MEMBER.userId }, { context: SYS })).id;
116+
ids.theirs = (await engine.insert(OWNED, { name: 'theirs', owner: OTHER.userId }, { context: SYS })).id;
117+
await engine.update(OWNED, { name: 'theirs, renamed' }, { where: { id: ids.theirs }, context: SYS });
118+
ids.board1 = (await engine.insert(OPEN, { name: 'board one' }, { context: SYS })).id;
119+
ids.board2 = (await engine.insert(OPEN, { name: 'board two' }, { context: SYS })).id;
120+
ids.gone = (await engine.insert(OPEN, { name: 'board gone' }, { context: SYS })).id;
121+
await engine.delete(OPEN, { where: { id: ids.gone }, context: SYS });
122+
123+
// Rows no CRUD mirror writes, but an app's own server action can: one that
124+
// names no parent, one that names an object no registry knows, and one
125+
// that names the activity stream itself.
126+
const stamp = new Date().toISOString();
127+
for (const row of [
128+
{ type: 'note', summary: 'no parent', object_name: null, record_id: null },
129+
{ type: 'note', summary: 'unknown parent', object_name: 'act_unregistered', record_id: 'x1' },
130+
{ type: 'note', summary: 'self parent', object_name: ACTIVITY, record_id: 'a1' },
131+
]) {
132+
await engine.insert(ACTIVITY, { ...row, timestamp: stamp }, { context: SYS });
133+
}
134+
135+
allRows = await engine.find(ACTIVITY, { context: SYS });
136+
}, 120_000);
137+
138+
afterAll(async () => {
139+
if (kernel) {
140+
await Promise.race([kernel.shutdown(), new Promise<void>((r) => setTimeout(r, 10_000))]);
141+
}
142+
}, 30_000);
143+
144+
it('control: the CRUD mirror and the direct writes produced every row this file reasons about', () => {
145+
expect(pairs(allRows)).toEqual(
146+
[
147+
`${OPEN}/${ids.board1}`,
148+
`${OPEN}/${ids.board2}`,
149+
`${OPEN}/${ids.gone}`,
150+
`${OPEN}/${ids.gone}`,
151+
`${OWNED}/${ids.mine}`,
152+
`${OWNED}/${ids.theirs}`,
153+
`${OWNED}/${ids.theirs}`,
154+
`${ACTIVITY}/a1`,
155+
'NULL/NULL',
156+
'act_unregistered/x1',
157+
].sort(),
158+
);
159+
});
160+
161+
it('control: the parent stand-in answers a member exactly the records it owns', async () => {
162+
const owned = await engine.find(OWNED, { context: MEMBER });
163+
expect(owned.map((r: Row) => r.id)).toEqual([ids.mine]);
164+
});
165+
166+
it('find: a member reads only the activity of records it can read', async () => {
167+
const rows = await engine.find(ACTIVITY, { context: MEMBER });
168+
expect(pairs(rows)).toEqual([`${OPEN}/${ids.board1}`, `${OPEN}/${ids.board2}`, `${OWNED}/${ids.mine}`].sort());
169+
});
170+
171+
it('find: another member reads its own record’s activity, not the first member’s', async () => {
172+
const rows = await engine.find(ACTIVITY, { context: OTHER });
173+
expect(pairs(rows)).toEqual(
174+
[`${OPEN}/${ids.board1}`, `${OPEN}/${ids.board2}`, `${OWNED}/${ids.theirs}`, `${OWNED}/${ids.theirs}`].sort(),
175+
);
176+
});
177+
178+
it('count: the list total is narrowed identically to the rows', async () => {
179+
expect(await engine.count(ACTIVITY, {}, { context: MEMBER })).toBe(3);
180+
});
181+
182+
it('findOne: a row whose parent the member cannot read is absent by id', async () => {
183+
const hidden = allRows.find((r) => r.object_name === OWNED && r.record_id === ids.theirs)!;
184+
const shown = allRows.find((r) => r.object_name === OWNED && r.record_id === ids.mine)!;
185+
expect(await engine.findOne(ACTIVITY, { where: { id: hidden.id }, context: MEMBER })).toBeNull();
186+
expect((await engine.findOne(ACTIVITY, { where: { id: shown.id }, context: MEMBER }))?.id).toBe(shown.id);
187+
});
188+
189+
it('aggregate: a grouped count sees only the readable rows', async () => {
190+
const groups = await engine.aggregate(
191+
ACTIVITY,
192+
{ groupBy: ['object_name'], aggregations: [{ function: 'count', alias: 'n' }], context: MEMBER },
193+
);
194+
const byObject = Object.fromEntries(groups.map((g: Row) => [g.object_name ?? 'NULL', Number(g.n)]));
195+
expect(byObject).toEqual({ [OPEN]: 2, [OWNED]: 1 });
196+
});
197+
198+
it('a query scoped to a record the member cannot read returns nothing', async () => {
199+
const rows = await engine.find(
200+
ACTIVITY,
201+
{ where: { object_name: OWNED, record_id: ids.theirs }, context: MEMBER },
202+
);
203+
expect(rows).toEqual([]);
204+
});
205+
206+
it('rows whose parent no longer exists, or that name no readable parent, stay out', async () => {
207+
const rows = await engine.find(ACTIVITY, { context: MEMBER });
208+
const seen = new Set(rows.map((r: Row) => r.object_name ?? 'NULL'));
209+
expect(rows.some((r: Row) => r.record_id === ids.gone)).toBe(false);
210+
for (const name of ['NULL', 'act_unregistered', ACTIVITY]) expect(seen.has(name)).toBe(false);
211+
});
212+
213+
it('a list read probes each parent object once, not once per row', async () => {
214+
parentProbes.length = 0;
215+
await engine.find(ACTIVITY, { context: OTHER });
216+
const perObject = parentProbes.filter((p) => p.userId === OTHER.userId).map((p) => `${p.object}:${p.operation}`);
217+
expect(perObject.sort()).toEqual([`${OPEN}:find`, `${OWNED}:find`]);
218+
});
219+
220+
it('an admin, who reads every record, keeps every row about a record that exists', async () => {
221+
const rows = await engine.find(ACTIVITY, { context: ADMIN });
222+
const existing = allRows.filter(
223+
(r) => (r.object_name === OWNED || r.object_name === OPEN) && r.record_id !== ids.gone,
224+
);
225+
expect(pairs(rows)).toEqual(pairs(existing));
226+
expect(await engine.count(ACTIVITY, {}, { context: ADMIN })).toBe(existing.length);
227+
});
228+
229+
it('a system read is not narrowed', async () => {
230+
const rows = await engine.find(ACTIVITY, { context: SYS });
231+
expect(rows.length).toBe(allRows.length);
232+
});
233+
});

0 commit comments

Comments
 (0)