From c355eaae43744038e5daf4be90ae528f8b2f379c Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Thu, 23 Jul 2026 14:37:00 -0400 Subject: [PATCH] printers low-toner alerts; geenforce display readiness + token scope-binding; air-gap docker deploy kit --- .env.example | 4 + docker-compose.airgap.yml | 90 ++++++ .../versions/7d30_apitoken_resourcescopes.py | 36 +++ plugins/geenforce/api/routes.py | 107 ++++++- .../geenforce/client/Invoke-ShopdbEnforce.ps1 | 95 ++++++- .../geenforce/client/ShopdbEnforceClient.psm1 | 193 ++++++++++++- plugins/geenforce/seed_display_scope.py | 264 ++++++++++++++++++ plugins/geenforce/service.py | 29 ++ plugins/printedparts/api/routes.py | 2 +- plugins/printers/frontend/routes.js | 6 + .../frontend/views/PrinterAlertsSettings.vue | 192 +++++++++++++ .../versions/0002_printersupplyalerts.py | 47 ++++ plugins/printers/models/__init__.py | 2 + plugins/printers/models/supply_alert.py | 40 +++ plugins/printers/plugin.py | 69 ++++- plugins/printers/services/__init__.py | 4 + plugins/printers/services/supply_alerts.py | 190 +++++++++++++ plugins/printers/services/supply_parts.py | 26 +- scripts/build-offline-bundle.ps1 | 108 +++++++ shopdb/__init__.py | 2 +- shopdb/api/__init__.py | 7 +- shopdb/core/api/apitokens.py | 30 ++ shopdb/core/models/apitoken.py | 27 ++ shopdb/plugins/alembic_template.py | 4 +- shopdb/utils/apitoken_auth.py | 37 ++- tests/test_plugin_migrations.py | 11 +- .../test_geenforce_blob_parity.py | 108 +++++++ .../test_geenforce_display_seed.py | 109 ++++++++ .../test_geenforce_payload_hardening.py | 135 +++++++++ .../test_geenforce_report_contract.py | 137 +++++++++ .../test_geenforce_resource_binding.py | 128 +++++++++ .../test_printers_supply_alerts.py | 165 +++++++++++ 32 files changed, 2358 insertions(+), 46 deletions(-) create mode 100644 docker-compose.airgap.yml create mode 100644 migrations/versions/7d30_apitoken_resourcescopes.py create mode 100644 plugins/geenforce/seed_display_scope.py create mode 100644 plugins/printers/frontend/views/PrinterAlertsSettings.vue create mode 100644 plugins/printers/migrations/versions/0002_printersupplyalerts.py create mode 100644 plugins/printers/models/supply_alert.py create mode 100644 plugins/printers/services/supply_alerts.py create mode 100644 scripts/build-offline-bundle.ps1 create mode 100644 tests/test_plugins/test_geenforce_blob_parity.py create mode 100644 tests/test_plugins/test_geenforce_display_seed.py create mode 100644 tests/test_plugins/test_geenforce_payload_hardening.py create mode 100644 tests/test_plugins/test_geenforce_report_contract.py create mode 100644 tests/test_plugins/test_geenforce_resource_binding.py create mode 100644 tests/test_plugins/test_printers_supply_alerts.py diff --git a/.env.example b/.env.example index 74297c83..a2689518 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,10 @@ MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PASSWORD MYSQL_PASSWORD=CHANGE_ME_APP_PASSWORD MYSQL_PORT=3306 API_PORT=5001 +# Air-gapped deploy only (docker-compose.airgap.yml): the loaded image tag, +# which MUST match what build-offline-bundle.ps1 -Version produced. Ignored by +# the connected build template (docker-compose.yml builds from source). +IMAGE_TAG=0.7.0 # ---- Zabbix integration (optional, for printer supply monitoring) ---- ZABBIX_URL= diff --git a/docker-compose.airgap.yml b/docker-compose.airgap.yml new file mode 100644 index 00000000..d5c426d7 --- /dev/null +++ b/docker-compose.airgap.yml @@ -0,0 +1,90 @@ +# shopdb-flask AIR-GAPPED single-site stack. +# +# For a site with NO internet. Nothing is built or pulled here: the images are +# built on a connected box (scripts/build-offline-bundle.ps1), shipped as a +# tarball, and `docker load`ed at the site. This file only RUNS pre-loaded +# images. See docs/DEPLOY-AIRGAP.md for the full runbook. +# +# Differences from docker-compose.yml (the connected/build template): +# - api uses `image:` (a loaded image), never `build: .` (build needs the net). +# - NO ./plugins bind mount. The image already carries every plugin baked in; +# binding a host ./plugins (which does not exist at an image-only site) would +# mask the baked plugins with an empty dir and load ZERO plugins. +# - a one-shot `migrate` service runs db upgrade + plugin upgrade-all + seed +# BEFORE api starts, so `up -d` alone brings up a working site (no manual +# `docker compose exec ... flask db upgrade` to forget). +# +# Usage at the site: +# docker load < shopdb-stack-.tar.gz +# cp .env.example .env # then edit: secrets, CORS_ORIGINS, IMAGE_TAG +# docker compose -f docker-compose.airgap.yml up -d +# docker compose -f docker-compose.airgap.yml exec api flask seed admin + +# Shared application environment, reused by the migrate one-shot and the api +# service so the two never drift. A YAML anchor, not a container. +x-app-env: &app-env + FLASK_APP: wsgi.py + FLASK_ENV: production + DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4 + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set} + JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set} + CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + ZABBIX_URL: ${ZABBIX_URL:-} + ZABBIX_TOKEN: ${ZABBIX_TOKEN:-} + +services: + db: + image: mysql:8.0 + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} + MYSQL_DATABASE: shopdb_flask + MYSQL_USER: shopdb + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set} + volumes: + - db_data:/var/lib/mysql + ports: + - "127.0.0.1:${MYSQL_PORT:-3306}:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + + # One-shot schema + seed. Runs to completion and exits; api waits for it. + # Every step is idempotent, so it is safe to run on every `up`. + migrate: + image: shopdb-flask:${IMAGE_TAG:-0.7.0} + restart: "no" + depends_on: + db: + condition: service_healthy + environment: + <<: *app-env + command: + - sh + - -c + - > + flask db upgrade && + flask plugin upgrade-all && + flask seed permissions && + flask seed settings && + flask seed reference-data + + api: + image: shopdb-flask:${IMAGE_TAG:-0.7.0} + restart: unless-stopped + depends_on: + db: + condition: service_healthy + migrate: + condition: service_completed_successfully + environment: + <<: *app-env + ports: + - "${API_PORT:-5001}:5001" + +volumes: + db_data: diff --git a/migrations/versions/7d30_apitoken_resourcescopes.py b/migrations/versions/7d30_apitoken_resourcescopes.py new file mode 100644 index 00000000..707fbf93 --- /dev/null +++ b/migrations/versions/7d30_apitoken_resourcescopes.py @@ -0,0 +1,36 @@ +"""apitokens.resourcescopes: pin a service token to specific resource scopes. + +A geenforce.fetch token handed to a fleet (e.g. displays) should reach only its +own manifest scope(s) and the blobs those scopes ship, not every scope by name +or every blob by hash. This nullable JSON column carries that allowlist; NULL = +unrestricted, so every existing token keeps working unchanged. Idempotent. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7d30_apitoken_resourcescopes' +down_revision = '7d29_supportteam_webhookurl' +branch_labels = None +depends_on = None + + +def _has_column(bind, table, column): + inspector = sa.inspect(bind) + if table not in inspector.get_table_names(): + return False + return column in {c['name'] for c in inspector.get_columns(table)} + + +def upgrade(): + bind = op.get_bind() + if not _has_column(bind, 'apitokens', 'resourcescopes'): + op.add_column('apitokens', + sa.Column('resourcescopes', sa.Text(), nullable=True)) + + +def downgrade(): + bind = op.get_bind() + if _has_column(bind, 'apitokens', 'resourcescopes'): + op.drop_column('apitokens', 'resourcescopes') diff --git a/plugins/geenforce/api/routes.py b/plugins/geenforce/api/routes.py index e2a773f4..ca545f20 100644 --- a/plugins/geenforce/api/routes.py +++ b/plugins/geenforce/api/routes.py @@ -9,15 +9,16 @@ 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, g from flask_jwt_extended import jwt_required from sqlalchemy.exc import IntegrityError from shopdb.api import ( - db, success_response, error_response, ErrorCodes, require_permission, - service_token_authorized, Setting, Application, + db, cache, success_response, error_response, ErrorCodes, require_permission, + authorized_service_token, Setting, Application, ) SHAREROOT_SETTING = 'geenforce_share_root' @@ -50,7 +51,12 @@ def _require_service_token(scope): def wrapper(f): @wraps(f) def decorated(*args, **kwargs): - if service_token_authorized(scope): + token = authorized_service_token(scope) + if token is not None: + # Stash for the route so it can honor the token's resource + # binding (token.resourcescopelist restricts which manifest + # scopes + blobs this token may pull; None = unrestricted). + g.geenforce_token = token return f(*args, **kwargs) return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key', http_code=401) @@ -58,6 +64,14 @@ def _require_service_token(scope): return wrapper +def _token_resource_scopes(): + """Resource-scope allowlist for the authorized token, or None if the token + may reach any scope. A bound token (e.g. a display's) is pinned to its own + manifest scope(s) so a leaked key cannot pull every scope's manifest+blobs.""" + token = getattr(g, 'geenforce_token', None) + return token.resourcescopelist if token is not None else None + + require_fetch_token = _require_service_token(FETCH_SCOPE) require_report_token = _require_service_token(REPORT_SCOPE) @@ -79,6 +93,11 @@ def get_manifest(): return error_response(ErrorCodes.VALIDATION_ERROR, 'pctype is required', http_code=400) + allowed = _token_resource_scopes() + if allowed is not None and scopename not in allowed: + return error_response(ErrorCodes.FORBIDDEN, + 'token is not allowed this scope', http_code=403) + scope = ManifestScope.query.filter_by( scopename=scopename, phase=phase).first() if not scope: @@ -101,6 +120,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/', methods=['GET']) @require_fetch_token def get_payload(sha256): @@ -111,17 +183,40 @@ 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', http_code=400) + + # A resource-bound token may only pull a blob its own scope(s) reference. + # Return 404 (not 403) so it cannot probe which hashes exist. Checked before + # the 304 shortcut so a bound token cannot even confirm a hash via ETag. + allowed = _token_resource_scopes() + if allowed is not None and not service.blob_referenced_by_scopes(sha, allowed): + return error_response(ErrorCodes.NOT_FOUND, 'no such payload', + http_code=404) + etag = f'"{sha}"' 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 +226,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', diff --git a/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 b/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 index e67adefb..66da901c 100644 --- a/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 +++ b/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 @@ -24,6 +24,17 @@ .PARAMETER ShadowMode Fetch + compare + report, but install from the share (no behavior change). +.PARAMETER IncludeCommon + Also fetch the fleet-wide CommonScope and merge it into -Scope, so the PC + enforces its own scope entries PLUS common's (on a Name conflict the -Scope + entry wins). OFF by default: a scope is enforced ALONE (self-sufficient). + Displays are self-sufficient and do NOT set this. Only a share-less non-display + PC that genuinely needs the fleet-wide common entries over HTTPS turns it on. + +.PARAMETER CommonScope + Name of the fleet-wide scope merged when -IncludeCommon is set (default + 'common'). Ignored without -IncludeCommon. + .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 +45,8 @@ param( [Parameter(Mandatory)] [string]$EnginePath, [string]$ShareManifestPath, [switch]$ShadowMode, + [switch]$IncludeCommon, + [string]$CommonScope = 'common', [string]$BaseUrl, [string]$ApiToken, [string]$LogFile = "C:\Logs\Shopfloor\shopdb-enforce-$(Get-Date -Format yyyyMMdd).log" @@ -46,6 +59,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 +91,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 +123,50 @@ 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 { + # Optional common-scope inheritance (OFF by default; displays are + # self-sufficient). Only when -IncludeCommon is set do we fetch the + # fleet-wide common scope (best-effort, same fail-safe cache) and merge + # it in with the pctype winning on conflict. Skipped when this run IS + # the common scope. + $manifestToMerge = $sync.Path + if ($IncludeCommon -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 diff --git a/plugins/geenforce/client/ShopdbEnforceClient.psm1 b/plugins/geenforce/client/ShopdbEnforceClient.psm1 index c9d26f06..b5df9b9b 100644 --- a/plugins/geenforce/client/ShopdbEnforceClient.psm1 +++ b/plugins/geenforce/client/ShopdbEnforceClient.psm1 @@ -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=; Skipped=; Failed=; Filtered=; + EnforcerVersion=; + 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 diff --git a/plugins/geenforce/seed_display_scope.py b/plugins/geenforce/seed_display_scope.py new file mode 100644 index 00000000..a257aafa --- /dev/null +++ b/plugins/geenforce/seed_display_scope.py @@ -0,0 +1,264 @@ +"""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. + +Self-sufficient: the display scope carries EVERYTHING a display enforces and +does NOT inherit the fleet-wide 'common' scope. Displays run the enforcer with +common-merge off (the client default), so common's SMB-backed fleet entries +never reach a share-less display. This keeps the display path simple and needs +no common-payload repackaging. + +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' +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. Self-sufficient: displays do NOT inherit the common ' + 'scope.'), + '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, + } diff --git a/plugins/geenforce/service.py b/plugins/geenforce/service.py index 8476760f..eff69f2c 100644 --- a/plugins/geenforce/service.py +++ b/plugins/geenforce/service.py @@ -8,6 +8,7 @@ Kept out of the CLI and routes so both share one implementation: """ import hashlib +import json import os import tempfile from datetime import datetime, timezone @@ -288,6 +289,34 @@ def blob_path(sha256): return os.path.join(_payload_dir(), sha256) +def blob_referenced_by_scopes(sha256, scopenames): + """True when any of these scopes' CURRENT published manifest references + sha256 as an entry payload. + + Backs the resource-bound token check on GET /payload: a token pinned to its + own scope(s) may only pull blobs those scopes actually ship, not any blob by + hash. Empty scopenames -> False (a bound-but-empty token reaches nothing). + """ + if not scopenames: + return False + rows = db.session.query(ManifestPublishedVersion).join( + ManifestScope, + ManifestPublishedVersion.scopeid == ManifestScope.scopeid, + ).filter( + ManifestScope.scopename.in_(list(scopenames)), + ManifestPublishedVersion.iscurrent == True, # noqa: E712 + ).all() + for row in rows: + try: + doc = json.loads(row.manifestjson) + except (ValueError, TypeError): + continue + for entry in doc.get('Applications', []) or []: + if entry.get('PayloadSha256') == sha256: + return True + return False + + def store_blob(rawbytes, filename, contenttype=None): """Store bytes in the content-addressed payload store; return the sha256. diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index 98f357a0..877244ee 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -380,7 +380,7 @@ def _alert_team_webhook(): if not team_id: return None try: - from shopdb.core.models import SupportTeam + from shopdb.api import SupportTeam team = db.session.get(SupportTeam, int(team_id)) except (ValueError, TypeError): return None diff --git a/plugins/printers/frontend/routes.js b/plugins/printers/frontend/routes.js index 5a8796ca..c89165a0 100644 --- a/plugins/printers/frontend/routes.js +++ b/plugins/printers/frontend/routes.js @@ -20,6 +20,12 @@ export default [ component: () => import('./views/ZabbixSettings.vue'), meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' } }, + { + path: 'settings/printer-alerts', + name: 'printer-alerts', + component: () => import('./views/PrinterAlertsSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' } + }, { path: 'printers', name: 'printers', diff --git a/plugins/printers/frontend/views/PrinterAlertsSettings.vue b/plugins/printers/frontend/views/PrinterAlertsSettings.vue new file mode 100644 index 00000000..c4b6de99 --- /dev/null +++ b/plugins/printers/frontend/views/PrinterAlertsSettings.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/plugins/printers/migrations/versions/0002_printersupplyalerts.py b/plugins/printers/migrations/versions/0002_printersupplyalerts.py new file mode 100644 index 00000000..e563e2ca --- /dev/null +++ b/plugins/printers/migrations/versions/0002_printersupplyalerts.py @@ -0,0 +1,47 @@ +"""printers: printersupplyalerts table (toner alert crossing state). + +Stores the last tier alerted per (printer, supply) so the poller fires once +on a downward crossing and re-arms after a refill. Idempotent create. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'printers0002supplyalerts' +down_revision = 'printers0001anchor' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + if 'printersupplyalerts' in inspector.get_table_names(): + return + op.create_table( + 'printersupplyalerts', + sa.Column('printersupplyalertid', sa.Integer(), primary_key=True), + sa.Column('printerid', sa.Integer(), nullable=False), + sa.Column('supplykey', sa.String(length=64), nullable=False), + sa.Column('lasttier', sa.String(length=16), nullable=False, + server_default='ok'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False, + server_default=sa.true()), + sa.ForeignKeyConstraint(['printerid'], ['printers.printerid'], + ondelete='CASCADE'), + sa.UniqueConstraint('printerid', 'supplykey', + name='uq_printersupplyalert_printer_key'), + ) + op.create_index('ix_printersupplyalerts_printerid', + 'printersupplyalerts', ['printerid']) + + +def downgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + if 'printersupplyalerts' not in inspector.get_table_names(): + return + op.drop_table('printersupplyalerts') diff --git a/plugins/printers/models/__init__.py b/plugins/printers/models/__init__.py index 0c64ef5b..863a0720 100644 --- a/plugins/printers/models/__init__.py +++ b/plugins/printers/models/__init__.py @@ -8,12 +8,14 @@ from .model_supply import ( # data-driven model -> toner/drum/waste mapping SUPPLY_COLORS, CAPACITY_TIERS, ) +from .supply_alert import PrinterSupplyAlert # per-printer toner alert state __all__ = [ 'Printer', 'PrinterType', 'PrinterDriver', 'ModelSupply', + 'PrinterSupplyAlert', 'SUPPLY_TYPES', 'SUPPLY_COLORS', 'CAPACITY_TIERS', diff --git a/plugins/printers/models/supply_alert.py b/plugins/printers/models/supply_alert.py new file mode 100644 index 00000000..de7a5a61 --- /dev/null +++ b/plugins/printers/models/supply_alert.py @@ -0,0 +1,40 @@ +"""Per-printer supply alert state. + +The supply poller (flask printers check-toner-alerts) is stateless: it reads +live levels from Zabbix each run. To alert once per downward crossing (and +re-arm after a refill) it needs to remember the last tier it alerted for each +printer + supply. One row per (printerid, supplykey); supplykey is the toner +color (black/cyan/magenta/yellow) or the raw item name when color is unknown. +""" + +from shopdb.api import db, BaseModel + + +class PrinterSupplyAlert(BaseModel): + """Last-alerted tier for one printer supply. lasttier in ok/warning/critical.""" + + __tablename__ = 'printersupplyalerts' + + printersupplyalertid = db.Column(db.Integer, primary_key=True) + printerid = db.Column( + db.Integer, + db.ForeignKey('printers.printerid', ondelete='CASCADE'), + nullable=False, + index=True, + ) + supplykey = db.Column( + db.String(64), + nullable=False, + comment='Toner color, or Zabbix item name when color is unknown', + ) + lasttier = db.Column( + db.String(16), + nullable=False, + default='ok', + comment='Last tier alerted: ok, warning, or critical', + ) + + __table_args__ = ( + db.UniqueConstraint('printerid', 'supplykey', + name='uq_printersupplyalert_printer_key'), + ) diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py index 7198092a..153fa7ae 100644 --- a/plugins/printers/plugin.py +++ b/plugins/printers/plugin.py @@ -9,9 +9,11 @@ from flask import Flask, Blueprint import click from shopdb.plugins.base import BasePlugin, PluginMeta -from shopdb.api import db, AssetType +from shopdb.api import db, AssetType, Setting -from .models import Printer, PrinterType, ModelSupply, PrinterDriver +from .models import ( + Printer, PrinterType, ModelSupply, PrinterDriver, PrinterSupplyAlert +) from .api import printers_asset_bp from .services import ZabbixService @@ -76,6 +78,7 @@ class PrintersPlugin(BasePlugin): PrinterType, # printer type classification PrinterDriver, # driver links (SMB/HTTP) ModelSupply, # model -> toner/drum/waste part numbers + PrinterSupplyAlert, # per-printer toner alert crossing state ] def get_services(self) -> Dict[str, Type]: @@ -103,8 +106,41 @@ class PrintersPlugin(BasePlugin): with app.app_context(): self._ensure_asset_type() self._ensure_printer_types() + self._seed_settings() logger.info("Printers plugin installed") + def on_enable(self, app: Flask) -> None: + # Idempotent re-seed so settings added in later versions reach sites + # that installed earlier (enable runs on every upgrade cycle). + with app.app_context(): + self._seed_settings() + + def _seed_settings(self) -> None: + """Seed low-toner alert recipient settings (idempotent).""" + defaults = [ + ('printers_alert_email', '', 'string', + 'Comma-separated low-toner alert recipients; empty uses the ' + 'site alert_recipients'), + ('printers_alert_userids', '', 'string', + 'Comma-separated shopdb user ids whose account emails receive ' + 'low-toner alerts'), + ('printers_alert_roleids', '', 'string', + 'Comma-separated role ids; every active member of these roles ' + 'receives low-toner alerts'), + ('printers_alert_supportteamid', '', 'string', + 'Support team whose webhook receives low-toner alerts; empty ' + 'uses the site alert_webhook_url'), + ('printers_alert_warning_threshold', '5', 'integer', + 'Toner percent remaining at or below which a warning email fires'), + ('printers_alert_critical_threshold', '0', 'integer', + 'Toner percent remaining at or below which a critical email fires'), + ] + for key, value, valuetype, description in defaults: + if Setting.get(key) is None: + Setting.set(key, value, valuetype=valuetype, + category='printers', description=description) + db.session.commit() + def _ensure_asset_type(self) -> None: """Ensure printer asset type exists.""" existing = AssetType.query.filter_by(assettype='printer').first() @@ -151,6 +187,19 @@ class PrintersPlugin(BasePlugin): """Called when plugin is uninstalled.""" logger.info("Printers plugin uninstalled") + def get_settings_cards(self) -> List[dict]: + return [ + { + 'group': 'Printers', + 'to': '/settings/printer-alerts', + 'icon': 'bell', + 'title': 'Low-Toner Alerts', + 'description': 'Who gets warning (5%) and critical (0%) toner ' + 'emails, and which support team webhook', + 'position': 48, + }, + ] + def get_cli_commands(self) -> List: """Return CLI commands for this plugin.""" @@ -159,6 +208,22 @@ class PrintersPlugin(BasePlugin): """Printers plugin commands.""" pass + @printerscli.command('check-toner-alerts') + def checktoneralerts(): + """Poll Zabbix for all printers and email/webhook low-toner crossings. + + Run on a schedule (scheduled task / cron). Fires a warning at or + below 5 percent and a critical at 0 percent, once per crossing.""" + from flask import current_app + from .services import check_supplies + + with current_app.app_context(): + summary = check_supplies() + click.echo( + f"Toner poll: {summary['polled']}/{summary['printers']} " + f"printers reachable, {summary['alerts']} alert(s) sent, " + f"{summary['rearmed']} re-armed.") + @printerscli.command('check-supplies') @click.argument('ip') def checksupplies(ip): diff --git a/plugins/printers/services/__init__.py b/plugins/printers/services/__init__.py index 6a78fa9c..10cde9a9 100644 --- a/plugins/printers/services/__init__.py +++ b/plugins/printers/services/__init__.py @@ -6,7 +6,9 @@ from .supply_parts import ( derivesupplytype, derivecolor, lookupsupplies, + alerttier, ) +from .supply_alerts import check_supplies from .seed_supplies import seedsupplies __all__ = [ @@ -15,5 +17,7 @@ __all__ = [ 'derivesupplytype', 'derivecolor', 'lookupsupplies', + 'alerttier', + 'check_supplies', 'seedsupplies', ] diff --git a/plugins/printers/services/supply_alerts.py b/plugins/printers/services/supply_alerts.py new file mode 100644 index 00000000..5f1ba948 --- /dev/null +++ b/plugins/printers/services/supply_alerts.py @@ -0,0 +1,190 @@ +"""Low-toner email/webhook alerting. + +The report shows live levels; this fires an alert when a toner crosses a +threshold on its way DOWN. A warning email fires at or below 5 percent, a +critical email at 0 percent (empty). State lives in printersupplyalerts so an +alert fires once per crossing and re-arms only after a refill. + +Recipients mirror the printedparts pattern: a plugin-scoped set of shopdb +users + roles + a free-text email list, falling back to the site-wide +alert_recipients; the webhook routes to a chosen support team's webhook, +falling back to the site-wide alert_webhook_url. +""" + +import logging + +from shopdb.api import db, Setting + +from ..models import Printer, PrinterSupplyAlert +from .supply_parts import derivesupplytype, derivecolor, alerttier, TIER_RANK + +logger = logging.getLogger(__name__) + + +def alert_recipients(): + """Merge selected shopdb users' account emails with the free-text list. + + Empty result means fall back to the site-wide alert_recipients.""" + from shopdb.api import User, Role + recipients = [] + userids = (Setting.get('printers_alert_userids') or '').strip() + for rawid in userids.split(','): + rawid = rawid.strip() + if not rawid.isdigit(): + continue + user = db.session.get(User, int(rawid)) + if user and user.isactive and user.email: + recipients.append(user.email) + roleids = (Setting.get('printers_alert_roleids') or '').strip() + for rawid in roleids.split(','): + rawid = rawid.strip() + if not rawid.isdigit(): + continue + role = db.session.get(Role, int(rawid)) + if role: + for user in role.users: + if user.isactive and user.email: + recipients.append(user.email) + freetext = Setting.get('printers_alert_email') or '' + for addr in freetext.replace(';', ',').split(','): + addr = addr.strip() + if addr: + recipients.append(addr) + # de-dupe, preserve order + seen = set() + unique = [] + for addr in recipients: + low = addr.lower() + if low not in seen: + seen.add(low) + unique.append(addr) + return unique + + +def _threshold(key, default): + """Read a numeric threshold setting, tolerating blank/garbage values.""" + try: + raw = Setting.get(key) + return float(raw) if raw not in (None, '') else float(default) + except (ValueError, TypeError): + return float(default) + + +def site_alert_recipients(): + """Site-wide alert_recipients setting as a clean list (email fallback).""" + raw = Setting.get('alert_recipients') or '' + return [r.strip() for r in raw.replace(';', ',').split(',') if r.strip()] + + +def alert_team_webhook(): + """Webhook URL of the support team chosen for toner alerts, or None. + + printers_alert_supportteamid selects a SupportTeam; alerts route to that + team's webhook. None -> send_webhook uses the site-wide default.""" + team_id = Setting.get('printers_alert_supportteamid') + if not team_id: + return None + try: + from shopdb.api import SupportTeam + team = db.session.get(SupportTeam, int(team_id)) + except (ValueError, TypeError): + return None + return (team.webhookurl or None) if team else None + + +def _printer_ipaddress(printer): + """Primary (else any) communication IP for a printer's asset, or None.""" + from shopdb.api import Communication + asset = printer.asset + if not asset: + return None + comm = Communication.query.filter_by( + assetid=asset.assetid, isprimary=True).first() \ + or Communication.query.filter_by(assetid=asset.assetid).first() + return comm.ipaddress if comm else None + + +def _fire_alert(printer, color, remaining, tier): + """Best-effort email + webhook for one toner crossing. Never raises.""" + from shopdb.api import send_email, send_webhook + name = printer.asset.name or printer.asset.assetnumber if printer.asset \ + else f'Printer {printer.printerid}' + label = 'CRITICAL - empty' if tier == 'critical' else 'Low' + subject = f'{label} toner: {name} ({color}) - {remaining:.0f}% remaining' + base = (Setting.get('site_base_url') or '').rstrip('/') + link = f'{base}/printers/{printer.printerid}' if base else '' + link_html = f'

View {name}

' if link else '' + html = (f'

{name} {color} toner is at ' + f'{remaining:.0f}% ({tier}).

{link_html}') + webhook_text = f'{name} {color} toner at {remaining:.0f}% ({tier})' + try: + send_webhook(subject, webhook_text, url=alert_team_webhook()) + recipients = alert_recipients() or site_alert_recipients() + if recipients: + send_email(recipients, subject, html) + except Exception: + logger.exception('Toner alert failed for printer %s', printer.printerid) + + +def check_supplies(service=None): + """Poll Zabbix for all active printers' toner and alert on crossings. + + Returns a summary dict. Intended to run on a schedule (a scheduled task or + cron calling `flask printers check-toner-alerts`).""" + from shopdb.api import Asset + from .zabbix_service import ZabbixService + + service = service or ZabbixService() + summary = {'printers': 0, 'polled': 0, 'alerts': 0, 'rearmed': 0} + if not service.isconfigured: + logger.warning('Zabbix not configured; skipping toner alert poll') + return summary + + warning_threshold = _threshold('printers_alert_warning_threshold', 5) + critical_threshold = _threshold('printers_alert_critical_threshold', 0) + + printers = db.session.query(Printer).join(Asset).filter( + Asset.isactive == True).all() # noqa: E712 + for printer in printers: + summary['printers'] += 1 + ipaddress = _printer_ipaddress(printer) + if not ipaddress or ipaddress == 'USB': + continue + supplies = service.getsuppliesbyip(ipaddress) + if not supplies: + continue + summary['polled'] += 1 + for supply in supplies: + supplyname = supply.get('name', '') + if derivesupplytype(supplyname) != 'toner': + continue + try: + remaining = float(supply.get('level', 0)) + except (TypeError, ValueError): + continue + color = supply.get('color') or derivecolor(supplyname) + supplykey = color if color and color != 'none' else supplyname + supplykey = supplykey[:64] + tier = alerttier(remaining, warning_threshold, critical_threshold) + + row = PrinterSupplyAlert.query.filter_by( + printerid=printer.printerid, supplykey=supplykey).first() + prior = row.lasttier if row else 'ok' + if tier == prior: + continue + if row is None: + row = PrinterSupplyAlert( + printerid=printer.printerid, supplykey=supplykey, + lasttier=tier) + db.session.add(row) + else: + row.lasttier = tier + + if TIER_RANK[tier] > TIER_RANK[prior]: + _fire_alert(printer, color, remaining, tier) + summary['alerts'] += 1 + else: + summary['rearmed'] += 1 + + db.session.commit() + return summary diff --git a/plugins/printers/services/supply_parts.py b/plugins/printers/services/supply_parts.py index 178b109a..8ba7c278 100644 --- a/plugins/printers/services/supply_parts.py +++ b/plugins/printers/services/supply_parts.py @@ -10,10 +10,34 @@ and reading the matching part numbers out of the database. from typing import Dict, List, Optional -# alert thresholds (percent remaining) +# display thresholds (percent remaining) for the ok/low/critical report badge CRITICAL_THRESHOLD = 5 LOW_THRESHOLD = 10 +# toner email-alert tiers (percent remaining). Distinct from the display +# badge above: a warning email fires at or below TONER_WARNING_THRESHOLD, a +# critical email at or below TONER_CRITICAL_THRESHOLD (empty). +TONER_WARNING_THRESHOLD = 5 +TONER_CRITICAL_THRESHOLD = 0 + + +def alerttier(remaining: float, warning: float = TONER_WARNING_THRESHOLD, + critical: float = TONER_CRITICAL_THRESHOLD) -> str: + """Map a toner percent-remaining to an email-alert tier. + + critical at or below the critical threshold (default 0 = empty), warning at + or below the warning threshold (default 5), else ok. Both are configurable + (printers_alert_critical_threshold / printers_alert_warning_threshold).""" + if remaining <= critical: + return 'critical' + if remaining <= warning: + return 'warning' + return 'ok' + + +# tier severity rank; an alert fires only when the rank increases (worsens) +TIER_RANK = {'ok': 0, 'warning': 1, 'critical': 2} + def derivesupplytype(name: str) -> str: """Map a Zabbix item name to a supply type.""" diff --git a/scripts/build-offline-bundle.ps1 b/scripts/build-offline-bundle.ps1 new file mode 100644 index 00000000..5e613675 --- /dev/null +++ b/scripts/build-offline-bundle.ps1 @@ -0,0 +1,108 @@ +<# +.SYNOPSIS + Build the shopdb-flask image on a CONNECTED box and bundle it (plus the MySQL + image) into one tarball for transfer to an air-gapped site. + +.DESCRIPTION + Air-gapped sites cannot pip install / npm ci / docker pull. This builds the + fully self-contained application image where connectivity exists, pulls the + MySQL image the stack needs, and `docker save`s both into a single gzipped + tarball with a SHA-256 checksum. Carry the .tar.gz to the site and follow + docs/DEPLOY-AIRGAP.md (docker load + docker-compose.airgap.yml). + + Run from the repo root. Requires Docker Desktop. The connected build box needs + clean access to Docker Hub + PyPI + npm. If it is behind GE Zscaler and the + build fails on pip/npm SSL (CERTIFICATE_VERIFY_FAILED), build from a box with + clean internet, or trust the corp root CA in the build - see + docs/DEPLOY-AIRGAP.md "Building behind Zscaler". + +.PARAMETER Version + Image tag for shopdb-flask (default 0.7.0). This is the IMAGE_TAG the site + sets in its .env so docker-compose.airgap.yml runs the matching image. + +.PARAMETER MysqlImage + MySQL image the stack runs (default mysql:8.0). Must match db.image in + docker-compose.airgap.yml. + +.PARAMETER OutDir + Where to write the tarball + checksum (default the repo root). + +.PARAMETER SkipBuild + Reuse an already-built shopdb-flask: image; only pull + save. + +.EXAMPLE + pwsh scripts/build-offline-bundle.ps1 -Version 0.7.0 +#> +[CmdletBinding()] +param( + [string]$Version = '0.7.0', + [string]$MysqlImage = 'mysql:8.0', + [string]$OutDir = '.', + [switch]$SkipBuild +) + +$ErrorActionPreference = 'Stop' + +function Assert-LastExit([string]$What) { + if ($LASTEXITCODE -ne 0) { throw "$What failed (exit $LASTEXITCODE)" } +} + +# Fail early if docker is missing rather than midway through a long build. +if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + throw 'docker not found on PATH. Install Docker Desktop and retry.' +} + +$appImage = "shopdb-flask:$Version" +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +Push-Location $repoRoot +try { + if (-not $SkipBuild) { + Write-Host "==> Building $appImage" -ForegroundColor Cyan + docker build -t $appImage . + Assert-LastExit 'docker build' + } else { + Write-Host "==> Skipping build; reusing $appImage" -ForegroundColor Yellow + } + + Write-Host "==> Pulling $MysqlImage" -ForegroundColor Cyan + docker pull $MysqlImage + Assert-LastExit 'docker pull' + + if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null } + $stem = "shopdb-stack-$Version" + $tar = Join-Path $OutDir "$stem.tar" + $gz = Join-Path $OutDir "$stem.tar.gz" + + Write-Host "==> Saving $appImage + $MysqlImage" -ForegroundColor Cyan + docker save -o $tar $appImage $MysqlImage + Assert-LastExit 'docker save' + + Write-Host "==> Compressing to $gz" -ForegroundColor Cyan + $inStream = [System.IO.File]::OpenRead($tar) + $outStream = [System.IO.File]::Create($gz) + $gzip = New-Object System.IO.Compression.GzipStream($outStream, [System.IO.Compression.CompressionMode]::Compress) + try { + $inStream.CopyTo($gzip) + } finally { + $gzip.Dispose(); $outStream.Dispose(); $inStream.Dispose() + } + Remove-Item $tar + + $hash = (Get-FileHash $gz -Algorithm SHA256).Hash.ToLower() + $sumFile = "$gz.sha256" + "$hash $stem.tar.gz" | Set-Content -Path $sumFile -NoNewline + $sizeMb = [math]::Round((Get-Item $gz).Length / 1MB, 1) + + Write-Host '' + Write-Host "Bundle ready: $gz ($sizeMb MB)" -ForegroundColor Green + Write-Host "SHA-256: $hash" + Write-Host "Checksum: $sumFile" + Write-Host '' + Write-Host 'At the air-gapped site (verify the checksum first):' + Write-Host " docker load -i $stem.tar.gz" + Write-Host " # set IMAGE_TAG=$Version in .env, then:" + Write-Host ' docker compose -f docker-compose.airgap.yml up -d' +} +finally { + Pop-Location +} diff --git a/shopdb/__init__.py b/shopdb/__init__.py index 24267741..ecb9b91b 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -36,7 +36,7 @@ from .plugins import plugin_manager # 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.14.0' +__contract_version__ = '0.15.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 57cb26a1..056b25a2 100644 --- a/shopdb/api/__init__.py +++ b/shopdb/api/__init__.py @@ -46,6 +46,7 @@ from shopdb.core.models import ( RelationshipType, User, Role, + SupportTeam, ) # Response + pagination helpers for plugin API blueprints @@ -63,7 +64,9 @@ 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 +from shopdb.utils.apitoken_auth import ( + service_token_authorized, authorized_service_token, +) # Import-mode helpers: preserve legacy timestamps during a bulk data import from shopdb.utils.import_mode import ( @@ -263,6 +266,8 @@ __all__ = [ 'require_permission', 'require_role', 'service_token_authorized', + 'authorized_service_token', + 'SupportTeam', # Import-mode helpers 'apply_import_timestamps', 'import_mode_active', diff --git a/shopdb/core/api/apitokens.py b/shopdb/core/api/apitokens.py index c06b5c79..484bf843 100644 --- a/shopdb/core/api/apitokens.py +++ b/shopdb/core/api/apitokens.py @@ -45,6 +45,24 @@ def _validate_scopes(scopes, owner): return scopes, None +def _validate_resourcescopes(resourcescopes): + """Validate a resourcescopes payload. Return (list_or_none, error_response). + + resourcescopes is a DIFFERENT axis from scopes: it pins the token to a set + of resource names (today geenforce manifest scope names, e.g. + gea-shopfloor-display), not permission names, so there is no catalog to + check against - a plugin owns the meaning. None/absent = unrestricted. + """ + if resourcescopes is None: + return None, None + if not isinstance(resourcescopes, list) \ + or not all(isinstance(s, str) for s in resourcescopes): + return None, error_response( + ErrorCodes.VALIDATION_ERROR, + 'resourcescopes must be a list of resource-scope names') + return [s.strip() for s in resourcescopes if s.strip()], None + + @apitokens_bp.route('', methods=['GET']) @jwt_required() def list_apitokens(): @@ -93,6 +111,11 @@ def create_apitoken(): if scope_error is not None: return scope_error + resourcescopelist, resource_error = _validate_resourcescopes( + data.get('resourcescopes')) + if resource_error is not None: + return resource_error + secret = ApiToken.generate_secret() token = ApiToken( userid=current_user.userid, @@ -102,6 +125,7 @@ def create_apitoken(): expiresat=expiresat, ) token.scopelist = scopelist + token.resourcescopelist = resourcescopelist db.session.add(token) db.session.flush() @@ -147,6 +171,12 @@ def update_apitoken(tokenid: int): if scope_error is not None: return scope_error token.scopelist = scopelist + if 'resourcescopes' in data: + resourcescopelist, resource_error = _validate_resourcescopes( + data.get('resourcescopes')) + if resource_error is not None: + return resource_error + token.resourcescopelist = resourcescopelist db.session.commit() return success_response(token.to_dict(), message='Token updated') diff --git a/shopdb/core/models/apitoken.py b/shopdb/core/models/apitoken.py index 86ff5019..9d5a39ec 100644 --- a/shopdb/core/models/apitoken.py +++ b/shopdb/core/models/apitoken.py @@ -53,6 +53,13 @@ class ApiToken(BaseModel): # authority). A scoped token grants ONLY these, intersected with what the # owner holds, and suspends the admin bypass. See scopelist below. scopes = db.Column(db.Text, nullable=True) + # JSON array of RESOURCE-scope names (a different axis from `scopes`, which + # is what the token may DO). Today these are geenforce manifest scope names + # (e.g. gea-shopfloor-display): a resource-bound geenforce.fetch token may + # only pull those scopes' manifests and only blobs those manifests + # reference. NULL = unrestricted (any resource), for back-compat. See + # resourcescopelist below. + resourcescopes = db.Column(db.Text, nullable=True) user = db.relationship('User', backref=db.backref('apitokens', lazy='dynamic')) @@ -98,6 +105,25 @@ class ApiToken(BaseModel): else: self.scopes = json.dumps(list(names)) + @property + def resourcescopelist(self): + """Parsed resource-scope names, or None when the token is unrestricted.""" + if self.resourcescopes is None: + return None + try: + value = json.loads(self.resourcescopes) + except (ValueError, TypeError): + return None + return value if isinstance(value, list) else None + + @resourcescopelist.setter + def resourcescopelist(self, names): + """Store a resource-scope list, or None to clear the restriction.""" + if names is None: + self.resourcescopes = None + else: + self.resourcescopes = json.dumps(list(names)) + @staticmethod def unknown_scope_names(names) -> list: """Return the subset of names that are not in the permission catalog. @@ -119,6 +145,7 @@ class ApiToken(BaseModel): 'expiresat': self.expiresat.isoformat() + 'Z' if self.expiresat else None, 'lastusedat': self.lastusedat.isoformat() + 'Z' if self.lastusedat else None, 'scopes': self.scopelist, + 'resourcescopes': self.resourcescopelist, 'isactive': self.isactive, 'isexpired': self.is_expired, 'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None, diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py index 076f72ec..2ae8698a 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -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'), diff --git a/shopdb/utils/apitoken_auth.py b/shopdb/utils/apitoken_auth.py index 99ae203e..ceb6b630 100644 --- a/shopdb/utils/apitoken_auth.py +++ b/shopdb/utils/apitoken_auth.py @@ -76,16 +76,13 @@ 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. +def authorized_service_token(scope): + """Return the ApiToken authorizing this request for `scope`, or None. - 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. + Same checks as service_token_authorized (managed token scoped for `scope`, + active owner holding the permission), but hands back the token itself so a + caller can read its resource binding (token.resourcescopelist) without + reaching into core token internals. Touches lastusedat on success. """ from shopdb.core.models import User @@ -99,15 +96,29 @@ def service_token_authorized(scope): if tokenid is not None: token = db.session.get(ApiToken, tokenid) if token is None: - return False + return None scopelist = token.scopelist if not scopelist or scope not in scopelist: - return False + return None user = db.session.get(User, token.userid) if user is None or not user.isactive or not user.haspermission(scope): - return False + return None touch_apitoken_lastused(token) - return True + return token + + +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. + """ + return authorized_service_token(scope) is not None def install_apitoken_auth(app): diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index 8113a211..e9719d80 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -46,7 +46,11 @@ 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' +# 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' diff --git a/tests/test_plugins/test_geenforce_blob_parity.py b/tests/test_plugins/test_geenforce_blob_parity.py new file mode 100644 index 00000000..ba7e0bdb --- /dev/null +++ b/tests/test_plugins/test_geenforce_blob_parity.py @@ -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 diff --git a/tests/test_plugins/test_geenforce_display_seed.py b/tests/test_plugins/test_geenforce_display_seed.py new file mode 100644 index 00000000..9d58fb7c --- /dev/null +++ b/tests/test_plugins/test_geenforce_display_seed.py @@ -0,0 +1,109 @@ +"""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 + # A plain runtime scope, not the common scope. Displays are self-sufficient + # and do NOT inherit common. + 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') diff --git a/tests/test_plugins/test_geenforce_payload_hardening.py b/tests/test_plugins/test_geenforce_payload_hardening.py new file mode 100644 index 00000000..1d7bc508 --- /dev/null +++ b/tests/test_plugins/test_geenforce_payload_hardening.py @@ -0,0 +1,135 @@ +"""GE-Enforce payload-download hardening: rate limit + served-size ceiling. + +GET /api/geenforce/payload/ 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 diff --git a/tests/test_plugins/test_geenforce_report_contract.py b/tests/test_plugins/test_geenforce_report_contract.py new file mode 100644 index 00000000..ed6fff3e --- /dev/null +++ b/tests/test_plugins/test_geenforce_report_contract.py @@ -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 diff --git a/tests/test_plugins/test_geenforce_resource_binding.py b/tests/test_plugins/test_geenforce_resource_binding.py new file mode 100644 index 00000000..41b11abc --- /dev/null +++ b/tests/test_plugins/test_geenforce_resource_binding.py @@ -0,0 +1,128 @@ +"""Resource-scope binding on geenforce fetch tokens. + +A geenforce.fetch token MAY be pinned to specific manifest scopes +(ApiToken.resourcescopes). A bound token can only pull those scopes' manifests +and only the blobs those scopes reference; an unbound token (NULL) keeps the +original behavior. This is what lets a fleet-wide display key be handed out +without exposing every scope + every blob in the content store. +""" + +import hashlib + +from plugins.geenforce import service + +DISPLAY = 'gea-shopfloor-display' +OTHER = 'gea-shopfloor-cmm' + +KIOSK_BYTES = b'kiosk-installer-payload-bytes' +KIOSK_SHA = hashlib.sha256(KIOSK_BYTES).hexdigest() + + +def _publish_display_scope(app): + """Publish a display scope whose one entry references KIOSK_SHA over http.""" + with app.app_context(): + service.store_blob(KIOSK_BYTES, 'kiosk.exe', 'application/octet-stream') + manifest = { + 'Version': '2.0', + 'Applications': [ + {'Name': 'Kiosk', 'Type': 'EXE', 'Installer': 'apps/kiosk.exe', + 'DetectionMethod': 'Always', + 'PayloadSource': 'http', 'PayloadSha256': KIOSK_SHA}, + ], + } + service.replace_scope_draft(DISPLAY, 'runtime', manifest) + service.publish_scope(DISPLAY, 'runtime', notes='display') + # A second, unrelated scope + blob the display token must NOT reach. + service.store_blob(b'secret-other-scope-bytes', 'other.exe') + service.replace_scope_draft(OTHER, 'runtime', { + 'Version': '2.6', + 'Applications': [{'Name': 'Beta', 'Type': 'PS1', + 'Script': 'scripts/beta.ps1', + 'DetectionMethod': 'Always'}]}) + service.publish_scope(OTHER, 'runtime', notes='other') + service.db.session.commit() + + +def _mint(client, auth_headers, resourcescopes=None): + payload = {'name': 'display svc', 'scopes': ['geenforce.fetch']} + if resourcescopes is not None: + payload['resourcescopes'] = resourcescopes + resp = client.post('/api/apitokens', json=payload, headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data'] + + +def _key(client, auth_headers, resourcescopes=None): + return {'X-API-Key': _mint(client, auth_headers, resourcescopes)['secret']} + + +# -- manifest binding --------------------------------------------------------- + +def test_bound_token_gets_its_own_scope(client, app, db, auth_headers): + _publish_display_scope(app) + headers = _key(client, auth_headers, resourcescopes=[DISPLAY]) + resp = client.get(f'/api/geenforce/manifest?pctype={DISPLAY}', headers=headers) + assert resp.status_code == 200 + + +def test_bound_token_forbidden_other_scope(client, app, db, auth_headers): + _publish_display_scope(app) + headers = _key(client, auth_headers, resourcescopes=[DISPLAY]) + resp = client.get(f'/api/geenforce/manifest?pctype={OTHER}', headers=headers) + assert resp.status_code == 403 + + +def test_unbound_token_reaches_any_scope(client, app, db, auth_headers): + _publish_display_scope(app) + headers = _key(client, auth_headers) # no resourcescopes -> unrestricted + assert client.get(f'/api/geenforce/manifest?pctype={DISPLAY}', + headers=headers).status_code == 200 + assert client.get(f'/api/geenforce/manifest?pctype={OTHER}', + headers=headers).status_code == 200 + + +# -- payload binding ---------------------------------------------------------- + +def test_bound_token_gets_referenced_blob(client, app, db, auth_headers): + _publish_display_scope(app) + headers = _key(client, auth_headers, resourcescopes=[DISPLAY]) + resp = client.get(f'/api/geenforce/payload/{KIOSK_SHA}', headers=headers) + assert resp.status_code == 200 + assert resp.data == KIOSK_BYTES + + +def test_bound_token_404_on_unreferenced_blob(client, app, db, auth_headers): + _publish_display_scope(app) + # A blob the display scope does NOT reference (belongs to OTHER). + other_sha = hashlib.sha256(b'secret-other-scope-bytes').hexdigest() + headers = _key(client, auth_headers, resourcescopes=[DISPLAY]) + resp = client.get(f'/api/geenforce/payload/{other_sha}', headers=headers) + assert resp.status_code == 404 + + +def test_unbound_token_gets_any_blob(client, app, db, auth_headers): + _publish_display_scope(app) + other_sha = hashlib.sha256(b'secret-other-scope-bytes').hexdigest() + headers = _key(client, auth_headers) + assert client.get(f'/api/geenforce/payload/{other_sha}', + headers=headers).status_code == 200 + + +# -- token model / API -------------------------------------------------------- + +def test_create_persists_resourcescopes(client, app, db, auth_headers): + data = _mint(client, auth_headers, resourcescopes=[DISPLAY]) + assert data['resourcescopes'] == [DISPLAY] + + +def test_resourcescopes_null_by_default(client, app, db, auth_headers): + data = _mint(client, auth_headers) + assert data['resourcescopes'] is None + + +def test_bad_resourcescopes_rejected(client, app, db, auth_headers): + resp = client.post('/api/apitokens', + json={'name': 'x', 'scopes': ['geenforce.fetch'], + 'resourcescopes': 'not-a-list'}, + headers=auth_headers) + assert resp.status_code == 400 diff --git a/tests/test_plugins/test_printers_supply_alerts.py b/tests/test_plugins/test_printers_supply_alerts.py new file mode 100644 index 00000000..0bc07aa8 --- /dev/null +++ b/tests/test_plugins/test_printers_supply_alerts.py @@ -0,0 +1,165 @@ +"""Low-toner alert tests. + +Invariants: the alert tier boundaries (5% warning, 0% critical), the +fire-once-per-crossing state machine (re-arm on refill), toner-only scope, +and recipient routing to a support team's webhook. +""" + +import pytest +from unittest.mock import patch + +from shopdb.api import db +from shopdb.core.models import AssetType, Setting, SupportTeam +from plugins.printers.models import PrinterSupplyAlert +from plugins.printers.services.supply_parts import alerttier +from plugins.printers.services.supply_alerts import check_supplies + + +class FakeZabbix: + """Stand-in Zabbix service: canned supplies keyed by IP.""" + + def __init__(self, byip): + self._byip = byip + self.isconfigured = True + + def getsuppliesbyip(self, ip): + return self._byip.get(ip, []) + + +@pytest.fixture +def printer_assettype(db): + at = AssetType(assettype='printer', pluginname='printers', + tablename='printers', description='Printers') + db.session.add(at) + db.session.commit() + return at + + +@pytest.fixture +def ip_comtype(db): + from shopdb.core.models import CommunicationType + row = CommunicationType.query.filter_by(comtype='IP').first() + if not row: + row = CommunicationType(comtype='IP', description='IP address') + db.session.add(row) + db.session.commit() + return row + + +@pytest.fixture +def network_printer(client, auth_headers, printer_assettype, ip_comtype): + """A network printer at 10.0.0.5 with a black toner, via the real API.""" + resp = client.post('/api/printers', json={ + 'assetnumber': 'CSF01-LaserJet', + 'hostname': 'wjprn05', + 'ipaddress': '10.0.0.5', + }, headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return '10.0.0.5' + + +def test_alerttier_boundaries(): + assert alerttier(50) == 'ok' + assert alerttier(6) == 'ok' + assert alerttier(5) == 'warning' + assert alerttier(1) == 'warning' + assert alerttier(0) == 'critical' + + +def _toner(level, color='black'): + return {'name': f'{color} toner', 'level': level, 'color': color} + + +def test_warning_crossing_fires_once_and_rearms(app, db, network_printer): + ip = network_printer + with app.app_context(): + # 20% -> ok, no alert + with patch('shopdb.api.send_webhook') as hook1, \ + patch('shopdb.api.send_email'): + check_supplies(FakeZabbix({ip: [_toner(20)]})) + assert not hook1.called + + # 4% -> warning, alert fires + with patch('shopdb.api.send_webhook') as hook2, \ + patch('shopdb.api.send_email'): + check_supplies(FakeZabbix({ip: [_toner(4)]})) + assert hook2.called + + # still 3% -> already warning, no repeat + with patch('shopdb.api.send_webhook') as hook3, \ + patch('shopdb.api.send_email'): + check_supplies(FakeZabbix({ip: [_toner(3)]})) + assert not hook3.called + + # refill to 80% -> re-arm (state back to ok), no alert + with patch('shopdb.api.send_webhook') as hook4, \ + patch('shopdb.api.send_email'): + check_supplies(FakeZabbix({ip: [_toner(80)]})) + assert not hook4.called + row = PrinterSupplyAlert.query.filter_by(supplykey='black').first() + assert row.lasttier == 'ok' + + # drop to 2% again -> warning fires again after the re-arm + with patch('shopdb.api.send_webhook') as hook5, \ + patch('shopdb.api.send_email'): + check_supplies(FakeZabbix({ip: [_toner(2)]})) + assert hook5.called + + +def test_direct_to_critical_fires_critical(app, db, network_printer): + ip = network_printer + with app.app_context(): + with patch('shopdb.api.send_webhook') as hook, \ + patch('shopdb.api.send_email'): + check_supplies(FakeZabbix({ip: [_toner(0)]})) + assert hook.called + subject = hook.call_args.args[0] + assert 'CRITICAL' in subject + row = PrinterSupplyAlert.query.filter_by(supplykey='black').first() + assert row.lasttier == 'critical' + + +def test_alerttier_respects_custom_thresholds(): + # warning at <=20, critical at <=8 + assert alerttier(25, warning=20, critical=8) == 'ok' + assert alerttier(20, warning=20, critical=8) == 'warning' + assert alerttier(8, warning=20, critical=8) == 'critical' + + +def test_custom_warning_threshold_setting_fires(app, db, network_printer): + ip = network_printer + with app.app_context(): + Setting.set('printers_alert_warning_threshold', '20') + # 15% would be 'ok' under the default 5% warning, but fires at 20% + with patch('shopdb.api.send_webhook') as hook, \ + patch('shopdb.api.send_email'): + check_supplies(FakeZabbix({ip: [_toner(15)]})) + assert hook.called + row = PrinterSupplyAlert.query.filter_by(supplykey='black').first() + assert row.lasttier == 'warning' + + +def test_non_toner_supply_ignored(app, db, network_printer): + ip = network_printer + with app.app_context(): + waste = {'name': 'waste toner box', 'level': 1, 'color': 'none'} + with patch('shopdb.api.send_email') as email, \ + patch('shopdb.api.send_webhook'): + check_supplies(FakeZabbix({ip: [waste]})) + assert not email.called + + +def test_routes_to_support_team_webhook(app, db, network_printer): + ip = network_printer + with app.app_context(): + team = SupportTeam(teamname='Print Team', + webhookurl='https://teams.example/print') + db.session.add(team) + db.session.commit() + Setting.set('printers_alert_supportteamid', str(team.supportteamid)) + + with patch('shopdb.api.send_webhook') as webhook, \ + patch('shopdb.api.send_email'): + check_supplies(FakeZabbix({ip: [_toner(3)]})) + assert webhook.called + assert webhook.call_args.kwargs['url'] == 'https://teams.example/print'