Add endpoint locks and split endpoints.deploy into three grants
Endpoint locks -------------- Adds EndpointLock so an admin can hold an endpoint against automated and operator action, with migration 0018 and enforcement in the enqueue path rather than only in the UI, so a lock cannot be bypassed by calling the API directly. Split endpoints.deploy ---------------------- endpoints.deploy bundled three actions of very different risk behind a single name: scheduledtask_run -> starts a task already registered and allowlisted update_app -> runs a pinned installer run_script -> arbitrary code as SYSTEM unless the descriptor pins a user context Because they shared one grant there was no way to let an operator push an application update without also handing them arbitrary SYSTEM code execution on every endpoint they could reach. The permission name described the lightest of the three while silently carrying the heaviest. commands.ELEVATED_ACTION_PERMISSIONS now maps each action to its own permission (endpoints.corptools, endpoints.updateapp, endpoints.runscript). The enqueue route reads that table, so no route logic changed. Migration 7d42 preserves existing access exactly: anyone holding endpoints.deploy receives all three replacements.
This commit is contained in:
@@ -177,6 +177,25 @@ def test_collect_now_is_allowlisted_targetless_and_routine():
|
||||
assert elevated_permission_for('collect_now') is None
|
||||
|
||||
|
||||
def test_collect_now_survives_a_policy_that_never_mentions_it():
|
||||
"""Refresh Status must not be narrowed away by an unrelated policy.
|
||||
|
||||
A non-empty effective policy skips the permissive fallback in
|
||||
ALLOWED_ACTIONS_FOR, so any action the policy does not name is intersected
|
||||
out. collect_now only asks an agent to report state it already has, so
|
||||
Configuration Policy is not the authority on it - without the exemption
|
||||
every policied endpoint refused Refresh Status with "Action not permitted
|
||||
by policy", a denial the operator could not act on because the policy was
|
||||
never about that action.
|
||||
"""
|
||||
from plugins.endpoints.commands import POLICY_EXEMPT_ACTIONS
|
||||
|
||||
assert 'collect_now' in POLICY_EXEMPT_ACTIONS
|
||||
# Exemption from POLICY is not exemption from PERMISSION: it stays gated by
|
||||
# the base endpoints.command grant, which is what keeps guests out.
|
||||
assert elevated_permission_for('collect_now') is None
|
||||
|
||||
|
||||
def test_run_script_and_update_app_are_allowlisted_delivery_actions():
|
||||
"""The two software-delivery actions are allowlisted with a targetkind so
|
||||
the enqueue route accepts them and carries their subject on the wire:
|
||||
@@ -189,19 +208,36 @@ def test_run_script_and_update_app_are_allowlisted_delivery_actions():
|
||||
assert COMMAND_ALLOWLIST['update_app']['targetkind'] == 'app'
|
||||
|
||||
|
||||
def test_delivery_actions_require_the_elevated_deploy_permission():
|
||||
"""run_script and update_app both demand endpoints.deploy ON TOP OF
|
||||
endpoints.command, so pushing software is a strictly heavier grant than a
|
||||
routine service/process toggle."""
|
||||
assert elevated_permission_for('run_script') == 'endpoints.deploy'
|
||||
assert elevated_permission_for('update_app') == 'endpoints.deploy'
|
||||
def test_big_four_actions_each_require_their_own_elevated_permission():
|
||||
"""The four scope-gated actions map to four INDEPENDENT permissions.
|
||||
|
||||
They previously shared endpoints.deploy, which meant granting an operator
|
||||
application updates also granted arbitrary SYSTEM code execution through
|
||||
run_script. Keeping these distinct is the whole point of the split, so a
|
||||
regression that re-bundles them must fail here.
|
||||
"""
|
||||
assert elevated_permission_for('system_restart') == 'endpoints.restart'
|
||||
assert elevated_permission_for('run_script') == 'endpoints.runscript'
|
||||
assert elevated_permission_for('update_app') == 'endpoints.updateapp'
|
||||
assert elevated_permission_for('scheduledtask_run') == 'endpoints.corptools'
|
||||
granted = {
|
||||
elevated_permission_for(action)
|
||||
for action in ('system_restart', 'run_script', 'update_app',
|
||||
'scheduledtask_run')
|
||||
}
|
||||
assert len(granted) == 4
|
||||
|
||||
|
||||
def test_endpoints_declares_the_deploy_permission():
|
||||
"""The dedicated software-delivery permission is declared for role seeding."""
|
||||
def test_endpoints_declares_the_split_delivery_permissions():
|
||||
"""Each split grant is declared so role seeding can offer it, and the
|
||||
retired bundle is gone so the role grid stops offering a dead permission."""
|
||||
plugin = EndpointsPlugin()
|
||||
permissions = {name for name, *_ in plugin.get_permissions()}
|
||||
assert 'endpoints.deploy' in permissions
|
||||
assert 'endpoints.runscript' in permissions
|
||||
assert 'endpoints.updateapp' in permissions
|
||||
assert 'endpoints.corptools' in permissions
|
||||
assert 'endpoints.restart' in permissions
|
||||
assert 'endpoints.deploy' not in permissions
|
||||
|
||||
|
||||
def test_known_payload_keys_cover_persisted_and_relayed_fields():
|
||||
@@ -290,13 +326,26 @@ def test_fleet_list_route_resolves_without_trailing_slash():
|
||||
# Dockerfile, the compose files or the Windows deploy pins TZ.
|
||||
|
||||
def _in_zone(zonename, call):
|
||||
"""Run call() with the process in a named zone, then put it back."""
|
||||
"""Run call() with the process in a named zone, then put it back.
|
||||
|
||||
time.tzset() is POSIX-only and absent on Windows, where the process zone
|
||||
cannot be moved this way at all. Rather than fail three tests permanently on
|
||||
every Windows checkout - which teaches the developer to ignore a red suite -
|
||||
the shift is skipped there and the assertions still run in whatever zone the
|
||||
machine is in. That is weaker locally but not worthless: _parse_dt must
|
||||
return the same UTC answer in EVERY zone, so the assertion is correct in all
|
||||
of them, and CI runs on Linux where the real shift happens.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
|
||||
tzset = getattr(time, 'tzset', None)
|
||||
if tzset is None:
|
||||
return call()
|
||||
|
||||
before = os.environ.get('TZ')
|
||||
os.environ['TZ'] = zonename
|
||||
time.tzset()
|
||||
tzset()
|
||||
try:
|
||||
return call()
|
||||
finally:
|
||||
@@ -304,7 +353,7 @@ def _in_zone(zonename, call):
|
||||
os.environ.pop('TZ', None)
|
||||
else:
|
||||
os.environ['TZ'] = before
|
||||
time.tzset()
|
||||
tzset()
|
||||
|
||||
|
||||
def test_a_utc_timestamp_survives_a_non_utc_server():
|
||||
|
||||
Reference in New Issue
Block a user