Skip to content

Commit 3962023

Browse files
os-zhuangclaude
andauthored
feat(spec,security): nav landing exclusivity + field-permission predicate guard (anti filter-oracle) (#2630)
* feat(spec,security): nav landing exclusivity + field-permission predicate guard Two guards completing objectui#2251 / objectui ADR-0055: spec — NavigationItemSchema rejects object items combining filters with recordId or viewName (superRefine on the union member; base schema stays extendable). Runtime precedence would silently ignore the extras — a stale recordId hijacking a configured filters slice — so the ambiguous shape is now unwritable (ADR-0053 correct-by-construction). The legacy recordId+viewName combination stays tolerated (documented). 4 new schema tests; api-surface unchanged. plugin-security — anti filter-oracle predicate guard. FieldMasker only masks RESULTS; filtering/sorting/grouping/aggregating by a hidden field still leaked its values through row presence. The middleware now rejects (403, reason: field_predicate_denied) caller queries whose where/orderBy/groupBy/having/aggregations/windowFunctions reference a non-readable field — evaluated against the caller's AST BEFORE RLS injection so RLS policies may keep referencing hidden fields. Rejection over silent dropping: removing an $and branch widens results and re-opens the oracle (Salesforce FLS errors the same way). 10 new unit tests; plugin-security suite 183/183. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018m3GX7EMKNPDZuee152EKK * docs(permissions): document the field-predicate 403 in the authorization layer table Layer 6 (field-level security) now states the predicate guard: caller queries filtering/sorting/grouping/aggregating by a non-readable field are rejected (403 field_predicate_denied) rather than value-leaking through row presence; RLS-injected predicates are exempt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018m3GX7EMKNPDZuee152EKK --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 75c310f commit 3962023

8 files changed

Lines changed: 352 additions & 3 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-security': minor
4+
---
5+
6+
feat(spec,security): make ambiguous nav landings unrepresentable + close the field-permission filter oracle (objectui#2251, objectui ADR-0055).
7+
8+
**spec — `ObjectNavItem` target exclusivity.** `NavigationItemSchema` now rejects an object nav item that combines `filters` with `recordId` or `viewName` (custom issue on `filters` with the fix in the message). Runtime precedence would silently ignore the extras — a stale `recordId` hijacking a configured `filters` slice — so the ambiguous combination is now unwritable (ADR-0053 correct-by-construction). FROM `{ filters, viewName }` / `{ filters, recordId }` TO exactly one landing field; the legacy `recordId` + `viewName` combination stays tolerated (documented: `viewName` is ignored). `filters` shipped in the same unreleased minor, so no released metadata is affected.
9+
10+
**plugin-security — field-level predicate guard.** `FieldMasker` strips non-readable fields from RESULTS, but predicates still leaked their values: filtering / sorting / grouping / aggregating by a hidden field changes row presence (a filter oracle — probe `salary >= X` even though the column is masked). The security middleware now rejects (403 `PermissionDeniedError`, `reason: 'field_predicate_denied'`) any caller query whose `where` / `orderBy` / `groupBy` / `having` / `aggregations` / `windowFunctions` reference a field the caller cannot read — evaluated against the caller's AST **before** RLS injection, so RLS policies may keep referencing hidden fields (e.g. `owner_id`). Rejection over silent predicate dropping: removing an `$and` branch widens results and re-opens the oracle. New exports: `assertReadableQueryFields`, `collectQueryFields`, `collectConditionFields`.

content/docs/permissions/authorization.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ site — the file you read when behavior surprises you.
4747
| 3 | **Object CRUD** | `allowRead/Create/Edit/Delete` (+ the destructive lifecycle class `allowTransfer/Restore/Purge`, gated ahead of the M2 operations — #1883) resolved across the caller's permission sets. | `packages/plugins/plugin-security/src/permission-evaluator.ts` `checkObjectPermission` | fail-closed 403 |
4848
| 4 | **OWD / sharing** | Org-wide default (`private` / `public_read` / `public_read_write` / `controlled_by_parent`), manual record shares, criteria sharing rules (owner-type rules are declared but seed-skipped — [not enforced](/docs/permissions/sharing-rules#owner-based-sharing-rules)), business-unit hierarchy widening (ADR-0057 D5: scope-depth hierarchy lives on `sys_business_unit`, not roles). | `packages/plugins/plugin-sharing/src/sharing-service.ts` + `sharing-rule-service.ts` | owner-only baseline |
4949
| 5 | **Row-level security** | CEL predicates (`using` read filter, `check` write post-image) compiled into the query. If **no** applicable policy compiles, the result is a deny-all sentinel (fail-closed); an uncompilable policy alongside compilable ones is excluded from the OR-union **with a logged warning** — exclusion can only narrow access, never widen it. Tenant isolation is a wildcard RLS rule AND-ed on top. | `packages/plugins/plugin-security/src/rls-compiler.ts` + `security-plugin.ts` | fail-closed |
50-
| 6 | **Field-level security** | Read mask (strip non-readable fields) + write deny per `fields` rules. | `packages/plugins/plugin-security/src/field-masker.ts` | see posture note below |
50+
| 6 | **Field-level security** | Read mask (strip non-readable fields) + write deny per `fields` rules. Caller queries that **filter, sort, group, or aggregate by** a non-readable field are rejected outright (HTTP 403, `field_predicate_denied`) — masking only the output would leave row presence as a value oracle. RLS-injected predicates are exempt (they run after the guard and may reference hidden fields like `owner_id`). | `packages/plugins/plugin-security/src/field-masker.ts` + `predicate-guard.ts` | fail-closed on predicates; see posture note below |
5151

5252
Two orthogonal identity-layer gates run before all of this: the ADR-0069
5353
**authentication-policy gate** (password expiry / enforced MFA blocks a gated

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export { SecurityPlugin } from './security-plugin.js';
1111
export { PermissionEvaluator } from './permission-evaluator.js';
1212
export { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js';
1313
export { FieldMasker } from './field-masker.js';
14+
export { assertReadableQueryFields, collectQueryFields, collectConditionFields } from './predicate-guard.js';
1415
export { PermissionDeniedError, isPermissionDeniedError } from './errors.js';
1516
export {
1617
securityObjects,
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/** Field-level predicate guard — anti filter-oracle (objectui#2251). */
4+
5+
import { describe, it, expect } from 'vitest';
6+
import {
7+
collectConditionFields,
8+
collectQueryFields,
9+
assertReadableQueryFields,
10+
} from './predicate-guard.js';
11+
import { isPermissionDeniedError } from './errors.js';
12+
13+
const HIDDEN_SALARY = { salary: { readable: false, editable: false } };
14+
15+
describe('collectConditionFields', () => {
16+
it('collects implicit equality, operators, and logical nesting', () => {
17+
const fields = collectConditionFields({
18+
status: 'open',
19+
salary: { $gte: 100000 },
20+
$or: [{ priority: 'high' }, { $not: { archived: true } }],
21+
$and: [{ due_date: { $lte: '2026-12-31' } }],
22+
});
23+
expect([...fields].sort()).toEqual(['archived', 'due_date', 'priority', 'salary', 'status']);
24+
});
25+
26+
it('gates dotted paths on the first segment', () => {
27+
expect([...collectConditionFields({ 'owner.name': 'x' })]).toEqual(['owner']);
28+
});
29+
});
30+
31+
describe('collectQueryFields', () => {
32+
it('covers where / orderBy / groupBy / having / aggregations / window functions', () => {
33+
const fields = collectQueryFields({
34+
where: { status: 'open' },
35+
orderBy: [{ field: 'salary', order: 'desc' }],
36+
groupBy: ['department', { field: 'hired_at', dateGranularity: 'month' }],
37+
having: { headcount: { $gt: 3 } },
38+
aggregations: [{ function: 'sum', field: 'bonus', alias: 'total', filter: { region: 'emea' } }],
39+
windowFunctions: [
40+
{ function: 'row_number', alias: 'r', over: { partitionBy: ['team'], orderBy: [{ field: 'score', order: 'desc' }] } },
41+
],
42+
});
43+
expect([...fields].sort()).toEqual([
44+
'bonus', 'department', 'headcount', 'hired_at', 'region', 'salary', 'score', 'status', 'team',
45+
]);
46+
});
47+
48+
it('does NOT collect the projection — masked selects are harmless', () => {
49+
const fields = collectQueryFields({ fields: ['salary', 'name'], where: { status: 'open' } });
50+
expect(fields.has('salary')).toBe(false);
51+
});
52+
});
53+
54+
describe('assertReadableQueryFields', () => {
55+
it('rejects a where predicate on a hidden field (the oracle)', () => {
56+
expect(() =>
57+
assertReadableQueryFields({ where: { salary: { $gte: 100000 } } }, HIDDEN_SALARY, 'employee'),
58+
).toThrowError(/salary/);
59+
});
60+
61+
it('rejects sorting by a hidden field and reports it as a 403 sentinel', () => {
62+
try {
63+
assertReadableQueryFields({ orderBy: [{ field: 'salary', order: 'desc' }] }, HIDDEN_SALARY, 'employee');
64+
expect.unreachable('should have thrown');
65+
} catch (e) {
66+
expect(isPermissionDeniedError(e)).toBe(true);
67+
expect((e as { details?: { fields?: string[] } }).details?.fields).toEqual(['salary']);
68+
}
69+
});
70+
71+
it('rejects hidden fields buried in $or branches', () => {
72+
expect(() =>
73+
assertReadableQueryFields(
74+
{ where: { $or: [{ status: 'open' }, { salary: { $gt: 1 } }] } },
75+
HIDDEN_SALARY,
76+
'employee',
77+
),
78+
).toThrow();
79+
});
80+
81+
it('passes queries touching only readable fields', () => {
82+
expect(() =>
83+
assertReadableQueryFields(
84+
{ where: { status: 'open' }, orderBy: [{ field: 'due_date', order: 'asc' }] },
85+
HIDDEN_SALARY,
86+
'employee',
87+
),
88+
).not.toThrow();
89+
});
90+
91+
it('passes when field permissions grant read (readable !== false)', () => {
92+
expect(() =>
93+
assertReadableQueryFields(
94+
{ where: { salary: { $gte: 1 } } },
95+
{ salary: { readable: true } },
96+
'employee',
97+
),
98+
).not.toThrow();
99+
});
100+
101+
it('no-ops when no field permissions are configured', () => {
102+
expect(() => assertReadableQueryFields({ where: { salary: 1 } }, {}, 'employee')).not.toThrow();
103+
});
104+
});
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Field-level predicate guard — anti filter-oracle (objectui#2251).
5+
*
6+
* FieldMasker strips unreadable fields from query RESULTS, but a caller can
7+
* still probe a hidden field's VALUE through predicates: filtering
8+
* `salary >= 100000` (or sorting / grouping by `salary`) changes which rows
9+
* come back even though the column itself is masked — presence/absence is
10+
* the oracle. The objectui `/data` surface (ADR-0055) makes arbitrary
11+
* URL-driven filters a first-class citizen, so this hole must be closed at
12+
* the engine, independent of anything the client sends.
13+
*
14+
* Policy: REJECT (403), never silently rewrite. Dropping predicates changes
15+
* query semantics unpredictably (removing an `$or` branch narrows results;
16+
* removing an `$and` branch widens them — the widening direction re-opens
17+
* the oracle). Salesforce FLS behaves the same way: querying a hidden field
18+
* is an error, not a silent no-op. The error message carries the offending
19+
* field names so authors can fix the query.
20+
*
21+
* Ordering contract: this guard MUST run against the CALLER-supplied AST,
22+
* before RLS filter injection — RLS policies legitimately reference fields
23+
* the caller cannot read (e.g. `owner_id`), and must not be rejected.
24+
*/
25+
26+
import { PermissionDeniedError } from './errors.js';
27+
28+
interface FieldPermissionLike {
29+
readable?: boolean;
30+
}
31+
32+
/** Logical keys of the FilterCondition grammar — never field names. */
33+
const LOGICAL_KEYS = new Set(['$and', '$or', '$not']);
34+
35+
/**
36+
* Collect every field name referenced by a FilterCondition. Dotted paths
37+
* and nested-relation conditions gate on their FIRST segment / top-level
38+
* relation field — local field permissions govern local traversal.
39+
*/
40+
export function collectConditionFields(condition: unknown, out: Set<string> = new Set()): Set<string> {
41+
if (!condition || typeof condition !== 'object' || Array.isArray(condition)) return out;
42+
for (const [key, value] of Object.entries(condition as Record<string, unknown>)) {
43+
if (LOGICAL_KEYS.has(key)) {
44+
if (Array.isArray(value)) for (const sub of value) collectConditionFields(sub, out);
45+
else collectConditionFields(value, out);
46+
continue;
47+
}
48+
out.add(key.split('.')[0]);
49+
}
50+
return out;
51+
}
52+
53+
/**
54+
* Collect every field referenced by the query's row-shaping clauses:
55+
* where / orderBy / groupBy / having / aggregations (field + FILTER) /
56+
* window functions (field + partitionBy + over.orderBy). `fields`
57+
* (projection) is intentionally NOT collected — selecting a hidden field is
58+
* harmless because FieldMasker strips it from the result; only predicates
59+
* leak.
60+
*/
61+
export function collectQueryFields(ast: Record<string, unknown>): Set<string> {
62+
const out = new Set<string>();
63+
collectConditionFields(ast.where, out);
64+
collectConditionFields(ast.having, out);
65+
66+
const orderBy = ast.orderBy;
67+
if (Array.isArray(orderBy)) {
68+
for (const s of orderBy) {
69+
const field = (s as { field?: unknown })?.field;
70+
if (typeof field === 'string') out.add(field.split('.')[0]);
71+
}
72+
}
73+
74+
const groupBy = ast.groupBy;
75+
if (Array.isArray(groupBy)) {
76+
for (const g of groupBy) {
77+
if (typeof g === 'string') out.add(g.split('.')[0]);
78+
else if (g && typeof g === 'object' && typeof (g as { field?: unknown }).field === 'string') {
79+
out.add(((g as { field: string }).field).split('.')[0]);
80+
}
81+
}
82+
}
83+
84+
const aggregations = ast.aggregations;
85+
if (Array.isArray(aggregations)) {
86+
for (const a of aggregations) {
87+
const field = (a as { field?: unknown })?.field;
88+
if (typeof field === 'string' && field !== '*') out.add(field.split('.')[0]);
89+
collectConditionFields((a as { filter?: unknown })?.filter, out);
90+
}
91+
}
92+
93+
const windowFunctions = ast.windowFunctions;
94+
if (Array.isArray(windowFunctions)) {
95+
for (const w of windowFunctions) {
96+
const field = (w as { field?: unknown })?.field;
97+
if (typeof field === 'string') out.add(field.split('.')[0]);
98+
const over = (w as { over?: { partitionBy?: unknown; orderBy?: unknown } })?.over;
99+
if (Array.isArray(over?.partitionBy)) {
100+
for (const p of over.partitionBy) if (typeof p === 'string') out.add(p.split('.')[0]);
101+
}
102+
if (Array.isArray(over?.orderBy)) {
103+
for (const s of over.orderBy) {
104+
const f = (s as { field?: unknown })?.field;
105+
if (typeof f === 'string') out.add(f.split('.')[0]);
106+
}
107+
}
108+
}
109+
}
110+
111+
return out;
112+
}
113+
114+
/**
115+
* Throw PermissionDeniedError (→ HTTP 403) when the caller-supplied query
116+
* references a field its field-level permissions mark non-readable.
117+
*/
118+
export function assertReadableQueryFields(
119+
ast: Record<string, unknown>,
120+
fieldPermissions: Record<string, FieldPermissionLike>,
121+
object: string,
122+
): void {
123+
const hidden = new Set(
124+
Object.entries(fieldPermissions)
125+
.filter(([, perm]) => perm && perm.readable === false)
126+
.map(([field]) => field),
127+
);
128+
if (hidden.size === 0) return;
129+
130+
const offending = [...collectQueryFields(ast)].filter((f) => hidden.has(f));
131+
if (offending.length === 0) return;
132+
133+
throw new PermissionDeniedError(
134+
`[Security] Access denied: query on '${object}' references field(s) not readable by the caller: `
135+
+ `${offending.join(', ')}. Filtering, sorting, grouping, or aggregating by a hidden field `
136+
+ `would leak its values (filter oracle) — remove these predicates or grant field read access.`,
137+
{ object, fields: offending, reason: 'field_predicate_denied' },
138+
);
139+
}

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.js'
1010
import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js';
1111
import { matchesFilterCondition } from '@objectstack/formula';
1212
import { FieldMasker } from './field-masker.js';
13+
import { assertReadableQueryFields } from './predicate-guard.js';
1314
import { PermissionDeniedError } from './errors.js';
1415
import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js';
1516
import {
@@ -682,6 +683,23 @@ export class SecurityPlugin implements Plugin {
682683
}
683684
}
684685

686+
// 2.9. Field-level predicate guard (anti filter-oracle, objectui#2251).
687+
// FieldMasker (step 4) only strips hidden fields from RESULTS — a
688+
// caller could still probe a hidden field's value by filtering /
689+
// sorting / grouping on it (row presence is the oracle; the objectui
690+
// /data surface makes URL-driven predicates first-class). Reject such
691+
// queries outright — silent predicate dropping would change query
692+
// semantics unpredictably. MUST run against the CALLER's AST, before
693+
// the RLS injection below: RLS policies legitimately reference fields
694+
// the caller cannot read (e.g. owner_id).
695+
if (opCtx.ast) {
696+
let guardPerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, permissionSets);
697+
guardPerms = this.foldFieldRequiredPermissions(guardPerms, secMeta.fieldRequiredPermissions, permissionSets);
698+
if (Object.keys(guardPerms).length > 0) {
699+
assertReadableQueryFields(opCtx.ast as unknown as Record<string, unknown>, guardPerms, opCtx.object);
700+
}
701+
}
702+
685703
// 3. RLS filter injection. The policy collection + field-existence
686704
// safety + compile (incl. the fail-closed deny sentinel) is shared with
687705
// the public getReadFilter service via computeRlsFilter, so the engine

packages/spec/src/ui/app.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,53 @@ describe('NavigationItemSchema (Recursive)', () => {
280280

281281
expect(() => NavigationItemSchema.parse(navItem)).not.toThrow();
282282
});
283+
284+
it('accepts a filters-only object item (bare data surface slice)', () => {
285+
const item = {
286+
id: 'nav_my_open',
287+
label: 'My Open',
288+
type: 'object' as const,
289+
objectName: 'ticket',
290+
filters: { owner_id: '{current_user_id}', status: 'open' },
291+
};
292+
expect(() => NavigationItemSchema.parse(item)).not.toThrow();
293+
});
294+
295+
it('rejects filters combined with viewName (ambiguous landing)', () => {
296+
const item = {
297+
id: 'nav_bad',
298+
label: 'Bad',
299+
type: 'object' as const,
300+
objectName: 'ticket',
301+
viewName: 'by_status',
302+
filters: { status: 'open' },
303+
};
304+
expect(() => NavigationItemSchema.parse(item)).toThrow();
305+
});
306+
307+
it('rejects filters combined with recordId (ambiguous landing)', () => {
308+
const item = {
309+
id: 'nav_bad2',
310+
label: 'Bad',
311+
type: 'object' as const,
312+
objectName: 'ticket',
313+
recordId: '{current_user_id}',
314+
filters: { status: 'open' },
315+
};
316+
expect(() => NavigationItemSchema.parse(item)).toThrow();
317+
});
318+
319+
it('tolerates the legacy recordId + viewName combination (documented: viewName ignored)', () => {
320+
const item = {
321+
id: 'nav_legacy',
322+
label: 'Legacy',
323+
type: 'object' as const,
324+
objectName: 'sys_user',
325+
recordId: '{current_user_id}',
326+
viewName: 'all',
327+
};
328+
expect(() => NavigationItemSchema.parse(item)).not.toThrow();
329+
});
283330
});
284331

285332
describe('AppSchema', () => {

0 commit comments

Comments
 (0)