343 lines
11 KiB
Go
343 lines
11 KiB
Go
// Command seed-platform-admin upserts a platform admin with a tenant company
|
|
// (membership admin + Free plan), matching self-serve register. Without a
|
|
// company, login leaves session company empty and every /api/* tenant call
|
|
// returns 400 "company not selected".
|
|
//
|
|
// Usage:
|
|
//
|
|
// npm run seed:platform-admin -- --email you@example.com --password '…'
|
|
// npm run seed:platform-admin -- --email you@example.com --promote-only
|
|
// npm run seed:platform-admin -- --email you@example.com --password '…' --company 'Acme Ops'
|
|
//
|
|
// Credentials are CLI-only (never from .env). DATABASE_URL may come from the
|
|
// shell or monorepo-root .env via config.LoadDotEnv. 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/descrybe/descrybe-v2/apps/api/internal/billing"
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
func main() {
|
|
config.LoadDotEnv()
|
|
|
|
postgresURL := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL")
|
|
email := flag.String("email", "", "Platform admin email (required; do not put in .env)")
|
|
password := flag.String("password", "", "Password (min 8; ignored with -promote-only; do not put in .env)")
|
|
name := flag.String("name", "", "Display name (defaults to email local-part)")
|
|
company := flag.String("company", "", "Company/workspace name (default: \"<name> workspace\")")
|
|
noCompany := flag.Bool("no-company", false, "Skip company creation (staff-only; dashboard tenant APIs will 400)")
|
|
promoteOnly := flag.Bool("promote-only", false, "Grant platform admin on an existing user; do not set password")
|
|
confirm := flag.Bool("confirm", false, "Required: acknowledge privilege + optional company writes")
|
|
flag.Parse()
|
|
|
|
opts, err := parseOptions(*postgresURL, *email, *password, *name, *company, *promoteOnly, *noCompany, *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)")
|
|
}
|
|
|
|
if opts.NoCompany {
|
|
fmt.Println(" company: skipped (-no-company)")
|
|
fmt.Println()
|
|
fmt.Println("WARNING: without a company, dashboard /api/* calls return 400 company not selected.")
|
|
return
|
|
}
|
|
|
|
companyID, companyName, companyCreated, err := ensureAdminCompany(ctx, pg, userID, opts.CompanyName)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
billingSvc := &billing.Service{Pool: pg}
|
|
planNote, err := ensureFreePlan(ctx, pg, billingSvc, companyID)
|
|
if err != nil {
|
|
log.Fatalf("provision Free plan: %v", err)
|
|
}
|
|
companyAction := "existing"
|
|
if companyCreated {
|
|
companyAction = "created"
|
|
}
|
|
fmt.Printf(" company: %s (%s) [%s]\n", companyName, companyID, companyAction)
|
|
fmt.Printf(" plan: %s\n", planNote)
|
|
fmt.Println()
|
|
fmt.Println("Sign in on the web app (company is selected on login). Platform panel: /admin.")
|
|
}
|
|
|
|
type options struct {
|
|
PostgresURL string
|
|
Email string
|
|
Password string
|
|
Name string
|
|
CompanyName string
|
|
PromoteOnly bool
|
|
NoCompany bool
|
|
}
|
|
|
|
func parseOptions(postgresURL, email, password, name, company string, promoteOnly, noCompany, 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 (export it, pass -postgres, or put DATABASE_URL in monorepo-root .env — the API loads .env, your shell may not)")
|
|
}
|
|
emailNorm := strings.ToLower(strings.TrimSpace(email))
|
|
if emailNorm == "" || !strings.Contains(emailNorm, "@") {
|
|
return options{}, fmt.Errorf("-email is required (pass on the CLI; do not store admin credentials in .env)")
|
|
}
|
|
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]
|
|
}
|
|
}
|
|
|
|
companyName := strings.TrimSpace(company)
|
|
if !noCompany && companyName == "" {
|
|
companyName = display + " workspace"
|
|
}
|
|
|
|
opts := options{
|
|
PostgresURL: pg,
|
|
Email: emailNorm,
|
|
Name: display,
|
|
CompanyName: companyName,
|
|
PromoteOnly: promoteOnly,
|
|
NoCompany: noCompany,
|
|
}
|
|
if promoteOnly {
|
|
return opts, nil
|
|
}
|
|
pass := password // keep as provided (do not trim interior spaces)
|
|
if len(pass) < 8 {
|
|
return options{}, fmt.Errorf("-password must be at least 8 characters (pass on the CLI; do not store in .env)")
|
|
}
|
|
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
|
|
}
|
|
|
|
// ensureAdminCompany attaches the user to a company as membership admin.
|
|
// If they already have any active membership, reuses the first company (idempotent repair).
|
|
func ensureAdminCompany(ctx context.Context, pg *pgxpool.Pool, userID uuid.UUID, companyName string) (uuid.UUID, string, bool, error) {
|
|
var existingID uuid.UUID
|
|
var existingName string
|
|
err := pg.QueryRow(ctx, `
|
|
SELECT c.id, c.name
|
|
FROM memberships m
|
|
JOIN companies c ON c.id = m.company_id
|
|
WHERE m.user_id = $1 AND m.status = 'active'
|
|
ORDER BY m.created_at NULLS LAST, c.name
|
|
LIMIT 1`, userID).Scan(&existingID, &existingName)
|
|
if err == nil {
|
|
_, _ = pg.Exec(ctx, `
|
|
UPDATE memberships
|
|
SET role = 'admin', status = 'active', updated_at = now()
|
|
WHERE user_id = $1 AND company_id = $2`, userID, existingID)
|
|
return existingID, existingName, false, nil
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return uuid.Nil, "", false, fmt.Errorf("lookup memberships: %w", err)
|
|
}
|
|
|
|
tx, err := pg.Begin(ctx)
|
|
if err != nil {
|
|
return uuid.Nil, "", false, fmt.Errorf("begin company: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var companyID uuid.UUID
|
|
err = tx.QueryRow(ctx, `INSERT INTO companies (name) VALUES ($1) RETURNING id`, companyName).Scan(&companyID)
|
|
if err != nil {
|
|
return uuid.Nil, "", false, fmt.Errorf("insert company: %w", err)
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO memberships (company_id, user_id, role, status)
|
|
VALUES ($1, $2, 'admin', 'active')`, companyID, userID)
|
|
if err != nil {
|
|
return uuid.Nil, "", false, fmt.Errorf("insert membership: %w", err)
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO company_settings (company_id) VALUES ($1)
|
|
ON CONFLICT DO NOTHING`, companyID)
|
|
if err != nil {
|
|
return uuid.Nil, "", false, fmt.Errorf("company_settings: %w", err)
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO credit_balances (company_id) VALUES ($1)
|
|
ON CONFLICT DO NOTHING`, companyID)
|
|
if err != nil {
|
|
return uuid.Nil, "", false, fmt.Errorf("credit_balances: %w", err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return uuid.Nil, "", false, fmt.Errorf("commit company: %w", err)
|
|
}
|
|
return companyID, companyName, true, nil
|
|
}
|
|
|
|
// ensureFreePlan assigns Free when missing. Syncs company_plans id sequence first —
|
|
// cutover/import DBs often leave BIGSERIAL behind MAX(id), which surfaces as
|
|
// duplicate key on company_plans_pkey during INSERT.
|
|
func ensureFreePlan(ctx context.Context, pg *pgxpool.Pool, billingSvc *billing.Service, companyID uuid.UUID) (string, error) {
|
|
if err := billingSvc.EnsureDefaultPlans(ctx); err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := pg.Exec(ctx, `
|
|
SELECT setval(
|
|
pg_get_serial_sequence('company_plans', 'id'),
|
|
GREATEST(1, (SELECT COALESCE(MAX(id), 1) FROM company_plans))
|
|
)`); err != nil {
|
|
return "", fmt.Errorf("sync company_plans sequence: %w", err)
|
|
}
|
|
assigned, err := billingSvc.AssignPlanByNameIfMissing(ctx, companyID, "Free")
|
|
if err != nil {
|
|
// Race / still-stuck sequence: if an active plan exists, treat as OK.
|
|
if has, hasErr := billingSvc.HasActivePlan(ctx, companyID); hasErr == nil && has {
|
|
return "already active (assign skipped after error)", nil
|
|
}
|
|
return "", err
|
|
}
|
|
if assigned {
|
|
return "Free (assigned)", nil
|
|
}
|
|
return "already active (unchanged)", nil
|
|
}
|