feat(oauth2): Map OAuth2 claims to Visible Server Groups - #10234
feat(oauth2): Map OAuth2 claims to Visible Server Groups#10234beasteers wants to merge 2 commits into
Conversation
Add OAUTH2_SERVER_GROUP_CLAIM and OAUTH2_SERVER_GROUP_CLAIM_MAPPING provider config keys. When configured, claim values determine which server groups a user can see, applied as an additional OR condition alongside ownership and shared-server visibility.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
WalkthroughOAuth2 provider settings now support server-group claims and optional mappings. Login resolves groups from ID-token or userinfo claims and stores them in the session. Server-group queries include matching OAuth2 groups. Mocked OIDC tests cover direct and mapped claims. ChangesOAuth2 Server-Group Visibility
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant OAuth2Provider
participant OAuth2Login
participant FlaskSession
participant ServerGroupQuery
OAuth2Provider->>OAuth2Login: Provide ID-token or userinfo claim
OAuth2Login->>OAuth2Login: Resolve and map server groups
OAuth2Login->>FlaskSession: Store oauth2_server_group_claims
ServerGroupQuery->>FlaskSession: Read OAuth2 server groups
ServerGroupQuery-->>OAuth2Login: Return accessible server groups
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@web/pgadmin/authenticate/oauth2.py`:
- Around line 559-563: Update the needs_userinfo decision in get_user_profile()
to also fetch userinfo when OAUTH2_SERVER_GROUP_CLAIM is configured but absent
from the ID-token claims, even when the username claim is present. Ensure the
existing server-group resolution can use the fetched profile, and add a
regression test covering a server-group claim available only in userinfo.
In `@web/pgadmin/browser/tests/test_oauth2_with_mocking.py`:
- Around line 471-478: Update the session assertion in the OAuth2 test around
expected_server_groups to compare server-group contents without requiring
ordering, using assertCountEqual for the non-None case while preserving the
existing absence assertion for None.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: fc568543-fd85-472e-b433-0aa726934cd3
📒 Files selected for processing (4)
web/config.pyweb/pgadmin/authenticate/oauth2.pyweb/pgadmin/browser/tests/test_oauth2_with_mocking.pyweb/pgadmin/utils/server_access.py
dpage
left a comment
There was a problem hiding this comment.
Thanks for this, and for the linked issue: claim-driven server group access is something we get asked about fairly regularly, so the direction is welcome. The claim plumbing in oauth2.py reads well, the needs_userinfo handling is right, and the session round-trip is covered by tests. My concerns are almost entirely about the single consumer in server_access.py, where I think there are two problems that need resolving before this can go in, plus a design question that is worth agreeing first.
Must fix
1. An OAuth2 user can rename another user's server group.
In web/pgadmin/utils/server_access.py, the hide_shared branch previously applied ServerGroup.user_id == current_user.id as a standalone filter, whereas it is now folded into or_(*conditions) alongside the claim condition. ServerGroupView.update() in web/pgadmin/browser/server_groups/__init__.py:226 passes hide_shared=True specifically to obtain an owned-only lookup, as its comment says, and it then assigns servergroup.name and commits without any further ownership check. So an OAuth2 user whose claim list happens to contain a name such as Production can PUT the id of somebody else's group of that name and rename it. The claim condition needs to stay out of the hide_shared=True branch; ideally the write paths would not share this query at all, since it is now doing double duty as both a visibility helper and an ownership check.
2. As written, the claim grants visibility but not access, so the feature is close to a no-op.
Server visibility is decided entirely by get_user_server_query() (server_access.py), which is still own-or-shared and is untouched here, and ServerModule.get_nodes() (browser/server_groups/servers/__init__.py:292) goes through it. That leaves three cases and none of them do what the issue asks for: a claim-granted group owned by another user shows up in the tree but is empty, because that owner's private servers are still filtered out; a group that does contain shared=True servers is already visible to every authenticated user via the existing has_shared_servers condition, so the new OR adds nothing; and with the hide_shared_server preference enabled, get_servers() skips non-owned servers anyway, so the group is empty again. To actually deliver #9704 the claim has to feed server access as well as group listing, not just the group query.
Design question
The description frames this as controlling access and offering different levels of access, but the implementation can only ever widen visibility, so an administrator cannot use it to confine a user to a subset of groups. The only existing mechanism that hides shared groups is the per-user hide_shared_server preference, which the user can simply switch off, so that is not an access control either. I think the semantics we want are "when the claim is configured, visibility becomes owned groups plus claim-granted groups", which is restrictive rather than additive, but it would be worth agreeing that before you spend more time on the implementation.
Should fix
Matching ServerGroup.name across all owners. ServerGroup rows are per-user, so ServerGroup.name.in_(...) matches every user's group with a given name. Two users each owning a group called "Production" will present the claim holder with two indistinguishable tree nodes, and ServerGroupView.properties() (server_groups/__init__.py:280) returns the owner's user_id, so it also discloses which accounts exist. The lookup should be scoped to a defined owner rather than matched globally by name.
Ordering of the session write. In web/pgadmin/authenticate/oauth2.py, session['oauth2_server_group_claims'] is written before the OAUTH2_ADDITIONAL_CLAIMS validation that can still reject the login. It is not exploitable, since the consumer is gated on current_user.auth_source == OAUTH2, but moving it below the valid_combined check is cleaner and costs nothing.
Test coverage for the behaviour that changed. The new cases assert what lands in the session, which is useful, but nothing exercises get_server_groups_for_user_query() with claims present, and in particular nothing covers the hide_shared=True path where the rename problem above lives. CodeRabbit also asked for a regression test covering a server group claim that exists only in the userinfo response; the needs_userinfo fix was made but that test has not been added.
Documentation. docs/en_US/oauth2.rst documents every other OAUTH2_* key in its configuration table, with a worked example section for OAUTH2_ADDITIONAL_CLAIMS, so the two new keys need equivalent rows and a short example covering what happens to unmapped claim values.
Minor
list(set(server_groups)) gives non-deterministic ordering, which is why the test needed assertCountEqual; list(dict.fromkeys(server_groups)) dedupes whilst preserving order. mapping.get(value) will raise TypeError: unhashable type if a claim value is a list or a dict, which some providers emit when groups are objects rather than strings, and since the call is not wrapped that would surface as a 500 during login rather than a clear message, so skipping non-scalar values would be safer. Finally, claim values are matched to group names exactly, with no trimming or case folding, which is a defensible choice but worth stating in the docs given how often provider group names arrive /-prefixed or differently cased.
Notes
Claims are resolved only at login, so a role change at the identity provider takes effect on the user's next login. That is reasonable, but it deserves a line in the documentation. The module docstring at the top of server_access.py also still says these helpers restrict users to servers they own or that have been explicitly shared, and only the function docstring was updated.
Verification
I ran browser.tests.test_oauth2_with_mocking against the branch and all 23 cases pass, including both new scenarios, and pycodestyle is clean on all four changed files. One unrelated failure appeared in ReverseEngineeredSQLTestCases, but it reproduces on an unmodified master in the same working tree, so it is environmental rather than anything to do with this change. Both CodeRabbit findings were valid and look correctly addressed in 19c0599.
Summary
Control access to server groups using OAuth2 token claims, letting you create different levels of access tied to centralized SSO roles.
Fixes #9704
What changed
Two new per-provider config keys were added to
OAUTH2_CONFIGinweb/config.py:OAUTH2_SERVER_GROUP_CLAIM— the claim (from the ID token or userinfo profile) whose values are treated as server group names.OAUTH2_SERVER_GROUP_CLAIM_MAPPING— optional dict mapping a claim value → one server group name or a list of server group names, useful when the claim carries opaque codes (e.g.readonly) that don't match group names directly.How it works
In
web/pgadmin/authenticate/oauth2.py, the new_extract_server_group_claims()resolves the allowed groups during login and stores them insession['oauth2_server_group_claims']:In
web/pgadmin/utils/server_access.py,get_server_groups_for_user_query()now appends an additionalORcondition matchingServerGroup.nameagainst the resolved claim-based groups — for OAuth2 users only. This applies alongside the existing ownership and shared-server (Server.shared) conditions.Tests
web/pgadmin/browser/tests/test_oauth2_with_mocking.py:oidc-server-groups-direct(no mapping) andoidc-server-groups-mapped(with mapping).session['oauth2_server_group_claims']is populated correctly.Backwards compatibility
Fully opt-in. When
OAUTH2_SERVER_GROUP_CLAIMis not set, behavior is unchanged.Summary by CodeRabbit
New Features
Documentation
Tests