Whether somebody with no account may see something was being answered one feature at a time: a securesend_allow_guests setting, an enable flag per kiosk, and very nearly a third for guides - each with its own settings page, its own vocabulary and its own hand-written branch inside a route. Every one of them answered a permissions question, and this app already administers permissions per role. So a caller with no token is not nobody: they hold the seeded `guest` role. require_permission consults it when there is no identity, which is the entire mechanism. The role is seeded holding NOTHING, so an upgrade opens no door; a site opens one by ticking a box in the same grid it uses for every other role, and closes it the same way. Revocation is read on every request rather than cached, because taking guest access away is what somebody does the moment they realise what they opened, and it has to bite immediately. The redundant @jwt_required() above require_permission is gone from 184 routes. It was belt-and-braces before; now it is actively wrong, because it refuses a request with no token before the permission check can have an opinion, so a grant would appear to do nothing. What a guest may hold is bounded. Any .view or .read permission is grantable. A WRITE has to be blessed by whoever owns it, through the new get_guest_grantable_permissions hook (contract 0.21.0), and Secure Send is the only case that exists: sending a password to a vendor is the one write this product means to offer somebody who never signs in. Everything else is refused by the API and greyed out in the grid rather than warned about - a checkbox that quietly makes deleting an asset anonymous is not a warning anybody reads twice. A guest write records no creator: the trail says when and from where, never who, and that is the reason for the bar. GE-Enforce and Slides gained read permissions so they can be opened to guests without handing out the manage right. Their read routes accept either through require_any_permission, so nothing an existing manager could reach has moved. The navigation endpoint now returns only what the caller can actually reach. Without that a signed-out visitor gets a sidebar of the whole app where every link bounces off the login page, which reads as a broken site rather than as a closed door. A nav item may name several permissions; holding any one is enough, which is what GE-Enforce and Slides need now that a viewer and a manager hold different strings. Two things this found on the way: current_user is a LocalProxy, so `current_user is None` is False for a request with nobody behind it - harmless under a required JWT, an AttributeError the moment verification became optional. And an image pasted into a guide before its first save was never attached to it, so it was visible to editors and invisible to every reader; saving now attaches it, and `flask guides fix-images` repairs what is stored. The guides guest setting written earlier today is gone. Granting guides.view to the guest role replaces it, which is the point.
1387 lines
59 KiB
Python
1387 lines
59 KiB
Python
"""GE-Enforce plugin API.
|
|
|
|
Two audiences:
|
|
- Admin (JWT + geenforce.manage): browse scopes and preview the draft manifest.
|
|
Full CRUD + publish lands in P2; this is the P1/first-slice read surface.
|
|
- Client (service token, geenforce.fetch scope): GET /manifest serves the
|
|
CURRENT PUBLISHED snapshot for a scope, never the live draft. Auth mirrors the
|
|
collector's managed-token pattern (X-API-Key or Bearer PAT).
|
|
"""
|
|
|
|
import ipaddress
|
|
import os
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
from functools import wraps
|
|
|
|
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, cache, success_response, error_response, ErrorCodes, require_permission,
|
|
require_any_permission,
|
|
authorized_service_token, Setting, Application,
|
|
)
|
|
|
|
SHAREROOT_SETTING = 'geenforce_share_root'
|
|
|
|
# Sort floor for revisions with no lastseenat (they sort last regardless).
|
|
_EPOCH = datetime.min
|
|
|
|
from ..models import (
|
|
ManifestScope, ManifestEntry, ManifestPublishedVersion,
|
|
ManifestEnforcementReport, ManifestEnforcementResult, ManifestPayload,
|
|
ManifestBlob, ENTRY_TYPES, PHASES,
|
|
)
|
|
from ..serializer import scope_to_manifest, entry_to_dict
|
|
from ..importer import build_entry, populate_entry
|
|
from ..filters import (
|
|
entry_applies, matches_pctype, matches_hostname, matches_machinenumber,
|
|
matches_cmmversion,
|
|
)
|
|
from .. import service
|
|
|
|
geenforce_bp = Blueprint('geenforce', __name__)
|
|
|
|
FETCH_SCOPE = 'geenforce.fetch'
|
|
REPORT_SCOPE = 'geenforce.report'
|
|
# Comma-separated CIDRs / plain IPs whose callers may reach the client
|
|
# endpoints WITHOUT a per-PC token (network-trust for a vaulted fleet).
|
|
# Empty/unset = disabled (token stays the only path).
|
|
ALLOWED_CIDRS_SETTING = 'geenforce_allowed_cidrs'
|
|
|
|
|
|
def _trusted_client_ip():
|
|
"""The trustworthy caller IP for the AUTH allowlist.
|
|
|
|
Uses request.remote_addr, NOT the raw X-Forwarded-For header. Proxies APPEND
|
|
to X-Forwarded-For, so its first hop is attacker-controlled: parsing it (as
|
|
_client_ip does for rate-limiting) would let any caller send
|
|
'X-Forwarded-For: <allowlisted-ip>' and bypass the token.
|
|
|
|
DEPLOYMENT DEPENDENCY, not a property of this function. remote_addr is only
|
|
trustworthy because of what sits in front:
|
|
|
|
- Behind IIS, the URL-Rewrite rule OVERWRITES X-Forwarded-For with the real
|
|
TCP peer, and waitress (--trusted-proxy=127.0.0.1
|
|
--trusted-proxy-headers=x-forwarded-for) derives remote_addr from it.
|
|
- Remove that rule and IIS still forwards whatever X-Forwarded-For the
|
|
CALLER sent. waitress trusts it because it comes from 127.0.0.1, so
|
|
remote_addr becomes attacker-controlled and this allowlist is bypassable
|
|
from anywhere on the network. It does NOT fail closed.
|
|
- A client reaching waitress directly is not a trusted proxy, so there
|
|
remote_addr is its own peer address.
|
|
|
|
The rule is the control. See docs/geenforce-api-cutover.md; the Windows
|
|
installer enables it and verifies it is live at the end of an install.
|
|
"""
|
|
return request.remote_addr or ''
|
|
|
|
|
|
def _ip_allowlisted():
|
|
"""True when the caller IP falls in the configured geenforce allowlist.
|
|
|
|
Lets vaulted fleet PCs reach the client endpoints without a per-PC token -
|
|
network trust replaces the shared secret. Fails closed: an unparseable
|
|
caller IP or malformed allowlist entry never matches. Empty setting = off.
|
|
Uses the SPOOF-RESISTANT remote_addr (see _trusted_client_ip), never the raw
|
|
X-Forwarded-For header.
|
|
"""
|
|
raw = (Setting.get(ALLOWED_CIDRS_SETTING) or '').strip()
|
|
if not raw:
|
|
return False
|
|
try:
|
|
ip = ipaddress.ip_address(_trusted_client_ip())
|
|
except ValueError:
|
|
return False
|
|
for part in raw.split(','):
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
try:
|
|
if ip in ipaddress.ip_network(part, strict=False):
|
|
return True
|
|
except ValueError:
|
|
continue
|
|
return False
|
|
|
|
|
|
def _require_service_token(scope):
|
|
"""Decorator factory: require a managed service token scoped for `scope`,
|
|
OR a caller from the configured IP allowlist.
|
|
|
|
Two client-auth paths:
|
|
1. a managed geenforce.fetch/report token (X-API-Key or Bearer PAT), or
|
|
2. a source IP in geenforce_allowed_cidrs (vault network trust).
|
|
No env-key fallback. Fail-closed: neither path -> 401.
|
|
"""
|
|
def wrapper(f):
|
|
@wraps(f)
|
|
def decorated(*args, **kwargs):
|
|
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)
|
|
# Network-trust path: an allowlisted vault IP reaches the client
|
|
# endpoints with no token. No token = no resource-scope binding
|
|
# (unrestricted), which the perimeter-trust model accepts.
|
|
if _ip_allowlisted():
|
|
g.geenforce_token = None
|
|
return f(*args, **kwargs)
|
|
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
|
http_code=401)
|
|
return decorated
|
|
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)
|
|
|
|
|
|
# -- client-facing endpoint ---------------------------------------------------
|
|
|
|
@geenforce_bp.route('/manifest', methods=['GET'])
|
|
@require_fetch_token
|
|
def get_manifest():
|
|
"""Serve the current published manifest for a scope (fat-client: full scope).
|
|
|
|
Query: pctype (=scopename, required), phase (default runtime). The engine
|
|
filters client-side, matching today, so subtype/hostname/machinenumber/
|
|
cmmversion are accepted but not applied here.
|
|
"""
|
|
scopename = (request.args.get('pctype') or '').strip()
|
|
phase = (request.args.get('phase') or 'runtime').strip()
|
|
if not scopename:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'pctype is required', http_code=400)
|
|
|
|
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:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'No scope: {scopename}', http_code=404)
|
|
|
|
published = scope.publishedversions.filter_by(iscurrent=True).first()
|
|
if not published:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'{scopename} has no published version',
|
|
http_code=404)
|
|
|
|
etag = f'"{scope.scopeid}-v{published.versionnumber}"'
|
|
if request.headers.get('If-None-Match') == etag:
|
|
return Response(status=304, headers={'ETag': etag})
|
|
return Response(published.manifestjson, mimetype='application/json',
|
|
headers={'ETag': etag,
|
|
'X-Manifest-Version': str(published.versionnumber)})
|
|
|
|
|
|
# -- 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):
|
|
"""Serve a payload blob by content hash over HTTPS.
|
|
|
|
Lets share-less (Intune/local-account) PCs pull installers the manifest
|
|
references without SMB. Sources: the content-addressed blob store (large
|
|
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',
|
|
as_attachment=True, download_name=blob.filename)
|
|
response.headers['ETag'] = etag
|
|
return response
|
|
|
|
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',
|
|
headers={'ETag': etag,
|
|
'Content-Disposition': f'attachment; filename="{inline.filename}"'})
|
|
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No payload for that hash',
|
|
http_code=404)
|
|
|
|
|
|
# -- client reporting (observed state) ----------------------------------------
|
|
|
|
@geenforce_bp.route('/report', methods=['POST'])
|
|
@require_report_token
|
|
def post_report():
|
|
"""Record one PC's enforcement cycle: applied version (did it receive the
|
|
update?) + per-entry self-heal outcomes (installed/skipped/failed)."""
|
|
payload = request.get_json(silent=True) or {}
|
|
if not (payload.get('hostname') or '').strip():
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'hostname is required', http_code=400)
|
|
try:
|
|
report = service.record_enforcement_report(payload)
|
|
db.session.commit()
|
|
except ValueError as exc:
|
|
db.session.rollback()
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc),
|
|
http_code=400)
|
|
return success_response({'reportid': report.reportid,
|
|
'status': report.status}, message='recorded')
|
|
|
|
|
|
# -- admin read surface (P2 adds full CRUD + publish) -------------------------
|
|
|
|
@geenforce_bp.route('/scopes', methods=['GET'])
|
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
|
def list_scopes():
|
|
"""List imaging PC-type scopes with entry + published-version counts."""
|
|
scopes = ManifestScope.query.order_by(
|
|
ManifestScope.phase, ManifestScope.scopename).all()
|
|
data = []
|
|
for scope in scopes:
|
|
current = scope.publishedversions.filter_by(iscurrent=True).first()
|
|
data.append({
|
|
'scopeid': scope.scopeid,
|
|
'scopename': scope.scopename,
|
|
'phase': scope.phase,
|
|
'manifestversion': scope.manifestversion,
|
|
'minlibversion': scope.minlibversion,
|
|
'minlibversion': scope.minlibversion,
|
|
'computertypeid': scope.computertypeid,
|
|
'measuringtooltypeid': scope.measuringtooltypeid,
|
|
'iscommon': scope.iscommon,
|
|
'entrycount': len(scope.entries),
|
|
'publishedversion': current.versionnumber if current else None,
|
|
})
|
|
return success_response(data)
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/preview', methods=['GET'])
|
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
|
def preview_scope(scopeid):
|
|
"""Render the DRAFT manifest JSON a publish would freeze (review before ship)."""
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope',
|
|
http_code=404)
|
|
return success_response({'scopename': scope.scopename,
|
|
'manifest': scope_to_manifest(scope)})
|
|
|
|
|
|
@geenforce_bp.route('/applications', methods=['GET'])
|
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
|
def list_applications():
|
|
"""The core Applications catalog (id + name) for the curated entry->app link
|
|
picker in the entry editor."""
|
|
apps = Application.query.filter_by(isactive=True).order_by(
|
|
Application.appname).all()
|
|
return success_response([{'appid': a.appid, 'appname': a.appname}
|
|
for a in apps])
|
|
|
|
|
|
# -- scope CRUD (geenforce.manage) --------------------------------------------
|
|
|
|
def _scope_summary(scope):
|
|
current = scope.publishedversions.filter_by(iscurrent=True).first()
|
|
return {
|
|
'scopeid': scope.scopeid,
|
|
'scopename': scope.scopename,
|
|
'phase': scope.phase,
|
|
'manifestversion': scope.manifestversion,
|
|
'description': scope.description,
|
|
'computertypeid': scope.computertypeid,
|
|
'measuringtooltypeid': scope.measuringtooltypeid,
|
|
'iscommon': scope.iscommon,
|
|
'entrycount': len(scope.entries),
|
|
'publishedversion': current.versionnumber if current else None,
|
|
}
|
|
|
|
|
|
def _entry_payload(entry):
|
|
"""Manifest-entry dict with entryid + sortorder + the curated app link.
|
|
|
|
appid/appname are shopdb metadata (not manifest fields), added alongside the
|
|
rendered manifest entry for the editor.
|
|
"""
|
|
data = {'entryid': entry.entryid, 'sortorder': entry.sortorder,
|
|
'appid': entry.appid, 'appname': None}
|
|
if entry.appid:
|
|
app = db.session.get(Application, entry.appid)
|
|
data['appname'] = app.appname if app else None
|
|
data.update(entry_to_dict(entry))
|
|
# Inline-payload metadata (shopdb-only, NOT manifest keys) for the editor.
|
|
data['payloadsource'] = entry.payloadsource
|
|
data['payloadref'] = entry.payloadref
|
|
data['payloadsha256'] = entry.payloadsha256
|
|
data['haspayload'] = (entry.payloadsource == 'inline'
|
|
and entry.payloadsha256 is not None)
|
|
return data
|
|
|
|
|
|
def _apply_app_link(entry, payload):
|
|
"""Set the optional curated appid from the payload (shopdb metadata, not a
|
|
manifest field, so handled outside populate_entry). Ignores an unknown id."""
|
|
if 'appid' not in payload:
|
|
return
|
|
appid = payload.get('appid')
|
|
if appid in (None, '', 0):
|
|
entry.appid = None
|
|
return
|
|
# Ignore a non-numeric id rather than 500 (matches the docstring contract).
|
|
try:
|
|
appid = int(appid)
|
|
except (ValueError, TypeError):
|
|
return
|
|
if db.session.get(Application, appid):
|
|
entry.appid = appid
|
|
|
|
|
|
@geenforce_bp.route('/scopes', methods=['POST'])
|
|
@require_permission('geenforce.manage')
|
|
def create_scope():
|
|
payload = request.get_json(silent=True) or {}
|
|
scopename = (payload.get('scopename') or '').strip()
|
|
phase = (payload.get('phase') or 'runtime').strip()
|
|
if not scopename:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'scopename is required', http_code=400)
|
|
if phase not in PHASES:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
f'phase must be one of {PHASES}', http_code=400)
|
|
if ManifestScope.query.filter_by(scopename=scopename, phase=phase).first():
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'scope already exists', http_code=400)
|
|
scope = ManifestScope(
|
|
scopename=scopename, phase=phase,
|
|
manifestversion=str(payload.get('manifestversion', '1.0')),
|
|
minlibversion=(payload.get('minlibversion') or None),
|
|
description=payload.get('description'),
|
|
computertypeid=payload.get('computertypeid'),
|
|
measuringtooltypeid=payload.get('measuringtooltypeid'),
|
|
iscommon=bool(payload.get('iscommon', scopename == 'common')))
|
|
db.session.add(scope)
|
|
db.session.commit()
|
|
return success_response(_scope_summary(scope), http_code=201)
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['GET'])
|
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
|
def get_scope(scopeid):
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
data = _scope_summary(scope)
|
|
data['entries'] = [_entry_payload(e) for e in scope.entries]
|
|
return success_response(data)
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['PUT'])
|
|
@require_permission('geenforce.manage')
|
|
def update_scope(scopeid):
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
payload = request.get_json(silent=True) or {}
|
|
for field in ('description', 'computertypeid', 'measuringtooltypeid'):
|
|
if field in payload:
|
|
setattr(scope, field, payload[field])
|
|
if 'manifestversion' in payload:
|
|
scope.manifestversion = str(payload['manifestversion'])
|
|
if 'minlibversion' in payload:
|
|
# Blank clears it, which returns the scope to a derived floor rather
|
|
# than pinning it at the empty string.
|
|
scope.minlibversion = (payload['minlibversion'] or None)
|
|
if 'iscommon' in payload:
|
|
scope.iscommon = bool(payload['iscommon'])
|
|
db.session.commit()
|
|
return success_response(_scope_summary(scope))
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['DELETE'])
|
|
@require_permission('geenforce.manage')
|
|
def delete_scope(scopeid):
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
db.session.delete(scope)
|
|
db.session.commit()
|
|
return success_response({'deleted': scopeid})
|
|
|
|
|
|
# -- entry CRUD (geenforce.manage) --------------------------------------------
|
|
|
|
def _validate_entry(payload):
|
|
"""Return an error string, or None if the entry payload is valid."""
|
|
if not (payload.get('Name') or '').strip():
|
|
return 'entry Name is required'
|
|
if payload.get('Type') not in ENTRY_TYPES:
|
|
return f'entry Type must be one of {ENTRY_TYPES}'
|
|
return None
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/entries', methods=['POST'])
|
|
@require_permission('geenforce.manage')
|
|
def create_entry(scopeid):
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
payload = request.get_json(silent=True) or {}
|
|
invalid = _validate_entry(payload)
|
|
if invalid:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
|
|
nextorder = max([e.sortorder for e in scope.entries], default=-1) + 1
|
|
entry = build_entry(payload, nextorder)
|
|
_apply_app_link(entry, payload)
|
|
scope.entries.append(entry)
|
|
try:
|
|
db.session.commit()
|
|
except IntegrityError:
|
|
db.session.rollback()
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'an entry with that Name already exists in this scope',
|
|
http_code=400)
|
|
return success_response(_entry_payload(entry), http_code=201)
|
|
|
|
|
|
@geenforce_bp.route('/entries/<int:entryid>', methods=['PUT'])
|
|
@require_permission('geenforce.manage')
|
|
def update_entry(entryid):
|
|
entry = db.session.get(ManifestEntry, entryid)
|
|
if not entry:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
|
|
payload = request.get_json(silent=True) or {}
|
|
invalid = _validate_entry(payload)
|
|
if invalid:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
|
|
# Free the one-to-one InUseCheck (unique entryid) before populate re-inserts
|
|
# it, so the replacement does not collide with the old row mid-flush.
|
|
if entry.inusecheck is not None:
|
|
db.session.delete(entry.inusecheck)
|
|
entry.inusecheck = None
|
|
db.session.flush()
|
|
populate_entry(entry, payload)
|
|
_apply_app_link(entry, payload)
|
|
try:
|
|
db.session.commit()
|
|
except IntegrityError:
|
|
db.session.rollback()
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'an entry with that Name already exists in this scope',
|
|
http_code=400)
|
|
return success_response(_entry_payload(entry))
|
|
|
|
|
|
@geenforce_bp.route('/entries/<int:entryid>', methods=['DELETE'])
|
|
@require_permission('geenforce.manage')
|
|
def delete_entry(entryid):
|
|
entry = db.session.get(ManifestEntry, entryid)
|
|
if not entry:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
|
|
db.session.delete(entry)
|
|
db.session.commit()
|
|
return success_response({'deleted': entryid})
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/entries/reorder', methods=['PUT'])
|
|
@require_permission('geenforce.manage')
|
|
def reorder_entries(scopeid):
|
|
"""Set entry order from a list of entryids (the ordering contract)."""
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
order = (request.get_json(silent=True) or {}).get('order') or []
|
|
owned = {e.entryid: e for e in scope.entries}
|
|
if set(order) != set(owned):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'order must list exactly this scope\'s entry ids',
|
|
http_code=400)
|
|
for position, entryid in enumerate(order):
|
|
owned[entryid].sortorder = position
|
|
db.session.commit()
|
|
return success_response({'order': order})
|
|
|
|
|
|
# -- simulate: "what would this PC get" ---------------------------------------
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/simulate', methods=['GET'])
|
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
|
def simulate_scope(scopeid):
|
|
"""Which draft entries apply to a given machine profile, and why the rest
|
|
are filtered out (reuses the engine-mirror filters)."""
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
profile = {
|
|
'pctype': request.args.get('pctype') or scope.scopename,
|
|
'subtype': request.args.get('subtype'),
|
|
'hostname': request.args.get('hostname'),
|
|
'machinenumber': request.args.get('machinenumber'),
|
|
'cmmversion': request.args.get('cmmversion'),
|
|
'phase': scope.phase,
|
|
}
|
|
applied, filtered = [], []
|
|
for entry in scope.entries:
|
|
entry_dict = entry_to_dict(entry)
|
|
if entry_applies(entry_dict, profile):
|
|
applied.append(entry.name)
|
|
else:
|
|
reasons = []
|
|
strict_allowed = profile.get('phase') == 'preinstall'
|
|
if not matches_pctype(entry_dict, profile['pctype'],
|
|
profile['subtype'], strict_allowed):
|
|
reasons.append('PCTypes')
|
|
if not matches_hostname(entry_dict, profile['hostname']):
|
|
reasons.append('TargetHostnames')
|
|
if not matches_machinenumber(entry_dict, profile['machinenumber']):
|
|
reasons.append('TargetMachineNumbers')
|
|
if not matches_cmmversion(entry_dict, profile['cmmversion']):
|
|
reasons.append('_CmmVersion')
|
|
filtered.append({'name': entry.name, 'filteredby': reasons})
|
|
return success_response({'profile': profile, 'applied': applied,
|
|
'filtered': filtered})
|
|
|
|
|
|
# -- compliance: "how much of the fleet has this app" -------------------------
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/compliance', methods=['GET'])
|
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
|
def scope_compliance(scopeid):
|
|
"""Fleet-install coverage per app-linked entry (from collected PC data).
|
|
|
|
One row per entry that carries a curated appid; installed/version-match
|
|
counts come from the computers plugin's ComputerInstalledApp. Degrades to
|
|
null counts (computersplugin: false) when that plugin is absent.
|
|
"""
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope',
|
|
http_code=404)
|
|
return success_response(service.compliance_for_scope(scope))
|
|
|
|
|
|
# -- inline payload upload / fetch (small scripts + configs) ------------------
|
|
|
|
PAYLOAD_MAX_BYTES = 1024 * 1024
|
|
|
|
|
|
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['POST'])
|
|
@require_permission('geenforce.publish')
|
|
def upload_entry_payload(entryid):
|
|
"""Store an inline payload (<= 1 MB) for an entry and point the entry at it."""
|
|
entry = db.session.get(ManifestEntry, entryid)
|
|
if not entry:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
|
|
uploaded = request.files.get('file')
|
|
if uploaded is None:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'a file is required', http_code=400)
|
|
rawbytes = uploaded.read()
|
|
if not rawbytes:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'payload is empty', http_code=400)
|
|
if len(rawbytes) > PAYLOAD_MAX_BYTES:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'payload exceeds 1 MB limit', http_code=400)
|
|
service.store_inline_payload(entry, uploaded.filename,
|
|
uploaded.mimetype, rawbytes)
|
|
db.session.commit()
|
|
return success_response(_entry_payload(entry), http_code=201)
|
|
|
|
|
|
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['GET'])
|
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
|
def download_entry_payload(entryid):
|
|
"""Return the stored inline payload bytes for an entry (404 if none)."""
|
|
entry = db.session.get(ManifestEntry, entryid)
|
|
if not entry:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
|
|
payload = ManifestPayload.query.filter_by(entryid=entryid).first()
|
|
if not payload:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'entry has no payload',
|
|
http_code=404)
|
|
return Response(
|
|
payload.payloadbytes,
|
|
mimetype=payload.contenttype or 'application/octet-stream',
|
|
headers={'Content-Disposition':
|
|
f'attachment; filename="{payload.filename}"'})
|
|
|
|
|
|
# -- publish lifecycle (geenforce.publish) ------------------------------------
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/publish', methods=['POST'])
|
|
@require_permission('geenforce.publish')
|
|
def publish_scope_route(scopeid):
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
from flask_jwt_extended import get_jwt_identity
|
|
body = request.get_json(silent=True) or {}
|
|
notes = body.get('notes')
|
|
try:
|
|
publishedby = int(get_jwt_identity())
|
|
except (TypeError, ValueError):
|
|
publishedby = None
|
|
try:
|
|
version = service.publish_scope(
|
|
scope.scopename, scope.phase, notes=notes,
|
|
publishedby=publishedby, force=bool(body.get('force')))
|
|
except service.LibVersionTooOldError as error:
|
|
# 409, not 500: the request is well-formed, the fleet is not ready. The
|
|
# hosts come back so the caller can act without going hunting.
|
|
return error_response(
|
|
ErrorCodes.CONFLICT, str(error), http_code=409,
|
|
details={'required': error.required, 'floor': error.floor,
|
|
'hosts': error.hosts})
|
|
db.session.commit()
|
|
return success_response({'versionnumber': version}, http_code=201)
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/publish-preflight', methods=['GET'])
|
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
|
def publish_preflight(scopeid):
|
|
"""Would publishing this scope outrun the fleet's enforcer lib?
|
|
|
|
Lets the UI warn BEFORE someone clicks publish, rather than only refusing
|
|
afterwards.
|
|
"""
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
hosts, floor = service.hosts_below_libversion(
|
|
scope.scopename, scope.phase, service.required_libversion(scope))
|
|
return success_response({
|
|
'required': service.required_libversion(scope),
|
|
'floor': floor,
|
|
'hostsbehind': hosts,
|
|
'canpublish': not hosts,
|
|
})
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/versions', methods=['GET'])
|
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
|
def list_versions(scopeid):
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
versions = ManifestPublishedVersion.query.filter_by(
|
|
scopeid=scopeid).order_by(
|
|
ManifestPublishedVersion.versionnumber.desc()).all()
|
|
return success_response([{
|
|
'versionnumber': v.versionnumber,
|
|
'iscurrent': v.iscurrent,
|
|
'publishedat': v.publishedat.isoformat() + 'Z' if v.publishedat else None,
|
|
'publishedby': v.publishedby,
|
|
'notes': v.notes,
|
|
} for v in versions])
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/versions/<int:versionnumber>',
|
|
methods=['GET'])
|
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
|
def get_version(scopeid, versionnumber):
|
|
version = ManifestPublishedVersion.query.filter_by(
|
|
scopeid=scopeid, versionnumber=versionnumber).first()
|
|
if not version:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such version',
|
|
http_code=404)
|
|
import json as _json
|
|
return success_response({'versionnumber': version.versionnumber,
|
|
'manifest': _json.loads(version.manifestjson)})
|
|
|
|
|
|
def _normalize_cidrs(raw):
|
|
"""Validate + normalize a comma/newline-separated CIDR list. Returns
|
|
(normalized_csv, bad_entries). Bare IPs are accepted (host route)."""
|
|
good, bad = [], []
|
|
for part in (raw or '').replace('\n', ',').split(','):
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
try:
|
|
good.append(str(ipaddress.ip_network(part, strict=False)))
|
|
except ValueError:
|
|
bad.append(part)
|
|
return ','.join(good), bad
|
|
|
|
|
|
@geenforce_bp.route('/config', methods=['GET'])
|
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
|
def get_config():
|
|
"""Plugin config: the on-share export root + the client IP allowlist."""
|
|
shareroot = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
|
|
cidrs = Setting.query.filter_by(key=ALLOWED_CIDRS_SETTING).first()
|
|
return success_response({
|
|
'shareroot': shareroot.value if shareroot else '',
|
|
'allowedcidrs': cidrs.value if cidrs else '',
|
|
})
|
|
|
|
|
|
@geenforce_bp.route('/config', methods=['PUT'])
|
|
@require_permission('geenforce.publish')
|
|
def put_config():
|
|
payload = request.get_json(silent=True) or {}
|
|
result = {}
|
|
if 'shareroot' in payload:
|
|
shareroot = (payload.get('shareroot') or '').strip()
|
|
Setting.set(SHAREROOT_SETTING, shareroot, valuetype='string',
|
|
category='geenforce',
|
|
description='On-share export root for GE-Enforce manifests')
|
|
result['shareroot'] = shareroot
|
|
if 'allowedcidrs' in payload:
|
|
normalized, bad = _normalize_cidrs(payload.get('allowedcidrs'))
|
|
if bad:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'Invalid CIDR(s): ' + ', '.join(bad), http_code=400)
|
|
Setting.set(ALLOWED_CIDRS_SETTING, normalized, valuetype='string',
|
|
category='geenforce',
|
|
description='Client IP allowlist (CIDRs) that may reach the '
|
|
'GE-Enforce client endpoints without a token')
|
|
result['allowedcidrs'] = normalized
|
|
db.session.commit()
|
|
return success_response(result)
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/export-share', methods=['POST'])
|
|
@require_permission('geenforce.publish')
|
|
def export_share_route(scopeid):
|
|
"""Write the scope's current published JSON to the configured share root
|
|
(backing up the old file to _meta/history first)."""
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
|
|
shareroot = setting.value if setting else ''
|
|
if not shareroot:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'Configure the share root first', http_code=400)
|
|
try:
|
|
path = service.export_scope_to_share(scope.scopename, scope.phase,
|
|
shareroot)
|
|
except (ValueError, OSError) as exc:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
|
|
return success_response({'path': path})
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/rollback', methods=['POST'])
|
|
@require_permission('geenforce.publish')
|
|
def rollback_scope_route(scopeid):
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
versionnumber = (request.get_json(silent=True) or {}).get('versionnumber')
|
|
if versionnumber is None:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'versionnumber is required', http_code=400)
|
|
try:
|
|
service.rollback_scope(scope.scopename, scope.phase, int(versionnumber))
|
|
db.session.commit()
|
|
except ValueError as exc:
|
|
db.session.rollback()
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
|
|
return success_response({'versionnumber': int(versionnumber)})
|
|
|
|
|
|
def _current_published_version(scopename, phase):
|
|
scope = ManifestScope.query.filter_by(
|
|
scopename=scopename, phase=phase).first()
|
|
if not scope:
|
|
return None
|
|
published = scope.publishedversions.filter_by(iscurrent=True).first()
|
|
return published.versionnumber if published else None
|
|
|
|
|
|
def _asset_facts(hostnames):
|
|
"""What ShopDB knows about each reporting host, resolved in bulk.
|
|
|
|
A report row only carries a hostname; everything an operator wants next -
|
|
which machine that PC drives, which measuring tool it is, where it sits,
|
|
whether its config is still being backed up - hangs off the asset behind it.
|
|
|
|
Bulk queries, never per row: this table shows the whole fleet, so a lookup
|
|
inside the loop would be one query per PC. Every plugin here is optional
|
|
(ADR-013), hence the ImportError guards - a lean build without computers
|
|
still renders the table, just without these columns.
|
|
|
|
Returns {lowercased hostname: {...}}.
|
|
"""
|
|
facts = {}
|
|
if not hostnames:
|
|
return facts
|
|
|
|
try:
|
|
from plugins.computers.models import Computer
|
|
except ImportError:
|
|
return facts
|
|
|
|
from shopdb.api import Asset
|
|
|
|
rows = (db.session.query(Computer, Asset)
|
|
.join(Asset, Asset.assetid == Computer.assetid)
|
|
.filter(Computer.hostname.in_(hostnames))
|
|
.all())
|
|
|
|
assetidtohost = {}
|
|
for computer, asset in rows:
|
|
key = (computer.hostname or '').lower()
|
|
assetidtohost[asset.assetid] = key
|
|
facts[key] = {
|
|
'computerid': computer.computerid,
|
|
'assetid': asset.assetid,
|
|
'assetnumber': asset.assetnumber,
|
|
'location': (asset.location.locationname if asset.location else None),
|
|
'mapx': asset.mapx,
|
|
'levelid': asset.levelid,
|
|
'mapy': asset.mapy,
|
|
'machinenumber': None, 'machineassetid': None,
|
|
'machinepluginid': None,
|
|
'toolassetnumber': None, 'toolassetid': None,
|
|
'toolpluginid': None,
|
|
'displayrole': None,
|
|
'backupkind': None, 'backuplastseen': None,
|
|
'backuplastseenat': None, 'backupcollectedat': None,
|
|
}
|
|
|
|
_attach_controlled_assets(facts, assetidtohost)
|
|
_attach_display_roles(facts, hostnames)
|
|
_attach_backup_state(facts, hostnames)
|
|
return facts
|
|
|
|
|
|
def _attach_controlled_assets(facts, assetidtohost):
|
|
"""Machine number / measuring-tool asset # for the asset each PC controls.
|
|
|
|
The collector records this as a 'controls' relationship, so read that rather
|
|
than re-deriving it - the same resolution living in two places is what put a
|
|
wrong subtype filter on the map.
|
|
"""
|
|
if not assetidtohost:
|
|
return
|
|
try:
|
|
from shopdb.api import Asset, AssetRelationship, RelationshipType
|
|
except ImportError:
|
|
return
|
|
|
|
controls = RelationshipType.query.filter_by(
|
|
relationshiptype='controls').first()
|
|
if not controls:
|
|
return
|
|
|
|
target = db.aliased(Asset)
|
|
rows = (db.session.query(AssetRelationship.sourceassetid, target)
|
|
.join(target, target.assetid == AssetRelationship.targetassetid)
|
|
.filter(AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
|
AssetRelationship.isactive.is_(True),
|
|
AssetRelationship.sourceassetid.in_(assetidtohost))
|
|
.all())
|
|
|
|
# assetid -> the PLUGIN extension id the detail routes key on. /machines/:id
|
|
# and /measuringtools/:id take machineid / measuringtoolid, NOT the core
|
|
# assetid; linking with the assetid lands on whichever record happens to
|
|
# share that number, which looks plausible and is wrong. Frontend
|
|
# getAssetRoute resolves the same way.
|
|
toolids = {}
|
|
try:
|
|
from plugins.measuringtools.models import MeasuringTool
|
|
toolids = {row.assetid: row.measuringtoolid for row in
|
|
MeasuringTool.query.with_entities(
|
|
MeasuringTool.assetid, MeasuringTool.measuringtoolid)}
|
|
except ImportError:
|
|
pass
|
|
machineids = {}
|
|
try:
|
|
from plugins.machines.models import Machine
|
|
machineids = {row.assetid: row.machineid for row in
|
|
Machine.query.with_entities(Machine.assetid, Machine.machineid)}
|
|
except ImportError:
|
|
pass
|
|
|
|
for sourceassetid, asset in rows:
|
|
entry = facts.get(assetidtohost.get(sourceassetid))
|
|
if not entry:
|
|
continue
|
|
if asset.assetid in toolids:
|
|
entry['toolassetnumber'] = asset.assetnumber
|
|
entry['toolassetid'] = asset.assetid
|
|
entry['toolpluginid'] = toolids.get(asset.assetid)
|
|
else:
|
|
entry['machinenumber'] = asset.assetnumber
|
|
entry['machineassetid'] = asset.assetid
|
|
# None when the machines plugin is not installed or the asset has no
|
|
# extension row: the frontend then renders the number without a link
|
|
# rather than a link that goes somewhere wrong.
|
|
entry['machinepluginid'] = machineids.get(asset.assetid)
|
|
|
|
|
|
def _attach_display_roles(facts, hostnames):
|
|
"""Dashboard / Lobby / parts kiosk, from the display-role mapping.
|
|
|
|
ShopDB's mirror of the display-type.txt each kiosk reads at startup.
|
|
"""
|
|
from shopdb.api import DashboardDefault
|
|
lowered = {name.lower() for name in hostnames}
|
|
for default in DashboardDefault.query.filter_by(isactive=True).all():
|
|
host = (default.fqdn or '').split('.')[0].lower()
|
|
if host and host in lowered and facts.get(host):
|
|
facts[host]['displayrole'] = default.displayrole
|
|
|
|
|
|
def _attach_backup_state(facts, hostnames):
|
|
"""Whether this PC's config is still being backed up, and of what kind.
|
|
|
|
Keyed on lastseenat, NOT the newest revision date. Dedup means an unchanged
|
|
config writes no revision, so a machine stable for six months has a
|
|
six-month-old newest revision and is perfectly healthy; the one to worry
|
|
about is the machine whose backup stopped running. sourcehostname is the PC
|
|
the config was read off, which is exactly the host reporting here.
|
|
"""
|
|
try:
|
|
from plugins.backups.models import BackupRevision
|
|
except ImportError:
|
|
return
|
|
rows = (BackupRevision.query
|
|
.filter(BackupRevision.sourcehostname.in_(hostnames))
|
|
.all())
|
|
# Newest confirmed first, ordered HERE rather than in SQL: putting nulls
|
|
# last needs ORDER BY ... NULLS LAST, which SQLite accepts and MySQL
|
|
# rejects outright - so the query passed every test and 500'd on the real
|
|
# database. Row counts here are one per host per kind, so sorting in Python
|
|
# costs nothing.
|
|
rows.sort(key=lambda revision: (revision.lastseenat is not None,
|
|
revision.lastseenat or _EPOCH),
|
|
reverse=True)
|
|
|
|
# Reduce to the FRESHEST revision per (host, kind), then judge a host by its
|
|
# STALEST kind.
|
|
#
|
|
# Until part markers, a PC reported exactly one kind and "newest wins" was
|
|
# the same thing as "the truth". A marker bay reports two - ntlars for the
|
|
# machine's DNC settings and partmarker for the controller's patterns - and
|
|
# newest-wins then means the daily ntlars post keeps the badge green while
|
|
# the marker backup has been failing for a month. The failure would show
|
|
# only on that marker's own asset page, which is not where anyone looks to
|
|
# find out that something stopped.
|
|
#
|
|
# So: a host is healthy when EVERYTHING it backs up is current, and the
|
|
# badge names the kind that is not. Freshest-per-kind first, because
|
|
# lastseenat only moves on the newest revision of a chain - an older
|
|
# revision of the same kind keeps its original value and would otherwise
|
|
# read as stale for ever.
|
|
#
|
|
# KNOWN CONSEQUENCE: a device retired from a PC leaves its last revision
|
|
# behind, so that host stays stale until the revision is pruned or removed.
|
|
# That is the honest failure direction - it complains about a backup that
|
|
# genuinely is not running any more, rather than staying quiet about one.
|
|
freshest = {}
|
|
for revision in rows:
|
|
key = (revision.sourcehostname or '').lower()
|
|
if key not in facts:
|
|
continue
|
|
kindkey = (key, revision.backupkind)
|
|
if kindkey not in freshest:
|
|
# rows are already newest-confirmed-first, so the first is freshest.
|
|
freshest[kindkey] = revision
|
|
|
|
# Among kinds that can be JUDGED, the stalest decides. A kind whose newest
|
|
# revision has never been confirmed (lastseenat NULL - a backfilled row, or
|
|
# one written before the column existed) is not stale; it is unmeasurable,
|
|
# and it must not win.
|
|
#
|
|
# Treating NULL as "worst" looked right and was wrong: a host with a healthy
|
|
# ntlars backup and one never-confirmed row showed NO BADGE, because the
|
|
# verdict for a NULL confirmation is tri-state None. A working backup then
|
|
# renders as nothing at all, which is the one thing the badge must never do.
|
|
# This is the same rule staleness.py already applies: a chain becomes
|
|
# measurable the first time its PC posts, and says nothing until then.
|
|
stalest = {}
|
|
for (key, _kind), revision in freshest.items():
|
|
if revision.lastseenat is None:
|
|
continue
|
|
current = stalest.get(key)
|
|
if current is None or revision.lastseenat < current.lastseenat:
|
|
stalest[key] = revision
|
|
|
|
# Every kind unmeasurable: fall back to any revision so the host still
|
|
# reports which kind it has, with a None verdict and no badge.
|
|
for (key, _kind), revision in freshest.items():
|
|
stalest.setdefault(key, revision)
|
|
|
|
for key, revision in stalest.items():
|
|
entry = facts.get(key)
|
|
if entry and entry['backuplastseen'] is None:
|
|
entry['backupkind'] = revision.backupkind
|
|
entry['backuplastseen'] = (
|
|
revision.lastseenat.isoformat() + 'Z'
|
|
if revision.lastseenat else None)
|
|
# Raw value kept beside the wire string so the verdict compares
|
|
# datetimes rather than re-parsing its own output.
|
|
entry['backuplastseenat'] = revision.lastseenat
|
|
# collectedat is when this configuration was CAPTURED, and it only
|
|
# moves when the config actually changed. Pairing it with lastseenat
|
|
# is what lets the hover say "checked X, and what it verified is
|
|
# still the backup taken at Y" instead of one date that has to mean
|
|
# both and ends up meaning neither.
|
|
entry['backupcollectedat'] = (
|
|
revision.collectedat.isoformat() + 'Z'
|
|
if revision.collectedat else None)
|
|
|
|
|
|
def _backup_ok(lastseenat, cutoff):
|
|
"""Tri-state verdict for one PC's backup: True good, False stale, None unknown.
|
|
|
|
A BOOLEAN rather than a date, because the question is "is this backed up?"
|
|
and every backup kind answers it the same way. Adding a new kind needs no
|
|
change here - it reports a revision like the others and inherits the badge.
|
|
|
|
None means there is nothing to judge: no backup revision for this host at
|
|
all, or the staleness check is disabled. The caller shows no badge rather
|
|
than a green one, since "never seen" must not read as healthy.
|
|
"""
|
|
if cutoff is None or lastseenat is None:
|
|
return None
|
|
return lastseenat >= cutoff
|
|
|
|
|
|
def _backup_stale_cutoff():
|
|
"""(cutoff datetime, threshold days) for 'this PC's backup has gone quiet'.
|
|
|
|
A raw date makes the reader do the arithmetic, and the arithmetic is not
|
|
obvious: the collector only ATTEMPTS every backups_intervalhours (24 by
|
|
default), so a confirmation from yesterday is the healthy steady state, not
|
|
a warning. What answers the question is the verdict.
|
|
|
|
The threshold is the backups plugin's own backups_staledays, read through
|
|
its service so there is ONE definition of stale rather than a second one
|
|
drifting here. Returns (None, 0) when the plugin is absent or the check is
|
|
disabled, and callers then show the date alone.
|
|
"""
|
|
try:
|
|
from plugins.backups.services import staleness
|
|
except ImportError:
|
|
return None, 0
|
|
try:
|
|
days = int(staleness.staledays())
|
|
except (TypeError, ValueError):
|
|
return None, 0
|
|
if days <= 0:
|
|
return None, 0
|
|
return service._utcnow() - timedelta(days=days), days
|
|
|
|
|
|
def _report_stale_cutoff():
|
|
"""(cutoff datetime, threshold minutes) for 'this PC has gone quiet'.
|
|
|
|
A report is a record of one cycle, not a heartbeat with an expiry: nothing
|
|
ages it. So a PC that stops reporting keeps the status of its last good
|
|
cycle and reads as healthy while it is unplugged. Comparing receivedat - the
|
|
SERVER's clock, not anything the client asserts - against a threshold is
|
|
what turns silence into a visible state.
|
|
|
|
Returns (None, 0) when the check is disabled, so callers skip it entirely.
|
|
"""
|
|
from shopdb.api import Setting
|
|
try:
|
|
minutes = int(Setting.get('geenforce_reportstaleminutes', 30) or 0)
|
|
except (TypeError, ValueError):
|
|
minutes = 30
|
|
if minutes <= 0:
|
|
return None, 0
|
|
# Same clock helper that WROTE receivedat (naive UTC), so the two cannot
|
|
# drift into comparing an aware datetime against a naive one.
|
|
return service._utcnow() - timedelta(minutes=minutes), minutes
|
|
|
|
|
|
@geenforce_bp.route('/reports', methods=['GET'])
|
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
|
def list_reports():
|
|
"""Latest enforcement report per PC (fleet compliance view).
|
|
|
|
Each row shows the applied vs latest published version (receivedlatest = the
|
|
PC picked up the update) and the install/skip/fail counts + status.
|
|
Optional filters: hostname, scopename.
|
|
"""
|
|
query = ManifestEnforcementReport.query.filter_by(iscurrent=True)
|
|
hostname = request.args.get('hostname')
|
|
scopename = request.args.get('scopename')
|
|
if hostname:
|
|
query = query.filter(ManifestEnforcementReport.hostname.ilike(hostname))
|
|
if scopename:
|
|
query = query.filter_by(scopename=scopename)
|
|
reports = query.order_by(
|
|
ManifestEnforcementReport.receivedat.desc()).all()
|
|
|
|
facts = _asset_facts({report.hostname for report in reports
|
|
if report.hostname})
|
|
|
|
stalecutoff, stalafter = _report_stale_cutoff()
|
|
backupcutoff, backupafterdays = _backup_stale_cutoff()
|
|
latest_cache = {}
|
|
data = []
|
|
for report in reports:
|
|
key = (report.scopename, report.phase)
|
|
if key not in latest_cache:
|
|
latest_cache[key] = _current_published_version(*key)
|
|
latest = latest_cache[key]
|
|
known = facts.get((report.hostname or '').lower(), {})
|
|
# Two separate ways to have gone quiet, and the table has a column for
|
|
# each. receivedat is the SERVER's record of the last report it got;
|
|
# lastcheckin is the client's own claim about when it last ran. A PC can
|
|
# fail either way round - silent to the server, or still posting while
|
|
# its own cycle has stopped advancing - so both count.
|
|
#
|
|
# They are checked independently rather than by taking the newer of the
|
|
# two: lastcheckin is client-asserted, so a wrong or future value there
|
|
# must not be able to mask real silence on receivedat.
|
|
isstale = stalecutoff is not None and (
|
|
report.receivedat is None
|
|
or report.receivedat < stalecutoff
|
|
or (report.lastcheckin is not None
|
|
and report.lastcheckin < stalecutoff))
|
|
data.append({
|
|
'reportid': report.reportid,
|
|
'hostname': report.hostname,
|
|
# A host ShopDB has no asset for still shows: the enforcement result
|
|
# is real even when the inventory is behind.
|
|
'computerid': known.get('computerid'),
|
|
'assetnumber': known.get('assetnumber'),
|
|
'location': known.get('location'),
|
|
'mapx': known.get('mapx'),
|
|
'mapy': known.get('mapy'),
|
|
# Coordinates are pixels of ONE level (ADR-017), so the level goes
|
|
# with them or the hover preview has nothing to draw on.
|
|
'levelid': known.get('levelid'),
|
|
'machinenumber': known.get('machinenumber'),
|
|
'machineassetid': known.get('machineassetid'),
|
|
'machinepluginid': known.get('machinepluginid'),
|
|
'toolassetnumber': known.get('toolassetnumber'),
|
|
'toolassetid': known.get('toolassetid'),
|
|
'toolpluginid': known.get('toolpluginid'),
|
|
# Reported by the device wins; the DashboardDefault mapping is a
|
|
# fallback for hosts on an older client that does not send it.
|
|
'displayrole': report.subtype or known.get('displayrole'),
|
|
'backupkind': known.get('backupkind'),
|
|
'backuplastseen': known.get('backuplastseen'),
|
|
# VERIFIED, not "last backup taken". Dedup means an unchanged config
|
|
# writes no new revision, so lastseenat is the last time the
|
|
# collector CHECKED and found the backup intact - which is the
|
|
# question. backupok says whether that check is recent enough to
|
|
# trust; the date alone reads as "nothing has happened since", which
|
|
# is the healthy steady state and looks like neglect.
|
|
'backupok': _backup_ok(known.get('backuplastseenat'), backupcutoff),
|
|
'backupcollectedat': known.get('backupcollectedat'),
|
|
'backupstaleafterdays': backupafterdays,
|
|
'scopename': report.scopename,
|
|
'phase': report.phase,
|
|
'appliedversion': report.appliedversion,
|
|
'latestversion': latest,
|
|
'receivedlatest': (latest is not None
|
|
and report.appliedversion == latest),
|
|
'enforcerversion': report.enforcerversion,
|
|
# 'status' is what the PC said about its LAST cycle and is left
|
|
# exactly as reported. 'isstale' is the server's own judgement that
|
|
# no cycle has been heard from in too long - a PC switched off after
|
|
# a clean run reports 'ok' forever, so the two are different facts
|
|
# and the table shows both.
|
|
'status': report.status,
|
|
'isstale': isstale,
|
|
'staleafterminutes': stalafter,
|
|
'installed': report.installedcount,
|
|
'skipped': report.skippedcount,
|
|
'failed': report.failedcount,
|
|
'filtered': report.filteredcount,
|
|
'lastcheckin': (report.lastcheckin.isoformat() + 'Z'
|
|
if report.lastcheckin else None),
|
|
'receivedat': (report.receivedat.isoformat() + 'Z'
|
|
if report.receivedat else None),
|
|
})
|
|
return success_response(data)
|
|
|
|
|
|
@geenforce_bp.route('/reports/<int:reportid>', methods=['GET'])
|
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
|
def get_report(reportid):
|
|
"""One report with per-entry outcomes (self-heal / failure detail)."""
|
|
report = db.session.get(ManifestEnforcementReport, reportid)
|
|
if not report:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such report',
|
|
http_code=404)
|
|
latest = _current_published_version(report.scopename, report.phase)
|
|
return success_response({
|
|
'reportid': report.reportid,
|
|
'hostname': report.hostname,
|
|
'scopename': report.scopename,
|
|
'phase': report.phase,
|
|
'appliedversion': report.appliedversion,
|
|
'latestversion': latest,
|
|
'receivedlatest': (latest is not None
|
|
and report.appliedversion == latest),
|
|
'status': report.status,
|
|
'results': [{
|
|
'entryname': r.entryname,
|
|
'action': r.action,
|
|
'selfhealed': r.selfhealed,
|
|
'exitcode': r.exitcode,
|
|
'message': r.message,
|
|
} for r in report.results],
|
|
})
|
|
|
|
|
|
@geenforce_bp.route('/dashboard/failures', methods=['GET'])
|
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
|
def dashboard_failures():
|
|
"""Entries that FAILED on their PC's most recent enforcement cycle.
|
|
|
|
The dashboard card behind this answers a question nothing else does: what is
|
|
broken on the floor right now. The reports table has held it all along -
|
|
status, the failing entry, its exit code and the engine's message - and the
|
|
only way to see any of it was to open one PC's report modal, one PC at a
|
|
time. A bay returned 500 to every collector report for a day and a half
|
|
before anyone looked.
|
|
|
|
Per ENTRY, not per report: "three PCs failed" is a number, while "Install
|
|
OpenText failed with exit 1603 on WJSF1234" is something a person can act
|
|
on. Only current reports (iscurrent) are considered, so a failure that has
|
|
since been fixed disappears on its own rather than needing dismissing.
|
|
"""
|
|
rows = (db.session.query(ManifestEnforcementResult, ManifestEnforcementReport)
|
|
.join(ManifestEnforcementReport,
|
|
ManifestEnforcementResult.reportid ==
|
|
ManifestEnforcementReport.reportid)
|
|
.filter(ManifestEnforcementReport.iscurrent.is_(True),
|
|
ManifestEnforcementResult.action == 'failed')
|
|
.order_by(ManifestEnforcementReport.receivedat.desc())
|
|
.limit(50)
|
|
.all())
|
|
|
|
# Resolve hostnames to computerids in ONE query so the card can link to the
|
|
# PC rather than just naming it. A row whose PC is unknown to shopdb still
|
|
# shows - the failure is real even when the inventory is behind.
|
|
hostnames = {report.hostname for _result, report in rows}
|
|
idbyhost = {}
|
|
if hostnames:
|
|
try:
|
|
from plugins.computers.models import Computer
|
|
for computer in Computer.query.filter(
|
|
Computer.hostname.in_(hostnames)).all():
|
|
idbyhost[(computer.hostname or '').lower()] = computer.computerid
|
|
except ImportError:
|
|
pass
|
|
|
|
return success_response([{
|
|
'hostname': report.hostname,
|
|
'computerid': idbyhost.get((report.hostname or '').lower()),
|
|
'scopename': report.scopename,
|
|
'entryname': result.entryname,
|
|
'exitcode': result.exitcode,
|
|
'message': (result.message or '').strip()[:200],
|
|
'receivedat': (report.receivedat.isoformat() + 'Z'
|
|
if report.receivedat else None),
|
|
} for result, report in rows])
|