geenforce: display-readiness batch (server hardening, PS client wiring, display scope)
Get GE-Enforce closer to running on credential-less Intune/Entra display PCs that pull manifest + payloads over HTTPS instead of SMB. Server (plugins/geenforce/api/routes.py): - Rate-limit + 512MB served-size ceiling on GET /payload/<sha256> (reuses the login limiter's cache pattern, config-overridable via GEENFORCE_PAYLOAD_*). - New tests: payload hardening, manifestblobs model-vs-migration parity, and a report-contract test locking the lowercase per-entry report keys. PS client (plugins/geenforce/client/): - Fix New-ShopdbReport per-entry key casing to lowercase (name/action/selfhealed/ exitcode/message) to match what the server reads; the engine emits PascalCase. - Enforce TLS 1.2 in the network functions. - Fetch + merge the fleet-wide common scope alongside the pctype scope (pctype wins on conflict; -NoCommon opt-out). - Normalize whatever the engine returns into a well-formed summary. - Make the empty-cache fail-safe observable: event-log entry + report ping instead of a silent exit 0. Manifest (plugins/geenforce/seed_display_scope.py + docs/GE-ENFORCE-DISPLAY.md): - Seed a gea-shopfloor-display scope: 4 Edge kiosk drift-heal registry entries + 1 data-driven dispatcher (Dashboard/Lobby/3DPrintRoom via display-type.txt). Kiosk EXEs stay image-baked; the manifest heals policy/config drift only. - Documents the common SMB-payload audit (entries needing http/inline before a share-less display can inherit common). Migration registry (shopdb/plugins/alembic_template.py + test): - Register the pre-existing manifestblobs and the new printersupplyalerts tables in PLUGIN_TABLE_OWNERS; update EXPECTED_HEAD_REVISION for geenforce (0002blobs), printers (0002supplyalerts), and printedparts (0004txnrev) which had drifted.
This commit is contained in:
@@ -95,6 +95,50 @@ at the end of its main loop) and knows each entry's action; shape them into the
|
||||
`results` list at the call site (`New-ShopdbReport` in the kit takes a summary
|
||||
with `Installed/Skipped/Failed/Filtered` + a `Results` list).
|
||||
|
||||
The engine emits per-entry outcomes in PascalCase (`Name/Action/SelfHealed/
|
||||
ExitCode/Message`); `New-ShopdbReport` maps every per-entry key down to the
|
||||
lowercase names above (`name/action/selfhealed/exitcode/message`) before POST,
|
||||
so the entire wire contract shopdb reads is lowercase. `ConvertTo-ShopdbSummary`
|
||||
first normalizes whatever the engine returns (a well-formed summary, a bare
|
||||
return code, `$null`, or several emitted objects) into the count/results shape
|
||||
`New-ShopdbReport` expects, so a not-yet-compliant engine still produces a valid
|
||||
report.
|
||||
|
||||
## Common-scope inheritance
|
||||
|
||||
Every PC inherits the fleet-wide `common` scope on top of its own pctype scope,
|
||||
mirroring the real GE-Enforce.ps1 (which applies `common\manifest.json` first,
|
||||
then the pctype's). `Invoke-ShopdbEnforce.ps1` fetches the `common` scope in
|
||||
addition to `-Scope` and merges it in via `Merge-ShopdbManifests`:
|
||||
|
||||
- entries are keyed by `Name` (case-insensitive);
|
||||
- common's unique entries come first, then all pctype entries (common enforces
|
||||
ahead of the pctype, as on the share);
|
||||
- on a `Name` conflict the pctype entry wins (its override replaces common's).
|
||||
|
||||
Common is fetched over the same fail-safe path (ETag + last-known-good cache).
|
||||
Pass `-NoCommon` to enforce the pctype alone, or `-CommonScope <name>` to inherit
|
||||
a different fleet scope. A run whose `-Scope` already is the common scope does
|
||||
not merge itself. This is how the three display subtypes (Dashboard, Lobby, 3D
|
||||
Print Room), selected by `C:\Enrollment\display-type.txt`, pick up shared policy
|
||||
without duplicating it per subtype.
|
||||
|
||||
## Fail-safe is observable, not silent
|
||||
|
||||
Any error still exits 0 - a bad web app never blocks or breaks a PC. But a fresh
|
||||
display with an EMPTY cache (first boot, shopdb unreachable or the token
|
||||
rejected with 401 / a TLS-trust failure) would otherwise enforce nothing
|
||||
*silently*. When no manifest and no cache are available, the kit:
|
||||
|
||||
- writes a Windows Application event-log entry (source `ShopdbEnforce`, event id
|
||||
1001, type Error) naming the scope and the reason (HTTP status or transport
|
||||
error), and
|
||||
- fires a best-effort report ping (counts `failed: 1`, a single
|
||||
`(manifest-fetch)` result carrying the reason) so the miss surfaces under
|
||||
GE-Enforce > Enforcement Reports.
|
||||
|
||||
The cycle still exits 0; the signal just makes the no-enforcement state visible.
|
||||
|
||||
## Cutover (safe, staged)
|
||||
|
||||
1. **Configure** the registry values on a canary PC; mint the token.
|
||||
|
||||
110
docs/GE-ENFORCE-DISPLAY.md
Normal file
110
docs/GE-ENFORCE-DISPLAY.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# GE-Enforce: the gea-shopfloor-display scope
|
||||
|
||||
Displays are the share-less corner of the fleet. They are Intune/Entra-joined,
|
||||
credential-less kiosk PCs that pull their manifest over HTTPS on port 443 and
|
||||
authenticate with a read-only service PAT scoped `geenforce.fetch`, sent as
|
||||
`X-API-Key`. They have no SMB share mount. The kiosk engine and the kiosk
|
||||
browser are baked into the display image, not shipped over HTTPS, so the display
|
||||
manifest heals POLICY / CONFIG drift and inherited common entries, never EXEs.
|
||||
|
||||
There are three display subtypes, selected by `C:\Enrollment\display-type.txt`:
|
||||
`Dashboard`, `Lobby`, and `3DPrintRoom`.
|
||||
|
||||
## Authoring the scope
|
||||
|
||||
The scope is authored programmatically by
|
||||
`plugins/geenforce/seed_display_scope.py`, which builds a manifest dict and
|
||||
hands it to `service.replace_scope_draft` (the same call the `import-share` CLI
|
||||
uses), then attaches the inline dispatcher payload. From a Flask app context:
|
||||
|
||||
```python
|
||||
from plugins.geenforce.seed_display_scope import seed_display_scope
|
||||
seed_display_scope(publish=True) # publish=False leaves it as a draft
|
||||
```
|
||||
|
||||
`replace_scope_draft` is an idempotent draft rebuild. `publish=True` additionally
|
||||
freezes an immutable published snapshot (that step is not idempotent: it always
|
||||
creates a new version).
|
||||
|
||||
### What the scope contains
|
||||
|
||||
1. Four `Registry` drift-heal entries that re-assert the Microsoft Edge kiosk
|
||||
relaunch policies set at imaging by `09-Setup-Display.ps1`. Each writes the
|
||||
value and detects drift with `DetectionMethod = ValueMatches` against the
|
||||
same path/name, so a display that loses a policy self-heals on the next
|
||||
enforce cycle with no keyboard or mouse on site:
|
||||
- `RelaunchNotification = 2` (DWord, Required auto-restart)
|
||||
- `RelaunchNotificationPeriod = 3600000` (DWord, 1 hour)
|
||||
- `RelaunchHeadsUpPeriod = 60000` (DWord, 1 minute)
|
||||
- `RelaunchWindow` (String, JSON, 02:00 start, 120 minute duration)
|
||||
2. One `PS1` dispatcher, delivered inline over HTTPS. It reads
|
||||
`C:\Enrollment\display-type.txt` and launches the kiosk target for the
|
||||
subtype. The subtype -> route map is a data-driven table
|
||||
(`DISPLAY_TYPE_TARGETS`) at the top of both the seed module and the generated
|
||||
script, so targets are easy to edit. `DetectionMethod = Always` so it
|
||||
re-asserts each cycle, but the script is idempotent (it skips relaunch if a
|
||||
kiosk process is already serving the target URL).
|
||||
|
||||
### display-type -> target map
|
||||
|
||||
| display-type.txt | kiosk route | notes |
|
||||
| --- | --- | --- |
|
||||
| `Dashboard` | `/shopfloor` | core ShopfloorDashboard, standalone full-screen |
|
||||
| `Lobby` | `/tv` | slides plugin TV dashboard (surface `lobby`) |
|
||||
| `3DPrintRoom` | `/parts-kiosk` | **PLACEHOLDER, TODO-confirm** printedparts parts kiosk route; confirm the real 3D-print-room target with the floor team before publishing to production displays |
|
||||
|
||||
## Inheritance: the client merges common
|
||||
|
||||
The manifest model has no inheritance column. The display scope is a plain
|
||||
(non-common) runtime scope carrying only display-specific entries. The CLIENT
|
||||
merges the fleet-wide `common` scope underneath the display scope at fetch time.
|
||||
So `common` is where the fleet-wide policy/config/self-update entries live, and
|
||||
`gea-shopfloor-display` adds the kiosk-only entries on top.
|
||||
|
||||
## Blocker: common carries SMB payloads that break on share-less displays
|
||||
|
||||
Before a share-less display can safely inherit `common`, every `common` entry
|
||||
that pulls a payload file from the SMB share must first be given an `http` or
|
||||
`inline` payload (with a `payloadsha256`). A display has no share mount, so any
|
||||
inherited entry whose `Installer` / `Source` / `Script` resolves to a
|
||||
share-relative path will fail its fetch.
|
||||
|
||||
Registry entries in `common` carry no payload (they write inline reg values) and
|
||||
are safe to inherit as-is. The entries below reference a share file and must be
|
||||
converted first. This audit is the authoritative to-convert list; the payload
|
||||
bytes themselves are not converted here (that needs the real payload files).
|
||||
|
||||
Common entries that reference an SMB/share payload (as of the on-share
|
||||
`common/manifest.json`, 22 entries, Version 2.0):
|
||||
|
||||
| # | Common entry | Type | Share payload | Field |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Adobe Acrobat Reader DC | CMD | `scripts/Install-AcroReader.cmd` | Installer |
|
||||
| 2 | Migrate pc-type.txt to gea-shopfloor-* taxonomy | PS1 | `scripts/Migrate-PCType.ps1` | Script |
|
||||
| 3 | WJF Defect Tracker | MSI | `apps/WJF_Defect_Tracker.msi` | Installer |
|
||||
| 4 | 3OF9 barcode font | File | `configs/3OF9.ttf` | Source |
|
||||
| 5 | Edge IE-Mode site list | File | `configs/enterprise-mode-site-list.xml` | Source |
|
||||
| 6 | Ensure VNC firewall rule | PS1 | `scripts/ensure-vnc-firewall.ps1` | Script |
|
||||
| 7 | FMS hosts pin (WJFMS3.AE.GE.COM) | PS1 | `scripts/Set-FmsHostsEntry.ps1` | Script |
|
||||
| 8 | Oracle Client 11.2 | CMD | `scripts/Install-Oracle11r2.cmd` | Installer |
|
||||
| 9 | PrinterInstallerMap (site-map printer installer) | File | `apps/PrinterInstallerMap.exe` | Source |
|
||||
| 10 | OpenText HostExplorer ShopFloor | CMD | `scripts/Setup-OpenText.cmd` | Installer |
|
||||
| 11 | GE-Enforce dispatcher (self-update) | File | `GE-Enforce.ps1` | Source |
|
||||
| 12 | Install-FromManifest lib (self-update) | File | `lib/Install-FromManifest.ps1` | Source |
|
||||
| 13 | Report asset (host + IP + machine number) to ShopDB | PS1 | `apps/Report-AssetToShopDB.ps1` | Script |
|
||||
| 14 | EventSaver screensaver (binary) | File | `apps/EventSaver.scr` | Source |
|
||||
| 15 | EventSaver screensaver (config) | File | `configs/EventSaver.ini` | Source |
|
||||
| 16 | EventSaver enable (per-user screensaver) | PS1 | `scripts/Set-EventSaverScreensaver.ps1` | Script |
|
||||
| 17 | EventSaver power (keep monitor awake) | PS1 | `scripts/Set-EventSaverPower.ps1` | Script |
|
||||
| 18 | EventSaver disable (measuring-tool bays) | PS1 | `scripts/Set-EventSaverDisable.ps1` | Script |
|
||||
| 19 | EventSaver disable (specific hostnames) | PS1 | `scripts/Set-EventSaverDisable.ps1` | Script |
|
||||
|
||||
Safe to inherit as-is (Registry entries, no share payload): `3OF9 barcode font
|
||||
registry entry`, `Edge IE-Mode policy level`, `Edge IE-Mode policy site list
|
||||
pointer`.
|
||||
|
||||
Not every entry above is relevant to a display (a display needs no Oracle
|
||||
client, OpenText, or Defect Tracker), so a follow-up decision is which common
|
||||
entries a display should actually run (via `PCTypes` targeting) versus which
|
||||
must be repackaged as `http`/`inline`. But any that survive targeting must have
|
||||
a non-SMB payload before displays inherit common.
|
||||
@@ -9,14 +9,15 @@ Two audiences:
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, request, Response, send_file
|
||||
from flask import Blueprint, request, Response, send_file, current_app
|
||||
from flask_jwt_extended import jwt_required
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shopdb.api import (
|
||||
db, success_response, error_response, ErrorCodes, require_permission,
|
||||
db, cache, success_response, error_response, ErrorCodes, require_permission,
|
||||
service_token_authorized, Setting, Application,
|
||||
)
|
||||
|
||||
@@ -101,6 +102,59 @@ def get_manifest():
|
||||
|
||||
# -- client payload download (share-less installer delivery) ------------------
|
||||
|
||||
# GET /payload hardening. This endpoint is reachable with only a read-only
|
||||
# geenforce.fetch token, so a leaked display token must not be able to pull
|
||||
# unbounded bytes or hammer it. Two bounds cap the blast radius:
|
||||
# - a per-IP fixed-window rate limit (same shape and cache extension as the
|
||||
# login limiter in shopdb.core.api.auth, so no new dependency), and
|
||||
# - a served-size ceiling: refuse to stream a blob larger than the cap.
|
||||
# Both are overridable via app config for a site that ships bigger installers.
|
||||
PAYLOAD_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024
|
||||
PAYLOAD_DOWNLOAD_RATELIMIT_MAX = 120
|
||||
PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS = 60
|
||||
|
||||
|
||||
def _client_ip():
|
||||
"""Caller IP for rate limiting, honoring the first X-Forwarded-For hop
|
||||
(mirrors shopdb.core.api.auth._login_ip)."""
|
||||
forwarded = request.headers.get('X-Forwarded-For')
|
||||
if forwarded:
|
||||
return forwarded.split(',')[0].strip()
|
||||
return request.remote_addr or 'unknown'
|
||||
|
||||
|
||||
def _payload_max_bytes():
|
||||
return current_app.config.get('GEENFORCE_PAYLOAD_MAX_BYTES',
|
||||
PAYLOAD_DOWNLOAD_MAX_BYTES)
|
||||
|
||||
|
||||
def _payload_download_ratelimited():
|
||||
"""Fixed-window per-IP limiter for the payload download endpoint.
|
||||
|
||||
Backed by the existing cache extension (no new dependency), same shape as
|
||||
the login limiter. Under the default SimpleCache the counter is per-process,
|
||||
so with N gunicorn workers the effective budget is N x the configured max; a
|
||||
shared cache backend (Redis/memcached) tightens it to a true global budget.
|
||||
Returns True when the caller is over budget for the current window.
|
||||
"""
|
||||
if not current_app.config.get('GEENFORCE_PAYLOAD_RATELIMIT_ENABLED', True):
|
||||
return False
|
||||
window = current_app.config.get(
|
||||
'GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS',
|
||||
PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS)
|
||||
maxhits = current_app.config.get(
|
||||
'GEENFORCE_PAYLOAD_RATELIMIT_MAX', PAYLOAD_DOWNLOAD_RATELIMIT_MAX)
|
||||
# Time bucket makes this a fixed window: the key rolls over at each window
|
||||
# boundary, so a per-hit set() cannot turn it into a sliding window.
|
||||
bucket = int(time.time() // window) if window > 0 else 0
|
||||
key = f'geenforcepayloadratelimit:{_client_ip()}:{bucket}'
|
||||
count = cache.get(key) or 0
|
||||
if count >= maxhits:
|
||||
return True
|
||||
cache.set(key, count + 1, timeout=window)
|
||||
return False
|
||||
|
||||
|
||||
@geenforce_bp.route('/payload/<sha256>', methods=['GET'])
|
||||
@require_fetch_token
|
||||
def get_payload(sha256):
|
||||
@@ -111,7 +165,15 @@ def get_payload(sha256):
|
||||
http payloads) first, then an inline DB payload with this hash. The client
|
||||
re-verifies the sha256, so the hash IS the integrity guarantee. ETag = the
|
||||
hash (content is immutable).
|
||||
|
||||
Hardened: per-IP rate limited, and a blob over the served-size ceiling is
|
||||
refused (413) rather than streamed.
|
||||
"""
|
||||
if _payload_download_ratelimited():
|
||||
return error_response('RATE_LIMITED',
|
||||
'Too many payload downloads. Try again later.',
|
||||
http_code=429)
|
||||
|
||||
sha = (sha256 or '').strip().lower()
|
||||
if len(sha) != 64 or any(c not in '0123456789abcdef' for c in sha):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'bad sha256',
|
||||
@@ -120,8 +182,14 @@ def get_payload(sha256):
|
||||
if request.headers.get('If-None-Match') == etag:
|
||||
return Response(status=304, headers={'ETag': etag})
|
||||
|
||||
maxbytes = _payload_max_bytes()
|
||||
|
||||
blob = db.session.get(ManifestBlob, sha)
|
||||
if blob and os.path.isfile(service.blob_path(sha)):
|
||||
if blob.sizebytes is not None and blob.sizebytes > maxbytes:
|
||||
return error_response('PAYLOAD_TOO_LARGE',
|
||||
'payload exceeds the download size limit',
|
||||
http_code=413)
|
||||
response = send_file(
|
||||
service.blob_path(sha),
|
||||
mimetype=blob.contenttype or 'application/octet-stream',
|
||||
@@ -131,6 +199,10 @@ def get_payload(sha256):
|
||||
|
||||
inline = ManifestPayload.query.filter_by(payloadsha256=sha).first()
|
||||
if inline:
|
||||
if inline.payloadbytes is not None and len(inline.payloadbytes) > maxbytes:
|
||||
return error_response('PAYLOAD_TOO_LARGE',
|
||||
'payload exceeds the download size limit',
|
||||
http_code=413)
|
||||
return Response(
|
||||
inline.payloadbytes,
|
||||
mimetype=inline.contenttype or 'application/octet-stream',
|
||||
|
||||
@@ -24,6 +24,16 @@
|
||||
.PARAMETER ShadowMode
|
||||
Fetch + compare + report, but install from the share (no behavior change).
|
||||
|
||||
.PARAMETER CommonScope
|
||||
The fleet-wide scope every PC inherits (default 'common'). Its manifest is
|
||||
fetched in addition to -Scope and merged in, so a display enforces its own
|
||||
scope entries PLUS common's. On a Name conflict the -Scope (pctype) entry
|
||||
wins. Set -NoCommon to disable, or point at a different common scope name.
|
||||
|
||||
.PARAMETER NoCommon
|
||||
Do not fetch or merge the common scope; enforce -Scope alone (the original
|
||||
single-scope behavior).
|
||||
|
||||
.NOTES
|
||||
Fail-safe: any error exits 0 so a bad web app never blocks or breaks a PC.
|
||||
Config comes from HKLM:\SOFTWARE\GE\ShopDB (BaseUrl, ApiToken) - see the psm1.
|
||||
@@ -34,6 +44,8 @@ param(
|
||||
[Parameter(Mandatory)] [string]$EnginePath,
|
||||
[string]$ShareManifestPath,
|
||||
[switch]$ShadowMode,
|
||||
[string]$CommonScope = 'common',
|
||||
[switch]$NoCommon,
|
||||
[string]$BaseUrl,
|
||||
[string]$ApiToken,
|
||||
[string]$LogFile = "C:\Logs\Shopfloor\shopdb-enforce-$(Get-Date -Format yyyyMMdd).log"
|
||||
@@ -46,6 +58,27 @@ function Write-Log {
|
||||
Write-Host $line
|
||||
}
|
||||
|
||||
function Write-ShopdbEventLog {
|
||||
<#
|
||||
Write a Windows Application event-log entry under source 'ShopdbEnforce'.
|
||||
Used to make an otherwise silent fail-safe (no manifest and an empty cache)
|
||||
observable to whoever watches the display. Best-effort: registering the
|
||||
source needs admin, which the SYSTEM scheduled task has; any failure is
|
||||
swallowed so it can never break the fail-safe.
|
||||
#>
|
||||
param([string]$Message,
|
||||
[string]$EntryType = 'Warning',
|
||||
[int]$EventId = 1001)
|
||||
$source = 'ShopdbEnforce'
|
||||
try {
|
||||
if (-not [System.Diagnostics.EventLog]::SourceExists($source)) {
|
||||
New-EventLog -LogName Application -Source $source -ErrorAction Stop
|
||||
}
|
||||
Write-EventLog -LogName Application -Source $source -EntryType $EntryType `
|
||||
-EventId $EventId -Message $Message -ErrorAction Stop
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
Import-Module (Join-Path $PSScriptRoot 'ShopdbEnforceClient.psm1') -Force
|
||||
|
||||
@@ -57,7 +90,21 @@ try {
|
||||
|
||||
$sync = Sync-ShopdbManifest -Scope $Scope -Config $config
|
||||
if (-not $sync.Path) {
|
||||
Write-Log "No manifest available for $Scope (shopdb unreachable, no cache)." 'WARN'
|
||||
# Fail-safe stays (exit 0), but a fresh display with an empty cache would
|
||||
# otherwise enforce nothing SILENTLY. Surface it: a Windows event-log
|
||||
# entry plus a best-effort report ping so it shows in Enforcement Reports.
|
||||
$reason = if ($sync.Error) { $sync.Error } else { 'shopdb unreachable and no cached manifest' }
|
||||
Write-Log "No manifest available for $Scope ($reason)." 'WARN'
|
||||
Write-ShopdbEventLog -Message ("GE-Enforce could not fetch a manifest for scope '$Scope' and has no cached copy; nothing was enforced this cycle. Reason: $reason") -EntryType 'Error' -EventId 1001
|
||||
try {
|
||||
$failReport = New-ShopdbReport -Scope $Scope -AppliedVersion 0 -Summary @{
|
||||
Installed = 0; Skipped = 0; Failed = 1; Filtered = 0; EnforcerVersion = '2.6'
|
||||
Results = @(@{ Name = '(manifest-fetch)'; Action = 'failed'; Message = $reason })
|
||||
}
|
||||
if (Send-ShopdbReport -Config $config -Report $failReport) {
|
||||
Write-Log 'Reported empty-cache fetch failure to shopdb.'
|
||||
}
|
||||
} catch {}
|
||||
exit 0
|
||||
}
|
||||
Write-Log "Manifest for $Scope from $($sync.Source) (v$($sync.Version))."
|
||||
@@ -75,29 +122,49 @@ try {
|
||||
|
||||
# Which manifest the engine actually runs against.
|
||||
if ($ShadowMode -and $ShareManifestPath) {
|
||||
# Shadow: install from the share exactly as today (no payload resolve).
|
||||
# Shadow: install from the share exactly as today (no payload resolve,
|
||||
# no common merge - the share already carries its own common scope).
|
||||
$manifestToRun = $ShareManifestPath
|
||||
} else {
|
||||
# Common-scope inheritance: a display enforces its own scope PLUS the
|
||||
# fleet-wide common scope. Fetch common too (best-effort, same fail-safe
|
||||
# cache) and merge it in with the pctype winning on conflict. Skipped
|
||||
# when -NoCommon, or when this run IS the common scope.
|
||||
$manifestToMerge = $sync.Path
|
||||
if (-not $NoCommon -and $CommonScope -and ($CommonScope -ine $Scope)) {
|
||||
$commonSync = Sync-ShopdbManifest -Scope $CommonScope -Config $config
|
||||
if ($commonSync.Path) {
|
||||
$manifestToMerge = Merge-ShopdbManifests -PrimaryManifestPath $sync.Path -CommonManifestPath $commonSync.Path
|
||||
if ($manifestToMerge -ne $sync.Path) {
|
||||
Write-Log "Merged common scope '$CommonScope' (from $($commonSync.Source), v$($commonSync.Version)) into $Scope."
|
||||
}
|
||||
} else {
|
||||
Write-Log "Common scope '$CommonScope' unavailable (no fetch, no cache) - enforcing $Scope alone." 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
# Cutover: stage any http/inline payloads to local files and rewrite the
|
||||
# manifest to point at them, so the UNCHANGED engine installs from local
|
||||
# (no SMB needed for share-less PCs).
|
||||
$manifestToRun = Resolve-ShopdbPayloads -ManifestPath $sync.Path -Config $config
|
||||
if ($manifestToRun -ne $sync.Path) {
|
||||
$manifestToRun = Resolve-ShopdbPayloads -ManifestPath $manifestToMerge -Config $config
|
||||
if ($manifestToRun -ne $manifestToMerge) {
|
||||
Write-Log "Resolved http/inline payloads to local files: $manifestToRun"
|
||||
}
|
||||
}
|
||||
|
||||
# --- INTEGRATION POINT ---------------------------------------------------
|
||||
# Run the engine. Install-FromManifest.ps1 is expected to return (or you
|
||||
# adapt it to return) a summary carrying Installed/Skipped/Failed/Filtered
|
||||
# and a Results list of @{Name;Action;SelfHealed;ExitCode;Message}. Wire this
|
||||
# to your engine's actual return/parse; the shape below is the contract.
|
||||
# Run the engine. EXPECTED ENGINE CONTRACT: Install-FromManifest.ps1 returns
|
||||
# a summary object (hashtable or PSCustomObject) carrying integer counts
|
||||
# Installed / Skipped / Failed / Filtered
|
||||
# a string EnforcerVersion, and a Results list of per-entry outcomes
|
||||
# @{ Name; Action; SelfHealed; ExitCode; Message }.
|
||||
# The engine may not honor that yet: it might return $null, a bare return
|
||||
# code, or emit several objects. ConvertTo-ShopdbSummary adapts whatever it
|
||||
# returns into a well-formed summary hashtable so the report stage always
|
||||
# gets clean input (we do NOT assume the engine was fixed).
|
||||
Write-Log "Running engine against $manifestToRun"
|
||||
$summary = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope
|
||||
if (-not $summary) {
|
||||
$summary = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0; Results = @() }
|
||||
}
|
||||
if (-not $summary.EnforcerVersion) { $summary.EnforcerVersion = '2.6' }
|
||||
$engineResult = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope
|
||||
$summary = ConvertTo-ShopdbSummary -EngineResult $engineResult
|
||||
|
||||
# Report the result (best-effort).
|
||||
$appliedVersion = 0
|
||||
|
||||
@@ -21,6 +21,43 @@
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Set-ShopdbTls {
|
||||
<#
|
||||
Force TLS 1.2 for the process-wide ServicePointManager. Windows PowerShell
|
||||
5.1 (what runs as SYSTEM on the display image) does not always negotiate
|
||||
TLS 1.2 by default, so every network helper calls this first. Mirrors the
|
||||
pattern in docs/COLLECTOR-INTEGRATION.md. Best-effort: never throws.
|
||||
#>
|
||||
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {}
|
||||
}
|
||||
|
||||
function Get-ShopdbProperty {
|
||||
<#
|
||||
Read a property/key from either a hashtable or a PSCustomObject, matching
|
||||
any of the given names case-insensitively. Returns $null when absent
|
||||
instead of throwing under Set-StrictMode. The engine's per-entry outcomes
|
||||
and summary may arrive as either shape, so all normalization goes through
|
||||
this.
|
||||
#>
|
||||
param($InputObject, [Parameter(Mandatory)][string[]]$Names)
|
||||
if ($null -eq $InputObject) { return $null }
|
||||
if ($InputObject -is [System.Collections.IDictionary]) {
|
||||
foreach ($wanted in $Names) {
|
||||
foreach ($key in @($InputObject.Keys)) {
|
||||
if ($key -is [string] -and $key -ieq $wanted) { return $InputObject[$key] }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
$properties = $InputObject.PSObject.Properties
|
||||
foreach ($wanted in $Names) {
|
||||
foreach ($property in $properties) {
|
||||
if ($property.Name -ieq $wanted) { return $property.Value }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-ShopdbConfig {
|
||||
param([string]$BaseUrl, [string]$ApiToken)
|
||||
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
|
||||
@@ -46,6 +83,7 @@ function Sync-ShopdbManifest {
|
||||
[Parameter(Mandatory)] [hashtable]$Config,
|
||||
[string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
|
||||
)
|
||||
Set-ShopdbTls
|
||||
if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null }
|
||||
$manifestPath = Join-Path $CacheDir "$Scope.json"
|
||||
$etagPath = Join-Path $CacheDir "$Scope.etag"
|
||||
@@ -80,7 +118,12 @@ function Sync-ShopdbManifest {
|
||||
if (Test-Path $manifestPath) {
|
||||
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-lastgood' }
|
||||
}
|
||||
return @{ Path = $null; Version = $null; Source = $null }
|
||||
# Nothing cached (e.g. a fresh display): report why so the failure is not
|
||||
# silent. Include the HTTP status (401 auth, TLS-trust surfaces as a
|
||||
# non-status transport error) and the exception message.
|
||||
$errText = $_.Exception.Message
|
||||
if ($status) { $errText = "HTTP $status - $errText" }
|
||||
return @{ Path = $null; Version = $null; Source = $null; Error = $errText }
|
||||
}
|
||||
# 304 without exception (some PS versions) -> use cache.
|
||||
if (Test-Path $manifestPath) {
|
||||
@@ -123,6 +166,7 @@ function Send-ShopdbReport {
|
||||
#>
|
||||
param([Parameter(Mandatory)][hashtable]$Config,
|
||||
[Parameter(Mandatory)][hashtable]$Report)
|
||||
Set-ShopdbTls
|
||||
try {
|
||||
$body = ($Report | ConvertTo-Json -Depth 6)
|
||||
Invoke-RestMethod -Uri "$($Config.BaseUrl)/api/geenforce/report" `
|
||||
@@ -137,14 +181,31 @@ function Send-ShopdbReport {
|
||||
function New-ShopdbReport {
|
||||
<#
|
||||
Build a report payload from an engine summary. `Summary` is expected to
|
||||
carry Installed/Skipped/Failed/Filtered counts and a Results list of
|
||||
@{ Name; Action; SelfHealed; ExitCode; Message }. Shape the engine's own
|
||||
per-entry outcomes into this at the call site.
|
||||
carry Installed/Skipped/Failed/Filtered counts and a Results list whose
|
||||
per-entry outcomes carry name/action and optionally selfhealed/exitcode/
|
||||
message. The engine emits these in PascalCase (Name/Action/SelfHealed/
|
||||
ExitCode/Message); the shopdb report contract is entirely lowercase, so
|
||||
this function maps every per-entry key down to lowercase. Case is matched
|
||||
case-insensitively, so a caller that already lowercased still works.
|
||||
#>
|
||||
param([string]$Hostname = $env:COMPUTERNAME,
|
||||
[Parameter(Mandatory)][string]$Scope,
|
||||
[int]$AppliedVersion,
|
||||
[Parameter(Mandatory)][hashtable]$Summary)
|
||||
$results = @(foreach ($entry in @($Summary.Results)) {
|
||||
if ($null -eq $entry) { continue }
|
||||
$mapped = @{
|
||||
name = [string](Get-ShopdbProperty -InputObject $entry -Names 'name')
|
||||
action = [string](Get-ShopdbProperty -InputObject $entry -Names 'action')
|
||||
}
|
||||
$selfHealed = Get-ShopdbProperty -InputObject $entry -Names 'selfhealed'
|
||||
if ($null -ne $selfHealed) { $mapped['selfhealed'] = [bool]$selfHealed }
|
||||
$exitCode = Get-ShopdbProperty -InputObject $entry -Names 'exitcode'
|
||||
if ($null -ne $exitCode) { $mapped['exitcode'] = [int]$exitCode }
|
||||
$message = Get-ShopdbProperty -InputObject $entry -Names 'message'
|
||||
if ($message) { $mapped['message'] = [string]$message }
|
||||
$mapped
|
||||
})
|
||||
return @{
|
||||
hostname = $Hostname
|
||||
scopename = $Scope
|
||||
@@ -156,7 +217,7 @@ function New-ShopdbReport {
|
||||
failed = [int]$Summary.Failed
|
||||
filtered = [int]$Summary.Filtered
|
||||
}
|
||||
results = @($Summary.Results)
|
||||
results = $results
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +234,7 @@ function Get-ShopdbPayload {
|
||||
[string]$Filename,
|
||||
[string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
|
||||
)
|
||||
Set-ShopdbTls
|
||||
$sha = $Sha256.Trim().ToLower()
|
||||
$payloadDir = Join-Path $CacheDir 'payloads'
|
||||
if (-not (Test-Path $payloadDir)) { New-Item -ItemType Directory -Path $payloadDir -Force | Out-Null }
|
||||
@@ -241,6 +303,125 @@ function Resolve-ShopdbPayloads {
|
||||
return $out
|
||||
}
|
||||
|
||||
function Merge-ShopdbManifests {
|
||||
<#
|
||||
Merge the fleet-wide 'common' scope manifest into a pctype (display) scope
|
||||
manifest and write the merged result to a sibling file. This mirrors how
|
||||
the real GE-Enforce.ps1 applies scopes in order (common first, then the
|
||||
pctype), except we produce a single merged manifest for the unchanged
|
||||
engine to run once. Merge rules:
|
||||
|
||||
- Entries are keyed by Name (case-insensitive).
|
||||
- common's unique entries come first, then all pctype entries, so
|
||||
common's own apps enforce ahead of the pctype's, matching the real
|
||||
script's common-then-pctype ordering.
|
||||
- On a Name conflict the PCTYPE entry wins (the pctype override replaces
|
||||
common's version, and keeps common's slot out of the list).
|
||||
|
||||
Returns the merged manifest path. If there is no common manifest, returns
|
||||
the primary path unchanged. The merged manifest keeps the pctype
|
||||
manifest's top-level Version (that is the version the display reports as
|
||||
applied).
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$PrimaryManifestPath,
|
||||
[string]$CommonManifestPath
|
||||
)
|
||||
if (-not $CommonManifestPath -or -not (Test-Path $CommonManifestPath)) { return $PrimaryManifestPath }
|
||||
|
||||
$primary = Get-Content -LiteralPath $PrimaryManifestPath -Raw | ConvertFrom-Json
|
||||
$common = Get-Content -LiteralPath $CommonManifestPath -Raw | ConvertFrom-Json
|
||||
|
||||
$primaryApps = @($primary.Applications)
|
||||
$commonApps = @($common.Applications)
|
||||
|
||||
$primaryNames = @{}
|
||||
foreach ($app in $primaryApps) {
|
||||
$name = [string]$app.Name
|
||||
if ($name) { $primaryNames[$name.ToLower()] = $true }
|
||||
}
|
||||
|
||||
# common entries the pctype does not override, then all pctype entries.
|
||||
$merged = @()
|
||||
foreach ($app in $commonApps) {
|
||||
$name = [string]$app.Name
|
||||
if ($name -and $primaryNames.ContainsKey($name.ToLower())) { continue }
|
||||
$merged += $app
|
||||
}
|
||||
foreach ($app in $primaryApps) { $merged += $app }
|
||||
|
||||
$primary.Applications = $merged
|
||||
$out = [System.IO.Path]::ChangeExtension($PrimaryManifestPath, '.merged.json')
|
||||
($primary | ConvertTo-Json -Depth 20) | Set-Content -LiteralPath $out -Encoding UTF8
|
||||
return $out
|
||||
}
|
||||
|
||||
function ConvertTo-ShopdbSummary {
|
||||
<#
|
||||
Normalize whatever the engine returns into a well-formed summary hashtable
|
||||
so New-ShopdbReport always has clean input, without assuming the engine was
|
||||
fixed.
|
||||
|
||||
Expected engine contract (what a compliant engine returns):
|
||||
@{ Installed=<int>; Skipped=<int>; Failed=<int>; Filtered=<int>;
|
||||
EnforcerVersion=<string>;
|
||||
Results=@( @{ Name; Action; SelfHealed; ExitCode; Message } ... ) }
|
||||
|
||||
This adapter tolerates any of:
|
||||
- $null / empty -> a zeroed summary.
|
||||
- a hashtable or PSCustomObject with those keys (any casing).
|
||||
- an array / multiple emitted objects -> the last element that looks
|
||||
like a summary (has any count or a Results list) is used.
|
||||
- a bare return code (int) or unrecognized object -> a zeroed summary.
|
||||
|
||||
Counts are coerced to int; a missing EnforcerVersion is left for the caller
|
||||
to default. Always returns a hashtable.
|
||||
#>
|
||||
param($EngineResult, [string]$DefaultEnforcerVersion = '2.6')
|
||||
|
||||
$zero = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0;
|
||||
Results = @(); EnforcerVersion = $DefaultEnforcerVersion }
|
||||
|
||||
$candidate = $EngineResult
|
||||
if ($candidate -is [System.Array]) {
|
||||
$picked = $null
|
||||
foreach ($item in $candidate) {
|
||||
if ($null -eq $item) { continue }
|
||||
$looksLikeSummary = $false
|
||||
foreach ($key in @('Installed', 'Skipped', 'Failed', 'Filtered', 'Results')) {
|
||||
if ($null -ne (Get-ShopdbProperty -InputObject $item -Names $key)) { $looksLikeSummary = $true; break }
|
||||
}
|
||||
if ($looksLikeSummary) { $picked = $item }
|
||||
}
|
||||
$candidate = $picked
|
||||
}
|
||||
|
||||
if ($null -eq $candidate) { return $zero }
|
||||
# A bare return code (int) or any object without the expected members reads
|
||||
# as all-null through Get-ShopdbProperty below, which yields the zeroed
|
||||
# summary - exactly the fail-open behavior we want for a non-compliant engine.
|
||||
$results = Get-ShopdbProperty -InputObject $candidate -Names 'Results'
|
||||
$enforcerVersion = [string](Get-ShopdbProperty -InputObject $candidate -Names 'EnforcerVersion')
|
||||
if (-not $enforcerVersion) { $enforcerVersion = $DefaultEnforcerVersion }
|
||||
|
||||
$toInt = {
|
||||
param($value)
|
||||
$parsed = 0
|
||||
if ($null -ne $value -and [int]::TryParse([string]$value, [ref]$parsed)) { return $parsed }
|
||||
return 0
|
||||
}
|
||||
|
||||
return @{
|
||||
Installed = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Installed'))
|
||||
Skipped = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Skipped'))
|
||||
Failed = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Failed'))
|
||||
Filtered = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Filtered', 'PCFiltered'))
|
||||
Results = @($results)
|
||||
EnforcerVersion = $enforcerVersion
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, `
|
||||
Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion, `
|
||||
Get-ShopdbPayload, Resolve-ShopdbPayloads
|
||||
Get-ShopdbPayload, Resolve-ShopdbPayloads, Merge-ShopdbManifests, `
|
||||
ConvertTo-ShopdbSummary
|
||||
|
||||
267
plugins/geenforce/seed_display_scope.py
Normal file
267
plugins/geenforce/seed_display_scope.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""Author the gea-shopfloor-display runtime scope programmatically.
|
||||
|
||||
Displays are Intune/Entra-joined, credential-less kiosk PCs that pull their
|
||||
manifest over HTTPS (no SMB share). The kiosk engine and the kiosk browser are
|
||||
BAKED INTO THE DISPLAY IMAGE, so this scope does not ship any EXE payloads; it
|
||||
heals POLICY / CONFIG drift only, plus one dispatcher that points the kiosk at
|
||||
the right target for the display subtype.
|
||||
|
||||
What this scope contains:
|
||||
- Four Registry drift-heal entries that re-assert the Microsoft Edge kiosk
|
||||
relaunch policies from the imaging script 09-Setup-Display.ps1 (so a display
|
||||
that loses those policies self-heals on the next enforce cycle without a
|
||||
keyboard or mouse on site).
|
||||
- One inline PS1 dispatcher that reads C:\\Enrollment\\display-type.txt and
|
||||
launches the kiosk target for the subtype. The display-type -> target map is
|
||||
a data-driven table (DISPLAY_TYPE_TARGETS) so the targets are easy to edit.
|
||||
|
||||
Inheritance: the manifest model has no inheritance column. The display scope is
|
||||
a plain (non-common) runtime scope; the CLIENT merges the fleet-wide 'common'
|
||||
scope with the display scope at fetch time. So this scope carries display-only
|
||||
entries and relies on the client to layer 'common' underneath. See the common
|
||||
SMB-payload audit in docs/GE-ENFORCE-DISPLAY.md before letting a share-less
|
||||
display inherit common.
|
||||
|
||||
Authoring path mirrors how every other scope is created: build a manifest dict
|
||||
and hand it to service.replace_scope_draft (the same call the import-share CLI
|
||||
uses), then attach the inline dispatcher payload and optionally publish. Re-
|
||||
running replace_scope_draft is an idempotent draft rebuild.
|
||||
"""
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
from . import service
|
||||
|
||||
|
||||
SCOPE_NAME = 'gea-shopfloor-display'
|
||||
SCOPE_PHASE = 'runtime'
|
||||
# Match the fleet-wide 'common' manifest version so a merged display + common
|
||||
# document stays internally consistent.
|
||||
SCOPE_VERSION = '2.0'
|
||||
|
||||
# Edge kiosk relaunch policy key. Values below mirror 09-Setup-Display.ps1
|
||||
# exactly so the imaging-time state and the enforced state never disagree.
|
||||
EDGE_POLICY_PATH = 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Edge'
|
||||
RELAUNCH_WINDOW_JSON = (
|
||||
'{"entries":[{"start":{"hour":2,"minute":0},"duration_mins":120}]}')
|
||||
|
||||
# Data-driven display-type -> kiosk target map. The value of
|
||||
# C:\Enrollment\display-type.txt selects the row; the target is a route the
|
||||
# kiosk browser opens against the local kiosk base URL. Edit here to retarget a
|
||||
# subtype. Keys are matched case-insensitively by the dispatcher.
|
||||
#
|
||||
# TODO-confirm: 3DPrintRoom points at the printedparts /parts-kiosk route as a
|
||||
# PLACEHOLDER. Confirm the real 3D-print-room kiosk target with the floor team
|
||||
# before this scope is published to production displays.
|
||||
DISPLAY_TYPE_TARGETS = {
|
||||
'Dashboard': '/shopfloor',
|
||||
'Lobby': '/tv',
|
||||
'3DPrintRoom': '/parts-kiosk',
|
||||
}
|
||||
|
||||
DISPATCHER_FILENAME = 'Invoke-DisplayKioskDispatch.ps1'
|
||||
|
||||
|
||||
def _relaunch_window_targets_comment():
|
||||
"""Human note that lists the data-driven targets, for the manifest comment."""
|
||||
pairs = ', '.join(f'{name}={route}'
|
||||
for name, route in DISPLAY_TYPE_TARGETS.items())
|
||||
return pairs
|
||||
|
||||
|
||||
def build_dispatcher_script():
|
||||
"""Return the inline dispatcher PowerShell as text.
|
||||
|
||||
The display-type -> target map is emitted as a hashtable at the top of the
|
||||
script (generated from DISPLAY_TYPE_TARGETS) so the on-PC script and the
|
||||
manifest metadata agree and both stay easy to edit.
|
||||
"""
|
||||
table_lines = []
|
||||
for display_type, route in DISPLAY_TYPE_TARGETS.items():
|
||||
table_lines.append(f" '{display_type}' = '{route}'")
|
||||
table_body = ';\n'.join(table_lines)
|
||||
|
||||
return f"""# Invoke-DisplayKioskDispatch.ps1 -- gea-shopfloor-display dispatcher.
|
||||
#
|
||||
# Reads C:\\Enrollment\\display-type.txt and launches the kiosk browser at the
|
||||
# route mapped for that display subtype. The kiosk browser is baked into the
|
||||
# display image; this script only points it at the right target.
|
||||
#
|
||||
# The DisplayTypeTargets table below is the single source of truth for the
|
||||
# subtype -> route map. Edit a row to retarget a subtype.
|
||||
#
|
||||
# TODO-confirm: 3DPrintRoom uses the printedparts /parts-kiosk route as a
|
||||
# PLACEHOLDER. Confirm the real 3D-print-room target before production use.
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
# --- Data-driven subtype -> kiosk route map ---
|
||||
$DisplayTypeTargets = @{{
|
||||
{table_body}
|
||||
}}
|
||||
|
||||
# Base URL the kiosk browser opens; the route from the table is appended. Edit
|
||||
# to point at this site's shopdb host. Kept here so the map above stays pure.
|
||||
$KioskBaseUrl = 'https://localhost'
|
||||
|
||||
$displayTypeFile = 'C:\\Enrollment\\display-type.txt'
|
||||
if (-not (Test-Path -LiteralPath $displayTypeFile)) {{
|
||||
Write-Host "display-type.txt not found at $displayTypeFile; nothing to launch."
|
||||
return
|
||||
}}
|
||||
|
||||
$displayType = (Get-Content -LiteralPath $displayTypeFile -Raw).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($displayType)) {{
|
||||
Write-Host 'display-type.txt is empty; nothing to launch.'
|
||||
return
|
||||
}}
|
||||
|
||||
# Case-insensitive lookup so 'lobby' and 'Lobby' both resolve.
|
||||
$matchedKey = $DisplayTypeTargets.Keys |
|
||||
Where-Object {{ $_ -ieq $displayType }} |
|
||||
Select-Object -First 1
|
||||
if (-not $matchedKey) {{
|
||||
Write-Host "Unknown display-type '$displayType'; known types: $($DisplayTypeTargets.Keys -join ', ')."
|
||||
return
|
||||
}}
|
||||
|
||||
$targetRoute = $DisplayTypeTargets[$matchedKey]
|
||||
$kioskUrl = "$KioskBaseUrl$targetRoute"
|
||||
Write-Host "display-type '$displayType' -> kiosk target $kioskUrl"
|
||||
|
||||
# Idempotent: if an Edge kiosk process is already serving this URL, leave it be
|
||||
# so an enforce cycle does not relaunch the kiosk every run.
|
||||
$alreadyRunning = Get-CimInstance Win32_Process -Filter "Name='msedge.exe'" -ErrorAction SilentlyContinue |
|
||||
Where-Object {{ $_.CommandLine -and $_.CommandLine.Contains($kioskUrl) }}
|
||||
if ($alreadyRunning) {{
|
||||
Write-Host 'Kiosk already running for this target; leaving it in place.'
|
||||
return
|
||||
}}
|
||||
|
||||
$edgeArguments = @("--kiosk", $kioskUrl, "--edge-kiosk-type=fullscreen", "--no-first-run")
|
||||
Start-Process -FilePath 'msedge.exe' -ArgumentList $edgeArguments
|
||||
Write-Host 'Launched kiosk browser.'
|
||||
"""
|
||||
|
||||
|
||||
def _registry_drift_heal_entry(name, regname, regvalue, regtype, comment):
|
||||
"""One Type=Registry entry that writes a value and detects drift via
|
||||
ValueMatches against that same path/name.
|
||||
|
||||
DetectionValue is stored as a string; the engine string-coerces for
|
||||
ValueMatches, so a DWord value of 2 detects against '2'.
|
||||
"""
|
||||
return {
|
||||
'_comment': comment,
|
||||
'Name': name,
|
||||
'Type': 'Registry',
|
||||
'RegPath': EDGE_POLICY_PATH,
|
||||
'RegName': regname,
|
||||
'RegValue': regvalue,
|
||||
'RegType': regtype,
|
||||
'DetectionMethod': 'ValueMatches',
|
||||
'DetectionPath': EDGE_POLICY_PATH,
|
||||
'DetectionName': regname,
|
||||
'DetectionValue': str(regvalue),
|
||||
}
|
||||
|
||||
|
||||
def build_display_manifest():
|
||||
"""Return the gea-shopfloor-display manifest dict (Applications in order).
|
||||
|
||||
Four Edge kiosk relaunch-policy drift-heal entries, then the one dispatcher
|
||||
entry. The dispatcher is declared PayloadSource=inline with no hash yet; the
|
||||
seed attaches the real payload bytes (and its sha256) after the draft rows
|
||||
exist. Kept payload-free otherwise: the kiosk engine and browser are baked
|
||||
into the display image, not shipped over HTTPS.
|
||||
"""
|
||||
applications = [
|
||||
_registry_drift_heal_entry(
|
||||
'Edge kiosk RelaunchNotification (Required auto-restart)',
|
||||
'RelaunchNotification', 2, 'DWord',
|
||||
'RelaunchNotification=2 (Required): Edge auto-restarts after the '
|
||||
'notification period. Displays have no operator to dismiss the '
|
||||
'update dialog, so this is the only mode that recovers unattended. '
|
||||
'Heals drift of the policy set at imaging by 09-Setup-Display.ps1.'),
|
||||
_registry_drift_heal_entry(
|
||||
'Edge kiosk RelaunchNotificationPeriod (1 hour)',
|
||||
'RelaunchNotificationPeriod', 3600000, 'DWord',
|
||||
'Milliseconds before the forced auto-restart. 3600000 ms = 1 hour.'),
|
||||
_registry_drift_heal_entry(
|
||||
'Edge kiosk RelaunchHeadsUpPeriod (1 minute)',
|
||||
'RelaunchHeadsUpPeriod', 60000, 'DWord',
|
||||
'Milliseconds of final warning before auto-restart. 60000 ms = 1 '
|
||||
'minute.'),
|
||||
_registry_drift_heal_entry(
|
||||
'Edge kiosk RelaunchWindow (02:00-04:00)',
|
||||
'RelaunchWindow', RELAUNCH_WINDOW_JSON, 'String',
|
||||
'Overnight forced-restart window (02:00 start, 120 minute '
|
||||
'duration) so business-hour updates wait until off-hours and the '
|
||||
'dialog stays invisible during the day.'),
|
||||
{
|
||||
'_comment': (
|
||||
'Kiosk dispatcher. Reads C:\\Enrollment\\display-type.txt and '
|
||||
'launches the kiosk target for the subtype. Data-driven map: '
|
||||
+ _relaunch_window_targets_comment()
|
||||
+ '. 3DPrintRoom target is a PLACEHOLDER (/parts-kiosk); '
|
||||
'TODO-confirm the real target. Delivered inline over HTTPS '
|
||||
'(share-less displays); DetectionMethod Always so it re-asserts '
|
||||
'each cycle, but the script is idempotent (skips if the kiosk is '
|
||||
'already serving the target URL).'),
|
||||
'Name': 'Display kiosk dispatcher (display-type.txt)',
|
||||
'Type': 'PS1',
|
||||
'Script': DISPATCHER_FILENAME,
|
||||
'PayloadSource': 'inline',
|
||||
'PayloadRef': DISPATCHER_FILENAME,
|
||||
'DetectionMethod': 'Always',
|
||||
},
|
||||
]
|
||||
return {
|
||||
'Version': SCOPE_VERSION,
|
||||
'_comment': (
|
||||
'gea-shopfloor-display runtime scope. Heals Edge kiosk relaunch '
|
||||
'policy drift and dispatches the kiosk to the subtype target. No '
|
||||
'EXE payloads: kiosk engine and browser are baked into the display '
|
||||
'image. The client merges the fleet-wide common scope underneath '
|
||||
'this one at fetch time.'),
|
||||
'Applications': applications,
|
||||
}
|
||||
|
||||
|
||||
def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'):
|
||||
"""Create/refresh the gea-shopfloor-display draft scope and its entries.
|
||||
|
||||
Idempotent for the draft: replace_scope_draft rebuilds the draft rows, and
|
||||
the inline dispatcher payload is content-addressed (a re-run stores the same
|
||||
bytes to the same sha256). Set publish=True to also freeze a published
|
||||
snapshot (that step is NOT idempotent: it always creates a new version).
|
||||
|
||||
Commits the session. Returns a summary dict:
|
||||
{scopeid, entrycount, entrytypes, dispatchersha256, publishedversion}.
|
||||
"""
|
||||
manifest = build_display_manifest()
|
||||
scope = service.replace_scope_draft(SCOPE_NAME, SCOPE_PHASE, manifest)
|
||||
# Flush so the new entries get entryids before the inline payload attaches.
|
||||
db.session.flush()
|
||||
|
||||
dispatcher = next(entry for entry in scope.entries
|
||||
if entry.name == 'Display kiosk dispatcher (display-type.txt)')
|
||||
scriptbytes = build_dispatcher_script().encode('utf-8')
|
||||
payload = service.store_inline_payload(
|
||||
dispatcher, DISPATCHER_FILENAME,
|
||||
'text/plain; charset=utf-8', scriptbytes)
|
||||
|
||||
publishedversion = None
|
||||
if publish:
|
||||
publishedversion = service.publish_scope(
|
||||
SCOPE_NAME, SCOPE_PHASE, notes=notes)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'scopeid': scope.scopeid,
|
||||
'entrycount': len(scope.entries),
|
||||
'entrytypes': [entry.entrytype for entry in scope.entries],
|
||||
'dispatchersha256': payload.payloadsha256,
|
||||
'publishedversion': publishedversion,
|
||||
}
|
||||
@@ -46,6 +46,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'manifestentryhostnames', 'manifestentrymachinenumbers',
|
||||
'manifestinusechecks', 'manifestinusecheckprocesses',
|
||||
'manifestpublishedversions', 'manifestpayloads',
|
||||
'manifestblobs',
|
||||
'manifestenforcementreports', 'manifestenforcementresults',
|
||||
'pctypealiases'),
|
||||
'knowledgebase': ('knowledgebase',),
|
||||
@@ -55,7 +56,8 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'notifications': ('notificationtypes', 'notifications'),
|
||||
'printedparts': ('printeditems', 'printeditemtransactions',
|
||||
'printeditemfiles'),
|
||||
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'),
|
||||
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers',
|
||||
'printersupplyalerts'),
|
||||
'slides': ('tvslides',),
|
||||
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
|
||||
'warranty': ('warranties', 'warrantyassets'),
|
||||
|
||||
@@ -46,7 +46,11 @@ CUTOVER_PLUGINS = (
|
||||
# stamp '<plugin>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'
|
||||
# geenforce adds the content-addressed blob store (manifestblobs) on top of its
|
||||
# baseline.
|
||||
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0002blobs'
|
||||
# printers adds the printersupplyalerts crossing-state table on top of its anchor.
|
||||
EXPECTED_HEAD_REVISION['printers'] = 'printers0002supplyalerts'
|
||||
# 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'
|
||||
@@ -54,8 +58,9 @@ EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
|
||||
EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
|
||||
# usb drops the dead usbcheckouts.machineid column on top of its anchor.
|
||||
EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid'
|
||||
# printedparts is post-cutover: its 0001 really creates its tables.
|
||||
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0003gagetag'
|
||||
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
|
||||
# the per-transaction revision column.
|
||||
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
||||
# notifications indexes businessunitid on top of its anchor.
|
||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx'
|
||||
|
||||
|
||||
108
tests/test_plugins/test_geenforce_blob_parity.py
Normal file
108
tests/test_plugins/test_geenforce_blob_parity.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""GE-Enforce manifestblobs model-vs-migration parity gate.
|
||||
|
||||
manifestblobs is created by the SECOND geenforce migration
|
||||
(0002_manifest_blobs.py), and the existing DDL-parity test only diffs the
|
||||
baseline (0001) tables - it does not even list ManifestBlob. So the http-blob
|
||||
store table has had NO parity coverage: a column added to the ManifestBlob model
|
||||
but not the migration (or vice versa) would ship a broken schema silently.
|
||||
|
||||
This gate builds manifestblobs two ways and diffs it column-by-column:
|
||||
1. the ManifestBlob model via metadata.create_all (desired shape), and
|
||||
2. the 0002 migration's upgrade() run against a fresh engine (shipped shape).
|
||||
Both go through the same SQLite dialect so reflected types render identically.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
|
||||
_BLOBS_MIGRATION = (Path(__file__).resolve().parent.parent.parent / 'plugins' /
|
||||
'geenforce' / 'migrations' / 'versions' /
|
||||
'0002_manifest_blobs.py')
|
||||
|
||||
_EXPECTED_COLUMNS = ('sha256', 'filename', 'contenttype', 'sizebytes',
|
||||
'createdat')
|
||||
|
||||
|
||||
def _migration_columns():
|
||||
"""Run the 0002 migration upgrade() on a fresh engine; return the reflected
|
||||
manifestblobs columns as {name: column-dict}."""
|
||||
spec = importlib.util.spec_from_file_location('geenforce_blobs',
|
||||
str(_BLOBS_MIGRATION))
|
||||
migration = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(migration)
|
||||
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
operations._install_proxy()
|
||||
try:
|
||||
migration.upgrade()
|
||||
finally:
|
||||
operations._remove_proxy()
|
||||
connection.commit()
|
||||
inspector = inspect(connection)
|
||||
assert 'manifestblobs' in inspector.get_table_names(), \
|
||||
'migration did not create manifestblobs'
|
||||
return {column['name']: column
|
||||
for column in inspector.get_columns('manifestblobs')}
|
||||
|
||||
|
||||
def _model_columns(db):
|
||||
"""Build manifestblobs from the ManifestBlob model; return {name: column}."""
|
||||
from plugins.geenforce.models.manifest import ManifestBlob
|
||||
|
||||
engine = create_engine('sqlite://')
|
||||
db.metadata.create_all(engine, tables=[ManifestBlob.__table__])
|
||||
return {column['name']: column
|
||||
for column in inspect(engine).get_columns('manifestblobs')}
|
||||
|
||||
|
||||
def _colkey(column):
|
||||
"""Normalize a reflected column to the facets we gate on (type, nullable)."""
|
||||
return (str(column['type']), bool(column['nullable']))
|
||||
|
||||
|
||||
def test_manifestblob_model_matches_migration(app, db):
|
||||
"""Every ManifestBlob column matches what the 0002 migration builds."""
|
||||
with app.app_context():
|
||||
modelcols = _model_columns(db)
|
||||
migrationcols = _migration_columns()
|
||||
|
||||
drift = []
|
||||
for name in sorted(set(modelcols) - set(migrationcols)):
|
||||
drift.append(f'{name}: in model, not in migration')
|
||||
for name in sorted(set(migrationcols) - set(modelcols)):
|
||||
drift.append(f'{name}: in migration, not in model')
|
||||
for name in sorted(set(modelcols) & set(migrationcols)):
|
||||
if _colkey(modelcols[name]) != _colkey(migrationcols[name]):
|
||||
drift.append(
|
||||
f'{name}: model {_colkey(modelcols[name])} != '
|
||||
f'migration {_colkey(migrationcols[name])}')
|
||||
|
||||
assert not drift, ('manifestblobs model/migration DDL drift:\n'
|
||||
+ '\n'.join(drift))
|
||||
|
||||
|
||||
def test_manifestblob_expected_columns_present_both_sides(app, db):
|
||||
"""Lock the load-bearing columns (sha256 primary key + registry fields)
|
||||
exist in BOTH the model and the migration."""
|
||||
with app.app_context():
|
||||
modelcols = _model_columns(db)
|
||||
migrationcols = _migration_columns()
|
||||
for name in _EXPECTED_COLUMNS:
|
||||
assert name in modelcols, f'{name} missing from ManifestBlob model'
|
||||
assert name in migrationcols, f'{name} missing from migration'
|
||||
|
||||
|
||||
def test_manifestblob_sha256_is_not_nullable(app, db):
|
||||
"""The content hash is the primary key: it must be NOT NULL on both sides."""
|
||||
with app.app_context():
|
||||
modelcols = _model_columns(db)
|
||||
migrationcols = _migration_columns()
|
||||
assert modelcols['sha256']['nullable'] is False
|
||||
assert migrationcols['sha256']['nullable'] is False
|
||||
108
tests/test_plugins/test_geenforce_display_seed.py
Normal file
108
tests/test_plugins/test_geenforce_display_seed.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Cover the gea-shopfloor-display authoring seed.
|
||||
|
||||
Running seed_display_scope must produce the display scope with the expected
|
||||
entry count and types: four Registry drift-heal entries (Edge kiosk relaunch
|
||||
policies) plus one inline PS1 dispatcher. Also checks the dispatcher payload is
|
||||
stored inline and the display-type map drives the dispatcher script.
|
||||
"""
|
||||
|
||||
from plugins.geenforce.models import (
|
||||
ManifestScope, ManifestEntry, ManifestPayload,
|
||||
)
|
||||
from plugins.geenforce.seed_display_scope import (
|
||||
seed_display_scope, build_display_manifest, build_dispatcher_script,
|
||||
DISPLAY_TYPE_TARGETS, SCOPE_NAME, DISPATCHER_FILENAME,
|
||||
)
|
||||
|
||||
|
||||
def test_seed_creates_display_scope(db):
|
||||
summary = seed_display_scope()
|
||||
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=SCOPE_NAME, phase='runtime').first()
|
||||
assert scope is not None
|
||||
# Not the common scope: the client merges common underneath at fetch time.
|
||||
assert scope.iscommon is False
|
||||
|
||||
# Four Registry drift-heal entries + one PS1 dispatcher, in order.
|
||||
assert summary['entrycount'] == 5
|
||||
assert summary['entrytypes'] == ['Registry', 'Registry', 'Registry',
|
||||
'Registry', 'PS1']
|
||||
|
||||
|
||||
def test_registry_entries_use_valuematches_detection(db):
|
||||
seed_display_scope()
|
||||
|
||||
registry_entries = ManifestEntry.query.filter_by(
|
||||
entrytype='Registry').order_by(ManifestEntry.sortorder).all()
|
||||
assert len(registry_entries) == 4
|
||||
regnames = {entry.regname for entry in registry_entries}
|
||||
assert regnames == {
|
||||
'RelaunchNotification', 'RelaunchNotificationPeriod',
|
||||
'RelaunchHeadsUpPeriod', 'RelaunchWindow',
|
||||
}
|
||||
for entry in registry_entries:
|
||||
assert entry.detectionmethod == 'ValueMatches'
|
||||
assert entry.regpath == 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Edge'
|
||||
# Detection targets the same value it writes, coerced to string.
|
||||
assert entry.detectionname == entry.regname
|
||||
assert entry.detectionvalue is not None
|
||||
|
||||
|
||||
def test_dispatcher_is_inline_and_data_driven(db):
|
||||
summary = seed_display_scope()
|
||||
|
||||
dispatcher = ManifestEntry.query.filter_by(entrytype='PS1').one()
|
||||
assert dispatcher.payloadsource == 'inline'
|
||||
assert dispatcher.payloadref == DISPATCHER_FILENAME
|
||||
assert dispatcher.payloadsha256 == summary['dispatchersha256']
|
||||
|
||||
payload = ManifestPayload.query.filter_by(
|
||||
entryid=dispatcher.entryid).one()
|
||||
scripttext = payload.payloadbytes.decode('utf-8')
|
||||
# The data-driven map surfaces every display-type and its target route.
|
||||
for display_type, route in DISPLAY_TYPE_TARGETS.items():
|
||||
assert display_type in scripttext
|
||||
assert route in scripttext
|
||||
assert 'display-type.txt' in scripttext
|
||||
|
||||
|
||||
def test_seed_publish_freezes_a_version(db):
|
||||
summary = seed_display_scope(publish=True)
|
||||
assert summary['publishedversion'] == 1
|
||||
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=SCOPE_NAME, phase='runtime').first()
|
||||
published = scope.publishedversions.filter_by(iscurrent=True).first()
|
||||
assert published is not None
|
||||
assert published.versionnumber == 1
|
||||
assert '"Version": "2.0"' in published.manifestjson
|
||||
|
||||
|
||||
def test_seed_draft_is_idempotent(db):
|
||||
first = seed_display_scope()
|
||||
second = seed_display_scope()
|
||||
# Re-running rebuilds the draft to the same shape (same scope, same count).
|
||||
assert first['scopeid'] == second['scopeid']
|
||||
assert first['entrycount'] == second['entrycount']
|
||||
assert first['dispatchersha256'] == second['dispatchersha256']
|
||||
|
||||
entries = ManifestEntry.query.filter_by(scopeid=second['scopeid']).all()
|
||||
assert len(entries) == 5
|
||||
|
||||
|
||||
def test_build_manifest_has_no_smb_exe_payloads(db):
|
||||
manifest = build_display_manifest()
|
||||
# No entry ships an EXE/MSI/File payload from a share: the only payload is
|
||||
# the inline dispatcher; everything else is a Registry policy heal.
|
||||
for entry in manifest['Applications']:
|
||||
assert entry.get('Installer') is None
|
||||
assert entry.get('Source') is None
|
||||
if entry['Type'] == 'PS1':
|
||||
assert entry.get('PayloadSource') == 'inline'
|
||||
|
||||
|
||||
def test_dispatcher_script_is_ascii():
|
||||
# Plain ASCII only (no smart quotes / em-dashes) so the naming gate stays
|
||||
# green and the on-PC script parses cleanly.
|
||||
build_dispatcher_script().encode('ascii')
|
||||
135
tests/test_plugins/test_geenforce_payload_hardening.py
Normal file
135
tests/test_plugins/test_geenforce_payload_hardening.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""GE-Enforce payload-download hardening: rate limit + served-size ceiling.
|
||||
|
||||
GET /api/geenforce/payload/<sha256> is reachable with only a read-only
|
||||
geenforce.fetch token, so a leaked display token must not be able to hammer it
|
||||
or pull unbounded bytes. These tests lock the two bounds added to the route:
|
||||
a per-IP fixed-window rate limit (same cache extension as the login limiter)
|
||||
and a size cap that refuses (413) a blob/inline payload over the configured max.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
from contextlib import contextmanager
|
||||
|
||||
from plugins.geenforce import service
|
||||
from plugins.geenforce.models import ManifestBlob
|
||||
|
||||
|
||||
def _fetch_key(client, auth_headers):
|
||||
resp = client.post('/api/apitokens',
|
||||
json={'name': 'svc', 'scopes': ['geenforce.fetch']},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return {'X-API-Key': resp.get_json()['data']['secret']}
|
||||
|
||||
|
||||
def _store(app, raw, filename='setup.exe'):
|
||||
with app.app_context():
|
||||
sha = service.store_blob(raw, filename, 'application/octet-stream')
|
||||
service.db.session.commit()
|
||||
return sha
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _config(app, **overrides):
|
||||
"""Temporarily override app.config keys (the app fixture is session-scoped,
|
||||
so every key is restored to its prior value/absence on exit)."""
|
||||
missing = object()
|
||||
saved = {key: app.config.get(key, missing) for key in overrides}
|
||||
app.config.update(overrides)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for key, value in saved.items():
|
||||
if value is missing:
|
||||
app.config.pop(key, None)
|
||||
else:
|
||||
app.config[key] = value
|
||||
|
||||
|
||||
def test_blob_within_cap_is_served(client, app, auth_headers):
|
||||
raw = b'x' * 64
|
||||
sha = _store(app, raw, 'ok.exe')
|
||||
headers = _fetch_key(client, auth_headers)
|
||||
with _config(app, GEENFORCE_PAYLOAD_MAX_BYTES=1024):
|
||||
resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == raw
|
||||
|
||||
|
||||
def test_oversized_blob_refused_413(client, app, auth_headers):
|
||||
raw = b'x' * 64
|
||||
sha = _store(app, raw, 'big.exe')
|
||||
headers = _fetch_key(client, auth_headers)
|
||||
with _config(app, GEENFORCE_PAYLOAD_MAX_BYTES=16):
|
||||
resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
|
||||
assert resp.status_code == 413
|
||||
# The blob registry row still exists; the cap gates delivery, not storage.
|
||||
with app.app_context():
|
||||
assert service.db.session.get(ManifestBlob, sha) is not None
|
||||
|
||||
|
||||
def test_oversized_inline_payload_refused_413(client, app, db, auth_headers):
|
||||
scopeid = client.post(
|
||||
'/api/geenforce/scopes',
|
||||
json={'scopename': 'gea-shopfloor-cmm', 'phase': 'runtime'},
|
||||
headers=auth_headers).get_json()['data']['scopeid']
|
||||
entryid = client.post(
|
||||
f'/api/geenforce/scopes/{scopeid}/entries',
|
||||
json={'Name': 'eDNC config', 'Type': 'PS1', 'Script': 's.ps1'},
|
||||
headers=auth_headers).get_json()['data']['entryid']
|
||||
content = b'inline config bytes here'
|
||||
up = client.post(f'/api/geenforce/entries/{entryid}/payload',
|
||||
data={'file': (io.BytesIO(content), 'config.reg')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert up.status_code == 201, up.get_json()
|
||||
sha = hashlib.sha256(content).hexdigest()
|
||||
|
||||
headers = _fetch_key(client, auth_headers)
|
||||
with _config(app, GEENFORCE_PAYLOAD_MAX_BYTES=4):
|
||||
resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
|
||||
assert resp.status_code == 413
|
||||
|
||||
|
||||
def test_payload_download_is_rate_limited(client, app, auth_headers):
|
||||
sha = _store(app, b'small blob')
|
||||
key = _fetch_key(client, auth_headers)
|
||||
# A unique caller IP isolates this bucket from other tests' shared counter.
|
||||
headers = {**key, 'X-Forwarded-For': '203.0.113.201'}
|
||||
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=3,
|
||||
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
|
||||
for _ in range(3):
|
||||
ok = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
|
||||
assert ok.status_code == 200, ok.get_json()
|
||||
# The 4th request in the same window is over budget.
|
||||
over = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
|
||||
assert over.status_code == 429
|
||||
|
||||
|
||||
def test_rate_limit_is_per_ip(client, app, auth_headers):
|
||||
"""A second caller (different X-Forwarded-For) has its own budget: one IP
|
||||
being throttled must not throttle everyone."""
|
||||
sha = _store(app, b'per-ip blob')
|
||||
key = _fetch_key(client, auth_headers)
|
||||
hot = {**key, 'X-Forwarded-For': '203.0.113.202'}
|
||||
cool = {**key, 'X-Forwarded-For': '203.0.113.203'}
|
||||
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=1,
|
||||
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
|
||||
assert client.get(f'/api/geenforce/payload/{sha}',
|
||||
headers=hot).status_code == 200
|
||||
assert client.get(f'/api/geenforce/payload/{sha}',
|
||||
headers=hot).status_code == 429
|
||||
# Fresh IP still gets its first request through.
|
||||
assert client.get(f'/api/geenforce/payload/{sha}',
|
||||
headers=cool).status_code == 200
|
||||
|
||||
|
||||
def test_rate_limit_can_be_disabled(client, app, auth_headers):
|
||||
sha = _store(app, b'unthrottled blob')
|
||||
key = _fetch_key(client, auth_headers)
|
||||
headers = {**key, 'X-Forwarded-For': '203.0.113.204'}
|
||||
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_ENABLED=False,
|
||||
GEENFORCE_PAYLOAD_RATELIMIT_MAX=1):
|
||||
for _ in range(5):
|
||||
assert client.get(f'/api/geenforce/payload/{sha}',
|
||||
headers=headers).status_code == 200
|
||||
137
tests/test_plugins/test_geenforce_report_contract.py
Normal file
137
tests/test_plugins/test_geenforce_report_contract.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""GE-Enforce enforcement-report per-entry key contract.
|
||||
|
||||
The server reads LOWERCASE keys from each results[] item -
|
||||
name / action / selfhealed / exitcode / message (see
|
||||
service.record_enforcement_report) - and maps them onto the
|
||||
ManifestEnforcementResult columns entryname / action / selfhealed / exitcode /
|
||||
message. This is the exact contract the PowerShell client fix targets: the
|
||||
client emits lowercase keys, so if the server ever silently switched to reading
|
||||
uppercase keys the client's entryname/selfhealed would drop to empty/false.
|
||||
|
||||
These tests POST a realistic multi-entry cycle with the lowercase keys and lock
|
||||
that the result columns populate, plus the negative case (uppercase keys do NOT
|
||||
populate) so a future contract flip fails loudly here.
|
||||
"""
|
||||
|
||||
from plugins.geenforce import service
|
||||
|
||||
|
||||
SCOPE = {
|
||||
'Version': '2.6',
|
||||
'Applications': [
|
||||
{'Name': 'VNC firewall rule', 'Type': 'PS1', 'Script': 'scripts/vnc.ps1'},
|
||||
{'Name': 'PC-DMIS 2023.1', 'Type': 'MSI', 'Installer': 'apps/pcdmis.msi'},
|
||||
{'Name': 'eDNC config', 'Type': 'PS1', 'Script': 'scripts/ednc.ps1'},
|
||||
{'Name': 'Blancco agent', 'Type': 'MSI', 'Installer': 'apps/blancco.msi'},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _seed_and_publish(app, scopename='gea-shopfloor-cmm'):
|
||||
with app.app_context():
|
||||
service.replace_scope_draft(scopename, 'runtime', SCOPE)
|
||||
service.publish_scope(scopename, 'runtime', notes='v')
|
||||
service.db.session.commit()
|
||||
|
||||
|
||||
def _report_token(client, auth_headers):
|
||||
resp = client.post('/api/apitokens',
|
||||
json={'name': 'svc', 'scopes': ['geenforce.report']},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['secret']
|
||||
|
||||
|
||||
def test_lowercase_keys_populate_result_columns(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _report_token(client, auth_headers)
|
||||
|
||||
post = client.post('/api/geenforce/report', json={
|
||||
'hostname': 'WJDISPLAY07',
|
||||
'scopename': 'gea-shopfloor-cmm',
|
||||
'phase': 'runtime',
|
||||
'appliedversion': 1,
|
||||
'enforcerversion': '2.6',
|
||||
'counts': {'installed': 2, 'skipped': 1, 'failed': 1, 'filtered': 0},
|
||||
'results': [
|
||||
{'name': 'VNC firewall rule', 'action': 'installed',
|
||||
'selfhealed': True, 'exitcode': 0, 'message': 'rule re-added'},
|
||||
{'name': 'PC-DMIS 2023.1', 'action': 'skipped',
|
||||
'selfhealed': False, 'exitcode': 0, 'message': ''},
|
||||
{'name': 'eDNC config', 'action': 'installed',
|
||||
'selfhealed': True, 'exitcode': 0},
|
||||
{'name': 'Blancco agent', 'action': 'failed',
|
||||
'selfhealed': False, 'exitcode': 1603, 'message': 'MSI 1603'},
|
||||
],
|
||||
}, headers={'X-API-Key': secret})
|
||||
assert post.status_code == 200, post.get_json()
|
||||
reportid = post.get_json()['data']['reportid']
|
||||
|
||||
detail = client.get(f'/api/geenforce/reports/{reportid}',
|
||||
headers=auth_headers).get_json()['data']
|
||||
byname = {r['entryname']: r for r in detail['results']}
|
||||
|
||||
# Every lowercase 'name' mapped onto entryname (nothing dropped).
|
||||
assert set(byname) == {'VNC firewall rule', 'PC-DMIS 2023.1',
|
||||
'eDNC config', 'Blancco agent'}
|
||||
|
||||
vnc = byname['VNC firewall rule']
|
||||
assert vnc['action'] == 'installed'
|
||||
assert vnc['selfhealed'] is True
|
||||
assert vnc['exitcode'] == 0
|
||||
assert vnc['message'] == 'rule re-added'
|
||||
|
||||
assert byname['PC-DMIS 2023.1']['action'] == 'skipped'
|
||||
assert byname['PC-DMIS 2023.1']['selfhealed'] is False
|
||||
|
||||
ednc = byname['eDNC config']
|
||||
assert ednc['selfhealed'] is True
|
||||
assert ednc['message'] is None # omitted key -> None
|
||||
|
||||
blancco = byname['Blancco agent']
|
||||
assert blancco['action'] == 'failed'
|
||||
assert blancco['exitcode'] == 1603
|
||||
assert blancco['message'] == 'MSI 1603'
|
||||
|
||||
# A failure is present, so the cycle status is 'failed' (a self-heal without
|
||||
# any failure would be 'selfhealed').
|
||||
assert detail['status'] == 'failed'
|
||||
|
||||
|
||||
def test_selfhealed_defaults_false_when_key_omitted(client, db, app,
|
||||
auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _report_token(client, auth_headers)
|
||||
post = client.post('/api/geenforce/report', json={
|
||||
'hostname': 'WJDISPLAY08',
|
||||
'scopename': 'gea-shopfloor-cmm',
|
||||
'results': [{'name': 'asset report', 'action': 'installed'}],
|
||||
}, headers={'X-API-Key': secret})
|
||||
reportid = post.get_json()['data']['reportid']
|
||||
|
||||
detail = client.get(f'/api/geenforce/reports/{reportid}',
|
||||
headers=auth_headers).get_json()['data']
|
||||
result = detail['results'][0]
|
||||
assert result['entryname'] == 'asset report'
|
||||
assert result['selfhealed'] is False
|
||||
|
||||
|
||||
def test_uppercase_keys_do_not_populate(client, db, app, auth_headers):
|
||||
"""Negative lock: the server reads LOWERCASE keys, so an uppercase-keyed
|
||||
item does NOT populate entryname/selfhealed. Guards against a silent flip to
|
||||
uppercase that would break the client's lowercase payload."""
|
||||
_seed_and_publish(app)
|
||||
secret = _report_token(client, auth_headers)
|
||||
post = client.post('/api/geenforce/report', json={
|
||||
'hostname': 'WJDISPLAY09',
|
||||
'scopename': 'gea-shopfloor-cmm',
|
||||
'results': [{'Name': 'wrong casing', 'Action': 'installed',
|
||||
'SelfHealed': True}],
|
||||
}, headers={'X-API-Key': secret})
|
||||
reportid = post.get_json()['data']['reportid']
|
||||
|
||||
detail = client.get(f'/api/geenforce/reports/{reportid}',
|
||||
headers=auth_headers).get_json()['data']
|
||||
result = detail['results'][0]
|
||||
assert result['entryname'] == '' # 'Name' not read -> default ''
|
||||
assert result['selfhealed'] is False # 'SelfHealed' not read -> default
|
||||
Reference in New Issue
Block a user