CVE-2026-11817Medium (CVSS 5.3)Fixed6 min read
A missing pair of parentheses let a Grafana Org Admin read other organizations' dashboard permissions
Grafana's permissions search built its WHERE clause by string concatenation. The organization filter came first, and thirty lines later an OR branch for action sets was appended without grouping, so SQL precedence detached that branch from the organization check. An Org Admin of one organization calling the search API received dashboard and folder UIDs from other organizations, together with which users held which access on them. Fixed in Grafana 12.3.9, 12.4.6, 13.0.4 and 13.1.1.
- Vendor
- Grafana Labs
- Product
- Grafana OSS and Enterprise
- Weakness
- CWE-863
- Affected
- 11.2.0 to 11.6.16, 12.2.0 to 12.2.10, 12.3.0 to 12.3.8, 12.4.0 to 12.4.5, 13.0.0 to 13.0.3, 13.1.0
- Fixed in
- 12.3.9, 12.4.6, 13.0.4, 13.1.1
- Advisory
- GHSA-72xj-fpfg-99ww
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
Disclosure timeline
May 19, 2026
Reported privately to security@grafana.com, PGP-encrypted. Grafana confirmed it had decrypted the report on May 27 and opened an internal security ticket on June 5.
Jun 6, 2026
At Grafana's suggestion, also filed through its vulnerability disclosure program on Intigriti.
Jun 9, 2026
CVE-2026-11817 reserved by Grafana Labs, acting as its own CNA.
Jul 15, 2026
Triage reproduced the proof of concept and forwarded the report to Grafana.
Jul 17, 2026
Grafana published its advisory, scored Medium 5.3 under CVSS 4.0. Its fixed-versions field read >=13.1.1 only.
Jul 21, 2026
12.3.9, 12.4.6, 13.0.4 and 13.1.1 released with the fix.
Jul 23, 2026
Patches forward-ported to main in grafana/grafana#129076 as VUL-2026-0094, including a second cross-org fix in the basic-role join.
Aug 17, 2026
CVE record published to MITRE. GHSA-72xj-fpfg-99ww mirrored it the next day; NVD lists it as undergoing analysis.
Sep 14, 2026
Grafana added the finder credit to the CVE record, an acknowledgement on the advisory page linking to github.com/Har1sh-k, and a Hall of Fame entry. The same change corrected the advisory's fixed versions to list all four lines: >=12.3.9 <12.4.0, >=12.4.6 <13.0.0, >=13.0.4 <13.1.0 and >=13.1.1.
Organizations are Grafana's isolation boundary. A single Grafana instance can host several of them, each with its own users, dashboards, folders, and permissions, and a user who is an Org Admin in one organization is supposed to see nothing of the others. Hosting providers and platform teams lean on that boundary to put unrelated tenants on one server.
The access-control API has a search endpoint that answers the question "which users hold which permissions in my organization." That endpoint answered for other organizations too.
The bug
GET /api/access-control/users/permissions/search accepts an actionPrefix, an action, a scope, and an optional user identifier. It is gated by users.permissions:read, which the Org Admin basic role holds with the scope users:*. The handler passes the options down to SearchUsersPermissions in the access-control store, which assembles one SQL statement by string concatenation.
The statement starts well. Every assignment source (direct user roles, team roles, basic roles, server admins) is unioned into up, joined to the permission table, and filtered to the caller's organization plus the global one:
q := `
SELECT user_id, p.action, p.scope
FROM ( ... ) AS up ` + roleNameFilterJoin + `
INNER JOIN permission AS p ON up.role_id = p.role_id
WHERE (up.org_id = ? OR up.org_id = ?)
`
params = append(params, orgID, accesscontrol.GlobalOrgID)
Then the prefix filter is appended. Grafana stores dashboard and folder grants as action sets (dashboards:edit, folders:view, and so on), and a prefix search for dashboards: needs to match every set that contains a dashboards: action as well as the individual actions, so the service resolves the prefix into a list of set names (the three dashboard sets, plus folder edit and admin, which carry dashboards:create) and the store adds an OR for them:
if options.ActionPrefix != "" {
q += ` AND p.action LIKE ?`
params = append(params, options.ActionPrefix+"%")
if len(options.ActionSets) > 0 {
q += ` OR p.action IN ( ? ` + strings.Repeat(", ?", len(options.ActionSets)-1) + ")"
for _, a := range options.ActionSets {
params = append(params, a)
}
}
}
No parentheses. For actionPrefix=dashboards: in organization 2, the rendered clause is:
WHERE (up.org_id = 2 OR up.org_id = 0)
AND p.action LIKE 'dashboards:%'
OR p.action IN ('dashboards:view', 'dashboards:edit', 'dashboards:admin',
'folders:edit', 'folders:admin')
AND (r.name LIKE 'managed:%' OR r.name LIKE 'externalservice:%')
AND binds tighter than OR, so the database reads that as two alternatives:
((up.org_id = 2 OR up.org_id = 0) AND p.action LIKE 'dashboards:%')
OR (p.action IN (...) AND (r.name LIKE 'managed:%' OR ...))
The second alternative has no organization in it. Every managed-role permission row whose action is a dashboard or folder action set, in every organization on the instance, satisfies it. And since Grafana records a dashboard share as exactly such a row (dashboards:edit on dashboards:uid:abc123 inside managed:users:7:permissions), the second alternative is precisely the set of dashboard and folder grants across the whole server.
What comes back
The service merges the store's rows with each user's basic role and expands the action sets back into individual actions, then the handler groups by user and action:
{
"7": {
"dashboards:read": ["dashboards:uid:k3Qx9pL"],
"dashboards:write": ["dashboards:uid:k3Qx9pL"],
"dashboards:create": ["folders:uid:aB2cD3e"]
}
}
The merge step keeps only users the caller is allowed to see, which for an Org Admin means every member of their own organization plus every server admin. What leaks is those users' grants everywhere else. In practice that set is not small. Grafana gives the creator of a dashboard or folder an explicit admin grant on it at creation time, so any dashboard that a server admin or a shared account has ever created in another organization shows up here by UID, along with everyone else who has been granted access to it. In a hosting setup, the operator's staff are members of every tenant organization, and the operator's own organization is the one whose Org Admin gets to read the map.
Grafana's summary of the exposure is accurate: dashboard and folder UIDs, and per-user permission and scope mappings. No dashboard contents, no queries, no datasource credentials. A UID is not a secret. The list of which users hold which access on which resources in a tenant you are not a member of is the thing organization isolation exists to hide.
The fix
grafana/grafana#129076 groups the two conditions so the surrounding filters apply to both, and says why:
// The action-prefix and action-set conditions must be grouped so the
// preceding org filter (and trailing role-prefix filter) always apply.
// Without the parentheses, SQL precedence detaches the action-set OR
// branch from the org filter and leaks permissions across orgs.
q += ` AND (p.action LIKE ?`
...
q += `)`
The same patch closes a second cross-organization path I had not reported. builtin_role holds one row per organization for each basic role, each pointing at that organization's managed role, and the union's basic-role branch joined it to org_user on role name alone. A user's Editor membership in organization 2 matched the Editor row of every organization, so the org_id in the union was the caller's while the role_id belonged to someone else. The join now also requires the organization to match, or the role to be global. Grafana tracked the pair as VUL-2026-0094.
Affected and fixed versions
Grafana's advisory lists the fix in 12.3.9, 12.4.6, 13.0.4 and 13.1.1, all released on 2026-07-21. The CVE record marks 11.2.0 through 11.6.16, 12.2.0 through 12.2.10, 12.3.0 through 12.3.8, 12.4.0 through 12.4.5, 13.0.0 through 13.0.3, and 13.1.0 as affected, for both Grafana OSS and Grafana Enterprise. The advisory lists no fixed version on the 12.2 or 11.6 lines; both are past end of life for public releases, so no public build carries the fix. The patches were forward-ported to main on 2026-07-23 in grafana/grafana#129076, two days after the releases.
If you run Grafana with more than one organization
Upgrade to 12.3.9, 12.4.6, 13.0.4 or 13.1.1, or anything later on your line. If you are on 12.2 or 11.6, your line is past end of life and has no fixed release, so the upgrade is to a supported line. Nothing was disclosed that needs rotating. If your organizations belong to different customers and any of them have Org Admins, the request log for /api/access-control/users/permissions/search with an actionPrefix is where you would look for prior use; single-organization deployments were never exposed.
The pattern worth keeping
The organization filter was on the first line of the query and the OR arrived thirty lines later inside a conditional, appended to a string. Nothing on either line looked wrong on its own; the bug only exists in the concatenation. Any query built this way needs every appended disjunction wrapped at the point it is appended, so a later reader never has to hold the whole string in their head to know what the precedence is. And a fixture with two organizations, each with a dashboard the other should not see, would have caught this in a unit test the first time the action-set branch was added.