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:
@@ -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
|
||||
`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user