Skip to content

Fixes #25703: prevent non-admin users from managing other users via API#27749

Open
sonika-shah wants to merge 1 commit intomainfrom
fix/issue-25703-rbac-user-management
Open

Fixes #25703: prevent non-admin users from managing other users via API#27749
sonika-shah wants to merge 1 commit intomainfrom
fix/issue-25703-rbac-user-management

Conversation

@sonika-shah
Copy link
Copy Markdown
Collaborator

Summary

  • Any authenticated (non-admin) user could PATCH another user's profile via PATCH /api/v1/users/{id}. The root cause: the default DataConsumerPolicy grants EditDescription, EditTags, EditGlossaryTerms, EditTier, and EditCertification on all resources. Since the patch() method in UserResource only checked admin for specific fields (/isAdmin, /isBot, /roles) and self for /personaPreferences, but delegated everything else to patchInternal(), the policy evaluator's broad allow-rules let non-admin users modify any field on another user.
  • The three DELETE /api/v1/users/… endpoints also lacked an explicit admin-only gate; they relied entirely on policy evaluation, which could admit non-admin requests through the OrganizationPolicy-Owner-Rule or future policy changes.

Fix

UserResource.java

  • patch(): Added a self-check at the start of the method. If the authenticated user is trying to patch a different user (their id ≠ the target id), authorizer.authorizeAdmin() is called immediately — mirroring the identical pattern in createOrUpdateUser() (PUT). Non-admin users can still patch their own profile.
  • delete(), deleteByIdAsync(), delete(name): Added authorizer.authorizeAdmin() before delegating to the parent delete implementations. User deletion is now an explicit admin-only operation.
  • Refactored the existing personaPreferences self-check to reuse the loggedInUser already fetched for the new check (eliminates one redundant DB round-trip).

Testing

New integration test: UserManagementRbacIT

Test Expected
test_nonAdminCannotPatchOtherUser non-admin PATCH on another user → 403
test_nonAdminCannotDeleteOtherUser non-admin DELETE on another user → 403
test_adminCanPatchOtherUser admin PATCH on another user → 200
test_nonAdminCanPatchOwnProfile non-admin PATCH on own profile → 200

To reproduce the vulnerability against an unpatched deployment:

# Obtain a token for any non-admin user
TOKEN=<non-admin-jwt>
VICTIM_ID=<uuid-of-another-user>

# PATCH — returned 200 before the fix, 403 after
curl -X PATCH "http://localhost:8585/api/v1/users/$VICTIM_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json-patch+json" \
  -d '[{"op":"replace","path":"/description","value":"pwned"}]'

# DELETE — returned 200 before the fix, 403 after
curl -X DELETE "http://localhost:8585/api/v1/users/$VICTIM_ID" \
  -H "Authorization: Bearer $TOKEN"

Closes #25703


Generated by Claude Code

…r users

The default DataConsumerPolicy granted EditDescription (and similar edit
operations) on all resources, including the user resource. This allowed
any authenticated user to PATCH another user's profile via
PATCH /api/v1/users/{id} without admin privileges, since the authorization
check in patchInternal() would match the EditDescription operation against
the DataConsumerPolicy rule.

Additionally, the DELETE endpoints on /api/v1/users lacked an explicit
admin-only guard, relying solely on policy evaluation which could allow
edge cases through the OrganizationPolicy owner-rule.

Fix:
- patch(): add a self-check at the start — non-admin users can only patch
  their own profile (id matches their own user id). Attempting to patch a
  different user now requires admin, matching the behavior of PUT /v1/users.
- delete(), deleteByIdAsync(), delete(by name): add authorizeAdmin() before
  delegating to the parent delete implementations, making user deletion
  an explicit admin-only operation.
- Refactor the personaPreferences self-check to reuse the loggedInUser
  fetched for the new self-check (avoids a redundant DB lookup).

Integration test: UserManagementRbacIT verifies that non-admin users
receive HTTP 403 when attempting to PATCH or DELETE other users, and that
they can still PATCH their own profile.
Copilot AI review requested due to automatic review settings April 26, 2026 23:18
@github-actions github-actions Bot added backend safe to test Add this label to run secure Github workflows on PRs labels Apr 26, 2026
Comment on lines 1074 to 1078
// Check if updating personaPreferences - users can only update their own
if (path.startsWith("/personaPreferences")) {
String authenticatedUserName = securityContext.getUserPrincipal().getName();
User authenticatedUser =
repository.getByName(uriInfo, authenticatedUserName, new Fields(Set.of("id")));
if (!authenticatedUser.getId().equals(id)) {
if (!loggedInUser.getId().equals(id)) {
throw new AuthorizationException("Users can only update their own persona preferences");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: personaPreferences check blocks admins from updating other users

After the new admin gate at line 1063-1065, only admins (or self) can reach the personaPreferences path check at line 1075-1078. For self-patches, the check passes. But for an admin patching another user's personaPreferences, the unconditional !loggedInUser.getId().equals(id) check throws AuthorizationException, blocking the operation.

While this was also the behavior before this PR, the refactoring makes it more visible: admins who pass the top-level gate are then blocked by a field-level check that ignores admin status. If admin management of persona preferences is a valid use case (consistent with admins being able to patch all other fields), this should be fixed.

Suggested fix:

if (path.startsWith("/personaPreferences")) {
  if (!loggedInUser.getId().equals(id)) {
    authorizer.authorizeAdmin(securityContext);
  }
}

Was this helpful? React with 👍 / 👎 | Reply gitar fix to apply this suggestion

@gitar-bot
Copy link
Copy Markdown

gitar-bot Bot commented Apr 26, 2026

Code Review ⚠️ Changes requested 0 resolved / 1 findings

Restricts non-admin API access to user management, but the personaPreferences check currently blocks admins from updating other users due to a faulty gate logic.

⚠️ Bug: personaPreferences check blocks admins from updating other users

📄 openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserResource.java:1074-1078

After the new admin gate at line 1063-1065, only admins (or self) can reach the personaPreferences path check at line 1075-1078. For self-patches, the check passes. But for an admin patching another user's personaPreferences, the unconditional !loggedInUser.getId().equals(id) check throws AuthorizationException, blocking the operation.

While this was also the behavior before this PR, the refactoring makes it more visible: admins who pass the top-level gate are then blocked by a field-level check that ignores admin status. If admin management of persona preferences is a valid use case (consistent with admins being able to patch all other fields), this should be fixed.

Suggested fix
if (path.startsWith("/personaPreferences")) {
  if (!loggedInUser.getId().equals(id)) {
    authorizer.authorizeAdmin(securityContext);
  }
}
🤖 Prompt for agents
Code Review: Restricts non-admin API access to user management, but the personaPreferences check currently blocks admins from updating other users due to a faulty gate logic.

1. ⚠️ Bug: personaPreferences check blocks admins from updating other users
   Files: openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserResource.java:1074-1078

   After the new admin gate at line 1063-1065, only admins (or self) can reach the `personaPreferences` path check at line 1075-1078. For self-patches, the check passes. But for an admin patching another user's `personaPreferences`, the unconditional `!loggedInUser.getId().equals(id)` check throws `AuthorizationException`, blocking the operation.
   
   While this was also the behavior before this PR, the refactoring makes it more visible: admins who pass the top-level gate are then blocked by a field-level check that ignores admin status. If admin management of persona preferences is a valid use case (consistent with admins being able to patch all other fields), this should be fixed.

   Suggested fix:
   if (path.startsWith("/personaPreferences")) {
     if (!loggedInUser.getId().equals(id)) {
       authorizer.authorizeAdmin(securityContext);
     }
   }

Options

Display: compact → Showing less information.

Comment with these commands to change:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR closes a broken access control gap in the Users REST API by enforcing explicit admin-only authorization for modifying or deleting other users, independent of broad default RBAC policies.

Changes:

  • Enforce “self-only” PATCH for non-admins on PATCH /v1/users/{id}; require admin when patching someone else.
  • Make user deletion endpoints explicitly admin-only (DELETE /v1/users/{id}, /async/{id}, and /name/{name}).
  • Add integration tests to validate non-admin cannot PATCH/DELETE other users, while preserving admin and self-patch behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserResource.java Adds explicit admin gating for cross-user PATCH and all DELETE user operations.
openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/UserManagementRbacIT.java Adds RBAC integration tests covering PATCH/DELETE permissions for admin vs non-admin users.

Comment on lines +54 to +60
User victim = createTestUser(ns, "victim");
String attackerEmail = ns.prefix("attacker") + "@test.openmetadata.org";
String attackerName = ns.prefix("attacker");

String attackerToken =
JwtAuthProvider.tokenFor(attackerName, attackerEmail, new String[] {}, TOKEN_TTL_SECONDS);

Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test generates an attacker JWT for a username that is never created in OpenMetadata. Since UserResource.patch() now looks up the logged-in user via repository.getByName(), this request can fail with 404 (attacker user not found) instead of exercising the intended 403 authorization path. Create the attacker user (via admin API) before calling PATCH, or loosen the assertion to accept the not-found case if that’s an expected auth behavior in this suite.

Copilot uses AI. Check for mistakes.
Comment on lines +82 to +88
User victim = createTestUser(ns, "del_victim");
String attackerEmail = ns.prefix("del_attacker") + "@test.openmetadata.org";
String attackerName = ns.prefix("del_attacker");

String attackerToken =
JwtAuthProvider.tokenFor(attackerName, attackerEmail, new String[] {}, TOKEN_TTL_SECONDS);

Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as the PATCH test: this test generates a token for del_attacker without creating that user first. With the new UserResource self/admin gate relying on DB lookup of the current user, the DELETE call may return 404 for an unknown principal rather than the expected 403. Create the attacker user before issuing the DELETE, so the test reliably validates the RBAC behavior.

Copilot uses AI. Check for mistakes.
@sonarqubecloud
Copy link
Copy Markdown

@github-actions
Copy link
Copy Markdown
Contributor

🟡 Playwright Results — all passed (12 flaky)

✅ 3961 passed · ❌ 0 failed · 🟡 12 flaky · ⏭️ 86 skipped

Shard Passed Failed Flaky Skipped
🟡 Shard 1 298 0 1 4
🟡 Shard 2 754 0 5 8
🟡 Shard 3 729 0 3 7
✅ Shard 4 759 0 0 18
✅ Shard 5 687 0 0 41
🟡 Shard 6 734 0 3 8
🟡 12 flaky test(s) (passed on retry)
  • Pages/UserCreationWithPersona.spec.ts › Create user with persona and verify on profile (shard 1, 1 retry)
  • Features/ActivityAPI.spec.ts › Activity event is created when description is updated (shard 2, 1 retry)
  • Features/ActivityAPI.spec.ts › Activity event shows the actor who made the change (shard 2, 1 retry)
  • Features/DomainFilterQueryFilter.spec.ts › Domain filter should work with different asset types (shard 2, 1 retry)
  • Features/Glossary/GlossaryRemoveOperations.spec.ts › should add and remove reviewer from glossary term (shard 2, 1 retry)
  • Features/IncidentManager.spec.ts › Next, Previous and page indicator (shard 2, 1 retry)
  • Features/RTL.spec.ts › Verify Following widget functionality (shard 3, 1 retry)
  • Flow/PersonaDeletionUserProfile.spec.ts › User profile loads correctly before and after persona deletion (shard 3, 1 retry)
  • Flow/PersonaFlow.spec.ts › Set default persona for team should work properly (shard 3, 1 retry)
  • Pages/Lineage/LineageFilters.spec.ts › Verify lineage schema filter selection (shard 6, 1 retry)
  • Pages/Lineage/LineageRightPanel.spec.ts › Verify custom properties tab IS visible for supported type: searchIndex (shard 6, 1 retry)
  • Pages/UserDetails.spec.ts › Create team with domain and verify visibility of inherited domain in user profile after team removal (shard 6, 1 retry)

📦 Download artifacts

How to debug locally
# Download playwright-test-results-<shard> artifact and unzip
npx playwright show-trace path/to/trace.zip    # view trace

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend safe to test Add this label to run secure Github workflows on PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Non-admin users can create/edit/delete users and teams via API

2 participants