Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions web/pgadmin/browser/server_groups/servers/roles/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,13 @@ def wrap(self, **kwargs):
except ValueError:
data[k] = v

# Capture the client-supplied keys before the validators below
# mutate 'data' (e.g. _validate_rolemembers adds derived keys
# such as 'rol_members_list'), so callers that need to know what
# the client actually sent (e.g. the membership-only update
# check) can rely on this instead of the mutated dict.
self.request_keys = set(data)

invalid_msg_arr = [
self._validate_rolname(kwargs.get('rid', -1), data),
self._validate_rolvaliduntil(data),
Expand Down Expand Up @@ -619,6 +626,7 @@ def _check_action(action, kwargs):
return fetch_name, check_permission, forbidden_msg

def _check_permission(self, check_permission, action, kwargs):
self.membership_only_update = False
if check_permission:
user = self.manager.user_info

Expand All @@ -627,6 +635,15 @@ def _check_permission(self, check_permission, action, kwargs):
(action != 'update' or 'rid' in kwargs) and \
kwargs['rid'] != -1 and \
user['id'] != kwargs['rid']:
# A role that only has ADMIN OPTION on this specific role
# (rather than being a superuser or having CREATEROLE) may
# still manage that role's membership, so don't forbid the
# request outright; the update handler restricts what such
# a request is allowed to change to membership only.
if action == 'update' and getattr(
self, 'has_admin_option', False):
self.membership_only_update = True
return False
return True
return False

Expand Down Expand Up @@ -658,6 +675,7 @@ def _check_and_fetch_name(self, fetch_name, kwargs):
self.role = row['rolname']
self.rolCanLogin = row['rolcanlogin']
self.rolSuper = row['rolsuper']
self.has_admin_option = row.get('has_admin_option', False)

return False, ''

Expand Down Expand Up @@ -713,16 +731,20 @@ def wrapped(self, **kwargs):
fetch_name, check_permission, \
forbidden_msg = RoleView._check_action(action, kwargs)

is_permission_error = self._check_permission(check_permission,
action, kwargs)
if is_permission_error:
return forbidden(forbidden_msg)

# Fetched first: the permission check needs to know
# whether the current user holds ADMIN OPTION on this
# role before it can decide whether to forbid the
# request.
is_error, errmsg = self._check_and_fetch_name(fetch_name,
kwargs)
if is_error:
return errmsg

is_permission_error = self._check_permission(check_permission,
action, kwargs)
if is_permission_error:
return forbidden(forbidden_msg)

return f(self, **kwargs)

return wrapped
Expand Down Expand Up @@ -1023,6 +1045,13 @@ def create(self, gid, sid):
@check_precondition(action='update')
@validate_request
def update(self, gid, sid, rid):
if getattr(self, 'membership_only_update', False) and \
not self.request_keys <= {'rolmembers'}:
return forbidden(
_("The current user does not have permission to update "
"the role. Users with ADMIN OPTION on this role may "
"only manage its membership.")
)

sql = render_template(
self.sql_path + self._UPDATE_SQL,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ export default class RoleSchema extends BaseUISchema {
return (!(user.is_superuser || user.can_create_role) && user.id != state.oid);
}

// A role that isn't a superuser or CREATEROLE holder can still manage
// this role's membership if they hold ADMIN OPTION on it themselves.
isMemberAdmin(state) {
return (state.rolmembers ?? []).some(
(member) => member.role === this.user.name && member.admin
);
}

membersReadOnly(state) {
return this.readOnly(state) && !this.isMemberAdmin(state);
}

memberDataFormatter(rawData) {
let members = '';
if(_.isObject(rawData)) {
Expand Down Expand Up @@ -194,8 +206,8 @@ export default class RoleSchema extends BaseUISchema {
mode: ['edit', 'create'], cell: 'text',
type: 'collection',
schema: obj.membershipSchema,
disabled: obj.readOnly,
canDelete: (state) => !obj.readOnly(state),
disabled: (state) => obj.membersReadOnly(state),
canDelete: (state) => !obj.membersReadOnly(state),
canDeleteRow: true,
helpMessage: obj.isReadOnly ? gettext('Select the checkbox for roles to include WITH ADMIN OPTION.') : gettext('Roles shown with a check mark have the WITH ADMIN OPTION set.'),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
SELECT
rolname, rolcanlogin, rolsuper
rolname, rolcanlogin, rolsuper,
EXISTS (
SELECT 1 FROM pg_catalog.pg_auth_members am
WHERE am.roleid = {{ rid }}::OID
AND am.member = (
SELECT oid FROM pg_catalog.pg_roles
WHERE rolname = current_user
)
AND am.admin_option
) AS has_admin_option
FROM
pg_catalog.pg_roles
WHERE oid = {{ rid }}::OID
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################

import json
from unittest.mock import MagicMock, patch

from pgadmin.utils.route import BaseTestGenerator
from pgadmin.browser.server_groups.servers.roles import RoleView


class RoleCheckPermissionTest(BaseTestGenerator):
"""Unit tests for RoleView._check_permission's ADMIN OPTION carve-out.

A role holder who is neither a superuser nor a CREATEROLE holder, but
who has been granted ADMIN OPTION on the specific role being updated,
should be allowed through the permission gate so they can manage that
role's membership - but only for 'update', never for 'drop', and the
view should record that the request must be restricted to membership
changes only.
"""
scenarios = [
('Check Role Node', dict(url='/browser/role/obj/'))
]

def setUp(self):
pass

def runTest(self):
view = RoleView(cmd=None)
view.manager = MagicMock()

# Plain user, no admin option: update is forbidden.
view.manager.user_info = {
'is_superuser': False, 'can_create_role': False, 'id': 5
}
view.has_admin_option = False
self.assertTrue(view._check_permission(True, 'update', {'rid': 10}))
self.assertFalse(view.membership_only_update)

# Same user, but with ADMIN OPTION on the target role: allowed
# through, flagged as membership-only.
view.has_admin_option = True
self.assertFalse(view._check_permission(True, 'update', {'rid': 10}))
self.assertTrue(view.membership_only_update)

# ADMIN OPTION does not extend to dropping the role.
self.assertTrue(view._check_permission(True, 'drop', {'rid': 10}))

# Superusers are unaffected by the ADMIN OPTION check.
view.manager.user_info = {
'is_superuser': True, 'can_create_role': False, 'id': 5
}
view.has_admin_option = False
self.assertFalse(view._check_permission(True, 'update', {'rid': 10}))

def tearDown(self):
pass


class RoleMembersOnlyUpdateRequestKeysTest(BaseTestGenerator):
"""Regression test for the membership-only update guard.

_validate_rolemembers() mutates the request dict in place, adding
derived keys ('rol_members_list', 'rol_members_revoked_list') that
the client never sent. The membership-only update guard in
RoleView.update() must check the client-supplied keys captured
before that mutation (self.request_keys), not the mutated dict,
otherwise a valid ADMIN OPTION request containing only 'rolmembers'
would be wrongly rejected as forbidden.
"""
scenarios = [
('Check Role Node', dict(url='/browser/role/obj/'))
]

def setUp(self):
pass

def runTest(self):
view = RoleView(cmd=None)
view.manager = MagicMock()
view.manager.version = 170000

data = {
'rolmembers': {
'added': [
{'role': 'member_role', 'admin': True,
'inherit': True, 'set': True}
],
'changed': [],
'deleted': []
}
}

# Mirror what validate_request() does: capture the client
# supplied keys before running the validators.
request_keys = set(data)

# This mutates 'data' in place, adding derived keys.
self.assertIsNone(view._validate_rolemembers(10, data))
self.assertIn('rol_members_list', data)

# The mutated dict is no longer a subset of {'rolmembers'} ...
self.assertFalse(set(data) <= {'rolmembers'})

# ... but the keys captured before mutation still are, so the
# membership-only guard (which must use request_keys) allows
# the request through instead of returning 403.
self.assertTrue(request_keys <= {'rolmembers'})
Comment thread
dpage marked this conversation as resolved.

def tearDown(self):
pass


class RoleUpdateAdminOptionMembershipOnlyTest(BaseTestGenerator):
"""End-to-end regression test for the ADMIN OPTION membership-only
update guard.

The two tests above exercise _check_permission() and
_validate_rolemembers() individually, but neither actually calls
validate_request() or RoleView.update(), so a regression that broke
how those two decorators interact (e.g. the membership-only guard
reading the wrong dict, or request_keys being set/consumed at the
wrong point in the chain) would slip past them.

This test drives RoleView.update() through its real decorator chain
(check_precondition -> validate_request -> update) with the driver,
connection and SQL rendering mocked out, submitting a 'rolmembers'
-only body as a user who holds ADMIN OPTION on the target role (but
is neither a superuser nor a CREATEROLE holder), and asserts the
request is NOT rejected with 403.
"""
scenarios = [
('Check Role Node', dict(url='/browser/role/obj/'))
]

def setUp(self):
pass

@patch('pgadmin.browser.server_groups.servers.roles.get_driver')
@patch('pgadmin.browser.server_groups.servers.roles.render_template')
def runTest(self, render_template_mock, get_driver_mock):
view = RoleView(cmd=None)

manager = MagicMock()
manager.version = 170000
manager.db_info = None
manager.user_info = {
'is_superuser': False, 'can_create_role': False, 'id': 5
}

conn = MagicMock()
conn.connected.return_value = True
# Used for the permission lookup, the ALTER ROLE, and the
# post-update node fetch alike; has_admin_option=True is what
# drives the ADMIN OPTION carve-out in _check_permission().
conn.execute_dict.return_value = (True, {'rows': [{
'rolname': 'grp_role', 'rolcanlogin': False, 'rolsuper': False,
'has_admin_option': True, 'description': None
}]})
manager.connection.return_value = conn

get_driver_mock.return_value.connection_manager.return_value = \
manager

# The client sends only 'rolmembers' - exactly what an ADMIN
# OPTION holder (who may manage membership only) is allowed to
# change.
body = {
'rolmembers': {
'added': [
{'role': 'member_role', 'admin': True,
'inherit': True, 'set': True}
],
'changed': [],
'deleted': []
}
}

with self.app.test_request_context(
data=json.dumps(body), content_type='application/json'
):
response = view.update(gid=1, sid=1, rid=10)

# The real _check_permission() call, driven off the mocked
# has_admin_option row, must have flagged this as a
# membership-only update ...
self.assertTrue(view.membership_only_update)
# ... and update() must let it through rather than forbidding it.
self.assertNotEqual(response.status_code, 403)

def tearDown(self):
pass
27 changes: 27 additions & 0 deletions web/regression/javascript/schema_ui_files/role.ui.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,32 @@ describe('RoleSchema', ()=>{
it('properties', async ()=>{
await getPropertiesView(createSchemaObject(), getInitData);
});

describe('membersReadOnly', ()=>{
it('is read only for a plain user who is not an admin member', ()=>{
const schemaObj = createSchemaObject();
const state = {oid: 123, rolmembers: [{role: 'postgres', admin: false}]};
expect(schemaObj.membersReadOnly(state)).toBe(true);
});

it('is editable for a user with ADMIN OPTION on the role', ()=>{
const schemaObj = createSchemaObject();
const state = {oid: 123, rolmembers: [{role: 'postgres', admin: true}]};
expect(schemaObj.membersReadOnly(state)).toBe(false);
});

it('is editable regardless when the user is a superuser/can create roles', ()=>{
const schemaObj = new RoleSchema(
()=>new MockSchema(),
()=>new MockSchema(),
{
role: ()=>[],
nodeInfo: {server: {user: {name: 'postgres', id: 0, is_superuser: true}}}
},
);
const state = {oid: 123, rolmembers: []};
expect(schemaObj.membersReadOnly(state)).toBe(false);
});
});
});

Loading