Skip to content

#outline: deep

Row Level Security (Postgres only)

Postgres-only feature

RLS in comby is only available for the PostgreSQL stores (comby-store-postgres). It is opt-in via a single store option. SQLite, in-memory, and any other backend do not support RLS — the tenant_uuid columns are present for parity but enforcement remains in the application layer.

Overview

Comby v3 ships Row Level Security Foundation as a defense-in-depth layer on top of the existing application-layer authorization. With RLS enabled on the Postgres stores, the database itself enforces tenant isolation — even if a query in the application layer accidentally omits a WHERE tenant_uuid = ... filter, Postgres refuses to return rows from another tenant.

This is intended for compliance scenarios (SOC 2, ISO 27001, GDPR) where multi-tenant data isolation must be guaranteed at the database layer.

Backend Support Matrix

BackendRLS availableNotes
comby-store-postgresOpt-in via EventStoreOptionWithRLSAppRole(...) (and equivalents on Command/Snapshot stores).
comby-store-sqliteSQLite has no RLS feature. Tenant isolation must be enforced by the application.
comby-store-redisCache backend — no tenant isolation primitive at the data layer.
comby-store-minioObject/data store — no row-level concept.
in-memory storesTest/dev only.

If your deployment is compliance-critical, choose the Postgres store and turn on the RLS option. Otherwise the framework runs unchanged.

Concept

┌─────────────────────────────────────────────────────────┐
│ Application Layer (comby Facade + Permission Functions) │
│   • Per-command/query permission checks                 │
│   • RequestContext.SenderIdentityUuid → group lookups   │
└─────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────┐
│ DB Layer (Postgres RLS — Postgres store only)           │
│   • SET LOCAL app.tenant_uuid at transaction start      │
│   • PERMISSIVE policy: tenant_uuid = current_setting()  │
│   • Fail-closed if SET LOCAL is missing                 │
└─────────────────────────────────────────────────────────┘

If the application-layer permission check is bypassed (bug, SQL injection, ad-hoc DBA query), Postgres still refuses to return rows from foreign tenants.

Schema Requirements

In Comby v3, all relevant tables carry a tenant_uuid column (and a nullable workspace_uuid). The columns exist for both Postgres and SQLite — only Postgres uses them for RLS enforcement, but having them on both keeps the data shape portable.

sql
-- events
CREATE TABLE events (
  ...,
  tenant_uuid    TEXT NOT NULL,
  workspace_uuid TEXT,         -- nullable: not all events are workspace-scoped
  ...
);

-- commands and snapshots: same shape

The schema is created automatically by the postgres store on first run; for upgrade-in-place, ALTER TABLE migrations are run at startup.

Enabling RLS (Postgres store)

Pass EventStoreOptionWithRLSAppRole(user, password) (and the equivalent options on the Command and Snapshot stores) when initialising the store:

go
import (
    "github.com/gradientzero/comby/v3"
    store "github.com/gradientzero/comby-store-postgres"
)

es := store.NewEventStorePostgres(host, port, ownerUser, ownerPwd, dbName)
err := es.Init(ctx,
    comby.EventStoreOptionWithRLSAppRole("comby_app", "<app-role-password>"),
)

cs := store.NewCommandStorePostgres(host, port, ownerUser, ownerPwd, dbName)
err  = cs.Init(ctx,
    comby.CommandStoreOptionWithRLSAppRole("comby_app", "<app-role-password>"),
)

ss := store.NewSnapshotStorePostgres(host, port, ownerUser, ownerPwd, dbName,
    store.SnapshotStorePostgresWithRLSAppRole("comby_app", "<app-role-password>"),
)
err  = ss.Init(ctx)

Each store's Init, when given the option, does three things automatically and idempotently — but only for its own table:

  1. Enables RLS + installs the policy on the store's own table (events for the EventStore, commands for the CommandStore, snapshots for the SnapshotStore). The owner connection runs the DDL.
  2. Provisions the app role via EnsureAppRole (CREATE ROLE … LOGIN NOBYPASSRLS, schema/sequence grants, plus GRANT SELECT/INSERT/UPDATE/DELETE on whichever comby tables exist at that moment).
  3. Opens a second *sql.DB pool as that app role. Tenant-scoped reads and writes route through it inside a transaction that runs SELECT set_config('app.tenant_uuid', $tenant, true) before the actual statement; cross-tenant operations stay on the original (BYPASSRLS) owner pool.

The per-table scoping means you can initialise stores in any order — EventStore.Init does not fail if commands/snapshots tables don't exist yet. Each store grants the role access to whichever tables already exist; later Init calls fill in the rest.

sql
-- What EnablePostgresRLS installs (once per table)
ALTER TABLE events ENABLE ROW LEVEL SECURITY;
ALTER TABLE events FORCE ROW LEVEL SECURITY;

DROP POLICY IF EXISTS comby_tenant_isolation ON events;
CREATE POLICY comby_tenant_isolation ON events
  AS PERMISSIVE
  FOR ALL
  USING      (tenant_uuid = current_setting('app.tenant_uuid', true))
  WITH CHECK (tenant_uuid = current_setting('app.tenant_uuid', true));

Notes on the policy choice:

  • PERMISSIVE, not RESTRICTIVE. Postgres applies default-deny unless at least one PERMISSIVE policy allows. A standalone RESTRICTIVE policy would deny all rows by itself.
  • current_setting(..., true). The missing_ok=true flag returns NULL instead of raising when the GUC is unset. The equality is then NULL (i.e., not true) and the row is filtered out — fail-closed without surfacing a query error.
  • No ::uuid cast. Tables use TEXT for tenant_uuid; comparing TEXT-to-TEXT avoids implicit-cast surprises.

Cross-Tenant Operations

Some operations are legitimately cross-tenant — readmodel restoration, system reactors (e.g. the bridge auto-revoke reactor), administrative diagnostics. They opt out via a context tag:

go
import "github.com/gradientzero/comby/v3"

func (rm *Reactor) BridgeStakeholderLeftEvent(ctx context.Context, ...) error {
    ctx = comby.WithRLSBypass(ctx)        // owner pool, no SET LOCAL wrapping
    bridge, _ := rm.AggregateRepository.GetAggregate(ctx, bridgeUuid)
    ...
}

comby.WithRLSBypass is the only way to legitimately read across tenants when RLS is enabled. Each call site is grep-able and reviewable. Without RLS configured the flag is a no-op.

SET LOCAL Pattern (low-level helper)

For hand-written SQL outside the store interfaces, comby-store-postgres exposes WithRLSSession:

go
err := store.WithRLSSession(ctx, db, tenantUuid, func(tx *sql.Tx) error {
    rows, err := tx.QueryContext(ctx, "SELECT ... FROM events WHERE ...")
    // process rows
    return nil
})

The helper begins a transaction, runs SELECT set_config('app.tenant_uuid', $1, true) (transaction-scoped), and runs the callback. The store's automatic routing uses the same helper internally.

Database Roles

Two roles are used by the Postgres store:

RoleRLSUse
owner (e.g. postgres)bypassed via BYPASSRLS (superuser default)DDL, migrations, restore-from-zero, system queries
app role (e.g. comby_app)subject to RLS (NOBYPASSRLS)All tenant-scoped reads/writes

The owner credentials are the user/password passed to NewEventStorePostgres(...). The app credentials are the user/password passed to EventStoreOptionWithRLSAppRole(...).

EnsureAppRole is called automatically on Init and is idempotent — it creates the role on the first run, then re-enforces NOBYPASSRLS and (re)sets the password on subsequent runs. Rotating the password is therefore an option-update + restart.

Connection Pooling

If you front Postgres with pgbouncer:

  • Required: transaction mode (default in many setups)
  • Forbidden: session mode — SET LOCAL would persist across the pool's reuse and could leak between tenants

Coverage Gaps (current limits)

The dual-pool routing covers the common write/read paths: Append, List with a tenant filter, Update, snapshot Save. Operations whose request struct does not carry a tenant — Get(eventUuid), Delete(eventUuid), Total, Info, Reset — currently route to the owner pool. These are admin/diagnostic paths in practice; tenant-scoped consumers should use List(WithTenantUuid) rather than UUID-only lookups. A future iteration can tighten this by carrying tenant context through the Get/Delete request structs.

Anonymous and System Tenant under RLS

Comby ships two well-known sentinel tenant UUIDs that get tagged on framework-level events that don't belong to any single end-user tenant:

SentinelConstantRole
Anonymous tenantcomby.ANONYMOUS_TENANT_UUIDPre-auth requests (/login, /register, …) and the storage bucket for Account aggregates, since an Account is cross-tenant by definition.
System tenantcomby.SYSTEM_TENANT_UUIDCross-tenant administrative operations, the bootstrap admin identity, internal logs.

From the database's perspective both are normal tenant_uuid values. RLS treats them like any other tenant.

What this means for the Account aggregate

AccountCreatedEvent, AccountPasswordChangedEvent, etc. all carry tenant_uuid = ANONYMOUS_TENANT_UUID. Under RLS that means:

  • A session with app.tenant_uuid = ANONYMOUS_TENANT_UUID (which is what the framework's AuthAnonymousCtx middleware sets for unauthenticated requests) can read every Account on the platform.
  • The framework's app-layer permission check on Account endpoints stays the only real boundary; RLS provides no additional defense-in-depth for the Account aggregate.
  • This is acceptable for the current model — Account stores email plus opaque credential material, not tenant-scoped business data — but it's worth knowing if you're evaluating RLS for a strict compliance use case.

What this means for Bridge identities

Tenant.InviteIdentity rejects any homeTenantUuid that is not a BridgeStakeholderJoined. Since ANONYMOUS_TENANT_UUID is never a real tenant aggregate, it can never be a stakeholder, and therefore never the home of a bridge identity. The user lifecycle is always:

anonymous (pre-auth)
  → authenticate → identity in home tenant A
  → (optional) identity in bridge C, anchored to A as home tenant

You cannot end up with an "anonymous bridge identity" by construction.

What this means for system reactors

System-tenant operations (bootstrap, cross-tenant reactors, internal jobs) typically run via the framework owner role which has BYPASSRLS. When a reactor or a bootstrap path operates under SYSTEM_TENANT_UUID, prefer comby.WithRLSBypass(ctx) over relying on the routing fallback — that way the call site is grep-able and the intent is explicit.

Future hardening

If you eventually need RLS to bound Account access (e.g. compliance scenarios where even an unauthenticated DB session must not enumerate accounts), the next step is to split the Account read paths so each Account is only readable from a session that has either (a) the matching accountUuid set in a separate GUC, or (b) WithRLSBypass. That's a sizeable refactor and is out of scope for the v3 RLS Foundation.

SQLite

SQLite has no real RLS. The schema columns tenant_uuid / workspace_uuid exist for parity, but enforcement remains at the application layer:

  • The framework's permission functions, query handlers, and read models all filter by tenant_uuid already.
  • For compliance-critical production setups, use Postgres. SQLite is positioned as a development / self-hosted tier.

There is no analogue of WithRLSAppRole for SQLite, and there will not be one — passing the option to a SQLite store is a no-op.

Disabling RLS

For diagnostics or recovery on Postgres:

go
err := store.DisablePostgresRLS(ctx, db)

Drops the comby_tenant_isolation policies and disables ROW LEVEL SECURITY on the comby tables. Don't run this in production.

Verified by Tests

The comby-store-postgres test suite includes 11 end-to-end RLS tests in rls.postgres_test.go:

  1. Backwards compatibility: store with no RLS option behaves as before
  2. EventStore Init provisions the app role + RLS on the events table (pg_class.relrowsecurity, pg_policies, pg_roles.rolbypassrls=false)
  3. Per-store scope: EventStore.Init alone enables RLS only for its own table and does not require commands/snapshots to exist yet
  4. App pool without SET LOCAL is fail-closed (0 reads, INSERT denied)
  5. Tenant isolation on List
  6. WITH CHECK rejects foreign-tenant INSERT
  7. WithRLSBypass(ctx) routes to the owner pool
  8. Bridge tenant boundary is enforced via tenant_uuid (bridge events invisible from a stakeholder home tenant session)
  9. RLS applies symmetrically to events, commands, snapshots once all three stores are initialised
  10. Reset (cross-tenant TRUNCATE) succeeds via the owner pool
  11. DisablePostgresRLS reverts to open access (sanity)

See Also

  • Event StoreEventStoreOptionWithRLSAppRole
  • Command StoreCommandStoreOptionWithRLSAppRole
  • Snapshot StoreSnapshotStorePostgresWithRLSAppRole
  • Bridge Tenant — leveraging tenant boundaries
  • comby-store-postgres/rls.postgres.go — implementation
  • comby-store-postgres/rls.postgres_test.go — verification suite