iop/apps/control-plane/cmd/control-plane/credential_http_handlers.go
toki 4c8441e6c9 feat(credential): Provider Credential Slot 라우팅을 구현한다
사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
2026-08-02 09:10:11 +09:00

237 lines
7.7 KiB
Go

package main
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"iop/apps/control-plane/internal/credentialops"
)
const maxProviderSecretBytes = 64 << 10
// registerCredentialHandlers exposes only principal-authenticated slot/route
// lifecycle operations. Production mounts it solely on the credential HTTPS
// listener; principal bootstrap remains an offline CLI operation.
func registerCredentialHandlers(mux *http.ServeMux, service *credentialops.Service, onMutation func(*http.Request) error) {
mux.Handle("/v1/credentials/slots", credentialCollectionHandler{service: service, onMutation: onMutation})
mux.Handle("/v1/credentials/slots/", credentialSlotHandler{service: service, onMutation: onMutation})
mux.Handle("/v1/credentials/routes", credentialRouteCollectionHandler{service: service, onMutation: onMutation})
mux.Handle("/v1/credentials/routes/", credentialRouteHandler{service: service, onMutation: onMutation})
}
func bearerToken(r *http.Request) ([]byte, bool) {
value := strings.TrimSpace(r.Header.Get("Authorization"))
if len(value) < 8 || !strings.EqualFold(value[:7], "Bearer ") || strings.TrimSpace(value[7:]) == "" {
return nil, false
}
return []byte(strings.TrimSpace(value[7:])), true
}
func readSecret(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
if r.Header.Get("Content-Type") != "application/octet-stream" {
http.Error(w, "application/octet-stream is required", http.StatusUnsupportedMediaType)
return nil, false
}
limited := io.LimitReader(r.Body, maxProviderSecretBytes+1)
secret, err := io.ReadAll(limited)
if err != nil || len(secret) == 0 || len(secret) > maxProviderSecretBytes {
zeroHTTPSecret(secret)
http.Error(w, "provider secret must be 1..65536 bytes", http.StatusRequestEntityTooLarge)
return nil, false
}
return secret, true
}
type credentialCollectionHandler struct {
service *credentialops.Service
onMutation func(*http.Request) error
}
func (h credentialCollectionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
token, ok := bearerToken(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
defer zeroHTTPSecret(token)
switch r.Method {
case http.MethodGet:
result, err := h.service.ListSlots(r.Context(), token)
writeCredentialResult(w, result, err)
case http.MethodPost:
secret, ok := readSecret(w, r)
if !ok {
return
}
defer zeroHTTPSecret(secret)
result, err := h.service.CreateSlot(r.Context(), token, credentialops.CreateSlotInput{
Vendor: r.Header.Get("IOP-Credential-Vendor"), CredentialKind: r.Header.Get("IOP-Credential-Kind"),
Alias: r.Header.Get("IOP-Credential-Alias"), ProviderSecret: secret,
})
writeCredentialMutationResult(w, r, result, err, h.onMutation)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
type credentialSlotHandler struct {
service *credentialops.Service
onMutation func(*http.Request) error
}
func (h credentialSlotHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
token, ok := bearerToken(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
defer zeroHTTPSecret(token)
path := strings.TrimPrefix(r.URL.Path, "/v1/credentials/slots/")
parts := strings.Split(path, "/")
if len(parts) != 2 || r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
revision, err := strconv.ParseInt(r.Header.Get("IOP-Expected-Revision"), 10, 64)
if err != nil || revision < 0 {
http.Error(w, "valid IOP-Expected-Revision is required", http.StatusBadRequest)
return
}
var result any
var callErr error
switch parts[1] {
case "rotate":
secret, ok := readSecret(w, r)
if !ok {
return
}
defer zeroHTTPSecret(secret)
result, callErr = h.service.RotateSlot(r.Context(), token, credentialops.RotateSlotInput{SlotID: parts[0], Revision: revision, ProviderSecret: secret})
case "disable":
result, callErr = h.service.DisableSlot(r.Context(), token, parts[0], revision)
case "enable":
result, callErr = h.service.EnableSlot(r.Context(), token, parts[0], revision)
case "revoke":
result, callErr = h.service.RevokeSlot(r.Context(), token, parts[0], revision)
default:
http.NotFound(w, r)
return
}
writeCredentialMutationResult(w, r, result, callErr, h.onMutation)
}
type credentialRouteCollectionHandler struct {
service *credentialops.Service
onMutation func(*http.Request) error
}
func (h credentialRouteCollectionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
token, ok := bearerToken(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
defer zeroHTTPSecret(token)
switch r.Method {
case http.MethodGet:
result, err := h.service.ListRoutes(r.Context(), token)
writeCredentialResult(w, result, err)
case http.MethodPost:
if contentType := r.Header.Get("Content-Type"); contentType != "application/json" {
http.Error(w, "application/json is required", http.StatusUnsupportedMediaType)
return
}
var payload struct {
SlotID string `json:"slot_id"`
Alias string `json:"alias"`
ProfileID string `json:"profile_id"`
UpstreamModel string `json:"upstream_model"`
ResourceSelector string `json:"resource_selector"`
}
dec := json.NewDecoder(io.LimitReader(r.Body, 64<<10))
dec.DisallowUnknownFields()
if err := dec.Decode(&payload); err != nil {
http.Error(w, "invalid route request", http.StatusBadRequest)
return
}
result, err := h.service.CreateRoute(r.Context(), token, credentialops.CreateRouteInput{
SlotID: payload.SlotID, Alias: payload.Alias, ProfileID: payload.ProfileID,
UpstreamModel: payload.UpstreamModel, ResourceSelector: payload.ResourceSelector,
})
writeCredentialMutationResult(w, r, result, err, h.onMutation)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
type credentialRouteHandler struct {
service *credentialops.Service
onMutation func(*http.Request) error
}
func (h credentialRouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
token, ok := bearerToken(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
defer zeroHTTPSecret(token)
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/credentials/routes/"), "/")
if len(parts) != 2 || r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
revision, err := strconv.ParseInt(r.Header.Get("IOP-Expected-Revision"), 10, 64)
if err != nil || revision < 0 {
http.Error(w, "valid IOP-Expected-Revision is required", http.StatusBadRequest)
return
}
var result any
var callErr error
switch parts[1] {
case "disable":
result, callErr = h.service.DisableRoute(r.Context(), token, parts[0], revision)
case "enable":
result, callErr = h.service.EnableRoute(r.Context(), token, parts[0], revision)
case "revoke":
result, callErr = h.service.RevokeRoute(r.Context(), token, parts[0], revision)
default:
http.NotFound(w, r)
return
}
writeCredentialMutationResult(w, r, result, callErr, h.onMutation)
}
func writeCredentialMutationResult(w http.ResponseWriter, r *http.Request, result any, err error, onMutation func(*http.Request) error) {
if err == nil && onMutation != nil {
err = onMutation(r)
}
writeCredentialResult(w, result, err)
}
func writeCredentialResult(w http.ResponseWriter, result any, err error) {
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, credentialops.ErrUnauthorized) {
status = http.StatusUnauthorized
} else if errors.Is(err, credentialops.ErrNotFound) {
status = http.StatusNotFound
} else if errors.Is(err, credentialops.ErrStaleRevision) {
status = http.StatusConflict
}
http.Error(w, http.StatusText(status), status)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
}
func zeroHTTPSecret(value []byte) {
for i := range value {
value[i] = 0
}
}