package server import ( "encoding/json" "errors" "fmt" "net/http" "strconv" "github.com/go-chi/chi/v5" "git.wntrmute.dev/kyle/mcr/internal/auth" "git.wntrmute.dev/kyle/mcr/internal/db" "git.wntrmute.dev/kyle/mcr/internal/policy" ) // PolicyReloader can reload policy rules from a store. type PolicyReloader interface { Reload(store policy.RuleStore) error } // policyCreateRequest is the JSON body for creating a policy rule. type policyCreateRequest struct { Priority int `json:"priority"` Description string `json:"description"` Effect string `json:"effect"` Roles []string `json:"roles,omitempty"` AccountTypes []string `json:"account_types,omitempty"` SubjectUUID string `json:"subject_uuid,omitempty"` Actions []string `json:"actions"` Repositories []string `json:"repositories,omitempty"` Enabled *bool `json:"enabled,omitempty"` // pointer to distinguish unset from false } // policyUpdateRequest is the JSON body for updating a policy rule. type policyUpdateRequest struct { Priority *int `json:"priority,omitempty"` Description *string `json:"description,omitempty"` Effect *string `json:"effect,omitempty"` Roles []string `json:"roles,omitempty"` AccountTypes []string `json:"account_types,omitempty"` SubjectUUID *string `json:"subject_uuid,omitempty"` Actions []string `json:"actions,omitempty"` Repositories []string `json:"repositories,omitempty"` Enabled *bool `json:"enabled,omitempty"` } var validActions = map[string]bool{ string(policy.ActionVersionCheck): true, string(policy.ActionPull): true, string(policy.ActionPush): true, string(policy.ActionDelete): true, string(policy.ActionCatalog): true, string(policy.ActionPolicyManage): true, } func validateActions(actions []string) error { for _, a := range actions { if !validActions[a] { return fmt.Errorf("invalid action: %q", a) } } return nil } func validateEffect(effect string) error { if effect != "allow" && effect != "deny" { return fmt.Errorf("invalid effect: %q (must be 'allow' or 'deny')", effect) } return nil } // AdminListPolicyRulesHandler handles GET /v1/policy/rules. func AdminListPolicyRulesHandler(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { limit := 50 offset := 0 if n := r.URL.Query().Get("n"); n != "" { if v, err := strconv.Atoi(n); err == nil && v > 0 { limit = v } } if o := r.URL.Query().Get("offset"); o != "" { if v, err := strconv.Atoi(o); err == nil && v >= 0 { offset = v } } rules, err := database.ListPolicyRules(limit, offset) if err != nil { writeAdminError(w, http.StatusInternalServerError, "internal error") return } if rules == nil { rules = []db.PolicyRuleRow{} } writeJSON(w, http.StatusOK, rules) } } // AdminCreatePolicyRuleHandler handles POST /v1/policy/rules. func AdminCreatePolicyRuleHandler(database *db.DB, engine PolicyReloader, auditFn AuditFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req policyCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeAdminError(w, http.StatusBadRequest, "invalid request body") return } if req.Priority < 1 { writeAdminError(w, http.StatusBadRequest, "priority must be >= 1 (0 is reserved for built-ins)") return } if req.Description == "" { writeAdminError(w, http.StatusBadRequest, "description is required") return } if err := validateEffect(req.Effect); err != nil { writeAdminError(w, http.StatusBadRequest, err.Error()) return } if len(req.Actions) == 0 { writeAdminError(w, http.StatusBadRequest, "at least one action is required") return } if err := validateActions(req.Actions); err != nil { writeAdminError(w, http.StatusBadRequest, err.Error()) return } enabled := true if req.Enabled != nil { enabled = *req.Enabled } claims := auth.ClaimsFromContext(r.Context()) createdBy := "" if claims != nil { createdBy = claims.Subject } row := db.PolicyRuleRow{ Priority: req.Priority, Description: req.Description, Effect: req.Effect, Roles: req.Roles, AccountTypes: req.AccountTypes, SubjectUUID: req.SubjectUUID, Actions: req.Actions, Repositories: req.Repositories, Enabled: enabled, CreatedBy: createdBy, } id, err := database.CreatePolicyRule(row) if err != nil { writeAdminError(w, http.StatusInternalServerError, "internal error") return } // Reload policy engine. if engine != nil { _ = engine.Reload(database) } if auditFn != nil { auditFn("policy_rule_created", createdBy, "", "", r.RemoteAddr, map[string]string{ "rule_id": strconv.FormatInt(id, 10), }) } created, err := database.GetPolicyRule(id) if err != nil { writeAdminError(w, http.StatusInternalServerError, "internal error") return } writeJSON(w, http.StatusCreated, created) } } // AdminGetPolicyRuleHandler handles GET /v1/policy/rules/{id}. func AdminGetPolicyRuleHandler(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeAdminError(w, http.StatusBadRequest, "invalid rule ID") return } rule, err := database.GetPolicyRule(id) if err != nil { if errors.Is(err, db.ErrPolicyRuleNotFound) { writeAdminError(w, http.StatusNotFound, "policy rule not found") return } writeAdminError(w, http.StatusInternalServerError, "internal error") return } writeJSON(w, http.StatusOK, rule) } } // AdminUpdatePolicyRuleHandler handles PATCH /v1/policy/rules/{id}. func AdminUpdatePolicyRuleHandler(database *db.DB, engine PolicyReloader, auditFn AuditFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeAdminError(w, http.StatusBadRequest, "invalid rule ID") return } var req policyUpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeAdminError(w, http.StatusBadRequest, "invalid request body") return } if req.Priority != nil && *req.Priority < 1 { writeAdminError(w, http.StatusBadRequest, "priority must be >= 1 (0 is reserved for built-ins)") return } if req.Effect != nil { if err := validateEffect(*req.Effect); err != nil { writeAdminError(w, http.StatusBadRequest, err.Error()) return } } if req.Actions != nil { if len(req.Actions) == 0 { writeAdminError(w, http.StatusBadRequest, "at least one action is required") return } if err := validateActions(req.Actions); err != nil { writeAdminError(w, http.StatusBadRequest, err.Error()) return } } updates := db.PolicyRuleRow{} if req.Priority != nil { updates.Priority = *req.Priority } if req.Description != nil { updates.Description = *req.Description } if req.Effect != nil { updates.Effect = *req.Effect } if req.Roles != nil { updates.Roles = req.Roles } if req.AccountTypes != nil { updates.AccountTypes = req.AccountTypes } if req.SubjectUUID != nil { updates.SubjectUUID = *req.SubjectUUID } if req.Actions != nil { updates.Actions = req.Actions } if req.Repositories != nil { updates.Repositories = req.Repositories } if err := database.UpdatePolicyRule(id, updates); err != nil { if errors.Is(err, db.ErrPolicyRuleNotFound) { writeAdminError(w, http.StatusNotFound, "policy rule not found") return } writeAdminError(w, http.StatusInternalServerError, "internal error") return } // Handle enabled separately since it's a bool. if req.Enabled != nil { if err := database.SetPolicyRuleEnabled(id, *req.Enabled); err != nil { writeAdminError(w, http.StatusInternalServerError, "internal error") return } } // Reload policy engine. if engine != nil { _ = engine.Reload(database) } if auditFn != nil { claims := auth.ClaimsFromContext(r.Context()) actorID := "" if claims != nil { actorID = claims.Subject } auditFn("policy_rule_updated", actorID, "", "", r.RemoteAddr, map[string]string{ "rule_id": strconv.FormatInt(id, 10), }) } updated, err := database.GetPolicyRule(id) if err != nil { writeAdminError(w, http.StatusInternalServerError, "internal error") return } writeJSON(w, http.StatusOK, updated) } } // AdminDeletePolicyRuleHandler handles DELETE /v1/policy/rules/{id}. func AdminDeletePolicyRuleHandler(database *db.DB, engine PolicyReloader, auditFn AuditFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeAdminError(w, http.StatusBadRequest, "invalid rule ID") return } if err := database.DeletePolicyRule(id); err != nil { if errors.Is(err, db.ErrPolicyRuleNotFound) { writeAdminError(w, http.StatusNotFound, "policy rule not found") return } writeAdminError(w, http.StatusInternalServerError, "internal error") return } // Reload policy engine. if engine != nil { _ = engine.Reload(database) } if auditFn != nil { claims := auth.ClaimsFromContext(r.Context()) actorID := "" if claims != nil { actorID = claims.Subject } auditFn("policy_rule_deleted", actorID, "", "", r.RemoteAddr, map[string]string{ "rule_id": strconv.FormatInt(id, 10), }) } w.WriteHeader(http.StatusNoContent) } }