|
| 1 | +// Package provision creates per-project data isolation on the shared engines |
| 2 | +// (spec 03, ARCHITECTURE §5): an idempotent, existence-guarded Postgres role + |
| 3 | +// database per project (NOT initdb.d, which only runs on a first-init empty |
| 4 | +// PGDATA). The SQL logic sits behind the Conn interface so it is unit-testable |
| 5 | +// without a live server; the pgx-backed Conn (pgx.go) is the real path. |
| 6 | +// |
| 7 | +// All provisioning mutations happen while the caller holds the machine-global |
| 8 | +// flock (CREATE ROLE / CREATE DATABASE race otherwise — DECISIONS D7/D8). |
| 9 | +package provision |
| 10 | + |
| 11 | +import ( |
| 12 | + "context" |
| 13 | + "fmt" |
| 14 | + "strings" |
| 15 | +) |
| 16 | + |
| 17 | +// Conn is the minimal Postgres surface the provisioner needs. |
| 18 | +type Conn interface { |
| 19 | + // Exec runs a statement (DDL/GRANT). CREATE DATABASE cannot run in a |
| 20 | + // transaction, so implementations must execute statements unwrapped. |
| 21 | + Exec(ctx context.Context, sql string, args ...any) error |
| 22 | + // Exists reports whether a guard query (e.g. SELECT 1 FROM pg_roles ...) |
| 23 | + // returns at least one row. |
| 24 | + Exists(ctx context.Context, sql string, args ...any) (bool, error) |
| 25 | +} |
| 26 | + |
| 27 | +// Credentials is the per-project Postgres connection identity, returned so the |
| 28 | +// orchestrator can inject it into the consuming service's env (the password is a |
| 29 | +// secret — passed via exec env, never written to a generated file, §7.5). |
| 30 | +type Credentials struct { |
| 31 | + Role string |
| 32 | + Database string |
| 33 | + Password string |
| 34 | +} |
| 35 | + |
| 36 | +// Postgres provisions per-project roles and databases on a shared Postgres. |
| 37 | +type Postgres struct{} |
| 38 | + |
| 39 | +// EnsureProject idempotently ensures a login role and an owned database exist for |
| 40 | +// project, with password kept in sync, and locks down PUBLIC so each role sees |
| 41 | +// only its own database. Existence-guarded because CREATE ROLE / CREATE DATABASE |
| 42 | +// are not idempotent (DECISIONS D8). Returns the resolved credentials. |
| 43 | +func (Postgres) EnsureProject(ctx context.Context, conn Conn, project, password string) (Credentials, error) { |
| 44 | + role := pgIdent(project) |
| 45 | + db := role // per-project database shares the role's name |
| 46 | + |
| 47 | + // 1. Role — create or keep its password in sync. |
| 48 | + roleExists, err := conn.Exists(ctx, `SELECT 1 FROM pg_roles WHERE rolname = $1`, role) |
| 49 | + if err != nil { |
| 50 | + return Credentials{}, fmt.Errorf("check role %q: %w", role, err) |
| 51 | + } |
| 52 | + if roleExists { |
| 53 | + if err := conn.Exec(ctx, `ALTER ROLE `+quoteIdent(role)+` WITH LOGIN PASSWORD `+quoteLiteral(password)); err != nil { |
| 54 | + return Credentials{}, fmt.Errorf("alter role %q: %w", role, err) |
| 55 | + } |
| 56 | + } else { |
| 57 | + if err := conn.Exec(ctx, `CREATE ROLE `+quoteIdent(role)+` WITH LOGIN PASSWORD `+quoteLiteral(password)); err != nil { |
| 58 | + return Credentials{}, fmt.Errorf("create role %q: %w", role, err) |
| 59 | + } |
| 60 | + } |
| 61 | + |
| 62 | + // 2. Database — guarded create (CREATE DATABASE is not idempotent and cannot |
| 63 | + // run in a transaction). |
| 64 | + dbExists, err := conn.Exists(ctx, `SELECT 1 FROM pg_database WHERE datname = $1`, db) |
| 65 | + if err != nil { |
| 66 | + return Credentials{}, fmt.Errorf("check database %q: %w", db, err) |
| 67 | + } |
| 68 | + if !dbExists { |
| 69 | + if err := conn.Exec(ctx, `CREATE DATABASE `+quoteIdent(db)+` OWNER `+quoteIdent(role)); err != nil { |
| 70 | + return Credentials{}, fmt.Errorf("create database %q: %w", db, err) |
| 71 | + } |
| 72 | + } |
| 73 | + |
| 74 | + // 3. Privileges — revoke PUBLIC, grant the owning role (idempotent). |
| 75 | + for _, stmt := range []string{ |
| 76 | + `REVOKE ALL ON DATABASE ` + quoteIdent(db) + ` FROM PUBLIC`, |
| 77 | + `GRANT ALL ON DATABASE ` + quoteIdent(db) + ` TO ` + quoteIdent(role), |
| 78 | + } { |
| 79 | + if err := conn.Exec(ctx, stmt); err != nil { |
| 80 | + return Credentials{}, fmt.Errorf("grant on %q: %w", db, err) |
| 81 | + } |
| 82 | + } |
| 83 | + |
| 84 | + return Credentials{Role: role, Database: db, Password: password}, nil |
| 85 | +} |
| 86 | + |
| 87 | +// pgIdent maps a (dsname-validated) project name to a safe unquoted-friendly |
| 88 | +// Postgres identifier: hyphens become underscores. The result is still quoted at |
| 89 | +// use so any residual characters are handled. |
| 90 | +func pgIdent(project string) string { |
| 91 | + return strings.ReplaceAll(project, "-", "_") |
| 92 | +} |
| 93 | + |
| 94 | +// quoteIdent double-quotes a Postgres identifier, doubling embedded quotes. |
| 95 | +func quoteIdent(s string) string { |
| 96 | + return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` |
| 97 | +} |
| 98 | + |
| 99 | +// quoteLiteral single-quotes a Postgres string literal, doubling embedded |
| 100 | +// quotes. Used for the role password (which cannot be a bind parameter in |
| 101 | +// CREATE/ALTER ROLE). |
| 102 | +func quoteLiteral(s string) string { |
| 103 | + return `'` + strings.ReplaceAll(s, `'`, `''`) + `'` |
| 104 | +} |
0 commit comments