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
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ GRAFANA_ADMIN_PASSWORD=change-this-admin-password
OIDC_ISSUER_URL=<your-oidc-issuer-url>
OIDC_CLIENT_ID=<your-client-id>
OIDC_CLIENT_SECRET=<your-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
Expand Down
10 changes: 6 additions & 4 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
1 change: 0 additions & 1 deletion deploy/cms.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 0 additions & 1 deletion deploy/compose.cms.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}
Expand Down
1 change: 0 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
60 changes: 40 additions & 20 deletions server/internal/database/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package database
import (
"context"
"database/sql"
"os"
"strings"
"time"

Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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 = ?",
Expand Down
Loading