Files
mcias/internal/ui/handlers_policy.go
Kyle Isom b2f2f04646 UI: pgcreds create button; show logged-in user
* web/templates/pgcreds.html: New Credentials card is now always
  rendered; Add Credentials toggle button reveals the create form
  (hidden by default). Shows a message when all system accounts
  already have credentials. Previously the card was hidden when
  UncredentialedAccounts was empty.
* internal/ui/ui.go: added ActorName string field to PageData;
  added actorName(r) helper resolving username from JWT claims
  via DB lookup, returns empty string if unauthenticated.
* internal/ui/handlers_*.go: all full-page PageData constructors
  now pass ActorName: u.actorName(r).
* web/templates/base.html: nav bar renders actor username as a
  muted label before the Logout button when logged in.
* web/static/style.css: added .nav-actor rule (muted grey, 0.85rem).
2026-03-12 11:38:57 -07:00

348 lines
9.4 KiB
Go

package ui
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"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)
return &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"),
}
}
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
}
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)
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
}