diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75899b1..258f35e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,14 @@ All notable changes to backendkit are documented here. Format:
malformed `scope`/`scp` claim only matters with `RequireScopes` (it is then a 403): callers that do
not require scopes see no change.
+### Fixed
+- **`WithPendingLoginManagedSchema` now also checks the `UPDATE` privilege.** `Put` is an
+ `INSERT … ON CONFLICT DO UPDATE`, which needs `UPDATE` on the table, but the start-up check
+ verified only `SELECT`, `INSERT` and `DELETE`. A role granted exactly those three passed the
+ check and then failed every sign-in. Such a role is now refused at start-up, naming `UPDATE`;
+ grant it `SELECT, INSERT, UPDATE, DELETE`, as for the session table. Found while moving the
+ monitoring console to the managed schema.
+
## [1.25.0] - 2026-10-10
Minor release on the **v1** line: additive API only. `bff.PendingLoginStore` keeps pending logins
diff --git a/bff/pending_login.go b/bff/pending_login.go
index 3d526c3..f0cf69b 100644
--- a/bff/pending_login.go
+++ b/bff/pending_login.go
@@ -164,7 +164,8 @@ func WithPendingLoginErrorHandler(f func(op string, err error)) PendingLoginStor
// WithPendingLoginManagedSchema tells NewPostgresPendingLoginStore that the
// table is created by the caller's own migrations, as for
// WithPostgresManagedSchema: it runs no DDL, checks the columns and that the
-// role holds SELECT, INSERT and DELETE. The migration must create:
+// role holds SELECT, INSERT, UPDATE and DELETE (Put is an INSERT … ON CONFLICT
+// DO UPDATE, which needs UPDATE). The migration must create:
//
// CREATE TABLE
(
// state text PRIMARY KEY,
@@ -227,7 +228,7 @@ func NewPostgresPendingLoginStore(ctx context.Context, db *sql.DB, key []byte, t
return nil, fmt.Errorf("bff: pending login store: %w", err)
}
if s.managed {
- if err := checkTable(ctx, db, s.table, "state, data, created_at", "SELECT", "INSERT", "DELETE"); err != nil {
+ if err := checkTable(ctx, db, s.table, "state, data, created_at", "SELECT", "INSERT", "UPDATE", "DELETE"); err != nil {
return nil, fmt.Errorf("bff: pending login store: managed table %s: %w", s.table, err)
}
return s, nil
diff --git a/bff/pending_login_test.go b/bff/pending_login_test.go
index de76479..d78e1a4 100644
--- a/bff/pending_login_test.go
+++ b/bff/pending_login_test.go
@@ -198,6 +198,29 @@ func TestPostgresPendingLoginStore_ManagedSchema(t *testing.T) {
}
}
+// Put is an INSERT … ON CONFLICT DO UPDATE, so a role without UPDATE cannot
+// store a login: the managed check must refuse it at start-up, naming the
+// privilege, instead of letting every sign-in fail.
+func TestPostgresPendingLoginStore_ManagedSchemaNeedsUpdate(t *testing.T) {
+ ctx := context.Background()
+ _, db := pgStore(t, time.Hour, time.Hour)
+ table := fmt.Sprintf("bff_test_pending_grant_%d", time.Now().UnixNano())
+ t.Cleanup(func() { _, _ = db.Exec("DROP TABLE IF EXISTS " + table) })
+ if _, err := db.Exec("CREATE TABLE " + table + " (state text PRIMARY KEY, data bytea NOT NULL, created_at timestamptz NOT NULL)"); err != nil {
+ t.Fatal(err)
+ }
+ store := db
+ if isSuperuser(t, db) {
+ store = limitedRoleDB(t, db, table, "SELECT, INSERT, DELETE")
+ } else if _, err := db.Exec("REVOKE UPDATE ON " + table + " FROM CURRENT_USER"); err != nil {
+ t.Fatal(err)
+ }
+ _, err := NewPostgresPendingLoginStore(ctx, store, testKey, 0, WithPendingLoginTable(table), WithPendingLoginManagedSchema())
+ if err == nil || !strings.Contains(err.Error(), "UPDATE") {
+ t.Fatalf("missing UPDATE privilege: %v, want a start-up error naming it", err)
+ }
+}
+
func TestNewPostgresPendingLoginStore_Validation(t *testing.T) {
ctx := context.Background()
if _, err := NewPostgresPendingLoginStore(ctx, nil, testKey, 0); err == nil {