From 2c90fe22bf877bbdbdec7a25fe2615164e5f9886 Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Guerrero Date: Thu, 7 May 2026 21:57:01 -0600 Subject: [PATCH] refactor(httpauth-firebase)!: delegate enrichment and authz to httpauth v0.1.0 EnrichmentMiddleware, AuthzMiddleware, IdentityEnricher, PermissionProvider, and related types are removed from this module. They now live in code.nochebuena.dev/go/httpauth, the provider-agnostic middleware layer. AuthMiddleware is updated to call httpauth.SetTokenData, fulfilling the integration contract between provider-specific auth and generic middleware. This module now has a single responsibility: Firebase JWT verification. BREAKING CHANGE: IdentityEnricher, PermissionProvider, EnrichmentMiddleware, AuthzMiddleware, and WithTenantHeader are no longer exported from this package. Import code.nochebuena.dev/go/httpauth for those identifiers. --- .gitea/CODEOWNERS | 1 + CLAUDE.md | 72 ++++++++++--------- auth.go | 30 ++------ authz.go | 37 ---------- compliance_test.go | 19 +---- doc.go | 21 +++--- enrichment.go | 66 ----------------- go.mod | 3 +- go.sum | 2 + httpauth_test.go | 172 ++------------------------------------------- 10 files changed, 65 insertions(+), 358 deletions(-) create mode 100644 .gitea/CODEOWNERS delete mode 100644 authz.go delete mode 100644 enrichment.go diff --git a/.gitea/CODEOWNERS b/.gitea/CODEOWNERS new file mode 100644 index 0000000..ae1f67b --- /dev/null +++ b/.gitea/CODEOWNERS @@ -0,0 +1 @@ +* @go/CoreDevelopers @go/Agents diff --git a/CLAUDE.md b/CLAUDE.md index 294c252..a924217 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,38 +13,47 @@ logic are decoupled from Firebase types entirely. ## Tier & Dependencies -**Tier:** 4 (transport auth layer; depends on Tier 0 `rbac` and external Firebase SDK) +**Tier:** 4 (transport auth layer; depends on Tier 3 `httpauth` and external Firebase SDK) **Module:** `code.nochebuena.dev/go/httpauth-firebase` -**Direct imports:** `code.nochebuena.dev/go/rbac`, `firebase.google.com/go/v4/auth` +**Direct imports:** `code.nochebuena.dev/go/httpauth`, `firebase.google.com/go/v4/auth` +**Transitive:** `code.nochebuena.dev/go/rbac` (indirect, pulled in by `httpauth`) `httpauth-firebase` does not import `logz`, `httpmw`, or `httputil`. It has no logger parameter — errors are returned as HTTP responses, not logged here. +`EnrichmentMiddleware`, `AuthzMiddleware`, `IdentityEnricher`, `WithTenantHeader`, +`Cache`, and permission providers all live in `code.nochebuena.dev/go/httpauth`. +This module only provides `AuthMiddleware` and the `TokenVerifier` interface. + ## Key Design Decisions - **Provider-specific naming** (ADR-001): The module is named `httpauth-firebase` because it imports Firebase directly. Other providers live in sibling modules - (`httpauth-auth0`, etc.). All converge on `rbac.Identity`. The `TokenVerifier` - interface is retained for unit-test mockability only. -- **rbac.Identity as output contract** (ADR-002): `EnrichmentMiddleware` stores the - final identity with `rbac.SetInContext`; `AuthzMiddleware` reads it with - `rbac.FromContext`. Downstream handlers only need to import `rbac`. Global ADR-003 - applies: `rbac` owns its context helpers. -- **Composable three-middleware stack** (ADR-003): `AuthMiddleware`, - `EnrichmentMiddleware`, and `AuthzMiddleware` are separate functions. Each can be - applied independently; each returns 401 if its upstream dependency is missing from - context. + (`httpauth-jwt`, etc.). All converge on `rbac.Identity` via the shared `httpauth` + contract. The `TokenVerifier` interface is retained for unit-test mockability only. +- **Integration contract via `httpauth.SetTokenData`**: `AuthMiddleware` stores + `uid` and `claims` using `httpauth.SetTokenData`, which writes to context keys + owned by the `httpauth` package. `EnrichmentMiddleware` (from `httpauth`) reads + them. This is the explicit integration point between provider-specific and + provider-agnostic middleware. +- **Single responsibility**: This module only verifies Firebase tokens. Identity + enrichment, RBAC, and caching all live in `code.nochebuena.dev/go/httpauth`. ## Patterns **Full stack (most routes):** ```go +import ( + httpauth "code.nochebuena.dev/go/httpauth-firebase" + httpauthmw "code.nochebuena.dev/go/httpauth" +) + r.Use(httpauth.AuthMiddleware(firebaseAuthClient, []string{"/health", "/metrics/*"})) -r.Use(httpauth.EnrichmentMiddleware(userEnricher, httpauth.WithTenantHeader("X-Tenant-ID"))) +r.Use(httpauthmw.EnrichmentMiddleware(userEnricher, httpauthmw.WithTenantHeader("X-Tenant-ID"))) // Per-route RBAC: -r.With(httpauth.AuthzMiddleware(permProvider, "orders", rbac.Write)). +r.With(httpauthmw.AuthzMiddleware(permProvider, "orders", rbac.Write)). Post("/orders", httputil.Handle(v, svc.CreateOrder)) ``` @@ -62,7 +71,7 @@ identity, ok := rbac.FromContext(r.Context()) if !ok { // should not happen if EnrichmentMiddleware is in the chain } -fmt.Println(identity.UID, identity.Role, identity.TenantID) +fmt.Println(identity.UID, identity.DisplayName, identity.TenantID) ``` **Public paths (bypass token check):** @@ -73,21 +82,15 @@ publicPaths := []string{"/health", "/ready", "/metrics/*"} r.Use(httpauth.AuthMiddleware(firebaseAuthClient, publicPaths)) ``` -**Interfaces the application must implement:** +**Interfaces the application must implement (defined in httpauth, not here):** ```go -// IdentityEnricher: called by EnrichmentMiddleware +// httpauthmw.IdentityEnricher: called by EnrichmentMiddleware type MyEnricher struct{ db *sql.DB } func (e *MyEnricher) Enrich(ctx context.Context, uid string, claims map[string]any) (rbac.Identity, error) { user, err := e.db.LookupUser(ctx, uid) if err != nil { return rbac.Identity{}, err } - return rbac.NewIdentity(uid, user.DisplayName, user.Email).WithRole(user.Role), nil -} - -// PermissionProvider: called by AuthzMiddleware -type MyPermProvider struct{} -func (p *MyPermProvider) ResolveMask(ctx context.Context, uid, resource string) (rbac.PermissionMask, error) { - return rbac.ReadWrite, nil + return rbac.NewIdentity(uid, user.DisplayName, user.Email), nil } ``` @@ -100,25 +103,24 @@ func (p *MyPermProvider) ResolveMask(ctx context.Context, uid, resource string) `AuthzMiddleware` reads `rbac.Identity` from context; if enrichment has not run, it will return 401. - Do not read `firebase.Token` fields directly in business logic. The token UID and - claims are stored under unexported context keys and are not part of the public API. - Use `rbac.FromContext` to read the enriched identity. + claims are stored in context by `httpauth.SetTokenData` under keys owned by the + `httpauth` package. Use `rbac.FromContext` to read the enriched identity. - Do not store application-specific data in Firebase custom claims and bypass the - `IdentityEnricher`. Claims are read by `EnrichmentMiddleware` and passed to - `Enrich`, but the enricher is the correct place to resolve application identity — - not raw Firebase claims spread across handlers. + `IdentityEnricher`. Claims are read by `EnrichmentMiddleware` (from `httpauth`) + and passed to `Enrich` — the enricher is the correct place to resolve identity. - Do not import `httpauth-firebase` from service or domain layers. It is a transport package. Dependency should flow inward: handler → service, never service → handler/middleware. +- Do not redefine `IdentityEnricher`, `Cache`, or permission providers here. They + live in `code.nochebuena.dev/go/httpauth`. ## Testing Notes - `compliance_test.go` verifies at compile time that `*mockVerifier` satisfies - `TokenVerifier`, `*mockEnricher` satisfies `IdentityEnricher`, and `*mockProvider` - satisfies `PermissionProvider`. + `TokenVerifier`. Interface checks for `IdentityEnricher`, `Cache`, and permission + providers live in `code.nochebuena.dev/go/httpauth`'s own compliance test. - `AuthMiddleware` can be tested by injecting a `mockVerifier` that returns a controlled `*auth.Token`. No real Firebase project is needed. -- `EnrichmentMiddleware` can be tested by pre-populating the request context with - `uid` via an upstream `AuthMiddleware` using a mock verifier. -- `AuthzMiddleware` can be tested by pre-populating the request context with an - `rbac.Identity` via `rbac.SetInContext` — no auth or enrichment middleware needed. +- `EnrichmentMiddleware` and `AuthzMiddleware` tests belong in the `httpauth` module, + not here. - Use `httptest.NewRecorder()` and `httptest.NewRequest()` for all HTTP-level tests. diff --git a/auth.go b/auth.go index d8ce5ac..2add46a 100644 --- a/auth.go +++ b/auth.go @@ -7,6 +7,8 @@ import ( "strings" "firebase.google.com/go/v4/auth" + + httpauthmw "code.nochebuena.dev/go/httpauth" ) // TokenVerifier abstracts Firebase JWT verification. @@ -16,30 +18,10 @@ type TokenVerifier interface { VerifyIDTokenAndCheckRevoked(ctx context.Context, idToken string) (*auth.Token, error) } -// ctxUIDKey and ctxClaimsKey are unexported typed context keys. -// Using distinct types prevents collisions with keys from other packages. -type ctxUIDKey struct{} -type ctxClaimsKey struct{} - -func setTokenData(ctx context.Context, uid string, claims map[string]any) context.Context { - ctx = context.WithValue(ctx, ctxUIDKey{}, uid) - ctx = context.WithValue(ctx, ctxClaimsKey{}, claims) - return ctx -} - -func getUID(ctx context.Context) (string, bool) { - uid, ok := ctx.Value(ctxUIDKey{}).(string) - return uid, ok && uid != "" -} - -func getClaims(ctx context.Context) (map[string]any, bool) { - claims, ok := ctx.Value(ctxClaimsKey{}).(map[string]any) - return claims, ok -} - // AuthMiddleware verifies the Bearer token and injects uid + claims into the -// request context. Requests to publicPaths are skipped without token verification -// (wildcards supported via path.Match). Returns 401 on missing or invalid tokens. +// request context via httpauth.SetTokenData. Requests to publicPaths are skipped +// without token verification (wildcards supported via path.Match). +// Returns 401 on missing or invalid tokens. func AuthMiddleware(verifier TokenVerifier, publicPaths []string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -63,7 +45,7 @@ func AuthMiddleware(verifier TokenVerifier, publicPaths []string) func(http.Hand return } - ctx := setTokenData(r.Context(), decoded.UID, decoded.Claims) + ctx := httpauthmw.SetTokenData(r.Context(), decoded.UID, decoded.Claims) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/authz.go b/authz.go deleted file mode 100644 index 5ffb788..0000000 --- a/authz.go +++ /dev/null @@ -1,37 +0,0 @@ -package httpauth - -import ( - "context" - "net/http" - - "code.nochebuena.dev/go/rbac" -) - -// PermissionProvider resolves the permission mask for a given uid on a resource. -type PermissionProvider interface { - ResolveMask(ctx context.Context, uid, resource string) (rbac.PermissionMask, error) -} - -// AuthzMiddleware reads the rbac.Identity from context (set by EnrichmentMiddleware) -// and gates the request against the required permission on resource. -// Returns 401 if no identity is in the context. -// Returns 403 if the identity lacks the required permission or if the provider errors. -func AuthzMiddleware(provider PermissionProvider, resource string, required rbac.Permission) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - identity, ok := rbac.FromContext(r.Context()) - if !ok { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - mask, err := provider.ResolveMask(r.Context(), identity.UID, resource) - if err != nil || !mask.Has(required) { - http.Error(w, "forbidden", http.StatusForbidden) - return - } - - next.ServeHTTP(w, r) - }) - } -} diff --git a/compliance_test.go b/compliance_test.go index 86fc6eb..0ea1fa5 100644 --- a/compliance_test.go +++ b/compliance_test.go @@ -6,7 +6,6 @@ import ( "firebase.google.com/go/v4/auth" httpauth "code.nochebuena.dev/go/httpauth-firebase" - "code.nochebuena.dev/go/rbac" ) type mockVerifier struct{} @@ -15,19 +14,5 @@ func (m *mockVerifier) VerifyIDTokenAndCheckRevoked(_ context.Context, _ string) return nil, nil } -type mockEnricher struct{} - -func (m *mockEnricher) Enrich(_ context.Context, _ string, _ map[string]any) (rbac.Identity, error) { - return rbac.Identity{}, nil -} - -type mockProvider struct{} - -func (m *mockProvider) ResolveMask(_ context.Context, _, _ string) (rbac.PermissionMask, error) { - return 0, nil -} - -// Compile-time interface satisfaction checks. -var _ httpauth.TokenVerifier = (*mockVerifier)(nil) -var _ httpauth.IdentityEnricher = (*mockEnricher)(nil) -var _ httpauth.PermissionProvider = (*mockProvider)(nil) +// Compile-time interface satisfaction check. +var _ httpauth.TokenVerifier = (*mockVerifier)(nil) diff --git a/doc.go b/doc.go index 61e02f9..55adada 100644 --- a/doc.go +++ b/doc.go @@ -1,17 +1,18 @@ -// Package httpauth provides Firebase-backed HTTP middleware for authentication, -// identity enrichment, and role-based access control. +// Package httpauth provides Firebase-backed HTTP authentication middleware. +// +// AuthMiddleware verifies Firebase Bearer tokens and injects uid + claims into +// the request context via httpauth.SetTokenData (code.nochebuena.dev/go/httpauth). +// Downstream middleware (EnrichmentMiddleware, AuthzMiddleware) comes from that +// package and is provider-agnostic. // // Typical middleware chain: // +// import httpauthmw "code.nochebuena.dev/go/httpauth" +// // r.Use(httpauth.AuthMiddleware(firebaseClient, publicPaths)) -// r.Use(httpauth.EnrichmentMiddleware(userEnricher, httpauth.WithTenantHeader("X-Tenant-ID"))) -// r.Use(httpauth.AuthzMiddleware(permProvider, "orders", rbac.Read)) +// r.Use(httpauthmw.EnrichmentMiddleware(userEnricher, httpauthmw.WithTenantHeader("X-Tenant-ID"))) +// r.With(httpauthmw.AuthzMiddleware(permProvider, "orders", rbac.Read)).Post("/orders", handler) // -// AuthMiddleware verifies Firebase Bearer tokens and injects uid + claims into -// the request context. EnrichmentMiddleware reads those values, calls the -// app-provided IdentityEnricher, and stores the full rbac.Identity. AuthzMiddleware -// resolves the permission mask and gates the request. -// -// All three middleware functions accept interfaces, so they can be tested without +// AuthMiddleware accepts a TokenVerifier interface, so it can be tested without // a live Firebase connection. package httpauth diff --git a/enrichment.go b/enrichment.go deleted file mode 100644 index 4349d88..0000000 --- a/enrichment.go +++ /dev/null @@ -1,66 +0,0 @@ -package httpauth - -import ( - "context" - "net/http" - - "code.nochebuena.dev/go/rbac" -) - -// IdentityEnricher builds an rbac.Identity from verified token claims. -// The application provides the implementation — typically reads from a user store. -type IdentityEnricher interface { - Enrich(ctx context.Context, uid string, claims map[string]any) (rbac.Identity, error) -} - -// EnrichOpt configures EnrichmentMiddleware. -type EnrichOpt func(*enrichConfig) - -type enrichConfig struct { - tenantHeader string -} - -// WithTenantHeader configures the request header from which TenantID is read. -// If the header is absent on a request, TenantID remains "" — no error is returned. -func WithTenantHeader(header string) EnrichOpt { - return func(c *enrichConfig) { - c.tenantHeader = header - } -} - -// EnrichmentMiddleware reads uid + claims injected by AuthMiddleware, calls -// enricher.Enrich to build a full rbac.Identity, and stores it in the context. -// Returns 401 if no uid is present (AuthMiddleware was not in the chain). -// Returns 500 if the enricher fails. -func EnrichmentMiddleware(enricher IdentityEnricher, opts ...EnrichOpt) func(http.Handler) http.Handler { - cfg := &enrichConfig{} - for _, o := range opts { - o(cfg) - } - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - uid, ok := getUID(r.Context()) - if !ok { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - claims, _ := getClaims(r.Context()) - - identity, err := enricher.Enrich(r.Context(), uid, claims) - if err != nil { - http.Error(w, "internal server error", http.StatusInternalServerError) - return - } - - if cfg.tenantHeader != "" { - if tenantID := r.Header.Get(cfg.tenantHeader); tenantID != "" { - identity = identity.WithTenant(tenantID) - } - } - - ctx := rbac.SetInContext(r.Context(), identity) - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } -} diff --git a/go.mod b/go.mod index eb79b1e..ca7828a 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,14 @@ module code.nochebuena.dev/go/httpauth-firebase go 1.25 require ( - code.nochebuena.dev/go/rbac v0.9.0 + code.nochebuena.dev/go/httpauth v0.1.0 firebase.google.com/go/v4 v4.15.0 ) require ( cloud.google.com/go/compute v1.24.0 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect + code.nochebuena.dev/go/rbac v0.9.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/go.sum b/go.sum index 0a90996..6879d64 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1Yl cloud.google.com/go/compute v1.24.0/go.mod h1:kw1/T+h/+tK2LJK0wiPPx1intgdAM3j/g3hFDlscY40= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +code.nochebuena.dev/go/httpauth v0.1.0 h1:86xldderCDBvBIvOYgbTg54C00nl1A1OaYVrTHL3BTY= +code.nochebuena.dev/go/httpauth v0.1.0/go.mod h1:DzvGBZVo9npBa1llB+sWL9lOcqaltRMmvh/eGXm+3jQ= code.nochebuena.dev/go/rbac v0.9.0 h1:2fQngWIOeluIaMmo+H2ajT0NVw8GjNFJVi6pbdB3f/o= code.nochebuena.dev/go/rbac v0.9.0/go.mod h1:LzW8tTJmdbu6HHN26NZZ3HzzdlZAd1sp6aml25Cfz5c= firebase.google.com/go/v4 v4.15.0 h1:k27M+cHbyN1YpBI2Cf4NSjeHnnYRB9ldXwpqA5KikN0= diff --git a/httpauth_test.go b/httpauth_test.go index efde99d..40fb4c2 100644 --- a/httpauth_test.go +++ b/httpauth_test.go @@ -8,8 +8,6 @@ import ( "testing" "firebase.google.com/go/v4/auth" - - "code.nochebuena.dev/go/rbac" ) // --- mocks --- @@ -23,46 +21,17 @@ func (m *mockVerifier) VerifyIDTokenAndCheckRevoked(_ context.Context, _ string) return m.token, m.err } -type mockEnricher struct { - identity rbac.Identity - err error -} - -func (m *mockEnricher) Enrich(_ context.Context, _ string, _ map[string]any) (rbac.Identity, error) { - return m.identity, m.err -} - -type mockProvider struct { - mask rbac.PermissionMask - err error -} - -func (m *mockProvider) ResolveMask(_ context.Context, _, _ string) (rbac.PermissionMask, error) { - return m.mask, m.err -} - -// testRead is permission bit 0, used in authz tests. -const testRead rbac.Permission = 0 - func chain(mw func(http.Handler) http.Handler, h http.HandlerFunc) http.Handler { return mw(h) } -// injectUID bypasses AuthMiddleware for EnrichmentMiddleware tests. -func injectUID(uid string, claims map[string]any, next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := setTokenData(r.Context(), uid, claims) - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} - // --- AuthMiddleware --- func TestAuthMiddleware_ValidToken(t *testing.T) { mv := &mockVerifier{token: &auth.Token{UID: "uid123", Claims: map[string]any{"name": "Alice"}}} - var capturedUID string + reached := false h := chain(AuthMiddleware(mv, nil), func(w http.ResponseWriter, r *http.Request) { - capturedUID, _ = getUID(r.Context()) + reached = true w.WriteHeader(http.StatusOK) }) req := httptest.NewRequest(http.MethodGet, "/api", nil) @@ -72,8 +41,8 @@ func TestAuthMiddleware_ValidToken(t *testing.T) { if rec.Code != http.StatusOK { t.Errorf("want 200, got %d", rec.Code) } - if capturedUID != "uid123" { - t.Errorf("want uid123, got %q", capturedUID) + if !reached { + t.Error("inner handler was not called") } } @@ -126,136 +95,3 @@ func TestAuthMiddleware_PublicPathWildcard(t *testing.T) { t.Errorf("want 200, got %d", rec.Code) } } - -// --- EnrichmentMiddleware --- - -func TestEnrichmentMiddleware_Success(t *testing.T) { - me := &mockEnricher{identity: rbac.NewIdentity("uid123", "Alice", "alice@example.com")} - var capturedIdentity rbac.Identity - inner := EnrichmentMiddleware(me)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedIdentity, _ = rbac.FromContext(r.Context()) - w.WriteHeader(http.StatusOK) - })) - h := injectUID("uid123", nil, inner) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) - if rec.Code != http.StatusOK { - t.Errorf("want 200, got %d", rec.Code) - } - if capturedIdentity.UID != "uid123" { - t.Errorf("want uid123, got %q", capturedIdentity.UID) - } -} - -func TestEnrichmentMiddleware_NoUID(t *testing.T) { - me := &mockEnricher{} - h := chain(EnrichmentMiddleware(me), func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) - if rec.Code != http.StatusUnauthorized { - t.Errorf("want 401, got %d", rec.Code) - } -} - -func TestEnrichmentMiddleware_EnricherError(t *testing.T) { - me := &mockEnricher{err: errors.New("db error")} - inner := EnrichmentMiddleware(me)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - h := injectUID("uid123", nil, inner) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) - if rec.Code != http.StatusInternalServerError { - t.Errorf("want 500, got %d", rec.Code) - } -} - -func TestEnrichmentMiddleware_WithTenant(t *testing.T) { - me := &mockEnricher{identity: rbac.NewIdentity("uid123", "", "")} - var capturedIdentity rbac.Identity - inner := EnrichmentMiddleware(me, WithTenantHeader("X-Tenant-ID"))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedIdentity, _ = rbac.FromContext(r.Context()) - w.WriteHeader(http.StatusOK) - })) - h := injectUID("uid123", nil, inner) - req := httptest.NewRequest(http.MethodGet, "/", nil) - req.Header.Set("X-Tenant-ID", "tenant-abc") - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - if capturedIdentity.TenantID != "tenant-abc" { - t.Errorf("want tenant-abc, got %q", capturedIdentity.TenantID) - } -} - -func TestEnrichmentMiddleware_NoTenantHeader(t *testing.T) { - me := &mockEnricher{identity: rbac.NewIdentity("uid123", "", "")} - var capturedIdentity rbac.Identity - inner := EnrichmentMiddleware(me, WithTenantHeader("X-Tenant-ID"))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedIdentity, _ = rbac.FromContext(r.Context()) - w.WriteHeader(http.StatusOK) - })) - h := injectUID("uid123", nil, inner) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) - if capturedIdentity.TenantID != "" { - t.Errorf("want empty TenantID, got %q", capturedIdentity.TenantID) - } -} - -// --- AuthzMiddleware --- - -func TestAuthzMiddleware_Allowed(t *testing.T) { - mp := &mockProvider{mask: rbac.PermissionMask(0).Grant(testRead)} - inner := AuthzMiddleware(mp, "orders", testRead)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - req := httptest.NewRequest(http.MethodGet, "/", nil) - ctx := rbac.SetInContext(req.Context(), rbac.NewIdentity("uid123", "", "")) - rec := httptest.NewRecorder() - inner.ServeHTTP(rec, req.WithContext(ctx)) - if rec.Code != http.StatusOK { - t.Errorf("want 200, got %d", rec.Code) - } -} - -func TestAuthzMiddleware_Denied(t *testing.T) { - mp := &mockProvider{mask: rbac.PermissionMask(0)} // no permissions granted - inner := AuthzMiddleware(mp, "orders", testRead)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - req := httptest.NewRequest(http.MethodGet, "/", nil) - ctx := rbac.SetInContext(req.Context(), rbac.NewIdentity("uid123", "", "")) - rec := httptest.NewRecorder() - inner.ServeHTTP(rec, req.WithContext(ctx)) - if rec.Code != http.StatusForbidden { - t.Errorf("want 403, got %d", rec.Code) - } -} - -func TestAuthzMiddleware_NoIdentity(t *testing.T) { - mp := &mockProvider{} - h := chain(AuthzMiddleware(mp, "orders", testRead), func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) - if rec.Code != http.StatusUnauthorized { - t.Errorf("want 401, got %d", rec.Code) - } -} - -func TestAuthzMiddleware_ProviderError(t *testing.T) { - mp := &mockProvider{err: errors.New("db error")} - inner := AuthzMiddleware(mp, "orders", testRead)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - req := httptest.NewRequest(http.MethodGet, "/", nil) - ctx := rbac.SetInContext(req.Context(), rbac.NewIdentity("uid123", "", "")) - rec := httptest.NewRecorder() - inner.ServeHTTP(rec, req.WithContext(ctx)) - if rec.Code != http.StatusForbidden { - t.Errorf("want 403, got %d", rec.Code) - } -}