Files
mcias/internal/ui/handlers_policy.go
Kyle Isom 22158824bd 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>
2026-03-12 14:38:38 -07:00

382 lines
10 KiB
Go

package ui
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/policy"
)
// ---- Policies page ----
// allActionStrings is the list of all policy action constants for the form UI.
var allActionStrings = []string{
string(policy.ActionListAccounts),
string(policy.ActionCreateAccount),
string(policy.ActionReadAccount),
string(policy.ActionUpdateAccount),
string(policy.ActionDeleteAccount),
string(policy.ActionReadRoles),
string(policy.ActionWriteRoles),
string(policy.ActionReadTags),
string(policy.ActionWriteTags),
string(policy.ActionIssueToken),
string(policy.ActionRevokeToken),
string(policy.ActionValidateToken),
string(policy.ActionRenewToken),
string(policy.ActionReadPGCreds),
string(policy.ActionWritePGCreds),
string(policy.ActionReadAudit),
string(policy.ActionEnrollTOTP),
string(policy.ActionRemoveTOTP),
string(policy.ActionLogin),
string(policy.ActionLogout),
string(policy.ActionListRules),
string(policy.ActionManageRules),
}
func (u *UIServer) handlePoliciesPage(w http.ResponseWriter, r *http.Request) {
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
rules, err := u.db.ListPolicyRules(false)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load policy rules")
return
}
views := make([]*PolicyRuleView, 0, len(rules))
for _, rec := range rules {
views = append(views, policyRuleToView(rec))
}
data := PoliciesData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
Rules: views,
AllActions: allActionStrings,
}
u.render(w, "policies", data)
}
// policyRuleToView converts a DB record to a template-friendly view.
func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView {
pretty := prettyJSONStr(rec.RuleJSON)
v := &PolicyRuleView{
ID: rec.ID,
Priority: rec.Priority,
Description: rec.Description,
RuleJSON: pretty,
Enabled: rec.Enabled,
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 {
var v json.RawMessage
if err := json.Unmarshal([]byte(s), &v); err != nil {
return s
}
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return s
}
return string(b)
}
// handleCreatePolicyRule handles POST /policies — creates a new policy rule.
func (u *UIServer) handleCreatePolicyRule(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
}
description := strings.TrimSpace(r.FormValue("description"))
if description == "" {
u.renderError(w, r, http.StatusBadRequest, "description is required")
return
}
priorityStr := r.FormValue("priority")
priority := 100
if priorityStr != "" {
p, err := strconv.Atoi(priorityStr)
if err != nil || p < 0 {
u.renderError(w, r, http.StatusBadRequest, "priority must be a non-negative integer")
return
}
priority = p
}
effectStr := r.FormValue("effect")
if effectStr != string(policy.Allow) && effectStr != string(policy.Deny) {
u.renderError(w, r, http.StatusBadRequest, "effect must be 'allow' or 'deny'")
return
}
body := policy.RuleBody{
Effect: policy.Effect(effectStr),
}
// Multi-value fields.
if roles := r.Form["roles"]; len(roles) > 0 {
body.Roles = roles
}
if types := r.Form["account_types"]; len(types) > 0 {
body.AccountTypes = types
}
if actions := r.Form["actions"]; len(actions) > 0 {
acts := make([]policy.Action, len(actions))
for i, a := range actions {
acts[i] = policy.Action(a)
}
body.Actions = acts
}
if resType := r.FormValue("resource_type"); resType != "" {
body.ResourceType = policy.ResourceType(resType)
}
body.SubjectUUID = strings.TrimSpace(r.FormValue("subject_uuid"))
body.OwnerMatchesSubject = r.FormValue("owner_matches_subject") == "1"
if svcNames := r.FormValue("service_names"); svcNames != "" {
body.ServiceNames = splitCommas(svcNames)
}
if tags := r.FormValue("required_tags"); tags != "" {
body.RequiredTags = splitCommas(tags)
}
ruleJSON, err := json.Marshal(body)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "internal error")
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 {
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
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
}
u.writeAudit(r, model.EventPolicyRuleCreated, actorID, nil,
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
u.render(w, "policy_row", policyRuleToView(rec))
}
// handleTogglePolicyRule handles PATCH /policies/{id}/enabled — enable or disable.
func (u *UIServer) handleTogglePolicyRule(w http.ResponseWriter, r *http.Request) {
rec, ok := u.loadPolicyRule(w, r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
return
}
enabledStr := r.FormValue("enabled")
enabled := enabledStr == "1" || enabledStr == "true"
if err := u.db.SetPolicyRuleEnabled(rec.ID, enabled); err != nil {
u.renderError(w, r, http.StatusInternalServerError, "update failed")
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.EventPolicyRuleUpdated, actorID, nil,
fmt.Sprintf(`{"rule_id":%d,"enabled":%v}`, rec.ID, enabled))
rec.Enabled = enabled
u.render(w, "policy_row", policyRuleToView(rec))
}
// handleDeletePolicyRule handles DELETE /policies/{id}.
func (u *UIServer) handleDeletePolicyRule(w http.ResponseWriter, r *http.Request) {
rec, ok := u.loadPolicyRule(w, r)
if !ok {
return
}
if err := u.db.DeletePolicyRule(rec.ID); err != nil {
u.renderError(w, r, http.StatusInternalServerError, "delete failed")
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.EventPolicyRuleDeleted, actorID, nil,
fmt.Sprintf(`{"rule_id":%d}`, rec.ID))
// Return empty string to remove the row from the DOM.
w.WriteHeader(http.StatusOK)
}
// ---- Tag management ----
// handleSetAccountTags handles PUT /accounts/{id}/tags from the UI.
func (u *UIServer) handleSetAccountTags(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
return
}
tagsRaw := strings.TrimSpace(r.FormValue("tags_text"))
var tags []string
if tagsRaw != "" {
tags = splitLines(tagsRaw)
}
// Validate: no empty tags.
for _, tag := range tags {
if tag == "" {
u.renderError(w, r, http.StatusBadRequest, "tag values must not be empty")
return
}
}
if err := u.db.SetAccountTags(acct.ID, tags); err != nil {
u.renderError(w, r, http.StatusInternalServerError, "update failed")
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.EventTagAdded, actorID, &acct.ID,
fmt.Sprintf(`{"account":%q,"tags":%d}`, acct.UUID, len(tags)))
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "tags_editor", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
Tags: tags,
})
}
// ---- Helpers ----
func (u *UIServer) loadPolicyRule(w http.ResponseWriter, r *http.Request) (*model.PolicyRuleRecord, bool) {
idStr := r.PathValue("id")
if idStr == "" {
u.renderError(w, r, http.StatusBadRequest, "rule id is required")
return nil, false
}
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
u.renderError(w, r, http.StatusBadRequest, "rule id must be an integer")
return nil, false
}
rec, err := u.db.GetPolicyRule(id)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
u.renderError(w, r, http.StatusNotFound, "policy rule not found")
return nil, false
}
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return nil, false
}
return rec, true
}
// splitCommas splits a comma-separated string and trims whitespace from each element.
func splitCommas(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// splitLines splits a newline-separated string and trims whitespace from each element.
func splitLines(s string) []string {
parts := strings.Split(s, "\n")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}