generated from coulomb/repo-seed
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:
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
|
||||
}
|
||||
299
src/internal/server/errors/enforcement_test.go
Normal file
299
src/internal/server/errors/enforcement_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user