From 5f218977e4cc61b103a3be4e459fe5aa6962006f Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 24 Sep 2026 17:56:34 +0200 Subject: [PATCH] feat(09-02): add admin create and reset-password commands - Commands hash with bcrypt, validate role codes, and revoke tokens on reset - Generated app main appends cabana.RuntimeCommands exactly once --- cabana/commands.go | 176 +++++++++++++++++++++++- internal/build/build.go | 2 + lagoon/backend_admin_migrations_test.go | 41 +++++- 3 files changed, 213 insertions(+), 6 deletions(-) diff --git a/cabana/commands.go b/cabana/commands.go index fe9eb02..b5cf919 100644 --- a/cabana/commands.go +++ b/cabana/commands.go @@ -1,11 +1,185 @@ package cabana import ( + "context" + "errors" + "fmt" + "strings" + "time" + "git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/bonfire" + "git.golem15.com/golem15/summercms/bouncer" + "git.golem15.com/golem15/summercms/lagoon" + "gorm.io/gorm" ) // RuntimeCommands returns the operator commands for backend administrators. func RuntimeCommands(app *backpack.App) []bonfire.Command { - return nil + return []bonfire.Command{ + { + Name: "admin:create", + Description: "Create an activated backend administrator", + Flags: []bonfire.Flag{ + {Name: "email", Description: "Admin email"}, + {Name: "password", Description: "Admin password"}, + {Name: "login", Description: "Login; defaults to the lower-cased email"}, + {Name: "superuser", Description: "Grant superuser", Bare: true}, + {Name: "role", Description: "Role code"}, + }, + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + return adminCreate(ctx, app, in, out) + }, + }, + { + Name: "admin:reset-password", + Description: "Reset a backend administrator password and revoke existing tokens", + Args: []bonfire.Arg{{ + Name: "identifier", + Description: "Login or email", + Required: true, + }}, + Flags: []bonfire.Flag{{ + Name: "password", + Description: "New password", + }}, + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + return adminResetPassword(ctx, app, in, out) + }, + }, + } +} + +func adminCreate(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error { + email := strings.ToLower(strings.TrimSpace(flagValue(in, "email"))) + password := flagValue(in, "password") + if email == "" || !strings.Contains(email, "@") || strings.TrimSpace(password) == "" { + return errors.New("cabana: email and password are required") + } + login := strings.TrimSpace(flagValue(in, "login")) + if login == "" { + login = email + } + roleCode := strings.TrimSpace(flagValue(in, "role")) + superuser := flagValue(in, "superuser") == "true" + return withAdminDB(ctx, app, func(gdb *gorm.DB) error { + return gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + roleID, err := roleIDByCode(tx, roleCode) + if err != nil { + return err + } + var existing int64 + if err := tx.Model(&BackendUser{}).Where("login = ? OR lower(email) = ?", login, email).Count(&existing).Error; err != nil { + return err + } + if existing > 0 { + return errors.New("cabana: admin already exists") + } + hash, err := bouncer.HashPassword(adminBcryptCost(app), password) + if err != nil { + return err + } + now := time.Now().UTC() + user := BackendUser{ + Login: login, + Email: email, + Password: hash, + IsActivated: true, + IsSuperuser: superuser, + RoleID: roleID, + ActivatedAt: &now, + } + if err := tx.Create(&user).Error; err != nil { + return err + } + out.Success(fmt.Sprintf("created admin %s <%s>", user.Login, user.Email)) + return nil + }) + }) +} + +func adminResetPassword(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error { + identifier := "" + if value, ok := in.Argument("identifier"); ok { + identifier = strings.TrimSpace(value) + } + if identifier == "" && len(in.Args()) > 0 { + identifier = strings.TrimSpace(in.Args()[0]) + } + password := flagValue(in, "password") + if identifier == "" || strings.TrimSpace(password) == "" { + return errors.New("cabana: identifier and password are required") + } + return withAdminDB(ctx, app, func(gdb *gorm.DB) error { + return gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var users []BackendUser + err := tx.Where("login = ? OR lower(email) = ?", identifier, strings.ToLower(identifier)).Find(&users).Error + if err != nil { + return err + } + if len(users) == 0 { + return errors.New("cabana: admin not found") + } + if len(users) > 1 { + return errors.New("cabana: ambiguous admin") + } + hash, err := bouncer.HashPassword(adminBcryptCost(app), password) + if err != nil { + return err + } + cutoff := time.Now().UTC().Add(time.Second) + if err := tx.Model(&BackendUser{}).Where("id = ?", users[0].ID).Updates(map[string]any{ + "password": hash, + "tokens_valid_after": cutoff, + }).Error; err != nil { + return err + } + out.Success(fmt.Sprintf("reset password for %s", users[0].Login)) + return nil + }) + }) +} + +func roleIDByCode(tx *gorm.DB, code string) (*uint, error) { + if code == "" { + return nil, nil + } + var ids []uint + if err := tx.Model(&BackendUserRole{}).Where("code = ?", code).Pluck("id", &ids).Error; err != nil { + return nil, err + } + switch len(ids) { + case 0: + return nil, fmt.Errorf("cabana: unknown role %q", code) + case 1: + return &ids[0], nil + default: + return nil, fmt.Errorf("cabana: ambiguous role %q", code) + } +} + +func flagValue(in bonfire.Input, name string) string { + if in == nil { + return "" + } + value, _ := in.Flag(name) + return value +} + +func withAdminDB(ctx context.Context, app *backpack.App, fn func(*gorm.DB) error) error { + if app == nil { + return errors.New("cabana: app is nil") + } + if gdb, ok := app.Lookup[*gorm.DB](); ok && gdb != nil { + return fn(gdb) + } + sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app) + if err != nil { + return err + } + defer sqlDB.Close() + if err := lagoon.Publish(app, sqlDB, gdb); err != nil { + return err + } + return fn(gdb) } diff --git a/internal/build/build.go b/internal/build/build.go index 390c593..53f5c73 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -87,6 +87,7 @@ func generateMain(m Manifest) ([]byte, error) { b.WriteString("\t\"os\"\n\n") b.WriteString("\t\"git.golem15.com/golem15/summercms/backpack\"\n") b.WriteString("\t\"git.golem15.com/golem15/summercms/bonfire\"\n") + b.WriteString("\t\"git.golem15.com/golem15/summercms/cabana\"\n") b.WriteString("\t\"git.golem15.com/golem15/summercms/compass\"\n") b.WriteString("\t\"git.golem15.com/golem15/summercms/lagoon\"\n") b.WriteString("\t\"git.golem15.com/golem15/summercms/pact\"\n") @@ -112,6 +113,7 @@ func generateMain(m Manifest) ([]byte, error) { b.WriteString("\tcommands := lagoon.RuntimeCommands(app, plugins)\n") b.WriteString("\tcommands = append(commands, surf.ServeCommand(app, plugins))\n") b.WriteString("\tcommands = append(commands, surf.RouteListCommand(app, plugins))\n") + b.WriteString("\tcommands = append(commands, cabana.RuntimeCommands(app)...)\n") b.WriteString("\tfor _, plugin := range plugins {\n") b.WriteString("\t\tif hasCommands, ok := plugin.(pact.HasCommands); ok {\n") b.WriteString("\t\t\tcommands = append(commands, hasCommands.Commands()...)\n") diff --git a/lagoon/backend_admin_migrations_test.go b/lagoon/backend_admin_migrations_test.go index 58d1f16..44bbce9 100644 --- a/lagoon/backend_admin_migrations_test.go +++ b/lagoon/backend_admin_migrations_test.go @@ -5,8 +5,8 @@ import ( "path/filepath" "strings" "testing" + "time" - "git.golem15.com/golem15/summercms/cabana" "git.golem15.com/golem15/summercms/party" "github.com/go-gormigrate/gormigrate/v2" "gorm.io/gorm" @@ -169,7 +169,7 @@ func TestBackendAdminWinterRow(t *testing.T) { if err := Migrate(gdb, nil); err != nil { t.Fatal(err) } - var role cabana.BackendUserRole + var role winterRole if err := gdb.Where("code = ?", "developer").First(&role).Error; err != nil { t.Fatal(err) } @@ -181,7 +181,7 @@ func TestBackendAdminWinterRow(t *testing.T) { VALUES ('Ada', 'Lovelace', 'ada', 'Ada@Example.Test', 'winter-hash', 'act', 'persist', 'reset', '{"backend.manage_access":1}', TRUE, FALSE, NULL, NULL, NULL, NOW(), NOW())`).Error; err != nil { t.Fatal(err) } - var user cabana.BackendUser + var user winterUser if err := gdb.Preload("Role").Where("login = ?", "ada").First(&user).Error; err != nil { t.Fatal(err) } @@ -202,10 +202,10 @@ func TestBackendAdminWinterRow(t *testing.T) { if err := gdb.Delete(&user).Error; err != nil { t.Fatal(err) } - if err := gdb.Where("login = ?", "ada").First(&cabana.BackendUser{}).Error; err == nil { + if err := gdb.Where("login = ?", "ada").First(&winterUser{}).Error; err == nil { t.Fatal("soft-deleted backend user remained visible") } - if err := gdb.Unscoped().Where("login = ?", "ada").First(&cabana.BackendUser{}).Error; err != nil { + if err := gdb.Unscoped().Where("login = ?", "ada").First(&winterUser{}).Error; err != nil { t.Fatalf("unscoped load: %v", err) } if err := gdb.Exec(`INSERT INTO backend_user_roles (name, code, is_system, created_at, updated_at) VALUES ('Editor A', 'shared', FALSE, NOW(), NOW())`).Error; err != nil { @@ -216,6 +216,37 @@ func TestBackendAdminWinterRow(t *testing.T) { } } +// winterUser mirrors cabana.BackendUser's column contract without importing +// cabana. Cabana commands call lagoon.OpenFromApp, so a lagoon test cannot +// import cabana. +type winterRole struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` + Code string `gorm:"column:code"` + IsSystem bool `gorm:"column:is_system"` +} + +func (winterRole) TableName() string { return "backend_user_roles" } + +type winterUser struct { + ID uint `gorm:"column:id;primaryKey"` + FirstName string `gorm:"column:first_name"` + LastName string `gorm:"column:last_name"` + Login string `gorm:"column:login"` + Email string `gorm:"column:email"` + Password string `gorm:"column:password"` + Permissions string `gorm:"column:permissions"` + IsActivated bool `gorm:"column:is_activated"` + IsSuperuser bool `gorm:"column:is_superuser"` + RoleID *uint `gorm:"column:role_id"` + LastLogin *time.Time `gorm:"column:last_login"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` + TokensValidAfter *time.Time `gorm:"column:tokens_valid_after"` + Role winterRole +} + +func (winterUser) TableName() string { return "backend_users" } + type systemRole struct { id int system bool