diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index 2491fbef..6abdadb0 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -599,6 +599,10 @@ lanes: - serviceId: hwlab-user-billing env: HWLAB_USER_BILLING_DB_URL: secretRef:hwlab-cloud-api-v03-db/database-url + HWLAB_BOOTSTRAP_ADMIN_ID: usr_v03_admin + HWLAB_BOOTSTRAP_ADMIN_USERNAME: admin + HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME: HWLAB v0.3 Admin + HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH: secretRef:hwlab-v03-bootstrap-admin/password-hash HWLAB_USER_BILLING_STATE_AUTHORITY: pk01-postgres HWLAB_USER_BILLING_EXTERNAL_DB_LABEL: pk01-external-postgres HWLAB_USER_BILLING_PORT: "6670" diff --git a/internal/userbilling/service.go b/internal/userbilling/service.go index 0c9d8046..40c186dd 100644 --- a/internal/userbilling/service.go +++ b/internal/userbilling/service.go @@ -35,20 +35,26 @@ const serviceID = "hwlab-user-billing" var migrationFS embed.FS type Config struct { - Addr string - DatabaseURL string - RedisURL string - InitialCredits int64 - CreditPerThousandTok int64 - SessionTTL time.Duration - ReservationTTL time.Duration - RegistrationEnabled bool - InternalToken string - RequireInternalToken bool - MigrateOnStart bool - StateAuthority string - RuntimeNamespace string - ExternalDatabaseLabel string + Addr string + DatabaseURL string + RedisURL string + InitialCredits int64 + CreditPerThousandTok int64 + SessionTTL time.Duration + ReservationTTL time.Duration + RegistrationEnabled bool + InternalToken string + RequireInternalToken bool + MigrateOnStart bool + StateAuthority string + RuntimeNamespace string + ExternalDatabaseLabel string + BootstrapAdminID string + BootstrapAdminEmail string + BootstrapAdminUsername string + BootstrapAdminDisplayName string + BootstrapAdminPasswordHash string + BootstrapAdminPassword string } type Server struct { @@ -315,20 +321,26 @@ func LoadConfig(env []string) Config { } port := first(values["HWLAB_USER_BILLING_PORT"], values["PORT"], "6670") cfg := Config{ - Addr: first(values["HWLAB_USER_BILLING_ADDR"], ":"+port), - DatabaseURL: first(values["HWLAB_USER_BILLING_DB_URL"], values["DATABASE_URL"]), - RedisURL: values["HWLAB_USER_BILLING_REDIS_URL"], - InitialCredits: parseInt64(values["HWLAB_USER_BILLING_INITIAL_CREDITS"], 0), - CreditPerThousandTok: parseInt64(values["HWLAB_USER_BILLING_CREDIT_PER_1K_TOKENS"], 1), - SessionTTL: parseDurationHours(values["HWLAB_USER_BILLING_SESSION_TTL_HOURS"], 720), - ReservationTTL: parseDurationMinutes(values["HWLAB_USER_BILLING_RESERVATION_TTL_MINUTES"], 30), - RegistrationEnabled: values["HWLAB_USER_BILLING_REGISTRATION_ENABLED"] != "0", - InternalToken: values["HWLAB_USER_BILLING_INTERNAL_TOKEN"], - RequireInternalToken: values["HWLAB_USER_BILLING_REQUIRE_INTERNAL_TOKEN"] == "1", - MigrateOnStart: values["HWLAB_USER_BILLING_MIGRATE_ON_START"] != "0", - StateAuthority: first(values["HWLAB_USER_BILLING_STATE_AUTHORITY"], "pk01-postgres"), - RuntimeNamespace: first(values["POD_NAMESPACE"], values["HWLAB_NAMESPACE"], "hwlab-v03"), - ExternalDatabaseLabel: first(values["HWLAB_USER_BILLING_EXTERNAL_DB_LABEL"], "pk01-external-postgres"), + Addr: first(values["HWLAB_USER_BILLING_ADDR"], ":"+port), + DatabaseURL: first(values["HWLAB_USER_BILLING_DB_URL"], values["DATABASE_URL"]), + RedisURL: values["HWLAB_USER_BILLING_REDIS_URL"], + InitialCredits: parseInt64(values["HWLAB_USER_BILLING_INITIAL_CREDITS"], 0), + CreditPerThousandTok: parseInt64(values["HWLAB_USER_BILLING_CREDIT_PER_1K_TOKENS"], 1), + SessionTTL: parseDurationHours(values["HWLAB_USER_BILLING_SESSION_TTL_HOURS"], 720), + ReservationTTL: parseDurationMinutes(values["HWLAB_USER_BILLING_RESERVATION_TTL_MINUTES"], 30), + RegistrationEnabled: values["HWLAB_USER_BILLING_REGISTRATION_ENABLED"] != "0", + InternalToken: values["HWLAB_USER_BILLING_INTERNAL_TOKEN"], + RequireInternalToken: values["HWLAB_USER_BILLING_REQUIRE_INTERNAL_TOKEN"] == "1", + MigrateOnStart: values["HWLAB_USER_BILLING_MIGRATE_ON_START"] != "0", + StateAuthority: first(values["HWLAB_USER_BILLING_STATE_AUTHORITY"], "pk01-postgres"), + RuntimeNamespace: first(values["POD_NAMESPACE"], values["HWLAB_NAMESPACE"], "hwlab-v03"), + ExternalDatabaseLabel: first(values["HWLAB_USER_BILLING_EXTERNAL_DB_LABEL"], "pk01-external-postgres"), + BootstrapAdminID: first(values["HWLAB_BOOTSTRAP_ADMIN_ID"], "usr_bootstrap_admin"), + BootstrapAdminEmail: values["HWLAB_BOOTSTRAP_ADMIN_EMAIL"], + BootstrapAdminUsername: first(values["HWLAB_BOOTSTRAP_ADMIN_USERNAME"], "admin"), + BootstrapAdminDisplayName: first(values["HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME"], "HWLAB Admin"), + BootstrapAdminPasswordHash: values["HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH"], + BootstrapAdminPassword: values["HWLAB_BOOTSTRAP_ADMIN_PASSWORD"], } return cfg } @@ -351,11 +363,67 @@ func NewServer(ctx context.Context, cfg Config) (*Server, error) { return nil, fmt.Errorf("migrate user billing schema: %w", err) } } + bootstrapCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if err := server.ensureBootstrapAdmin(bootstrapCtx); err != nil { + return nil, fmt.Errorf("ensure bootstrap admin: %w", err) + } } server.routes() return server, nil } +func (s *Server) ensureBootstrapAdmin(ctx context.Context) error { + if s.db == nil { + return nil + } + username := normalizeUsername(s.config.BootstrapAdminUsername) + if username == "" { + return nil + } + passwordHash := strings.TrimSpace(s.config.BootstrapAdminPasswordHash) + if passwordHash == "" && strings.TrimSpace(s.config.BootstrapAdminPassword) != "" { + hash, err := hashPassword(s.config.BootstrapAdminPassword) + if err != nil { + return err + } + passwordHash = hash + } + if passwordHash == "" { + return nil + } + adminID := strings.TrimSpace(first(s.config.BootstrapAdminID, "usr_bootstrap_admin")) + email := strings.ToLower(strings.TrimSpace(s.config.BootstrapAdminEmail)) + if email == "" { + email = username + "@bootstrap.hwlab.local" + } + displayName := strings.TrimSpace(first(s.config.BootstrapAdminDisplayName, "HWLAB Admin")) + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `UPDATE hwlab_users SET email = $3, display_name = $4, password_hash = $5, status = 'active', role = 'admin', email_verified = true, updated_at = now() WHERE id = $1 OR username = $2`, adminID, username, email, displayName, passwordHash) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + _, err = tx.ExecContext(ctx, `INSERT INTO hwlab_users (id, email, username, display_name, password_hash, status, role, email_verified) VALUES ($1, $2, $3, $4, $5, 'active', 'admin', true)`, adminID, email, username, displayName, passwordHash) + if err != nil { + return err + } + } + _, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_accounts (user_id, balance_credits, reserved_credits, plan_id) VALUES ($1, $2, 0, 'default') ON CONFLICT (user_id) DO NOTHING`, adminID, nonNegative(s.config.InitialCredits)) + if err != nil { + return err + } + return tx.Commit() +} + func (s *Server) Close() { if s.db != nil { _ = s.db.Close() @@ -2744,6 +2812,15 @@ func hashPassword(password string) (string, error) { } func verifyPassword(encoded, password string) bool { + if strings.HasPrefix(encoded, "sha256:") { + parts := strings.Split(encoded, ":") + if len(parts) != 3 || parts[1] == "" || parts[2] == "" { + return false + } + digest := sha256.Sum256([]byte(parts[1] + ":" + password)) + actual := hex.EncodeToString(digest[:]) + return subtle.ConstantTimeCompare([]byte(actual), []byte(parts[2])) == 1 + } parts := strings.Split(encoded, "$") if len(parts) != 4 || parts[0] != "pbkdf2_sha256" { return false