// Command provision-owner adds an owner to an EXISTING organization and prints a one time activation // link. Unlike provision-org (which creates a fresh org), this targets an org that already holds data, // so an operator can take ownership of a seeded demo org without moving the data. It writes only the // owner user and the invite; the owner then sets a password and enrolls MFA through the accept flow. // Migrator role only (it inserts directly into the users and user_invites tables it owns). // // Env: MM_MIGRATOR_DATABASE_URL (or MM_DATABASE_URL). Flags: org, email, name, base-url, expiry-hours. package main import ( "context" "flag" "fmt" "log" "os" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/platform/security" ) func main() { org := flag.String("org", "", "existing organization id (uuid)") email := flag.String("email", "", "owner email") name := flag.String("name", "", "owner full name") baseURL := flag.String("base-url", "https://screenmatch.thatwebhostingguy.com", "site base url for the activation link") expiryHours := flag.Int("expiry-hours", 168, "activation link lifetime in hours") flag.Parse() if *org == "" || *email == "" || *name == "" { log.Fatal("provision-owner: org, email, and name are required") } orgID, err := uuid.Parse(*org) if err != nil { log.Fatalf("provision-owner: bad org id %q: %v", *org, err) } dsn := firstNonEmpty(os.Getenv("MM_MIGRATOR_DATABASE_URL"), os.Getenv("MM_DATABASE_URL")) if dsn == "" { log.Fatal("provision-owner: set MM_MIGRATOR_DATABASE_URL (the BYPASSRLS migrator DSN)") } // The token and its hash come from the same function the accept flow validates against, so they // match by construction. Only the hash is stored; the raw token rides in the activation link. token, tokenHash, err := security.NewSessionToken() if err != nil { log.Fatalf("provision-owner: generate token: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() conn, err := pgx.Connect(ctx, dsn) if err != nil { log.Fatalf("provision-owner: connect: %v", err) } defer conn.Close(ctx) var orgName string if err := conn.QueryRow(ctx, `SELECT name FROM organizations WHERE id = $1`, orgID).Scan(&orgName); err != nil { log.Fatalf("provision-owner: org %s not found: %v", orgID, err) } var userID uuid.UUID err = conn.QueryRow(ctx, `INSERT INTO users (organization_id, email, full_name, role, status, mfa_enrolled) VALUES ($1, $2, $3, 'owner', 'invited', false) RETURNING id`, orgID, *email, *name).Scan(&userID) if err != nil { log.Fatalf("provision-owner: create owner (email may already exist globally): %v", err) } if _, err := conn.Exec(ctx, `INSERT INTO user_invites (organization_id, email_normalized, invited_user_id, token_hash, role, expires_at, created_by) VALUES ($1, lower($2), $3, $4, 'owner', now() + make_interval(secs => $5), $3)`, orgID, *email, userID, tokenHash, *expiryHours*3600); err != nil { log.Fatalf("provision-owner: create invite: %v", err) } fmt.Printf("owner provisioned in org %q (%s)\n", orgName, orgID) fmt.Printf("owner user id: %s\n", userID) fmt.Printf("ACTIVATION LINK: %s/accept?token=%s\n", strings.TrimRight(*baseURL, "/"), token) } func firstNonEmpty(vals ...string) string { for _, v := range vals { if s := strings.TrimSpace(v); s != "" { return s } } return "" }