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 datasource —
TenantConnectionInterceptor(an Agroal pool interceptor) issuesSET LOCAL app.current_tenant = '<id>'once per JTA transaction, from the tenant resolved off the JWT.SET LOCALdies with the transaction — nothing leaks onto the pooled connection. Background/system work sets the sentinel'0'; a request claiming tenant<= 0is rejected (sentinel-spoofing guard, fail closed). - Readonly datasource —
TenantRlsScope.applyTo(connection, tenantId)(one shared entry point) marks the connection read-only, sets the GUC, and emptiessearch_pathso 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 NULL → zero 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_adminisBYPASSRLSby 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_admincredentials. - 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.