diff --git a/docs/PLUGIN-HOOKS.md b/docs/PLUGIN-HOOKS.md index 0263d23..bed411e 100644 --- a/docs/PLUGIN-HOOKS.md +++ b/docs/PLUGIN-HOOKS.md @@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra The framework declares its contract version in `shopdb/__init__.py`: ```python -__contract_version__ = '0.10.0' +__contract_version__ = '0.11.0' ``` Each plugin's `manifest.json` declares the range of contract versions it supports: @@ -469,7 +469,11 @@ What `shopdb.api` exposes: - Responses: `success_response`, `error_response`, `paginated_response`, `ErrorCodes` - Pagination: `get_pagination_params`, `paginate_query` -- Authorization: `require_permission`, `require_role` +- Authorization: `require_permission`, `require_role`, + `service_token_authorized` + (`service_token_authorized(scope)` returns True when the request carries a + managed service token scoped for `scope` whose owner holds that permission - + for unattended plugin endpoints like the GE-Enforce fetch API) - Helpers: `audit_log`, `resolve_asset_position`, `resolve_dualpath_pairs`, `dualpath_single_machine_enabled` - Import mode: `apply_import_timestamps`, `import_mode_active`, diff --git a/plugins/geenforce/__init__.py b/plugins/geenforce/__init__.py new file mode 100644 index 0000000..8798ba9 --- /dev/null +++ b/plugins/geenforce/__init__.py @@ -0,0 +1,5 @@ +"""GE-Enforce manifest-store plugin package.""" + +from .plugin import GeEnforcePlugin + +__all__ = ['GeEnforcePlugin'] diff --git a/plugins/geenforce/api/__init__.py b/plugins/geenforce/api/__init__.py new file mode 100644 index 0000000..34c47e7 --- /dev/null +++ b/plugins/geenforce/api/__init__.py @@ -0,0 +1,5 @@ +"""GE-Enforce plugin API blueprint.""" + +from .routes import geenforce_bp + +__all__ = ['geenforce_bp'] diff --git a/plugins/geenforce/api/routes.py b/plugins/geenforce/api/routes.py new file mode 100644 index 0000000..5e6a28c --- /dev/null +++ b/plugins/geenforce/api/routes.py @@ -0,0 +1,117 @@ +"""GE-Enforce plugin API. + +Two audiences: +- Admin (JWT + geenforce.manage): browse scopes and preview the draft manifest. + Full CRUD + publish lands in P2; this is the P1/first-slice read surface. +- Client (service token, geenforce.fetch scope): GET /manifest serves the + CURRENT PUBLISHED snapshot for a scope, never the live draft. Auth mirrors the + collector's managed-token pattern (X-API-Key or Bearer PAT), plus an optional + GEENFORCE_API_KEY env bootstrap. +""" + +from functools import wraps + +from flask import Blueprint, request, current_app, Response +from flask_jwt_extended import jwt_required + +from shopdb.api import ( + db, success_response, error_response, ErrorCodes, require_permission, + service_token_authorized, +) + +from ..models import ManifestScope, ManifestPublishedVersion +from ..serializer import scope_to_manifest + +geenforce_bp = Blueprint('geenforce', __name__) + +FETCH_SCOPE = 'geenforce.fetch' + + +def require_fetch_token(f): + """Require a geenforce.fetch service token OR the env bootstrap key.""" + @wraps(f) + def decorated(*args, **kwargs): + if service_token_authorized(FETCH_SCOPE): + return f(*args, **kwargs) + expected = current_app.config.get('GEENFORCE_API_KEY') + if expected and request.headers.get('X-API-Key') == expected: + return f(*args, **kwargs) + return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key', + http_code=401) + return decorated + + +# -- client-facing endpoint --------------------------------------------------- + +@geenforce_bp.route('/manifest', methods=['GET']) +@require_fetch_token +def get_manifest(): + """Serve the current published manifest for a scope (fat-client: full scope). + + Query: pctype (=scopename, required), phase (default runtime). The engine + filters client-side, matching today, so subtype/hostname/machinenumber/ + cmmversion are accepted but not applied here. + """ + scopename = (request.args.get('pctype') or '').strip() + phase = (request.args.get('phase') or 'runtime').strip() + if not scopename: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'pctype is required', http_code=400) + + scope = ManifestScope.query.filter_by( + scopename=scopename, phase=phase).first() + if not scope: + return error_response(ErrorCodes.NOT_FOUND, + f'No scope: {scopename}', http_code=404) + + published = scope.publishedversions.filter_by(iscurrent=True).first() + if not published: + return error_response(ErrorCodes.NOT_FOUND, + f'{scopename} has no published version', + http_code=404) + + etag = f'"{scope.scopeid}-v{published.versionnumber}"' + if request.headers.get('If-None-Match') == etag: + return Response(status=304, headers={'ETag': etag}) + return Response(published.manifestjson, mimetype='application/json', + headers={'ETag': etag, + 'X-Manifest-Version': str(published.versionnumber)}) + + +# -- admin read surface (P2 adds full CRUD + publish) ------------------------- + +@geenforce_bp.route('/scopes', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def list_scopes(): + """List imaging PC-type scopes with entry + published-version counts.""" + scopes = ManifestScope.query.order_by( + ManifestScope.phase, ManifestScope.scopename).all() + data = [] + for scope in scopes: + current = scope.publishedversions.filter_by(iscurrent=True).first() + data.append({ + 'scopeid': scope.scopeid, + 'scopename': scope.scopename, + 'phase': scope.phase, + 'manifestversion': scope.manifestversion, + 'computertypeid': scope.computertypeid, + 'measuringtooltypeid': scope.measuringtooltypeid, + 'iscommon': scope.iscommon, + 'entrycount': len(scope.entries), + 'publishedversion': current.versionnumber if current else None, + }) + return success_response(data) + + +@geenforce_bp.route('/scopes//preview', methods=['GET']) +@jwt_required() +@require_permission('geenforce.manage') +def preview_scope(scopeid): + """Render the DRAFT manifest JSON a publish would freeze (review before ship).""" + scope = db.session.get(ManifestScope, scopeid) + if not scope: + return error_response(ErrorCodes.NOT_FOUND, 'No such scope', + http_code=404) + return success_response({'scopename': scope.scopename, + 'manifest': scope_to_manifest(scope)}) diff --git a/plugins/geenforce/filters.py b/plugins/geenforce/filters.py new file mode 100644 index 0000000..f7dbb40 --- /dev/null +++ b/plugins/geenforce/filters.py @@ -0,0 +1,126 @@ +"""Python mirror of the GE-Enforce engine's targeting filters. + +These re-implement, field for field, the four filter functions in +`Install-FromManifest.ps1` (Test-PCTypeMatches, Test-HostnameMatches, +Test-MachineNumberMatches, Test-CmmVersionMatches) plus the PCTypes alias graph +(lines 463-475). They operate on plain manifest-entry dicts (the exact shape of +an Applications[] entry) and a machine-profile dict, so the SAME code answers +two questions: + + 1. Parity harness: do the on-share manifest and the shopdb-rebuilt manifest + select the same entries in the same order for a set of machine profiles? + 2. Simulator: "what would PC Y get" - which entries apply and why. + +The engine lib stays the single source of truth; this is a mirror. If the lib's +alias map changes, ALIAS_GROUPS here must change with it (the parity harness's +legacy-name profiles fail loudly on divergence). +""" + +import fnmatch + +# Mirror of $script:_pcTypeAliasGroups in Install-FromManifest.ps1:463-475. +# Rows are alias sets: names in the same set all match the same identity. +ALIAS_GROUPS = [ + ('Standard', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections', + 'gea-shopfloor-common'), + ('Standard-Machine', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections'), + ('Standard-Timeclock', 'gea-shopfloor-common'), + ('CMM', 'gea-shopfloor-cmm'), + ('Keyence', 'gea-shopfloor-keyence'), + ('Lab', 'gea-shopfloor-common'), + ('WaxAndTrace', 'gea-shopfloor-waxtrace'), + ('Genspect', 'gea-shopfloor-genspect'), + ('Display', 'gea-shopfloor-display'), + ('Heattreat', 'gea-shopfloor-heattreat'), + ('PartMarker', 'gea-shopfloor-partmarker'), +] + + +def _alias_sets(name): + """Every alias group containing `name` (case-insensitive).""" + lname = (name or '').lower() + return [g for g in ALIAS_GROUPS if any(n.lower() == lname for n in g)] + + +def matches_pctype(entry, pctype, subtype=None): + """Test-PCTypeMatches: no PCTypes = all; '*' = all; alias-set intersection.""" + values = entry.get('PCTypes') or [] + if not values: + return True + if not pctype: + return True + # Names the current PC matches: bare type, "type-subtype", and all aliases. + mynames = set() + mynames.add(pctype.lower()) + seeds = [pctype] + if subtype: + combined = f'{pctype}-{subtype}' + mynames.add(combined.lower()) + seeds.append(combined) + for seed in seeds: + for group in _alias_sets(seed): + for alias in group: + mynames.add(alias.lower()) + for value in values: + if value == '*': + return True + if value.lower() in mynames: + return True + # The manifest value may itself be an alias - expand and check overlap. + for group in _alias_sets(value): + for alias in group: + if alias.lower() in mynames: + return True + return False + + +def matches_hostname(entry, hostname): + """Test-HostnameMatches: exact or PowerShell -like glob (*, ?, [set]).""" + patterns = entry.get('TargetHostnames') or [] + if not patterns: + return True + myname = (hostname or '').lower() + for pattern in patterns: + lpattern = (pattern or '').lower() + if lpattern == myname: + return True + if fnmatch.fnmatch(myname, lpattern): + return True + return False + + +def matches_machinenumber(entry, machinenumber): + """Test-MachineNumberMatches: filter present but PC has no number = exclude.""" + numbers = entry.get('TargetMachineNumbers') or [] + if not numbers: + return True + if not machinenumber: + return False + mynumber = str(machinenumber).lower() + return any(str(n).lower() == mynumber for n in numbers) + + +def matches_cmmversion(entry, cmmversion): + """Test-CmmVersionMatches: untagged always applies; no PC version = install-all.""" + tag = entry.get('_CmmVersion') + if not tag: + return True + if not cmmversion: + return True + return str(tag).lower() == str(cmmversion).lower() + + +def entry_applies(entry, profile): + """All four filters ANDed, exactly as the engine's main loop applies them. + + `profile` keys: pctype, subtype, hostname, machinenumber, cmmversion. + """ + return (matches_pctype(entry, profile.get('pctype'), profile.get('subtype')) + and matches_hostname(entry, profile.get('hostname')) + and matches_machinenumber(entry, profile.get('machinenumber')) + and matches_cmmversion(entry, profile.get('cmmversion'))) + + +def applicable_entry_names(applications, profile): + """Ordered list of entry Names that pass all filters for a profile.""" + return [e.get('Name') for e in applications if entry_applies(e, profile)] diff --git a/plugins/geenforce/importer.py b/plugins/geenforce/importer.py new file mode 100644 index 0000000..2d8b092 --- /dev/null +++ b/plugins/geenforce/importer.py @@ -0,0 +1,102 @@ +"""Parse on-share manifest.json / preinstall.json into draft DB rows. + +The reverse of serializer.py. `build_scope` turns a parsed manifest dict into a +ManifestScope with its ordered entries and child rows (not yet committed). +`discover_share` walks a GE-Enforce share root and yields (scopename, phase, +manifest_dict) for common + every gea-shopfloor-* runtime manifest, skipping +`.bak` variants. `load_preinstall` reads a preinstall.json. + +Import is a rebuild: the caller replaces a scope's draft rows so re-running is +idempotent. +""" + +import json +import os + +from .models import ( + ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname, + ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess, +) +from .serializer import _SCALAR_FIELDS, _BOOL_FLAGS + +# manifest key -> model attr (reverse of the serializer's scalar map). +_KEY_TO_ATTR = {mk: attr for attr, mk in _SCALAR_FIELDS} +_FLAG_TO_ATTR = {mk: attr for attr, mk in _BOOL_FLAGS} + + +def build_entry(entry_dict, sortorder): + """Build a ManifestEntry (+ children) from one Applications[] entry.""" + entry = ManifestEntry(sortorder=sortorder, + name=entry_dict.get('Name'), + entrytype=entry_dict.get('Type')) + if '_comment' in entry_dict: + entry.comment = entry_dict['_comment'] + for key, attr in _KEY_TO_ATTR.items(): + if attr in ('name', 'entrytype'): + continue + if key in entry_dict and entry_dict[key] is not None: + setattr(entry, attr, entry_dict[key]) + # RegValue stored as its raw JSON literal so DWord vs string typing survives. + if 'RegValue' in entry_dict: + entry.regvalue = json.dumps(entry_dict['RegValue']) + for key, attr in _FLAG_TO_ATTR.items(): + if entry_dict.get(key): + setattr(entry, attr, True) + # Multi-value filters -> child rows (preserve order). + for i, value in enumerate(entry_dict.get('PCTypes') or []): + entry.pctypes.append(ManifestEntryPcType(sortorder=i, pctypevalue=value)) + for i, value in enumerate(entry_dict.get('TargetHostnames') or []): + entry.hostnames.append( + ManifestEntryHostname(sortorder=i, hostnamepattern=value)) + for i, value in enumerate(entry_dict.get('TargetMachineNumbers') or []): + entry.machinenumbers.append( + ManifestEntryMachineNumber(sortorder=i, machinenumber=str(value))) + # InUseCheck object + Processes[]. + inuse = entry_dict.get('InUseCheck') + if inuse: + check = ManifestInUseCheck(behavior=inuse.get('Behavior')) + for i, proc in enumerate(inuse.get('Processes') or []): + check.processes.append(ManifestInUseCheckProcess( + sortorder=i, + processname=proc.get('Name'), + exepath=proc.get('ExePath'), + gracefulclosetimeoutsec=proc.get('GracefulCloseTimeoutSec'))) + entry.inusecheck = check + return entry + + +def build_scope(scopename, phase, manifest_dict, iscommon=False): + """Build a ManifestScope (+ entries) from a parsed manifest dict.""" + scope = ManifestScope( + scopename=scopename, + phase=phase, + manifestversion=str(manifest_dict.get('Version', '1.0')), + topcomment=manifest_dict.get('_comment'), + site=manifest_dict.get('Site'), + iscommon=iscommon) + for i, entry_dict in enumerate(manifest_dict.get('Applications') or []): + scope.entries.append(build_entry(entry_dict, i)) + return scope + + +def discover_share(shareroot): + """Yield (scopename, phase, manifest_dict) for runtime manifests on a share. + + Reads `common/manifest.json` and every `gea-shopfloor-*/manifest.json`. + Skips `.bak` / `.pre-*.bak` variants (only the exact `manifest.json`). + """ + for name in sorted(os.listdir(shareroot)): + if name != 'common' and not name.startswith('gea-shopfloor-'): + continue + path = os.path.join(shareroot, name, 'manifest.json') + if not os.path.isfile(path): + continue + with open(path) as handle: + manifest = json.load(handle) + yield name, 'runtime', manifest + + +def load_manifest_file(path): + """Parse a single manifest.json / preinstall.json file.""" + with open(path) as handle: + return json.load(handle) diff --git a/plugins/geenforce/manifest.json b/plugins/geenforce/manifest.json new file mode 100644 index 0000000..3829823 --- /dev/null +++ b/plugins/geenforce/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "geenforce", + "version": "0.1.0", + "description": "GE-Enforce manifest store. Owns imaging PC-type scopes and their install manifests (apps, scripts, files, registry, version gates) as shopdb data, served to the GE-Enforce client as JSON. Requires GE-Enforce lib >= 2.6 on target PCs (the _CmmVersion gate).", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.7.0,<1.0.0", + "api_prefix": "/api/geenforce", + "default_enabled": false, + "provides": { + "features": ["manifest-store", "imaging-pc-types"] + } +} diff --git a/plugins/geenforce/migrations/env.py b/plugins/geenforce/migrations/env.py new file mode 100644 index 0000000..bb8998d --- /dev/null +++ b/plugins/geenforce/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the GE-Enforce plugin migration chain. + +Delegates to the shared runner in shopdb.plugins.alembic_template, which filters +the metadata to this plugin's tables and drives Alembic against the per-plugin +version table alembic_version_geenforce (ADR-008). This plugin is NEW: its 0001 +baseline really CREATES its tables, because the core chain never built them. +""" +import os + +os.environ['PLUGIN_NAME'] = 'geenforce' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/geenforce/migrations/script.py.mako b/plugins/geenforce/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/plugins/geenforce/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/plugins/geenforce/migrations/versions/0001_geenforce_baseline.py b/plugins/geenforce/migrations/versions/0001_geenforce_baseline.py new file mode 100644 index 0000000..f8a9511 --- /dev/null +++ b/plugins/geenforce/migrations/versions/0001_geenforce_baseline.py @@ -0,0 +1,198 @@ +"""geenforce plugin baseline (real create). + +This plugin was built after the ADR-008 ownership cutover, so this baseline +actually CREATES the plugin's tables (the core chain never knew about them). It +runs from `flask plugin install geenforce` / `flask plugin upgrade-all` after +`flask db upgrade` builds the core schema. All foreign keys are intra-plugin, so +tables are created parent-first. computertypeid / measuringtooltypeid / +publishedby are SOFT references (plain columns, no FK) to optional plugins/core. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'geenforce0001baseline' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'manifestscopes', + sa.Column('scopeid', sa.Integer(), nullable=False), + sa.Column('scopename', sa.String(length=64), nullable=False), + sa.Column('phase', sa.String(length=16), nullable=False), + sa.Column('computertypeid', sa.Integer(), nullable=True), + sa.Column('measuringtooltypeid', sa.Integer(), nullable=True), + sa.Column('manifestversion', sa.String(length=16), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('topcomment', sa.Text(), nullable=True), + sa.Column('site', sa.String(length=100), nullable=True), + sa.Column('iscommon', sa.Boolean(), nullable=False), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('scopeid'), + sa.UniqueConstraint('scopename', 'phase', name='uq_scope_name_phase'), + ) + op.create_table( + 'manifestentries', + sa.Column('entryid', sa.Integer(), nullable=False), + sa.Column('scopeid', sa.Integer(), nullable=False), + sa.Column('sortorder', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('entrytype', sa.String(length=16), nullable=False), + sa.Column('comment', sa.Text(), nullable=True), + sa.Column('installer', sa.String(length=255), nullable=True), + sa.Column('installargs', sa.Text(), nullable=True), + sa.Column('scriptpath', sa.String(length=255), nullable=True), + sa.Column('scriptargs', sa.String(length=255), nullable=True), + sa.Column('sourcepath', sa.String(length=255), nullable=True), + sa.Column('destination', sa.String(length=255), nullable=True), + sa.Column('regpath', sa.String(length=255), nullable=True), + sa.Column('regname', sa.String(length=128), nullable=True), + sa.Column('regvalue', sa.Text(), nullable=True), + sa.Column('regtype', sa.String(length=16), nullable=True), + sa.Column('payloadsource', sa.String(length=8), nullable=False), + sa.Column('payloadref', sa.String(length=512), nullable=True), + sa.Column('payloadsha256', sa.String(length=64), nullable=True), + sa.Column('detectionmethod', sa.String(length=16), nullable=True), + sa.Column('detectionpath', sa.String(length=255), nullable=True), + sa.Column('detectionname', sa.String(length=128), nullable=True), + sa.Column('detectionvalue', sa.String(length=255), nullable=True), + sa.Column('detectionpattern', sa.String(length=255), nullable=True), + sa.Column('cmmversion', sa.String(length=16), nullable=True), + sa.Column('logfile', sa.String(length=255), nullable=True), + sa.Column('waittimeoutsec', sa.Integer(), nullable=True), + sa.Column('applymode', sa.String(length=16), nullable=True), + sa.Column('updatewindow', sa.String(length=11), nullable=True), + sa.Column('preenrollment', sa.Boolean(), nullable=False), + sa.Column('killafterdetection', sa.Boolean(), nullable=False), + sa.Column('pctypesstrict', sa.Boolean(), nullable=False), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['scopeid'], ['manifestscopes.scopeid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('entryid'), + sa.UniqueConstraint('scopeid', 'name', name='uq_entry_scope_name'), + ) + op.create_index('idx_entry_scope', 'manifestentries', ['scopeid']) + op.create_index('idx_entry_scope_order', 'manifestentries', + ['scopeid', 'sortorder']) + op.create_table( + 'manifestentrypctypes', + sa.Column('entrypctypeid', sa.Integer(), nullable=False), + sa.Column('entryid', sa.Integer(), nullable=False), + sa.Column('sortorder', sa.Integer(), nullable=False), + sa.Column('pctypevalue', sa.String(length=64), nullable=False), + sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('entrypctypeid'), + ) + op.create_index('idx_pctype_entry', 'manifestentrypctypes', ['entryid']) + op.create_table( + 'manifestentryhostnames', + sa.Column('entryhostnameid', sa.Integer(), nullable=False), + sa.Column('entryid', sa.Integer(), nullable=False), + sa.Column('sortorder', sa.Integer(), nullable=False), + sa.Column('hostnamepattern', sa.String(length=64), nullable=False), + sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('entryhostnameid'), + ) + op.create_index('idx_hostname_entry', 'manifestentryhostnames', ['entryid']) + op.create_table( + 'manifestentrymachinenumbers', + sa.Column('entrymachinenumberid', sa.Integer(), nullable=False), + sa.Column('entryid', sa.Integer(), nullable=False), + sa.Column('sortorder', sa.Integer(), nullable=False), + sa.Column('machinenumber', sa.String(length=16), nullable=False), + sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('entrymachinenumberid'), + ) + op.create_index('idx_machinenumber_entry', 'manifestentrymachinenumbers', + ['entryid']) + op.create_table( + 'manifestinusechecks', + sa.Column('inusecheckid', sa.Integer(), nullable=False), + sa.Column('entryid', sa.Integer(), nullable=False), + sa.Column('behavior', sa.String(length=20), nullable=False), + sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('inusecheckid'), + sa.UniqueConstraint('entryid'), + ) + op.create_table( + 'manifestinusecheckprocesses', + sa.Column('inusecheckprocessid', sa.Integer(), nullable=False), + sa.Column('inusecheckid', sa.Integer(), nullable=False), + sa.Column('sortorder', sa.Integer(), nullable=False), + sa.Column('processname', sa.String(length=64), nullable=False), + sa.Column('exepath', sa.String(length=255), nullable=True), + sa.Column('gracefulclosetimeoutsec', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['inusecheckid'], + ['manifestinusechecks.inusecheckid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('inusecheckprocessid'), + ) + op.create_index('idx_process_check', 'manifestinusecheckprocesses', + ['inusecheckid']) + op.create_table( + 'manifestpublishedversions', + sa.Column('publishedversionid', sa.Integer(), nullable=False), + sa.Column('scopeid', sa.Integer(), nullable=False), + sa.Column('versionnumber', sa.Integer(), nullable=False), + sa.Column('manifestjson', sa.Text(length=16777215), nullable=False), + sa.Column('publishedat', sa.DateTime(), nullable=False), + sa.Column('publishedby', sa.Integer(), nullable=True), + sa.Column('iscurrent', sa.Boolean(), nullable=False), + sa.Column('notes', sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint(['scopeid'], ['manifestscopes.scopeid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('publishedversionid'), + sa.UniqueConstraint('scopeid', 'versionnumber', + name='uq_published_scope_version'), + ) + op.create_index('idx_published_scope', 'manifestpublishedversions', + ['scopeid']) + op.create_table( + 'manifestpayloads', + sa.Column('payloadid', sa.Integer(), nullable=False), + sa.Column('entryid', sa.Integer(), nullable=False), + sa.Column('filename', sa.String(length=255), nullable=False), + sa.Column('contenttype', sa.String(length=128), nullable=True), + sa.Column('payloadbytes', sa.LargeBinary(length=16777215), nullable=False), + sa.Column('payloadsha256', sa.String(length=64), nullable=False), + sa.Column('uploadedat', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('payloadid'), + ) + op.create_index('idx_payload_entry', 'manifestpayloads', ['entryid']) + op.create_table( + 'pctypealiases', + sa.Column('aliasid', sa.Integer(), nullable=False), + sa.Column('aliasgroup', sa.Integer(), nullable=False), + sa.Column('aliasname', sa.String(length=64), nullable=False), + sa.PrimaryKeyConstraint('aliasid'), + sa.UniqueConstraint('aliasgroup', 'aliasname', + name='uq_alias_group_name'), + ) + op.create_index('idx_alias_group', 'pctypealiases', ['aliasgroup']) + + +def downgrade(): + op.drop_table('pctypealiases') + op.drop_table('manifestpayloads') + op.drop_table('manifestpublishedversions') + op.drop_table('manifestinusecheckprocesses') + op.drop_table('manifestinusechecks') + op.drop_table('manifestentrymachinenumbers') + op.drop_table('manifestentryhostnames') + op.drop_table('manifestentrypctypes') + op.drop_table('manifestentries') + op.drop_table('manifestscopes') diff --git a/plugins/geenforce/models/__init__.py b/plugins/geenforce/models/__init__.py new file mode 100644 index 0000000..fbcfd1a --- /dev/null +++ b/plugins/geenforce/models/__init__.py @@ -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', +] diff --git a/plugins/geenforce/models/manifest.py b/plugins/geenforce/models/manifest.py new file mode 100644 index 0000000..418f929 --- /dev/null +++ b/plugins/geenforce/models/manifest.py @@ -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"" + + +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"" + + +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'), + ) diff --git a/plugins/geenforce/parity.py b/plugins/geenforce/parity.py new file mode 100644 index 0000000..85093ba --- /dev/null +++ b/plugins/geenforce/parity.py @@ -0,0 +1,94 @@ +"""P1 behavioral-parity harness (Gate A). + +Proves the model is lossless WITHOUT byte-diffing: for each manifest, import it +into in-memory rows and render it back out, then check that + + 1. every entry is field-for-field identical (engine-relevant fields only, + dropping _comment and key order), in the same order, AND + 2. the same entries fire in the same order for a set of machine-profile + fixtures, using the same filter logic the engine uses (filters.py). + +Runs with no database (build_scope produces detached ORM objects whose +relationship lists are read directly), so it is cheap to re-run and wrap in CI. +Output is one readable line per scope. +""" + +import json +import os + +from .importer import build_scope +from .serializer import scope_to_manifest, canonical_entry +from .filters import applicable_entry_names + +_FIXTURES_PATH = os.path.join(os.path.dirname(__file__), 'parityfixtures.json') + + +def load_fixtures(): + with open(_FIXTURES_PATH) as handle: + return json.load(handle) + + +def check_scope(scopename, phase, original, fixtures): + """Compare an original manifest against its import+export round-trip.""" + rebuilt = scope_to_manifest(build_scope(scopename, phase, original)) + orig_apps = original.get('Applications') or [] + rebuilt_apps = rebuilt.get('Applications') or [] + + # Check 1: field-identical, order-preserving. + identical = 0 + firstdiff = None + if len(orig_apps) != len(rebuilt_apps): + firstdiff = (f'entry count {len(orig_apps)} vs {len(rebuilt_apps)}') + for i in range(min(len(orig_apps), len(rebuilt_apps))): + co = canonical_entry(orig_apps[i]) + cr = canonical_entry(rebuilt_apps[i]) + if co == cr: + identical += 1 + elif firstdiff is None: + name = orig_apps[i].get('Name', f'#{i}') + diffkeys = sorted( + k for k in set(co) | set(cr) if co.get(k) != cr.get(k)) + firstdiff = f'entry "{name}" differs on {diffkeys}' + + # Check 2: same entries fire in the same order for each profile. + profiles_same = 0 + for profile in fixtures: + if (applicable_entry_names(orig_apps, profile) + == applicable_entry_names(rebuilt_apps, profile)): + profiles_same += 1 + elif firstdiff is None: + firstdiff = f'filter mismatch for profile {profile.get("label")}' + + passed = (identical == len(orig_apps) == len(rebuilt_apps) + and profiles_same == len(fixtures)) + return { + 'scopename': scopename, + 'phase': phase, + 'entries_total': len(orig_apps), + 'entries_identical': identical, + 'profiles_total': len(fixtures), + 'profiles_same': profiles_same, + 'passed': passed, + 'firstdiff': firstdiff, + } + + +def run_parity(manifests, fixtures=None): + """Run parity over a list of (scopename, phase, manifest_dict). Returns + (results, all_passed).""" + fixtures = fixtures if fixtures is not None else load_fixtures() + results = [check_scope(name, phase, manifest, fixtures) + for name, phase, manifest in manifests] + return results, all(r['passed'] for r in results) + + +def format_result(result): + """One human-readable line for a scope result.""" + status = 'PASS' if result['passed'] else 'FAIL' + line = (f"scope {result['scopename']:<28} " + f"entries {result['entries_identical']}/{result['entries_total']} identical " + f"profiles {result['profiles_same']}/{result['profiles_total']} same-fire " + f"{status}") + if not result['passed'] and result['firstdiff']: + line += f"\n -> {result['firstdiff']}" + return line diff --git a/plugins/geenforce/parityfixtures.json b/plugins/geenforce/parityfixtures.json new file mode 100644 index 0000000..2ec3045 --- /dev/null +++ b/plugins/geenforce/parityfixtures.json @@ -0,0 +1,20 @@ +[ + {"label": "collections-okuma-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC0615", "machinenumber": "3201", "cmmversion": ""}, + {"label": "collections-mtconnect-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC7501", "machinenumber": "7501", "cmmversion": ""}, + {"label": "collections-other-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC0001", "machinenumber": "0001", "cmmversion": ""}, + {"label": "nocollections", "pctype": "gea-shopfloor-nocollections", "subtype": "", "hostname": "WJPC2000", "machinenumber": "", "cmmversion": ""}, + {"label": "common-timeclock", "pctype": "gea-shopfloor-common", "subtype": "", "hostname": "WJPC3000", "machinenumber": "", "cmmversion": ""}, + {"label": "cmm-2016", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM01", "machinenumber": "9101", "cmmversion": "2016"}, + {"label": "cmm-2019", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM02", "machinenumber": "9102", "cmmversion": "2019"}, + {"label": "cmm-2026", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM11", "machinenumber": "9111", "cmmversion": "2026"}, + {"label": "cmm-noversion-legacy", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM99", "machinenumber": "9199", "cmmversion": ""}, + {"label": "keyence-vr6000", "pctype": "Keyence", "subtype": "vr6000", "hostname": "WJKEY01", "machinenumber": "8001", "cmmversion": ""}, + {"label": "keyence-vr3000", "pctype": "Keyence", "subtype": "vr3000", "hostname": "WJKEY02", "machinenumber": "8002", "cmmversion": ""}, + {"label": "genspect", "pctype": "gea-shopfloor-genspect", "subtype": "", "hostname": "WJGEN01", "machinenumber": "8100", "cmmversion": ""}, + {"label": "heattreat", "pctype": "gea-shopfloor-heattreat", "subtype": "", "hostname": "WJHT01", "machinenumber": "8200", "cmmversion": ""}, + {"label": "waxtrace", "pctype": "gea-shopfloor-waxtrace", "subtype": "", "hostname": "WJWAX01", "machinenumber": "8300", "cmmversion": ""}, + {"label": "partmarker", "pctype": "gea-shopfloor-partmarker", "subtype": "", "hostname": "WJPM01", "machinenumber": "8400", "cmmversion": ""}, + {"label": "display", "pctype": "gea-shopfloor-display", "subtype": "", "hostname": "WJDISP01", "machinenumber": "", "cmmversion": ""}, + {"label": "legacy-standard-machine", "pctype": "Standard", "subtype": "Machine", "hostname": "WJLEG01", "machinenumber": "3202", "cmmversion": ""}, + {"label": "legacy-cmm-name", "pctype": "CMM", "subtype": "", "hostname": "WJLEG02", "machinenumber": "9102", "cmmversion": "2019"} +] diff --git a/plugins/geenforce/plugin.py b/plugins/geenforce/plugin.py new file mode 100644 index 0000000..dcb8383 --- /dev/null +++ b/plugins/geenforce/plugin.py @@ -0,0 +1,178 @@ +"""GE-Enforce manifest-store plugin. + +Owns the imaging-PC-type scopes and their install manifests as shopdb data +(see docs/proposals/ge-enforce-plugin.md). This is the P0/P1 foundation: models, +the alias-graph seed, and the client-facing manifest endpoint. Authoring UI and +client cutover come in later phases. +""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +import click +from flask import Flask, Blueprint + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.api import db + +from .api import geenforce_bp +from .models import ( + ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname, + ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess, + ManifestPublishedVersion, ManifestPayload, PcTypeAlias, +) +from .filters import ALIAS_GROUPS + +logger = logging.getLogger(__name__) + + +class GeEnforcePlugin(BasePlugin): + """Desired-state manifest store for the GE-Enforce client.""" + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + manifest_path = Path(__file__).parent / 'manifest.json' + if manifest_path.exists(): + with open(manifest_path, 'r') as handle: + return json.load(handle) + return {} + + @property + def meta(self) -> PluginMeta: + return PluginMeta( + name=self._manifest.get('name', 'geenforce'), + version=self._manifest.get('version', '0.1.0'), + description=self._manifest.get('description', 'GE-Enforce manifest store'), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.7.0,<1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/geenforce'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + return geenforce_bp + + def get_models(self) -> List[Type]: + return [ + ManifestScope, ManifestEntry, ManifestEntryPcType, + ManifestEntryHostname, ManifestEntryMachineNumber, + ManifestInUseCheck, ManifestInUseCheckProcess, + ManifestPublishedVersion, ManifestPayload, PcTypeAlias, + ] + + def get_permissions(self) -> List: + """RBAC permissions this plugin owns (edit vs ship are split).""" + return [ + ('geenforce.manage', 'Edit imaging PC types and manifest drafts', + 'geenforce'), + ('geenforce.publish', 'Publish, roll back, and export manifests', + 'geenforce'), + ('geenforce.fetch', 'Fetch published manifests (client service token)', + 'geenforce'), + ] + + def init_app(self, app: Flask, db_instance) -> None: + logger.info(f"GE-Enforce plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + with app.app_context(): + self._seed_aliases() + db.session.commit() + logger.info("GE-Enforce plugin installed") + + def _seed_aliases(self) -> None: + """Seed pctypealiases from the engine lib's alias graph (idempotent).""" + for group_index, group in enumerate(ALIAS_GROUPS): + for aliasname in group: + exists = PcTypeAlias.query.filter_by( + aliasgroup=group_index, aliasname=aliasname).first() + if not exists: + db.session.add(PcTypeAlias( + aliasgroup=group_index, aliasname=aliasname)) + + def get_cli_commands(self) -> List: + """CLI: parity (Gate A), import-share, export-share, publish.""" + + @click.group('geenforce') + def geenforce_cli(): + """GE-Enforce manifest-store commands.""" + + @geenforce_cli.command('parity') + @click.option('--shareroot', required=True, + help='GE-Enforce share root (contains common/, ' + 'gea-shopfloor-*/).') + @click.option('--preinstall', default=None, + help='Optional path to a preinstall.json to include.') + def parity_cmd(shareroot, preinstall): + """Prove import+export is behaviorally lossless (Gate A). DB-free.""" + from .importer import discover_share, load_manifest_file + from .parity import run_parity, format_result + + manifests = list(discover_share(shareroot)) + if preinstall: + manifests.append( + ('preinstall', 'preinstall', load_manifest_file(preinstall))) + results, ok = run_parity(manifests) + for result in results: + click.echo(format_result(result)) + click.echo(f"RESULT: {'PASS' if ok else 'FAIL'} " + f"({len(results)} scopes)") + raise SystemExit(0 if ok else 1) + + @geenforce_cli.command('import-share') + @click.option('--shareroot', required=True) + @click.option('--preinstall', default=None) + @click.option('--scope', default=None, + help='Import only this scope name (else all).') + def import_share_cmd(shareroot, preinstall, scope): + """Import on-share manifests into draft rows (idempotent rebuild).""" + from flask import current_app + from .importer import discover_share, load_manifest_file, build_scope + from .service import replace_scope_draft + + with current_app.app_context(): + sources = list(discover_share(shareroot)) + if preinstall: + sources.append(('preinstall', 'preinstall', + load_manifest_file(preinstall))) + count = 0 + for name, phase, manifest in sources: + if scope and name != scope: + continue + replace_scope_draft(name, phase, manifest) + count += 1 + db.session.commit() + click.echo(f"Imported {count} scope(s).") + + @geenforce_cli.command('publish') + @click.argument('scopename') + @click.option('--phase', default='runtime') + @click.option('--notes', default=None) + def publish_cmd(scopename, phase, notes): + """Freeze the current draft of a scope into a published snapshot.""" + from flask import current_app + from .service import publish_scope + + with current_app.app_context(): + version = publish_scope(scopename, phase, notes=notes) + db.session.commit() + click.echo(f"Published {scopename}/{phase} as v{version}.") + + @geenforce_cli.command('export-share') + @click.argument('scopename') + @click.option('--shareroot', required=True) + @click.option('--phase', default='runtime') + def export_share_cmd(scopename, shareroot, phase): + """Write a scope's published JSON to the share (with history backup).""" + from flask import current_app + from .service import export_scope_to_share + + with current_app.app_context(): + path = export_scope_to_share(scopename, phase, shareroot) + click.echo(f"Exported to {path}.") + + return [geenforce_cli] diff --git a/plugins/geenforce/serializer.py b/plugins/geenforce/serializer.py new file mode 100644 index 0000000..7fe4073 --- /dev/null +++ b/plugins/geenforce/serializer.py @@ -0,0 +1,133 @@ +"""Rebuild manifest JSON from DB rows, and canonicalize entries for parity. + +`scope_to_manifest` renders a ManifestScope back into the exact JSON structure +GE-Enforce consumes (Version / _comment / Applications, plus Site for +preinstall). `canonical_entry` reduces an Applications[] entry (from either the +on-share file or a rebuilt scope) to only the fields the engine reads, dropping +`_comment` and key order, so the parity harness can compare behavior not bytes. +""" + +import json + +# Scalar entry fields: model attribute -> manifest key. Emitted only when set, +# so a rebuilt entry has the same sparse key presence as the original. +_SCALAR_FIELDS = [ + ('name', 'Name'), + ('entrytype', 'Type'), + ('installer', 'Installer'), + ('installargs', 'InstallArgs'), + ('scriptpath', 'Script'), + ('scriptargs', 'Args'), + ('sourcepath', 'Source'), + ('destination', 'Destination'), + ('regpath', 'RegPath'), + ('regname', 'RegName'), + ('regtype', 'RegType'), + ('detectionmethod', 'DetectionMethod'), + ('detectionpath', 'DetectionPath'), + ('detectionname', 'DetectionName'), + ('detectionvalue', 'DetectionValue'), + ('detectionpattern', 'DetectionPattern'), + ('cmmversion', '_CmmVersion'), + ('logfile', 'LogFile'), + ('waittimeoutsec', 'WaitTimeoutSec'), + ('applymode', 'ApplyMode'), + ('updatewindow', 'UpdateWindow'), +] + +# Boolean flags emitted only when True (matches how preinstall.json carries them). +_BOOL_FLAGS = [ + ('preenrollment', 'PreEnrollment'), + ('killafterdetection', 'KillAfterDetection'), + ('pctypesstrict', 'PCTypesStrict'), +] + +# The complete set of engine-relevant keys the parity check compares. +ENGINE_KEYS = ( + [mk for _, mk in _SCALAR_FIELDS] + + [mk for _, mk in _BOOL_FLAGS] + + ['PCTypes', 'TargetHostnames', 'TargetMachineNumbers', 'RegValue', 'InUseCheck'] +) + + +def entry_to_dict(entry): + """Render a ManifestEntry model back into a manifest Applications[] entry.""" + result = {} + if entry.comment: + result['_comment'] = entry.comment + for attr, key in _SCALAR_FIELDS: + value = getattr(entry, attr) + if value is not None and value != '': + result[key] = value + # RegValue: stored as a raw JSON literal, reconstitute its real type. + if entry.regvalue is not None: + result['RegValue'] = json.loads(entry.regvalue) + # Multi-value filters (only when present). + if entry.pctypes: + result['PCTypes'] = [p.pctypevalue for p in entry.pctypes] + if entry.hostnames: + result['TargetHostnames'] = [h.hostnamepattern for h in entry.hostnames] + if entry.machinenumbers: + result['TargetMachineNumbers'] = [m.machinenumber + for m in entry.machinenumbers] + for attr, key in _BOOL_FLAGS: + if getattr(entry, attr): + result[key] = True + # InUseCheck (nested object + Processes[]). + if entry.inusecheck: + procs = [] + for proc in entry.inusecheck.processes: + pd = {'Name': proc.processname} + if proc.exepath is not None: + pd['ExePath'] = proc.exepath + if proc.gracefulclosetimeoutsec is not None: + pd['GracefulCloseTimeoutSec'] = proc.gracefulclosetimeoutsec + procs.append(pd) + result['InUseCheck'] = { + 'Behavior': entry.inusecheck.behavior, + 'Processes': procs, + } + return result + + +def scope_to_manifest(scope): + """Render a ManifestScope back into a full manifest.json structure.""" + manifest = {'Version': scope.manifestversion} + if scope.topcomment: + manifest['_comment'] = scope.topcomment + if scope.site: + manifest['Site'] = scope.site + manifest['Applications'] = [entry_to_dict(e) for e in scope.entries] + return manifest + + +def scope_to_json(scope, indent=2): + """The serialized JSON text a client/export would receive.""" + return json.dumps(scope_to_manifest(scope), indent=indent) + + +def canonical_entry(entry_dict): + """Reduce an Applications[] entry to engine-relevant fields for parity. + + Drops `_comment` and key order. Normalizes InUseCheck and the process list + so two entries that behave identically compare equal regardless of source. + """ + canon = {} + for key in ENGINE_KEYS: + if key == 'InUseCheck': + continue + if key in entry_dict and entry_dict[key] is not None and entry_dict[key] != '': + canon[key] = entry_dict[key] + inuse = entry_dict.get('InUseCheck') + if inuse: + procs = [] + for proc in inuse.get('Processes', []): + pd = {'Name': proc.get('Name')} + if proc.get('ExePath') is not None: + pd['ExePath'] = proc.get('ExePath') + if proc.get('GracefulCloseTimeoutSec') is not None: + pd['GracefulCloseTimeoutSec'] = proc.get('GracefulCloseTimeoutSec') + procs.append(pd) + canon['InUseCheck'] = {'Behavior': inuse.get('Behavior'), + 'Processes': procs} + return canon diff --git a/plugins/geenforce/service.py b/plugins/geenforce/service.py new file mode 100644 index 0000000..179f9de --- /dev/null +++ b/plugins/geenforce/service.py @@ -0,0 +1,121 @@ +"""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 os +from datetime import datetime, timezone + +from sqlalchemy import func + +from shopdb.api import db + +from .models import ManifestScope, ManifestPublishedVersion +from .importer import build_entry +from .serializer import scope_to_json + + +def _utcnow(): + return datetime.now(timezone.utc).replace(tzinfo=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 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) + + with open(target, 'w') as handle: + handle.write(published.manifestjson) + return target diff --git a/shopdb/__init__.py b/shopdb/__init__.py index 73db900..df00c52 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -32,7 +32,11 @@ from .plugins import plugin_manager # catalog. Consumed by full_permission_catalog() (core + enabled plugins), # which backs seeding, the role grid, and API-token scope validation. Additive # optional hook, minor bump. -__contract_version__ = '0.10.0' +# 0.11.0: added service_token_authorized(scope) to shopdb.api so a plugin's +# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped +# managed service token without importing core token internals. Additive name +# on the import surface, minor bump. +__contract_version__ = '0.11.0' # Product release version (see ADR-007). The product version and the # plugin-contract version above are distinct series with independent diff --git a/shopdb/api/__init__.py b/shopdb/api/__init__.py index 8ce20ae..d4b2ac9 100644 --- a/shopdb/api/__init__.py +++ b/shopdb/api/__init__.py @@ -58,6 +58,11 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query # Authorization decorators for gating plugin write routes from shopdb.utils.authz import require_permission, require_role +# Service-token authorization for unattended plugin endpoints (collector, +# GE-Enforce fetch, ...): checks a scoped managed token without exposing token +# internals. +from shopdb.utils.apitoken_auth import service_token_authorized + # Import-mode helpers: preserve legacy timestamps during a bulk data import from shopdb.utils.import_mode import ( apply_import_timestamps, @@ -254,6 +259,7 @@ __all__ = [ # Authorization decorators 'require_permission', 'require_role', + 'service_token_authorized', # Import-mode helpers 'apply_import_timestamps', 'import_mode_active', diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py index c6bfcd6..9a2de31 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -42,6 +42,11 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = { 'computers': ('computertypes', 'computers', 'computerinstalledapps', 'accessprotocols', 'computeraccess'), 'employees': ('directoryemployees',), + 'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes', + 'manifestentryhostnames', 'manifestentrymachinenumbers', + 'manifestinusechecks', 'manifestinusecheckprocesses', + 'manifestpublishedversions', 'manifestpayloads', + 'pctypealiases'), 'knowledgebase': ('knowledgebase',), 'machines': ('machinetypes', 'machines'), 'measuringtools': ('measuringtooltypes', 'measuringtools'), diff --git a/shopdb/utils/apitoken_auth.py b/shopdb/utils/apitoken_auth.py index f5b918f..99ae203 100644 --- a/shopdb/utils/apitoken_auth.py +++ b/shopdb/utils/apitoken_auth.py @@ -76,6 +76,40 @@ def touch_apitoken_lastused(token): db.session.commit() +def service_token_authorized(scope): + """True when the current request carries a managed token scoped for `scope` + whose owner is active and holds that permission. Accepts X-API-Key or a + Bearer PAT (the before_request shim resolves Bearer into g.apitokenid). + Touches lastusedat on success. + + The single contract-surface entry point for unattended SERVICE tokens + (collector.ingest, geenforce.fetch, ...), so plugins authorize a service + token without reaching into core token internals. Returns False on any + miss; the caller returns its own 401. + """ + from shopdb.core.models import User + + api_key = request.headers.get('X-API-Key') + token = None + if api_key and api_key.startswith(TOKEN_SECRET_PREFIX): + resolved = resolve_api_token(api_key) + token = resolved[0] if resolved else None + else: + tokenid = getattr(g, 'apitokenid', None) + if tokenid is not None: + token = db.session.get(ApiToken, tokenid) + if token is None: + return False + scopelist = token.scopelist + if not scopelist or scope not in scopelist: + return False + user = db.session.get(User, token.userid) + if user is None or not user.isactive or not user.haspermission(scope): + return False + touch_apitoken_lastused(token) + return True + + def install_apitoken_auth(app): """Register the before_request PAT shim on the app.""" diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index ff23d05..845e92f 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -46,6 +46,7 @@ CUTOVER_PLUGINS = ( # stamp '0001anchor'; measuringtools stamps its real baseline id. EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PLUGINS} EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline' +EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0001baseline' # machines (renamed from equipment) keeps its original anchor id and adds the # rename revision on top, so its head is not the f-string default. EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename' diff --git a/tests/test_plugins/test_geenforce_manifest.py b/tests/test_plugins/test_geenforce_manifest.py new file mode 100644 index 0000000..99e4efd --- /dev/null +++ b/tests/test_plugins/test_geenforce_manifest.py @@ -0,0 +1,152 @@ +"""GE-Enforce first slice: import a scope, publish it, serve it to a client. + +Exercises the vertical the plan calls the first slice (via a small synthetic +scope rather than the real gea-shopfloor-cmm): draft import -> publish -> +GET /api/geenforce/manifest with a geenforce.fetch service token; ETag/304; +draft edits never change the served bytes; publish + rollback change them. +""" + +import json + +import pytest + +from plugins.geenforce.models import ManifestScope +from plugins.geenforce import service + + +SCOPE = { + 'Version': '2.6', + 'Applications': [ + {'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/alpha.msi', + 'DetectionMethod': 'Registry', 'DetectionPath': 'HKLM:\\SOFTWARE\\Alpha'}, + {'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/beta.ps1', + 'DetectionMethod': 'Always'}, + ], +} + + +def _seed_and_publish(app, scopename='gea-shopfloor-cmm', manifest=None): + with app.app_context(): + service.replace_scope_draft(scopename, 'runtime', manifest or SCOPE) + service.publish_scope(scopename, 'runtime', notes='initial') + service.db.session.commit() + + +def _mint_fetch_token(client, auth_headers): + resp = client.post('/api/apitokens', + json={'name': 'geenforce svc', 'scopes': ['geenforce.fetch']}, + headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data']['secret'] + + +def test_import_publish_serve(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _mint_fetch_token(client, auth_headers) + + resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}) + assert resp.status_code == 200, resp.data + body = json.loads(resp.data) + assert [e['Name'] for e in body['Applications']] == ['Alpha', 'Beta'] + assert resp.headers.get('ETag') + assert resp.headers.get('X-Manifest-Version') == '1' + + +def test_etag_304(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _mint_fetch_token(client, auth_headers) + first = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}) + etag = first.headers['ETag'] + again = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret, 'If-None-Match': etag}) + assert again.status_code == 304 + + +def test_draft_edit_does_not_change_served_bytes(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _mint_fetch_token(client, auth_headers) + before = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}).data + + # Edit the DRAFT (add an entry) but do NOT publish. + changed = {'Version': '2.6', 'Applications': SCOPE['Applications'] + [ + {'Name': 'Gamma', 'Type': 'PS1', 'Script': 'scripts/gamma.ps1'}]} + with app.app_context(): + service.replace_scope_draft('gea-shopfloor-cmm', 'runtime', changed) + service.db.session.commit() + + after = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}).data + assert before == after # served bytes come from the published snapshot only + + +def test_publish_then_rollback(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _mint_fetch_token(client, auth_headers) + v1 = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}).data + + changed = {'Version': '2.6', 'Applications': SCOPE['Applications'] + [ + {'Name': 'Gamma', 'Type': 'PS1', 'Script': 'scripts/gamma.ps1'}]} + with app.app_context(): + service.replace_scope_draft('gea-shopfloor-cmm', 'runtime', changed) + service.publish_scope('gea-shopfloor-cmm', 'runtime', notes='add gamma') + service.db.session.commit() + + v2 = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}) + assert v2.headers['X-Manifest-Version'] == '2' + assert b'Gamma' in v2.data + + with app.app_context(): + service.rollback_scope('gea-shopfloor-cmm', 'runtime', 1) + service.db.session.commit() + + rolled = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}) + assert rolled.data == v1 # byte-identical: published snapshots are frozen text + + +def test_unauthenticated_rejected(client, db, app): + _seed_and_publish(app) + resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm') + assert resp.status_code == 401 + + +def test_wrong_scope_rejected(client, db, app, auth_headers): + _seed_and_publish(app) + resp = client.post('/api/apitokens', + json={'name': 'wrong', 'scopes': ['collector.ingest']}, + headers=auth_headers) + secret = resp.get_json()['data']['secret'] + manifest = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-API-Key': secret}) + assert manifest.status_code == 401 + + +def test_unpublished_scope_404(client, db, app, auth_headers): + with app.app_context(): + service.replace_scope_draft('gea-shopfloor-keyence', 'runtime', SCOPE) + service.db.session.commit() # draft only, never published + secret = _mint_fetch_token(client, auth_headers) + resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-keyence', + headers={'X-API-Key': secret}) + assert resp.status_code == 404 + + +def test_admin_list_and_preview(client, db, app, auth_headers): + _seed_and_publish(app) + listing = client.get('/api/geenforce/scopes', headers=auth_headers) + assert listing.status_code == 200 + scopes = listing.get_json()['data'] + cmm = next(s for s in scopes if s['scopename'] == 'gea-shopfloor-cmm') + assert cmm['entrycount'] == 2 + assert cmm['publishedversion'] == 1 + + preview = client.get(f"/api/geenforce/scopes/{cmm['scopeid']}/preview", + headers=auth_headers) + assert preview.status_code == 200 + assert [e['Name'] for e in preview.get_json()['data']['manifest']['Applications']] \ + == ['Alpha', 'Beta'] diff --git a/tests/test_plugins/test_geenforce_parity.py b/tests/test_plugins/test_geenforce_parity.py new file mode 100644 index 0000000..f8986f5 --- /dev/null +++ b/tests/test_plugins/test_geenforce_parity.py @@ -0,0 +1,142 @@ +"""GE-Enforce P1 parity gate (Gate A): import + export is behaviorally lossless. + +Runs the DB-free parity harness (parse -> in-memory rows -> render -> compare) +over a synthetic, site-neutral manifest that exercises every entry type, +detection method, targeting filter, RegValue typing, InUseCheck, and preinstall +flag. The synthetic fixture is what CI gates on (no real site data is vendored +into the framework repo). If the real GE-Enforce reference share is present +(dev), it is also checked. +""" + +import os + +import pytest + +from plugins.geenforce.parity import run_parity, load_fixtures +from plugins.geenforce.importer import discover_share, load_manifest_file + + +# A site-neutral manifest covering the whole schema surface. +SYNTHETIC = { + 'Version': '2.6', + '_comment': 'Synthetic parity fixture (not a real site manifest).', + 'Applications': [ + { + '_comment': 'MSI with Registry detection, version gate, InUseCheck.', + 'Name': 'Sample MSI', 'Type': 'MSI', + 'Installer': 'apps/sample.msi', + 'InstallArgs': '/qn /norestart ALLUSERS=1', + '_CmmVersion': '2019', + 'DetectionMethod': 'Registry', + 'DetectionPath': 'HKLM:\\SOFTWARE\\Sample', + 'DetectionName': 'DisplayVersion', 'DetectionValue': '1.2.3.4', + 'InUseCheck': { + 'Behavior': 'CloseAndReopen', + 'Processes': [ + {'Name': 'sample', 'ExePath': 'C:\\sample.exe', + 'GracefulCloseTimeoutSec': 15}, + {'Name': 'other'}, + ], + }, + }, + { + 'Name': 'Sample EXE', 'Type': 'EXE', 'Installer': 'apps/sample.exe', + 'InstallArgs': '/quiet', 'WaitTimeoutSec': 60, + 'DetectionMethod': 'FileVersion', + 'DetectionPath': 'C:\\sample.exe', 'DetectionValue': '1.0.0.0', + }, + { + 'Name': 'Sample PS1', 'Type': 'PS1', 'Script': 'scripts/run.ps1', + 'Args': '-Force', 'DetectionMethod': 'Always', + }, + { + 'Name': 'Sample File', 'Type': 'File', 'Source': 'configs/app.json', + 'Destination': 'C:\\ProgramData\\app.json', + 'DetectionMethod': 'Hash', 'DetectionPath': 'C:\\ProgramData\\app.json', + 'DetectionValue': 'a' * 64, + 'PCTypes': ['gea-shopfloor-collections', 'gea-shopfloor-cmm'], + }, + { + 'Name': 'Sample Registry DWord', 'Type': 'Registry', + 'RegPath': 'HKLM:\\SOFTWARE\\Sample', 'RegName': 'Enabled', + 'RegValue': 1, 'RegType': 'DWord', + 'DetectionMethod': 'ValueMatches', + 'DetectionPath': 'HKLM:\\SOFTWARE\\Sample', 'DetectionName': 'Enabled', + }, + { + 'Name': 'Sample Registry String', 'Type': 'Registry', + 'RegPath': 'HKLM:\\SOFTWARE\\Sample', 'RegName': 'Mode', + 'RegValue': 'on', 'RegType': 'String', + }, + { + 'Name': 'Sample INF', 'Type': 'INF', 'Installer': 'configs/driver.inf', + 'DetectionMethod': 'pnputil', 'DetectionPattern': 'SampleDriver', + }, + { + 'Name': 'Sample bay-gated', 'Type': 'CMD', 'Installer': 'scripts/bay.cmd', + 'TargetMachineNumbers': ['3201', '3202'], + }, + { + 'Name': 'Sample host-gated', 'Type': 'BAT', 'Installer': 'scripts/host.bat', + 'TargetHostnames': ['WJS-*', 'WJPC0615'], + }, + { + 'Name': 'Sample always-installs (no detection)', 'Type': 'PS1', + 'Script': 'scripts/every.ps1', + }, + { + 'Name': 'Sample preinstall-flagged', 'Type': 'EXE', + 'Installer': 'apps/pre.exe', 'PreEnrollment': True, + 'PCTypesStrict': True, 'KillAfterDetection': True, + 'PCTypes': ['gea-shopfloor-nocollections'], + }, + ], +} + +REFERENCE_SHARE = '/home/camp/pxe-images/tsgwp00525-v2/shared/dt/shopfloor' +REFERENCE_PREINSTALL = '/home/camp/projects/pxe/playbook/preinstall/preinstall.json' + + +def test_synthetic_manifest_round_trips_losslessly(): + """The synthetic manifest imports + exports with full behavioral parity.""" + results, ok = run_parity([('synthetic', 'runtime', SYNTHETIC)]) + result = results[0] + assert result['entries_identical'] == result['entries_total'], result['firstdiff'] + assert result['profiles_same'] == result['profiles_total'], result['firstdiff'] + assert ok + + +def test_regvalue_numeric_type_preserved(): + """A DWord RegValue of 1 round-trips as int 1, not the string '1'.""" + from plugins.geenforce.importer import build_scope + from plugins.geenforce.serializer import scope_to_manifest + + rebuilt = scope_to_manifest(build_scope('synthetic', 'runtime', SYNTHETIC)) + dword = next(e for e in rebuilt['Applications'] + if e['Name'] == 'Sample Registry DWord') + assert dword['RegValue'] == 1 + assert isinstance(dword['RegValue'], int) + + +@pytest.mark.skipif(not os.path.isdir(REFERENCE_SHARE), + reason='GE-Enforce reference share not present') +def test_reference_share_round_trips_losslessly(): + """Every real on-share manifest round-trips (the live Gate A).""" + manifests = list(discover_share(REFERENCE_SHARE)) + if os.path.isfile(REFERENCE_PREINSTALL): + manifests.append(('preinstall', 'preinstall', + load_manifest_file(REFERENCE_PREINSTALL))) + results, ok = run_parity(manifests) + failed = [r for r in results if not r['passed']] + assert ok, f"parity failures: {[(r['scopename'], r['firstdiff']) for r in failed]}" + + +def test_fixtures_cover_every_pctype(): + """The machine-profile fixtures include one of each imaging pctype.""" + labels = {f['pctype'] for f in load_fixtures()} + for pctype in ('gea-shopfloor-collections', 'gea-shopfloor-nocollections', + 'gea-shopfloor-common', 'gea-shopfloor-cmm', + 'gea-shopfloor-genspect', 'gea-shopfloor-heattreat', + 'gea-shopfloor-waxtrace', 'gea-shopfloor-partmarker', + 'gea-shopfloor-display'): + assert pctype in labels