SEP-1867: Resolve a single-user lookup through Grafana's org-users listing when the service account is org-scoped - #1391
Open
marcuscruz-percona wants to merge 5 commits into
Open
SEP-1867: Resolve a single-user lookup through Grafana's org-users listing when the service account is org-scoped#1391marcuscruz-percona wants to merge 5 commits into
marcuscruz-percona wants to merge 5 commits into
Conversation
Grafana's user-lookup endpoint needs instance-scoped `users:read`, held only by a Server Admin, so a service account scoped to one org is refused and an admin's `retrieve_user` call fails with a permission error instead of the record. That refusal alone now falls back to the org-users listing the same token already reads for the listing route. The fallback matches a login before an email so an email-shaped login cannot resolve another user's record, and it refuses a row naming no role the provider ranks rather than serving it as a user holding no access. The server-admin path is unchanged: the lookup is still the first and only call when it succeeds.
marcuscruz-percona
requested review from
a team,
peter-o-addo and
yyyyyyyan
as code owners
August 21, 2026 13:59
Contributor
There was a problem hiding this comment.
Pull request overview
Adds a focused Grafana org-scoped fallback while preserving the server-admin lookup path.
Changes:
- Falls back to organization users on lookup 403 responses.
- Resolves login/email matches and organization roles.
- Adds extensive tests and a changelog fragment.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
app/core/auth/providers/grafana/models.py |
Implements the org-scoped fallback. |
tests/app/core/auth/providers/grafana/test_models.py |
Covers fallback behavior and errors. |
changelog.d/SEP-1867.fixed.md |
Documents the user-facing fix. |
Suppressed comments (1)
app/core/auth/providers/grafana/models.py:490
- This helper directly calls
get_org_users(), whose upstream failures propagate asHTTPException, but its:raises:block omits that path. Document it alongside the two locally raised errors.
:raises HTTPNotFoundException: If no org user matches ``username``.
:raises GrafanaException: If the matched row names no role this provider
ranks.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Review feedback on the org-scoped fallback surfaced two things the previous shape got wrong. The fallback refused any matched row whose role it could not rank, raising a 502. That contradicted the read path it was meant to mirror: the org-users listing serves such a row with the lowest role, and so does the server-admin lookup. Grafana models "holds no basic role" as a real membership state, so one user could be listed successfully and refused when fetched singly. Roles now rank through `_rank_org_role`, shared by every record shape this provider maps, so the single lookup, the listing, and the login flow cannot disagree about one row. A role Grafana does not define still grants nothing, but is logged as schema drift instead of read as an access decision. The listing itself was cast, not checked, so a payload off contract escaped from inside the mapping as a `KeyError` or `AttributeError` and surfaced as an unhandled 500. Both read paths now go through `_org_user_records`, which validates the shape before any field is read and reports a violation as an upstream error. This also removes the duplicated fetch-and-cast between the two callers. Alongside those: the matching predicate moves next to the record type it reads, the fallback is renamed to say that it reads the network rather than mapping a record already in hand, and the `:raises:` contracts name both upstream calls. Coverage adds the route-level path the fallback exists for — an admin fetching another user while the lookup endpoint is refused — plus the listing contract violations and the agreement between the two read paths.
The upstream-error mapping is part of the same fix, not a separate behavior change, so it belongs in the fixed fragment rather than a second file. The unsupported-role warning is log-only and needs no entry.
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||
…mail The listing guard rejected a null ``email``, which the mapper had always read as an optional string, so one such row failed the whole listing where it used to map to an empty string. The guard now accepts it, and the reader says why. The route tests awaited the route function, so nothing covered routing, the current-user dependency, or serialization. They now issue requests against an app that mounts that same function under the Grafana user model, since the application binds its own at import to a model requiring a field Grafana records do not carry. One of them asserts what the reported bug is about: a user holding no organization role reaches the client at the lowest rank rather than as an error. Alongside those: the two matching passes fold into one over both fields, so the precedence rule reads once rather than twice; the matcher case-folds its own input, which was previously a contract the caller had to honour; a duplicated async marker goes; and the changelog fragment names who the fix reaches and the staleness window it inherits.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
GrafanaUser.get_user()called Grafana's user-lookup endpoint unconditionally. That endpoint needs instance-scopedusers:read, which only a Grafana Server Admin holds, so a service account scoped to a single org was refused andGET /api/users/{username}returned a permission error to a SEP admin instead of the record — the shape the PMM-embedded topology mints. A 403 from that lookup now falls back toGET /api/org/users, the listing the same token already reads forlist_users().loginOrEmailparameter, and is case-folded because Grafana treats logins case-insensitively. A miss is a flat404 User not found, carrying none of Grafana's own error text.Review round two
Two things the first shape got wrong, both fixed in 52b6133:
"None", or undefined) with a 502. That contradicted the read path it was meant to mirror — the org-users listing serves such a row with the lowest role, and so does the server-admin lookup — so a Grafana user with RBAC "no basic role" could be listed successfully and refused when fetched singly. Roles now rank through_rank_org_role, shared by every record shape this provider maps, so the single lookup, the listing, and the login flow cannot disagree about one row. A role Grafana does not define still grants nothing, but is logged as schema drift rather than read as an access decision.KeyError/AttributeErrorand surface as an unhandled 500. Both read paths now go through_org_user_records(), which checks the shape before any field is read and reports a violation as an upstream error. This also removes the duplicated fetch-and-cast between the two callers. Behavior change beyond the reported bug:list_users()now reports a malformed Grafana payload as 502 instead of 500, and its previouslyKeyError-asserting test was rewritten.Also folded in: the matching predicate moved next to the record type it reads,
_from_org_scoperenamed to_get_user_from_org_listing(it reads the network rather than mapping a record already in hand),:raises:contracts naming both upstream calls, and the route-level test for the path the fallback exists for.Reviewed and kept
Four points a review round raised and this PR keeps deliberately, so a reviewer does not have to re-derive the reasoning:
list_users()now reports a malformed Grafana payload as 502 rather than 500. Both read paths share_org_user_records(). Validating only the fallback would leave the duplicated fetch-and-cast in place and leavelist_users()raising an unhandled error, so the shared reader was the deliberate choice. Disclosed in the changelog fragment._rank_org_roleis also used by the login flow's org loop. That reuse is the fix for the split policy — restricting it to the org-listing shapes would re-create the disagreement between read paths. Behaviour there is unchanged apart from the drift warning.login == username. Parity with the lookup endpoint's ownloginOrEmailparameter. Login is matched across the whole listing before any email, because Grafana permits an email-shaped login: a single pass would resolve by listing order and could serve user B for a request naming user A.response_modelat import to the configured provider's user model, which requiresowner— a field a Grafana record does not carry — so a Grafana user cannot pass validation on the route as mounted there. Requests still exercise routing, the current-user dependency, the path parameter, and serialization.Known, inherent limitations of the org-scoped topology, not defects this fix eliminates: org-user records carry no server-admin flag, so the fallback can never assert
super_admin(the same asymmetryget_users()already documents), and it inheritsget_org_users()'s@alru_cache(ttl=300), so a rename can resolve stale for up to 5 minutes on that already-cached call.Tested
admin,GET /api/users/{other_username}returns 200 with the record and the user's true org role, where it previously returned a permission error carrying Grafana's messageGET /api/users/{other_user_email}returns 200 with the same recordGET /api/users/{username}returns 200 withrole: none, matching whatGET /api/users/reports for the same userGET /api/users/{unknown_username}returns 404User not found, with no Grafana text in the bodyGET /api/users/{own_username}returns 200, short-circuiting before any upstream callviewer:GET /api/users/{other_username}still returns 403 from the route's own gateGET /api/users/{other_username}is identical tomain, and debug logs show no/api/org/userscall for that lookupChecklist
make test) — 10177 passed, 0 failed, 424 skippedmake run-pre-commit)make makemigrations) — N/A, no table models touchedchangelog.d/if the change is user-facing (make changelog-add), or confirmed N/A