Files
chorus/internal/adminbootstrap/bootstrap.go
T

196 lines
5.3 KiB
Go

// Package adminbootstrap safely initializes the first Chorus administrator.
package adminbootstrap
import (
"context"
"database/sql"
"errors"
"strings"
"golang.org/x/crypto/bcrypt"
)
const chorusOperatorRoleKey = "chorus_operator"
var (
ErrMigrationPrecondition = errors.New("administrator bootstrap prerequisites are unavailable")
ErrAccountState = errors.New("administrator account is not eligible for bootstrap")
ErrDatabaseUnavailable = errors.New("cannot connect to administrator database")
)
// Config contains the external inputs accepted by the bootstrap command.
// Password is intentionally held only in memory and is never included in errors.
type Config struct {
DSN string
Username string
Password string
ResetPassword bool
}
// Result describes the non-sensitive effect of one bootstrap run.
type Result int
const (
Created Result = iota + 1
Unchanged
PasswordReset
)
// LoadConfig reads the command's dedicated environment variables. It does not
// accept defaults so an operator must make every bootstrap action explicit.
func LoadConfig(lookup func(string) (string, bool)) (Config, error) {
read := func(name string) string {
value, _ := lookup(name)
return value
}
cfg := Config{
DSN: strings.TrimSpace(read("CHORUS_DSN")),
Username: strings.TrimSpace(read("CHORUS_ADMIN_USERNAME")),
Password: read("CHORUS_ADMIN_PASSWORD"),
}
if value, ok := lookup("CHORUS_ADMIN_RESET_PASSWORD"); ok && value != "" {
if value != "1" {
return Config{}, errors.New("CHORUS_ADMIN_RESET_PASSWORD must be 1 when set")
}
cfg.ResetPassword = true
}
return cfg, cfg.Validate()
}
// Validate rejects inputs that cannot be represented safely in the audited
// schema or processed by bcrypt. It never includes values in returned errors.
func (c Config) Validate() error {
if strings.TrimSpace(c.DSN) == "" {
return errors.New("CHORUS_DSN is required")
}
if c.Username == "" {
return errors.New("CHORUS_ADMIN_USERNAME is required")
}
if len([]byte(c.Username)) > 64 {
return errors.New("CHORUS_ADMIN_USERNAME exceeds 64 bytes")
}
if c.Password == "" {
return errors.New("CHORUS_ADMIN_PASSWORD is required")
}
if len([]byte(c.Password)) > 72 {
return errors.New("CHORUS_ADMIN_PASSWORD exceeds 72 bytes")
}
return nil
}
// Bootstrap creates an enabled administrator or explicitly resets its password.
// Existing records are never repaired implicitly: a disabled, deleted, empty-
// password, or role-mismatched account is treated as an operator intervention.
func Bootstrap(ctx context.Context, db *sql.DB, cfg Config) (Result, error) {
return bootstrap(ctx, db, cfg, chorusOperatorRoleKey)
}
func bootstrap(ctx context.Context, db *sql.DB, cfg Config, roleKey string) (Result, error) {
if err := cfg.Validate(); err != nil {
return 0, err
}
if db == nil {
return 0, ErrDatabaseUnavailable
}
if err := db.PingContext(ctx); err != nil {
return 0, ErrDatabaseUnavailable
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return 0, ErrDatabaseUnavailable
}
defer tx.Rollback()
roleID, err := activeChorusOperatorRole(ctx, tx, roleKey)
if err != nil {
return 0, err
}
var (
userID int64
storedHash string
status string
storedRoleID int64
active bool
)
err = tx.QueryRowContext(ctx, `
SELECT user_id, password, status, role_id, deleted_at IS NULL
FROM sys_user
WHERE username = ?
FOR UPDATE`, cfg.Username).Scan(&userID, &storedHash, &status, &storedRoleID, &active)
switch {
case errors.Is(err, sql.ErrNoRows):
hash, hashErr := bcryptHash(cfg.Password)
if hashErr != nil {
return 0, hashErr
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO sys_user (username, password, role_id, status)
VALUES (?, ?, ?, '2')`, cfg.Username, hash, roleID); err != nil {
return 0, ErrDatabaseUnavailable
}
if err := tx.Commit(); err != nil {
return 0, ErrDatabaseUnavailable
}
return Created, nil
case err != nil:
return 0, ErrDatabaseUnavailable
}
if !active || status != "2" || storedRoleID != roleID || storedHash == "" {
return 0, ErrAccountState
}
if !cfg.ResetPassword {
if err := tx.Commit(); err != nil {
return 0, ErrDatabaseUnavailable
}
return Unchanged, nil
}
hash, err := bcryptHash(cfg.Password)
if err != nil {
return 0, err
}
result, err := tx.ExecContext(ctx, `
UPDATE sys_user
SET password = ?, salt = '', update_by = 0
WHERE user_id = ? AND role_id = ? AND status = '2' AND deleted_at IS NULL`, hash, userID, roleID)
if err != nil {
return 0, ErrDatabaseUnavailable
}
rowsAffected, err := result.RowsAffected()
if err != nil || rowsAffected != 1 {
return 0, ErrAccountState
}
if err := tx.Commit(); err != nil {
return 0, ErrDatabaseUnavailable
}
return PasswordReset, nil
}
func activeChorusOperatorRole(ctx context.Context, tx *sql.Tx, roleKey string) (int64, error) {
var roleID int64
err := tx.QueryRowContext(ctx, `
SELECT role_id
FROM sys_role
WHERE role_key = ? AND status = '2' AND deleted_at IS NULL
FOR UPDATE`, roleKey).Scan(&roleID)
if errors.Is(err, sql.ErrNoRows) {
return 0, ErrMigrationPrecondition
}
if err != nil {
return 0, ErrDatabaseUnavailable
}
return roleID, nil
}
func bcryptHash(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", errors.New("cannot generate administrator password hash")
}
return string(hash), nil
}