196 lines
5.3 KiB
Go
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
|
|
}
|