grpcctl: add auth login and policy commands

- Add auth/login and auth/logout to mciasgrpcctl, calling
  the existing AuthService.Login/Logout RPCs; password is
  always prompted interactively (term.ReadPassword), never
  accepted as a flag, raw bytes zeroed after use
- Add proto/mcias/v1/policy.proto with PolicyService
  (List, Create, Get, Update, Delete policy rules)
- Regenerate gen/mcias/v1/ stubs to include policy
- Implement internal/grpcserver/policyservice.go delegating
  to the same db layer as the REST policy handlers
- Register PolicyService in grpcserver.go
- Add policy list/create/get/update/delete to mciasgrpcctl
- Update mciasgrpcctl man page with new commands

Security: auth login uses the same interactive password
prompt pattern as mciasctl; password never appears in
process args, shell history, or logs; raw bytes zeroed
after string conversion (same as REST CLI and REST server).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 20:51:10 -07:00
parent cbcb1a0533
commit 7ede54afb2
21 changed files with 1835 additions and 25 deletions

View File

@@ -10,7 +10,7 @@
//
// Global flags:
//
// -server URL of the mciassrv instance (default: https://localhost:8443)
// -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)
//
@@ -61,7 +61,7 @@ import (
func main() {
// Global flags.
serverURL := flag.String("server", "https://localhost:8443", "mciassrv base URL")
serverURL := flag.String("server", "https://mcias.metacircular.net: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
@@ -871,7 +871,7 @@ func usage() {
Usage: mciasctl [global flags] <command> [args]
Global flags:
-server URL of the mciassrv instance (default: https://localhost:8443)
-server URL of the mciassrv instance (default: https://mcias.metacircular.net:8443)
-token Bearer token (or set MCIAS_TOKEN env var)
-cacert Path to CA certificate for TLS verification

View File

@@ -1,7 +1,8 @@
// Command mciasgrpcctl is the MCIAS gRPC admin CLI.
//
// It connects to a running mciassrv gRPC listener and provides subcommands for
// managing accounts, roles, tokens, and Postgres credentials via the gRPC API.
// managing accounts, roles, tokens, Postgres credentials, and policy rules via
// the gRPC API.
//
// Usage:
//
@@ -9,7 +10,7 @@
//
// Global flags:
//
// -server gRPC server address (default: localhost:9443)
// -server gRPC server address (default: mcias.metacircular.net:9443)
// -token Bearer token for authentication (or set MCIAS_TOKEN env var)
// -cacert Path to CA certificate for TLS verification (optional)
//
@@ -18,6 +19,9 @@
// health
// pubkey
//
// auth login -username NAME [-totp CODE]
// auth logout
//
// account list
// account create -username NAME -password PASS [-type human|system]
// account get -id UUID
@@ -33,6 +37,12 @@
//
// 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] [-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
package main
import (
@@ -43,9 +53,11 @@ import (
"flag"
"fmt"
"os"
"strconv"
"strings"
"time"
"golang.org/x/term"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
@@ -55,7 +67,7 @@ import (
func main() {
// Global flags.
serverAddr := flag.String("server", "localhost:9443", "gRPC server address (host:port)")
serverAddr := flag.String("server", "mcias.metacircular.net:9443", "gRPC server address (host:port)")
tokenFlag := flag.String("token", "", "bearer token (or set MCIAS_TOKEN)")
caCert := flag.String("cacert", "", "path to CA certificate for TLS")
flag.Usage = usage
@@ -93,6 +105,8 @@ func main() {
ctl.runHealth()
case "pubkey":
ctl.runPubKey()
case "auth":
ctl.runAuth(subArgs)
case "account":
ctl.runAccount(subArgs)
case "role":
@@ -101,6 +115,8 @@ func main() {
ctl.runToken(subArgs)
case "pgcreds":
ctl.runPGCreds(subArgs)
case "policy":
ctl.runPolicy(subArgs)
default:
fatalf("unknown command %q; run with no args to see usage", command)
}
@@ -162,6 +178,89 @@ func (c *controller) runPubKey() {
})
}
// ---- auth subcommands ----
func (c *controller) runAuth(args []string) {
if len(args) == 0 {
fatalf("auth requires a subcommand: login, logout")
}
switch args[0] {
case "login":
c.authLogin(args[1:])
case "logout":
c.authLogout()
default:
fatalf("unknown auth subcommand %q", args[0])
}
}
// authLogin authenticates with the gRPC 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 (c *controller) authLogin(args []string) {
fs := flag.NewFlagSet("auth login", flag.ExitOnError)
username := fs.String("username", "", "username (required)")
totpCode := fs.String("totp", "", "TOTP code (required if TOTP is enrolled)")
_ = fs.Parse(args)
if *username == "" {
fatalf("auth login: -username is required")
}
// 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)
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
}
authCl := mciasv1.NewAuthServiceClient(c.conn)
// Login is a public RPC — no auth context needed.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := authCl.Login(ctx, &mciasv1.LoginRequest{
Username: *username,
Password: passwd,
TotpCode: *totpCode,
})
if err != nil {
fatalf("auth login: %v", err)
}
// Print token to stdout so it can be captured by scripts, e.g.:
// export MCIAS_TOKEN=$(mciasgrpcctl auth login -username alice)
fmt.Println(resp.Token)
if resp.ExpiresAt != nil {
fmt.Fprintf(os.Stderr, "expires: %s\n", resp.ExpiresAt.AsTime().UTC().Format(time.RFC3339))
}
}
// authLogout revokes the caller's current JWT via the gRPC AuthService.
func (c *controller) authLogout() {
authCl := mciasv1.NewAuthServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
if _, err := authCl.Logout(ctx, &mciasv1.LogoutRequest{}); err != nil {
fatalf("auth logout: %v", err)
}
fmt.Println("logged out")
}
// ---- account subcommands ----
func (c *controller) runAccount(args []string) {
@@ -518,6 +617,208 @@ func (c *controller) pgCredsSet(args []string) {
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() {
cl := mciasv1.NewPolicyServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
resp, err := cl.ListPolicyRules(ctx, &mciasv1.ListPolicyRulesRequest{})
if err != nil {
fatalf("policy list: %v", err)
}
printJSON(resp.Rules)
}
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)")
notBefore := fs.String("not-before", "", "earliest activation time (RFC3339, optional)")
expiresAt := fs.String("expires-at", "", "expiry time (RFC3339, optional)")
_ = 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 interface{}
if err := json.Unmarshal(ruleBytes, &ruleBody); err != nil {
fatalf("policy create: invalid JSON in %s: %v", *jsonFile, err)
}
if *notBefore != "" {
if _, err := time.Parse(time.RFC3339, *notBefore); err != nil {
fatalf("policy create: -not-before must be RFC3339: %v", err)
}
}
if *expiresAt != "" {
if _, err := time.Parse(time.RFC3339, *expiresAt); err != nil {
fatalf("policy create: -expires-at must be RFC3339: %v", err)
}
}
cl := mciasv1.NewPolicyServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
resp, err := cl.CreatePolicyRule(ctx, &mciasv1.CreatePolicyRuleRequest{
Description: *description,
RuleJson: string(ruleBytes),
Priority: int32(*priority), //nolint:gosec // priority is a small positive integer
NotBefore: *notBefore,
ExpiresAt: *expiresAt,
})
if err != nil {
fatalf("policy create: %v", err)
}
printJSON(resp.Rule)
}
func (c *controller) policyGet(args []string) {
fs := flag.NewFlagSet("policy get", flag.ExitOnError)
idStr := fs.String("id", "", "rule ID (required)")
_ = fs.Parse(args)
if *idStr == "" {
fatalf("policy get: -id is required")
}
id, err := strconv.ParseInt(*idStr, 10, 64)
if err != nil {
fatalf("policy get: -id must be an integer")
}
cl := mciasv1.NewPolicyServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
resp, err := cl.GetPolicyRule(ctx, &mciasv1.GetPolicyRuleRequest{Id: id})
if err != nil {
fatalf("policy get: %v", err)
}
printJSON(resp.Rule)
}
func (c *controller) policyUpdate(args []string) {
fs := flag.NewFlagSet("policy update", flag.ExitOnError)
idStr := fs.String("id", "", "rule ID (required)")
priority := fs.Int("priority", -1, "new priority (-1 = no change)")
enabled := fs.String("enabled", "", "true or false")
notBefore := fs.String("not-before", "", "earliest activation time (RFC3339)")
expiresAt := fs.String("expires-at", "", "expiry time (RFC3339)")
clearNotBefore := fs.Bool("clear-not-before", false, "remove not_before constraint")
clearExpiresAt := fs.Bool("clear-expires-at", false, "remove expires_at constraint")
_ = fs.Parse(args)
if *idStr == "" {
fatalf("policy update: -id is required")
}
id, err := strconv.ParseInt(*idStr, 10, 64)
if err != nil {
fatalf("policy update: -id must be an integer")
}
req := &mciasv1.UpdatePolicyRuleRequest{
Id: id,
ClearNotBefore: *clearNotBefore,
ClearExpiresAt: *clearExpiresAt,
}
if *priority >= 0 {
v := int32(*priority) //nolint:gosec // priority is a small positive integer
req.Priority = &v
}
if *enabled != "" {
switch *enabled {
case "true":
b := true
req.Enabled = &b
case "false":
b := false
req.Enabled = &b
default:
fatalf("policy update: -enabled must be true or false")
}
}
if !*clearNotBefore && *notBefore != "" {
if _, err := time.Parse(time.RFC3339, *notBefore); err != nil {
fatalf("policy update: -not-before must be RFC3339: %v", err)
}
req.NotBefore = *notBefore
}
if !*clearExpiresAt && *expiresAt != "" {
if _, err := time.Parse(time.RFC3339, *expiresAt); err != nil {
fatalf("policy update: -expires-at must be RFC3339: %v", err)
}
req.ExpiresAt = *expiresAt
}
cl := mciasv1.NewPolicyServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
resp, err := cl.UpdatePolicyRule(ctx, req)
if err != nil {
fatalf("policy update: %v", err)
}
printJSON(resp.Rule)
}
func (c *controller) policyDelete(args []string) {
fs := flag.NewFlagSet("policy delete", flag.ExitOnError)
idStr := fs.String("id", "", "rule ID (required)")
_ = fs.Parse(args)
if *idStr == "" {
fatalf("policy delete: -id is required")
}
id, err := strconv.ParseInt(*idStr, 10, 64)
if err != nil {
fatalf("policy delete: -id must be an integer")
}
cl := mciasv1.NewPolicyServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
if _, err := cl.DeletePolicyRule(ctx, &mciasv1.DeletePolicyRuleRequest{Id: id}); err != nil {
fatalf("policy delete: %v", err)
}
fmt.Println("policy rule deleted")
}
// ---- gRPC connection ----
// newGRPCConn dials the gRPC server with TLS.
@@ -575,7 +876,7 @@ func usage() {
Usage: mciasgrpcctl [global flags] <command> [args]
Global flags:
-server gRPC server address (default: localhost:9443)
-server gRPC server address (default: mcias.metacircular.net:9443)
-token Bearer token (or set MCIAS_TOKEN env var)
-cacert Path to CA certificate for TLS verification
@@ -583,6 +884,12 @@ Commands:
health
pubkey
auth login -username NAME [-totp CODE]
Obtain a bearer token. Password is always prompted interactively.
Token is written to stdout; expiry to stderr.
Example: export MCIAS_TOKEN=$(mciasgrpcctl auth login -username alice)
auth logout Revoke the current bearer token.
account list
account create -username NAME -password PASS [-type human|system]
account get -id UUID
@@ -598,5 +905,16 @@ Commands:
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]
[-not-before RFC3339] [-expires-at RFC3339]
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]
[-not-before RFC3339] [-expires-at RFC3339]
[-clear-not-before] [-clear-expires-at]
policy delete -id ID
`)
}