diff --git a/plugins/geenforce/api/routes.py b/plugins/geenforce/api/routes.py index ca545f2..8e41a3d 100644 --- a/plugins/geenforce/api/routes.py +++ b/plugins/geenforce/api/routes.py @@ -8,6 +8,7 @@ Two audiences: collector's managed-token pattern (X-API-Key or Bearer PAT). """ +import ipaddress import os import time from functools import wraps @@ -39,14 +40,46 @@ 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 _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. + """ + raw = (Setting.get(ALLOWED_CIDRS_SETTING) or '').strip() + if not raw: + return False + try: + ip = ipaddress.ip_address(_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`. + """Decorator factory: require a managed service token scoped for `scope`, + OR a caller from the configured IP allowlist. - Tokens are the only client auth path (the client kit provisions a - geenforce.fetch/report token). No env-key fallback: it was never wired into - config and an unscoped shared key is a needless backdoor. + 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) @@ -58,6 +91,12 @@ def _require_service_token(scope): # 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 @@ -702,13 +741,32 @@ def get_version(scopeid, 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 (for export-to-share).""" - setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first() - return success_response({'shareroot': setting.value if setting else ''}) + """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']) @@ -716,11 +774,26 @@ def get_config(): @require_permission('geenforce.publish') def put_config(): payload = request.get_json(silent=True) or {} - Setting.set(SHAREROOT_SETTING, (payload.get('shareroot') or '').strip(), - valuetype='string', category='geenforce', - description='On-share export root for GE-Enforce manifests') + 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({'shareroot': (payload.get('shareroot') or '').strip()}) + return success_response(result) @geenforce_bp.route('/scopes//export-share', methods=['POST']) diff --git a/plugins/geenforce/frontend/routes.js b/plugins/geenforce/frontend/routes.js index caa3d75..52c575e 100644 --- a/plugins/geenforce/frontend/routes.js +++ b/plugins/geenforce/frontend/routes.js @@ -24,6 +24,12 @@ export default [ name: 'geenforce-reports', component: () => import('./views/EnforcementReports.vue'), meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' } + }, + { + path: 'settings', + name: 'geenforce-settings', + component: () => import('./views/GeEnforceSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' } } ] } diff --git a/plugins/geenforce/frontend/views/GeEnforceLayout.vue b/plugins/geenforce/frontend/views/GeEnforceLayout.vue index b5c74af..c06db07 100644 --- a/plugins/geenforce/frontend/views/GeEnforceLayout.vue +++ b/plugins/geenforce/frontend/views/GeEnforceLayout.vue @@ -7,6 +7,7 @@ diff --git a/plugins/geenforce/frontend/views/GeEnforceSettings.vue b/plugins/geenforce/frontend/views/GeEnforceSettings.vue new file mode 100644 index 0000000..b0bcb21 --- /dev/null +++ b/plugins/geenforce/frontend/views/GeEnforceSettings.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/tests/test_plugins/test_geenforce_manifest.py b/tests/test_plugins/test_geenforce_manifest.py index 99e4efd..1ebc11b 100644 --- a/tests/test_plugins/test_geenforce_manifest.py +++ b/tests/test_plugins/test_geenforce_manifest.py @@ -115,6 +115,38 @@ def test_unauthenticated_rejected(client, db, app): assert resp.status_code == 401 +def _set_allowlist(app, value): + from shopdb.api import Setting + with app.app_context(): + Setting.set('geenforce_allowed_cidrs', value, 'string', 'geenforce') + service.db.session.commit() + + +def test_ip_allowlist_allows_without_token(client, db, app): + # An allowlisted caller reaches the manifest with NO token (vault trust). + _seed_and_publish(app) + _set_allowlist(app, '127.0.0.0/8, 10.134.48.0/23') # test client is 127.0.0.1 + resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm') + assert resp.status_code == 200, resp.data + assert b'Alpha' in resp.data + + +def test_ip_not_in_allowlist_still_rejected(client, db, app): + # A caller outside the allowlist and with no token is refused (fail-closed). + _seed_and_publish(app) + _set_allowlist(app, '10.0.0.0/8') # test client 127.0.0.1 is NOT in 10/8 + resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm') + assert resp.status_code == 401 + + +def test_empty_allowlist_keeps_token_required(client, db, app): + # Empty/unset allowlist = disabled; token stays the only path (back-compat). + _seed_and_publish(app) + _set_allowlist(app, '') + resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm') + assert resp.status_code == 401 + + def test_wrong_scope_rejected(client, db, app, auth_headers): _seed_and_publish(app) resp = client.post('/api/apitokens',