GE-Enforce: compliance view, inline payload upload, frontend test harness

Fleet-install compliance for app-linked manifest entries: new service
compliance_for_scope + GET /geenforce/scopes/<id>/compliance count active
ComputerInstalledApp rows by curated appid (null-safe when computers plugin
absent). ManifestEditor gains a compliance panel. Curated appid stays shopdb
metadata and never enters manifest JSON, so behavioral parity is unaffected.

Inline manifest payloads: store_inline_payload (sha256, 1MB cap,
payloadsource='inline') + POST/GET /geenforce/entries/<id>/payload; editor
gains an upload control. Entry payload metadata surfaced in _entry_payload.

Frontend test harness: extract the editor's entry-form logic into pure
entryForm.js (buildEntryPayload, describeEntry, availableEntryTypes, scope
gates, ...) and cover it with 45 vitest tests. ManifestEditor now imports
those helpers, so the tests exercise the shipped code path (no duplication).

908 backend tests pass; vitest 45 pass; frontend build green; naming green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-13 07:24:51 -04:00
parent 3355436fcd
commit 4e5b4228c1
10 changed files with 3411 additions and 145 deletions

View File

@@ -24,7 +24,7 @@ SHAREROOT_SETTING = 'geenforce_share_root'
from ..models import (
ManifestScope, ManifestEntry, ManifestPublishedVersion,
ManifestEnforcementReport, ENTRY_TYPES, PHASES,
ManifestEnforcementReport, ManifestPayload, ENTRY_TYPES, PHASES,
)
from ..serializer import scope_to_manifest, entry_to_dict
from ..importer import build_entry, populate_entry
@@ -202,6 +202,12 @@ def _entry_payload(entry):
app = db.session.get(Application, entry.appid)
data['appname'] = app.appname if app else None
data.update(entry_to_dict(entry))
# Inline-payload metadata (shopdb-only, NOT manifest keys) for the editor.
data['payloadsource'] = entry.payloadsource
data['payloadref'] = entry.payloadref
data['payloadsha256'] = entry.payloadsha256
data['haspayload'] = (entry.payloadsource == 'inline'
and entry.payloadsha256 is not None)
return data
@@ -426,6 +432,74 @@ def simulate_scope(scopeid):
'filtered': filtered})
# -- compliance: "how much of the fleet has this app" -------------------------
@geenforce_bp.route('/scopes/<int:scopeid>/compliance', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def scope_compliance(scopeid):
"""Fleet-install coverage per app-linked entry (from collected PC data).
One row per entry that carries a curated appid; installed/version-match
counts come from the computers plugin's ComputerInstalledApp. Degrades to
null counts (computersplugin: false) when that plugin is absent.
"""
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope',
http_code=404)
return success_response(service.compliance_for_scope(scope))
# -- inline payload upload / fetch (small scripts + configs) ------------------
PAYLOAD_MAX_BYTES = 1024 * 1024
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['POST'])
@jwt_required()
@require_permission('geenforce.publish')
def upload_entry_payload(entryid):
"""Store an inline payload (<= 1 MB) for an entry and point the entry at it."""
entry = db.session.get(ManifestEntry, entryid)
if not entry:
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
uploaded = request.files.get('file')
if uploaded is None:
return error_response(ErrorCodes.VALIDATION_ERROR,
'a file is required', http_code=400)
rawbytes = uploaded.read()
if not rawbytes:
return error_response(ErrorCodes.VALIDATION_ERROR,
'payload is empty', http_code=400)
if len(rawbytes) > PAYLOAD_MAX_BYTES:
return error_response(ErrorCodes.VALIDATION_ERROR,
'payload exceeds 1 MB limit', http_code=400)
service.store_inline_payload(entry, uploaded.filename,
uploaded.mimetype, rawbytes)
db.session.commit()
return success_response(_entry_payload(entry), http_code=201)
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def download_entry_payload(entryid):
"""Return the stored inline payload bytes for an entry (404 if none)."""
entry = db.session.get(ManifestEntry, entryid)
if not entry:
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
payload = ManifestPayload.query.filter_by(entryid=entryid).first()
if not payload:
return error_response(ErrorCodes.NOT_FOUND, 'entry has no payload',
http_code=404)
return Response(
payload.payloadbytes,
mimetype=payload.contenttype or 'application/octet-stream',
headers={'Content-Disposition':
f'attachment; filename="{payload.filename}"'})
# -- publish lifecycle (geenforce.publish) ------------------------------------
@geenforce_bp.route('/scopes/<int:scopeid>/publish', methods=['POST'])

View File

@@ -7,17 +7,18 @@ Kept out of the CLI and routes so both share one implementation:
- `rollback_scope` / `export_scope_to_share` round out the publish lifecycle.
"""
import hashlib
import os
import tempfile
from datetime import datetime, timezone
from sqlalchemy import func
from shopdb.api import db
from shopdb.api import db, Application
from .models import (
ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport,
ManifestEnforcementResult,
ManifestEnforcementResult, ManifestPayload,
)
from .importer import build_entry
from .serializer import scope_to_json
@@ -27,6 +28,20 @@ def _utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _installed_app_model():
"""Lazily import the computers plugin's ComputerInstalledApp.
The computers plugin is optional; importing it lazily (not at module load)
keeps geenforce usable when computers is absent or disabled. Mirrors
collector._computer_models. Returns the model class, or None if absent.
"""
try:
from plugins.computers.models import ComputerInstalledApp
return ComputerInstalledApp
except ImportError:
return None
def replace_scope_draft(scopename, phase, manifest):
"""Create/refresh a scope and REPLACE its draft entries. Published versions
are left untouched. Returns the scope (uncommitted)."""
@@ -175,6 +190,111 @@ def _parse_dt(value):
return None
def compliance_for_scope(scope):
"""Fleet-install coverage for a scope's app-linked entries.
One row per ManifestEntry that carries a curated appid (unlinked entries
are skipped), ordered by sortorder. Counts come from the computers plugin's
ComputerInstalledApp (active rows only, one row per PC per app). Degrades
gracefully with null counts when the computers plugin is absent.
Returns the response data dict (scopeid/scopename/phase/computersplugin/rows).
"""
linked = [e for e in sorted(scope.entries, key=lambda e: e.sortorder)
if e.appid is not None]
installedmodel = _installed_app_model()
computerspresent = installedmodel is not None
# One grouped query for the whole scope's appid set (fleet is tiny).
installedbyapp = {}
if computerspresent and linked:
appids = {e.appid for e in linked}
counted = db.session.query(
installedmodel.appid, func.count(installedmodel.id)
).filter(
installedmodel.isactive == True,
installedmodel.appid.in_(appids),
).group_by(installedmodel.appid).all()
installedbyapp = {appid: count for appid, count in counted}
rows = []
for entry in linked:
app = db.session.get(Application, entry.appid)
appname = app.appname if app else None
expectedversion = (entry.detectionvalue
if entry.detectionmethod == 'FileVersion' else None)
if not computerspresent:
installedcount = None
versionmatchcount = None
coveragenote = ('computers plugin not installed; install counts '
'unavailable')
else:
installedcount = installedbyapp.get(entry.appid, 0)
if installedcount == 0:
versionmatchcount = None if expectedversion is None else 0
coveragenote = 'not installed on any collected PC'
elif expectedversion is None:
versionmatchcount = None
method = entry.detectionmethod or 'none'
coveragenote = (f'installed on {installedcount} PC(s); no '
f'version target (detection is {method})')
else:
versionmatchcount = db.session.query(
func.count(installedmodel.id)
).filter(
installedmodel.isactive == True,
installedmodel.appid == entry.appid,
installedmodel.installedversion == expectedversion,
).scalar() or 0
coveragenote = (f'{versionmatchcount} of {installedcount} '
'collected PCs on the expected version')
rows.append({
'entryid': entry.entryid,
'entryname': entry.name,
'appid': entry.appid,
'appname': appname,
'expectedversion': expectedversion,
'installedcount': installedcount,
'versionmatchcount': versionmatchcount,
'coveragenote': coveragenote,
})
return {
'scopeid': scope.scopeid,
'scopename': scope.scopename,
'phase': scope.phase,
'computersplugin': computerspresent,
'rows': rows,
}
def store_inline_payload(entry, filename, contenttype, rawbytes):
"""Replace the entry's inline payload with these bytes (uncommitted).
Computes payloadsha256, upserts the single ManifestPayload row (the table
has no unique constraint on entryid, so the one-inline-payload-per-entry
rule is enforced here), and points the entry at it (payloadsource='inline').
Returns the ManifestPayload.
"""
sha = hashlib.sha256(rawbytes).hexdigest()
ManifestPayload.query.filter_by(entryid=entry.entryid).delete()
db.session.flush()
payload = ManifestPayload(
entryid=entry.entryid,
filename=filename,
contenttype=contenttype,
payloadbytes=rawbytes,
payloadsha256=sha,
uploadedat=_utcnow())
db.session.add(payload)
entry.payloadsource = 'inline'
entry.payloadref = filename
entry.payloadsha256 = sha
return payload
def export_scope_to_share(scopename, phase, shareroot):
"""Write a scope's current published JSON to the share, backing up the old
file to _meta/history first. Returns the written path."""