From 4885112437e63fb773365b71fdcfcb36b5d10d16 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 07:32:25 +0000 Subject: [PATCH 1/2] =?UTF-8?q?docs(spec):=20ADR-0056=20D8=20=E2=80=94=20m?= =?UTF-8?q?ark=20unenforced=20compliance/encryption/masking/RLS-config=20E?= =?UTF-8?q?XPERIMENTAL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx --- .changeset/adr-0056-d8-experimental-markers.md | 14 ++++++++++++++ packages/spec/src/security/rls.zod.ts | 4 ++++ packages/spec/src/system/compliance.zod.ts | 6 ++++++ packages/spec/src/system/encryption.zod.ts | 4 ++++ packages/spec/src/system/masking.zod.ts | 5 +++++ packages/spec/src/system/security-context.zod.ts | 4 ++++ 6 files changed, 37 insertions(+) create mode 100644 .changeset/adr-0056-d8-experimental-markers.md diff --git a/.changeset/adr-0056-d8-experimental-markers.md b/.changeset/adr-0056-d8-experimental-markers.md new file mode 100644 index 0000000000..6107cbf0b7 --- /dev/null +++ b/.changeset/adr-0056-d8-experimental-markers.md @@ -0,0 +1,14 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): mark unenforced compliance/encryption/masking/RLS-config surface EXPERIMENTAL (ADR-0056 D8) + +Per ADR-0049's enforce-or-remove gate (and ADR-0056 D8), the security-adjacent +schemas that are parsed but have **no runtime consumer** now carry an explicit +`⚠️ EXPERIMENTAL — NOT ENFORCED` header so the no-op is visible to authors and the +reference docs: GDPR/HIPAA/PCI compliance configs, field-level encryption, data +masking, the unified security-context governance, and the global `RLSConfig` / +`RLSAuditEvent` (distinct from the ENFORCED `RowLevelSecurityPolicySchema`, which is +left untouched). No behaviour change — these were already inert; the marker makes +the inertness honest rather than silent. diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index c7906f45c4..df7cf6b90a 100644 --- a/packages/spec/src/security/rls.zod.ts +++ b/packages/spec/src/security/rls.zod.ts @@ -422,6 +422,8 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => z.object({ * * Records a single RLS policy evaluation event for compliance and debugging. */ +// ⚠️ EXPERIMENTAL — NOT ENFORCED (ADR-0056). RLS audit events are not emitted by the +// runtime RLS path — no consumer writes these. Authoring does NOT change behaviour (per ADR-0049). export const RLSAuditEventSchema = lazySchema(() => z.object({ /** ISO 8601 timestamp of the evaluation */ timestamp: z.string() @@ -518,6 +520,8 @@ export type RLSAuditConfig = z.infer; * Global configuration for the Row-Level Security system. * Defines how RLS is enforced across the entire platform. */ +// ⚠️ EXPERIMENTAL — NOT ENFORCED (ADR-0056). Global RLSConfig (defaultPolicy / bypassRoles / +// caching / audit) is not read by the SecurityPlugin RLS path. Authoring does NOT change behaviour (per ADR-0049). export const RLSConfigSchema = lazySchema(() => z.object({ /** * Global RLS enable/disable flag. diff --git a/packages/spec/src/system/compliance.zod.ts b/packages/spec/src/system/compliance.zod.ts index 2ded674860..e186a1d371 100644 --- a/packages/spec/src/system/compliance.zod.ts +++ b/packages/spec/src/system/compliance.zod.ts @@ -1,6 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; + +// ⚠️ EXPERIMENTAL — NOT ENFORCED (ADR-0056). The GDPR/HIPAA/PCI-DSS schemas below +// are parsed but have NO runtime consumer — no data-subject-rights engine, +// retention enforcer, BAA gate, or tokenizer reads them. Authoring them does NOT +// change behaviour; recorded as roadmap intent (M2+). Do not rely on them for +// compliance until a consumer lands (per ADR-0049 enforce-or-remove). import { ComplianceFrameworkSchema } from './security-context.zod'; /** diff --git a/packages/spec/src/system/encryption.zod.ts b/packages/spec/src/system/encryption.zod.ts index 1fcc05c4ba..5a2ac9fae0 100644 --- a/packages/spec/src/system/encryption.zod.ts +++ b/packages/spec/src/system/encryption.zod.ts @@ -2,6 +2,10 @@ import { z } from 'zod'; +// ⚠️ EXPERIMENTAL — NOT ENFORCED (ADR-0056). Field-level encryption is declared but +// no runtime crypto provider reads this config to encrypt/decrypt at rest. +// Authoring it does NOT change behaviour (roadmap M2+; per ADR-0049). + /** * Field-level encryption protocol * GDPR/HIPAA/PCI-DSS compliant diff --git a/packages/spec/src/system/masking.zod.ts b/packages/spec/src/system/masking.zod.ts index 7361ef556d..ef7cc124aa 100644 --- a/packages/spec/src/system/masking.zod.ts +++ b/packages/spec/src/system/masking.zod.ts @@ -2,6 +2,11 @@ import { z } from 'zod'; +// ⚠️ EXPERIMENTAL — NOT ENFORCED (ADR-0056). Data-masking rules are declared but no +// redaction layer applies them — Field-Level Security (PermissionSet.fields) is the +// enforced field-visibility mechanism today. Authoring masking does NOT change +// behaviour (roadmap M2+; per ADR-0049). + /** * Data masking protocol for PII protection */ diff --git a/packages/spec/src/system/security-context.zod.ts b/packages/spec/src/system/security-context.zod.ts index f6a2ca2b23..2b40e6f1e2 100644 --- a/packages/spec/src/system/security-context.zod.ts +++ b/packages/spec/src/system/security-context.zod.ts @@ -2,6 +2,10 @@ import { z } from 'zod'; +// ⚠️ EXPERIMENTAL — NOT ENFORCED (ADR-0056). The unified security-context governance +// (audit/encryption/masking/compliance correlation) is declared but has no runtime +// consumer. Authoring it does NOT change behaviour (roadmap M2+; per ADR-0049). + /** * Unified Security Context Protocol * From 7c4cc8a3376d5ec60a311e00a7752b3489d28bad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 07:40:33 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(security):=20ADR-0056=20D4=20=E2=80=94?= =?UTF-8?q?=20RLS=20compiler=20surfaces=20uncompilable=20predicates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx --- .changeset/adr-0056-d4-rls-no-silent-drop.md | 14 ++++++ .../plugin-security/src/rls-compiler.ts | 38 ++++++++++++++++ .../src/security-plugin.test.ts | 43 ++++++++++++++++++- .../plugin-security/src/security-plugin.ts | 1 + 4 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 .changeset/adr-0056-d4-rls-no-silent-drop.md diff --git a/.changeset/adr-0056-d4-rls-no-silent-drop.md b/.changeset/adr-0056-d4-rls-no-silent-drop.md new file mode 100644 index 0000000000..0c974a5565 --- /dev/null +++ b/.changeset/adr-0056-d4-rls-no-silent-drop.md @@ -0,0 +1,14 @@ +--- +"@objectstack/plugin-security": minor +--- + +feat(security): RLS predicates that won't compile are surfaced, not silently dropped (ADR-0056 D4) + +The RLS compiler previously dropped any `using`/`check` it could not parse (e.g. `==`, +`AND`/`OR`, ranges) in silence — if it was the only policy, the object lost protection +with no signal (the class of bug that left a showcase owner predicate inert for two PRs). +Now the compiler WARNS (via the security plugin's logger) when an **unsupported-shape** +predicate is dropped, distinguishing it from the intentional "context variable absent" +fail-closed skip. Also exports `isSupportedRlsExpression(expr)` so an authoring-time gate +(`objectstack compile`) can reject a predicate the runtime would never enforce. No change +to compiled filters for valid predicates; fail-closed semantics preserved. diff --git a/packages/plugins/plugin-security/src/rls-compiler.ts b/packages/plugins/plugin-security/src/rls-compiler.ts index 45446a40f3..a14faffa34 100644 --- a/packages/plugins/plugin-security/src/rls-compiler.ts +++ b/packages/plugins/plugin-security/src/rls-compiler.ts @@ -58,6 +58,28 @@ export const RLS_DENY_FILTER: Record = Object.freeze({ id: '__rls_deny__:00000000-0000-0000-0000-000000000000', }); +/** + * Recognize whether an RLS `using` / `check` expression matches one of the SHAPES + * the compiler can compile (equality against a `current_user.*` var, equality + * against a string literal, set-membership against a `current_user.*` array, or + * the `1 = 1` allow-all). This is SHAPE-only — it does not check whether the + * referenced context variable is populated at runtime. + * + * ADR-0056 D4: exposed so an authoring-time gate (`objectstack compile`) can REJECT + * a predicate the runtime would silently drop — the class of bug where + * `owner == current_user.name` (`==`, unsupported) compiled to nothing and left an + * object unprotected. A `false` here means "this predicate will never enforce". + */ +export function isSupportedRlsExpression(expression: string): boolean { + if (!expression) return false; + const e = expression.trim(); + if (/^\s*1\s*=\s*1\s*$/.test(e)) return true; + if (/^\s*\w+\s*=\s*current_user\.\w+\s*$/.test(e)) return true; + if (/^\s*\w+\s*=\s*'[^']*'\s*$/.test(e)) return true; + if (/^\s*\w+\s+IN\s+\(\s*current_user\.\w+\s*\)\s*$/i.test(e)) return true; + return false; +} + /** * RLSCompiler * @@ -65,6 +87,12 @@ export const RLS_DENY_FILTER: Record = Object.freeze({ * Converts `using` / `check` expressions into ObjectQL-compatible filter conditions. */ export class RLSCompiler { + /** Optional logger so a SILENTLY-dropped (uncompilable-shape) policy is observable (ADR-0056 D4). */ + private logger?: { warn?: (message: string, meta?: Record) => void }; + setLogger(logger: { warn?: (message: string, meta?: Record) => void } | undefined): void { + this.logger = logger; + } + /** * Compile RLS policies into a query filter for the given user context. * Multiple policies for the same object/operation are OR-combined (any match allows access). @@ -115,6 +143,16 @@ export class RLSCompiler { const filter = this.compileExpression(policy.using, userCtx); if (filter) { filters.push(filter); + } else if (!isSupportedRlsExpression(policy.using)) { + // ADR-0056 D4: an UNSUPPORTED-SHAPE predicate (e.g. `==`, AND/OR, ranges) + // compiles to nothing and would silently vanish, leaving the object + // unprotected. Surface it instead of dropping in silence. (A SUPPORTED + // shape that returned null is the intentional "context var absent" path — + // it fails closed downstream and is not warned here.) + this.logger?.warn?.( + `[RLS] policy '${(policy as { name?: string }).name ?? '(unnamed)'}' on '${(policy as { object?: string }).object ?? '?'}' ` + + `has an uncompilable predicate and was DROPPED (no enforcement): ${policy.using}`, + ); } } diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index a2b3ef72d9..0c01aae2b6 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, vi } from 'vitest'; import { SecurityPlugin } from './security-plugin.js'; import { PermissionEvaluator } from './permission-evaluator.js'; import { FieldMasker } from './field-masker.js'; -import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; +import { RLSCompiler, RLS_DENY_FILTER, isSupportedRlsExpression } from './rls-compiler.js'; import type { PermissionSet } from '@objectstack/spec/security'; import { RLS } from '@objectstack/spec/security'; @@ -1193,3 +1193,44 @@ describe('RLSCompiler', () => { expect(filter).not.toEqual(RLS_DENY_FILTER); }); }); + +// --------------------------------------------------------------------------- +// ADR-0056 D4 — RLS predicates that won't compile must not vanish in silence +// --------------------------------------------------------------------------- +describe('RLSCompiler D4 — uncompilable predicates are surfaced', () => { + it('isSupportedRlsExpression accepts the compilable shapes', () => { + expect(isSupportedRlsExpression('owner_id = current_user.id')).toBe(true); + expect(isSupportedRlsExpression('owner = current_user.email')).toBe(true); + expect(isSupportedRlsExpression("status = 'published'")).toBe(true); + expect(isSupportedRlsExpression('id IN (current_user.org_user_ids)')).toBe(true); + expect(isSupportedRlsExpression('1 = 1')).toBe(true); + }); + + it('isSupportedRlsExpression rejects shapes the runtime would drop', () => { + expect(isSupportedRlsExpression('owner == current_user.name')).toBe(false); // `==` + expect(isSupportedRlsExpression('a = current_user.id AND b = 1')).toBe(false); // AND + expect(isSupportedRlsExpression('amount > 100')).toBe(false); // range + expect(isSupportedRlsExpression('')).toBe(false); + }); + + it('WARNS (does not silently drop) an unsupported-shape policy', () => { + const warned: string[] = []; + const compiler = new RLSCompiler(); + compiler.setLogger({ warn: (message: string) => warned.push(message) }); + const policy: any = { name: 'bad', object: 'thing', operation: 'select', using: 'owner == current_user.name' }; + const filter = compiler.compileFilter([policy], { userId: 'u1' } as any); + expect(filter).toEqual(RLS_DENY_FILTER); // only policy dropped → fail-closed + expect(warned.length).toBe(1); + expect(warned[0]).toContain('uncompilable predicate'); + }); + + it('does NOT warn a SUPPORTED shape whose context var is merely absent', () => { + const warned: string[] = []; + const compiler = new RLSCompiler(); + compiler.setLogger({ warn: (message: string) => warned.push(message) }); + // valid shape; `department` simply isn't in the context → intentional fail-closed skip. + const policy: any = { name: 'dept', object: 'thing', operation: 'select', using: 'dept = current_user.department' }; + compiler.compileFilter([policy], { userId: 'u1' } as any); + expect(warned.length).toBe(0); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 63410c19ec..5cec78dd1d 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -205,6 +205,7 @@ export class SecurityPlugin implements Plugin { this.metadata = metadata; this.ql = ql; this.logger = ctx.logger; + this.rlsCompiler.setLogger?.(ctx.logger); // Probe for OrgScopingPlugin presence. When registered, its // `init()` exposes itself as the `org-scoping` service. We capture