Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 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
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.
```

3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ dev:
--jwt-public-key="$$PUBLIC_KEY" \
--admin-secret=admin \
--client-id=kbyuFDidLLm280LIwVFiazOqjO3ty8KH \
--client-secret=60Op4HFM0I8ajz0WdiStAbziZ-VFQttXuxixHHs2R7r7-CW8GR79l-mmLqMhc-Sa
--client-secret=60Op4HFM0I8ajz0WdiStAbziZ-VFQttXuxixHHs2R7r7-CW8GR79l-mmLqMhc-Sa \
--authorization-enforcement=enforcing

test:
go clean --testcache && TEST_DBS="sqlite" $(GO_TEST_ALL)
Expand Down
37 changes: 37 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,35 @@ 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,
},
)
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 +541,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
161 changes: 161 additions & 0 deletions internal/authorization/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package authorization

import (
"strings"
"sync"
"sync/atomic"
"time"
)

// cache is a local in-memory cache with TTL support.
// It uses sync.Map for concurrent access and tracks per-key expiry.
// A distributed cache (via memory_store) will be layered on top in Phase 7.
type cache struct {
ttl time.Duration
data sync.Map
expiryMap sync.Map
counters sync.Map // counter key string -> *int64 (atomic-incremented)
// validSets holds membership-style caches (known resource names, known scope names).
// Stored separately from .data so string-valued entries never collide, and so
// the typed map lookup is O(1) without string parsing.
// A zero-length set is a valid cached value meaning "DB was reachable and empty".
validSets sync.Map // cache key -> map[string]struct{}
}

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

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

// get retrieves a cached value by key. Returns the value and whether the key
// was found and still valid. Expired entries are lazily deleted on access.
// Both positive and negative cached results (authorization "true"/"false")
// follow the same lookup path, avoiding a cache-stampede on repeated
// deny evaluations for the same (principal, resource, scope).
func (c *cache) get(key string) (string, bool) {
if !c.enabled() {
return "", false
}

expiry, ok := c.expiryMap.Load(key)
if !ok {
return "", false
}
if time.Now().After(expiry.(time.Time)) {
// Lazily evict expired entry.
c.data.Delete(key)
c.expiryMap.Delete(key)
return "", false
}

val, ok := c.data.Load(key)
if !ok {
return "", false
}
return val.(string), true
}

// set stores a value in the cache with the configured TTL.
// Both "true" and "false" values are cached (negative caching)
// to prevent cache stampede on non-existent resource:scope combos.
func (c *cache) set(key string, value string) {
if !c.enabled() {
return
}
c.data.Store(key, value)
c.expiryMap.Store(key, time.Now().Add(c.ttl))
}

// deleteByPrefix removes all cached entries whose key starts with the given prefix.
// Used when admin mutations change resources, scopes, or policies to invalidate
// all related cached decisions. Iterates both the string-valued data map and the
// typed validSets map so both storage tiers are wiped in lockstep.
func (c *cache) deleteByPrefix(prefix string) {
c.data.Range(func(key, _ any) bool {
if strings.HasPrefix(key.(string), prefix) {
c.data.Delete(key)
c.expiryMap.Delete(key)
}
return true
})
c.validSets.Range(func(key, _ any) bool {
if strings.HasPrefix(key.(string), prefix) {
c.validSets.Delete(key)
c.expiryMap.Delete(key)
}
return true
})
}

// 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))
}

// 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"
}

// unmatchedCounterKey builds the map key for a (resource, scope) unmatched event.
func unmatchedCounterKey(resource, scope string) string {
return "authz:unmatched:" + resource + ":" + scope
}

// bumpUnmatched increments the unmatched-check counter for the given (resource, scope).
// Counters are in-process only; they are reset on restart. A future dashboard view
// reads them to surface "uncovered checks" to operators during rollout.
func (c *cache) bumpUnmatched(resource, scope string) {
key := unmatchedCounterKey(resource, scope)
v, _ := c.counters.LoadOrStore(key, new(int64))
atomic.AddInt64(v.(*int64), 1)
}

// unmatchedCount returns the current unmatched counter for the given (resource, scope).
// Returns 0 if the key has never been bumped.
func (c *cache) unmatchedCount(resource, scope string) int64 {
key := unmatchedCounterKey(resource, scope)
if v, ok := c.counters.Load(key); ok {
return atomic.LoadInt64(v.(*int64))
}
return 0
}
22 changes: 22 additions & 0 deletions internal/authorization/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package authorization

import (
"testing"
)

func TestCache_UnmatchedCounter_IncrementsAndReads(t *testing.T) {
c := newCache(60) // 60s TTL, irrelevant for counter which persists in the separate map
c.bumpUnmatched("orders", "read")
c.bumpUnmatched("orders", "read")
c.bumpUnmatched("users", "delete")

if got := c.unmatchedCount("orders", "read"); got != 2 {
t.Fatalf("expected orders:read count=2, got %d", got)
}
if got := c.unmatchedCount("users", "delete"); got != 1 {
t.Fatalf("expected users:delete count=1, got %d", got)
}
if got := c.unmatchedCount("nope", "nope"); got != 0 {
t.Fatalf("expected unknown count=0, got %d", got)
}
}
Loading