diff --git a/CHANGELOG.md b/CHANGELOG.md index dd8e58e..6d530e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ 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). +## [1.0.0] — 2026-05-08 + +### Added + +- `NewChainPermissionProvider(providers ...rbac.PermissionProvider) rbac.PermissionProvider` — + tries each provider in order and returns the first non-zero mask; propagates errors + immediately without consulting subsequent providers; primary use case is a JWT claims + fast-path (`ClaimsPermissionProvider`) chained with a DB-backed fallback + (`CachedPermissionProvider`) + +### Changed + +- Dependency `code.nochebuena.dev/go/rbac` bumped from v0.9.0 to v1.0.0 + +### Unchanged + +`SetTokenData`, `EnrichmentMiddleware`, `AuthzMiddleware`, `IdentityEnricher`, +`WithTenantHeader`, `Cache`, `NewClaimsPermissionProvider`, and +`NewCachedPermissionProvider` are API-compatible with v0.1.0. + +[1.0.0]: https://code.nochebuena.dev/go/httpauth/releases/tag/v1.0.0 + ## [0.1.0] - 2026-05-08 ### Added diff --git a/chain_provider.go b/chain_provider.go new file mode 100644 index 0000000..c48aa9f --- /dev/null +++ b/chain_provider.go @@ -0,0 +1,39 @@ +package httpauth + +import ( + "context" + + "code.nochebuena.dev/go/rbac" +) + +type chainPermissionProvider struct { + providers []rbac.PermissionProvider +} + +// NewChainPermissionProvider returns an rbac.PermissionProvider that tries each +// provider in order and returns the first non-zero mask. If all providers return 0, +// the result is 0. On error from any provider, the error is propagated immediately +// and subsequent providers are not consulted. +// +// Primary use case: JWT claims fast-path with DB fallback. +// +// chain := httpauth.NewChainPermissionProvider( +// httpauth.NewClaimsPermissionProvider("permisos"), // JWT claims — no DB call +// httpauth.NewCachedPermissionProvider(dbProvider, cache, 5*time.Minute), // fallback +// ) +func NewChainPermissionProvider(providers ...rbac.PermissionProvider) rbac.PermissionProvider { + return &chainPermissionProvider{providers: providers} +} + +func (c *chainPermissionProvider) ResolveMask(ctx context.Context, uid, resource string) (rbac.PermissionMask, error) { + for _, p := range c.providers { + mask, err := p.ResolveMask(ctx, uid, resource) + if err != nil { + return 0, err + } + if mask != 0 { + return mask, nil + } + } + return 0, nil +} diff --git a/go.mod b/go.mod index 386bf68..8996f98 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module code.nochebuena.dev/go/httpauth go 1.25 -require code.nochebuena.dev/go/rbac v0.9.0 +require code.nochebuena.dev/go/rbac v1.0.0 diff --git a/go.sum b/go.sum index 58b17cb..10bf86c 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +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= +code.nochebuena.dev/go/rbac v1.0.0 h1:FnsU1HU6vvwchKuZNxDa9RPIFeNwJi0vShWvHKABMws= +code.nochebuena.dev/go/rbac v1.0.0/go.mod h1:LzW8tTJmdbu6HHN26NZZ3HzzdlZAd1sp6aml25Cfz5c= diff --git a/httpauth_test.go b/httpauth_test.go index 6630f26..cade31f 100644 --- a/httpauth_test.go +++ b/httpauth_test.go @@ -299,3 +299,52 @@ func TestCachedPermissionProvider_InnerError(t *testing.T) { t.Error("expected error from inner, got nil") } } + +// --- ChainPermissionProvider --- + +func TestChainPermissionProvider_FirstNonZero(t *testing.T) { + first := &mockProvider{mask: 515} + second := &mockProvider{mask: 999} + p := NewChainPermissionProvider(first, second) + 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 first provider, got %d", mask) + } +} + +func TestChainPermissionProvider_Fallthrough(t *testing.T) { + first := &mockProvider{mask: 0} + second := &mockProvider{mask: 42} + p := NewChainPermissionProvider(first, second) + mask, err := p.ResolveMask(context.Background(), "uid1", "usuarios") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mask != 42 { + t.Errorf("want 42 from second provider, got %d", mask) + } +} + +func TestChainPermissionProvider_AllZero(t *testing.T) { + p := NewChainPermissionProvider(&mockProvider{mask: 0}, &mockProvider{mask: 0}) + mask, err := p.ResolveMask(context.Background(), "uid1", "usuarios") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mask != 0 { + t.Errorf("want 0 when all providers return 0, got %d", mask) + } +} + +func TestChainPermissionProvider_ErrorPropagates(t *testing.T) { + first := &mockProvider{err: errors.New("db error")} + second := &mockProvider{mask: 42} + p := NewChainPermissionProvider(first, second) + _, err := p.ResolveMask(context.Background(), "uid1", "usuarios") + if err == nil { + t.Error("expected error from first provider, got nil") + } +}