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
This commit is contained in:
@@ -1,11 +1,185 @@
|
|||||||
package cabana
|
package cabana
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/backpack"
|
"git.golem15.com/golem15/summercms/backpack"
|
||||||
"git.golem15.com/golem15/summercms/bonfire"
|
"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.
|
// RuntimeCommands returns the operator commands for backend administrators.
|
||||||
func RuntimeCommands(app *backpack.App) []bonfire.Command {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ func generateMain(m Manifest) ([]byte, error) {
|
|||||||
b.WriteString("\t\"os\"\n\n")
|
b.WriteString("\t\"os\"\n\n")
|
||||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/backpack\"\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/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/compass\"\n")
|
||||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/lagoon\"\n")
|
b.WriteString("\t\"git.golem15.com/golem15/summercms/lagoon\"\n")
|
||||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/pact\"\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 := lagoon.RuntimeCommands(app, plugins)\n")
|
||||||
b.WriteString("\tcommands = append(commands, surf.ServeCommand(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, surf.RouteListCommand(app, plugins))\n")
|
||||||
|
b.WriteString("\tcommands = append(commands, cabana.RuntimeCommands(app)...)\n")
|
||||||
b.WriteString("\tfor _, plugin := range plugins {\n")
|
b.WriteString("\tfor _, plugin := range plugins {\n")
|
||||||
b.WriteString("\t\tif hasCommands, ok := plugin.(pact.HasCommands); ok {\n")
|
b.WriteString("\t\tif hasCommands, ok := plugin.(pact.HasCommands); ok {\n")
|
||||||
b.WriteString("\t\t\tcommands = append(commands, hasCommands.Commands()...)\n")
|
b.WriteString("\t\t\tcommands = append(commands, hasCommands.Commands()...)\n")
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/cabana"
|
|
||||||
"git.golem15.com/golem15/summercms/party"
|
"git.golem15.com/golem15/summercms/party"
|
||||||
"github.com/go-gormigrate/gormigrate/v2"
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -169,7 +169,7 @@ func TestBackendAdminWinterRow(t *testing.T) {
|
|||||||
if err := Migrate(gdb, nil); err != nil {
|
if err := Migrate(gdb, nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var role cabana.BackendUserRole
|
var role winterRole
|
||||||
if err := gdb.Where("code = ?", "developer").First(&role).Error; err != nil {
|
if err := gdb.Where("code = ?", "developer").First(&role).Error; err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var user cabana.BackendUser
|
var user winterUser
|
||||||
if err := gdb.Preload("Role").Where("login = ?", "ada").First(&user).Error; err != nil {
|
if err := gdb.Preload("Role").Where("login = ?", "ada").First(&user).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -202,10 +202,10 @@ func TestBackendAdminWinterRow(t *testing.T) {
|
|||||||
if err := gdb.Delete(&user).Error; err != nil {
|
if err := gdb.Delete(&user).Error; err != nil {
|
||||||
t.Fatal(err)
|
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")
|
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)
|
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 {
|
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 {
|
type systemRole struct {
|
||||||
id int
|
id int
|
||||||
system bool
|
system bool
|
||||||
|
|||||||
Reference in New Issue
Block a user