Phases 5, 6, 8: OCI pull/push paths and admin REST API
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>
This commit is contained in:
343
internal/server/admin_policy.go
Normal file
343
internal/server/admin_policy.go
Normal file
@@ -0,0 +1,343 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user