From 18e5a16f7eeb1e6e9e99521eb674efe6205da071 Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Guerrero Date: Thu, 7 May 2026 21:37:25 -0600 Subject: [PATCH] =?UTF-8?q?feat(httpauth):=20initial=20release=20=E2=80=94?= =?UTF-8?q?=20provider-agnostic=20HTTP=20auth=20middleware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provides SetTokenData for upstream AuthMiddleware implementations, EnrichmentMiddleware and AuthzMiddleware compatible with any provider that calls SetTokenData, ClaimsPermissionProvider for JWT-embedded permissions, and CachedPermissionProvider for TTL-backed runtime resolution via any Cache implementation. --- .gitea/CODEOWNERS | 1 + .gitignore | 38 ++++++ CHANGELOG.md | 29 +++++ CLAUDE.md | 111 +++++++++++++++++ LICENSE | 21 ++++ README.md | 112 +++++++++++++++++ auth.go | 25 ++++ authz.go | 32 +++++ cache.go | 44 +++++++ claims_provider.go | 39 ++++++ compliance_test.go | 33 +++++ doc.go | 19 +++ enrichment.go | 67 ++++++++++ go.mod | 5 + go.sum | 2 + httpauth_test.go | 301 +++++++++++++++++++++++++++++++++++++++++++++ 16 files changed, 879 insertions(+) create mode 100644 .gitea/CODEOWNERS 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 cache.go create mode 100644 claims_provider.go create mode 100644 compliance_test.go create mode 100644 doc.go create mode 100644 enrichment.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 httpauth_test.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/.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..dd8e58e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# 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.1.0] - 2026-05-08 + +### Added + +- `SetTokenData(ctx, uid, claims) context.Context` — injects verified uid and raw claims into the request context; called by provider-specific AuthMiddleware implementations (e.g. `httpauth-firebase`, `httpauth-jwt`) after token verification; downstream middleware reads these values via unexported helpers in the same package +- `IdentityEnricher` interface — application-implemented; receives `uid string` and `claims map[string]any`, returns `rbac.Identity`; called by `EnrichmentMiddleware` on every authenticated 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 +- `EnrichmentMiddleware(enricher IdentityEnricher, opts ...EnrichOpt) func(http.Handler) http.Handler` — reads uid and claims stored by any upstream AuthMiddleware via `SetTokenData`, calls `enricher.Enrich`, and stores the resulting `rbac.Identity` in context via `rbac.SetInContext`; returns 401 if no uid is present; returns 500 if the enricher fails +- `AuthzMiddleware(provider rbac.PermissionProvider, resource string, required rbac.Permission) func(http.Handler) http.Handler` — reads `rbac.Identity` from context via `rbac.FromContext`, resolves the permission mask via the provided `rbac.PermissionProvider`, 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; uses `rbac.PermissionProvider` directly without redefining it +- `Cache` interface — abstracts the caching backend for permission masks; `Get(ctx, key) (int64, bool, error)` and `Set(ctx, key, value, ttl) error`; implementations are typically backed by Valkey or Redis +- `NewCachedPermissionProvider(inner rbac.PermissionProvider, cache Cache, ttl time.Duration) rbac.PermissionProvider` — wraps any `rbac.PermissionProvider` with a TTL-based cache layer; cache key format is `rbac:{uid}:{resource}`; on cache miss falls through to inner and populates the cache; on cache error falls through silently — never fails due to cache unavailability +- `NewClaimsPermissionProvider(claimsKey string) rbac.PermissionProvider` — reads pre-computed permission masks from JWT claims stored in the request context by `SetTokenData`; expects `claims[claimsKey]` to be a `map[string]any` where each key is a resource name and the value is the bitmask as `int64` or `float64` (JSON decodes numbers as float64); returns 0 without error if the claim is absent + +### Design Notes + +- `AuthzMiddleware` uses `rbac.PermissionProvider` directly rather than redefining a local interface; `rbac` is the single source of truth for this contract +- `EnrichmentMiddleware` and `AuthzMiddleware` are provider-agnostic — they depend only on `SetTokenData` having been called upstream; any `AuthMiddleware` that calls `SetTokenData` (Firebase, JWT, API key, etc.) is compatible without changes to the enrichment or authorization layer +- Two `rbac.PermissionProvider` implementations ship with this module for the two common architectures: `ClaimsPermissionProvider` for simple applications that embed permissions in the JWT (no per-request DB or network call), and `CachedPermissionProvider` for applications where the permission set is too large to embed or needs to be independently revocable +- `CachedPermissionProvider` uses TTL-based expiry exclusively; explicit invalidation is left to callers who can interact with the `Cache` directly using the `rbac:{uid}:{resource}` key format + +[0.1.0]: https://code.nochebuena.dev/go/httpauth/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9b74c07 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,111 @@ +# httpauth + +Provider-agnostic HTTP middleware for identity enrichment and RBAC authorization. + +## Purpose + +`httpauth` is the shared foundation for all `httpauth-*` provider modules. It provides +`EnrichmentMiddleware`, `AuthzMiddleware`, two `rbac.PermissionProvider` implementations +(`ClaimsPermissionProvider` and `CachedPermissionProvider`), and `SetTokenData` — the +bridge between a provider-specific `AuthMiddleware` and the rest of the auth stack. + +Any `AuthMiddleware` that calls `SetTokenData` after token verification is compatible. +Downstream code reads identity exclusively via `rbac.FromContext` — no provider type +leaks past the middleware boundary. + +## Tier & Dependencies + +**Tier:** 3 (transport auth layer; depends only on Tier 0 `rbac`; no external SDK) +**Module:** `code.nochebuena.dev/go/httpauth` +**Direct imports:** `code.nochebuena.dev/go/rbac` only + +`httpauth` does not import `logz`, `httpmw`, `httputil`, Firebase, or any JWT library. +It has no logger parameter — errors are returned as HTTP responses. + +## Key Design Decisions + +- **`rbac.PermissionProvider` without redefinition:** `AuthzMiddleware` accepts + `rbac.PermissionProvider` directly. `rbac` is the single source of truth for this + interface. Provider-specific modules (e.g. `httpauth-firebase`) previously defined + their own `PermissionProvider` locally — that duplication is removed. +- **`SetTokenData` as the integration contract:** Provider-specific `AuthMiddleware` + implementations call `SetTokenData(ctx, uid, claims)` after verifying the token. + The context keys are unexported typed structs. `EnrichmentMiddleware` reads them + via the unexported helpers `getUID` and `getClaims` in the same package. +- **Two permission strategies:** `ClaimsPermissionProvider` (JWT-embedded, no DB call) + and `CachedPermissionProvider` (TTL-backed runtime resolution) are first-class + implementations. Choose based on token size and revocation requirements. +- **Cache falls through on error:** `CachedPermissionProvider` treats cache errors as + misses — a cache outage degrades gracefully to the inner provider. + +## Patterns + +**Full stack with self-issued JWT:** + +```go +r.Use(jwtauth.AuthMiddleware(signer, publicPaths, nil)) +r.Use(httpauth.EnrichmentMiddleware(myEnricher, httpauth.WithTenantHeader("X-Tenant-ID"))) + +// Simple app — permissions embedded in JWT: +claimsProvider := httpauth.NewClaimsPermissionProvider("permisos") +r.With(httpauth.AuthzMiddleware(claimsProvider, "usuarios", rbac.Permission(1))). + Get("/usuarios", handler) + +// Complex app — runtime resolution with cache: +cachedProvider := httpauth.NewCachedPermissionProvider(dbProvider, valkeyCache, 5*time.Minute) +r.With(httpauth.AuthzMiddleware(cachedProvider, "usuarios", rbac.Permission(1))). + Get("/usuarios", handler) +``` + +**Provider-specific AuthMiddleware calling SetTokenData:** + +```go +// Inside httpauth-jwt or httpauth-firebase AuthMiddleware, after token verification: +ctx := httpauth.SetTokenData(r.Context(), verified.UID, verified.Claims) +next.ServeHTTP(w, r.WithContext(ctx)) +``` + +**Reading identity in a handler:** + +```go +identity, ok := rbac.FromContext(r.Context()) +if !ok { + // should not happen if EnrichmentMiddleware is in the chain +} +``` + +**Implementing Cache (e.g. with Valkey):** + +```go +type valkeyCache struct{ client valkey.Client } + +func (c *valkeyCache) Get(ctx context.Context, key string) (int64, bool, error) { ... } +func (c *valkeyCache) Set(ctx context.Context, key string, val int64, ttl time.Duration) error { ... } +``` + +**Cache key for manual invalidation:** `rbac:{uid}:{resource}` + +## What to Avoid + +- Do not call `SetTokenData` from application or domain layer code. It is the + exclusive responsibility of provider-specific `AuthMiddleware` implementations. +- 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 import `httpauth` from service or domain layers. It is a transport package. +- Do not define a local `PermissionProvider` interface in provider modules that import + this package — use `rbac.PermissionProvider` directly. + +## Testing Notes + +- `compliance_test.go` verifies at compile time that mock types satisfy `IdentityEnricher` + and `Cache`, and that `rbac.PermissionProvider` is satisfied by the two built-in + provider implementations. +- `EnrichmentMiddleware` tests use `injectTokenData(uid, claims, next)` — a helper + that calls `SetTokenData` to bypass a real upstream `AuthMiddleware`. +- `AuthzMiddleware` tests pre-populate context with `rbac.SetInContext` — no enrichment + middleware needed. +- `ClaimsPermissionProvider` tests exercise both `float64` (JSON decode) and `int64` + paths for the mask value. +- `CachedPermissionProvider` tests exercise cache hit, miss, cache error fallthrough, + and inner provider error propagation. 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..1175f77 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# httpauth + +Provider-agnostic HTTP middleware for identity enrichment and RBAC authorization. + +## Overview + +Three composable `func(http.Handler) http.Handler` middleware functions and two `rbac.PermissionProvider` implementations: + +| Component | Responsibility | +|---|---| +| `EnrichmentMiddleware` | Calls app-provided `IdentityEnricher`; stores `rbac.Identity` in context | +| `AuthzMiddleware` | Resolves permission mask via `rbac.PermissionProvider`; gates request | +| `ClaimsPermissionProvider` | Reads pre-computed masks from JWT claims — no DB call | +| `CachedPermissionProvider` | Wraps any provider with a TTL cache; falls through on miss or error | +| `SetTokenData` | Injects uid + claims from any verified token into the request context | + +Any upstream `AuthMiddleware` that calls `SetTokenData` is compatible — Firebase, self-issued JWT, API key, etc. + +## Installation + +``` +require code.nochebuena.dev/go/httpauth v0.1.0 +``` + +## Usage + +### With JWT-embedded permissions (simple apps) + +```go +// Auth middleware (e.g. httpauth-jwt) calls httpauth.SetTokenData after verification. +// JWT claims include: { "permisos": { "usuarios": 515, "roles": 6 } } + +r.Use(jwtauth.AuthMiddleware(signer, publicPaths, nil)) +r.Use(httpauth.EnrichmentMiddleware(myEnricher)) + +claimsProvider := httpauth.NewClaimsPermissionProvider("permisos") +r.With(httpauth.AuthzMiddleware(claimsProvider, "usuarios", rbac.Permission(1))). + Get("/usuarios", handler) +``` + +### With runtime resolution + cache (complex apps) + +```go +r.Use(jwtauth.AuthMiddleware(signer, publicPaths, nil)) +r.Use(httpauth.EnrichmentMiddleware(myEnricher, httpauth.WithTenantHeader("X-Tenant-ID"))) + +dbProvider := myapp.NewDBPermissionProvider(db) +cachedProvider := httpauth.NewCachedPermissionProvider(dbProvider, valkeyCache, 5*time.Minute) + +r.With(httpauth.AuthzMiddleware(cachedProvider, "usuarios", rbac.Permission(1))). + Get("/usuarios", handler) +``` + +## Interfaces + +### IdentityEnricher + +```go +type IdentityEnricher interface { + Enrich(ctx context.Context, uid string, claims map[string]any) (rbac.Identity, error) +} +``` + +Implement in your application to load user data and return an `rbac.Identity`. + +### Cache + +```go +type Cache interface { + Get(ctx context.Context, key string) (int64, bool, error) + Set(ctx context.Context, key string, value int64, ttl time.Duration) error +} +``` + +Implement with Valkey, Redis, or any in-memory store. Cache keys follow the format `rbac:{uid}:{resource}`. + +### rbac.PermissionProvider (from the `rbac` package) + +```go +type PermissionProvider interface { + ResolveMask(ctx context.Context, uid, resource string) (rbac.PermissionMask, error) +} +``` + +`AuthzMiddleware` accepts any implementation — `ClaimsPermissionProvider`, `CachedPermissionProvider`, or your own. + +## SetTokenData + +```go +func SetTokenData(ctx context.Context, uid string, claims map[string]any) context.Context +``` + +Called by provider-specific `AuthMiddleware` implementations after token verification. `EnrichmentMiddleware` reads the injected values automatically. + +## Options + +| Option | Description | +|---|---| +| `WithTenantHeader(header)` | Reads `TenantID` from the named request header. If absent, `TenantID` remains `""`. | + +## HTTP status codes + +| Condition | Status | +|---|---| +| No uid in context (EnrichmentMiddleware) | 401 | +| Enricher error | 500 | +| No `rbac.Identity` in context (AuthzMiddleware) | 401 | +| Permission denied or provider error | 403 | + +## Cache key format + +`CachedPermissionProvider` uses `rbac:{uid}:{resource}` as the cache key. To invalidate manually, delete the key directly via your `Cache` implementation. diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..e349708 --- /dev/null +++ b/auth.go @@ -0,0 +1,25 @@ +package httpauth + +import "context" + +type ctxUIDKey struct{} +type ctxClaimsKey struct{} + +// SetTokenData injects a verified uid and raw claims into the context. +// Called by provider-specific AuthMiddleware implementations after token verification. +// EnrichmentMiddleware reads these values automatically via unexported helpers. +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 +} diff --git a/authz.go b/authz.go new file mode 100644 index 0000000..52289e4 --- /dev/null +++ b/authz.go @@ -0,0 +1,32 @@ +package httpauth + +import ( + "net/http" + + "code.nochebuena.dev/go/rbac" +) + +// AuthzMiddleware reads the rbac.Identity from context (set by EnrichmentMiddleware) +// and gates the request against the required permission on resource. +// Uses rbac.PermissionProvider directly — no local redefinition of the interface. +// Returns 401 if no identity is in context. +// Returns 403 if the identity lacks the required permission or if the provider errors. +func AuthzMiddleware(provider rbac.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/cache.go b/cache.go new file mode 100644 index 0000000..0916ea1 --- /dev/null +++ b/cache.go @@ -0,0 +1,44 @@ +package httpauth + +import ( + "context" + "fmt" + "time" + + "code.nochebuena.dev/go/rbac" +) + +// Cache abstracts the caching backend for permission masks. +// Implementations are typically backed by Valkey or Redis. +type Cache interface { + Get(ctx context.Context, key string) (int64, bool, error) + Set(ctx context.Context, key string, value int64, ttl time.Duration) error +} + +type cachedPermissionProvider struct { + inner rbac.PermissionProvider + cache Cache + ttl time.Duration +} + +// NewCachedPermissionProvider wraps inner with a TTL-based cache layer. +// Cache key format: "rbac:{uid}:{resource}". +// On cache miss, falls through to inner and populates the cache. +// On cache error, falls through to inner silently — never fails due to cache unavailability. +// For explicit invalidation, delete "rbac:{uid}:{resource}" directly via your Cache. +func NewCachedPermissionProvider(inner rbac.PermissionProvider, cache Cache, ttl time.Duration) rbac.PermissionProvider { + return &cachedPermissionProvider{inner: inner, cache: cache, ttl: ttl} +} + +func (p *cachedPermissionProvider) ResolveMask(ctx context.Context, uid, resource string) (rbac.PermissionMask, error) { + key := fmt.Sprintf("rbac:%s:%s", uid, resource) + if val, ok, err := p.cache.Get(ctx, key); err == nil && ok { + return rbac.PermissionMask(val), nil + } + mask, err := p.inner.ResolveMask(ctx, uid, resource) + if err != nil { + return 0, err + } + _ = p.cache.Set(ctx, key, int64(mask), p.ttl) + return mask, nil +} diff --git a/claims_provider.go b/claims_provider.go new file mode 100644 index 0000000..9b478ba --- /dev/null +++ b/claims_provider.go @@ -0,0 +1,39 @@ +package httpauth + +import ( + "context" + + "code.nochebuena.dev/go/rbac" +) + +type claimsPermissionProvider struct { + claimsKey string +} + +// NewClaimsPermissionProvider returns an rbac.PermissionProvider that reads +// pre-computed permission masks from JWT claims stored in the request context +// by SetTokenData. Expects claims[claimsKey] to be a map[string]any where each +// key is a resource name and the value is the bitmask as int64 or float64 +// (JSON unmarshaling decodes numbers as float64). +// Returns 0 without error if the claim is absent — callers treat 0 as no access. +func NewClaimsPermissionProvider(claimsKey string) rbac.PermissionProvider { + return &claimsPermissionProvider{claimsKey: claimsKey} +} + +func (p *claimsPermissionProvider) ResolveMask(ctx context.Context, _, resource string) (rbac.PermissionMask, error) { + claims, ok := getClaims(ctx) + if !ok { + return 0, nil + } + permisos, ok := claims[p.claimsKey].(map[string]any) + if !ok { + return 0, nil + } + switch v := permisos[resource].(type) { + case int64: + return rbac.PermissionMask(v), nil + case float64: + return rbac.PermissionMask(int64(v)), nil + } + return 0, nil +} diff --git a/compliance_test.go b/compliance_test.go new file mode 100644 index 0000000..98008c5 --- /dev/null +++ b/compliance_test.go @@ -0,0 +1,33 @@ +package httpauth_test + +import ( + "context" + "time" + + httpauth "code.nochebuena.dev/go/httpauth" + "code.nochebuena.dev/go/rbac" +) + +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 +} + +type mockCache struct{} + +func (m *mockCache) Get(_ context.Context, _ string) (int64, bool, error) { return 0, false, nil } +func (m *mockCache) Set(_ context.Context, _ string, _ int64, _ time.Duration) error { + return nil +} + +// Compile-time interface satisfaction checks. +var _ httpauth.IdentityEnricher = (*mockEnricher)(nil) +var _ rbac.PermissionProvider = (*mockProvider)(nil) +var _ httpauth.Cache = (*mockCache)(nil) diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..c167bca --- /dev/null +++ b/doc.go @@ -0,0 +1,19 @@ +// Package httpauth provides provider-agnostic HTTP middleware for identity +// enrichment and RBAC authorization. +// +// Any upstream AuthMiddleware that calls [SetTokenData] to inject uid and claims +// into the request context is compatible with this package — Firebase, self-issued +// JWT, API key, etc. +// +// Typical middleware chain: +// +// r.Use(jwtauth.AuthMiddleware(signer, publicPaths, nil)) +// r.Use(httpauth.EnrichmentMiddleware(userEnricher, httpauth.WithTenantHeader("X-Tenant-ID"))) +// +// // Choose one PermissionProvider: +// claimsProvider := httpauth.NewClaimsPermissionProvider("permisos") // JWT-embedded +// cachedProvider := httpauth.NewCachedPermissionProvider(db, cache, ttl) // runtime + cache +// +// r.With(httpauth.AuthzMiddleware(provider, "orders", rbac.Permission(1))). +// Post("/orders", handler) +package httpauth diff --git a/enrichment.go b/enrichment.go new file mode 100644 index 0000000..63f73ab --- /dev/null +++ b/enrichment.go @@ -0,0 +1,67 @@ +package httpauth + +import ( + "context" + "net/http" + + "code.nochebuena.dev/go/rbac" +) + +// IdentityEnricher builds an rbac.Identity from verified token data. +// 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 any upstream AuthMiddleware +// via SetTokenData, calls enricher.Enrich, and stores the resulting rbac.Identity +// in context via rbac.SetInContext. +// Returns 401 if no uid is present (SetTokenData was not called upstream). +// 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..386bf68 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module code.nochebuena.dev/go/httpauth + +go 1.25 + +require code.nochebuena.dev/go/rbac v0.9.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..58b17cb --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +code.nochebuena.dev/go/rbac v0.9.0 h1:2fQngWIOeluIaMmo+H2ajT0NVw8GjNFJVi6pbdB3f/o= +code.nochebuena.dev/go/rbac v0.9.0/go.mod h1:LzW8tTJmdbu6HHN26NZZ3HzzdlZAd1sp6aml25Cfz5c= diff --git a/httpauth_test.go b/httpauth_test.go new file mode 100644 index 0000000..6630f26 --- /dev/null +++ b/httpauth_test.go @@ -0,0 +1,301 @@ +package httpauth + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "code.nochebuena.dev/go/rbac" +) + +// --- mocks --- + +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 +} + +type mockCache struct { + val int64 + exists bool + getErr error + setErr error +} + +func (m *mockCache) Get(_ context.Context, _ string) (int64, bool, error) { + return m.val, m.exists, m.getErr +} + +func (m *mockCache) Set(_ context.Context, _ string, val int64, _ time.Duration) error { + m.val = val + return m.setErr +} + +// testPerm is permission bit 1, used in authz tests. +const testPerm rbac.Permission = 1 + +// injectTokenData bypasses an upstream AuthMiddleware for testing downstream middleware. +func injectTokenData(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)) + }) +} + +// --- EnrichmentMiddleware --- + +func TestEnrichmentMiddleware_Success(t *testing.T) { + me := &mockEnricher{identity: rbac.NewIdentity("uid1", "Alice", "alice@example.com")} + var got rbac.Identity + inner := EnrichmentMiddleware(me)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got, _ = rbac.FromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })) + h := injectTokenData("uid1", 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 got.UID != "uid1" { + t.Errorf("want uid1, got %q", got.UID) + } +} + +func TestEnrichmentMiddleware_NoUID(t *testing.T) { + me := &mockEnricher{} + h := EnrichmentMiddleware(me)(http.HandlerFunc(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 := injectTokenData("uid1", 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_WithTenantHeader(t *testing.T) { + me := &mockEnricher{identity: rbac.NewIdentity("uid1", "", "")} + var got rbac.Identity + inner := EnrichmentMiddleware(me, WithTenantHeader("X-Tenant-ID"))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got, _ = rbac.FromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })) + h := injectTokenData("uid1", nil, inner) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Tenant-ID", "tenant-abc") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if got.TenantID != "tenant-abc" { + t.Errorf("want tenant-abc, got %q", got.TenantID) + } +} + +func TestEnrichmentMiddleware_TenantHeaderAbsent(t *testing.T) { + me := &mockEnricher{identity: rbac.NewIdentity("uid1", "", "")} + var got rbac.Identity + inner := EnrichmentMiddleware(me, WithTenantHeader("X-Tenant-ID"))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got, _ = rbac.FromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })) + h := injectTokenData("uid1", nil, inner) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if got.TenantID != "" { + t.Errorf("want empty TenantID, got %q", got.TenantID) + } +} + +// --- AuthzMiddleware --- + +func TestAuthzMiddleware_Allowed(t *testing.T) { + mp := &mockProvider{mask: rbac.PermissionMask(0).Grant(testPerm)} + inner := AuthzMiddleware(mp, "resource", testPerm)(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("uid1", "", "")) + 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)} + inner := AuthzMiddleware(mp, "resource", testPerm)(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("uid1", "", "")) + 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 := AuthzMiddleware(mp, "resource", testPerm)(http.HandlerFunc(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, "resource", testPerm)(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("uid1", "", "")) + rec := httptest.NewRecorder() + inner.ServeHTTP(rec, req.WithContext(ctx)) + if rec.Code != http.StatusForbidden { + t.Errorf("want 403, got %d", rec.Code) + } +} + +// --- ClaimsPermissionProvider --- + +func TestClaimsPermissionProvider_Float64(t *testing.T) { + p := NewClaimsPermissionProvider("permisos") + ctx := SetTokenData(context.Background(), "uid1", map[string]any{ + "permisos": map[string]any{"usuarios": float64(515)}, + }) + mask, err := p.ResolveMask(ctx, "uid1", "usuarios") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mask != 515 { + t.Errorf("want 515, got %d", mask) + } +} + +func TestClaimsPermissionProvider_Int64(t *testing.T) { + p := NewClaimsPermissionProvider("permisos") + ctx := SetTokenData(context.Background(), "uid1", map[string]any{ + "permisos": map[string]any{"roles": int64(6)}, + }) + mask, err := p.ResolveMask(ctx, "uid1", "roles") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mask != 6 { + t.Errorf("want 6, got %d", mask) + } +} + +func TestClaimsPermissionProvider_NoClaims(t *testing.T) { + p := NewClaimsPermissionProvider("permisos") + mask, err := p.ResolveMask(context.Background(), "uid1", "usuarios") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mask != 0 { + t.Errorf("want 0, got %d", mask) + } +} + +func TestClaimsPermissionProvider_ResourceAbsent(t *testing.T) { + p := NewClaimsPermissionProvider("permisos") + ctx := SetTokenData(context.Background(), "uid1", map[string]any{ + "permisos": map[string]any{}, + }) + mask, err := p.ResolveMask(ctx, "uid1", "usuarios") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mask != 0 { + t.Errorf("want 0, got %d", mask) + } +} + +// --- CachedPermissionProvider --- + +func TestCachedPermissionProvider_Hit(t *testing.T) { + inner := &mockProvider{mask: 999} + cache := &mockCache{val: 515, exists: true} + p := NewCachedPermissionProvider(inner, cache, time.Minute) + mask, err := p.ResolveMask(context.Background(), "uid1", "usuarios") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mask != 515 { + t.Errorf("want 515 from cache, got %d", mask) + } +} + +func TestCachedPermissionProvider_Miss(t *testing.T) { + inner := &mockProvider{mask: 515} + cache := &mockCache{exists: false} + p := NewCachedPermissionProvider(inner, cache, time.Minute) + mask, err := p.ResolveMask(context.Background(), "uid1", "usuarios") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mask != 515 { + t.Errorf("want 515 from inner, got %d", mask) + } + if cache.val != 515 { + t.Errorf("expected cache populated with 515, got %d", cache.val) + } +} + +func TestCachedPermissionProvider_CacheErrorFallsThrough(t *testing.T) { + inner := &mockProvider{mask: 515} + cache := &mockCache{getErr: errors.New("valkey unavailable")} + p := NewCachedPermissionProvider(inner, cache, time.Minute) + mask, err := p.ResolveMask(context.Background(), "uid1", "usuarios") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mask != 515 { + t.Errorf("want 515 from inner on cache error, got %d", mask) + } +} + +func TestCachedPermissionProvider_InnerError(t *testing.T) { + inner := &mockProvider{err: errors.New("db error")} + cache := &mockCache{exists: false} + p := NewCachedPermissionProvider(inner, cache, time.Minute) + _, err := p.ResolveMask(context.Background(), "uid1", "usuarios") + if err == nil { + t.Error("expected error from inner, got nil") + } +}