From d1de096c72272ddb35387e1210d31b7570a84a3e Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Date: Thu, 19 Mar 2026 13:44:45 +0000 Subject: [PATCH] docs(httpauth-firebase): fix rbac tier reference from 1 to 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rbac is a Tier 0 module (no micro-lib dependencies). The dependency line incorrectly cited it as Tier 1. The module's own tier (4) is unchanged — it remains the auth layer above the transport infrastructure. --- .devcontainer/devcontainer.json | 26 ++ .gitignore | 38 +++ CHANGELOG.md | 27 ++ CLAUDE.md | 124 +++++++++ LICENSE | 21 ++ README.md | 82 ++++++ auth.go | 70 +++++ authz.go | 37 +++ compliance_test.go | 33 +++ doc.go | 17 ++ docs/adr/ADR-001-provider-specific-naming.md | 49 ++++ .../ADR-002-rbac-identity-output-contract.md | 55 ++++ .../ADR-003-composable-middleware-stack.md | 60 ++++ enrichment.go | 66 +++++ go.mod | 39 +++ go.sum | 183 ++++++++++++ httpauth_test.go | 261 ++++++++++++++++++ 17 files changed, 1188 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 auth.go create mode 100644 authz.go create mode 100644 compliance_test.go create mode 100644 doc.go create mode 100644 docs/adr/ADR-001-provider-specific-naming.md create mode 100644 docs/adr/ADR-002-rbac-identity-output-contract.md create mode 100644 docs/adr/ADR-003-composable-middleware-stack.md create mode 100644 enrichment.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 httpauth_test.go diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..54f5aae --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,26 @@ +{ + "name": "Go", + "image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie", + "features": { + "ghcr.io/devcontainers-extra/features/claude-code:1": {} + }, + "forwardPorts": [], + "postCreateCommand": "go version", + "customizations": { + "vscode": { + "settings": { + "files.autoSave": "afterDelay", + "files.autoSaveDelay": 1000, + "explorer.compactFolders": false, + "explorer.showEmptyFolders": true + }, + "extensions": [ + "golang.go", + "eamodio.golang-postfix-completion", + "quicktype.quicktype", + "usernamehw.errorlens" + ] + } + }, + "remoteUser": "vscode" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..221da82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with go test -c +*.test + +# Output of go build +*.out + +# Dependency directory +vendor/ + +# Go workspace file +go.work +go.work.sum + +# Environment files +.env +.env.* + +# Editor / IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# VCS files +COMMIT.md +RELEASE.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e3b09d2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to this module will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.9.0] - 2026-03-18 + +### Added + +- `TokenVerifier` interface — abstracts `*auth.Client` for unit-test mockability; `*auth.Client` satisfies it directly in production via its `VerifyIDTokenAndCheckRevoked` method +- `IdentityEnricher` interface — application-implemented; receives `uid string` and `claims map[string]any`, returns `rbac.Identity`; called by `EnrichmentMiddleware` on every request +- `PermissionProvider` interface — application-implemented; receives `uid` and `resource` string, returns `rbac.PermissionMask`; called by `AuthzMiddleware` on every request +- `EnrichOpt` functional option type for configuring `EnrichmentMiddleware` +- `WithTenantHeader(header string) EnrichOpt` — reads a tenant ID from the named request header and attaches it to the identity via `rbac.Identity.WithTenant`; absent header leaves `TenantID` as an empty string with no error +- `AuthMiddleware(verifier TokenVerifier, publicPaths []string) func(http.Handler) http.Handler` — verifies `Authorization: Bearer ` via Firebase JWT verification and injects the verified `uid` and raw claims into the request context under unexported typed keys; paths matching any pattern in `publicPaths` bypass token verification (glob patterns via `path.Match`, `*` wildcard supported); returns 401 on missing or invalid tokens +- `EnrichmentMiddleware(enricher IdentityEnricher, opts ...EnrichOpt) func(http.Handler) http.Handler` — reads the uid and claims stored by `AuthMiddleware`, calls `enricher.Enrich`, and stores the resulting `rbac.Identity` in context via `rbac.SetInContext`; returns 401 if `AuthMiddleware` has not run upstream; returns 500 if the enricher fails +- `AuthzMiddleware(provider PermissionProvider, resource string, required rbac.Permission) func(http.Handler) http.Handler` — reads `rbac.Identity` from context via `rbac.FromContext`, resolves the permission mask for the identity's UID on `resource`, and gates the request against the required permission bit; returns 401 if no identity is in context; returns 403 if the permission check fails or the provider returns an error + +### Design Notes + +- The three middleware functions are intentionally separate so they can be applied at different scopes: `AuthMiddleware` at the root router, `EnrichmentMiddleware` on authenticated route groups, and `AuthzMiddleware` per-route or per-group with different resource and permission arguments +- The module is named `httpauth-firebase` rather than `httpauth` because it imports the Firebase SDK directly; other providers (`httpauth-auth0`, `httpauth-jwt`, etc.) are separate sibling modules that all converge on the same `rbac.Identity` output contract, which means downstream handlers and business logic never depend on a specific auth provider +- No logger parameter is accepted; errors are returned as plain-text HTTP responses, keeping the dependency surface to `rbac` and `firebase.google.com/go/v4` only + +[0.9.0]: https://code.nochebuena.dev/go/httpauth-firebase/releases/tag/v0.9.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..294c252 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,124 @@ +# httpauth-firebase + +Firebase-backed HTTP middleware for authentication, identity enrichment, and +role-based access control. + +## Purpose + +`httpauth-firebase` provides three composable `net/http` middleware functions that +implement the full auth stack: JWT verification via Firebase, app-specific identity +enrichment via a caller-supplied `IdentityEnricher`, and permission enforcement via +`rbac`. The output contract is always `rbac.Identity` — downstream code and business +logic are decoupled from Firebase types entirely. + +## Tier & Dependencies + +**Tier:** 4 (transport auth layer; depends on Tier 0 `rbac` and external Firebase SDK) +**Module:** `code.nochebuena.dev/go/httpauth-firebase` +**Direct imports:** `code.nochebuena.dev/go/rbac`, `firebase.google.com/go/v4/auth` + +`httpauth-firebase` does not import `logz`, `httpmw`, or `httputil`. It has no +logger parameter — errors are returned as HTTP responses, not logged here. + +## 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. + +## Patterns + +**Full stack (most routes):** + +```go +r.Use(httpauth.AuthMiddleware(firebaseAuthClient, []string{"/health", "/metrics/*"})) +r.Use(httpauth.EnrichmentMiddleware(userEnricher, httpauth.WithTenantHeader("X-Tenant-ID"))) + +// Per-route RBAC: +r.With(httpauth.AuthzMiddleware(permProvider, "orders", rbac.Write)). + Post("/orders", httputil.Handle(v, svc.CreateOrder)) +``` + +**Token-only (webhook verification):** + +```go +r.Use(httpauth.AuthMiddleware(firebaseAuthClient, nil)) +// No enrichment, no authz — just verify the token is valid +``` + +**Reading identity in a handler:** + +```go +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) +``` + +**Public paths (bypass token check):** + +```go +// path.Match patterns — supports * wildcard +publicPaths := []string{"/health", "/ready", "/metrics/*"} +r.Use(httpauth.AuthMiddleware(firebaseAuthClient, publicPaths)) +``` + +**Interfaces the application must implement:** + +```go +// 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 +} +``` + +## What to Avoid + +- Do not use `AuthMiddleware` alone and assume the request is fully authorised. + Token verification only confirms the token is valid; it does not enforce + application-level roles or permissions. +- Do not put `AuthzMiddleware` before `EnrichmentMiddleware` in the chain. + `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. +- 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. +- 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. + +## Testing Notes + +- `compliance_test.go` verifies at compile time that `*mockVerifier` satisfies + `TokenVerifier`, `*mockEnricher` satisfies `IdentityEnricher`, and `*mockProvider` + satisfies `PermissionProvider`. +- `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. +- Use `httptest.NewRecorder()` and `httptest.NewRequest()` for all HTTP-level tests. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b33b48 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 NOCHEBUENADEV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..562b7b8 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# httpauth-firebase + +Firebase-backed HTTP middleware for authentication, identity enrichment, and RBAC authorization. + +## Overview + +Three composable `func(http.Handler) http.Handler` middleware functions: + +| Middleware | Responsibility | +|---|---| +| `AuthMiddleware` | Verifies Firebase Bearer token; injects uid + claims into context | +| `EnrichmentMiddleware` | Calls app-provided `IdentityEnricher`; stores `rbac.Identity` in context | +| `AuthzMiddleware` | Resolves permission mask; gates request | + +All functions accept interfaces — testable without a live Firebase connection. + +## Installation + +``` +require code.nochebuena.dev/go/httpauth-firebase v0.1.0 +``` + +## Usage + +```go +r.Use(httpauth.AuthMiddleware(firebaseAuthClient, []string{"/health", "/public/*"})) +r.Use(httpauth.EnrichmentMiddleware(myUserEnricher, httpauth.WithTenantHeader("X-Tenant-ID"))) +r.Use(httpauth.AuthzMiddleware(myPermProvider, "orders", rbac.Read)) +``` + +## Interfaces + +### TokenVerifier + +```go +type TokenVerifier interface { + VerifyIDTokenAndCheckRevoked(ctx context.Context, idToken string) (*auth.Token, error) +} +``` + +`*firebase/auth.Client` satisfies this directly. Swap in a mock for tests. + +### IdentityEnricher + +```go +type IdentityEnricher interface { + Enrich(ctx context.Context, uid string, claims map[string]any) (rbac.Identity, error) +} +``` + +Implement this in your application to load user data from your store and return an `rbac.Identity`. + +### PermissionProvider + +```go +type PermissionProvider interface { + ResolveMask(ctx context.Context, uid, resource string) (rbac.PermissionMask, error) +} +``` + +Returns the permission bitmask for the user on a given resource. + +## Options + +| Option | Description | +|---|---| +| `WithTenantHeader(header)` | Reads `TenantID` from the named request header. If absent, `TenantID` remains `""`. | + +## Public paths + +`AuthMiddleware` skips token verification for requests matching any pattern in `publicPaths`. Patterns use `path.Match` semantics (e.g. `"/public/*"`). + +## HTTP status codes + +| Condition | Status | +|---|---| +| Missing or malformed `Authorization` header | 401 | +| Token verification failure | 401 | +| No `rbac.Identity` in context (AuthzMiddleware) | 401 | +| Missing uid in context (EnrichmentMiddleware) | 401 | +| Enricher error | 500 | +| Permission denied or provider error | 403 | diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..d8ce5ac --- /dev/null +++ b/auth.go @@ -0,0 +1,70 @@ +package httpauth + +import ( + "context" + "net/http" + "path" + "strings" + + "firebase.google.com/go/v4/auth" +) + +// TokenVerifier abstracts Firebase JWT verification. +// *auth.Client satisfies this interface directly — no adapter needed in production. +// Retained solely for unit-test mockability. +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. +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) { + for _, pattern := range publicPaths { + if matched, _ := path.Match(pattern, r.URL.Path); matched { + next.ServeHTTP(w, r) + return + } + } + + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + token := strings.TrimPrefix(authHeader, "Bearer ") + + decoded, err := verifier.VerifyIDTokenAndCheckRevoked(r.Context(), token) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + ctx := setTokenData(r.Context(), decoded.UID, decoded.Claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/authz.go b/authz.go new file mode 100644 index 0000000..5ffb788 --- /dev/null +++ b/authz.go @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..86fc6eb --- /dev/null +++ b/compliance_test.go @@ -0,0 +1,33 @@ +package httpauth_test + +import ( + "context" + + "firebase.google.com/go/v4/auth" + + httpauth "code.nochebuena.dev/go/httpauth-firebase" + "code.nochebuena.dev/go/rbac" +) + +type mockVerifier struct{} + +func (m *mockVerifier) VerifyIDTokenAndCheckRevoked(_ context.Context, _ string) (*auth.Token, error) { + 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) diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..61e02f9 --- /dev/null +++ b/doc.go @@ -0,0 +1,17 @@ +// Package httpauth provides Firebase-backed HTTP middleware for authentication, +// identity enrichment, and role-based access control. +// +// Typical middleware chain: +// +// r.Use(httpauth.AuthMiddleware(firebaseClient, publicPaths)) +// r.Use(httpauth.EnrichmentMiddleware(userEnricher, httpauth.WithTenantHeader("X-Tenant-ID"))) +// r.Use(httpauth.AuthzMiddleware(permProvider, "orders", rbac.Read)) +// +// 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 +// a live Firebase connection. +package httpauth diff --git a/docs/adr/ADR-001-provider-specific-naming.md b/docs/adr/ADR-001-provider-specific-naming.md new file mode 100644 index 0000000..935787a --- /dev/null +++ b/docs/adr/ADR-001-provider-specific-naming.md @@ -0,0 +1,49 @@ +# ADR-001: Provider-Specific Module Naming (httpauth-firebase) + +**Status:** Accepted +**Date:** 2026-03-18 + +## Context + +An auth middleware module originally named `httpauth` was designed to be +provider-agnostic: it defined a `TokenVerifier` interface and duck-typed +`firebase.Client` so the module never imported Firebase directly. The intent was +that any JWT provider could be supported by implementing `TokenVerifier`. + +Under global ADR-001, framework modules import their named dependencies directly +rather than duck-typing them away. An auth module for Firebase should import +`firebase.google.com/go/v4/auth` directly, since that is its explicit purpose. +This raises the naming question: if the module imports Firebase, should it still be +called `httpauth`? + +## Decision + +The module is named **`httpauth-firebase`**: + +- Go module path: `code.nochebuena.dev/go/httpauth-firebase` +- Directory: `micro-lib/httpauth-firebase/` +- Package name: `httpauth` (short import alias; the module path carries the provider name) + +A different auth provider would live in `httpauth-auth0`, `httpauth-jwks`, etc. +All `httpauth-*` modules converge on the same output contract: `rbac.Identity` stored +in context via `rbac.SetInContext`. + +The `TokenVerifier` interface is retained **solely for unit-test mockability** — +not for provider abstraction. In production, `*auth.Client` from +`firebase.google.com/go/v4/auth` satisfies `TokenVerifier` directly without any +adapter. + +## Consequences + +- The module name is honest: scanning the module list, a developer immediately knows + this is Firebase-specific and not a generic auth abstraction. +- Other auth providers are accommodated by creating sibling modules + (`httpauth-auth0`, etc.) with identical output contracts, not by implementing an + interface in this module. +- The `TokenVerifier` interface remains exported so test code outside the package can + implement it (`mockVerifier` in `compliance_test.go`). Its presence does not imply + that swapping providers at runtime is a supported pattern. +- Applications that switch from Firebase to another provider replace the module + import; the rest of the auth stack (`EnrichmentMiddleware`, `AuthzMiddleware`) and + all business logic remain unchanged because they depend on `rbac.Identity`, not on + Firebase types. diff --git a/docs/adr/ADR-002-rbac-identity-output-contract.md b/docs/adr/ADR-002-rbac-identity-output-contract.md new file mode 100644 index 0000000..b3837b1 --- /dev/null +++ b/docs/adr/ADR-002-rbac-identity-output-contract.md @@ -0,0 +1,55 @@ +# ADR-002: rbac.Identity as the Output Contract + +**Status:** Accepted +**Date:** 2026-03-18 + +## Context + +`AuthMiddleware` verifies a Firebase JWT and extracts a UID and claims map from the +decoded token. Downstream code needs a richer identity: application-specific role, +tenant, display name, email. Several design options exist: + +1. Expose Firebase token fields (`auth.Token`) directly in context — ties all + downstream code to Firebase types. +2. Define a custom identity struct in `httpauth-firebase` — decouples from Firebase + but creates a module-specific contract that other `httpauth-*` modules cannot share. +3. Use `rbac.Identity` as the shared identity type — all `httpauth-*` modules + produce the same type; downstream code and `AuthzMiddleware` depend on `rbac` + only, not on any Firebase types. + +## Decision + +`EnrichmentMiddleware` calls `rbac.SetInContext(ctx, identity)` to store the +enriched identity. `AuthzMiddleware` reads it with `rbac.FromContext(ctx)`. +Downstream business logic and service handlers use `rbac.FromContext` directly — +they never import `httpauth-firebase`. + +The flow is: + +1. `AuthMiddleware` (this module): verifies Firebase JWT → stores `uid` + `claims` + in context under unexported keys local to this package. +2. `EnrichmentMiddleware` (this module): reads `uid` + `claims` → calls + `IdentityEnricher.Enrich` → stores `rbac.Identity` via `rbac.SetInContext`. +3. `AuthzMiddleware` (this module): reads `rbac.Identity` via `rbac.FromContext` → + calls `PermissionProvider.ResolveMask` → allows or rejects. + +The intermediate `uid` + `claims` context values are stored under unexported typed +keys (`ctxUIDKey{}`, `ctxClaimsKey{}`). They are internal to `httpauth-firebase` +and not part of the public API. + +## Consequences + +- Business logic and service layers only need to import `rbac` to read the caller's + identity. They have no knowledge of Firebase, JWTs, or token claims. +- Switching from Firebase to another provider (e.g. Auth0) requires replacing the + `AuthMiddleware` module. `EnrichmentMiddleware`, `AuthzMiddleware`, and all + downstream code remain unchanged because they operate on `rbac.Identity`. +- `IdentityEnricher` is the application's extension point: it receives the Firebase + UID and raw claims and returns a fully populated `rbac.Identity` with role and + tenant. This is the only place where app-specific user store queries should occur. +- `rbac.Identity.WithTenant(tenantID)` is called in `EnrichmentMiddleware` if a + tenant header is configured. The base identity from `Enrich` is immutable; a new + value is returned. +- Global ADR-003 (context helpers live with data owners) is applied here: `rbac` + owns `SetInContext` and `FromContext` because `rbac.Identity` is a RBAC concern, + not an auth transport concern. diff --git a/docs/adr/ADR-003-composable-middleware-stack.md b/docs/adr/ADR-003-composable-middleware-stack.md new file mode 100644 index 0000000..1a161f0 --- /dev/null +++ b/docs/adr/ADR-003-composable-middleware-stack.md @@ -0,0 +1,60 @@ +# ADR-003: Composable Three-Middleware Auth Stack + +**Status:** Accepted +**Date:** 2026-03-18 + +## Context + +HTTP authentication and authorisation involve at least three distinct concerns: + +1. **Token verification**: Is this JWT valid and non-revoked? Who issued it? +2. **Identity enrichment**: Given the verified UID, what role and tenant does this + user have in the application? +3. **Permission enforcement**: Does this identity have the required permission on + this resource? + +A single "auth middleware" that handles all three concerns is a common pattern, but +it creates problems: + +- Enrichment requires a user-store lookup. Not every route needs enrichment (e.g. + public endpoints that only verify the token is structurally valid). +- Permission enforcement is per-resource and per-route. A single monolithic + middleware cannot vary by route without routing logic inside it. +- Testing each concern in isolation becomes difficult when they are combined. + +## Decision + +Three separate middleware functions are provided: + +- **`AuthMiddleware(verifier, publicPaths)`**: Verifies the Bearer token using + Firebase. Stores `uid` and `claims` in context. Returns 401 on missing or invalid + token. Routes matching `publicPaths` (glob patterns) are bypassed entirely. +- **`EnrichmentMiddleware(enricher, opts...)`**: Reads `uid` + `claims` from context. + Calls `IdentityEnricher.Enrich` (app-provided). Stores `rbac.Identity` in context. + Returns 401 if no UID is present (i.e. `AuthMiddleware` was not in the chain). + Optionally reads a tenant ID from a configurable request header. +- **`AuthzMiddleware(provider, resource, required)`**: Reads `rbac.Identity` from + context. Calls `PermissionProvider.ResolveMask` to get the permission mask for the + identity's UID on the named resource. Returns 401 if no identity is present, 403 + if the mask does not include the required permission. + +Each function returns a `func(http.Handler) http.Handler`, composable with any +standard middleware stack. + +## Consequences + +- Each middleware can be tested in isolation. `AuthMiddleware` tests do not need a + real user store; `EnrichmentMiddleware` tests do not need a Firebase client; + `AuthzMiddleware` tests do not need either. +- Routes can apply only the subset they need. A webhook endpoint that only verifies + token validity can use `AuthMiddleware` alone. A read-only endpoint can use + `AuthMiddleware` + `EnrichmentMiddleware` without `AuthzMiddleware`. +- The order constraint is enforced by dependency: `EnrichmentMiddleware` depends on + values written by `AuthMiddleware`; `AuthzMiddleware` depends on values written by + `EnrichmentMiddleware`. Incorrect ordering produces 401 (missing UID or identity) + rather than a silent bug. +- `publicPaths` in `AuthMiddleware` uses `path.Match` for glob matching. Patterns + like `/health`, `/metrics/*` can be listed to skip token verification entirely. +- `IdentityEnricher` and `PermissionProvider` are interfaces injected at + construction time, making the middleware fully testable without a real Firebase + connection or database. diff --git a/enrichment.go b/enrichment.go new file mode 100644 index 0000000..4349d88 --- /dev/null +++ b/enrichment.go @@ -0,0 +1,66 @@ +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 new file mode 100644 index 0000000..eb79b1e --- /dev/null +++ b/go.mod @@ -0,0 +1,39 @@ +module code.nochebuena.dev/go/httpauth-firebase + +go 1.25 + +require ( + code.nochebuena.dev/go/rbac v0.9.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 + 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 + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/s2a-go v0.1.7 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.24.0 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/oauth2 v0.18.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect + google.golang.org/api v0.170.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/appengine/v2 v2.0.2 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2 // indirect + google.golang.org/grpc v1.62.1 // indirect + google.golang.org/protobuf v1.33.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0a90996 --- /dev/null +++ b/go.sum @@ -0,0 +1,183 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg= +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/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= +firebase.google.com/go/v4 v4.15.0/go.mod h1:S/4MJqVZn1robtXkHhpRUbwOC4gdYtgsiMMJQ4x+xmQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= +github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= +github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= +golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.170.0 h1:zMaruDePM88zxZBG+NG8+reALO2rfLhe/JShitLyT48= +google.golang.org/api v0.170.0/go.mod h1:/xql9M2btF85xac/VAm4PsLMTLVGUOpq4BE9R8jyNy8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/appengine/v2 v2.0.2 h1:MSqyWy2shDLwG7chbwBJ5uMyw6SNqJzhJHNDwYB0Akk= +google.golang.org/appengine/v2 v2.0.2/go.mod h1:PkgRUWz4o1XOvbqtWTkBtCitEJ5Tp4HoVEdMMYQR/8E= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2 h1:9IZDv+/GcI6u+a4jRFRLxQs0RUCfavGfoOgEW6jpkI0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= +google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/httpauth_test.go b/httpauth_test.go new file mode 100644 index 0000000..efde99d --- /dev/null +++ b/httpauth_test.go @@ -0,0 +1,261 @@ +package httpauth + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "firebase.google.com/go/v4/auth" + + "code.nochebuena.dev/go/rbac" +) + +// --- mocks --- + +type mockVerifier struct { + token *auth.Token + err error +} + +func (m *mockVerifier) VerifyIDTokenAndCheckRevoked(_ context.Context, _ string) (*auth.Token, error) { + 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 + h := chain(AuthMiddleware(mv, nil), func(w http.ResponseWriter, r *http.Request) { + capturedUID, _ = getUID(r.Context()) + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/api", nil) + req.Header.Set("Authorization", "Bearer valid-token") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("want 200, got %d", rec.Code) + } + if capturedUID != "uid123" { + t.Errorf("want uid123, got %q", capturedUID) + } +} + +func TestAuthMiddleware_InvalidToken(t *testing.T) { + mv := &mockVerifier{err: errors.New("token invalid")} + h := chain(AuthMiddleware(mv, nil), func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/api", nil) + req.Header.Set("Authorization", "Bearer bad-token") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("want 401, got %d", rec.Code) + } +} + +func TestAuthMiddleware_MissingHeader(t *testing.T) { + mv := &mockVerifier{} + h := chain(AuthMiddleware(mv, nil), func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api", nil)) + if rec.Code != http.StatusUnauthorized { + t.Errorf("want 401, got %d", rec.Code) + } +} + +func TestAuthMiddleware_PublicPath(t *testing.T) { + mv := &mockVerifier{err: errors.New("should not be called")} + h := chain(AuthMiddleware(mv, []string{"/health"}), func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) + if rec.Code != http.StatusOK { + t.Errorf("want 200, got %d", rec.Code) + } +} + +func TestAuthMiddleware_PublicPathWildcard(t *testing.T) { + mv := &mockVerifier{err: errors.New("should not be called")} + h := chain(AuthMiddleware(mv, []string{"/public/*"}), func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/public/resource", nil)) + if rec.Code != http.StatusOK { + 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) + } +}