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:
benjamin.baker
2026-09-07 12:38:55 -04:00
parent 627d3f5ff5
commit 52c9d2b009
10 changed files with 2174 additions and 19 deletions

View File

@@ -16,6 +16,8 @@ from .policy import (
POLICY_SOURCE_SHIPPED,
ACTION_MODE_DESIRED,
ACTION_MODE_GATE,
PolicyActionHold,
DEFAULT_HOLD_MINUTES,
)
__all__ = [
@@ -32,4 +34,6 @@ __all__ = [
'POLICY_SOURCE_SHIPPED',
'ACTION_MODE_DESIRED',
'ACTION_MODE_GATE',
'PolicyActionHold',
'DEFAULT_HOLD_MINUTES',
]

View File

@@ -202,3 +202,82 @@ class BusinessPolicyItem(BaseModel):
def __repr__(self):
return (f"<BusinessPolicyItem bp={self.businesspolicyid} "
f"cp={self.configurationpolicyid}>")
# Default life of a break-glass suppression hold, used when the caller names no
# window. Long enough to comfortably outlast one maintenance window, short
# enough that nobody has to remember to clear it: policy resumes on its own.
#
# A caller MAY name any window instead, including none at all (expiresat NULL,
# an indefinite hold). That is a deliberate escape hatch for work that cannot be
# bounded up front, and it forfeits the self-healing this default exists to
# provide - see the class docstring.
DEFAULT_HOLD_MINUTES = 240
class PolicyActionHold(BaseModel):
"""A time-boxed suspension of desired-state reconciliation for one action.
An admin break-glass override on a `gate`-mode action is a one-shot - policy
only said "not exposed", and nothing reverses it. An override on a
`desired`-mode action is a fight: the admin stops a service and the
reconciliation loop restarts it on the next check-in, so the override looks
broken in production. A hold records that fight's outcome server-side, and
the resolver drops the held action out of the desired set until it expires.
Expiry is evaluated at RESOLVE time against expiresat, never by a sweeper
job. A sweeper that stalls would silently extend every hold it failed to
clear, which is policy disabled by accident - the failure mode this design
exists to avoid. Rows are left behind after expiry deliberately: an expired
hold is the audit record of what was suppressed, by whom, and why.
expiresat is NULLABLE, and null means indefinite: the hold applies until an
admin releases it by hand. This is the one case where the self-healing
property above does not hold, so treat it as the sharp edge it is - an
indefinite hold silently disables reconciliation of that action forever, and
the endpoint will keep reporting healthy while it drifts. Nothing in the
system surfaces a stale hold; the only guard is the admin who placed it.
Every read of this table must therefore test `expiresat IS NULL OR
expiresat > now`. Testing only the comparison drops indefinite holds, which
would let policy quietly resume against an admin who asked it not to.
"""
__tablename__ = 'policyactionholds'
policyactionholdid = db.Column(db.Integer, primary_key=True)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
)
# Allowlisted action name, same vocabulary as ConfigurationPolicyAction.
action = db.Column(db.String(100), nullable=False)
# Naive UTC, matching BaseModel timestamps. Null means indefinite - the
# hold stands until released by hand.
expiresat = db.Column(db.DateTime, nullable=True)
# Who placed it. The id goes null if the user is deleted, but the username
# snapshot survives: a break-glass record that forgets who used it when the
# account is removed is worthless at review time.
createdbyuserid = db.Column(
db.Integer,
db.ForeignKey('users.userid', ondelete='SET NULL'),
nullable=True,
)
createdbyusername = db.Column(db.String(100), nullable=True)
reason = db.Column(db.Text, nullable=False)
__table_args__ = (
db.Index('idx_policyactionhold_asset', 'assetid'),
db.Index('idx_policyactionhold_expiry', 'expiresat'),
)
def __repr__(self):
until = self.expiresat or 'indefinite'
return (f"<PolicyActionHold asset={self.assetid} {self.action} "
f"until={until}>")