From 97f6fe5d3c8cdc3f8c5344ed55d1ed97ed1ffd80 Mon Sep 17 00:00:00 2001 From: ssavutu Date: Fri, 31 Jul 2026 18:27:53 -0400 Subject: [PATCH] feat(auth): bootstrap the first user as admin, default the rest to editor New CMS users were promoted to admin whenever CMS_AUTO_PROMOTE_ALL_ADMINS was set, and existing users were re-promoted on every login. Drop the flag and give new accounts the editor role instead. The first user to log in against an empty cms_users table is still bootstrapped as an admin so a fresh install has someone who can manage roles. The count and the insert share a transaction with a locking read so two simultaneous first logins can't both come out as admin. Existing admins keep their role; this only governs account creation. Co-Authored-By: Claude Opus 5 --- .env.example | 1 - deploy/README.md | 10 +++--- deploy/cms.env.example | 1 - deploy/compose.cms.yml | 1 - docker-compose.yml | 1 - server/internal/database/users.go | 60 ++++++++++++++++++++----------- 6 files changed, 46 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index 1b0533b..0d50d7c 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,6 @@ GRAFANA_ADMIN_PASSWORD=change-this-admin-password OIDC_ISSUER_URL= OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= -CMS_AUTO_PROMOTE_ALL_ADMINS=false CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP=false # Delta production variables live in deploy/cms.env.example. Do not put diff --git a/deploy/README.md b/deploy/README.md index adb85d6..99ad6af 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -218,7 +218,6 @@ The file must contain the exact immutable image tag for the active deployment: - `FRONTEND_ORIGIN` - `OIDC_REDIRECT_URI` - `CMS_SESSION_TTL_SECONDS` -- `CMS_AUTO_PROMOTE_ALL_ADMINS` - `CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP` - `AKISMET_API_KEY` - optional; leave empty to disable comment spam filtering. - `AKISMET_BLOG_URL` - full public site URL Akismet should associate with @@ -232,9 +231,12 @@ The file must contain the exact immutable image tag for the active deployment: - `MEDIA_MAX_UPLOAD_BYTES` - per-file upload cap in bytes. Empty uses the 90 MiB default. Must stay at or below Nginx's `client_max_body_size`. -Keep `CMS_AUTO_PROMOTE_ALL_ADMINS=false` and -`CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP=false` in production. Rebuild taxonomy -through the admin endpoint after deploys when needed. +Keep `CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP=false` in production. Rebuild +taxonomy through the admin endpoint after deploys when needed. + +New users are created as editors. The very first user to log in to an empty +`cms_users` table is bootstrapped as an admin; promote anyone else from the +users screen. ## Required GitHub Production Environment Variables diff --git a/deploy/cms.env.example b/deploy/cms.env.example index eabf8e6..ab242d9 100644 --- a/deploy/cms.env.example +++ b/deploy/cms.env.example @@ -12,7 +12,6 @@ OIDC_CLIENT_SECRET= FRONTEND_ORIGIN= OIDC_REDIRECT_URI= CMS_SESSION_TTL_SECONDS= -CMS_AUTO_PROMOTE_ALL_ADMINS= CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP= AKISMET_API_KEY= AKISMET_BLOG_URL= diff --git a/deploy/compose.cms.yml b/deploy/compose.cms.yml index b8e470e..c707afa 100644 --- a/deploy/compose.cms.yml +++ b/deploy/compose.cms.yml @@ -27,7 +27,6 @@ x-backend-base: &backend-base FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:?FRONTEND_ORIGIN is required} OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:?OIDC_REDIRECT_URI is required} CMS_SESSION_TTL_SECONDS: ${CMS_SESSION_TTL_SECONDS:-604800} - CMS_AUTO_PROMOTE_ALL_ADMINS: ${CMS_AUTO_PROMOTE_ALL_ADMINS:-false} CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP: ${CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP:-false} AKISMET_API_KEY: ${AKISMET_API_KEY:-} AKISMET_BLOG_URL: ${AKISMET_BLOG_URL:-} diff --git a/docker-compose.yml b/docker-compose.yml index 39713b6..73b2dbf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,7 +43,6 @@ services: TLS_KEY_FILE: /app/certs/localhost.key OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-} OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} - CMS_AUTO_PROMOTE_ALL_ADMINS: ${CMS_AUTO_PROMOTE_ALL_ADMINS:-false} CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP: ${CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP:-false} depends_on: mariadb: diff --git a/server/internal/database/users.go b/server/internal/database/users.go index 02c166e..5560925 100644 --- a/server/internal/database/users.go +++ b/server/internal/database/users.go @@ -3,7 +3,6 @@ package database import ( "context" "database/sql" - "os" "strings" "time" @@ -96,14 +95,8 @@ func EnsureUsersTable(ctx context.Context, conn *sql.DB) error { } func FindOrCreateUser(ctx context.Context, conn *sql.DB, sub, email, name string) (*models.User, error) { - autoPromoteAllAdmins := strings.EqualFold(strings.TrimSpace(os.Getenv("CMS_AUTO_PROMOTE_ALL_ADMINS")), "true") user, err := findUserBySub(ctx, conn, sub) if err == nil { - if autoPromoteAllAdmins && user.Role != models.RoleAdmin { - if err := UpdateUserRole(ctx, conn, user.ID, models.RoleAdmin); err == nil { - user.Role = models.RoleAdmin - } - } _ = updateLastLogin(ctx, conn, user.ID) return user, nil } @@ -113,19 +106,7 @@ func FindOrCreateUser(ctx context.Context, conn *sql.DB, sub, email, name string authorID := findAuthorIDByEmail(ctx, conn, email) - role := models.RoleEditor - if autoPromoteAllAdmins { - role = models.RoleAdmin - } - - res, err := conn.ExecContext(ctx, - "INSERT INTO cms_users (sub, email, name, role, author_id) VALUES (?, ?, ?, ?, ?)", - sub, email, name, role, authorID, - ) - if err != nil { - return nil, err - } - id, err := res.LastInsertId() + id, role, err := insertUser(ctx, conn, sub, email, name, authorID) if err != nil { return nil, err } @@ -143,6 +124,45 @@ func FindOrCreateUser(ctx context.Context, conn *sql.DB, sub, email, name string }, nil } +// insertUser creates a CMS user, bootstrapping the very first one as an admin so +// a fresh install has someone who can manage roles. Everyone after that starts as +// an editor and has to be promoted from the users screen. The count and the +// insert share a transaction (and a locking read) so two simultaneous first +// logins can't both come out as admin. +func insertUser(ctx context.Context, conn *sql.DB, sub, email, name string, authorID *int64) (int64, models.Role, error) { + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + return 0, "", err + } + defer func() { _ = tx.Rollback() }() + + var existing int64 + if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM cms_users FOR UPDATE").Scan(&existing); err != nil { + return 0, "", err + } + + role := models.RoleEditor + if existing == 0 { + role = models.RoleAdmin + } + + res, err := tx.ExecContext(ctx, + "INSERT INTO cms_users (sub, email, name, role, author_id) VALUES (?, ?, ?, ?, ?)", + sub, email, name, role, authorID, + ) + if err != nil { + return 0, "", err + } + id, err := res.LastInsertId() + if err != nil { + return 0, "", err + } + if err := tx.Commit(); err != nil { + return 0, "", err + } + return id, role, nil +} + func findUserBySub(ctx context.Context, conn *sql.DB, sub string) (*models.User, error) { row := conn.QueryRowContext(ctx, "SELECT id, sub, email, name, role, author_id, created_at, last_login_at FROM cms_users WHERE sub = ?",