Skip to content

Commit e2e35dc

Browse files
Add confirmation prompt for destructive bulk Admin API methods
Selector-style methods (delete_all_resources, delete_resources_by_prefix, delete_resources_by_tag) can wipe up to 1000 matching assets per call without the caller enumerating each target. Prompt before invoking them via handle_api_command, bypassable with the existing -F/--force flag. Explicit-ID deletes, tag/context updates, restores, folder ops and reads are unaffected.
1 parent 6c61c37 commit e2e35dc

4 files changed

Lines changed: 124 additions & 1 deletion

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@ venv
1010
.idea
1111

1212
.cld-sync
13+
.cld-settings
14+
.venv

cloudinary_cli/core/admin.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
help="Pass optional parameters as interpreted strings.")
2222
@option("-A", "--auto_paginate", is_flag=True, help="Will auto paginate Admin API calls.", default=False)
2323
@option("-ff", "--filter_fields", multiple=True, help="Filter fields to return when using auto pagination.")
24-
@option("-F", "--force", is_flag=True, help="Skip confirmation when running --auto-paginate.")
24+
@option("-F", "--force", is_flag=True,
25+
help="Skip confirmations for auto pagination and destructive bulk API methods.")
2526
@option("-ls", "--ls", is_flag=True, help="List all available methods in the Admin API.")
2627
@option("--save", nargs=1, help="Save output to a file.")
2728
@option("-d", "--doc", is_flag=True, help="Open the Admin API reference in a browser.")

cloudinary_cli/utils/api_utils.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,37 @@
2020

2121
_cursor_fields = {"resource": "derived_next_cursor"}
2222

23+
# Selector-style destructive bulk Admin API methods.
24+
# These can match an open-ended set of assets without the caller enumerating each target,
25+
# so an interactive confirmation is required (bypassable via --force).
26+
DESTRUCTIVE_BULK_API_METHODS = {
27+
"delete_all_resources",
28+
"delete_resources_by_prefix",
29+
"delete_resources_by_tag",
30+
}
31+
32+
# Server-enforced cap on how many resources a single destructive bulk call can affect
33+
# (NConfig.max_resource_count_for_delete). Used purely for clearer prompt wording.
34+
MAX_DESTRUCTIVE_BULK_PER_CALL = 1000
35+
36+
37+
def is_destructive_bulk_api_method(method_name):
38+
return method_name in DESTRUCTIVE_BULK_API_METHODS
39+
40+
41+
def confirm_destructive_bulk_api_method(api_name, method_name, force=False):
42+
if force or not is_destructive_bulk_api_method(method_name):
43+
return True
44+
45+
display_api_name = api_name.capitalize()
46+
if not confirm_action(
47+
f"This will delete up to {MAX_DESTRUCTIVE_BULK_PER_CALL} matching assets via "
48+
f"{display_api_name} API method '{method_name}'. Continue? (y/N)"):
49+
logger.info("Stopping.")
50+
return False
51+
52+
return True
53+
2354

2455
def query_cld_folder(folder, folder_mode, status=None):
2556
files = {}
@@ -311,6 +342,9 @@ def handle_api_command(
311342
log_exception(e)
312343
return False
313344

345+
if not confirm_destructive_bulk_api_method(api_name, func.__name__, force):
346+
return False
347+
314348
if not is_valid_cloudinary_config():
315349
raise ConfigurationError("No Cloudinary configuration found.")
316350

test/test_cli_api.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
API_MOCK_RESPONSE = api_response_mock()
1212
UPLOAD_MOCK_RESPONSE = uploader_response_mock()
1313

14+
CONFIRM_ACTION_PATCH = "cloudinary_cli.utils.api_utils.confirm_action"
15+
1416

1517
class TestCLIApi(unittest.TestCase):
1618
runner = CliRunner()
@@ -39,3 +41,87 @@ def test_provisioning(self, mocker):
3941

4042
self.assertEqual(0, result.exit_code, result.output)
4143
self.assertIn('"foo": "bar"', result.output)
44+
45+
46+
class TestDestructiveBulkConfirmation(unittest.TestCase):
47+
runner = CliRunner()
48+
49+
@patch(CONFIRM_ACTION_PATCH, return_value=False)
50+
@patch(URLLIB3_REQUEST)
51+
def test_delete_all_resources_decline_skips_call(self, http_mock, confirm_mock):
52+
http_mock.return_value = API_MOCK_RESPONSE
53+
result = self.runner.invoke(cli, ['admin', 'delete_all_resources'])
54+
55+
self.assertEqual(0, result.exit_code, result.output)
56+
confirm_mock.assert_called_once()
57+
self.assertFalse(http_mock.called, "SDK should not be called when user declines")
58+
59+
@patch(CONFIRM_ACTION_PATCH, return_value=True)
60+
@patch(URLLIB3_REQUEST)
61+
def test_delete_all_resources_accept_calls_sdk(self, http_mock, confirm_mock):
62+
http_mock.return_value = API_MOCK_RESPONSE
63+
result = self.runner.invoke(cli, ['admin', 'delete_all_resources'])
64+
65+
self.assertEqual(0, result.exit_code, result.output)
66+
confirm_mock.assert_called_once()
67+
self.assertTrue(http_mock.called, "SDK should be called when user accepts")
68+
69+
@patch(CONFIRM_ACTION_PATCH, return_value=False)
70+
@patch(URLLIB3_REQUEST)
71+
def test_delete_all_resources_force_skips_prompt(self, http_mock, confirm_mock):
72+
http_mock.return_value = API_MOCK_RESPONSE
73+
result = self.runner.invoke(cli, ['admin', '-F', 'delete_all_resources'])
74+
75+
self.assertEqual(0, result.exit_code, result.output)
76+
self.assertFalse(confirm_mock.called, "--force should bypass the confirmation prompt")
77+
self.assertTrue(http_mock.called, "SDK should be called when --force is set")
78+
79+
@patch(CONFIRM_ACTION_PATCH, return_value=False)
80+
@patch(URLLIB3_REQUEST)
81+
def test_delete_resources_by_tag_decline_skips_call(self, http_mock, confirm_mock):
82+
http_mock.return_value = API_MOCK_RESPONSE
83+
result = self.runner.invoke(cli, ['admin', 'delete_resources_by_tag', 'mytag'])
84+
85+
self.assertEqual(0, result.exit_code, result.output)
86+
confirm_mock.assert_called_once()
87+
self.assertFalse(http_mock.called, "SDK should not be called when user declines")
88+
89+
@patch(CONFIRM_ACTION_PATCH, return_value=False)
90+
@patch(URLLIB3_REQUEST)
91+
def test_delete_resources_explicit_ids_no_prompt(self, http_mock, confirm_mock):
92+
http_mock.return_value = API_MOCK_RESPONSE
93+
result = self.runner.invoke(cli, ['admin', 'delete_resources', 'public_id1', 'public_id2'])
94+
95+
self.assertEqual(0, result.exit_code, result.output)
96+
self.assertFalse(confirm_mock.called, "Explicit-ID delete must not prompt")
97+
self.assertTrue(http_mock.called, "SDK should be called for explicit-ID delete")
98+
99+
@patch(CONFIRM_ACTION_PATCH, return_value=False)
100+
@patch(URLLIB3_REQUEST)
101+
def test_uploader_add_tag_no_prompt(self, http_mock, confirm_mock):
102+
http_mock.return_value = UPLOAD_MOCK_RESPONSE
103+
result = self.runner.invoke(cli, ['uploader', 'add_tag', 'mytag', 'public_id1'])
104+
105+
self.assertEqual(0, result.exit_code, result.output)
106+
self.assertFalse(confirm_mock.called, "Non-destructive bulk methods must not prompt")
107+
self.assertTrue(http_mock.called, "SDK should be called for non-destructive bulk methods")
108+
109+
@patch(CONFIRM_ACTION_PATCH, return_value=False)
110+
@patch(URLLIB3_REQUEST)
111+
def test_admin_resources_read_no_prompt(self, http_mock, confirm_mock):
112+
http_mock.return_value = API_MOCK_RESPONSE
113+
result = self.runner.invoke(cli, ['admin', 'resources'])
114+
115+
self.assertEqual(0, result.exit_code, result.output)
116+
self.assertFalse(confirm_mock.called, "Read commands must not prompt")
117+
self.assertTrue(http_mock.called, "SDK should be called for read commands")
118+
119+
@patch(CONFIRM_ACTION_PATCH, return_value=False)
120+
@patch(URLLIB3_REQUEST)
121+
def test_admin_resources_read_with_force_no_prompt(self, http_mock, confirm_mock):
122+
http_mock.return_value = API_MOCK_RESPONSE
123+
result = self.runner.invoke(cli, ['admin', '-F', 'resources'])
124+
125+
self.assertEqual(0, result.exit_code, result.output)
126+
self.assertFalse(confirm_mock.called, "Read commands must not prompt regardless of --force")
127+
self.assertTrue(http_mock.called)

0 commit comments

Comments
 (0)