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:
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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}>")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user