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 }