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