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.
284 lines
12 KiB
Python
284 lines
12 KiB
Python
"""Configuration and business policy models (RMM-style).
|
|
|
|
DT staff author reusable Configuration Policies (a single rule binding
|
|
allowlisted endpoint actions to endpoints matched by a boolean scope) and group
|
|
them into ordered Business Policies assigned to a fleet. When an endpoint reports
|
|
in, the server folds the matching Business Policies in priority order to compute
|
|
that endpoint's effective policy (first match wins per action).
|
|
|
|
These models live in the computers plugin so policies sit next to ComputerType.
|
|
The action VOCABULARY stays authoritative in plugins/endpoints/commands.py; this
|
|
layer references action NAMES only. The `source` column is the seam for a future
|
|
GE-Enforce sync (local-authored rows vs imported/managed rows).
|
|
|
|
The whole surface is gated by the computers_configpolicies_enabled setting; the
|
|
models register unconditionally (SQLAlchemy needs them), but routes, panels, and
|
|
enforcement stay inert while the flag is off, and the migration downgrade drops
|
|
every table for a clean rollback.
|
|
"""
|
|
|
|
from shopdb.api import db, BaseModel
|
|
from .jsontext import JSONText
|
|
|
|
|
|
# Provenance of a policy row.
|
|
# leaves room for `geenforce` (or another manager) to own rows later without a
|
|
# schema change - an imported row is read-only in the UI and reconciled by its
|
|
# owner. Kept a plain string, not an enum column, so adding a source is data.
|
|
POLICY_SOURCE_LOCAL = 'local'
|
|
|
|
# Product-owned rows we ship (the shopfloor app monitor bundle). Same read-only
|
|
# treatment as any non-local source: the CRUD API refuses to edit or delete
|
|
# them, the seeder reconciles them. Distinct from 'local' so a site's own
|
|
# policies are never confused with what the product shipped.
|
|
POLICY_SOURCE_SHIPPED = 'shipped'
|
|
|
|
|
|
class ConfigurationPolicy(BaseModel):
|
|
"""A single reusable policy: a scope plus the actions it applies.
|
|
|
|
Scope is a boolean expression (AND/OR/NOT over PC types and applications)
|
|
stored as a small typed JSON AST in scopejson; see services/policyscope.py
|
|
for the evaluator. Actions live in ConfigurationPolicyAction rows.
|
|
"""
|
|
__tablename__ = 'configurationpolicies'
|
|
|
|
configurationpolicyid = db.Column(db.Integer, primary_key=True)
|
|
name = db.Column(db.String(150), unique=True, nullable=False)
|
|
description = db.Column(db.Text)
|
|
|
|
# Boolean target AST: {"type":"and","nodes":[{"type":"pctype","id":3},
|
|
# {"type":"not","node":{"type":"app","id":12}}]}. Validated server-side
|
|
# (known node types, depth cap) before it is stored - see policyscope.
|
|
scopejson = db.Column(JSONText, nullable=True)
|
|
|
|
# GE-Enforce interop seam (see POLICY_SOURCE_LOCAL).
|
|
source = db.Column(db.String(50), default=POLICY_SOURCE_LOCAL, nullable=False)
|
|
|
|
# createddate, modifieddate, isactive come from BaseModel.
|
|
|
|
actions = db.relationship(
|
|
'ConfigurationPolicyAction',
|
|
back_populates='configurationpolicy',
|
|
cascade='all, delete-orphan',
|
|
)
|
|
|
|
__table_args__ = (
|
|
db.Index('idx_configpolicy_source', 'source'),
|
|
db.Index('idx_configpolicy_active', 'isactive'),
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<ConfigurationPolicy {self.name}>"
|
|
|
|
|
|
# What a policy action does to a matched endpoint.
|
|
# desired: reconciled desired-state. desiredstate says what it should be
|
|
# (e.g. running=true for mtconnect); the server enqueues the
|
|
# drift-correcting command when telemetry disagrees.
|
|
# gate: allowlist-gating only. The action is PERMITTED (and shown) on
|
|
# matched endpoints; it constrains what may be enqueued, it does not
|
|
# drive anything on its own.
|
|
ACTION_MODE_DESIRED = 'desired'
|
|
ACTION_MODE_GATE = 'gate'
|
|
|
|
|
|
class ConfigurationPolicyAction(BaseModel):
|
|
"""One allowlisted action carried by a ConfigurationPolicy.
|
|
|
|
`action` is a name from plugins/endpoints/commands.py COMMAND_ALLOWLIST;
|
|
it is validated against that allowlist at write time. `mode` selects
|
|
desired-state reconciliation vs allowlist-gating.
|
|
"""
|
|
__tablename__ = 'configurationpolicyactions'
|
|
|
|
configurationpolicyactionid = db.Column(db.Integer, primary_key=True)
|
|
configurationpolicyid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('configurationpolicies.configurationpolicyid',
|
|
ondelete='CASCADE'),
|
|
nullable=False,
|
|
)
|
|
|
|
# Allowlisted action name (endpoints commands.py). Not an FK - the allowlist
|
|
# is code, not a table - so it is validated in the API layer.
|
|
action = db.Column(db.String(100), nullable=False)
|
|
|
|
mode = db.Column(db.String(20), default=ACTION_MODE_GATE, nullable=False)
|
|
|
|
# Desired-state target for mode=desired, as JSON so different actions can
|
|
# express different targets (e.g. {"running": true}). Null for gate mode.
|
|
desiredstate = db.Column(JSONText, nullable=True)
|
|
|
|
# Optional action parameters passed through to the command (JSON), and an
|
|
# optional reconcile cadence in minutes (null = reconcile on every report).
|
|
params = db.Column(JSONText, nullable=True)
|
|
intervalminutes = db.Column(db.Integer, nullable=True)
|
|
|
|
sortorder = db.Column(db.Integer, default=0, nullable=False)
|
|
|
|
configurationpolicy = db.relationship(
|
|
'ConfigurationPolicy', back_populates='actions')
|
|
|
|
__table_args__ = (
|
|
db.UniqueConstraint('configurationpolicyid', 'action',
|
|
name='uq_configpolicyaction'),
|
|
db.Index('idx_configpolicyaction_policy', 'configurationpolicyid'),
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<ConfigurationPolicyAction {self.action} mode={self.mode}>"
|
|
|
|
|
|
class BusinessPolicy(BaseModel):
|
|
"""An ordered collection of Configuration Policies assigned to a fleet.
|
|
|
|
Priority orders Business Policies against each other when several match one
|
|
endpoint: lower priority number is evaluated first, and the first policy to
|
|
speak on a given action name wins (first-match-wins per action).
|
|
"""
|
|
__tablename__ = 'businesspolicies'
|
|
|
|
businesspolicyid = db.Column(db.Integer, primary_key=True)
|
|
name = db.Column(db.String(150), unique=True, nullable=False)
|
|
description = db.Column(db.Text)
|
|
|
|
# Lower runs first. Ties broken by businesspolicyid for a stable order.
|
|
priority = db.Column(db.Integer, default=100, nullable=False)
|
|
|
|
source = db.Column(db.String(50), default=POLICY_SOURCE_LOCAL, nullable=False)
|
|
|
|
# createddate, modifieddate, isactive come from BaseModel.
|
|
|
|
items = db.relationship(
|
|
'BusinessPolicyItem',
|
|
back_populates='businesspolicy',
|
|
cascade='all, delete-orphan',
|
|
order_by='BusinessPolicyItem.sortorder',
|
|
)
|
|
|
|
__table_args__ = (
|
|
db.Index('idx_businesspolicy_priority', 'priority'),
|
|
db.Index('idx_businesspolicy_active', 'isactive'),
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<BusinessPolicy {self.name} priority={self.priority}>"
|
|
|
|
|
|
class BusinessPolicyItem(BaseModel):
|
|
"""Membership of a Configuration Policy in a Business Policy, ordered.
|
|
|
|
sortorder sets the walk order WITHIN a Business Policy; combined with the
|
|
Business Policy priority it gives a fully deterministic fold order for the
|
|
effective-policy resolver.
|
|
"""
|
|
__tablename__ = 'businesspolicyitems'
|
|
|
|
businesspolicyitemid = db.Column(db.Integer, primary_key=True)
|
|
businesspolicyid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('businesspolicies.businesspolicyid', ondelete='CASCADE'),
|
|
nullable=False,
|
|
)
|
|
configurationpolicyid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('configurationpolicies.configurationpolicyid',
|
|
ondelete='CASCADE'),
|
|
nullable=False,
|
|
)
|
|
sortorder = db.Column(db.Integer, default=0, nullable=False)
|
|
|
|
businesspolicy = db.relationship('BusinessPolicy', back_populates='items')
|
|
configurationpolicy = db.relationship('ConfigurationPolicy')
|
|
|
|
__table_args__ = (
|
|
db.UniqueConstraint('businesspolicyid', 'configurationpolicyid',
|
|
name='uq_businesspolicyitem'),
|
|
db.Index('idx_bpitem_business', 'businesspolicyid'),
|
|
db.Index('idx_bpitem_config', 'configurationpolicyid'),
|
|
)
|
|
|
|
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}>")
|
|
|