Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
105309a
feat(authz): add authorization schemas and constants
lakhansamani Apr 13, 2026
42a3965
feat(authz): extend storage provider interface with 28 authorization …
lakhansamani Apr 13, 2026
2954519
feat(authz): implement SQL storage provider for authorization
lakhansamani Apr 13, 2026
6227741
feat(authz): implement MongoDB storage provider for authorization
lakhansamani Apr 13, 2026
0104b1c
feat(authz): implement ArangoDB, Cassandra, Couchbase, DynamoDB provi…
lakhansamani Apr 13, 2026
5a92eaf
feat(authz): implement authorization evaluation engine
lakhansamani Apr 13, 2026
fec92e9
feat(authz): add cache methods to memory store providers
lakhansamani Apr 13, 2026
f9f91d2
feat(authz): add CLI flags and wire authorization provider
lakhansamani Apr 14, 2026
3dc4f98
feat(authz): add authorization GraphQL schema and resolvers
lakhansamani Apr 14, 2026
e2ea245
feat(authz): implement authorization GraphQL handlers
lakhansamani Apr 14, 2026
91d544d
feat(authz): add REST check-permission endpoint
lakhansamani Apr 14, 2026
5e0646f
test(authz): add comprehensive authorization integration tests
lakhansamani Apr 14, 2026
7a94204
feat(authz): add authorization dashboard UI
lakhansamani Apr 14, 2026
fdc2b47
fix(authz): address security audit findings (H-1, H-2, C-2, C-3, M-3)
lakhansamani Apr 14, 2026
8eda5d0
chore(deps): upgrade pgx v5.5.4->v5.9.1, gorm postgres v1.5.4->v1.6.0
lakhansamani Apr 14, 2026
1967da2
feat(authz): add Prometheus metrics for check outcomes, unmatched che…
lakhansamani Apr 21, 2026
b557d4b
feat(authz): add per-key warn rate limiter for unmatched checks
lakhansamani Apr 21, 2026
ab1b12b
fix(authz): rewrite warn-limiter tests to avoid || short-circuit
lakhansamani Apr 21, 2026
7bb1fed
feat(authz): add in-process per-(resource,scope) unmatched counter
lakhansamani Apr 21, 2026
7199051
feat(authz): drop 'disabled' mode, wire metrics and rate-limited warn…
lakhansamani Apr 21, 2026
0f53c00
refactor(authz): use testSetupWithAuthzMode helper; clarify test-defa…
lakhansamani Apr 21, 2026
6d66677
feat(authz): default enforcement to permissive, migrate legacy 'disab…
lakhansamani Apr 21, 2026
bca16fd
refactor(authz): simplify NormalizeAuthzEnforcement and log startup p…
lakhansamani Apr 22, 2026
3f1c50b
fix(authz): pagination offset, fail-closed validation, typed valid-se…
lakhansamani Apr 22, 2026
5e77014
fix(authz): compensating rollback, typo-tolerant flag, string constan…
lakhansamani Apr 22, 2026
78ab37d
refactor(authz): drop phantom error return, startup probe timeout, sa…
lakhansamani Apr 22, 2026
a69ff1e
fix(authz): explicit deny semantics, role-aware cache key, atomic Upd…
lakhansamani Apr 30, 2026
52764bf
fix(authz,dashboard): wire typo warn, SPA deep-link fallback, deny at…
lakhansamani May 4, 2026
75cc6e4
test(integration): fix flaky TestSession and noisy storage Close in c…
lakhansamani May 12, 2026
4e879db
fix(session): synchronize session rollover and tighten security window
lakhansamani May 12, 2026
3d11699
observability(authz): audit authz CRUD, cover all metric labels, refr…
lakhansamani May 12, 2026
7ae2a5d
feat(authz): validate policy targets against configured roles
lakhansamani May 18, 2026
fa2d064
feat(authz): add required_permissions, drop check_permission surface,…
lakhansamani May 18, 2026
b3ef1c3
fix(http): allow GraphiQL CDN scripts via scoped CSP for /playground
lakhansamani May 18, 2026
313e87b
metrics(authz): add required_permissions_checks_total counter
lakhansamani May 18, 2026
e2782a1
metrics(authz): polish required_permissions counter doc comments
lakhansamani May 18, 2026
0c6e80c
feat(authz): record per-endpoint required_permissions outcome metric
lakhansamani May 18, 2026
85cf83b
fix(authz): re-login in metrics subtest to avoid stale access token
lakhansamani May 18, 2026
639437e
docs(authz): clarify required_permissions helper invariants and test …
lakhansamani May 18, 2026
71f4569
refactor(authz): collapse evaluator to enforcing-only path
lakhansamani May 18, 2026
6e832f1
metrics(authz): drop mode label, collapse unmatched_allowed|denied to…
lakhansamani May 18, 2026
505a821
test(authz): purge permissive-mode test fixtures (Task 7 pull-forward)
lakhansamani May 18, 2026
e3472cc
config(authz): remove AuthorizationEnforcement field, deprecate CLI flag
lakhansamani May 18, 2026
f7b09f1
constants(authz): remove enforcement mode constants
lakhansamani May 19, 2026
2aa818d
docs(authz): document enforcement removal and required_permissions me…
lakhansamani May 19, 2026
309bc16
chore(authz): drop the never-shipped --authorization-enforcement flag…
lakhansamani May 19, 2026
756950e
refactor(authz): delegate decision cache to memory_store provider
lakhansamani May 19, 2026
3f676d0
fix(authz): guard memory_store IPC when cache is disabled and narrow …
lakhansamani May 19, 2026
a2d7aba
test(storage): add FGA CRUD coverage for Resource/Scope/Policy/Permis…
lakhansamani May 20, 2026
fa24b03
test(authz): cover my_permissions, cache invalidation, list pagination
lakhansamani May 20, 2026
d89eea5
chore(make): drop --authorization-enforcement from make dev
lakhansamani May 20, 2026
c61016c
fix(storage): FGA parity for MongoDB, ArangoDB, DynamoDB, Couchbase
lakhansamani May 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **Fine-grained authorization is always enforcing.** The previously-proposed `--authorization-enforcement` flag and its dual `permissive`/`enforcing` modes were removed before shipping. `required_permissions` checks against an unmatched or denied `(resource, scope)` pair return `unauthorized`. There is no permissive "log but allow" mode.
- **Authz Prometheus shape**: `authorizer_authz_checks_total` has only a `result` label (`allowed|denied|unmatched|error`); `authorizer_authz_unmatched_total` has no labels.

### Added

- **`authorizer_required_permissions_checks_total{endpoint, outcome}`**: per-endpoint Prometheus counter for FGA adoption + enforcement signal. Outcomes are `granted`, `denied`, `not_requested`, `error`. Endpoints are `session`, `validate_session`, `validate_jwt_token`. Alert on `outcome="error"` rising; it indicates a storage/validation failure preventing checks from completing.
- **`--rate-limit-fail-closed`**: when the rate-limit backend returns an error, respond with `503` instead of allowing the request (default remains fail-open).
- **`--metrics-host`**: bind address for the dedicated `/metrics` listener (default `127.0.0.1`). Use `0.0.0.0` when a scraper on another host/pod must reach the metrics port over the network; keep the metrics port off public ingress.
- **OIDC Discovery — `grant_types_supported` includes `implicit`**: honestly reflects that `/authorize` accepts `response_type=token` and `response_type=id_token`.
Expand Down
52 changes: 52 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,3 +502,55 @@ The v2 repo ships with a `Makefile` that wraps the most common development and b
- **authorizer-react:** [github.com/authorizerdev/authorizer-react](https://github.com/authorizerdev/authorizer-react) (v2.0.0-rc.1, see CHANGELOG.md)
- **Docs:** [docs.authorizer.dev](https://docs.authorizer.dev/) (to be updated for v2)

---

## Fine-Grained Authorization (FGA) — new in v2

> v1 had no FGA. This section is a quick-start for the new feature, not a migration step. Skip it if you don't plan to use `required_permissions`.

### Model

v2 ships a Keycloak-inspired four-pillar authorization engine:

| Concept | Purpose |
| ---------- | ----------------------------------------------------------------------- |
| Resource | A noun you protect (`docs`, `billing`). |
| Scope | An action on a resource (`read`, `write`). |
| Policy | A principal selector — by role, user ID, or attribute. |
| Permission | Binds `(resource, scopes, policies, decision_strategy)` together. |

Authorization is **always enforcing**. A `required_permissions` check against an undefined or denied `(resource, scope)` returns `unauthorized` — there is no permissive "log but allow" mode.

### Adoption pattern

Three GraphQL operations accept an optional `required_permissions: [PermissionInput!]` field:

- `session`
- `validate_session`
- `validate_jwt_token`

Pre-existing callers that don't pass the field see no behavior change. **Define the policy graph (resources → scopes → policies → permissions) via the dashboard or admin GraphQL mutations before any caller starts sending `required_permissions`.** Otherwise the call returns `unauthorized`.

### Observability

Per-endpoint adoption + denial signal:

```promql
sum by (endpoint, outcome) (rate(authorizer_required_permissions_checks_total[5m]))
```

| `outcome` | What it means | Operator action |
| --------------- | ------------------------------------------------------------------ | -------------------------------------------------------------- |
| `granted` | All requested permissions allowed. | Healthy baseline. |
| `denied` | One or more requested permissions denied. | Investigate policy gap or attacker probe. |
| `not_requested` | Caller omitted `required_permissions`. | Track adoption rate per endpoint. |
| `error` | `CheckPermission` errored (storage / validation failure). | **Alert.** Should sit at zero — non-zero means infra problem. |

### Startup probe

If the server boots with zero permissions configured, you'll see a single warn line:

```
authz: 0 permissions configured — all authorization checks will DENY. Seed permissions via the dashboard or admin GraphQL mutations.
```

38 changes: 38 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import (

"github.com/authorizerdev/authorizer/internal/audit"
"github.com/authorizerdev/authorizer/internal/authenticators"
"github.com/authorizerdev/authorizer/internal/authorization"
"github.com/authorizerdev/authorizer/internal/config"
"github.com/authorizerdev/authorizer/internal/constants"
"github.com/authorizerdev/authorizer/internal/email"
"github.com/authorizerdev/authorizer/internal/events"
"github.com/authorizerdev/authorizer/internal/graph/model"
"github.com/authorizerdev/authorizer/internal/http_handlers"
"github.com/authorizerdev/authorizer/internal/memory_store"
"github.com/authorizerdev/authorizer/internal/metrics"
Expand Down Expand Up @@ -235,6 +237,11 @@ func init() {
// Back-channel logout (OIDC BCL 1.0)
f.StringVar(&rootArgs.config.BackchannelLogoutURI, "backchannel-logout-uri", "", "URL to POST a signed logout_token to when users log out successfully. Leave empty (default) to disable back-channel logout notifications. See OIDC Back-Channel Logout 1.0.")

// Fine-grained authorization flags
f.Int64Var(&rootArgs.config.AuthorizationCacheTTL, "authorization-cache-ttl", 300, "Cache TTL in seconds for permission checks (0 to disable)")
f.BoolVar(&rootArgs.config.IncludePermissionsInToken, "include-permissions-in-token", false, "Include permissions in JWT access tokens")
f.BoolVar(&rootArgs.config.AuthorizationLogAllChecks, "authorization-log-all-checks", false, "Audit log all permission checks, not just denials")

// Deprecated flags
f.MarkDeprecated("database_url", "use --database-url instead")
f.MarkDeprecated("database_type", "use --database-type instead")
Expand Down Expand Up @@ -455,6 +462,36 @@ func runRoot(c *cobra.Command, args []string) {
}
defer rateLimitProvider.Close()

// Authorization provider
authorizationProvider, err := authorization.New(
&authorization.Config{
CacheTTL: rootArgs.config.AuthorizationCacheTTL,
},
&authorization.Dependencies{
Log: &log,
StorageProvider: storageProvider,
MemoryStoreProvider: memoryStoreProvider,
},
)
if err != nil {
log.Fatal().Err(err).Msg("failed to create authorization provider")
}

// Check once at startup whether any permissions exist. If zero, emit a
// loud warn so operators don't lock themselves out in prod. Bounded
// context prevents a hung DB at boot from blocking startup indefinitely.
probeCtx, probeCancel := context.WithTimeout(context.Background(), 5*time.Second)
_, pr, lerr := storageProvider.ListPermissions(probeCtx, &model.Pagination{Limit: 1, Page: 1})
probeCancel()
switch {
case lerr != nil:
log.Warn().Err(lerr).Msg("authz: failed to probe permission count at startup; authorization is enforcing")
case pr != nil && pr.Total == 0:
log.Warn().Msg("authz: 0 permissions configured — all authorization checks will DENY. Seed permissions via the dashboard or admin GraphQL mutations.")
default:
log.Info().Msg("authz: enforcing; unmatched CheckPermission calls will be DENIED.")
}

// SMS provider
smsProvider, err := sms.New(&rootArgs.config, &sms.Dependencies{
Log: &log,
Expand Down Expand Up @@ -505,6 +542,7 @@ func runRoot(c *cobra.Command, args []string) {
TokenProvider: tokenProvider,
OAuthProvider: oauthProvider,
RateLimitProvider: rateLimitProvider,
AuthorizationProvider: authorizationProvider,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create http provider")
Expand Down
9 changes: 5 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ require (
golang.org/x/time v0.15.0
gopkg.in/mail.v2 v2.3.1
gorm.io/driver/mysql v1.5.2
gorm.io/driver/postgres v1.5.4
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlserver v1.5.2
gorm.io/gorm v1.25.5
gorm.io/gorm v1.25.10
)

require (
Expand Down Expand Up @@ -88,13 +88,14 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.5.4 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/libsql/libsql-client-go v0.0.0-20231026052543-fce76c0f39a7 // indirect
github.com/libsql/sqlite-antlr4-parser v0.0.0-20230802215326-5cb5bb604475 // indirect
Expand Down
16 changes: 8 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -210,10 +210,10 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8=
github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
Expand Down Expand Up @@ -481,14 +481,14 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlserver v1.5.2 h1:+o4RQ8w1ohPbADhFqDxeeZnSWjwOcBnxBckjTbcP4wk=
gorm.io/driver/sqlserver v1.5.2/go.mod h1:gaKF0MO0cfTq9Q3/XhkowSw4g6nIwHPGAs4hzKCmvBo=
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.2-0.20230610234218-206613868439/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
Expand Down
91 changes: 91 additions & 0 deletions internal/authorization/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package authorization

import (
"sync"
"time"
)

// cache holds in-process membership caches that don't fit the string-only
// memory_store.Provider API. Decision results (allowed / denied for a
// (principal, resource, scope)) live in memory_store instead — see
// evaluator.go.
//
// validSets caches the bounded set of known resource and scope names so
// validateResourceExists / validateScopeExists avoid a storage round-trip
// on every CheckPermission call. A zero-length set is a valid cached value
// meaning "DB was reachable and empty".
type cache struct {
ttl time.Duration
validSets sync.Map // cache key -> map[string]struct{}
expiryMap sync.Map // cache key -> time.Time
}

// newCache creates a new local membership cache. If ttlSeconds is 0,
// caching is disabled and getValidSet always reports miss.
func newCache(ttlSeconds int64) *cache {
return &cache{
ttl: time.Duration(ttlSeconds) * time.Second,
}
}

// enabled reports whether caching is active (TTL > 0).
func (c *cache) enabled() bool {
return c.ttl > 0
}

// getValidSet returns the cached membership set for the given key.
// The second return value reports whether the cache had an entry at all.
// Callers must not mutate the returned map.
func (c *cache) getValidSet(key string) (map[string]struct{}, bool) {
if !c.enabled() {
return nil, false
}
expiry, ok := c.expiryMap.Load(key)
if !ok {
return nil, false
}
if time.Now().After(expiry.(time.Time)) {
c.validSets.Delete(key)
c.expiryMap.Delete(key)
return nil, false
}
v, ok := c.validSets.Load(key)
if !ok {
return nil, false
}
return v.(map[string]struct{}), true
}

// setValidSet stores a membership set under the given key with the
// configured TTL.
func (c *cache) setValidSet(key string, set map[string]struct{}) {
if !c.enabled() {
return
}
c.validSets.Store(key, set)
c.expiryMap.Store(key, time.Now().Add(c.ttl))
}

// invalidateValidSets evicts all cached validSets entries. Called when an
// admin mutation may have changed the resource or scope catalog. No-op when
// caching is disabled — symmetric with setValidSet/getValidSet.
func (c *cache) invalidateValidSets() {
if !c.enabled() {
return
}
c.validSets.Range(func(key, _ any) bool {
c.validSets.Delete(key)
c.expiryMap.Delete(key)
return true
})
}

// validResourcesKey returns the cache key for the set of known resource names.
func validResourcesKey() string {
return "authz:valid_resources"
}

// validScopesKey returns the cache key for the set of known scope names.
func validScopesKey() string {
return "authz:valid_scopes"
}
77 changes: 77 additions & 0 deletions internal/authorization/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package authorization

import (
"testing"
)

func TestCache_ValidSets(t *testing.T) {
t.Run("miss when caching disabled (TTL=0)", func(t *testing.T) {
c := newCache(0)
_, ok := c.getValidSet("authz:valid_resources")
if ok {
t.Fatal("expected cache miss when TTL is 0")
}
})

t.Run("miss before any set", func(t *testing.T) {
c := newCache(60)
_, ok := c.getValidSet("authz:valid_resources")
if ok {
t.Fatal("expected cache miss for unset key")
}
})

t.Run("hit after set", func(t *testing.T) {
c := newCache(60)
set := map[string]struct{}{"orders": {}, "users": {}}
c.setValidSet("authz:valid_resources", set)

got, ok := c.getValidSet("authz:valid_resources")
if !ok {
t.Fatal("expected cache hit after setValidSet")
}
if len(got) != 2 {
t.Fatalf("expected 2 entries, got %d", len(got))
}
if _, found := got["orders"]; !found {
t.Error("expected 'orders' in cached set")
}
})

t.Run("empty set is a valid cache hit", func(t *testing.T) {
c := newCache(60)
c.setValidSet("authz:valid_resources", map[string]struct{}{})

got, ok := c.getValidSet("authz:valid_resources")
if !ok {
t.Fatal("expected cache hit for empty set (DB reachable but empty)")
}
if len(got) != 0 {
t.Fatalf("expected 0 entries, got %d", len(got))
}
})

t.Run("invalidateValidSets clears all entries", func(t *testing.T) {
c := newCache(60)
c.setValidSet(validResourcesKey(), map[string]struct{}{"orders": {}})
c.setValidSet(validScopesKey(), map[string]struct{}{"read": {}})

c.invalidateValidSets()

if _, ok := c.getValidSet(validResourcesKey()); ok {
t.Error("expected resources set to be evicted after invalidateValidSets")
}
if _, ok := c.getValidSet(validScopesKey()); ok {
t.Error("expected scopes set to be evicted after invalidateValidSets")
}
})

t.Run("setValidSet is no-op when TTL=0", func(t *testing.T) {
c := newCache(0)
c.setValidSet(validResourcesKey(), map[string]struct{}{"orders": {}})
_, ok := c.getValidSet(validResourcesKey())
if ok {
t.Fatal("expected no cache storage when TTL is 0")
}
})
}
Loading