Files
mcias/cmd/mciasctl/main.go
Kyle Isom cff7276293 mciasctl: convert from flag to cobra
Adds shell completion support (zsh, bash, fish) via cobra's built-in
completion command. All existing behavior and security measures are
preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:48:24 -07:00

1086 lines
30 KiB
Go

// Command mciasctl is the MCIAS admin CLI.
//
// It connects to a running mciassrv instance and provides subcommands for
// managing accounts, roles, tokens, Postgres credentials, policy rules, and
// account tags.
//
// Usage:
//
// mciasctl [global flags] <command> [args]
//
// Global flags:
//
// --server URL of the mciassrv instance (default: https://mcias.metacircular.net:8443)
// --token Bearer token for authentication (or set MCIAS_TOKEN env var)
// --cacert Path to CA certificate for TLS verification (optional)
//
// Commands:
//
// auth login --username NAME [--totp CODE]
// auth change-password (passwords always prompted interactively)
//
// account list
// account create --username NAME [--type human|system]
// account get --id UUID
// account update --id UUID [--status active|inactive]
// account delete --id UUID
// account set-password --id UUID
//
// role list --id UUID
// role set --id UUID --roles role1,role2,...
// role grant --id UUID --role ROLE
// role revoke --id UUID --role ROLE
//
// token issue --id UUID
// token revoke --jti JTI
//
// pgcreds list
// pgcreds set --id UUID --host HOST [--port PORT] --db DB --user USER [--password PASS]
// pgcreds get --id UUID
//
// policy list
// policy create --description STR --json FILE [--priority N] [--not-before RFC3339] [--expires-at RFC3339]
// policy get --id ID
// policy update --id ID [--priority N] [--enabled true|false] [--not-before RFC3339] [--expires-at RFC3339] [--clear-not-before] [--clear-expires-at]
// policy delete --id ID
//
// tag list --id UUID
// tag set --id UUID --tags tag1,tag2,...
package main
import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"golang.org/x/term"
)
// Global flags bound by the root command's PersistentFlags.
var (
serverURL string
tokenFlag string
caCertPath string
)
func main() {
root := &cobra.Command{
Use: "mciasctl",
Short: "MCIAS admin CLI",
Long: "mciasctl connects to a running mciassrv instance and provides subcommands\nfor managing accounts, roles, tokens, Postgres credentials, policy rules,\nand account tags.",
}
root.PersistentFlags().StringVar(&serverURL, "server", "https://mcias.metacircular.net:8443", "mciassrv base URL")
root.PersistentFlags().StringVar(&tokenFlag, "token", "", "bearer token (or set MCIAS_TOKEN)")
root.PersistentFlags().StringVar(&caCertPath, "cacert", "", "path to CA certificate for TLS")
root.AddCommand(authCmd())
root.AddCommand(accountCmd())
root.AddCommand(roleCmd())
root.AddCommand(tokenCmd())
root.AddCommand(pgcredsCmd())
root.AddCommand(policyCmd())
root.AddCommand(tagCmd())
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
// newController builds a controller from the global flags. Called at the start
// of every leaf command's Run function.
func newController() *controller {
// Resolve token from flag or environment.
bearerToken := tokenFlag
if bearerToken == "" {
bearerToken = os.Getenv("MCIAS_TOKEN")
}
client, err := newHTTPClient(caCertPath)
if err != nil {
fatalf("build HTTP client: %v", err)
}
return &controller{
serverURL: strings.TrimRight(serverURL, "/"),
token: bearerToken,
client: client,
}
}
// controller holds shared state for all subcommands.
type controller struct {
client *http.Client
serverURL string
token string
}
// ---- auth subcommands ----
func authCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "Authentication commands",
}
cmd.AddCommand(authLoginCmd())
cmd.AddCommand(authChangePasswordCmd())
return cmd
}
// authLoginCmd authenticates with the server using username and password, then
// prints the resulting bearer token to stdout. The password is always prompted
// interactively; it is never accepted as a command-line flag to prevent it from
// appearing in shell history, ps output, and process argument lists.
//
// Security: terminal echo is disabled during password entry
// (golang.org/x/term.ReadPassword); the raw byte slice is zeroed after use.
func authLoginCmd() *cobra.Command {
var username string
var totpCode string
cmd := &cobra.Command{
Use: "login",
Short: "Obtain a bearer token",
Long: `Obtain a bearer token. Password is always prompted interactively
(never accepted as a flag) to avoid shell-history exposure.
Token is written to stdout; expiry to stderr.
Example: export MCIAS_TOKEN=$(mciasctl auth login --username alice)`,
Run: func(cmd *cobra.Command, args []string) {
if username == "" {
fatalf("auth login: --username is required")
}
c := newController()
// Security: always prompt interactively; never accept password as a flag.
// This prevents the credential from appearing in shell history, ps output,
// and /proc/PID/cmdline.
fmt.Fprint(os.Stderr, "Password: ")
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
fmt.Fprintln(os.Stderr) // newline after hidden input
if err != nil {
fatalf("read password: %v", err)
}
passwd := string(raw)
// Zero the raw byte slice once copied into the string.
for i := range raw {
raw[i] = 0
}
body := map[string]string{
"username": username,
"password": passwd,
}
if totpCode != "" {
body["totp_code"] = totpCode
}
var result struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
c.doRequest("POST", "/v1/auth/login", body, &result)
// Print token to stdout so it can be captured by scripts, e.g.:
// export MCIAS_TOKEN=$(mciasctl auth login --username alice)
fmt.Println(result.Token)
if result.ExpiresAt != "" {
fmt.Fprintf(os.Stderr, "expires: %s\n", result.ExpiresAt)
}
},
}
cmd.Flags().StringVar(&username, "username", "", "username (required)")
cmd.Flags().StringVar(&totpCode, "totp", "", "TOTP code (required if TOTP is enrolled)")
return cmd
}
// authChangePasswordCmd allows an authenticated user to change their own password.
// A valid bearer token must be set (via --token flag or MCIAS_TOKEN env var).
// Both passwords are always prompted interactively; they are never accepted as
// command-line flags to prevent them from appearing in shell history, ps
// output, and process argument lists.
//
// Security: terminal echo is disabled during entry (golang.org/x/term);
// raw byte slices are zeroed after use. The server requires the current
// password to prevent token-theft attacks. On success all other active
// sessions are revoked server-side.
func authChangePasswordCmd() *cobra.Command {
return &cobra.Command{
Use: "change-password",
Short: "Change the current user's own password",
Long: `Change the current user's own password. Requires a valid bearer
token. Current and new passwords are always prompted interactively.
Revokes all other active sessions on success.`,
Run: func(cmd *cobra.Command, args []string) {
c := newController()
// Security: always prompt interactively; never accept passwords as flags.
fmt.Fprint(os.Stderr, "Current password: ")
rawCurrent, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
fmt.Fprintln(os.Stderr)
if err != nil {
fatalf("read current password: %v", err)
}
currentPasswd := string(rawCurrent)
for i := range rawCurrent {
rawCurrent[i] = 0
}
fmt.Fprint(os.Stderr, "New password: ")
rawNew, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
fmt.Fprintln(os.Stderr)
if err != nil {
fatalf("read new password: %v", err)
}
newPasswd := string(rawNew)
for i := range rawNew {
rawNew[i] = 0
}
body := map[string]string{
"current_password": currentPasswd,
"new_password": newPasswd,
}
c.doRequest("PUT", "/v1/auth/password", body, nil)
fmt.Println("password changed; other active sessions revoked")
},
}
}
// ---- account subcommands ----
func accountCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "account",
Short: "Account management commands",
}
cmd.AddCommand(accountListCmd())
cmd.AddCommand(accountCreateCmd())
cmd.AddCommand(accountGetCmd())
cmd.AddCommand(accountUpdateCmd())
cmd.AddCommand(accountDeleteCmd())
cmd.AddCommand(accountSetPasswordCmd())
return cmd
}
func accountListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all accounts",
Run: func(cmd *cobra.Command, args []string) {
c := newController()
var result []json.RawMessage
c.doRequest("GET", "/v1/accounts", nil, &result)
printJSON(result)
},
}
}
func accountCreateCmd() *cobra.Command {
var username string
var accountType string
cmd := &cobra.Command{
Use: "create",
Short: "Create a new account",
Run: func(cmd *cobra.Command, args []string) {
if username == "" {
fatalf("account create: --username is required")
}
c := newController()
// Security: always prompt interactively for human-account passwords; never
// accept them as a flag. Terminal echo is disabled; the raw byte slice is
// zeroed after conversion to string. System accounts have no password.
var passwd string
if accountType == "human" {
fmt.Fprint(os.Stderr, "Password: ")
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
fmt.Fprintln(os.Stderr)
if err != nil {
fatalf("read password: %v", err)
}
passwd = string(raw)
for i := range raw {
raw[i] = 0
}
}
body := map[string]string{
"username": username,
"account_type": accountType,
}
if passwd != "" {
body["password"] = passwd
}
var result json.RawMessage
c.doRequest("POST", "/v1/accounts", body, &result)
printJSON(result)
},
}
cmd.Flags().StringVar(&username, "username", "", "username (required)")
cmd.Flags().StringVar(&accountType, "type", "human", "account type: human or system")
return cmd
}
func accountGetCmd() *cobra.Command {
var id string
cmd := &cobra.Command{
Use: "get",
Short: "Get account details",
Run: func(cmd *cobra.Command, args []string) {
if id == "" {
fatalf("account get: --id is required")
}
c := newController()
var result json.RawMessage
c.doRequest("GET", "/v1/accounts/"+id, nil, &result)
printJSON(result)
},
}
cmd.Flags().StringVar(&id, "id", "", "account UUID (required)")
return cmd
}
func accountUpdateCmd() *cobra.Command {
var id string
var status string
cmd := &cobra.Command{
Use: "update",
Short: "Update an account",
Run: func(cmd *cobra.Command, args []string) {
if id == "" {
fatalf("account update: --id is required")
}
if status == "" {
fatalf("account update: --status is required")
}
c := newController()
body := map[string]string{"status": status}
c.doRequest("PATCH", "/v1/accounts/"+id, body, nil)
fmt.Println("account updated")
},
}
cmd.Flags().StringVar(&id, "id", "", "account UUID (required)")
cmd.Flags().StringVar(&status, "status", "", "new status: active or inactive")
return cmd
}
func accountDeleteCmd() *cobra.Command {
var id string
cmd := &cobra.Command{
Use: "delete",
Short: "Delete an account",
Run: func(cmd *cobra.Command, args []string) {
if id == "" {
fatalf("account delete: --id is required")
}
c := newController()
c.doRequest("DELETE", "/v1/accounts/"+id, nil, nil)
fmt.Println("account deleted")
},
}
cmd.Flags().StringVar(&id, "id", "", "account UUID (required)")
return cmd
}
// accountSetPasswordCmd resets a human account's password (admin operation).
// No current password is required. All active sessions for the target account
// are revoked by the server on success.
//
// Security: the new password is always prompted interactively; it is never
// accepted as a command-line flag to prevent it from appearing in shell
// history, ps output, and process argument lists. Terminal echo is disabled
// (golang.org/x/term); the raw byte slice is zeroed after use.
func accountSetPasswordCmd() *cobra.Command {
var id string
cmd := &cobra.Command{
Use: "set-password",
Short: "Reset a human account's password (admin)",
Long: `Admin: reset a human account's password without requiring the
current password. New password is always prompted interactively.
Revokes all active sessions for the account.`,
Run: func(cmd *cobra.Command, args []string) {
if id == "" {
fatalf("account set-password: --id is required")
}
c := newController()
// Security: always prompt interactively; never accept password as a flag.
fmt.Fprint(os.Stderr, "New password: ")
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
fmt.Fprintln(os.Stderr)
if err != nil {
fatalf("read password: %v", err)
}
passwd := string(raw)
for i := range raw {
raw[i] = 0
}
body := map[string]string{"new_password": passwd}
c.doRequest("PUT", "/v1/accounts/"+id+"/password", body, nil)
fmt.Println("password updated; all active sessions revoked")
},
}
cmd.Flags().StringVar(&id, "id", "", "account UUID (required)")
return cmd
}
// ---- role subcommands ----
func roleCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "role",
Short: "Role management commands",
}
cmd.AddCommand(roleListCmd())
cmd.AddCommand(roleSetCmd())
cmd.AddCommand(roleGrantCmd())
cmd.AddCommand(roleRevokeCmd())
return cmd
}
func roleListCmd() *cobra.Command {
var id string
cmd := &cobra.Command{
Use: "list",
Short: "List roles for an account",
Run: func(cmd *cobra.Command, args []string) {
if id == "" {
fatalf("role list: --id is required")
}
c := newController()
var result json.RawMessage
c.doRequest("GET", "/v1/accounts/"+id+"/roles", nil, &result)
printJSON(result)
},
}
cmd.Flags().StringVar(&id, "id", "", "account UUID (required)")
return cmd
}
func roleSetCmd() *cobra.Command {
var id string
var rolesFlag string
cmd := &cobra.Command{
Use: "set",
Short: "Set roles for an account",
Run: func(cmd *cobra.Command, args []string) {
if id == "" {
fatalf("role set: --id is required")
}
c := newController()
roles := []string{}
if rolesFlag != "" {
for _, r := range strings.Split(rolesFlag, ",") {
r = strings.TrimSpace(r)
if r != "" {
roles = append(roles, r)
}
}
}
body := map[string][]string{"roles": roles}
c.doRequest("PUT", "/v1/accounts/"+id+"/roles", body, nil)
fmt.Printf("roles set: %v\n", roles)
},
}
cmd.Flags().StringVar(&id, "id", "", "account UUID (required)")
cmd.Flags().StringVar(&rolesFlag, "roles", "", "comma-separated list of roles")
return cmd
}
func roleGrantCmd() *cobra.Command {
var id string
var role string
cmd := &cobra.Command{
Use: "grant",
Short: "Grant a role to an account",
Run: func(cmd *cobra.Command, args []string) {
if id == "" {
fatalf("role grant: --id is required")
}
if role == "" {
fatalf("role grant: --role is required")
}
c := newController()
body := map[string]string{"role": role}
c.doRequest("POST", "/v1/accounts/"+id+"/roles", body, nil)
fmt.Printf("role granted: %s\n", role)
},
}
cmd.Flags().StringVar(&id, "id", "", "account UUID (required)")
cmd.Flags().StringVar(&role, "role", "", "role name (required)")
return cmd
}
func roleRevokeCmd() *cobra.Command {
var id string
var role string
cmd := &cobra.Command{
Use: "revoke",
Short: "Revoke a role from an account",
Run: func(cmd *cobra.Command, args []string) {
if id == "" {
fatalf("role revoke: --id is required")
}
if role == "" {
fatalf("role revoke: --role is required")
}
c := newController()
c.doRequest("DELETE", "/v1/accounts/"+id+"/roles/"+role, nil, nil)
fmt.Printf("role revoked: %s\n", role)
},
}
cmd.Flags().StringVar(&id, "id", "", "account UUID (required)")
cmd.Flags().StringVar(&role, "role", "", "role name (required)")
return cmd
}
// ---- token subcommands ----
func tokenCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "token",
Short: "Token management commands",
}
cmd.AddCommand(tokenIssueCmd())
cmd.AddCommand(tokenRevokeCmd())
return cmd
}
func tokenIssueCmd() *cobra.Command {
var id string
cmd := &cobra.Command{
Use: "issue",
Short: "Issue a token for a system account",
Run: func(cmd *cobra.Command, args []string) {
if id == "" {
fatalf("token issue: --id is required")
}
c := newController()
body := map[string]string{"account_id": id}
var result json.RawMessage
c.doRequest("POST", "/v1/token/issue", body, &result)
printJSON(result)
},
}
cmd.Flags().StringVar(&id, "id", "", "system account UUID (required)")
return cmd
}
func tokenRevokeCmd() *cobra.Command {
var jti string
cmd := &cobra.Command{
Use: "revoke",
Short: "Revoke a token by JTI",
Run: func(cmd *cobra.Command, args []string) {
if jti == "" {
fatalf("token revoke: --jti is required")
}
c := newController()
c.doRequest("DELETE", "/v1/token/"+jti, nil, nil)
fmt.Println("token revoked")
},
}
cmd.Flags().StringVar(&jti, "jti", "", "JTI of the token to revoke (required)")
return cmd
}
// ---- pgcreds subcommands ----
func pgcredsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "pgcreds",
Short: "Postgres credential management commands",
}
cmd.AddCommand(pgcredsListCmd())
cmd.AddCommand(pgcredsGetCmd())
cmd.AddCommand(pgcredsSetCmd())
return cmd
}
func pgcredsListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all Postgres credentials",
Run: func(cmd *cobra.Command, args []string) {
c := newController()
var result json.RawMessage
c.doRequest("GET", "/v1/pgcreds", nil, &result)
printJSON(result)
},
}
}
func pgcredsGetCmd() *cobra.Command {
var id string
cmd := &cobra.Command{
Use: "get",
Short: "Get Postgres credentials for an account",
Run: func(cmd *cobra.Command, args []string) {
if id == "" {
fatalf("pgcreds get: --id is required")
}
c := newController()
var result json.RawMessage
c.doRequest("GET", "/v1/accounts/"+id+"/pgcreds", nil, &result)
printJSON(result)
},
}
cmd.Flags().StringVar(&id, "id", "", "account UUID (required)")
return cmd
}
func pgcredsSetCmd() *cobra.Command {
var id string
var host string
var port int
var dbName string
var username string
var password string
cmd := &cobra.Command{
Use: "set",
Short: "Set Postgres credentials for an account",
Run: func(cmd *cobra.Command, args []string) {
if id == "" || host == "" || dbName == "" || username == "" {
fatalf("pgcreds set: --id, --host, --db, and --user are required")
}
c := newController()
// Prompt for the Postgres password interactively if not supplied so it
// stays out of shell history.
// Security: terminal echo is disabled during entry; the raw byte slice is
// zeroed after conversion to string.
passwd := password
if passwd == "" {
fmt.Fprint(os.Stderr, "Postgres password: ")
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
fmt.Fprintln(os.Stderr)
if err != nil {
fatalf("read password: %v", err)
}
passwd = string(raw)
for i := range raw {
raw[i] = 0
}
}
body := map[string]interface{}{
"host": host,
"port": port,
"database": dbName,
"username": username,
"password": passwd,
}
c.doRequest("PUT", "/v1/accounts/"+id+"/pgcreds", body, nil)
fmt.Println("credentials stored")
},
}
cmd.Flags().StringVar(&id, "id", "", "account UUID (required)")
cmd.Flags().StringVar(&host, "host", "", "Postgres host (required)")
cmd.Flags().IntVar(&port, "port", 5432, "Postgres port")
cmd.Flags().StringVar(&dbName, "db", "", "Postgres database name (required)")
cmd.Flags().StringVar(&username, "user", "", "Postgres username (required)")
cmd.Flags().StringVar(&password, "password", "", "Postgres password (prompted if omitted)")
return cmd
}
// ---- policy subcommands ----
func policyCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "policy",
Short: "Policy rule management commands",
}
cmd.AddCommand(policyListCmd())
cmd.AddCommand(policyCreateCmd())
cmd.AddCommand(policyGetCmd())
cmd.AddCommand(policyUpdateCmd())
cmd.AddCommand(policyDeleteCmd())
return cmd
}
func policyListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all policy rules",
Run: func(cmd *cobra.Command, args []string) {
c := newController()
var result json.RawMessage
c.doRequest("GET", "/v1/policy/rules", nil, &result)
printJSON(result)
},
}
}
func policyCreateCmd() *cobra.Command {
var description string
var jsonFile string
var priority int
var notBefore string
var expiresAt string
cmd := &cobra.Command{
Use: "create",
Short: "Create a new policy rule",
Run: func(cmd *cobra.Command, args []string) {
if description == "" {
fatalf("policy create: --description is required")
}
if jsonFile == "" {
fatalf("policy create: --json is required (path to rule body JSON file)")
}
c := newController()
// G304: path comes from a CLI flag supplied by the operator.
ruleBytes, err := os.ReadFile(jsonFile) //nolint:gosec
if err != nil {
fatalf("policy create: read %s: %v", jsonFile, err)
}
// Validate that the file contains valid JSON before sending.
var ruleBody json.RawMessage
if err := json.Unmarshal(ruleBytes, &ruleBody); err != nil {
fatalf("policy create: invalid JSON in %s: %v", jsonFile, err)
}
body := map[string]interface{}{
"description": description,
"priority": priority,
"rule": ruleBody,
}
if notBefore != "" {
if _, err := time.Parse(time.RFC3339, notBefore); err != nil {
fatalf("policy create: --not-before must be RFC3339: %v", err)
}
body["not_before"] = notBefore
}
if expiresAt != "" {
if _, err := time.Parse(time.RFC3339, expiresAt); err != nil {
fatalf("policy create: --expires-at must be RFC3339: %v", err)
}
body["expires_at"] = expiresAt
}
var result json.RawMessage
c.doRequest("POST", "/v1/policy/rules", body, &result)
printJSON(result)
},
}
cmd.Flags().StringVar(&description, "description", "", "rule description (required)")
cmd.Flags().StringVar(&jsonFile, "json", "", "path to JSON file containing the rule body (required)")
cmd.Flags().IntVar(&priority, "priority", 100, "rule priority (lower = evaluated first)")
cmd.Flags().StringVar(&notBefore, "not-before", "", "earliest activation time (RFC3339, optional)")
cmd.Flags().StringVar(&expiresAt, "expires-at", "", "expiry time (RFC3339, optional)")
return cmd
}
func policyGetCmd() *cobra.Command {
var id string
cmd := &cobra.Command{
Use: "get",
Short: "Get a policy rule",
Run: func(cmd *cobra.Command, args []string) {
if id == "" {
fatalf("policy get: --id is required")
}
c := newController()
var result json.RawMessage
c.doRequest("GET", "/v1/policy/rules/"+id, nil, &result)
printJSON(result)
},
}
cmd.Flags().StringVar(&id, "id", "", "rule ID (required)")
return cmd
}
func policyUpdateCmd() *cobra.Command {
var id string
var priority int
var enabled string
var notBefore string
var expiresAt string
var clearNotBefore bool
var clearExpiresAt bool
cmd := &cobra.Command{
Use: "update",
Short: "Update a policy rule",
Run: func(cmd *cobra.Command, args []string) {
if id == "" {
fatalf("policy update: --id is required")
}
c := newController()
body := map[string]interface{}{}
if priority >= 0 {
body["priority"] = priority
}
if enabled != "" {
switch enabled {
case "true":
b := true
body["enabled"] = b
case "false":
b := false
body["enabled"] = b
default:
fatalf("policy update: --enabled must be true or false")
}
}
if clearNotBefore {
body["clear_not_before"] = true
} else if notBefore != "" {
if _, err := time.Parse(time.RFC3339, notBefore); err != nil {
fatalf("policy update: --not-before must be RFC3339: %v", err)
}
body["not_before"] = notBefore
}
if clearExpiresAt {
body["clear_expires_at"] = true
} else if expiresAt != "" {
if _, err := time.Parse(time.RFC3339, expiresAt); err != nil {
fatalf("policy update: --expires-at must be RFC3339: %v", err)
}
body["expires_at"] = expiresAt
}
if len(body) == 0 {
fatalf("policy update: at least one flag is required")
}
var result json.RawMessage
c.doRequest("PATCH", "/v1/policy/rules/"+id, body, &result)
printJSON(result)
},
}
cmd.Flags().StringVar(&id, "id", "", "rule ID (required)")
cmd.Flags().IntVar(&priority, "priority", -1, "new priority (-1 = no change)")
cmd.Flags().StringVar(&enabled, "enabled", "", "true or false")
cmd.Flags().StringVar(&notBefore, "not-before", "", "earliest activation time (RFC3339)")
cmd.Flags().StringVar(&expiresAt, "expires-at", "", "expiry time (RFC3339)")
cmd.Flags().BoolVar(&clearNotBefore, "clear-not-before", false, "remove not_before constraint")
cmd.Flags().BoolVar(&clearExpiresAt, "clear-expires-at", false, "remove expires_at constraint")
return cmd
}
func policyDeleteCmd() *cobra.Command {
var id string
cmd := &cobra.Command{
Use: "delete",
Short: "Delete a policy rule",
Run: func(cmd *cobra.Command, args []string) {
if id == "" {
fatalf("policy delete: --id is required")
}
c := newController()
c.doRequest("DELETE", "/v1/policy/rules/"+id, nil, nil)
fmt.Println("policy rule deleted")
},
}
cmd.Flags().StringVar(&id, "id", "", "rule ID (required)")
return cmd
}
// ---- tag subcommands ----
func tagCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "tag",
Short: "Account tag management commands",
}
cmd.AddCommand(tagListCmd())
cmd.AddCommand(tagSetCmd())
return cmd
}
func tagListCmd() *cobra.Command {
var id string
cmd := &cobra.Command{
Use: "list",
Short: "List tags for an account",
Run: func(cmd *cobra.Command, args []string) {
if id == "" {
fatalf("tag list: --id is required")
}
c := newController()
var result json.RawMessage
c.doRequest("GET", "/v1/accounts/"+id+"/tags", nil, &result)
printJSON(result)
},
}
cmd.Flags().StringVar(&id, "id", "", "account UUID (required)")
return cmd
}
func tagSetCmd() *cobra.Command {
var id string
var tagsFlag string
cmd := &cobra.Command{
Use: "set",
Short: "Set tags for an account",
Long: "Set tags for an account. Pass empty --tags \"\" to clear all tags.",
Run: func(cmd *cobra.Command, args []string) {
if id == "" {
fatalf("tag set: --id is required")
}
c := newController()
tags := []string{}
if tagsFlag != "" {
for _, t := range strings.Split(tagsFlag, ",") {
t = strings.TrimSpace(t)
if t != "" {
tags = append(tags, t)
}
}
}
body := map[string][]string{"tags": tags}
c.doRequest("PUT", "/v1/accounts/"+id+"/tags", body, nil)
fmt.Printf("tags set: %v\n", tags)
},
}
cmd.Flags().StringVar(&id, "id", "", "account UUID (required)")
cmd.Flags().StringVar(&tagsFlag, "tags", "", "comma-separated list of tags (empty string clears all tags)")
return cmd
}
// ---- HTTP helpers ----
// doRequest performs an authenticated JSON HTTP request. If result is non-nil,
// the response body is decoded into it. Exits on error.
func (c *controller) doRequest(method, path string, body, result interface{}) {
url := c.serverURL + path
var bodyReader *strings.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
fatalf("marshal request body: %v", err)
}
bodyReader = strings.NewReader(string(b))
} else {
bodyReader = strings.NewReader("")
}
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
fatalf("create request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.client.Do(req)
if err != nil {
fatalf("HTTP %s %s: %v", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
var errBody map[string]string
_ = json.NewDecoder(resp.Body).Decode(&errBody)
msg := errBody["error"]
if msg == "" {
msg = resp.Status
}
fatalf("server returned %d: %s", resp.StatusCode, msg)
}
if result != nil && resp.StatusCode != http.StatusNoContent {
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
fatalf("decode response: %v", err)
}
}
}
// newHTTPClient builds an http.Client with optional custom CA certificate.
// Security: TLS 1.2+ is required; the system CA pool is used by default.
func newHTTPClient(caCertPath string) (*http.Client, error) {
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS12,
}
if caCertPath != "" {
// G304: path comes from a CLI flag supplied by the operator, not from
// untrusted input. File inclusion is intentional.
pemData, err := os.ReadFile(caCertPath) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("read CA cert: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pemData) {
return nil, fmt.Errorf("no valid certificates found in %s", caCertPath)
}
tlsCfg.RootCAs = pool
}
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsCfg,
},
Timeout: 30 * time.Second,
}, nil
}
// printJSON pretty-prints a JSON value to stdout.
func printJSON(v interface{}) {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
fatalf("encode output: %v", err)
}
}
// fatalf prints an error message and exits with code 1.
func fatalf(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "mciasctl: "+format+"\n", args...)
os.Exit(1)
}