Fix awx CLI modify command for users with object-level permissions (#16276)

The awx CLI derives available fields for the `modify` command from
the list endpoint's POST action schema. Users with object-level
admin permissions (e.g., Project Admin) but no list-level POST
permission see no field flags, making modify unusable despite having
PUT access on the detail endpoint.

Fall back to the detail endpoint's action schema when POST is not
available on the list endpoint, and prefer PUT over POST when
building modify arguments.

Signed-off-by: Seth Foster <fosterbseth@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Seth Foster
2026-02-12 13:26:15 -05:00
committed by GitHub
parent c515b86fa6
commit 9700fb01f2
3 changed files with 65 additions and 1 deletions

View File

@@ -251,7 +251,13 @@ class CLI(object):
if self.resource != 'settings':
for method in ('list', 'modify', 'create'):
if method in parser.parser.choices:
parser.build_query_arguments(method, 'GET' if method == 'list' else 'POST')
if method == 'list':
http_method = 'GET'
elif method == 'modify' and 'PUT' in parser.options:
http_method = 'PUT'
else:
http_method = 'POST'
parser.build_query_arguments(method, http_method)
if from_sphinx:
parsed, extra = self.parser.parse_known_args(self.argv)
else:

View File

@@ -102,6 +102,18 @@ class ResourceOptionsParser(object):
if '299' in warning and 'deprecated' in warning:
self.deprecated = True
self.allowed_options = options.headers.get('Allow', '').split(', ')
# If the user can PUT on the detail endpoint but doesn't have
# POST on the list endpoint, use the detail endpoint's
# action schema so that 'modify' fields are populated.
if 'POST' not in self.options and 'PUT' in self.allowed_options:
try:
detail_actions = options.json().get('actions', {})
except Exception:
detail_actions = {}
if 'PUT' in detail_actions:
self.options['PUT'] = detail_actions['PUT']
elif 'GET' in detail_actions:
self.options['PUT'] = detail_actions['GET']
def build_list_actions(self):
action_map = {
@@ -109,6 +121,10 @@ class ResourceOptionsParser(object):
'POST': 'create',
}
for method, action in self.options.items():
# Skip 'PUT', which may be added by get_allowed_options
# and is handled separately by build_detail_actions
if method not in action_map:
continue
method = action_map[method]
parser = self.parser.add_parser(method, help='')
if method == 'list':

View File

@@ -11,6 +11,24 @@ class ResourceOptionsParser(ResourceOptionsParser):
self.allowed_options = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
class NoPostResourceOptionsParser(ResourceOptionsParser):
"""Simulates a user with object-level PUT but no list-level POST."""
detail_put_actions = {}
def get_allowed_options(self):
self.allowed_options = ['GET', 'PUT', 'PATCH', 'DELETE']
# Simulate the logic from the real get_allowed_options that
# falls back to the detail endpoint's PUT schema when POST
# is not available on the list endpoint.
if 'POST' not in self.options and 'PUT' in self.allowed_options:
if self.detail_put_actions:
self.options['PUT'] = self.detail_put_actions
def handle_custom_actions(self):
pass
class OptionsPage(Page):
def options(self):
return self
@@ -185,6 +203,30 @@ class TestOptions(unittest.TestCase):
self.parser.choices[method].print_help(out)
assert 'positional arguments:\n id' in out.getvalue()
def test_modify_without_list_post(self):
"""User with object-level PUT but no list-level POST can still modify."""
page = OptionsPage.from_json(
{
'actions': {
'GET': {},
}
}
)
NoPostResourceOptionsParser.detail_put_actions = {
'scm_branch': {'type': 'string', 'help_text': 'SCM branch'},
'description': {'type': 'string', 'help_text': 'Description'},
}
options = NoPostResourceOptionsParser(None, page, 'projects', self.parser)
assert 'modify' in self.parser.choices
assert 'create' not in self.parser.choices
options.build_query_arguments('modify', 'PUT')
out = StringIO()
self.parser.choices['modify'].print_help(out)
assert '--scm_branch TEXT' in out.getvalue()
assert '--description TEXT' in out.getvalue()
class TestSettingsOptions(unittest.TestCase):
def setUp(self):