main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
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}>")
|
|
|