diff --git a/CHANGELOG.md b/CHANGELOG.md index f8c0c99..4bccc88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ All notable changes to backendkit are documented here. Format: ## [Unreleased] +### Added +- `bff.WithPostgresManagedSchema()`: `NewPostgresStore` runs no DDL, for a table created by the + caller's migrations, so the BFF's database role needs only `SELECT`, `INSERT`, `UPDATE` and + `DELETE` on it (PostgreSQL checks `CREATE` on the schema, and table ownership for the index, + even when they already exist). At start-up the store checks the table's columns and each of + those four privileges, and fails naming what is missing. The default is unchanged. Requested by + Lakebridge (#82). + ### Changed - **The module now requires Go 1.27.1** (`go 1.27.1`; was `go 1.25.0` with `toolchain go1.27.1`). Importers need Go 1.27.1 or later; with `GOTOOLCHAIN=auto`, the default, an older `go` command diff --git a/README.md b/README.md index d4822eb..8efbf37 100644 --- a/README.md +++ b/README.md @@ -716,6 +716,14 @@ handler (`WithPostgresErrorHandler`; default: the standard logger). For another implement `SessionStore` (`Get`, `Put`, `Delete`, `Sweep`) with `Session.Snapshot` / `NewSessionFromSnapshot`. A `Gateway` must be used by pointer and never copied. +**Least-privilege database role.** By default `NewPostgresStore` runs `CREATE TABLE IF NOT EXISTS` +and `CREATE INDEX IF NOT EXISTS`, which PostgreSQL refuses to a role without `CREATE` on the schema +and ownership of the table, even when both already exist. When your migrations own the table, pass +`bff.WithPostgresManagedSchema()`: the store runs no DDL, checks at start-up that the table has its +columns and that the role holds `SELECT`, `INSERT`, `UPDATE` and `DELETE` on it, and fails +otherwise. The `CREATE TABLE` and `CREATE INDEX` your migration must run are in its doc comment; +the table name (`WithPostgresTable`) is unqualified, so put the schema in the role's `search_path`. + Full API: [pkg.go.dev/…/bff](https://pkg.go.dev/github.com/ovander/backendkit/bff). --- diff --git a/bff/postgres_store.go b/bff/postgres_store.go index ed51987..09020bd 100644 --- a/bff/postgres_store.go +++ b/bff/postgres_store.go @@ -38,6 +38,7 @@ type PostgresStore struct { absolute time.Duration now func() time.Time onError func(op string, err error) + managed bool } // PostgresStoreTombstoneTTL is how long Delete keeps a tombstone that stops a @@ -65,8 +66,31 @@ func WithPostgresErrorHandler(f func(op string, err error)) PostgresStoreOption return func(p *PostgresStore) { p.onError = f } } +// WithPostgresManagedSchema tells NewPostgresStore that the table and its +// last_seen index are created by the caller's own migrations, so it runs no +// DDL and the store can use a role holding only SELECT, INSERT, UPDATE and +// DELETE on the table: PostgreSQL checks CREATE on the schema, and table +// ownership for the index, even when the objects already exist. +// NewPostgresStore then checks that the table has the expected columns and +// that the role holds each of those four privileges, and fails otherwise. The +// migration must create, under the configured name (WithPostgresTable; +// unqualified, so resolved through the role's search_path): +// +// CREATE TABLE ( +// id text PRIMARY KEY, +// data bytea NOT NULL, +// created_at timestamptz NOT NULL, +// last_seen timestamptz NOT NULL, +// deleted_at timestamptz +// ); +// CREATE INDEX
_last_seen_idx ON
(last_seen); +func WithPostgresManagedSchema() PostgresStoreOption { + return func(p *PostgresStore) { p.managed = true } +} + // NewPostgresStore checks the connection, creates the table if it does not -// exist and returns the store. key encrypts the session data and must be 32 +// exist (or, with WithPostgresManagedSchema, checks the existing one) and +// returns the store. key encrypts the session data and must be 32 // bytes (AES-256); keep it with the BFF's other secrets. Changing it signs // every user out. idle and absolute are as for NewMemoryStore; zero disables // that bound. @@ -101,6 +125,12 @@ func NewPostgresStore(ctx context.Context, db *sql.DB, key []byte, idle, absolut if err := db.PingContext(ctx); err != nil { return nil, fmt.Errorf("bff: postgres store: %w", err) } + if p.managed { + if err := p.checkManagedTable(ctx); err != nil { + return nil, fmt.Errorf("bff: postgres store: managed table %s: %w", p.table, err) + } + return p, nil + } if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS `+p.table+` ( id text PRIMARY KEY, data bytea NOT NULL, @@ -116,6 +146,32 @@ func NewPostgresStore(ctx context.Context, db *sql.DB, key []byte, idle, absolut return p, nil } +// checkManagedTable verifies, without DDL, that the migration-owned table has +// the columns the store uses and that the role may read and write it, so a +// missing grant fails at start-up rather than at the first sign-in. +func (p *PostgresStore) checkManagedTable(ctx context.Context) error { + // The table name is a checked identifier; LIMIT 0 reads no row. + rows, err := p.db.QueryContext(ctx, `SELECT id, data, created_at, last_seen, deleted_at FROM `+p.table+` LIMIT 0`) + if err != nil { + return err + } + if err := rows.Close(); err != nil { + return err + } + // One call per privilege: given a list, has_table_privilege is true when + // any of them is held. + for _, priv := range []string{"SELECT", "INSERT", "UPDATE", "DELETE"} { + var ok bool + if err := p.db.QueryRowContext(ctx, `SELECT has_table_privilege($1, $2)`, p.table, priv).Scan(&ok); err != nil { + return err + } + if !ok { + return fmt.Errorf("the role lacks %s on the table", priv) + } + } + return nil +} + func opContext() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), postgresStoreOpTimeout) } diff --git a/bff/postgres_store_test.go b/bff/postgres_store_test.go index e764879..40dffee 100644 --- a/bff/postgres_store_test.go +++ b/bff/postgres_store_test.go @@ -191,3 +191,87 @@ func TestNewPostgresStore_RefusesBadConfig(t *testing.T) { t.Errorf("bad table name: %v", err) } } + +// pgDB opens TEST_DATABASE_URL and returns a fresh table name, dropped at the +// end, for tests that create the table themselves. +func pgDB(t *testing.T) (*sql.DB, string) { + t.Helper() + s, db := pgStore(t, time.Hour, 8*time.Hour) // skips without TEST_DATABASE_URL + table := s.table + "_managed" + t.Cleanup(func() { _, _ = db.Exec("DROP TABLE IF EXISTS " + table) }) + return db, table +} + +const managedTableDDL = `CREATE TABLE %s ( + id text PRIMARY KEY, + data bytea NOT NULL, + created_at timestamptz NOT NULL, + last_seen timestamptz NOT NULL, + deleted_at timestamptz +)` + +// With WithPostgresManagedSchema the store runs no DDL: on a table created by +// a migration (here without the index), sessions round-trip and no index +// appears; on a missing table it refuses to start and creates nothing. +func TestPostgresStore_ManagedSchema(t *testing.T) { + db, table := pgDB(t) + ctx := context.Background() + + if _, err := NewPostgresStore(ctx, db, testKey, time.Hour, 8*time.Hour, + WithPostgresTable(table), WithPostgresManagedSchema()); err == nil { + t.Fatal("a managed store started on a missing table") + } + var exists bool + if err := db.QueryRow(`SELECT to_regclass($1) IS NOT NULL`, table).Scan(&exists); err != nil || exists { + t.Fatalf("the managed store created its table (exists=%v, err=%v)", exists, err) + } + + if _, err := db.Exec(fmt.Sprintf(managedTableDDL, table)); err != nil { + t.Fatal(err) + } + s, err := NewPostgresStore(ctx, db, testKey, time.Hour, 8*time.Hour, WithPostgresTable(table), + WithPostgresManagedSchema(), WithPostgresErrorHandler(func(op string, err error) { t.Errorf("%s: %v", op, err) })) + if err != nil { + t.Fatalf("managed store on an existing table: %v", err) + } + s.Put(testSession("m1", time.Now())) + if got, ok := s.Get("m1"); !ok || got.RefreshToken() != "refresh-m1" { + t.Fatalf("Get = %s, %v", got, ok) + } + s.Delete("m1") + if _, ok := s.Get("m1"); ok { + t.Fatal("deleted session still readable") + } + if err := db.QueryRow(`SELECT to_regclass($1) IS NOT NULL`, table+"_last_seen_idx").Scan(&exists); err != nil || exists { + t.Fatalf("the managed store created the index (exists=%v, err=%v): it must run no DDL", exists, err) + } +} + +// A managed table the role cannot fully use fails at start-up, naming the +// missing privilege, instead of at the first sign-in; so does a table without +// the expected columns. +func TestPostgresStore_ManagedSchemaChecksTheTable(t *testing.T) { + db, table := pgDB(t) + ctx := context.Background() + if _, err := db.Exec(fmt.Sprintf(managedTableDDL, table)); err != nil { + t.Fatal(err) + } + // The owner may revoke its own privileges; has_table_privilege then says no. + if _, err := db.Exec("REVOKE DELETE ON " + table + " FROM CURRENT_USER"); err != nil { + t.Fatal(err) + } + _, err := NewPostgresStore(ctx, db, testKey, 0, 0, WithPostgresTable(table), WithPostgresManagedSchema()) + if err == nil || !strings.Contains(err.Error(), "DELETE") { + t.Fatalf("missing DELETE privilege: %v, want a start-up error naming it", err) + } + + if _, err := db.Exec("DROP TABLE " + table); err != nil { + t.Fatal(err) + } + if _, err := db.Exec("CREATE TABLE " + table + " (id text PRIMARY KEY)"); err != nil { + t.Fatal(err) + } + if _, err := NewPostgresStore(ctx, db, testKey, 0, 0, WithPostgresTable(table), WithPostgresManagedSchema()); err == nil { + t.Fatal("a table without the store's columns was accepted") + } +} diff --git a/docs/CLIENT-INTEGRATION.md b/docs/CLIENT-INTEGRATION.md index a2e9bfc..1e775da 100644 --- a/docs/CLIENT-INTEGRATION.md +++ b/docs/CLIENT-INTEGRATION.md @@ -723,7 +723,9 @@ never forwarded; CSRF is checked in constant time; only a refresh Socrate rejects ends a session) is listed in the README's [`bff` reference](../README.md#bff). `MemoryStore` loses its sessions on a restart; `bff.NewPostgresStore` keeps them in PostgreSQL, encrypted, and is -shared by several instances (README). With more than one instance, also keep the +shared by several instances (README). For a role holding only `SELECT`, `INSERT`, `UPDATE` and +`DELETE` on a migration-owned table, add `bff.WithPostgresManagedSchema()`: the store then runs no +DDL. With more than one instance, also keep the `pending` logins in a shared store. [`oauth2-admin/bff`](https://github.com/ovander/oauth2-admin/tree/main/bff) is a complete BFF built this way, with logout and token revocation.