- internal/ui/ui.go: add PGCred, Tags to AccountDetailData; register
PUT /accounts/{id}/pgcreds and PUT /accounts/{id}/tags routes; add
pgcreds_form.html and tags_editor.html to shared template set; remove
unused AccountTagsData; fix fieldalignment on PolicyRuleView, PoliciesData
- internal/ui/handlers_accounts.go: add handleSetPGCreds — encrypts
password via crypto.SealAESGCM, writes audit EventPGCredUpdated, renders
pgcreds_form fragment; password never echoed; load PG creds and tags in
handleAccountDetail
- internal/ui/handlers_policy.go: fix handleSetAccountTags to render with
AccountDetailData instead of removed AccountTagsData
- internal/ui/ui_test.go: add 5 PG credential UI tests
- web/templates/fragments/pgcreds_form.html: new fragment — metadata display
+ set/replace form; system accounts only; password write-only
- web/templates/fragments/tags_editor.html: new fragment — textarea editor
with HTMX PUT for atomic tag replacement
- web/templates/fragments/policy_form.html: rewrite to use structured fields
matching handleCreatePolicyRule (roles/account_types/actions multi-select,
resource_type, subject_uuid, service_names, required_tags, checkbox)
- web/templates/policies.html: new policies management page
- web/templates/fragments/policy_row.html: new HTMX table row with toggle
and delete
- web/templates/account_detail.html: add Tags card and PG Credentials card
- web/templates/base.html: add Policies nav link
- internal/server/server.go: remove ~220 lines of duplicate tag/policy
handler code (real implementations are in handlers_policy.go)
- internal/policy/engine_wrapper.go: fix corrupted source; use errors.New
- internal/db/policy_test.go: use model.AccountTypeHuman constant
- cmd/mciasctl/main.go: add nolint:gosec to int(os.Stdin.Fd()) calls
- gofmt/goimports: db/policy_test.go, policy/defaults.go,
policy/engine_test.go, ui/ui.go, cmd/mciasctl/main.go
- fieldalignment: model.PolicyRuleRecord, policy.Engine, policy.Rule,
policy.RuleBody, ui.PolicyRuleView
Security: PG password encrypted AES-256-GCM with fresh random nonce before
storage; plaintext never logged or returned in any response; audit event
written on every credential write.
802 lines
21 KiB
Go
802 lines
21 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://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] <command> [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.
|
|
`)
|
|
}
|