feat: implement T14, T10 — enforcement middleware, LLDAP adapter
- T14: Unsupported feature registry with 7 pre-registered profile boundaries - T10: LLDAP adapter implementing UserRepository; validator-gated reads 24 tests pass, go vet clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
22f7a7dc50
commit
b0adbc5daa
8 changed files with 1262 additions and 3 deletions
203
src/internal/server/errors/enforcement.go
Normal file
203
src/internal/server/errors/enforcement.go
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue