First execution phases of docs/proposals/ge-enforce-plugin.md. The GE-Enforce manifest becomes shopdb data. P0 scaffold: new geenforce plugin (api_prefix /api/geenforce, default_enabled false, core_version >=0.7.0). Registered in PLUGIN_TABLE_OWNERS (ADR-008); its 0001 baseline really creates the tables. P1a model: one wide manifestentries table + entrytype discriminator (not STI, not JSON blob), manifestscopes (UNIQUE scopename+phase), the three multi-value filter child tables, inusechecks + processes, immutable manifestpublishedversions (frozen rendered JSON), manifestpayloads (inline, capped), pctypealiases (mirror of the engine lib's alias graph). regvalue stored as its raw JSON literal so DWord typing survives. P1c importer + exporter: parse common + gea-shopfloor-* + preinstall.json into draft rows and rebuild the JSON verbatim from rows in sortorder. P1d parity harness (GATE A): filters.py mirrors the engine's four filter functions + alias graph; parity.py proves import+export is behaviorally lossless (field-identical + same-entries-fire across 18 machine-profile fixtures) WITHOUT byte-diffing. Verified PASS against all 11 real reference manifests (64 entries) and a synthetic site-neutral fixture covering every type/filter (the CI gate). First slice (gea-shopfloor-cmm shape): service layer (import/publish/rollback/ export-to-share), CLI (parity, import-share, publish, export-share), and the client endpoint GET /api/geenforce/manifest serving the current published snapshot (never the draft) with ETag/304. Split permissions geenforce.manage/publish/fetch. Tests prove import->publish->serve, draft edits never change served bytes, publish+rollback, and auth (401 unauth/wrong-scope). Contract 0.11.0: added service_token_authorized(scope) to shopdb.api so plugin service endpoints authorize a scoped managed token without importing core token internals. Documented in PLUGIN-HOOKS.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
289 lines
13 KiB
Python
289 lines
13 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 docs/proposals/ge-enforce-plugin.md):
|
|
- 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')
|
|
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
|
|
|
|
# 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 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'),
|
|
)
|