ibexharness
DocsBlogReleasesRoadmap
GitHub
ibexharness

Documentation

OverviewIssuing API keysOrg and project modelMulti-tenant RLS
Auth›Multi-tenant RLS
Auth

Multi-tenant RLS

Row-level security policies isolating tenant data in Postgres.

Postgres row-level security (RLS) ensures each organization's data is invisible to other tenants, even if application code regresses. Policies live in the ibex_core schema and ship with versioned migrations under infra/migrations/ (ADR-0005).

RLS is one layer in a defense-in-depth model — auth queries still include explicit org_id filters. See Tenant isolation.

Defense in depth

Application org checks + RLS + cross-tenant 403 responses (not 404) together prevent enumeration and data leaks. A single missing check should not compromise isolation.

How session org context works

Mermaid diagram: sequenceDiagram
+--------------+                      +-------+                           +----------+   
| gRPC handler |                      | Store |                           | Postgres |   
+--------------+                      +-------+                           +----------+   
        |                                 |                                     |        
        |  Query with org_id from token   |                                     |        
        |--------------------------------->                                     |        
        |                                 |                                     |        
        |                                 |    SET app.current_org_id = $org    |        
        |                                 |------------------------------------->        
        |                                 |                                     |        
        |                                 |  SELECT ... FROM ibex_core.tokens   |        
        |                                 |------------------------------------->        
        |                                 |                                     |        
        |                                 |    Only rows matching RLS policy    |        
        |                                 <.....................................|        
        |                                 |                                     |        
+--------------+                      +-------+                           +----------+   
| gRPC handler |                      | Store |                           | Postgres |   
+--------------+                      +-------+                           +----------+   

Auth sets app.current_org_id (or equivalent session variable) before every tenant-scoped query. Policies reference this setting — queries without it see zero rows or fail closed depending on table policy.

Protected tables (Phase 1)

TableIsolation policyPhase 1 usage
ibex_core.organizationsOrg-scoped readsTenant root
ibex_core.usersOrg-scoped membershipToken audit
ibex_core.agentsOrg-scoped CRUDAgent verification
ibex_core.tokensOrg-scoped CRUDPAT hash storage

Future tables (memories, sessions, directives) will follow the same pattern before their services launch.

Migration workflow

1

Start Postgres

make compose-dev-up — dev DSN on port 5432.

2

Apply migrations

make db-migrate — idempotent; records version in schema_migrations.

3

Optional seed

make db-seed — dev rows only; never on staging/production.

4

CI parity

Every PR runs db-migrate-smoke against ephemeral Postgres.

Roll back one step in dev only: make db-migrate-down. Check version: make db-version.

Verify RLS is enabled

bash
psql "$POSTGRES_DSN" -c \
  "SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname = 'ibex_core';"

Expected: rowsecurity = t on core tenant tables.

Inspect policies:

SQL
SELECT polname, polcmd, polroles::regrole[]
FROM pg_policy p
JOIN pg_class c ON p.polrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE n.nspname = 'ibex_core';

Cross-tenant denial contract

When org A requests org B's agent or token:

LayerBehavior
Proxy403 AGENT_NOT_AUTHORIZED or PATH_ORG_MISMATCH
gRPCPermissionDenied — not NotFound
Store(nil, nil) for wrong-org row — mapped to 403
RLSZero rows returned even if WHERE clause regresses

Ambiguous denials prevent attackers from learning whether a UUID exists in another org. Audit logs record cross-tenant attempts in production configurations.

Redis and application namespacing

RLS protects Postgres only. Other stores namespace by org at the key level:

ratelimit:{org_id}:rpm:{minute}
auth:token:{sha256_hash}   # hash scoped; org in cached payload

ClickHouse (Phase 2+ traces) has no RLS — every query must filter org_id explicitly.

Testing isolation

bash
make compose-test-up
go test -tags=integration ./services/auth/...
go test -tags=integration -run '^TestSecurity_' ./services/proxy/...

Auth store integration tests use real Postgres (testcontainers or compose-test on port 5433). Windows developers: set $env:POSTGRES_TEST_DSN to dev Postgres on 5432 if preferred.

Never disable RLS in production

Local debugging with superuser bypass is acceptable; production roles must not hold BYPASSRLS.

Policy evolution rules

From ADR-0005:

  • New tenant tables ship with RLS enabled in the same migration that creates the table
  • Policy changes require integration tests proving cross-org denial
  • Breaking policy changes need an ADR and compatibility plan

Related

  • Org and project model — entity relationships
  • Auth overview — who sets session org context
  • Security overview — threat model
  • ADR-0005 — migration sequencing
  • ADR-0014 — users and agents schema

Was this page helpful?

Edit on GitHub

Last updated on

PreviousOrg and project modelNextSecurity

On this page

  • How session org context works
  • Protected tables (Phase 1)
  • Migration workflow
  • Verify RLS is enabled
  • Cross-tenant denial contract
  • Redis and application namespacing
  • Testing isolation
  • Policy evolution rules
  • Related
0%