AuthZService: improve authz caching
grafana/grafana#103633Golden 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's full review
As posted on the PR — the complete output from FriendlyReviewer.
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. Listuses stale cache without requery: UnlikeCheck,Listdoes 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
Listrequery 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,CheckreturnsAllowed:falseimmediately and never requeries the DB. UnlikepermCache, a denial entry is never invalidated or refreshed on hit — it lives untilshortCacheTTL(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 emptyNamethat currently has no permissions caches a denial undername="",parent="", masking later grants for that action. - 🔴 pkg/services/authz/rbac/cache.go (L31)
userPermDenialCacheKeyisnamespace + ".perm_" + userUID + "_" + action + "_" + name + "_" + parentand omits identity type. Anonymous and render subjects both resolve to UID"0", so they share the denial-cache namespace. Since the denial-cache lookup inCheckruns beforegetCachedIdentityPermissions, the renderer's unconditionaldashboards:read/folders:readgrant 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: includeidentityTypein the key or skip the denial cache forTypeRenderService.
Worth checking
- 🟡 pkg/services/authz/rbac/service.go (L180)
InList, whengetCachedIdentityPermissionsreturns a cached scope map (even a stale one), it is used directly;getIdentityPermissionsis only called on a full cache miss. There is no per-resource requery as inCheck. A user who creates a dashboard and is granted permission will not see it inListresults until thepermCacheTTL expires (30s). This is inconsistent with the newCheckbehavior 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" seedspermCachewithfalsevalues, butgetScopeMapnever producesfalse— the test returnsfalseeven without the denial cache, so it doesn't prove the short-circuit; it should seedtrueand assert the denial cache still wins. (2) There is no test for the denial-cache SET path (real DB deny populates cache, secondCheckreturns fast without a DB call). (3)TestService_CacheListonly covers the cache-hit path — noListcache-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,permissionCacheUsageis 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 apermCachehit 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)
NoopCacheis a newly exported type in the publicpkg/services/authzpackage 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 TTLLocalCache. 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 threeNoopCachemethods is namedlc(reminiscent of "local cache"), which is misleading for a no-op implementation and likely copy-pasted fromLocalCache. Consider renaming the receiver (e.g.,nornop) 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 usesNoopCache, 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 toshortCacheTTLwithout 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
Checkrequeries the DB when the specific resource is absent from cached permissions. However,Listdoes 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: theCheckpath can now fetch newly created resources, butListstill 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.
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
permCachewithfalsevalues thatgetScopeMapnever 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
permCachehit 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.