Files
shopdb-flask/plugins/geenforce/service.py
cproudlock b00ef72581 geenforce: HTTPS payload delivery (content-addressed blob store + endpoint)
Lets share-less (Intune/local-account) PCs pull installers the manifest
references over HTTPS instead of SMB - the general capability the whole fleet
migrates toward. New ManifestBlob registry (migration 0002) with bytes on disk
at instance/geenforce/payloads/<sha256> (deduped by content); service.store_blob
+ blob_path; client-facing GET /api/geenforce/payload/<sha256> (geenforce.fetch
token, ETag=hash, serves the blob store or an inline DB payload by hash). The
serializer now emits PayloadSource/PayloadSha256/PayloadRef for http/inline
entries only (smb entries round-trip unchanged - parity green). CLI
'flask geenforce add-payload <file>' registers a blob and prints its sha256.
This is the shopdb half (B1); the PS client/engine fetch is B2.
2026-07-21 10:10:59 -04:00

382 lines
14 KiB
Python

"""DB-touching operations for the manifest store.
Kept out of the CLI and routes so both share one implementation:
- `replace_scope_draft` imports/re-imports a scope's DRAFT entries WITHOUT
touching its published-version history (re-import is idempotent + safe).
- `publish_scope` freezes the current draft into a new immutable snapshot.
- `rollback_scope` / `export_scope_to_share` round out the publish lifecycle.
"""
import hashlib
import os
import tempfile
from datetime import datetime, timezone
from flask import current_app
from sqlalchemy import func
from shopdb.api import db, Application
from .models import (
ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport,
ManifestEnforcementResult, ManifestPayload, ManifestBlob,
)
from .importer import build_entry
from .serializer import scope_to_json
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)."""
scope = ManifestScope.query.filter_by(
scopename=scopename, phase=phase).first()
if not scope:
scope = ManifestScope(scopename=scopename, phase=phase)
db.session.add(scope)
scope.manifestversion = str(manifest.get('Version', '1.0'))
scope.topcomment = manifest.get('_comment')
scope.site = manifest.get('Site')
scope.iscommon = (scopename == 'common')
for entry in list(scope.entries):
db.session.delete(entry)
db.session.flush()
for i, entry_dict in enumerate(manifest.get('Applications') or []):
scope.entries.append(build_entry(entry_dict, i))
return scope
def publish_scope(scopename, phase, notes=None, publishedby=None):
"""Freeze the current draft into a new published snapshot. Returns the
version number (uncommitted)."""
scope = ManifestScope.query.filter_by(
scopename=scopename, phase=phase).first()
if not scope:
raise ValueError(f'No scope {scopename}/{phase}')
text = scope_to_json(scope)
maxversion = db.session.query(
func.max(ManifestPublishedVersion.versionnumber)
).filter_by(scopeid=scope.scopeid).scalar() or 0
ManifestPublishedVersion.query.filter_by(
scopeid=scope.scopeid, iscurrent=True
).update({'iscurrent': False})
db.session.add(ManifestPublishedVersion(
scopeid=scope.scopeid,
versionnumber=maxversion + 1,
manifestjson=text,
publishedat=_utcnow(),
publishedby=publishedby,
iscurrent=True,
notes=notes))
return maxversion + 1
def rollback_scope(scopename, phase, versionnumber):
"""Make an older published version current again (uncommitted)."""
scope = ManifestScope.query.filter_by(
scopename=scopename, phase=phase).first()
if not scope:
raise ValueError(f'No scope {scopename}/{phase}')
target = ManifestPublishedVersion.query.filter_by(
scopeid=scope.scopeid, versionnumber=versionnumber).first()
if not target:
raise ValueError(f'No version {versionnumber} for {scopename}')
ManifestPublishedVersion.query.filter_by(
scopeid=scope.scopeid, iscurrent=True
).update({'iscurrent': False})
target.iscurrent = True
return versionnumber
def record_enforcement_report(payload):
"""Record one PC's enforcement cycle (observed state). Upserts the latest
report per (hostname, scopename, phase) and keeps prior ones as history.
Payload (all but hostname optional):
hostname, scopename, phase, appliedversion, enforcerversion, lastcheckin,
counts {installed, skipped, failed, filtered},
results [{name, action, selfhealed, exitcode, message}]
Returns the new ManifestEnforcementReport (uncommitted).
"""
hostname = (payload.get('hostname') or '').strip()
if not hostname:
raise ValueError('hostname is required')
scopename = (payload.get('scopename') or '').strip()
phase = (payload.get('phase') or 'runtime').strip()
counts = payload.get('counts') or {}
results = payload.get('results') or []
# Guard wrong JSON shapes: a bad type must be a clean 400, not a 500 from a
# later .get()/iteration (the route maps ValueError to 400).
if not isinstance(counts, dict):
raise ValueError('counts must be an object')
if not isinstance(results, list):
raise ValueError('results must be a list')
failed = int(counts.get('failed', 0))
installed = int(counts.get('installed', 0))
# Derive status from EXPLICIT self-heal flags only, never the raw installed
# count: every healthy cycle runs Always/no-detection scripts (asset report,
# VNC firewall, EventSaver) that the engine counts as "installed", so keying
# self-heal off installed>0 would mark the common scope selfhealed forever
# and make 'ok' unreachable. A drift correction is one the client flags.
if failed > 0:
status = 'failed'
elif any(r.get('selfhealed') for r in results):
status = 'selfhealed'
else:
status = 'ok'
# Demote the prior current report for this host+scope. Hostname match is
# case-insensitive to match the ilike read path - otherwise a PC reporting
# its name in different casing would leave two iscurrent rows.
ManifestEnforcementReport.query.filter(
func.lower(ManifestEnforcementReport.hostname) == hostname.lower(),
ManifestEnforcementReport.scopename == scopename,
ManifestEnforcementReport.phase == phase,
ManifestEnforcementReport.iscurrent == True
).update({'iscurrent': False}, synchronize_session=False)
report = ManifestEnforcementReport(
hostname=hostname,
scopename=scopename,
phase=phase,
appliedversion=payload.get('appliedversion'),
enforcerversion=payload.get('enforcerversion'),
installedcount=installed,
skippedcount=int(counts.get('skipped', 0)),
failedcount=failed,
filteredcount=int(counts.get('filtered', 0)),
status=status,
lastcheckin=_parse_dt(payload.get('lastcheckin')),
receivedat=_utcnow(),
iscurrent=True)
for item in results:
report.results.append(ManifestEnforcementResult(
entryname=item.get('name', ''),
action=item.get('action', ''),
# Explicit flag only; do NOT infer from action == 'installed'
# (Always/no-detection scripts install every cycle without being a
# drift correction).
selfhealed=bool(item.get('selfhealed', False)),
exitcode=item.get('exitcode'),
message=item.get('message')))
db.session.add(report)
return report
def _parse_dt(value):
if not value:
return None
try:
return datetime.fromisoformat(str(value).replace('Z', '+00:00')).replace(
tzinfo=None)
except (ValueError, TypeError):
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 _payload_dir():
d = os.path.join(current_app.instance_path, 'geenforce', 'payloads')
os.makedirs(d, exist_ok=True)
return d
def blob_path(sha256):
"""Filesystem path where a blob's bytes live (may or may not exist)."""
return os.path.join(_payload_dir(), sha256)
def store_blob(rawbytes, filename, contenttype=None):
"""Store bytes in the content-addressed payload store; return the sha256.
Deduped by content hash - an already-present blob is not rewritten. Upserts
a ManifestBlob registry row (uncommitted). Entries reference it by
payloadsha256; the client fetches GET /api/geenforce/payload/<sha256>.
"""
sha = hashlib.sha256(rawbytes).hexdigest()
path = blob_path(sha)
if not os.path.exists(path):
fd, tmp = tempfile.mkstemp(dir=_payload_dir(), suffix='.tmp')
try:
with os.fdopen(fd, 'wb') as handle:
handle.write(rawbytes)
os.replace(tmp, path)
except Exception:
if os.path.exists(tmp):
os.remove(tmp)
raise
if not db.session.get(ManifestBlob, sha):
db.session.add(ManifestBlob(
sha256=sha, filename=filename, contenttype=contenttype,
sizebytes=len(rawbytes), createdat=_utcnow()))
return sha
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."""
scope = ManifestScope.query.filter_by(
scopename=scopename, phase=phase).first()
if not scope:
raise ValueError(f'No scope {scopename}/{phase}')
published = scope.publishedversions.filter_by(iscurrent=True).first()
if not published:
raise ValueError(f'{scopename} has no published version')
if phase == 'preinstall':
target = os.path.join(shareroot, 'preinstall.json')
else:
target = os.path.join(shareroot, scopename, 'manifest.json')
os.makedirs(os.path.dirname(target), exist_ok=True)
if os.path.isfile(target):
historydir = os.path.join(shareroot, '_meta', 'history')
os.makedirs(historydir, exist_ok=True)
stamp = _utcnow().strftime('%Y%m%d-%H%M%S')
with open(target) as src:
old = src.read()
with open(os.path.join(historydir, f'{stamp}-{scopename}.json'), 'w') as dst:
dst.write(old)
# Atomic write: a partial/failed write must never leave the live on-share
# manifest (which every PC reads) truncated. Write a temp file in the same
# directory, then rename over the target.
targetdir = os.path.dirname(target)
fd, tmppath = tempfile.mkstemp(dir=targetdir, suffix='.tmp')
try:
with os.fdopen(fd, 'w') as handle:
handle.write(published.manifestjson)
os.replace(tmppath, target)
except Exception:
if os.path.exists(tmppath):
os.remove(tmppath)
raise
return target