Files
mcr/internal/server/admin_policy.go
Kyle Isom d5580f01f2 Migrate module path from kyle/ to mc/ org
All import paths updated to git.wntrmute.dev/mc/. Bumps mcdsl to v1.2.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:05:59 -07:00

344 lines
9.4 KiB
Go

package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"git.wntrmute.dev/mc/mcr/internal/auth"
"git.wntrmute.dev/mc/mcr/internal/db"
"git.wntrmute.dev/mc/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)
}
}