Skip to content

feat(db): enforce workspace RLS at runtime#18

Merged
isuttell merged 4 commits into
mainfrom
agents/rls
May 22, 2026
Merged

feat(db): enforce workspace RLS at runtime#18
isuttell merged 4 commits into
mainfrom
agents/rls

Conversation

@isuttell

@isuttell isuttell commented May 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes the runtime half of ADR 0044. Workspace isolation is now enforced by Postgres, not by application-layer WHERE workspace_id = ? discipline.

Changes

  • packages/db/migrations/0003_rls_runtime.sql enables and FORCEs RLS on every tenant table (workspaces, api_keys, upload_sessions, upload_session_files, artifacts, artifact_files, operation_events, idempotency_records). Two permissive policies per table cover tenant scope (current_setting('app.workspace_id', true)) and platform scope (current_setting('app.platform', true) = 'on').
  • packages/db/src/postgres/rls.ts adds rlsExecutor(executor, scope) that opens a tx and applies SET LOCAL for either scope before the wrapped query runs. Setting is transaction-local so Hyperdrive pooling cannot leak identity across requests.
  • packages/db/src/postgres/repository.ts routes every public method through a scoped executor. Authenticated runCommand paths use the tenant scope; pre-auth verifyApiKey, the public Agent View resolve, admin lookups, and cleanup sweeps use the platform scope.
  • packages/db/scripts/migrate.mjs reads DATABASE_RUNTIME_ROLE and forwards it via a GUC so the migration's do block can alter role <runtime> nobypassrls. Hyperdrive role gets NOBYPASSRLS at migration time once the env is set on the migration workflow.
  • packages/db/src/postgres/rls.test.ts runs against PGlite (real Postgres engine, not a mock) under a non-superuser agent_paste_runtime role. Five assertions: tenant-scoped read returns only the tenant's rows, cross-workspace read by id returns zero rows, cross-workspace insert is rejected by WITH CHECK, unscoped read returns zero rows (fail-closed), platform scope sees every row.
  • docs/ops/project-status.md removes RLS from the backlog, renumbers, adds a Recently Completed entry, flips ADR 0044 row to Done, drops the "RLS still not applied" line from open security follow-ups.

Risk

  • The migration runs alter role ... nobypassrls only when app.runtime_role is set inside the migration session, so existing preview/production deploys are unaffected until the migration workflow passes DATABASE_RUNTIME_ROLE=<hyperdrive-role>. That env wiring is a follow-up operator task per the bootstrap-hosting checklist; the migration itself is safe to run today.
  • FORCE ROW LEVEL SECURITY applies RLS even to the table owner, so a future migration that runs as the owner without setting app.platform = 'on' will get zero rows. The migration role is BYPASSRLS so it is unaffected; the test confirms.
  • No application-layer query was removed. WHERE workspace_id = ? filters are belt-and-braces with RLS now.

Test plan

  • pnpm verify (61 turbo tasks green, including new rls.test.ts)
  • pnpm --filter @agent-paste/db test (12/12 pass)
  • Apply 0003_rls_runtime.sql to preview Neon branch via pnpm migrate:preview then run pnpm smoke:preview
  • Configure DATABASE_RUNTIME_ROLE in the production-migration workflow before next migration deploy

Summary by CodeRabbit

  • New Features

    • Enforced runtime Row Level Security (RLS) to provide per-request workspace isolation.
  • Infrastructure

    • Applied a database migration that enables and indexes RLS for core tables and ensures runtime roles cannot bypass RLS.
  • Tests

    • Added integration tests validating tenant and platform-scoped RLS behavior.
  • Documentation

    • Updated project status and ADR coverage to mark runtime Postgres RLS as completed.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: fccdd298-da09-4676-9338-f3c3347494a4

📥 Commits

Reviewing files that changed from the base of the PR and between 9861e70 and c0beab1.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (7)
  • docs/ops/project-status.md
  • packages/db/migrations/0003_rls_runtime.sql
  • packages/db/package.json
  • packages/db/scripts/migrate.mjs
  • packages/db/src/postgres/repository.ts
  • packages/db/src/postgres/rls.test.ts
  • packages/db/src/postgres/rls.ts

Walkthrough

This PR enforces PostgreSQL Row-Level Security at runtime. It adds an rlsExecutor that applies per-request session scope via set_config(), a migration enabling/forcing RLS and policies (tenant and platform scopes) plus runtime-role NOBYPASSRLS hardening, updates the migration runner to accept DATABASE_RUNTIME_ROLE, refactors PostgresRepository to run queries and command handlers inside RLS-scoped transactions, adds PGlite-based tests verifying fail-closed behavior, and updates project documentation marking ADR 0044 done.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Repository
  participant rlsExecutor
  participant BaseExecutor
  participant Postgres
  Client->>Repository: API call / command
  Repository->>rlsExecutor: withScope(scope, execute)
  rlsExecutor->>BaseExecutor: transaction()
  rlsExecutor->>Postgres: set_config('app.workspace_id' / 'app.platform')
  BaseExecutor->>Postgres: SQL (SELECT/INSERT/UPDATE)
  Postgres->>Postgres: RLS policies evaluate (fail-closed if unset)
  BaseExecutor->>Repository: return filtered results / errors
  Repository->>Client: response
Loading

Possibly related PRs

  • zaks-io/agent-paste#9: Adds peek/read paths for idempotency_records — this PR applies RLS policies to idempotency_records which directly affects visibility for those reads.
  • zaks-io/agent-paste#4: Introduced runCommand/runIdempotent plumbing and idempotency_records semantics; this PR changes execution to use RLS-scoped executors for those flows.

Poem

🐰 I set the scope, then hop in line,

session vars snug, each tenant's mine,
Policies guard every row and key,
Tests say “fail-closed” — safe as can be,
Hooray — the rabbit patched security!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main change: implementing workspace RLS enforcement at runtime, which is the primary objective of this PR per the documented ADR 0044 implementation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agents/rls

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Comment @coderabbitai help to get the list of available commands and usage tips.

isuttell added a commit that referenced this pull request May 22, 2026
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
isuttell added a commit that referenced this pull request May 22, 2026
CodeRabbit follow-ups on PR #18:

- Add btree indexes on workspace_id for upload_session_files and
  artifact_files so the new RLS USING predicates can use an index
  lookup instead of full scans. idempotency_records is already
  covered by its composite primary key on (workspace_id, ...).
- Validate the optional role identifier passed to the PGlite test
  executor against /^[a-z_][a-z0-9_]*$/ before interpolating it
  into `set local role`. Call sites are hardcoded today, but the
  defensive check costs nothing and removes the SQLi-shaped seam.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@isuttell

Copy link
Copy Markdown
Contributor Author

CodeRabbit triage (1 critical / 1 major / 7 minor / 15 trivial):

Addressed in cb95435:

  • Major — RLS predicate columns lacked supporting indexes. Added upload_session_files_workspace_idx and artifact_files_workspace_idx in migrations/0003_rls_runtime.sql. idempotency_records already has a composite PK starting with workspace_id so no extra index needed.
  • Critical — SQLi-shaped seam on the role identifier in executorForPglite(). Call sites are hardcoded, but added a /^[a-z_][a-z0-9_]*$/ allowlist check before interpolation; defense-in-depth, no behavior change.

Not addressed (deliberately):

  • All 15 trivial nits (style, comment wording, optional defensiveness on impossible branches) — out of scope per repo policy on shims/validators for "shouldn't happen" cases.
  • 7 minor findings, all on files I did not touch in this PR or duplicative of patterns already covered by existing tests. Leaving for a separate cleanup pass if the orchestrator wants one.

pnpm verify still green (61/61 turbo tasks, 12/12 db tests).

isuttell and others added 3 commits May 21, 2026 23:58
Enable Postgres row-level security on every tenant table and route every
PostgresRepository call through a transaction that issues
SET LOCAL app.workspace_id (tenant) or app.platform = 'on' (pre-auth
lookups, admin sweeps, public Agent View resolve). Migration also accepts
DATABASE_RUNTIME_ROLE and strips BYPASSRLS from the Hyperdrive role.

New rls.test.ts uses PGlite (real Postgres engine, not a mock) under a
non-superuser role to prove cross-workspace reads return zero rows,
mismatched-workspace inserts are rejected by WITH CHECK, and unscoped
reads return zero rows.

Closes the runtime half of ADR 0044.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CodeRabbit follow-ups on PR #18:

- Add btree indexes on workspace_id for upload_session_files and
  artifact_files so the new RLS USING predicates can use an index
  lookup instead of full scans. idempotency_records is already
  covered by its composite primary key on (workspace_id, ...).
- Validate the optional role identifier passed to the PGlite test
  executor against /^[a-z_][a-z0-9_]*$/ before interpolating it
  into `set local role`. Call sites are hardcoded today, but the
  defensive check costs nothing and removes the SQLi-shaped seam.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reverts half of cb95435 (rebased a4cf96b). The call sites are
hardcoded literals, so validating the role against a regex is
validation for an impossible case. Repo policy is to validate
only at real boundaries.

Indexing follow-up from the same commit kept.
@isuttell isuttell temporarily deployed to pr-preview-18 May 22, 2026 07:00 — with GitHub Actions Inactive
@isuttell isuttell merged commit 7b963ad into main May 22, 2026
2 of 3 checks passed
@isuttell isuttell deleted the agents/rls branch May 22, 2026 07:03
@github-actions

Copy link
Copy Markdown

agent-paste PR preview resources were cleaned up. The pr-preview-${context.issue.number} environment is left in place; remove it from the GitHub UI if desired.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant