Checkpoint: password reset, rule expiry, migrations

- Self-service and admin password-change endpoints
  (PUT /v1/auth/password, PUT /v1/accounts/{id}/password)
- Policy rule time-scoped expiry (not_before / expires_at)
  with migration 000006 and engine filtering
- golang-migrate integration; embedded SQL migrations
- PolicyRecord fieldalignment lint fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 14:38:38 -07:00
parent d7b69ed983
commit 22158824bd
25 changed files with 1574 additions and 137 deletions

View File

@@ -16,13 +16,15 @@
//
// Commands:
//
// auth login -username NAME [-password PASS] [-totp CODE]
// auth login -username NAME [-totp CODE]
// auth change-password (passwords always prompted interactively)
//
// 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
// account create -username NAME [-type human|system]
// account get -id UUID
// account update -id UUID [-status active|inactive]
// account delete -id UUID
// account set-password -id UUID
//
// role list -id UUID
// role set -id UUID -roles role1,role2,...
@@ -34,9 +36,9 @@
// pgcreds get -id UUID
//
// policy list
// policy create -description STR -json FILE [-priority N]
// 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]
// 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
//
// tag list -id UUID
@@ -123,28 +125,28 @@ type controller struct {
func (c *controller) runAuth(args []string) {
if len(args) == 0 {
fatalf("auth requires a subcommand: login")
fatalf("auth requires a subcommand: login, change-password")
}
switch args[0] {
case "login":
c.authLogin(args[1:])
case "change-password":
c.authChangePassword(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).
// 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: 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.
// 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)")
password := fs.String("password", "", "password (reads from stdin if omitted)")
totpCode := fs.String("totp", "", "TOTP code (required if TOTP is enrolled)")
_ = fs.Parse(args)
@@ -152,21 +154,19 @@ func (c *controller) authLogin(args []string) {
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
}
// 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) // 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{
@@ -191,11 +191,53 @@ func (c *controller) authLogin(args []string) {
}
}
// authChangePassword allows an authenticated user to change their own password.
// A valid bearer token must be set (via -token flag or MCIAS_TOKEN env var).
// Both passwords are always prompted interactively; they are never accepted as
// command-line flags to prevent them from appearing in shell history, ps
// output, and process argument lists.
//
// Security: terminal echo is disabled during entry (golang.org/x/term);
// raw byte slices are zeroed after use. The server requires the current
// password to prevent token-theft attacks. On success all other active
// sessions are revoked server-side.
func (c *controller) authChangePassword(_ []string) {
// Security: always prompt interactively; never accept passwords as flags.
fmt.Fprint(os.Stderr, "Current password: ")
rawCurrent, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
fmt.Fprintln(os.Stderr)
if err != nil {
fatalf("read current password: %v", err)
}
currentPasswd := string(rawCurrent)
for i := range rawCurrent {
rawCurrent[i] = 0
}
fmt.Fprint(os.Stderr, "New password: ")
rawNew, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
fmt.Fprintln(os.Stderr)
if err != nil {
fatalf("read new password: %v", err)
}
newPasswd := string(rawNew)
for i := range rawNew {
rawNew[i] = 0
}
body := map[string]string{
"current_password": currentPasswd,
"new_password": newPasswd,
}
c.doRequest("PUT", "/v1/auth/password", body, nil)
fmt.Println("password changed; other active sessions revoked")
}
// ---- account subcommands ----
func (c *controller) runAccount(args []string) {
if len(args) == 0 {
fatalf("account requires a subcommand: list, create, get, update, delete")
fatalf("account requires a subcommand: list, create, get, update, delete, set-password")
}
switch args[0] {
case "list":
@@ -208,6 +250,8 @@ func (c *controller) runAccount(args []string) {
c.accountUpdate(args[1:])
case "delete":
c.accountDelete(args[1:])
case "set-password":
c.accountSetPassword(args[1:])
default:
fatalf("unknown account subcommand %q", args[0])
}
@@ -222,7 +266,6 @@ func (c *controller) accountList() {
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)
@@ -230,12 +273,11 @@ func (c *controller) accountCreate(args []string) {
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" {
// Security: always prompt interactively for human-account passwords; never
// accept them as a flag. Terminal echo is disabled; the raw byte slice is
// zeroed after conversion to string. System accounts have no password.
var passwd string
if *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)
@@ -306,6 +348,40 @@ func (c *controller) accountDelete(args []string) {
fmt.Println("account deleted")
}
// accountSetPassword resets a human account's password (admin operation).
// No current password is required. All active sessions for the target account
// are revoked by the server on success.
//
// Security: the new 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. Terminal echo is disabled
// (golang.org/x/term); the raw byte slice is zeroed after use.
func (c *controller) accountSetPassword(args []string) {
fs := flag.NewFlagSet("account set-password", flag.ExitOnError)
id := fs.String("id", "", "account UUID (required)")
_ = fs.Parse(args)
if *id == "" {
fatalf("account set-password: -id is required")
}
// Security: always prompt interactively; never accept password as a flag.
fmt.Fprint(os.Stderr, "New 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{"new_password": passwd}
c.doRequest("PUT", "/v1/accounts/"+*id+"/password", body, nil)
fmt.Println("password updated; all active sessions revoked")
}
// ---- role subcommands ----
func (c *controller) runRole(args []string) {
@@ -511,6 +587,8 @@ func (c *controller) policyCreate(args []string) {
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 == "" {
@@ -537,6 +615,18 @@ func (c *controller) policyCreate(args []string) {
"priority": *priority,
"rule": ruleBody,
}
if *notBefore != "" {
if _, err := time.Parse(time.RFC3339, *notBefore); err != nil {
fatalf("policy create: -not-before must be RFC3339: %v", err)
}
body["not_before"] = *notBefore
}
if *expiresAt != "" {
if _, err := time.Parse(time.RFC3339, *expiresAt); err != nil {
fatalf("policy create: -expires-at must be RFC3339: %v", err)
}
body["expires_at"] = *expiresAt
}
var result json.RawMessage
c.doRequest("POST", "/v1/policy/rules", body, &result)
@@ -562,6 +652,10 @@ func (c *controller) policyUpdate(args []string) {
id := 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 *id == "" {
@@ -584,8 +678,24 @@ func (c *controller) policyUpdate(args []string) {
fatalf("policy update: -enabled must be true or false")
}
}
if *clearNotBefore {
body["clear_not_before"] = true
} else if *notBefore != "" {
if _, err := time.Parse(time.RFC3339, *notBefore); err != nil {
fatalf("policy update: -not-before must be RFC3339: %v", err)
}
body["not_before"] = *notBefore
}
if *clearExpiresAt {
body["clear_expires_at"] = true
} else if *expiresAt != "" {
if _, err := time.Parse(time.RFC3339, *expiresAt); err != nil {
fatalf("policy update: -expires-at must be RFC3339: %v", err)
}
body["expires_at"] = *expiresAt
}
if len(body) == 0 {
fatalf("policy update: at least one of -priority or -enabled is required")
fatalf("policy update: at least one flag is required")
}
var result json.RawMessage
@@ -766,16 +876,25 @@ Global flags:
-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.
auth login -username NAME [-totp CODE]
Obtain a bearer token. Password is always prompted interactively
(never accepted as a flag) to avoid shell-history exposure.
Token is written to stdout; expiry to stderr.
Example: export MCIAS_TOKEN=$(mciasctl auth login -username alice)
auth change-password
Change the current user's own password. Requires a valid bearer
token. Current and new passwords are always prompted interactively.
Revokes all other active sessions on success.
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
account create -username NAME [-type human|system]
account get -id UUID
account update -id UUID -status active|inactive
account delete -id UUID
account set-password -id UUID
Admin: reset a human account's password without requiring the
current password. New password is always prompted interactively.
Revokes all active sessions for the account.
role list -id UUID
role set -id UUID -roles role1,role2,...
@@ -788,10 +907,13 @@ Commands:
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
tag list -id UUID