Add multi-action policy holds and exempt collect_now from policy
Adds PolicyActionHold so an admin can suspend specific actions on a computer without editing the policy itself, covering the break-glass case where a machine must be quiesced immediately. Holds cover multiple actions in one atomic submission rather than one row per action, so a partial failure cannot leave a half-applied hold. Duration is free-form with 0 meaning indefinite (migration 0006 makes expiresat nullable); the prior fixed ceiling was removed because an admin quiescing a misbehaving machine does not know in advance how long it needs, and a hold silently expiring is worse than one that must be cleared deliberately. The What This Does explainer reflects indefinite holds. collect_now exemption: refreshing status was denied on any endpoint with a non-empty effective policy even when the caller held the matching grant, because policy intersection silently removed the action. collect_now is a read-only status refresh with no side effects, so it now sits in POLICY_EXEMPT_ACTIONS alongside the other routine actions. Verified against a synthetic non-empty policy: operators can refresh status again while every write action stays gated.
This commit is contained in:
@@ -14,7 +14,7 @@ allowlist, so a bad tree or an unknown action never reaches the database.
|
||||
from flask import request
|
||||
|
||||
from shopdb.api import (db, AuditLog, success_response, error_response,
|
||||
ErrorCodes, require_permission)
|
||||
ErrorCodes, require_permission, caller_holds)
|
||||
|
||||
from .routes import computers_bp
|
||||
from ..models import (ConfigurationPolicy, ConfigurationPolicyAction,
|
||||
@@ -26,6 +26,77 @@ from ..services.policyscope import validate_scope, ScopeError
|
||||
_MODES = {ACTION_MODE_DESIRED, ACTION_MODE_GATE}
|
||||
|
||||
|
||||
def _elevated_denial(actionnames):
|
||||
"""Refuse unless the caller may author every elevated action in actionnames.
|
||||
|
||||
A policy is a deferred instruction to run something. Gating only the enqueue
|
||||
route left authoring wide open: computers.edit alone could author a policy
|
||||
carrying run_script and let the agent execute it, which is the whole point of
|
||||
splitting the big four undone. The permission to CAUSE an action must be the
|
||||
same permission required to RUN it.
|
||||
|
||||
caller_holds, not current_user.haspermission: a scoped personal token is a
|
||||
narrower caller than its owner, and asking the owner is the exact bypass
|
||||
require_permission exists to prevent.
|
||||
"""
|
||||
from plugins.endpoints.commands import elevated_permission_for
|
||||
|
||||
missing = []
|
||||
for name in sorted(set(actionnames)):
|
||||
elevated = elevated_permission_for(name)
|
||||
if elevated and not caller_holds(elevated) and elevated not in missing:
|
||||
missing.append(elevated)
|
||||
if not missing:
|
||||
return None
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'This policy carries actions you may not author. Missing: '
|
||||
+ ', '.join(missing),
|
||||
http_code=403)
|
||||
|
||||
|
||||
def _requested_actionnames(actions):
|
||||
return [(row.get('action') or '').strip() for row in actions or []]
|
||||
|
||||
|
||||
def _policy_actionnames(policy):
|
||||
return [a.action for a in policy.actions]
|
||||
|
||||
|
||||
def _composed_actionnames(configpolicyids):
|
||||
"""Every action reachable through a set of configuration policies.
|
||||
|
||||
Composition is an authoring act: adding a configuration policy to a business
|
||||
policy causes its actions to run on everything the business policy targets,
|
||||
without the composer ever naming those actions.
|
||||
"""
|
||||
names = []
|
||||
for cpid in configpolicyids:
|
||||
policy = db.session.get(ConfigurationPolicy, cpid)
|
||||
if policy:
|
||||
names.extend(_policy_actionnames(policy))
|
||||
return names
|
||||
|
||||
|
||||
def _changes_effect(data, policy=None):
|
||||
"""Whether this payload changes WHAT a policy does, rather than how it reads.
|
||||
|
||||
Scope widening, reactivation, priority and composition all change the effect
|
||||
of an already-authored action without touching the action list, so gating the
|
||||
action payload alone would leave every one of them open. Renaming a policy or
|
||||
editing its description changes nothing that runs, so it stays ungated.
|
||||
"""
|
||||
for field in ('actions', 'items', 'scopejson', 'priority'):
|
||||
if field in data:
|
||||
return True
|
||||
if 'isactive' in data:
|
||||
# Only reactivation matters. Deactivating a policy cannot widen anything,
|
||||
# and refusing it would strand a live policy nobody present can switch off.
|
||||
was = getattr(policy, 'isactive', False) if policy is not None else False
|
||||
return bool(data['isactive']) and not was
|
||||
return False
|
||||
|
||||
|
||||
def _feature_gate():
|
||||
"""Return a 404 response when the policy feature is off, else None."""
|
||||
if not policy_flag_enabled():
|
||||
@@ -155,6 +226,10 @@ def create_configuration_policy():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Invalid scope: {exc}')
|
||||
|
||||
denial = _elevated_denial(_requested_actionnames(data.get('actions')))
|
||||
if denial:
|
||||
return denial
|
||||
|
||||
policy = ConfigurationPolicy(
|
||||
name=name,
|
||||
description=data.get('description'),
|
||||
@@ -189,6 +264,17 @@ def update_configuration_policy(policy_id: int):
|
||||
http_code=409)
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Both sides. The incoming actions are what the caller is asking to cause;
|
||||
# the existing ones are what a scope widening or reactivation would newly
|
||||
# unleash on machines that were never in range before.
|
||||
if _changes_effect(data, policy):
|
||||
names = list(_policy_actionnames(policy))
|
||||
if 'actions' in data:
|
||||
names.extend(_requested_actionnames(data['actions']))
|
||||
denial = _elevated_denial(names)
|
||||
if denial:
|
||||
return denial
|
||||
|
||||
if 'name' in data:
|
||||
newname = (data['name'] or '').strip()
|
||||
if newname and newname != policy.name:
|
||||
@@ -242,6 +328,11 @@ def delete_configuration_policy(policy_id: int):
|
||||
ErrorCodes.CONFLICT,
|
||||
f'Cannot delete: used by {inuse} business policy item(s)',
|
||||
http_code=409)
|
||||
# Deleting a gate policy REMOVES a restriction, so it changes what endpoints
|
||||
# will run just as surely as authoring one does.
|
||||
denial = _elevated_denial(_policy_actionnames(policy))
|
||||
if denial:
|
||||
return denial
|
||||
db.session.delete(policy)
|
||||
AuditLog.log('deleted', 'ConfigurationPolicy', entityname=policy.name)
|
||||
db.session.commit()
|
||||
@@ -324,6 +415,12 @@ def create_business_policy():
|
||||
priority=data.get('priority', 100),
|
||||
source=POLICY_SOURCE_LOCAL,
|
||||
)
|
||||
requested = [row.get('configurationpolicyid')
|
||||
for row in data.get('items') or []]
|
||||
denial = _elevated_denial(_composed_actionnames(
|
||||
[cpid for cpid in requested if cpid is not None]))
|
||||
if denial:
|
||||
return denial
|
||||
err = _apply_items(policy, data.get('items'))
|
||||
if err:
|
||||
return err
|
||||
@@ -349,6 +446,20 @@ def update_business_policy(policy_id: int):
|
||||
'Policy is managed externally and is read-only',
|
||||
http_code=409)
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Composition is the quiet escalation: attaching a configuration policy runs
|
||||
# its actions everywhere this business policy reaches, without the composer
|
||||
# naming a single one of them.
|
||||
if _changes_effect(data, policy):
|
||||
cpids = [it.configurationpolicyid for it in policy.items]
|
||||
for row in data.get('items') or []:
|
||||
cpid = row.get('configurationpolicyid')
|
||||
if cpid is not None:
|
||||
cpids.append(cpid)
|
||||
denial = _elevated_denial(_composed_actionnames(cpids))
|
||||
if denial:
|
||||
return denial
|
||||
|
||||
if 'name' in data:
|
||||
newname = (data['name'] or '').strip()
|
||||
if newname and newname != policy.name:
|
||||
@@ -387,6 +498,10 @@ def delete_business_policy(policy_id: int):
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
'Policy is managed externally and is read-only',
|
||||
http_code=409)
|
||||
denial = _elevated_denial(_composed_actionnames(
|
||||
[it.configurationpolicyid for it in policy.items]))
|
||||
if denial:
|
||||
return denial
|
||||
db.session.delete(policy)
|
||||
AuditLog.log('deleted', 'BusinessPolicy', entityname=policy.name)
|
||||
db.session.commit()
|
||||
@@ -405,16 +520,29 @@ def list_policy_actions():
|
||||
Returns command actions tagged kind='command'. Monitor attachments are not
|
||||
listed here: the UI builds that branch from the endpoints monitor catalog
|
||||
(endpointsApi.monitors) so newly created monitors appear without a reload.
|
||||
|
||||
Actions the caller may not author are marked rather than hidden. Hiding them
|
||||
would make the editor look like the capability does not exist; marking them
|
||||
says it exists and is not yours, which is the difference between a confusing
|
||||
UI and an honest one. The authoring gate is enforced server-side regardless.
|
||||
"""
|
||||
gate = _feature_gate()
|
||||
if gate:
|
||||
return gate
|
||||
from plugins.endpoints.commands import COMMAND_ALLOWLIST
|
||||
return success_response([
|
||||
{'action': name, 'label': meta.get('label'),
|
||||
'description': meta.get('description'), 'kind': 'command'}
|
||||
for name, meta in COMMAND_ALLOWLIST.items()
|
||||
])
|
||||
from plugins.endpoints.commands import (COMMAND_ALLOWLIST,
|
||||
elevated_permission_for)
|
||||
catalog = []
|
||||
for name, meta in COMMAND_ALLOWLIST.items():
|
||||
elevated = elevated_permission_for(name)
|
||||
catalog.append({
|
||||
'action': name,
|
||||
'label': meta.get('label'),
|
||||
'description': meta.get('description'),
|
||||
'kind': 'command',
|
||||
'requiredpermission': elevated,
|
||||
'mayauthor': not elevated or caller_holds(elevated),
|
||||
})
|
||||
return success_response(catalog)
|
||||
|
||||
|
||||
@computers_bp.route('/policies/effective/<int:asset_id>', methods=['GET'])
|
||||
|
||||
Reference in New Issue
Block a user