Skip to content

Commit f57eddc

Browse files
fix(plugin-sharing)!: an unproven owner anchor on a federated object narrows the read and bulk-write scopes, never leaves them unscoped (#27476)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiCYUqfgybT1XmDEJf4JRV5j)_ Fixes #27425 Clause-②: no (narrowing) Metadata-protocol: none ## 维护者速读 - **改了什么:** 外部(federated)对象上,`owner_id` 只有在 `fields` 声明、或 `external.columnMap` 把某个远端列映射到 `owner_id` 时,才算"有所有者列"。否则(平台注入的锚点)读过滤在 `org` 以下的读深度不再返回 `null`(不加过滤),而是 deny-all(只保留待审批记录那条按 id 的分支);批量写过滤同样收紧。fail-closed:证明不了就收窄,绝不放宽。 - **为什么改:** 以前只看 `fields` 声明,没声明就当"没有所有者列"、读不加范围;但 driver 仍可能返回 `owner_id`,于是 `private` 对象对成员不设防。 - **可见变化(BREAKING,patch):** 这类 `private` 外部对象上,成员读不到行;成员 PATCH 由 403 变为 404(编辑前的读同样被收窄)。`public_read` 仍是 403。`org` 深度、Modify All、系统调用、本地对象、声明了 `owner_id` 的外部对象都不变。示例里的两个外部对象是 `public_read_write`,不受影响。 - **先看:** `packages/plugins/plugin-sharing/src/sharing-service.ts`(`buildReadFilter` / `buildWriteFilter` 的 phantom 分支挪到 `org` 判定之后并改为收窄)、`federated-phantom-anchors.ts`(`mapsRemoteColumnOntoOwner`)。 - **两分钟验证:** `pnpm --filter @objectstack/plugin-sharing exec vitest run src/federated-phantom-owner-scoping.test.ts src/external-owner-anchor-sql-read.test.ts`,预期 55 个用例全过。 - **需要你做的:** 无,席位落地。 ## What changes Record sharing decides whether a federated object's `owner_id` is a real column by provenance (`hasPhantomOwnerAnchor`). That test read `fields` only, and a `true` answer made `buildReadFilter` / `buildWriteFilter` return `null` (#7858: "ownership contributes nothing"). A driver can still serve `owner_id` without the schema declaring it, so the unscoped read handed a `private` object's rows to members. - **Provenance.** The owner column is proven when `fields` declares `owner_id` (unchanged) or an `external.columnMap` entry maps a remote column onto `owner_id` (new: `mapsRemoteColumnOntoOwner`). The built-in SQL drivers apply the map and serve the column; a driver that does not apply it serves no `owner_id`, so the owner predicate admits no row there (plugin drivers are floored by the engine since #27403). The map counting as declared keeps the wording of #27422's warning true ("serves the check on a built-in SQL driver"). - **Read filter.** On an unproven anchor, below `org` read depth: no owner predicate and no share rows (a grant on such an object is already refused as inert by `inertGrantReason`); the pending-approval arm stays (an id list the approvals service vouches for). The answer is `{ id: { $in: approvalIds } }` or `{ id: '__deny_all__' }`. `org` still returns `null` (it never reads the owner column). The principal-less case now reaches the existing deny-all. - **Bulk write filter.** The same narrowing (`{ id: '__deny_all__' }` below `org`). It returned `null` before, wider than `checkEdit` / `checkDelete`, which refuse every row of such an object. This is the declared deviation from the card's read-only task list. - **Unit scope (`owning_business_unit_id`).** Checked: no read or write gate composes a predicate over it. `unit` / `unit_and_below` resolve to an `owner_id $in` set through the hierarchy resolver, so they take the same narrowing (pinned at every non-`org` depth). Follow-on wording, each a sentence this fix makes false (surface amendments 1 and 2 on the card): the `security-external-owner-unmapped` message names `404` on `private` and keeps `403` elsewhere; its `os explain` text says reads are refused; the virtual-objects callout says `404`; the external-datasources guide says what `private` needs after its first example. ## Tests - `federated-phantom-owner-scoping.test.ts`: the #7858 `null` pins flip to deny-all at every non-`org` depth (read and bulk write), `org` stays `null`, principal-less deny-all, an external principal on a `public_read` phantom object closed, the approval arm kept. New rows-level block over the sharing double: a plugin driver returning undeclared `owner_id` reads no row; `columnMap` onto `owner_id` reads own-only; a driver that does not apply the map reads no row; declared reads own-only. - New `external-owner-anchor-sql-read.test.ts`: real `ObjectQL` + `SqlDriver` (better-sqlite3) + the sharing middleware over a remote owner column. Served but neither declared nor mapped: no row; `columnMap` onto `owner_id`: own-only; declared: own-only. A premise case shows the driver serves `owner_id` on all three. - `plugin-driver-external-object.spike.test.ts` (e3): 404 `RECORD_NOT_FOUND`, no `driver.update`, and the scoped pre-image read carries the deny sentinel. - `validate-external-owner-sharing.test.ts`: one case pinning the refusal wording per model. - Ablation: with `sharing-service.ts` at `origin/main` and the tests at head, 12 pins go red (every new read/write deny-all pin, both rows-level undeclared cases, the SQL undeclared case); restored, green. ## Verification Local, at head. `pnpm --filter @objectstack/plugin-sharing test`: 58 files / 1290 tests passed. `pnpm --filter @objectstack/lint test`: 177 files / 7590 passed. `@objectstack/plugin-security`: 236 files / 5111 passed. Runtime spike + federated boot binding: 16 passed. REST external tests: 15 passed. `typecheck` (tsc + `check:test-typecheck`) clean for plugin-sharing, lint and runtime. Derived gates: see the os-dev report on the card. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ihL2g7zSyVZAkBv268xuK --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f7188b5 commit f57eddc

11 files changed

Lines changed: 375 additions & 57 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
'@objectstack/lint': patch
4+
---
5+
6+
fix(plugin-sharing)!: on a federated object whose `owner_id` is the platform's injected anchor, the read and bulk-write scopes narrow below `org` instead of going unscoped (#27425)
7+
8+
Clause-②: no (narrowing) — `SharingService.buildReadFilter` and `buildWriteFilter` answer a deny-all scope, where they answered `null` (no scope), for a caller below `org` depth on an ADR-0015 federated object that neither declares `owner_id` in `fields` nor maps a remote column onto it; on `private` such an object's refused edit answers 404 where it answered 403. Nothing is widened and no key is added, removed or renamed.
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: the record-sharing scope of one class of federated object fails closed where it went unscoped. The remedy is a metadata choice the author already has (declare the owner column, or choose the sharing model on purpose), so there is nothing for a migration to rewrite and no tombstone. -->
12+
13+
**BREAKING** read-scope narrowing in record sharing (`@objectstack/plugin-sharing`).
14+
15+
- **Before.** On a federated object (`external` binding) with an owner-keyed sharing model, the sharing service decided whether `owner_id` was real from the declared `fields` alone. When the author had not declared it, the registry's injected anchor was taken to mean "no owner column", and the read filter applied no scope. A driver can still serve `owner_id` (the SQL driver through `external.columnMap`, a remote column of that name, a plugin driver on its own), so a `private` object answered its rows to members it was meant to hide them from. Bulk updates and deletes went unscoped the same way, while single-record writes were refused with 403.
16+
- **Now.** The owner column counts as proven when `fields` declares `owner_id` or an `external.columnMap` entry maps a remote column onto it; such an object keeps ordinary owner scoping (a driver that does not apply the map serves no `owner_id`, so the owner predicate admits nothing there). Otherwise an unproven owner narrows the scope: below `org` read depth the read filter admits no row by ownership, no owner predicate and no share rows (a grant on such an object is already refused as inert), and a pending approval step still opens the record it waits on. The bulk write filter admits no row below `org` write depth. Because an edit reads its record through the same scope first, a member's PATCH on a `private` object of this shape now answers 404 `RECORD_NOT_FOUND` where it answered 403 `PERMISSION_DENIED`; on `public_read` it stays 403. `org` depth, the Modify All bypass, system callers and local objects are unchanged.
17+
- **Who sees it.** An app with a federated object on `private` (including no `sharingModel`, which the runtime treats as `private`), or `public_read` for bulk writes, that neither declares nor maps `owner_id`: members below `org` depth now read no row there, and their refused edits answer 404. An external principal on a `public_read` object meets the same closed scope. `os validate`'s `security-external-owner-unmapped` warning (`@objectstack/lint`) now names the 404 on `private`, and its explanation says reads are refused too.
18+
19+
**The remedy.** Choose on purpose. Declare `owner_id` in `fields` and have the driver return the remote owner under that name (a built-in SQL driver through `external.columnMap`; a plugin driver must return it itself), and the object scopes to the owner. Or choose `sharingModel: 'public_read'` (everyone reads) or `'public_read_write'` (everyone reads and edits, a `beforeUpdate` hook decides who may).

‎content/docs/data-modeling/external-datasources.mdx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ export const Customer = ObjectSchema.create({
9191
That's it. `GET /api/v1/data/ext_customer` now returns live rows from the remote
9292
`customers` table; filters (`?region=EU`) push down to the remote query.
9393

94+
`private` keys reads to the row's owner, and only an `owner_id` declared in `fields`,
95+
or named by an `external.columnMap` entry, counts as one. Without it a member reads
96+
no row, while a holder of View All (`viewAllRecords`) still reads them all. For a
97+
table everyone may read, choose `sharingModel: 'public_read'`.
98+
9499
The object's **own** `datasource` must name a datasource declared
95100
`schemaMode: 'external'` or `'validate-only'` (ADR-0015 §4.2). An `external` binding on
96101
`'default'` (also the value when `datasource` is omitted) is refused by `ObjectSchema.create()`,

‎content/docs/data-modeling/virtual-objects.mdx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -372,10 +372,12 @@ What each piece does on a plugin driver:
372372
`PATCH /data/berth_slot/:id` above.
373373

374374
<Callout type="warn" title="Choose the sharing model on purpose">
375-
On the default sharing model, **every member edit is refused** (`403`), even for a
376-
member whose permission set grants `allowEdit`. Before a write, the platform's ownership
377-
checks ask the remote for `owner_id` and for `created_by` equal to the caller — columns a
378-
remote row does not carry — so every row reads as someone else's
375+
On the default sharing model, **every member edit is refused**, even for a member whose
376+
permission set grants `allowEdit`. That model keys reads to the row's `owner_id`, a column
377+
a remote row does not carry, so the member's read scope admits no row: lists come back
378+
empty and an edit answers `404`. On `public_read` the rows are readable, but before a write
379+
the platform's ownership checks ask the remote for `owner_id` and for `created_by` equal to
380+
the caller, find neither, and refuse the edit with `403`
379381
([#27332](https://github.com/objectstack-ai/objectstack/issues/27332)). Declare
380382
`sharingModel: 'public_read_write'` and decide who may edit in `beforeUpdate`, as above.
381383
Serving an owner column the checks can read is the other way out; the reference test

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -743,7 +743,8 @@ const SECURITY_EXTERNAL_OWNER_UNMAPPED_EXPLANATION: RuleExplanation = {
743743
'the remote database owns the table and the platform creates no column for either. So the remote row ' +
744744
'answers no `owner_id` (record sharing refuses) and no `created_by` equal to the caller (the floor ' +
745745
'refuses). A member whose permission set grants `allowEdit` is refused every edit and every delete ' +
746-
'with 403 `PERMISSION_DENIED`, unless an app-authored row-level policy admits the row; otherwise only ' +
746+
'with 403 `PERMISSION_DENIED` (404 on `private`, where reads are scoped by the same owner and no row is ' +
747+
'in reach), unless an app-authored row-level policy admits the row; otherwise only ' +
747748
'an administrator can write. An object with no `sharingModel` is ' +
748749
'judged as \'private\', the value the runtime falls back to.',
749750
'`ownership: \'none\'` (or `\'org\'`, `\'business_unit\'`) is not a way out: without `owner_id` record ' +
@@ -756,8 +757,9 @@ const SECURITY_EXTERNAL_OWNER_UNMAPPED_EXPLANATION: RuleExplanation = {
756757
'that name, so the owner edits and deletes the row and other members are refused. A built-in SQL ' +
757758
'driver does that through `external.columnMap` (remote column to `owner_id`); `columnMap` is applied ' +
758759
'by those drivers, not by the engine, so a plugin driver must return the field itself. Declaring ' +
759-
'the field matters for reads too: the platform\'s own `owner_id` is treated as unstored, and reads ' +
760-
'are not scoped by owner. On an object without `owner_id`, declare `created_by` the same way instead, ' +
760+
'the field matters for reads too: the platform\'s own `owner_id`, with no field or `columnMap` entry ' +
761+
'naming it, proves no owner, so on `private` a member reads no row at all. On an object without ' +
762+
'`owner_id`, declare `created_by` the same way instead, ' +
761763
'so the creator writes the row.',
762764
'A `columnMap` entry for the column without a declared field is reported too, with its own wording: ' +
763765
'it serves the check on a built-in SQL driver and does nothing on a plugin driver, and the ' +

‎packages/lint/src/validate-external-owner-sharing.test.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
/**
1212
* The two external objects of `packages/runtime/src/plugin-driver-external-object.spike.test.ts`,
1313
* copied verbatim (fields trimmed to what the rule reads). That spike measures the runtime half:
14-
* on `spike_owned` (no `sharingModel`) a member holding `allowEdit` gets 403 on every PATCH,
14+
* on `spike_owned` (no `sharingModel`) a member holding `allowEdit` is refused every PATCH (404
15+
* since #27425: the read scope admits no row),
1516
* step (e3); on `spike_node` (`public_read_write`) the same member's write reaches the driver.
1617
*/
1718
const SPIKE_STACK = defineStack({
@@ -135,6 +136,14 @@ describe('security-external-owner-unmapped — which objects it judges', () => {
135136
expect(findings[0]!.message).toContain('columnMap');
136137
});
137138

139+
it('[#27425] names the refusal the model produces: 404 on private (reads are scoped too), 403 on public_read', () => {
140+
for (const obj of [ext(), ext({ sharingModel: 'private' }), ext({ sharingModel: 'private' }, { columnMap: { assignee: 'owner_id' } })]) {
141+
expect(findingsFor(obj)[0]!.message).toContain("refused (404: the member's read scope admits no row)");
142+
}
143+
expect(findingsFor(ext({ sharingModel: 'public_read' }))[0]!.message).toContain('refused (403)');
144+
expect(findingsFor(ext({ sharingModel: 'private', ownership: 'none' }))[0]!.message).toContain('refused (403)');
145+
});
146+
138147
it('a columnMap that maps only created_by does not serve the owner check', () => {
139148
const findings = findingsFor(ext({ sharingModel: 'private' }, { columnMap: { author: 'created_by' } }));
140149
expect(findings).toHaveLength(1);

‎packages/lint/src/validate-external-owner-sharing.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,13 @@ export function validateExternalOwnerSharing(stack: AnyRec): ExternalOwnerSharin
163163
: 'with no owner_id, the ownership floor reads created_by == the caller';
164164
const mapped = columnMapTargets(external as AnyRec, column);
165165
const role = column === OWNER_COLUMN ? 'owner' : 'creator';
166+
// [#27425] On `private`, record sharing also scopes READS by owner_id, and
167+
// an owner it cannot read admits no row: the edit's pre-image read finds
168+
// none, so the refusal is a 404 rather than the write check's 403.
169+
const refusal =
170+
column === OWNER_COLUMN && effectiveSharingModelOf(obj) === 'private'
171+
? "(404: the member's read scope admits no row)"
172+
: '(403)';
166173
const declareWayOut =
167174
`declare ${column} in fields and have the driver return the remote ${role} under that name ` +
168175
'(a built-in SQL driver does it through external.columnMap; a plugin driver must return it itself)';
@@ -178,10 +185,10 @@ export function validateExternalOwnerSharing(stack: AnyRec): ExternalOwnerSharin
178185
message: mapped
179186
? `external object on sharing model ${model} maps a remote column to '${column}' through ` +
180187
`external.columnMap but declares no '${column}' field; ${check} before every edit and delete, ` +
181-
'and only a built-in SQL driver applies columnMap, so on a plugin driver every member write is refused (403) ' +
188+
`and only a built-in SQL driver applies columnMap, so on a plugin driver every member write is refused ${refusal} ` +
182189
'unless an app-authored row-level policy admits the row'
183190
: `external object on sharing model ${model} declares no '${column}' field a remote row serves; ` +
184-
`${check} before every edit and delete, so every member write is refused (403) unless an app-authored ` +
191+
`${check} before every edit and delete, so every member write is refused ${refusal} unless an app-authored ` +
185192
'row-level policy admits the row',
186193
hint: mapped
187194
? `Declare ${column} in fields (a plugin driver must return the remote ${role} under that name itself), ` +
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27425] The SQL-driver half of the pin, over `private` federated objects
5+
* whose remote rows carry an owner.
6+
*
7+
* - The remote column is literally `owner_id` and `fields` do not declare it:
8+
* the driver serves it, but the registered field is the platform's injected
9+
* anchor and nothing declared says the column exists. Before the fix that
10+
* read as "ownership contributes nothing" (`null`), so a member read every
11+
* row; now the scope narrows to none.
12+
* - `external.columnMap` maps the remote owner column onto `owner_id`: the map
13+
* is declared metadata, the SQL driver applies it, and the member reads own
14+
* rows only, as with a declared field.
15+
* - `owner_id` declared in `fields`: the control, own rows only.
16+
*
17+
* Real `ObjectQL`, real `SqlDriver` on `better-sqlite3`, the real sharing
18+
* middleware: the registry's injection, the driver's remap and the composed
19+
* filter are each the shipped code, not a fixture of it.
20+
*/
21+
22+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
23+
import { ObjectQL } from '@objectstack/objectql';
24+
import { SqlDriver } from '@objectstack/driver-sql';
25+
26+
import { SharingService } from './sharing-service.js';
27+
import { buildSharingMiddleware } from './sharing-plugin.js';
28+
import { hasPhantomOwnerAnchor } from './federated-phantom-anchors.js';
29+
30+
const MEMBER = 'u_member';
31+
const ADMIN = 'u_admin';
32+
33+
/** The remote table: its owner column is called `assignee`. */
34+
const REMOTE = {
35+
name: 'remote_rows',
36+
fields: {
37+
id: { type: 'text', name: 'id', label: 'Id', primary: true },
38+
name: { type: 'text', name: 'name', label: 'Name' },
39+
assignee: { type: 'text', name: 'assignee', label: 'Assignee' },
40+
},
41+
};
42+
43+
const SHARE_FIELDS: Record<string, Record<string, unknown>> = {
44+
id: { type: 'text', name: 'id', label: 'Id', primary: true },
45+
object_name: { type: 'text', name: 'object_name', label: 'Object' },
46+
record_id: { type: 'text', name: 'record_id', label: 'Record' },
47+
recipient_type: { type: 'text', name: 'recipient_type', label: 'Recipient type' },
48+
recipient_id: { type: 'text', name: 'recipient_id', label: 'Recipient' },
49+
access_level: { type: 'text', name: 'access_level', label: 'Access' },
50+
};
51+
52+
/** A second remote table whose owner column is literally `owner_id`. */
53+
const REMOTE_OWNED = {
54+
name: 'remote_owned',
55+
fields: {
56+
id: { type: 'text', name: 'id', label: 'Id', primary: true },
57+
name: { type: 'text', name: 'name', label: 'Name' },
58+
owner_id: { type: 'text', name: 'owner_id', label: 'Owner' },
59+
},
60+
};
61+
62+
const binding = { remoteName: REMOTE.name, columnMap: { assignee: 'owner_id' } };
63+
64+
/** Served but undeclared: no map, no field, the registry injects its anchor. */
65+
const SERVED_UNDECLARED = {
66+
name: 'ext_owned',
67+
label: 'External owned rows',
68+
sharingModel: 'private',
69+
external: { remoteName: REMOTE_OWNED.name },
70+
fields: { name: { type: 'text', label: 'Name' } },
71+
};
72+
73+
/** `owner_id` undeclared, but the columnMap names the remote owner column. */
74+
const MAPPED = {
75+
name: 'ext_rows',
76+
label: 'External rows',
77+
sharingModel: 'private',
78+
external: binding,
79+
fields: { name: { type: 'text', label: 'Name' } },
80+
};
81+
82+
/** The control: `owner_id` declared, the author vouches for the column. */
83+
const DECLARED = {
84+
name: 'ext_rows_declared',
85+
label: 'External rows (declared owner)',
86+
sharingModel: 'private',
87+
external: binding,
88+
fields: {
89+
name: { type: 'text', label: 'Name' },
90+
owner_id: { type: 'lookup', reference: 'sys_user', label: 'Owner' },
91+
},
92+
};
93+
94+
describe('[#27425] a private SQL federated object whose remote rows carry an owner', () => {
95+
let driver: SqlDriver;
96+
let ql: ObjectQL;
97+
98+
beforeAll(async () => {
99+
driver = new SqlDriver({
100+
client: 'better-sqlite3',
101+
connection: { filename: ':memory:' },
102+
useNullAsDefault: true,
103+
});
104+
ql = new ObjectQL();
105+
ql.registerDriver(driver as never, true);
106+
await ql.init();
107+
108+
await driver.initObjects([
109+
REMOTE as never,
110+
REMOTE_OWNED as never,
111+
{ name: 'sys_record_share', fields: SHARE_FIELDS } as never,
112+
]);
113+
await driver.create(REMOTE.name, { id: 'r_mine', name: 'Mine', assignee: MEMBER });
114+
await driver.create(REMOTE.name, { id: 'r_theirs', name: 'Theirs', assignee: ADMIN });
115+
await driver.create(REMOTE_OWNED.name, { id: 'r_mine', name: 'Mine', owner_id: MEMBER });
116+
await driver.create(REMOTE_OWNED.name, { id: 'r_theirs', name: 'Theirs', owner_id: ADMIN });
117+
118+
ql.registerObject({ name: 'sys_record_share', label: 'Record share', isSystem: true, fields: SHARE_FIELDS } as never);
119+
for (const object of [SERVED_UNDECLARED, MAPPED, DECLARED]) {
120+
ql.registerObject(object as never);
121+
// What the schema sync does for a federated object: bind it, no DDL.
122+
driver.registerExternalObject(ql.getSchema(object.name) as never);
123+
}
124+
ql.registerMiddleware(buildSharingMiddleware(new SharingService({ engine: ql as never })) as never, { object: '*' });
125+
});
126+
127+
afterAll(async () => {
128+
await driver?.disconnect?.();
129+
});
130+
131+
const memberReads = async (object: string): Promise<string[]> => {
132+
const rows = (await ql.find(object, { context: { userId: MEMBER } } as never)) as Array<{ id: string }>;
133+
return rows.map((r) => r.id).sort();
134+
};
135+
136+
it('PREMISE: the driver serves owner_id on all three, and only the unmapped, undeclared one is the injected anchor', async () => {
137+
for (const object of [SERVED_UNDECLARED.name, MAPPED.name, DECLARED.name]) {
138+
const served = (await driver.find(object, {} as never)) as Array<Record<string, unknown>>;
139+
expect(served.map((r) => [r.id, r.owner_id]).sort()).toEqual([
140+
['r_mine', MEMBER],
141+
['r_theirs', ADMIN],
142+
]);
143+
}
144+
expect(hasPhantomOwnerAnchor(ql.getSchema(SERVED_UNDECLARED.name))).toBe(true);
145+
expect(hasPhantomOwnerAnchor(ql.getSchema(MAPPED.name))).toBe(false);
146+
expect(hasPhantomOwnerAnchor(ql.getSchema(DECLARED.name))).toBe(false);
147+
});
148+
149+
it('owner_id served but neither declared nor mapped: the member reads no row (it read 2 of 2 before)', async () => {
150+
expect(await memberReads(SERVED_UNDECLARED.name)).toEqual([]);
151+
});
152+
153+
it('columnMap → owner_id with no declared field: own rows only (it read 2 of 2 before)', async () => {
154+
expect(await memberReads(MAPPED.name)).toEqual(['r_mine']);
155+
});
156+
157+
it('ANTI-VACUITY: owner_id declared over the same table reads own-only', async () => {
158+
expect(await memberReads(DECLARED.name)).toEqual(['r_mine']);
159+
});
160+
});

0 commit comments

Comments
 (0)