The product shipped one Configuration Policy carrying every shopfloor app monitor, wrapped in a shipped Site Policy that applied it to every endpoint. That bundled two decisions a site cannot separate: which monitors exist, and which endpoints get them. A site that wanted eDNC on one PC type and MTConnect on another had to delete the shipped policy and rebuild both halves. Ship one Configuration Policy per monitor with no PC type attribution, and stop shipping a Site Policy at all. Composition is a site decision; the product now supplies parts, not an assembled opinion. Shipped Configuration Policies become editable and deletable. Provenance stays a source tag rather than a lock, which is safe because seeding is insert-if- absent and no reconciling seeder exists, so a site edit is never stomped. The stale comments claiming shipped rows were read-only and reconciled described behavior the code never had.
307 lines
13 KiB
Python
307 lines
13 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'
|
|
|
|
# Rows the product seeds (the shopfloor monitor policies, one per monitor).
|
|
# Distinct from 'local' so a site's own policies are never confused with what
|
|
# the product shipped, and so the UI can badge the origin.
|
|
#
|
|
# A shipped row is EDITABLE and DELETABLE, unlike a genuinely external source.
|
|
# The product ships a starting point, not an opinion about one site's floor: a
|
|
# plant that runs no MTConnect must be able to drop that policy. source stays
|
|
# 'shipped' through an edit, so provenance is a fact about where the row came
|
|
# from rather than a claim about who controls it now.
|
|
#
|
|
# Nothing reconciles these rows. The seeding migrations are insert-if-absent
|
|
# (they SELECT by name and return early if it exists), so an upgrade cannot
|
|
# stomp a site's edits. That property is load-bearing now that the rows are
|
|
# editable - do not replace it with a reconciling seeder without adding a
|
|
# dirty flag first.
|
|
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}>")
|
|
|