Refuse to start on a runtime user that can get around the grants

Fifth section of #23: the start-up check. Before any migration runs, on
both connections, the console refuses when the two strings log in as one
user or reach different databases, when the runtime user is a superuser
or a member of a superuser role, when it owns or is a member of the
owner of the database, when the database is owned by neither the
migration user nor a superuser, when the runtime user can create objects
in the database, in public or in a console schema, or when it owns an
object in those schemas that a name lookup could resolve (relations,
functions, operators, types, and operator classes, families and
extensions database-wide). After the migrations and the boot grant, on
the runtime connection, it refuses when console_runtime is missing, when
the runtime user does not inherit it or holds it with SET, when the
schema is behind the code, when the runtime user reaches an owner role,
owns an object, holds any direct membership but console_runtime, or
holds a privilege an enclosure withholds. Each refusal names the
statement that fixes it, with names quoted by the database; a check
query that fails refuses too. migrate up, down and status refuse a
database whose owner is neither user before goose runs.

The boot grant is now WITH INHERIT TRUE, SET FALSE: the runtime user
needs only to inherit console_runtime, and without SET on that first
membership no SET ROLE reaches a role through it. Granting again
updates an existing membership in place; this worktree's runtime user
held SET from the previous section and lost it at the suite's boot.

Every check runs in a read-only REPEATABLE READ transaction that begins
with SET LOCAL search_path = pg_catalog, pg_temp, and names every
function and catalog relation with pg_catalog., so nothing the runtime
user planted (a function such as public.quote_ident(name), or an
operator such as public.=(oid, integer), which qualified function names
alone did not exclude) runs in the migration user's session or writes a
statement the console prints. TestCheckQueriesQualifyEveryName scans
the query constants and fails on an unqualified function call or catalog
relation.

Full suite on reset databases: 4370 pass, 15 skip, none failed. The
console booted on the test stack and logged "runtime database user
checked" with the runtime and migration users. make lint passes. Break
runs that disable an early refusal or a test's cleanup can leave a role
or a membership in the shared test cluster; those were removed after
the run.

Deliberate breaks, scripts/mutation-check -side new, all CAUGHT (76):
  checks 3a to 3c run before checks 1 to 3
  checks 1 to 4 run after the migrations
  the boot grant runs before the superuser check
  the database-ownership check removed (TestCheckBeforeMigrating)
  the database-ownership check removed (TestOpenRuntimeDatabase)
  check 4 tests datdba = current_user in place of MEMBER
  check 4 reads the first database in pg_database
  check 4 without its datname = current_database() filter
  the migration identity read outside the pinned read-only transaction, with an unqualified quote_ident
  the runtime identity read outside the pinned read-only transaction, with an unqualified quote_ident
  the migration pool opened without role=none (owner set its own role)
  check 12 reads has_schema_privilege in place of the ACL entries
  check 12's schema CREATE query removed (TestCheckBeforeMigrating)
  check 12's schema CREATE query removed (TestOpenRuntimeDatabase)
  check 12's ownership query removed (TestCheckBeforeMigrating)
  check 12's ownership query removed (TestOpenRuntimeDatabase)
  check 12 runs after the migrations
  the boot grant's pg_catalog.format written format
  check 12's queries read outside the pinned read-only transaction (TestCheckBeforeMigrating)
  check 12's queries read outside the pinned read-only transaction (TestOpenRuntimeDatabase)
  CheckRuntimeUser's transaction without the search-path pin
  the second query of checks 9 and 12 casts the login with regrole
  CheckMigrationDatabaseOwner removed from dbMigration
  dbMigration checks before up only
  CheckMigrationDatabaseOwner removed from dbRuntime
  the owner half refuses a superuser-owned database
  copy 3d prints the database name unquoted
  check 11 without the four-privilege read
  copy 9 prints the user in place of the grantee
  check 11's grantee filter dropped
  check 11's predefined-role query removed
  copy 3c prints the direct role unquoted
  copy 6 prints the direct role unquoted
  check 6 tests MEMBER in place of USAGE
  check 8 tests USAGE in place of MEMBER
  check 9 without functions
  check 9 without public
  check 9 tests owner = current_user in place of MEMBER
  the direct-membership check removed (TestCheckRuntimeUser)
  the direct-membership check removed (TestOpenRuntimeDatabase)
  the direct-membership check counts console_runtime
  the direct-membership check runs before check 8
  check 11 removed
  check 3 without its membership half
  copy 5 quotes the name in Go (%q)
  an exemption when env is development
  a failed check query passes
  the scratch database is dropped after the login that owns it (test-side)
  an unqualified quote_ident( in the identity query
  an unqualified pg_roles in the owner check
  PendingMigrations calls to_regclass without pg_catalog.
  the query constants renamed so none begins query (test-side)
  the search-path pin removed from the pinned read-only transaction
  the search-path pin removed from the savepoint a test runs a check in
  check 5 removed
  check 6 does not read the SET option
  check 6 does not read the SET option (separate-step start)
  the boot grant leaves the SET option (TestGrantRuntimeRole)
  the boot grant leaves the SET option (TestOpenRuntimeDatabase)
  the database CREATE query removed (TestCheckBeforeMigrating)
  the database CREATE query removed (TestOpenRuntimeDatabase)
  the database CREATE query reads has_database_privilege in place of the ACL entries
  check 12's ownership query reads public only
  check 12's ownership query reads public only (TestOpenRuntimeDatabase)
  check 9 without operators
  check 9 without types
  check 9 without operator classes
  check 9 without operator families
  check 9 without extensions
  start's refusal log lacks the owner refusal
  the scan folds no case (test-side)
  the scan reads a quoted call as no call (test-side)
  the scan reads no line comment (test-side)
  the scan skips a concatenated constant (test-side)
  the scan passes a query constant without a value (test-side)
  copy 5a prints only the GRANT, not the REVOKE by the grantor
This commit is contained in:
2026-10-23 09:43:35 -04:00
parent f9c9b3225e
commit dd4a0e2ec0
10 changed files with 2598 additions and 144 deletions
+104 -37
View File
@@ -58,7 +58,7 @@ func openDB(ctx context.Context, logger *slog.Logger, mode dbMode) (*sql.DB, fun
if dsn == "" {
return nil, nil, errors.New("member-console migrate needs db-migrate-dsn (MC_DB_MIGRATE_DSN or MC_DB_MIGRATE_DSN_FILE)")
}
database, err = db.ConnectMigration(ctx, logger, db.DefaultDBConfig(dsn))
database, err = openMigration(ctx, logger, dsn)
case dbConfig:
dsn, derr := runtimeDSN()
if derr != nil {
@@ -106,7 +106,8 @@ func runtimeDSN() (string, error) {
// failure to initialize.
func logOpenFailure(logger *slog.Logger, err error) {
var refusal *db.RuntimeUserRefusal
if errors.As(err, &refusal) {
var ownerRefusal *db.DatabaseOwnerRefusal
if errors.As(err, &refusal) || errors.As(err, &ownerRefusal) {
logger.Error("refused to start", slog.Any("error", err))
return
}
@@ -114,75 +115,141 @@ func logOpenFailure(logger *slog.Logger, err error) {
}
// openRuntimeDatabase is the boot sequence of dbRuntime (design, "The boot
// sequence"): it opens the runtime pool, and with migrateDSN set it opens the
// migration pool, runs every stream's migrations there, grants the runtime
// group role to the runtime user there, and closes it before it returns;
// then it refuses a schema that is behind the code. The runtime user runs
// no DDL: a pending migration is the migration connection's, or the
// operator's `migrate up`.
// sequence"): it opens the runtime pool; with migrateDSN set it opens the
// migration pool, refuses a configuration that would reopen the bypass,
// runs every stream's migrations there, grants the runtime group role to the
// runtime user there, and closes it; then it checks the runtime user and the
// schema it meets. The runtime user runs no DDL: a pending migration is the
// migration connection's, or the operator's `migrate up`.
func openRuntimeDatabase(ctx context.Context, logger *slog.Logger, dsn, migrateDSN string) (*sql.DB, error) {
runtime, err := db.Connect(ctx, logger, db.DefaultDBConfig(dsn))
if err != nil {
return nil, err
}
if migrateDSN == "" {
logger.Info("migrations not run: db-migrate-dsn is not set")
} else if err := migrateAndGrant(ctx, logger, runtime, migrateDSN); err != nil {
return nil, errors.Join(err, runtime.Close())
err = bootSeparateStep(ctx, logger, runtime)
} else {
err = bootWithMigration(ctx, logger, runtime, migrateDSN)
}
if err := refuseBehindSchema(ctx, runtime); err != nil {
if err != nil {
return nil, errors.Join(err, runtime.Close())
}
return runtime, nil
}
// migrateAndGrant runs the migrations and the boot grant on the migration
// connection, which it closes before it returns, and logs that the
// migrations were applied.
func migrateAndGrant(ctx context.Context, logger *slog.Logger, runtime *sql.DB, migrateDSN string) error {
// bootSeparateStep is the boot with db-migrate-dsn left out: the checks that
// need no schema run on the runtime connection, no migration runs, and the
// runtime user is checked against the schema as it is.
func bootSeparateStep(ctx context.Context, logger *slog.Logger, runtime *sql.DB) error {
check := db.NewRuntimeCheck(migrationSources())
id, err := db.ReadRuntimeIdentity(ctx, runtime)
if err != nil {
return err
}
if err := db.CheckIdentities(id, nil); err != nil {
return err
}
if err := db.CheckBeforeMigrating(ctx, runtime); err != nil {
return err
}
if err := db.CheckSchemasBeforeMigrating(ctx, runtime, id.User, check.CreateSchemas()); err != nil {
return err
}
logger.Info("migrations not run: db-migrate-dsn is not set")
if err := db.CheckRuntimeUser(ctx, runtime, check); err != nil {
return err
}
logger.Info("runtime database user checked", slog.String("db_user", id.User))
return nil
}
// bootWithMigration is the boot with db-migrate-dsn set. It closes the
// migration pool before it checks the runtime user, so a check meets the
// schema as the runtime connection will serve it.
func bootWithMigration(ctx context.Context, logger *slog.Logger, runtime *sql.DB, migrateDSN string) error {
migration, err := db.ConnectMigration(ctx, logger, db.DefaultDBConfig(migrateDSN))
if err != nil {
return err
}
if err := runMigrationsAndGrant(ctx, runtime, migration); err != nil {
id, migrationUser, err := migrateAndGrant(ctx, runtime, migration)
if err != nil {
return errors.Join(err, migration.Close())
}
if err := migration.Close(); err != nil {
logger.Warn("closing the migration connection", slog.Any("error", err))
}
logger.Info("database migrations applied")
if err := db.CheckRuntimeUser(ctx, runtime, db.NewRuntimeCheck(migrationSources())); err != nil {
return err
}
logger.Info("runtime database user checked",
slog.String("db_user", id.User), slog.String("migration_user", migrationUser))
return nil
}
// runMigrationsAndGrant runs every stream's migrations on migration, then
// grants the runtime group role to the user runtime logs in as.
func runMigrationsAndGrant(ctx context.Context, runtime, migration *sql.DB) error {
if err := db.RunMigrations(migration, migrationSources()); err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
user, err := db.CurrentUser(ctx, runtime)
// migrateAndGrant refuses the configurations that must not migrate (checks 1
// to 4 and 12), runs every stream's migrations on migration, and grants the
// runtime group role to the user runtime logs in as. It returns the runtime
// identity and the migration user's name.
func migrateAndGrant(ctx context.Context, runtime, migration *sql.DB) (db.RuntimeIdentity, string, error) {
id, migrationUser, err := refuseBeforeMigrating(ctx, runtime, migration)
if err != nil {
return &db.RuntimeUserRefusal{Message: fmt.Sprintf("could not check the db-dsn user: %v", err)}
return id, "", err
}
if err := db.RunMigrations(migration, migrationSources()); err != nil {
return id, "", fmt.Errorf("failed to run migrations: %w", err)
}
err = db.InTx(ctx, migration, db.Plain, func(tx *sql.Tx) error {
return db.GrantRuntimeRole(ctx, tx, user)
return db.GrantRuntimeRole(ctx, tx, id.User)
})
if err != nil {
return &db.RuntimeUserRefusal{Message: fmt.Sprintf(
"could not grant %s to %s on the db-migrate-dsn connection: %v", db.RuntimeGroupRole, user, err)}
return id, "", &db.RuntimeUserRefusal{Message: fmt.Sprintf(
"could not grant %s to %s on the db-migrate-dsn connection: %v", db.RuntimeGroupRole, id.User, err)}
}
return nil
return id, migrationUser, nil
}
// refuseBehindSchema reads the ledgers as the runtime user and refuses a
// schema that is behind this release.
func refuseBehindSchema(ctx context.Context, runtime *sql.DB) error {
pending, err := db.PendingMigrations(ctx, runtime, migrationSources())
// refuseBeforeMigrating runs the checks that need no schema, in the order of
// the design: both identities, checks 1 to 3, the membership and ownership
// of the runtime user (3a to 3c), the database's owner on the migration
// connection (3d), and what the runtime user can create or owns where the
// migrations resolve names (3e, 3f, 7, 8).
func refuseBeforeMigrating(ctx context.Context, runtime, migration *sql.DB) (db.RuntimeIdentity, string, error) {
id, err := db.ReadRuntimeIdentity(ctx, runtime)
if err != nil {
return &db.RuntimeUserRefusal{Message: fmt.Sprintf("could not check the db-dsn user: %v", err)}
return id, "", err
}
if len(pending) > 0 {
return db.SchemaBehindRefusal(pending)
mid, err := db.ReadMigrationIdentity(ctx, migration)
if err != nil {
return id, "", err
}
return nil
if err := db.CheckIdentities(id, &mid); err != nil {
return id, "", err
}
if err := db.CheckBeforeMigrating(ctx, runtime); err != nil {
return id, "", err
}
if err := db.CheckMigrationDatabaseOwner(ctx, migration); err != nil {
return id, "", err
}
check := db.NewRuntimeCheck(migrationSources())
if err := db.CheckSchemasBeforeMigrating(ctx, migration, id.User, check.CreateSchemas()); err != nil {
return id, "", err
}
return id, mid.User, nil
}
// openMigration opens the migration pool and refuses a database that is owned
// by neither the migration user nor a superuser role before any migrate
// command runs goose, because goose's Status creates a missing ledger in
// public (check 4, owner half).
func openMigration(ctx context.Context, logger *slog.Logger, dsn string) (*sql.DB, error) {
database, err := db.ConnectMigration(ctx, logger, db.DefaultDBConfig(dsn))
if err != nil {
return nil, err
}
if err := db.CheckMigrationDatabaseOwner(ctx, database); err != nil {
return nil, errors.Join(err, database.Close())
}
return database, nil
}
+398 -5
View File
@@ -24,6 +24,7 @@ import (
"testing"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
@@ -79,6 +80,7 @@ func newBootBox(t *testing.T) *bootBox {
admin: dbtest.Connect(t, dbtest.DSN(t))}
dbtest.On(t, box.admin).Exec(fmt.Sprintf("CREATE ROLE %s LOGIN PASSWORD '%s'", box.login, runtimeLoginPassword))
t.Cleanup(func() {
box.dropDatabase()
if _, err := box.admin.ExecContext(context.Background(), "DROP ROLE IF EXISTS "+box.login); err != nil {
t.Errorf("drop role %s: %v", box.login, err)
}
@@ -115,7 +117,7 @@ func (b *bootBox) migrateTo(short bool) {
}
dbtest.UpTo(b.t, scratch, src, version)
}
dbtest.On(b.t, scratch).Exec(fmt.Sprintf("GRANT %s TO %s WITH INHERIT TRUE", db.RuntimeGroupRole, b.login))
dbtest.On(b.t, scratch).Exec(fmt.Sprintf("GRANT %s TO %s WITH INHERIT TRUE, SET FALSE", db.RuntimeGroupRole, b.login))
}
// open opens the database in runtime mode and returns the pool and the log.
@@ -166,6 +168,114 @@ func (b *bootBox) migrationSessions() int64 {
return n
}
// scratchAdmin opens the scratch database as the migration user, which is the
// superuser the test stack's TEST_DATABASE_URL names.
func (b *bootBox) scratchAdmin() *dbtest.Q {
b.t.Helper()
return dbtest.On(b.t, dbtest.Connect(b.t, b.scratch))
}
// ownedBy gives the scratch database to role.
func (b *bootBox) ownedBy(role string) {
b.t.Helper()
dbtest.On(b.t, b.admin).Exec(fmt.Sprintf("ALTER DATABASE %s OWNER TO %s", b.name, role))
}
// dropDatabase drops the scratch database ahead of the roles that own it or
// hold privileges in it. Go runs t.Cleanup functions in reverse order and
// dbtest.Scratch registers its drop first, while DROP ROLE fails as long as
// the role owns the database or an object in it, so each cleanup that drops a
// role calls this first.
func (b *bootBox) dropDatabase() {
if _, err := b.admin.ExecContext(context.Background(), fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", b.name)); err != nil {
b.t.Errorf("drop database %s: %v", b.name, err)
}
}
// thirdRole creates a login-less role, neither the migration user nor the
// runtime login, which the test drops.
func (b *bootBox) thirdRole() string {
b.t.Helper()
role := dbtest.Name("mc_third_")
dbtest.On(b.t, b.admin).Exec("CREATE ROLE " + role)
b.t.Cleanup(func() {
b.dropDatabase()
if _, err := b.admin.ExecContext(context.Background(), "DROP ROLE IF EXISTS "+role); err != nil {
b.t.Errorf("drop role %s: %v", role, err)
}
})
return role
}
// plant runs a statement as the runtime login, which creates what the test
// plants.
func (b *bootBox) plant(statement string) {
b.t.Helper()
dbtest.On(b.t, dbtest.Connect(b.t, b.runtime)).Exec(statement)
}
// wantRefusal opens the database in runtime mode and asserts the refusal.
func (b *bootBox) wantRefusal(want string) {
b.t.Helper()
_, _, err := b.open()
var refusal *db.RuntimeUserRefusal
if !errors.As(err, &refusal) || err.Error() != want {
b.t.Errorf("openDB error = %v, want the refusal %q", err, want)
}
}
// wantLedgers asserts that the ledgers read as before.
func (b *bootBox) wantLedgers(before string) {
b.t.Helper()
if after := b.ledgers(); after != before {
b.t.Errorf("the ledgers changed: %s, want %s", after, before)
}
}
// wantNoMembership asserts that the runtime login holds no membership in the
// group role.
func (b *bootBox) wantNoMembership() {
b.t.Helper()
n := dbtest.On(b.t, b.admin).Int(`SELECT count(*) FROM pg_auth_members m
JOIN pg_roles g ON g.oid = m.roleid JOIN pg_roles u ON u.oid = m.member
WHERE g.rolname = $1 AND u.rolname = $2`, db.RuntimeGroupRole, b.login)
if n != 0 {
b.t.Errorf("the runtime login %q was granted %s, want no membership", b.login, db.RuntimeGroupRole)
}
}
// plantedBody is the body of every function the rows plant. A planted function
// that writes a marker row is blind in a read-only transaction (the write fails)
// and in a savepoint that rolls back (the row goes), so it raises instead: an
// exception shows in every path, turns the check into copy 11 and puts this text
// in the sentence the row compares.
const plantedBody = `BEGIN RAISE EXCEPTION 'planted function ran as % (superuser %)', current_user, current_setting('is_superuser'); END`
// grantWithSet makes the box's login a member of the group role as the boot
// grant does not leave it: with SET, which lets the login SET ROLE to the group
// role and to any role the group role holds with SET.
func (b *bootBox) grantWithSet() {
b.t.Helper()
b.scratchAdmin().Exec(fmt.Sprintf("GRANT %s TO %s WITH INHERIT TRUE, SET TRUE", db.RuntimeGroupRole, b.login))
}
// setRole opens a connection as the runtime login and runs SET ROLE to role on
// it, the way a session of the login would; the membership's SET option is
// checked against the session user, which a SET LOCAL ROLE inside the test's
// superuser session would not exercise.
func (b *bootBox) setRole(role string) error {
b.t.Helper()
_, err := dbtest.Connect(b.t, b.runtime).ExecContext(context.Background(), "SET ROLE "+role)
return err
}
// createInPublic is the sentence of copy 3e for a login that holds CREATE on
// public through a grant to itself.
func (b *bootBox) createInPublic() string {
return fmt.Sprintf("db-dsn logs in as %s, which can create objects in schema public through a grant to %s; "+
"as the migration user, run: REVOKE CREATE ON SCHEMA public FROM %s;", b.login, b.login, b.login)
}
// openRuntimeDatabaseRows are the rows of TestOpenRuntimeDatabase, each run
// by its own named function.
var openRuntimeDatabaseRows = []struct {
@@ -177,12 +287,30 @@ var openRuntimeDatabaseRows = []struct {
{"without db-migrate-dsn on a current database", bootCurrentWithoutMigrationString},
{"migrate up without db-migrate-dsn", migrateUpWithoutMigrationString},
{"a runtime login that set its own role", bootWithSelfSetRole},
{"one user for both strings", bootWithOneUser},
{"a superuser runtime login", bootWithSuperuser},
{"a runtime login that owns the database", bootWithDatabaseOwner},
{"a database owner that planted quote_ident", bootWithPlantedQuoteIdent},
{"a runtime login that can create in public and planted format", bootWithPlantedFormat},
{"a runtime login that can create in public and planted an operator", bootWithPlantedOperator},
{"a runtime login that owns a function in public", bootWithOwnedPublicFunction},
{"start on a database owned by another role", bootOnAForeignOwner},
{"migrate commands on a database owned by another role", migrateCommandsRefuseAForeignOwner},
{"migrate up on a database whose owner set its own role", migrateUpRefusesAnOwnerThatSetItsRole},
{"a runtime login that holds pg_read_server_files", bootWithPredefinedRole},
{"two databases", bootWithTwoDatabases},
{"a runtime login that can create schemas in the database", bootWithDatabaseCreate},
{"a runtime login that owns a table in core before migrating", bootWithOwnedStreamTable},
{"without db-migrate-dsn on a membership that can SET ROLE", bootSeparateStepWithSet},
{"the boot grant closes a SET ROLE chain through the group role", bootClosesASetRoleChain},
}
// TestOpenRuntimeDatabase covers task 4.8 (design D1, D8, D11): the runtime
// mode migrates and grants on the migration connection, closes it, and serves
// as the runtime login; without db-migrate-dsn it runs nothing and refuses a
// schema that is behind.
// TestOpenRuntimeDatabase covers tasks 4.8 and 5.5 (design D1, D2, D8, D9,
// D11): the runtime mode refuses a configuration that would reopen the bypass
// before it migrates, migrates and grants on the migration connection, closes
// it, checks the runtime user, and serves as the runtime login; without
// db-migrate-dsn it runs nothing and refuses a schema that is behind. Every
// row runs with env set to development, because nothing reads env.
func TestOpenRuntimeDatabase(t *testing.T) {
// viper.Reset drops the environment binding; blanking the variables as
// well keeps the rows off a database that make test exports.
@@ -263,6 +391,7 @@ func bootCurrentWithoutMigrationString(t *testing.T) {
func migrateUpWithoutMigrationString(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
viper.Set("env", "development")
viper.Set("db-dsn", "postgres://runtime:pw@127.0.0.1:1/x?connect_timeout=1")
const want = "member-console migrate needs db-migrate-dsn (MC_DB_MIGRATE_DSN or MC_DB_MIGRATE_DSN_FILE)"
if err := migrateUpCmd.RunE(migrateUpCmd, nil); err == nil || err.Error() != want {
@@ -284,3 +413,267 @@ func bootWithSelfSetRole(t *testing.T) {
t.Errorf("SELECT current_user on the returned pool = %q, want the login %q, not the role it set", got, box.login)
}
}
func bootWithOneUser(t *testing.T) {
box := newBootBox(t)
before := box.ledgers()
box.configure(true)
viper.Set("db-dsn", box.scratch)
user := dbtest.On(t, box.admin).String("SELECT current_user::text")
box.wantRefusal(fmt.Sprintf("db-dsn and db-migrate-dsn both log in as %s; "+
"db-dsn needs a user of its own that is not a superuser and owns nothing", user))
box.wantLedgers(before)
}
func bootWithSuperuser(t *testing.T) {
box := newBootBox(t)
dbtest.On(t, box.admin).Exec("ALTER ROLE " + box.login + " SUPERUSER")
before := box.ledgers()
box.configure(true)
box.wantRefusal(fmt.Sprintf("db-dsn logs in as %s, a superuser; set db-dsn to a user without SUPERUSER", box.login))
box.wantLedgers(before)
box.wantNoMembership()
}
func bootWithDatabaseOwner(t *testing.T) {
box := newBootBox(t)
box.ownedBy(box.login)
before := box.ledgers()
box.configure(true)
box.wantRefusal(fmt.Sprintf("db-dsn logs in as %s, which owns the database %s; "+
"as the migration user, run: REASSIGN OWNED BY %s TO CURRENT_USER;", box.login, box.name, box.login))
box.wantLedgers(before)
box.wantNoMembership()
}
// bootWithPlantedQuoteIdent plants public.quote_ident(name), which beats the
// catalog's for a name argument whatever the search path, as the database's
// owner. Neither connection may run it before the refusal: a run raises, and
// the refusal that would show is copy 11 with the function's text.
func bootWithPlantedQuoteIdent(t *testing.T) {
box := newBootBox(t)
box.ownedBy(box.login)
box.plant(`CREATE FUNCTION public.quote_ident(name) RETURNS text LANGUAGE plpgsql AS $$ ` + plantedBody + ` $$`)
before := box.ledgers()
box.configure(true)
box.wantRefusal(fmt.Sprintf("db-dsn logs in as %s, which owns the database %s; "+
"as the migration user, run: REASSIGN OWNED BY %s TO CURRENT_USER;", box.login, box.name, box.login))
box.wantLedgers(before)
}
func bootWithPlantedFormat(t *testing.T) {
box := newBootBox(t)
box.scratchAdmin().Exec("GRANT CREATE ON SCHEMA public TO " + box.login)
box.plant(`CREATE FUNCTION public.format(text, text) RETURNS text LANGUAGE plpgsql AS $$ ` + plantedBody + ` $$`)
before := box.ledgers()
box.configure(true)
box.wantRefusal(box.createInPublic())
box.wantLedgers(before)
}
// bootWithPlantedOperator plants public.=(oid, integer), which check 12's own
// query would call as the migration user without the search-path pin; a call
// raises.
func bootWithPlantedOperator(t *testing.T) {
box := newBootBox(t)
box.scratchAdmin().Exec("GRANT CREATE ON SCHEMA public TO " + box.login)
box.plant(`CREATE FUNCTION public.mc_eq(oid, integer) RETURNS boolean LANGUAGE plpgsql AS $$ ` + plantedBody + ` $$`)
box.plant("CREATE OPERATOR public.= (LEFTARG = oid, RIGHTARG = integer, FUNCTION = public.mc_eq)")
before := box.ledgers()
box.configure(true)
box.wantRefusal(box.createInPublic())
box.wantLedgers(before)
}
func bootWithOwnedPublicFunction(t *testing.T) {
box := newBootBox(t)
q := box.scratchAdmin()
q.Exec("CREATE FUNCTION public.mc_f() RETURNS integer LANGUAGE sql AS 'SELECT 1'")
q.Exec("ALTER FUNCTION public.mc_f() OWNER TO " + box.login)
before := box.ledgers()
box.configure(true)
box.wantRefusal(fmt.Sprintf("db-dsn logs in as %s, which owns function public.mc_f(); "+
"as the migration user, run: REASSIGN OWNED BY %s TO CURRENT_USER;", box.login, box.login))
box.wantLedgers(before)
}
// migrateCommands are the three commands that open the migration pool.
var migrateCommands = []*cobra.Command{migrateUpCmd, migrateDownCmd, migrateStatusCmd}
// ownerRefusal is the sentence of copy 3d for the scratch database.
func (b *bootBox) ownerRefusal(owner string) string {
return fmt.Sprintf("database %s is owned by %s, not the migration user; as the migration user, run: ALTER DATABASE %s OWNER TO CURRENT_USER;",
b.name, owner, b.name)
}
// wantMigrateRefusal runs a migrate command and asserts the refusal of copy 3d
// and that the ledgers read as before.
func (b *bootBox) wantMigrateRefusal(c *cobra.Command, want, before string) {
b.t.Helper()
err := c.RunE(c, nil)
var refusal *db.DatabaseOwnerRefusal
if !errors.As(err, &refusal) || err.Error() != want {
b.t.Errorf("migrate %s error = %v, want the refusal %q", c.Name(), err, want)
}
b.wantLedgers(before)
}
// wantOwnerRefusal opens the database in runtime mode and asserts the refusal
// of copy 3d.
func (b *bootBox) wantOwnerRefusal(want string) {
b.t.Helper()
_, _, err := b.open()
var refusal *db.DatabaseOwnerRefusal
if !errors.As(err, &refusal) || err.Error() != want {
b.t.Errorf("openDB error = %v, want the refusal %q", err, want)
}
}
func bootOnAForeignOwner(t *testing.T) {
box := newBootBox(t)
owner := box.thirdRole()
box.ownedBy(owner)
before := box.ledgers()
box.configure(true)
box.wantOwnerRefusal(box.ownerRefusal(owner))
box.wantLedgers(before)
box.wantNoMembership()
}
func migrateCommandsRefuseAForeignOwner(t *testing.T) {
box := newBootBox(t)
owner := box.thirdRole()
box.ownedBy(owner)
before := box.ledgers()
box.configure(true)
for _, c := range migrateCommands {
box.wantMigrateRefusal(c, box.ownerRefusal(owner), before)
}
}
func migrateUpRefusesAnOwnerThatSetItsRole(t *testing.T) {
box := newBootBox(t)
owner := box.thirdRole()
box.ownedBy(owner)
dbtest.On(t, box.admin).Exec(fmt.Sprintf("ALTER DATABASE %s SET role = '%s'", box.name, owner))
before := box.ledgers()
box.configure(true)
box.wantMigrateRefusal(migrateUpCmd, box.ownerRefusal(owner), before)
}
func bootWithPredefinedRole(t *testing.T) {
box := newBootBox(t)
dbtest.On(t, box.admin).Exec("GRANT pg_read_server_files TO " + box.login)
box.configure(true)
box.wantRefusal(fmt.Sprintf("db-dsn logs in as %s, a direct member of pg_read_server_files; "+
"as the migration user, run: REVOKE pg_read_server_files FROM %s;", box.login, box.login))
q := box.scratchAdmin()
for stream, want := range highestVersions(t) {
if got := q.Int("SELECT max(version_id) FROM public.goose_db_version_" + stream + " WHERE is_applied"); got != want {
t.Errorf("ledger %s is at %d, want %d: the check runs after the migrations", stream, got, want)
}
}
}
func bootWithTwoDatabases(t *testing.T) {
box := newBootBox(t)
second := dbtest.Scratch(t)
u, err := url.Parse(second)
if err != nil {
t.Fatalf("parse the second scratch address: %v", err)
}
secondName := strings.TrimPrefix(u.Path, "/")
before := box.ledgers()
box.configure(true)
viper.Set("db-migrate-dsn", second)
box.wantRefusal(fmt.Sprintf("db-dsn connects to database %s and db-migrate-dsn to database %s; both must name the same database",
box.name, secondName))
box.wantLedgers(before)
}
func bootWithDatabaseCreate(t *testing.T) {
box := newBootBox(t)
dbtest.On(t, box.admin).Exec(fmt.Sprintf("GRANT CREATE ON DATABASE %s TO %s", box.name, box.login))
before := box.ledgers()
box.configure(true)
box.wantRefusal(fmt.Sprintf("db-dsn logs in as %s, which can create schemas in database %s through a grant to %s; "+
"as the migration user, run: REVOKE CREATE ON DATABASE %s FROM %s;", box.login, box.name, box.login, box.name, box.login))
box.wantLedgers(before)
box.wantNoMembership()
}
// bootWithOwnedStreamTable shows check 9 reading a stream schema before the
// migrations: the database has no migration yet, and the login owns a table in
// a core schema that the first migration would find.
func bootWithOwnedStreamTable(t *testing.T) {
box := newBootBox(t)
q := box.scratchAdmin()
q.Exec("CREATE SCHEMA core")
q.Exec("CREATE TABLE core.mc_t (id integer)")
q.Exec("ALTER TABLE core.mc_t OWNER TO " + box.login)
before := box.ledgers()
box.configure(true)
box.wantRefusal(fmt.Sprintf("db-dsn logs in as %s, which owns relation core.mc_t; "+
"as the migration user, run: REASSIGN OWNED BY %s TO CURRENT_USER;", box.login, box.login))
box.wantLedgers(before)
box.wantNoMembership()
}
func bootSeparateStepWithSet(t *testing.T) {
box := newBootBox(t)
box.migrateTo(false)
box.grantWithSet()
box.configure(false)
grantor := box.scratchAdmin().String("SELECT pg_catalog.quote_ident(pg_catalog.pg_get_userbyid(m.grantor)) " +
"FROM pg_catalog.pg_auth_members m WHERE m.roleid = 'console_runtime'::pg_catalog.regrole AND m.set_option")
box.wantRefusal(fmt.Sprintf("db-dsn logs in as %s, which can SET ROLE to %s through a grant by %s; "+
"as the migration user, run: REVOKE %s FROM %s GRANTED BY %s; GRANT %s TO %s WITH INHERIT TRUE, SET FALSE;",
box.login, db.RuntimeGroupRole, grantor, db.RuntimeGroupRole, box.login, grantor, db.RuntimeGroupRole, box.login))
}
// bootClosesASetRoleChain is an existing deployment: the login holds the group
// role with SET, and the group role holds a role that can insert into an
// enclosed table with INHERIT FALSE, SET TRUE, which no privilege check of the
// login's counts. The login can SET ROLE to that role before the boot and
// cannot after it.
func bootClosesASetRoleChain(t *testing.T) {
box := newBootBox(t)
box.migrateTo(false)
chained := box.thirdRole()
box.scratchAdmin().Exec("GRANT INSERT ON core.pool_provisions TO " + chained)
dbtest.On(t, box.admin).Exec(fmt.Sprintf("GRANT %s TO %s WITH INHERIT FALSE, SET TRUE", chained, db.RuntimeGroupRole))
box.grantWithSet()
if err := box.setRole(chained); err != nil {
t.Fatalf("before the boot, SET ROLE %s as the runtime login = %v, want it to work", chained, err)
}
box.configure(true)
if _, _, err := box.open(); err != nil {
t.Fatalf("openDB: %v", err)
}
dbtest.WantPgError(t, box.setRole(chained), dbtest.SQLStatePrivilege, "")
}
// TestLogOpenFailure covers copy 17: start logs a refusal, the owner refusal of
// copy 3d among them, as "refused to start", and any other error as a failure
// to initialize.
func TestLogOpenFailure(t *testing.T) {
rows := []struct {
name string
err error
want string
}{
{"a runtime user refusal", &db.RuntimeUserRefusal{Message: "refusal"}, `msg="refused to start"`},
{"a database owner refusal", &db.DatabaseOwnerRefusal{Message: "refusal"}, `msg="refused to start"`},
{"a wrapped database owner refusal", fmt.Errorf("opening: %w", &db.DatabaseOwnerRefusal{Message: "refusal"}), `msg="refused to start"`},
{"any other error", errors.New("boom"), `msg="failed to initialize database"`},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
var logs bytes.Buffer
logOpenFailure(slog.New(slog.NewTextHandler(&logs, nil)), row.err)
if !strings.Contains(logs.String(), row.want) {
t.Errorf("log = %q, want it to contain %s", logs.String(), row.want)
}
})
}
}
+23 -1
View File
@@ -14,7 +14,8 @@ import (
// TestGrantRuntimeRole covers task 4.8 (design D8): the boot grant, run in a
// transaction the test rolls back because its setting is local to one, makes
// a NOINHERIT login whose name needs quoting inherit console_runtime, and a
// second grant succeeds and changes nothing. Role memberships are
// second grant succeeds and changes nothing. The grant leaves the SET option
// false, also over a membership that exists with SET TRUE. Role memberships are
// cluster-wide, so the rollback is what keeps the shared cluster as it was.
func TestGrantRuntimeRole(t *testing.T) {
q := dbtest.Tx(t, dbtest.Open(t))
@@ -30,6 +31,12 @@ func TestGrantRuntimeRole(t *testing.T) {
inherits := func() bool {
return q.String(`SELECT pg_has_role($1, $2, 'USAGE')::text`, login, db.RuntimeGroupRole) == "true"
}
sets := func() bool {
return q.String(`SELECT m.set_option::text FROM pg_auth_members m
JOIN pg_roles g ON g.oid = m.roleid
JOIN pg_roles u ON u.oid = m.member
WHERE g.rolname = $1 AND u.rolname = $2`, db.RuntimeGroupRole, login) == "true"
}
if inherits() || members() != 0 {
t.Fatalf("before the grant, %q must hold no membership (members=%d)", login, members())
}
@@ -45,5 +52,20 @@ func TestGrantRuntimeRole(t *testing.T) {
if n := members(); n != 1 {
t.Errorf("%s: %q has %d memberships in %s, want 1", attempt, login, n, db.RuntimeGroupRole)
}
if sets() {
t.Errorf("%s: the membership of %q in %s carries SET, want it without", attempt, login, db.RuntimeGroupRole)
}
}
q.Exec(`GRANT ` + db.RuntimeGroupRole + ` TO "` + login + `" WITH INHERIT TRUE, SET TRUE`)
if !sets() {
t.Fatalf("the test's own grant left %q without SET", login)
}
if err := db.GrantRuntimeRole(ctx, q.Tx(), login); err != nil {
t.Fatalf("grant over a membership with SET: %v", err)
}
if sets() || !inherits() || members() != 1 {
t.Errorf("after the grant over SET TRUE, %q sets=%t inherits=%t members=%d, want sets=false inherits=true members=1",
login, sets(), inherits(), members())
}
}
+17 -15
View File
@@ -200,29 +200,31 @@ var migrationFile = regexp.MustCompile(`^(\d+)_.*\.sql$`)
// privilege (design D2): a function a role planted in a schema on the path
// would otherwise run in the caller's session. The transaction begins with
// the search-path pin for the operators and for any name a later edit leaves
// bare.
func PendingMigrations(ctx context.Context, q Beginner, sources []MigrationSource) ([]PendingStream, error) {
tx, err := Begin(ctx, q, pinned)
// bare. A transaction q is read in a savepoint (see Reader), which is how
// CheckRuntimeUser runs it as check 7.
func PendingMigrations(ctx context.Context, q Reader, sources []MigrationSource) ([]PendingStream, error) {
var pending []PendingStream
err := readPinned(ctx, q, func(x Reader) error {
for _, src := range sources {
stream, err := pendingStream(ctx, x, src)
if err != nil {
return err
}
if stream != nil {
pending = append(pending, *stream)
}
}
return nil
})
if err != nil {
return nil, err
}
defer tx.Rollback()
var pending []PendingStream
for _, src := range sources {
stream, err := pendingStream(ctx, tx, src)
if err != nil {
return nil, err
}
if stream != nil {
pending = append(pending, *stream)
}
}
return pending, nil
}
// pendingStream compares one stream's embedded versions with its ledger and
// returns nil when the ledger records them all.
func pendingStream(ctx context.Context, tx *sql.Tx, src MigrationSource) (*PendingStream, error) {
func pendingStream(ctx context.Context, tx Reader, src MigrationSource) (*PendingStream, error) {
embedded, err := embeddedVersions(src)
if err != nil {
return nil, err
+390
View File
@@ -0,0 +1,390 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package db_test
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"slices"
"sort"
"strconv"
"strings"
"testing"
)
// queryConstantPrefix begins the name of every string constant that holds SQL
// the console runs with privilege or prints as a statement.
const queryConstantPrefix = "query"
// queryFiles are the files whose query constants the scan reads.
var queryFiles = []string{"runtime_user.go", "migrations.go"}
// syntaxBeforeParenthesis are SQL words that precede a parenthesis without
// being a function call.
var syntaxBeforeParenthesis = map[string]string{
"ANY": "ANY (array) is syntax", "ALL": "ALL (subquery) is syntax", "IN": "IN (list) is syntax",
"EXISTS": "EXISTS (subquery) is syntax", "VALUES": "VALUES (row) is syntax", "CAST": "CAST (x AS t) is syntax",
"COALESCE": "COALESCE (a, b) is syntax", "CASE": "CASE (expression) is syntax", "AND": "AND (condition) is syntax",
"OR": "OR (condition) is syntax", "ON": "ON (condition) is syntax", "FROM": "FROM (subquery) is syntax",
"JOIN": "JOIN (subquery) is syntax", "SELECT": "SELECT (expression) is syntax", "WHERE": "WHERE (condition) is syntax",
"WHEN": "WHEN (condition) is syntax", "THEN": "THEN (expression) is syntax", "ELSE": "ELSE (expression) is syntax",
}
// namedExceptions are tokens a single constant may name without pg_catalog.
var namedExceptions = map[string]map[string]string{
"queryPinSearchPath": {"pg_temp": "pg_temp is a schema name in the search-path pin, not a catalog relation"},
}
const catalogQualifier = "pg_catalog"
type tokenKind int
const (
identToken tokenKind = iota
dotToken
parenToken
otherToken
)
// sqlToken is an identifier, a dot, an opening parenthesis, or any other
// character or literal of a query, which only keeps its neighbours apart.
type sqlToken struct {
kind tokenKind
// text is the identifier as written, without the quotes of a quoted one;
// folded is the name Postgres looks up, lowercase when it was unquoted.
text, folded string
quoted bool
}
func isIdentStart(c byte) bool {
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c >= 0x80
}
func isIdentPart(c byte) bool { return isIdentStart(c) || (c >= '0' && c <= '9') }
// skipQuoted returns the index after the quoted run that begins at i, in which
// the quote character doubled stands for itself.
func skipQuoted(query string, i int) int {
quote := query[i]
for j := i + 1; j < len(query); j++ {
if query[j] != quote {
continue
}
if j+1 < len(query) && query[j+1] == quote {
j++
continue
}
return j + 1
}
return len(query)
}
// skipLineComment returns the index of the newline that ends the comment at i.
func skipLineComment(query string, i int) int {
if end := strings.IndexByte(query[i:], '\n'); end >= 0 {
return i + end
}
return len(query)
}
// skipBlockComment returns the index after the comment at i, which Postgres
// lets nest.
func skipBlockComment(query string, i int) int {
depth := 0
for j := i; j+1 < len(query); j++ {
switch query[j : j+2] {
case "/*":
depth++
j++
case "*/":
depth--
j++
if depth == 0 {
return j + 1
}
}
}
return len(query)
}
// skipDollar returns the index after what a dollar sign at i begins: a
// positional parameter, or the delimiter of a dollar-quoted body. The body
// itself is code and the lexer reads it.
func skipDollar(query string, i int) int {
j := i + 1
if j < len(query) && query[j] >= '0' && query[j] <= '9' {
for j < len(query) && query[j] >= '0' && query[j] <= '9' {
j++
}
return j
}
for j < len(query) && isIdentPart(query[j]) {
j++
}
if j < len(query) && query[j] == '$' {
return j + 1
}
return i + 1
}
// lexSQL reads a query left to right and returns its tokens. A string literal
// is one token that holds no names, and a line comment or block comment is
// none, so an apostrophe in a comment cannot open a literal that swallows the
// next name; a quoted identifier is one token whose quotes are dropped.
func lexSQL(query string) []sqlToken {
var tokens []sqlToken
for i := 0; i < len(query); {
switch c := query[i]; {
case c == '\'':
i = skipQuoted(query, i)
tokens = append(tokens, sqlToken{kind: otherToken})
case strings.HasPrefix(query[i:], "--"):
i = skipLineComment(query, i)
case strings.HasPrefix(query[i:], "/*"):
i = skipBlockComment(query, i)
case c == '"':
end := skipQuoted(query, i)
name := strings.ReplaceAll(query[i+1:max(i+1, end-1)], `""`, `"`)
tokens = append(tokens, sqlToken{kind: identToken, text: name, folded: name, quoted: true})
i = end
case c == '$':
i = skipDollar(query, i)
case isIdentStart(c):
j := i + 1
for j < len(query) && isIdentPart(query[j]) {
j++
}
tokens = append(tokens, sqlToken{kind: identToken, text: query[i:j], folded: strings.ToLower(query[i:j])})
i = j
case c == '.':
tokens = append(tokens, sqlToken{kind: dotToken})
i++
case c == '(':
tokens = append(tokens, sqlToken{kind: parenToken})
i++
case c != ' ' && c != '\t' && c != '\n' && c != '\r':
tokens = append(tokens, sqlToken{kind: otherToken})
i++
default:
i++
}
}
return tokens
}
// parseQueryConstants returns the string constants of a Go source whose names
// begin queryConstantPrefix, by name, with the value of a literal or of a
// concatenation of literals. A query constant whose value the scan cannot read
// (a missing value, a call, a reference to another name) is a problem, never
// a silent skip.
func parseQueryConstants(filename string, src any) (map[string]string, []string, error) {
parsed, err := parser.ParseFile(token.NewFileSet(), filename, src, 0)
if err != nil {
return nil, nil, err
}
found := map[string]string{}
var problems []string
for _, decl := range parsed.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.CONST {
continue
}
for _, spec := range gen.Specs {
value, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for i, name := range value.Names {
if !strings.HasPrefix(name.Name, queryConstantPrefix) {
continue
}
if i >= len(value.Values) {
problems = append(problems, name.Name+" has no value of its own (an implicit repetition or a missing value)")
continue
}
text, ok := constantString(value.Values[i])
if !ok {
problems = append(problems, name.Name+" is not a string literal or a concatenation of string literals")
continue
}
found[name.Name] = text
}
}
}
return found, problems, nil
}
// constantString evaluates a string literal, a parenthesized one, or a
// concatenation of them.
func constantString(expr ast.Expr) (string, bool) {
switch x := expr.(type) {
case *ast.BasicLit:
if x.Kind != token.STRING {
return "", false
}
text, err := strconv.Unquote(x.Value)
return text, err == nil
case *ast.ParenExpr:
return constantString(x.X)
case *ast.BinaryExpr:
left, leftOK := constantString(x.X)
right, rightOK := constantString(x.Y)
return left + right, x.Op == token.ADD && leftOK && rightOK
}
return "", false
}
// unqualifiedTokens returns, for one query, each function call and each
// catalog relation that pg_catalog does not precede, as "token" or "token(",
// the token as written.
func unqualifiedTokens(name, query string) []string {
tokens := lexSQL(query)
var found []string
for i, tok := range tokens {
if tok.kind != identToken {
continue
}
qualified := i >= 2 && tokens[i-1].kind == dotToken && tokens[i-2].kind == identToken &&
tokens[i-2].folded == catalogQualifier
call := i+1 < len(tokens) && tokens[i+1].kind == parenToken
alias := i >= 1 && tokens[i-1].kind == identToken && !tokens[i-1].quoted && tokens[i-1].folded == "as"
if bad := unqualifiedToken(name, tok, qualified, call, alias); bad != "" {
found = append(found, bad)
}
}
return found
}
// unqualifiedToken says what is wrong with one identifier, or returns "".
func unqualifiedToken(query string, tok sqlToken, qualified, call, alias bool) string {
if qualified || tok.folded == catalogQualifier {
return ""
}
if _, listed := namedExceptions[query][tok.folded]; listed {
return ""
}
if call {
_, syntax := syntaxBeforeParenthesis[strings.ToUpper(tok.folded)]
if (syntax && !tok.quoted) || alias {
return ""
}
return tok.text + "("
}
if strings.HasPrefix(tok.folded, "pg_") {
return tok.text
}
return ""
}
// TestCheckQueriesQualifyEveryName covers task 5.8 (design D2, the
// qualification principle): every function call and every catalog relation in
// the check queries, the boot-grant statement and the pending check's queries
// is written with pg_catalog. The scan does not read operators; the
// search-path pin covers them, and the planted-operator rows of
// TestCheckBeforeMigrating and TestOpenRuntimeDatabase show it.
func TestCheckQueriesQualifyEveryName(t *testing.T) {
scanned := 0
for _, file := range queryFiles {
queries, problems, err := parseQueryConstants(file, nil)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
for _, problem := range problems {
t.Errorf("%s: %s, so the scan cannot read it", file, problem)
}
names := make([]string, 0, len(queries))
for name := range queries {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
scanned++
for _, token := range unqualifiedTokens(name, queries[name]) {
t.Errorf("%s in %s: %s is not preceded by pg_catalog.", name, file, token)
}
}
if len(queries) == 0 {
t.Errorf("%s holds no string constant whose name begins %q, so the scan reads nothing", file, queryConstantPrefix)
}
}
if scanned == 0 {
t.Fatalf("found no string constant whose name begins %q in %v", queryConstantPrefix, queryFiles)
}
}
// scanRows are the queries TestScanReadsEverySpelling hands to the scan, with
// the tokens it must report.
var scanRows = []struct {
name, query string
want []string
}{
{"a qualified query", `SELECT pg_catalog.quote_ident(current_user) FROM pg_catalog.pg_roles r`, nil},
{"an apostrophe in a line comment", "SELECT 1 -- the user's role\nFROM pg_roles r WHERE r.rolname = 'x'", []string{"pg_roles"}},
{"an apostrophe in a nested block comment", `/* it's /* nested */ still */ SELECT 1 FROM pg_roles`, []string{"pg_roles"}},
{"an upper-case catalog relation", `SELECT 1 FROM PG_ROLES r`, []string{"PG_ROLES"}},
{"a quoted call", `SELECT "quote_ident"(current_user)`, []string{"quote_ident("}},
{"a quoted catalog relation", `SELECT 1 FROM "pg_roles" r`, []string{"pg_roles"}},
{"a quoted qualifier and name", `SELECT "pg_catalog"."quote_ident"(x)`, nil},
{"an upper-case qualifier", `SELECT PG_CATALOG.quote_ident(x)`, nil},
{"another schema's qualifier", `SELECT public.quote_ident(x)`, []string{"quote_ident("}},
{"a comment marker and a name inside a literal", `SELECT '--pg_roles', quote_ident(x)`, []string{"quote_ident("}},
{"a doubled quote inside a literal", `SELECT 'it''s pg_roles', pg_class.x`, []string{"pg_class"}},
{"a dollar-quoted body is code", `DO $$ BEGIN PERFORM format('x'); END $$`, []string{"format("}},
{"a tagged dollar-quoted body is code", `DO $body$ BEGIN PERFORM quote_ident(1); END $body$`, []string{"quote_ident("}},
{"parameters and syntax before a parenthesis", `SELECT 1 WHERE x = ANY($1) AND y IN (1, 2) AND EXISTS (SELECT 1)`, nil},
{"a quoted word that looks like syntax is a call", `SELECT "ANY"(x)`, []string{"ANY("}},
{"an alias with a column list", `SELECT 1 FROM (VALUES (1)) AS p(priv)`, nil},
}
// TestScanReadsEverySpelling tests the scan on queries written in the
// spellings that once passed it.
func TestScanReadsEverySpelling(t *testing.T) {
for _, row := range scanRows {
t.Run(row.name, func(t *testing.T) {
if got := unqualifiedTokens("queryRow", row.query); !slices.Equal(got, row.want) {
t.Errorf("unqualifiedTokens(%q) = %q, want %q", row.query, got, row.want)
}
})
}
}
// constantSource is a Go source whose query constants are written in the ways
// the scan must read or refuse.
const constantSource = `package x
const queryJoined = "SELECT " +
("quote_ident(x)")
const (
queryFirst, querySecond = "a", "b"
queryIota = iota
queryRepeated
queryBuilt = prefix + "x"
notAQuery = "ignored"
)
`
// TestScanReadsConstants tests the collection of query constants: a literal, a
// parenthesized literal and a concatenation are read, and every other value is
// a problem named after its constant.
func TestScanReadsConstants(t *testing.T) {
found, problems, err := parseQueryConstants("x.go", constantSource)
if err != nil {
t.Fatalf("parse: %v", err)
}
want := map[string]string{"queryJoined": "SELECT quote_ident(x)", "queryFirst": "a", "querySecond": "b"}
if fmt.Sprint(found) != fmt.Sprint(want) {
t.Errorf("constants = %v, want %v", found, want)
}
for _, name := range []string{"queryIota", "queryRepeated", "queryBuilt"} {
if !slices.ContainsFunc(problems, func(p string) bool { return strings.HasPrefix(p, name+" ") }) {
t.Errorf("problems = %q, want one naming %s", problems, name)
}
}
if len(problems) != 3 {
t.Errorf("problems = %q, want exactly the three above", problems)
}
}
+737 -33
View File
@@ -6,6 +6,7 @@ package db
import (
"context"
"database/sql"
"errors"
"fmt"
)
@@ -28,10 +29,9 @@ type RuntimeUserRefusal struct {
func (r *RuntimeUserRefusal) Error() string { return r.Message }
// SchemaBehindRefusal is the refusal for a schema that is behind this
// release (copy 4), with the detail PendingDetail renders.
func SchemaBehindRefusal(pending []PendingStream) *RuntimeUserRefusal {
return &RuntimeUserRefusal{Message: "the schema is behind this release (" + PendingDetail(pending) +
// schemaBehind is copy 4 with the given detail (copies 4a to 4d).
func schemaBehind(detail string) *RuntimeUserRefusal {
return &RuntimeUserRefusal{Message: "the schema is behind this release (" + detail +
"); run member-console migrate up with db-migrate-dsn set, then start again"}
}
@@ -41,32 +41,6 @@ func SchemaBehindRefusal(pending []PendingStream) *RuntimeUserRefusal {
// search-path pin). pg_temp is a schema name here, not a catalog relation.
const queryPinSearchPath = `SET LOCAL search_path = pg_catalog, pg_temp`
// pinned opens a transaction that runs the search-path pin first.
var pinned = FirstRuns(func(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, queryPinSearchPath)
return err
})
// queryCurrentUser reads the login a connection runs as.
const queryCurrentUser = `SELECT current_user::text`
// CurrentUser returns the login that q's connection runs as. The boot grant
// takes its name from the runtime connection, not from the connection string,
// so a string whose user part is URL-encoded, defaulted by the driver or
// rewritten by a pooler still names the user the console serves as.
func CurrentUser(ctx context.Context, q Beginner) (string, error) {
tx, err := Begin(ctx, q, pinned)
if err != nil {
return "", err
}
defer tx.Rollback()
var user string
if err := tx.QueryRowContext(ctx, queryCurrentUser).Scan(&user); err != nil {
return "", err
}
return user, nil
}
// queryGrantSetting passes the runtime user's name into a setting that lasts
// until the transaction ends, so no Go code concatenates SQL.
const queryGrantSetting = `SELECT pg_catalog.set_config('member_console.runtime_user', $1, true)`
@@ -74,10 +48,12 @@ const queryGrantSetting = `SELECT pg_catalog.set_config('member_console.runtime_
// queryGrantRuntimeRole builds the grant in the database and quotes the name
// with %I. WITH INHERIT TRUE makes the grant pass privileges whatever the
// login's own INHERIT attribute says: a login created NOINHERIT would
// otherwise inherit nothing through it.
// otherwise inherit nothing through it. SET FALSE keeps the login from
// SET ROLE to console_runtime or to any role console_runtime holds with SET,
// which no privilege check of the login's own would count.
const queryGrantRuntimeRole = `DO $$
BEGIN
EXECUTE pg_catalog.format('GRANT console_runtime TO %I WITH INHERIT TRUE',
EXECUTE pg_catalog.format('GRANT console_runtime TO %I WITH INHERIT TRUE, SET FALSE',
pg_catalog.current_setting('member_console.runtime_user'));
END
$$`
@@ -90,7 +66,8 @@ $$`
// superuser, and a role that can create objects in public could otherwise
// plant a function with the argument types of format. Granting a membership
// that exists from the same grantor changes nothing, so the grant is
// idempotent.
// idempotent, and a membership that exists with SET TRUE from the same grantor
// is updated in place.
func GrantRuntimeRole(ctx context.Context, tx *sql.Tx, name string) error {
if _, err := tx.ExecContext(ctx, queryPinSearchPath); err != nil {
return fmt.Errorf("pinning the search path: %w", err)
@@ -103,3 +80,730 @@ func GrantRuntimeRole(ctx context.Context, tx *sql.Tx, name string) error {
}
return nil
}
// DatabaseOwnerRefusal is the refusal for a database that is owned by neither
// the migration user nor a superuser role (copy 3d). It is its own type
// because the migrate commands print it and start logs it with the other
// refusals.
type DatabaseOwnerRefusal struct {
// Message is the whole sentence.
Message string
}
func (r *DatabaseOwnerRefusal) Error() string { return r.Message }
// Reader is what a check reads through. A pool or a pinned connection (a
// Beginner) gets a read-only REPEATABLE READ transaction of the check's own,
// which begins with the search-path pin. A transaction gets a savepoint
// inside it that holds the pin and is rolled back when the check ends, so a
// test can run a check inside the transaction where it created the roles the
// check meets, under SET LOCAL ROLE, and the pin does not outlive the check.
type Reader interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
// pinnedSnapshot is the transaction every check reads in: read-only, one
// snapshot, and the search-path pin before any query (design D2, D9).
var pinnedSnapshot = Kind{opts: ReadOnlyRepeatableRead.opts, first: func(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, queryPinSearchPath)
return err
}}
const (
querySavepoint = `SAVEPOINT mc_check`
queryRollbackSavepoint = `ROLLBACK TO SAVEPOINT mc_check`
queryReleaseSavepoint = `RELEASE SAVEPOINT mc_check`
)
// readPinned runs fn in the pinned read transaction of a Beginner, or in a
// savepoint of the transaction r is. fn's error is a failed query.
func readPinned(ctx context.Context, r Reader, fn func(Reader) error) error {
if b, ok := r.(Beginner); ok {
tx, err := Begin(ctx, b, pinnedSnapshot)
if err != nil {
return err
}
defer tx.Rollback()
return fn(tx)
}
return readInSavepoint(ctx, r, fn)
}
// readInSavepoint is readPinned for a caller that already holds a
// transaction. Rolling back to the savepoint undoes the pin and recovers a
// transaction that a failed query aborted.
func readInSavepoint(ctx context.Context, r Reader, fn func(Reader) error) error {
if _, err := r.ExecContext(ctx, querySavepoint); err != nil {
return err
}
var runErr error
if _, err := r.ExecContext(ctx, queryPinSearchPath); err != nil {
runErr = err
} else {
runErr = fn(r)
}
_, rollbackErr := r.ExecContext(ctx, queryRollbackSavepoint)
_, releaseErr := r.ExecContext(ctx, queryReleaseSavepoint)
return errors.Join(runErr, rollbackErr, releaseErr)
}
// sentence renders the text of a refusal; a check step returns it, or the
// empty string for a pass.
func sentence(format string, args ...any) string { return fmt.Sprintf(format, args...) }
// refuse builds a refusal from a sentence.
func refuse(format string, args ...any) *RuntimeUserRefusal {
return &RuntimeUserRefusal{Message: sentence(format, args...)}
}
// checkFailed is copy 11: a read that fails is never an answer. dsn is the
// option of the connection the read ran on.
func checkFailed(dsn string, err error) *RuntimeUserRefusal {
return refuse("could not check the %s user: %v", dsn, err)
}
const (
runtimeDSN = "db-dsn"
migrationDSN = "db-migrate-dsn"
)
// runChecks runs steps in order in one pinned read, and returns the first
// refusal, or copy 11 for a failed query. A step returns its refusal and its
// query error apart, so a failed query is never taken as a pass.
func runChecks[S any](ctx context.Context, r Reader, state S, steps ...func(context.Context, Reader, S) (string, error)) error {
var message string
err := readPinned(ctx, r, func(x Reader) error {
for _, step := range steps {
var stepErr error
if message, stepErr = step(ctx, x, state); stepErr != nil || message != "" {
return stepErr
}
}
return nil
})
if err != nil {
return checkFailed(runtimeDSN, err)
}
if message != "" {
return &RuntimeUserRefusal{Message: message}
}
return nil
}
// RuntimeIdentity is who the runtime connection is and which database it
// reached, read with qualified names.
type RuntimeIdentity struct {
// User is current_user; Quoted is the same quoted for a statement.
User, Quoted string
// Database is current_database().
Database string
// Superuser is the user's rolsuper.
Superuser bool
}
// MigrationIdentity is who the migration connection is and which database it
// reached. It carries nothing the database's owner could have shaped.
type MigrationIdentity struct {
User, Database string
}
// queryRuntimeIdentity is the runtime connection's identity: qualified, so a
// function the runtime user planted never runs (design D2).
const queryRuntimeIdentity = `SELECT current_user::text, pg_catalog.quote_ident(current_user),
pg_catalog.current_database()::text, r.rolsuper
FROM pg_catalog.pg_roles r WHERE r.rolname = current_user`
// queryMigrationIdentity is the one query the migration connection runs
// before the runtime half of check 4 has passed (design D2, rule 1).
const queryMigrationIdentity = `SELECT current_user::text, pg_catalog.current_database()::text`
// ReadRuntimeIdentity reads the identity of the runtime connection. The boot
// grant takes its name from here, not from the connection string, so a string
// whose user part is URL-encoded, defaulted by the driver or rewritten by a
// pooler still names the user the console serves as. A failed read is copy 11.
func ReadRuntimeIdentity(ctx context.Context, q Reader) (RuntimeIdentity, error) {
var id RuntimeIdentity
err := readPinned(ctx, q, func(x Reader) error {
return x.QueryRowContext(ctx, queryRuntimeIdentity).Scan(&id.User, &id.Quoted, &id.Database, &id.Superuser)
})
if err != nil {
return RuntimeIdentity{}, checkFailed(runtimeDSN, err)
}
return id, nil
}
// ReadMigrationIdentity reads the identity of the migration connection with
// the one qualified query and nothing else. A failed read is copy 11.
func ReadMigrationIdentity(ctx context.Context, q Reader) (MigrationIdentity, error) {
var id MigrationIdentity
err := readPinned(ctx, q, func(x Reader) error {
return x.QueryRowContext(ctx, queryMigrationIdentity).Scan(&id.User, &id.Database)
})
if err != nil {
return MigrationIdentity{}, checkFailed(migrationDSN, err)
}
return id, nil
}
// CheckIdentities runs checks 1 to 3 on the two identities (copies 1, 2, 3).
// migration is nil in the separate-step mode, which has no migration
// connection and so checks 1 and 2 do not apply.
func CheckIdentities(runtime RuntimeIdentity, migration *MigrationIdentity) error {
if migration != nil {
if migration.User == runtime.User {
return refuse("db-dsn and db-migrate-dsn both log in as %s; db-dsn needs a user of its own that is not a superuser and owns nothing",
runtime.User)
}
if migration.Database != runtime.Database {
return refuse("db-dsn connects to database %s and db-migrate-dsn to database %s; both must name the same database",
runtime.Database, migration.Database)
}
}
if runtime.Superuser {
return refuse("db-dsn logs in as %s, a superuser; set db-dsn to a user without SUPERUSER", runtime.User)
}
return nil
}
// queryLogin reads the login a runtime transaction runs as, and its name
// quoted for a statement.
const queryLogin = `SELECT current_user::text, pg_catalog.quote_ident(current_user)`
// login is the name a check refuses by.
type login struct{ name, quoted string }
func readLogin(ctx context.Context, x Reader) (login, error) {
var l login
err := x.QueryRowContext(ctx, queryLogin).Scan(&l.name, &l.quoted)
return l, err
}
// queryCheckSuperuserMembership is the membership half of check 3: the first
// superuser role the user can reach, and the direct grant that leads to it. A
// superuser matches it too, through the bootstrap superuser, which is why the
// identity check of copy 3 runs first.
const queryCheckSuperuserMembership = `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`
// queryCheckDatabaseOwnership is the runtime half of check 4: the owner of the
// current database, when the user is it or can act as it.
const queryCheckDatabaseOwnership = `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')`
// CheckBeforeMigrating runs the membership half of check 3 and the runtime
// half of check 4 in one read-only REPEATABLE READ transaction, before any
// migration and before the boot grant. It returns a *RuntimeUserRefusal (copy
// 3a, 3b or 3c, or copy 11 for a failed query) or nil.
func CheckBeforeMigrating(ctx context.Context, q Reader) error {
return runChecks(ctx, q, struct{}{}, checkSuperuserMembership, checkDatabaseOwnership)
}
func checkSuperuserMembership(ctx context.Context, x Reader, _ struct{}) (string, error) {
l, err := readLogin(ctx, x)
if err != nil {
return "", err
}
var role string
var direct sql.NullString
err = x.QueryRowContext(ctx, queryCheckSuperuserMembership).Scan(&role, &direct)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
return sentence("db-dsn logs in as %s, a member of the superuser %s; as the migration user, run: REVOKE %s FROM %s;",
l.name, role, direct.String, l.quoted), nil
}
func checkDatabaseOwnership(ctx context.Context, x Reader, _ struct{}) (string, error) {
l, err := readLogin(ctx, x)
if err != nil {
return "", err
}
var database, owner string
var isOwner bool
var direct sql.NullString
err = x.QueryRowContext(ctx, queryCheckDatabaseOwnership).Scan(&database, &owner, &isOwner, &direct)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
if isOwner {
return sentence("db-dsn logs in as %s, which owns the database %s; as the migration user, run: REASSIGN OWNED BY %s TO CURRENT_USER;",
l.name, database, l.quoted), nil
}
return sentence("db-dsn logs in as %s, a member of %s, which owns the database %s; as the migration user, run: REVOKE %s FROM %s;",
l.name, owner, database, direct.String, l.quoted), nil
}
// queryCheckDatabaseOwner is the owner half of check 4, on the migration
// connection: the current database, when its owner is neither the migration
// user nor a superuser role. Every name is qualified (design D2).
const queryCheckDatabaseOwner = `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`
// CheckMigrationDatabaseOwner runs the owner half of check 4 on the migration
// connection and returns a *DatabaseOwnerRefusal (copy 3d), copy 11 for a
// failed query, or nil. Every migrate command and start run it before goose
// does, because goose's Status creates a missing ledger in public.
func CheckMigrationDatabaseOwner(ctx context.Context, q Reader) error {
var refusal error
err := readPinned(ctx, q, func(x Reader) error {
var database, owner string
err := x.QueryRowContext(ctx, queryCheckDatabaseOwner).Scan(&database, &owner)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return err
}
refusal = &DatabaseOwnerRefusal{Message: fmt.Sprintf(
"database %s is owned by %s, not the migration user; as the migration user, run: ALTER DATABASE %s OWNER TO CURRENT_USER;",
database, owner, database)}
return nil
})
if err != nil {
return checkFailed(migrationDSN, err)
}
return refusal
}
// queryCheckCreate is the CREATE half of check 12: $1 is the runtime login and
// $2 the schemas; it returns 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. It reads the entries and not has_schema_privilege
// so that a grant the login can only SET ROLE to is caught and the grantee is
// named. The schema owner's own entry is left out.
const queryCheckCreate = `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`
// queryCheckDatabaseCreate is the database half of check 12: the grantee of
// the ACL entry that lets the login ($1) create schemas in the current
// database, PUBLIC first, with the database's name quoted. It reads the entries
// for the reasons queryCheckCreate does, and leaves the database owner's own
// entry out.
const queryCheckDatabaseCreate = `SELECT pg_catalog.quote_ident(d.datname),
CASE WHEN a.grantee = 0 THEN 'PUBLIC'
ELSE pg_catalog.quote_ident(pg_catalog.pg_get_userbyid(a.grantee)) END
FROM pg_catalog.pg_database d
CROSS JOIN LATERAL pg_catalog.aclexplode(COALESCE(d.datacl, pg_catalog.acldefault('d', d.datdba))) a
WHERE d.datname = pg_catalog.current_database()
AND a.privilege_type = 'CREATE'
AND a.grantee <> d.datdba
AND (a.grantee = 0 OR pg_catalog.pg_has_role($1::name, a.grantee, 'MEMBER'))
ORDER BY a.grantee <> 0, 2
LIMIT 1`
// queryOwnedObject is check 9: the first object the login owns or can act as
// the owner of. $1 is the schema names, $2 the login; the outer columns are
// the kind, the object's name, the owner's oid and name, and the login quoted.
// The kinds are the ones a planted object could change what a name or an
// operator in a migration resolves to: relations, functions, operators and
// types in the schemas, and, in the whole database because Postgres picks a
// default operator class without reading the search path, operator classes,
// operator families and extensions. Collations, conversions and text search
// configurations and dictionaries are left out: they resolve by the first match
// on the path, pg_catalog is searched first, and no migration names one outside
// pg_catalog. Casts and triggers have no owner; a cast needs ownership of a type
// it converts, and a trigger runs a function, both listed.
const queryOwnedObject = `SELECT kind, name, owner, pg_catalog.pg_get_userbyid(owner), pg_catalog.quote_ident($2::name) 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)
UNION ALL
SELECT 'operator', o.oid::regoperator::text, o.oprowner
FROM pg_catalog.pg_operator o JOIN pg_catalog.pg_namespace n ON n.oid = o.oprnamespace
WHERE n.nspname = ANY($1)
UNION ALL
SELECT 'type', pg_catalog.format('%I.%I', n.nspname, t.typname), t.typowner
FROM pg_catalog.pg_type t JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname = ANY($1) AND t.typrelid = 0 AND t.typcategory <> 'A'
UNION ALL
SELECT 'operator class', pg_catalog.format('%I.%I', n.nspname, c.opcname), c.opcowner
FROM pg_catalog.pg_opclass c JOIN pg_catalog.pg_namespace n ON n.oid = c.opcnamespace
UNION ALL
SELECT 'operator family', pg_catalog.format('%I.%I', n.nspname, f.opfname), f.opfowner
FROM pg_catalog.pg_opfamily f JOIN pg_catalog.pg_namespace n ON n.oid = f.opfnamespace
UNION ALL
SELECT 'extension', pg_catalog.quote_ident(e.extname), e.extowner
FROM pg_catalog.pg_extension e
) o
WHERE pg_catalog.pg_has_role($2::name, o.owner, 'MEMBER')
ORDER BY kind, name LIMIT 1`
// queryDirectRoleTo finds the direct role through which the login reaches the
// role with oid $1. The login ($2) is looked up by name in pg_roles and never
// cast with regrole, which parses the value as an identifier and lowercases a
// mixed-case name.
const queryDirectRoleTo = `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 r.oid FROM pg_catalog.pg_roles r WHERE r.rolname = $2::name)
AND pg_catalog.pg_has_role(m.roleid, $1::oid, 'MEMBER')
ORDER BY d.rolname LIMIT 1`
// publicSchema is the schema the public half of checks 9 and 12 reads.
var publicSchema = []string{"public"}
// ownedObjectRefusal runs check 9 over schemas for the login named name and
// returns copy 7 or 8 for the first object it owns or can act as the owner
// of.
func ownedObjectRefusal(ctx context.Context, x Reader, name string, schemas []string) (string, error) {
var kind, object, ownerName, quoted string
var owner uint32
err := x.QueryRowContext(ctx, queryOwnedObject, schemas, name).Scan(&kind, &object, &owner, &ownerName, &quoted)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
if ownerName == name {
return sentence("db-dsn logs in as %s, which owns %s %s; as the migration user, run: REASSIGN OWNED BY %s TO CURRENT_USER;",
name, kind, object, quoted), nil
}
var direct sql.NullString
if err := x.QueryRowContext(ctx, queryDirectRoleTo, owner, name).Scan(&direct); err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err
}
return sentence("db-dsn logs in as %s, a member of %s, which owns %s %s; as the migration user, run: REVOKE %s FROM %s;",
name, ownerName, kind, object, direct.String, quoted), nil
}
// CheckSchemasBeforeMigrating runs check 12 for the runtime login named
// login: the CREATE query over schemas (public and the five streams), the
// CREATE query over the database, then check 9's query over the same schemas.
// It returns a *RuntimeUserRefusal (copy 3e, 3f, 7 or 8, or copy 11). cmd calls
// it on the migration connection after CheckMigrationDatabaseOwner, so the
// migration run meets nothing that the runtime user could have planted where
// its names resolve, and on the runtime connection in the separate-step mode,
// which runs no migration.
func CheckSchemasBeforeMigrating(ctx context.Context, q Reader, login string, schemas []string) error {
return runChecks(ctx, q, login, checkCreate(schemas), checkDatabaseCreate, checkOwnership(schemas))
}
func checkCreate(schemas []string) func(context.Context, Reader, string) (string, error) {
return func(ctx context.Context, x Reader, name string) (string, error) {
var schema, grantee string
err := x.QueryRowContext(ctx, queryCheckCreate, name, schemas).Scan(&schema, &grantee)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
return sentence("db-dsn logs in as %s, which can create objects in schema %s through a grant to %s; as the migration user, run: REVOKE CREATE ON SCHEMA %s FROM %s;",
name, schema, grantee, schema, grantee), nil
}
}
func checkDatabaseCreate(ctx context.Context, x Reader, name string) (string, error) {
var database, grantee string
err := x.QueryRowContext(ctx, queryCheckDatabaseCreate, name).Scan(&database, &grantee)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
return sentence("db-dsn logs in as %s, which can create schemas in database %s through a grant to %s; as the migration user, run: REVOKE CREATE ON DATABASE %s FROM %s;",
name, database, grantee, database, grantee), nil
}
func checkOwnership(schemas []string) func(context.Context, Reader, string) (string, error) {
return func(ctx context.Context, x Reader, name string) (string, error) {
return ownedObjectRefusal(ctx, x, name, schemas)
}
}
// RuntimeCheck is what CheckRuntimeUser needs to know about the streams: the
// sources whose ledgers must be current, and the owner roles the runtime user
// must not reach.
type RuntimeCheck struct {
// Sources are the migration sources, in order.
Sources []MigrationSource
// OwnerRoles are the five streams' owner roles, {stream}_owner.
OwnerRoles []string
}
// NewRuntimeCheck builds the check for sources: the owner roles are named
// after the streams.
func NewRuntimeCheck(sources []MigrationSource) RuntimeCheck {
check := RuntimeCheck{Sources: sources}
for _, src := range sources {
check.OwnerRoles = append(check.OwnerRoles, src.Name+"_owner")
}
return check
}
// Streams are the names of the streams, which are also their schemas.
func (c RuntimeCheck) Streams() []string {
streams := make([]string, len(c.Sources))
for i, src := range c.Sources {
streams[i] = src.Name
}
return streams
}
// CreateSchemas are the schemas check 12 reads CREATE and ownership in:
// public and the streams'.
func (c RuntimeCheck) CreateSchemas() []string {
return append(append([]string{}, publicSchema...), c.Streams()...)
}
// runtimeState is what each step of CheckRuntimeUser knows.
type runtimeState struct {
login login
check RuntimeCheck
}
const (
queryRoleExists = `SELECT pg_catalog.to_regrole('console_runtime') IS NOT NULL`
queryInheritsRole = `SELECT pg_catalog.pg_has_role(current_user, 'console_runtime', 'USAGE')`
// queryMembershipSets is the grantor of the first membership of
// console_runtime that the user holds directly with the SET option; no row
// when none carries it.
queryMembershipSets = `SELECT pg_catalog.quote_ident(pg_catalog.pg_get_userbyid(m.grantor))
FROM pg_catalog.pg_auth_members m JOIN pg_catalog.pg_roles g ON g.oid = m.roleid
WHERE g.rolname = 'console_runtime'
AND m.member = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user)
AND m.set_option
ORDER BY 1 LIMIT 1`
)
// queryOwnerRoleMember is check 8: the first owner role ($1) the user can
// reach, and the direct grant that leads to it.
const queryOwnerRoleMember = `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`
// queryOtherDirectMembership is check 10: the first direct membership that is
// not console_runtime.
const queryOtherDirectMembership = `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`
// queryInsertProbe is check 11's INSERT probe on core.pool_provisions, with
// the grantee that reaches the user (NULL when a predefined role gives the
// privilege).
const queryInsertProbe = `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`
// queryFourPrivileges is check 11's read of the first relation of the streams'
// schemas ($1) on which the user holds TRUNCATE, REFERENCES, TRIGGER or
// MAINTAIN, with the grantee that reaches the user.
const queryFourPrivileges = `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`
// queryPredefinedRole finds, for a privilege ($1, INSERT or MAINTAIN) that has
// no ACL entry, the predefined role that gives it and the role it is granted
// to.
const queryPredefinedRole = `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`
// CheckRuntimeUser runs checks 5 to 11 in this order in one read-only
// REPEATABLE READ transaction, after the migrations and the boot grant: the
// group role exists (copy 4 with 4a), the user inherits it and cannot SET ROLE
// to it (copies 5, 5a), the
// schema is current (copy 4), the user reaches no owner role (copy 6), owns
// nothing and cannot act as an owner (copies 7, 8), holds no direct
// membership but console_runtime (copy 8a), and holds none of the privileges
// the enclosures withhold (copies 9, 9a). It returns a *RuntimeUserRefusal or
// nil; a failed query is copy 11.
func CheckRuntimeUser(ctx context.Context, q Reader, check RuntimeCheck) error {
return runChecks(ctx, q, &runtimeState{check: check},
readRuntimeLogin, checkRoleExists, checkInheritsRole, checkPending, checkOwnerRoles,
checkOwnedObjects, checkDirectMemberships, checkPrivileges)
}
// readRuntimeLogin is not a check: it reads the name the refusals use.
func readRuntimeLogin(ctx context.Context, x Reader, st *runtimeState) (string, error) {
l, err := readLogin(ctx, x)
st.login = l
return "", err
}
func checkRoleExists(ctx context.Context, x Reader, _ *runtimeState) (string, error) {
var exists bool
if err := x.QueryRowContext(ctx, queryRoleExists).Scan(&exists); err != nil {
return "", err
}
if exists {
return "", nil
}
return schemaBehind(RuntimeGroupRole + " does not exist").Message, nil
}
func checkInheritsRole(ctx context.Context, x Reader, st *runtimeState) (string, error) {
var inherits bool
if err := x.QueryRowContext(ctx, queryInheritsRole).Scan(&inherits); err != nil {
return "", err
}
if !inherits {
return sentence("db-dsn logs in as %s, which is not a member of console_runtime; as the migration user, run: GRANT console_runtime TO %s WITH INHERIT TRUE, SET FALSE;",
st.login.name, st.login.quoted), nil
}
var grantor string
err := x.QueryRowContext(ctx, queryMembershipSets).Scan(&grantor)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
return sentence("db-dsn logs in as %s, which can SET ROLE to console_runtime through a grant by %s; as the migration user, run: REVOKE console_runtime FROM %s GRANTED BY %s; GRANT console_runtime TO %s WITH INHERIT TRUE, SET FALSE;",
st.login.name, grantor, st.login.quoted, grantor, st.login.quoted), nil
}
// checkPending is check 7: the ledgers record every embedded version.
func checkPending(ctx context.Context, x Reader, st *runtimeState) (string, error) {
pending, err := PendingMigrations(ctx, x, st.check.Sources)
if err != nil {
return "", err
}
if len(pending) > 0 {
return schemaBehind(PendingDetail(pending)).Message, nil
}
return "", nil
}
// checkOwnerRoles is check 8: the user reaches none of the streams' owner
// roles.
func checkOwnerRoles(ctx context.Context, x Reader, st *runtimeState) (string, error) {
var role string
var direct sql.NullString
err := x.QueryRowContext(ctx, queryOwnerRoleMember, st.check.OwnerRoles).Scan(&role, &direct)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
return sentence("db-dsn logs in as %s, a member of %s; as the migration user, run: REVOKE %s FROM %s;",
st.login.name, role, direct.String, st.login.quoted), nil
}
// checkOwnedObjects is check 9 over the streams' schemas and public.
func checkOwnedObjects(ctx context.Context, x Reader, st *runtimeState) (string, error) {
schemas := append(st.check.Streams(), publicSchema...)
return ownedObjectRefusal(ctx, x, st.login.name, schemas)
}
// checkDirectMemberships is check 10: console_runtime is the user's only
// direct membership.
func checkDirectMemberships(ctx context.Context, x Reader, st *runtimeState) (string, error) {
var role string
err := x.QueryRowContext(ctx, queryOtherDirectMembership).Scan(&role)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
return sentence("db-dsn logs in as %s, a direct member of %s; as the migration user, run: REVOKE %s FROM %s;",
st.login.name, role, role, st.login.quoted), nil
}
// checkPrivileges is check 11: the user can insert into core.pool_provisions,
// or holds one of the four privileges the writers lose, on no relation.
func checkPrivileges(ctx context.Context, x Reader, st *runtimeState) (string, error) {
var grantee sql.NullString
err := x.QueryRowContext(ctx, queryInsertProbe).Scan(&grantee)
if err == nil {
return privilegeRefusal(ctx, x, st, "INSERT", "core.pool_provisions", grantee)
}
if !errors.Is(err, sql.ErrNoRows) {
return "", err
}
var privilege, relation string
err = x.QueryRowContext(ctx, queryFourPrivileges, st.check.Streams()).Scan(&privilege, &relation, &grantee)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
return privilegeRefusal(ctx, x, st, privilege, relation, grantee)
}
// privilegeRefusal is copy 9 for a privilege that an ACL entry gives the user
// or PUBLIC, and copy 9a when no entry does and a predefined role granted to
// a role the user reaches does.
func privilegeRefusal(ctx context.Context, x Reader, st *runtimeState, privilege, relation string, grantee sql.NullString) (string, error) {
if !grantee.Valid && (privilege == "INSERT" || privilege == "MAINTAIN") {
var predefined, member string
err := x.QueryRowContext(ctx, queryPredefinedRole, privilege).Scan(&predefined, &member)
if err == nil {
return sentence("db-dsn logs in as %s, which holds %s on %s through %s, granted to %s; as the migration user, run: REVOKE %s FROM %s;",
st.login.name, privilege, relation, predefined, member, predefined, member), nil
}
if !errors.Is(err, sql.ErrNoRows) {
return "", err
}
}
return sentence("db-dsn logs in as %s, which holds %s on %s through a grant to %s; as the migration user, run: REVOKE %s ON %s FROM %s;",
st.login.name, privilege, relation, grantee.String, privilege, relation, grantee.String), nil
}
+799
View File
@@ -0,0 +1,799 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package db_test
// Tests of the start-up check (openspec change runtime-database-user, design
// D9, tasks 5.3 and 5.4). Every row creates the roles and objects it needs
// inside a transaction it rolls back and runs the check under SET LOCAL ROLE
// (or, for the checks that run on the migration connection, as the superuser
// test login with the runtime login as an argument). The rows whose subject is
// a database's owner run on a scratch database that a role the test creates
// owns; that role is committed, so the test drops it in its cleanup, after
// the database.
import (
"context"
"errors"
"fmt"
"net/url"
"regexp"
"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"
)
var plainIdentifier = regexp.MustCompile(`^[a-z_][a-z0-9_]*$`)
// ident quotes a role or object name as the database's quote_ident does for
// the names these tests use.
func ident(name string) string {
if plainIdentifier.MatchString(name) {
return name
}
return `"` + name + `"`
}
// checkTx begins a transaction on the shared test database that the test
// rolls back, on a connection with the runtime pool's search_path, so a name
// the check prints is qualified only because the check pins its own path.
func checkTx(t *testing.T) *dbtest.Q {
t.Helper()
return dbtest.Tx(t, dbtest.Open(t, dbtest.WithSearchPath("core", "public")))
}
// runtimeCheck is the check cmd builds.
func runtimeCheck() db.RuntimeCheck { return db.NewRuntimeCheck(migrate.Sources()) }
// createSchemas is public and the five streams, as cmd passes them to check 12.
func createSchemas() []string { return runtimeCheck().CreateSchemas() }
// asLogin makes the rest of the transaction run as role.
func asLogin(q *dbtest.Q, role string) { q.Exec("SET LOCAL ROLE " + ident(role)) }
// memberOfRuntime creates a login that holds console_runtime as the boot grant
// leaves it, with INHERIT and without SET, and nothing else.
func memberOfRuntime(q *dbtest.Q, name string) {
q.Exec("CREATE ROLE " + ident(name) + " LOGIN")
q.Exec("GRANT " + db.RuntimeGroupRole + " TO " + ident(name) + " WITH INHERIT TRUE, SET FALSE")
}
// wantRefusal asserts that err is a start refusal whose whole sentence is want,
// or nil when want is empty.
func wantRefusal(t *testing.T, err error, want string) {
t.Helper()
if want == "" {
if err != nil {
t.Errorf("check = %v, want a pass", err)
}
return
}
var refusal *db.RuntimeUserRefusal
if !errors.As(err, &refusal) {
t.Errorf("check = %v, want the refusal %q", err, want)
return
}
if refusal.Error() != want {
t.Errorf("refusal = %q, want %q", refusal.Error(), want)
}
}
// committedRole creates a role in the cluster, outside any transaction, and
// drops it when the test ends. Cleanups run last first, so a database the
// role owns must be created after the role.
func committedRole(t *testing.T, attributes string) string {
t.Helper()
return committedRoleNamed(t, dbtest.Name("mc_owner_"), attributes)
}
// committedRoleNamed is committedRole for a name the caller chose, quoted in
// the statements it runs.
func committedRoleNamed(t *testing.T, role, attributes string) string {
t.Helper()
admin := dbtest.Connect(t, dbtest.DSN(t))
dbtest.On(t, admin).Exec("CREATE ROLE " + ident(role) + " " + attributes)
t.Cleanup(func() {
if _, err := admin.ExecContext(context.Background(), "DROP ROLE IF EXISTS "+ident(role)); err != nil {
t.Errorf("drop role %s: %v", role, err)
}
})
return role
}
// ownedScratch creates a scratch database and gives it to owner. It returns
// the database's address and name.
func ownedScratch(t *testing.T, owner string) (string, string) {
t.Helper()
dsn := dbtest.Scratch(t)
name := scratchDatabaseName(t, dsn)
dbtest.On(t, dbtest.Connect(t, dbtest.DSN(t))).Exec("ALTER DATABASE " + name + " OWNER TO " + ident(owner))
return dsn, name
}
// namedDatabase creates a database whose name needs quoting, owned by owner,
// and returns its address and name. The test drops it in its cleanup.
func namedDatabase(t *testing.T, owner string) (string, string) {
t.Helper()
admin := dbtest.Connect(t, dbtest.DSN(t))
name := dbtest.Name("Mc-Db-")
dbtest.On(t, admin).Exec("CREATE DATABASE " + ident(name) + " OWNER " + ident(owner))
t.Cleanup(func() {
if _, err := admin.ExecContext(context.Background(), "DROP DATABASE IF EXISTS "+ident(name)+" WITH (FORCE)"); err != nil {
t.Errorf("drop database %s: %v", name, err)
}
})
u, err := url.Parse(dbtest.DSN(t))
if err != nil {
t.Fatalf("parse TEST_DATABASE_URL: %v", err)
}
u.Path = "/" + name
return u.String(), name
}
// beforeMigratingRow is one row of TestCheckBeforeMigrating.
type beforeMigratingRow struct {
name string
run func(t *testing.T)
}
func beforeMigratingRows() []beforeMigratingRow {
return []beforeMigratingRow{
{"a member of console_runtime that owns nothing passes", beforeCleanMember},
{"a member of a superuser role is refused", beforeSuperuserMember},
{"the owner of the database is refused", beforeDatabaseOwner},
{"a member of the database's owner through another role is refused", beforeOwnerMember},
{"a role that owns another database passes", beforeOwnerOfAnotherDatabase},
{"a closed database is a failed check", beforeClosedDatabase},
{"check 12: a login that owns nothing and cannot create passes", schemasClean},
{"check 12: CREATE on public through PUBLIC is refused", schemasPublicCreate},
{"check 12: CREATE through a role it can only SET is refused", schemasCreateThroughSetOnly},
{"check 12: CREATE on a console schema is refused", schemasCoreCreate},
{"check 12: an owned function in public is refused", schemasOwnsFunction},
{"check 12: a function owned through a mixed-case login is refused", schemasOwnsFunctionThroughRole},
{"check 12: a planted operator never runs", schemasPlantedOperator},
{"check 12: CREATE on the database through PUBLIC is refused", schemasDatabaseCreatePublic},
{"check 12: CREATE on the database through a role it can only SET is refused", schemasDatabaseCreateThroughSetOnly},
{"check 12: an owned table in core is refused", schemasOwnsStreamTable},
{"check 12: an owned operator in public is refused", schemasOwnsOperator},
{"check 12: an owned type in public is refused", schemasOwnsType},
{"check 12: an owned operator class in another schema is refused", schemasOwnsOperatorClass},
{"check 12: an owned operator family in another schema is refused", schemasOwnsOperatorFamily},
{"check 12: an owned extension is refused", schemasOwnsExtension},
{"check 12: a closed database is a failed check", schemasClosedDatabase},
}
}
// TestCheckBeforeMigrating covers task 5.3 (design D9, checks 3, 4 and 12).
func TestCheckBeforeMigrating(t *testing.T) {
for _, row := range beforeMigratingRows() {
t.Run(row.name, row.run)
}
}
func beforeCleanMember(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("mc_rt_")
memberOfRuntime(q, login)
asLogin(q, login)
wantRefusal(t, db.CheckBeforeMigrating(context.Background(), q.Tx()), "")
}
func beforeSuperuserMember(t *testing.T) {
q := checkTx(t)
login, super := dbtest.Name("mc_rt_"), dbtest.Name("mc_super_")
memberOfRuntime(q, login)
q.Exec("CREATE ROLE " + super + " SUPERUSER")
q.Exec("GRANT " + super + " TO " + login)
asLogin(q, login)
want := fmt.Sprintf("db-dsn logs in as %s, a member of the superuser %s; as the migration user, run: REVOKE %s FROM %s;",
login, super, super, login)
wantRefusal(t, db.CheckBeforeMigrating(context.Background(), q.Tx()), want)
}
func beforeDatabaseOwner(t *testing.T) {
owner := committedRole(t, "")
dsn, name := ownedScratch(t, owner)
q := dbtest.Tx(t, dbtest.Connect(t, dsn))
asLogin(q, owner)
want := fmt.Sprintf("db-dsn logs in as %s, which owns the database %s; as the migration user, run: REASSIGN OWNED BY %s TO CURRENT_USER;",
owner, name, owner)
wantRefusal(t, db.CheckBeforeMigrating(context.Background(), q.Tx()), want)
}
func beforeOwnerMember(t *testing.T) {
owner := committedRole(t, "")
dsn, name := ownedScratch(t, owner)
q := dbtest.Tx(t, dbtest.Connect(t, dsn))
via, login := dbtest.Name("Mc-Via-"), dbtest.Name("mc_rt_")
q.Exec("CREATE ROLE " + ident(via))
q.Exec("GRANT " + owner + " TO " + ident(via))
q.Exec("CREATE ROLE " + login)
q.Exec("GRANT " + ident(via) + " TO " + login)
asLogin(q, login)
want := fmt.Sprintf("db-dsn logs in as %s, a member of %s, which owns the database %s; as the migration user, run: REVOKE %s FROM %s;",
login, owner, name, ident(via), login)
wantRefusal(t, db.CheckBeforeMigrating(context.Background(), q.Tx()), want)
}
func beforeOwnerOfAnotherDatabase(t *testing.T) {
owner := committedRole(t, "")
ownedScratch(t, owner)
q := checkTx(t)
asLogin(q, owner)
wantRefusal(t, db.CheckBeforeMigrating(context.Background(), q.Tx()), "")
}
func beforeClosedDatabase(t *testing.T) {
err := db.CheckBeforeMigrating(context.Background(), dbtest.Closed(t))
wantRefusal(t, err, "could not check the db-dsn user: sql: database is closed")
}
func schemasClean(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("mc_rt_")
memberOfRuntime(q, login)
wantRefusal(t, db.CheckSchemasBeforeMigrating(context.Background(), q.Tx(), login, createSchemas()), "")
}
func schemasPublicCreate(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("mc_rt_")
memberOfRuntime(q, login)
q.Exec("GRANT CREATE ON SCHEMA public TO PUBLIC")
want := fmt.Sprintf("db-dsn logs in as %s, which can create objects in schema public through a grant to PUBLIC; "+
"as the migration user, run: REVOKE CREATE ON SCHEMA public FROM PUBLIC;", login)
wantRefusal(t, db.CheckSchemasBeforeMigrating(context.Background(), q.Tx(), login, createSchemas()), want)
}
func schemasCreateThroughSetOnly(t *testing.T) {
q := checkTx(t)
login, creator := dbtest.Name("mc_rt_"), dbtest.Name("Mc-Creator-")
memberOfRuntime(q, login)
q.Exec("CREATE ROLE " + ident(creator))
q.Exec("GRANT CREATE ON SCHEMA public TO " + ident(creator))
q.Exec("GRANT " + ident(creator) + " TO " + login + " WITH INHERIT FALSE, SET TRUE")
want := fmt.Sprintf("db-dsn logs in as %s, which can create objects in schema public through a grant to %s; "+
"as the migration user, run: REVOKE CREATE ON SCHEMA public FROM %s;", login, ident(creator), ident(creator))
wantRefusal(t, db.CheckSchemasBeforeMigrating(context.Background(), q.Tx(), login, createSchemas()), want)
}
func schemasCoreCreate(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("mc_rt_")
memberOfRuntime(q, login)
q.Exec("GRANT CREATE ON SCHEMA core TO " + login)
want := fmt.Sprintf("db-dsn logs in as %s, which can create objects in schema core through a grant to %s; "+
"as the migration user, run: REVOKE CREATE ON SCHEMA core FROM %s;", login, login, login)
wantRefusal(t, db.CheckSchemasBeforeMigrating(context.Background(), q.Tx(), login, createSchemas()), want)
}
// publicFunction creates a function in public that owner owns and returns its
// name.
func publicFunction(q *dbtest.Q, owner string) string {
name := dbtest.Name("mc_f_")
q.Exec("CREATE FUNCTION public." + name + "() RETURNS integer LANGUAGE sql AS 'SELECT 1'")
q.Exec("ALTER FUNCTION public." + name + "() OWNER TO " + ident(owner))
return name
}
func schemasOwnsFunction(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("mc_rt_")
memberOfRuntime(q, login)
function := publicFunction(q, login)
want := fmt.Sprintf("db-dsn logs in as %s, which owns function public.%s(); as the migration user, run: REASSIGN OWNED BY %s TO CURRENT_USER;",
login, function, login)
wantRefusal(t, db.CheckSchemasBeforeMigrating(context.Background(), q.Tx(), login, createSchemas()), want)
}
func schemasOwnsFunctionThroughRole(t *testing.T) {
q := checkTx(t)
owner, via, login := dbtest.Name("Mc-Owner-"), dbtest.Name("Mc-Via-"), dbtest.Name("Mc-Rt-")
q.Exec("CREATE ROLE " + ident(owner))
function := publicFunction(q, owner)
q.Exec("CREATE ROLE " + ident(via) + " IN ROLE " + ident(owner))
q.Exec("CREATE ROLE " + ident(login) + " IN ROLE " + ident(via) + "," + db.RuntimeGroupRole)
want := fmt.Sprintf("db-dsn logs in as %s, a member of %s, which owns function public.%s(); as the migration user, run: REVOKE %s FROM %s;",
login, owner, function, ident(via), ident(login))
wantRefusal(t, db.CheckSchemasBeforeMigrating(context.Background(), q.Tx(), login, createSchemas()), want)
}
// plantedFunctionBody raises, where a function that wrote a marker row would be
// blind: inside the savepoint of a test the row is rolled back with it, and in a
// read-only transaction the write fails. An exception shows in both, turns the
// check into copy 11, and puts its text in the sentence the row compares.
const plantedFunctionBody = `BEGIN RAISE EXCEPTION 'planted function ran as %', current_user; END`
// schemasPlantedOperator plants public.=(oid, integer), which check 12's own
// query would call for a.grantee = 0, and shows that the check refuses with
// its normal sentence: a call of the planted function would raise and make the
// result copy 11.
func schemasPlantedOperator(t *testing.T) {
q := checkTx(t)
login, planter, function := dbtest.Name("mc_rt_"), dbtest.Name("mc_planter_"), dbtest.Name("mc_eq_")
memberOfRuntime(q, login)
q.Exec("GRANT CREATE ON SCHEMA public TO PUBLIC")
q.Exec("CREATE ROLE " + planter)
asLogin(q, planter)
q.Exec("CREATE FUNCTION public." + function + "(oid, integer) RETURNS boolean LANGUAGE plpgsql AS $$ " + plantedFunctionBody + " $$")
q.Exec("CREATE OPERATOR public.= (LEFTARG = oid, RIGHTARG = integer, FUNCTION = public." + function + ")")
q.Exec("RESET ROLE")
want := fmt.Sprintf("db-dsn logs in as %s, which can create objects in schema public through a grant to PUBLIC; "+
"as the migration user, run: REVOKE CREATE ON SCHEMA public FROM PUBLIC;", login)
wantRefusal(t, db.CheckSchemasBeforeMigrating(context.Background(), q.Tx(), login, createSchemas()), want)
}
// databaseCreateRefusal is copy 3f for the shared test database.
func databaseCreateRefusal(login, database, grantee string) string {
return fmt.Sprintf("db-dsn logs in as %s, which can create schemas in database %s through a grant to %s; "+
"as the migration user, run: REVOKE CREATE ON DATABASE %s FROM %s;", login, ident(database), grantee, ident(database), grantee)
}
func schemasDatabaseCreatePublic(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("mc_rt_")
memberOfRuntime(q, login)
database := q.String("SELECT current_database()")
q.Exec("GRANT CREATE ON DATABASE " + ident(database) + " TO PUBLIC")
wantRefusal(t, db.CheckSchemasBeforeMigrating(context.Background(), q.Tx(), login, createSchemas()),
databaseCreateRefusal(login, database, "PUBLIC"))
}
func schemasDatabaseCreateThroughSetOnly(t *testing.T) {
q := checkTx(t)
login, creator := dbtest.Name("mc_rt_"), dbtest.Name("Mc-Creator-")
memberOfRuntime(q, login)
database := q.String("SELECT current_database()")
q.Exec("CREATE ROLE " + ident(creator))
q.Exec("GRANT CREATE ON DATABASE " + ident(database) + " TO " + ident(creator))
q.Exec("GRANT " + ident(creator) + " TO " + login + " WITH INHERIT FALSE, SET TRUE")
wantRefusal(t, db.CheckSchemasBeforeMigrating(context.Background(), q.Tx(), login, createSchemas()),
databaseCreateRefusal(login, database, ident(creator)))
}
// wantOwned asserts copy 7 for an object of kind and name that login owns.
func wantOwned(t *testing.T, q *dbtest.Q, login, kind, name string) {
t.Helper()
want := fmt.Sprintf("db-dsn logs in as %s, which owns %s %s; as the migration user, run: REASSIGN OWNED BY %s TO CURRENT_USER;",
login, kind, name, login)
wantRefusal(t, db.CheckSchemasBeforeMigrating(context.Background(), q.Tx(), login, createSchemas()), want)
}
func schemasOwnsStreamTable(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("mc_rt_")
memberOfRuntime(q, login)
table := tableInCore(q)
q.Exec("ALTER TABLE core." + table + " OWNER TO " + login)
wantOwned(t, q, login, "relation", "core."+table)
}
func schemasOwnsOperator(t *testing.T) {
q := checkTx(t)
login, function := dbtest.Name("mc_rt_"), dbtest.Name("mc_f_")
memberOfRuntime(q, login)
q.Exec("CREATE FUNCTION public." + function + "(integer, integer) RETURNS boolean LANGUAGE sql AS 'SELECT true'")
q.Exec("CREATE OPERATOR public.@#@ (LEFTARG = integer, RIGHTARG = integer, FUNCTION = public." + function + ")")
q.Exec("ALTER OPERATOR public.@#@ (integer, integer) OWNER TO " + login)
wantOwned(t, q, login, "operator", "public.@#@(integer,integer)")
}
func schemasOwnsType(t *testing.T) {
q := checkTx(t)
login, kind := dbtest.Name("mc_rt_"), dbtest.Name("mc_ty_")
memberOfRuntime(q, login)
q.Exec("CREATE TYPE public." + kind + " AS ENUM ('a')")
q.Exec("ALTER TYPE public." + kind + " OWNER TO " + login)
wantOwned(t, q, login, "type", "public."+kind)
}
// otherSchema creates a schema outside the six that check 12 reads, where only
// the whole-database kinds are read.
func otherSchema(q *dbtest.Q) string {
schema := dbtest.Name("mc_s_")
q.Exec("CREATE SCHEMA " + schema)
return schema
}
func schemasOwnsOperatorClass(t *testing.T) {
q := checkTx(t)
login, class := dbtest.Name("mc_rt_"), dbtest.Name("mc_oc_")
memberOfRuntime(q, login)
schema := otherSchema(q)
q.Exec("CREATE OPERATOR CLASS " + schema + "." + class + " FOR TYPE integer USING btree AS FUNCTION 1 btint4cmp(integer, integer)")
q.Exec("ALTER OPERATOR CLASS " + schema + "." + class + " USING btree OWNER TO " + login)
wantOwned(t, q, login, "operator class", schema+"."+class)
}
func schemasOwnsOperatorFamily(t *testing.T) {
q := checkTx(t)
login, family := dbtest.Name("mc_rt_"), dbtest.Name("mc_of_")
memberOfRuntime(q, login)
schema := otherSchema(q)
q.Exec("CREATE OPERATOR FAMILY " + schema + "." + family + " USING btree")
q.Exec("ALTER OPERATOR FAMILY " + schema + "." + family + " USING btree OWNER TO " + login)
wantOwned(t, q, login, "operator family", schema+"."+family)
}
// schemasOwnsExtension has the login create a trusted extension, which Postgres
// runs as the bootstrap superuser and leaves the login owning only the
// extension itself, none of its objects; the login's CREATE grants are revoked
// again so that check 12 reads the extension and not the grants.
func schemasOwnsExtension(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("mc_rt_")
memberOfRuntime(q, login)
database := q.String("SELECT current_database()")
q.Exec("GRANT CREATE ON DATABASE " + ident(database) + " TO " + login)
q.Exec("GRANT CREATE ON SCHEMA public TO " + login)
asLogin(q, login)
q.Exec("CREATE EXTENSION pg_trgm SCHEMA public")
q.Exec("RESET ROLE")
q.Exec("REVOKE CREATE ON DATABASE " + ident(database) + " FROM " + login)
q.Exec("REVOKE CREATE ON SCHEMA public FROM " + login)
wantOwned(t, q, login, "extension", "pg_trgm")
}
func schemasClosedDatabase(t *testing.T) {
err := db.CheckSchemasBeforeMigrating(context.Background(), dbtest.Closed(t), "nobody", createSchemas())
wantRefusal(t, err, "could not check the db-dsn user: sql: database is closed")
}
// ownerRow is one row of TestCheckMigrationDatabaseOwner.
type ownerRow struct {
name string
run func(t *testing.T)
}
func ownerRows() []ownerRow {
return []ownerRow{
{"a database owned by the migration user passes", ownerIsMigrationUser},
{"a database owned by a superuser role passes", ownerIsSuperuserRole},
{"a database owned by another role is refused", ownerIsAnotherRole},
{"a closed database is a failed check", ownerClosedDatabase},
{"a closed database on the migration identity read names db-migrate-dsn", migrationIdentityClosedDatabase},
}
}
// TestCheckMigrationDatabaseOwner covers task 5.3 (design D9, check 4, owner
// half): the check runs on the migration connection, here the superuser test
// login, against scratch databases.
func TestCheckMigrationDatabaseOwner(t *testing.T) {
for _, row := range ownerRows() {
t.Run(row.name, row.run)
}
}
func ownerIsMigrationUser(t *testing.T) {
scratch := dbtest.Connect(t, dbtest.Scratch(t))
if err := db.CheckMigrationDatabaseOwner(context.Background(), scratch); err != nil {
t.Errorf("check = %v, want a pass", err)
}
}
func ownerIsSuperuserRole(t *testing.T) {
owner := committedRole(t, "SUPERUSER")
dsn, _ := ownedScratch(t, owner)
if err := db.CheckMigrationDatabaseOwner(context.Background(), dbtest.Connect(t, dsn)); err != nil {
t.Errorf("check = %v, want a pass", err)
}
}
func ownerIsAnotherRole(t *testing.T) {
owner := committedRoleNamed(t, dbtest.Name("Mc-Owner-"), "")
dsn, name := namedDatabase(t, owner)
err := db.CheckMigrationDatabaseOwner(context.Background(), dbtest.Connect(t, dsn))
want := fmt.Sprintf("database %s is owned by %s, not the migration user; as the migration user, run: ALTER DATABASE %s OWNER TO CURRENT_USER;",
ident(name), ident(owner), ident(name))
var refusal *db.DatabaseOwnerRefusal
if !errors.As(err, &refusal) {
t.Fatalf("check = %v, want the refusal %q", err, want)
}
if refusal.Error() != want {
t.Errorf("refusal = %q, want %q", refusal.Error(), want)
}
}
func ownerClosedDatabase(t *testing.T) {
err := db.CheckMigrationDatabaseOwner(context.Background(), dbtest.Closed(t))
wantRefusal(t, err, "could not check the db-migrate-dsn user: sql: database is closed")
}
func migrationIdentityClosedDatabase(t *testing.T) {
_, err := db.ReadMigrationIdentity(context.Background(), dbtest.Closed(t))
wantRefusal(t, err, "could not check the db-migrate-dsn user: sql: database is closed")
}
// runtimeRow is one row of TestCheckRuntimeUser.
type runtimeRow struct {
name string
run func(t *testing.T)
}
func runtimeRows() []runtimeRow {
return []runtimeRow{
{"a member of console_runtime passes", runtimePasses},
{"a role that is no member is copy 5", runtimeNoMember},
{"a membership without INHERIT is copy 5", runtimeMemberWithoutInherit},
{"a membership with SET granted by the bootstrap superuser is copy 5a", runtimeMemberWithSet},
{"a membership with SET granted by another role is copy 5a and the printed statements fix it", runtimeMemberWithSetByAnotherGrantor},
{"a renamed console_runtime is copy 4a", runtimeGroupRoleMissing},
{"a direct member of core_owner is copy 6", runtimeDirectOwnerRole},
{"a member of core_owner through another role is copy 6", runtimeOwnerRoleThroughRole},
{"a member of core_owner without INHERIT is copy 6", runtimeOwnerRoleWithoutInherit},
{"the owner of a table in core is copy 7", runtimeOwnsTable},
{"the owner of a function in core is copy 7", runtimeOwnsCoreFunction},
{"the owner of a function in public is copy 7", runtimeOwnsPublicFunction},
{"a member of a role that owns a table is copy 8", runtimeMemberOfTableOwner},
{"TRIGGER through a grant to the user is copy 9", runtimeTriggerGrant},
{"TRUNCATE through a grant to core_reader is copy 9", runtimeTruncateThroughReader},
{"MAINTAIN through pg_maintain is copy 9a", runtimeMaintainThroughPredefinedRole},
{"a direct member of an unrelated role is copy 8a", runtimeUnrelatedRole},
{"a direct member of pg_execute_server_program is copy 8a", predefinedRoleRow("pg_execute_server_program")},
{"a direct member of pg_read_server_files is copy 8a", predefinedRoleRow("pg_read_server_files")},
{"a direct member of pg_write_all_data is copy 8a", predefinedRoleRow("pg_write_all_data")},
{"INSERT on core.pool_provisions through the user is copy 9", runtimeInsertGrant("")},
{"INSERT on core.pool_provisions through PUBLIC is copy 9", runtimeInsertGrant("PUBLIC")},
{"a closed database is a failed check", runtimeClosedDatabase},
}
}
// TestCheckRuntimeUser covers task 5.4 (design D9, checks 5 to 11).
func TestCheckRuntimeUser(t *testing.T) {
for _, row := range runtimeRows() {
t.Run(row.name, row.run)
}
}
// checkRuntimeAs runs CheckRuntimeUser as role in q's transaction.
func checkRuntimeAs(q *dbtest.Q, role string) error {
asLogin(q, role)
return db.CheckRuntimeUser(context.Background(), q.Tx(), runtimeCheck())
}
func runtimePasses(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("mc_rt_")
memberOfRuntime(q, login)
wantRefusal(t, checkRuntimeAs(q, login), "")
}
func runtimeNoMember(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("Mc-Rt-")
q.Exec("CREATE ROLE " + ident(login))
want := fmt.Sprintf("db-dsn logs in as %s, which is not a member of console_runtime; "+
"as the migration user, run: GRANT console_runtime TO %s WITH INHERIT TRUE, SET FALSE;", login, ident(login))
wantRefusal(t, checkRuntimeAs(q, login), want)
}
func runtimeMemberWithoutInherit(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("mc_rt_")
q.Exec("CREATE ROLE " + login)
q.Exec("GRANT console_runtime TO " + login + " WITH INHERIT FALSE")
want := fmt.Sprintf("db-dsn logs in as %s, which is not a member of console_runtime; "+
"as the migration user, run: GRANT console_runtime TO %s WITH INHERIT TRUE, SET FALSE;", login, login)
wantRefusal(t, checkRuntimeAs(q, login), want)
}
func runtimeMemberWithSet(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("Mc-Rt-")
q.Exec("CREATE ROLE " + ident(login))
q.Exec("GRANT console_runtime TO " + ident(login) + " WITH INHERIT TRUE, SET TRUE")
grantor := q.String("SELECT pg_catalog.quote_ident(current_user)")
want := fmt.Sprintf("db-dsn logs in as %s, which can SET ROLE to console_runtime through a grant by %s; "+
"as the migration user, run: REVOKE console_runtime FROM %s GRANTED BY %s; "+
"GRANT console_runtime TO %s WITH INHERIT TRUE, SET FALSE;", login, grantor, ident(login), grantor, ident(login))
wantRefusal(t, checkRuntimeAs(q, login), want)
}
func runtimeMemberWithSetByAnotherGrantor(t *testing.T) {
q := checkTx(t)
login, grantor := dbtest.Name("mc_rt_"), dbtest.Name("Mc-Gr-")
q.Exec("CREATE ROLE " + ident(grantor))
q.Exec("GRANT console_runtime TO " + ident(grantor) + " WITH ADMIN TRUE")
q.Exec("CREATE ROLE " + login + " LOGIN")
q.Exec("GRANT console_runtime TO " + login + " WITH INHERIT TRUE, SET TRUE GRANTED BY " + ident(grantor))
want := fmt.Sprintf("db-dsn logs in as %s, which can SET ROLE to console_runtime through a grant by %s; "+
"as the migration user, run: REVOKE console_runtime FROM %s GRANTED BY %s; "+
"GRANT console_runtime TO %s WITH INHERIT TRUE, SET FALSE;", login, ident(grantor), login, ident(grantor), login)
err := checkRuntimeAs(q, login)
wantRefusal(t, err, want)
var refusal *db.RuntimeUserRefusal
if !errors.As(err, &refusal) {
return
}
q.Exec("RESET ROLE")
for _, statement := range strings.Split(strings.SplitN(refusal.Error(), "run: ", 2)[1], "; ") {
q.Exec(strings.TrimSuffix(statement, ";"))
}
wantRefusal(t, checkRuntimeAs(q, login), "")
}
func runtimeGroupRoleMissing(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("mc_rt_")
memberOfRuntime(q, login)
q.Exec("ALTER ROLE console_runtime RENAME TO " + dbtest.Name("mc_renamed_"))
const want = "the schema is behind this release (console_runtime does not exist); " +
"run member-console migrate up with db-migrate-dsn set, then start again"
wantRefusal(t, checkRuntimeAs(q, login), want)
}
func runtimeDirectOwnerRole(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("mc_rt_")
memberOfRuntime(q, login)
q.Exec("GRANT core_owner TO " + login)
want := fmt.Sprintf("db-dsn logs in as %s, a member of core_owner; as the migration user, run: REVOKE core_owner FROM %s;", login, login)
wantRefusal(t, checkRuntimeAs(q, login), want)
}
func runtimeOwnerRoleThroughRole(t *testing.T) {
q := checkTx(t)
login, via := dbtest.Name("mc_rt_"), dbtest.Name("Mc-Via-")
memberOfRuntime(q, login)
q.Exec("CREATE ROLE " + ident(via))
q.Exec("GRANT core_owner TO " + ident(via))
q.Exec("GRANT " + ident(via) + " TO " + login)
want := fmt.Sprintf("db-dsn logs in as %s, a member of core_owner; as the migration user, run: REVOKE %s FROM %s;",
login, ident(via), login)
wantRefusal(t, checkRuntimeAs(q, login), want)
}
func runtimeOwnerRoleWithoutInherit(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("mc_rt_")
memberOfRuntime(q, login)
q.Exec("GRANT core_owner TO " + login + " WITH INHERIT FALSE, SET TRUE")
want := fmt.Sprintf("db-dsn logs in as %s, a member of core_owner; as the migration user, run: REVOKE core_owner FROM %s;", login, login)
wantRefusal(t, checkRuntimeAs(q, login), want)
}
func runtimeOwnsTable(t *testing.T) {
q := checkTx(t)
login, table := dbtest.Name("mc_rt_"), dbtest.Name("mc_t_")
memberOfRuntime(q, login)
q.Exec("CREATE TABLE core." + table + " (id integer)")
q.Exec("ALTER TABLE core." + table + " OWNER TO " + login)
want := fmt.Sprintf("db-dsn logs in as %s, which owns relation core.%s; as the migration user, run: REASSIGN OWNED BY %s TO CURRENT_USER;",
login, table, login)
wantRefusal(t, checkRuntimeAs(q, login), want)
}
func runtimeOwnsCoreFunction(t *testing.T) {
q := checkTx(t)
login, kind, function := dbtest.Name("mc_rt_"), dbtest.Name("mc_ty_"), dbtest.Name("mc_f_")
memberOfRuntime(q, login)
q.Exec("CREATE TYPE core." + kind + " AS ENUM ('a')")
q.Exec("CREATE FUNCTION core." + function + "(core." + kind + ") RETURNS integer LANGUAGE sql AS 'SELECT 1'")
q.Exec("ALTER FUNCTION core." + function + "(core." + kind + ") OWNER TO " + login)
want := fmt.Sprintf("db-dsn logs in as %s, which owns function core.%s(core.%s); as the migration user, run: REASSIGN OWNED BY %s TO CURRENT_USER;",
login, function, kind, login)
wantRefusal(t, checkRuntimeAs(q, login), want)
}
func runtimeOwnsPublicFunction(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("mc_rt_")
memberOfRuntime(q, login)
function := publicFunction(q, login)
want := fmt.Sprintf("db-dsn logs in as %s, which owns function public.%s(); as the migration user, run: REASSIGN OWNED BY %s TO CURRENT_USER;",
login, function, login)
wantRefusal(t, checkRuntimeAs(q, login), want)
}
func runtimeMemberOfTableOwner(t *testing.T) {
q := checkTx(t)
login, owner, table := dbtest.Name("mc_rt_"), dbtest.Name("mc_tbl_owner_"), dbtest.Name("mc_t_")
memberOfRuntime(q, login)
q.Exec("CREATE ROLE " + owner)
q.Exec("CREATE TABLE core." + table + " (id integer)")
q.Exec("ALTER TABLE core." + table + " OWNER TO " + owner)
q.Exec("GRANT " + owner + " TO " + login)
want := fmt.Sprintf("db-dsn logs in as %s, a member of %s, which owns relation core.%s; as the migration user, run: REVOKE %s FROM %s;",
login, owner, table, owner, login)
wantRefusal(t, checkRuntimeAs(q, login), want)
}
// tableInCore creates a table in core that the superuser owns and returns its
// name.
func tableInCore(q *dbtest.Q) string {
table := dbtest.Name("mc_t_")
q.Exec("CREATE TABLE core." + table + " (id integer)")
return table
}
func runtimeTriggerGrant(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("mc_rt_")
memberOfRuntime(q, login)
table := tableInCore(q)
q.Exec("GRANT TRIGGER ON core." + table + " TO " + login)
want := fmt.Sprintf("db-dsn logs in as %s, which holds TRIGGER on core.%s through a grant to %s; "+
"as the migration user, run: REVOKE TRIGGER ON core.%s FROM %s;", login, table, login, table, login)
wantRefusal(t, checkRuntimeAs(q, login), want)
}
func runtimeTruncateThroughReader(t *testing.T) {
q := checkTx(t)
login, owner := dbtest.Name("mc_rt_"), dbtest.Name("a_mc_owner_")
memberOfRuntime(q, login)
table := tableInCore(q)
q.Exec("CREATE ROLE " + owner)
q.Exec("ALTER TABLE core." + table + " OWNER TO " + owner)
q.Exec("GRANT TRUNCATE ON core." + table + " TO core_reader")
want := fmt.Sprintf("db-dsn logs in as %s, which holds TRUNCATE on core.%s through a grant to core_reader; "+
"as the migration user, run: REVOKE TRUNCATE ON core.%s FROM core_reader;", login, table, table)
wantRefusal(t, checkRuntimeAs(q, login), want)
}
// firstRelation is the first relation of the five schemas by name, which is
// the one the check names when every relation grants the privilege.
func firstRelation(q *dbtest.Q) string {
return q.String(`SELECT format('%I.%I', 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 1 LIMIT 1`, runtimeCheck().Streams())
}
func runtimeMaintainThroughPredefinedRole(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("mc_rt_")
memberOfRuntime(q, login)
q.Exec("GRANT pg_maintain TO console_runtime")
relation := firstRelation(q)
want := fmt.Sprintf("db-dsn logs in as %s, which holds MAINTAIN on %s through pg_maintain, granted to console_runtime; "+
"as the migration user, run: REVOKE pg_maintain FROM console_runtime;", login, relation)
wantRefusal(t, checkRuntimeAs(q, login), want)
}
func runtimeUnrelatedRole(t *testing.T) {
q := checkTx(t)
login, extra := dbtest.Name("mc_rt_"), dbtest.Name("Mc-Extra-")
memberOfRuntime(q, login)
q.Exec("CREATE ROLE " + ident(extra))
q.Exec("GRANT " + ident(extra) + " TO " + login)
want := fmt.Sprintf("db-dsn logs in as %s, a direct member of %s; as the migration user, run: REVOKE %s FROM %s;",
login, ident(extra), ident(extra), login)
wantRefusal(t, checkRuntimeAs(q, login), want)
}
func predefinedRoleRow(role string) func(t *testing.T) {
return func(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("mc_rt_")
memberOfRuntime(q, login)
q.Exec("GRANT " + role + " TO " + login)
want := fmt.Sprintf("db-dsn logs in as %s, a direct member of %s; as the migration user, run: REVOKE %s FROM %s;",
login, role, role, login)
wantRefusal(t, checkRuntimeAs(q, login), want)
}
}
// runtimeInsertGrant grants INSERT on core.pool_provisions to the user, or to
// PUBLIC when grantee is "PUBLIC".
func runtimeInsertGrant(grantee string) func(t *testing.T) {
return func(t *testing.T) {
q := checkTx(t)
login := dbtest.Name("mc_rt_")
memberOfRuntime(q, login)
if grantee == "" {
grantee = login
}
q.Exec("GRANT INSERT ON core.pool_provisions TO " + grantee)
want := fmt.Sprintf("db-dsn logs in as %s, which holds INSERT on core.pool_provisions through a grant to %s; "+
"as the migration user, run: REVOKE INSERT ON core.pool_provisions FROM %s;", login, grantee, grantee)
wantRefusal(t, checkRuntimeAs(q, login), want)
}
}
func runtimeClosedDatabase(t *testing.T) {
err := db.CheckRuntimeUser(context.Background(), dbtest.Closed(t), runtimeCheck())
wantRefusal(t, err, "could not check the db-dsn user: sql: database is closed")
}
@@ -58,7 +58,7 @@ The process that migrates at startup holds the migration password for as long as
### 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 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`, in a schema of the five streams or in the database, or owns an object the migrations' names resolve to in them or in the database (check 12, which includes check 9's query). `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 qualification covers functions and relations. It cannot cover operators, because an operator is written between its operands and the queries compare an `oid` with an integer literal (`a.grantee = 0`) and a `name` with a `text` array element (`n.nspname = ANY($2)`), for neither of which `pg_catalog` has an exact-type operator; a planted `public.=(oid, integer)` is an exact match, wins whatever the path order, and its function runs as the session's user inside the check's own query, before the check can refuse. Qualifying each operator as `OPERATOR(pg_catalog.=)` would work but leaves a scan that must parse every operator, so operators are closed by the second line:
@@ -69,7 +69,7 @@ The database-ownership check also protects the migration run itself, and four ru
1. **Until the runtime half of check 4 has passed, the migration connection runs one query, with no unqualified function and no unqualified relation, in a transaction that begins with the search-path pin:** `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.
4. **The migration run waits for check 12.** A runtime user that holds `CREATE` on `public`, on a schema of the five streams or on the database, or owns a function, operator, type, operator class, operator family or extension there, can plant what the boot grant or an unqualified name or operator in a pending migration would resolve to and 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`
@@ -168,7 +168,7 @@ SET LOCAL search_path = pg_catalog, pg_temp;
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',
EXECUTE pg_catalog.format('GRANT console_runtime TO %I WITH INHERIT TRUE, SET FALSE',
pg_catalog.current_setting('member_console.runtime_user'));
END
$$;
@@ -179,7 +179,8 @@ $$;
- **The transaction begins with the search-path pin** (D2): the migration connection's path is `public`, and the pin removes `public` from it for these statements, so nothing a role planted in `public`, an operator included, is a candidate for any name in them.
- **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.
- **`SET FALSE`** because the runtime user needs to inherit `console_runtime`, not to switch to it. With the default `SET TRUE` the user can `SET ROLE` to `console_runtime` and to any role that `console_runtime` holds with `SET`, inherited or not. Nothing the checks read counts that route: `has_table_privilege` and the grantee filter of check 11 follow inheritance, and check 10 reads direct memberships. A role granted to `console_runtime` with `INHERIT FALSE, SET TRUE` that can insert into `core.pool_provisions` is reachable by `SET ROLE` and invisible to checks 10 and 11 (a probe on Postgres 18.1: the insert succeeded after `SET ROLE`). With `SET FALSE` on the user's one direct edge, no `SET ROLE` chain starts below it. A grant that exists from the same grantor is updated in place (probed: `set_option` went from true to false and inheritance stayed), so a deployment whose membership predates this change is corrected at its next start. Check 6 refuses a membership that carries `SET`, which covers a separate-step deployment whose operator made the grant by hand.
- **It runs at every start.** Granting a membership that exists from the same grantor with the same options 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
@@ -193,29 +194,30 @@ The check refuses the start on the first failing condition, in this order, which
| 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 |
| 6 | The runtime user does not inherit `console_runtime` (`pg_has_role(current_user, 'console_runtime', 'USAGE')`), or a direct membership of it carries the `SET` option (`pg_auth_members.set_option`) | runtime | after migrating, and after the boot grant | 5, 5a |
| 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 |
| 9 | The runtime user is, or is a member of, the owner of a schema of the five streams or `public`, of a relation, function, operator or type in them, or of an operator class, operator family or extension of the database | runtime | after migrating, and in full before migrating, in check 12 (see the bullet) | 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 |
| 12 | The runtime user holds `CREATE` on `public`, on a schema of the five streams (ACL entries) or on the current database (ACL entries), or owns, or is a member of a role that owns, any object check 9 reads (the whole 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, 3f, 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 6 refuses a membership that carries `SET`.** The user needs to inherit `console_runtime` and nothing else (D8): with `SET`, it can `SET ROLE` to `console_runtime` and to every role `console_runtime` holds with `SET`, including one it holds with `INHERIT FALSE`, which the privilege checks do not count. The check runs after the inheritance test, so a membership without `INHERIT` is copy 5 first. It reads `pg_auth_members.set_option` of the rows whose member is the user and whose role is `console_runtime`, from any grantor, because `SET ROLE` works if any row carries the option. Memberships beyond the first edge are not reachable: check 10 refuses any other direct membership, and the boot grant's `SET FALSE` closes the first. The check reads the grantor of the first such row (ordered by the quoted grantor name) and copy 5a prints a `REVOKE ... GRANTED BY <grantor>` before the `GRANT`, because the `GRANT` alone updates the row of its own grantor and leaves a row another grantor made carrying `SET` (failure row 69).
- **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 9 covers every object a name or an operator in a migration could resolve to, and runs in full before the migrations.** 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. Its kinds, in the five schemas and `public`: schemas, relations (tables, views, sequences, indexes, composite types), functions and procedures, operators (`pg_operator.oprowner`) and types that are not composite or array (`pg_type.typowner`: enum, domain, range). A planted function or operator whose operand types match exactly wins an unqualified call whatever the path order, and a planted type or relation can take a name the migration expects to create or find. In the whole database, because Postgres chooses a default operator class by access method and type and not by name, so the search path does not limit it: operator classes (`opcowner`), operator families (`opfowner`) and extensions (`extowner`). The extension is the concrete route: core migration 00001 runs `CREATE EXTENSION IF NOT EXISTS btree_gist`, `btree_gist` is a trusted extension, and a login with `CREATE` on the database can create it first; the script runs as the bootstrap superuser and the login owns only the extension row (probed on Postgres 18.1: none of the extension's operators belonged to it), so the migration's `IF NOT EXISTS` skips and the migration's GiST indexes use the login's extension. Left out, with the reason: collations, conversions and text search configurations and dictionaries resolve by the first match on the path with `pg_catalog` searched first, and no migration names one outside `pg_catalog` (`COLLATE "C"` is the only use); a cast and a trigger have no owner, a cast needs ownership of a type it converts, and a trigger runs a function, both of which are listed. The full run happens before the migrations, in check 12, on the migration connection (the runtime connection in the separate-step mode), because a planted object of any listed kind can run in the migration run itself. The run after the migrations stays: a fresh database has none of the stream objects before the run, the separate-step mode has no migration connection to run it on first, and a membership granted while the run executed is read only afterwards. `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.
- **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`, in a schema of the five streams or in the database, or that owns an object check 9 reads, can place what wins an unqualified call or operator, or what a migration finds instead of creating. It runs three queries in one transaction, by qualified names and with the runtime login as a parameter: the first schema of `public` and the five on which that login holds `CREATE`; the database's `CREATE` entry (the third query below); and check 9's query over `public` and the five schemas. All 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. `CREATE` on the database matters because the holder creates a schema of any name: a schema named like the migration user is first on the migration user's default path (`"$user", public`) and is where the manual runs as the migration user (`scripts/db-reader-role.sql` after every deploy, D17 step 5's queries) resolve a name first, and a schema named like a stream the first migration has not created yet becomes that stream's schema, owned by the login (probed on Postgres 18.1: after `GRANT CREATE ON DATABASE`, the login created schema `boot` holding `boot.quote_ident(name)`, and a superuser session on the default path ran it). The queries read ACL entries, not `has_schema_privilege`, for two reasons: they name 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 they test 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 owner's own entry is left out for the schemas and the database: 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 of these transactions begins with `SET LOCAL search_path = pg_catalog, pg_temp` (D2, the search-path pin), so that a planted operator or function is not a candidate for any name in a query, the superuser migration connection's queries in particular; on the runtime connection the pin also makes `regprocedure::text` print schema-qualified names, which copies 7 and 8 show. Each query is a Go string constant whose name begins `query`; the structural test of the qualification principle (tasks 5.8) reads those constants.
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, three queries in one transaction) run on that pool, each in a read-only transaction of its own, before the migrations. Each of these transactions begins with `SET LOCAL search_path = pg_catalog, pg_temp` (D2, the search-path pin), so that a planted operator or function is not a candidate for any name in a query, the superuser migration connection's queries in particular; on the runtime connection the pin also makes `regprocedure::text` print schema-qualified names, which copies 7 and 8 show. 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
-- every transaction below, on both connections, begins with the pin (D2) before its first query:
@@ -269,9 +271,30 @@ WHERE n.nspname = ANY($2)
ORDER BY 1, a.grantee <> 0, 2
LIMIT 1;
-- 12, the database half, same connection and parameters as the schema query ($1 the runtime login):
-- the grantee of the ACL entry that lets the login create schemas in the current database (PUBLIC first)
SELECT pg_catalog.quote_ident(d.datname),
CASE WHEN a.grantee = 0 THEN 'PUBLIC'
ELSE pg_catalog.quote_ident(pg_catalog.pg_get_userbyid(a.grantee)) END
FROM pg_catalog.pg_database d
CROSS JOIN LATERAL pg_catalog.aclexplode(COALESCE(d.datacl, pg_catalog.acldefault('d', d.datdba))) a
WHERE d.datname = pg_catalog.current_database()
AND a.privilege_type = 'CREATE'
AND a.grantee <> d.datdba
AND (a.grantee = 0 OR pg_catalog.pg_has_role($1::name, a.grantee, 'MEMBER'))
ORDER BY 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');
-- 6, the SET option: the quoted grantor of the first direct membership of console_runtime that carries it; no row when none does
SELECT pg_catalog.quote_ident(pg_catalog.pg_get_userbyid(m.grantor))
FROM pg_catalog.pg_auth_members m JOIN pg_catalog.pg_roles g ON g.oid = m.roleid
WHERE g.rolname = 'console_runtime'
AND m.member = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user)
AND m.set_option
ORDER BY 1 LIMIT 1;
-- 8: the first owner role the user can reach, and the direct grant that leads to it
SELECT o.rolname,
@@ -284,9 +307,9 @@ 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 (
-- 9, and the ownership half of 12: the first object the user owns or can act as the owner of
-- $1 the schema names (the five and public, before and after migrating), $2 the login
SELECT kind, name, owner, pg_catalog.pg_get_userbyid(owner), pg_catalog.quote_ident($2::name) 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
@@ -297,6 +320,23 @@ SELECT kind, name, owner, pg_catalog.pg_get_userbyid(owner) FROM (
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)
UNION ALL
SELECT 'operator', o.oid::regoperator::text, o.oprowner
FROM pg_catalog.pg_operator o JOIN pg_catalog.pg_namespace n ON n.oid = o.oprnamespace
WHERE n.nspname = ANY($1)
UNION ALL
SELECT 'type', pg_catalog.format('%I.%I', n.nspname, t.typname), t.typowner
FROM pg_catalog.pg_type t JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname = ANY($1) AND t.typrelid = 0 AND t.typcategory <> 'A'
UNION ALL -- the next three: the whole database, no schema filter
SELECT 'operator class', pg_catalog.format('%I.%I', n.nspname, c.opcname), c.opcowner
FROM pg_catalog.pg_opclass c JOIN pg_catalog.pg_namespace n ON n.oid = c.opcnamespace
UNION ALL
SELECT 'operator family', pg_catalog.format('%I.%I', n.nspname, f.opfname), f.opfowner
FROM pg_catalog.pg_opfamily f JOIN pg_catalog.pg_namespace n ON n.oid = f.opfnamespace
UNION ALL
SELECT 'extension', pg_catalog.quote_ident(e.extname), e.extowner
FROM pg_catalog.pg_extension e
) o
WHERE pg_catalog.pg_has_role($2::name, o.owner, 'MEMBER')
ORDER BY kind, name LIMIT 1;
@@ -352,9 +392,9 @@ WHERE g.rolname = CASE $1 WHEN 'INSERT' THEN 'pg_write_all_data' WHEN 'MAINTAIN'
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.
`$1` is `public` and the five stream names for check 9, in check 12 and after the migrations alike, 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), and the fifth column is that login quoted, which copy 7 and 8 print. `$1` of the database query of check 12 is the runtime login. 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.
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, 3f, 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
@@ -516,19 +556,19 @@ With `db-migrate-dsn` set:
| 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 |
| 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`, a stream schema or the database, or owns an object check 9 reads in the five schemas, `public` or the database (check 12); run every stream's migrations | exit 1 with copy 3d, 3e, 3f, 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 with `INHERIT TRUE, SET FALSE` (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 |
| 8 | runtime | checks 5 to 11 | exit 1 with copy 4 to 9, 5a, 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 |
Every transaction of steps 4, 5, 6 and 8 (the identity reads, the owner half of check 4, check 12, the boot grant, and checks 5 to 11 with the pending check) begins with `SET LOCAL search_path = pg_catalog, pg_temp` (D2, the search-path pin). The migrations of step 5 do not: they run with the pool's `search_path=public`.
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.
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, 3f, 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; rows 63 and 64 by the fourth.
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; rows 63 and 64 by the fourth; rows 65 to 69 by the review of section 5.
| # | What breaks | Evidence | When it shows | Handling |
|---|---|---|---|---|
@@ -587,15 +627,20 @@ Every way the switch could break something, the evidence, and how this design ha
| 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 |
| 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 (a run of the function would raise and make the result copy 11) |
| 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 |
| 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 raise from the planted function |
| 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 |
| 63 | A runtime user that holds `CREATE` on `public` plants `public.=(oid, integer)` (or `=(name, text)`); check 12's own query, `a.grantee = 0` and `n.nspname = ANY($2)`, then runs the planted function as the superuser migration user before check 12 can refuse; qualification cannot prevent it, because `pg_catalog` has no operator for those operand types and the planted one is an exact match | a probe on Postgres 18.1: the planted operator ran as the superuser inside check 12's query, and with the pin it did not fire and the row was still returned | the configuration check 12 exists to refuse, at the start that checks it | D2, the search-path pin: every check and boot-grant transaction on both connections begins with `SET LOCAL search_path = pg_catalog, pg_temp`; `TestOpenRuntimeDatabase` and `TestCheckBeforeMigrating` plant the operator and expect no marker row and the normal refusal |
| 63 | A runtime user that holds `CREATE` on `public` plants `public.=(oid, integer)` (or `=(name, text)`); check 12's own query, `a.grantee = 0` and `n.nspname = ANY($2)`, then runs the planted function as the superuser migration user before check 12 can refuse; qualification cannot prevent it, because `pg_catalog` has no operator for those operand types and the planted one is an exact match | a probe on Postgres 18.1: the planted operator ran as the superuser inside check 12's query, and with the pin it did not fire and the row was still returned | the configuration check 12 exists to refuse, at the start that checks it | D2, the search-path pin: every check and boot-grant transaction on both connections begins with `SET LOCAL search_path = pg_catalog, pg_temp`; `TestOpenRuntimeDatabase` and `TestCheckBeforeMigrating` plant the operator and expect that the planted function never raises and the normal refusal |
| 64 | The second query of checks 9 and 12 cast the login with `regrole`, which parses the value as an identifier: for a login such as `Mc-Rt` it fails with `role "mc-rt" does not exist`, and the refusal that should print copy 8 (or the object half of copy 3e) prints copy 11 and loses the `REVOKE` | a probe on Postgres 18.1 | any runtime login whose name needs quoting, on the path that already refuses | D9: the login is looked up with `pg_catalog.pg_roles` by `rolname = $2::name`; a mixed-case login row in `TestCheckRuntimeUser` and `TestCheckBeforeMigrating` |
| 65 | A role that `console_runtime` holds with `INHERIT FALSE, SET TRUE` and that can write enclosed tables is reachable by `SET ROLE` from every runtime user, and checks 10 and 11 read inheritance and direct memberships only; the same holds for `pg_write_all_data` or `pg_maintain` granted to `console_runtime` that way | a probe on Postgres 18.1: `has_table_privilege` false, check 10's query empty, `SET ROLE` succeeded, the insert into `core.pool_provisions` succeeded | a host that grants a role to `console_runtime`, or a deployment whose membership predates this change | D8: the boot grant is `WITH INHERIT TRUE, SET FALSE`, which also updates an existing membership in place; check 6 refuses a membership that carries `SET` (copy 5a); `TestOpenRuntimeDatabase` runs `SET ROLE` as the runtime login on a real connection before and after the boot |
| 66 | A runtime user with `CREATE` on the database creates a schema named like the migration user, which is first on the migration user's default path, or like a stream the first migration has not created, and plants a function there; or creates the trusted extension `btree_gist` before core migration 00001 does, so that the migration's `IF NOT EXISTS` skips it and its GiST indexes use the login's operator classes | a probe on Postgres 18.1: after `GRANT CREATE ON DATABASE`, the login created schema `boot` with `boot.quote_ident(name)`, and a superuser session on the default path ran it, while check 12 read no row; `CREATE EXTENSION pg_trgm` as the login left it owning the extension only | a host's `GRANT CREATE ON DATABASE`, or a database that predates a restrictive default | D9 check 12: the database's `CREATE` entry is read before migrating, with the grantee named (copy 3f); check 9 reads extensions, operator classes and operator families of the whole database |
| 67 | An operator, type, operator class, operator family or extension that the runtime user owns changes what an unqualified operator or name in a migration resolves to; check 9 read relations, functions and schemas only, and in `public` only before migrating | a probe on Postgres 18.1: a planted `public.=(oid, integer)` owned by the login survived check 9 | a runtime user with a past `CREATE` grant, or a database restored from a dump | D9 check 9 reads those kinds (the bullet lists them and the three it leaves out, with the reason) and runs in full, over `public` and the five schemas, before migrating (check 12) and again after |
| 68 | A runtime user that owns a relation of a stream schema before a start with a pending migration can use it during the migration run | a probe on Postgres 18.1: the owner created a trigger whose function lived in its own `pg_temp`, and a superuser session that wrote the table ran it | a hand change by the host; every start that finishes refuses the ownership afterwards | D9 check 12 runs check 9's query over the five schemas before the migrations, so the ownership is refused before the run; a `TRIGGER` grant to the user on a relation is read after the migrations, by check 11 (the four-privilege read before migrating is not added) |
| 69 | A membership of `console_runtime` that another grantor made keeps its own `SET` option: the boot grant updates the row of its own grantor only, a superuser's grants being recorded under the bootstrap superuser | a probe on Postgres 18.1: after a second grantor's grant, two rows existed and the re-grant changed one | a migration user that is not a superuser, or one that changed between starts | check 6 refuses while any row carries `SET`; copy 5a names the grantor of the first such row and prints `REVOKE console_runtime FROM <user> GRANTED BY <grantor>` before the `GRANT`, so the printed statements remove that row; handled (a grantor that was dropped is not a case: Postgres refuses to drop a role that granted a membership) |
## Copy
@@ -615,12 +660,14 @@ Every sentence a person reads that this change adds or changes. The UI copy rule
| 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>;` |
| 3f | start, check 12, the user can create schemas in the database | `db-dsn logs in as <user>, which can create schemas in database <name> through a grant to <grantee>; as the migration user, run: REVOKE CREATE ON DATABASE <quoted name> 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;` |
| 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, SET FALSE;` |
| 5a | start, check 6, the membership carries SET | `db-dsn logs in as <user>, which can SET ROLE to console_runtime through a grant by <grantor>; as the migration user, run: REVOKE console_runtime FROM <quoted user> GRANTED BY <grantor>; GRANT console_runtime TO <quoted user> WITH INHERIT TRUE, SET FALSE;` (`<grantor>` quoted) |
| 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>;` |
@@ -628,7 +675,7 @@ Every sentence a person reads that this change adds or changes. The UI copy rule
| 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>` |
| 11 | start, a check query | `could not check the <dsn> user: <error>`, `<dsn>` being `db-migrate-dsn` when the failed query ran on the migration connection and `db-dsn` otherwise |
| 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)` |
@@ -687,8 +734,8 @@ Tests follow `docs/testing.md`. The tasks list each test with the breaks it must
- **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); the same `CREATE` row with `public.=(oid, integer)` planted by a role the test creates, whose function writes a marker row (recording `current_user`), expecting the normal copy 3e and no marker row; a member of a role that owns a function in `public`, the login named `Mc-Rt-<n>` so the name needs quoting, expecting copy 8 and not copy 11 (the second query of check 9 looks the login up by name); 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 the same login having planted `public.=(oid, integer)` with such a marker, copy 3e and no marker row (check 12's own query would otherwise run it as the migration user; the search-path pin prevents it); 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 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, dropped in the cleanup): a clean member of `console_runtime`, granted as the boot grant leaves it, passes both; a member of a superuser role; the owner of the database, and a member of the role that owns it; check 12, through `CheckSchemasBeforeMigrating` as the migration connection would run it: a login that holds `CREATE` on `public` through a grant to `PUBLIC`, through a role it can only `SET` (`Mc-Creator-<n>`) and on a schema of `core`; the same two grants on the database itself (copy 3f); the owner of a function in `public`, of a table in `core`, of an operator in `public`, of a type in `public`, of an operator class and of an operator family in a schema the test created, and of an extension the login created itself (`pg_trgm`, as a trusted extension, with its `CREATE` grants revoked again); the same `CREATE` row with `public.=(oid, integer)` planted by a role the test creates, whose function raises an exception that names `current_user`, expecting the normal copy 3e (a call of the function would turn the result into copy 11 with that text, in the savepoint a test runs a check in as well as in a read-only transaction, where a marker row would be rolled back or refused); a member of a role that owns a function in `public`, the login named `Mc-Rt-<n>` so the name needs quoting, expecting copy 8 and not copy 11 (the second query of check 9 looks the login up by name); not a member of `console_runtime` (a role name that needs quoting, so the copy's quoting shows); a member of it with `INHERIT TRUE, SET TRUE` (copy 5a); `console_runtime` renamed inside the transaction (copy 4a); 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). `TestGrantRuntimeRole` checks that the boot grant leaves `SET` false, also over a membership that exists with `SET TRUE`. 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. A structural test of the scan itself (`TestScanReadsEverySpelling`, `TestScanReadsConstants`) runs it on queries written with an apostrophe in a comment, upper-case and quoted names, quoted calls and dollar-quoted bodies, and on constants written as concatenations.
- **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)`, whose body raises an exception naming `current_user` and `is_superuser`, copy 3b and the ledger untouched (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 body, copy 3e; with the same login having planted `public.=(oid, integer)` with such a body, copy 3e (check 12's own query would otherwise run it as the migration user and the result would be copy 11 with the function's text; the search-path pin prevents it); with a runtime login that owns a function in `public` and holds no `CREATE`, copy 7 before any migration; with a runtime login that holds `CREATE` on the database, copy 3f, the ledger untouched and no membership granted; with a runtime login that owns a table in a `core` schema created ahead of the first migration, 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; with it left out on a membership that carries `SET`, copy 5a; with a membership that carries `SET` and a role granted to `console_runtime` with `INHERIT FALSE, SET TRUE` that can insert into `core.pool_provisions`, the login's `SET ROLE` to that role works on a real connection before the boot and fails with `42501` after it. `TestLogOpenFailure` covers copy 17 for a refusal, the owner refusal of copy 3d and any other error. 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 scan does not cover operators: the search-path pin does (D2), and the planted-operator rows of `TestCheckBeforeMigrating` and `TestOpenRuntimeDatabase` show it. The pin statement's own constant is listed with its reason, because `pg_temp` is a schema name and not a catalog relation.
- **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.
@@ -702,7 +749,7 @@ Tests follow `docs/testing.md`. The tasks list each test with the breaks it must
- **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.
- **The migrations themselves run with `search_path=public`, without the pin.** Their SQL is the shipped files, written for that path, and the pin would change what they create. What protects the run is the order: the database-ownership refusal and check 12 (no `CREATE` on `public` or a stream schema, no owned function in `public`) come before the first migration, so no role the console serves as can have planted an operator or function there. A role that is not the runtime user and can create objects in `public` is outside what the check can see, and is the host's own configuration.
- **The migrations themselves run with `search_path=public`, without the pin.** Their SQL is the shipped files, written for that path, and the pin would change what they create. What protects the run is the order: the database-ownership refusal and check 12 (no `CREATE` on `public`, a stream schema or the database, no owned object of the kinds check 9 reads) come before the first migration, so no role the console serves as can have planted an operator or function there. A role that is not the runtime user and can create objects in `public` is outside what the check can see, and is the host's own configuration.
- **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.
@@ -2,7 +2,7 @@
### 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. Every transaction in which the console runs a check query or the boot grant, on either connection, SHALL begin with `SET LOCAL search_path = pg_catalog, pg_temp`, so that no operator or function a role planted in another schema can be chosen for any name in it. 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.
`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, SET FALSE` (so that an existing membership that carries `SET` loses it), 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. Every transaction in which the console runs a check query or the boot grant, on either connection, SHALL begin with `SET LOCAL search_path = pg_catalog, pg_temp`, so that no operator or function a role planted in another schema can be chosen for any name in it. 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
@@ -47,7 +47,7 @@
#### Scenario: A planted operator does not run in a check
- **WHEN** `db-dsn` logs in as a user that holds `CREATE` on `public` and has created `public.=(oid, integer)`
- **THEN** `start` SHALL refuse with the refusal for `CREATE` on `public`, the operator SHALL NOT have run on either connection, and no migration SHALL have run
- **THEN** `start` SHALL refuse with the refusal for `CREATE` on `public`, the operator's function SHALL NOT have run on either connection (a run would raise and make the refusal a failed check), and no migration SHALL have run
#### Scenario: A query that names a function without its schema fails the build
@@ -77,7 +77,7 @@
### 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.
`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`, or holds a membership of it that carries the `SET` option; 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 `public`, of a relation, function, operator or type in them, or of an operator class, operator family or extension of the database; 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`, on a schema of the streams or on the database, or owns, or is a member of a role that owns, any object of the kinds listed above. The first four conditions (one user, one database, superuser, database ownership), the `CREATE` conditions and the ownership conditions SHALL be checked before any migration runs and before the boot grant; the ownership conditions again, and 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
@@ -114,7 +114,18 @@
#### 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
- **THEN** it SHALL refuse with the statement `GRANT console_runtime TO <user> WITH INHERIT TRUE, SET FALSE;`, the user's name quoted, to run as the migration user
#### Scenario: A membership that can SET ROLE is refused
- **WHEN** `start` runs without `db-migrate-dsn` and the runtime user's membership of `console_runtime` carries `SET`
- **THEN** it SHALL refuse with the statements `REVOKE console_runtime FROM <user> GRANTED BY <grantor>; GRANT console_runtime TO <user> WITH INHERIT TRUE, SET FALSE;`, the names quoted, to run as the migration user, naming `SET ROLE` and the grantor of the membership that carries it
#### Scenario: The boot grant removes SET from an existing membership
- **WHEN** `start` runs with `db-migrate-dsn` set, the runtime user's membership of `console_runtime` carries `SET`, and `console_runtime` holds a role with `INHERIT FALSE, SET TRUE` that can insert into `core.pool_provisions`
- **THEN** the runtime user SHALL be able to `SET ROLE` to that role before the start and SHALL NOT be able to after it, on a connection logged in as the runtime user
- **AND** the start SHALL succeed
#### Scenario: Membership of an owner role is refused
@@ -123,8 +134,14 @@
#### 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
- **WHEN** the runtime user owns a table, a view, a function, an operator or a type in one of the streams' schemas or in `public`, or an operator class, an operator family or an extension in the database, or owns the database, or is a member of a role that owns one of them
- **THEN** `start` SHALL refuse, naming the kind, the object and the statement that fixes it
#### Scenario: Ownership of a stream's object is refused before migrating
- **WHEN** the runtime user owns a relation in one of the streams' schemas and the database has pending migrations
- **THEN** `start` SHALL refuse before any migration runs, naming the relation and the statement `REASSIGN OWNED BY <user> TO CURRENT_USER;`
- **AND** no ledger SHALL change
#### Scenario: A direct membership beyond the group role is refused
@@ -149,6 +166,12 @@
- **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 can create schemas in the database is refused before migrating
- **WHEN** the runtime user holds `CREATE` on the database through a grant to itself, to `PUBLIC` or to a role it is a member of
- **THEN** `start` SHALL refuse before any migration runs, naming the database, the grantee and the statement `REVOKE CREATE ON DATABASE <database> FROM <grantee>;`, the names quoted
- **AND** no migration SHALL have run and no grant SHALL have been made
#### 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`
+26 -19
View File
@@ -142,34 +142,41 @@ This section's `make test` passes only after the documented stack respin of item
## 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`. Every transaction these functions open, on either connection (the identity reads, the owner half of check 4, check 12, `CheckRuntimeUser`'s), begins with `SET LOCAL search_path = pg_catalog, pg_temp`, a constant of its own, before its first query (design D2, the search-path pin). The second query of checks 9 and 12 looks the login up as `(SELECT r.oid FROM pg_catalog.pg_roles r WHERE r.rolname = $2::name)`, never with `regrole`. 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, with the login itself named `Mc-Rt-<n>` so that its name needs quoting (copy 8 and not copy 11, because the second query of check 9 looks the login up by name); a login holding `CREATE` on `public` through a grant to `PUBLIC` after a role the test creates has planted `public.=(oid, integer)`, whose function writes a marker row recording `current_user` into a table the test created, copy 3e and no marker row; 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 schema-qualified signature (the pin of design D2 makes `regprocedure::text` qualify it); 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; the same login having created `public.=(oid, integer)` 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);
- [x] 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, the `CREATE` query over the current database (the ACL entries of `pg_database`, the grantee named, the owner's own entry left out), then check 9's whole query over `public` and the five schemas with the login as a parameter, and returns a `*RuntimeUserRefusal` with copy 3e, 3f, 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, 5a, 8a and 9a, or copy 11; check 6 refuses a user that does not inherit `console_runtime` (copy 5) and then a user whose direct membership of it carries `SET` (copy 5a, `pg_auth_members.set_option` of any grantor's row). `GrantRuntimeRole` grants `WITH INHERIT TRUE, SET FALSE`, and copy 5 and 5a print that statement. Check 9's query reads the schemas, relations, functions, operators and types of the schemas it is given, and the operator classes, operator families and extensions of the whole database (design D9 names the kinds it leaves out, with the reason). `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`. Every transaction these functions open, on either connection (the identity reads, the owner half of check 4, check 12, `CheckRuntimeUser`'s), begins with `SET LOCAL search_path = pg_catalog, pg_temp`, a constant of its own, before its first query (design D2, the search-path pin). The second query of checks 9 and 12 looks the login up as `(SELECT r.oid FROM pg_catalog.pg_roles r WHERE r.rolname = $2::name)`, never with `regrole`. 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.
- [x] 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, 3f, 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`.
- [x] 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, with the login itself named `Mc-Rt-<n>` so that its name needs quoting (copy 8 and not copy 11, because the second query of check 9 looks the login up by name); a login holding `CREATE` on `public` through a grant to `PUBLIC` after a role the test creates has planted `public.=(oid, integer)`, whose function raises an exception naming `current_user`, copy 3e (a marker row would be rolled back with the savepoint a test runs a check in and refused in a read-only transaction, so the planted function raises instead: an exception shows in both paths and turns the result into copy 11 carrying its text); `GRANT CREATE ON DATABASE` to `PUBLIC`, and to `Mc-Creator-<n>` of which the login is a member with `INHERIT FALSE, SET TRUE`, copy 3f naming the database and the grantee, quoted; the owner of a table in `core`, copy 7 naming `core.<table>`; the owner of an operator in `public` (`public.@#@(integer,integer)`), of a type in `public`, of an operator class and of an operator family in a schema the test creates, and of an extension the login creates itself (`pg_trgm`, a trusted extension, with the login's `CREATE` grants revoked afterwards), copy 7 naming the kind; 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.
- [x] 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` granted `WITH INHERIT TRUE, SET FALSE` 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 member granted `WITH INHERIT TRUE, SET TRUE`, copy 5a naming the bootstrap grantor; a member whose `SET` membership a role named `Mc-Gr-<n>` (holding `console_runtime` `WITH ADMIN`) granted, copy 5a naming that grantor, after which the two printed statements run as the test's superuser connection and the check passes; `console_runtime` renamed inside the transaction, copy 4 with 4a (`console_runtime does not exist`); 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 schema-qualified signature (the pin of design D2 makes `regprocedure::text` qualify it); 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 naming `db-dsn`; `TestCheckMigrationDatabaseOwner` and a row for the migration identity read on a closed database, copy 11 naming `db-migrate-dsn`. Every row but the passing one asserts the whole sentence. `TestGrantRuntimeRole` (section 4's test) also asserts that the membership carries no `SET` after the first and the second grant, and after a grant over a membership created `WITH INHERIT TRUE, SET TRUE`.
- [x] 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)`, whose body raises an exception naming `current_user` and `is_superuser`, copy 3b and the ledgers untouched, because neither connection runs the function (a run would make the result copy 11 carrying that text, in whichever transaction it ran); a runtime login that holds `CREATE` on `public` and has created `public.format(text, text)` with such a body, copy 3e and the ledgers untouched; the same login having created `public.=(oid, integer)` with such a body, copy 3e and the ledgers untouched; a runtime login that holds `CREATE` on the database, copy 3f, the ledgers untouched and no membership granted; a runtime login that owns a table in a `core` schema created ahead of the first migration, copy 7, the ledgers untouched and no membership granted; `db-migrate-dsn` left out on a membership that carries `SET`, copy 5a; a membership that carries `SET` on a database where `console_runtime` holds a role with `INHERIT FALSE, SET TRUE` that can insert into `core.pool_provisions`, `SET ROLE` to that role working on a connection logged in as the runtime login before the start and failing with `42501` after it; 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` (section 4's `migrate up` row included). `TestLogOpenFailure` asserts copy 17 for a runtime user refusal, a database owner refusal (wrapped or not) and any other error. 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.
- [x] 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.
- [x] 5.7 Breaks:
- checks 3a to 3c before checks 1 to 3: caught by the superuser row of `TestOpenRuntimeDatabase` (a superuser is a member of every role, so copy 3a prints in place of copy 3). The order of 3a before 3b and 3c inside `CheckBeforeMigrating` has no break: copy 3 refuses a superuser first, so `cmd` cannot observe it;
- 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 boot grant before the superuser check (granted only to a login the test created): 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 identity reads outside the pinned read-only transaction, with an unqualified `quote_ident` (the migration pool's, the runtime pool's): caught by the planted `quote_ident` row of `TestOpenRuntimeDatabase`, whose function raises, so the result shows its text. With the pin kept, an unqualified `quote_ident(` is not caught by that row (`public` is not on the pinned path) and only the scan catches it; the planted-function rows show the qualification only when the pin is gone as well, and the scan is its guard, as design D2 says;
- the search-path pin alone removed from the pinned read-only transaction: caught by the planted-operator row and the owned-function row of `TestOpenRuntimeDatabase`; from the savepoint a test runs a check in: caught by the planted-operator row and the owned-function rows of `TestCheckBeforeMigrating` and `TestCheckRuntimeUser`. The read-only mode and the `REPEATABLE READ` level of that transaction have no break: neither changes a result while the pin holds, so no test can observe them;
- 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's schema 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 database query likewise with `has_database_privilege`: caught by the database `Mc-Creator` row;
- check 12's schema `CREATE` query removed: caught by the `CREATE` row of `TestCheckBeforeMigrating` and the `public.format` row of `TestOpenRuntimeDatabase`; the database `CREATE` query removed: caught by the database rows of both tests;
- check 12's ownership query removed: caught by the owned-function rows of both tests; limited to `public`: caught by the owned-table row of `TestCheckBeforeMigrating` and the owned-table row of `TestOpenRuntimeDatabase`;
- check 12's queries read outside the pinned read-only transaction (not the pin alone): caught by the planted-operator rows of both tests;
- check 12 run after the migrations: caught by the `public.format` and owned-function rows' ledger assertions;
- check 9 without functions, operators, types, operator classes, operator families or extensions: caught by the row for that kind in `TestCheckBeforeMigrating` (and, for functions, `TestCheckRuntimeUser`);
- the boot grant's `pg_catalog.format` written `format`: caught by the scan of item 5.8;
- a check transaction (check 12's, on the migration connection) without its `SET LOCAL search_path = pg_catalog, pg_temp`: caught by the planted-operator rows of `TestCheckBeforeMigrating` and `TestOpenRuntimeDatabase` (a marker row is written);
- the boot grant without `SET FALSE`: caught by `TestGrantRuntimeRole` (the membership carries `SET`) and by `TestOpenRuntimeDatabase` (check 6 then refuses the start with copy 5a);
- check 5 removed: caught by the renamed-role row of `TestCheckRuntimeUser` (copy 11 prints in place of copy 4);
- copy 5a printing only the `GRANT` (no `REVOKE ... GRANTED BY <grantor>`): caught by the other-grantor row of `TestCheckRuntimeUser`, whose re-check after running the printed statements still refuses;
- copy 11 naming `db-dsn` for a failed read on the migration connection: caught by the closed-database rows of `TestCheckMigrationDatabaseOwner` and `TestCheckRuntimeUser`;
- check 6 not reading the `SET` option: caught by the `SET TRUE` row of `TestCheckRuntimeUser` and the separate-step row of `TestOpenRuntimeDatabase`;
- `CheckRuntimeUser`'s transaction without the pin: caught by the function rows of `TestCheckRuntimeUser`, whose copy 7 asserts the schema-qualified signature (`core.<name>(core.<type>)`, not `<name>(<type>)`);
- the second query of check 9 or 12 casting the login with `regrole`: caught by the mixed-case `Mc-Rt-<n>` indirect-owner row of `TestCheckBeforeMigrating` (copy 11 in place of copy 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;
- `logOpenFailure` not logging a `*DatabaseOwnerRefusal` as a refusal: caught by `TestLogOpenFailure`;
- 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`;
@@ -179,7 +186,6 @@ This section's `make test` passes only after the documented stack respin of item
- 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`;
@@ -187,15 +193,16 @@ This section's `make test` passes only after the documented stack respin of item
- 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;
- copy 5 quotes the name in Go (`%q`): caught by the rows with a lowercase login (Go's `%q` and `quote_ident` print the same for a name that needs quoting, so the mixed-case rows cannot tell them apart);
- 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 scan does not look at operators; the search-path pin covers them (design D2), and the planted-operator rows of items 5.3 and 5.5 show it. The pin's own constant (`SET LOCAL search_path = pg_catalog, pg_temp`) is an exception listed with its reason: `pg_temp` is a schema name, not a catalog relation. 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.
- the queries' constants renamed so that none begins `query` (a test-side record): caught by the scan's own empty-collection failure;
- the scan's own spellings (test-side records): a case-sensitive `pg_` test, a quoted call read as no call, a line comment not read as one, a concatenated constant skipped and a constant without a value skipped: each caught by `TestScanReadsEverySpelling` or `TestScanReadsConstants`.
- [x] 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), read as a literal, a parenthesized literal or a concatenation of literals, and fail for a `query` constant whose value is none of those or is missing (an implicit repetition). Read each with one left-to-right lexer that skips string literals (with `''`), `--` comments and nested `/* */` comments, reads a quoted identifier as one token, reads dollar-quoted bodies as code and skips parameters, and fail for each function call (an identifier followed by `(`) and each catalog relation (an identifier whose lower-cased name begins `pg_`) that `pg_catalog.` does not precede, with the qualifier and the name compared case-insensitively and quoted or not, naming the query's constant and the token as written. 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 (unquoted only); an identifier directly after `AS` is an alias with a column list (`AS p(priv)`). The scan does not look at operators; the search-path pin covers them (design D2), and the planted-operator rows of items 5.3 and 5.5 show it. The pin's own constant (`SET LOCAL search_path = pg_catalog, pg_temp`) is an exception listed with its reason: `pg_temp` is a schema name, not a catalog relation. The test also fails when it finds no `query` constant in either file, so a rename cannot empty it. `TestScanReadsEverySpelling` and `TestScanReadsConstants` test the scan itself on queries written with an apostrophe in a line comment and in a nested block comment, an upper-case and a quoted catalog relation, a quoted call, a quoted and an upper-case qualifier, another schema's qualifier, a comment marker and a name inside a literal, a doubled quote inside a literal, dollar-quoted bodies, parameters, syntax words and an alias with a column list, and on constants written as a concatenation, an `iota` and an implicit repetition. The breaks in item 5.7 name what the scan must catch.
## 6. Deployment docs and the procedure for an existing deployment