printers low-toner alerts; geenforce display readiness + token scope-binding; air-gap docker deploy kit

This commit is contained in:
2026-07-23 14:37:00 -04:00
parent 73b05e5b1c
commit c355eaae43
32 changed files with 2358 additions and 46 deletions

View File

@@ -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=

90
docker-compose.airgap.yml Normal file
View File

@@ -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-<version>.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 <user> <email> <password>
# 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:

View File

@@ -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')

View File

@@ -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/<sha256>', 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',

View File

@@ -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

View File

@@ -21,6 +21,43 @@
Set-StrictMode -Version Latest
function Set-ShopdbTls {
<#
Force TLS 1.2 for the process-wide ServicePointManager. Windows PowerShell
5.1 (what runs as SYSTEM on the display image) does not always negotiate
TLS 1.2 by default, so every network helper calls this first. Mirrors the
pattern in docs/COLLECTOR-INTEGRATION.md. Best-effort: never throws.
#>
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {}
}
function Get-ShopdbProperty {
<#
Read a property/key from either a hashtable or a PSCustomObject, matching
any of the given names case-insensitively. Returns $null when absent
instead of throwing under Set-StrictMode. The engine's per-entry outcomes
and summary may arrive as either shape, so all normalization goes through
this.
#>
param($InputObject, [Parameter(Mandatory)][string[]]$Names)
if ($null -eq $InputObject) { return $null }
if ($InputObject -is [System.Collections.IDictionary]) {
foreach ($wanted in $Names) {
foreach ($key in @($InputObject.Keys)) {
if ($key -is [string] -and $key -ieq $wanted) { return $InputObject[$key] }
}
}
return $null
}
$properties = $InputObject.PSObject.Properties
foreach ($wanted in $Names) {
foreach ($property in $properties) {
if ($property.Name -ieq $wanted) { return $property.Value }
}
}
return $null
}
function Get-ShopdbConfig {
param([string]$BaseUrl, [string]$ApiToken)
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
@@ -46,6 +83,7 @@ function Sync-ShopdbManifest {
[Parameter(Mandatory)] [hashtable]$Config,
[string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
)
Set-ShopdbTls
if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null }
$manifestPath = Join-Path $CacheDir "$Scope.json"
$etagPath = Join-Path $CacheDir "$Scope.etag"
@@ -80,7 +118,12 @@ function Sync-ShopdbManifest {
if (Test-Path $manifestPath) {
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-lastgood' }
}
return @{ Path = $null; Version = $null; Source = $null }
# Nothing cached (e.g. a fresh display): report why so the failure is not
# silent. Include the HTTP status (401 auth, TLS-trust surfaces as a
# non-status transport error) and the exception message.
$errText = $_.Exception.Message
if ($status) { $errText = "HTTP $status - $errText" }
return @{ Path = $null; Version = $null; Source = $null; Error = $errText }
}
# 304 without exception (some PS versions) -> use cache.
if (Test-Path $manifestPath) {
@@ -123,6 +166,7 @@ function Send-ShopdbReport {
#>
param([Parameter(Mandatory)][hashtable]$Config,
[Parameter(Mandatory)][hashtable]$Report)
Set-ShopdbTls
try {
$body = ($Report | ConvertTo-Json -Depth 6)
Invoke-RestMethod -Uri "$($Config.BaseUrl)/api/geenforce/report" `
@@ -137,14 +181,31 @@ function Send-ShopdbReport {
function New-ShopdbReport {
<#
Build a report payload from an engine summary. `Summary` is expected to
carry Installed/Skipped/Failed/Filtered counts and a Results list of
@{ Name; Action; SelfHealed; ExitCode; Message }. Shape the engine's own
per-entry outcomes into this at the call site.
carry Installed/Skipped/Failed/Filtered counts and a Results list whose
per-entry outcomes carry name/action and optionally selfhealed/exitcode/
message. The engine emits these in PascalCase (Name/Action/SelfHealed/
ExitCode/Message); the shopdb report contract is entirely lowercase, so
this function maps every per-entry key down to lowercase. Case is matched
case-insensitively, so a caller that already lowercased still works.
#>
param([string]$Hostname = $env:COMPUTERNAME,
[Parameter(Mandatory)][string]$Scope,
[int]$AppliedVersion,
[Parameter(Mandatory)][hashtable]$Summary)
$results = @(foreach ($entry in @($Summary.Results)) {
if ($null -eq $entry) { continue }
$mapped = @{
name = [string](Get-ShopdbProperty -InputObject $entry -Names 'name')
action = [string](Get-ShopdbProperty -InputObject $entry -Names 'action')
}
$selfHealed = Get-ShopdbProperty -InputObject $entry -Names 'selfhealed'
if ($null -ne $selfHealed) { $mapped['selfhealed'] = [bool]$selfHealed }
$exitCode = Get-ShopdbProperty -InputObject $entry -Names 'exitcode'
if ($null -ne $exitCode) { $mapped['exitcode'] = [int]$exitCode }
$message = Get-ShopdbProperty -InputObject $entry -Names 'message'
if ($message) { $mapped['message'] = [string]$message }
$mapped
})
return @{
hostname = $Hostname
scopename = $Scope
@@ -156,7 +217,7 @@ function New-ShopdbReport {
failed = [int]$Summary.Failed
filtered = [int]$Summary.Filtered
}
results = @($Summary.Results)
results = $results
}
}
@@ -173,6 +234,7 @@ function Get-ShopdbPayload {
[string]$Filename,
[string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
)
Set-ShopdbTls
$sha = $Sha256.Trim().ToLower()
$payloadDir = Join-Path $CacheDir 'payloads'
if (-not (Test-Path $payloadDir)) { New-Item -ItemType Directory -Path $payloadDir -Force | Out-Null }
@@ -241,6 +303,125 @@ function Resolve-ShopdbPayloads {
return $out
}
function Merge-ShopdbManifests {
<#
Merge the fleet-wide 'common' scope manifest into a pctype (display) scope
manifest and write the merged result to a sibling file. This mirrors how
the real GE-Enforce.ps1 applies scopes in order (common first, then the
pctype), except we produce a single merged manifest for the unchanged
engine to run once. Merge rules:
- Entries are keyed by Name (case-insensitive).
- common's unique entries come first, then all pctype entries, so
common's own apps enforce ahead of the pctype's, matching the real
script's common-then-pctype ordering.
- On a Name conflict the PCTYPE entry wins (the pctype override replaces
common's version, and keeps common's slot out of the list).
Returns the merged manifest path. If there is no common manifest, returns
the primary path unchanged. The merged manifest keeps the pctype
manifest's top-level Version (that is the version the display reports as
applied).
#>
param(
[Parameter(Mandatory)][string]$PrimaryManifestPath,
[string]$CommonManifestPath
)
if (-not $CommonManifestPath -or -not (Test-Path $CommonManifestPath)) { return $PrimaryManifestPath }
$primary = Get-Content -LiteralPath $PrimaryManifestPath -Raw | ConvertFrom-Json
$common = Get-Content -LiteralPath $CommonManifestPath -Raw | ConvertFrom-Json
$primaryApps = @($primary.Applications)
$commonApps = @($common.Applications)
$primaryNames = @{}
foreach ($app in $primaryApps) {
$name = [string]$app.Name
if ($name) { $primaryNames[$name.ToLower()] = $true }
}
# common entries the pctype does not override, then all pctype entries.
$merged = @()
foreach ($app in $commonApps) {
$name = [string]$app.Name
if ($name -and $primaryNames.ContainsKey($name.ToLower())) { continue }
$merged += $app
}
foreach ($app in $primaryApps) { $merged += $app }
$primary.Applications = $merged
$out = [System.IO.Path]::ChangeExtension($PrimaryManifestPath, '.merged.json')
($primary | ConvertTo-Json -Depth 20) | Set-Content -LiteralPath $out -Encoding UTF8
return $out
}
function ConvertTo-ShopdbSummary {
<#
Normalize whatever the engine returns into a well-formed summary hashtable
so New-ShopdbReport always has clean input, without assuming the engine was
fixed.
Expected engine contract (what a compliant engine returns):
@{ Installed=<int>; Skipped=<int>; Failed=<int>; Filtered=<int>;
EnforcerVersion=<string>;
Results=@( @{ Name; Action; SelfHealed; ExitCode; Message } ... ) }
This adapter tolerates any of:
- $null / empty -> a zeroed summary.
- a hashtable or PSCustomObject with those keys (any casing).
- an array / multiple emitted objects -> the last element that looks
like a summary (has any count or a Results list) is used.
- a bare return code (int) or unrecognized object -> a zeroed summary.
Counts are coerced to int; a missing EnforcerVersion is left for the caller
to default. Always returns a hashtable.
#>
param($EngineResult, [string]$DefaultEnforcerVersion = '2.6')
$zero = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0;
Results = @(); EnforcerVersion = $DefaultEnforcerVersion }
$candidate = $EngineResult
if ($candidate -is [System.Array]) {
$picked = $null
foreach ($item in $candidate) {
if ($null -eq $item) { continue }
$looksLikeSummary = $false
foreach ($key in @('Installed', 'Skipped', 'Failed', 'Filtered', 'Results')) {
if ($null -ne (Get-ShopdbProperty -InputObject $item -Names $key)) { $looksLikeSummary = $true; break }
}
if ($looksLikeSummary) { $picked = $item }
}
$candidate = $picked
}
if ($null -eq $candidate) { return $zero }
# A bare return code (int) or any object without the expected members reads
# as all-null through Get-ShopdbProperty below, which yields the zeroed
# summary - exactly the fail-open behavior we want for a non-compliant engine.
$results = Get-ShopdbProperty -InputObject $candidate -Names 'Results'
$enforcerVersion = [string](Get-ShopdbProperty -InputObject $candidate -Names 'EnforcerVersion')
if (-not $enforcerVersion) { $enforcerVersion = $DefaultEnforcerVersion }
$toInt = {
param($value)
$parsed = 0
if ($null -ne $value -and [int]::TryParse([string]$value, [ref]$parsed)) { return $parsed }
return 0
}
return @{
Installed = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Installed'))
Skipped = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Skipped'))
Failed = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Failed'))
Filtered = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Filtered', 'PCFiltered'))
Results = @($results)
EnforcerVersion = $enforcerVersion
}
}
Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, `
Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion, `
Get-ShopdbPayload, Resolve-ShopdbPayloads
Get-ShopdbPayload, Resolve-ShopdbPayloads, Merge-ShopdbManifests, `
ConvertTo-ShopdbSummary

View File

@@ -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,
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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',

View File

@@ -0,0 +1,192 @@
<template>
<div>
<div class="page-header">
<h2>Low-Toner Alerts</h2>
</div>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
<p class="field-hint">
A scheduled poll checks Zabbix toner levels. A warning email fires when
a toner crosses to the warning threshold or below, a critical email at
the critical threshold. Alerts fire once per crossing; a refill re-arms
them.
</p>
<div class="form-row">
<div class="form-group">
<label>Warning threshold (% remaining)</label>
<input v-model.number="values.printers_alert_warning_threshold"
type="number" min="0" max="100" class="form-control" />
</div>
<div class="form-group">
<label>Critical threshold (% remaining)</label>
<input v-model.number="values.printers_alert_critical_threshold"
type="number" min="0" max="100" class="form-control" />
</div>
</div>
<div class="form-group">
<label>Alert shopdb users</label>
<div class="user-picker">
<label v-for="candidate in users" :key="candidate.userid" class="user-row">
<input type="checkbox" :value="String(candidate.userid)"
v-model="selectedUserids" />
<span>{{ candidate.username }}</span>
<span class="user-email">{{ candidate.email }}</span>
</label>
<p v-if="users.length === 0" class="field-hint">No users loaded</p>
</div>
<p class="field-hint">
Selected users receive low-toner alerts at their account email.
</p>
</div>
<div class="form-group">
<label>Alert roles</label>
<div class="user-picker">
<label v-for="role in roles" :key="role.roleid" class="user-row">
<input type="checkbox" :value="String(role.roleid)"
v-model="selectedRoleids" />
<span>{{ role.rolename }}</span>
<span class="user-email">{{ role.description }}</span>
</label>
<p v-if="roles.length === 0" class="field-hint">No roles loaded</p>
</div>
<p class="field-hint">
Every active member of a selected role receives low-toner alerts.
</p>
</div>
<div class="form-group">
<label>Additional alert emails</label>
<input v-model="values.printers_alert_email" type="text"
class="form-control" placeholder="print-team@example.com, lead@example.com" />
<p class="field-hint">
Comma-separated. Empty uses the site-wide alert recipients
(Settings &gt; System &gt; Email).
</p>
</div>
<div class="form-group">
<label>Alert support team (Teams webhook)</label>
<select v-model="values.printers_alert_supportteamid" class="form-control">
<option value="">Site default webhook</option>
<option v-for="team in supportTeams" :key="team.supportteamid" :value="String(team.supportteamid)">
{{ team.teamname }}{{ team.webhookurl ? '' : ' (no webhook set)' }}
</option>
</select>
<p class="field-hint">
Low-toner alerts post to this team's webhook (set on Settings &gt;
Support Teams). Empty uses the site-wide alert webhook.
</p>
</div>
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { settingsApi, usersApi, supportteamsApi } from '@/api'
const KEYS = [
'printers_alert_email',
'printers_alert_userids',
'printers_alert_roleids',
'printers_alert_supportteamid',
'printers_alert_warning_threshold',
'printers_alert_critical_threshold'
]
const values = ref({
printers_alert_email: '',
printers_alert_userids: '',
printers_alert_roleids: '',
printers_alert_supportteamid: '',
printers_alert_warning_threshold: 5,
printers_alert_critical_threshold: 0
})
const supportTeams = ref([])
const users = ref([])
const selectedUserids = ref([])
const roles = ref([])
const selectedRoleids = ref([])
const saving = ref(false)
const message = ref('')
const error = ref('')
onMounted(async () => {
try {
const response = await settingsApi.list({ category: 'printers' })
const rows = response.data.data || []
for (const row of rows) {
if (KEYS.includes(row.key)) values.value[row.key] = row.value
}
values.value.printers_alert_warning_threshold =
parseInt(values.value.printers_alert_warning_threshold, 10) || 0
values.value.printers_alert_critical_threshold =
parseInt(values.value.printers_alert_critical_threshold, 10) || 0
selectedUserids.value = (values.value.printers_alert_userids || '')
.split(',').map(id => id.trim()).filter(Boolean)
const usersResponse = await usersApi.list()
users.value = (usersResponse.data.data || []).filter(
candidate => candidate.isactive && candidate.email)
selectedRoleids.value = (values.value.printers_alert_roleids || '')
.split(',').map(id => id.trim()).filter(Boolean)
const rolesResponse = await usersApi.roles.list()
roles.value = rolesResponse.data.data || []
const teamsResponse = await supportteamsApi.list()
supportTeams.value = teamsResponse.data.data || []
} catch (loadError) {
error.value = 'Could not load settings'
console.error(loadError)
}
})
async function save() {
saving.value = true
message.value = ''
error.value = ''
try {
values.value.printers_alert_userids = selectedUserids.value.join(',')
values.value.printers_alert_roleids = selectedRoleids.value.join(',')
for (const key of KEYS) {
await settingsApi.update(key, String(values.value[key] ?? ''))
}
message.value = 'Settings saved'
} catch (saveError) {
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
.form-row { display: flex; gap: 1rem; }
.form-row .form-group { flex: 1; }
.user-picker {
max-height: 12rem;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 0.35rem;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.user-row {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.user-email { color: var(--text-light); font-size: 0.85rem; }
</style>

View File

@@ -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')

View File

@@ -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',

View File

@@ -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'),
)

View File

@@ -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):

View File

@@ -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',
]

View File

@@ -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'<p><a href="{link}">View {name}</a></p>' if link else ''
html = (f'<p><strong>{name}</strong> {color} toner is at '
f'<strong>{remaining:.0f}%</strong> ({tier}).</p>{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

View File

@@ -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."""

View File

@@ -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:<Version> 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
}

View File

@@ -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

View File

@@ -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',

View File

@@ -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')

View File

@@ -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,

View File

@@ -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'),

View File

@@ -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):

View File

@@ -46,7 +46,11 @@ CUTOVER_PLUGINS = (
# stamp '<plugin>0001anchor'; measuringtools stamps its real baseline id.
EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PLUGINS}
EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0001baseline'
# geenforce adds the content-addressed blob store (manifestblobs) on top of its
# baseline.
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0002blobs'
# printers adds the printersupplyalerts crossing-state table on top of its anchor.
EXPECTED_HEAD_REVISION['printers'] = 'printers0002supplyalerts'
# machines (renamed from equipment) keeps its original anchor id and adds the
# rename revision on top, so its head is not the f-string default.
EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
@@ -54,8 +58,9 @@ EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
# usb drops the dead usbcheckouts.machineid column on top of its anchor.
EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid'
# printedparts is post-cutover: its 0001 really creates its tables.
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0003gagetag'
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
# the per-transaction revision column.
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
# notifications indexes businessunitid on top of its anchor.
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx'

View File

@@ -0,0 +1,108 @@
"""GE-Enforce manifestblobs model-vs-migration parity gate.
manifestblobs is created by the SECOND geenforce migration
(0002_manifest_blobs.py), and the existing DDL-parity test only diffs the
baseline (0001) tables - it does not even list ManifestBlob. So the http-blob
store table has had NO parity coverage: a column added to the ManifestBlob model
but not the migration (or vice versa) would ship a broken schema silently.
This gate builds manifestblobs two ways and diffs it column-by-column:
1. the ManifestBlob model via metadata.create_all (desired shape), and
2. the 0002 migration's upgrade() run against a fresh engine (shipped shape).
Both go through the same SQLite dialect so reflected types render identically.
"""
import importlib.util
from pathlib import Path
from sqlalchemy import create_engine, inspect
from alembic.migration import MigrationContext
from alembic.operations import Operations
_BLOBS_MIGRATION = (Path(__file__).resolve().parent.parent.parent / 'plugins' /
'geenforce' / 'migrations' / 'versions' /
'0002_manifest_blobs.py')
_EXPECTED_COLUMNS = ('sha256', 'filename', 'contenttype', 'sizebytes',
'createdat')
def _migration_columns():
"""Run the 0002 migration upgrade() on a fresh engine; return the reflected
manifestblobs columns as {name: column-dict}."""
spec = importlib.util.spec_from_file_location('geenforce_blobs',
str(_BLOBS_MIGRATION))
migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration)
engine = create_engine('sqlite://')
with engine.connect() as connection:
operations = Operations(MigrationContext.configure(connection))
operations._install_proxy()
try:
migration.upgrade()
finally:
operations._remove_proxy()
connection.commit()
inspector = inspect(connection)
assert 'manifestblobs' in inspector.get_table_names(), \
'migration did not create manifestblobs'
return {column['name']: column
for column in inspector.get_columns('manifestblobs')}
def _model_columns(db):
"""Build manifestblobs from the ManifestBlob model; return {name: column}."""
from plugins.geenforce.models.manifest import ManifestBlob
engine = create_engine('sqlite://')
db.metadata.create_all(engine, tables=[ManifestBlob.__table__])
return {column['name']: column
for column in inspect(engine).get_columns('manifestblobs')}
def _colkey(column):
"""Normalize a reflected column to the facets we gate on (type, nullable)."""
return (str(column['type']), bool(column['nullable']))
def test_manifestblob_model_matches_migration(app, db):
"""Every ManifestBlob column matches what the 0002 migration builds."""
with app.app_context():
modelcols = _model_columns(db)
migrationcols = _migration_columns()
drift = []
for name in sorted(set(modelcols) - set(migrationcols)):
drift.append(f'{name}: in model, not in migration')
for name in sorted(set(migrationcols) - set(modelcols)):
drift.append(f'{name}: in migration, not in model')
for name in sorted(set(modelcols) & set(migrationcols)):
if _colkey(modelcols[name]) != _colkey(migrationcols[name]):
drift.append(
f'{name}: model {_colkey(modelcols[name])} != '
f'migration {_colkey(migrationcols[name])}')
assert not drift, ('manifestblobs model/migration DDL drift:\n'
+ '\n'.join(drift))
def test_manifestblob_expected_columns_present_both_sides(app, db):
"""Lock the load-bearing columns (sha256 primary key + registry fields)
exist in BOTH the model and the migration."""
with app.app_context():
modelcols = _model_columns(db)
migrationcols = _migration_columns()
for name in _EXPECTED_COLUMNS:
assert name in modelcols, f'{name} missing from ManifestBlob model'
assert name in migrationcols, f'{name} missing from migration'
def test_manifestblob_sha256_is_not_nullable(app, db):
"""The content hash is the primary key: it must be NOT NULL on both sides."""
with app.app_context():
modelcols = _model_columns(db)
migrationcols = _migration_columns()
assert modelcols['sha256']['nullable'] is False
assert migrationcols['sha256']['nullable'] is False

View File

@@ -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')

View File

@@ -0,0 +1,135 @@
"""GE-Enforce payload-download hardening: rate limit + served-size ceiling.
GET /api/geenforce/payload/<sha256> is reachable with only a read-only
geenforce.fetch token, so a leaked display token must not be able to hammer it
or pull unbounded bytes. These tests lock the two bounds added to the route:
a per-IP fixed-window rate limit (same cache extension as the login limiter)
and a size cap that refuses (413) a blob/inline payload over the configured max.
"""
import hashlib
import io
from contextlib import contextmanager
from plugins.geenforce import service
from plugins.geenforce.models import ManifestBlob
def _fetch_key(client, auth_headers):
resp = client.post('/api/apitokens',
json={'name': 'svc', 'scopes': ['geenforce.fetch']},
headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
return {'X-API-Key': resp.get_json()['data']['secret']}
def _store(app, raw, filename='setup.exe'):
with app.app_context():
sha = service.store_blob(raw, filename, 'application/octet-stream')
service.db.session.commit()
return sha
@contextmanager
def _config(app, **overrides):
"""Temporarily override app.config keys (the app fixture is session-scoped,
so every key is restored to its prior value/absence on exit)."""
missing = object()
saved = {key: app.config.get(key, missing) for key in overrides}
app.config.update(overrides)
try:
yield
finally:
for key, value in saved.items():
if value is missing:
app.config.pop(key, None)
else:
app.config[key] = value
def test_blob_within_cap_is_served(client, app, auth_headers):
raw = b'x' * 64
sha = _store(app, raw, 'ok.exe')
headers = _fetch_key(client, auth_headers)
with _config(app, GEENFORCE_PAYLOAD_MAX_BYTES=1024):
resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
assert resp.status_code == 200
assert resp.data == raw
def test_oversized_blob_refused_413(client, app, auth_headers):
raw = b'x' * 64
sha = _store(app, raw, 'big.exe')
headers = _fetch_key(client, auth_headers)
with _config(app, GEENFORCE_PAYLOAD_MAX_BYTES=16):
resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
assert resp.status_code == 413
# The blob registry row still exists; the cap gates delivery, not storage.
with app.app_context():
assert service.db.session.get(ManifestBlob, sha) is not None
def test_oversized_inline_payload_refused_413(client, app, db, auth_headers):
scopeid = client.post(
'/api/geenforce/scopes',
json={'scopename': 'gea-shopfloor-cmm', 'phase': 'runtime'},
headers=auth_headers).get_json()['data']['scopeid']
entryid = client.post(
f'/api/geenforce/scopes/{scopeid}/entries',
json={'Name': 'eDNC config', 'Type': 'PS1', 'Script': 's.ps1'},
headers=auth_headers).get_json()['data']['entryid']
content = b'inline config bytes here'
up = client.post(f'/api/geenforce/entries/{entryid}/payload',
data={'file': (io.BytesIO(content), 'config.reg')},
content_type='multipart/form-data', headers=auth_headers)
assert up.status_code == 201, up.get_json()
sha = hashlib.sha256(content).hexdigest()
headers = _fetch_key(client, auth_headers)
with _config(app, GEENFORCE_PAYLOAD_MAX_BYTES=4):
resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
assert resp.status_code == 413
def test_payload_download_is_rate_limited(client, app, auth_headers):
sha = _store(app, b'small blob')
key = _fetch_key(client, auth_headers)
# A unique caller IP isolates this bucket from other tests' shared counter.
headers = {**key, 'X-Forwarded-For': '203.0.113.201'}
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=3,
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
for _ in range(3):
ok = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
assert ok.status_code == 200, ok.get_json()
# The 4th request in the same window is over budget.
over = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
assert over.status_code == 429
def test_rate_limit_is_per_ip(client, app, auth_headers):
"""A second caller (different X-Forwarded-For) has its own budget: one IP
being throttled must not throttle everyone."""
sha = _store(app, b'per-ip blob')
key = _fetch_key(client, auth_headers)
hot = {**key, 'X-Forwarded-For': '203.0.113.202'}
cool = {**key, 'X-Forwarded-For': '203.0.113.203'}
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=1,
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
assert client.get(f'/api/geenforce/payload/{sha}',
headers=hot).status_code == 200
assert client.get(f'/api/geenforce/payload/{sha}',
headers=hot).status_code == 429
# Fresh IP still gets its first request through.
assert client.get(f'/api/geenforce/payload/{sha}',
headers=cool).status_code == 200
def test_rate_limit_can_be_disabled(client, app, auth_headers):
sha = _store(app, b'unthrottled blob')
key = _fetch_key(client, auth_headers)
headers = {**key, 'X-Forwarded-For': '203.0.113.204'}
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_ENABLED=False,
GEENFORCE_PAYLOAD_RATELIMIT_MAX=1):
for _ in range(5):
assert client.get(f'/api/geenforce/payload/{sha}',
headers=headers).status_code == 200

View File

@@ -0,0 +1,137 @@
"""GE-Enforce enforcement-report per-entry key contract.
The server reads LOWERCASE keys from each results[] item -
name / action / selfhealed / exitcode / message (see
service.record_enforcement_report) - and maps them onto the
ManifestEnforcementResult columns entryname / action / selfhealed / exitcode /
message. This is the exact contract the PowerShell client fix targets: the
client emits lowercase keys, so if the server ever silently switched to reading
uppercase keys the client's entryname/selfhealed would drop to empty/false.
These tests POST a realistic multi-entry cycle with the lowercase keys and lock
that the result columns populate, plus the negative case (uppercase keys do NOT
populate) so a future contract flip fails loudly here.
"""
from plugins.geenforce import service
SCOPE = {
'Version': '2.6',
'Applications': [
{'Name': 'VNC firewall rule', 'Type': 'PS1', 'Script': 'scripts/vnc.ps1'},
{'Name': 'PC-DMIS 2023.1', 'Type': 'MSI', 'Installer': 'apps/pcdmis.msi'},
{'Name': 'eDNC config', 'Type': 'PS1', 'Script': 'scripts/ednc.ps1'},
{'Name': 'Blancco agent', 'Type': 'MSI', 'Installer': 'apps/blancco.msi'},
],
}
def _seed_and_publish(app, scopename='gea-shopfloor-cmm'):
with app.app_context():
service.replace_scope_draft(scopename, 'runtime', SCOPE)
service.publish_scope(scopename, 'runtime', notes='v')
service.db.session.commit()
def _report_token(client, auth_headers):
resp = client.post('/api/apitokens',
json={'name': 'svc', 'scopes': ['geenforce.report']},
headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()['data']['secret']
def test_lowercase_keys_populate_result_columns(client, db, app, auth_headers):
_seed_and_publish(app)
secret = _report_token(client, auth_headers)
post = client.post('/api/geenforce/report', json={
'hostname': 'WJDISPLAY07',
'scopename': 'gea-shopfloor-cmm',
'phase': 'runtime',
'appliedversion': 1,
'enforcerversion': '2.6',
'counts': {'installed': 2, 'skipped': 1, 'failed': 1, 'filtered': 0},
'results': [
{'name': 'VNC firewall rule', 'action': 'installed',
'selfhealed': True, 'exitcode': 0, 'message': 'rule re-added'},
{'name': 'PC-DMIS 2023.1', 'action': 'skipped',
'selfhealed': False, 'exitcode': 0, 'message': ''},
{'name': 'eDNC config', 'action': 'installed',
'selfhealed': True, 'exitcode': 0},
{'name': 'Blancco agent', 'action': 'failed',
'selfhealed': False, 'exitcode': 1603, 'message': 'MSI 1603'},
],
}, headers={'X-API-Key': secret})
assert post.status_code == 200, post.get_json()
reportid = post.get_json()['data']['reportid']
detail = client.get(f'/api/geenforce/reports/{reportid}',
headers=auth_headers).get_json()['data']
byname = {r['entryname']: r for r in detail['results']}
# Every lowercase 'name' mapped onto entryname (nothing dropped).
assert set(byname) == {'VNC firewall rule', 'PC-DMIS 2023.1',
'eDNC config', 'Blancco agent'}
vnc = byname['VNC firewall rule']
assert vnc['action'] == 'installed'
assert vnc['selfhealed'] is True
assert vnc['exitcode'] == 0
assert vnc['message'] == 'rule re-added'
assert byname['PC-DMIS 2023.1']['action'] == 'skipped'
assert byname['PC-DMIS 2023.1']['selfhealed'] is False
ednc = byname['eDNC config']
assert ednc['selfhealed'] is True
assert ednc['message'] is None # omitted key -> None
blancco = byname['Blancco agent']
assert blancco['action'] == 'failed'
assert blancco['exitcode'] == 1603
assert blancco['message'] == 'MSI 1603'
# A failure is present, so the cycle status is 'failed' (a self-heal without
# any failure would be 'selfhealed').
assert detail['status'] == 'failed'
def test_selfhealed_defaults_false_when_key_omitted(client, db, app,
auth_headers):
_seed_and_publish(app)
secret = _report_token(client, auth_headers)
post = client.post('/api/geenforce/report', json={
'hostname': 'WJDISPLAY08',
'scopename': 'gea-shopfloor-cmm',
'results': [{'name': 'asset report', 'action': 'installed'}],
}, headers={'X-API-Key': secret})
reportid = post.get_json()['data']['reportid']
detail = client.get(f'/api/geenforce/reports/{reportid}',
headers=auth_headers).get_json()['data']
result = detail['results'][0]
assert result['entryname'] == 'asset report'
assert result['selfhealed'] is False
def test_uppercase_keys_do_not_populate(client, db, app, auth_headers):
"""Negative lock: the server reads LOWERCASE keys, so an uppercase-keyed
item does NOT populate entryname/selfhealed. Guards against a silent flip to
uppercase that would break the client's lowercase payload."""
_seed_and_publish(app)
secret = _report_token(client, auth_headers)
post = client.post('/api/geenforce/report', json={
'hostname': 'WJDISPLAY09',
'scopename': 'gea-shopfloor-cmm',
'results': [{'Name': 'wrong casing', 'Action': 'installed',
'SelfHealed': True}],
}, headers={'X-API-Key': secret})
reportid = post.get_json()['data']['reportid']
detail = client.get(f'/api/geenforce/reports/{reportid}',
headers=auth_headers).get_json()['data']
result = detail['results'][0]
assert result['entryname'] == '' # 'Name' not read -> default ''
assert result['selfhealed'] is False # 'SelfHealed' not read -> default

View File

@@ -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

View File

@@ -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'