// 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] [args] // // Global flags: // // -server URL of the mciassrv instance (default: https://localhost: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 [-password PASS] [-totp CODE] // // account list // account create -username NAME [-password PASS] [-type human|system] // account get -id UUID // account update -id UUID [-status active|inactive] // account delete -id UUID // // role list -id UUID // role set -id UUID -roles role1,role2,... // // token issue -id UUID // token revoke -jti JTI // // 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] // policy get -id ID // policy update -id ID [-priority N] [-enabled true|false] // policy delete -id ID // // tag list -id UUID // tag set -id UUID -tags tag1,tag2,... package main import ( "crypto/tls" "crypto/x509" "encoding/json" "flag" "fmt" "net/http" "os" "strings" "time" "golang.org/x/term" ) func main() { // Global flags. serverURL := flag.String("server", "https://localhost:8443", "mciassrv base URL") tokenFlag := flag.String("token", "", "bearer token (or set MCIAS_TOKEN)") caCert := flag.String("cacert", "", "path to CA certificate for TLS") flag.Usage = usage flag.Parse() // Resolve token from flag or environment. bearerToken := *tokenFlag if bearerToken == "" { bearerToken = os.Getenv("MCIAS_TOKEN") } args := flag.Args() if len(args) == 0 { usage() os.Exit(1) } // Build HTTP client. client, err := newHTTPClient(*caCert) if err != nil { fatalf("build HTTP client: %v", err) } ctl := &controller{ serverURL: strings.TrimRight(*serverURL, "/"), token: bearerToken, client: client, } command := args[0] subArgs := args[1:] switch command { case "auth": ctl.runAuth(subArgs) case "account": ctl.runAccount(subArgs) case "role": ctl.runRole(subArgs) case "token": ctl.runToken(subArgs) case "pgcreds": ctl.runPGCreds(subArgs) case "policy": ctl.runPolicy(subArgs) case "tag": ctl.runTag(subArgs) default: fatalf("unknown command %q; run with no args to see usage", command) } } // controller holds shared state for all subcommands. type controller struct { client *http.Client serverURL string token string } // ---- auth subcommands ---- func (c *controller) runAuth(args []string) { if len(args) == 0 { fatalf("auth requires a subcommand: login") } switch args[0] { case "login": c.authLogin(args[1:]) default: fatalf("unknown auth subcommand %q", args[0]) } } // authLogin authenticates with the server using username and password, then // prints the resulting bearer token to stdout. If -password is not supplied on // the command line, the user is prompted interactively (input is hidden so the // password does not appear in shell history or terminal output). // // Security: passwords are never stored by this process beyond the lifetime of // the HTTP request. Interactive reads use golang.org/x/term.ReadPassword so // that terminal echo is disabled; the byte slice is zeroed after use. func (c *controller) authLogin(args []string) { fs := flag.NewFlagSet("auth login", flag.ExitOnError) username := fs.String("username", "", "username (required)") password := fs.String("password", "", "password (reads from stdin if omitted)") totpCode := fs.String("totp", "", "TOTP code (required if TOTP is enrolled)") _ = fs.Parse(args) if *username == "" { fatalf("auth login: -username is required") } // If no password flag was provided, prompt interactively so it does not // appear in process arguments or shell history. passwd := *password if passwd == "" { 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) } } // ---- account subcommands ---- func (c *controller) runAccount(args []string) { if len(args) == 0 { fatalf("account requires a subcommand: list, create, get, update, delete") } switch args[0] { case "list": c.accountList() case "create": c.accountCreate(args[1:]) case "get": c.accountGet(args[1:]) case "update": c.accountUpdate(args[1:]) case "delete": c.accountDelete(args[1:]) default: fatalf("unknown account subcommand %q", args[0]) } } func (c *controller) accountList() { var result []json.RawMessage c.doRequest("GET", "/v1/accounts", nil, &result) printJSON(result) } func (c *controller) accountCreate(args []string) { fs := flag.NewFlagSet("account create", flag.ExitOnError) username := fs.String("username", "", "username (required)") password := fs.String("password", "", "password for human accounts (prompted if omitted)") accountType := fs.String("type", "human", "account type: human or system") _ = fs.Parse(args) if *username == "" { fatalf("account create: -username is required") } // For human accounts, prompt for a password interactively if one was not // supplied on the command line so it stays out of shell history. // Security: terminal echo is disabled during entry; the raw byte slice is // zeroed after conversion to string. System accounts have no password. passwd := *password if passwd == "" && *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) } func (c *controller) 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") } var result json.RawMessage c.doRequest("GET", "/v1/accounts/"+*id, nil, &result) printJSON(result) } func (c *controller) accountUpdate(args []string) { fs := flag.NewFlagSet("account update", flag.ExitOnError) id := fs.String("id", "", "account UUID (required)") status := fs.String("status", "", "new status: active or inactive") _ = fs.Parse(args) if *id == "" { fatalf("account update: -id is required") } if *status == "" { fatalf("account update: -status is required") } body := map[string]string{"status": *status} c.doRequest("PATCH", "/v1/accounts/"+*id, body, nil) fmt.Println("account updated") } func (c *controller) accountDelete(args []string) { fs := flag.NewFlagSet("account delete", flag.ExitOnError) id := fs.String("id", "", "account UUID (required)") _ = fs.Parse(args) if *id == "" { fatalf("account delete: -id is required") } c.doRequest("DELETE", "/v1/accounts/"+*id, nil, nil) fmt.Println("account deleted") } // ---- role subcommands ---- func (c *controller) runRole(args []string) { if len(args) == 0 { fatalf("role requires a subcommand: list, set") } switch args[0] { case "list": c.roleList(args[1:]) case "set": c.roleSet(args[1:]) default: fatalf("unknown role subcommand %q", args[0]) } } func (c *controller) roleList(args []string) { fs := flag.NewFlagSet("role list", flag.ExitOnError) id := fs.String("id", "", "account UUID (required)") _ = fs.Parse(args) if *id == "" { fatalf("role list: -id is required") } var result json.RawMessage c.doRequest("GET", "/v1/accounts/"+*id+"/roles", nil, &result) printJSON(result) } func (c *controller) roleSet(args []string) { fs := flag.NewFlagSet("role set", flag.ExitOnError) id := fs.String("id", "", "account UUID (required)") rolesFlag := fs.String("roles", "", "comma-separated list of roles") _ = fs.Parse(args) if *id == "" { fatalf("role set: -id is required") } 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) } // ---- token subcommands ---- func (c *controller) runToken(args []string) { if len(args) == 0 { fatalf("token requires a subcommand: issue, revoke") } switch args[0] { case "issue": c.tokenIssue(args[1:]) case "revoke": c.tokenRevoke(args[1:]) default: fatalf("unknown token subcommand %q", args[0]) } } func (c *controller) tokenIssue(args []string) { fs := flag.NewFlagSet("token issue", flag.ExitOnError) id := fs.String("id", "", "system account UUID (required)") _ = fs.Parse(args) if *id == "" { fatalf("token issue: -id is required") } body := map[string]string{"account_id": *id} var result json.RawMessage c.doRequest("POST", "/v1/token/issue", body, &result) printJSON(result) } func (c *controller) tokenRevoke(args []string) { fs := flag.NewFlagSet("token revoke", flag.ExitOnError) jti := fs.String("jti", "", "JTI of the token to revoke (required)") _ = fs.Parse(args) if *jti == "" { fatalf("token revoke: -jti is required") } c.doRequest("DELETE", "/v1/token/"+*jti, nil, nil) fmt.Println("token revoked") } // ---- pgcreds subcommands ---- func (c *controller) runPGCreds(args []string) { if len(args) == 0 { fatalf("pgcreds requires a subcommand: get, set") } switch args[0] { case "get": c.pgCredsGet(args[1:]) case "set": c.pgCredsSet(args[1:]) default: fatalf("unknown pgcreds subcommand %q", args[0]) } } func (c *controller) pgCredsGet(args []string) { fs := flag.NewFlagSet("pgcreds get", flag.ExitOnError) id := fs.String("id", "", "account UUID (required)") _ = fs.Parse(args) if *id == "" { fatalf("pgcreds get: -id is required") } var result json.RawMessage c.doRequest("GET", "/v1/accounts/"+*id+"/pgcreds", nil, &result) printJSON(result) } func (c *controller) pgCredsSet(args []string) { fs := flag.NewFlagSet("pgcreds set", flag.ExitOnError) id := fs.String("id", "", "account UUID (required)") host := fs.String("host", "", "Postgres host (required)") port := fs.Int("port", 5432, "Postgres port") dbName := fs.String("db", "", "Postgres database name (required)") username := fs.String("user", "", "Postgres username (required)") password := fs.String("password", "", "Postgres password (prompted if omitted)") _ = fs.Parse(args) if *id == "" || *host == "" || *dbName == "" || *username == "" { fatalf("pgcreds set: -id, -host, -db, and -user are required") } // 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") } // ---- policy subcommands ---- func (c *controller) runPolicy(args []string) { if len(args) == 0 { fatalf("policy requires a subcommand: list, create, get, update, delete") } switch args[0] { case "list": c.policyList() case "create": c.policyCreate(args[1:]) case "get": c.policyGet(args[1:]) case "update": c.policyUpdate(args[1:]) case "delete": c.policyDelete(args[1:]) default: fatalf("unknown policy subcommand %q", args[0]) } } func (c *controller) policyList() { var result json.RawMessage c.doRequest("GET", "/v1/policy/rules", nil, &result) printJSON(result) } func (c *controller) policyCreate(args []string) { fs := flag.NewFlagSet("policy create", flag.ExitOnError) description := fs.String("description", "", "rule description (required)") jsonFile := fs.String("json", "", "path to JSON file containing the rule body (required)") priority := fs.Int("priority", 100, "rule priority (lower = evaluated first)") _ = fs.Parse(args) if *description == "" { fatalf("policy create: -description is required") } if *jsonFile == "" { fatalf("policy create: -json is required (path to rule body JSON file)") } // 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, } var result json.RawMessage c.doRequest("POST", "/v1/policy/rules", body, &result) printJSON(result) } func (c *controller) policyGet(args []string) { fs := flag.NewFlagSet("policy get", flag.ExitOnError) id := fs.String("id", "", "rule ID (required)") _ = fs.Parse(args) if *id == "" { fatalf("policy get: -id is required") } var result json.RawMessage c.doRequest("GET", "/v1/policy/rules/"+*id, nil, &result) printJSON(result) } func (c *controller) policyUpdate(args []string) { fs := flag.NewFlagSet("policy update", flag.ExitOnError) id := fs.String("id", "", "rule ID (required)") priority := fs.Int("priority", -1, "new priority (-1 = no change)") enabled := fs.String("enabled", "", "true or false") _ = fs.Parse(args) if *id == "" { fatalf("policy update: -id is required") } 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 len(body) == 0 { fatalf("policy update: at least one of -priority or -enabled is required") } var result json.RawMessage c.doRequest("PATCH", "/v1/policy/rules/"+*id, body, &result) printJSON(result) } func (c *controller) policyDelete(args []string) { fs := flag.NewFlagSet("policy delete", flag.ExitOnError) id := fs.String("id", "", "rule ID (required)") _ = fs.Parse(args) if *id == "" { fatalf("policy delete: -id is required") } c.doRequest("DELETE", "/v1/policy/rules/"+*id, nil, nil) fmt.Println("policy rule deleted") } // ---- tag subcommands ---- func (c *controller) runTag(args []string) { if len(args) == 0 { fatalf("tag requires a subcommand: list, set") } switch args[0] { case "list": c.tagList(args[1:]) case "set": c.tagSet(args[1:]) default: fatalf("unknown tag subcommand %q", args[0]) } } func (c *controller) tagList(args []string) { fs := flag.NewFlagSet("tag list", flag.ExitOnError) id := fs.String("id", "", "account UUID (required)") _ = fs.Parse(args) if *id == "" { fatalf("tag list: -id is required") } var result json.RawMessage c.doRequest("GET", "/v1/accounts/"+*id+"/tags", nil, &result) printJSON(result) } func (c *controller) tagSet(args []string) { fs := flag.NewFlagSet("tag set", flag.ExitOnError) id := fs.String("id", "", "account UUID (required)") tagsFlag := fs.String("tags", "", "comma-separated list of tags (empty string clears all tags)") _ = fs.Parse(args) if *id == "" { fatalf("tag set: -id is required") } 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) } // ---- 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) } func usage() { fmt.Fprintf(os.Stderr, `mciasctl - MCIAS admin CLI Usage: mciasctl [global flags] [args] Global flags: -server URL of the mciassrv instance (default: https://localhost:8443) -token Bearer token (or set MCIAS_TOKEN env var) -cacert Path to CA certificate for TLS verification Commands: auth login -username NAME [-password PASS] [-totp CODE] Obtain a bearer token. Password is prompted if -password is omitted. Token is written to stdout; expiry to stderr. Example: export MCIAS_TOKEN=$(mciasctl auth login -username alice) account list account create -username NAME [-password PASS] [-type human|system] account get -id UUID account update -id UUID -status active|inactive account delete -id UUID role list -id UUID role set -id UUID -roles role1,role2,... token issue -id UUID token revoke -jti JTI pgcreds get -id UUID pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS] policy list policy create -description STR -json FILE [-priority N] FILE must contain a JSON rule body, e.g.: {"effect":"allow","actions":["pgcreds:read"],"resource_type":"pgcreds","owner_matches_subject":true} policy get -id ID policy update -id ID [-priority N] [-enabled true|false] policy delete -id ID tag list -id UUID tag set -id UUID -tags tag1,tag2,... Pass empty -tags "" to clear all tags. `) }