Files
mcias/cmd/mciasgrpcctl/main.go
Kyle Isom 4114d087ce Add granular role grant/revoke endpoints to REST and gRPC APIs
- Add POST /v1/accounts/{id}/roles and DELETE /v1/accounts/{id}/roles/{role} REST endpoints
- Add GrantRole and RevokeRole RPCs to AccountService in gRPC API
- Update OpenAPI specification with new endpoints
- Add grant and revoke subcommands to mciasctl
- Add grant and revoke subcommands to mciasgrpcctl
- Regenerate proto files with new message types and RPCs
- Implement gRPC server methods for granular role management
- All existing tests pass; build verified with goimports
Security: Role changes are audited via EventRoleGranted and EventRoleRevoked events,
consistent with existing SetRoles implementation.
2026-03-12 20:55:49 -07:00

975 lines
26 KiB
Go

// Command mciasgrpcctl is the MCIAS gRPC admin CLI.
//
// It connects to a running mciassrv gRPC listener and provides subcommands for
// managing accounts, roles, tokens, Postgres credentials, and policy rules via
// the gRPC API.
//
// Usage:
//
// mciasgrpcctl [global flags] <command> [args]
//
// Global flags:
//
// -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)
//
// Commands:
//
// 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
// account update -id UUID -status active|inactive
// account delete -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 validate -token TOKEN
// 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] [-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 (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"flag"
"fmt"
"os"
"strconv"
"strings"
"time"
"golang.org/x/term"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
)
func main() {
// Global flags.
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
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 gRPC connection.
conn, err := newGRPCConn(*serverAddr, *caCert)
if err != nil {
fatalf("connect to gRPC server: %v", err)
}
defer func() { _ = conn.Close() }()
ctl := &controller{
conn: conn,
token: bearerToken,
}
command := args[0]
subArgs := args[1:]
switch command {
case "health":
ctl.runHealth()
case "pubkey":
ctl.runPubKey()
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)
default:
fatalf("unknown command %q; run with no args to see usage", command)
}
}
// controller holds the shared gRPC connection and token for all subcommands.
type controller struct {
conn *grpc.ClientConn
token string
}
// authCtx returns a context with the Bearer token injected as gRPC metadata.
// Security: token is placed in the "authorization" key per the gRPC convention
// that mirrors the HTTP Authorization header. Value is never logged.
func (c *controller) authCtx() context.Context {
ctx := context.Background()
if c.token == "" {
return ctx
}
// Security: metadata key "authorization" matches the server-side
// extractBearerFromMD expectation; value is "Bearer <token>".
return metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+c.token)
}
// callCtx returns an authCtx with a 30-second deadline.
func (c *controller) callCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(c.authCtx(), 30*time.Second)
}
// ---- health / pubkey ----
func (c *controller) runHealth() {
adminCl := mciasv1.NewAdminServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
resp, err := adminCl.Health(ctx, &mciasv1.HealthRequest{})
if err != nil {
fatalf("health: %v", err)
}
printJSON(map[string]string{"status": resp.Status})
}
func (c *controller) runPubKey() {
adminCl := mciasv1.NewAdminServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
resp, err := adminCl.GetPublicKey(ctx, &mciasv1.GetPublicKeyRequest{})
if err != nil {
fatalf("pubkey: %v", err)
}
printJSON(map[string]string{
"kty": resp.Kty,
"crv": resp.Crv,
"use": resp.Use,
"alg": resp.Alg,
"x": resp.X,
})
}
// ---- 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) {
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() {
cl := mciasv1.NewAccountServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
resp, err := cl.ListAccounts(ctx, &mciasv1.ListAccountsRequest{})
if err != nil {
fatalf("account list: %v", err)
}
printJSON(resp.Accounts)
}
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")
}
cl := mciasv1.NewAccountServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
resp, err := cl.CreateAccount(ctx, &mciasv1.CreateAccountRequest{
Username: *username,
Password: *password,
AccountType: *accountType,
})
if err != nil {
fatalf("account create: %v", err)
}
printJSON(resp.Account)
}
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")
}
cl := mciasv1.NewAccountServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
resp, err := cl.GetAccount(ctx, &mciasv1.GetAccountRequest{Id: *id})
if err != nil {
fatalf("account get: %v", err)
}
printJSON(resp.Account)
}
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 (required)")
_ = fs.Parse(args)
if *id == "" {
fatalf("account update: -id is required")
}
if *status == "" {
fatalf("account update: -status is required")
}
cl := mciasv1.NewAccountServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
_, err := cl.UpdateAccount(ctx, &mciasv1.UpdateAccountRequest{
Id: *id,
Status: *status,
})
if err != nil {
fatalf("account update: %v", err)
}
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")
}
cl := mciasv1.NewAccountServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
_, err := cl.DeleteAccount(ctx, &mciasv1.DeleteAccountRequest{Id: *id})
if err != nil {
fatalf("account delete: %v", err)
}
fmt.Println("account deleted")
}
// ---- role subcommands ----
func (c *controller) runRole(args []string) {
if len(args) == 0 {
fatalf("role requires a subcommand: list, set, grant, revoke")
}
switch args[0] {
case "list":
c.roleList(args[1:])
case "set":
c.roleSet(args[1:])
case "grant":
c.roleGrant(args[1:])
case "revoke":
c.roleRevoke(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")
}
cl := mciasv1.NewAccountServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
resp, err := cl.GetRoles(ctx, &mciasv1.GetRolesRequest{Id: *id})
if err != nil {
fatalf("role list: %v", err)
}
printJSON(resp.Roles)
}
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")
}
var roles []string
if *rolesFlag != "" {
for _, r := range strings.Split(*rolesFlag, ",") {
r = strings.TrimSpace(r)
if r != "" {
roles = append(roles, r)
}
}
}
cl := mciasv1.NewAccountServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
_, err := cl.SetRoles(ctx, &mciasv1.SetRolesRequest{Id: *id, Roles: roles})
if err != nil {
fatalf("role set: %v", err)
}
fmt.Printf("roles set: %v\n", roles)
}
func (c *controller) roleGrant(args []string) {
fs := flag.NewFlagSet("role grant", flag.ExitOnError)
id := fs.String("id", "", "account UUID (required)")
role := fs.String("role", "", "role name (required)")
_ = fs.Parse(args)
if *id == "" {
fatalf("role grant: -id is required")
}
if *role == "" {
fatalf("role grant: -role is required")
}
cl := mciasv1.NewAccountServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
_, err := cl.GrantRole(ctx, &mciasv1.GrantRoleRequest{Id: *id, Role: *role})
if err != nil {
fatalf("role grant: %v", err)
}
fmt.Printf("role granted: %s\n", *role)
}
func (c *controller) roleRevoke(args []string) {
fs := flag.NewFlagSet("role revoke", flag.ExitOnError)
id := fs.String("id", "", "account UUID (required)")
role := fs.String("role", "", "role name (required)")
_ = fs.Parse(args)
if *id == "" {
fatalf("role revoke: -id is required")
}
if *role == "" {
fatalf("role revoke: -role is required")
}
cl := mciasv1.NewAccountServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
_, err := cl.RevokeRole(ctx, &mciasv1.RevokeRoleRequest{Id: *id, Role: *role})
if err != nil {
fatalf("role revoke: %v", err)
}
fmt.Printf("role revoked: %s\n", *role)
}
// ---- token subcommands ----
func (c *controller) runToken(args []string) {
if len(args) == 0 {
fatalf("token requires a subcommand: validate, issue, revoke")
}
switch args[0] {
case "validate":
c.tokenValidate(args[1:])
case "issue":
c.tokenIssue(args[1:])
case "revoke":
c.tokenRevoke(args[1:])
default:
fatalf("unknown token subcommand %q", args[0])
}
}
func (c *controller) tokenValidate(args []string) {
fs := flag.NewFlagSet("token validate", flag.ExitOnError)
tok := fs.String("token", "", "JWT to validate (required)")
_ = fs.Parse(args)
if *tok == "" {
fatalf("token validate: -token is required")
}
cl := mciasv1.NewTokenServiceClient(c.conn)
// ValidateToken is public — no auth context needed.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := cl.ValidateToken(ctx, &mciasv1.ValidateTokenRequest{Token: *tok})
if err != nil {
fatalf("token validate: %v", err)
}
printJSON(map[string]interface{}{
"valid": resp.Valid,
"subject": resp.Subject,
"roles": resp.Roles,
})
}
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")
}
cl := mciasv1.NewTokenServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
resp, err := cl.IssueServiceToken(ctx, &mciasv1.IssueServiceTokenRequest{AccountId: *id})
if err != nil {
fatalf("token issue: %v", err)
}
printJSON(map[string]string{"token": resp.Token})
}
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")
}
cl := mciasv1.NewTokenServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
_, err := cl.RevokeToken(ctx, &mciasv1.RevokeTokenRequest{Jti: *jti})
if err != nil {
fatalf("token revoke: %v", err)
}
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")
}
cl := mciasv1.NewCredentialServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
resp, err := cl.GetPGCreds(ctx, &mciasv1.GetPGCredsRequest{Id: *id})
if err != nil {
fatalf("pgcreds get: %v", err)
}
if resp.Creds == nil {
fatalf("pgcreds get: no credentials returned")
}
printJSON(map[string]interface{}{
"host": resp.Creds.Host,
"port": resp.Creds.Port,
"database": resp.Creds.Database,
"username": resp.Creds.Username,
"password": resp.Creds.Password,
})
}
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")
}
cl := mciasv1.NewCredentialServiceClient(c.conn)
ctx, cancel := c.callCtx()
defer cancel()
_, err := cl.SetPGCreds(ctx, &mciasv1.SetPGCredsRequest{
Id: *id,
Creds: &mciasv1.PGCreds{
Host: *host,
Port: int32(*port), //nolint:gosec // G115: port validated as [1,65535] by flag parsing
Database: *dbName,
Username: *username,
Password: *password,
},
})
if err != nil {
fatalf("pgcreds set: %v", err)
}
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.
// If caCertPath is empty, the system CA pool is used.
// Security: TLS 1.2+ is enforced by the crypto/tls defaults on the client side.
// The connection is insecure-skip-verify-free; operators can supply a custom CA
// for self-signed certs without disabling certificate validation.
func newGRPCConn(serverAddr, caCertPath string) (*grpc.ClientConn, 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
}
creds := credentials.NewTLS(tlsCfg)
conn, err := grpc.NewClient(serverAddr, grpc.WithTransportCredentials(creds))
if err != nil {
return nil, fmt.Errorf("dial %s: %w", serverAddr, err)
}
return conn, nil
}
// ---- helpers ----
// printJSON pretty-prints a value as JSON 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 to stderr and exits with code 1.
func fatalf(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "mciasgrpcctl: "+format+"\n", args...)
os.Exit(1)
}
func usage() {
fmt.Fprintf(os.Stderr, `mciasgrpcctl - MCIAS gRPC admin CLI
Usage: mciasgrpcctl [global flags] <command> [args]
Global flags:
-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
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
account update -id UUID -status active|inactive
account delete -id UUID
role list -id UUID
role set -id UUID -roles role1,role2,...
token validate -token TOKEN
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]
[-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
`)
}