AuthZService: improve authz caching

grafana/grafana#103633
Golden recall: 100% Extra findings: 6

Golden Comments (2/2 found)

# Comment Severity Status FriendlyReviewer Detail
#1 The Check operation exhibits asymmetric cache trust logic: cached permission grants are trusted and returned immediately, but cached denials from the same permission cache are ignored, leading to a fresh database lookup. This allows stale cached grants to provide access to revoked resources, posing a security risk. HIGH ✓ Found "Also note a permCache hit that denies the resource and falls through to the DB is counted as "false" even though the permission cache was hit — semantics are now ambiguous." (service.go L118). Matched by mechanism: same asymmetric trust (grants served from cache, denials re-checked in DB) — but the security risk of stale grants granting access to revoked resources is not formulated
#2 The test comment says the cached permissions 'allow access', but the map stores false for dashboards:uid:dash1, so checkPermission will still treat this scope as not allowed. LOW ✓ Found ""Should deny on explicit cache deny entry" seeds permCache with false values, but getScopeMap never produces false — the test returns false even without the denial cache, so it doesn't prove the short-circuit." (service_test.go L963)

Supplementary Findings (6 findings)

Finding File Severity Legitimate?
Denial entries cached up to shortCacheTTL (30s) are never revalidated or invalidated on hit — re-introduces the exact staleness the MR was meant to fix service.go:117 HIGH ✓ Directly contradicts the PR's stated goal: a user granted after a denial stays denied for up to 30s
Denial cache key omits identity type — anonymous and render subjects both resolve to UID "0", so a cached anonymous denial blocks rendering of public dashboards for up to 30s cache.go:31 HIGH ✓ Genuine cross-subject cache collision with user-visible breakage; arguably the most severe finding on the PR
List uses stale cached scope maps without the per-resource DB requery that Check has — newly created resources stay missing from listings until TTL expiry service.go:180 MEDIUM ✓ Real inconsistency with the "fetch newly created dashboards/folders" goal of the PR
NoopCache newly exported in the public pkg/services/authz package with a single internal usage rbac.go:239 LOW ✓ API-surface pollution; borderline, exporting may be intentional
In-proc client path disables client caching (NoopCache) while the remote path keeps a 30s TTL LocalCache — asymmetry undocumented rbac.go:103 LOW ✓ Intentional per the PR, but an undocumented divergence future readers could "fix" back
Receiver named lc (reminiscent of "local cache") on all three NoopCache methods rbac.go:241 LOW ✓ Borderline: naming nit only

Honest Analysis

FriendlyReviewer found all 2 golden comments of this PR — recall 100%. The LOW test golden (service_test.go L963) is a direct match: the "deny" test seeds permCache with false values that getScopeMap never produces, so the test cannot prove the short-circuit it claims to verify. Same test, same essence, same impossibility.

The HIGH golden (#1) is the loosest match of the two. FriendlyReviewer identified the same asymmetric mechanism at service.go L118 — a permCache hit that denies a resource falls through to the DB while grants are served from the cache — but framed it as metric ambiguity, not as the security risk of stale cached grants providing access to revoked resources. The mechanism is found; the consequence the dataset cares about is not formulated. It is counted as found with that nuance clearly documented.

The six supplementary findings include the two most severe issues on the PR: the stale denial cache (service.go L117), which re-introduces the exact staleness the MR was meant to fix — a user granted permission after a denial stays denied for up to 30s — and the identity-type-less denial key (cache.go L31), where anonymous and render subjects share UID "0" and a cached anonymous denial can break rendering of public dashboards. Both are server-side correctness bugs the dataset never credits.

On balance, this is a strong result: both goldens found, and the review's own reds are arguably more user-visible than the scored HIGH. The honest caveat is that on golden #1 the security consequence is inferred rather than stated, and a couple of the extras (exported NoopCache, receiver naming) are cosmetic. But nothing in the supplementary set is wrong, and the two reds are real.

FriendlyReviewer's full review

As posted on the PR — the complete output from FriendlyReviewer.

friendly-reviewer bot commented now

Hi there 👋

🌥️ Tech 🌤️ Feat
2 high, 2 medium, 4 low 1 ok, 3 mitigated

This MR introduces significant caching changes to the authz service to fix a staleness bug where newly created dashboards/folders are not immediately accessible to their creators. The client-side in-proc path correctly disables the authLib cache (using a NoopCache), and the server side adds a denial cache and a requery-on-miss path for Check. However, the review found several correctness issues that undermine the PR's stated goal:

  • Stale denial cache: A denial entry is cached for up to shortCacheTTL (30s) and is never revalidated on hit. This re-introduces the exact staleness the MR is meant to fix — a user granted permission after a denial will still be denied for up to 30s.
  • Renderer denial collision: The denial cache key omits identity type. Anonymous users and the render service both resolve to UID "0", so a cached anonymous denial can deny the renderer (which is unconditionally granted read) for public dashboards, because the denial cache is checked before the renderer's always-grant.
  • List uses stale cache without requery: Unlike Check, List does not requery the DB on a stale cache hit, so newly created resources remain missing from listing results until TTL expiration.
  • Metric ambiguity: Denial-cache hits are counted against permissionCacheUsage, muddying monitoring.
  • Insufficient test coverage: The new denial-cache SET/short-circuit paths and List requery fallback are not actually tested; the "deny" test uses an impossible cached state.

On the client-side API, the changes are backward compatible; minor concerns are an unnecessarily exported NoopCache, an asymmetric caching behavior between in-proc and remote paths (intentional, but should be documented), and a misleading receiver name. Overall, the blocking issues on the server-side caching logic require fixes before merge.


These might need a close look

  • 🔴 pkg/services/authz/rbac/service.go (L117)
    On a denial-cache hit, Check returns Allowed:false immediately and never requeries the DB. Unlike permCache, a denial entry is never invalidated or refreshed on hit — it lives until shortCacheTTL (30s). This re-introduces the staleness the MR is meant to fix: a user denied before being granted (e.g., after creating a dashboard or an admin grants a parent folder) will be denied for up to 30s. Document this tradeoff and ideally wire up invalidation on permission changes. Edge case: an action-level check with empty Name that currently has no permissions caches a denial under name="", parent="", masking later grants for that action.
  • 🔴 pkg/services/authz/rbac/cache.go (L31)
    userPermDenialCacheKey is namespace + ".perm_" + userUID + "_" + action + "_" + name + "_" + parent and omits identity type. Anonymous and render subjects both resolve to UID "0", so they share the denial-cache namespace. Since the denial-cache lookup in Check runs before getCachedIdentityPermissions, the renderer's unconditional dashboards:read/folders:read grant is never consulted when a denial is cached. A cached anonymous denial on a dashboard can break rendering of public dashboards for up to 30s. Fix: include identityType in the key or skip the denial cache for TypeRenderService.

Worth checking

  • 🟡 pkg/services/authz/rbac/service.go (L180)
    In List, when getCachedIdentityPermissions returns a cached scope map (even a stale one), it is used directly; getIdentityPermissions is only called on a full cache miss. There is no per-resource requery as in Check. A user who creates a dashboard and is granted permission will not see it in List results until the permCache TTL expires (30s). This is inconsistent with the new Check behavior and undermines the "fetch newly created dashboards/folders" goal for listing. Reconcile the TTL/requery policy or document eventual consistency.
  • 🟡 pkg/services/authz/rbac/service_test.go (L963)
    The new denial-cache behavior is not properly tested: (1) "Should deny on explicit cache deny entry" seeds permCache with false values, but getScopeMap never produces false — the test returns false even without the denial cache, so it doesn't prove the short-circuit; it should seed true and assert the denial cache still wins. (2) There is no test for the denial-cache SET path (real DB deny populates cache, second Check returns fast without a DB call). (3) TestService_CacheList only covers the cache-hit path — no List cache-miss/requery fallback test. (4) No test covers the anonymous/render UID collision or the stale-denial-after-grant scenario.
Small things (take or leave)
  • 🔵 pkg/services/authz/rbac/service.go (L118)
    On a denial-cache hit, permissionCacheUsage is incremented with label "true", conflating denial-cache hits with permission-cache hits. This inflates the permission-cache hit-rate metric and muddies monitoring. Consider a separate counter (or label) for denial-cache hits/misses. Also note a permCache hit that denies the resource and falls through to the DB is counted as "false" even though the permission cache was hit — semantics are now ambiguous.
  • 🔵 pkg/services/authz/rbac.go (L239)
    NoopCache is a newly exported type in the public pkg/services/authz package but is only used internally (single usage at line 103). Exporting it pollutes the public API surface with no benefit since there are no external callers. Recommend unexporting it (noopCache), or add a doc comment if it must stay exported.
  • 🔵 pkg/services/authz/rbac.go (L103)
    The in-proc client path now disables client-side caching entirely (NoopCache) while the remote path (newRemoteRBACClient, ~line 162) still uses a 30s TTL LocalCache. This asymmetry is intentional per the PR (server-side caches replace the client cache), but it should be explicitly documented so future readers don't "fix" it back and re-introduce the original staleness bug.
  • 🔵 pkg/services/authz/rbac.go (L241)
    The receiver on all three NoopCache methods is named lc (reminiscent of "local cache"), which is misleading for a no-op implementation and likely copy-pasted from LocalCache. Consider renaming the receiver (e.g., n or nop) for clarity.

✅ Feature-level checklist

Looks good

  • Remove the use of authLib cache when running authZ service in-proc
    The in-proc client path now uses NoopCache, which always misses, effectively disabling the client-side authLib cache. Backward compatible with existing exported signatures.

Partially covered

  • ⚠️ Add a denial cache to return quickly when we know a user does not have permission on a specific resource
    The denial cache is implemented and short-circuits on hit. However, it serves stale denials up to shortCacheTTL without revalidation, and the key omits identity type, allowing a cached anonymous denial to block the renderer. These correctness issues must be addressed.
  • ⚠️ If the permission denial cache is not hit and the requested resource is not found in cached permissions, requery permissions from the DB
    Check requeries the DB when the specific resource is absent from cached permissions. However, List does not requery on a stale cache hit, so newly created resources remain missing from listing results until TTL expiry — inconsistent with the stated requirement.
  • ⚠️ Allow users to fetch newly created dashboards and folders (fixed by the above changes)
    Partially achieved: the Check path can now fetch newly created resources, but List still uses stale cached permissions without requery, and the stale denial cache can mask new grants for up to 30s. These gaps undermine the core goal and need resolution.