mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2026-11-02 08:10:03 -06:00
Users holding ADMIN OPTION on a role, directly or through an inherited membership as PostgreSQL itself allows, can now change its members, whilst all other property changes still require CREATEROLE.
This commit is contained in:
1 parent
0cbddf21ba
commit
e85db0fb8b
7 files changed
+521
-9
No files matched your search
@@ -567,6 +567,18 @@ rolmembership:{
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
data[k] = v
|
data[k] = v
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return precondition_required(
|
||||||
|
_("Request data must be a JSON object.")
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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 = [
|
invalid_msg_arr = [
|
||||||
self._validate_rolname(kwargs.get('rid', -1), data),
|
self._validate_rolname(kwargs.get('rid', -1), data),
|
||||||
self._validate_rolvaliduntil(data),
|
self._validate_rolvaliduntil(data),
|
||||||
@@ -619,6 +631,7 @@ rolmembership:{
|
|||||||
return fetch_name, check_permission, forbidden_msg
|
return fetch_name, check_permission, forbidden_msg
|
||||||
|
|
||||||
def _check_permission(self, check_permission, action, kwargs):
|
def _check_permission(self, check_permission, action, kwargs):
|
||||||
|
self.membership_only_update = False
|
||||||
if check_permission:
|
if check_permission:
|
||||||
user = self.manager.user_info
|
user = self.manager.user_info
|
||||||
|
|
||||||
@@ -627,6 +640,15 @@ rolmembership:{
|
|||||||
(action != 'update' or 'rid' in kwargs) and \
|
(action != 'update' or 'rid' in kwargs) and \
|
||||||
kwargs['rid'] != -1 and \
|
kwargs['rid'] != -1 and \
|
||||||
user['id'] != kwargs['rid']:
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -658,9 +680,25 @@ rolmembership:{
|
|||||||
self.role = row['rolname']
|
self.role = row['rolname']
|
||||||
self.rolCanLogin = row['rolcanlogin']
|
self.rolCanLogin = row['rolcanlogin']
|
||||||
self.rolSuper = row['rolsuper']
|
self.rolSuper = row['rolsuper']
|
||||||
|
self.has_admin_option = row.get('has_admin_option', False)
|
||||||
|
|
||||||
return False, ''
|
return False, ''
|
||||||
|
|
||||||
|
def _has_admin_option(self, rid):
|
||||||
|
"""Return True if the connecting user may GRANT/REVOKE membership
|
||||||
|
in the role 'rid', as determined by permission.sql. Any failure to
|
||||||
|
look it up counts as False, so the dialog fails closed."""
|
||||||
|
status, res = self.conn.execute_dict(
|
||||||
|
render_template(
|
||||||
|
self.sql_path + 'permission.sql',
|
||||||
|
rid=rid,
|
||||||
|
conn=self.conn
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not status or len(res['rows']) == 0:
|
||||||
|
return False
|
||||||
|
return bool(res['rows'][0].get('has_admin_option', False))
|
||||||
|
|
||||||
def check_precondition(action=None):
|
def check_precondition(action=None):
|
||||||
"""
|
"""
|
||||||
This function will behave as a decorator which will checks the status
|
This function will behave as a decorator which will checks the status
|
||||||
@@ -713,16 +751,20 @@ rolmembership:{
|
|||||||
fetch_name, check_permission, \
|
fetch_name, check_permission, \
|
||||||
forbidden_msg = RoleView._check_action(action, kwargs)
|
forbidden_msg = RoleView._check_action(action, kwargs)
|
||||||
|
|
||||||
is_permission_error = self._check_permission(check_permission,
|
# Fetched first: the permission check needs to know
|
||||||
action, kwargs)
|
# whether the current user holds ADMIN OPTION on this
|
||||||
if is_permission_error:
|
# role before it can decide whether to forbid the
|
||||||
return forbidden(forbidden_msg)
|
# request.
|
||||||
|
|
||||||
is_error, errmsg = self._check_and_fetch_name(fetch_name,
|
is_error, errmsg = self._check_and_fetch_name(fetch_name,
|
||||||
kwargs)
|
kwargs)
|
||||||
if is_error:
|
if is_error:
|
||||||
return errmsg
|
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 f(self, **kwargs)
|
||||||
|
|
||||||
return wrapped
|
return wrapped
|
||||||
@@ -895,6 +937,11 @@ rolmembership:{
|
|||||||
res['rows'][0]['is_sys_obj'] = (
|
res['rows'][0]['is_sys_obj'] = (
|
||||||
res['rows'][0]['oid'] <= self._DATABASE_LAST_SYSTEM_OID or
|
res['rows'][0]['oid'] <= self._DATABASE_LAST_SYSTEM_OID or
|
||||||
self.datistemplate)
|
self.datistemplate)
|
||||||
|
# Tell the dialog whether the connecting user may manage this role's
|
||||||
|
# membership (ADMIN OPTION, held directly or through another role).
|
||||||
|
# The same permission.sql drives the update guard, so the UI and the
|
||||||
|
# backend cannot disagree about who qualifies.
|
||||||
|
res['rows'][0]['has_admin_option'] = self._has_admin_option(rid)
|
||||||
res = {**res['rows'][0], 'variables': variables['rows']}
|
res = {**res['rows'][0], 'variables': variables['rows']}
|
||||||
return ajax_response(
|
return ajax_response(
|
||||||
response=res,
|
response=res,
|
||||||
@@ -1023,6 +1070,19 @@ rolmembership:{
|
|||||||
@check_precondition(action='update')
|
@check_precondition(action='update')
|
||||||
@validate_request
|
@validate_request
|
||||||
def update(self, gid, sid, rid):
|
def update(self, gid, sid, rid):
|
||||||
|
# The properties dialog always sends the role's 'oid' alongside the
|
||||||
|
# changed fields, so it is allowed here as well; the role being
|
||||||
|
# updated is identified by 'rid' from the URL, not by that value.
|
||||||
|
# request_keys is set by validate_request; if that has not run the
|
||||||
|
# guard must fail closed rather than let the request through.
|
||||||
|
if getattr(self, 'membership_only_update', False) and \
|
||||||
|
not getattr(self, 'request_keys', set()) <= \
|
||||||
|
{'rolmembers', 'oid'}:
|
||||||
|
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(
|
sql = render_template(
|
||||||
self.sql_path + self._UPDATE_SQL,
|
self.sql_path + self._UPDATE_SQL,
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export default class RoleSchema extends BaseUISchema {
|
|||||||
|
|
||||||
this.isReadOnly = null;
|
this.isReadOnly = null;
|
||||||
this.nodeInfo = this.fieldOptions.nodeInfo;
|
this.nodeInfo = this.fieldOptions.nodeInfo;
|
||||||
this.user = this.nodeInfo.server.user;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get idAttribute() {
|
get idAttribute() {
|
||||||
@@ -55,6 +54,19 @@ export default class RoleSchema extends BaseUISchema {
|
|||||||
return (!(user.is_superuser || user.can_create_role) && user.id != state.oid);
|
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, directly or
|
||||||
|
// through another role. The backend works that out (has_admin_option in
|
||||||
|
// the properties response) with the same query that gates the update,
|
||||||
|
// so it is not re-derived from the member list here.
|
||||||
|
isMemberAdmin(state) {
|
||||||
|
return Boolean(state?.has_admin_option);
|
||||||
|
}
|
||||||
|
|
||||||
|
membersReadOnly(state) {
|
||||||
|
return this.readOnly(state) && !this.isMemberAdmin(state);
|
||||||
|
}
|
||||||
|
|
||||||
memberDataFormatter(rawData) {
|
memberDataFormatter(rawData) {
|
||||||
let members = '';
|
let members = '';
|
||||||
if(_.isObject(rawData)) {
|
if(_.isObject(rawData)) {
|
||||||
@@ -194,8 +206,8 @@ export default class RoleSchema extends BaseUISchema {
|
|||||||
mode: ['edit', 'create'], cell: 'text',
|
mode: ['edit', 'create'], cell: 'text',
|
||||||
type: 'collection',
|
type: 'collection',
|
||||||
schema: obj.membershipSchema,
|
schema: obj.membershipSchema,
|
||||||
disabled: obj.readOnly,
|
disabled: (state) => obj.membersReadOnly(state),
|
||||||
canDelete: (state) => !obj.readOnly(state),
|
canDelete: (state) => !obj.membersReadOnly(state),
|
||||||
canDeleteRow: true,
|
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.'),
|
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.'),
|
||||||
},
|
},
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
SELECT
|
||||||
|
rolname, rolcanlogin, rolsuper,
|
||||||
|
-- Whether the connecting user may GRANT/REVOKE membership in this role.
|
||||||
|
-- On PostgreSQL 16 and later the server's own check
|
||||||
|
-- (check_role_membership_authorization and select_best_admin in user.c)
|
||||||
|
-- is has_admin_privs_of_role(): ADMIN OPTION held directly, or through a
|
||||||
|
-- chain of memberships each granted WITH INHERIT TRUE. pg_has_role(...,
|
||||||
|
-- 'WITH ADMIN OPTION') still uses is_admin_of_role(), which ignores
|
||||||
|
-- INHERIT and so says yes where the server answers "no possible
|
||||||
|
-- grantors", hence the explicit walk over pg_auth_members here.
|
||||||
|
(
|
||||||
|
SELECT rolsuper FROM pg_catalog.pg_roles WHERE rolname = current_user
|
||||||
|
) OR EXISTS (
|
||||||
|
WITH RECURSIVE admin_path(roleid, admin_option, inherit_option) AS (
|
||||||
|
SELECT am.roleid, am.admin_option, am.inherit_option
|
||||||
|
FROM pg_catalog.pg_auth_members am
|
||||||
|
WHERE am.member = (
|
||||||
|
SELECT oid FROM pg_catalog.pg_roles
|
||||||
|
WHERE rolname = current_user
|
||||||
|
)
|
||||||
|
UNION
|
||||||
|
SELECT am.roleid, am.admin_option, am.inherit_option
|
||||||
|
FROM pg_catalog.pg_auth_members am
|
||||||
|
JOIN admin_path p ON am.member = p.roleid
|
||||||
|
WHERE p.inherit_option
|
||||||
|
)
|
||||||
|
SELECT 1 FROM admin_path
|
||||||
|
WHERE roleid = {{ rid }}::OID AND admin_option
|
||||||
|
) AS has_admin_option
|
||||||
|
FROM
|
||||||
|
pg_catalog.pg_roles
|
||||||
|
WHERE oid = {{ rid }}::OID
|
||||||
+10
-1
@@ -1,5 +1,14 @@
|
|||||||
SELECT
|
SELECT
|
||||||
rolname, rolcanlogin, rolsuper
|
rolname, rolcanlogin, rolsuper,
|
||||||
|
-- Whether the connecting user may GRANT/REVOKE membership in this role.
|
||||||
|
-- On PostgreSQL 14 and 15 the server's own check (AddRoleMems/DelRoleMems
|
||||||
|
-- in user.c) is is_admin_of_role(), which walks pg_auth_members without
|
||||||
|
-- regard to rolinherit, and pg_has_role(..., 'WITH ADMIN OPTION') calls
|
||||||
|
-- exactly that function, so this matches the server's rule, including
|
||||||
|
-- ADMIN OPTION held through another role the current user is a member of.
|
||||||
|
pg_catalog.pg_has_role(
|
||||||
|
current_user, {{ rid }}::OID, 'USAGE WITH ADMIN OPTION'
|
||||||
|
) AS has_admin_option
|
||||||
FROM
|
FROM
|
||||||
pg_catalog.pg_roles
|
pg_catalog.pg_roles
|
||||||
WHERE oid = {{ rid }}::OID
|
WHERE oid = {{ rid }}::OID
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
##########################################################################
|
||||||
|
#
|
||||||
|
# pgAdmin 4 - PostgreSQL Tools
|
||||||
|
#
|
||||||
|
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
|
||||||
|
# This software is released under the PostgreSQL Licence
|
||||||
|
#
|
||||||
|
##########################################################################
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from flask import render_template
|
||||||
|
|
||||||
|
from pgadmin.utils.route import BaseTestGenerator
|
||||||
|
from regression import parent_node_dict
|
||||||
|
from regression.python_test_utils import test_utils as utils
|
||||||
|
|
||||||
|
|
||||||
|
class RoleAdminOptionPermissionTestCase(BaseTestGenerator):
|
||||||
|
"""Live test of the has_admin_option column in permission.sql.
|
||||||
|
|
||||||
|
The column must say exactly what the server itself allows for
|
||||||
|
GRANT/REVOKE of the role: ADMIN OPTION held directly, or through
|
||||||
|
another role the current user is a member of. On PostgreSQL 16 and
|
||||||
|
later a membership granted WITH INHERIT FALSE does not carry the
|
||||||
|
ADMIN OPTION along, whereas on 14 and 15 it does. Each case is
|
||||||
|
checked both against the expected value and against whether a real
|
||||||
|
GRANT as that role succeeds, so the template cannot drift from the
|
||||||
|
server's rule unnoticed.
|
||||||
|
"""
|
||||||
|
scenarios = [
|
||||||
|
('Check Role Node', dict(url='/browser/role/obj/'))
|
||||||
|
]
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.server_id = parent_node_dict["server"][-1]["server_id"]
|
||||||
|
suffix = str(uuid.uuid4())[1:8]
|
||||||
|
self.target = 'adm_target_%s' % suffix
|
||||||
|
self.newmember = 'adm_newmember_%s' % suffix
|
||||||
|
self.holder = 'adm_holder_%s' % suffix
|
||||||
|
self.direct = 'adm_direct_%s' % suffix
|
||||||
|
self.inherited = 'adm_inherited_%s' % suffix
|
||||||
|
self.noinherit = 'adm_noinherit_%s' % suffix
|
||||||
|
self.plain = 'adm_plain_%s' % suffix
|
||||||
|
self.member = 'adm_member_%s' % suffix
|
||||||
|
self.all_roles = [
|
||||||
|
self.target, self.newmember, self.holder, self.direct,
|
||||||
|
self.inherited, self.noinherit, self.plain, self.member
|
||||||
|
]
|
||||||
|
|
||||||
|
self.connection = utils.get_db_connection(
|
||||||
|
self.server['db'], self.server['username'],
|
||||||
|
self.server['db_password'], self.server['host'],
|
||||||
|
self.server['port'], self.server['sslmode'])
|
||||||
|
self.connection.autocommit = True
|
||||||
|
self.server_version = self.connection.info.server_version
|
||||||
|
|
||||||
|
cur = self.connection.cursor()
|
||||||
|
for role in self.all_roles:
|
||||||
|
cur.execute('CREATE ROLE "%s"' % role)
|
||||||
|
# direct: ADMIN OPTION on the target itself.
|
||||||
|
cur.execute('GRANT "%s" TO "%s" WITH ADMIN OPTION' %
|
||||||
|
(self.target, self.direct))
|
||||||
|
# holder has ADMIN OPTION; inherited and noinherit are members of
|
||||||
|
# holder, with and without INHERIT respectively.
|
||||||
|
cur.execute('GRANT "%s" TO "%s" WITH ADMIN OPTION' %
|
||||||
|
(self.target, self.holder))
|
||||||
|
cur.execute('GRANT "%s" TO "%s"' % (self.holder, self.inherited))
|
||||||
|
if self.server_version >= 160000:
|
||||||
|
cur.execute('GRANT "%s" TO "%s" WITH INHERIT FALSE' %
|
||||||
|
(self.holder, self.noinherit))
|
||||||
|
else:
|
||||||
|
cur.execute('ALTER ROLE "%s" NOINHERIT' % self.noinherit)
|
||||||
|
cur.execute('GRANT "%s" TO "%s"' % (self.holder, self.noinherit))
|
||||||
|
# member: plain membership of the target, no ADMIN OPTION.
|
||||||
|
cur.execute('GRANT "%s" TO "%s"' % (self.target, self.member))
|
||||||
|
cur.execute("SELECT oid FROM pg_catalog.pg_roles WHERE rolname = %s",
|
||||||
|
(self.target,))
|
||||||
|
self.target_oid = cur.fetchone()[0]
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
def _has_admin_option(self, as_role):
|
||||||
|
with self.app.app_context():
|
||||||
|
sql = render_template(
|
||||||
|
'roles/sql/#{0}#/permission.sql'.format(self.server_version),
|
||||||
|
rid=self.target_oid, conn=None)
|
||||||
|
cur = self.connection.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute('SET ROLE "%s"' % as_role)
|
||||||
|
cur.execute(sql)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return row[3]
|
||||||
|
finally:
|
||||||
|
cur.execute('RESET ROLE')
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
def _server_allows_grant(self, as_role):
|
||||||
|
cur = self.connection.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute('SET ROLE "%s"' % as_role)
|
||||||
|
try:
|
||||||
|
cur.execute('GRANT "%s" TO "%s"' %
|
||||||
|
(self.target, self.newmember))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
cur.execute('REVOKE "%s" FROM "%s"' %
|
||||||
|
(self.target, self.newmember))
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
cur.execute('RESET ROLE')
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
def runTest(self):
|
||||||
|
noinherit_expected = self.server_version < 160000
|
||||||
|
cases = [
|
||||||
|
(self.direct, True),
|
||||||
|
(self.inherited, True),
|
||||||
|
(self.noinherit, noinherit_expected),
|
||||||
|
(self.plain, False),
|
||||||
|
(self.member, False),
|
||||||
|
]
|
||||||
|
for role, expected in cases:
|
||||||
|
self.assertEqual(
|
||||||
|
self._has_admin_option(role), expected,
|
||||||
|
'has_admin_option for %s' % role)
|
||||||
|
self.assertEqual(
|
||||||
|
self._server_allows_grant(role), expected,
|
||||||
|
'server GRANT authorisation for %s' % role)
|
||||||
|
|
||||||
|
# The properties response carries the flag for the dialog; the
|
||||||
|
# test server user is a superuser, who always qualifies.
|
||||||
|
response = self.tester.get(
|
||||||
|
self.url + str(utils.SERVER_GROUP) + '/' +
|
||||||
|
str(self.server_id) + '/' + str(self.target_oid),
|
||||||
|
follow_redirects=True)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.json['has_admin_option'])
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
cur = self.connection.cursor()
|
||||||
|
for role in self.all_roles:
|
||||||
|
cur.execute('DROP ROLE IF EXISTS "%s"' % role)
|
||||||
|
cur.close()
|
||||||
|
self.connection.close()
|
||||||
+218
@@ -0,0 +1,218 @@
|
|||||||
|
##########################################################################
|
||||||
|
#
|
||||||
|
# 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'})
|
||||||
|
|
||||||
|
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 body the properties dialog actually sends: the changed
|
||||||
|
# 'rolmembers' collection plus the role's 'oid', which
|
||||||
|
# SchemaState.changes() appends to every edit-mode payload.
|
||||||
|
# 'rolmembers' is exactly what an ADMIN OPTION holder (who may
|
||||||
|
# manage membership only) is allowed to change.
|
||||||
|
body = {
|
||||||
|
'oid': 10,
|
||||||
|
'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)
|
||||||
|
|
||||||
|
# Anything beyond membership is still forbidden to an ADMIN
|
||||||
|
# OPTION holder, so this can't be used to escalate the role.
|
||||||
|
body['rolsuper'] = True
|
||||||
|
with self.app.test_request_context(
|
||||||
|
data=json.dumps(body), content_type='application/json'
|
||||||
|
):
|
||||||
|
response = view.update(gid=1, sid=1, rid=10)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
# A body that isn't a JSON object is rejected as a client error
|
||||||
|
# rather than failing with a server error.
|
||||||
|
with self.app.test_request_context(
|
||||||
|
data=json.dumps([{}]), content_type='application/json'
|
||||||
|
):
|
||||||
|
response = view.update(gid=1, sid=1, rid=10)
|
||||||
|
self.assertEqual(response.status_code, 428)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
pass
|
||||||
@@ -45,5 +45,42 @@ describe('RoleSchema', ()=>{
|
|||||||
it('properties', async ()=>{
|
it('properties', async ()=>{
|
||||||
await getPropertiesView(createSchemaObject(), getInitData);
|
await getPropertiesView(createSchemaObject(), getInitData);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('membersReadOnly', ()=>{
|
||||||
|
it('is read only for a plain user without ADMIN OPTION on the role', ()=>{
|
||||||
|
const schemaObj = createSchemaObject();
|
||||||
|
const state = {oid: 123, has_admin_option: false, rolmembers: [{role: 'postgres', admin: false}]};
|
||||||
|
expect(schemaObj.membersReadOnly(state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is read only when the backend has not said whether ADMIN OPTION is held', ()=>{
|
||||||
|
const schemaObj = createSchemaObject();
|
||||||
|
// Fail closed: a direct admin row in the member list is not enough on
|
||||||
|
// its own, the backend flag is the only thing consulted.
|
||||||
|
const state = {oid: 123, rolmembers: [{role: 'postgres', admin: true}]};
|
||||||
|
expect(schemaObj.membersReadOnly(state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is editable for a user the backend reports as holding ADMIN OPTION', ()=>{
|
||||||
|
const schemaObj = createSchemaObject();
|
||||||
|
// Covers ADMIN OPTION inherited through another role too: the user
|
||||||
|
// need not appear in the member list at all.
|
||||||
|
const state = {oid: 123, has_admin_option: true, rolmembers: []};
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Reference in new issue
Block a user