Files
shopdb-flask/plugins/computers/models/policy.py
cproudlock 784964c907 Bring the wilm branch into main: endpoints plugin, agent, policies, CSV import
Ports the corporate-GitHub wilm branch onto main, keeping the two branches
separate. Thirteen upstream commits arrive with their authorship intact; the
review fixes and the portability work below are on top.

What arrives:

  endpoints plugin  - a ShopDB agent reporting endpoint state, telemetry,
                      services and interfaces, with a command channel the agent
                      long-polls. Ingest stays on the generic collector route
                      (ADR-006); the agent key is a UI-managed single-scope
                      token whose revocation is an instant kill switch.
  computers policies - Configuration and Business Policies (RMM-style), scope
                      trees validated at write time, actions checked against
                      the endpoints command allowlist. Feature-gated: every
                      route 404s while the setting is off, so enabling it can
                      never half-expose the surface.
  asset CSV import  - templates, a collector script carrying the site path, and
                      a validate-then-commit import. A plugin can own a virtual
                      column plus a post-write row hook, which is how a PC's
                      machine number becomes a controls link without a
                      hand-built relationships sheet.
  agent/            - the Windows agent and its installer.

Contract 0.21.0 -> 0.24.0, additive throughout: register_import_hook (0.22),
mint/list/revoke_service_token (0.23), collector_authorized (0.24). The stability
table had no rows for any of them and now does.

MySQL 5.6 portability. The branch stored three policy columns as sa.JSON, the
only JSON columns in the product, and 5.6 has no JSON type: the migration failed
with a bare syntax error near the word JSON. They are TEXT now, through a
JSONText type decorator, so all seventeen call sites still read and write dicts.
The convention elsewhere is already TEXT (guides.blocksjson,
geenforce.manifestjson).

Review fixes carried on top:

  - six policy GETs were jwt_required(optional=True), which authenticates a
    caller without checking anything; they require computers.view now, and six
    redundant jwt_required decorators are gone
  - the agent installer took a default BaseUrl and CollectorKey, so a mistyped
    run silently pointed an endpoint at the wrong instance; both are mandatory
    and a non-https BaseUrl throws unless -AllowInsecureHttp is passed
  - a committing CSV import wrote no audit row

Documentation: the 34 new routes are described in docs/api-inventory.json, which
is hand-maintained on purpose, and the generated spec now covers 331 paths and
477 operations across 22 surfaces. DEVELOPMENT-SETUP's 1071 troubleshooting row
said "use MySQL 8 for dev" and now gives the actual fix, including the trap that
5.6 downgrades ROW_FORMAT=DYNAMIC to Compact with a warning instead of an error.

Full suite 2123 passed, vitest 260 passed, all seventeen plugin chains migrate
clean on MySQL 5.6 and the naming, docs, parity and read-surface gates are green.
2026-08-28 16:17:17 -04:00

199 lines
7.8 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'
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}>")