Enforcing RLS with the restricted align_app role
Align isolates tenants at two layers: the application always scopes queries to a
single tenant (withTenant / SET LOCAL app.current_tenant), and PostgreSQL
Row-Level Security (RLS) is a hard backstop that filters every row by tenant_id.
RLS only protects you if the app connects as a non-owner role - PostgreSQL lets a table owner bypass RLS. This is the same runbook for a new install and for an existing one. Installation walks a new install through the same commands right after migrations run, because the role does not exist before then.
It is automatic if you set rlsCutover.enabled (see the next section). Otherwise you set
align_app's password and add it to the database secret yourself. This page also covers
brain, which the install guide does not.
The migrations job keeps using the owner connection, because it creates roles,
tables and FORCE ROW LEVEL SECURITY policies.
Let the chart do it (ALI-1806)
Set rlsCutover.enabled: true. A hook Job runs after the migrations and before the gateway
starts. It sets the align_app login password, refuses a role that could bypass RLS, and
connects with app-url to confirm the gateway's connection works. It replaces steps 2 and 5
below. Two keys must exist in the database.secretName Secret first:
app-password: the password to set.app-url:postgresql://align_app:<that password>@<host>:5432/<db>.
postgresql.enabled: true generates both. With your own database, add them yourself. The Job is
safe to repeat on every upgrade. It does not restart a gateway that is already running, so on an
existing release restart it once after the first run. Brain stays on the owner connection, as
described under Brain below.
If you use RDS Proxy (read this first)
When the app connects through RDS Proxy (DATABASE_APP_URL points at a *.proxy-*
endpoint), two things must be true before the cutover works - both were real failures
caught during the preview rollout:
-
Register
align_appwith the proxy. RDS Proxy keeps its own credential registry; a role it doesn't know is rejected (This RDS proxy has no credentials for the role align_app) even though the role authenticates fine directly. In align-infra this is theadditional_rolesinput on therds-proxymodule (creates a Secrets Manager secret + an extra proxyauthblock + IAM access). Apply that before re-sealing. -
No libpq
optionsstartup parameter. RDS Proxy rejects command-line options (Feature not supported: RDS Proxy currently doesn't support command-line options). The gateway setsstatement_timeoutper-connection via a poolconnecthandler (notoptions: '-c statement_timeout=...'); make sure your gateway image includes that fix before pointingDATABASE_APP_URLat the proxy, or every query fails FATAL.
Direct-to-RDS deployments (no proxy) are unaffected by both.
One-time cutover
1. Create the role (automatic)
Migration 073_create_align_app_role.sql creates align_app as a NOLOGIN role with
least-privilege grants. It runs with every migration job - nothing to do.
2. Give the role a password (in-cluster)
RDS is typically private, so run this from inside the cluster. Start a pod from the
migrations image, which has psql and reaches the database like the migrations job does, then:
psql "postgres://<owner-user>:<owner-pass>@<host>:5432/<db>" -v ON_ERROR_STOP=1 \
-v app_pw="<align_app password>" <<'SQL'
ALTER ROLE align_app WITH LOGIN PASSWORD :'app_pw';
SQL
Pick any strong password and use the same value in step 3. psql fills in :'app_pw' from
stdin and quotes it, so the shell never builds the password into SQL. A -c string would not
be filled in. The command fails if the role does not exist yet, which means migration 073 has
not run.
3. Re-seal the database secret with app-url
Add an app-url key to the align-database secret, using the same tool you use for your other
secrets (kubectl, Sealed Secrets, External Secrets). It is the align_app connection string:
postgresql://align_app:<align_app password>@<host>:5432/<db>
The owner url stays in the secret, because migrations use it.
4. Roll the gateway
The gateway prefers DATABASE_APP_URL (wired from the optional app-url secret key)
over DATABASE_URL. Once the key exists, restart the gateway:
kubectl rollout restart deploy/align-gateway -n align-<env>
Until app-url exists, DATABASE_APP_URL is unset and the gateway falls back to the
owner DATABASE_URL, so deploying the Helm change ahead of the secret is non-breaking.
DATABASE_APP_URL is only wired on the direct-connection path. When the gateway's
PgBouncer sidecar is enabled (gateway.pgbouncer.enabled: true), the chart does not
emit app-url, because it points at the database directly and the gateway prefers it
over the PgBouncer connection - so the cutover requires PgBouncer disabled until the
app role can be routed through the sidecar (a separate change).
5. Verify
As the owner, confirm RLS is enabled AND forced:
SELECT relname, relrowsecurity, relforcerowsecurity
FROM pg_class WHERE relname = 'decision_snapshots';
-- relrowsecurity = t, relforcerowsecurity = t
Then connect directly as align_app (using the app-url connection string,
not SET ROLE - the owner is usually not a member of align_app, and SET ROLE
would fail on a typical RDS setup). Confirm both the effective user and that a
tenant-scoped read is filtered:
psql "postgres://align_app:<align_app-pass>@<host>:5432/<db>"
SELECT current_user; -- align_app
SELECT set_config('app.current_tenant', '<tenant-uuid>', false);
SELECT count(*) FROM decision_snapshots; -- only this tenant's rows
Brain (optional follow-up)
The brain service connects via discrete POSTGRES_USER / POSTGRES_PASSWORD keys and
also sets app.current_tenant, so it benefits from the same backstop. Pointing brain at
align_app needs a small change to read app-specific credentials; it is tracked
separately. Brain remains on the owner connection until then - the gateway cutover above
is independent and safe on its own.
Rollback
Remove (or blank) the app-url key and re-seal; the gateway falls back to the owner
DATABASE_URL on its next restart. No data migration is involved.