Skip to content

Commit fa8964d

Browse files
os-zhuangclaude
andauthored
feat(security): ADR-0056 P1 honesty — D8 experimental markers + D4 RLS no-silent-drop (#2064)
* docs(spec): ADR-0056 D8 — mark unenforced compliance/encryption/masking/RLS-config EXPERIMENTAL Honesty pass per ADR-0049/ADR-0056 D8: the security-adjacent schemas with no runtime consumer get an explicit `⚠️ EXPERIMENTAL — NOT ENFORCED` header (GDPR/HIPAA/PCI, encryption, masking, security-context governance, global RLSConfig + RLSAuditEvent). The ENFORCED RowLevelSecurityPolicySchema is deliberately left unmarked. Comment-only; spec builds; liveness gate green (these aren't governed types — the markers are documentation honesty, not a gate change). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx * feat(security): ADR-0056 D4 — RLS compiler surfaces uncompilable predicates Stop silently dropping a `using`/`check` whose SHAPE the compiler cannot compile (`==`, AND/OR, ranges) — warn via the security plugin logger so an unenforced predicate is observable, distinguished from the intentional context-var-absent skip. Export `isSupportedRlsExpression(expr)` for a future authoring-time compile gate. Additive: valid predicates compile identically; fail-closed preserved. 4 new unit tests; plugin-security 83; RLS/OWD dogfood proofs 16/16. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 37e9acb commit fa8964d

10 files changed

Lines changed: 132 additions & 1 deletion

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
feat(security): RLS predicates that won't compile are surfaced, not silently dropped (ADR-0056 D4)
6+
7+
The RLS compiler previously dropped any `using`/`check` it could not parse (e.g. `==`,
8+
`AND`/`OR`, ranges) in silence — if it was the only policy, the object lost protection
9+
with no signal (the class of bug that left a showcase owner predicate inert for two PRs).
10+
Now the compiler WARNS (via the security plugin's logger) when an **unsupported-shape**
11+
predicate is dropped, distinguishing it from the intentional "context variable absent"
12+
fail-closed skip. Also exports `isSupportedRlsExpression(expr)` so an authoring-time gate
13+
(`objectstack compile`) can reject a predicate the runtime would never enforce. No change
14+
to compiled filters for valid predicates; fail-closed semantics preserved.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): mark unenforced compliance/encryption/masking/RLS-config surface EXPERIMENTAL (ADR-0056 D8)
6+
7+
Per ADR-0049's enforce-or-remove gate (and ADR-0056 D8), the security-adjacent
8+
schemas that are parsed but have **no runtime consumer** now carry an explicit
9+
`⚠️ EXPERIMENTAL — NOT ENFORCED` header so the no-op is visible to authors and the
10+
reference docs: GDPR/HIPAA/PCI compliance configs, field-level encryption, data
11+
masking, the unified security-context governance, and the global `RLSConfig` /
12+
`RLSAuditEvent` (distinct from the ENFORCED `RowLevelSecurityPolicySchema`, which is
13+
left untouched). No behaviour change — these were already inert; the marker makes
14+
the inertness honest rather than silent.

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,41 @@ export const RLS_DENY_FILTER: Record<string, unknown> = Object.freeze({
5858
id: '__rls_deny__:00000000-0000-0000-0000-000000000000',
5959
});
6060

61+
/**
62+
* Recognize whether an RLS `using` / `check` expression matches one of the SHAPES
63+
* the compiler can compile (equality against a `current_user.*` var, equality
64+
* against a string literal, set-membership against a `current_user.*` array, or
65+
* the `1 = 1` allow-all). This is SHAPE-only — it does not check whether the
66+
* referenced context variable is populated at runtime.
67+
*
68+
* ADR-0056 D4: exposed so an authoring-time gate (`objectstack compile`) can REJECT
69+
* a predicate the runtime would silently drop — the class of bug where
70+
* `owner == current_user.name` (`==`, unsupported) compiled to nothing and left an
71+
* object unprotected. A `false` here means "this predicate will never enforce".
72+
*/
73+
export function isSupportedRlsExpression(expression: string): boolean {
74+
if (!expression) return false;
75+
const e = expression.trim();
76+
if (/^\s*1\s*=\s*1\s*$/.test(e)) return true;
77+
if (/^\s*\w+\s*=\s*current_user\.\w+\s*$/.test(e)) return true;
78+
if (/^\s*\w+\s*=\s*'[^']*'\s*$/.test(e)) return true;
79+
if (/^\s*\w+\s+IN\s+\(\s*current_user\.\w+\s*\)\s*$/i.test(e)) return true;
80+
return false;
81+
}
82+
6183
/**
6284
* RLSCompiler
6385
*
6486
* Compiles Row-Level Security policy expressions into query filters.
6587
* Converts `using` / `check` expressions into ObjectQL-compatible filter conditions.
6688
*/
6789
export class RLSCompiler {
90+
/** Optional logger so a SILENTLY-dropped (uncompilable-shape) policy is observable (ADR-0056 D4). */
91+
private logger?: { warn?: (message: string, meta?: Record<string, unknown>) => void };
92+
setLogger(logger: { warn?: (message: string, meta?: Record<string, unknown>) => void } | undefined): void {
93+
this.logger = logger;
94+
}
95+
6896
/**
6997
* Compile RLS policies into a query filter for the given user context.
7098
* Multiple policies for the same object/operation are OR-combined (any match allows access).
@@ -115,6 +143,16 @@ export class RLSCompiler {
115143
const filter = this.compileExpression(policy.using, userCtx);
116144
if (filter) {
117145
filters.push(filter);
146+
} else if (!isSupportedRlsExpression(policy.using)) {
147+
// ADR-0056 D4: an UNSUPPORTED-SHAPE predicate (e.g. `==`, AND/OR, ranges)
148+
// compiles to nothing and would silently vanish, leaving the object
149+
// unprotected. Surface it instead of dropping in silence. (A SUPPORTED
150+
// shape that returned null is the intentional "context var absent" path —
151+
// it fails closed downstream and is not warned here.)
152+
this.logger?.warn?.(
153+
`[RLS] policy '${(policy as { name?: string }).name ?? '(unnamed)'}' on '${(policy as { object?: string }).object ?? '?'}' ` +
154+
`has an uncompilable predicate and was DROPPED (no enforcement): ${policy.using}`,
155+
);
118156
}
119157
}
120158

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { describe, it, expect, vi } from 'vitest';
44
import { SecurityPlugin } from './security-plugin.js';
55
import { PermissionEvaluator } from './permission-evaluator.js';
66
import { FieldMasker } from './field-masker.js';
7-
import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js';
7+
import { RLSCompiler, RLS_DENY_FILTER, isSupportedRlsExpression } from './rls-compiler.js';
88
import type { PermissionSet } from '@objectstack/spec/security';
99
import { RLS } from '@objectstack/spec/security';
1010

@@ -1193,3 +1193,44 @@ describe('RLSCompiler', () => {
11931193
expect(filter).not.toEqual(RLS_DENY_FILTER);
11941194
});
11951195
});
1196+
1197+
// ---------------------------------------------------------------------------
1198+
// ADR-0056 D4 — RLS predicates that won't compile must not vanish in silence
1199+
// ---------------------------------------------------------------------------
1200+
describe('RLSCompiler D4 — uncompilable predicates are surfaced', () => {
1201+
it('isSupportedRlsExpression accepts the compilable shapes', () => {
1202+
expect(isSupportedRlsExpression('owner_id = current_user.id')).toBe(true);
1203+
expect(isSupportedRlsExpression('owner = current_user.email')).toBe(true);
1204+
expect(isSupportedRlsExpression("status = 'published'")).toBe(true);
1205+
expect(isSupportedRlsExpression('id IN (current_user.org_user_ids)')).toBe(true);
1206+
expect(isSupportedRlsExpression('1 = 1')).toBe(true);
1207+
});
1208+
1209+
it('isSupportedRlsExpression rejects shapes the runtime would drop', () => {
1210+
expect(isSupportedRlsExpression('owner == current_user.name')).toBe(false); // `==`
1211+
expect(isSupportedRlsExpression('a = current_user.id AND b = 1')).toBe(false); // AND
1212+
expect(isSupportedRlsExpression('amount > 100')).toBe(false); // range
1213+
expect(isSupportedRlsExpression('')).toBe(false);
1214+
});
1215+
1216+
it('WARNS (does not silently drop) an unsupported-shape policy', () => {
1217+
const warned: string[] = [];
1218+
const compiler = new RLSCompiler();
1219+
compiler.setLogger({ warn: (message: string) => warned.push(message) });
1220+
const policy: any = { name: 'bad', object: 'thing', operation: 'select', using: 'owner == current_user.name' };
1221+
const filter = compiler.compileFilter([policy], { userId: 'u1' } as any);
1222+
expect(filter).toEqual(RLS_DENY_FILTER); // only policy dropped → fail-closed
1223+
expect(warned.length).toBe(1);
1224+
expect(warned[0]).toContain('uncompilable predicate');
1225+
});
1226+
1227+
it('does NOT warn a SUPPORTED shape whose context var is merely absent', () => {
1228+
const warned: string[] = [];
1229+
const compiler = new RLSCompiler();
1230+
compiler.setLogger({ warn: (message: string) => warned.push(message) });
1231+
// valid shape; `department` simply isn't in the context → intentional fail-closed skip.
1232+
const policy: any = { name: 'dept', object: 'thing', operation: 'select', using: 'dept = current_user.department' };
1233+
compiler.compileFilter([policy], { userId: 'u1' } as any);
1234+
expect(warned.length).toBe(0);
1235+
});
1236+
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ export class SecurityPlugin implements Plugin {
205205
this.metadata = metadata;
206206
this.ql = ql;
207207
this.logger = ctx.logger;
208+
this.rlsCompiler.setLogger?.(ctx.logger);
208209

209210
// Probe for OrgScopingPlugin presence. When registered, its
210211
// `init()` exposes itself as the `org-scoping` service. We capture

packages/spec/src/security/rls.zod.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,8 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => z.object({
422422
*
423423
* Records a single RLS policy evaluation event for compliance and debugging.
424424
*/
425+
// ⚠️ EXPERIMENTAL — NOT ENFORCED (ADR-0056). RLS audit events are not emitted by the
426+
// runtime RLS path — no consumer writes these. Authoring does NOT change behaviour (per ADR-0049).
425427
export const RLSAuditEventSchema = lazySchema(() => z.object({
426428
/** ISO 8601 timestamp of the evaluation */
427429
timestamp: z.string()
@@ -518,6 +520,8 @@ export type RLSAuditConfig = z.infer<typeof RLSAuditConfigSchema>;
518520
* Global configuration for the Row-Level Security system.
519521
* Defines how RLS is enforced across the entire platform.
520522
*/
523+
// ⚠️ EXPERIMENTAL — NOT ENFORCED (ADR-0056). Global RLSConfig (defaultPolicy / bypassRoles /
524+
// caching / audit) is not read by the SecurityPlugin RLS path. Authoring does NOT change behaviour (per ADR-0049).
521525
export const RLSConfigSchema = lazySchema(() => z.object({
522526
/**
523527
* Global RLS enable/disable flag.

packages/spec/src/system/compliance.zod.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { z } from 'zod';
4+
5+
// ⚠️ EXPERIMENTAL — NOT ENFORCED (ADR-0056). The GDPR/HIPAA/PCI-DSS schemas below
6+
// are parsed but have NO runtime consumer — no data-subject-rights engine,
7+
// retention enforcer, BAA gate, or tokenizer reads them. Authoring them does NOT
8+
// change behaviour; recorded as roadmap intent (M2+). Do not rely on them for
9+
// compliance until a consumer lands (per ADR-0049 enforce-or-remove).
410
import { ComplianceFrameworkSchema } from './security-context.zod';
511

612
/**

packages/spec/src/system/encryption.zod.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
import { z } from 'zod';
44

5+
// ⚠️ EXPERIMENTAL — NOT ENFORCED (ADR-0056). Field-level encryption is declared but
6+
// no runtime crypto provider reads this config to encrypt/decrypt at rest.
7+
// Authoring it does NOT change behaviour (roadmap M2+; per ADR-0049).
8+
59
/**
610
* Field-level encryption protocol
711
* GDPR/HIPAA/PCI-DSS compliant

packages/spec/src/system/masking.zod.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
import { z } from 'zod';
44

5+
// ⚠️ EXPERIMENTAL — NOT ENFORCED (ADR-0056). Data-masking rules are declared but no
6+
// redaction layer applies them — Field-Level Security (PermissionSet.fields) is the
7+
// enforced field-visibility mechanism today. Authoring masking does NOT change
8+
// behaviour (roadmap M2+; per ADR-0049).
9+
510
/**
611
* Data masking protocol for PII protection
712
*/

packages/spec/src/system/security-context.zod.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
import { z } from 'zod';
44

5+
// ⚠️ EXPERIMENTAL — NOT ENFORCED (ADR-0056). The unified security-context governance
6+
// (audit/encryption/masking/compliance correlation) is declared but has no runtime
7+
// consumer. Authoring it does NOT change behaviour (roadmap M2+; per ADR-0049).
8+
59
/**
610
* Unified Security Context Protocol
711
*

0 commit comments

Comments
 (0)