- 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>
382 lines
10 KiB
Go
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
|
|
}
|