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:
@@ -57,17 +57,39 @@ class EffectivePolicy:
|
||||
actions maps action name -> EffectiveAction (first-match-wins already
|
||||
applied). Convenience views expose the gate allowlist and desired-state set
|
||||
that enforcement and the endpoints seam consume.
|
||||
|
||||
heldactions are action names under an unexpired break-glass hold. They are
|
||||
withheld from desired_actions (reconciliation stops fighting the admin) but
|
||||
deliberately LEFT IN allowed_actions: a hold suspends drift correction, it
|
||||
does not revoke permission to run the action by hand.
|
||||
"""
|
||||
|
||||
def __init__(self, actions):
|
||||
def __init__(self, actions, heldactions=None):
|
||||
self.actions = actions
|
||||
self.heldactions = set(heldactions or ())
|
||||
|
||||
def allowed_actions(self):
|
||||
"""Set of action names permitted on this endpoint (gate + desired)."""
|
||||
return set(self.actions.keys())
|
||||
|
||||
def desired_actions(self):
|
||||
"""action name -> EffectiveAction for mode == 'desired' only."""
|
||||
"""action name -> EffectiveAction for mode == 'desired' only.
|
||||
|
||||
Held actions are excluded, so the reconciler does not re-enqueue the
|
||||
very change an admin just broke glass to make.
|
||||
"""
|
||||
return {name: eff for name, eff in self.actions.items()
|
||||
if eff.mode == 'desired' and name not in self.heldactions}
|
||||
|
||||
def holdable_actions(self):
|
||||
"""Desired-mode action names, INCLUDING ones already held.
|
||||
|
||||
Deliberately not desired_actions(): that view hides held actions, so
|
||||
building a "what can I hold" picker from it would drop an action the
|
||||
moment it was held and make the current state unreadable. Only
|
||||
desired-mode actions appear, because a hold suspends reconciliation and
|
||||
gate-mode actions have none to suspend.
|
||||
"""
|
||||
return {name: eff for name, eff in self.actions.items()
|
||||
if eff.mode == 'desired'}
|
||||
|
||||
@@ -165,7 +187,35 @@ def effective_policy_for(assetid):
|
||||
return EffectivePolicy({})
|
||||
|
||||
business_policies = BusinessPolicy.query.filter_by(isactive=True).all()
|
||||
return build_effective_policy(computer, business_policies)
|
||||
effective = build_effective_policy(computer, business_policies)
|
||||
effective.heldactions = _held_actions_for(assetid)
|
||||
return effective
|
||||
|
||||
|
||||
def _held_actions_for(assetid):
|
||||
"""Action names under an unexpired break-glass hold for this endpoint.
|
||||
|
||||
Expiry is compared here, at resolve time, rather than swept by a background
|
||||
job. A sweeper that stalled would silently keep every hold it failed to
|
||||
clear alive, which is reconciliation disabled by accident - so the row's own
|
||||
expiresat is the single authority on whether it still applies.
|
||||
|
||||
A null expiresat is an INDEFINITE hold and is always active. It must be
|
||||
matched explicitly: a bare `expiresat > now` drops null rows, which would
|
||||
resume reconciliation of exactly the actions an admin pinned open-endedly -
|
||||
the reconciler restarting a service someone deliberately stopped.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import or_
|
||||
from ..models import PolicyActionHold
|
||||
|
||||
rows = (PolicyActionHold.query
|
||||
.filter(PolicyActionHold.assetid == assetid,
|
||||
PolicyActionHold.isactive.is_(True),
|
||||
or_(PolicyActionHold.expiresat.is_(None),
|
||||
PolicyActionHold.expiresat > datetime.utcnow()))
|
||||
.all())
|
||||
return {row.action for row in rows}
|
||||
|
||||
|
||||
def _monitor_action_ids(effective):
|
||||
|
||||
Reference in New Issue
Block a user