Files
shopdb-flask/plugins/computers/models/policy.py
Cam Proudlock 7bda8e6d60 Store the policy JSON columns as TEXT for MySQL 5.6
The branch stored scopejson, desiredstate and params as sa.JSON, the only JSON
columns in the product. MySQL 5.6 has no JSON type - it arrived in 5.7 - and the
migration failed there with a bare syntax error near the word JSON.

They are TEXT now, through a JSONText type decorator, so the seventeen call sites
still assign and read dicts. The convention elsewhere is already TEXT:
guides.blocksjson, geenforce.manifestjson.

Spec regenerated: 331 paths, 477 operations across 22 surfaces.
2026-08-28 16:18:34 -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}>")