Skip to content

Row-Level Security & database roles

The first question a security reviewer asks of a multi-tenant hub: what stops tenant A from reading tenant B's rows? In lumnik the answer is PostgreSQL itself — every tenanted table carries a Row-Level Security policy, and the application connects as a role that cannot bypass it.

The three database roles

Created outside Flyway by infra/postgres/init/01-roles.sql (superuser context — the migration user intentionally lacks CREATEROLE):

Role Privileges Purpose
lumnik_app SELECT/INSERT/UPDATE/DELETE on tables, USAGE/SELECT/UPDATE on sequences (via ALTER DEFAULT PRIVILEGES from lumnik_admin). Subject to RLS. The default datasource — every request-path query.
lumnik_admin LOGIN + BYPASSRLS. Owns every table (Flyway runs as it; dynamic connector tables are created through the admin datasource too). Migrations and deliberate cross-tenant work: GDPR purge, dynamic-table DDL, secret resolution, the workflow dormancy sweep. JTA enlistment is disabled on this datasource (application.properties) so it is never picked up by accident inside a request transaction.
lumnik_readonly SELECT only, default_transaction_read_only = on, USAGE on schema connector (connector/V15__schema_card_and_readonly.sql + per-table grants from the dynamic writers). Subject to RLS. The physical firewall under LLM-generated SQL: ask-to-SQL and kind:View render/detail run only through this datasource.

lumnik_app owns nothing — table ownership sits with lumnik_admin, so the owner-bypasses-own-RLS rule of PostgreSQL never applies to the request path.

How a query gets its tenant

The policy predicate reads a transaction-local GUC, app.current_tenant:

  • Default datasourceTenantConnectionInterceptor (an Agroal pool interceptor) issues SET LOCAL app.current_tenant = '<id>' once per JTA transaction, from the tenant resolved off the JWT. SET LOCAL dies with the transaction — nothing leaks onto the pooled connection. Background/system work sets the sentinel '0'; a request claiming tenant <= 0 is rejected (sentinel-spoofing guard, fail closed).
  • Readonly datasourceTenantRlsScope.applyTo(connection, tenantId) (one shared entry point) marks the connection read-only, sets the GUC, and empties search_path so a bare table name cannot sidestep the schema-qualified leak guard.

The policy shape

Since core/V21__rls_tenant_nullif_guard.sql, every statically migrated tenant policy reads:

USING (tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::bigint)

No tenant set (or set to '') → the predicate is NULLzero rows, not an error and never all rows. (Before V21, an empty GUC crashed the query with 22P02; the guard made the failure mode "no data" instead.) System-shared tables (core.event_outbox, connector.* run bookkeeping — V4, V22, V24) additionally accept the '0' sentinel.

Dynamically created connector tables (connector.t_*, ext.*, mapping-target schemas) get the same guarded policy at creation time (DynamicTableWriter / HybridJsonbWriter), and core/V31__rls_dynamic_tables_nullif_guard.sql healed every dynamic table created before the guard — surgically, by predicate shape, leaving the '0'-sentinel system policies untouched.

Original policies: core/V2__rls_policies.sql, identity/V6__rls_policies.sql.

Credentials

Each datasource's credentials are env-overridable (application.properties): LUMNIK_APP_DB_USERNAME / LUMNIK_APP_DB_PASSWORD, LUMNIK_ADMIN_DB_USERNAME / LUMNIK_ADMIN_DB_PASSWORD, LUMNIK_READONLY_DB_USERNAME / LUMNIK_READONLY_DB_PASSWORD; all three share one DB_URL. Note the honest caveat: the self-host compose and the k8s chart currently keep the three role passwords at their 01-roles.sql defaults — Postgres is not published outside the internal network, and making them configurable is a tracked follow-up. Change them at the database if your Postgres is reachable.

What RLS does NOT protect against

  • The admin datasource. lumnik_admin is BYPASSRLS by design; every code path using it must filter explicitly (WHERE tenant_id = ? — e.g. PgcryptoSecretResolver). A bug there is a cross-tenant bug RLS will not catch.
  • The database superuser, or anyone holding the lumnik_admin credentials.
  • A wrong GUC. RLS trusts app.current_tenant; the tenant resolution upstream (JWT claim → TenantContextFilter) is part of the trusted computing base.

Secrets at rest: the master key

Tenant secrets (platform.secret, platform/V14__platform_secret.sql) are encrypted with pgcrypto (pgp_sym_encrypt, AES-256) under one symmetric master key: lumnik.secret.master-key, injected via env LUMNIK_SECRET_MASTER_KEY. The self-host and k8s installers generate it (openssl rand -base64 32) into .env / a Helm secret at install time; MasterKeyValidator refuses to boot on a blank, placeholder, or short (< 16 chars) key. Rotating an individual secret's value is supported (re-apply it; rotated_at is stamped). Rotating the master key itself is not supported today: there is no re-encryption tooling, so changing LUMNIK_SECRET_MASTER_KEY leaves every stored secret undecryptable and losing it makes them unrecoverable — back the key up, and expect to re-enter all secrets if it ever changes. A second key of the same pattern, LUMNIK_CRYPTO_SECRET_KEY (lumnik.crypto.secret-key, SecretCipher), covers connector/webhook credentials and has the same no-rotation posture.

See also

  • Roles & permissions — the application-layer RBAC above this.
  • Scopes — the métier boundary layered on top of tenant isolation.