Phase 5 (OCI pull): internal/oci/ package with manifest GET/HEAD by tag/digest, blob GET/HEAD with repo membership check, tag listing with OCI pagination, catalog listing. Multi-segment repo names via parseOCIPath() right-split routing. DB query layer in internal/db/repository.go. Phase 6 (OCI push): blob uploads (monolithic and chunked) with uploadManager tracking in-progress BlobWriters, manifest push implementing full ARCHITECTURE.md §5 flow in a single SQLite transaction (create repo, upsert manifest, populate manifest_blobs, atomic tag move). Digest verification on both blob commit and manifest push-by-digest. Phase 8 (admin REST): /v1 endpoints for auth (login/logout/health), repository management (list/detail/delete), policy CRUD with engine reload, audit log listing with filters, GC trigger/status stubs. RequireAdmin middleware, platform-standard error format. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
344 lines
9.4 KiB
Go
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/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)
|
|
}
|
|
}
|