Document the two database users and how an existing deployment moves to them

Sixth section of #23, docs only. docs/production-deployment.md explains
why the console connects as two users and what the runtime user may not
be (a superuser, an owner of the database or of any object, or a member
of any role but console_runtime), how a new deployment creates the
runtime login with its password set in psql, how to run migrations as a
separate step, and the steps for a deployment that has connected as one
login so far: create the runtime login, store its string as a new secret
(Docker Swarm secrets cannot be changed), point MC_DB_DSN_FILE at it and
MC_DB_MIGRATE_DSN_FILE at the existing secret, deploy, verify the
sessions, privileges, memberships, database settings and the functions
of public, re-run the reader-role script, and how to roll back. The
bootstrap superuser of the official image cannot lose SUPERUSER, so it
stays as the migration user. docs/database-management.md runs the
reader-role script as the migration user.

make lint and TestEnvironmentReferenceDocumentsEveryConfigKey pass; the
section changes no code, and the previous section's full suite ran with
these files already in the tree.
This commit is contained in:
2026-10-23 09:43:57 -04:00
parent dd4a0e2ec0
commit e722e43603
3 changed files with 156 additions and 20 deletions
+10 -7
View File
@@ -241,9 +241,12 @@ login role at all.
### Running the script
Run it as the role that owns the console's schemas, which also needs
`CREATEROLE`. With the official `postgres` image that is the `POSTGRES_USER`
the console connects as, a superuser:
Run it as the migration user, the login that owns the console's schemas and
that `db-migrate-dsn` names; the script also needs `CREATEROLE`. With the
official `postgres` image the migration user is the `POSTGRES_USER`, a
superuser. On a managed database, run it as a user that has `CREATEROLE` and
owns the console's tables. The runtime user (`db-dsn`) owns nothing and cannot
run it:
psql -d <database> -v ON_ERROR_STOP=1 -f scripts/db-reader-role.sql
@@ -279,10 +282,10 @@ reach the database port. `console_reader` adds nothing to that exposure, and
a password on it would not be asked for; the remedy is `scram-sha-256` in
that line. Distribution packages authenticate the socket by `peer`, which
admits `psql -U console_reader` only from an operating-system user of that
name or through a `pg_ident.conf` map. On a managed database the user the
console connects as may lack `CREATEROLE`, and the run fails; run it as a
user that has `CREATEROLE` and owns the console's tables, then give the role
a password and allow it in the provider's network rules.
name or through a `pg_ident.conf` map. On a managed database the
migration user may lack `CREATEROLE`, and the run fails; run it as a user
that has `CREATEROLE` and owns the console's tables, then give the role a
password and allow it in the provider's network rules.
### Keeping the list current
+143 -10
View File
@@ -119,14 +119,48 @@ sign-in page and the app introduce themselves consistently.
## 5. Database
Follow [database-management.md](database-management.md) for the schema
layout and migration conventions. There is nothing extra to run before
first boot: **migrations run automatically at startup** (`member-console
start` calls `db.ConnectAndMigrate` before serving any request —
`cmd/start.go`). A fresh database becomes fully migrated on the first
successful boot; there is no separate "run migrations" step required for a
new deployment, though `member-console migrate up|down|status` is available
for out-of-band migration control (e.g. running the pending migrations before
a rolling restart).
layout and migration conventions.
**The console connects as two users.** The runtime user (`db-dsn`) serves
every request. It is not a superuser, owns neither the database nor any
object in it, and holds no role but `console_runtime`, the group role that
carries the privileges the console's writes need. The console refuses to
start when the runtime user is a superuser, owns the database or an object, or
holds another role, because a user that owned its own tables could grant itself
what the migrations revoke. Some hosting providers grant a new login
administrative roles by default; revoke them once from the runtime login
(`REVOKE <role> FROM <runtime user>;`). The migration user (`db-migrate-dsn`)
owns the console's schemas, runs the migrations and grants `console_runtime`
to the runtime user. It may be a superuser.
**Create the two users for a new deployment.** With the official `postgres`
image, `POSTGRES_USER` is the migration user, and its connection string is
`db-migrate-dsn`. Create the runtime login by hand, as that user, and set its
password interactively so that the password reaches neither the shell's
history nor the server's log:
```sql
CREATE ROLE <runtime user> LOGIN;
\password <runtime user>
```
Set `db-dsn` (`MC_DB_DSN`, or `MC_DB_DSN_FILE`) to the runtime user's
connection string and `db-migrate-dsn` (`MC_DB_MIGRATE_DSN`, or
`MC_DB_MIGRATE_DSN_FILE`) to the migration user's. At startup, `member-console
start` migrates only on the `db-migrate-dsn` connection, grants
`console_runtime` to the runtime user there, closes that connection, checks
the runtime user, and only then serves. A fresh database becomes fully
migrated on the first successful boot. `member-console migrate up|down|status`
also uses `db-migrate-dsn`.
**Run migrations as a separate step.** To keep the migration password out of
the running process, leave `db-migrate-dsn` off the service and run
`member-console migrate up` with it set as a one-shot step before each
deploy. With `db-migrate-dsn` unset, `start` runs no migration, and it
refuses to start while the schema is behind the code. After the first run of
`migrate up`, run, as the migration user, the `GRANT console_runtime TO
<runtime user> WITH INHERIT TRUE, SET FALSE;` statement that the refusal names, because
only a boot with `db-migrate-dsn` set grants it.
**Check configuration before restarting.** `member-console config validate`
connects to the database, parses every stored override and the resolved
@@ -174,7 +208,7 @@ and the member's row would stay Pending.
**Retry a failed desired-state delivery by hand.** The relay stops
retrying a row of `core.desired_state_outbox` after five failed attempts,
marks it `failed` and logs its subject and error. No page lists these rows
yet. Connected with the application's database role, list them:
yet. Connected as the runtime user, list them:
```sql
SELECT id, subject_kind, subject_id, resource_key, attempts, last_error, created_at
@@ -192,6 +226,102 @@ SET status = 'pending', attempts = 0, claimed_until = NULL, last_error = NULL
WHERE id = '<row id>' AND status = 'failed';
```
### Move an existing deployment to the two users
A deployment that has run a release before this change connects as one
login, which is the bootstrap superuser of the official image. Postgres does
not let that user lose `SUPERUSER`, so it stays as it is and becomes the
migration user. The steps below add the runtime user and are the same for any
host; angle brackets mark values you choose.
1. Connected as the user the console connects as today, create the runtime
login, then set its password in `psql`:
```sql
CREATE ROLE <runtime user> LOGIN;
\password <runtime user>
```
`\password` prompts for the password, so it appears in neither the
shell's history nor the server's log.
2. Store the runtime user's connection string as a new secret. Docker Swarm
secrets cannot be changed once created, so the new string is a secret
with a new name. Create it from standard input, type the string, and end
with Ctrl-D:
```sh
docker secret create <new secret> -
```
3. In the service definition, point `MC_DB_DSN_FILE` at the new secret. Add
`MC_DB_MIGRATE_DSN_FILE` pointing at the existing secret, which now holds
the migration user's string. Declare the new secret on the service and at
the top level of the stack file.
4. Deploy the release. Its first boot migrates, grants `console_runtime` to
the runtime user, closes the migration connection and checks the runtime
user.
5. Verify, connected as the migration user:
- The boot log has a `runtime database user checked` line that names the
runtime user.
- The console's sessions run under the runtime user:
```sql
SELECT usename, count(*) FROM pg_stat_activity
WHERE datname = current_database() GROUP BY usename;
```
- The runtime user cannot insert into a table the console fences, so this
returns `false`:
```sql
SELECT has_table_privilege('<runtime user>', 'core.pool_provisions', 'INSERT');
```
- The runtime user holds no role but `console_runtime`, so this lists that
one row:
```sql
SELECT g.rolname FROM pg_auth_members m
JOIN pg_roles g ON g.oid = m.roleid
WHERE m.member = '<runtime user>'::regrole;
```
- Database-level settings list only what you set on purpose. A setting
attached to the database, for example by a user that owned it once,
survives a change of owner and applies to every later session. The
console's own sessions ignore a `search_path` or `role` set this way,
but another superuser session in the database does not:
```sql
SELECT setconfig FROM pg_db_role_setting
WHERE setdatabase = (SELECT oid FROM pg_database WHERE datname = current_database());
```
- The functions in `public` that no extension owns are only
`update_updated_at_column()`. A function another user created in
`public` whose argument types match a catalog function's (for example
`public.quote_ident(name)`) would capture unqualified calls in later
migrations, which run as the migration user; once its owner is
reassigned to the migration user, the console's own check no longer
reports it:
```sql
SELECT p.oid::regprocedure FROM pg_catalog.pg_proc p
WHERE p.pronamespace = 'public'::regnamespace
AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_depend d
WHERE d.objid = p.oid AND d.deptype = 'e');
```
6. Run `scripts/db-reader-role.sql` as the migration user, as after every
deploy that migrates
([database-management.md](database-management.md#running-the-script)).
7. To roll back, deploy the previous compose file and image tag. The previous
release connects as the migration user again. `console_runtime`, its
memberships, the function that takes the plan-act obligation lock and the
ownership of `core.webhook_events` stay, and the previous release does not
use them. Its migration run applies nothing, because it knows only
versions that are already applied. Partitions that the previous release
creates meanwhile belong to the migration user; after rolling forward
again, give each to `core_owner`:
```sql
ALTER TABLE core.webhook_events_<YYYY_MM> OWNER TO core_owner;
```
## 6. Integrations
Configure whichever of these your deployment actually offers. Each is
@@ -239,7 +369,10 @@ they live, what the console shows members, and what the API key can read.
With the database reachable, `base-url` set, the session store's password
configured, and the OIDC keys pointed at your realm, start the process (`member-console
start`, or your image's equivalent entrypoint). At startup it, in order:
runs pending migrations, validates the aggregated configuration (failing
opens the runtime connection; when `db-migrate-dsn` is set, opens the
migration connection, runs pending migrations, grants `console_runtime` to
the runtime user and closes the migration connection; checks the runtime user
and logs `runtime database user checked`; validates the aggregated configuration (failing
fast with every problem named at once if something required is missing —
see [environment-reference.md](environment-reference.md) for what's
required), connects to Temporal if configured, and starts serving.
@@ -206,9 +206,9 @@ This section's `make test` passes only after the documented stack respin of item
## 6. Deployment docs and the procedure for an existing deployment
- [ ] 6.1 `docs/production-deployment.md`, step 5 and step 7, and the procedure for an existing deployment, as design D17 writes them, with placeholders only, including that the runtime login owns neither the database nor any object and holds no role but `console_runtime`, and that the verification reads `pg_db_role_setting` and the functions of `public` (design D17 step 5). "Retry a failed desired-state delivery by hand" says "the runtime user".
- [ ] 6.2 `docs/database-management.md`, "Running the script" and "Logging in": the script runs as the migration user (`POSTGRES_USER` with the official image, a superuser); on a managed database, as a user with `CREATEROLE` that owns the console's tables.
- [ ] 6.3 No code changes; `make test` passes.
- [x] 6.1 `docs/production-deployment.md`, step 5 and step 7, and the procedure for an existing deployment, as design D17 writes them, with placeholders only, including that the runtime login owns neither the database nor any object and holds no role but `console_runtime`, and that the verification reads `pg_db_role_setting` and the functions of `public` (design D17 step 5). "Retry a failed desired-state delivery by hand" says "the runtime user".
- [x] 6.2 `docs/database-management.md`, "Running the script" and "Logging in": the script runs as the migration user (`POSTGRES_USER` with the official image, a superuser); on a managed database, as a user with `CREATEROLE` that owns the console's tables.
- [x] 6.3 No code changes; `make test` passes.
## 7. Landing