#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
| Backend | RLS available | Notes |
|---|---|---|
comby-store-postgres | ✅ | Opt-in via EventStoreOptionWithRLSAppRole(...) (and equivalents on Command/Snapshot stores). |
comby-store-sqlite | ❌ | SQLite has no RLS feature. Tenant isolation must be enforced by the application. |
comby-store-redis | ❌ | Cache backend — no tenant isolation primitive at the data layer. |
comby-store-minio | ❌ | Object/data store — no row-level concept. |
| in-memory stores | ❌ | Test/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.
-- events
CREATE TABLE events (
...,
tenant_uuid TEXT NOT NULL,
workspace_uuid TEXT, -- nullable: not all events are workspace-scoped
...
);
-- commands and snapshots: same shapeThe 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:
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:
- Enables RLS + installs the policy on the store's own table (
eventsfor the EventStore,commandsfor the CommandStore,snapshotsfor the SnapshotStore). The owner connection runs the DDL. - Provisions the app role via
EnsureAppRole(CREATE ROLE … LOGIN NOBYPASSRLS, schema/sequence grants, plusGRANT SELECT/INSERT/UPDATE/DELETEon whichever comby tables exist at that moment). - Opens a second
*sql.DBpool as that app role. Tenant-scoped reads and writes route through it inside a transaction that runsSELECT 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.
-- 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). Themissing_ok=trueflag returnsNULLinstead of raising when the GUC is unset. The equality is thenNULL(i.e., not true) and the row is filtered out — fail-closed without surfacing a query error.- No
::uuidcast. Tables useTEXTfortenant_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:
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:
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:
| Role | RLS | Use |
|---|---|---|
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 LOCALwould 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:
| Sentinel | Constant | Role |
|---|---|---|
| Anonymous tenant | comby.ANONYMOUS_TENANT_UUID | Pre-auth requests (/login, /register, …) and the storage bucket for Account aggregates, since an Account is cross-tenant by definition. |
| System tenant | comby.SYSTEM_TENANT_UUID | Cross-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'sAuthAnonymousCtxmiddleware 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 tenantYou 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_uuidalready. - 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:
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:
- Backwards compatibility: store with no RLS option behaves as before
- EventStore
Initprovisions the app role + RLS on the events table (pg_class.relrowsecurity,pg_policies,pg_roles.rolbypassrls=false) - Per-store scope: EventStore.Init alone enables RLS only for its own table and does not require
commands/snapshotsto exist yet - App pool without
SET LOCALis fail-closed (0 reads, INSERT denied) - Tenant isolation on
List - WITH CHECK rejects foreign-tenant INSERT
WithRLSBypass(ctx)routes to the owner pool- Bridge tenant boundary is enforced via
tenant_uuid(bridge events invisible from a stakeholder home tenant session) - RLS applies symmetrically to events, commands, snapshots once all three stores are initialised
Reset(cross-tenant TRUNCATE) succeeds via the owner poolDisablePostgresRLSreverts to open access (sanity)
See Also
- Event Store —
EventStoreOptionWithRLSAppRole - Command Store —
CommandStoreOptionWithRLSAppRole - Snapshot Store —
SnapshotStorePostgresWithRLSAppRole - Bridge Tenant — leveraging tenant boundaries
comby-store-postgres/rls.postgres.go— implementationcomby-store-postgres/rls.postgres_test.go— verification suite