Skip to content

Commit 328ccc5

Browse files
os-zhuangclaude
andauthored
fix(analytics): 记录级权限范围与 measure 字段校验 (#4467, #4437) (#4494)
* wip: analytics record-level scoping (#4467) + measure field validation (#4437) Two of the three v17 verification defects on the analytics query path. Both reproduced live on a showcase dev server before the change and re-verified after; regression tests still to be added (hence wip). #4467 — /analytics/query ignored record-level scoping `ISecurityService.getReadFilter` documents itself as "the same filter the engine middleware AND-s into every find", exposed for paths that bypass the middleware (the analytics raw-SQL path has no other source of scope). That middleware chain is TWO siblings: plugin-security's RLS injection and plugin-sharing's owner/share visibility filter. Only the RLS half was ever computed, so the analytics path ran with no owner predicate at all. Live repro (showcase, `showcase_private_note` sharingModel:'private', admin owns 5, member holds 2 shares and no viewAllRecords): GET /data/showcase_private_note member -> total 2 correct POST /analytics/query {measures:[count]} member -> count 5 LEAK ... + dimensions:["title"] member -> all 5 titles getReadFilter now resolves plugin-sharing's buildReadFilter through the late-bound `sharing` service and AND-composes it with the RLS filter, and computes the ADR-0057 D1 `__readScope` depth the middleware normally stashes on the context (no middleware runs on this path). Resolved for every non-system caller ahead of the RLS branches — none of the RLS stand-downs is a reason to drop a sibling middleware's predicate — and a resolution failure denies rather than emitting unscoped SQL. #4437 — a measure naming a missing field 500'd with SQLITE_ERROR `inferMeasure('ghost_sum')` built `SUM(ghost)` with no way to know the field exists; the driver threw `no such column` and the caller got `500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver error class on the wire for a plain typo (ADR-0112). The DATA route has refused the same mistake with a 400 naming the field since #4315/#4254. `ensureCube` now validates each measure's resolved source field against the backing object's field names before any SQL is built, and rejects with the same envelope the data route uses (400 INVALID_FIELD + field/object/param). Gated the same way as the #3867 inference gate: only for a cube whose `sql` is a bare object name, only when the new `getObjectFieldNames` probe answers, and only for measures whose source is a bare column (count(*) and dotted cross-object references pass through). Validation runs before the cube is registered so a rejected query leaves no trace in the registry. Refs #4467, #4437 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * test: pin the analytics scoping + measure-field gates (#4467, #4437) Regression cases for the two fixes in the previous commit, plus a polish to the #4437 rejection message. #4467 — `security-plugin.test.ts` gains an OWD/sharing block under the existing `getReadFilter service` describe: AND-composition with the RLS filter, the sharing predicate surviving alone when RLS contributes nothing, the ADR-0057 D1 `__readScope` depth being passed (no middleware runs on this path to stash it), fail-closed on a sharing-resolution throw, the isSystem bypass, and a deployment without plugin-sharing being unaffected. The harness gains an optional `sharing` service double. #4437 — a new `measure-source-field-gate.test.ts` covering the 400 envelope and its `field`/`object`/`param`/`measure` members, the dotted `total.sum` spelling, registry non-poisoning, every legitimate measure spelling still running, an authored cube whose declared measure lost its field, and the three stand-downs (no probe, an object the probe cannot describe, and a cube whose `sql` is an expression rather than an object name). A dotted cross-object measure is asserted to reach the STRATEGY — the layer that owns that decision — rather than being reported as a missing column here. Polish: the rejection listed the caller's own typo as a valid alternative on the auto-inference path, because `cube.measures` there was inferred from the very query being rejected. The suggestion list now excludes measures that failed the check, and names the object's known fields. Refs #4467, #4437 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * chore: add changeset for the analytics scoping + measure-field fixes (#4467, #4437) Both packages are publishable and both changes are observable on a public surface, so this is a real changeset rather than an empty one. Levelled `minor` on both counts. #4467 narrows a public read surface — analytics results a principal could previously read they now cannot, so counts drop and `dimensions` groupings lose rows for non-superuser callers on owner-private objects. #4437 changes the response envelope for a caller-shaped mistake (500 SQLITE_ERROR → 400 INVALID_FIELD), which any caller branching on `error.code` will observe. Neither changes an API signature: `ISecurityService.getReadFilter`'s declaration is untouched, and the implementation merely started honouring the contract it already documented. Refs #4467, #4437 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0800433 commit 328ccc5

6 files changed

Lines changed: 746 additions & 5 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
"@objectstack/service-analytics": minor
4+
---
5+
6+
fix(security,analytics): scope /analytics/query to the caller's readable records, and refuse a measure over a missing field (#4467, #4437)
7+
8+
Two defects on the analytics query path, both found by the v17 verification run
9+
(#3909 / #4482), both reproduced against a live showcase server before the fix
10+
and re-verified with the same requests after.
11+
12+
## #4467`/analytics/query` applied no record-level scoping
13+
14+
`ISecurityService.getReadFilter` documents itself as "the same filter the engine
15+
middleware AND-s into every find", and exists precisely for paths that bypass
16+
that middleware — its own doc comment names the analytics raw-SQL path. But the
17+
chain it mirrors is TWO sibling middlewares: plugin-security's RLS injection and
18+
plugin-sharing's owner/share visibility filter (`buildSharingMiddleware` AND-s
19+
`buildReadFilter` into `ast.where` for `find`/`findOne`/`count`/`aggregate`).
20+
Only the RLS half was ever computed here, and analytics has no other source of
21+
scope, so the OWD/share predicate simply never existed on that path.
22+
23+
Live repro: `showcase_private_note` is `sharingModel: 'private'`; an admin owns
24+
5 notes, a member holds read shares on exactly 2 and no `viewAllRecords`.
25+
`GET /data/showcase_private_note` correctly returned 2 for the member, while
26+
`POST /analytics/query {measures:['count']}` returned 5 — and adding
27+
`dimensions:['title']` returned all five titles, i.e. the VALUES of a column
28+
that caller may not read, not merely a bad count. Any authenticated caller who
29+
could reach `/analytics` could enumerate the field values of every row of any
30+
object exposed as a cube, regardless of OWD, sharing rules, or RLS.
31+
32+
`getReadFilter` now resolves plugin-sharing's `buildReadFilter` through the
33+
late-bound `sharing` service and AND-composes it with the RLS filter — the same
34+
composition the two middlewares reach by both writing into `ast.where`. It also
35+
computes the ADR-0057 D1 `__readScope` depth that the security middleware
36+
normally stashes on the context for plugin-sharing to widen its owner-match
37+
with, using the same `getEffectiveScope` call the middleware makes: no
38+
middleware runs on this path, and without it a caller granted `unit`/`org` read
39+
depth would be silently narrowed to `own`. The sharing predicate is resolved for
40+
every non-system caller AHEAD of the RLS stand-down branches, because those are
41+
the RLS middleware's own early exits and none of them is a reason to drop a
42+
sibling middleware's predicate; a sharing-resolution failure denies outright
43+
rather than falling through to half a scope.
44+
45+
**Why `minor` rather than `patch`.** This is an observable behaviour change on a
46+
public read surface, in the narrowing direction: analytics results that a
47+
principal could previously read they now cannot. Counts drop, `dimensions`
48+
groupings lose rows, and any dashboard, report, or export built on
49+
`/analytics/query` over an owner-private object will show smaller numbers for
50+
non-superuser principals — correctly, but visibly. Deployments that had (however
51+
unknowingly) come to depend on the unscoped totals will see them change on
52+
upgrade, so this warrants more than a patch-level note even though it is a
53+
security fix. No API signature changed: `ISecurityService.getReadFilter`'s
54+
declaration is untouched — the implementation merely started honouring the
55+
contract it already documented.
56+
57+
## #4437 — a measure naming a missing field 500'd with SQLITE_ERROR
58+
59+
`inferMeasure('ghost_sum')` maps a suffix convention onto a field name and has
60+
no way to know the field exists, so it built `SUM(ghost)`, the driver threw
61+
`no such column`, and the caller got
62+
`500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver error
63+
class as the `error.code` for what is a plain typo, which ADR-0112 forbids. A
64+
dotted spelling took the same path (`measures:['total.sum']` prefix-strips to
65+
`sum``SUM(sum)` → 500). The DATA route has refused the identical mistake with
66+
a `400 INVALID_FIELD` naming the field since #4315/#4254.
67+
68+
`AnalyticsService.ensureCube` now validates each measure's resolved source field
69+
against the backing object's field names before any SQL is built, and rejects
70+
with the same envelope the data route produces (`400 INVALID_FIELD` carrying
71+
`field`, `object`, `param`, `measure`) so one mistake has one shape across
72+
`/data` and `/analytics`. The new `getObjectFieldNames` config hook reads the
73+
same schema registry `isRegisteredObject` already consults and the data path's
74+
own gate reads, so "which fields exist" has a single answer across both routes.
75+
76+
The gate is tiered exactly like the #3867 cube-inference gate, deliberately
77+
narrow: it applies only when the cube's `sql` is a bare object name (an authored
78+
cube whose `sql` is a real SQL expression has no field list to check against),
79+
only when the probe answers (no data engine, or an external datasource whose
80+
columns are not mirrored locally, stands down), and only to measures whose
81+
source is a bare column — `count(*)` has no source field, and a dotted
82+
cross-object reference resolves through a join this layer cannot see, so both
83+
pass through untouched. `id`/`created_at`/`updated_at` are admitted
84+
unconditionally, matching the data path's `resolveQueryFields`: a gate stricter
85+
than the engine it guards would reject queries that used to work. Validation
86+
runs before the cube is registered, so a rejected query leaves no trace in the
87+
registry — otherwise a retry would find a "registered" cube carrying the bogus
88+
measure and sail straight into SQL.
89+
90+
This half is `minor` for the same envelope reason: a request that used to return
91+
500 now returns 400 with a different `code`, which is a visible contract change
92+
for any caller branching on the response.

packages/plugins/plugin-security/src/security-plugin.test.ts

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ describe('SecurityPlugin', () => {
140140
// wildcard `current_user.organization_id` RLS policies. Otherwise it
141141
// strips them so single-tenant deployments aren't filtered to nothing.
142142
// -------------------------------------------------------------------------
143-
const makeMiddlewareCtx = (overrides: { permissionSets: PermissionSet[]; objectFields?: string[]; schemaExtra?: Record<string, any>; orgScoping?: boolean; findOneImpl?: (query: any) => any }) => {
143+
const makeMiddlewareCtx = (overrides: { permissionSets: PermissionSet[]; objectFields?: string[]; schemaExtra?: Record<string, any>; orgScoping?: boolean; findOneImpl?: (query: any) => any; sharing?: any }) => {
144144
const fields: Record<string, any> = {};
145145
for (const f of overrides.objectFields ?? ['id', 'organization_id', 'owner_id', 'name']) {
146146
fields[f] = { name: f };
@@ -177,6 +177,10 @@ describe('SecurityPlugin', () => {
177177
// Sentinel object — SecurityPlugin only checks truthiness.
178178
services['org-scoping'] = { name: 'com.objectstack.org-scoping' };
179179
}
180+
// [#4467] The optional plugin-sharing service. Absent by default, which is
181+
// exactly the deployment shape every case above assumes; supply it to
182+
// exercise the OWD/sharing half of the read scope.
183+
if (overrides.sharing) services['sharing'] = overrides.sharing;
180184
const ctx: any = {
181185
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
182186
registerService: vi.fn(),
@@ -1355,6 +1359,155 @@ describe('SecurityPlugin', () => {
13551359
const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] });
13561360
expect(filter).toEqual(RLS_DENY_FILTER);
13571361
});
1362+
1363+
// -----------------------------------------------------------------------
1364+
// [#4467] The OWD / record-sharing half of the read scope.
1365+
//
1366+
// `getReadFilter` promises "the same filter the engine middleware AND-s
1367+
// into every find". That chain is TWO sibling middlewares — this plugin's
1368+
// RLS injection and plugin-sharing's owner/share visibility filter — and
1369+
// only the RLS half was ever computed here. The analytics raw-SQL path has
1370+
// no other source of scope, so `POST /analytics/query` ran with no owner
1371+
// predicate at all. Live repro on showcase before the fix, member holding
1372+
// shares on 2 of an admin's 5 private notes and no `viewAllRecords`:
1373+
//
1374+
// GET /data/showcase_private_note member → total 2 correct
1375+
// POST /analytics/query {measures:[count]} member → count 5 LEAK
1376+
// ... + dimensions:["title"] member → all 5 titles
1377+
//
1378+
// The dimension case is why this is a disclosure and not just a bad count:
1379+
// grouping returns the VALUES of a column the caller may not read.
1380+
// -----------------------------------------------------------------------
1381+
describe('[#4467] OWD / sharing composition', () => {
1382+
/** A plugin-sharing double that scopes `task` to owner-or-shared. */
1383+
const ownerOrShared = {
1384+
buildReadFilter: vi.fn(async (_object: string, ctx: any) => ({
1385+
$or: [{ owner_id: ctx.userId }, { id: { $in: ['rec-1', 'rec-2'] } }],
1386+
})),
1387+
};
1388+
1389+
it('AND-composes the sharing predicate with the RLS filter', async () => {
1390+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
1391+
const harness = makeMiddlewareCtx({
1392+
permissionSets: [tenantPolicySet],
1393+
sharing: ownerOrShared,
1394+
});
1395+
await plugin.init(harness.ctx);
1396+
await plugin.start(harness.ctx);
1397+
1398+
const filter = await plugin.getReadFilter('task', {
1399+
userId: 'u1', tenantId: 'org-1', positions: [], permissions: [],
1400+
});
1401+
1402+
// Pre-fix this was `{ organization_id: 'org-1' }` alone — every row of
1403+
// the tenant, regardless of ownership.
1404+
expect(filter).toEqual({
1405+
$and: [
1406+
{ organization_id: 'org-1' },
1407+
{ $or: [{ owner_id: 'u1' }, { id: { $in: ['rec-1', 'rec-2'] } }] },
1408+
],
1409+
});
1410+
});
1411+
1412+
it('returns the sharing predicate alone when RLS contributes nothing', async () => {
1413+
// An owner-private object in a deployment with no tenant policy: the
1414+
// sharing half is then the ONLY thing standing between the caller and
1415+
// every row, so it must survive on its own rather than collapsing to
1416+
// `undefined` with the empty RLS half.
1417+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
1418+
const harness = makeMiddlewareCtx({
1419+
permissionSets: [{
1420+
name: 'member_default',
1421+
label: 'Member',
1422+
objects: { '*': { allowRead: true } },
1423+
} as any],
1424+
sharing: ownerOrShared,
1425+
});
1426+
await plugin.init(harness.ctx);
1427+
await plugin.start(harness.ctx);
1428+
1429+
const filter = await plugin.getReadFilter('task', {
1430+
userId: 'u1', tenantId: 'org-1', positions: [], permissions: [],
1431+
});
1432+
1433+
expect(filter).toEqual({ $or: [{ owner_id: 'u1' }, { id: { $in: ['rec-1', 'rec-2'] } }] });
1434+
});
1435+
1436+
it('passes the ADR-0057 D1 read DEPTH the middleware would have stashed', async () => {
1437+
// plugin-sharing widens its owner-match from `__readScope`, which the
1438+
// engine middleware writes onto the context before the sharing
1439+
// middleware runs. No middleware runs on this path, so getReadFilter
1440+
// must compute it — otherwise a caller granted `org` read depth is
1441+
// silently narrowed to `own` here while `/data` shows them everything.
1442+
const capture = { buildReadFilter: vi.fn(async () => null) };
1443+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
1444+
const harness = makeMiddlewareCtx({
1445+
permissionSets: [{
1446+
name: 'member_default',
1447+
label: 'Member',
1448+
objects: { '*': { allowRead: true, readScope: 'unit' } },
1449+
} as any],
1450+
sharing: capture,
1451+
});
1452+
await plugin.init(harness.ctx);
1453+
await plugin.start(harness.ctx);
1454+
1455+
await plugin.getReadFilter('task', {
1456+
userId: 'u1', tenantId: 'org-1', positions: [], permissions: [],
1457+
});
1458+
1459+
expect(capture.buildReadFilter).toHaveBeenCalledWith(
1460+
'task',
1461+
expect.objectContaining({ __readScope: 'unit', userId: 'u1' }),
1462+
);
1463+
});
1464+
1465+
it('fail-closed: a sharing-resolution throw denies rather than under-scoping', async () => {
1466+
// Dropping this predicate is precisely the leak, so an unresolvable
1467+
// sharing layer must deny — never fall through to the RLS half alone.
1468+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
1469+
const harness = makeMiddlewareCtx({
1470+
permissionSets: [tenantPolicySet],
1471+
sharing: { buildReadFilter: async () => { throw new Error('share store unavailable'); } },
1472+
});
1473+
await plugin.init(harness.ctx);
1474+
await plugin.start(harness.ctx);
1475+
1476+
const filter = await plugin.getReadFilter('task', {
1477+
userId: 'u1', tenantId: 'org-1', positions: [], permissions: [],
1478+
});
1479+
1480+
expect(filter).toEqual(RLS_DENY_FILTER);
1481+
});
1482+
1483+
it('a system context still bypasses both halves', async () => {
1484+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
1485+
const sharing = { buildReadFilter: vi.fn(async () => ({ owner_id: 'u1' })) };
1486+
const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet], sharing });
1487+
await plugin.init(harness.ctx);
1488+
await plugin.start(harness.ctx);
1489+
1490+
const filter = await plugin.getReadFilter('task', { isSystem: true, userId: 'u1', tenantId: 'org-1' });
1491+
1492+
expect(filter).toBeUndefined();
1493+
expect(sharing.buildReadFilter).not.toHaveBeenCalled();
1494+
});
1495+
1496+
it('a deployment without plugin-sharing is unaffected', async () => {
1497+
// The service is optional; its absence must not change the RLS answer
1498+
// (and must not throw on the lookup).
1499+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
1500+
const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet] });
1501+
await plugin.init(harness.ctx);
1502+
await plugin.start(harness.ctx);
1503+
1504+
const filter = await plugin.getReadFilter('task', {
1505+
userId: 'u1', tenantId: 'org-1', positions: [], permissions: [],
1506+
});
1507+
1508+
expect(filter).toEqual({ organization_id: 'org-1' });
1509+
});
1510+
});
13581511
});
13591512

13601513
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)