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).
|
collector's managed-token pattern (X-API-Key or Bearer PAT).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
@@ -39,14 +40,46 @@ geenforce_bp = Blueprint('geenforce', __name__)
|
|||||||
|
|
||||||
FETCH_SCOPE = 'geenforce.fetch'
|
FETCH_SCOPE = 'geenforce.fetch'
|
||||||
REPORT_SCOPE = 'geenforce.report'
|
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):
|
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
|
Two client-auth paths:
|
||||||
geenforce.fetch/report token). No env-key fallback: it was never wired into
|
1. a managed geenforce.fetch/report token (X-API-Key or Bearer PAT), or
|
||||||
config and an unscoped shared key is a needless backdoor.
|
2. a source IP in geenforce_allowed_cidrs (vault network trust).
|
||||||
|
No env-key fallback. Fail-closed: neither path -> 401.
|
||||||
"""
|
"""
|
||||||
def wrapper(f):
|
def wrapper(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
@@ -58,6 +91,12 @@ def _require_service_token(scope):
|
|||||||
# scopes + blobs this token may pull; None = unrestricted).
|
# scopes + blobs this token may pull; None = unrestricted).
|
||||||
g.geenforce_token = token
|
g.geenforce_token = token
|
||||||
return f(*args, **kwargs)
|
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',
|
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
||||||
http_code=401)
|
http_code=401)
|
||||||
return decorated
|
return decorated
|
||||||
@@ -702,13 +741,32 @@ def get_version(scopeid, versionnumber):
|
|||||||
'manifest': _json.loads(version.manifestjson)})
|
'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'])
|
@geenforce_bp.route('/config', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
@require_permission('geenforce.manage')
|
@require_permission('geenforce.manage')
|
||||||
def get_config():
|
def get_config():
|
||||||
"""Plugin config: the on-share export root (for export-to-share)."""
|
"""Plugin config: the on-share export root + the client IP allowlist."""
|
||||||
setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
|
shareroot = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
|
||||||
return success_response({'shareroot': setting.value if setting else ''})
|
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'])
|
@geenforce_bp.route('/config', methods=['PUT'])
|
||||||
@@ -716,11 +774,26 @@ def get_config():
|
|||||||
@require_permission('geenforce.publish')
|
@require_permission('geenforce.publish')
|
||||||
def put_config():
|
def put_config():
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
Setting.set(SHAREROOT_SETTING, (payload.get('shareroot') or '').strip(),
|
result = {}
|
||||||
valuetype='string', category='geenforce',
|
if 'shareroot' in payload:
|
||||||
description='On-share export root for GE-Enforce manifests')
|
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()
|
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'])
|
@geenforce_bp.route('/scopes/<int:scopeid>/export-share', methods=['POST'])
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export default [
|
|||||||
name: 'geenforce-reports',
|
name: 'geenforce-reports',
|
||||||
component: () => import('./views/EnforcementReports.vue'),
|
component: () => import('./views/EnforcementReports.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
|
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">
|
<nav class="section-tabs">
|
||||||
<router-link to="/geenforce/manifests" class="tab">Manifests</router-link>
|
<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/reports" class="tab">Enforcement Reports</router-link>
|
||||||
|
<router-link to="/geenforce/settings" class="tab">Settings</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</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
|
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):
|
def test_wrong_scope_rejected(client, db, app, auth_headers):
|
||||||
_seed_and_publish(app)
|
_seed_and_publish(app)
|
||||||
resp = client.post('/api/apitokens',
|
resp = client.post('/api/apitokens',
|
||||||
|
|||||||
Reference in New Issue
Block a user