key-cape/src/internal/server/errors/enforcement.go

204 lines
7.4 KiB
Go
Raw Normal View History

// Package errors implements the unsupported feature enforcement layer for KeyCape.
// Every request passes through the Registry middleware before reaching any handler.
// If a registered feature is detected the middleware writes a ProfileError JSON
// response, emits an EventUnsupportedFeature telemetry event, and short-circuits
// the handler chain. Adding a new unsupported feature requires only a call to
// Register — no handler changes are needed.
package errors
import (
"net/http"
"strings"
"time"
profileerrors "keycape/internal/errors"
"keycape/internal/server/telemetry"
)
// UnsupportedFeature describes a profile boundary that KeyCape enforces.
type UnsupportedFeature struct {
// Name is a stable string identifier used in telemetry and error payloads.
Name string
// ErrorType is the profile error category emitted when this feature is triggered.
ErrorType profileerrors.ErrorType
// Description is a human-readable explanation of why the feature is blocked.
Description string
// Detector reports whether the given request triggers this feature.
Detector func(r *http.Request) bool
}
// Registry holds all known unsupported features and exposes middleware that
// enforces them on every incoming request.
type Registry struct {
features []UnsupportedFeature
}
// NewRegistry returns an empty Registry. Use Register to add features and
// DefaultRegistry to obtain one pre-populated with the spec-mandated set.
func NewRegistry() *Registry {
return &Registry{}
}
// Register appends a feature to the registry. Registered features are checked
// in insertion order; the first match wins.
func (reg *Registry) Register(f UnsupportedFeature) {
reg.features = append(reg.features, f)
}
// Middleware returns an http.Handler that evaluates all registered features
// for every request before delegating to next.
//
// If a feature is triggered:
// - A ProfileError JSON response is written with an appropriate HTTP status.
// - An EventUnsupportedFeature telemetry event is emitted via the Emitter
// stored in the request context (a NoopEmitter is used when none is set).
// - next is NOT called.
//
// If no feature matches, next is called normally.
func (reg *Registry) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, f := range reg.features {
if f.Detector(r) {
pe := &profileerrors.ProfileError{
Error: f.ErrorType,
Description: f.Description,
Feature: f.Name,
}
pe.Write(w, httpStatusFor(f.ErrorType))
em := telemetry.EmitterFromContext(r.Context())
em.Emit(r.Context(), telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventUnsupportedFeature,
Feature: f.Name,
ErrorType: string(f.ErrorType),
Endpoint: r.URL.Path,
Result: "failure",
Environment: "",
TraceID: "",
ClientID: r.URL.Query().Get("client_id"),
})
return
}
}
next.ServeHTTP(w, r)
})
}
// httpStatusFor maps an ErrorType to its canonical HTTP status code.
func httpStatusFor(et profileerrors.ErrorType) int {
switch et {
case profileerrors.ErrInvalidProfileUsage:
return http.StatusBadRequest
case profileerrors.ErrRejectedForSafety:
return http.StatusForbidden
case profileerrors.ErrKeycloakModeOnly:
return http.StatusNotImplemented
default: // ErrFeatureNotSupported
return http.StatusNotImplemented
}
}
// ---------------------------------------------------------------------------
// Default feature set (spec §4 — normative).
// ---------------------------------------------------------------------------
// DefaultRegistry returns a Registry pre-populated with all spec-mandated
// unsupported features. No handler changes are required to enforce new entries.
func DefaultRegistry() *Registry {
reg := NewRegistry()
// 1. Dynamic client registration (RFC 7591) — not in the profile.
reg.Register(UnsupportedFeature{
Name: "dynamic_client_registration",
ErrorType: profileerrors.ErrFeatureNotSupported,
Description: "Dynamic client registration is not part of the NetKingdom IAM Profile. Register clients statically in KeyCape configuration.",
Detector: func(r *http.Request) bool {
return (r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/connect/register")) ||
strings.Contains(r.URL.Path, "registration")
},
})
// 2. Implicit flow — blocked for security.
reg.Register(UnsupportedFeature{
Name: "implicit_flow",
ErrorType: profileerrors.ErrRejectedForSafety,
Description: "The implicit flow (response_type=token or id_token) is rejected. Use the authorization code flow with PKCE.",
Detector: func(r *http.Request) bool {
rt := r.URL.Query().Get("response_type")
if rt == "" {
return false
}
// Blocked when response_type contains "token" or "id_token" but NOT when it is exactly "code".
// "code token" (hybrid) is also blocked.
return rt == "token" || rt == "id_token" || strings.Contains(rt, "token") && rt != "code"
},
})
// 3. Wildcard redirect_uri — blocked for security.
reg.Register(UnsupportedFeature{
Name: "wildcard_redirect_uri",
ErrorType: profileerrors.ErrRejectedForSafety,
Description: "Wildcard redirect URIs are not permitted. Register exact redirect URIs in the client configuration.",
Detector: func(r *http.Request) bool {
return strings.Contains(r.URL.Query().Get("redirect_uri"), "*")
},
})
// 4. Identity brokering — available only in Keycloak mode.
reg.Register(UnsupportedFeature{
Name: "identity_broker",
ErrorType: profileerrors.ErrKeycloakModeOnly,
Description: "Identity brokering is available only in expanded (Keycloak) mode.",
Detector: func(r *http.Request) bool {
return strings.Contains(r.URL.Path, "/broker/")
},
})
// 5. PKCE plain method — blocked for security (must use S256).
// Registered BEFORE missing_pkce so a plain-method request is reported
// as pkce_plain_method, not missing_pkce.
reg.Register(UnsupportedFeature{
Name: "pkce_plain_method",
ErrorType: profileerrors.ErrRejectedForSafety,
Description: "PKCE plain code challenge method is not allowed. Use S256.",
Detector: func(r *http.Request) bool {
return r.URL.Query().Get("code_challenge_method") == "plain"
},
})
// 6. Missing PKCE on /authorize — invalid profile usage.
reg.Register(UnsupportedFeature{
Name: "missing_pkce",
ErrorType: profileerrors.ErrInvalidProfileUsage,
Description: "Requests to /authorize must include a code_challenge (PKCE S256 required).",
Detector: func(r *http.Request) bool {
return strings.HasSuffix(r.URL.Path, "/authorize") &&
r.URL.Query().Get("code_challenge") == ""
},
})
// 7. Unknown grant type on /token.
reg.Register(UnsupportedFeature{
Name: "unknown_grant_type",
ErrorType: profileerrors.ErrFeatureNotSupported,
Description: "Only authorization_code and refresh_token grant types are supported.",
Detector: func(r *http.Request) bool {
if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/token") {
return false
}
gt := r.URL.Query().Get("grant_type")
if gt == "" {
// Also check form body if already parsed — callers may pre-parse.
gt = r.FormValue("grant_type")
}
if gt == "" {
return false // no grant_type present; let the handler decide
}
return gt != "authorization_code" && gt != "refresh_token"
},
})
return reg
}