From 6d1086ac68b57655643eef1b37215c3690d4861f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:59:32 +0000 Subject: [PATCH] feat(bff): WithPostgresManagedSchema for a migration-owned session table NewPostgresStore always ran CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS. PostgreSQL checks CREATE on the schema, and table ownership for the index, before it checks existence, so a role holding only SELECT, INSERT, UPDATE and DELETE on a table created by migrations could not start the store (reproduced on PostgreSQL 16). WithPostgresManagedSchema() (additive; default unchanged) makes the store run no DDL. At start-up it checks that the table has the store's columns and that the role holds each of SELECT, INSERT, UPDATE and DELETE (one has_table_privilege call each: with a list it is true when any is held), so a missing grant fails at start-up, not at the first sign-in. The doc comment gives the migration's CREATE TABLE and CREATE INDEX. Closes #82 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GKRxaeYxyDhmt42cehLsGA --- CHANGELOG.md | 8 ++++ README.md | 8 ++++ bff/postgres_store.go | 58 +++++++++++++++++++++++++- bff/postgres_store_test.go | 84 ++++++++++++++++++++++++++++++++++++++ docs/CLIENT-INTEGRATION.md | 4 +- 5 files changed, 160 insertions(+), 2 deletions(-) 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.