Skip to content

feat(security): expose getReadFilter; analytics enforces real RLS (ADR-0021 D-C) [security review]#1657

Merged
os-zhuang merged 1 commit into
mainfrom
feat/security-getreadfilter-analytics-rls
Jun 7, 2026
Merged

feat(security): expose getReadFilter; analytics enforces real RLS (ADR-0021 D-C) [security review]#1657
os-zhuang merged 1 commit into
mainfrom
feat/security-getreadfilter-analytics-rls

Conversation

@os-zhuang
Copy link
Copy Markdown
Contributor

⚠️ Security-sensitive — please review before merge. Touches the RLS resolution shared by the engine find middleware (R1 hot path) and exposes it to the analytics raw-SQL path. Not auto-merged.

Closes the last P0 gap of ADR-0021 D-C: the analytics raw-SQL path (which bypasses the ObjectQL engine middleware) can now enforce the same tenant/RLS row scoping the engine applies on find, in production — not just when a test wires a provider. The analytics auto-bridge seam (getService('security').getReadFilter) was already in place from the earlier P0 work; this supplies the security side + the async plumbing.

Security side (plugin-security)

  • Extracted the middleware's per-request RLS resolution into two shared private methods, used by both the engine middleware and the new public method, so the find-path and analytics-path scoping are provably in lock-step:
    • resolvePermissionSetsForContext() — roles + explicit perms + implicit/post-resolution baseline fallback.
    • computeRlsFilter() — collect applicable policies → field-existence safety (wildcard organization_id policy on a column the object lacks → deny sentinel; explicit tenancy opt-out → skip) → compile (+ deny sentinel).
      The middleware now calls these; behavior is byte-identical (59 existing tests green).
  • New async getReadFilter(object, context). Returns undefined (system / anonymous → no scope), a FilterCondition (AND into the object's scan), or the RLS_DENY_FILTER sentinel (policies applied but none compiled, or resolution threw) — fail-closed: a degraded permission subsystem denies (zero rows), never leaks cross-tenant data.
  • Registers the security service exposing getReadFilter (only once the metadata/ql/dbLoader handles are wired in start()).

Analytics side (service-analytics)

  • The provider may now be async (DB-backed RLS). Since the strategy builds SQL synchronously, AnalyticsService pre-resolves the scope for every object the query scans — base (cube.sql) + every cube.joins[*].name, a superset of what the strategy scopes — into a Map BEFORE the SQL builder runs; the strategy reads each object's filter synchronously from that map. NativeSQLStrategy is untouched (StrategyContext.getReadScope stays sync → no spec contract change).
  • Fail-closed: if the provider throws for any object, the whole query is rejected — no unscoped SQL is ever emitted/executed.

Review focus

  1. Parity: computeRlsFilter / resolvePermissionSetsForContext are exact extractions of the middleware logic — confirm no behavioral drift (the suite asserts find-path outputs unchanged).
  2. Fail-closed completeness: deny on missing column, on resolution throw (security side), and on provider rejection (analytics side).
  3. Object-set coverage: pre-resolved set (cube.sql + all cube.joins[*].name) ⊇ the objects applyReadScope scans.
  4. getReadFilter returning undefined for single-tenant (org-scoping off) — intended (tenant_isolation is stripped without plugin-org-scoping).

Tests

  • plugin-security: +8 (parity with find-path, fail-closed deny on missing column + resolution throw, tenancy opt-out, system/anon bypass, service registration) — 86 green.
  • service-analytics: +2 (async provider scopes base+joined by tenant; async reject → fail-closed, zero SQL emitted) — 87 green. Existing sync-provider + deny-sentinel gates unchanged.
  • Smoke: kernel boots clean with Security + AnalyticsServicePlugin.

🤖 Generated with Claude Code

… RLS (ADR-0021 D-C, Task #9)

Closes the last P0 gap: the analytics raw-SQL path (which bypasses the ObjectQL
engine middleware) can now enforce the SAME tenant/RLS row scoping the engine
applies on `find`, in production — not just when a test wires a provider.

## Security side (plugin-security)
- Extract the middleware's per-request RLS resolution into two shared private
  methods used by BOTH the engine middleware and the new public method, so the
  find-path and the analytics path are provably in lock-step:
    - resolvePermissionSetsForContext() — roles + explicit + implicit/post-resolution baseline fallback.
    - computeRlsFilter() — collect applicable policies → field-existence safety (wildcard org policy on a column the object lacks → deny sentinel; tenancy opt-out → skip) → compile (+ deny sentinel).
  The middleware now calls these; behavior is byte-identical (59 existing tests green).
- New `async getReadFilter(object, context)` composing the two. Returns:
  undefined (system / anonymous → no scope), a FilterCondition (AND into the
  object's scan), or the RLS_DENY_FILTER sentinel (policies applied but none
  compiled, OR resolution threw) — FAIL-CLOSED: a degraded permission subsystem
  denies (zero rows), never leaks cross-tenant rows.
- Register the `security` service exposing getReadFilter (once metadata/ql/dbLoader
  handles are wired in start()), which the analytics plugin auto-bridges to.

## Analytics side (service-analytics)
- The provider may now be async (getReadFilter can hit the DB). Since the strategy
  builds SQL synchronously, AnalyticsService pre-resolves the read scope for every
  object the query scans — base (`cube.sql`) + every `cube.joins[*].name`, a
  superset of what the strategy scopes — into a Map BEFORE the SQL builder runs;
  the strategy reads each object's filter synchronously from that map. The
  NativeSQLStrategy is untouched (StrategyContext.getReadScope stays sync → no
  spec contract change).
- Fail-closed: if the provider throws for any object, the whole query is rejected
  (no unscoped SQL is ever emitted/executed).

## Tests
- plugin-security: +8 getReadFilter tests (parity with find-path, fail-closed deny
  on missing column + on resolution throw, tenancy opt-out, system/anon bypass,
  service registration). 86 green.
- service-analytics: +2 (async provider scopes base+joined by tenant; async reject
  → fail-closed, zero SQL emitted). 87 green. Existing sync-provider + deny-sentinel
  integration gates still pass unchanged.
- Smoke: kernel boots clean with both plugins (Security + AnalyticsServicePlugin).

Resolves the SecurityPlugin half of Task #9; the analytics auto-bridge seam was
already in place from the P0 work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Jun 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jun 7, 2026 3:42pm

Request Review

@os-zhuang os-zhuang merged commit 7d52fea into main Jun 7, 2026
12 checks passed
@os-zhuang os-zhuang deleted the feat/security-getreadfilter-analytics-rls branch June 7, 2026 20:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants