Grant every writer to one runtime role, and leave writers only the four writes

First section of #23. The console still connects as before; this section
puts in place the role its runtime user will hold.

Each stream's new migration grants its writer, and SELECT on its goose
ledger, to console_runtime, a NOLOGIN role core creates, and revokes the
writer from member_console where an earlier release granted it there.
The five baselines no longer grant a writer to any login, so a fresh
install needs no role of a particular name.

The baselines granted ALL on their tables to the writers, which gave the
runtime path TRUNCATE, REFERENCES, TRIGGER and MAINTAIN as well. With
TRIGGER, a user holding the writer could put a trigger on an ordinary
table; a SECURITY DEFINER function's write to that table then ran the
trigger as the function's owner, which wrote the enclosed tables
directly. Each stream's writer_dml_only migration revokes the four, and
TestRuntimeRolePrivileges fails, naming the relation and the privilege,
when console_runtime holds any of them, holds a column grant that reopens
a write, or is denied a write it needs.

The enclosure tests now drop to console_runtime instead of core_writer
or a copied role, and the conferral enclosure test probes TRUNCATE,
CREATE TRIGGER and LOCK TABLE on the three position tables.

Full suite on reset databases: 4195 pass, 15 skip; one failure,
TestIntegrationSettingsWalkthrough's missing toast (#222), passed when
run alone. make lint passes.

Deliberate breaks, scripts/mutation-check -side new, all CAUGHT (39):
  core 00029 without GRANT core_writer TO console_runtime
  fedwiki 00005 grants domains_writer in place of fedwiki_writer
  stripe 00006 without its revoke from member_console
  discourse 00004 without its ledger grant (two tests)
  domains 00003 without GRANT domains_writer TO console_runtime
  domains 00003's revoke without its guard
  core and discourse baselines with the login grant restored
  core 00029's Down revokes core_writer from console_runtime
  domains 00003's Down revokes domains_writer from console_runtime
  core 00029's Down drops the role without its dependency handler
  core 00029's Down grants core_writer back to member_console
  core 00030 emptied (catalog test, three probes)
  core 00030 leaves TRIGGER (catalog test, probe)
  core 00030 revokes from two position tables only
  the domains, fedwiki, stripe and discourse writer_dml_only emptied
  GRANT ALL on integration_config_overrides appended after the revoke
  00009's grant loses core_writer
  00027's SELECT on acts becomes SELECT, INSERT (two tests)
  00005's revoke loses INSERT; 00024's SELECT becomes SELECT, INSERT
  core 00029 also grants INSERT, or TRIGGER, on pool_provisions (four)
  column UPDATE and REFERENCES grants on pool_provisions, column
  UPDATE on the outbox's subject_id
  test side: the membership assertion without its fedwiki_writer
  exception; a locked entry for a table that does not exist
The two converted checks were not run on the old side.
This commit is contained in:
2026-10-23 05:56:52 -04:00
parent fe75c82759
commit 028e2ef448
39 changed files with 2724 additions and 130 deletions
+20 -12
View File
@@ -712,29 +712,37 @@ this section assumes.
- **Role triple, created by your own baseline** — not by core's. Your
`00001_init.sql` creates `{key}_owner`/`{key}_writer`/`{key}_reader`
(all `NOLOGIN`), grants `USAGE` on your schema to all three and `CREATE`
to `{key}_owner`, and grants table privileges (`ALL` to owner+writer,
`SELECT` to reader) — extended to cover every table you create, including
to `{key}_owner`, and grants table privileges (`ALL` to owner,
`SELECT, INSERT, UPDATE, DELETE` to writer, `SELECT` to reader) — extended to cover every table you create, including
ones added by a later migration in your own stream, not just `00001`.
- **`core_reader` grant.** Because your tables FK into `core` and your own
code paths read core tables, your baseline grants `GRANT core_reader TO
{key}_writer`. Core always runs first in `internal/migrate.Sources()`, so
`core_reader` already exists by the time your grant runs.
- **`member_console` membership, created by your baseline.** The DSN login
role (`member_console`, created by Postgres `initdb` via `POSTGRES_USER`)
must be an explicit member of your `{key}_writer` role — `GRANT
{key}_writer TO member_console` — stated in your own migration rather
than relied upon implicitly.
- **`console_runtime` membership.** A migration of your stream grants
`{key}_writer` and `SELECT` on `goose_db_version_<key>` to
`console_runtime` — `GRANT {key}_writer TO console_runtime` — never to a
login (FedWiki's is `00005_runtime_group_role.sql`). Core creates the
role first (its migration `00029`), so it exists when your grant runs.
Tables are granted to `{key}_writer` as `SELECT, INSERT, UPDATE, DELETE`
by name, never `ALL`; the existing streams' `writer_dml_only` migrations
revoke `TRUNCATE`, `REFERENCES`, `TRIGGER` and `MAINTAIN` from the tables
an earlier `GRANT ALL` covered.
- **FKs point from your schema into `core` only** — never into another
integration's schema, and never the reverse (core never FKs into an
integration schema). There are no integration-to-integration FK edges
today, which is exactly why integration migration order is
interchangeable.
- **Down migration mirrors Up in reverse**: revoke the `member_console`
membership, revoke the `core_reader` grant, revoke table/schema grants,
- **Down migration mirrors Up in reverse**: revoke the ledger grant to
`console_runtime` and leave the membership (roles are cluster-wide, so
a Down in one database must not take the privilege away from the others),
revoke the `core_reader` grant, revoke table/schema grants,
drop triggers and tables, drop the schema, and drop the three roles last
(see `internal/integrations/fedwiki/store/migrations/00001_init.sql` and
`internal/integrations/stripe/store/migrations/00001_init.sql` for the
worked pattern both streams follow).
worked pattern both streams follow, and
`internal/integrations/fedwiki/store/migrations/00005_runtime_group_role.sql`
for the `console_runtime` grant and its Down).
- **Cluster-level caveat.** Roles are global to the PostgreSQL cluster, not
schema-scoped: `CREATE ROLE {key}_owner` (etc.) errors if that role
already exists — the same behavior core's own role creation has always
@@ -787,8 +795,8 @@ the signal to design one.
**FedWiki is fully conformant**: its module, workflows, HTTP handlers,
templates, and static assets all live under `internal/integrations/fedwiki/`,
and its `00001_init.sql` creates its own role triple and `member_console`
membership.
and its migrations create its own role triple (`00001_init.sql`) and its
grants to `console_runtime` (`00005_runtime_group_role.sql`).
**Stripe is partially conformant.** Its module (`store/`), Temporal workflows
(including the outbox drainer), webhook HTTP handler, `ConfigSpec`, and DB
+18 -9
View File
@@ -142,15 +142,24 @@ own `{key}_owner` / `{key}_writer` / `{key}_reader` triple. Because
integration tables FK into `core`, each integration's baseline also grants
`core_reader` to its own `{key}_writer`; this is safe because `core` always
migrates first (see `internal/migrate.Sources()`), so `core_reader` already
exists by the time an integration's baseline runs. The `member_console` login
role (the app's DSN user) ends up a member of `core_writer` and every
`{key}_writer` — membership in `core_writer` is granted by the core
baseline, and membership in each `{key}_writer` is granted by that
integration's own baseline. Owner roles are reserved for running migrations.
Each schema's authoring stream is solely responsible for its own roles,
grants, and `member_console` membership — there is no privilege wiring
performed from outside a schema's own migration stream. The one exception is
`console_reader`, below.
exists by the time an integration's baseline runs.
The runtime user reaches the writers through `console_runtime`, a `NOLOGIN`
group role that core migration `00029` creates. Each stream's group-role
migration grants `console_runtime` that stream's `{key}_writer` and `SELECT`
on that stream's goose ledger, so the role is a member of five writers and
reads five ledgers. No baseline names a login, and each group-role migration
revokes its writer from `member_console` on a database that an earlier release
migrated. The writers hold at most `SELECT`, `INSERT`, `UPDATE` and `DELETE`
on a table, and no other privilege: each stream's `writer_dml_only`
migration revokes `TRUNCATE`, `REFERENCES`, `TRIGGER` and `MAINTAIN`, and
`TestRuntimeRolePrivileges` fails when `console_runtime` holds one of them on
any relation. The console grants `console_runtime` to the runtime user at
start. Owner roles are granted to no login. Each schema's authoring stream is
solely responsible for its own roles, grants and its group-role migration —
there is no privilege wiring performed from outside a schema's own migration
stream, apart from the console's grant of `console_runtime` to the runtime user
at start and `console_reader`, below.
## The `console_reader` role
+4 -5
View File
@@ -19,6 +19,7 @@ import (
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
)
@@ -53,13 +54,12 @@ func newActFixture(t *testing.T, q *dbtest.Q) actFixture {
func TestActRecordIsEnclosed(t *testing.T) {
q := dbtest.Tx(t, dbtest.Open(t))
tx := q.Tx()
app := applicationRole(t, tx)
f := newActFixture(t, q)
q.Exec(exclusiveRendezvous)
asRole(t, tx, app, func() {
asRole(t, tx, db.RuntimeGroupRole, func() {
for _, table := range []string{"core.acts", "core.plan_act_details"} {
q.Refuse(dbtest.SQLStatePrivilege, "", `INSERT INTO `+table+` DEFAULT VALUES`)
q.Refuse(dbtest.SQLStatePrivilege, "", `INSERT INTO `+table+` SELECT * FROM `+table+` WHERE FALSE`)
q.Refuse(dbtest.SQLStatePrivilege, "", `UPDATE `+table+` SET act_id = act_id WHERE FALSE`)
q.Refuse(dbtest.SQLStatePrivilege, "", `DELETE FROM `+table+` WHERE FALSE`)
q.Refuse(dbtest.SQLStatePrivilege, "", `TRUNCATE `+table)
@@ -196,11 +196,10 @@ func TestRecordPlanActStoresARequestIDOnlyWhenItFits(t *testing.T) {
func TestRecordPlanActIgnoresACallersTemporaryTables(t *testing.T) {
q := dbtest.Tx(t, dbtest.Open(t))
tx := q.Tx()
app := applicationRole(t, tx)
f := newActFixture(t, q)
q.Exec(exclusiveRendezvous)
asRole(t, tx, app, func() {
asRole(t, tx, db.RuntimeGroupRole, func() {
q.Exec(`CREATE TEMPORARY TABLE acts (LIKE core.acts INCLUDING DEFAULTS) ON COMMIT DROP`)
q.Exec(`CREATE TEMPORARY TABLE plan_act_details (LIKE core.plan_act_details INCLUDING DEFAULTS) ON COMMIT DROP`)
q.Exec(`GRANT ALL ON pg_temp.acts, pg_temp.plan_act_details TO PUBLIC`)
@@ -15,6 +15,7 @@ import (
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
)
@@ -24,7 +25,6 @@ func TestDesiredStateOutboxEnclosure(t *testing.T) {
database := dbtest.Open(t)
q := dbtest.Tx(t, database)
ctx, tx := context.Background(), q.Tx()
app := applicationRole(t, tx)
provider := fxProvider(t, tx)
key := fxKey(t, tx, provider, "boolean", "person")
@@ -34,7 +34,7 @@ func TestDesiredStateOutboxEnclosure(t *testing.T) {
connectionID := fxConnection(t, tx, provider, true)
takeNotices(t, ctx, tx)
asRole(t, tx, app, func() {
asRole(t, tx, db.RuntimeGroupRole, func() {
q.Refuse(dbtest.SQLStatePrivilege, "",
`INSERT INTO core.desired_state_outbox (subject_kind, subject_id) VALUES ('pool', $1)`, pool)
@@ -96,11 +96,10 @@ func TestPersonCutOffEnclosure(t *testing.T) {
database := dbtest.Open(t)
q := dbtest.Tx(t, database)
tx := q.Tx()
app := applicationRole(t, tx)
person := fxPerson(t, tx)
actor := fxPerson(t, tx)
asRole(t, tx, app, func() {
asRole(t, tx, db.RuntimeGroupRole, func() {
q.Refuse(dbtest.SQLStatePrivilege, "",
`INSERT INTO core.person_cut_offs (person_id) VALUES ($1)`, person)
var changed bool
@@ -131,13 +130,12 @@ func TestEnclosuresIgnoreACallersTemporaryTables(t *testing.T) {
database := dbtest.Open(t)
q := dbtest.Tx(t, database)
tx := q.Tx()
app := applicationRole(t, tx)
provider := fxProvider(t, tx)
key := fxKey(t, tx, provider, "boolean", "person")
person := fxPerson(t, tx)
pool := fxPool(t, tx, fxOrg(t, tx, person), "default")
asRole(t, tx, app, func() {
asRole(t, tx, db.RuntimeGroupRole, func() {
q.Exec(`CREATE TEMPORARY TABLE person_cut_offs (LIKE core.person_cut_offs) ON COMMIT DROP`)
q.Exec(`CREATE TEMPORARY TABLE desired_state_outbox (LIKE core.desired_state_outbox) ON COMMIT DROP`)
q.Exec(`SET LOCAL search_path = pg_temp, core`)
@@ -166,10 +164,9 @@ func TestRequeueIgnoresACallersTemporaryTables(t *testing.T) {
database := dbtest.Open(t)
q := dbtest.Tx(t, database)
tx := q.Tx()
app := applicationRole(t, tx)
ghost := uuid.NewString()
asRole(t, tx, app, func() {
asRole(t, tx, db.RuntimeGroupRole, func() {
q.Exec(`CREATE TEMPORARY TABLE persons (person_id uuid) ON COMMIT DROP`)
q.Exec(`INSERT INTO pg_temp.persons VALUES ($1)`, ghost)
q.Exec(`CREATE TEMPORARY TABLE desired_state_outbox (LIKE core.desired_state_outbox INCLUDING DEFAULTS) ON COMMIT DROP`)
@@ -213,7 +210,6 @@ func TestDeliveryTablePrivileges(t *testing.T) {
database := dbtest.Open(t)
q := dbtest.Tx(t, database)
tx := q.Tx()
app := applicationRole(t, tx)
provider := fxProvider(t, tx)
key := fxKey(t, tx, provider, "numeric", "organization")
connectionID := fxConnection(t, tx, provider, true)
@@ -233,8 +229,8 @@ func TestDeliveryTablePrivileges(t *testing.T) {
}
}
// The application role writes the ordinary delivery tables.
asRole(t, tx, app, func() {
// The runtime group role writes the ordinary delivery tables.
asRole(t, tx, db.RuntimeGroupRole, func() {
var requestID string
q.Scan([]any{&requestID},
`INSERT INTO core.lifecycle_requests (instance_id, generation, connection_id, resource_key, workspace_id, operation, workflow_id)
@@ -281,7 +277,6 @@ func TestPersonCutOffSetter(t *testing.T) {
database := dbtest.Open(t)
q := dbtest.Tx(t, database)
ctx, tx := context.Background(), q.Tx()
app := applicationRole(t, tx)
person := fxPerson(t, tx)
actor := fxPerson(t, tx)
lifter := fxPerson(t, tx)
@@ -293,7 +288,7 @@ func TestPersonCutOffSetter(t *testing.T) {
set := func(target string, cutOff bool, by string) bool {
t.Helper()
var changed bool
asRole(t, tx, app, func() {
asRole(t, tx, db.RuntimeGroupRole, func() {
q.Scan([]any{&changed}, `SELECT core.set_person_cut_off($1, $2, $3)`, target, cutOff, by)
})
return changed
@@ -376,7 +371,7 @@ func TestPersonCutOffSetter(t *testing.T) {
// A NULL person or cut_off is refused, never read as a lift, and the
// open cut-off stays open.
asRole(t, tx, app, func() {
asRole(t, tx, db.RuntimeGroupRole, func() {
q.Refuse(dbtest.SQLStateNullValue, "", `SELECT core.set_person_cut_off($1, NULL, NULL)`, person)
q.Refuse(dbtest.SQLStateNullValue, "", `SELECT core.set_person_cut_off(NULL, FALSE, NULL)`)
})
@@ -7,9 +7,10 @@ package db_test
// integration-delivery-core, section 2). Every fixture is written inside the
// caller's transaction, which the test rolls back, except where a test needs
// the clock to move between transactions and cleans up after itself. The
// test login is a cluster superuser; a test that needs an application role's
// privileges drops to it with SET LOCAL ROLE. Providers and keys are made up
// per test, so no fixture depends on which integrations have registered.
// test login is a cluster superuser; a test that needs the runtime user's
// privileges drops to db.RuntimeGroupRole, the group role the runtime user
// holds, with SET LOCAL ROLE. Providers and keys are made up per test, so no
// fixture depends on which integrations have registered.
import (
"context"
@@ -221,24 +222,3 @@ func asRole(t *testing.T, tx *sql.Tx, role string, fn func()) {
defer q.Exec("RESET ROLE")
fn()
}
// applicationRole creates, inside the transaction, a role holding every role
// the login role is a member of, without the login role's superuser bit,
// and returns its name: the privileges the application's login role has in
// a deployment, where it is not a superuser. CREATE ROLE rolls back with the
// transaction.
func applicationRole(t *testing.T, tx *sql.Tx) string {
t.Helper()
q := dbtest.On(t, tx)
name := dbtest.Name("delivery_app_")
q.Exec("CREATE ROLE " + name + " NOLOGIN")
roles := q.Strings(
`SELECT m.roleid::regrole::text FROM pg_auth_members m WHERE m.member = current_user::regrole`)
if !slices.Contains(roles, "core_writer") {
t.Fatalf("the login role is not a member of core_writer (memberships %v)", roles)
}
for _, r := range roles {
q.Exec("GRANT " + r + " TO " + name)
}
return name
}
+3 -9
View File
@@ -1164,19 +1164,13 @@ GRANT SELECT ON ALL TABLES IN SCHEMA core TO core_reader;
-- (`GRANT core_reader TO {slug}_writer` for fedwiki and stripe) live in
-- their own stream's 00001 (Decision 7).
-- member_console (the DSN login role, created by Postgres initdb via
-- POSTGRES_USER) previously acquired privileges only by being a cluster
-- superuser -- undocumented in-repo (design.md Decision 3 gap). State its
-- application-level privileges explicitly; owner roles stay reserved for
-- migrations. Each integration stream's own `GRANT {slug}_writer TO
-- member_console` lives in its own stream's 00001 (Decision 7).
GRANT core_writer TO member_console;
-- No login is named here. The runtime user reaches the writer roles through
-- the group role console_runtime (migrations 00029 and its siblings in the
-- other streams).
-- +goose Down
-- +goose StatementBegin
REVOKE core_writer FROM member_console;
REVOKE SELECT ON ALL TABLES IN SCHEMA core FROM core_reader;
REVOKE ALL ON ALL TABLES IN SCHEMA core FROM core_writer;
REVOKE ALL ON ALL TABLES IN SCHEMA core FROM core_owner;
@@ -0,0 +1,73 @@
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- ---------------------------------------------------------------------------
-- The runtime group role (openspec change runtime-database-user, design D5 to
-- D7). The console serves from a login that is not a superuser and owns
-- nothing. That login reaches the five writer roles through one NOLOGIN group
-- role, console_runtime, which this migration creates and which each stream's
-- own group-role migration grants its writer to. No stream names a login, so
-- the runtime user can have any name; the console grants it console_runtime at
-- start.
--
-- A release before this one granted each writer to the login named
-- member_console from the baselines. On a database that holds that
-- membership, the guarded revoke below removes it, because the group role now
-- carries the privileges and the login should hold none by name. The guard
-- tests the role as well as the membership: revoking from a role that does not
-- exist is an error, while revoking a membership that does not exist is only a
-- warning.
--
-- The ledger grant is unqualified, as goose created the table, so it resolves
-- through the migration connection's search_path. It lets the runtime user
-- read the ledger to find pending migrations.
--
-- The Down revokes only what lives in this database: the ledger grant, then
-- the role when no other database in the cluster still uses it. Role
-- memberships are cluster-wide, so the Down neither revokes core_writer from
-- console_runtime nor grants it back to member_console; either would change
-- every other database in the cluster that is still migrated.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'console_runtime') THEN
CREATE ROLE console_runtime NOLOGIN;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'member_console')
AND EXISTS (SELECT 1 FROM pg_auth_members m
JOIN pg_roles r ON r.oid = m.roleid
JOIN pg_roles u ON u.oid = m.member
WHERE r.rolname = 'core_writer' AND u.rolname = 'member_console') THEN
REVOKE core_writer FROM member_console;
END IF;
END
$$;
-- +goose StatementEnd
-- +goose StatementBegin
GRANT core_writer TO console_runtime;
-- +goose StatementEnd
-- +goose StatementBegin
GRANT SELECT ON goose_db_version_core TO console_runtime;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
REVOKE SELECT ON goose_db_version_core FROM console_runtime;
-- +goose StatementEnd
-- +goose StatementBegin
-- Another database in this cluster may still grant the role something, in
-- which case DROP ROLE raises dependent_objects_still_exist; the role stays.
DO $$
BEGIN
DROP ROLE IF EXISTS console_runtime;
EXCEPTION WHEN dependent_objects_still_exist THEN
RAISE NOTICE 'role console_runtime is still in use elsewhere in this cluster; left in place';
END
$$;
-- +goose StatementEnd
@@ -0,0 +1,30 @@
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- ---------------------------------------------------------------------------
-- The core writer keeps the four DML privileges (openspec change
-- runtime-database-user, design D13). GRANT ALL in the baseline and in later
-- migrations also gave core_writer TRUNCATE, REFERENCES, TRIGGER and MAINTAIN.
-- Each is a way around an enclosure (a trigger runs as the owner of a definer
-- function that writes the table; TRUNCATE empties a table; TRUNCATE and
-- MAINTAIN allow LOCK TABLE ... ACCESS EXCLUSIVE), and no code path uses them.
-- This migration runs after every earlier grant of the stream, so one
-- statement corrects fresh and existing databases alike; the earlier GRANT ALL
-- lines are not edited.
--
-- The Down does nothing. Re-granting the four privileges on every table would
-- also hand back TRUNCATE on tables whose enclosure had revoked it, and no
-- code path used them.
--
-- A table added later is granted SELECT, INSERT, UPDATE, DELETE by name, never
-- ALL. TestRuntimeRolePrivileges fails when the group role holds one of the
-- four on any relation.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
REVOKE TRUNCATE, REFERENCES, TRIGGER, MAINTAIN ON ALL TABLES IN SCHEMA core FROM core_writer;
-- +goose StatementEnd
-- +goose Down
-- Nothing to undo; the header comment says why.
+3 -4
View File
@@ -21,6 +21,7 @@ import (
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
)
@@ -237,14 +238,13 @@ func TestRecordPlanActRefusesARetryOfAnActThatOwesNothing(t *testing.T) {
func TestPlanActObligationTablesAreEnclosed(t *testing.T) {
q := dbtest.Tx(t, dbtest.Open(t))
tx := q.Tx()
app := applicationRole(t, tx)
f := newObligationFixture(t, q)
orgType := f.orgType("enclosed_")
org, pool := f.org(orgType, true)
act := f.defaultChange(orgType)
f.obligation(act, pool, orgType, org)
asRole(t, tx, app, func() {
asRole(t, tx, db.RuntimeGroupRole, func() {
for _, table := range []string{"core.plan_act_reached_org_types", "core.plan_act_orgs_without_pool", "core.plan_act_obligations"} {
q.Refuse(dbtest.SQLStatePrivilege, "", `INSERT INTO `+table+` DEFAULT VALUES`)
q.Refuse(dbtest.SQLStatePrivilege, "", `UPDATE `+table+` SET act_id = act_id WHERE FALSE`)
@@ -320,14 +320,13 @@ func TestPlanActWritersAreEnclosedInTheCatalog(t *testing.T) {
func TestPlanActFunctionsIgnoreACallersTemporaryTables(t *testing.T) {
q := dbtest.Tx(t, dbtest.Open(t))
tx := q.Tx()
app := applicationRole(t, tx)
f := newObligationFixture(t, q)
orgType := f.orgType("shadow_")
f.org(orgType, true)
f.org(orgType, false)
act := f.defaultChange(orgType)
asRole(t, tx, app, func() {
asRole(t, tx, db.RuntimeGroupRole, func() {
for _, table := range []string{"plan_act_obligations", "plan_act_orgs_without_pool", "plan_act_reached_org_types"} {
q.Exec(`CREATE TEMPORARY TABLE ` + table + ` (LIKE core.` + table + ` INCLUDING DEFAULTS) ON COMMIT DROP`)
q.Exec(`GRANT ALL ON pg_temp.` + table + ` TO PUBLIC`)
+231
View File
@@ -0,0 +1,231 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package db_test
// TestRuntimeRolePrivileges reads the catalog of the migrated test database
// and checks what console_runtime, the group role the runtime user holds, can
// do with every relation of the five streams' schemas (openspec change
// runtime-database-user, design D16). A migration that adds a table and
// forgets its grant, or over-grants, fails here naming the relation and the
// privilege.
import (
"slices"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/migrate"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
)
// lockedTable is a table whose enclosure withholds some of INSERT, UPDATE and
// DELETE from the writer. holds lists the ones among the three it keeps; every
// locked table is also readable.
type lockedTable struct {
name string
holds []string
}
var lockedTables = []lockedTable{
{"core.pool_provisions", nil},
{"core.pool_provision_ladders", nil},
{"core.pool_provision_transitions", nil},
{"core.entitlement_set_rules", nil},
{"core.entitlement_set_changes", nil},
{"core.entitlement_set_change_effects", nil},
{"core.entitlement_set_change_obligations", []string{"UPDATE"}},
{"core.acts", nil},
{"core.plan_act_details", nil},
{"core.plan_act_obligations", nil},
{"core.plan_act_reached_org_types", nil},
{"core.plan_act_orgs_without_pool", nil},
{"core.desired_state_outbox", []string{"DELETE"}},
{"core.person_cut_offs", nil},
}
const outboxTable = "core.desired_state_outbox"
// outboxUpdatableColumns are the columns of core.desired_state_outbox the
// writer may update; the relay claims and completes a row and never retargets
// it.
var outboxUpdatableColumns = []string{
"status", "attempts", "claimed_until", "expansion_cursor", "last_error", "completed_at",
}
// streamNames are the registered migration streams, which are also their
// schemas' names.
var streamNames = func() []string {
var names []string
for _, src := range migrate.Sources() {
names = append(names, src.Name)
}
return names
}()
type relation struct {
name string
kind string
partition bool
}
func (r relation) isTable() bool { return r.kind == "r" || r.kind == "p" }
// schemaRelations lists every table, partitioned table, view, materialized
// view and foreign table of the five schemas, partitions included.
func schemaRelations(q *dbtest.Q) []relation {
rows := q.Strings(`SELECT c.relkind::text || '|' || c.relispartition::text || '|' || n.nspname || '.' || c.relname
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = ANY ($1) AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
ORDER BY n.nspname, c.relname`, "{"+strings.Join(streamNames, ",")+"}")
out := make([]relation, 0, len(rows))
for _, row := range rows {
parts := strings.SplitN(row, "|", 3)
out = append(out, relation{name: parts[2], kind: parts[0], partition: parts[1] == "true"})
}
return out
}
func groupRoleHolds(q *dbtest.Q, relation, privilege string) bool {
return q.String(`SELECT has_table_privilege($1, $2::regclass, $3)::text`,
db.RuntimeGroupRole, relation, privilege) == "true"
}
// groupRoleHoldsAnyColumn reports whether the role holds the privilege on the
// relation or on any one of its columns, so a column grant cannot reopen a
// write.
func groupRoleHoldsAnyColumn(q *dbtest.Q, relation, privilege string) bool {
return q.String(`SELECT has_any_column_privilege($1, $2::regclass, $3)::text`,
db.RuntimeGroupRole, relation, privilege) == "true"
}
func lockedEntry(name string) (lockedTable, bool) {
for _, l := range lockedTables {
if l.name == name {
return l, true
}
}
return lockedTable{}, false
}
func checkReadable(q *dbtest.Q, t *testing.T, rels []relation) {
t.Helper()
for _, r := range rels {
if !r.partition && !groupRoleHolds(q, r.name, "SELECT") {
t.Errorf("%s cannot SELECT from %s", db.RuntimeGroupRole, r.name)
}
}
}
func checkWritePrivileges(q *dbtest.Q, t *testing.T, rels []relation) {
t.Helper()
for _, r := range rels {
if r.partition || !r.isTable() {
continue
}
locked, isLocked := lockedEntry(r.name)
for _, privilege := range []string{"INSERT", "UPDATE", "DELETE"} {
want := !isLocked || slices.Contains(locked.holds, privilege)
got := groupRoleHolds(q, r.name, privilege)
// A column grant also reopens a write; the outbox's updatable
// columns are checked one by one in checkOutboxColumns.
if !want && !got && privilege != "DELETE" && (r.name != outboxTable || privilege != "UPDATE") {
got = groupRoleHoldsAnyColumn(q, r.name, privilege)
}
if got != want {
t.Errorf("%s holds %s on %s = %v, want %v", db.RuntimeGroupRole, privilege, r.name, got, want)
}
}
}
}
// checkLockedEntriesExist fails for a locked entry that names no relation, so
// the list cannot outlive a table.
func checkLockedEntriesExist(t *testing.T, rels []relation) {
t.Helper()
for _, l := range lockedTables {
found := slices.ContainsFunc(rels, func(r relation) bool { return r.name == l.name })
if !found {
t.Errorf("the locked list names %s, which is no relation of the five schemas", l.name)
}
}
}
func checkOutboxColumns(q *dbtest.Q, t *testing.T) {
t.Helper()
columns := q.Strings(`SELECT attname::text FROM pg_attribute
WHERE attrelid = 'core.desired_state_outbox'::regclass AND attnum > 0 AND NOT attisdropped
ORDER BY attnum`)
for _, column := range columns {
got := q.String(`SELECT has_column_privilege($1, 'core.desired_state_outbox'::regclass, $2, 'UPDATE')::text`,
db.RuntimeGroupRole, column) == "true"
if want := slices.Contains(outboxUpdatableColumns, column); got != want {
t.Errorf("%s holds UPDATE on core.desired_state_outbox.%s = %v, want %v", db.RuntimeGroupRole, column, got, want)
}
}
}
// checkNoExtraPrivileges covers every relation of every kind, partitions and
// views included: a later GRANT ALL fails here naming the table and TRIGGER.
func checkNoExtraPrivileges(q *dbtest.Q, t *testing.T, rels []relation) {
t.Helper()
for _, r := range rels {
for _, privilege := range []string{"TRUNCATE", "REFERENCES", "TRIGGER", "MAINTAIN"} {
held := groupRoleHolds(q, r.name, privilege)
if !held && privilege == "REFERENCES" {
held = groupRoleHoldsAnyColumn(q, r.name, privilege)
}
if held {
t.Errorf("%s holds %s on %s, want none", db.RuntimeGroupRole, privilege, r.name)
}
}
}
}
func checkLedgersReadable(q *dbtest.Q, t *testing.T) {
t.Helper()
for _, stream := range streamNames {
if !groupRoleHolds(q, ledgerOf(stream), "SELECT") {
t.Errorf("%s cannot SELECT from the ledger %s", db.RuntimeGroupRole, ledgerOf(stream))
}
}
}
// checkNoLoginReachesAWriter asserts that no role but the group role is a
// direct member of a writer role, apart from the edge the FedWiki stream
// declares between its own role and domains_writer.
func checkNoLoginReachesAWriter(q *dbtest.Q, t *testing.T) {
t.Helper()
writers := make([]string, 0, len(streamNames))
for _, stream := range streamNames {
writers = append(writers, writerOf(stream))
}
edges := q.Strings(`SELECT u.rolname::text || ' > ' || r.rolname::text
FROM pg_auth_members m
JOIN pg_roles r ON r.oid = m.roleid
JOIN pg_roles u ON u.oid = m.member
WHERE r.rolname = ANY ($1) AND u.rolname <> $2
ORDER BY 1`, "{"+strings.Join(writers, ",")+"}", db.RuntimeGroupRole)
for _, edge := range edges {
if edge == "fedwiki_writer > domains_writer" {
continue
}
t.Errorf("%s: a role other than %s is a direct member of a writer role", edge, db.RuntimeGroupRole)
}
}
func TestRuntimeRolePrivileges(t *testing.T) {
q := dbtest.On(t, dbtest.Open(t))
rels := schemaRelations(q)
if len(rels) == 0 {
t.Fatal("the five schemas hold no relation")
}
checkReadable(q, t, rels)
checkWritePrivileges(q, t, rels)
checkLockedEntriesExist(t, rels)
checkOutboxColumns(q, t)
checkNoExtraPrivileges(q, t, rels)
checkLedgersReadable(q, t)
checkNoLoginReachesAWriter(q, t)
}
+202
View File
@@ -0,0 +1,202 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package db_test
// Tests of the runtime group role's migrations (openspec change
// runtime-database-user, design D5 to D7). Role memberships are cluster-wide,
// so a test that changes one either does it inside a transaction it rolls
// back or restores the cluster in its cleanup.
import (
"fmt"
"slices"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/migrate"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
)
// groupRoleMigrations names the group-role migration of each stream, core
// first, which is the order a real run applies them in.
var groupRoleMigrations = []struct {
stream string
file string
version int64
}{
{"core", "00029_runtime_group_role.sql", 29},
{"domains", "00003_runtime_group_role.sql", 3},
{"fedwiki", "00005_runtime_group_role.sql", 5},
{"stripe", "00006_runtime_group_role.sql", 6},
{"discourse", "00004_runtime_group_role.sql", 4},
}
func sourceNamed(t *testing.T, stream string) db.MigrationSource {
t.Helper()
for _, src := range migrate.Sources() {
if src.Name == stream {
return src
}
}
t.Fatalf("no migration stream %q", stream)
return db.MigrationSource{}
}
// roleExists reports whether the cluster has the role.
func roleExists(q *dbtest.Q, role string) bool {
return q.Int(`SELECT count(*) FROM pg_roles WHERE rolname = $1`, role) == 1
}
// directMemberships lists the writer roles that member holds directly.
func directMemberships(q *dbtest.Q, member string) []string {
return q.Strings(`SELECT r.rolname FROM pg_auth_members m
JOIN pg_roles r ON r.oid = m.roleid
JOIN pg_roles u ON u.oid = m.member
WHERE u.rolname = $1 AND r.rolname LIKE '%\_writer' ORDER BY r.rolname`, member)
}
// holdsDirectly reports whether member is a direct, inheriting member of role.
// pg_has_role is not enough: console_runtime also reaches domains_writer
// through fedwiki_writer, so a lost direct grant would still read as held.
func holdsDirectly(q *dbtest.Q, member, role string) bool {
return q.Int(`SELECT count(*) FROM pg_auth_members m
JOIN pg_roles r ON r.oid = m.roleid
JOIN pg_roles u ON u.oid = m.member
WHERE u.rolname = $1 AND r.rolname = $2 AND m.inherit_option`, member, role) == 1
}
func writerOf(stream string) string { return stream + "_writer" }
func ledgerOf(stream string) string { return "public.goose_db_version_" + stream }
// simulateEarlierRelease takes the group role's grants away and gives each
// writer to member_console, which is a database an earlier release migrated.
func simulateEarlierRelease(q *dbtest.Q) {
for _, m := range groupRoleMigrations {
q.Exec(fmt.Sprintf("REVOKE %s FROM %s", writerOf(m.stream), db.RuntimeGroupRole))
q.Exec(fmt.Sprintf("REVOKE SELECT ON %s FROM %s", ledgerOf(m.stream), db.RuntimeGroupRole))
}
if roleExists(q, "member_console") {
for _, m := range groupRoleMigrations {
q.Exec(fmt.Sprintf("GRANT %s TO member_console", writerOf(m.stream)))
}
}
}
func assertGroupRoleHolds(q *dbtest.Q, t *testing.T) {
t.Helper()
for _, m := range groupRoleMigrations {
if !holdsDirectly(q, db.RuntimeGroupRole, writerOf(m.stream)) {
t.Errorf("%s is not a direct, inheriting member of %s after %s", db.RuntimeGroupRole, writerOf(m.stream), m.file)
}
reads := q.String(`SELECT has_table_privilege($1, $2, 'SELECT')::text`, db.RuntimeGroupRole, ledgerOf(m.stream))
if reads != "true" {
t.Errorf("%s cannot read %s after %s", db.RuntimeGroupRole, ledgerOf(m.stream), m.file)
}
}
if roleExists(q, "member_console") {
if held := directMemberships(q, "member_console"); len(held) != 0 {
t.Errorf("member_console is a direct member of %v, want none", held)
}
}
}
func TestStreamsGrantTheirWriterToTheRuntimeRole(t *testing.T) {
conn := dbtest.Connect(t, dbtest.SimpleProtocol(dbtest.DSN(t)))
t.Run("a database an earlier release migrated", func(t *testing.T) {
q := dbtest.Tx(t, conn)
simulateEarlierRelease(q)
for _, m := range groupRoleMigrations {
q.Exec(dbtest.UpSQL(t, sourceNamed(t, m.stream), m.file))
}
assertGroupRoleHolds(q, t)
})
// Each Up section with member_console replaced by a name no role has. On
// the test stack member_console always exists, so no other row notices a
// missing guard.
t.Run("a login no role has", func(t *testing.T) {
q := dbtest.Tx(t, conn)
for _, m := range groupRoleMigrations {
absent := dbtest.Name("mc_absent_")
up := strings.ReplaceAll(dbtest.UpSQL(t, sourceNamed(t, m.stream), m.file), "member_console", absent)
if err := q.Attempt(up); err != nil {
t.Errorf("%s with no role named %s: %v", m.file, absent, err)
}
}
})
}
// revokeWritersFromLogin removes every writer from member_console, when that
// role exists. The membership is cluster-wide.
func revokeWritersFromLogin(q *dbtest.Q) {
if !roleExists(q, "member_console") {
return
}
for _, m := range groupRoleMigrations {
q.Exec(fmt.Sprintf("REVOKE %s FROM member_console", writerOf(m.stream)))
}
}
// restoreRuntimeRole puts the cluster back where the migrations leave it:
// every writer granted to the group role and none to member_console.
func restoreRuntimeRole(q *dbtest.Q) {
for _, m := range groupRoleMigrations {
q.Exec(fmt.Sprintf("GRANT %s TO %s", writerOf(m.stream), db.RuntimeGroupRole))
}
revokeWritersFromLogin(q)
}
// TestBaselinesGrantNoLogin migrates core to the version before 00029, then
// version 1 of every other stream, and asserts the baselines grant no writer
// to member_console. Core goes first and goes that far because a real run
// migrates core to its end before any other stream, and domains' baseline
// inserts a kind column that core 00008 adds; the run stops before the
// group-role migrations because they would revoke a restored grant and hide it.
func TestBaselinesGrantNoLogin(t *testing.T) {
admin := dbtest.Open(t)
revoke := dbtest.On(t, admin)
revokeWritersFromLogin(revoke)
t.Cleanup(func() { revokeWritersFromLogin(revoke) })
scratch := dbtest.Connect(t, dbtest.Scratch(t))
dbtest.UpTo(t, scratch, sourceNamed(t, "core"), groupRoleMigrations[0].version-1)
for _, m := range groupRoleMigrations[1:] {
dbtest.UpTo(t, scratch, sourceNamed(t, m.stream), 1)
}
if held := directMemberships(revoke, "member_console"); len(held) != 0 {
t.Errorf("after the baselines member_console is a direct member of %v, want none", held)
}
}
// TestRuntimeRoleOutlivesARollbackElsewhere rolls back every group-role
// migration on a scratch database and asserts the cluster-wide role and its
// memberships stay, because the shared test database still grants the role
// its ledgers.
func TestRuntimeRoleOutlivesARollbackElsewhere(t *testing.T) {
admin := dbtest.Open(t)
q := dbtest.On(t, admin)
t.Cleanup(func() { restoreRuntimeRole(q) })
scratch := dbtest.Connect(t, dbtest.Scratch(t))
if err := db.RunMigrations(scratch, migrate.Sources()); err != nil {
t.Fatalf("migrate the scratch database: %v", err)
}
rollback := slices.Clone(groupRoleMigrations)
slices.Reverse(rollback)
for _, m := range rollback {
dbtest.ApplyVersion(t, scratch, sourceNamed(t, m.stream), m.version, false)
}
if !roleExists(q, db.RuntimeGroupRole) {
t.Fatalf("%s was dropped by a rollback on a scratch database", db.RuntimeGroupRole)
}
for _, m := range groupRoleMigrations {
if !holdsDirectly(q, db.RuntimeGroupRole, writerOf(m.stream)) {
t.Errorf("%s is no longer a direct, inheriting member of %s after the rollback", db.RuntimeGroupRole, writerOf(m.stream))
}
}
if held := directMemberships(q, "member_console"); len(held) != 0 {
t.Errorf("member_console is a direct member of %v after the rollback, want none", held)
}
}
+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package db
// RuntimeGroupRole is the NOLOGIN group role the console's runtime user
// holds. Core migration 00029 creates it and grants it core_writer and SELECT
// on the core goose ledger; the group-role migration of each other stream
// (domains 00003, FedWiki 00005, Stripe 00006, Discourse 00004) grants it that
// stream's writer and ledger. No migration grants it to a login: the console
// does that at start.
const RuntimeGroupRole = "console_runtime"
@@ -195,11 +195,6 @@ GRANT SELECT ON ALL TABLES IN SCHEMA domains TO domains_reader;
-- (Decision 7).
GRANT core_reader TO domains_writer;
-- member_console is the DSN login role; state its application-level
-- privilege explicitly. Owner roles stay reserved for migrations
-- (Decision 7).
GRANT domains_writer TO member_console;
-- The external-domain gate's resource key. Platform-owned: `provider` stays
-- NULL and the name is deliberately NOT `domains_`-prefixed — platform keys
-- are unprefixed by the provider-registry contract, and a leading `domains_`
@@ -223,7 +218,6 @@ ON CONFLICT (resource_key) DO NOTHING;
-- rather than silently orphaning operator-authored rules.
DELETE FROM core.resource_keys WHERE resource_key = 'external_domain_claims';
REVOKE domains_writer FROM member_console;
REVOKE core_reader FROM domains_writer;
REVOKE ALL ON ALL TABLES IN SCHEMA domains FROM domains_owner;
@@ -0,0 +1,49 @@
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- ---------------------------------------------------------------------------
-- The domains stream's grants to the runtime group role (openspec change
-- runtime-database-user, design D5 to D7). Core's 00029 creates
-- console_runtime, and core always migrates first, so the role exists here.
-- This migration grants it domains_writer and SELECT on this stream's goose
-- ledger. The ledger name is unqualified, as goose created it, so it resolves
-- through the migration connection's search_path.
--
-- The baseline used to grant domains_writer to the login named
-- member_console. The guarded revoke removes that membership from a database
-- an earlier release migrated. The guard tests the role as well as the
-- membership, because revoking from a role that does not exist is an error.
--
-- The Down revokes only the ledger grant. Memberships are cluster-wide, so it
-- leaves domains_writer with console_runtime and does not grant it back to
-- member_console; either would change every other database in the cluster that
-- is still migrated.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'member_console')
AND EXISTS (SELECT 1 FROM pg_auth_members m
JOIN pg_roles r ON r.oid = m.roleid
JOIN pg_roles u ON u.oid = m.member
WHERE r.rolname = 'domains_writer' AND u.rolname = 'member_console') THEN
REVOKE domains_writer FROM member_console;
END IF;
END
$$;
-- +goose StatementEnd
-- +goose StatementBegin
GRANT domains_writer TO console_runtime;
-- +goose StatementEnd
-- +goose StatementBegin
GRANT SELECT ON goose_db_version_domains TO console_runtime;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
REVOKE SELECT ON goose_db_version_domains FROM console_runtime;
-- +goose StatementEnd
@@ -0,0 +1,31 @@
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- ---------------------------------------------------------------------------
-- The domains writer keeps the four DML privileges (openspec change
-- runtime-database-user, design D13). GRANT ALL in the baseline and in later
-- migrations also gave domains_writer TRUNCATE, REFERENCES, TRIGGER and
-- MAINTAIN.
-- Each is a way around an enclosure (a trigger runs as the owner of a definer
-- function that writes the table; TRUNCATE empties a table; TRUNCATE and
-- MAINTAIN allow LOCK TABLE ... ACCESS EXCLUSIVE), and no code path uses them.
-- This migration runs after every earlier grant of the stream, so one
-- statement corrects fresh and existing databases alike; the earlier GRANT ALL
-- lines are not edited.
--
-- The Down does nothing. Re-granting the four privileges on every table would
-- also hand back TRUNCATE on tables whose enclosure had revoked it, and no
-- code path used them.
--
-- A table added later is granted SELECT, INSERT, UPDATE, DELETE by name, never
-- ALL. TestRuntimeRolePrivileges fails when the group role holds one of the
-- four on any relation.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
REVOKE TRUNCATE, REFERENCES, TRIGGER, MAINTAIN ON ALL TABLES IN SCHEMA domains FROM domains_writer;
-- +goose StatementEnd
-- +goose Down
-- Nothing to undo; the header comment says why.
@@ -3,19 +3,24 @@
package entitlements_test
// The Doc 41 enclosure (design D1, tasks 6.3): core_writer — the application
// role — must have no direct DML on the three position tables and EXECUTE on
// exactly the five conferral functions. The whole test runs as core_writer
// via SET LOCAL ROLE inside one rolled-back transaction; expected privilege
// failures are probed inside savepoints so they don't abort it.
// The Doc 41 enclosure (design D1, tasks 6.3): the runtime user must have no
// direct write on the three position tables (INSERT, UPDATE, DELETE, TRUNCATE,
// TRIGGER or MAINTAIN) and EXECUTE on exactly the five conferral functions.
// The test login is a superuser, which no revoke binds, so the whole test runs
// as console_runtime, the group role the runtime user holds, via SET LOCAL
// ROLE inside one rolled-back transaction; expected privilege failures are
// probed inside savepoints so they don't abort it.
import (
"database/sql"
"strings"
"errors"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
)
func TestConferralEnclosure_WriterRole(t *testing.T) {
@@ -32,19 +37,27 @@ func TestConferralEnclosure_WriterRole(t *testing.T) {
addon := createTestProduct(t, ctx, tx, "encl-addon", 2, false)
subID := proofSubscription(t, ctx, tx, to.org.OrgID)
// Everything below runs with the application role's privileges. SET
// LOCAL scopes the role to this transaction.
if _, err := tx.ExecContext(ctx, "SET LOCAL ROLE core_writer"); err != nil {
t.Fatalf("SET LOCAL ROLE core_writer: %v", err)
// Everything below runs with the runtime user's privileges. SET LOCAL
// scopes the role to this transaction.
if _, err := tx.ExecContext(ctx, "SET LOCAL ROLE "+db.RuntimeGroupRole); err != nil {
t.Fatalf("SET LOCAL ROLE %s: %v", db.RuntimeGroupRole, err)
}
// The trigger function the CREATE TRIGGER probes name, made first so that
// a refusal cannot be a missing function.
if _, err := tx.ExecContext(ctx, `CREATE FUNCTION pg_temp.enclosure_probe() RETURNS trigger
LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$`); err != nil {
t.Fatalf("create the probe trigger function: %v", err)
}
// --- Direct DML on each position table must fail with
// insufficient_privilege (SQLSTATE 42501). ---
denied := []struct {
type denial struct {
name string
stmt string
args []any
}{
}
denied := []denial{
{"INSERT pool_provisions",
`INSERT INTO core.pool_provisions (pool_id, grant_id, product_id, entitlement_set_id, quantity, status)
VALUES ($1, $2, $3, $4, 1, 'active')`,
@@ -78,6 +91,15 @@ func TestConferralEnclosure_WriterRole(t *testing.T) {
`DELETE FROM core.pool_provision_transitions WHERE provision_id = $1`,
[]any{planProvision}},
}
for _, table := range []string{"pool_provisions", "pool_provision_ladders", "pool_provision_transitions"} {
denied = append(denied,
denial{"TRUNCATE " + table, `TRUNCATE core.` + table, nil},
denial{"CREATE TRIGGER on " + table,
`CREATE TRIGGER enclosure_probe BEFORE INSERT ON core.` + table +
` FOR EACH ROW EXECUTE FUNCTION pg_temp.enclosure_probe()`, nil},
denial{"LOCK TABLE " + table, `LOCK TABLE core.` + table + ` IN ACCESS EXCLUSIVE MODE`, nil},
)
}
for _, d := range denied {
proofSavepoint(t, ctx, tx, func() {
_, err := tx.ExecContext(ctx, d.stmt, d.args...)
@@ -85,13 +107,14 @@ func TestConferralEnclosure_WriterRole(t *testing.T) {
t.Errorf("%s: expected insufficient_privilege, got nil", d.name)
return
}
if !strings.Contains(err.Error(), "42501") && !strings.Contains(err.Error(), "permission denied") {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != dbtest.SQLStatePrivilege {
t.Errorf("%s: expected permission denied (42501), got: %v", d.name, err)
}
})
}
// --- EXECUTE on each of the five functions succeeds as core_writer. ---
// --- EXECUTE on each of the five functions succeeds as console_runtime. ---
// confer: a genuine conferral of the off-ladder addon (grants stay
// writer-writable — the decree is application bookkeeping).
+10 -12
View File
@@ -12,11 +12,11 @@ import (
"context"
"database/sql"
"encoding/json"
"strings"
"testing"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
)
@@ -285,9 +285,9 @@ func enclosureResettle(t *testing.T, fx enclosureFixture, added entitlements.Com
// TestRuleTableDMLRevoked pins the enclosure itself: the application role has
// no INSERT on core.entitlement_set_rules, so no code path can write a rule
// without its act row. SET LOCAL ROLE drops to core_writer because the test
// stack's login role is a cluster superuser, which bypasses every privilege
// check (migration 00001 grants core_writer to member_console).
// without its act row. SET LOCAL ROLE drops to console_runtime, the group role
// the runtime user holds, because the test login is a cluster superuser, which
// bypasses every privilege check.
func TestRuleTableDMLRevoked(t *testing.T) {
database := dbtest.Open(t)
ctx := context.Background()
@@ -304,18 +304,16 @@ func TestRuleTableDMLRevoked(t *testing.T) {
t.Fatal(err)
}
defer tx.Rollback()
// The test login is a superuser, which no revoke binds; the app
// role is what the enclosure guards.
if _, err := tx.ExecContext(ctx, "SET LOCAL ROLE core_writer"); err != nil {
t.Fatalf("set role core_writer: %v", err)
// The test login is a superuser, which no revoke binds; the group
// role is what the runtime user holds and the enclosure guards.
if _, err := tx.ExecContext(ctx, "SET LOCAL ROLE "+db.RuntimeGroupRole); err != nil {
t.Fatalf("set role %s: %v", db.RuntimeGroupRole, err)
}
_, err = tx.ExecContext(ctx, statement)
if err == nil {
t.Fatalf("core_writer ran a direct %s on rules, want a permission error", name)
}
if !strings.Contains(strings.ToLower(err.Error()), "permission denied") {
t.Fatalf("%s error = %v, want permission denied", name, err)
t.Fatalf("%s ran a direct %s on rules, want a permission error", db.RuntimeGroupRole, name)
}
dbtest.WantPgError(t, err, dbtest.SQLStatePrivilege, "")
})
}
}
@@ -117,16 +117,11 @@ GRANT SELECT ON ALL TABLES IN SCHEMA discourse TO discourse_reader;
-- entitlements) to compute desired group membership.
GRANT core_reader TO discourse_writer;
-- member_console (the DSN login role) acquires this stream's write
-- privileges explicitly, stated in this stream's own migration.
GRANT discourse_writer TO member_console;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
REVOKE discourse_writer FROM member_console;
REVOKE core_reader FROM discourse_writer;
REVOKE ALL ON ALL TABLES IN SCHEMA discourse FROM discourse_owner;
@@ -0,0 +1,49 @@
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- ---------------------------------------------------------------------------
-- The discourse stream's grants to the runtime group role (openspec change
-- runtime-database-user, design D5 to D7). Core's 00029 creates
-- console_runtime, and core always migrates first, so the role exists here.
-- This migration grants it discourse_writer and SELECT on this stream's goose
-- ledger. The ledger name is unqualified, as goose created it, so it resolves
-- through the migration connection's search_path.
--
-- The baseline used to grant discourse_writer to the login named
-- member_console. The guarded revoke removes that membership from a database
-- an earlier release migrated. The guard tests the role as well as the
-- membership, because revoking from a role that does not exist is an error.
--
-- The Down revokes only the ledger grant. Memberships are cluster-wide, so it
-- leaves discourse_writer with console_runtime and does not grant it back to
-- member_console; either would change every other database in the cluster that
-- is still migrated.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'member_console')
AND EXISTS (SELECT 1 FROM pg_auth_members m
JOIN pg_roles r ON r.oid = m.roleid
JOIN pg_roles u ON u.oid = m.member
WHERE r.rolname = 'discourse_writer' AND u.rolname = 'member_console') THEN
REVOKE discourse_writer FROM member_console;
END IF;
END
$$;
-- +goose StatementEnd
-- +goose StatementBegin
GRANT discourse_writer TO console_runtime;
-- +goose StatementEnd
-- +goose StatementBegin
GRANT SELECT ON goose_db_version_discourse TO console_runtime;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
REVOKE SELECT ON goose_db_version_discourse FROM console_runtime;
-- +goose StatementEnd
@@ -0,0 +1,31 @@
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- ---------------------------------------------------------------------------
-- The discourse writer keeps the four DML privileges (openspec change
-- runtime-database-user, design D13). GRANT ALL in the baseline and in later
-- migrations also gave discourse_writer TRUNCATE, REFERENCES, TRIGGER and
-- MAINTAIN.
-- Each is a way around an enclosure (a trigger runs as the owner of a definer
-- function that writes the table; TRUNCATE empties a table; TRUNCATE and
-- MAINTAIN allow LOCK TABLE ... ACCESS EXCLUSIVE), and no code path uses them.
-- This migration runs after every earlier grant of the stream, so one
-- statement corrects fresh and existing databases alike; the earlier GRANT ALL
-- lines are not edited.
--
-- The Down does nothing. Re-granting the four privileges on every table would
-- also hand back TRUNCATE on tables whose enclosure had revoked it, and no
-- code path used them.
--
-- A table added later is granted SELECT, INSERT, UPDATE, DELETE by name, never
-- ALL. TestRuntimeRolePrivileges fails when the group role holds one of the
-- four on any relation.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
REVOKE TRUNCATE, REFERENCES, TRIGGER, MAINTAIN ON ALL TABLES IN SCHEMA discourse FROM discourse_writer;
-- +goose StatementEnd
-- +goose Down
-- Nothing to undo; the header comment says why.
@@ -92,19 +92,11 @@ GRANT SELECT ON ALL TABLES IN SCHEMA fedwiki TO fedwiki_reader;
-- Decision 7).
GRANT core_reader TO fedwiki_writer;
-- member_console (the DSN login role, created by Postgres initdb via
-- POSTGRES_USER) previously acquired privileges only by being a cluster
-- superuser -- undocumented in-repo (design.md Decision 3 gap). State its
-- application-level privilege explicitly (moved from the core baseline --
-- Decision 7).
GRANT fedwiki_writer TO member_console;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
REVOKE fedwiki_writer FROM member_console;
REVOKE core_reader FROM fedwiki_writer;
REVOKE ALL ON ALL TABLES IN SCHEMA fedwiki FROM fedwiki_owner;
@@ -0,0 +1,49 @@
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- ---------------------------------------------------------------------------
-- The fedwiki stream's grants to the runtime group role (openspec change
-- runtime-database-user, design D5 to D7). Core's 00029 creates
-- console_runtime, and core always migrates first, so the role exists here.
-- This migration grants it fedwiki_writer and SELECT on this stream's goose
-- ledger. The ledger name is unqualified, as goose created it, so it resolves
-- through the migration connection's search_path.
--
-- The baseline used to grant fedwiki_writer to the login named
-- member_console. The guarded revoke removes that membership from a database
-- an earlier release migrated. The guard tests the role as well as the
-- membership, because revoking from a role that does not exist is an error.
--
-- The Down revokes only the ledger grant. Memberships are cluster-wide, so it
-- leaves fedwiki_writer with console_runtime and does not grant it back to
-- member_console; either would change every other database in the cluster that
-- is still migrated.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'member_console')
AND EXISTS (SELECT 1 FROM pg_auth_members m
JOIN pg_roles r ON r.oid = m.roleid
JOIN pg_roles u ON u.oid = m.member
WHERE r.rolname = 'fedwiki_writer' AND u.rolname = 'member_console') THEN
REVOKE fedwiki_writer FROM member_console;
END IF;
END
$$;
-- +goose StatementEnd
-- +goose StatementBegin
GRANT fedwiki_writer TO console_runtime;
-- +goose StatementEnd
-- +goose StatementBegin
GRANT SELECT ON goose_db_version_fedwiki TO console_runtime;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
REVOKE SELECT ON goose_db_version_fedwiki FROM console_runtime;
-- +goose StatementEnd
@@ -0,0 +1,31 @@
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- ---------------------------------------------------------------------------
-- The fedwiki writer keeps the four DML privileges (openspec change
-- runtime-database-user, design D13). GRANT ALL in the baseline and in later
-- migrations also gave fedwiki_writer TRUNCATE, REFERENCES, TRIGGER and
-- MAINTAIN.
-- Each is a way around an enclosure (a trigger runs as the owner of a definer
-- function that writes the table; TRUNCATE empties a table; TRUNCATE and
-- MAINTAIN allow LOCK TABLE ... ACCESS EXCLUSIVE), and no code path uses them.
-- This migration runs after every earlier grant of the stream, so one
-- statement corrects fresh and existing databases alike; the earlier GRANT ALL
-- lines are not edited.
--
-- The Down does nothing. Re-granting the four privileges on every table would
-- also hand back TRUNCATE on tables whose enclosure had revoked it, and no
-- code path used them.
--
-- A table added later is granted SELECT, INSERT, UPDATE, DELETE by name, never
-- ALL. TestRuntimeRolePrivileges fails when the group role holds one of the
-- four on any relation.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
REVOKE TRUNCATE, REFERENCES, TRIGGER, MAINTAIN ON ALL TABLES IN SCHEMA fedwiki FROM fedwiki_writer;
-- +goose StatementEnd
-- +goose Down
-- Nothing to undo; the header comment says why.
@@ -157,19 +157,11 @@ GRANT SELECT ON ALL TABLES IN SCHEMA stripe TO stripe_reader;
-- reader-inheritance pattern (moved from the core baseline -- Decision 7).
GRANT core_reader TO stripe_writer;
-- member_console (the DSN login role, created by Postgres initdb via
-- POSTGRES_USER) previously acquired privileges only by being a cluster
-- superuser -- undocumented in-repo (design.md Decision 3 gap). State its
-- application-level privilege explicitly (moved from the core baseline --
-- Decision 7).
GRANT stripe_writer TO member_console;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
REVOKE stripe_writer FROM member_console;
REVOKE core_reader FROM stripe_writer;
DROP TRIGGER IF EXISTS trigger_payment_method_mappings_updated_at ON stripe.payment_method_mappings;
@@ -0,0 +1,49 @@
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- ---------------------------------------------------------------------------
-- The stripe stream's grants to the runtime group role (openspec change
-- runtime-database-user, design D5 to D7). Core's 00029 creates
-- console_runtime, and core always migrates first, so the role exists here.
-- This migration grants it stripe_writer and SELECT on this stream's goose
-- ledger. The ledger name is unqualified, as goose created it, so it resolves
-- through the migration connection's search_path.
--
-- The baseline used to grant stripe_writer to the login named
-- member_console. The guarded revoke removes that membership from a database
-- an earlier release migrated. The guard tests the role as well as the
-- membership, because revoking from a role that does not exist is an error.
--
-- The Down revokes only the ledger grant. Memberships are cluster-wide, so it
-- leaves stripe_writer with console_runtime and does not grant it back to
-- member_console; either would change every other database in the cluster that
-- is still migrated.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'member_console')
AND EXISTS (SELECT 1 FROM pg_auth_members m
JOIN pg_roles r ON r.oid = m.roleid
JOIN pg_roles u ON u.oid = m.member
WHERE r.rolname = 'stripe_writer' AND u.rolname = 'member_console') THEN
REVOKE stripe_writer FROM member_console;
END IF;
END
$$;
-- +goose StatementEnd
-- +goose StatementBegin
GRANT stripe_writer TO console_runtime;
-- +goose StatementEnd
-- +goose StatementBegin
GRANT SELECT ON goose_db_version_stripe TO console_runtime;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
REVOKE SELECT ON goose_db_version_stripe FROM console_runtime;
-- +goose StatementEnd
@@ -0,0 +1,31 @@
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- ---------------------------------------------------------------------------
-- The stripe writer keeps the four DML privileges (openspec change
-- runtime-database-user, design D13). GRANT ALL in the baseline and in later
-- migrations also gave stripe_writer TRUNCATE, REFERENCES, TRIGGER and
-- MAINTAIN.
-- Each is a way around an enclosure (a trigger runs as the owner of a definer
-- function that writes the table; TRUNCATE empties a table; TRUNCATE and
-- MAINTAIN allow LOCK TABLE ... ACCESS EXCLUSIVE), and no code path uses them.
-- This migration runs after every earlier grant of the stream, so one
-- statement corrects fresh and existing databases alike; the earlier GRANT ALL
-- lines are not edited.
--
-- The Down does nothing. Re-granting the four privileges on every table would
-- also hand back TRUNCATE on tables whose enclosure had revoked it, and no
-- code path used them.
--
-- A table added later is granted SELECT, INSERT, UPDATE, DELETE by name, never
-- ALL. TestRuntimeRolePrivileges fails when the group role holds one of the
-- four on any relation.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
REVOKE TRUNCATE, REFERENCES, TRIGGER, MAINTAIN ON ALL TABLES IN SCHEMA stripe FROM stripe_writer;
-- +goose StatementEnd
-- +goose Down
-- Nothing to undo; the header comment says why.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-23
@@ -0,0 +1,696 @@
## Context
Line numbers below are on the commit this change starts from (`fe75c827`). "The five streams" are the migration sources `internal/migrate.Sources()` returns, in order: `core`, `domains`, `fedwiki`, `stripe`, `discourse`. A stream's name is also its schema's name and the suffix of its goose ledger, `goose_db_version_<stream>`.
### The roles today
Each stream's `00001_init.sql` creates a guarded `NOLOGIN` triple, `{stream}_owner`, `{stream}_writer` and `{stream}_reader` (core `internal/db/migrations/00001_init.sql:1134-1148`, domains `internal/domains/migrations/00001_init.sql:25-37`, Stripe `internal/integrations/stripe/store/migrations/00001_init.sql:15-27`, FedWiki `internal/integrations/fedwiki/store/migrations/00001_init.sql:19-31`, Discourse `internal/integrations/discourse/store/migrations/00001_init.sql:20-32`). It grants `USAGE` on its schema to all three and `CREATE` to the owner, `ALL` on its tables to the owner and the writer (which gives the writer `TRUNCATE`, `REFERENCES`, `TRIGGER` and `MAINTAIN` besides the four DML privileges, and no code path uses them), and `SELECT` to the reader; each later migration that creates a table grants it the same way. No migration creates a login, and no migration uses `ALTER DEFAULT PRIVILEGES`.
Each baseline then grants its writer to the login by name: `GRANT core_writer TO member_console` (core `00001_init.sql:1173`), and the same for `domains_writer` (domains `:201`), `stripe_writer` (Stripe `:165`), `fedwiki_writer` (FedWiki `:100`) and `discourse_writer` (Discourse `:122`). Every integration writer and `domains_writer` hold `core_reader`, and FedWiki's `00003_domains_registry.sql:89` grants `domains_writer` to `fedwiki_writer`. No role is a member of any owner role.
The enclosures revoke from `core_writer`: `INSERT`, `UPDATE` and `DELETE` on the three position tables (core `00005_doc41_conferral_functions.sql:637-639`, which leaves the `TRUNCATE`, `TRIGGER`, `REFERENCES` and `MAINTAIN` that `GRANT ALL` gave); every write on `core.entitlement_set_rules` (`00018:567`); every write but `UPDATE` on the obligations, and every write on the changes and effects (`00018:200-206`); every write on the act record's five tables (`00027:182-186`, `00028:506-508`); `INSERT`, `TRUNCATE` and most column updates on `core.desired_state_outbox`, and every write on `core.person_cut_offs` (`00024:773-788`). The definer functions behind them are owned by `core_owner`, pin `search_path = core, pg_temp`, and have `EXECUTE` revoked from `PUBLIC` and granted to `core_writer`.
### How the console connects
One key pair, `db-dsn` and `db-dsn-file` (`cmd/start.go:583-584`, the secret pair at `:754`, validated at `internal/config/validate.go:36-44`). `start` opens the database through `openDB(ctx, logger, dbMigrated)` (`cmd/start.go:165`, `cmd/database.go:28-45`), which calls `db.ConnectAndMigrate` (`internal/db/database.go:151-172`): it opens a plain pool on the DSN, runs every stream's migrations, closes it, and opens the application pool on the same DSN with `search_path=core,public`. `seed-demo` opens it the same way (`cmd/seed_demo.go:35`). `migrate up|down|status` (`cmd/migrate.go:32,59,86`), `config list|set|clear` (`cmd/config.go:125`) and `config validate` (`cmd/config_validate.go:82`) open a plain pool on the same DSN. Only `start` and `config validate` call `resolveSecretFiles` (`cmd/start.go:134`, `cmd/config_validate.go:71`), so the other five commands ignore `db-dsn-file`.
### What depends on superuser or ownership
At runtime, one path: `db.EnsureWebhookEventPartitions` (`internal/db/partitions.go:39-69`) runs `CREATE TABLE IF NOT EXISTS core.webhook_events_YYYY_MM PARTITION OF core.webhook_events`, at boot (`cmd/bootstate.go:73-75`, logged at Warn and not fatal) and from the Temporal schedule (`internal/workflows/maintenance/activities.go:45-54`, registered at `cmd/start.go:462-479`). Creating a partition needs `CREATE` on schema `core`, which no writer holds, and ownership of the parent table. Nothing else in the runtime path runs DDL, `TRUNCATE`, `COPY`, `LISTEN` or an administrative function, and no runtime code writes an enclosed table directly. One runtime query does take a row lock on an enclosed table: `LockPlanActObligation` (`internal/entitlements/queries/plan_acts.sql:55-66`) reads `core.plan_act_obligations ... FOR UPDATE OF ob`, called at `internal/entitlements/plan_act_move.go:93` from `EnactObligation`, which the plan-act drain (`plan_act_drain.go:259`, run by the Temporal activity and by the in-request drains at `plan_act_drain.go:422` and `plan_act_retry.go:79`) runs for each obligation. Postgres requires `UPDATE` on at least one column for `FOR UPDATE`, `FOR NO KEY UPDATE`, `FOR SHARE` and `FOR KEY SHARE`, and the writer holds `SELECT` only on that table (D16's locked list), so the query works only because the login is a superuser (D19). A scan of every locking read in the runtime queries finds no other on a table the writer is locked out of: the other enclosed tables with a locking read, `core.entitlement_set_change_obligations` and `core.desired_state_outbox`, keep `UPDATE` on the table or on columns.
The migrations need a superuser, or an owner with `CREATE` on the database, `CREATEROLE` and `SET` membership in every owner role: they create schemas, an extension, roles and role grants, and they `ALTER FUNCTION ... OWNER TO core_owner`.
### The test stack
One Postgres, `POSTGRES_USER=member_console` (`test/compose.yaml:17-26`), which `initdb` makes the bootstrap superuser. `test/bootstrap-stack.sh` writes `MC_DB_DSN` (`:367`), `TEST_DATABASE_URL`, `E2E_DATABASE_URL` and `POSTGRES_ADMIN_DSN` (`:404-406`), all as `member_console`, and keeps an existing `test/.env` untouched (`:240-247`). `test/reset-app-db.sh` migrates the application database on `MC_DB_DSN` (`:115`), sets `session_replication_role = replica` to pin timestamps (`:117-120`), and dumps and restores it (`:129`). The screens test sets the same replica mode on the application database (`test/e2e/screens/screens_test.go:830-845`). `test/mockshot-seed.sql` writes `core.pool_provisions` directly (`:289`, `:300`, `:347`, `:425`), and `test/mockshot-nuke.sql` deletes from it (`:23`) and claims to run "by a login role that is a member of core_owner" (`:111-114`), which no role is: the file works because its user is a superuser.
The tests that prove an enclosure run on `TEST_DATABASE_URL` as the superuser and drop privileges by hand: `TestConferralEnclosure_WriterRole` with `SET LOCAL ROLE core_writer` (`internal/entitlements/conferral_enclosure_test.go:37`), `TestRuleTableDMLRevoked` the same way (`internal/entitlements/enclosure_test.go:309`), and the act, delivery and plan-act tests through `applicationRole` (`internal/db/integration_delivery_fixtures_test.go:225-244`), which creates a role holding the login's memberships and fails unless the login is a member of `core_writer`.
## Goals / Non-Goals
**Goals:**
- The console serves from a user that is not a superuser, owns nothing in the console's schemas or its database and cannot act as an owner, so every revoke binds it.
- The writer roles hold `SELECT`, `INSERT`, `UPDATE` and `DELETE` and no other table privilege, so no revoke can be walked around with a trigger, a truncate or a lock.
- The console refuses to start on any configuration that would reopen the bypass, and says what to fix.
- Migrations still run at startup for the deployments that want it, on a separate connection that is closed before the console serves.
- Any login name works for the runtime user, and a new integration needs no hand step on any host.
- The test stack runs the same two users, so the tests and the screens exercise the privileges a deployment has.
**Non-Goals:**
- Moving every object to its schema's owner role (#252; D3 says what is left for it).
- Renaming or changing `env` (#251).
- Switching any running deployment; the procedure is documented (D17).
- A migration user that is not a superuser.
## Decisions
### D1. Two users and two connection strings
`db-dsn` names the runtime user. `db-migrate-dsn` names the migration user, the one that owns the schemas and has always run the migrations. Both are file-backed secrets with `-file` variants, `db-migrate-dsn` is optional, because `db-dsn` alone is a valid configuration (the separate-step mode below), and `ValidateStart` checks that `db-migrate-dsn`, when set, is a PostgreSQL URL, with the same message shape as `db-dsn` (copy 14).
With `db-migrate-dsn` set, `start` opens a pool on it with the startup parameters `search_path=public` in place of the console's custom `core,public` (the `ConnectPlain` reason in `docs/database-management.md`; D2 gives the new reason) and `role=none` (D2, rule 2), runs every stream's migrations there, grants the runtime group role there (D8), and closes it before it serves. With it left out, `start` runs no migrations, logs that it did not (copy 18), and refuses to start while the schema is behind its code (D10). A host that wants the running process never to hold the migration password runs `member-console migrate up` as a step of its own before each deploy and leaves `db-migrate-dsn` out of the service.
`migrate up|down|status` open `db-migrate-dsn`, with the same startup parameters, and refuse to run without it (copy 12). Falling back to `db-dsn` would run DDL as the runtime user, which fails on the first `CREATE` with a permission error that does not say which setting is missing.
The process that migrates at startup holds the migration password for as long as it runs, so the runtime user fences code paths, not a compromised process. A host that needs the stronger property uses the separate-step mode. This is the trade the two-user design accepts.
### D2. The checks that need no schema run before any migration
The start-up check (D9) runs most of its checks after the migrations, on the runtime connection, because they read objects the migrations create. Five of its checks read nothing the migrations create: whether both connection strings log in as one user, whether they reach one database, whether the runtime user is a superuser or a member of a superuser role, whether it owns the database or is a member of the role that does, and whether it can create objects in `public` or in a schema of the five streams, or owns an object in `public` (check 12). `start` runs those five before it migrates. A deploy whose `db-dsn` is wrong then refuses without changing the schema, so the previous release, which may still be serving during a rolling deploy, is not left on a schema migrated by a process that never started. The superuser check also runs before the boot grant, so the console never grants `console_runtime` to a superuser.
**The qualification principle.** SQL that the console runs on the migration connection, and SQL whose output it prints as a statement for the operator to run, resolves no unqualified function and no unqualified relation. Every function is written `pg_catalog.<name>`, every catalog relation `pg_catalog.<name>`, and a goose ledger `public.<name>`. The hole is one shape in several places: a role that can create an object in a schema on the search path (a database's owner through `public`, a user granted `CREATE`) plants a function whose argument types match a call exactly, Postgres prefers that function to the catalog's whatever the path, and the planted code then runs with the privilege of the session that made the call, or writes the statement an operator runs as the migration user. The principle applies to the boot grant (D8), to every query of the start-up check on both connections (D9), because a refusal's output is a statement the operator runs, and to the pending check (D10). The migrations' own SQL is the shipped files, whose name resolution the pool's `search_path=public` and check 12 bound. The scope is functions and relations: operators are not listed because each operator in these queries has an exact-type entry in `pg_catalog`, which the search path reaches first. A structural test scans the check queries, the boot-grant statement and the pending check's queries and fails, naming the query and the token, for a function call or a catalog relation without `pg_catalog.` (tasks 5.8); the planted-function rows of `TestOpenRuntimeDatabase` show the effect, the scan shows the cause.
The database-ownership check also protects the migration run itself, and four rules make that protection hold (D9, check 4):
1. **Until the runtime half of check 4 has passed, the migration connection runs one query, with no unqualified function and no unqualified relation:** `SELECT current_user, pg_catalog.current_database()`. A database's owner owns `public` through `pg_database_owner`, so it can create `public.quote_ident(name)`, which beats `pg_catalog.quote_ident(text)` for a `name` argument whatever the search path, and it can run `ALTER DATABASE ... SET search_path`. Either runs the owner's code in the migration user's superuser session. The superuser bit and the quoted name come from the runtime connection, through qualified names, so a function the runtime user planted never runs.
2. **Both pools open with startup parameters that replace any database-level or role-level setting:** the migration pool with `search_path=public&role=none`, the runtime pool with `search_path=core,public&role=none`. A startup parameter outranks `ALTER DATABASE ... SET` and `ALTER ROLE ... SET`, and the pool's open replaces any value the connection string carries. `search_path=public` removes the case of failure row 41 and is where goose's ledgers are expected (D10). `role=none` keeps `current_user` the login: the database's owner can run `ALTER DATABASE ... SET role = '<itself>'`, which would start the migration user's session as that owner, so that the owner half of check 4 compares the owner with itself and goose runs as the owner; and the runtime login can run `ALTER ROLE CURRENT_USER SET role = 'console_runtime'`, after which `current_user` is the group role and a refusal names the wrong user. The `SET LOCAL ROLE` of the tests (D15) is unaffected, because it runs after the session starts. The older reason for a plain pool, a pgx and Postgres 18 interaction with the custom `core,public` path, concerned that path; section 4's fresh-database test migrates through the new pool and compares the resulting schema with a plain replay, so a difference fails `make test`.
3. **Every `migrate` command and `start` refuse a database whose owner is neither the migration user nor a superuser role, before goose runs** (copy 3d), `migrate status` included, because goose's `Status` creates any missing ledger in `public`. Without it, the separate-step `migrate up` would run the whole migration run against a database that the runtime login owns, and `start`'s check 4 would refuse only afterwards.
4. **The migration run waits for check 12.** A runtime user that holds `CREATE` on `public` or on a schema of the five streams, or owns a function in `public`, can plant a function that the boot grant or an unqualified call in a pending migration would run as the superuser. The qualification principle removes the boot grant's exposure; check 12 refuses the configuration before the migrations, whose own SQL the console does not control.
### D3. The migration user keeps its objects; `core.webhook_events` moves to `core_owner`
Every object keeps the owner it has, except `core.webhook_events` and its partitions, which move to `core_owner` so that the partition function (D4), running as `core_owner`, owns the parent table that `CREATE TABLE ... PARTITION OF` requires. `ALTER TABLE ... OWNER TO` on the parent does not change its partitions' owners, so the migration loops over `pg_inherits` and moves each partition too; the parent's indexes and identity sequence move with it. The partitions the function creates later belong to `core_owner` because the function runs as `core_owner`.
The `per-schema-roles` spec said the owner roles run the migrations, which no deployment does. The corrected requirement says what is true: objects in each schema are owned by the user that runs migrations; `core_owner` owns the core schema's `SECURITY DEFINER` functions and `core.webhook_events` with its partitions; the runtime user owns nothing.
How this leaves #252 open: nothing in this change depends on the migration user owning the objects. The start-up check refuses a runtime user that owns, or can act as the owner of, the database or any object, whoever that owner is. The catalog test (D16) asserts privileges, not owners, except for the partitioned table and the function. Moving every object to its owner role later is a per-object `ALTER ... OWNER TO` loop (Postgres refuses `REASSIGN OWNED BY` for the bootstrap superuser), needs the migration user to hold `SET` on each owner role, as this change's move already does for `core_owner`, and changes who may run `scripts/db-reader-role.sql`, which this change leaves with the migration user.
### D4. Partitions are created by a function owned by `core_owner`
`core.ensure_webhook_event_partitions(p_now TIMESTAMPTZ, p_months_ahead INTEGER) RETURNS VOID`, in core migration `00031_webhook_partition_function.sql`:
```sql
CREATE FUNCTION core.ensure_webhook_event_partitions(p_now TIMESTAMPTZ, p_months_ahead INTEGER)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = core, pg_temp
AS $$
DECLARE
month_start TIMESTAMP; -- a UTC wall-clock time
BEGIN
IF p_now IS NULL THEN
RAISE EXCEPTION 'p_now must not be null' USING ERRCODE = 'invalid_parameter_value';
END IF;
IF p_months_ahead IS NULL OR p_months_ahead NOT BETWEEN 0 AND 12 THEN
RAISE EXCEPTION 'months ahead must be between 0 and 12, got %', p_months_ahead
USING ERRCODE = 'invalid_parameter_value';
END IF;
FOR i IN 0..p_months_ahead LOOP
month_start := date_trunc('month', p_now AT TIME ZONE 'UTC') + make_interval(months => i);
EXECUTE format(
'CREATE TABLE IF NOT EXISTS core.%I PARTITION OF core.webhook_events FOR VALUES FROM (%L) TO (%L)',
'webhook_events_' || to_char(month_start, 'YYYY_MM'),
month_start AT TIME ZONE 'UTC',
(month_start + INTERVAL '1 month') AT TIME ZONE 'UTC');
END LOOP;
END
$$;
ALTER FUNCTION core.ensure_webhook_event_partitions(TIMESTAMPTZ, INTEGER) OWNER TO core_owner;
REVOKE EXECUTE ON FUNCTION core.ensure_webhook_event_partitions(TIMESTAMPTZ, INTEGER) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION core.ensure_webhook_event_partitions(TIMESTAMPTZ, INTEGER) TO console_runtime;
```
- **The names and bounds are today's.** The Go code names a partition `core.webhook_events_%04d_%02d` after the UTC month and bounds it from that month's first instant to the next month's, in UTC (`partitions.go:40-62`). The function computes the same in SQL, in UTC whatever the session's `TimeZone`; `%L` of a `timestamptz` renders the offset, so the bound is the same instant in any session. The parity rule in `partitions.go`'s comment holds between the function and the baseline's `DO` block (`00001_init.sql:1048-1064`) only because section 3 corrects that block: it computed `m_start` as a `DATE` in the session's time zone, so on a server whose `TimeZone` is not UTC the initial partitions' bounds were that zone's midnights and the function's first `ensure` would overlap them. The corrected block computes `DATE_TRUNC('month', NOW() AT TIME ZONE 'UTC')` into a `TIMESTAMP`, adds `make_interval(months => i)`, and renders the bounds with `AT TIME ZONE 'UTC'`, as the function does. On a UTC server, such as the official image and the test stack, its result is unchanged, so `schemaSnapshot` and the replay tests see the same partitions; databases that applied the baseline are untouched.
- **The caller passes the time.** `db.EnsureWebhookEventPartitions` keeps its signature's time and window, and the partition test keeps proving the gap with a month no real run reaches. A runtime caller can therefore ask for partitions in any month; the window is capped at 12 months ahead so one call creates at most 13 tables. Nothing else bounds the runtime user's use of the table: it can already insert any number of rows.
- **`EXECUTE` goes to `console_runtime`, not `core_writer`.** Creating a partition is a privilege of the process that serves the console, not of every role that holds `core_writer`.
- **The Go side.** `EnsureWebhookEventPartitions(ctx, ex, now, monthsAhead)` runs `SELECT core.ensure_webhook_event_partitions($1, $2)` on an executor it is given (a pool, a connection or a transaction), so the partition test can run it as `console_runtime`. The comment about pgx refusing placeholders in partition DDL goes: the statement is a function call. The boot pass and the activity call it as they call the Go function today; a failure is still logged at Warn and does not stop the boot.
- **The Down section** drops the function, gives the parent and every partition back to `CURRENT_USER`, the user running the Down, and then runs `GRANT ALL ON core.webhook_events TO core_owner` and the same for each partition in its loop. The baseline's `GRANT ALL ON ALL TABLES IN SCHEMA core TO core_owner` (`00001_init.sql:1156`) left `core_owner` an explicit entry on the parent and on each baseline partition; the Up's `OWNER TO core_owner` merges that entry into the owner's, and the Down's `OWNER TO CURRENT_USER` would leave `core_owner` with none, so without the re-grant the database is not back where the baseline left it. A partition the function created has no such entry before the Down and gains one; the privilege matches the baseline's partitions and is harmless.
### D5. The runtime group role and its grants
Core migration `00029_runtime_group_role.sql` creates `console_runtime NOLOGIN`, guarded by `pg_roles` like the stream triples, and grants it `core_writer` and `SELECT` on `goose_db_version_core`. Each other stream's new migration grants its own writer and `SELECT` on its own ledger: domains `00003_runtime_group_role.sql`, FedWiki `00005_runtime_group_role.sql`, Stripe `00006_runtime_group_role.sql`, Discourse `00004_runtime_group_role.sql`. Core runs first in every migration run, so `console_runtime` exists when a later stream grants to it, as `core_reader` does for the baselines. A new integration's baseline does the same (`docs/building-an-integration.md`). The `writer_dml_only` migration of each stream (D13) follows its group-role migration.
```sql
-- domains/00003_runtime_group_role.sql, Up; the other streams differ only in names
-- +goose StatementBegin
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_auth_members m
JOIN pg_roles r ON r.oid = m.roleid
JOIN pg_roles u ON u.oid = m.member
WHERE r.rolname = 'domains_writer' AND u.rolname = 'member_console') THEN
REVOKE domains_writer FROM member_console;
END IF;
END
$$;
GRANT domains_writer TO console_runtime;
GRANT SELECT ON goose_db_version_domains TO console_runtime;
-- +goose StatementEnd
```
- **The ledger grant** is what lets the runtime user read the ledgers for the pending check (D10). The table name is unqualified, so it resolves through the migration connection's `search_path`, which is `public` (D2, rule 2), the same way goose created the table.
- **No owner role is granted to `console_runtime`**, and no stream grants a writer to a login by name.
- **`console_runtime` holds nothing a writer does not**, apart from the partition function's `EXECUTE` (D4) and the ledgers' `SELECT`, and no writer holds more than the four DML privileges (D13).
### D6. The baselines name no login
Each of the five baselines drops its `GRANT {key}_writer TO member_console` and, in its Down section, the matching `REVOKE`. A fresh install then names no role of any particular name, so it works whatever the migration user is called. The edit reaches only databases that have not applied the baseline; goose records version 1 as applied on every other, and the five group-role migrations (D5) clean those: each revokes its writer from `member_console` when `pg_roles` has that role and `pg_auth_members` records the membership. The guard tests the role as well as the membership, because Postgres treats `REVOKE <role> FROM <role that does not exist>` as an error, whereas revoking a membership that does not exist from an existing role is a warning. The migrations' Down sections do not grant it back, for the reason in D7. After this change no login reaches a writer role except through `console_runtime`:
- the by-name grants are the mechanism this change replaces; left in place, they keep a login with writer privileges that no code path needs, and a reader of the catalog cannot tell whether they matter;
- `member_console` is the test stack's migration user and the migration user of every deployment set up from the documented compose files. A migration user that is a superuser loses nothing by the revoke. One that is not (out of scope today) should not hold writer DML through a name it happens to share with the old login;
- the catalog test can then state the rule "no role but `console_runtime` is a direct member of a writer role, apart from the edges the streams declare between their own roles".
### D7. A Down section leaves memberships to the role's tolerant removal
Role memberships are cluster-wide, while a Down section runs in one database. A Down that ran `REVOKE domains_writer FROM console_runtime` in one database would take the privilege away from every database in the cluster that still uses it, including, in the test stack, the shared test database and the running application whenever a test rolls a migration back on a scratch database. So the new Down sections revoke only what lives in their database (the ledger grant, the function, the ownership move) and leave the memberships, both the grants to `console_runtime` and the revokes from `member_console`: a Down that granted a writer back to `member_console` would do so for the whole cluster too, and a test that rolls a migration back on a scratch database would leave the shared test cluster with a membership no migration expects. The release before this change connects as a superuser and needs neither. Core's Down drops `console_runtime` with the baselines' tolerant pattern: `DROP ROLE IF EXISTS` inside a block that catches `dependent_objects_still_exist` and leaves the role when another database still grants it anything, which every migrated database does through its ledger grant. In a cluster with one database, the full rollback drops the role, and its memberships go with it.
### D8. The boot grant
On the migration connection, after the migrations, the console grants `console_runtime` to the runtime user in one transaction:
```sql
SELECT pg_catalog.set_config('member_console.runtime_user', $1, true);
DO $$
BEGIN
EXECUTE pg_catalog.format('GRANT console_runtime TO %I WITH INHERIT TRUE',
pg_catalog.current_setting('member_console.runtime_user'));
END
$$;
```
- **The name comes from the runtime connection** (`SELECT current_user` on it), not from parsing `db-dsn`, so a connection string whose user part is URL-encoded, defaulted by the driver or rewritten by a pooler still names the user the console serves as.
- **The statement is built in the database.** The name travels as a bound parameter into a transaction-local setting, and `pg_catalog.format('%I')` quotes it, so no Go code concatenates SQL.
- **Every function is qualified** (D2, the qualification principle): this statement runs as the migration user, a superuser, and a runtime user that can create objects in `public` could otherwise plant `public.format(text, text)`, which Postgres prefers to the catalog's `format(text, VARIADIC "any")` for these argument types. Check 12 refuses such a user before this statement runs, and the qualified names mean the statement would not call the function if one were there.
- **`WITH INHERIT TRUE`** makes the grant pass privileges whatever the runtime login's own `INHERIT` attribute says. A login created `NOINHERIT` (as `console_reader` is) would otherwise get a grant through which it inherits nothing, and every query would fail on privileges. The membership check (D9) tests inheritance, `pg_has_role(..., 'USAGE')`, for the same reason, and the separate-step refusal names the same statement (copy 5).
- **It runs at every start.** Granting a membership that exists from the same grantor changes nothing (Postgres reports a notice), so the grant is idempotent, and a deployment whose runtime user changes needs no hand step.
- A failure refuses the start (copy 10): without the membership the runtime user holds nothing.
### D9. The start-up check
The check refuses the start on the first failing condition, in this order, which is the numbers' order except that check 12 runs after check 4 and before check 5; it is numbered last so that the other numbers stay as they are. Every query of the check follows the qualification principle (D2): each function and catalog relation below is written with `pg_catalog.`. A superuser passes every membership test Postgres offers, so every check after the superuser check runs only for a user that is not one. The order also makes each refusal's fix reachable: the ledger read needs membership in `console_runtime`, so membership is checked first, and an owner-role membership or an owned object is reported, with the fix that names it, before the general rule that the user holds no other role.
| # | Condition | Connection | When | Copy |
|---|---|---|---|---|
| 1 | Both connection strings log in as one user (`current_user` on each) | both | before migrating; only with `db-migrate-dsn` | 1 |
| 2 | They reach different databases (`current_database()` on each) | both | before migrating; only with `db-migrate-dsn` | 2 |
| 3 | The runtime user is a superuser (`rolsuper` of `current_user`), or a member of a superuser role, which `SET ROLE` turns into a superuser | runtime | before migrating, and before the boot grant | 3, 3a |
| 4 | The runtime user is, or is a member of a role that is, the owner of the current database (`pg_has_role(current_user, datdba, 'MEMBER')`); and, on the migration connection, the owner is neither the migration user nor a superuser role | runtime, then migration | before migrating, and before the boot grant; the owner half also in every `migrate` command | 3b, 3c, 3d |
| 5 | `console_runtime` does not exist | runtime | after migrating | 4 |
| 6 | The runtime user does not inherit `console_runtime` (`pg_has_role(current_user, 'console_runtime', 'USAGE')`) | runtime | after migrating, and after the boot grant | 5 |
| 7 | The schema is behind the code (D10) | runtime | after migrating, in both modes | 4 |
| 8 | The runtime user is a member, directly or through another role, of a console owner role (`pg_has_role(current_user, '<stream>_owner', 'MEMBER')` for each stream) | runtime | after migrating | 6 |
| 9 | The runtime user is, or is a member of, the owner of a schema of the five streams, or of a relation or function in those schemas or in `public` | runtime | after migrating; its `public` half also before migrating, in check 12 | 7, 8 |
| 10 | The runtime user is a direct member of any role other than `console_runtime` | runtime | after migrating | 8a |
| 11 | The runtime user can insert into `core.pool_provisions`, or holds `TRUNCATE`, `REFERENCES`, `TRIGGER` or `MAINTAIN` on any relation of the five schemas (`has_table_privilege`) | runtime | after migrating | 9, 9a |
| 12 | The runtime user holds `CREATE` on `public` or on a schema of the five streams (`has_schema_privilege`), or owns, or is a member of a role that owns, a relation or function in `public` (the `public` half of check 9) | migration; runtime in the separate-step mode | before migrating, after check 4, and before the boot grant; in the separate-step mode with checks 5 to 11 | 3e, 7, 8 |
- **Check 3 covers membership of a superuser role.** The superuser attribute is not inherited, but a member with `SET` can `SET ROLE` to the superuser role and bypass every check from there, so a member of any role with `rolsuper` is refused like a superuser. Membership of the migration user is caught here when the migration user is a superuser, and by checks 4 and 9 when it is not.
- **Check 4 refuses a user that owns the database, and runs before the migrations.** A database's owner is a member of `pg_database_owner`, which owns `public`; the owner of a schema can drop and replace any object in it, the goose ledgers among them, and a database's owner can plant settings and functions that a superuser session in the database picks up; D2 lists the rules that keep them out of the migration connection. That route fires during the migration run, so the check runs before it. `pg_has_role(current_user, datdba, 'MEMBER')` is true when the user owns the database and when it is a member of the role that does. The fix for ownership is `REASSIGN OWNED BY <user> TO CURRENT_USER;` run as the migration user, which moves the database to the migration user as well as anything else the user owns; Postgres refuses that statement for the bootstrap superuser, which check 3 already refused. When the user holds the ownership through a membership, `REASSIGN OWNED` does not change it, so the refusal prints the `REVOKE` of the direct membership (copy 3c). A superuser that does not own the database matches this membership case (a superuser is a member of every role), so when check 4 runs on its own it prints copy 3c with an empty direct role; the Go side scans the direct role as nullable, and `CheckBeforeMigrating` runs the membership half of check 3 first, which refuses a superuser as copy 3a.
- **Check 4's owner half is the one rule that holds for every owner.** After the runtime half passes, the migration connection reads, by qualified names only, the owner of the current database: the check refuses when that owner is neither the migration user nor a role with `rolsuper`, with the statement `ALTER DATABASE <quoted name> OWNER TO CURRENT_USER;` to run as the migration user (copy 3d). It runs in `start` after the runtime half and before goose, and in every `migrate` command before goose, `migrate status` included, because goose's `Status` creates any missing ledger in `public` (one rule for the migration connection, at the cost of one query); in the separate-step mode `start` has no migration connection and skips it. A database owned by any other role is a database whose `public` that role controls, whether or not it is the runtime user.
- **Check 7 runs in both modes.** With `db-migrate-dsn` set the schema was just migrated, but on the migration connection's database; check 2 compares database names, and check 7 also catches a runtime connection that reaches a database of the same name in another cluster.
- **Check 8 names the five stream owner roles**, not every role whose name ends in `_owner`: Postgres's own `pg_database_owner` ends that way, and every database owner is a member of it; check 4 covers it.
- **Check 9 covers functions and `public`, not only schemas and relations.** A function the runtime user owns can be replaced; an invoker trigger function in `core` (such as `core.sync_pool_provision_ladders`) or `public.update_updated_at_column` runs with the privileges of whoever fires its trigger, which inside a definer function is `core_owner`. It covers `public` for the same reason and for the goose ledgers, whose owner could forge a migration state; the `public` half also runs before the migrations, in check 12, and again here because the ledgers are created during the run. `public`'s own owner, `pg_database_owner`, matches only for a user that owns the database, which check 4 refused earlier, because owning the database is membership in that role. `pg_has_role(<login>, owner, 'MEMBER')` is true when the user is the owner, so one test covers both "owns" and "is a member of a role that owns", including membership in the migration user.
- **Check 10 refuses every direct membership but `console_runtime`.** The rows of `pg_auth_members` whose member is the user are the user's direct memberships. Predefined roles open routes around the grants or the process: `pg_write_all_data` writes every table whatever the revokes say; `pg_execute_server_program` runs a program on the server as its operating-system user, which can log in to the server as a superuser over a trusted local socket; `pg_read_server_files` reads server files such as `pg_hba.conf`; `pg_maintain` locks and truncates every table. A hosting provider's administrative role is a direct membership too. The rule does not list them: the documentation says the runtime login holds no role but `console_runtime`, and the check refuses any other, so a role added to Postgres or by a provider later is refused without a change here. Memberships the user holds through `console_runtime` are not direct and are covered by checks 8, 9 and 11 and by the catalog test (D16). The implicit membership in `pg_database_owner` that comes with owning the database is not a row of `pg_auth_members`; check 4 covers it. The check runs after checks 8 and 9 so that a direct membership of an owner role is reported with the owner it reaches.
- **Check 11 is a probe, and it reads the four privileges.** It catches what the catalog cannot list in one query: a stray `GRANT` to the runtime user or to `PUBLIC`. It reads, in the same transaction, the first relation of the five schemas on which `pg_catalog.has_table_privilege(current_user, c.oid, p)` holds for `TRUNCATE`, `REFERENCES`, `TRIGGER` or `MAINTAIN`. The catalog test (D16) covers migrations only, and checks 9 and 10 cover ownership and memberships, so a host's `GRANT TRIGGER ON core.grants TO <runtime user>` would otherwise reopen the trigger route (D13) unseen. Both of its reads also return the grantee, from `pg_catalog.aclexplode(c.relacl)` restricted to the entries that reach the user (a grant to `PUBLIC`, or to a role the user inherits), so that copy 9 prints the one `REVOKE` that removes the privilege, to `PUBLIC`, to the user or to `core_writer`. A privilege that has no ACL entry comes from a predefined role that the user reaches through a role it is a member of (`pg_maintain` for `MAINTAIN`, `pg_write_all_data` for `INSERT`); a third query then finds that role and the role it is granted to, and copy 9a prints `REVOKE <predefined role> FROM <quoted member>;`. A direct membership of such a role is refused earlier, by check 10.
- **Check 12 closes the window before the migrations.** The migration run executes SQL whose names the console does not control (each migration), and the boot grant executes a statement the console writes; a runtime user that can create objects in `public`, or in a schema of the five streams, or that owns a function in `public`, can place a function that wins an unqualified call. The first query reads, by qualified names and with the runtime login as a parameter, the first of `public` and the five schemas on which that login holds `CREATE` (`pg_catalog.has_schema_privilege`); the second is check 9's query restricted to `public`, with the same parameters. Both run on the migration connection, after the owner half of check 4 has passed so that the database's owner is not the runtime user; in the separate-step mode, which runs no migration and has no migration connection, they run on the runtime connection with checks 5 to 11, which keeps one query text for both modes. The query reads the schema's ACL entries, not `has_schema_privilege`, for two reasons: it names the grantee of the entry that gives the login `CREATE` (`PUBLIC`, the login itself, or a role it is a member of), so the printed `REVOKE CREATE ... FROM <grantee>` removes exactly that entry and the refusal does not repeat after the fix; and it tests membership with `MEMBER`, so a role the login can only `SET ROLE` to, whose privileges `has_schema_privilege` does not count, is caught too. The schema owner's own entry is left out: ownership is checks 4 and 9's subject, and revoking `CREATE` from the migration user would break the next migration.
- **Each refusal names its fix.** Where the fix is one statement, the refusal prints it with the user's name and the direct role's name quoted by `pg_catalog.quote_ident` in the database (copies 3a to 3e, 5 to 8, 8a, 9, 9a), so a name that needs quoting (`Mc-Admin`) yields a statement that runs, and no function the refused user created is called to quote it. Where a membership is indirect, the refusal names the role through which the user holds it, found as the user's direct membership that leads to the owner.
- **There is no exemption.** No setting turns the check off, and nothing reads `env`.
- **A failed check query refuses the start** (copy 11). A read that fails is never an answer.
- On success the console logs one line naming both users (copy 19).
The queries of the runtime connection run in one `REPEATABLE READ` read-only transaction on the runtime pool so they read one snapshot. The migration connection's queries (the identity read, the owner half of check 4 and check 12) run on that pool, each in a read-only transaction of its own, before the migrations. Each query is a Go string constant whose name begins `query`; the structural test of the qualification principle (tasks 5.8) reads those constants.
```sql
-- identity, on the migration connection, before the runtime half of check 4 passes: no unqualified function, no unqualified relation
SELECT current_user, pg_catalog.current_database();
-- identity, on the runtime connection (qualified, so a function the runtime user planted never runs)
SELECT current_user, pg_catalog.quote_ident(current_user), pg_catalog.current_database(), r.rolsuper
FROM pg_catalog.pg_roles r WHERE r.rolname = current_user;
-- 3, the membership half: the first superuser role the user can reach, and the direct grant that leads to it
SELECT s.rolname,
(SELECT pg_catalog.quote_ident(d.rolname)
FROM pg_catalog.pg_auth_members m JOIN pg_catalog.pg_roles d ON d.oid = m.roleid
WHERE m.member = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user)
AND pg_catalog.pg_has_role(m.roleid, s.oid, 'MEMBER')
ORDER BY d.rolname LIMIT 1)
FROM pg_catalog.pg_roles s
WHERE s.rolsuper AND s.rolname <> current_user AND pg_catalog.pg_has_role(current_user, s.oid, 'MEMBER')
ORDER BY s.rolname LIMIT 1;
-- 4: the owner of the current database, when the user is it or can act as it
SELECT d.datname, pg_catalog.quote_ident(o.rolname), o.rolname = current_user,
(SELECT pg_catalog.quote_ident(g.rolname)
FROM pg_catalog.pg_auth_members m JOIN pg_catalog.pg_roles g ON g.oid = m.roleid
WHERE m.member = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user)
AND pg_catalog.pg_has_role(m.roleid, d.datdba, 'MEMBER')
ORDER BY g.rolname LIMIT 1)
FROM pg_catalog.pg_database d JOIN pg_catalog.pg_roles o ON o.oid = d.datdba
WHERE d.datname = pg_catalog.current_database() AND pg_catalog.pg_has_role(current_user, d.datdba, 'MEMBER');
-- 4, the owner half, on the migration connection
SELECT pg_catalog.quote_ident(d.datname), pg_catalog.quote_ident(o.rolname)
FROM pg_catalog.pg_database d JOIN pg_catalog.pg_roles o ON o.oid = d.datdba
WHERE d.datname = pg_catalog.current_database() AND o.rolname <> current_user AND NOT o.rolsuper;
-- 12, the CREATE half, on the migration connection after check 4 (on the runtime connection in the separate-step mode):
-- $1 the runtime login, $2 'public' and the five schema names; the first schema the login can create objects in,
-- and the grantee of the ACL entry that lets it (PUBLIC first), so the printed REVOKE removes that entry
SELECT pg_catalog.quote_ident(n.nspname),
CASE WHEN a.grantee = 0 THEN 'PUBLIC'
ELSE pg_catalog.quote_ident(pg_catalog.pg_get_userbyid(a.grantee)) END
FROM pg_catalog.pg_namespace n
CROSS JOIN LATERAL pg_catalog.aclexplode(COALESCE(n.nspacl, pg_catalog.acldefault('n', n.nspowner))) a
WHERE n.nspname = ANY($2)
AND a.privilege_type = 'CREATE'
AND a.grantee <> n.nspowner
AND (a.grantee = 0 OR pg_catalog.pg_has_role($1::name, a.grantee, 'MEMBER'))
ORDER BY 1, a.grantee <> 0, 2
LIMIT 1;
-- 5, 6
SELECT pg_catalog.to_regrole('console_runtime') IS NOT NULL;
SELECT pg_catalog.pg_has_role(current_user, 'console_runtime', 'USAGE');
-- 8: the first owner role the user can reach, and the direct grant that leads to it
SELECT o.rolname,
(SELECT pg_catalog.quote_ident(d.rolname)
FROM pg_catalog.pg_auth_members m JOIN pg_catalog.pg_roles d ON d.oid = m.roleid
WHERE m.member = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user)
AND pg_catalog.pg_has_role(m.roleid, o.oid, 'MEMBER')
ORDER BY d.rolname LIMIT 1)
FROM pg_catalog.pg_roles o
WHERE o.rolname = ANY($1) AND pg_catalog.pg_has_role(current_user, o.oid, 'MEMBER')
ORDER BY o.rolname LIMIT 1;
-- 9, and the public half of 12: the first object the user owns or can act as the owner of
-- $1 the schema names (the five and public after migrating; public alone in check 12), $2 the login
SELECT kind, name, owner, pg_catalog.pg_get_userbyid(owner) FROM (
SELECT 'schema' AS kind, pg_catalog.quote_ident(n.nspname) AS name, n.nspowner AS owner
FROM pg_catalog.pg_namespace n WHERE n.nspname = ANY($1)
UNION ALL
SELECT 'relation', pg_catalog.format('%I.%I', n.nspname, c.relname), c.relowner
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = ANY($1)
UNION ALL
SELECT 'function', p.oid::regprocedure::text, p.proowner
FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = ANY($1)
) o
WHERE pg_catalog.pg_has_role($2::name, o.owner, 'MEMBER')
ORDER BY kind, name LIMIT 1;
-- 9 and 12, when the owner is not the user itself: the direct role that leads to it
-- ($1 the oid the query above returned, $2 the login)
SELECT pg_catalog.quote_ident(d.rolname)
FROM pg_catalog.pg_auth_members m JOIN pg_catalog.pg_roles d ON d.oid = m.roleid
WHERE m.member = $2::name::regrole::oid
AND pg_catalog.pg_has_role(m.roleid, $1::oid, 'MEMBER')
ORDER BY d.rolname LIMIT 1;
-- 10: the first direct membership that is not console_runtime
SELECT pg_catalog.quote_ident(g.rolname)
FROM pg_catalog.pg_auth_members m JOIN pg_catalog.pg_roles g ON g.oid = m.roleid
WHERE m.member = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user)
AND g.rolname <> 'console_runtime'
ORDER BY g.rolname LIMIT 1;
-- 11: the INSERT probe, with the grantee that reaches the user (NULL when the privilege comes from a predefined role)
SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC'
ELSE pg_catalog.quote_ident(pg_catalog.pg_get_userbyid(a.grantee)) END
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN LATERAL pg_catalog.aclexplode(c.relacl) a
ON a.privilege_type = 'INSERT'
AND (a.grantee = 0 OR pg_catalog.pg_has_role(current_user, a.grantee, 'USAGE'))
WHERE n.nspname = 'core' AND c.relname = 'pool_provisions'
AND pg_catalog.has_table_privilege(current_user, c.oid, 'INSERT')
ORDER BY 1 LIMIT 1;
-- 11: the first relation that grants one of the four privileges, with the grantee that reaches the user
SELECT p.priv, pg_catalog.format('%I.%I', n.nspname, c.relname),
CASE WHEN a.grantee = 0 THEN 'PUBLIC'
ELSE pg_catalog.quote_ident(pg_catalog.pg_get_userbyid(a.grantee)) END
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
CROSS JOIN (VALUES ('TRUNCATE'), ('REFERENCES'), ('TRIGGER'), ('MAINTAIN')) AS p(priv)
LEFT JOIN LATERAL pg_catalog.aclexplode(c.relacl) a
ON a.privilege_type = p.priv
AND (a.grantee = 0 OR pg_catalog.pg_has_role(current_user, a.grantee, 'USAGE'))
WHERE n.nspname = ANY($1) AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
AND pg_catalog.has_table_privilege(current_user, c.oid, p.priv)
ORDER BY 2, 1, 3 LIMIT 1;
-- 11, when the grantee is NULL: the predefined role that gives the privilege, and the role it is granted to
-- ($1 the privilege, 'INSERT' or 'MAINTAIN')
SELECT pg_catalog.quote_ident(g.rolname), pg_catalog.quote_ident(mr.rolname)
FROM pg_catalog.pg_auth_members m
JOIN pg_catalog.pg_roles g ON g.oid = m.roleid
JOIN pg_catalog.pg_roles mr ON mr.oid = m.member
WHERE g.rolname = CASE $1 WHEN 'INSERT' THEN 'pg_write_all_data' WHEN 'MAINTAIN' THEN 'pg_maintain' END
AND pg_catalog.pg_has_role(current_user, m.member, 'USAGE')
ORDER BY mr.rolname LIMIT 1;
```
`$1` is the five stream names for check 9 (with `public` added after the migrations, and `public` alone in check 12) and the five owner role names for check 8, both built from `migrate.Sources()` in `cmd`; `$2` of the first query of check 9 is the runtime login (`current_user` on the runtime connection, the login from the identity read in check 12). Check 9's refusal names the owner role by name (the fourth column) when it is not the user itself, with the direct grant that leads to it found by the second query of check 9, quoted as in check 8. `$1` of check 11's second query is the five stream names. Check 11's third query runs only when the grantee column is NULL, for the two privileges a predefined role can give.
The Go side lives in `internal/db/runtime_user.go`: `RuntimeGroupRole` (the constant `"console_runtime"`), `CurrentUser` (the identity query), `GrantRuntimeRole` (D8, on a transaction, because its setting is local to one), `CheckBeforeMigrating` (the membership half of check 3, and the runtime half of check 4), `CheckMigrationDatabaseOwner` (the owner half of check 4, on the migration connection, returning copy 3d), `CheckSchemasBeforeMigrating` (check 12, taking the runtime login, on either connection, returning copy 3e, 7 or 8) and `CheckRuntimeUser` (checks 5 to 11), each taking an executor so a test can run it inside a transaction under `SET LOCAL ROLE`. A refusal is a `*RuntimeUserRefusal` whose `Error()` is the copy; `cmd` logs it with copy 17 and exits 1.
### D10. The pending check reads the ledgers itself
`db.PendingMigrations(ctx, q, sources)` returns, per stream, the embedded versions its ledger does not record as applied. For each source it lists the version numbers of the embedded files (the same parsing as `dbtest.highestVersion`), resolves the ledger with `pg_catalog.to_regclass('public.goose_db_version_<stream>')`, reads `SELECT version_id FROM public.goose_db_version_<stream> WHERE is_applied` when `pg_catalog.has_table_privilege(current_user, 'public.goose_db_version_<stream>', 'SELECT')` holds, and compares. A missing ledger is a stream with every version pending (copy 4b); an unreadable ledger is reported as such (copy 4c), because the grant that makes it readable comes with this change's migrations and is missing on a database that has not had them.
The name is qualified on purpose. On the runtime connection's `search_path` (`core,public`), Postgres looks in the session's `pg_temp` schema before the others, so a runtime user with arbitrary SQL could create a temporary table named like a ledger and make an unqualified lookup read it. The functions are qualified for the reason of D2's qualification principle, and the pending check's queries are among those the structural test scans. The goose ledgers live in `public` because the migration connection opens with `search_path=public` (D2, rule 2).
It does not use goose's provider: `Provider.HasPending` creates a ledger it does not find, and looks for it in `current_schema()`, which is `core` on the runtime connection.
### D11. Which connection each command opens
| Command | Connection | Migrations | Boot grant | Start-up check |
|---|---|---|---|---|
| `start` | both (D1) | on `db-migrate-dsn`, when set | when `db-migrate-dsn` is set | yes |
| `seed-demo` | as `start` | as `start` | as `start` | yes |
| `migrate up\|down\|status` | `db-migrate-dsn`, `search_path=public`, `role=none` | the command's own | no | the owner half of check 4, in all three |
| `config list\|set\|clear`, `config validate` | `db-dsn`, plain | no | no | no |
`seed-demo` opens the database as `start` does because it runs the same boot data state (`ensureBootDataState`) and its writes go through the console's own paths (the one raw insert, `core.resource_keys`, is a writer table); a seeded database then matches a booted one, and `test/reset-app-db.sh` gives the runtime user its membership before the application ever starts. The `config` commands read and write `core.integration_config_overrides`, which the runtime user writes, and they are tools an operator runs by hand, not the serving process, so they take no part in the boot.
`cmd/database.go` holds the modes: `dbRuntime` (the sequence below), `dbMigration` (`db-migrate-dsn`, opened with `search_path=public` and `role=none`, which runs the owner half of check 4 before every `migrate` command, `status` included) and `dbConfig` (`db-dsn`, plain). `db.ConnectAndMigrate` is deleted.
### D12. Every database-opening command reads the `-file` keys
`resolveSecretFiles` keeps its job for `start` and `config validate`, which resolve every secret before `ValidateStart`. The database pairs (`db-dsn`/`db-dsn-file`, and from section 4 `db-migrate-dsn`/`db-migrate-dsn-file`) move to a list of their own, `databaseSecretPairs`, which `resolveSecretFiles` includes and a new `resolveDatabaseSecretFiles` resolves alone, through the same loop (`resolveSecretPairs`). `migrate up|down|status`, `config list|set|clear` and `seed-demo` call `resolveDatabaseSecretFiles` before they open the database. They do not resolve the other secrets: a one-shot migration container may mount only the database's secret while its environment names other secret files.
`openDB` does not resolve the pairs itself: a second resolution after `resolveSecretFiles` would see both the value and the file set and report them as ambiguous. Instead it refuses a mode whose connection string is empty (copies 12 and 13), so a command that forgot to resolve fails loudly rather than letting pgx connect with libpq's defaults (the local socket and the operating-system user).
### D13. Writers hold the four DML privileges and no others
`GRANT ALL ON ALL TABLES IN SCHEMA <schema> TO <schema>_writer` in each baseline, and the per-table `GRANT ALL ... TO ..._writer` in seven later migrations (core `00007`, `00009`, `00015`; FedWiki `00002`, `00003`, `00004`; Stripe `00002`), give a writer more than `SELECT`, `INSERT`, `UPDATE` and `DELETE`: they also give it `TRUNCATE`, `REFERENCES`, `TRIGGER` and, in Postgres 17 and later, `MAINTAIN`. The console requires Postgres 18, so all four exist. Each is a way around an enclosure, and nothing in the runtime path uses any of them:
- `TRIGGER` on a table lets a role create a trigger on it. Postgres runs a trigger with the privileges of the user whose statement fired it, so when a `SECURITY DEFINER` function owned by `core_owner` writes an enclosed table that carries the trigger, the trigger's function runs as `core_owner` and can write the locked tables directly. Any role may create a function in its own `pg_temp` schema, so `TRIGGER` on one table the definer functions write is enough.
- `TRUNCATE` empties a table without `DELETE`. `TRUNCATE` and `MAINTAIN` also allow `LOCK TABLE ... ACCESS EXCLUSIVE`, which blocks every other session on that table.
- `REFERENCES` lets a role add a foreign key to a table.
A new migration in each stream, `writer_dml_only`, runs `REVOKE TRUNCATE, REFERENCES, TRIGGER, MAINTAIN ON ALL TABLES IN SCHEMA <schema> FROM <schema>_writer`: core `00030_writer_dml_only.sql`, domains `00004`, FedWiki `00006`, Stripe `00007`, Discourse `00005`. It runs after every earlier grant of its stream, so one statement corrects a fresh database and an existing one alike, and the baselines' and the later migrations' `GRANT ALL` lines are not edited. The Down section does nothing: re-granting the four privileges on every table would also hand `TRUNCATE` back on tables whose enclosure had revoked it, and no code path used them. A table that a migration adds after this one, and grants with `GRANT ALL`, is not corrected; the catalog test (D16) fails for it, naming the table and the privilege.
`TestConferralEnclosure_WriterRole` probes `TRUNCATE`, `CREATE TRIGGER` and `LOCK TABLE ... IN ACCESS EXCLUSIVE MODE` on each position table and expects `42501`; the `TRUNCATE` privilege check runs before the foreign-key check that would otherwise refuse truncating `core.pool_provisions`, so the probe tells the two apart, and the trigger probe creates its function in `pg_temp` first so that the privilege error cannot be a missing function. The revoke lands in section 1 with the group role, because the catalog test of that section asserts that `console_runtime` holds none of the four.
### D14. The test stack on two users
- **`postgres-seed`** is a one-shot service in `test/compose.yaml`, like `keycloak-seed` and `temporal-seed`: image `postgres:18.1`, `restart: "no"`, `depends_on: postgres: condition: service_healthy`, running `test/seed/postgres/seed-runtime-user.sh` mounted read-only. `postgres` gains a health check (`pg_isready -U member_console -d member_console`). The script retries `psql -h postgres` as `member_console` until the server answers (the image's first start runs `initdb` behind a temporary server that refuses TCP), then creates `member_console_runtime` with `LOGIN` and the password `member_console_runtime` when it is missing, and sets `LOGIN NOSUPERUSER PASSWORD 'member_console_runtime'` on every run, so the role converges whatever an earlier run left. It runs on every `docker compose up`, so an existing volume gains the role without a teardown. It grants nothing: the boot grant does.
- **The runtime login's name** is `member_console_runtime`: it pairs with `member_console`, today's user, which stays the migration user, and it cannot be mistaken for the group role.
- **`test/bootstrap-stack.sh`** writes `MC_DB_DSN=postgres://member_console_runtime:member_console_runtime@localhost:${POSTGRES_PORT}/member_console?sslmode=disable` and `MC_DB_MIGRATE_DSN=postgres://member_console:member_console@localhost:${POSTGRES_PORT}/member_console?sslmode=disable`, and its summary names both users (copy 29). `TEST_DATABASE_URL`, `E2E_DATABASE_URL` and `POSTGRES_ADMIN_DSN` stay `member_console`: scratch databases, migration replays, `CREATE ROLE` and `SET ROLE` need a superuser.
- **The guard.** `test/bootstrap-stack.sh` (on a re-run), `test/reset-test-db.sh`, `test/reset-app-db.sh` and `test/seed-demo.sh` refuse a `test/.env` without `MC_DB_MIGRATE_DSN`, with copy 28, exit status 1, and before they touch a container or a database, following `reset-test-db.sh`'s existing guard. `reset-test-db.sh` does not read the key, but `make test` runs it first in every worktree, so the stale file is reported there before the application's start refuses its superuser. The fix stops the stack first: with the stack up, a fresh bootstrap would find the stack's own ports bound and pick another slot.
- **`test/reset-test-db.sh`** runs `MC_DB_MIGRATE_DSN="$dsn" go run . migrate up`.
- **`test/reset-app-db.sh`** runs `migrate up`, the timestamp pin and `pg_dump` on `MC_DB_MIGRATE_DSN`, and builds the snapshot's address from `MC_DB_MIGRATE_DSN`, because the restore creates schemas and the pin needs `session_replication_role`, which only a superuser sets. The database name still comes from `MC_DB_DSN`, and the guard runs before both are read. `seed-demo` runs with both keys, so it migrates (a no-op), grants `console_runtime` to the runtime user, checks it and seeds as it.
- **`test/e2e/browsertest`.** `AppDB` keeps opening `MC_DB_DSN`, now the runtime user, so the walkthroughs' fixtures write with the application's privileges. The fixtures write only writer tables (`core.users`, `core.persons`, `core.organizations`, `core.accounts`, `core.invoices`, `core.integration_config_overrides`, `core.numeric_entitlements`, `core.numeric_entitlement_usage`, `core.instance_settings`, the catalog tables, `fedwiki.sites`, `stripe.price_mappings`). A new `AppAdminDB` opens `MC_DB_MIGRATE_DSN` and skips with copy 32 when it is missing; the screens' `pinTimestamps` uses it.
- **`test/mockshot-seed.sql` and `test/mockshot-nuke.sql`** keep running as `psql -U member_console`, the migration user, which their headers already name; the nuke's comment says that the stack's migration user is a superuser, which is what lets `SET ROLE core_owner` and the direct deletes run.
- **`test/mc-config.yaml`** gets the same two strings for the default ports, so a stack run without `test/.env` uses two users too.
- **`test/seed/discourse/seed-discourse.sh`** keeps `MC_DB_DSN`: it writes through `core.confer` and `core.commit_rule_change` and into writer tables. Its header says the runtime user needs `console_runtime` first, which the first `start` or a `reset-app-db.sh` grants.
### D15. The tests that prove the locks run as `console_runtime`
The tests that prove an enclosure drop to `console_runtime` with `SET LOCAL ROLE` through the existing `asRole` helper, and `applicationRole` is deleted. `console_runtime` is exactly what the runtime user holds: the boot grant gives it, and the start-up check refuses a runtime user that holds an owner role, an object or a superuser bit beyond it. A test as `console_runtime` therefore also catches a privilege granted to the group role directly, which a test as `core_writer` would miss. `applicationRole` copied the test login's memberships; once the baselines grant no login (D6) the test login holds none, and the copy proves nothing. `TestConferralEnclosure_WriterRole` and `TestRuleTableDMLRevoked` move from `core_writer` to `console_runtime`; the act, delivery and plan-act tests move from `applicationRole`. The tests that check `core_reader` or a bare role keep doing so. `TEST_DATABASE_URL` stays the superuser, and `db.RuntimeGroupRole` names the role in every test.
### D16. A catalog test of the runtime role
`TestRuntimeRolePrivileges` (`internal/db`) reads the catalog of the migrated test database for every relation in the five streams' schemas whose kind is a table, a partitioned table or a view, partitions excluded from the first four checks (they are read and written through their parent):
- every relation is readable by `console_runtime` (`has_table_privilege(..., 'SELECT')`);
- every table that is not in the locked list holds `INSERT`, `UPDATE` and `DELETE`;
- every locked table holds exactly the privileges its entry lists among `INSERT`, `UPDATE` and `DELETE`, with column `UPDATE` checked by `has_column_privilege` for `core.desired_state_outbox`;
- a locked entry that names no relation fails the test, so the list cannot outlive a table;
- **no relation of any kind in the five schemas, partitions and views included, grants `console_runtime` `TRUNCATE`, `REFERENCES`, `TRIGGER` or `MAINTAIN`**; a failure names the relation and the first such privilege, so a migration that writes `GRANT ALL` after the revoke of D13 fails `make test` with the table and `TRIGGER`;
- every stream's ledger is readable, and `core.ensure_webhook_event_partitions` and `core.lock_plan_act_obligation` are executable (from section 3);
- no role other than `console_runtime` is a direct member of a writer role, apart from `fedwiki_writer`, which the FedWiki stream makes a member of `domains_writer`.
The locked list, with what the writer keeps:
| Table | Holds |
|---|---|
| `core.pool_provisions`, `core.pool_provision_ladders`, `core.pool_provision_transitions` | `SELECT` |
| `core.entitlement_set_rules` | `SELECT` |
| `core.entitlement_set_changes`, `core.entitlement_set_change_effects` | `SELECT` |
| `core.entitlement_set_change_obligations` | `SELECT`, `UPDATE` |
| `core.acts`, `core.plan_act_details` | `SELECT` |
| `core.plan_act_obligations`, `core.plan_act_reached_org_types`, `core.plan_act_orgs_without_pool` | `SELECT` |
| `core.desired_state_outbox` | `SELECT`, `DELETE`, `UPDATE` on `status`, `attempts`, `claimed_until`, `expansion_cursor`, `last_error`, `completed_at` |
| `core.person_cut_offs` | `SELECT` |
The catalog test counts privileges, not locking reads: a `FOR UPDATE` on a locked table fails for lack of `UPDATE` and is not a missing `SELECT`, `INSERT`, `UPDATE` or `DELETE` entry of the list. D19 covers the one such query the runtime has with a test of its own. A migration that adds a table and forgets its grant now fails `make test` instead of failing in production, a migration that over-grants fails it too, and an enclosure stays an absent grant. The test runs on the shared test database; a break in a migration file reaches it through `test/reset-test-db.sh`, which the break list runs as its `prepare` command. A role membership is cluster-wide and survives a reset, so the membership test (`TestStreamsGrantTheirWriterToTheRuntimeRole`) runs each stream's group-role Up section again inside a transaction that first revokes the memberships, and rolls back.
### D17. Docs
- **`docs/production-deployment.md`**, step 5 ("Database"), says that the console connects as two users and why the runtime user must be neither a superuser nor an owner, nor the owner of the database, and why it holds no role but `console_runtime` (including the administrative roles a hosting provider may grant a new login, which the operator revokes once, and why it does not own the database); how to create them for a new deployment (with the official image, `POSTGRES_USER` is the migration user, and the runtime login is created by hand and given a password with `\password`); the two keys; that `start` migrates only on `db-migrate-dsn` and closes that connection before it serves; and the separate-step mode (leave `db-migrate-dsn` off the service, run `member-console migrate up` as a one-shot step before each deploy, and once run the `GRANT` the refusal names). Step 7 ("First boot") gets the boot order of the sequence below. "Retry a failed desired-state delivery by hand" says "the runtime user" for "the application's database role"; the update it shows touches only the columns the runtime user may update.
- **The procedure for an existing deployment**, in the same guide, written for any deployment and with placeholders only:
1. Connected as the user the console connects as today, which becomes the migration user, create the runtime login: `CREATE ROLE <runtime user> LOGIN;`, then `\password <runtime user>` in `psql`, which keeps the password out of the shell's history and 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 it is a new secret with a new name, created from standard input (`docker secret create <new secret> -`, then the string and Ctrl-D).
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, and declare the new secret on the service and at the top level.
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: the boot log's `runtime database user checked` line names the runtime user; `SELECT usename, count(*) FROM pg_stat_activity WHERE datname = current_database() GROUP BY usename;` shows the console's sessions under it; `SELECT has_table_privilege('<runtime user>', 'core.pool_provisions', 'INSERT');` returns false; `SELECT setconfig FROM pg_db_role_setting WHERE setdatabase = (SELECT oid FROM pg_database WHERE datname = current_database());` lists only settings the operator set on purpose, because `REASSIGN OWNED` does not clear a database-level setting a former owner planted (the console's own sessions ignore a planted `search_path` or `role`, D2, but another superuser session would not); `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');` lists only `update_updated_at_column()`, because `REASSIGN OWNED` hands the functions a former owner created in `public` to the migration user, where check 9 no longer sees them and an exact-type shadow of a catalog function keeps capturing unqualified calls in later migrations.
6. Re-run `scripts/db-reader-role.sql` as the migration user, as after every deploy that migrates.
7. To roll back, deploy the previous service definition and image. The previous release connects as the migration user again; `console_runtime`, its memberships, the function and the ownership of `core.webhook_events` stay and are unused by it, and its migration run applies nothing because every version it knows is applied. Partitions it creates in that time belong to the migration user; after rolling forward again, give each to `core_owner` with `ALTER TABLE core.webhook_events_<YYYY_MM> OWNER TO core_owner;`.
The guide says that Postgres does not let the bootstrap superuser (the official image's `POSTGRES_USER`) lose `SUPERUSER`, so the existing login stays as it is and becomes the migration user.
- **`docs/environment-reference.md`**: `db-dsn` and `db-dsn-file` describe the runtime user; two rows for `db-migrate-dsn` and `db-migrate-dsn-file`.
- **`docs/database-locks.md`**: the plan act obligation row's "Taken by" cell and the move's paragraph name `core.lock_plan_act_obligation`, which the `LockPlanActObligation` query calls; the lock, its mode and its order do not change.
- **`docs/hosting.md`**: five secrets, with `db-migrate-dsn` among them.
- **`docs/database-management.md`**: "Migrations run automatically on startup" says on the migration user's connection, when `db-migrate-dsn` is set; the `ConnectAndMigrate` paragraph names the migration connection; the Roles section describes `console_runtime`, the boot grant, who owns what (D3) and that owner roles are granted to no login; the reader script runs as the migration user (`POSTGRES_USER` with the official image), and on a managed database as a user with `CREATEROLE` that owns the console's tables.
- **`docs/building-an-integration.md`** (section 7 and the conformance status): a baseline grants `{key}_writer` and `SELECT` on its ledger to `console_runtime`, never to a login; a table is granted the four DML privileges by name, never `GRANT ALL`, because the catalog test fails for any privilege beyond them (D13); the Down section leaves that membership (D7).
- **`docs/testing.md`**, **`test/AGENTS.md`** and **`test/README.md`**: the two users, the `.env` keys, `AppAdminDB`, the regeneration command, and that `TEST_DATABASE_URL` stays the superuser.
### D18. The reader script stays as it is
`scripts/db-reader-role.sql` runs as the role that owns the console's schemas, which stays the migration user (D3). Its column grants on `core.webhook_events` survive the ownership move, because Postgres rewrites a table's existing grants to the new owner. The new function revokes `EXECUTE` from `PUBLIC`, which the script's refusals require. No column is added, so `internal/migrate/testdata/console-reader-columns.txt` does not change. A deployment re-runs the script after deploying this release by the standing rule, though nothing it reads changes.
### D19. The plan-act obligation lock goes through a function owned by `core_owner`
`EnactObligation` locks the obligation of one (act, pool) with `LockPlanActObligation` (`SELECT ... FROM core.plan_act_obligations ... FOR UPDATE OF ob`), after the pool row, to read in one statement whether the act still owns the obligation's org type and who put it in motion. Postgres requires `UPDATE` on a table for a locking read. The obligations are enclosed (D16's locked list): the writer holds `SELECT` only, so that every status change goes through a function and a transition the plan acts do not name is not a privilege the writer holds. As the runtime user the query fails with `42501` at `ObligationReadObligation`, and no plan act settles its pools. Package tests run as the superuser test login and do not see it.
Two routes keep the enclosure's rule. A column `UPDATE` grant on the obligations would contradict it and is rejected. A `SECURITY DEFINER` function owned by `core_owner` takes the lock for the caller and is the route, the one the other writes to the table already take:
```sql
CREATE FUNCTION core.lock_plan_act_obligation(p_act_id UUID, p_pool_id UUID)
RETURNS TABLE (act_id UUID, pool_id UUID, org_type VARCHAR, org_id UUID, status VARCHAR,
owned BOOLEAN, motion_act_id UUID)
LANGUAGE sql
SECURITY DEFINER
SET search_path = core, pg_temp
AS $$
SELECT ob.act_id, ob.pool_id, ob.org_type, ob.org_id, ob.status,
core.plan_act_owns_org_type(ob.act_id, ob.org_type)::boolean,
COALESCE(ob.requeued_by_act_id, ob.act_id)::uuid
FROM core.plan_act_obligations ob
WHERE ob.act_id = p_act_id AND ob.pool_id = p_pool_id
FOR UPDATE OF ob;
$$;
ALTER FUNCTION core.lock_plan_act_obligation(UUID, UUID) OWNER TO core_owner;
REVOKE EXECUTE ON FUNCTION core.lock_plan_act_obligation(UUID, UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION core.lock_plan_act_obligation(UUID, UUID) TO core_writer;
```
- **The sqlc query becomes a call.** sqlc cannot see a function's output columns; the alias list names them and the casts give them their non-null types, as the repo's other queries on functions do (`ListResumableGrantsHeldBy`):
```sql
SELECT l.act_id::uuid AS act_id, l.pool_id::uuid AS pool_id, l.org_type::varchar AS org_type,
l.org_id::uuid AS org_id, l.status::varchar AS status, l.owned::boolean AS owned,
l.motion_act_id::uuid AS motion_act_id
FROM core.lock_plan_act_obligation(sqlc.arg(act_id)::uuid, sqlc.arg(pool_id)::uuid)
AS l(act_id, pool_id, org_type, org_id, status, owned, motion_act_id);
```
The generated row type is today's `LockPlanActObligationRow`, so the Go row type, the caller and the `sql.ErrNoRows` that means "skip" do not change, because the function returns no row for a missing obligation. The query's comment keeps its account of why only the obligation row is read.
- **The lock is the same lock.** One statement reads only the obligation row and locks it `FOR UPDATE` in the caller's transaction, so Postgres's recheck of a row a Retry requeued while the statement waited has no join to drop it, the lock order (the pool row, then the obligation row) is unchanged, and `docs/database-locks.md` gains no new path; only its "Taken by" cell names the function. A function with a `SET` clause is never inlined, so the definer's privileges apply.
- **`EXECUTE` goes to `core_writer`**, as for the four other definer functions of `00028` (`console_runtime` inherits it). D4's function goes to `console_runtime` alone because only the serving process creates partitions; every session that moves an obligation holds the writer role.
- **It adds no capability.** The function takes a lock and returns a row; it writes nothing. A runtime user with arbitrary SQL can already read the obligations and hold `FOR UPDATE` locks on the pool rows the same move takes.
- **The test runs the query as `console_runtime`** (task 3.12) and probes the bare statement under the same role, which fails with `42501`, so a later change that returns the lock to the table fails `make test` and a grant of `UPDATE` on the obligations fails the locked list of D16.
- **It lands before section 4.** Section 4 switches the test stack and, with it, every deployment to the runtime user; the function ships in section 3, with the partition function, because both are core migrations that give the runtime user a path through the enclosure that its role alone does not have, and both need only `console_runtime`, which section 1 creates.
## The boot sequence
With `db-migrate-dsn` set:
| Step | Connection | What it does | A failure |
|---|---|---|---|
| 1 | none | resolve the secret files, validate the configuration (`ValidateStart`) | exit 1, nothing opened |
| 2 | runtime | open the pool (`search_path=core,public` and `role=none`) and ping it | exit 1, nothing written |
| 3 | migration | open the pool with `search_path=public` and `role=none` and ping it | exit 1, nothing written |
| 4 | both | on the migration connection read `current_user` and `pg_catalog.current_database()` and nothing else; on the runtime connection read the identity (`current_user`, its quoted form, `current_database()`, `rolsuper`), the superuser memberships and the owner of the database; refuse checks 1 to 4 (the runtime half) | exit 1 with copy 1, 2, 3, 3a, 3b or 3c; nothing written |
| 5 | migration | read the database's owner and refuse an owner that is neither the migration user nor a superuser role (check 4, owner half); refuse a runtime user that can create objects in `public` or a stream schema, or owns an object in `public` (check 12); run every stream's migrations | exit 1 with copy 3d, 3e, 7 or 8, nothing written; or exit 1 on a migration, each of which commits or rolls back on its own, as today |
| 6 | migration | grant `console_runtime` to the runtime user (D8) | exit 1 with copy 10; the migrations stay applied |
| 7 | migration | close the pool; log copy 20 | a failed close is logged at Warn ("closing the migration connection", as today); the boot continues |
| 8 | runtime | checks 5 to 11 | exit 1 with copy 4 to 9, 8a, 9a or 11 |
| 9 | runtime | log copy 19; the boot continues as today: overrides, boot data state (the partition function among it), Temporal, the server | as today |
With `db-migrate-dsn` left out: step 1; step 2; the identity read and checks 3 and 4, runtime half (copies 3, 3a, 3b, 3c); check 12 on the runtime connection (copies 3e, 7, 8); copy 18 logged; checks 5 to 11; copy 19; the boot continues. No migration runs and no grant is made. `seed-demo` runs the same sequence and then seeds.
## Failure table
Every way the switch could break something, the evidence, and how this design handles it. Rows 1 to 17 were known when the change was proposed; rows 18 to 47 were found while designing it; rows 48 to 54 were added by the review of the draft; rows 55 to 58 by the second review; rows 59 to 62 by the third.
| # | What breaks | Evidence | When it shows | Handling |
|---|---|---|---|---|
| 1 | The runtime user cannot create webhook partitions; after the lookahead, webhook inserts fail for lack of a partition | `partitions.go:59-63`; `bootstate.go:73-75` logs at Warn; `activities.go:47` | silent at boot; inserts fail about three months later | D4: the function owned by `core_owner`, executable by `console_runtime`; the catalog test asserts the `EXECUTE` |
| 2 | Boot migrations fail on a connection that owns nothing | `database.go:151-172`; `start.go:165` | first boot with a pending migration | D1: migrations run only on `db-migrate-dsn` |
| 3 | `SUPERUSER` cannot be taken from the bootstrap user; an owner that is not a superuser still bypasses the revokes | catalog of the test stack | immediately | a new login; D9 checks 3, 8 and 9 |
| 4 | A new login holds nothing: the grants name `member_console` | core `00001:1173` and the four other baselines | the first query at boot (`start.go:178`) | D5 and D8; D6 removes the by-name grants from the baselines |
| 5 | Order: a release that refuses a superuser cannot boot on a configuration that predates it, and a hand grant cannot precede the migration that creates the role | the boot sequence | the first deploy | D8: the boot grant on the migration connection, so one deploy does it; the procedure creates only the login before the deploy |
| 6 | A later migration forgets a grant, which a superuser login hides and a runtime user does not | no default privileges | the first use after a deploy | D16: the catalog test |
| 7 | `applicationRole` fails once the test login is no member of `core_writer` | `integration_delivery_fixtures_test.go:237-239` | `make test` after D6 | D15: the tests drop to `console_runtime`; `applicationRole` is deleted in the same commit |
| 8 | `reset-app-db.sh`, the screens' pin and the mockshot files need a superuser on the application database | `reset-app-db.sh:115-129`; `screens_test.go:830-845`; `mockshot-seed.sql:289`; `mockshot-nuke.sql:23` | `make screens`, demos | D14: they use `MC_DB_MIGRATE_DSN` or `-U member_console` |
| 9 | An existing `test/.env` lacks the new key | `bootstrap-stack.sh:240-247` keeps an existing file | every existing worktree | D14: the guard in four scripts, copy 28 |
| 10 | The runtime login is missing from existing volumes; `initdb` scripts run only on an empty data directory | `compose.yaml:23-24` | the application's start in the stack | D14: `postgres-seed` runs on every `up` |
| 11 | `migrate`, `config list\|set\|clear` and `seed-demo` ignore `-file` keys | no `resolveSecretFiles` call in `migrate.go`, `config.go`, `seed_demo.go` | a migration or a seed in a container that mounts its secrets | D12 |
| 12 | Swarm secrets cannot be changed | the platform's secret model | the host procedure | D17: a new secret name; the old one becomes the migration user's |
| 13 | The reader script needs an owner with `CREATEROLE` | `db-reader-role.sql:15-18` | each deploy that migrates | D18: unchanged; it runs as the migration user |
| 14 | A migration user that is not a superuser fails at `OWNER TO core_owner` | `00005:619` and the other `ALTER FUNCTION` lines; now also `00031` | managed Postgres | out of scope; the proposal names what such a user needs |
| 15 | `TRUNCATE`, `TRIGGER`, `REFERENCES` and `MAINTAIN` stay open on every table, the position tables included | `00005:637-639` revokes only the three writes, and `GRANT ALL` gave the rest | never noticed | D13: every writer loses all four; rows 48 to 50 |
| 16 | `SET ROLE` per connection on one superuser login: `RESET ROLE` or `DISCARD ALL` returns a pooled connection to the superuser | Postgres's `SET ROLE` rules | any such statement | not taken: two logins |
| 17 | Roll back: the ledger is ahead of the previous release | goose's own check reports a missing migration only below the highest applied version | a roll back | D17 step 7; the previous release's run applies nothing and connects as the migration user |
| 18 | `openDB` resolving the `-file` keys after `start` already did would report the pair as ambiguous | `resolveSecretFiles` sets the value and leaves the file key set (`start.go:757-772`) | every boot | D12: each command resolves once, before it opens; `openDB` refuses an empty string instead |
| 19 | A wrong `db-dsn` (a superuser, the migration user, another database) is found only after the schema was migrated, while the previous release may still serve | the old order migrated first | a rolling deploy with a bad secret | D2: checks 1 to 4 run before the migrations |
| 20 | The two strings reach different databases, so the console migrates one and serves the other | nothing compares them today | a typo in a database name | D9 checks 2 and 7 |
| 21 | A runtime login created `NOINHERIT` gets a membership that passes no privilege | Postgres 16 and later take a grant's default `INHERIT` from the member | a host that copies `console_reader`'s recipe | D8: `WITH INHERIT TRUE`; check 6 tests `USAGE`, not `MEMBER` |
| 22 | The pending check cannot read the goose ledgers, which the migration user owns | the ledgers are created by goose on the migration connection | the separate-step mode | D5: each stream grants `SELECT` on its ledger; D10: an unreadable ledger is reported, never taken as current |
| 23 | goose's own pending check creates a missing ledger and looks for it in `current_schema()`, which is `core` on the runtime connection | `Provider.HasPending` in goose v3.24 | the separate-step mode on a never-migrated database | D10: a read-only check of our own |
| 24 | A Down section that revokes a cluster-wide membership takes privileges from every database in the cluster, including the shared test database and the running stack, when a test rolls a migration back on a scratch database | memberships live in `pg_auth_members`, not in a database | a migration test of a Down | D7 |
| 25 | A superuser passes every `pg_has_role` test, so a membership or ownership check would pass a superuser | Postgres's role functions | a superuser runtime user | D9: check 3 runs first |
| 26 | Membership in the migration user, or in any role that owns console objects, gives owner rights without an `*_owner` role | ownership is inherited through membership | a runtime login made a member of the migration user | D9 check 9 tests the owner with `MEMBER` |
| 27 | `pg_write_all_data`, or a grant to `PUBLIC`, gives writes on enclosed tables | Postgres's predefined roles; `has_table_privilege` counts both | a host's hand grant | D9 checks 10 and 11; the catalog test sees a `PUBLIC` grant through `has_table_privilege` |
| 28 | An owned invoker function can be replaced, and a trigger function runs as `core_owner` inside a definer function | `core.sync_pool_provision_ladders`, `public.update_updated_at_column` | a runtime user that owns a function | D9 check 9 covers functions and `public` |
| 29 | Every database owner is a member of `pg_database_owner`, a role whose name ends in `_owner` | Postgres's predefined roles | a runtime user that owns the database | D9 check 8 names the five stream owner roles, and check 4 refuses a runtime user that owns the database |
| 30 | The reader script cannot grant on `core.webhook_events` once `core_owner` owns it, when the script's runner is an owner that is not a superuser | a grant needs ownership or a grant option | a managed database | out of scope with row 14; a superuser runner grants as before |
| 31 | Roll back and forward again leaves the partitions the previous release created owned by the migration user | the previous release creates partitions as its own user | after a roll back | D17 step 7 gives the statement; the runtime user owns none of them, so the check passes |
| 32 | The new function would let `console_reader` run a definer function if `PUBLIC` kept `EXECUTE`, and the reader script refuses to run then | `db-reader-role.sql:41-52`; `TestConsoleReaderColumns` | `make test` | D4 revokes `EXECUTE` from `PUBLIC` |
| 33 | Regenerating `test/.env` while the stack is up moves it to another port slot, because bootstrap finds the stack's own ports bound | `bootstrap-stack.sh:279-286` | an existing worktree | copy 28 stops the stack before it regenerates |
| 34 | The farm-name test's fixture `.env` lacks `MC_DB_MIGRATE_DSN`, so the bootstrap guard refuses it | `test/scripts_farm_name_test.go:77-80` | `make test` in section 4 | the fixture gains the key in the same commit |
| 35 | A walkthrough fixture that writes an enclosed table would fail as the runtime user | `AppDB` callers | the walkthroughs | checked: no fixture writes one (D14); only the pin needs the superuser, through `AppAdminDB` |
| 36 | `seed-discourse.sh` as the runtime user before any start has no privileges | it reads `MC_DB_DSN` | a hand seed on a fresh stack | D14: its header names the precondition |
| 37 | `migrate` with no `db-migrate-dsn` would run DDL as the runtime user, or as libpq's default user with an empty string | `openDB` reads one key today | a migration step configured with the old key | copy 12 |
| 38 | An empty `db-dsn` lets pgx connect with libpq's defaults in the `config` commands, which run no validation | `config.go:125` opens without `ValidateStart` | an operator's shell without the variables | copy 13 |
| 39 | The boot grant fails when the migration user cannot grant `console_runtime` | a non-superuser migration user without `ADMIN` on the role | managed Postgres | copy 10; out of scope with row 14 |
| 40 | Two processes boot at once and both grant | a rolling deploy | the first boot | the grant is idempotent (D8) |
| 41 | The pending check reads `public.goose_db_version_<stream>`; a migration user whose name matches an existing schema makes goose create the ledgers in that schema | goose creates its table unqualified | an unusual cluster | removed by the migration pool's `search_path=public` (D2): goose creates its ledgers in `public`, whatever the user's name |
| 42 | The start-up check's own queries fail (a dropped connection, a missing catalog privilege) | any read | the boot | copy 11; a failed read never passes the check |
| 43 | A test of the check creates roles, which are cluster-wide | `CREATE ROLE` | `make test` | the check's table test creates its roles inside a transaction it rolls back; the boot test's login roles are dropped in cleanup |
| 44 | The group-role migrations' revoke from `member_console` is cluster-wide, so another database in the same cluster that still serves an older release as a member, not a superuser, of the writers would lose them | `pg_auth_members` | a cluster shared with an older release | every documented deployment serves the older release as a superuser, which needs no membership; the revoke runs only where the membership exists |
| 45 | The revoke from `member_console` fails on a cluster with no role of that name, because Postgres treats a revoke from a role that does not exist as an error, where a revoke of an absent membership from an existing role is a warning | a probe on Postgres 18 | a new deployment whose migration user has another name | D6: the baselines grant no login, so a fresh install needs no such role, and the revoke in each group-role migration is guarded on the role existing and on the membership |
| 46 | A member of a superuser role is not itself a superuser, and `SET ROLE` makes it one | `rolsuper` is not inherited; `SET ROLE` needs only membership | a runtime login granted the migration user, or a distribution's `postgres` role | D9 check 3, copy 3a |
| 47 | The screens' snapshot rebuilds, because its fingerprint covers the migrations | `reset-app-db.sh:97` | the first `make screens` after section 1 | expected; the rebuilt snapshot's rows and pinned clocks are the same, so no screen changes |
| 48 | A writer with `TRIGGER` on an enclosed table attaches a trigger that fires inside a definer function with `core_owner`'s privileges and writes the locked tables | Postgres runs a trigger with the privileges of the user whose statement fired it; `GRANT ALL ON ALL TABLES` gives `TRIGGER`; a function in `pg_temp` can be created by any role with `TEMP`, which `PUBLIC` holds | a runtime user that can run arbitrary SQL, the case the fence exists for | D13 revokes `TRIGGER` from every writer; D16 asserts it on every relation; `TestConferralEnclosure_WriterRole` probes `CREATE TRIGGER` on the position tables |
| 49 | `TRUNCATE` empties an enclosed table, `TRUNCATE` and `MAINTAIN` allow `LOCK TABLE ... ACCESS EXCLUSIVE`, which blocks every other session, and `REFERENCES` lets a writer add a foreign key to a table it does not own | the same `GRANT ALL`; Postgres 17 and later define `MAINTAIN` | the same | D13 revokes all four; the enclosure test probes `TRUNCATE` and `LOCK TABLE` |
| 50 | A later migration's `GRANT ALL ... TO {key}_writer` hands the four privileges back | the five baselines and seven later migrations use `GRANT ALL` | any future table | D16: the catalog test fails naming the table and the privilege; the new migrations cannot revoke what a later migration grants |
| 51 | The runtime user owns the database: it is a member of `pg_database_owner`, which owns `public`, so it can drop and replant the goose ledgers, and it can run `ALTER DATABASE ... SET search_path`, which a later superuser session in the database honours; an unqualified function call there runs the user's own function as the superuser | Postgres's role rules; `pg_database_owner` membership follows ownership of the database | a `db-dsn` login created as the database's owner, or a provider that makes the connecting role the owner | D9 check 4, before the migrations; the fix is `REASSIGN OWNED BY <user> TO CURRENT_USER;`, which moves the database too |
| 52 | A database-level setting, or a function in `public`, planted while the runtime user once owned the database survives the move: `REASSIGN OWNED` leaves `pg_db_role_setting` alone and hands the user's functions in `public` to the migration user, where check 9 no longer sees them and an exact-type shadow of a catalog function (`public.quote_ident(name)`) keeps capturing unqualified calls | `pg_db_role_setting`; Postgres's function resolution prefers an exact argument-type match in any schema of the path | a deployment that ran with that configuration, with arbitrary SQL at that time | recorded risk, not a check (Risks); the console's sessions ignore a planted `search_path` or `role` (D2, rule 2); D17 step 5 reads the settings and the functions of `public` once |
| 53 | The runtime user holds a predefined or administrative role directly: `pg_execute_server_program` runs a program as the server's operating-system user, `pg_read_server_files` reads server files, `pg_write_all_data` writes every table whatever the revokes say, and a provider's administrative role may grant more | Postgres's predefined roles | a host that grants the roles by hand, or a provider that grants them to a new login | D9 check 10 refuses any direct membership but `console_runtime`, naming `REVOKE <role> FROM <user>;` |
| 54 | A temporary table named like a ledger shadows it: Postgres searches `pg_temp` before the other schemas for an unqualified name, so the pending check could read a forged ledger | the runtime connection's `search_path` is `core,public` | a runtime user with arbitrary SQL, in the separate-step mode | D10 reads `public.goose_db_version_<stream>` by qualified name |
| 55 | A locking read of an enclosed table needs `UPDATE`, and the console's superuser login hid it: the plan-act obligation move's `SELECT ... FOR UPDATE` on `core.plan_act_obligations` fails with `42501` as `console_runtime` | `plan_acts.sql:55-66`; `plan_act_move.go:93`; Postgres requires `UPDATE` for any locking read; the writer holds `SELECT` only (D16); a probe as `console_runtime` fails on this table and passes on the two enclosed tables that keep `UPDATE` | every obligation move once the console serves as the runtime user, so no plan act settles its pools; no package test sees it, because they run as the superuser | D19: `core.lock_plan_act_obligation` takes the lock; section 3 lands it before section 4 switches the stack; `TestLockPlanActObligationRunsAsTheRuntimeRole` runs the query as `console_runtime` |
| 56 | The migration connection runs SQL the database's owner controls before check 4 can refuse: the owner can plant `public.quote_ident(name)` or set a database-level `search_path`, and the identity query's unqualified call then runs the owner's code in the migration user's superuser session | an exact-type function in `public` beats the catalog's whatever the path; `ALTER DATABASE ... SET search_path` applies to every new session | `start` with a runtime login that owns the database and a password held outside the console | D2: until the runtime half of check 4 passes, the migration connection runs `SELECT current_user, pg_catalog.current_database()` only; the superuser bit and the quoted name come from the runtime connection, through qualified names; every migration pool opens with `search_path=public` and `role=none`; `TestOpenRuntimeDatabase` plants the function and expects copy 3b and no marker row |
| 57 | `migrate up` runs the whole migration run against a database that a login with a password outside the console owns, and `start` refuses only afterwards | the separate-step mode runs `migrate up` with no check | the same | D2, rule 3 and D9 check 4 (owner half): every `migrate` command and `start` refuse an owner that is neither the migration user nor a superuser role, copy 3d; `migrate status` is included because goose's `Status` creates a missing ledger |
| 58 | A host's `GRANT TRIGGER`, `TRUNCATE`, `REFERENCES` or `MAINTAIN` on a console table to the runtime user, or to `PUBLIC`, reopens the trigger route that D13 closes by their absence | the catalog test reads migrations, and checks 9 and 10 read ownership and memberships | a host's hand grant | D9 check 11 reads the four privileges on every relation of the five schemas, and copy 9 names the privilege, the relation and the grantee, or copy 9a the predefined role |
| 59 | A refusal quotes a name with an unqualified `quote_ident`, so a runtime user that owns the database can plant `public.quote_ident(name)` and write the statement the console tells the operator to run as the migration user, a superuser | an exact-type function in `public` beats the catalog's whatever the path; the runtime identity read called it in the runtime session | `start` with a runtime login that owns the database | D2, the qualification principle: every function and catalog relation of the check queries on both connections is `pg_catalog`-qualified; the structural test scans the query constants; `TestOpenRuntimeDatabase`'s planted-function row expects no marker row |
| 60 | A database's owner runs `ALTER DATABASE ... SET role = '<itself>'`, so the migration user's session starts as that owner: the identity read returns the owner, the owner half compares the owner with itself and passes, and goose runs as the owner | a `role` setting applies at session start and a superuser can take any role | `start` or `migrate up` on a database owned by a role with a password outside the console | D2, rule 2: `role=none` on the migration pool; the owner-set-role row expects copy 3d and the ledgers untouched |
| 61 | The runtime login runs `ALTER ROLE CURRENT_USER SET role = 'console_runtime'`, so its sessions report `current_user = console_runtime`; the boot grant fails with an unrelated error, and in the separate-step mode check 10 prints `REVOKE core_writer FROM console_runtime;`, which strips the writer from every runtime user in the cluster | the same | a runtime login with arbitrary SQL | D2, rule 2: `role=none` on the runtime pool; the self-set-role row expects a normal boot |
| 62 | A runtime user holds `CREATE` on `public` (the ACL an upgraded database keeps from before Postgres 15, or a host's grant) or on a stream's schema, plants `public.format(text, text)`, and the next start's boot grant or an unqualified call in a pending migration runs it as the superuser; check 9 sees it only after the migrations | a probe on Postgres 18: the planted function ran as the superuser | a non-default configuration, plus arbitrary SQL at that time | D9 check 12, before the migrations: copy 3e for the privilege, copy 7 or 8 for an owned object in `public`; D8 calls only qualified functions |
## Copy
Every sentence a person reads that this change adds or changes. The UI copy rules (`AGENTS.md`, `openspec/specs/ui-vocabulary/spec.md` "Copy earns its place") apply to these strings: no filler phrase, no em dash, facts only. Angle brackets mark a value filled in at run time; a quoted name is quoted by `quote_ident`. Every entry waits for approval.
### Refusals
`start` logs each with copy 17 as the message and the sentence as its `error` attribute; `seed-demo` returns it as its error; `migrate` and the `config` commands print theirs as they print their errors today. The privilege of copies 9 and 9a is `INSERT` for the probe of `core.pool_provisions` and one of `TRUNCATE`, `REFERENCES`, `TRIGGER` or `MAINTAIN` for the second read, the relation is written `schema.relation` with each part quoted as the database quotes an identifier, and the role of copies 3a to 8 that sits in the `<direct role>` or `<quoted direct role>` position is quoted for the statement. Copy 8a prints the quoted name in both places. The grantee of copy 9 is `PUBLIC`, or a role name quoted as the database quotes an identifier; the predefined role of copy 9a is the one that gives the privilege, and its member is quoted.
| # | Where | Text |
|---|---|---|
| 1 | start, check 1 | `db-dsn and db-migrate-dsn both log in as <user>; db-dsn needs a user of its own that is not a superuser and owns nothing` |
| 2 | start, check 2 | `db-dsn connects to database <name> and db-migrate-dsn to database <name>; both must name the same database` |
| 3 | start, check 3 | `db-dsn logs in as <user>, a superuser; set db-dsn to a user without SUPERUSER` |
| 3a | start, check 3, through a membership | `db-dsn logs in as <user>, a member of the superuser <role>; as the migration user, run: REVOKE <quoted direct role> FROM <quoted user>;` |
| 3b | start, check 4, the user owns the database | `db-dsn logs in as <user>, which owns the database <name>; as the migration user, run: REASSIGN OWNED BY <quoted user> TO CURRENT_USER;` |
| 3c | start, check 4, through a membership | `db-dsn logs in as <user>, a member of <owner>, which owns the database <name>; as the migration user, run: REVOKE <quoted direct role> FROM <quoted user>;` |
| 3d | start and every `migrate` command, check 4, owner half | `database <name> is owned by <owner>, not the migration user; as the migration user, run: ALTER DATABASE <quoted name> OWNER TO CURRENT_USER;` |
| 3e | start, check 12, the user can create objects in a schema | `db-dsn logs in as <user>, which can create objects in schema <schema> through a grant to <grantee>; as the migration user, run: REVOKE CREATE ON SCHEMA <quoted schema> FROM <grantee>;` |
| 4 | start, checks 5 and 7 | `the schema is behind this release (<detail>); run member-console migrate up with db-migrate-dsn set, then start again` |
| 4a | detail, check 5 | `console_runtime does not exist` |
| 4b | detail, check 7 | `<stream> has no migration ledger` |
| 4c | detail, check 7 | `<user> cannot read <ledger>` |
| 4d | detail, check 7 | `pending migrations: <stream> <version>, <version>; <stream> <version>` |
| 5 | start, check 6 | `db-dsn logs in as <user>, which is not a member of console_runtime; as the migration user, run: GRANT console_runtime TO <quoted user> WITH INHERIT TRUE;` |
| 6 | start, check 8 | `db-dsn logs in as <user>, a member of <owner role>; as the migration user, run: REVOKE <quoted direct role> FROM <quoted user>;` |
| 7 | start, check 9, the user owns the object | `db-dsn logs in as <user>, which owns <kind> <object>; as the migration user, run: REASSIGN OWNED BY <quoted user> TO CURRENT_USER;` |
| 8 | start, check 9, through a membership | `db-dsn logs in as <user>, a member of <owner>, which owns <kind> <object>; as the migration user, run: REVOKE <quoted direct role> FROM <quoted user>;` |
| 8a | start, check 10 | `db-dsn logs in as <user>, a direct member of <quoted role>; as the migration user, run: REVOKE <quoted role> FROM <quoted user>;` |
| 9 | start, check 11, through a grant | `db-dsn logs in as <user>, which holds <privilege> on <relation> through a grant to <grantee>; as the migration user, run: REVOKE <privilege> ON <relation> FROM <grantee>;` |
| 9a | start, check 11, through a predefined role | `db-dsn logs in as <user>, which holds <privilege> on <relation> through <predefined role>, granted to <quoted member>; as the migration user, run: REVOKE <predefined role> FROM <quoted member>;` |
| 10 | start, the boot grant | `could not grant console_runtime to <user> on the db-migrate-dsn connection: <error>` |
| 11 | start, a check query | `could not check the db-dsn user: <error>` |
| 12 | migrate, no migration string | `member-console migrate needs db-migrate-dsn (MC_DB_MIGRATE_DSN or MC_DB_MIGRATE_DSN_FILE)` |
| 13 | config commands and seed-demo, no runtime string | `db-dsn is not set (MC_DB_DSN or MC_DB_DSN_FILE)` |
| 14 | `ValidateStart` | `db-migrate-dsn is not a valid PostgreSQL URL (want postgres://user:pass@host:5432/dbname)` |
| 15 | secret resolution | `db-migrate-dsn: both a direct value and a file path are set; use only one` |
| 16 | secret resolution | `db-migrate-dsn: failed to load configuration from file <path>: <error>` |
### Log lines
| # | Where | Text |
|---|---|---|
| 17 | start, the message of a refusal at Error | `refused to start` |
| 18 | start and seed-demo at Info, separate-step mode | `migrations not run: db-migrate-dsn is not set` |
| 19 | start and seed-demo at Info, after the check | `runtime database user checked`, with attributes `db_user` and, when `db-migrate-dsn` is set, `migration_user` |
| 20 | start at Info, reused | `database migrations applied`, now logged after the migration connection closes |
### Flag help
| # | Flag | Text |
|---|---|---|
| 21 | `--db-dsn` (changed) | `PostgreSQL connection string of the runtime user (e.g., postgres://user:pass@localhost:5432/dbname?sslmode=disable; required, or db-dsn-file)` |
| 22 | `--db-dsn-file` (changed) | `Path to file containing the runtime user's PostgreSQL connection string` |
| 23 | `--db-migrate-dsn` | `PostgreSQL connection string of the migration user; start runs migrations only when it is set` |
| 24 | `--db-migrate-dsn-file` | `Path to file containing the migration user's PostgreSQL connection string` |
### Database errors
| # | Where | Text |
|---|---|---|
| 25 | `core.ensure_webhook_event_partitions` | `p_now must not be null` |
| 26 | `core.ensure_webhook_event_partitions` | `months ahead must be between 0 and 12, got <n>` |
### The starter configuration (`internal/embeds/mc-config.yaml`)
| # | Where | Text |
|---|---|---|
| 27 | above `db-dsn`, replacing `# PostgreSQL connection string.` | `# PostgreSQL connection string of the runtime user: not a superuser, owns nothing.` |
| 27a | a new commented line after `db-dsn` | `#db-migrate-dsn: "" # the migration user's string; start runs migrations only when it is set` |
### The test stack
| # | Where | Text |
|---|---|---|
| 28 | the guard in `bootstrap-stack.sh`, `reset-test-db.sh`, `reset-app-db.sh`, `seed-demo.sh` (stderr, exit 1) | `error: MC_DB_MIGRATE_DSN is not set in <env file>.`, a blank line, `This .env predates the stack's runtime database user. Regenerate it:`, a blank line, ` cd test && docker compose down && rm .env && ./bootstrap-stack.sh && docker compose up -d`, a blank line, `Volumes survive, and postgres-seed adds the runtime user to the existing cluster.` |
| 29 | `bootstrap-stack.sh` summary, replacing the Postgres line | ` Postgres: localhost:<port> (runtime member_console_runtime / member_console_runtime, migration member_console / member_console)` |
| 30 | `postgres-seed` output | `runtime user member_console_runtime ready` |
| 31 | `postgres-seed` failure | `ERROR: postgres-seed gave up waiting for postgres` |
| 32 | `browsertest.AppAdminDB` skip | `MC_DB_MIGRATE_DSN not set in test/.env; cd test && docker compose down && rm .env && ./bootstrap-stack.sh && docker compose up -d` |
## Tests
Tests follow `docs/testing.md`. The tasks list each test with the breaks it must catch.
- **The group role** (`internal/db`): `TestStreamsGrantTheirWriterToTheRuntimeRole` runs, in a transaction it rolls back, a revoke of the five memberships and a grant of the five writers back to `member_console` (when that role exists), which simulates a database an earlier release migrated; then the Up sections of the five new group-role migrations (`dbtest.UpSQL` on a `dbtest.SimpleProtocol` connection), then asserts that `console_runtime` inherits each writer, that `member_console` holds none, and that `console_runtime` reads each ledger. A further row runs each group-role Up section with `member_console` replaced by a name no role has (`dbtest.Name("mc_absent_")`) and expects no error, which proves the guard of D6; on the test stack `member_console` always exists, so no other row would notice the guard missing. `TestBaselinesGrantNoLogin`, on a scratch database, migrates core to the version before `00029` with `dbtest.UpTo`, then version 1 of domains, FedWiki, Stripe and Discourse, and asserts that `member_console` is a direct member of no writer role afterwards (D6). Core goes first and goes that far because a real run migrates core to its end before any other stream (domains' baseline inserts a `kind` column that core `00008` adds), and the run stops before the group-role migrations because they would revoke a restored grant and hide the break. A test on a scratch database applies core `00029` up and down with `dbtest.ApplyVersion` and asserts that the role survives the Down while another database still grants it, and that the memberships survive (D7).
- **The catalog** (`internal/db`): `TestRuntimeRolePrivileges` (D16), which also fails for a baseline or a later migration that leaves `GRANT ALL` on a table, naming the table and `TRIGGER`.
- **The enclosures**: the existing tests, as `console_runtime` (D15); `TestConferralEnclosure_WriterRole` gains the `TRUNCATE`, `CREATE TRIGGER` and `LOCK TABLE` probes (D13).
- **The partition function** (`internal/db`): `TestEnsureWebhookEventPartitions_ClosesTheGapAndIsIdempotent` runs, in one transaction it rolls back, under `SET LOCAL ROLE console_runtime`: the insert into the synthetic month fails in a savepoint, the ensure creates the four partitions, the insert lands, a second ensure creates nothing, and each created partition is owned by `core_owner`. `TestWebhookPartitionsBelongToCoreOwner` asserts the parent's and every partition's owner, the function's `SECURITY DEFINER`, owner, `search_path`, and that `PUBLIC` cannot execute it. `TestRuntimeRoleCannotCreatePartitionsDirectly` runs the partition DDL as `console_runtime` and expects `42501`. A row runs the function with 13 months and with a null time and expects `22023`. `TestWebhookOwnershipMoveRoundTrips` runs `00031` up, down and up again on a scratch database and compares `schemaSnapshot` (which records `relowner` and the ACLs, rendered order-insensitively: `schemaSnapshot` sorts the entries of `relacl`, `attacl`, `nspacl` and `proacl`, so a re-granted entry appended at the end compares equal) after the down with the snapshot before the up, and after the second up with the snapshot after the first. The Down's re-grant to `core_owner` (D4) is what makes the first comparison hold. `TestBaselinePartitionBoundsAreUTC` applies core version 1 through a connection whose `TimeZone` is `Pacific/Kiritimati` and asserts the three partitions' bounds are the UTC month starts.
- **The obligation lock** (`internal/entitlements`, `internal/db`): `TestLockPlanActObligationRunsAsTheRuntimeRole` runs `LockPlanActObligation` and the reads around it in a transaction it rolls back under `SET LOCAL ROLE console_runtime`, and asserts the row of `TestLockPlanActObligationReadsTheMove`, the requeued row, and `sql.ErrNoRows` for a missing obligation; in the same transaction, a savepoint runs the bare `SELECT ... FOR UPDATE` on `core.plan_act_obligations` and expects `42501`. `TestPlanActWritersAreEnclosedInTheCatalog` (`internal/db/plan_act_obligations_test.go`) gains `lock_plan_act_obligation` in its list of functions and in its `PUBLIC` and `core_reader` refusal loop, which asserts that the function is `SECURITY DEFINER`, owned by `core_owner`, configured `search_path=core, pg_temp`, not executable by `PUBLIC` and `core_reader`, and executable by `core_writer` and `console_runtime`, with no new test. `TestLockPlanActObligationSurvivesARequeueItWaitedOn` and the plan-act drain tests run unchanged and prove that the lock is still taken and that the recheck still returns the row.
- **The pending check** (`internal/db`): on scratch databases migrated in part with `dbtest.UpTo`: a stream behind lists its versions; a stream with no ledger lists all; a ledger `console_runtime` cannot read is reported; a database migrated in full has none; a temporary table named like a ledger, created on the checking connection, changes nothing.
- **The start-up check** (`internal/db`): `TestCheckBeforeMigrating` and `TestCheckRuntimeUser`, one row per refusal through a named function, each inside a transaction that creates the roles it needs and drops to one with `SET LOCAL ROLE` (the database-ownership rows run on a scratch database owned by a role the test creates): a clean member of `console_runtime` passes both; a member of a superuser role; the owner of the database, and a member of the role that owns it; a login that holds `CREATE` on `public` through a grant to `PUBLIC`, and the owner of a function in `public` (check 12, through `CheckSchemasBeforeMigrating`, as the migration connection would run it); not a member of `console_runtime` (a role name that needs quoting, so the copy's quoting shows); a direct and an indirect member of `core_owner`; the owner of a table in `core`, of a function in `core` and of a function in `public`, and a member of a role that owns a table; a direct member of an unrelated role, of `pg_execute_server_program`, of `pg_read_server_files` and of `pg_write_all_data`; a holder of `INSERT` on `core.pool_provisions` through a grant to the user and through a grant to `PUBLIC`; a holder of `TRIGGER` on a table of `core` through a grant to the user (copy 9 naming the user as grantee), of `TRUNCATE` on a table through a grant to `core_reader`, which `core_writer` holds (copy 9 naming `core_reader`), and of `MAINTAIN` through `pg_maintain` granted to `console_runtime` (copy 9a). The rows for copies 3c and 6 through another role use an intermediate role whose name needs quoting (`Mc-Via-<n>`), so the quoted direct role shows. Each asserts the exact sentence.
- **The boot sequence** (`cmd`): `TestOpenRuntimeDatabase`, on scratch databases with login roles the test creates and drops: with both strings, the database is migrated, the runtime login inherits `console_runtime`, the returned pool serves as the runtime login, and no session of the migration user stays open on the scratch database; with one user for both, the refusal is copy 1 and the ledger is untouched; with a superuser runtime login, copy 3 and the ledger untouched; with a runtime login that owns the scratch database, copy 3b and the ledger untouched; with that login owning the scratch database and having planted `public.quote_ident(name)`, which writes a marker row (recording `current_user` and `is_superuser`) into a table the test created and the login may insert into, copy 3b, the ledger untouched and no marker row (neither connection runs the function: the migration connection's identity read is qualified and the runtime connection's is too); with a runtime login that holds `CREATE` on `public` and has planted `public.format(text, text)` with such a marker, copy 3e and no marker row; with a runtime login that owns a function in `public` and holds no `CREATE`, copy 7 before any migration; `migrate up`, `migrate down` and `migrate status` against a scratch database owned by a role that is neither the migration user nor a superuser, copy 3d and the ledger untouched; the same against a database owned by a third role that has run `ALTER DATABASE ... SET role = '<itself>'`, copy 3d from `migrate up` and the ledgers untouched; with a runtime login that is a member of `console_runtime` and has run `ALTER ROLE CURRENT_USER SET role = 'console_runtime'`, a normal boot with the login as `current_user` on the returned pool; with `ALTER DATABASE` setting a `search_path` that names a schema the test created, the migrations run through the new pool and the ledgers are in `public`; with a runtime login that holds `pg_read_server_files`, copy 8a; with two databases, copy 2; with `db-migrate-dsn` left out on a database behind, copy 4 and no migration ran; with it left out on a current database, the pool is returned and nothing ran. Every row runs with `env` set to `development`.
- **The migration pool** (`internal/db`, in a `db_test` file, because the test exercises `db.ConnectMigration` and `cmd` cannot import `schemaSnapshot`): `TestMigrationPoolIgnoresADatabaseSearchPath` on scratch databases the test creates: a database whose `ALTER DATABASE` sets `search_path` to a schema the test created, migrated through the pool, with `SHOW search_path` returning `public` and every ledger in `public`; a database that sets `role` to a role the test created, whose pool reports `current_user` as the migration login; and two fresh scratch databases with no extra schema, one migrated through the pool and one by a plain replay, with equal `schemaSnapshot`s.
- **The qualification principle** (`internal/db`): `TestCheckQueriesQualifyEveryName` reads the string constants whose names begin `query` in `runtime_user.go` and `migrations.go` (the check queries, the boot-grant statement and the pending check's queries) and fails, naming the query and the token, for a function call or a catalog relation that lacks `pg_catalog.`. A function call is an identifier followed by `(` that no `pg_catalog.` precedes; a catalog relation is any `pg_`-prefixed identifier without it. String literals and `--` comments are removed first. The exceptions are listed in the test, each with its reason: `ANY`, `ALL`, `IN`, `EXISTS`, `VALUES`, `CAST`, `COALESCE`, `CASE`, `AND`, `OR`, `ON`, `FROM`, `JOIN`, `SELECT`, `WHERE`, `WHEN`, `THEN` and `ELSE` are SQL syntax that precedes a parenthesis, and an identifier directly after `AS` is an alias with a column list (`AS p(priv)`). Any other identifier before a parenthesis fails the scan, so a new function must be qualified or listed with its reason.
- **The `-file` keys** (`cmd`): `TestDatabaseCommandsReadTheFileKeys`, one row per command (`migrate up`, `migrate status`, `config list`, `config set`, `config clear`, `seed-demo`): the key's `-file` variant names a file holding a string that reaches a closed port, and the command's error is a connection error to that port, not copy 12 or 13.
- **The stack scripts** (`test`): `TestScriptsRefuseAnEnvWithoutTheMigrationDSN`, with the shims of `TestScriptsRefuseUnknownArguments`, runs the four scripts beside a `.env` that has every other key and asserts copy 28, exit status 1, and that no shim ran.
- **Deliberate breaks.** Each new test is proved with `scripts/mutation-check` on the new side; the breaks are in `tasks.md` and in each commit message.
## Risks / Trade-offs
- **The serving process still holds the migration password when it migrates at startup.** The fence binds code paths, not a compromised process. The separate-step mode is the answer for a host that needs more, at the cost of a deploy step.
- **Every deployment changes its configuration to upgrade.** The release refuses a superuser `db-dsn`, so a deploy without the procedure does not start. The refusal says what to set, and the previous release can be redeployed unchanged (D17 step 7).
- **The check reads the catalog at every boot.** The queries read `pg_class` and `pg_proc` of the console's schemas and `public` once; that is a few thousand rows.
- **A host can still weaken the runtime user after boot.** The check runs at start; a grant made while the console runs takes effect until the next start, when the check refuses it.
- **A database-level setting or a function in `public` planted earlier is not found.** If the runtime user once owned the database and ran `ALTER DATABASE ... SET search_path` or `SET role`, or created a function in `public`, moving the ownership (check 4, copy 3b) does not clear the setting, and `REASSIGN OWNED` hands the function to the migration user, where check 9 no longer sees it. A later superuser session in that database (not the console's own sessions, which open with `search_path` and `role=none` startup parameters, D2) would honour the setting, and an exact-type shadow of a catalog function would keep capturing unqualified calls in later migrations. It takes a past misconfiguration plus arbitrary SQL at that time, and refusing database-level settings would also refuse legitimate ones, so it is a recorded risk and not a check. The procedure in `docs/production-deployment.md` reads `pg_db_role_setting` and the functions of `public` once.
- **A membership granted by a non-superuser survives the printed `REVOKE`.** A membership granted by a role that holds `ADMIN` on the target and is not a superuser is recorded with that grantor, and `REVOKE` run by the migration user prints a warning and leaves the row; `REVOKE ... GRANTED BY <grantor>` removes it. With a superuser migration user this happens only for grants a non-superuser made, and the next start refuses again, naming the same role.
- **The migration pool's `search_path=public` and `role=none` are different custom parameters from the one the plain pool avoided.** The reason for a plain pool, in `docs/database-management.md`, is a pgx and Postgres 18 interaction with the application's `core,public` path. `public` alone is what the default path resolves to when no schema is named for the user, which no documented deployment has; section 4's test migrates a scratch database through the new pool and compares its `schemaSnapshot` with a plain replay's, so a dropped object fails `make test`.
- **A hosting provider that gives every new login administrative roles makes the first start refuse.** Check 10 refuses any direct membership but `console_runtime`; the refusal prints the `REVOKE`, and the operator runs it once. The alternative, a list of refused roles, would miss the next role a provider adds.
- **The writers lose privileges nothing in the runtime path used.** A later feature that needs `TRUNCATE`, `TRIGGER`, `MAINTAIN` or `REFERENCES` on a writer table must do it through a migration-owned function, not a writer grant; the catalog test names the table and the privilege when a migration grants one.
@@ -0,0 +1,74 @@
## Why
The migrations enclose several tables behind `SECURITY DEFINER` functions by revoking direct writes from `core_writer`: the three position tables (`core.pool_provisions`, `core.pool_provision_ladders`, `core.pool_provision_transitions`), `core.entitlement_set_rules`, the rule-change ledger, the act record, `core.person_cut_offs` and the insert enclosure of `core.desired_state_outbox`. None of these revokes binds the console itself, for four reasons.
1. **The console connects as the bootstrap superuser.** The official `postgres` image creates `POSTGRES_USER` as the cluster's bootstrap superuser, and the test stack (`test/compose.yaml`, `test/bootstrap-stack.sh`) and the documented deployment both connect as that user. A superuser passes every privilege check except the right to log in, so the revokes never apply to it. Postgres refuses to remove `SUPERUSER` from the bootstrap user, so the fix is a second login, not an `ALTER ROLE`.
2. **The same login owns the objects.** It owns every schema, table, view, sequence and goose ledger the migrations create; only the definer functions belong to `core_owner`. An owner holds every privilege on its own tables, so a login that stopped being a superuser but still owned the tables would still bypass the revokes. The login the console runs as must own nothing and must not be able to act as an owner.
3. **The runtime path runs DDL.** `db.EnsureWebhookEventPartitions` (`internal/db/partitions.go`) creates the monthly partitions of `core.webhook_events` at boot (`cmd/bootstate.go`) and from the Temporal maintenance schedule (`internal/workflows/maintenance/activities.go`). Creating a partition needs `CREATE` on schema `core` and ownership of the parent table. `start` also runs every migration at boot on the connection string it then serves from (`db.ConnectAndMigrate`, `internal/db/database.go`).
4. **The writers hold more than the four DML privileges.** `GRANT ALL ON ALL TABLES ... TO {key}_writer` also gives `TRUNCATE`, `REFERENCES`, `TRIGGER` and `MAINTAIN`. With `TRIGGER` on a table, a role can attach a trigger to it; Postgres runs a trigger with the privileges of the user whose statement fired it, so a `SECURITY DEFINER` function owned by `core_owner` that writes that table runs the trigger as `core_owner`, which writes the locked tables directly. `TRUNCATE` and `MAINTAIN` also empty or lock a table.
5. **One runtime query takes a row lock on an enclosed table.** `LockPlanActObligation` (`internal/entitlements/queries/plan_acts.sql`) runs `SELECT ... FROM core.plan_act_obligations ... FOR UPDATE`, and Postgres requires `UPDATE` on at least one column of a table for any locking read. The writer holds `SELECT` only on that table, because the obligations are enclosed rather than given column `UPDATE` grants, so the query works today only because the login is a superuser, and every plan-act obligation move would fail with a privilege error once the console serves as the runtime user.
A non-superuser login that owns nothing would therefore fail its first partition, its first plan-act obligation move and its first migration, and it would hold no privileges at all, because the five migration streams grant their writer roles to the login by the name `member_console` (`GRANT core_writer TO member_console` in core's `00001_init.sql`, and the same in the domains, FedWiki, Stripe and Discourse baselines), and the group of privileges it would get through them is wider than the console uses.
## What Changes
- **Two Postgres users and two connection strings.** `db-dsn` (`MC_DB_DSN`, `db-dsn-file`, `MC_DB_DSN_FILE`) names the runtime user, the user the console serves from. A new `db-migrate-dsn` (`MC_DB_MIGRATE_DSN`, `db-migrate-dsn-file`, `MC_DB_MIGRATE_DSN_FILE`) names the migration user and feeds only the migration connection. `start` migrates on the migration connection, grants the runtime group role to the runtime user there, closes it, and then runs only on the runtime connection. `migrate up|down|status` use `db-migrate-dsn` and refuse to run without it.
- **A separate-step mode.** With `db-migrate-dsn` left out, `start` runs no migrations and refuses to start while the schema is behind its code, naming the command that brings it up to date. A host that wants the running process never to hold the migration password runs `member-console migrate up` as its own step before each deploy.
- **A runtime group role.** A new core migration creates the `NOLOGIN` role `console_runtime`. Each migration stream grants its own `{key}_writer` role, and `SELECT` on its own goose ledger, to `console_runtime` in a new migration (core, domains, FedWiki, Stripe, Discourse). The five baselines stop granting their writer to `member_console` (design D6), and the same migrations revoke each writer from `member_console` on a database that already holds the membership, when that role exists. `docs/building-an-integration.md` teaches `GRANT {key}_writer TO console_runtime;`.
- **Writers hold only the four DML privileges.** A second new migration in each stream (`writer_dml_only`) revokes `TRUNCATE`, `REFERENCES`, `TRIGGER` and `MAINTAIN` on all tables of the stream's schema from its writer (design D13). It runs after every earlier grant, so it corrects fresh and existing databases alike, and the `GRANT ALL` lines of the earlier migrations are not edited.
- **The boot grant.** At every start, on the migration connection, the console grants `console_runtime` to the user the runtime connection logs in as. It learns that name from the runtime connection's `current_user` and builds the statement in the database with `format('%I')`, so any login name works and no deployment needs a hand step per release or per integration.
- **A start-up check, always on.** The console refuses to start when the runtime user is a superuser or a member of a superuser role, is the same user as the migration user, connects to another database than the migration user, is not a member of `console_runtime`, owns the database or is a member of the role that does, is a member of a console owner role (`core_owner`, `domains_owner`, `fedwiki_owner`, `stripe_owner`, `discourse_owner`), owns (or is a member of a role that owns) any schema, relation or function in the console's schemas, is a direct member of any role other than `console_runtime`, or holds `INSERT` on `core.pool_provisions`, or `TRUNCATE`, `REFERENCES`, `TRIGGER` or `MAINTAIN` on any relation of the console's schemas, or can create objects in `public` or in a console schema. The checks that do not depend on the schema run before any migration, so a wrong `db-dsn` never migrates the database, and a runtime user that owns the database, or can create objects where the migration run resolves names, is refused before the migration run that it could affect. SQL that the console runs on the migration connection, or prints as a statement for the operator to run, names every function and catalog relation with its schema (`pg_catalog.<name>`), so an object the runtime user planted never runs in a privileged session, and a structural test fails when a check query names one without it. Before the database-ownership check passes, the migration connection runs only a query with qualified catalog names. Every connection opens with startup parameters that override database-level and role-level settings (`search_path`, and `role=none` so that `current_user` is the login), and every `migrate` command and `start` refuse a database that is owned by neither the migration user nor a superuser role, naming the statement that fixes it. There is no exemption, no opt-out setting, and nothing keyed on `env`. Each refusal names what to fix.
- **Ownership stays where it is, except for the partitioned table.** The migration user keeps owning what it owns today. `core.webhook_events` and its partitions move to `core_owner`, and partitions are created by a new function, `core.ensure_webhook_event_partitions`, `SECURITY DEFINER`, owned by `core_owner`, with a pinned `search_path`, `EXECUTE` revoked from `PUBLIC` and granted to `console_runtime`. The boot pass and the Temporal schedule both call it. The `per-schema-roles` spec is corrected to say who owns what. The core baseline's initial-partition block computes its month bounds in UTC, as the function does.
- **The obligation lock goes through a function owned by `core_owner`.** A new core function, `core.lock_plan_act_obligation`, `SECURITY DEFINER`, owned by `core_owner`, with a pinned `search_path`, `EXECUTE` revoked from `PUBLIC` and granted to `core_writer`, takes the row lock `LockPlanActObligation` takes today and returns the same columns. The sqlc query becomes a call to it, the lock order in `docs/database-locks.md` is unchanged, and `core_writer` and `console_runtime` still hold no `UPDATE` on the obligations. It lands in the same section as the partition function, before the section that switches the application and the test stack to the runtime user.
- **The test stack runs on two users.** A one-shot compose service, `postgres-seed`, creates the runtime login `member_console_runtime` in new and existing volumes. `test/bootstrap-stack.sh` writes `MC_DB_DSN` as the runtime user and `MC_DB_MIGRATE_DSN` as `member_console`, today's user. The stack scripts refuse a `test/.env` that lacks `MC_DB_MIGRATE_DSN` and print the command that regenerates it. The work that needs a superuser on the application database (`test/reset-app-db.sh`, the screens' timestamp pinning, `test/mockshot-*.sql`) uses the migration user. `TEST_DATABASE_URL` stays the superuser.
- **The tests that prove the locks run as `console_runtime`.** The enclosure tests drop to `console_runtime` with `SET LOCAL ROLE`, and the `applicationRole` fixture, which copied the login's memberships, goes.
- **A catalog test of the runtime role.** As `console_runtime`, every table and view in the console's schemas is readable, every table outside an explicit list of locked tables is writable, each locked table refuses the privileges its enclosure revokes, and no table, view or partition grants `console_runtime` `TRUNCATE`, `REFERENCES`, `TRIGGER` or `MAINTAIN`, so a later `GRANT ALL` fails `make test`. Grants stay explicit in each migration; no `ALTER DEFAULT PRIVILEGES` is added.
- **Docs.** `docs/production-deployment.md` describes the two users, the separate-step mode, and a procedure that moves an existing deployment onto two users and back. `docs/environment-reference.md`, `docs/hosting.md`, `docs/database-management.md`, `docs/building-an-integration.md`, `docs/testing.md`, `test/AGENTS.md` and `test/README.md` follow.
- **BREAKING (deployers):** a deployment that upgrades without a runtime user and `db-migrate-dsn` does not start: its `db-dsn` names a superuser, and the start-up check refuses it. The procedure in `docs/production-deployment.md` is the upgrade path.
## Quick fixes inside this change
Listed here because each is small and was found while investigating #23.
- **Writers hold only `SELECT`, `INSERT`, `UPDATE` and `DELETE`.** Migration 00005 revoked `INSERT`, `UPDATE` and `DELETE` from `core_writer` on the three position tables and left what `GRANT ALL` gave it: `TRUNCATE`, `REFERENCES`, `TRIGGER` and `MAINTAIN`. Postgres lets a role holding `TRIGGER` on a table create a trigger there, and runs a trigger with the privileges of the user whose statement fired it, so a trigger on a table that a `SECURITY DEFINER` function writes runs as the function's owner; `TRUNCATE` empties a table, and `TRUNCATE` or `MAINTAIN` allows `LOCK TABLE ... ACCESS EXCLUSIVE`. A new migration in each stream revokes the four, `TestConferralEnclosure_WriterRole` probes `TRUNCATE`, `CREATE TRIGGER` and `LOCK TABLE` on the position tables, and the catalog test fails for any table that grants `console_runtime` one of the four. It lands with the group role.
- **The core baseline's initial partitions are computed in UTC.** The block in `00001_init.sql` took the first of the month in the session's time zone, while the Go code and the new function use UTC; the block now agrees with them. Databases that applied the baseline are untouched, and on a UTC server the result is the same as before.
- **The plan-act obligation move takes its row lock through a function.** `LockPlanActObligation` is a code path that works only because the console's login is a superuser: a locking read of `core.plan_act_obligations` needs `UPDATE`, which the enclosure withholds from the writer on purpose. A new migration, core `00032_lock_plan_act_obligation.sql`, adds `core.lock_plan_act_obligation(act_id, pool_id)`, a `SECURITY DEFINER` function owned by `core_owner`, `SET search_path = core, pg_temp`, `EXECUTE` revoked from `PUBLIC` and granted to `core_writer` as the other definer functions of `00028` are, which runs the same single-table `SELECT ... FOR UPDATE` and returns the same seven columns; the sqlc query becomes `SELECT ... FROM core.lock_plan_act_obligation(...)`. It is not its own change because it is the defect class this change removes (a path that a superuser login hides), because the partition function handles the other such path the same way, and because it must land before the switch to the runtime user for the switch to work. A test runs the move's lock under `SET LOCAL ROLE console_runtime`, and the bare `SELECT ... FOR UPDATE` on the table under the same role is refused with `42501`, which keeps the enclosure honest. No grant is added to the table.
- **Every database command reads the `-file` keys.** Only `start` and `config validate` load `db-dsn-file` today; `migrate`, `config list`, `config set`, `config clear` and `seed-demo` ignore it, so a container that mounts the connection string as a file cannot run them. Each command now resolves the database's `-file` keys the same way, and a command that finds no connection string refuses instead of letting the driver fall back to its defaults. It lands as its own commit with its own test.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `per-schema-roles`: modified "Owner role grants full DDL and data access" (who owns what), "Writer role grants read-write data access" (the writer holds the four DML privileges and no other; the writer reaches the application through `console_runtime`), "Application login role wiring is explicit" (the runtime group role in place of the login's name) and "Entitlements position-machinery enclosure" (`TRUNCATE`, `TRIGGER` and `MAINTAIN` revoked; the tables' real names). Added "The runtime group role's privileges are checked against the catalog" and "A locking read of an enclosed table goes through a function owned by core_owner".
- `module-migrations`: modified "Baseline migration creates core schema and roles" (the baselines grant no login; a later migration revokes an earlier release's by-name grant).
- `webhook-partition-maintenance`: modified "Webhook event partitions are provisioned ahead of need" (the function owned by `core_owner` in place of a `CREATE` grant to the runtime role; the baseline's initial partitions in UTC).
- `startup-configuration`: modified "Boot-time configuration validation" (`db-migrate-dsn`) and "Connection strings and secrets never reach the logs" (`db-migrate-dsn-file`; every database-opening command reads the `-file` keys). Added "Start migrates on the migration connection and serves from the runtime connection" (both connections open with `search_path` and `role=none`; the migration connection reads only qualified names before the ownership check; a database owned by neither the migration user nor a superuser is refused by every `migrate` command and `start`) and "The console refuses a runtime user that can bypass the grants" (the four privileges, as well as `INSERT` on `core.pool_provisions`; `CREATE` on `public` or a console schema, before migrating).
- `postgres-database`: modified "Database migrations via goose" (migrations at startup only on the migration connection).
- `worktree-test-stacks`: modified "Bootstrap script generates a per-worktree .env" and "Application reads MC_-prefixed env vars to override mc-config.yaml". Added "The stack's Postgres carries a runtime login", "Stack scripts refuse an env file without the migration connection string" and "Superuser work on the application database uses the migration connection string".
- `test-database-isolation`: added "Tests of the grants run as the runtime group role".
- `deployment-docs`: modified "A production deployment guide exists under docs/" (two users, the runtime login owning neither the database nor any role but `console_runtime`, the separate-step mode, the procedure for an existing deployment and its verification of the database's settings and of `public`).
- `discourse-integration-infrastructure`: modified "Conformant integration registration" (`discourse_writer` membership for `console_runtime`; the baseline grants no login).
## Impact
- **Schema:** twelve migrations: core `00029_runtime_group_role.sql`, domains `00003_runtime_group_role.sql`, FedWiki `00005_runtime_group_role.sql`, Stripe `00006_runtime_group_role.sql` and Discourse `00004_runtime_group_role.sql` (the group role and its grants); core `00030_writer_dml_only.sql`, domains `00004_writer_dml_only.sql`, FedWiki `00006_writer_dml_only.sql`, Stripe `00007_writer_dml_only.sql` and Discourse `00005_writer_dml_only.sql` (the privilege revoke); core `00031_webhook_partition_function.sql` (the ownership move and the function); core `00032_lock_plan_act_obligation.sql` (the obligation lock's function). The five baselines lose their `GRANT {key}_writer TO member_console` statement and its Down `REVOKE`, and the core baseline's initial-partition block is computed in UTC. No table or column is added.
- **Roles:** `console_runtime` (`NOLOGIN`) is created, cluster-wide, guarded like the stream triples. The five writer roles become its members, lose `TRUNCATE`, `REFERENCES`, `TRIGGER` and `MAINTAIN`, and stop being members of `member_console` on every database that had the membership. `core_owner` comes to own `core.webhook_events`, its partitions and the two new functions. No login is created by a migration.
- **Config keys:** `db-migrate-dsn` and `db-migrate-dsn-file` (`MC_DB_MIGRATE_DSN`, `MC_DB_MIGRATE_DSN_FILE`) are new; `db-dsn` now names the runtime user. `env` is unchanged and still picks only the log format.
- **Code:** `cmd/database.go`, `cmd/start.go`, `cmd/migrate.go`, `cmd/config.go`, `cmd/seed_demo.go`, `internal/config/validate.go`, `internal/db/database.go` (`ConnectAndMigrate` is deleted), `internal/db/migrations.go` (the pending check), `internal/db/runtime_user.go` (new: the boot grant and the start-up check, with every name `pg_catalog`-qualified), `internal/db/partitions.go`, `internal/entitlements/queries/plan_acts.sql` and its generated `plan_acts.sql.go`, `internal/embeds/mc-config.yaml`.
- **Tests:** new tests of the group role's grants, the catalog, the partition function, the obligation lock run as the runtime role, the pending check, the boot grant, the start-up check, a scan of the check and boot-grant queries for an unqualified function or catalog relation, the `-file` resolution and the stack scripts' guard. The enclosure tests move from `core_writer` or `applicationRole` to `console_runtime`; `applicationRole` is deleted. No golden file and no screen changes.
- **Test stack:** `test/compose.yaml` (`postgres-seed`, a health check on `postgres`), `test/seed/postgres/seed-runtime-user.sh` (new), `test/bootstrap-stack.sh`, `test/reset-test-db.sh`, `test/reset-app-db.sh`, `test/seed-demo.sh`, `test/mc-config.yaml`, `test/mockshot-nuke.sql` (a false comment corrected), `test/e2e/browsertest/session.go`, `test/e2e/screens/screens_test.go`, `test/seed/discourse/seed-discourse.sh` (its header), and the script tests in `test/`. Every existing worktree regenerates its `test/.env` once.
- **Reader role:** `scripts/db-reader-role.sql` does not change, and `internal/migrate/testdata/console-reader-columns.txt` does not change: no column is added. The script still runs as the migration user, which owns the schemas. A deployment re-runs it after deploying, as after every migration, though nothing this change adds is a column the role reads. The new functions revoke `EXECUTE` from `PUBLIC`, which the script requires.
- **Docs:** `docs/database-locks.md` (the obligation row's lock is taken by the new function), `docs/production-deployment.md`, `docs/environment-reference.md`, `docs/hosting.md`, `docs/database-management.md`, `docs/building-an-integration.md`, `docs/testing.md`, `test/AGENTS.md`, `test/README.md`.
- **Deployments:** each deployment creates a runtime login and adds a second secret before it deploys this release; `docs/production-deployment.md` gives the procedure and its roll back. The procedure is run by the host's operator, not by this change.
## Out of scope
- **Renaming `env`** (#251). It keeps picking the log format, and nothing in this change reads it.
- **Moving every object to its schema's owner role** (#252). This change moves `core.webhook_events` and its partitions only; design D3 says what it leaves for that issue.
- **Switching any running deployment.** The procedure is documented and handed to the deployment's operator.
- **A migration user that is not a superuser**, as on managed Postgres. The migrations already need a superuser, or an owner with `CREATE` on the database, `CREATEROLE` and `SET` membership in every owner role; this change adds the ownership move of `core.webhook_events`, which needs the same `SET` membership in `core_owner`.
@@ -0,0 +1,28 @@
## MODIFIED Requirements
### Requirement: A production deployment guide exists under docs/
The repository SHALL contain a production deployment guide under `docs/` covering, for an arbitrary deployment (not wiki.cafe specifically): the required services and how the app connects to each; the two database users the console connects as (a migration user that owns the schemas and the database, and a runtime user that is not a superuser, owns neither the database nor any object, and holds no role but `console_runtime`), how to create them, the two connection strings and the separate-step mode in which the running process holds only the runtime user's; TLS/reverse-proxy expectations and trusted-origin implications; identity-provider realm setup, including the instruction to align the realm's display branding with the configured deployment name (`MC_DEPLOYMENT_NAME`) so the sign-in page and the app introduce themselves with one name; Stripe webhook endpoint setup and secret handling; FedWiki farm connection and allowed site domains; first-boot behavior (migrations run at startup on the migration user's connection when one is configured, what seeding exists and that demo seeding is not for production); and a procedure that moves an existing deployment from one database user to two, with its verification and its roll back.
#### Scenario: Guide covers the realm-branding alignment
- **WHEN** a deployer follows the identity-provider section of the guide
- **THEN** it instructs them to align realm display branding with the
configured deployment name, and explains why (the sign-in page is rendered by the IdP, not the app)
#### Scenario: Guide is deployment-agnostic
- **WHEN** the guide names hosts, domains, or organizations in examples
- **THEN** they are placeholders or neutral examples, not assumptions that
the deployment is wiki.cafe
#### Scenario: Guide covers the two database users
- **WHEN** a deployer sets up the database for a new deployment by following the guide
- **THEN** it instructs them to create a runtime login that is not a superuser, does not own the database and owns nothing, that holds no role but `console_runtime` (revoking any role a hosting provider granted it), to give it a password without putting the password on a command line, and to set `db-dsn` to it and `db-migrate-dsn` to the migration user
#### Scenario: Guide moves an existing deployment to two users
- **WHEN** an operator of a deployment that connects as one superuser follows the guide's procedure
- **THEN** it takes them through creating the runtime login, storing its connection string as a new secret, pointing `db-dsn` at it and `db-migrate-dsn` at the existing secret, deploying, verifying which user the sessions run as and the database's settings and the functions in `public`, re-running the reader-role script, and rolling back
- **AND** it states that the bootstrap superuser keeps `SUPERUSER` and becomes the migration user
@@ -0,0 +1,24 @@
## MODIFIED Requirements
### Requirement: Conformant integration registration
The Discourse integration SHALL live at `internal/integrations/discourse/` and register through the existing integration registry with a manifest declaring `Kind: provisioning`, the `discourse_`-prefixed resource key `discourse_posting`, and read-class operations (`list`, `describe`). Its migrations SHALL own the `discourse` schema, the `discourse_owner`/`discourse_writer`/`discourse_reader` role triple, the `core_reader` grant to `discourse_writer`, `discourse_writer` membership for `console_runtime` and `SELECT` on its ledger for `console_runtime` (the baseline grants no writer to a login, and a later migration revokes a membership that an earlier release granted to `member_console`), the `core.resource_keys` row for `discourse_posting`, and an independent `goose_db_version_discourse` ledger, with Down migrations mirroring Up in reverse order.
The manifest SHALL declare the person as the recipient of `discourse_posting`, because the key is delivered as forum group membership, and registration SHALL stamp `recipient = 'person'` on the key. Discourse SHALL keep its own schema, workflows, schedules and convergence sweep: its group mappings stay in `discourse.group_mappings`, and the desired-state records the console stores for Discourse's connection SHALL NOT be pushed to the forum while no transport serves that connection.
#### Scenario: Boot with the integration registered
- **WHEN** the application boots with the Discourse integration present in the registry
- **THEN** its migrations run under the `discourse` goose ledger after core migrations, and provider registration validates and upserts its manifest without error
- **AND** `core.resource_keys` records `recipient = 'person'` for `discourse_posting`
#### Scenario: Down migration reverses cleanly
- **WHEN** the integration's Down migrations run against a database where Up has been applied
- **THEN** all `discourse`-schema objects, grants, and roles are removed in reverse order leaving core untouched
#### Scenario: Stored records agree with Discourse's desired members
- **WHEN** the console's records for Discourse's connection are current and the desired members for `discourse_posting` are computed by Discourse's own query on the same data
- **THEN** the persons whose record holds `discourse_posting` are exactly the persons that query returns, less any person recorded as cut off and any person entitled only through the System organization, which is no recipient
#### Scenario: Discourse's records are not pushed
- **WHEN** a person gains `discourse_posting` and the console updates that person's record at Discourse's connection
- **THEN** no push to the forum starts from the record
- **AND** the person reaches the forum group through Discourse's own sweep, as before
@@ -0,0 +1,46 @@
## MODIFIED Requirements
### Requirement: Baseline migration creates core schema and roles
The first core-stream migration SHALL create the `core` schema, the `core`
role triple, the core table grants, all 38 end-state domain tables (plus the
`product_kinds` view) in FK-dependency order, and the shared `public`
functions. Integration schemas, role triples and grants SHALL NOT be created
by the core stream — each integration's own baseline creates them. The combined baselines SHALL remain structurally
complete against the superseded 10-source chain — the same object set
(tables, columns, constraints, indexes, triggers, functions, net grant
effects, seed rows) modulo the rename map and the documented drop list. No
baseline SHALL grant a role to a login by name, so a fresh install needs no
role of any particular name. A later migration in each stream revokes its
writer from `member_console` where that role exists and holds the membership,
which cleans a database that an earlier release migrated, and grants the
writer to the `console_runtime` group role instead (the `per-schema-roles`
capability).
#### Scenario: Baseline on fresh install
- **WHEN** all baseline migrations run on an empty database
- **THEN** the normalized object set SHALL equal that of a database migrated
through the superseded per-module chain (reachable via git history), after
applying the five-schemas→core rename and the documented drop list
#### Scenario: Core baseline creates no integration objects
- **WHEN** only the core stream has run
- **THEN** no `stripe_*` or `fedwiki_*` role and no `stripe` or `fedwiki`
schema SHALL exist
#### Scenario: A fresh install needs no login of any name
- **WHEN** all baseline migrations run on an empty cluster in which no role
named `member_console` exists
- **THEN** every migration SHALL succeed
- **AND** no baseline SHALL have granted a role to any login
#### Scenario: The by-name membership does not survive a full migration
- **WHEN** every migration of every stream has run
- **THEN** `member_console`, where it exists, SHALL be a member of no writer
role
- **AND** `console_runtime` SHALL be a member of `core_writer` and of every
installed stream's writer role
@@ -0,0 +1,252 @@
## ADDED Requirements
### Requirement: The runtime group role's privileges are checked against the catalog
A test SHALL read the catalog of a migrated database and fail unless, for `console_runtime`: every table, partitioned table and view in the schemas of the migration streams is readable; every such table outside an explicit list of locked tables holds `INSERT`, `UPDATE` and `DELETE`; each locked table holds exactly the privileges its entry lists among `INSERT`, `UPDATE` and `DELETE`, with column `UPDATE` where the entry names columns; no table, partitioned table, partition or view in those schemas grants it `TRUNCATE`, `REFERENCES`, `TRIGGER` or `MAINTAIN`; every entry of the list names an existing table; every stream's goose ledger is readable; `core.ensure_webhook_event_partitions` and `core.lock_plan_act_obligation` are executable; and no role other than `console_runtime` is a direct member of a writer role, apart from `fedwiki_writer`'s membership of `domains_writer`. Partitions SHALL be checked for reading and writing through their parent table. The migrations SHALL NOT use `ALTER DEFAULT PRIVILEGES`: each table's grants SHALL be written in the migration that creates it, and an enclosure SHALL remain a grant that is absent.
#### Scenario: A forgotten grant fails the test
- **WHEN** a migration creates a table in one of the streams' schemas and grants nothing on it to its writer role
- **THEN** the catalog test SHALL fail, naming the table and the privilege `console_runtime` lacks
#### Scenario: A lost revoke fails the test
- **WHEN** a migration grants `INSERT` on `core.pool_provisions` to `core_writer`, or to `console_runtime`
- **THEN** the catalog test SHALL fail, naming the table and the privilege its locked entry does not list
#### Scenario: A privilege beyond the four fails the test
- **WHEN** a baseline or a migration grants `ALL` on a table to a writer role after the stream's revoke of `TRUNCATE`, `REFERENCES`, `TRIGGER` and `MAINTAIN`, or the revoke is missing
- **THEN** the catalog test SHALL fail, naming the table and the privilege `TRIGGER`
#### Scenario: A stale entry fails the test
- **WHEN** the locked list names a table that no longer exists
- **THEN** the catalog test SHALL fail, naming the entry
### Requirement: A locking read of an enclosed table goes through a function owned by core_owner
A statement that takes a row lock (`FOR UPDATE`, `FOR NO KEY UPDATE`, `FOR SHARE` or `FOR KEY SHARE`) on a table that this capability encloses SHALL be run by a `SECURITY DEFINER` function, because PostgreSQL requires `UPDATE` on the table for a locking read and the enclosure withholds it. The function that locks a plan-act obligation, `core.lock_plan_act_obligation(p_act_id, p_pool_id)`, SHALL be owned by `core_owner`, SHALL declare `SET search_path = core, pg_temp`, SHALL have `EXECUTE` revoked from `PUBLIC` and granted to `core_writer`, SHALL take the row lock on `core.plan_act_obligations` in a single statement that reads only the obligation row, and SHALL return the columns the move reads: the act, the pool, the org type, the organization, the status, whether the act still owns the org type, and the act that put the obligation in motion. The enclosure SHALL NOT be loosened by an `UPDATE` grant on `core.plan_act_obligations` for the sake of the lock, and the function SHALL return no row for an obligation that does not exist.
#### Scenario: The runtime role locks an obligation through the function
- **WHEN** a session holding only `console_runtime` calls `core.lock_plan_act_obligation` for an existing obligation
- **THEN** the row SHALL be returned and locked until the session's transaction ends
- **AND** a second transaction that locks the same obligation SHALL wait
#### Scenario: The runtime role cannot lock the table directly
- **WHEN** a session holding only `console_runtime` runs `SELECT ... FROM core.plan_act_obligations FOR UPDATE`
- **THEN** PostgreSQL SHALL reject the statement on privilege grounds
#### Scenario: PUBLIC cannot lock an obligation through the function
- **WHEN** a login role that is not a member of `core_writer` calls `core.lock_plan_act_obligation`
- **THEN** the call SHALL be rejected on privilege grounds
#### Scenario: A missing obligation returns no row
- **WHEN** the function is called for an act and pool with no obligation
- **THEN** it SHALL return no row and raise no error
## MODIFIED Requirements
### Requirement: Owner role grants full DDL and data access
Each `{schema}_owner` role SHALL hold `ALL` privileges on the tables of its schema and `CREATE` on the schema. Objects in each schema SHALL be owned by the user that runs migrations, with two exceptions owned by `core_owner`: the core schema's `SECURITY DEFINER` functions, and `core.webhook_events` with its partitions, including every partition `core.ensure_webhook_event_partitions` creates at runtime. The runtime user SHALL own nothing in the console's schemas. No owner role SHALL be granted to `console_runtime`, to the runtime user, or to any login.
#### Scenario: Ownership after migrations
- **WHEN** every migration stream has run on a fresh database
- **THEN** every schema, table, view and sequence of the streams, other than `core.webhook_events` and its partitions, SHALL be owned by the user that ran the migrations
- **AND** `core.webhook_events`, each of its partitions and every `SECURITY DEFINER` function in `core` SHALL be owned by `core_owner`
#### Scenario: A partition created at runtime belongs to core_owner
- **WHEN** the runtime user calls `core.ensure_webhook_event_partitions` for a month that has no partition
- **THEN** the new partition SHALL be owned by `core_owner`
- **AND** the runtime user SHALL own no object in the console's schemas
### Requirement: Writer role grants read-write data access
The `{schema}_writer` role SHALL have `SELECT`, `INSERT`, `UPDATE`, `DELETE`
privileges on all tables in its schema, EXCEPT for `INSERT`, `UPDATE` and
`DELETE` on any table that a capability spec explicitly designates as
enclosed behind SECURITY DEFINER functions, and EXCEPT for `INSERT` and
`UPDATE` on any table that a capability spec explicitly designates as an
insert enclosure. It SHALL hold no other table privilege: `TRUNCATE`,
`REFERENCES`, `TRIGGER` and `MAINTAIN` SHALL be revoked from it on every table
of its schema, so that it cannot empty or lock a table, add a foreign key, or
attach a trigger that fires with the privileges of a function owner.
For each such enclosed table,
`{schema}_writer`'s direct INSERT, UPDATE, DELETE and TRUNCATE privileges SHALL be
revoked, and `{schema}_writer` SHALL instead be granted EXECUTE on the
designated functions as its sole write path; SELECT on the enclosed table
SHALL be unaffected. For each insert-enclosed table, `{schema}_writer` SHALL
hold no INSERT, SHALL hold UPDATE only on the columns the designating spec
names, and SHALL keep SELECT and DELETE; rows SHALL be inserted only by the
SECURITY DEFINER triggers and functions that spec names. The `core`
schema's `pool_provisions`,
`pool_provision_ladders`, and `pool_provision_transitions` tables are the
first such enclosure — see the "Entitlements position-machinery enclosure"
requirement in this same capability for the full grant/revoke posture and the
five conferral functions; it is not repeated here. `core.person_cut_offs` is
enclosed the same way, and so are the act record's five tables
(`core.acts`, `core.plan_act_details`, `core.plan_act_reached_org_types`,
`core.plan_act_orgs_without_pool` and `core.plan_act_obligations`). The
obligations are a mutable work list, and they are enclosed rather than given
column UPDATE grants so that every status change of an obligation goes
through a function, and a transition the plan acts do not name is not a
privilege the writer holds. `core.desired_state_outbox` is the first insert
enclosure — see the "Person cut-off enclosure", "Act record enclosure" and
"Desired-state outbox insert enclosure" requirements in this same capability.
It SHALL reach the application's runtime user only through membership of
`console_runtime` (see "Application login role wiring is explicit").
#### Scenario: Writer holds no privilege beyond the four
- **WHEN** every migration has run
- **THEN** no `{schema}_writer` role and not `console_runtime` SHALL hold `TRUNCATE`, `REFERENCES`, `TRIGGER` or `MAINTAIN` on any table, partition or view of the streams' schemas
- **AND** this SHALL hold for a fresh database and for one migrated by an earlier release
#### Scenario: Writer role can perform CRUD operations
- **WHEN** a session holding `core_writer` works on a `core` table that no capability spec encloses
- **THEN** it SHALL be able to `SELECT`, `INSERT`, `UPDATE`, and `DELETE` rows in it
- **AND** it SHALL NOT be able to `ALTER TABLE` or `DROP TABLE`
#### Scenario: Enclosed tables are excepted from the unqualified writer grant
- **WHEN** a capability spec designates one or more tables in a schema as
enclosed behind SECURITY DEFINER functions (for example `core`'s
three position tables)
- **THEN** `{schema}_writer` SHALL NOT hold direct INSERT, UPDATE, DELETE or
TRUNCATE privileges on those tables
- **AND** `{schema}_writer` SHALL instead hold EXECUTE on the designated
functions as its only path to writing them
- **AND** `{schema}_writer`'s privileges on every other, non-enclosed table
in the schema SHALL remain the unqualified SELECT/INSERT/UPDATE/DELETE
grant
#### Scenario: Insert-enclosed tables refuse the writer's inserts
- **WHEN** a capability spec designates a table as an insert enclosure (for
example `core.desired_state_outbox`)
- **THEN** `{schema}_writer` SHALL NOT hold INSERT on that table
- **AND** `{schema}_writer` SHALL hold UPDATE only on the columns that spec
names, and SELECT and DELETE on the table
### Requirement: Application login role wiring is explicit
The runtime user's privileges SHALL be declared explicitly in migrations,
through one `NOLOGIN` group role, `console_runtime`, which the core stream
creates, guarded by `pg_roles` because roles are cluster-wide. Each
migration stream SHALL grant its own `{schema}_writer` to `console_runtime`,
and `SELECT` on its own goose ledger, in its own migration; the core stream
grants `core_writer`. A stream that runs after core MAY grant to
`console_runtime` because core always migrates first. No baseline or
migration SHALL grant a writer role to a login by name, and the migrations
that grant the writers to `console_runtime` SHALL revoke each writer from
`member_console` where that role exists and holds the membership. Owner roles SHALL NOT be granted to `console_runtime`.
The console SHALL grant `console_runtime` to the runtime user at start (the
`startup-configuration` capability). A stream's Down section SHALL revoke the
privileges it granted within its own database and SHALL leave the
memberships it granted or revoked, which are cluster-wide; the core stream's Down SHALL
drop `console_runtime` only when no database in the cluster still grants it a
privilege, and SHALL leave it in place otherwise.
#### Scenario: Runtime role privileges after fresh install
- **WHEN** all migrations complete on a fresh database
- **THEN** `console_runtime` SHALL be able to read and write the tables in
`core`, `domains`, `stripe`, `fedwiki` and `discourse` that no capability
spec encloses
- **AND** `console_runtime` SHALL be able to read every stream's goose ledger
- **AND** `console_runtime` SHALL NOT be able to execute DDL in any schema
#### Scenario: Integration removal leaves core wiring intact
- **WHEN** an integration and its stream are absent from a deployment
- **THEN** `console_runtime` SHALL still hold `core_writer` membership from
the core stream alone
#### Scenario: No login holds a writer by name
- **WHEN** the migrations run on a cluster where `member_console` is a
member of the writer roles
- **THEN** `member_console` SHALL be a member of none of them afterwards
- **AND** `console_runtime` SHALL be a member of each
#### Scenario: A cluster without member_console migrates
- **WHEN** the migrations run on a cluster in which no role named `member_console` exists
- **THEN** every migration SHALL succeed and `console_runtime` SHALL hold the writers
#### Scenario: Any login name works
- **WHEN** the runtime user is a login of any name and the console has
granted it `console_runtime`
- **THEN** it SHALL hold the writer privileges of every installed stream
#### Scenario: A rollback in one database leaves the others' privileges
- **WHEN** a stream's migration that grants to `console_runtime` is rolled
back in one database of a cluster whose other database still uses
`console_runtime`
- **THEN** `console_runtime` SHALL still exist and SHALL keep its
memberships
### Requirement: Entitlements position-machinery enclosure
Building on the per-schema owner/writer/reader convention, the `core` schema SHALL enclose direct write access to its position machinery. `core_writer` SHALL NOT hold INSERT, UPDATE, DELETE or TRUNCATE privileges on `core.pool_provisions`, `core.pool_provision_ladders`, or `core.pool_provision_transitions`, and, like every table, SHALL NOT hold REFERENCES, TRIGGER or MAINTAIN on them. EXECUTE on the five conferral functions, `confer`, `end_conferral`, `sync_source_status`, `align_conferral_shape` and `update_conferral_bounds`, SHALL be revoked from `PUBLIC` and granted to `core_writer` alone, making the five functions the sole legal doorway to the three tables. `core.grants` SHALL remain outside the enclosure: `core_writer` SHALL retain its ordinary writer-role INSERT/UPDATE/DELETE on `grants`, because the decree is application bookkeeping and only its enactment (the position write) is enclosed. SELECT privileges on the three enclosed tables SHALL be unchanged from the standard writer/reader posture; the enclosure narrows write access only.
#### Scenario: Writer role cannot write position tables directly
- **WHEN** a session holding only `core_writer` attempts `INSERT`,
`UPDATE`, `DELETE` or `TRUNCATE` against `core.pool_provisions`,
`core.pool_provision_ladders`, or
`core.pool_provision_transitions`
- **THEN** the statement SHALL be rejected by PostgreSQL's privilege system
- **AND** the session's only path to change those tables' contents SHALL be
calling one of the five conferral functions
#### Scenario: The runtime group role cannot attach a trigger to or lock a position table
- **WHEN** a session holding only `console_runtime` attempts `CREATE TRIGGER` on `core.pool_provisions`, `core.pool_provision_ladders` or `core.pool_provision_transitions`, naming a trigger function it created in its `pg_temp` schema, or attempts `LOCK TABLE` on one in `ACCESS EXCLUSIVE` mode
- **THEN** the statement SHALL be rejected by PostgreSQL's privilege system
#### Scenario: Writer role can execute the five conferral functions
- **WHEN** a session holding `core_writer` calls
`core.confer`, `core.end_conferral`,
`core.sync_source_status`, `core.align_conferral_shape`,
or `core.update_conferral_bounds`
- **THEN** the call SHALL succeed on privilege grounds, subject to the
function's own contract and named errors
#### Scenario: PUBLIC cannot execute the conferral functions
- **WHEN** a login role that is not a member of `core_writer`, and
holds no other explicit grant, attempts to call any of the five conferral
functions
- **THEN** the call SHALL be rejected
- **AND** this SHALL hold even though PostgreSQL grants EXECUTE on new
functions to `PUBLIC` by default, because that default grant is explicitly
revoked
#### Scenario: Grants table remains writer-writable
- **WHEN** a session holding `core_writer` inserts, updates, or
deletes a row in `core.grants`
- **THEN** the operation SHALL succeed under the ordinary writer-role
privileges
- **AND** the enclosure SHALL NOT extend to `core.grants`
#### Scenario: Read access to position tables is unaffected
- **WHEN** a session holding `core_writer` or `core_reader`
selects from `core.pool_provisions`,
`core.pool_provision_ladders`, or
`core.pool_provision_transitions`
- **THEN** the SELECT SHALL succeed exactly as under the standard
per-schema writer/reader posture, unchanged by the enclosure
@@ -0,0 +1,16 @@
## MODIFIED Requirements
### Requirement: Database migrations via goose
The system SHALL use goose to manage PostgreSQL database migrations with embedded migration files. Migrations SHALL run on the migration user's connection (`db-migrate-dsn`), at startup when it is set and through the migration CLI commands, and never on the runtime user's connection (`db-dsn`).
#### Scenario: Migrations run on startup
- **WHEN** the application starts with `db-migrate-dsn` set and connects to PostgreSQL
- **THEN** pending migrations are automatically applied on the migration user's connection
#### Scenario: No migrations without the migration connection string
- **WHEN** the application starts with `db-migrate-dsn` unset
- **THEN** no migration is applied
#### Scenario: Migration CLI commands
- **WHEN** the user runs `migrate up`, `migrate down`, or `migrate status`
- **THEN** the corresponding goose operation is executed against the PostgreSQL database named by `db-migrate-dsn`
@@ -0,0 +1,225 @@
## ADDED Requirements
### Requirement: Start migrates on the migration connection and serves from the runtime connection
`db-dsn` SHALL name the runtime user, the user the console serves from, and `db-migrate-dsn` SHALL name the migration user, the user that owns the schemas and runs migrations. With `db-migrate-dsn` set, `start` SHALL open both connections, SHALL run the checks of "The console refuses a runtime user that can bypass the grants" that read no migrated object before it runs any migration, SHALL run every stream's migrations on the migration connection, SHALL then grant `console_runtime` to the runtime user on the migration connection, and SHALL close the migration connection before it serves. The grant SHALL name the user the runtime connection reports as `current_user`, SHALL be built in the database with identifier quoting (`format('%I')`) from a bound parameter, never by concatenating SQL in Go, SHALL carry `WITH INHERIT TRUE`, and SHALL be idempotent. With `db-migrate-dsn` left out, `start` SHALL run no migration, SHALL log that it ran none, and SHALL refuse to start while the ledger of any stream lacks a version the stream embeds, naming the command that applies them; it SHALL read each ledger by its schema-qualified name `public.goose_db_version_<stream>`, so that a temporary table of the same name cannot stand in for it. Every migration connection (`start`'s and each `migrate` command's) SHALL open with the startup parameters `search_path=public` and `role=none`, and the runtime connection with `search_path=core,public` and `role=none`; a startup parameter overrides a database-level or role-level setting, so `current_user` on each connection is the login that the string names. SQL that the console runs on the migration connection, and SQL whose output it prints as a statement for the operator to run, SHALL name every function and every catalog relation with its schema (`pg_catalog.<name>`, and `public.<name>` for a goose ledger), so that an object a role planted in a schema on the search path never runs in that session. Until the check that the runtime user does not own the database has passed, the migration connection SHALL run only a query with no unqualified function and no unqualified relation. `migrate up`, `migrate down`, `migrate status` and `start` SHALL refuse, before any migration runs, a database whose owner is neither the migration user nor a superuser role, naming the statement `ALTER DATABASE <name> OWNER TO CURRENT_USER;` with the name quoted as the database quotes an identifier. `migrate up`, `migrate down` and `migrate status` SHALL use `db-migrate-dsn` and SHALL refuse to run without it. `seed-demo` SHALL open the database as `start` does.
#### Scenario: Start migrates as the migration user and serves as the runtime user
- **WHEN** `start` runs with `db-dsn` naming a runtime login and `db-migrate-dsn` naming the migration user, against a database with pending migrations
- **THEN** the migrations SHALL be applied by the migration user
- **AND** the runtime login SHALL inherit `console_runtime` afterwards
- **AND** every query the console serves SHALL run as the runtime login
#### Scenario: The migration connection is closed before serving
- **WHEN** `start` has finished its boot with `db-migrate-dsn` set
- **THEN** no session of the migration user opened by the console SHALL remain open
#### Scenario: A schema behind its code is refused without the migration setting
- **WHEN** `start` runs without `db-migrate-dsn` against a database whose ledgers lack versions the release embeds
- **THEN** it SHALL refuse to start, naming the streams and versions that are pending and `member-console migrate up`
- **AND** it SHALL run no migration
#### Scenario: A database-level search_path does not reach the migration connection
- **WHEN** the database carries `ALTER DATABASE ... SET search_path` naming a schema other than `public`
- **THEN** the migrations SHALL run with `search_path` set to `public` and each stream's ledger SHALL be created in `public`
#### Scenario: A role setting does not reach either connection
- **WHEN** the database's owner has run `ALTER DATABASE ... SET role = '<itself>'`, or the runtime login has run `ALTER ROLE CURRENT_USER SET role = 'console_runtime'`
- **THEN** `current_user` on the migration connection SHALL be the migration login and on the runtime connection the runtime login
- **AND** a database owned by that other role SHALL still be refused with the owner statement
#### Scenario: A database owned by another role is refused before migrating
- **WHEN** `migrate up`, `migrate down`, `migrate status` or `start` runs against a database whose owner is neither the migration user nor a superuser role
- **THEN** it SHALL refuse before any migration runs, naming the database, the owner and the statement `ALTER DATABASE <name> OWNER TO CURRENT_USER;`
- **AND** no ledger SHALL change, and `migrate status` SHALL create none
#### Scenario: The database's owner cannot run code in either session
- **WHEN** `db-dsn` logs in as a user that owns the database and has created `public.quote_ident(name)`
- **THEN** `start` SHALL refuse with the database-ownership refusal, the function SHALL NOT have run on either connection, and no migration SHALL have run
#### Scenario: A query that names a function without its schema fails the build
- **WHEN** a check query, the boot-grant statement or a pending-check query calls a function, or reads a catalog relation, without `pg_catalog.` (SQL syntax that precedes a parenthesis excepted, by a listed reason)
- **THEN** a test SHALL fail, naming the query and the token
#### Scenario: A temporary table does not stand in for a ledger
- **WHEN** `start` runs without `db-migrate-dsn` and the runtime connection holds a temporary table named like a stream's ledger
- **THEN** the pending check SHALL read the ledger in `public`, not the temporary table
#### Scenario: A current schema boots without the migration setting
- **WHEN** `start` runs without `db-migrate-dsn` against a fully migrated database whose runtime user inherits `console_runtime`
- **THEN** it SHALL start, run no migration and grant nothing
#### Scenario: migrate refuses without the migration setting
- **WHEN** `member-console migrate up` runs with `db-migrate-dsn` and `db-migrate-dsn-file` unset
- **THEN** it SHALL exit with an error naming `db-migrate-dsn` and its environment variables
- **AND** it SHALL open no connection
#### Scenario: The boot grant works for a name that needs quoting
- **WHEN** the runtime login's name contains a character that needs quoting, such as an upper-case letter or a hyphen
- **THEN** the boot grant SHALL grant `console_runtime` to exactly that login
### Requirement: The console refuses a runtime user that can bypass the grants
`start`, and `seed-demo`, SHALL refuse to start when, on the runtime connection, the runtime user: is the same user as the migration user; reaches another database than the migration user; is a superuser, or a member of a superuser role; finds no `console_runtime` role; does not inherit `console_runtime`; sees a schema behind its code; is a member, directly or through another role, of a stream's owner role (`core_owner`, `domains_owner`, `fedwiki_owner`, `stripe_owner`, `discourse_owner`); is, or is a member of, the owner of a schema of the streams or of a relation or function in those schemas or in `public`; is a direct member of any role other than `console_runtime`; or holds `INSERT` on `core.pool_provisions`, or `TRUNCATE`, `REFERENCES`, `TRIGGER` or `MAINTAIN` on any relation of the streams' schemas, through a grant to the user, to a role it is a member of, or to `PUBLIC`. The runtime user SHALL also be refused when it is, or is a member of, the owner of the database, and when it holds `CREATE` on `public` or on a schema of the streams, or owns, or is a member of a role that owns, a relation or function in `public`. The first four conditions (one user, one database, superuser, database ownership) and the `CREATE` and `public` conditions SHALL be checked before any migration runs and before the boot grant; the others after the migrations. A superuser SHALL be refused before any membership or ownership condition is tested. Each refusal SHALL name what is wrong and what to run or set to fix it, quoting the user's name and the name of any role in a printed statement as the database quotes an identifier. A failed check query SHALL refuse the start. No setting SHALL turn the check off, and no value of `env` SHALL change it. A check that passes SHALL be logged with the runtime user's name and, when `db-migrate-dsn` is set, the migration user's.
#### Scenario: A superuser runtime user is refused before migrating
- **WHEN** `db-dsn` logs in as a superuser and the database has pending migrations
- **THEN** `start` SHALL refuse, naming the user and `SUPERUSER`
- **AND** no migration SHALL have run and no grant SHALL have been made
#### Scenario: A runtime user that owns the database is refused before migrating
- **WHEN** `db-dsn` logs in as a user that owns the database and the database has pending migrations
- **THEN** `start` SHALL refuse before any migration runs, naming the user and the database and the statement `REASSIGN OWNED BY <user> TO CURRENT_USER;`, the user's name quoted, to run as the migration user
- **AND** no migration SHALL have run and no grant SHALL have been made
#### Scenario: A member of the database owner's role is refused before migrating
- **WHEN** `db-dsn` logs in as a user that is a member of the role that owns the database
- **THEN** `start` SHALL refuse before any migration runs, naming the owner and the membership to revoke
#### Scenario: A member of a superuser role is refused
- **WHEN** `db-dsn` logs in as a user that is not a superuser and is a member of a superuser role
- **THEN** `start` SHALL refuse before any migration runs, naming the superuser role and the membership to revoke
#### Scenario: One user for both connections is refused
- **WHEN** `db-dsn` and `db-migrate-dsn` log in as the same user
- **THEN** `start` SHALL refuse before any migration runs, naming the user
#### Scenario: Two databases are refused
- **WHEN** `db-dsn` and `db-migrate-dsn` reach databases with different names
- **THEN** `start` SHALL refuse before any migration runs, naming both databases
#### Scenario: A runtime user without the group role is told the grant
- **WHEN** `start` runs without `db-migrate-dsn` and the runtime user does not inherit `console_runtime`
- **THEN** it SHALL refuse with the statement `GRANT console_runtime TO <user> WITH INHERIT TRUE;`, the user's name quoted, to run as the migration user
#### Scenario: Membership of an owner role is refused
- **WHEN** the runtime user is a member of `core_owner`, directly or through another role, with `INHERIT` or with `SET` only
- **THEN** `start` SHALL refuse, naming the owner role and the membership to revoke
#### Scenario: Ownership is refused
- **WHEN** the runtime user owns a table, a view or a function in one of the streams' schemas or in `public`, or owns the database, or is a member of a role that owns one of them
- **THEN** `start` SHALL refuse, naming the object and the statement that fixes it
#### Scenario: A direct membership beyond the group role is refused
- **WHEN** the runtime user is a direct member of any role other than `console_runtime`, such as `pg_execute_server_program`, `pg_read_server_files`, `pg_write_all_data` or an administrative role that a hosting provider grants
- **THEN** `start` SHALL refuse, naming the role and the statement `REVOKE <role> FROM <user>;`, to run as the migration user
- **AND** a direct membership of a stream's owner role SHALL be refused with the owner role named, before this rule applies
#### Scenario: A stray write grant is refused
- **WHEN** the runtime user can insert into `core.pool_provisions`, through a direct grant or a grant to `PUBLIC`
- **THEN** `start` SHALL refuse, naming the table
#### Scenario: A stray trigger grant is refused
- **WHEN** a host grants `TRIGGER`, `TRUNCATE`, `REFERENCES` or `MAINTAIN` on a table of the streams' schemas to the runtime user, to `PUBLIC` or to a role the user inherits
- **THEN** `start` SHALL refuse, naming the privilege, the table, the grantee and the `REVOKE` of that grant
- **AND** a privilege that comes from a predefined role granted to a role the user inherits SHALL be refused naming the predefined role and the role it is granted to
#### Scenario: A runtime user that can create objects is refused before migrating
- **WHEN** the runtime user holds `CREATE` on `public` or on a schema of the streams, and has created `public.format(text, text)`
- **THEN** `start` SHALL refuse before any migration runs, naming the schema, the grantee whose grant allows it, and the statement `REVOKE CREATE ON SCHEMA <schema> FROM <grantee>;`, the names quoted
- **AND** the function SHALL NOT have run and no migration SHALL have run
#### Scenario: A runtime user that owns a function in public is refused before migrating
- **WHEN** the runtime user owns a function or relation in `public`
- **THEN** `start` SHALL refuse before any migration runs, naming the object and the statement that fixes it
#### Scenario: A role name that needs quoting yields a runnable statement
- **WHEN** the runtime user holds a refused membership through a role whose name needs quoting, such as `Mc-Admin`
- **THEN** the refusal's `REVOKE` statement SHALL quote that role's name
#### Scenario: No environment label exempts the check
- **WHEN** `env` is `development` and the runtime user is a superuser
- **THEN** `start` SHALL refuse as it does under any other label
## MODIFIED Requirements
### Requirement: Boot-time configuration validation
Boot-time validation SHALL run before database migrations and before any service
starts, SHALL name the offending configuration key and how to set it (its `MC_*`
environment variable or `mc-config.yaml` key), and a single run SHALL report every
problem at once rather than only the first.
The following core configuration SHALL be required unconditionally: `db-dsn` (a
valid PostgreSQL URL), `valkey-addr`, `oidc-idp-issuer-url` (a valid URL),
`oidc-sp-client-id`, and `base-url` (a valid URL). `db-migrate-dsn` SHALL be
optional, and when set SHALL be a valid PostgreSQL URL. `oidc-sp-client-secret` SHALL
NOT be required (public PKCE clients have none). `csrf-secret` SHALL NOT be
required and SHALL NOT be read: cross-origin protection issues no token, so there
is no key to derive one from.
#### Scenario: Missing required core configuration is reported before migrations
- **WHEN** a required core key is absent at startup
- **THEN** validation SHALL fail before database migrations run
- **AND** the error SHALL name the key and how to set it
#### Scenario: Every problem is reported at once
- **WHEN** several configuration problems are present
- **THEN** the command SHALL report every problem in a single aggregated error, not
just the first
#### Scenario: A deployment that still sets csrf-secret boots normally
- **WHEN** a configuration file or environment still carries `csrf-secret`, of any
length or none
- **THEN** startup SHALL ignore it and SHALL NOT fail on its value
#### Scenario: A malformed migration connection string is reported
- **WHEN** `db-migrate-dsn` is set and is not a PostgreSQL URL
- **THEN** validation SHALL fail naming `db-migrate-dsn` and the expected `postgres://` shape, and the error SHALL NOT contain the configured value
### Requirement: Connection strings and secrets never reach the logs
No log statement SHALL write a connection string, a password, or any key declared secret. `db-dsn` and `db-migrate-dsn` SHALL be file-backed secrets like the others: `db-dsn-file` and `db-migrate-dsn-file` SHALL be accepted, both a value and its file set at once SHALL be reported as ambiguous, and a validation error about a value SHALL name the key and the expected shape only. A log line about a database connection SHALL carry at most host, port, database and user. Every command that opens the database (`start`, `seed-demo`, `migrate up|down|status`, `config list|set|clear`, `config validate`) SHALL read the `-file` variant of each connection string it opens the same way, and SHALL refuse to open a connection whose string is empty, naming the key and its environment variables.
#### Scenario: The boot log names the database without its password
- **WHEN** the console connects to the database
- **THEN** the log line carries host, port, database and user and SHALL NOT contain the password
#### Scenario: A malformed DSN is reported without the value
- **WHEN** `db-dsn` is not a PostgreSQL URL
- **THEN** validation fails naming `db-dsn` and the expected `postgres://` shape, and the error SHALL NOT contain the configured value
#### Scenario: The DSN can come from a file
- **WHEN** `db-dsn-file` names a readable file and `db-dsn` is unset
- **THEN** the file's contents SHALL be the DSN
- **AND** setting both SHALL fail startup as an ambiguity, as for the other file-backed secrets
#### Scenario: A command other than start reads the file
- **WHEN** `migrate up` runs with `db-migrate-dsn-file`, or `config list`, `config set`, `config clear` or `seed-demo` runs with `db-dsn-file`, naming a readable file and the value unset
- **THEN** the command SHALL connect with the file's contents
#### Scenario: An empty connection string is refused
- **WHEN** a `config` command runs with neither `db-dsn` nor `db-dsn-file` set
- **THEN** it SHALL exit with an error naming `db-dsn` and its environment variables
- **AND** it SHALL open no connection
@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: Tests of the grants run as the runtime group role
`TEST_DATABASE_URL` and `E2E_DATABASE_URL` SHALL connect as the stack's migration user, a superuser, because scratch databases, migration replays, role creation and `SET ROLE` need one. A test that proves an enclosure or a grant SHALL drop to `console_runtime` with `SET LOCAL ROLE` inside its transaction, so it proves what the runtime user holds. No test SHALL derive the application's privileges from the test login's own memberships.
#### Scenario: An enclosure test runs as the group role
- **WHEN** a test asserts that the application cannot insert into an enclosed table
- **THEN** it SHALL attempt the insert under `SET LOCAL ROLE console_runtime` and expect PostgreSQL's privilege error
#### Scenario: A grant to the group role itself is caught
- **WHEN** a migration grants `INSERT` on an enclosed table directly to `console_runtime`
- **THEN** the enclosure test of that table SHALL fail
@@ -0,0 +1,36 @@
## MODIFIED Requirements
### Requirement: Webhook event partitions are provisioned ahead of need
The system SHALL provide an idempotent ensure operation that creates monthly RANGE partitions of `core.webhook_events` (named `core.webhook_events_YYYY_MM`, month-start to next-month-start bounds in UTC) covering the current month plus at least the next three months. Running the operation when partitions already exist SHALL be a no-op (`CREATE TABLE IF NOT EXISTS`). The operation SHALL be the function `core.ensure_webhook_event_partitions(p_now, p_months_ahead)`, created `SECURITY DEFINER`, owned by `core_owner`, declaring `SET search_path = core, pg_temp`, with `EXECUTE` revoked from `PUBLIC` and granted to `console_runtime`. `core_owner` SHALL own `core.webhook_events` and every partition of it, which is what lets the function create a partition. The function SHALL compute each month's name and bounds in UTC whatever the session's time zone, SHALL refuse a null time, and SHALL refuse a window outside 0 to 12 months ahead, creating nothing. The runtime role SHALL hold no `CREATE` on schema `core` and SHALL create partitions only through the function; the boot pass and the scheduled run SHALL both call it. The initial partitions that the core baseline creates SHALL have the same names and the same UTC bounds, whatever the session's time zone when the baseline runs.
#### Scenario: Missing future partitions are created
- **WHEN** the ensure operation runs in a month whose next three monthly partitions do not all exist
- **THEN** the missing partitions are created with correct month boundaries
- **AND** webhook inserts timestamped in any of those months succeed
#### Scenario: Ensure is idempotent
- **WHEN** the ensure operation runs twice in succession
- **THEN** the second run completes without error and creates nothing
#### Scenario: The runtime role creates partitions through the function
- **WHEN** a session holding only `console_runtime` calls `core.ensure_webhook_event_partitions` for a month with no partition
- **THEN** the partitions SHALL be created and owned by `core_owner`
#### Scenario: The runtime role cannot create a partition directly
- **WHEN** a session holding only `console_runtime` runs `CREATE TABLE ... PARTITION OF core.webhook_events`
- **THEN** PostgreSQL SHALL reject the statement on privilege grounds
#### Scenario: The baseline's partitions are in UTC
- **WHEN** the core baseline runs in a session whose time zone is `Pacific/Kiritimati`
- **THEN** the three partitions it creates SHALL start at midnight UTC on the first of the current month and of the next two months
#### Scenario: An out-of-range window is refused
- **WHEN** the function is called with 13 months ahead, a negative number of months, or a null time
- **THEN** it SHALL raise `invalid_parameter_value` and create no partition
@@ -0,0 +1,127 @@
## ADDED Requirements
### Requirement: The stack's Postgres carries a runtime login
The test stack SHALL run the application as two Postgres users, as a deployment does: `member_console`, which `POSTGRES_USER` makes the cluster's bootstrap superuser, as the migration user, and `member_console_runtime`, a login that is not a superuser and owns nothing, as the runtime user. A one-shot compose service, `postgres-seed`, SHALL create `member_console_runtime` when it is missing and SHALL set its `LOGIN`, `NOSUPERUSER` and test-only password on every run, after `postgres` reports healthy, retrying until the server accepts connections. It SHALL run on every `docker compose up`, so a stack whose data directory predates it gains the login without a teardown. It SHALL grant the login nothing: the application's start grants it `console_runtime`.
#### Scenario: A new stack has the runtime login
- **WHEN** `docker compose up -d` brings up a stack with an empty data directory
- **THEN** after `postgres-seed` completes, `member_console_runtime` SHALL exist with `LOGIN` and without `SUPERUSER`
#### Scenario: An existing stack gains the runtime login
- **WHEN** `docker compose up -d` runs against a data directory created before `postgres-seed` existed
- **THEN** `postgres-seed` SHALL create `member_console_runtime` and the database's rows SHALL be unchanged
#### Scenario: The seed is idempotent
- **WHEN** `postgres-seed` runs a second time
- **THEN** it SHALL complete successfully and the login SHALL be unchanged
### Requirement: Stack scripts refuse an env file without the migration connection string
`test/bootstrap-stack.sh` on a re-run, `test/reset-test-db.sh`, `test/reset-app-db.sh` and `test/seed-demo.sh` SHALL refuse a `test/.env` that does not set `MC_DB_MIGRATE_DSN`: each SHALL print to standard error that the file predates the stack's runtime database user, followed by the commands that stop the stack, remove the file, regenerate it and start the stack again, and SHALL exit with status 1 before it runs a container, a database command or `go`.
#### Scenario: An old env file is refused with the fix
- **WHEN** one of the four scripts runs beside a `test/.env` that sets every other key it reads and not `MC_DB_MIGRATE_DSN`
- **THEN** it SHALL print the refusal and the regeneration commands to standard error and exit with status 1
- **AND** it SHALL run no container, database or `go` command
### Requirement: Superuser work on the application database uses the migration connection string
Work on the application database that needs a superuser or the schemas' owner SHALL connect with `MC_DB_MIGRATE_DSN`, never `MC_DB_DSN`: `test/reset-app-db.sh`'s migration, timestamp pin, dump and snapshot restore; the screen-capture utility's timestamp pin; and `test/mockshot-seed.sql` and `test/mockshot-nuke.sql`, which run as `member_console`. The browser tests' own fixtures SHALL write through `MC_DB_DSN`, the runtime user, so they hold the application's privileges. `TEST_DATABASE_URL`, `E2E_DATABASE_URL` and `POSTGRES_ADMIN_DSN` SHALL keep connecting as `member_console`.
#### Scenario: The demo snapshot is built as the migration user
- **WHEN** `test/reset-app-db.sh --rebuild` runs
- **THEN** it SHALL migrate, pin timestamps, dump and restore with `MC_DB_MIGRATE_DSN`
- **AND** the demo seed SHALL run with both keys, so it seeds as the runtime user
#### Scenario: A walkthrough fixture writes as the runtime user
- **WHEN** a browser walkthrough writes its fixture rows through `AppDB`
- **THEN** the rows SHALL be written by `member_console_runtime`
## MODIFIED Requirements
### Requirement: Bootstrap script generates a per-worktree .env
The `test/bootstrap-stack.sh` script SHALL generate a `test/.env` file containing port assignments, `COMPOSE_PROJECT_NAME`, the compose-profile composition knob, Keycloak hostname, member-console base URL, temporal-ui base URL, the application's two connection strings (`MC_DB_DSN` as the runtime user `member_console_runtime`, `MC_DB_MIGRATE_DSN` as the migration user `member_console`), and the test-database DSNs, based on a slug derived from the current worktree.
#### Scenario: Bootstrap derives slug from worktree directory basename
- **WHEN** the bootstrap script is invoked without arguments from a worktree at `/home/user/Repos/mc-llm-a`
- **THEN** the script uses `mc-llm-a` as the slug
- **AND** writes `COMPOSE_PROJECT_NAME=mc-llm-a` to `test/.env`
#### Scenario: Bootstrap accepts an explicit slug argument
- **WHEN** the bootstrap script is invoked as `./test/bootstrap-stack.sh custom-slug`
- **THEN** the script uses `custom-slug` as the slug regardless of the worktree directory name
- **AND** writes `COMPOSE_PROJECT_NAME=custom-slug` to `test/.env`
#### Scenario: Bootstrap allocates ports deterministically by slug hash
- **WHEN** the bootstrap script computes the port allocation for a given slug
- **THEN** the slot is `fnv32(slug) mod 200`
- **AND** each service port equals `default_port + slot * 50`
- **AND** running bootstrap a second time with the same slug in a clean environment produces the same port assignments
#### Scenario: Bootstrap recovers from port collisions
- **WHEN** the bootstrap script's hashed slot would assign ports that are already bound on the host
- **THEN** the script increments the slot (mod 200) until a slot whose ports are all free is found
- **AND** the chosen slot is persisted in `test/.env` so subsequent runs in the same worktree do not re-probe
#### Scenario: Bootstrap is idempotent when .env already exists
- **WHEN** the bootstrap script is invoked in a worktree where `test/.env` already exists and sets `MC_DB_MIGRATE_DSN`
- **THEN** the script does not modify `test/.env`
- **AND** prints the existing port and URL summary
- **AND** exits with status 0
#### Scenario: Bootstrap refuses an env file that predates the runtime user
- **WHEN** the bootstrap script is invoked in a worktree whose `test/.env` does not set `MC_DB_MIGRATE_DSN`
- **THEN** the script does not modify `test/.env`
- **AND** prints the refusal and the regeneration commands to standard error and exits with status 1
#### Scenario: Bootstrap prints a port and URL summary
- **WHEN** the bootstrap script completes successfully
- **THEN** the script prints a table to stdout listing the stack name, every assigned host port, the active integration profiles, and the member-console, Keycloak, and Temporal UI URLs that the developer should use
- **AND** names the runtime and the migration database users
- **AND** presents the FedWiki and Discourse URLs as conditional on their profiles
#### Scenario: Bootstrap writes the application's two connection strings
- **WHEN** the bootstrap script generates `test/.env`
- **THEN** `MC_DB_DSN` SHALL log in as `member_console_runtime` and `MC_DB_MIGRATE_DSN` as `member_console`
- **AND** both SHALL name the application database on the worktree-allocated Postgres host port
#### Scenario: Bootstrap writes the test-database DSNs
- **WHEN** the bootstrap script generates `test/.env`
- **THEN** the file SHALL contain a DSN for the package-test database, a DSN
for the in-process e2e database, and a maintenance DSN used to drop and
recreate them
- **AND** all three SHALL address the same worktree-allocated Postgres host
port as `MC_DB_DSN`
- **AND** neither test DSN SHALL name the application database
### Requirement: Application reads MC_-prefixed env vars to override mc-config.yaml
The bootstrap script SHALL produce a `test/.env` whose `MC_*` variables, when exported into the shell that runs `member-console start`, override the corresponding `mc-config.yaml` keys via Viper's existing `AutomaticEnv` binding.
#### Scenario: Environment overrides config file port
- **WHEN** `test/.env` defines `MC_PORT=8090` and the developer exports it before running `member-console start --config test/mc-config.yaml`
- **THEN** the application listens on port 8090 instead of the `port: 8081` value in the config file
#### Scenario: Environment overrides database DSN
- **WHEN** `test/.env` defines `MC_DB_DSN=postgres://member_console_runtime:member_console_runtime@localhost:5532/member_console?sslmode=disable` and `MC_DB_MIGRATE_DSN=postgres://member_console:member_console@localhost:5532/member_console?sslmode=disable`
- **AND** the developer exports them before running `member-console start --config test/mc-config.yaml`
- **THEN** the application migrates and serves on Postgres host port 5532 instead of the 5432 in the config file
@@ -0,0 +1,207 @@
Tests follow `docs/testing.md`: database tests open the database through `dbtest`, a test whose subject is a migration or the migrator gets a `dbtest.Scratch` database, and tests meet the same complexity limit as production code, so a table runs its rows through a named function. A test that proves a privilege by running a statement drops to `db.RuntimeGroupRole` with `SET LOCAL ROLE` inside a transaction it rolls back, and asserts the SQLSTATE (`42501` for a privilege, `22023` for a refused argument), never only a word of the message. A test that creates a role does it inside a transaction it rolls back, or drops the role in its cleanup. A refusal's test compares the whole sentence with its entry in the design's Copy table.
Sections 1 to 6 are one commit each, and their items are that commit's parts; section 7 is not a commit. Every commit passes `make test` with the test stack up, which runs `make lint` first, including `TestConsoleReaderColumns`, `TestEnvironmentReferenceDocumentsEveryConfigKey`, `filler-copy` and `em-dash-copy`. No golden file and no screen changes in any section, and no existing test's result changes except where a section's items name the test; any other change is a stop. The copy is the design's Copy table, approved by the maintainer before section 1 starts; a string that differs from its approved entry is a stop. Sections 1 to 3 prove the roles by `SET LOCAL ROLE console_runtime` on the superuser `TEST_DATABASE_URL`, so they need no runtime login and pass on any worktree's stack as it stands; section 4 does not (its preamble says so). Paths under `migrations/` are in the stream the item names: `internal/db/migrations/` (core), `internal/domains/migrations/`, and `internal/integrations/<key>/store/migrations/`.
**Breaks.** Each section's list is written to a file outside the repository and run with `go run ./scripts/mutation-check -list <file> -side new` on the section's commit, after its green control run; a new test has no old side. The enclosure tests that section 1 moves from `core_writer` or `applicationRole` to `console_runtime` are converted checks: their breaks also run with `-side old` on a checkout of the parent commit, and a break the old side misses is named in the commit message as the strength the move adds. A break in a migration file reaches the shared test database only after it is migrated again, so a list with such a break carries `"prepare": "cd test && ./reset-test-db.sh"`; a break in a role membership, which is cluster-wide and survives a reset, reaches its test without one (design D16). Every record must be CAUGHT. An `old` text below is the code as the section's tasks write it; when the committed code words it differently, the record is corrected to the committed text before the run, never dropped. The commit message lists every break with its result.
## 1. The runtime group role, the writers' privileges and the baselines' logins
- [x] 1.1 Core `migrations/00029_runtime_group_role.sql` (design D5, D6, D7), one `StatementBegin` block per section. Up: a `DO` block that creates `console_runtime NOLOGIN` when `pg_roles` has no such role, and revokes `core_writer` from `member_console` when `pg_roles` has that role and `pg_auth_members` records the membership; then `GRANT core_writer TO console_runtime` and `GRANT SELECT ON goose_db_version_core TO console_runtime` (unqualified, as goose created it). Down: `REVOKE SELECT ON goose_db_version_core FROM console_runtime`, and `DROP ROLE IF EXISTS console_runtime` inside a block that catches `dependent_objects_still_exist` and raises a notice, as the baselines' Down sections do. The Down neither revokes `core_writer` from `console_runtime` nor grants it back to `member_console`. The file's header comment says why the role exists, why the grant is unqualified, and why the Down leaves both memberships.
- [x] 1.2 The same for the other four streams: domains `migrations/00003_runtime_group_role.sql`, FedWiki `migrations/00005_runtime_group_role.sql`, Stripe `migrations/00006_runtime_group_role.sql`, Discourse `migrations/00004_runtime_group_role.sql`, each with its own writer and its own ledger, the guarded revoke from `member_console` (the role must exist and hold the membership, because revoking from a role that does not exist is an error), and a Down that revokes only the ledger grant. None creates or drops `console_runtime`.
- [x] 1.3 The five baselines name no login (design D6): delete `GRANT core_writer TO member_console` and its Down `REVOKE` from core `internal/db/migrations/00001_init.sql` (`:1173` and its Down), and the same statement and Down `REVOKE` for `domains_writer` (`internal/domains/migrations/00001_init.sql:201`), `fedwiki_writer` (`internal/integrations/fedwiki/store/migrations/00001_init.sql:100`), `stripe_writer` (`internal/integrations/stripe/store/migrations/00001_init.sql:165`) and `discourse_writer` (`internal/integrations/discourse/store/migrations/00001_init.sql:122`). The comment above core's statement, which says the role keeps its privileges explicitly, is rewritten to say that the runtime user reaches the writers through `console_runtime` (migrations `00029` and its siblings). Nothing else in the baselines changes in this section.
- [x] 1.4 The writers keep the four DML privileges (design D13): `migrations/00030_writer_dml_only.sql` in core, `00004_writer_dml_only.sql` in domains, `00006_writer_dml_only.sql` in FedWiki, `00007_writer_dml_only.sql` in Stripe and `00005_writer_dml_only.sql` in Discourse. Each Up is one statement, `REVOKE TRUNCATE, REFERENCES, TRIGGER, MAINTAIN ON ALL TABLES IN SCHEMA <schema> FROM <schema>_writer`; each Down does nothing, and the header comment says why (re-granting would also undo enclosures that revoked `TRUNCATE` earlier, and no code path used the privileges) and that a table added later must be granted `SELECT, INSERT, UPDATE, DELETE` by name, never `ALL`. The earlier `GRANT ALL` lines are not edited.
- [x] 1.5 `internal/db/runtime_user.go` (new): the constant `RuntimeGroupRole = "console_runtime"`, with a doc comment naming the migrations that grant to it.
- [x] 1.6 The enclosure tests move to the group role (design D15). Delete `applicationRole` from `internal/db/integration_delivery_fixtures_test.go` and update the file's header comment; every caller (`acts_test.go`, `integration_delivery_enclosure_test.go`, `plan_act_obligations_test.go`) passes `db.RuntimeGroupRole` to `asRole`. `TestConferralEnclosure_WriterRole` (`internal/entitlements/conferral_enclosure_test.go`) and `TestRuleTableDMLRevoked` (`internal/entitlements/enclosure_test.go`) run `SET LOCAL ROLE console_runtime` through the constant, and their comments say that the test login is a superuser and that the group role is what the runtime user holds. `TestConferralEnclosure_WriterRole` gains three probes on each of the three position tables, each in a savepoint expecting SQLSTATE `42501`: `TRUNCATE <table>`; `CREATE TRIGGER` naming a trigger function the test created in `pg_temp` first, so the error cannot be a missing function; and `LOCK TABLE <table> IN ACCESS EXCLUSIVE MODE`. Its header comment names `TRUNCATE`, `TRIGGER` and `MAINTAIN` with the other three writes. The tests that drop to `core_reader`, `core_owner`, `core_writer` (`plan_act_obligations_test.go:848`) or a bare role keep doing so. The other tests' results do not change.
- [x] 1.7 `TestStreamsGrantTheirWriterToTheRuntimeRole` (`internal/db/runtime_role_test.go`), on a `dbtest.SimpleProtocol` connection to the test database, in one transaction it rolls back: revoke the five writers and the five ledger grants from `console_runtime`; grant the writers to `member_console` when that role exists, which simulates a database an earlier release migrated; run the Up section of each of the five group-role migrations (`dbtest.UpSQL`), core first; then assert that `console_runtime` inherits each writer (`pg_has_role(..., 'USAGE')`), that `member_console` is a direct member of none, and that `console_runtime` holds `SELECT` on each stream's ledger. A last row runs each of the five Up sections again with `member_console` replaced by a name no role has (`dbtest.Name("mc_absent_")`) and expects no error: on the test stack `member_console` always exists, so no other row notices a missing guard. `TestBaselinesGrantNoLogin`, on a scratch database: revoke the five writers from `member_console` first, when the role exists; migrate core to version 28, the one before `00029`, with `dbtest.UpTo`, then apply version 1 of domains, FedWiki, Stripe and Discourse; assert that `member_console` is a direct member of no writer role; revoke again in cleanup, because the membership would be cluster-wide. Core goes first and goes that far because a real run migrates core to its end before any other stream, and domains' baseline inserts a `kind` column that core `00008` adds, so version 1 of core alone fails the test on correct code; the run stops before the group-role migrations because they would revoke a restored grant and hide it. `TestRuntimeRoleOutlivesARollbackElsewhere` migrates a scratch database with `migrate.Sources()`, rolls back each stream's group-role migration with `dbtest.ApplyVersion(..., false)`, integrations first, then core `00029`, and asserts that `console_runtime` still exists, because the shared test database still grants it the ledgers, that it still inherits `core_writer`, and that `member_console` is still a direct member of no writer role.
- [x] 1.8 `TestRuntimeRolePrivileges` (`internal/db/runtime_role_privileges_test.go`, design D16), on the shared test database: the locked list as a table in the test, each entry naming the privileges it holds (the three position tables hold `SELECT`); for every relation of the five schemas, partitions and views included, `console_runtime` holds none of `TRUNCATE`, `REFERENCES`, `TRIGGER` and `MAINTAIN`. Every failure names the relation and the privilege. It also asserts that no role but `console_runtime` is a direct member of a writer role, apart from `fedwiki_writer`, which the FedWiki stream makes a member of `domains_writer`.
- [x] 1.9 Breaks:
- core `00029` without `GRANT core_writer TO console_runtime`: caught by `TestStreamsGrantTheirWriterToTheRuntimeRole` (no reset);
- FedWiki `00005` grants `domains_writer` in place of `fedwiki_writer`: caught by the same test;
- Stripe `00006` without its revoke from `member_console`: caught by the same test;
- Discourse `00004` without its ledger grant: caught by the same test and, with the reset, by `TestRuntimeRolePrivileges`;
- the core baseline with `GRANT core_writer TO member_console` restored: caught by `TestBaselinesGrantNoLogin`;
- the Discourse baseline with `GRANT discourse_writer TO member_console` restored: caught by `TestBaselinesGrantNoLogin`;
- core `00029`'s Down revokes `core_writer` from `console_runtime`: caught by `TestRuntimeRoleOutlivesARollbackElsewhere`;
- core `00029`'s Down drops `console_runtime` without the `dependent_objects_still_exist` handler: caught by the same test, whose rollback then fails;
- core `00029`'s Down grants `core_writer` back to `member_console`: caught by the same test;
- core `00030` emptied, so the baseline's `GRANT ALL` stands (with the reset): caught by `TestRuntimeRolePrivileges`, naming a core table and `TRIGGER`, and by the three probes of `TestConferralEnclosure_WriterRole`;
- core `00030` revokes `TRUNCATE`, `REFERENCES` and `MAINTAIN` and leaves `TRIGGER` (with the reset): caught by `TestRuntimeRolePrivileges` naming the table and `TRIGGER`, and by the `CREATE TRIGGER` probe;
- core `00030` revokes from two position tables only, by listing the tables in place of `ALL TABLES` (with the reset): caught by the probes on the third table;
- domains `00004`, FedWiki `00006`, Stripe `00007` and Discourse `00005`, each emptied in turn (with the reset): caught by `TestRuntimeRolePrivileges`, naming a table of that stream's schema and `TRIGGER`;
- `GRANT ALL ON core.integration_config_overrides TO core_writer` appended after the revoke in core `00030` (with the reset): caught by `TestRuntimeRolePrivileges`, naming that table and `TRIGGER`;
- `00009`'s `GRANT ALL ON core.integration_config_overrides TO core_owner, core_writer` loses `core_writer` (with the reset): caught by `TestRuntimeRolePrivileges`, naming the table and `INSERT`;
- `00027`'s `GRANT SELECT ON core.acts, core.plan_act_details TO core_writer` becomes `GRANT SELECT, INSERT` (with the reset): caught by `TestRuntimeRolePrivileges` and by `TestActRecordIsEnclosed`;
- a locked entry for a table that does not exist added to the list: caught by `TestRuntimeRolePrivileges` (a test-side record; it proves the stale-entry check);
- the membership assertion of `TestRuntimeRolePrivileges` without its `fedwiki_writer` exception: caught, naming `fedwiki_writer` (a test-side record; a production break would leave a cluster-wide membership behind);
- domains `00003`'s revoke without its guard on the role existing: caught by the absent-role row of `TestStreamsGrantTheirWriterToTheRuntimeRole` (no reset);
- as converted checks, on both sides (with the reset): `00005`'s `REVOKE INSERT, UPDATE, DELETE ON core.pool_provisions FROM core_writer` loses `INSERT`: caught by `TestConferralEnclosure_WriterRole` on both sides; `00024`'s `GRANT SELECT ON core.person_cut_offs TO core_writer` becomes `GRANT SELECT, INSERT`: caught by `TestPersonCutOffEnclosure` on both sides;
- on the new side only, since `console_runtime` does not exist on the old one (with the reset): core `00029` also grants `INSERT` on `core.pool_provisions` to `console_runtime`: caught by `TestConferralEnclosure_WriterRole` and `TestRuntimeRolePrivileges`, a grant that a test run as `core_writer` cannot see; the same grant of `TRIGGER`: caught by the `CREATE TRIGGER` probe and `TestRuntimeRolePrivileges`.
- [x] 1.10 `docs/building-an-integration.md`, section 7: the "`member_console` membership" bullet becomes "`console_runtime` membership": a baseline grants `{key}_writer` and `SELECT` on `goose_db_version_<key>` to `console_runtime`, never to a login, and core creates the role first; a table is granted `SELECT, INSERT, UPDATE, DELETE` by name and never `ALL`, and the stream's `writer_dml_only` migration revokes the other four; the Down bullet says the Down revokes the ledger grant and leaves the membership (design D7). Section 9's FedWiki paragraph says "its role triple and its grants to `console_runtime`". `docs/database-management.md`, "Roles": the paragraph about `member_console` describes `console_runtime`, the five memberships, the ledger grants, that the writers hold the four DML privileges and no other, that the console grants the role to the runtime user at start, and that owner roles are granted to no login; "Owner roles are reserved for running migrations" goes. The ownership paragraph lands in section 3.
- [x] 1.11 No golden moves; `internal/migrate/testdata/console-reader-columns.txt` does not change.
## 2. Quick fix: every command that opens the database reads the `-file` keys
- [ ] 2.1 `cmd/start.go` (design D12): split the core secret pairs into `databaseSecretPairs` (`db-dsn`/`db-dsn-file`) and the rest; the loop of `resolveSecretFiles` becomes `resolveSecretPairs(pairs)`; `resolveSecretFiles` resolves the core pairs, the database pairs and the integrations' pairs as today; a new `resolveDatabaseSecretFiles` resolves `databaseSecretPairs` alone. Doc comments say which commands call which and why a command resolves once.
- [ ] 2.2 `migrate up`, `migrate down`, `migrate status` (`cmd/migrate.go`), `connectSettings` (`cmd/config.go`, the one path of `config list`, `set` and `clear`) and `seed-demo` (`cmd/seed_demo.go`) call `resolveDatabaseSecretFiles` before `openDB`. `connectSettings` returns its error, and the three `Run` functions print it and exit as they do today, so a test can call it.
- [ ] 2.3 `openDB` refuses an empty `db-dsn` with copy 13 before it opens anything.
- [ ] 2.4 `TestDatabaseCommandsReadTheFileKeys` (`cmd/database_files_test.go`), one row per path through a named function: `migrate up` and `migrate status` (their `RunE`), `connectSettings`, and `seed-demo` (its `RunE`). Each row sets only `db-dsn-file`, naming a file that holds `postgres://u:p@127.0.0.1:<a closed port>/x?connect_timeout=1`, and asserts that the error is a connection failure that names that port, not copy 13. A second table sets neither key and asserts copy 13 for each path. `TestResolveSecretFiles_DBDSN` passes unchanged.
- [ ] 2.5 Breaks:
- `migrate up` without its `resolveDatabaseSecretFiles` call: caught by its row (copy 13 in place of the connection failure);
- `connectSettings` without the call: caught by its row;
- `seed-demo` without the call: caught by its row;
- `openDB` without the empty check: caught by the second table;
- `resolveDatabaseSecretFiles` resolves an empty list: caught by every row of the first table.
## 3. Runtime paths through functions owned by `core_owner`: webhook partitions and the plan-act obligation lock
The plan-act obligation lock (items 3.10 to 3.14) lives in this section, not in a section of its own, because it is the same kind of change as the partition function (a core migration that gives the runtime user a path through the enclosure that its role alone does not have), it needs only `console_runtime` from section 1, it passes `make test` alone, and it lands before section 4, which is the first section that serves anything as the runtime user. A section of its own would renumber sections 4 to 7 and every reference to them.
- [ ] 3.1 Core `migrations/00031_webhook_partition_function.sql` (design D3, D4): Up moves `core.webhook_events` to `core_owner`, then each of its partitions through a `DO` loop over `pg_inherits`; creates `core.ensure_webhook_event_partitions` as the design writes it; sets its owner, revokes `EXECUTE` from `PUBLIC` and grants it to `console_runtime`. Down drops the function, gives the parent and each partition to `CURRENT_USER`, and then, in the same loop, runs `GRANT ALL ON core.webhook_events TO core_owner` and the same for each partition, so the database is back where the baseline's `GRANT ALL ON ALL TABLES IN SCHEMA core TO core_owner` left it (the Up's `OWNER TO core_owner` merges that explicit entry into the owner's, and without the re-grant the Down leaves `core_owner` none). The header comment says why `core_owner` owns the table, why the Down re-grants, and that the bounds must match the baseline's `DO` block and the Go names.
- [ ] 3.2 Core `internal/db/migrations/00001_init.sql`, the initial-partition `DO` block (`:1048-1064`; design D4): `m_start` becomes a `TIMESTAMP` computed as `DATE_TRUNC('month', NOW() AT TIME ZONE 'UTC') + make_interval(months => i)`, the upper bound is `m_start + INTERVAL '1 month'`, and both bounds are rendered with `AT TIME ZONE 'UTC'`, so the block names and bounds the partitions as the function does in any session time zone. The block's comment says so. Nothing else in the baseline changes in this section.
- [ ] 3.3 `internal/db/partitions.go`: `EnsureWebhookEventPartitions(ctx, ex, now, monthsAhead)` takes an executor (`ExecContext`) and runs `SELECT core.ensure_webhook_event_partitions($1, $2)`. The doc comment says that the function, owned by `core_owner`, creates the partitions, and that the naming and bounds parity is between the function and the baseline's `DO` block; the pgx placeholder paragraph goes. `cmd/bootstate.go` and `internal/workflows/maintenance/activities.go` compile unchanged.
- [ ] 3.4 `internal/db/partitions_test.go`, rewritten. `TestEnsureWebhookEventPartitions_ClosesTheGapAndIsIdempotent` runs in one transaction it rolls back, under `SET LOCAL ROLE console_runtime` and `SET LOCAL TimeZone = 'Pacific/Kiritimati'`, with the synthetic month of today's test: an insert into the target month fails with `23514` in a savepoint; the ensure creates the four partitions; inserts at the target month's first hour and last hour (UTC) each land in the target month's partition; a second ensure creates nothing; every created partition is owned by `core_owner`. Nothing is dropped, since the transaction rolls back. `TestWebhookPartitionsBelongToCoreOwner` asserts the owner of `core.webhook_events` and of every partition, and that the function is `SECURITY DEFINER`, owned by `core_owner`, configured `search_path=core, pg_temp`, and not executable by `PUBLIC`. `TestRuntimeRoleCannotCreatePartitionsDirectly`, as `console_runtime`, runs the `CREATE TABLE ... PARTITION OF` the Go code ran before and expects `42501`. `TestEnsureWebhookEventPartitionsRefusesAWindow`, one row each for 13 months, -1 months and a null time, expects `22023` and no new partition. The file's header comment says what runs as whom.
- [ ] 3.5 `schemaSnapshot` (`internal/db/integration_delivery_migration_test.go:152-153`) renders every ACL order-insensitively, for `relacl`, `attacl`, `nspacl` and `proacl`: for example `(SELECT string_agg(x::text, ',' ORDER BY x::text) FROM unnest(c.relacl) x)` in place of `c.relacl::text`. Sorting both sides keeps equal sets equal and unequal sets unequal, so the three existing up/down/up tests keep their results, and a Down that re-grants an entry appended at the end of the ACL compares equal. `TestWebhookOwnershipMoveRoundTrips` (`internal/db`), on a scratch database migrated with `dbtest.UpTo` to core `00030`: record `schemaSnapshot` (which records `relowner` and the ACLs); apply `00031` up and record again; apply it down and assert that the snapshot equals the first, so the parent and every partition are back with the user that ran the Down; apply it up again and assert that the snapshot equals the second.
- [ ] 3.6 `TestBaselinePartitionBoundsAreUTC` (`internal/db`), on a scratch database: apply core version 1 on a connection whose `TimeZone` is `Pacific/Kiritimati` (a runtime parameter of the connection string), then assert that each of the three initial partitions' bounds, read as `timestamptz`, is midnight UTC on the first of the current UTC month and of the two months after it, and that the names carry those months.
- [ ] 3.7 `TestRuntimeRolePrivileges` asserts `EXECUTE` on `core.ensure_webhook_event_partitions` and on `core.lock_plan_act_obligation` for `console_runtime`.
- [ ] 3.8 Breaks (the migrations' with the reset):
- the function computes the month in the session's time zone (`date_trunc('month', p_now)` without `AT TIME ZONE 'UTC'`): caught by the last-hour insert;
- no `GRANT EXECUTE ... TO console_runtime`: caught by the gap test and `TestRuntimeRolePrivileges`;
- `SECURITY INVOKER` in place of `SECURITY DEFINER`: caught by the gap test (`42501` on the create) and the ownership test;
- no `SET search_path`: caught by the ownership test;
- no `REVOKE EXECUTE ... FROM PUBLIC`: caught by the ownership test and `TestConsoleReaderColumns`;
- the partition loop in the Up removed: caught by the ownership test, on a partition the baseline created;
- the window check allows 13: caught by the refusal test;
- `EnsureWebhookEventPartitions` passes `monthsAhead - 1`: caught by the gap test;
- the baseline block takes `DATE_TRUNC('month', NOW())` in the session's time zone, as it did: caught by `TestBaselinePartitionBoundsAreUTC` (a scratch database; no reset);
- `00031`'s Down gives the parent to `core_owner` in place of `CURRENT_USER`: caught by `TestWebhookOwnershipMoveRoundTrips`;
- `00031`'s Down leaves the partitions with `core_owner`: caught by the same test;
- `00031`'s Down without the `core_owner` re-grant: caught by `TestWebhookOwnershipMoveRoundTrips`;
- `schemaSnapshot` with the ACLs in stored order, as the test code reads it (a test-side record): caught by `TestWebhookOwnershipMoveRoundTrips` on a correct Down, whose re-grant appends the entry at the end;
- `TestRuntimeRoleCannotCreatePartitionsDirectly` without its `SET LOCAL ROLE` (a test-side record): caught, because the statement succeeds as the superuser;
- [ ] 3.9 `docs/database-management.md`, "Roles": a paragraph on ownership (design D3): objects belong to the migration user, `core_owner` owns the definer functions and `core.webhook_events` with its partitions, and the runtime user owns nothing, neither in the schemas nor the database.
- [ ] 3.10 Core `migrations/00032_lock_plan_act_obligation.sql` (design D19): Up creates `core.lock_plan_act_obligation(p_act_id UUID, p_pool_id UUID)` as the design writes it, sets its owner to `core_owner`, revokes `EXECUTE` from `PUBLIC` and grants it to `core_writer`, in the order and shape of `00028`'s other definer functions; Down drops the function. The header comment says that a locking read needs `UPDATE`, which the enclosure withholds from the writer, why the function reads only the obligation row, and that the lock, its mode and its order are those of the query it replaces.
- [ ] 3.11 `internal/entitlements/queries/plan_acts.sql`: `LockPlanActObligation` becomes the query below (design D19); its comment keeps the account of why only the obligation row is read, adds that the function takes the lock for the runtime role, and says that sqlc cannot see a function's output columns, so the alias list names them and the casts give them their non-null types. Regenerate with `make sqlc-generate`, which restores the SPDX header that sqlc drops, so `internal/entitlements/plan_acts.sql.go` is regenerated; the row type, `LockPlanActObligationRow`, and `plan_act_move.go` do not change.
```sql
SELECT l.act_id::uuid AS act_id, l.pool_id::uuid AS pool_id, l.org_type::varchar AS org_type,
l.org_id::uuid AS org_id, l.status::varchar AS status, l.owned::boolean AS owned,
l.motion_act_id::uuid AS motion_act_id
FROM core.lock_plan_act_obligation(sqlc.arg(act_id)::uuid, sqlc.arg(pool_id)::uuid)
AS l(act_id, pool_id, org_type, org_id, status, owned, motion_act_id);
```
- [ ] 3.12 `TestLockPlanActObligationRunsAsTheRuntimeRole` (`internal/entitlements/plan_act_queries_db_test.go`, with the helpers of `TestLockPlanActObligationReadsTheMove`), in one transaction it rolls back: write the act and its obligation as the test login, `SET LOCAL ROLE console_runtime`, call `LockPlanActObligation` and assert the row of `TestLockPlanActObligationReadsTheMove`; requeue it and assert the Retry as the motion; assert `sql.ErrNoRows` for a pool with no obligation; and in a savepoint run `SELECT ... FROM core.plan_act_obligations WHERE false FOR UPDATE` and assert SQLSTATE `42501`, which names the reason for the function. `TestPlanActWritersAreEnclosedInTheCatalog` (`internal/db/plan_act_obligations_test.go`) gains `lock_plan_act_obligation` in its list of functions and in its `PUBLIC` and `core_reader` refusal loop, which asserts that the function is `SECURITY DEFINER`, owned by `core_owner`, configured `search_path=core, pg_temp`, executable by `core_writer` and `console_runtime`, and not by `PUBLIC` or `core_reader`; no new test. `TestLockPlanActObligationSurvivesARequeueItWaitedOn` and the plan-act drain tests run unchanged.
- [ ] 3.13 `docs/database-locks.md`: in the plan act obligation row's "Taken by" cell and in the move's paragraph, the `LockPlanActObligation` query is described as calling `core.lock_plan_act_obligation`, which takes the same `FOR UPDATE` after the pool row; the lock, its mode and the order are unchanged. Nothing else in the file changes.
- [ ] 3.14 Breaks for 3.10 to 3.13 (the migration's with the reset):
- the sqlc query returned to the bare `SELECT ... FROM core.plan_act_obligations ... FOR UPDATE OF ob` (the generated text edited the same way): caught by `TestLockPlanActObligationRunsAsTheRuntimeRole` (`42501` at the lock);
- the function without `FOR UPDATE`: caught by `TestLockPlanActObligationSurvivesARequeueItWaitedOn` (the move never waits);
- `SECURITY INVOKER` in place of `SECURITY DEFINER`: caught by `TestLockPlanActObligationRunsAsTheRuntimeRole` and `TestPlanActWritersAreEnclosedInTheCatalog`;
- no `SET search_path`: caught by `TestPlanActWritersAreEnclosedInTheCatalog`;
- no `REVOKE EXECUTE ... FROM PUBLIC`: caught by `TestPlanActWritersAreEnclosedInTheCatalog` and `TestConsoleReaderColumns`;
- no `GRANT EXECUTE ... TO core_writer`: caught by `TestLockPlanActObligationRunsAsTheRuntimeRole` and `TestRuntimeRolePrivileges`;
- the function written to return a single composite row in place of `TABLE` (a missing pair would return a row of nulls): caught by the `sql.ErrNoRows` assertion of `TestLockPlanActObligationRunsAsTheRuntimeRole`;
- `UPDATE` granted on `core.plan_act_obligations` to `core_writer` in place of the function (with the reset): caught by `TestRuntimeRolePrivileges` (the locked list holds `SELECT` only).
## 4. Two connection strings, the boot grant, and the test stack on two users
This section's `make test` passes only after the documented stack respin of item 4.9, which the implementer performs in the worktree before the section's suite runs: the guard refuses a `test/.env` without `MC_DB_MIGRATE_DSN`, and `postgres-seed` creates the runtime login only on `docker compose up`. "Every commit passes `make test`" therefore holds from a stack so respun, not from a fresh checkout beside an old `test/.env`.
- [ ] 4.1 Keys (design D1): `startCmd` registers `db-migrate-dsn` and `db-migrate-dsn-file` (copies 23, 24), and the help of `db-dsn` and `db-dsn-file` changes (copies 21, 22); `databaseSecretPairs` gains `db-migrate-dsn`/`db-migrate-dsn-file`; `ValidateStart` refuses a malformed `db-migrate-dsn` (copy 14) and treats it as optional.
- [ ] 4.2 `internal/db`: `PendingMigrations(ctx, q, sources)` (design D10) in `migrations.go`, which reads each ledger as `public.goose_db_version_<stream>` by qualified name and calls `pg_catalog.to_regclass` and `pg_catalog.has_table_privilege` (design D2, the qualification principle), with a doc comment that says why it does not use goose's provider and why the names are qualified; `ConnectMigration(ctx, logger, config)`, which opens the migration pool with the startup parameters `search_path=public` and `role=none` as runtime parameters of the connection string, replacing any `search_path` or `role` the string carries (`ensureSearchPath`, `internal/db/database.go:43-60`, keeps an operator's value and is not reused, so a `db-migrate-dsn` copied from the runtime string cannot open with `core,public`); the runtime pool's open gains `role=none`, always replacing the string's value, beside its `search_path=core,public` (`ConnectPlain` stays for `dbConfig`); `CurrentUser` and `GrantRuntimeRole(ctx, tx, name)` (design D8, whose statement calls `pg_catalog.set_config`, `pg_catalog.format` and `pg_catalog.current_setting`; it takes a transaction because the setting lasts until the transaction ends, and `cmd` runs it through `db.InTx`) in `runtime_user.go`, and the refusal type `RuntimeUserRefusal`. Every SQL string of these functions is a constant whose name begins `query` (item 5.8 scans them). `ConnectAndMigrate` is deleted.
- [ ] 4.3 `cmd/database.go` (design D11): modes `dbRuntime`, `dbMigration` and `dbConfig`. `dbMigration` opens `db-migrate-dsn` with `search_path=public` and `role=none` (design D2, rule 2) and refuses without it (copy 12). `dbConfig` is today's `dbPlain` on `db-dsn`. `dbRuntime` opens the runtime pool with `role=none` beside its `search_path`, and runs the boot sequence's steps 2, 3, 6 and 7, step 5's migrations, and the pending check in both modes (copies 4, 4b to 4d, 10, 18, 20); the other checks, and steps 4 and 8 in full, are section 5's, with copy 4a, which belongs to check 5. `start` and `seed-demo` open `dbRuntime`; `start` logs a refusal with copy 17; the `migrate` commands open `dbMigration`; the `config` commands open `dbConfig`. The comment on `openDB` says which mode each command uses and why.
- [ ] 4.4 `internal/embeds/mc-config.yaml`: copies 27 and 27a.
- [ ] 4.5 Test stack (design D14): `test/compose.yaml` gives `postgres` a health check and adds `postgres-seed`; `test/seed/postgres/seed-runtime-user.sh` (new, SPDX header, copies 30 and 31); `test/bootstrap-stack.sh` writes `MC_DB_DSN` and `MC_DB_MIGRATE_DSN`, prints copy 29, refuses a stale `.env` on a re-run with copy 28, and its header says what it writes; `test/reset-test-db.sh` refuses a stale `.env` (copy 28) after its existing guard and runs `MC_DB_MIGRATE_DSN="$dsn" go run . migrate up`; `test/reset-app-db.sh` refuses a stale `.env` and uses `MC_DB_MIGRATE_DSN` for `migrate up`, the pin, `pg_dump` and the snapshot's address, and its header says so; `test/seed-demo.sh` refuses a stale `.env`; `test/mc-config.yaml` carries both strings; `test/mockshot-nuke.sql`'s comment (`:111-114`) says that the stack's migration user is a superuser, which `SET ROLE core_owner` relies on; `test/seed/discourse/seed-discourse.sh`'s header says the runtime user needs `console_runtime`, which the first `start` or `reset-app-db.sh` grants.
- [ ] 4.6 `test/e2e/browsertest/session.go`: `AppAdminDB` opens `MC_DB_MIGRATE_DSN` and skips with copy 32; `AppDB`'s comment says it is the runtime user. `test/e2e/screens/screens_test.go`'s `pinTimestamps` uses `AppAdminDB`.
- [ ] 4.7 Docs: `docs/environment-reference.md` (rows for `db-migrate-dsn` and `db-migrate-dsn-file`; `db-dsn` and `db-dsn-file` name the runtime user); `docs/hosting.md` (five secrets); `docs/database-management.md` ("Migrations run automatically on startup" and the `ConnectAndMigrate` paragraph, design D17; the migration connection opens with `search_path=public`, and the `ConnectPlain` reason is restated for the application's `core,public` path); `docs/testing.md`, `test/AGENTS.md` (the `.env` keys block, the databases table's user column, the regeneration command at `:210-211`) and `test/README.md` (the Postgres row names both users).
- [ ] 4.8 Tests:
- `TestPendingMigrations` (`internal/db`), on scratch databases: migrated in full, nothing pending; a stream stopped short with `dbtest.UpTo`, its missing versions; a database with no Discourse ledger, Discourse with every version; a ledger whose `SELECT` grant to `console_runtime` is revoked, the unreadable detail; a temporary table named like a ledger, created on the checking connection, changes no result; each read as `console_runtime` on a connection with `search_path=core,public`.
- `TestMigrationPoolIgnoresADatabaseSearchPath` (`internal/db`, in a `db_test` file such as `internal/db/migration_pool_test.go`, because it exercises `db.ConnectMigration` and `schemaSnapshot` lives in package `db_test`, which `cmd` cannot import), on scratch databases the test creates: with `ALTER DATABASE` setting `search_path` to a schema the test created and then `public`, the migrations run through the migration pool, `SHOW search_path` on it returns `public`, and every ledger is in `public` and read by `PendingMigrations`; a row whose database sets `role` to a role the test creates, expecting `SELECT current_user` on the pool to be the migration login; and two fresh scratch databases with no extra schema (`schemaSnapshot` reads every user schema, so a database that carries the first row's extra schema never compares equal), one migrated through the migration pool and one by a plain replay (`dbtest`), with equal snapshots, so a dropped object fails the test.
- `TestGrantRuntimeRole` (`internal/db`), in a transaction it rolls back, which `GrantRuntimeRole` takes because its setting is local to a transaction: a role named `Mc-Runtime-<n>`, created `LOGIN NOINHERIT`, inherits `console_runtime` after the grant; a second grant succeeds and changes nothing.
- `TestOpenRuntimeDatabase` (`cmd/database_db_test.go`), on scratch databases with login roles the test creates with a password and drops in its cleanup: with both strings on a fresh scratch database, the runtime login inherits `console_runtime`, `SELECT current_user` on the returned pool is the runtime login, the ledgers read through the returned pool are at every embedded version, and `pg_stat_activity`, read from the shared test database, shows no session of the migration user on the scratch database; without `db-migrate-dsn` on a scratch database stopped short, copy 4 with 4d, and the ledgers unchanged; without it on a current database, the pool is returned and nothing ran; `migrate up` without `db-migrate-dsn`, copy 12; a runtime login that is a member of `console_runtime` and has run `ALTER ROLE CURRENT_USER SET role = 'console_runtime'` (its later sessions would report `current_user` as the group role), a normal boot with the login as `current_user` on the returned pool.
- `TestValidateStart` gains a row for copy 14; `TestResolveSecretFiles_DBDSN` gains the `db-migrate-dsn` pair's two rows; `TestDatabaseCommandsReadTheFileKeys`'s `migrate` rows set `db-migrate-dsn-file` and expect copy 12 when neither key is set.
- `TestScriptsRefuseAnEnvWithoutTheMigrationDSN` (`test/scripts_env_test.go`), with the shims of `TestScriptsRefuseUnknownArguments`, runs each of the four scripts beside a `.env` with every other key it reads and asserts copy 28 on stderr, exit status 1, and that no shim ran. `TestBootstrapChecksTheFarmName`'s fixture `.env` gains `MC_DB_MIGRATE_DSN`; its result does not change.
- [ ] 4.9 Before this section's `make test`, regenerate this worktree's `test/.env` with copy 28's commands; `docker compose logs postgres-seed` shows copy 30. Then `cd test && ./reset-app-db.sh --rebuild`, start the application, and check that it boots, that the boot log shows copy 20, and that the operator and member pages load.
- [ ] 4.10 Walkthroughs from the demo snapshot before and after (`test/AGENTS.md`, "Browser walkthroughs"), with `go test -count=1 ./test/e2e/operator-walkthroughs/` on the parent commit with the `test/.env` it writes, and on this one with the regenerated file: the test names, results and skip messages match. `make screens` lists no changed screen.
- [ ] 4.11 Breaks (the migration-free ones need no reset):
- `dbRuntime` runs the migrations on the runtime pool: caught by `TestOpenRuntimeDatabase`'s first row (the runtime login cannot create a schema);
- `dbRuntime` skips the boot grant: caught by the first row's membership assertion;
- `dbRuntime` leaves the migration pool open: caught by the first row's `pg_stat_activity` assertion;
- `dbRuntime` skips the pending check when `db-migrate-dsn` is unset: caught by the second row;
- `dbRuntime` migrates on `db-dsn` when `db-migrate-dsn` is unset: caught by the second row (the ledgers change);
- `GrantRuntimeRole` without `WITH INHERIT TRUE`: caught by `TestGrantRuntimeRole`;
- `GrantRuntimeRole` with `%s` in place of `%I`: caught by `TestGrantRuntimeRole` (the mixed-case name);
- `PendingMigrations` takes a missing ledger as current: caught by `TestPendingMigrations`;
- `PendingMigrations` resolves the ledger without the `public.` qualifier: caught by the temporary-table row of `TestPendingMigrations`;
- the migration pool opened without `search_path=public`: caught by `TestMigrationPoolIgnoresADatabaseSearchPath` (the ledgers land in the test's schema);
- the migration pool opened without `role=none`: caught by the `role` row of `TestMigrationPoolIgnoresADatabaseSearchPath` (`current_user` is the test's role);
- the runtime pool opened without `role=none`: caught by the self-set-role row of `TestOpenRuntimeDatabase` (the boot grant names `console_runtime` and fails, copy 10);
- `ConnectMigration` keeps the `search_path` or `role` that the string carries, as `ensureSearchPath` does: caught by `TestMigrationPoolIgnoresADatabaseSearchPath`, whose scratch connection string carries `search_path=core,public&role=<the test's role>`;
- `dbMigration` falls back to `db-dsn`: caught by the `migrate up` row;
- the bootstrap guard removed: caught by `TestScriptsRefuseAnEnvWithoutTheMigrationDSN`'s bootstrap row;
- `reset-app-db.sh`'s guard after its first `psql`: caught by its row (a shim ran).
## 5. The start-up check refuses a runtime user that can bypass the grants
- [ ] 5.1 `internal/db/runtime_user.go`: `CheckBeforeMigrating(ctx, q)` runs the membership half of check 3 and the runtime half of check 4 of design D9 in one read-only `REPEATABLE READ` transaction and returns a `*RuntimeUserRefusal` with copy 3a, 3b or 3c, or copy 11 on a failed query; it scans the direct role as nullable. `CheckMigrationDatabaseOwner(ctx, q)` runs the owner half of check 4 on the migration connection, with qualified names only, and returns a `*DatabaseOwnerRefusal` with copy 3d, or copy 11 on a failed query. The identity read on the migration connection is `SELECT current_user, pg_catalog.current_database()` and nothing else; the superuser bit and the quoted name come from the runtime connection. `CheckSchemasBeforeMigrating(ctx, q, login, schemas)` runs check 12 (design D9): the `CREATE` query over `public` and the five schemas, then check 9's query restricted to `public` with the login as a parameter, and returns a `*RuntimeUserRefusal` with copy 3e, 7 or 8, or copy 11; `cmd` calls it on the migration connection after `CheckMigrationDatabaseOwner`, and on the runtime connection in the separate-step mode. `CheckRuntimeUser(ctx, q, check)` runs checks 5 to 11 in that order in one such transaction and returns a `*RuntimeUserRefusal` with copies 4 to 9, 8a and 9a, or copy 11. `RuntimeCheck` carries the stream names and the owner role names; `cmd` builds both from `migrate.Sources()`. Every function and catalog relation of every query is written with `pg_catalog.` (design D2, the qualification principle), and every query is a constant whose name begins `query`. The queries of checks 3, 4, 8 and 9 select `pg_catalog.quote_ident` of the direct role; check 9's first query also selects `pg_catalog.pg_get_userbyid(owner)`, which copy 8 prints; and check 11's two reads return the grantee (`PUBLIC`, or a quoted role name) from `pg_catalog.aclexplode` of the relation's ACL, restricted to entries that reach the user (a grant to `PUBLIC` or to a role the user inherits), so copy 9 prints the one `REVOKE` that removes the privilege; when the grantee is null, a third query names the predefined role (`pg_write_all_data` for `INSERT`, `pg_maintain` for `MAINTAIN`) and the role it is granted to, and the refusal is copy 9a. Check 11 also reads the first relation of the five schemas on which the user holds `TRUNCATE`, `REFERENCES`, `TRIGGER` or `MAINTAIN`. The pending check of section 4 moves into `CheckRuntimeUser` as check 7.
- [ ] 5.2 `cmd/database.go`: `dbRuntime` reads both identities after it opens the pools (the migration connection's with the one qualified query) and refuses checks 1 to 3 (copies 1, 2, 3), calls `CheckBeforeMigrating` (copies 3a, 3b, 3c), then `CheckMigrationDatabaseOwner` (copy 3d), then `CheckSchemasBeforeMigrating` on the migration connection (copies 3e, 7, 8), before it migrates and before the boot grant (in the separate-step mode it calls `CheckSchemasBeforeMigrating` on the runtime connection before `CheckRuntimeUser`), and calls `CheckRuntimeUser` after it closes the migration pool; a passing check logs copy 19. `dbMigration` calls `CheckMigrationDatabaseOwner` before every `migrate` command (`up`, `down` and `status`) runs goose, because goose's `Status` creates any missing ledger in `public`. Nothing reads `env`.
- [ ] 5.3 `TestCheckBeforeMigrating` (`internal/db/runtime_user_test.go`), one row per case through a named function, each dropping to the row's role with `SET LOCAL ROLE` inside a transaction it rolls back that creates the roles the row needs (the database rows run on a scratch database that a role the test creates owns, dropped in cleanup after the database): a member of `console_runtime` that owns nothing passes; one that is also a member of a superuser role created in the transaction, copy 3a; the owner of the scratch database, copy 3b naming the database; a member of the role that owns the scratch database, through an intermediate role named `Mc-Via-<n>`, copy 3c naming the owner and the direct role, quoted; a role that owns another database and not this one passes; and the check run on a closed database (`dbtest.Closed`), copy 11. (A superuser that does not own the database reaches copy 3a from this function, because the membership half of check 3 runs first and a superuser is a member of the bootstrap superuser; the superuser row of `TestOpenRuntimeDatabase` covers check 4's order.) The same test runs `CheckSchemasBeforeMigrating` (check 12) with a login role created in the transaction: a login that owns nothing and holds no `CREATE` passes; after `GRANT CREATE ON SCHEMA public TO PUBLIC` in the transaction, copy 3e naming `public` and the grantee `PUBLIC`; `GRANT CREATE ON SCHEMA public` to a role the test creates (`Mc-Creator`, mixed case so the quoting shows) and of which the login is a member with `INHERIT FALSE, SET TRUE`, copy 3e naming that role quoted, which proves both the grantee and the `MEMBER` test; after `GRANT CREATE ON SCHEMA core TO` the login, copy 3e naming `core`; a login that owns a function in `public`, copy 7 with the function's signature, and one that is a member of a role that owns such a function through `Mc-Via-<n>`, copy 8 naming the owner by name and the direct role, quoted; and a closed database, copy 11. `TestCheckMigrationDatabaseOwner` adds rows: a scratch database owned by the migration user passes, one owned by a superuser role passes, one owned by a role the test creates, copy 3d naming the owner and the quoted database name, and a closed database, copy 11. Every row but the passing ones asserts the whole sentence.
- [ ] 5.4 `TestCheckRuntimeUser` (`internal/db/runtime_user_test.go`), one row per case through a named function, each in a transaction it rolls back that creates the roles the row needs and drops to the row's role with `SET LOCAL ROLE`: a member of `console_runtime` passes; a role named `Mc-Rt-<n>` that is no member, copy 5 with the name quoted; a member of `console_runtime` granted `WITH INHERIT FALSE`, copy 5; a direct member of `core_owner`, copy 6 naming `core_owner` in both places, which also shows that the owner role is reported before check 10; a member of `core_owner` through another role named `Mc-Via-<n>`, copy 6 naming that role, quoted, in the `REVOKE`; a member of `core_owner` granted `WITH INHERIT FALSE, SET TRUE`, copy 6; the owner of a table in `core` created in the transaction, copy 7; the owner of a function in `core`, copy 7 with its signature; the owner of a function in `public`, copy 7; a member of a role that owns a table, copy 8; a holder of `TRIGGER` on a table of `core` through a grant to the user, copy 9 naming `TRIGGER`, the table and the user as grantee; a holder of `TRUNCATE` on a table of `core` through a grant to `core_reader`, which `core_writer` holds, copy 9 naming `core_reader` as grantee; a holder of `MAINTAIN` through `pg_maintain` granted to `console_runtime`, copy 9a naming `pg_maintain` and `console_runtime`; a direct member of an unrelated role named `Mc-Extra-<n>`, copy 8a with both names quoted; a direct member of `pg_execute_server_program`, of `pg_read_server_files` and of `pg_write_all_data`, copy 8a for each; a holder of `INSERT` on `core.pool_provisions` through a grant to the user, and through a grant to `PUBLIC`, copy 9 naming the user and `PUBLIC` as grantee; and the check run on a closed database (`dbtest.Closed`), copy 11. Every row but the passing one asserts the whole sentence.
- [ ] 5.5 `TestOpenRuntimeDatabase` (rows of section 4 stay) gains rows: one user for both strings, copy 1, and the ledgers untouched; a superuser runtime login the test creates (`LOGIN SUPERUSER`), copy 3, the ledgers untouched and no membership granted; a runtime login that owns the scratch database, copy 3b, the ledgers untouched and no membership granted; the same login having created `public.quote_ident(name)`, which writes a marker row (recording `current_user` and `is_superuser`) into a table the test created and the login may insert into, copy 3b, the ledgers untouched and no marker row, because neither connection runs the function; a runtime login that holds `CREATE` on `public` and has created `public.format(text, text)` with such a marker, copy 3e, the ledgers untouched and no marker row; a runtime login that owns a function in `public` and holds no `CREATE`, copy 7, the ledgers untouched; `migrate up`, `migrate down` and `migrate status` against a scratch database owned by a role that is neither the migration user nor a superuser, copy 3d and the ledgers untouched (for `status`, no ledger created); the same against a scratch database owned by a third role that has run `ALTER DATABASE ... SET role = '<itself>'`, copy 3d from `migrate up` and the ledgers untouched; a runtime login that holds `pg_read_server_files`, copy 8a after the migrations ran; two scratch databases, copy 2; and every row runs with `env` set to `development`. Each row's cleanup drops the scratch database before the login that owns it, because Go runs `t.Cleanup` functions in reverse order, `dbtest.Scratch` registers its drop first, and `DROP ROLE` fails while the role owns the database. Section 4's rows pass unchanged.
- [ ] 5.6 Start this worktree's application on the stack and check that the boot log shows copy 19 naming `member_console_runtime` and `member_console`. Set `MC_DB_DSN` to the migration user's string in one shell and check that the start refuses with copy 1.
- [ ] 5.7 Breaks:
- check 4 before check 3: caught by the superuser row of `TestOpenRuntimeDatabase` (a superuser is a member of every role and matches check 4's membership case, so copy 3c with an empty direct role would print in place of copy 3);
- checks 1 to 4 after the migrations: caught by the same-user, superuser and database-owner rows' ledger assertions;
- the boot grant before the superuser check: caught by the superuser row's membership assertion;
- the database-ownership check removed: caught by the copy 3b row of `TestCheckBeforeMigrating` and the database-owner row of `TestOpenRuntimeDatabase`;
- check 4 tests `datdba = current_user` in place of `pg_has_role(..., 'MEMBER')`: caught by the copy 3c row;
- check 4 reads the first database in `pg_database` in place of `current_database()`: caught by the copy 3b row;
- check 4 without its `datname = current_database()` filter: caught by the row whose role owns another database;
- the identity query run on the migration pool (unqualified `quote_ident`, `pg_roles`): caught by the planted-function row of `TestOpenRuntimeDatabase` (the marker row is written);
- the runtime identity read with an unqualified `quote_ident`: caught by the planted-function row (a marker row is written);
- the migration pool without `role=none`: caught by the owner-set-role row of `TestOpenRuntimeDatabase` (the owner half passes and goose runs as the owner);
- check 12's query reads `has_schema_privilege` in place of the ACL entries (so a `SET`-only membership is not counted and the grantee is lost): caught by the `Mc-Creator` row of `TestCheckBeforeMigrating`;
- the `CREATE` check removed: caught by the `CREATE` row of `TestCheckBeforeMigrating` and the `public.format` row of `TestOpenRuntimeDatabase`;
- check 12's `public` half removed: caught by the owned-function rows of both tests;
- check 12 run after the migrations: caught by the `public.format` and owned-function rows' ledger assertions;
- the boot grant's `pg_catalog.format` written `format`: caught by the scan of item 5.8;
- `CheckMigrationDatabaseOwner` removed from `dbMigration`: caught by the `migrate up` row of `TestOpenRuntimeDatabase`;
- `dbMigration` checks before `up` only: caught by the `migrate down` and `migrate status` rows of `TestOpenRuntimeDatabase` (the `status` row finds a ledger created);
- `CheckMigrationDatabaseOwner` removed from `dbRuntime`: caught by `start`'s copy 3d row;
- the owner half refuses a superuser-owned database: caught by `TestCheckMigrationDatabaseOwner`'s superuser-owner row;
- the refusal of copy 3d prints the name unquoted: caught by the copy 3d row;
- check 11 without the four-privilege read: caught by the `TRIGGER` row of `TestCheckRuntimeUser`;
- copy 9 prints the user in place of the grantee: caught by the `core_reader` row;
- check 11's grantee filter dropped, so every ACL entry counts: caught by the `core_reader` row (a grantee the user does not reach is named in place of `core_reader`);
- check 11's predefined-role query removed: caught by the `pg_maintain` row (copy 9 prints with an empty grantee);
- copy 3c or copy 6 prints the direct role unquoted: caught by the `Mc-Via-<n>` rows;
- check 6 tests `MEMBER` in place of `USAGE`: caught by a row whose role holds the membership with `INHERIT FALSE`;
- check 8 tests `USAGE` in place of `MEMBER`: caught by a row whose role holds `core_owner` with `INHERIT FALSE, SET TRUE`;
- check 9 without functions: caught by both function rows;
- check 9 without `public`: caught by the `public` function row;
- check 9 tests `owner = current_user` in place of `pg_has_role(..., 'MEMBER')`: caught by the copy 8 row;
- the direct-membership check removed: caught by the unrelated-role row, the three predefined-role rows and the `pg_read_server_files` row of `TestOpenRuntimeDatabase`;
- the direct-membership check counts `console_runtime`: caught by the passing rows;
- the direct-membership check before check 8: caught by the direct `core_owner` row (copy 8a in place of copy 6);
- check 11 removed: caught by the two `INSERT` rows;
- check 3 without its membership half: caught by the copy 3a row;
- the refusal quotes the name in Go (`%q`): caught by the copy 5 row;
- an exemption when `env` is `development`: caught by the `env` rows of `TestOpenRuntimeDatabase`;
- a failed check query passes (the error dropped): caught by the closed-database rows, which expect copy 11;
- `TestOpenRuntimeDatabase`'s cleanup dropping the login before the scratch database (a test-side record): caught, because `DROP ROLE` fails while the login owns the database;
- an unqualified `quote_ident(` in the identity query: caught by the scan, naming the query;
- an unqualified `pg_roles` in the owner check: caught by the scan;
- `PendingMigrations` calls `to_regclass` without `pg_catalog.`: caught by the scan, naming the query and `to_regclass(`;
- the queries' constants renamed so that none begins `query` (a test-side record): caught by the scan's own empty-collection failure.
- [ ] 5.8 `TestCheckQueriesQualifyEveryName` (`internal/db/qualified_names_test.go`, design D2, the qualification principle), in package `db_test`: parse `runtime_user.go` and `migrations.go` with `go/parser`, collect every string constant whose name begins `query` (the check queries, the boot-grant statement and the pending check's queries), remove string literals and `--` comments, and fail for each function call (an identifier directly followed by `(`, after optional spaces, that no `pg_catalog.` precedes) and each catalog relation (an identifier starting `pg_` that no `pg_catalog.` precedes and that is not `pg_catalog` itself), naming the query's constant and the token. The exceptions are a table in the test, each with its reason: `ANY`, `ALL`, `IN`, `EXISTS`, `VALUES`, `CAST`, `COALESCE`, `CASE`, `AND`, `OR`, `ON`, `FROM`, `JOIN`, `SELECT`, `WHERE`, `WHEN`, `THEN`, `ELSE` are SQL syntax that precedes a parenthesis, not calls; an identifier directly after `AS` is an alias with a column list (`AS p(priv)`). The test also fails when it finds no `query` constant in either file, so a rename cannot empty it. The breaks in item 5.7 name what the scan must catch.
## 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.
## 7. Landing
- [ ] 7.1 With the test stack up, `make test` passes (it runs `make lint` first), and `openspec validate runtime-database-user --strict` passes.
- [ ] 7.2 Post a comment on #23 under CONTRIBUTING.md's rules for forge text, and close it. The comment states that the console serves from a runtime user that is not a superuser and owns nothing, and migrates on a second connection string that it closes before serving; that the migrations' revokes now bind the console; that the console refuses to start on a configuration that would reopen the bypass, with no exemption; that a host can run migrations as a separate step and keep the migration password out of the running process; that the writer roles now hold only `SELECT`, `INSERT`, `UPDATE` and `DELETE` (no `TRUNCATE`, `TRIGGER`, `MAINTAIN` or `REFERENCES`), that the plan-act obligation move takes its row lock through a function owned by `core_owner`, because a locking read of an enclosed table needs `UPDATE`, that the core baseline's initial partitions are computed in UTC, and that the `-file` keys of the other commands are fixed in the same change; and that a deployment changes its configuration to upgrade, following the procedure in `docs/production-deployment.md`. It names the commits of sections 1 to 6.
- [ ] 7.3 Post a comment on #252 under the same rules: `core.webhook_events` and its partitions now belong to `core_owner`, and partitions are created by a `SECURITY DEFINER` function it owns; the runtime user is refused when it owns or can act as the owner of the database or any object, whoever owns it, so moving the rest is not needed for the fence; moving them needs a per-object `ALTER ... OWNER TO` loop, because Postgres refuses `REASSIGN OWNED BY` for the bootstrap superuser, and `SET` membership in each owner role for the migration user; and `scripts/db-reader-role.sql` is run by the schemas' owner, which that move would change.
- [ ] 7.4 Hand the host procedure of `docs/production-deployment.md` to the maintainer, with the values his deployment needs filled in outside the repository. No agent runs it.