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:
2026-03-13 01:45:21 +01:00
parent 22f7a7dc50
commit b0adbc5daa
8 changed files with 1262 additions and 3 deletions

View 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
}

View File

@@ -0,0 +1,299 @@
package errors_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
profileerrors "keycape/internal/errors"
serverrors "keycape/internal/server/errors"
"keycape/internal/server/telemetry"
)
// ---------------------------------------------------------------------------
// recEmitter records emitted events for assertions.
// ---------------------------------------------------------------------------
type recEmitter struct {
events []telemetry.Event
}
func (r *recEmitter) Emit(_ context.Context, ev telemetry.Event) {
r.events = append(r.events, ev)
}
func newRecEmitter() *recEmitter { return &recEmitter{} }
// ---------------------------------------------------------------------------
// Helper: build request with emitter in context.
// ---------------------------------------------------------------------------
func reqWithEmitter(method, target string, em telemetry.Emitter) *http.Request {
req := httptest.NewRequest(method, target, nil)
ctx := telemetry.WithEmitter(req.Context(), em)
return req.WithContext(ctx)
}
// ---------------------------------------------------------------------------
// Tests — default registry features triggered.
// ---------------------------------------------------------------------------
func TestDefaultRegistry_DynamicClientRegistration_PostConnect(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
w := serve(handler, reqWithEmitter(http.MethodPost, "/connect/register", em))
assertProfileError(t, w, profileerrors.ErrFeatureNotSupported, "dynamic_client_registration")
assertTelemetryEmitted(t, em, "dynamic_client_registration")
}
func TestDefaultRegistry_DynamicClientRegistration_PathContainsRegistration(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
w := serve(handler, reqWithEmitter(http.MethodGet, "/oauth/registration/info", em))
assertProfileError(t, w, profileerrors.ErrFeatureNotSupported, "dynamic_client_registration")
assertTelemetryEmitted(t, em, "dynamic_client_registration")
}
func TestDefaultRegistry_ImplicitFlow_Token(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
w := serve(handler, reqWithEmitter(http.MethodGet, "/authorize?response_type=token", em))
assertProfileError(t, w, profileerrors.ErrRejectedForSafety, "implicit_flow")
assertTelemetryEmitted(t, em, "implicit_flow")
}
func TestDefaultRegistry_ImplicitFlow_IDToken(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
w := serve(handler, reqWithEmitter(http.MethodGet, "/authorize?response_type=id_token", em))
assertProfileError(t, w, profileerrors.ErrRejectedForSafety, "implicit_flow")
assertTelemetryEmitted(t, em, "implicit_flow")
}
func TestDefaultRegistry_WildcardRedirectURI(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
w := serve(handler, reqWithEmitter(http.MethodGet, "/authorize?redirect_uri=https%3A%2F%2Fexample.com%2F*%2Fcb", em))
assertProfileError(t, w, profileerrors.ErrRejectedForSafety, "wildcard_redirect_uri")
assertTelemetryEmitted(t, em, "wildcard_redirect_uri")
}
func TestDefaultRegistry_IdentityBroker(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
w := serve(handler, reqWithEmitter(http.MethodGet, "/auth/realms/master/broker/github/endpoint", em))
assertProfileError(t, w, profileerrors.ErrKeycloakModeOnly, "identity_broker")
assertTelemetryEmitted(t, em, "identity_broker")
}
func TestDefaultRegistry_MissingPKCE(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
// /authorize without code_challenge
w := serve(handler, reqWithEmitter(http.MethodGet, "/authorize?response_type=code&client_id=myapp", em))
assertProfileError(t, w, profileerrors.ErrInvalidProfileUsage, "missing_pkce")
assertTelemetryEmitted(t, em, "missing_pkce")
}
func TestDefaultRegistry_MissingPKCE_WithCodeChallenge_PassesThrough(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
handler := reg.Middleware(next)
w := serve(handler, reqWithEmitter(http.MethodGet, "/authorize?response_type=code&code_challenge=abc&code_challenge_method=S256", em))
if !called {
t.Fatal("expected next handler to be called when code_challenge is present")
}
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestDefaultRegistry_PKCEPlainMethod(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
w := serve(handler, reqWithEmitter(http.MethodGet, "/authorize?code_challenge=abc&code_challenge_method=plain", em))
assertProfileError(t, w, profileerrors.ErrRejectedForSafety, "pkce_plain_method")
assertTelemetryEmitted(t, em, "pkce_plain_method")
}
func TestDefaultRegistry_UnknownGrantType(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
w := serve(handler, reqWithEmitter(http.MethodPost, "/token?grant_type=client_credentials", em))
assertProfileError(t, w, profileerrors.ErrFeatureNotSupported, "unknown_grant_type")
assertTelemetryEmitted(t, em, "unknown_grant_type")
}
func TestDefaultRegistry_UnknownGrantType_AllowedTypes(t *testing.T) {
reg := serverrors.DefaultRegistry()
handler := reg.Middleware(alwaysOK())
for _, gt := range []string{"authorization_code", "refresh_token"} {
req := reqWithEmitter(http.MethodPost, "/token?grant_type="+gt, newRecEmitter())
w := serve(handler, req)
if w.Code != http.StatusOK {
t.Fatalf("grant_type=%q: expected 200 (pass-through), got %d: %s", gt, w.Code, w.Body.String())
}
}
}
// ---------------------------------------------------------------------------
// Tests — no feature triggered: passes through.
// ---------------------------------------------------------------------------
func TestDefaultRegistry_NoMatchPassesThrough(t *testing.T) {
reg := serverrors.DefaultRegistry()
em := newRecEmitter()
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
handler := reg.Middleware(next)
w := serve(handler, reqWithEmitter(http.MethodGet, "/userinfo", em))
if !called {
t.Fatal("expected next handler to be called for unmatched request")
}
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if len(em.events) != 0 {
t.Fatalf("expected no telemetry events, got %d", len(em.events))
}
}
// ---------------------------------------------------------------------------
// Tests — custom feature registration.
// ---------------------------------------------------------------------------
func TestRegistry_CustomFeature(t *testing.T) {
reg := serverrors.NewRegistry()
reg.Register(serverrors.UnsupportedFeature{
Name: "test_feature",
ErrorType: profileerrors.ErrFeatureNotSupported,
Description: "test feature blocked",
Detector: func(r *http.Request) bool { return strings.Contains(r.URL.Path, "/test-blocked") },
})
em := newRecEmitter()
handler := reg.Middleware(alwaysOK())
w := serve(handler, reqWithEmitter(http.MethodGet, "/test-blocked/foo", em))
assertProfileError(t, w, profileerrors.ErrFeatureNotSupported, "test_feature")
assertTelemetryEmitted(t, em, "test_feature")
}
func TestRegistry_CustomFeature_NoMatch_PassesThrough(t *testing.T) {
reg := serverrors.NewRegistry()
reg.Register(serverrors.UnsupportedFeature{
Name: "test_feature",
ErrorType: profileerrors.ErrFeatureNotSupported,
Description: "test feature blocked",
Detector: func(r *http.Request) bool { return strings.Contains(r.URL.Path, "/test-blocked") },
})
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
handler := reg.Middleware(next)
w := serve(handler, reqWithEmitter(http.MethodGet, "/safe-path", newRecEmitter()))
if !called {
t.Fatal("expected next to be called when no feature matches")
}
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func alwaysOK() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
}
func serve(h http.Handler, r *http.Request) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func assertProfileError(t *testing.T, w *httptest.ResponseRecorder, errType profileerrors.ErrorType, feature string) {
t.Helper()
if w.Code == http.StatusOK {
t.Fatalf("expected non-200 status, got 200")
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, "application/json") {
t.Fatalf("expected application/json content type, got %q", ct)
}
var pe profileerrors.ProfileError
if err := json.NewDecoder(w.Body).Decode(&pe); err != nil {
t.Fatalf("failed to decode ProfileError: %v", err)
}
if pe.Error != errType {
t.Errorf("expected error type %q, got %q", errType, pe.Error)
}
if pe.Feature != feature {
t.Errorf("expected feature %q, got %q", feature, pe.Feature)
}
}
func assertTelemetryEmitted(t *testing.T, em *recEmitter, feature string) {
t.Helper()
if len(em.events) == 0 {
t.Fatalf("expected telemetry event for feature %q, got none", feature)
}
last := em.events[len(em.events)-1]
if last.EventType != telemetry.EventUnsupportedFeature {
t.Errorf("expected event type %q, got %q", telemetry.EventUnsupportedFeature, last.EventType)
}
if last.Feature != feature {
t.Errorf("expected feature %q in event, got %q", feature, last.Feature)
}
}