Files
mcns/internal/server/records.go
Kyle Isom 871b1fb8f4 Add record-level authorization for system accounts
Record mutations (create, update, delete) no longer require admin role.
Authorization rules:
  - admin: full access (unchanged)
  - system mcp-agent: create/delete any record
  - system account α: create/delete records named α only
  - human users: read-only (unchanged)

Zone mutations remain admin-only. Both REST and gRPC paths enforce the
same rules. Update checks authorization against both old and new names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:52:43 -07:00

199 lines
5.1 KiB
Go

package server
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"git.wntrmute.dev/mc/mcns/internal/db"
)
type createRecordRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Value string `json:"value"`
TTL int `json:"ttl"`
}
func listRecordsHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
zoneName := chi.URLParam(r, "zone")
nameFilter := r.URL.Query().Get("name")
typeFilter := r.URL.Query().Get("type")
records, err := database.ListRecords(zoneName, nameFilter, typeFilter)
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, "zone not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list records")
return
}
if records == nil {
records = []db.Record{}
}
writeJSON(w, http.StatusOK, map[string]any{"records": records})
}
}
func getRecordHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid record ID")
return
}
record, err := database.GetRecord(id)
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, "record not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get record")
return
}
writeJSON(w, http.StatusOK, record)
}
}
func createRecordHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
zoneName := chi.URLParam(r, "zone")
var req createRecordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if req.Type == "" {
writeError(w, http.StatusBadRequest, "type is required")
return
}
if req.Value == "" {
writeError(w, http.StatusBadRequest, "value is required")
return
}
if !authorizeRecordMutation(tokenInfoFromContext(r.Context()), req.Name) {
writeError(w, http.StatusForbidden, "not authorized for record name")
return
}
record, err := database.CreateRecord(zoneName, req.Name, req.Type, req.Value, req.TTL)
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, "zone not found")
return
}
if errors.Is(err, db.ErrConflict) {
writeError(w, http.StatusConflict, err.Error())
return
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, record)
}
}
func updateRecordHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid record ID")
return
}
var req createRecordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if req.Type == "" {
writeError(w, http.StatusBadRequest, "type is required")
return
}
if req.Value == "" {
writeError(w, http.StatusBadRequest, "value is required")
return
}
// Authorize against both old and new record names.
info := tokenInfoFromContext(r.Context())
existing, lookupErr := database.GetRecord(id)
if lookupErr == nil && !authorizeRecordMutation(info, existing.Name) {
writeError(w, http.StatusForbidden, "not authorized for record name")
return
}
if !authorizeRecordMutation(info, req.Name) {
writeError(w, http.StatusForbidden, "not authorized for record name")
return
}
record, err := database.UpdateRecord(id, req.Name, req.Type, req.Value, req.TTL)
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, "record not found")
return
}
if errors.Is(err, db.ErrConflict) {
writeError(w, http.StatusConflict, err.Error())
return
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, record)
}
}
func deleteRecordHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid record ID")
return
}
// Look up the record to authorize by name.
existing, lookupErr := database.GetRecord(id)
if lookupErr == nil && !authorizeRecordMutation(tokenInfoFromContext(r.Context()), existing.Name) {
writeError(w, http.StatusForbidden, "not authorized for record name")
return
}
err = database.DeleteRecord(id)
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, "record not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete record")
return
}
w.WriteHeader(http.StatusNoContent)
}
}