Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---
Expand Down
58 changes: 57 additions & 1 deletion bff/postgres_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <table> (
// id text PRIMARY KEY,
// data bytea NOT NULL,
// created_at timestamptz NOT NULL,
// last_seen timestamptz NOT NULL,
// deleted_at timestamptz
// );
// CREATE INDEX <table>_last_seen_idx ON <table> (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.
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
Expand Down
84 changes: 84 additions & 0 deletions bff/postgres_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
4 changes: 3 additions & 1 deletion docs/CLIENT-INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading