Files
mcias/cmd/mciasctl/main.go
Kyle Isom 59d51a1d38 Implement Phase 7: gRPC dual-stack interface
- proto/mcias/v1/: AdminService, AuthService, TokenService,
  AccountService, CredentialService; generated Go stubs in gen/
- internal/grpcserver: full handler implementations sharing all
  business logic (auth, token, db, crypto) with REST server;
  interceptor chain: logging -> auth (JWT alg-first + revocation) ->
  rate-limit (token bucket, 10 req/s, burst 10, per-IP)
- internal/config: optional grpc_addr field in [server] section
- cmd/mciassrv: dual-stack startup; gRPC/TLS listener on grpc_addr
  when configured; graceful shutdown of both servers in 15s window
- cmd/mciasgrpcctl: companion gRPC CLI mirroring mciasctl commands
  (health, pubkey, account, role, token, pgcreds) using TLS with
  optional custom CA cert
- internal/grpcserver/grpcserver_test.go: 20 tests via bufconn covering
  public RPCs, auth interceptor (no token, invalid, revoked -> 401),
  non-admin -> 403, Login/Logout/RenewToken/ValidateToken flows,
  AccountService CRUD, SetPGCreds/GetPGCreds AES-GCM round-trip,
  credential fields absent from all responses
Security:
  JWT validation path identical to REST: alg header checked before
  signature, alg:none rejected, revocation table checked after sig.
  Authorization metadata value never logged by any interceptor.
  Credential fields (PasswordHash, TOTPSecret*, PGPassword) absent from
  all proto response messages — enforced by proto design and confirmed
  by test TestCredentialFieldsAbsentFromAccountResponse.
  Login dummy-Argon2 timing guard preserves timing uniformity for
  unknown users (same as REST handleLogin).
  TLS required at listener level; cmd/mciassrv uses
  credentials.NewServerTLSFromFile; no h2c offered.
137 tests pass, zero race conditions (go test -race ./...)
2026-03-11 14:38:47 -07:00

474 lines
12 KiB
Go

// Command mciasctl is the MCIAS admin CLI.
//
// It connects to a running mciassrv instance and provides subcommands for
// managing accounts, roles, tokens, and Postgres credentials.
//
// 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:
//
// 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
package main
import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"strings"
"time"
)
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 "account":
ctl.runAccount(subArgs)
case "role":
ctl.runRole(subArgs)
case "token":
ctl.runToken(subArgs)
case "pgcreds":
ctl.runPGCreds(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
}
// ---- 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 (required for human accounts)")
accountType := fs.String("type", "human", "account type: human or system")
_ = fs.Parse(args)
if *username == "" {
fatalf("account create: -username is required")
}
body := map[string]string{
"username": *username,
"account_type": *accountType,
}
if *password != "" {
body["password"] = *password
}
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 (required)")
_ = fs.Parse(args)
if *id == "" || *host == "" || *dbName == "" || *username == "" || *password == "" {
fatalf("pgcreds set: -id, -host, -db, -user, and -password are required")
}
body := map[string]interface{}{
"host": *host,
"port": *port,
"database": *dbName,
"username": *username,
"password": *password,
}
c.doRequest("PUT", "/v1/accounts/"+*id+"/pgcreds", body, nil)
fmt.Println("credentials stored")
}
// ---- 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:
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
`)
}