package auth import ( "database/sql" "errors" "fmt" "os" "strings" "time" "github.com/Grace-Solutions/OrchestrAD/internal/crypto" "github.com/Grace-Solutions/OrchestrAD/internal/logging" "github.com/Grace-Solutions/OrchestrAD/internal/models" "github.com/Grace-Solutions/OrchestrAD/internal/repository" "github.com/google/uuid" ) // Seeded by migration 001_initial_schema.up.sql. const adminRoleID = "00000000-0000-0000-0000-000000000002" // DefaultBootstrapPassword is used to seed the first administrator when the // operator has not supplied ORCHESTRAD_BOOTSTRAP_PASSWORD or _FILE and the // users table is empty. The account is flagged as password_reset_required, // so the operator must change it at first login before they can reach any // other screen. const DefaultBootstrapPassword = "admin" // EnsureBootstrapAdmin seeds or rotates the local administrator account from // environment variables or secret files. It is safe to call on every startup. // // Recognized environment variables (all optional): // // ORCHESTRAD_BOOTSTRAP_USERNAME default "admin" // ORCHESTRAD_BOOTSTRAP_PASSWORD plaintext; takes precedence over _FILE // ORCHESTRAD_BOOTSTRAP_PASSWORD_FILE path to a file containing the password // ORCHESTRAD_BOOTSTRAP_EMAIL optional contact email // ORCHESTRAD_BOOTSTRAP_DISPLAY_NAME defaults to "Administrator" // // Behavior: // - If the user exists and a password was supplied, the password is rotated. // - If the user exists and no password was supplied, the record is left alone. // - If the user does not exist and a password was supplied, the user is // created and granted the Admin role. // - If the user does not exist, no password was supplied, and the users // table is empty, the account is seeded with DefaultBootstrapPassword // and flagged as password_reset_required so the operator is forced to // replace the well-known default at first login. func EnsureBootstrapAdmin(db *sql.DB, logger *logging.Logger) error { username := envOr("ORCHESTRAD_BOOTSTRAP_USERNAME", "admin") password, source, err := readBootstrapPassword() if err != nil { return fmt.Errorf("reading bootstrap password: %w", err) } displayName := envOr("ORCHESTRAD_BOOTSTRAP_DISPLAY_NAME", "Administrator") email := strings.TrimSpace(os.Getenv("ORCHESTRAD_BOOTSTRAP_EMAIL")) users := repository.NewUserRepository(db) existing, err := users.GetByUsername(username) if err != nil { return fmt.Errorf("looking up bootstrap user %q: %w", username, err) } if existing != nil { if password == "" { logger.Debug("Bootstrap", "User %q already exists; no password rotation requested", username) return nil } hash, err := crypto.HashPassword(password) if err != nil { return fmt.Errorf("hashing bootstrap password: %w", err) } existing.PasswordHash = &hash existing.IsActive = true if email != "" { existing.Email = &email } if displayName != "" { existing.DisplayName = &displayName } if err := users.Update(existing); err != nil { return fmt.Errorf("updating bootstrap user %q: %w", username, err) } if err := assignAdminRole(db, existing.ID); err != nil { return fmt.Errorf("assigning admin role to %q: %w", username, err) } logger.Info("Bootstrap", "Rotated password for %q (source: %s)", username, source) return nil } // When no password is supplied we only seed the default administrator // if the users table is empty. forceReset is set whenever the account // ends up with the well-known default so the first login must pass // through the change-password flow. forceReset := false if password == "" { empty, err := usersTableEmpty(db) if err != nil { return fmt.Errorf("checking users table: %w", err) } if !empty { logger.Debug("Bootstrap", "User %q not found and no bootstrap password supplied; users table is non-empty, skipping", username) return nil } password = DefaultBootstrapPassword source = "default" forceReset = true } hash, err := crypto.HashPassword(password) if err != nil { return fmt.Errorf("hashing bootstrap password: %w", err) } now := time.Now().UTC() user := &models.User{ ID: uuid.New().String(), Username: username, PasswordHash: &hash, DisplayName: strPtr(displayName), IsActive: true, IsOIDCUser: false, PasswordResetRequired: forceReset, CreatedUTC: now, UpdatedUTC: now, } if email != "" { user.Email = &email } if err := users.Create(user); err != nil { return fmt.Errorf("creating bootstrap user %q: %w", username, err) } if err := assignAdminRole(db, user.ID); err != nil { return fmt.Errorf("assigning admin role to %q: %w", username, err) } if forceReset { logger.Warn("Bootstrap", "================================================================") logger.Warn("Bootstrap", " Seeded default administrator account.") logger.Warn("Bootstrap", " Username: %s", username) logger.Warn("Bootstrap", " Password: %s", password) logger.Warn("Bootstrap", " You will be required to change this password at first login.") logger.Warn("Bootstrap", "================================================================") } else { logger.Info("Bootstrap", "Created administrator %q (source: %s)", username, source) } return nil } func readBootstrapPassword() (string, string, error) { if pw := os.Getenv("ORCHESTRAD_BOOTSTRAP_PASSWORD"); pw != "" { return pw, "env", nil } if path := strings.TrimSpace(os.Getenv("ORCHESTRAD_BOOTSTRAP_PASSWORD_FILE")); path != "" { data, err := os.ReadFile(path) if err != nil { return "", "", fmt.Errorf("reading %s: %w", path, err) } pw := strings.TrimRight(strings.TrimRight(string(data), "\n"), "\r") if pw == "" { return "", "", errors.New("password file is empty") } return pw, "file:" + path, nil } return "", "", nil } func envOr(key, def string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } return def } func assignAdminRole(db *sql.DB, userID string) error { now := time.Now().UTC().Format(time.RFC3339) _, err := db.Exec(` INSERT OR IGNORE INTO user_roles (user_id, role_id, created_utc) VALUES (?, ?, ?) `, userID, adminRoleID, now) return err } func usersTableEmpty(db *sql.DB) (bool, error) { var count int if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE deleted_utc IS NULL`).Scan(&count); err != nil { return false, err } return count == 0, nil } func strPtr(s string) *string { if s == "" { return nil } return &s }