diff --git a/apps/api/cmd/seed-platform-admin/main.go b/apps/api/cmd/seed-platform-admin/main.go new file mode 100644 index 0000000..66f8c7b --- /dev/null +++ b/apps/api/cmd/seed-platform-admin/main.go @@ -0,0 +1,222 @@ +// Command seed-platform-admin upserts the first (or additional) platform admin +// for greenfield / production deploys where no legacy admin_users cutover ran +// and seed-demo must not be used. +// +// Usage: +// +// cd apps/api +// go run ./cmd/seed-platform-admin \ +// -postgres "$env:DATABASE_URL" \ +// -email you@example.com \ +// -password 'choose-a-strong-password' \ +// -confirm +// +// Env aliases: PLATFORM_ADMIN_EMAIL, PLATFORM_ADMIN_PASSWORD, DATABASE_URL. +// +// -promote-only grants admin on an existing user without changing the password +// (-password is ignored). Always requires -confirm. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/descrybe/descrybe-v2/apps/api/internal/auth" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +func main() { + postgresURL := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL") + email := flag.String("email", os.Getenv("PLATFORM_ADMIN_EMAIL"), "Platform admin email") + password := flag.String("password", os.Getenv("PLATFORM_ADMIN_PASSWORD"), "Password (min 8; ignored with -promote-only)") + name := flag.String("name", "", "Display name (defaults to email local-part)") + promoteOnly := flag.Bool("promote-only", false, "Grant platform admin on an existing user; do not set password") + confirm := flag.Bool("confirm", false, "Required: acknowledge this writes is_platform_admin + staff_role=admin") + flag.Parse() + + opts, err := parseOptions(*postgresURL, *email, *password, *name, *promoteOnly, *confirm, os.Getenv("APP_ENV")) + if err != nil { + log.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + pg, err := pgxpool.New(ctx, opts.PostgresURL) + if err != nil { + log.Fatalf("postgres: %v", err) + } + defer pg.Close() + + userID, created, err := upsertPlatformAdmin(ctx, pg, opts) + if err != nil { + log.Fatal(err) + } + + action := "updated" + if created { + action = "created" + } + fmt.Printf("platform admin %s\n", action) + fmt.Printf(" id: %s\n", userID) + fmt.Printf(" email: %s\n", opts.Email) + fmt.Printf(" is_platform_admin: true\n") + fmt.Printf(" staff_role: %s\n", auth.StaffRoleAdmin) + if opts.PromoteOnly { + fmt.Println(" password: unchanged (-promote-only)") + } else { + fmt.Println(" password: set (argon2id)") + } + fmt.Println() + fmt.Println("Sign in on the web app, then open /admin.") +} + +type options struct { + PostgresURL string + Email string + Password string + Name string + PromoteOnly bool +} + +func parseOptions(postgresURL, email, password, name string, promoteOnly, confirm bool, appEnv string) (options, error) { + if !confirm { + return options{}, fmt.Errorf("-confirm is required (refuses silent privilege grants)") + } + pg := strings.TrimSpace(postgresURL) + if pg == "" { + return options{}, fmt.Errorf("-postgres / DATABASE_URL is required") + } + emailNorm := strings.ToLower(strings.TrimSpace(email)) + if emailNorm == "" || !strings.Contains(emailNorm, "@") { + return options{}, fmt.Errorf("-email / PLATFORM_ADMIN_EMAIL is required") + } + if err := rejectLocalDemoAccount(emailNorm, appEnv); err != nil { + return options{}, err + } + + display := strings.TrimSpace(name) + if display == "" { + display = emailNorm + if i := strings.IndexByte(display, '@'); i > 0 { + display = display[:i] + } + } + + opts := options{ + PostgresURL: pg, + Email: emailNorm, + Name: display, + PromoteOnly: promoteOnly, + } + if promoteOnly { + return opts, nil + } + pass := password // keep as provided (do not trim interior spaces) + if len(pass) < 8 { + return options{}, fmt.Errorf("-password / PLATFORM_ADMIN_PASSWORD must be at least 8 characters") + } + if err := rejectDemoPassword(pass, appEnv); err != nil { + return options{}, err + } + opts.Password = pass + return opts, nil +} + +func rejectLocalDemoAccount(email, appEnv string) error { + if !isProductionEnv(appEnv) { + return nil + } + if strings.HasSuffix(email, ".local") || strings.HasSuffix(email, "@descrybe.test") { + return fmt.Errorf("refusing demo/local emails in production APP_ENV=%q", strings.TrimSpace(appEnv)) + } + return nil +} + +func rejectDemoPassword(password, appEnv string) error { + if !isProductionEnv(appEnv) { + return nil + } + if password == "DemoPass123!" { + return fmt.Errorf("refusing seed-demo password in production") + } + return nil +} + +func isProductionEnv(appEnv string) bool { + switch strings.ToLower(strings.TrimSpace(appEnv)) { + case "production", "prod": + return true + default: + return false + } +} + +func upsertPlatformAdmin(ctx context.Context, pg *pgxpool.Pool, opts options) (uuid.UUID, bool, error) { + if opts.PromoteOnly { + var userID uuid.UUID + err := pg.QueryRow(ctx, ` + UPDATE users + SET is_platform_admin = true, + staff_role = $2, + is_active = true, + updated_at = now() + WHERE email = $1 + RETURNING id`, opts.Email, auth.StaffRoleAdmin).Scan(&userID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, false, fmt.Errorf("user %q not found (-promote-only requires an existing account)", opts.Email) + } + return uuid.Nil, false, fmt.Errorf("promote: %w", err) + } + return userID, false, nil + } + + hash, err := auth.HashPassword(opts.Password) + if err != nil { + return uuid.Nil, false, fmt.Errorf("hash password: %w", err) + } + + tx, err := pg.Begin(ctx) + if err != nil { + return uuid.Nil, false, fmt.Errorf("begin: %w", err) + } + defer tx.Rollback(ctx) + + var existed bool + err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`, opts.Email).Scan(&existed) + if err != nil { + return uuid.Nil, false, fmt.Errorf("lookup: %w", err) + } + + var userID uuid.UUID + err = tx.QueryRow(ctx, ` + INSERT INTO users ( + email, name, password_hash, must_set_password, + is_platform_admin, staff_role, is_active, updated_at + ) VALUES ($1, $2, $3, false, true, $4, true, now()) + ON CONFLICT (email) DO UPDATE SET + name = EXCLUDED.name, + password_hash = EXCLUDED.password_hash, + must_set_password = false, + is_platform_admin = true, + staff_role = EXCLUDED.staff_role, + is_active = true, + updated_at = now() + RETURNING id`, opts.Email, opts.Name, hash, auth.StaffRoleAdmin).Scan(&userID) + if err != nil { + return uuid.Nil, false, fmt.Errorf("upsert user: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return uuid.Nil, false, fmt.Errorf("commit: %w", err) + } + return userID, !existed, nil +} diff --git a/apps/api/cmd/seed-platform-admin/main_test.go b/apps/api/cmd/seed-platform-admin/main_test.go new file mode 100644 index 0000000..a327eae --- /dev/null +++ b/apps/api/cmd/seed-platform-admin/main_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "strings" + "testing" +) + +func TestParseOptionsRequiresConfirm(t *testing.T) { + t.Parallel() + _, err := parseOptions("postgres://x", "a@b.com", "password1", "", false, false, "development") + if err == nil || !strings.Contains(err.Error(), "-confirm") { + t.Fatalf("err = %v, want -confirm required", err) + } +} + +func TestParseOptionsRequiresEmailAndPassword(t *testing.T) { + t.Parallel() + _, err := parseOptions("postgres://x", "", "password1", "", false, true, "development") + if err == nil { + t.Fatal("expected email error") + } + _, err = parseOptions("postgres://x", "a@b.com", "short", "", false, true, "development") + if err == nil || !strings.Contains(err.Error(), "8") { + t.Fatalf("err = %v, want min length", err) + } +} + +func TestParseOptionsPromoteOnlySkipsPassword(t *testing.T) { + t.Parallel() + opts, err := parseOptions("postgres://x", "Ops@Example.COM", "", "Ops", true, true, "production") + if err != nil { + t.Fatal(err) + } + if opts.Email != "ops@example.com" { + t.Fatalf("email = %q", opts.Email) + } + if opts.Password != "" || !opts.PromoteOnly { + t.Fatalf("promote-only opts = %+v", opts) + } + if opts.Name != "Ops" { + t.Fatalf("name = %q", opts.Name) + } +} + +func TestParseOptionsRejectsDemoInProduction(t *testing.T) { + t.Parallel() + _, err := parseOptions("postgres://x", "demo@descrybe.local", "securepass", "", false, true, "production") + if err == nil || !strings.Contains(err.Error(), "demo/local") { + t.Fatalf("err = %v, want demo/local refuse", err) + } + _, err = parseOptions("postgres://x", "you@example.com", "DemoPass123!", "", false, true, "prod") + if err == nil || !strings.Contains(err.Error(), "seed-demo password") { + t.Fatalf("err = %v, want demo password refuse", err) + } +} + +func TestParseOptionsAllowsDemoLocally(t *testing.T) { + t.Parallel() + opts, err := parseOptions("postgres://x", "demo@descrybe.local", "DemoPass123!", "", false, true, "development") + if err != nil { + t.Fatal(err) + } + if opts.Email != "demo@descrybe.local" { + t.Fatalf("email = %q", opts.Email) + } +}