feat(httpauth): initial release — provider-agnostic HTTP auth middleware
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.
This commit is contained in:
1
.gitea/CODEOWNERS
Normal file
1
.gitea/CODEOWNERS
Normal file
@@ -0,0 +1 @@
|
||||
* @go/CoreDevelopers @go/Agents
|
||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -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
|
||||
29
CHANGELOG.md
Normal file
29
CHANGELOG.md
Normal file
@@ -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
|
||||
111
CLAUDE.md
Normal file
111
CLAUDE.md
Normal file
@@ -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.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
||||
112
README.md
Normal file
112
README.md
Normal file
@@ -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.
|
||||
25
auth.go
Normal file
25
auth.go
Normal file
@@ -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
|
||||
}
|
||||
32
authz.go
Normal file
32
authz.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
44
cache.go
Normal file
44
cache.go
Normal file
@@ -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
|
||||
}
|
||||
39
claims_provider.go
Normal file
39
claims_provider.go
Normal file
@@ -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
|
||||
}
|
||||
33
compliance_test.go
Normal file
33
compliance_test.go
Normal file
@@ -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)
|
||||
19
doc.go
Normal file
19
doc.go
Normal file
@@ -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
|
||||
67
enrichment.go
Normal file
67
enrichment.go
Normal file
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module code.nochebuena.dev/go/httpauth
|
||||
|
||||
go 1.25
|
||||
|
||||
require code.nochebuena.dev/go/rbac v0.9.0
|
||||
2
go.sum
Normal file
2
go.sum
Normal file
@@ -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=
|
||||
301
httpauth_test.go
Normal file
301
httpauth_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user