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.