geenforce: IP allowlist for client endpoints + admin Settings tab
Fleet PCs on a trusted (vaulted) network can now reach the GE-Enforce client endpoints (manifest, payload, report) without a per-PC token: the auth path accepts a valid geenforce.fetch/report token OR a source IP in the configured allowlist (setting geenforce_allowed_cidrs). Fail-closed; an empty allowlist means the token stays the only path, so existing deployments are unchanged. Rationale: the client token lives in HKLM on every kiosk, so it does not defend against a compromised kiosk anyway - network-perimeter trust is the same practical strength with far less provisioning + no token-rotation churn on a DB wipe. Documented in-UI that this is perimeter trust, not per-device identity. - _ip_allowlisted() (ipaddress, X-Forwarded-For-aware via _client_ip) - /geenforce/config GET/PUT extended with allowedcidrs, server-validated + normalized (bad CIDR -> 400) - new GE-Enforce > Settings tab (GeEnforceSettings.vue) to edit the allowlist in admin, no SQL - 3 regression tests (allow by IP, reject outside list, empty = token required)
This commit is contained in:
@@ -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/<int:scopeid>/export-share', methods=['POST'])
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<nav class="section-tabs">
|
||||
<router-link to="/geenforce/manifests" class="tab">Manifests</router-link>
|
||||
<router-link to="/geenforce/reports" class="tab">Enforcement Reports</router-link>
|
||||
<router-link to="/geenforce/settings" class="tab">Settings</router-link>
|
||||
</nav>
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
109
plugins/geenforce/frontend/views/GeEnforceSettings.vue
Normal file
109
plugins/geenforce/frontend/views/GeEnforceSettings.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="geenforce-settings">
|
||||
<h2>Client Access</h2>
|
||||
<p class="muted">
|
||||
Fleet PCs on a trusted network can reach the GE-Enforce client endpoints
|
||||
(manifest, payload, report) without a per-PC token. List the trusted
|
||||
networks below. Leave it empty to require a <code>geenforce.fetch</code>
|
||||
token instead.
|
||||
</p>
|
||||
|
||||
<label class="field-label" for="cidrs">Allowed networks (CIDR)</label>
|
||||
<textarea
|
||||
id="cidrs"
|
||||
v-model="cidrs"
|
||||
class="cidr-input"
|
||||
rows="5"
|
||||
spellcheck="false"
|
||||
placeholder="10.134.48.0/23 10.48.249.0/26"
|
||||
></textarea>
|
||||
<small class="input-hint">
|
||||
One CIDR (or a plain IP) per line, or comma-separated. Example:
|
||||
<code>10.134.48.0/23, 10.48.249.0/26</code>. A caller from any of these
|
||||
networks may pull manifests + payloads and post reports with no token.
|
||||
</small>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<span v-if="message" :class="['status', ok ? 'ok' : 'err']">{{ message }}</span>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<strong>Note:</strong> this is network-perimeter trust. Any host on a
|
||||
listed network is trusted - including a compromised one. It does not
|
||||
replace per-device identity if you need tamper-proof report integrity.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '@/api'
|
||||
|
||||
const cidrs = ref('')
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
const ok = ref(false)
|
||||
|
||||
function unwrap(response) { return response.data.data }
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const config = unwrap(await api.get('/geenforce/config'))
|
||||
cidrs.value = config.allowedcidrs || ''
|
||||
} catch (e) {
|
||||
message.value = 'Could not load config.'
|
||||
ok.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
message.value = ''
|
||||
try {
|
||||
const config = unwrap(await api.put('/geenforce/config', { allowedcidrs: cidrs.value }))
|
||||
cidrs.value = config.allowedcidrs || '' // server returns the normalized list
|
||||
ok.value = true
|
||||
message.value = 'Saved.'
|
||||
} catch (e) {
|
||||
ok.value = false
|
||||
message.value = e?.response?.data?.data?.error?.message || 'Save failed.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.geenforce-settings { max-width: 640px; }
|
||||
.muted { color: var(--text-light); }
|
||||
.field-label { display: block; font-weight: 600; margin: 1rem 0 0.35rem; }
|
||||
.cidr-input {
|
||||
width: 100%;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
background: var(--bg-card-solid);
|
||||
color: var(--text);
|
||||
resize: vertical;
|
||||
}
|
||||
.input-hint { display: block; color: var(--text-light); margin-top: 0.35rem; }
|
||||
.actions { display: flex; align-items: center; gap: 0.75rem; margin-top: 1rem; }
|
||||
.status.ok { color: var(--success); font-weight: 600; }
|
||||
.status.err { color: var(--danger); font-weight: 600; }
|
||||
.note {
|
||||
margin-top: 1.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-left: 4px solid var(--warning);
|
||||
background: var(--bg-card);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user