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

@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"strconv"
"time"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/middleware"
@@ -90,6 +91,8 @@ func (s *Server) handleSetTags(w http.ResponseWriter, r *http.Request) {
type policyRuleResponse struct {
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
NotBefore *string `json:"not_before,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
Description string `json:"description"`
RuleBody policy.RuleBody `json:"rule"`
ID int64 `json:"id"`
@@ -102,15 +105,24 @@ func policyRuleToResponse(rec *model.PolicyRuleRecord) (policyRuleResponse, erro
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
return policyRuleResponse{}, fmt.Errorf("decode rule body: %w", err)
}
return policyRuleResponse{
resp := policyRuleResponse{
ID: rec.ID,
Priority: rec.Priority,
Description: rec.Description,
RuleBody: body,
Enabled: rec.Enabled,
CreatedAt: rec.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: rec.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}, nil
CreatedAt: rec.CreatedAt.Format(time.RFC3339),
UpdatedAt: rec.UpdatedAt.Format(time.RFC3339),
}
if rec.NotBefore != nil {
s := rec.NotBefore.UTC().Format(time.RFC3339)
resp.NotBefore = &s
}
if rec.ExpiresAt != nil {
s := rec.ExpiresAt.UTC().Format(time.RFC3339)
resp.ExpiresAt = &s
}
return resp, nil
}
func (s *Server) handleListPolicyRules(w http.ResponseWriter, _ *http.Request) {
@@ -133,6 +145,8 @@ func (s *Server) handleListPolicyRules(w http.ResponseWriter, _ *http.Request) {
type createPolicyRuleRequest struct {
Description string `json:"description"`
NotBefore *string `json:"not_before,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
Rule policy.RuleBody `json:"rule"`
Priority int `json:"priority"`
}
@@ -157,6 +171,29 @@ func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request)
priority = 100 // default
}
// Parse optional time-scoped validity window.
var notBefore, expiresAt *time.Time
if req.NotBefore != nil {
t, err := time.Parse(time.RFC3339, *req.NotBefore)
if err != nil {
middleware.WriteError(w, http.StatusBadRequest, "not_before must be RFC3339", "bad_request")
return
}
notBefore = &t
}
if req.ExpiresAt != nil {
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
if err != nil {
middleware.WriteError(w, http.StatusBadRequest, "expires_at must be RFC3339", "bad_request")
return
}
expiresAt = &t
}
if notBefore != nil && expiresAt != nil && !expiresAt.After(*notBefore) {
middleware.WriteError(w, http.StatusBadRequest, "expires_at must be after not_before", "bad_request")
return
}
ruleJSON, err := json.Marshal(req.Rule)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
@@ -171,7 +208,7 @@ func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request)
}
}
rec, err := s.db.CreatePolicyRule(req.Description, priority, string(ruleJSON), createdBy)
rec, err := s.db.CreatePolicyRule(req.Description, priority, string(ruleJSON), createdBy, notBefore, expiresAt)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
@@ -202,10 +239,14 @@ func (s *Server) handleGetPolicyRule(w http.ResponseWriter, r *http.Request) {
}
type updatePolicyRuleRequest struct {
Description *string `json:"description,omitempty"`
Rule *policy.RuleBody `json:"rule,omitempty"`
Priority *int `json:"priority,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Description *string `json:"description,omitempty"`
NotBefore *string `json:"not_before,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
Rule *policy.RuleBody `json:"rule,omitempty"`
Priority *int `json:"priority,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
ClearNotBefore *bool `json:"clear_not_before,omitempty"`
ClearExpiresAt *bool `json:"clear_expires_at,omitempty"`
}
func (s *Server) handleUpdatePolicyRule(w http.ResponseWriter, r *http.Request) {
@@ -230,11 +271,39 @@ func (s *Server) handleUpdatePolicyRule(w http.ResponseWriter, r *http.Request)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
s := string(b)
ruleJSON = &s
js := string(b)
ruleJSON = &js
}
if err := s.db.UpdatePolicyRule(rec.ID, req.Description, req.Priority, ruleJSON); err != nil {
// Parse optional time-scoped validity window updates.
// Double-pointer semantics: nil = no change, non-nil→nil = clear, non-nil→non-nil = set.
var notBefore, expiresAt **time.Time
if req.ClearNotBefore != nil && *req.ClearNotBefore {
var nilTime *time.Time
notBefore = &nilTime // non-nil outer, nil inner → set to NULL
} else if req.NotBefore != nil {
t, err := time.Parse(time.RFC3339, *req.NotBefore)
if err != nil {
middleware.WriteError(w, http.StatusBadRequest, "not_before must be RFC3339", "bad_request")
return
}
tp := &t
notBefore = &tp
}
if req.ClearExpiresAt != nil && *req.ClearExpiresAt {
var nilTime *time.Time
expiresAt = &nilTime // non-nil outer, nil inner → set to NULL
} else if req.ExpiresAt != nil {
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
if err != nil {
middleware.WriteError(w, http.StatusBadRequest, "expires_at must be RFC3339", "bad_request")
return
}
tp := &t
expiresAt = &tp
}
if err := s.db.UpdatePolicyRule(rec.ID, req.Description, req.Priority, ruleJSON, notBefore, expiresAt); err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}

View File

@@ -121,6 +121,10 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
mux.Handle("GET /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleGetTags)))
mux.Handle("PUT /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleSetTags)))
mux.Handle("PUT /v1/accounts/{id}/password", requireAdmin(http.HandlerFunc(s.handleAdminSetPassword)))
// Self-service password change (requires valid token; actor must match target account).
mux.Handle("PUT /v1/auth/password", requireAuth(http.HandlerFunc(s.handleChangePassword)))
mux.Handle("GET /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleListPolicyRules)))
mux.Handle("POST /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleCreatePolicyRule)))
mux.Handle("GET /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleGetPolicyRule)))
@@ -801,6 +805,183 @@ func (s *Server) handleTOTPRemove(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// ---- Password change endpoints ----
// adminSetPasswordRequest is the request body for PUT /v1/accounts/{id}/password.
// Used by admins to reset any human account's password without requiring the
// current password.
type adminSetPasswordRequest struct {
NewPassword string `json:"new_password"`
}
// handleAdminSetPassword allows an admin to reset any human account's password.
// No current-password verification is required because the admin role already
// represents a higher trust level, matching the break-glass recovery pattern.
//
// Security: new password is validated (minimum length) and hashed with Argon2id
// before storage. The plaintext is never logged. All active tokens for the
// target account are revoked so that a compromised-account recovery fully
// invalidates any outstanding sessions.
func (s *Server) handleAdminSetPassword(w http.ResponseWriter, r *http.Request) {
acct, ok := s.loadAccount(w, r)
if !ok {
return
}
if acct.AccountType != model.AccountTypeHuman {
middleware.WriteError(w, http.StatusBadRequest, "password can only be set on human accounts", "bad_request")
return
}
var req adminSetPasswordRequest
if !decodeJSON(w, r, &req) {
return
}
// Security (F-13): enforce minimum length before hashing.
if err := validate.Password(req.NewPassword); err != nil {
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
return
}
hash, err := auth.HashPassword(req.NewPassword, auth.ArgonParams{
Time: s.cfg.Argon2.Time,
Memory: s.cfg.Argon2.Memory,
Threads: s.cfg.Argon2.Threads,
})
if err != nil {
s.logger.Error("hash password (admin reset)", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if err := s.db.UpdatePasswordHash(acct.ID, hash); err != nil {
s.logger.Error("update password hash", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
// Security: revoke all active sessions so a compromised account cannot
// continue to use old tokens after a password reset. Failure here means
// the API's documented guarantee ("all active sessions revoked") cannot be
// upheld, so we return 500 rather than silently succeeding.
if err := s.db.RevokeAllUserTokens(acct.ID, "password_reset"); err != nil {
s.logger.Error("revoke tokens on password reset", "error", err, "account_id", acct.ID)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
actor := middleware.ClaimsFromContext(r.Context())
var actorID *int64
if actor != nil {
if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil {
actorID = &a.ID
}
}
s.writeAudit(r, model.EventPasswordChanged, actorID, &acct.ID, `{"via":"admin_reset"}`)
w.WriteHeader(http.StatusNoContent)
}
// changePasswordRequest is the request body for PUT /v1/auth/password.
// The current_password is required to prevent token-theft attacks: an attacker
// who steals a valid JWT cannot change the password without also knowing the
// existing one.
type changePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
// handleChangePassword allows an authenticated user to change their own password.
// The current password must be verified before the new hash is written.
//
// Security: current password is verified with Argon2id (constant-time).
// Lockout is checked and failures are recorded to prevent the endpoint from
// being used as an oracle for the current password. On success, all other
// active sessions (other JTIs) are revoked so stale tokens cannot be used
// after a credential rotation.
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
acct, err := s.db.GetAccountByUUID(claims.Subject)
if err != nil {
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
return
}
if acct.AccountType != model.AccountTypeHuman {
middleware.WriteError(w, http.StatusBadRequest, "password change is only available for human accounts", "bad_request")
return
}
var req changePasswordRequest
if !decodeJSON(w, r, &req) {
return
}
if req.CurrentPassword == "" || req.NewPassword == "" {
middleware.WriteError(w, http.StatusBadRequest, "current_password and new_password are required", "bad_request")
return
}
// Security: check lockout before verifying (same as login flow) so an
// attacker cannot use this endpoint to brute-force the current password.
locked, lockErr := s.db.IsLockedOut(acct.ID)
if lockErr != nil {
s.logger.Error("lockout check (password change)", "error", lockErr)
}
if locked {
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"locked"}`)
middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked")
return
}
// Security: verify the current password with the same constant-time
// Argon2id path used at login to prevent timing oracles.
ok, verifyErr := auth.VerifyPassword(req.CurrentPassword, acct.PasswordHash)
if verifyErr != nil || !ok {
_ = s.db.RecordLoginFailure(acct.ID)
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"wrong_current_password"}`)
middleware.WriteError(w, http.StatusUnauthorized, "current password is incorrect", "unauthorized")
return
}
// Security (F-13): enforce minimum length on the new password before hashing.
if err := validate.Password(req.NewPassword); err != nil {
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
return
}
hash, err := auth.HashPassword(req.NewPassword, auth.ArgonParams{
Time: s.cfg.Argon2.Time,
Memory: s.cfg.Argon2.Memory,
Threads: s.cfg.Argon2.Threads,
})
if err != nil {
s.logger.Error("hash password (self-service change)", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if err := s.db.UpdatePasswordHash(acct.ID, hash); err != nil {
s.logger.Error("update password hash", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
// Security: clear the failure counter since the user proved knowledge of
// the current password, then revoke all tokens *except* the current one so
// the caller retains their active session but any other stolen sessions are
// invalidated. Revocation failure breaks the documented guarantee so we
// return 500 rather than silently succeeding.
_ = s.db.ClearLoginFailures(acct.ID)
if err := s.db.RevokeAllUserTokensExcept(acct.ID, claims.JTI, "password_changed"); err != nil {
s.logger.Error("revoke other tokens on password change", "error", err, "account_id", acct.ID)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
s.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"via":"self_service"}`)
w.WriteHeader(http.StatusNoContent)
}
// ---- Postgres credential endpoints ----
type pgCredRequest struct {