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

@@ -896,6 +896,97 @@ func (u *UIServer) handleCreatePGCreds(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/pgcreds", http.StatusSeeOther)
}
// handleAdminResetPassword allows an admin to set a new password for any human
// account without requiring the current password. On success all active tokens
// for the target account are revoked so a compromised account is fully
// invalidated.
//
// Security: new password is validated (minimum 12 chars) and hashed with
// Argon2id before storage. The plaintext is never logged or included in any
// response. Audit event EventPasswordChanged is recorded on success.
func (u *UIServer) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
return
}
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
if acct.AccountType != model.AccountTypeHuman {
u.renderError(w, r, http.StatusBadRequest, "password can only be reset for human accounts")
return
}
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_password")
if newPassword == "" {
u.renderError(w, r, http.StatusBadRequest, "new password is required")
return
}
// Server-side equality check mirrors the client-side guard; defends against
// direct POST requests that bypass the JavaScript confirmation.
if newPassword != confirmPassword {
u.renderError(w, r, http.StatusBadRequest, "passwords do not match")
return
}
// Security (F-13): enforce minimum length before hashing.
if err := validate.Password(newPassword); err != nil {
u.renderError(w, r, http.StatusBadRequest, err.Error())
return
}
hash, err := auth.HashPassword(newPassword, auth.ArgonParams{
Time: u.cfg.Argon2.Time,
Memory: u.cfg.Argon2.Memory,
Threads: u.cfg.Argon2.Threads,
})
if err != nil {
u.logger.Error("hash password (admin reset)", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
if err := u.db.UpdatePasswordHash(acct.ID, hash); err != nil {
u.logger.Error("update password hash", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to update password")
return
}
// Security: revoke all active sessions for the target account so an
// attacker who held a valid token cannot continue to use it after reset.
// Render an error fragment rather than silently claiming success if
// revocation fails.
if err := u.db.RevokeAllUserTokens(acct.ID, "password_reset"); err != nil {
u.logger.Error("revoke tokens on admin password reset", "account_id", acct.ID, "error", err)
u.renderError(w, r, http.StatusInternalServerError, "password updated but session revocation failed; revoke tokens manually")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventPasswordChanged, actorID, &acct.ID, `{"via":"admin_reset"}`)
// Return a success fragment so HTMX can display confirmation inline.
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "password_reset_result", AccountDetailData{
PageData: PageData{
CSRFToken: csrfToken,
Flash: "Password updated and all active sessions revoked.",
},
Account: acct,
})
}
// handleIssueSystemToken issues a long-lived service token for a system account.
func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
@@ -70,7 +71,7 @@ func (u *UIServer) handlePoliciesPage(w http.ResponseWriter, r *http.Request) {
// policyRuleToView converts a DB record to a template-friendly view.
func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView {
pretty := prettyJSONStr(rec.RuleJSON)
return &PolicyRuleView{
v := &PolicyRuleView{
ID: rec.ID,
Priority: rec.Priority,
Description: rec.Description,
@@ -79,6 +80,16 @@ func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView {
CreatedAt: rec.CreatedAt.Format("2006-01-02 15:04 UTC"),
UpdatedAt: rec.UpdatedAt.Format("2006-01-02 15:04 UTC"),
}
now := time.Now()
if rec.NotBefore != nil {
v.NotBefore = rec.NotBefore.UTC().Format("2006-01-02 15:04 UTC")
v.IsPending = now.Before(*rec.NotBefore)
}
if rec.ExpiresAt != nil {
v.ExpiresAt = rec.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC")
v.IsExpired = now.After(*rec.ExpiresAt)
}
return v
}
func prettyJSONStr(s string) string {
@@ -160,6 +171,29 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
return
}
// Parse optional time-scoped validity window from datetime-local inputs.
var notBefore, expiresAt *time.Time
if nbStr := strings.TrimSpace(r.FormValue("not_before")); nbStr != "" {
t, err := time.Parse("2006-01-02T15:04", nbStr)
if err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid not_before time format")
return
}
notBefore = &t
}
if eaStr := strings.TrimSpace(r.FormValue("expires_at")); eaStr != "" {
t, err := time.Parse("2006-01-02T15:04", eaStr)
if err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid expires_at time format")
return
}
expiresAt = &t
}
if notBefore != nil && expiresAt != nil && !expiresAt.After(*notBefore) {
u.renderError(w, r, http.StatusBadRequest, "expires_at must be after not_before")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
@@ -168,7 +202,7 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
}
}
rec, err := u.db.CreatePolicyRule(description, priority, string(ruleJSON), actorID)
rec, err := u.db.CreatePolicyRule(description, priority, string(ruleJSON), actorID, notBefore, expiresAt)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, fmt.Sprintf("create policy rule: %v", err))
return

View File

@@ -190,6 +190,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
"templates/fragments/tags_editor.html",
"templates/fragments/policy_row.html",
"templates/fragments/policy_form.html",
"templates/fragments/password_reset_form.html",
}
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
if err != nil {
@@ -293,6 +294,7 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.Handle("PATCH /policies/{id}/enabled", admin(u.handleTogglePolicyRule))
uiMux.Handle("DELETE /policies/{id}", admin(u.handleDeletePolicyRule))
uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags))
uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword))
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
// catch-all for all UI paths; the more-specific /v1/ API patterns registered
@@ -593,9 +595,13 @@ type PolicyRuleView struct {
RuleJSON string
CreatedAt string
UpdatedAt string
NotBefore string // empty if not set
ExpiresAt string // empty if not set
ID int64
Priority int
Enabled bool
IsExpired bool // true if expires_at is in the past
IsPending bool // true if not_before is in the future
}
// PoliciesData is the view model for the policies list page.