Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/adr-0056-d4-rls-no-silent-drop.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions .changeset/adr-0056-d8-experimental-markers.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 38 additions & 0 deletions packages/plugins/plugin-security/src/rls-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,41 @@ export const RLS_DENY_FILTER: Record<string, unknown> = 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
*
* Compiles Row-Level Security policy expressions into query filters.
* 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<string, unknown>) => void };
setLogger(logger: { warn?: (message: string, meta?: Record<string, unknown>) => 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).
Expand Down Expand Up @@ -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}`,
);
}
}

Expand Down
43 changes: 42 additions & 1 deletion packages/plugins/plugin-security/src/security-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/spec/src/security/rls.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -518,6 +520,8 @@ export type RLSAuditConfig = z.infer<typeof RLSAuditConfigSchema>;
* 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.
Expand Down
6 changes: 6 additions & 0 deletions packages/spec/src/system/compliance.zod.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down
4 changes: 4 additions & 0 deletions packages/spec/src/system/encryption.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions packages/spec/src/system/masking.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
4 changes: 4 additions & 0 deletions packages/spec/src/system/security-context.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
Loading