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.
386 lines
18 KiB
Python
386 lines
18 KiB
Python
"""GE-Enforce manifest-store models.
|
|
|
|
The manifest that GE-Enforce runs on each PC becomes shopdb data. A
|
|
`ManifestScope` is one imaging PC type (a `gea-shopfloor-*` scope, the `common`
|
|
fleet-wide scope, or the single flat `preinstall` scope). Each scope owns an
|
|
ordered list of `ManifestEntry` rows - one per Applications[] entry in the
|
|
on-share manifest.json - plus child rows for the multi-value targeting filters
|
|
and the nested InUseCheck.
|
|
|
|
Design choices (see the ge-enforce-plugin proposal in the project wiki):
|
|
- ONE wide entries table with an `entrytype` discriminator and nullable
|
|
per-type columns. Not STI, not a JSON blob: the whole fleet is ~64 entries,
|
|
so sparse columns are free and every field stays queryable + plain-SQL
|
|
readable by IT.
|
|
- Array order IS execution order, so `sortorder` is a real column and the
|
|
contract; config-restore entries sit after their installer on purpose.
|
|
- `computertypeid` / `measuringtooltypeid` are SOFT references (plain Integer,
|
|
no DB foreign key) to other plugins' tables, which may not be installed.
|
|
- Published manifests are immutable snapshots that freeze the rendered JSON
|
|
document (`ManifestPublishedVersion`); the draft (`ManifestEntry`) is never
|
|
served to a client.
|
|
"""
|
|
|
|
from shopdb.api import db, BaseModel
|
|
|
|
|
|
# Allowed enumerations, validated in the API/service layer (stored as strings
|
|
# for portability, matching the rest of the codebase).
|
|
PHASES = ('runtime', 'preinstall')
|
|
ENTRY_TYPES = ('MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry')
|
|
REG_TYPES = ('String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary')
|
|
PAYLOAD_SOURCES = ('smb', 'http', 'inline')
|
|
DETECTION_METHODS = ('Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile',
|
|
'ValueMatches', 'pnputil', 'Always')
|
|
APPLY_MODES = ('Nightly', 'Immediate', 'ImmediateReboot')
|
|
INUSE_BEHAVIORS = ('Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot')
|
|
|
|
|
|
class ManifestScope(BaseModel):
|
|
"""One imaging PC type / manifest scope (the working/draft head)."""
|
|
__tablename__ = 'manifestscopes'
|
|
|
|
scopeid = db.Column(db.Integer, primary_key=True)
|
|
# 'common', 'gea-shopfloor-cmm', 'preinstall', ...
|
|
scopename = db.Column(db.String(64), nullable=False)
|
|
# 'runtime' (per-pctype manifest.json) or 'preinstall' (one flat manifest)
|
|
phase = db.Column(db.String(16), nullable=False, default='runtime')
|
|
# Soft refs (no FK): the plugins that own these tables are optional.
|
|
computertypeid = db.Column(db.Integer, nullable=True)
|
|
measuringtooltypeid = db.Column(db.Integer, nullable=True)
|
|
manifestversion = db.Column(db.String(16), nullable=False, default='1.0')
|
|
# Lowest GE-Enforce client library that may enforce this scope, e.g. '2.6'.
|
|
#
|
|
# SEPARATE FROM manifestversion, which is the manifest's own 'Version' field
|
|
# and is whatever its author wrote - seed_display_scope picks '2.0'. The
|
|
# publish gate used manifestversion as the library floor, so for every
|
|
# code-authored scope it compared the fleet against a number that says
|
|
# nothing about library features, and passed. NULL means "derive it", see
|
|
# service.required_libversion.
|
|
minlibversion = db.Column(db.String(16), nullable=True)
|
|
description = db.Column(db.String(255), nullable=True)
|
|
# The manifest-level '_comment' (documentation), preserved verbatim so
|
|
# export-to-share round-trips it. Excluded from behavioral parity.
|
|
topcomment = db.Column(db.Text, nullable=True)
|
|
iscommon = db.Column(db.Boolean, nullable=False, default=False)
|
|
# Preinstall manifests carry a top-level 'Site' field.
|
|
site = db.Column(db.String(100), nullable=True)
|
|
|
|
entries = db.relationship(
|
|
'ManifestEntry', back_populates='scope',
|
|
cascade='all, delete-orphan', order_by='ManifestEntry.sortorder',
|
|
lazy='selectin')
|
|
publishedversions = db.relationship(
|
|
'ManifestPublishedVersion', back_populates='scope',
|
|
cascade='all, delete-orphan',
|
|
order_by='ManifestPublishedVersion.versionnumber', lazy='dynamic')
|
|
|
|
__table_args__ = (
|
|
db.UniqueConstraint('scopename', 'phase', name='uq_scope_name_phase'),
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<ManifestScope {self.scopename}/{self.phase}>"
|
|
|
|
|
|
class ManifestEntry(BaseModel):
|
|
"""One Applications[] entry (draft). Wide table, entrytype discriminator."""
|
|
__tablename__ = 'manifestentries'
|
|
|
|
entryid = db.Column(db.Integer, primary_key=True)
|
|
scopeid = db.Column(
|
|
db.Integer, db.ForeignKey('manifestscopes.scopeid', ondelete='CASCADE'),
|
|
nullable=False, index=True)
|
|
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
|
name = db.Column(db.String(128), nullable=False)
|
|
entrytype = db.Column(db.String(16), nullable=False)
|
|
comment = db.Column(db.Text, nullable=True) # manifest '_comment'
|
|
|
|
# Type-specific payload references (nullable; validated per entrytype).
|
|
installer = db.Column(db.String(255), nullable=True)
|
|
installargs = db.Column(db.Text, nullable=True) # can run long
|
|
scriptpath = db.Column(db.String(255), nullable=True) # 'Script'
|
|
scriptargs = db.Column(db.String(255), nullable=True) # 'Args'
|
|
sourcepath = db.Column(db.String(255), nullable=True) # 'Source'
|
|
destination = db.Column(db.String(255), nullable=True)
|
|
regpath = db.Column(db.String(255), nullable=True)
|
|
regname = db.Column(db.String(128), nullable=True)
|
|
# Raw JSON literal preserved verbatim (1 vs "1"); DWord typing depends on it.
|
|
regvalue = db.Column(db.Text, nullable=True)
|
|
regtype = db.Column(db.String(16), nullable=True)
|
|
|
|
# Payload transport + integrity (integrity hash is SEPARATE from detection).
|
|
payloadsource = db.Column(db.String(8), nullable=False, default='smb')
|
|
payloadref = db.Column(db.String(512), nullable=True)
|
|
payloadsha256 = db.Column(db.String(64), nullable=True)
|
|
|
|
# Detection (decides whether the action fires / self-heals).
|
|
detectionmethod = db.Column(db.String(16), nullable=True)
|
|
detectionpath = db.Column(db.String(255), nullable=True)
|
|
detectionname = db.Column(db.String(128), nullable=True)
|
|
detectionvalue = db.Column(db.String(255), nullable=True)
|
|
detectionpattern = db.Column(db.String(255), nullable=True)
|
|
|
|
# Gates + control.
|
|
cmmversion = db.Column(db.String(16), nullable=True) # '_CmmVersion'
|
|
logfile = db.Column(db.String(255), nullable=True)
|
|
waittimeoutsec = db.Column(db.Integer, nullable=True)
|
|
applymode = db.Column(db.String(16), nullable=True) # inert in engine today
|
|
updatewindow = db.Column(db.String(11), nullable=True) # 'HH:MM-HH:MM', inert
|
|
|
|
# Optional CURATED link to a core Applications catalog row (soft ref, no FK
|
|
# to keep the plugin decoupled). shopdb metadata only - NOT part of the
|
|
# manifest JSON the engine reads, so it never affects enforcement or parity.
|
|
appid = db.Column(db.Integer, nullable=True)
|
|
|
|
# Preinstall-only flags.
|
|
preenrollment = db.Column(db.Boolean, nullable=False, default=False)
|
|
killafterdetection = db.Column(db.Boolean, nullable=False, default=False)
|
|
pctypesstrict = db.Column(db.Boolean, nullable=False, default=False)
|
|
|
|
scope = db.relationship('ManifestScope', back_populates='entries')
|
|
pctypes = db.relationship(
|
|
'ManifestEntryPcType', back_populates='entry',
|
|
cascade='all, delete-orphan', order_by='ManifestEntryPcType.sortorder',
|
|
lazy='selectin')
|
|
hostnames = db.relationship(
|
|
'ManifestEntryHostname', back_populates='entry',
|
|
cascade='all, delete-orphan', order_by='ManifestEntryHostname.sortorder',
|
|
lazy='selectin')
|
|
machinenumbers = db.relationship(
|
|
'ManifestEntryMachineNumber', back_populates='entry',
|
|
cascade='all, delete-orphan',
|
|
order_by='ManifestEntryMachineNumber.sortorder', lazy='selectin')
|
|
inusecheck = db.relationship(
|
|
'ManifestInUseCheck', back_populates='entry', uselist=False,
|
|
cascade='all, delete-orphan', lazy='selectin')
|
|
|
|
__table_args__ = (
|
|
db.UniqueConstraint('scopeid', 'name', name='uq_entry_scope_name'),
|
|
db.Index('idx_entry_scope_order', 'scopeid', 'sortorder'),
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<ManifestEntry {self.name} ({self.entrytype})>"
|
|
|
|
|
|
class ManifestEntryPcType(db.Model):
|
|
"""One value of an entry's PCTypes filter (verbatim, incl. '*' and aliases)."""
|
|
__tablename__ = 'manifestentrypctypes'
|
|
|
|
entrypctypeid = db.Column(db.Integer, primary_key=True)
|
|
entryid = db.Column(
|
|
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
|
|
nullable=False, index=True)
|
|
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
|
pctypevalue = db.Column(db.String(64), nullable=False)
|
|
|
|
entry = db.relationship('ManifestEntry', back_populates='pctypes')
|
|
|
|
|
|
class ManifestEntryHostname(db.Model):
|
|
"""One value of an entry's TargetHostnames filter (wildcards kept verbatim)."""
|
|
__tablename__ = 'manifestentryhostnames'
|
|
|
|
entryhostnameid = db.Column(db.Integer, primary_key=True)
|
|
entryid = db.Column(
|
|
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
|
|
nullable=False, index=True)
|
|
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
|
hostnamepattern = db.Column(db.String(64), nullable=False)
|
|
|
|
entry = db.relationship('ManifestEntry', back_populates='hostnames')
|
|
|
|
|
|
class ManifestEntryMachineNumber(db.Model):
|
|
"""One value of an entry's TargetMachineNumbers filter."""
|
|
__tablename__ = 'manifestentrymachinenumbers'
|
|
|
|
entrymachinenumberid = db.Column(db.Integer, primary_key=True)
|
|
entryid = db.Column(
|
|
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
|
|
nullable=False, index=True)
|
|
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
|
machinenumber = db.Column(db.String(16), nullable=False)
|
|
|
|
entry = db.relationship('ManifestEntry', back_populates='machinenumbers')
|
|
|
|
|
|
class ManifestInUseCheck(db.Model):
|
|
"""The InUseCheck object on an entry (0..1), with its Processes[] children."""
|
|
__tablename__ = 'manifestinusechecks'
|
|
|
|
inusecheckid = db.Column(db.Integer, primary_key=True)
|
|
entryid = db.Column(
|
|
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
|
|
nullable=False, unique=True)
|
|
behavior = db.Column(db.String(20), nullable=False)
|
|
|
|
entry = db.relationship('ManifestEntry', back_populates='inusecheck')
|
|
processes = db.relationship(
|
|
'ManifestInUseCheckProcess', back_populates='inusecheck',
|
|
cascade='all, delete-orphan',
|
|
order_by='ManifestInUseCheckProcess.sortorder', lazy='selectin')
|
|
|
|
|
|
class ManifestInUseCheckProcess(db.Model):
|
|
"""One process in an InUseCheck's Processes[] list."""
|
|
__tablename__ = 'manifestinusecheckprocesses'
|
|
|
|
inusecheckprocessid = db.Column(db.Integer, primary_key=True)
|
|
inusecheckid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('manifestinusechecks.inusecheckid', ondelete='CASCADE'),
|
|
nullable=False, index=True)
|
|
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
|
processname = db.Column(db.String(64), nullable=False)
|
|
exepath = db.Column(db.String(255), nullable=True)
|
|
# Null = engine default (10); do not bake the default into the row.
|
|
gracefulclosetimeoutsec = db.Column(db.Integer, nullable=True)
|
|
|
|
inusecheck = db.relationship('ManifestInUseCheck', back_populates='processes')
|
|
|
|
|
|
class ManifestPublishedVersion(db.Model):
|
|
"""Immutable published snapshot: the frozen rendered JSON document.
|
|
|
|
The client is always served the `iscurrent` snapshot for a scope, never the
|
|
live draft. Rollback flips `iscurrent`. Freezing the text (not row-mirroring)
|
|
makes immutability structural and rollback a one-flag change.
|
|
"""
|
|
__tablename__ = 'manifestpublishedversions'
|
|
|
|
publishedversionid = db.Column(db.Integer, primary_key=True)
|
|
scopeid = db.Column(
|
|
db.Integer, db.ForeignKey('manifestscopes.scopeid', ondelete='CASCADE'),
|
|
nullable=False, index=True)
|
|
versionnumber = db.Column(db.Integer, nullable=False)
|
|
# MEDIUMTEXT on MySQL (a full scope with comments can exceed 64 KB TEXT).
|
|
manifestjson = db.Column(db.Text(length=16777215), nullable=False)
|
|
publishedat = db.Column(db.DateTime, nullable=False)
|
|
publishedby = db.Column(db.Integer, nullable=True) # soft ref to users
|
|
iscurrent = db.Column(db.Boolean, nullable=False, default=False)
|
|
notes = db.Column(db.String(255), nullable=True)
|
|
|
|
scope = db.relationship('ManifestScope', back_populates='publishedversions')
|
|
|
|
__table_args__ = (
|
|
db.UniqueConstraint('scopeid', 'versionnumber',
|
|
name='uq_published_scope_version'),
|
|
)
|
|
|
|
|
|
class ManifestPayload(db.Model):
|
|
"""Inline payload bytes for payloadsource='inline' (small scripts/configs)."""
|
|
__tablename__ = 'manifestpayloads'
|
|
|
|
payloadid = db.Column(db.Integer, primary_key=True)
|
|
entryid = db.Column(
|
|
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
|
|
nullable=False, index=True)
|
|
filename = db.Column(db.String(255), nullable=False)
|
|
contenttype = db.Column(db.String(128), nullable=True)
|
|
payloadbytes = db.Column(db.LargeBinary(length=16777215), nullable=False)
|
|
payloadsha256 = db.Column(db.String(64), nullable=False)
|
|
uploadedat = db.Column(db.DateTime, nullable=False)
|
|
|
|
|
|
class ManifestEnforcementReport(db.Model):
|
|
"""One enforcement cycle reported by a PC (observed state).
|
|
|
|
Each cycle the client POSTs its result for a scope: the published version it
|
|
actually applied (so shopdb knows whether the PC RECEIVED the latest update),
|
|
the enforcer version, and the installed/skipped/failed/filtered counts. The
|
|
latest report per (hostname, scopename, phase) carries `iscurrent`; older
|
|
ones are history. Pairs desired state (the manifest) with observed state.
|
|
"""
|
|
__tablename__ = 'manifestenforcementreports'
|
|
|
|
reportid = db.Column(db.Integer, primary_key=True)
|
|
hostname = db.Column(db.String(100), nullable=False, index=True)
|
|
scopename = db.Column(db.String(64), nullable=False)
|
|
phase = db.Column(db.String(16), nullable=False, default='runtime')
|
|
# Published version the client actually ran; compare to the scope's current
|
|
# published version to see whether this PC received the latest manifest.
|
|
appliedversion = db.Column(db.Integer, nullable=True)
|
|
enforcerversion = db.Column(db.String(20), nullable=True)
|
|
# What the PC says it IS within its scope (a display's Dashboard / Lobby /
|
|
# 3DPrintRoom). Reported by the device, which reads it from its own
|
|
# enrollment file - not inferred from a mapping table that may have no row
|
|
# for this host.
|
|
subtype = db.Column(db.String(50), nullable=True)
|
|
installedcount = db.Column(db.Integer, nullable=False, default=0)
|
|
skippedcount = db.Column(db.Integer, nullable=False, default=0)
|
|
failedcount = db.Column(db.Integer, nullable=False, default=0)
|
|
filteredcount = db.Column(db.Integer, nullable=False, default=0)
|
|
# 'ok' | 'failed' (any failure) | 'selfhealed' (drift corrected, no failure).
|
|
status = db.Column(db.String(16), nullable=False, default='ok')
|
|
lastcheckin = db.Column(db.DateTime, nullable=True) # PC-reported time
|
|
receivedat = db.Column(db.DateTime, nullable=False) # server time
|
|
iscurrent = db.Column(db.Boolean, nullable=False, default=True, index=True)
|
|
|
|
results = db.relationship(
|
|
'ManifestEnforcementResult', back_populates='report',
|
|
cascade='all, delete-orphan', lazy='selectin')
|
|
|
|
__table_args__ = (
|
|
db.Index('idx_report_host_scope', 'hostname', 'scopename', 'phase'),
|
|
)
|
|
|
|
|
|
class ManifestEnforcementResult(db.Model):
|
|
"""One entry's outcome within an enforcement cycle (self-heal detail)."""
|
|
__tablename__ = 'manifestenforcementresults'
|
|
|
|
resultid = db.Column(db.Integer, primary_key=True)
|
|
reportid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('manifestenforcementreports.reportid', ondelete='CASCADE'),
|
|
nullable=False, index=True)
|
|
entryname = db.Column(db.String(128), nullable=False)
|
|
# 'installed' (action fired - a self-heal when it should already be present),
|
|
# 'skipped' (detected present), 'failed', 'filtered'.
|
|
action = db.Column(db.String(16), nullable=False)
|
|
# True when this install was a drift correction (self-heal), not a first
|
|
# install. Client-supplied; defaults to whether the action installed.
|
|
selfhealed = db.Column(db.Boolean, nullable=False, default=False)
|
|
exitcode = db.Column(db.Integer, nullable=True)
|
|
message = db.Column(db.Text, nullable=True) # warning / error text
|
|
|
|
report = db.relationship('ManifestEnforcementReport', back_populates='results')
|
|
|
|
|
|
class PcTypeAlias(db.Model):
|
|
"""Mirror of the engine lib's PCTypes alias graph (Install-FromManifest.ps1).
|
|
|
|
Rows sharing an `aliasgroup` are one alias set. Server-side resolve/validate
|
|
only; the engine lib stays the single source of truth (never inverted).
|
|
"""
|
|
__tablename__ = 'pctypealiases'
|
|
|
|
aliasid = db.Column(db.Integer, primary_key=True)
|
|
aliasgroup = db.Column(db.Integer, nullable=False, index=True)
|
|
aliasname = db.Column(db.String(64), nullable=False)
|
|
|
|
__table_args__ = (
|
|
db.UniqueConstraint('aliasgroup', 'aliasname', name='uq_alias_group_name'),
|
|
)
|
|
|
|
|
|
class ManifestBlob(db.Model):
|
|
"""Content-addressed payload blob for http-delivered installers.
|
|
|
|
Bytes live on disk at <instance>/geenforce/payloads/<sha256> (deduped by
|
|
content, so a payload shared by many entries is stored once). Manifest
|
|
entries reference a blob by payloadsha256; the client fetches it from
|
|
GET /api/geenforce/payload/<sha256> over HTTPS and verifies the hash. This
|
|
is how big installers reach share-less (Intune/local-account) PCs.
|
|
"""
|
|
__tablename__ = 'manifestblobs'
|
|
|
|
sha256 = db.Column(db.String(64), primary_key=True)
|
|
filename = db.Column(db.String(255), nullable=False)
|
|
contenttype = db.Column(db.String(128), nullable=True)
|
|
sizebytes = db.Column(db.BigInteger, nullable=False)
|
|
createdat = db.Column(db.DateTime, nullable=False)
|