• v1.0.0 8a306abed0

    Rene Nochebuena released this 2026-05-29 10:11:48 -06:00 | 0 commits to main since this release

    v1.0.0

    code.nochebuena.dev/einherjar/auth


    Architecture Decisions Resolved

    Decision Outcome
    Root factory Excluded — auth is composition-only; no universal default stack exists (see ADR-001)
    Logger in middleware Added — routes through httputil.Error for consistent log levels and response shape (see ADR-002)
    web dependency Accepted — auth depends on web solely for httputil.Error (see ADR-003)
    Enrichment model BagEnricher chain over bag — flexible, multi-attribute enrichment without untyped context scatter (see ADR-004)
    AuthzMiddleware on provider error Fail-closed → 403 — provider failure denies access
    Cache key for multi-tenant TenantID auto-included from SecurityBag; WithCacheKey for extra dimensions
    ClaimsPermissionProvider claims format Flat map: claims[key][resource] = mask; wildcard "*" fallback
    GetClaims visibility Exported from authmw — used by ClaimsPermissionProvider and custom enrichers

    API

    Package sentinel

    import "code.nochebuena.dev/einherjar/auth"
    
    // Module identifies this package to observability systems.
    // auth is a function-only package — not registered with the launcher as a lifecycle component.
    // Register Module manually with any version registry if needed.
    var Module observability.Identifiable
    

    authmw

    import "code.nochebuena.dev/einherjar/auth/authmw"
    
    // Function type — registered via WithBagEnricher
    type BagEnricher func(bag security.SecurityBag, r *http.Request) security.SecurityBag
    
    // Interface (application implements)
    type IdentityEnricher interface {
        Enrich(ctx context.Context, uid string, claims map[string]any) (security.Identity, error)
    }
    
    // Option type
    type EnrichOpt func(*enrichConfig)
    func WithTenantHeader(header string) EnrichOpt
    func WithBagEnricher(fn BagEnricher) EnrichOpt
    
    // Integration contract — called by auth-jwt / auth-firebase after token verification
    func SetTokenData(ctx context.Context, uid string, claims map[string]any) context.Context
    
    // Accessor for raw claims stored by SetTokenData; nil if not present
    func GetClaims(ctx context.Context) map[string]any
    
    // Middleware constructors
    func EnrichmentMiddleware(logger logging.Logger, enricher IdentityEnricher, opts ...EnrichOpt) func(http.Handler) http.Handler
    func AuthzMiddleware(logger logging.Logger, provider security.PermissionProvider, resource string, required security.Permission) func(http.Handler) http.Handler
    

    EnrichmentMiddleware behaviour:

    • Missing uid in context → 401 UNAUTHENTICATED
    • Enricher error → 500 INTERNAL (logged at Error)
    • BagEnrichers run in registration order after the base Identity is built
    • Bag stored via security.SetBagInContext — downstream reads via BagFromContext or FromContext

    AuthzMiddleware behaviour:

    • No identity in context → 401 UNAUTHENTICATED
    • Provider error → 403 PERMISSION_DENIED (fail-closed)
    • !mask.Has(required) → 403 PERMISSION_DENIED

    rbac

    import "code.nochebuena.dev/einherjar/auth/rbac"
    
    // Interface (cache-valkey implements via duck typing)
    type Cache interface {
        Get(ctx context.Context, key string) (int64, bool, error)
        Set(ctx context.Context, key string, value int64, ttl time.Duration) error
    }
    
    // Option type
    type CachedOpt func(*cachedConfig)
    func WithCacheKey(fn func(security.SecurityBag, string, string) string) CachedOpt
    
    // Constructors (all return security.PermissionProvider)
    func NewClaimsPermissionProvider(claimsKey string) security.PermissionProvider
    func NewCachedPermissionProvider(inner security.PermissionProvider, cache Cache, ttl time.Duration, opts ...CachedOpt) security.PermissionProvider
    func NewChainPermissionProvider(providers ...security.PermissionProvider) security.PermissionProvider
    

    ClaimsPermissionProvider:

    • Reads claims[claimsKey][resource] from context (stored by SetTokenData)
    • Wildcard fallback: claims[claimsKey]["*"] when resource is absent
    • Handles int64, float64, json.Number — zero DB calls

    CachedPermissionProvider:

    • Default key: "rbac:{uid}:{resource}" or "rbac:{tenantID}:{uid}:{resource}"
    • TenantID from security.BagFromContext(ctx) — automatic
    • WithCacheKey overrides for additional dimensions (hardware IDs, grant codes)
    • Cache miss / error → delegates to inner provider; set errors ignored (best-effort)

    ChainPermissionProvider:

    • Iterates providers in order; returns first non-zero mask
    • Error in any provider short-circuits immediately

    Wiring example

    enricher := userservice.NewIdentityEnricher(userRepo)
    
    // Build permission chain: JWT claims fast-path, then DB with cache.
    permissions := rbac.NewChainPermissionProvider(
        rbac.NewClaimsPermissionProvider("perms"),
        rbac.NewCachedPermissionProvider(dbProvider, valkeyCache, 5*time.Minute),
    )
    
    // Provider AuthMiddleware (auth-jwt / auth-firebase) runs first.
    // Then enrichment globally:
    srv.Use(authmw.EnrichmentMiddleware(logger, enricher,
        authmw.WithTenantHeader("X-Tenant-ID"),
    ))
    
    // Per-route authorization:
    const ReadOrders = security.Permission(0)
    srv.With(authmw.AuthzMiddleware(logger, permissions, "orders", ReadOrders)).
        Get("/orders", ordersHandler)
    

    Install

    go get code.nochebuena.dev/einherjar/auth@v1.0.0
    

    Dependencies

    Module Version Role
    code.nochebuena.dev/einherjar/contracts v1.0.0 SecurityBag, Identity, Permission, PermissionMask, PermissionProvider
    code.nochebuena.dev/einherjar/core v1.0.0 xerrors, logz
    code.nochebuena.dev/einherjar/web v1.0.0 httputil.Error (centralized error handler)
    Downloads