Security hardening, multi-site portability, and a documentation and API-surface sweep

This commit is contained in:
2026-08-14 16:28:34 -04:00
parent 72f8da0e58
commit 265ca2eb01
76 changed files with 3684 additions and 811 deletions

View File

@@ -352,6 +352,8 @@ def list_scopes():
'scopename': scope.scopename,
'phase': scope.phase,
'manifestversion': scope.manifestversion,
'minlibversion': scope.minlibversion,
'minlibversion': scope.minlibversion,
'computertypeid': scope.computertypeid,
'measuringtooltypeid': scope.measuringtooltypeid,
'iscommon': scope.iscommon,
@@ -462,6 +464,7 @@ def create_scope():
scope = ManifestScope(
scopename=scopename, phase=phase,
manifestversion=str(payload.get('manifestversion', '1.0')),
minlibversion=(payload.get('minlibversion') or None),
description=payload.get('description'),
computertypeid=payload.get('computertypeid'),
measuringtooltypeid=payload.get('measuringtooltypeid'),
@@ -496,6 +499,10 @@ def update_scope(scopeid):
setattr(scope, field, payload[field])
if 'manifestversion' in payload:
scope.manifestversion = str(payload['manifestversion'])
if 'minlibversion' in payload:
# Blank clears it, which returns the scope to a derived floor rather
# than pinning it at the empty string.
scope.minlibversion = (payload['minlibversion'] or None)
if 'iscommon' in payload:
scope.iscommon = bool(payload['iscommon'])
db.session.commit()
@@ -764,9 +771,9 @@ def publish_preflight(scopeid):
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
hosts, floor = service.hosts_below_libversion(
scope.scopename, scope.phase, scope.manifestversion)
scope.scopename, scope.phase, service.required_libversion(scope))
return success_response({
'required': scope.manifestversion,
'required': service.required_libversion(scope),
'floor': floor,
'hostsbehind': hosts,
'canpublish': not hosts,

View File

@@ -65,8 +65,8 @@
.EXAMPLE
.\Install-GEEnforce.ps1 -PCType gea-shopfloor-cmm -MachineNumber 0615 `
-CmmVersion 2019 -ShareRoot \\server\share\dt\shopfloor -Site "West Jefferson" `
-ShopdbUrl https://shopdb.site.geaerospace.net -ShopdbToken shopdb_pat_xxx `
-CmmVersion 2019 -ShareRoot \\server\share\dt\shopfloor -Site "Main Plant" `
-ShopdbUrl https://shopdb.example.net -ShopdbToken shopdb_pat_xxx `
-EngineSource \\server\share\dt\shopfloor\common
#>
[CmdletBinding()]

View File

@@ -20,10 +20,30 @@
# Runs as SYSTEM under GE-Enforce, from an entry gated to the test bays.
# Fail-safe: exits 0 on every path - shadowing must never stop a bay enforcing.
param(
# This site's ShopDB. Optional: a bay that already carries a BaseUrl in the
# registry keeps it, so the fleet needs this only on the first cycle or when
# the address changes. There is deliberately NO default - see below.
[string] $BaseUrl = ''
)
$ErrorActionPreference = 'Continue'
# RE-ENTRANCY GUARD. This entry lives IN the manifest the shadow run enforces,
# so without it the thing recurses without bound: engine runs the manifest ->
# reaches this entry -> we invoke the runner -> the runner runs the engine
# against the SAME manifest -> reaches this entry again. Measured on the test
# bays 2026-08-13: 347 nested cycles in 36 minutes, one every five seconds,
# until the share unmounted. The task-based predecessor never hit this because
# it registered a task instead of invoking the runner.
#
# An environment variable, because it is inherited by every child process and
# therefore covers the nested engine and runner without a file to clean up or a
# stale lock to age out. Set for THIS process tree only.
if ($env:SHOPDB_SHADOW_ACTIVE -eq '1') { exit 0 }
$env:SHOPDB_SHADOW_ACTIVE = '1'
$InstallDir = 'C:\Program Files\GE\Shopfloor'
$BaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb'
$LegacyTask = 'ShopDB GE-Enforce (shadow)'
function Write-ShadowLog {
@@ -66,12 +86,30 @@ try {
}
}
# BaseUrl only when it differs, so a hand-set value is not churned.
# THE BAY'S OWN VALUE WINS. This used to hold one site's ShopDB URL as a
# compiled-in constant and write it whenever the registry disagreed. At the
# site it was written for that reads as "heal drift"; anywhere else it reads
# as "overwrite this site's address on every enforce cycle", and the site
# cannot win because the cycle repeats. A second plant could not point its
# own bays at its own server.
#
# So: an explicitly passed -BaseUrl seeds the value, an existing registry
# value is never touched, and with neither there is nothing honest to write.
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null }
if ((Get-ItemProperty -Path $regPath -Name BaseUrl -ErrorAction SilentlyContinue).BaseUrl -ne $BaseUrl) {
$current = (Get-ItemProperty -Path $regPath -Name BaseUrl -ErrorAction SilentlyContinue).BaseUrl
if (-not $current) {
if (-not $BaseUrl) {
Write-ShadowLog "no BaseUrl in $regPath and none passed - set it, or pass -BaseUrl in the manifest entry. Skipping this cycle."
exit 0
}
Set-ItemProperty -Path $regPath -Name BaseUrl -Value $BaseUrl
Write-ShadowLog "BaseUrl set to $BaseUrl"
Write-ShadowLog "BaseUrl seeded as $BaseUrl"
} elseif ($BaseUrl -and $BaseUrl -ne $current) {
# Say it, do not do it. A changed address is a real event, and it should
# be a deliberate one, not a side effect of whatever the manifest last
# carried.
Write-ShadowLog "BaseUrl in registry is $current; -BaseUrl passed $BaseUrl. Keeping the registry value."
}
Write-ShadowLog "shadowing $scope against $shareManifest"
@@ -83,3 +121,8 @@ catch {
Write-ShadowLog "FAILED: $_"
exit 0
}
finally {
# Clear it so the NEXT enforce cycle shadows again. Without this the guard
# would latch for the life of the process tree and shadow would run once.
$env:SHOPDB_SHADOW_ACTIVE = $null
}

View File

@@ -14,7 +14,7 @@
a PC is never left unmanaged because the web app is down.
Config (params override registry): HKLM:\SOFTWARE\GE\ShopDB
BaseUrl e.g. https://shopdb.example.geaerospace.net
BaseUrl e.g. https://shopdb.example.net
ApiToken a geenforce.fetch (+ geenforce.report) managed service token,
provisioned the same way as SFLD creds (Azure DSC).
#>

View File

@@ -15,11 +15,11 @@
class="cidr-input"
rows="5"
spellcheck="false"
placeholder="10.134.48.0/23
10.48.249.0/26"
placeholder="192.0.2.0/24
198.51.100.0/26"
></textarea>
<small class="input-hint">
One CIDR (or a plain IP) per line, or comma-separated. Example:
<code>10.134.48.0/23, 10.48.249.0/26</code>. A caller from any of these
<code>192.0.2.0/24, 198.51.100.0/26</code>. A caller from any of these
networks may pull manifests + payloads and post reports with no token.
</small>

View File

@@ -19,10 +19,28 @@ branch_labels = None
depends_on = None
def _columns(bind):
insp = sa.inspect(bind)
if 'manifestenforcementreports' not in insp.get_table_names():
return None
return {c['name'] for c in insp.get_columns('manifestenforcementreports')}
def upgrade():
# Guarded, like network0003prefix. On a FRESH database the table is built
# from the SQLAlchemy models, which already declare this column, so an
# unconditional add fails with "duplicate column name" - and this chain has
# been re-running its head on MySQL (see the missing commit in
# shopdb/plugins/alembic_template.py), so a redeploy hit the same wall.
columns = _columns(op.get_bind())
if columns is None or 'subtype' in columns:
return
op.add_column('manifestenforcementreports',
sa.Column('subtype', sa.String(length=50), nullable=True))
def downgrade():
columns = _columns(op.get_bind())
if columns is None or 'subtype' not in columns:
return
op.drop_column('manifestenforcementreports', 'subtype')

View File

@@ -0,0 +1,49 @@
"""A scope declares the client library it needs, separately from its own version.
The publish gate exists to refuse a manifest the fleet's GE-Enforce library
cannot read correctly. It was comparing reported library versions against
`manifestversion` - the manifest's own 'Version' field, which for a share
imported manifest happens to share the library's numbering but for a
code-authored scope is whatever the author chose (seed_display_scope writes
'2.0'). Every kiosk runs something newer than that, so the gate passed on the
one scope most in need of it.
`minlibversion` says the thing the gate actually wants to know. NULL means
derive it from what the manifest uses, so existing scopes need no edit.
Revision ID: geenforce0004minlib
Revises: geenforce0003subtype
"""
from alembic import op
import sqlalchemy as sa
revision = 'geenforce0004minlib'
down_revision = 'geenforce0003subtype'
branch_labels = None
depends_on = None
TABLE = 'manifestscopes'
def _columns(bind):
insp = sa.inspect(bind)
if TABLE not in insp.get_table_names():
return None
return {c['name'] for c in insp.get_columns(TABLE)}
def upgrade():
# Guarded like geenforce0003subtype and network0003prefix: on a fresh
# database the table is built from the models, which already declare this.
columns = _columns(op.get_bind())
if columns is None or 'minlibversion' in columns:
return
op.add_column(TABLE, sa.Column('minlibversion', sa.String(length=16),
nullable=True))
def downgrade():
columns = _columns(op.get_bind())
if columns is None or 'minlibversion' not in columns:
return
op.drop_column(TABLE, 'minlibversion')

View File

@@ -49,6 +49,15 @@ class ManifestScope(BaseModel):
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')
# Lowest GE-Enforce client library that may enforce this scope, e.g. '2.6'.
#
# SEPARATE FROM manifestversion, which is the manifest's own 'Version' field
# and is whatever its author wrote - seed_display_scope picks '2.0'. The
# publish gate used manifestversion as the library floor, so for every
# code-authored scope it compared the fleet against a number that says
# nothing about library features, and passed. NULL means "derive it", see
# service.required_libversion.
minlibversion = db.Column(db.String(16), nullable=True)
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.

View File

@@ -84,6 +84,20 @@ def _relaunch_window_targets_comment():
return pairs
def _site_base_url():
"""This site's public base URL from settings, or '' when it is unset.
Wrapped because the script builders are also called with no application
context (the seed tests build the text and check it compiles), and a
settings lookup there raises rather than returning a default.
"""
try:
from shopdb.api import Setting
return (Setting.get('site_base_url', '') or '').strip().rstrip('/')
except Exception:
return ''
def build_dispatcher_script():
"""Return the inline dispatcher PowerShell as text.
@@ -96,6 +110,11 @@ def build_dispatcher_script():
table_lines.append(f" '{display_type}' = '{route}'")
table_body = ';\n'.join(table_lines)
# Baked in at seed time because the emitted script runs on a kiosk with no
# route back to the database. Blank when the site has not set it, and the
# dispatcher refuses rather than guessing - see the comment on $KioskBaseUrl.
kiosk_base_url = _site_base_url()
return f"""# Invoke-DisplayKioskDispatch.ps1 -- gea-shopfloor-display dispatcher.
#
# Reads C:\\Enrollment\\display-type.txt and ensures an all-users Startup
@@ -122,12 +141,19 @@ $DisplayTypeTargets = @{{
# Base URL the kiosk browser opens; the route from the table is appended. Read
# from HKLM (the value the GE-Enforce client is configured with) so it stays
# site-agnostic; falls back to the West Jefferson shopdb host.
$KioskBaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb'
# site-agnostic. The fallback is THIS site's site_base_url setting, baked in
# when the scope was seeded - it used to be one particular plant's host, which
# meant a kiosk anywhere else with no registry value quietly opened a server it
# has no business reaching.
$KioskBaseUrl = '{kiosk_base_url}'
try {{
$shopdbConfig = Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\GE\\ShopDB' -Name BaseUrl -ErrorAction Stop
if ($shopdbConfig.BaseUrl) {{ $KioskBaseUrl = ([string]$shopdbConfig.BaseUrl).TrimEnd('/') }}
}} catch {{}}
if (-not $KioskBaseUrl) {{
Write-Host 'no ShopDB base URL: set HKLM:\\SOFTWARE\\GE\\ShopDB BaseUrl, or set site_base_url in Settings and re-publish the display scope.'
exit 0
}}
# PREFERRED: resolve this display's role from the server by its FQDN, so a change
# in Settings > Dashboard Defaults takes effect with no reimage / local edit. The
@@ -136,7 +162,7 @@ try {{
$path = $null
$serial = ''
try {{ $serial = ([string](Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber).Trim() }} catch {{}}
$fqdnDomain = 'device.geaerospace.net'
$fqdnDomain = 'device.geaerospace.net' # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
try {{
$ddom = Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\GE\\ShopDB' -Name DisplayFqdnDomain -ErrorAction Stop
if ($ddom.DisplayFqdnDomain) {{ $fqdnDomain = ([string]$ddom.DisplayFqdnDomain).Trim().Trim('.') }}
@@ -200,8 +226,15 @@ Get-ChildItem -LiteralPath $startup -Filter '*.lnk' -ErrorAction SilentlyContinu
# -kiosk matches both '-kiosk' and '--kiosk'. Also catch any browser
# shortcut pointing at a shopdb kiosk URL (incl. the dead
# shopfloor-dashboard route + --app variants).
# The host comes from $KioskBaseUrl, not from a literal: this used
# to name one site's server, so at any other site a legacy shortcut
# pointing at that site's own ShopDB was not recognised and never
# cleaned up.
$kioskHost = ''
try {{ $kioskHost = ([uri]$KioskBaseUrl).Host }} catch {{}}
$kioskArgs = ($existing.Arguments -match '(?i)-kiosk') -or `
($existing.Arguments -match '(?i)tsgwp00525|/shopdb/|shopfloor-dashboard')
($kioskHost -and $existing.Arguments -like "*$kioskHost*") -or `
($existing.Arguments -match '(?i)/shopdb/|shopfloor-dashboard')
if ($isBrowser -and $kioskArgs) {{ $drop = $true }}
}} catch {{}}
}}

View File

@@ -116,6 +116,45 @@ def hosts_below_libversion(scopename, phase, required):
return sorted(set(behind)), floor
# Manifest features and the client library that first understood them. A
# library older than this does not merely skip the feature, it MISREADS the
# entry: 2.6 added _CmmVersion, and an older lib treats a version-gated entry as
# ungated and installs every PC-DMIS build it cannot detect.
#
# Only features that NARROW behaviour belong here. An addition an old lib
# ignores harmlessly does not need a floor.
LIBVERSION_FEATURES = (
('cmmversion', '2.6'),
)
def required_libversion(scope):
"""Lowest client library that may enforce this scope.
An explicit minlibversion wins. Otherwise it is derived from the features
the draft actually uses, so a scope written before this column existed is
still gated on what it contains rather than on manifestversion - which is
the manifest's own 'Version' field and says nothing about the library.
Falls back to manifestversion when nothing else applies, preserving the
behaviour share-imported manifests already relied on, where the two
numbering schemes do coincide.
"""
declared = (scope.minlibversion or '').strip()
if declared:
return declared
floor = None
for attribute, version in LIBVERSION_FEATURES:
if any(getattr(entry, attribute, None) for entry in scope.entries):
candidate = parse_libversion(version)
if candidate and (floor is None or candidate > floor[0]):
floor = (candidate, version)
if floor:
return floor[1]
return scope.manifestversion
def _installed_app_model():
"""Lazily import the computers plugin's ComputerInstalledApp.
@@ -184,11 +223,11 @@ def publish_scope(scopename, phase, notes=None, publishedby=None, force=False):
raise ValueError(f'No scope {scopename}/{phase}')
if not force:
behind, floor = hosts_below_libversion(
scopename, phase, scope.manifestversion)
required = required_libversion(scope)
behind, floor = hosts_below_libversion(scopename, phase, required)
if behind:
raise LibVersionTooOldError(
scopename, scope.manifestversion, floor or 'unknown', behind)
scopename, required, floor or 'unknown', behind)
text = scope_to_json(scope)
maxversion = db.session.query(