package main import ( "flag" "fmt" "os" "strings" "git.wntrmute.dev/kyle/mcias/internal/auth" "git.wntrmute.dev/kyle/mcias/internal/model" "golang.org/x/term" ) func (t *tool) runAccount(args []string) { if len(args) == 0 { fatalf("account requires a subcommand: list, get, create, set-password, set-status, reset-totp") } switch args[0] { case "list": t.accountList() case "get": t.accountGet(args[1:]) case "create": t.accountCreate(args[1:]) case "set-password": t.accountSetPassword(args[1:]) case "set-status": t.accountSetStatus(args[1:]) case "reset-totp": t.accountResetTOTP(args[1:]) default: fatalf("unknown account subcommand %q", args[0]) } } func (t *tool) accountList() { accounts, err := t.db.ListAccounts() if err != nil { fatalf("list accounts: %v", err) } if len(accounts) == 0 { fmt.Println("no accounts found") return } fmt.Printf("%-36s %-20s %-8s %-10s\n", "UUID", "USERNAME", "TYPE", "STATUS") fmt.Println(strings.Repeat("-", 80)) for _, a := range accounts { fmt.Printf("%-36s %-20s %-8s %-10s\n", a.UUID, a.Username, string(a.AccountType), string(a.Status)) } } func (t *tool) accountGet(args []string) { fs := flag.NewFlagSet("account get", flag.ExitOnError) id := fs.String("id", "", "account UUID (required)") _ = fs.Parse(args) if *id == "" { fatalf("account get: --id is required") } a, err := t.db.GetAccountByUUID(*id) if err != nil { fatalf("get account: %v", err) } fmt.Printf("UUID: %s\n", a.UUID) fmt.Printf("Username: %s\n", a.Username) fmt.Printf("Type: %s\n", a.AccountType) fmt.Printf("Status: %s\n", a.Status) fmt.Printf("TOTP required: %v\n", a.TOTPRequired) fmt.Printf("Created: %s\n", a.CreatedAt.Format("2006-01-02T15:04:05Z")) fmt.Printf("Updated: %s\n", a.UpdatedAt.Format("2006-01-02T15:04:05Z")) if a.DeletedAt != nil { fmt.Printf("Deleted: %s\n", a.DeletedAt.Format("2006-01-02T15:04:05Z")) } } func (t *tool) accountCreate(args []string) { fs := flag.NewFlagSet("account create", flag.ExitOnError) username := fs.String("username", "", "username (required)") accountType := fs.String("type", "human", "account type: human or system") _ = fs.Parse(args) if *username == "" { fatalf("account create: --username is required") } if *accountType != "human" && *accountType != "system" { fatalf("account create: --type must be human or system") } atype := model.AccountType(*accountType) a, err := t.db.CreateAccount(*username, atype, "") if err != nil { fatalf("create account: %v", err) } if err := t.db.WriteAuditEvent("account_created", nil, &a.ID, "", fmt.Sprintf(`{"actor":"mciasdb","username":%q}`, *username)); err != nil { fmt.Fprintf(os.Stderr, "warning: write audit event: %v\n", err) } fmt.Printf("created account %s (UUID: %s)\n", *username, a.UUID) } // accountSetPassword prompts twice for a new password, hashes it with // Argon2id, and updates the account's password_hash column. // // Security: No --password flag is provided; passwords must be entered // interactively so they never appear in shell history or process listings. // The password is hashed with Argon2id using OWASP-compliant parameters before // any database write. func (t *tool) accountSetPassword(args []string) { fs := flag.NewFlagSet("account set-password", flag.ExitOnError) id := fs.String("id", "", "account UUID (required)") _ = fs.Parse(args) if *id == "" { fatalf("account set-password: --id is required") } a, err := t.db.GetAccountByUUID(*id) if err != nil { fatalf("get account: %v", err) } fmt.Printf("Setting password for account %s (%s)\n", a.Username, a.UUID) password, err := readPassword("New password: ") if err != nil { fatalf("read password: %v", err) } confirm, err := readPassword("Confirm password: ") if err != nil { fatalf("read confirm: %v", err) } if password != confirm { fatalf("passwords do not match") } if password == "" { fatalf("password must not be empty") } hash, err := auth.HashPassword(password, auth.DefaultArgonParams()) if err != nil { fatalf("hash password: %v", err) } if err := t.db.UpdatePasswordHash(a.ID, hash); err != nil { fatalf("update password hash: %v", err) } if err := t.db.WriteAuditEvent("account_updated", nil, &a.ID, "", `{"actor":"mciasdb","action":"set_password"}`); err != nil { fmt.Fprintf(os.Stderr, "warning: write audit event: %v\n", err) } fmt.Printf("password updated for account %s\n", a.Username) } func (t *tool) accountSetStatus(args []string) { fs := flag.NewFlagSet("account set-status", flag.ExitOnError) id := fs.String("id", "", "account UUID (required)") status := fs.String("status", "", "new status: active, inactive, or deleted (required)") _ = fs.Parse(args) if *id == "" { fatalf("account set-status: --id is required") } if *status == "" { fatalf("account set-status: --status is required") } var newStatus model.AccountStatus switch *status { case "active": newStatus = model.AccountStatusActive case "inactive": newStatus = model.AccountStatusInactive case "deleted": newStatus = model.AccountStatusDeleted default: fatalf("account set-status: --status must be active, inactive, or deleted") } a, err := t.db.GetAccountByUUID(*id) if err != nil { fatalf("get account: %v", err) } if err := t.db.UpdateAccountStatus(a.ID, newStatus); err != nil { fatalf("update account status: %v", err) } eventType := "account_updated" if newStatus == model.AccountStatusDeleted { eventType = "account_deleted" } if err := t.db.WriteAuditEvent(eventType, nil, &a.ID, "", fmt.Sprintf(`{"actor":"mciasdb","status":%q}`, *status)); err != nil { fmt.Fprintf(os.Stderr, "warning: write audit event: %v\n", err) } fmt.Printf("account %s status set to %s\n", a.Username, *status) } // accountResetTOTP clears TOTP fields for the account, disabling the // TOTP requirement. This is a break-glass operation for locked-out users. func (t *tool) accountResetTOTP(args []string) { fs := flag.NewFlagSet("account reset-totp", flag.ExitOnError) id := fs.String("id", "", "account UUID (required)") _ = fs.Parse(args) if *id == "" { fatalf("account reset-totp: --id is required") } a, err := t.db.GetAccountByUUID(*id) if err != nil { fatalf("get account: %v", err) } if err := t.db.ClearTOTP(a.ID); err != nil { fatalf("clear TOTP: %v", err) } if err := t.db.WriteAuditEvent("totp_removed", nil, &a.ID, "", `{"actor":"mciasdb","action":"reset_totp"}`); err != nil { fmt.Fprintf(os.Stderr, "warning: write audit event: %v\n", err) } fmt.Printf("TOTP cleared for account %s\n", a.Username) } // readPassword reads a password from the terminal without echo. // Falls back to a regular line read if stdin is not a terminal (e.g. in tests). func readPassword(prompt string) (string, error) { fmt.Fprint(os.Stderr, prompt) fd := int(os.Stdin.Fd()) //nolint:gosec // G115: file descriptors are non-negative and fit in int on all supported platforms if term.IsTerminal(fd) { pw, err := term.ReadPassword(fd) fmt.Fprintln(os.Stderr) // newline after hidden input if err != nil { return "", fmt.Errorf("read password from terminal: %w", err) } return string(pw), nil } // Not a terminal: read a plain line (for piped input in tests). var line string _, err := fmt.Fscanln(os.Stdin, &line) if err != nil { return "", fmt.Errorf("read password: %w", err) } return line, nil }