Build GE-Enforce manifest-store plugin (P0/P1): model, importer, parity gate
All checks were successful
CI / backend (push) Successful in 1m36s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s

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>
This commit is contained in:
cproudlock
2026-07-12 16:53:18 -04:00
parent cf2f9c308e
commit d85b33bd68
24 changed files with 1830 additions and 3 deletions

View File

@@ -0,0 +1,41 @@
"""GE-Enforce plugin models."""
from .manifest import (
ManifestScope,
ManifestEntry,
ManifestEntryPcType,
ManifestEntryHostname,
ManifestEntryMachineNumber,
ManifestInUseCheck,
ManifestInUseCheckProcess,
ManifestPublishedVersion,
ManifestPayload,
PcTypeAlias,
PHASES,
ENTRY_TYPES,
REG_TYPES,
PAYLOAD_SOURCES,
DETECTION_METHODS,
APPLY_MODES,
INUSE_BEHAVIORS,
)
__all__ = [
'ManifestScope',
'ManifestEntry',
'ManifestEntryPcType',
'ManifestEntryHostname',
'ManifestEntryMachineNumber',
'ManifestInUseCheck',
'ManifestInUseCheckProcess',
'ManifestPublishedVersion',
'ManifestPayload',
'PcTypeAlias',
'PHASES',
'ENTRY_TYPES',
'REG_TYPES',
'PAYLOAD_SOURCES',
'DETECTION_METHODS',
'APPLY_MODES',
'INUSE_BEHAVIORS',
]

View File

@@ -0,0 +1,288 @@
"""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'),
)