CLIENT IP / SPOOFABILITY. docs/geenforce-api-cutover.md claimed that removing the IIS rewrite rule made the allowlist fail closed and that it does NOT become spoofable. The opposite is true. IIS never sets X-Forwarded-For on its own; the rule is the only thing that does. Remove it and IIS still forwards whatever X-Forwarded-For the CALLER sent, waitress trusts it because it arrives from 127.0.0.1, and remote_addr becomes attacker-controlled - so a token-less caller can fetch manifests from anywhere on the network. The document and the _trusted_client_ip docstring now say so, waitress runs with --trusted-proxy-count=1, and stage 5 checks the rule is actually live rather than assuming it. The wizard question is rephrased to something an operator can verify with their network team instead of guessing at. NON-ASCII. The style gate only ever checked .py/.vue/.js/.ts, so documentation accumulated em-dashes, arrows and box-drawing characters against this repo's own convention - including in files added this week. Cleaned, and the gate now uses INCLUDES_ALL so Markdown, JSON and YAML are covered. PLUGIN DEFAULTS. The wizard pre-ticked measuringtools and printedparts, both of which ship default_enabled=false, so every site taking the defaults installed and enabled them against their manifests. Inno has no JSON parser so the list must be hardcoded, but tests/test_installer_defaults.py now fails when it drifts. UPGRADES. The payload copy merges, so a plugin dropped from a site's profile kept its code forever - which defeats a lean build and leaves core's optional-import guards succeeding for a plugin the site no longer has. Stale plugin directories are now deregistered and removed before the copy. add-plugin used 'plugin install', which for the five default_enabled=false plugins left them installed but DISABLED - and printed a green success line anyway. It now goes through apply-profile, and the success line is gated on the exit code. Invoke-Flask records its own exit status, because $LASTEXITCODE keeps a stale value when flask.exe is missing and no native command runs. CHARSET. The utf8mb4 compiler hook lived inline in migrations/env.py, so it covered the CORE chain only: plugin baselines inherited the server default, which on a latin1 server means two charsets in one database. It is now shopdb/utils/mysql_charset.py, imported by both, and preflight reports the database's default charset. BACKUP HONESTY. The dump was described as 'all of your asset data'. Uploaded branding and floor-map images live in instance\ on disk, not in the database, so a restore from the .sql alone comes back with no map. backup now archives instance\ alongside it and says both are needed. VERSIONING. AppVersion was hardcoded at 0.9.0 while the product, the frontend and the newest tag said 0.7.0 - and 0.9.0 collides with a retired contract version. Both builders now generate version.iss from shopdb/__init__.py. Smaller: rollback overwrites .env before deleting it, as uninstall already did; appcmd unlocks are scoped to this site's location rather than server-wide, with the wide unlock as a fallback; DEVELOPMENT-SETUP says Python 3.14; the README plugin list gains printedparts; prune-schema --force is documented as first-provisioning-only; HTTPS is documented as not-the-default with the steps to add it; the DBA SQL is on the wizard's database page; the features page says unticking does not remove an installed feature; and the installer README states that bundle-lock cannot vouch for the exe itself - that needs signing or an out-of-band hash, neither of which is wired up.
957 lines
39 KiB
Python
957 lines
39 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 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,
|
|
authorized_service_token, Setting, Application,
|
|
)
|
|
|
|
SHAREROOT_SETTING = 'geenforce_share_root'
|
|
|
|
from ..models import (
|
|
ManifestScope, ManifestEntry, ManifestPublishedVersion,
|
|
ManifestEnforcementReport, 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'])
|
|
@jwt_required()
|
|
@require_permission('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,
|
|
'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'])
|
|
@jwt_required()
|
|
@require_permission('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'])
|
|
@jwt_required()
|
|
@require_permission('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'])
|
|
@jwt_required()
|
|
@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')),
|
|
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'])
|
|
@jwt_required()
|
|
@require_permission('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'])
|
|
@jwt_required()
|
|
@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 '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'])
|
|
@jwt_required()
|
|
@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'])
|
|
@jwt_required()
|
|
@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'])
|
|
@jwt_required()
|
|
@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'])
|
|
@jwt_required()
|
|
@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'])
|
|
@jwt_required()
|
|
@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'])
|
|
@jwt_required()
|
|
@require_permission('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'])
|
|
@jwt_required()
|
|
@require_permission('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'])
|
|
@jwt_required()
|
|
@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'])
|
|
@jwt_required()
|
|
@require_permission('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'])
|
|
@jwt_required()
|
|
@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
|
|
notes = (request.get_json(silent=True) or {}).get('notes')
|
|
try:
|
|
publishedby = int(get_jwt_identity())
|
|
except (TypeError, ValueError):
|
|
publishedby = None
|
|
version = service.publish_scope(scope.scopename, scope.phase,
|
|
notes=notes, publishedby=publishedby)
|
|
db.session.commit()
|
|
return success_response({'versionnumber': version}, http_code=201)
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/versions', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('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'])
|
|
@jwt_required()
|
|
@require_permission('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'])
|
|
@jwt_required()
|
|
@require_permission('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'])
|
|
@jwt_required()
|
|
@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'])
|
|
@jwt_required()
|
|
@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'])
|
|
@jwt_required()
|
|
@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
|
|
|
|
|
|
@geenforce_bp.route('/reports', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('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()
|
|
|
|
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]
|
|
data.append({
|
|
'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),
|
|
'enforcerversion': report.enforcerversion,
|
|
'status': report.status,
|
|
'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'])
|
|
@jwt_required()
|
|
@require_permission('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],
|
|
})
|