Files
shopdb-flask/plugins/computers/models/policy.py
benjamin.baker aa9dafc5ca Add application-driven policy generation with an acknowledgement gate
Ticking "Required on all PCs" now generates a configuration policy that
installs the application on every non-server endpoint it matches, rather
than only flagging a software-compliance report row.

Because that save is a fleet-wide deployment, it requires explicit
acknowledgement. The gate is enforced in the API, not only in the form:
a confirmation dialog that can be sidestepped by POSTing directly is
decoration, not a safeguard. It fires only on the transition INTO the
required state, since re-saving an already-required application deploys
nothing new.

Generated policies carry a new 'autoapp' source. They stay editable, and
hand-editing one transfers ownership to 'local' so it survives a later
uncheck; 'shipped' remains read-only. Existing required applications are
not migrated retroactively - policies are only created on save.

Also adds the monitorable fields that the shipped-policy breakup will
build on, and repairs a mangled non-ASCII byte in copilot-instructions.md
that was failing the style gate.
2026-09-07 13:27:59 -04:00

296 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'
# Rows generated FOR US by another surface rather than authored by hand: today
# the "Required on all PCs" checkbox on an Application. Distinct from 'local' so
# unchecking the box knows which policy it owns and may safely remove.
#
# It doubles as the "untouched" test. An autoapp row is one nobody has
# customised, so it is safe to delete when the box is unchecked. The moment an
# admin edits it through the policy CRUD API it becomes 'local' - their work
# now, not ours - and unchecking warns instead of deleting. That keeps the
# generated policy convenient without letting it silently destroy hand-authored
# changes.
POLICY_SOURCE_AUTOAPP = 'autoapp'
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}>")