harden CSV import reconciliation and preserve saved model selections

Default fuzzy reconcile threshold to 0 and refuse merges when digit runs differ so numbered labels like Bay 311 never bind to Bay 310. Keep edit forms from clearing saved model fields when category filtering hides untyped rows. Includes in-progress computers policy and endpoints command feature work.
This commit is contained in:
benjamin.baker
2026-08-28 09:29:09 -04:00
parent d68c6db9ab
commit 01734e5449
46 changed files with 4241 additions and 52 deletions

View File

@@ -39,17 +39,56 @@ def _computer_models():
return None
def _token_is_collector(token):
"""True when this managed token is a collector service token: scoped for
collector.ingest AND its owner actually holds that permission (admins do)."""
def _collector_scopes_for(pluginname=None):
"""The scope names that authorize a collector request.
The generic scope `collector.ingest` authorizes every collector. A plugin
MAY also mint a token scoped to `collector.ingest.<pluginname>` so that
rotating/revoking it stops only that plugin's collector traffic (endpoints
uses this for its regenerable agent kill switch). Both are accepted.
"""
scopes = {COLLECTOR_SCOPE}
if pluginname:
scopes.add(f'{COLLECTOR_SCOPE}.{pluginname}')
return scopes
def _env_fallback_disabled(pluginname=None):
"""True when a plugin has switched OFF the bootstrap env-key fallback.
Once an operator provisions a UI-managed collector token, the plugin sets
its `plugin.<pluginname>.envkeyfallback` setting to false so the old .env
key stops authorizing. That turns key revocation into a real kill switch:
rotating or revoking the managed token cannot be bypassed by a lingering env
key. Absent
setting = fallback stays ON (back-compat, so nothing breaks on upgrade).
"""
if not pluginname:
return False
from shopdb.core.models.setting import Setting
key = f'plugin.{pluginname}.envkeyfallback'
return Setting.get(key, default=True) is False
def _token_is_collector(token, pluginname=None):
"""True when this managed token is a collector service token: scoped for a
collector scope AND its owner actually holds that permission (admins do).
Accepts the generic `collector.ingest` or, when pluginname is given, the
plugin-specific `collector.ingest.<pluginname>` scope.
"""
from shopdb.core.models import User
scopelist = token.scopelist
if not scopelist or COLLECTOR_SCOPE not in scopelist:
if not scopelist:
return False
allowed = _collector_scopes_for(pluginname)
held = [s for s in scopelist if s in allowed]
if not held:
return False
user = db.session.get(User, token.userid)
return (user is not None and user.isactive
and user.haspermission(COLLECTOR_SCOPE))
and any(user.haspermission(s) for s in held))
def _collector_managed_token(api_key):
@@ -73,24 +112,28 @@ def _collector_managed_token(api_key):
return None
def _check_collector_auth(expected_key):
def _check_collector_auth(expected_key, pluginname=None):
"""Authorize a collector request. Returns None when authorized, else the
error_response to return.
Accepts a collector-scoped managed token (Bearer or X-API-Key) OR the env
key via X-API-Key (legacy/bootstrap fallback). Env keys stay supported so
nothing breaks; a valid managed token works even with no env key set.
When pluginname is given, a token scoped to collector.ingest.<pluginname>
also authorizes (in addition to the generic collector.ingest scope).
"""
from shopdb.utils.apitoken_auth import touch_apitoken_lastused
api_key = request.headers.get('X-API-Key')
token = _collector_managed_token(api_key)
if token is not None and _token_is_collector(token):
if token is not None and _token_is_collector(token, pluginname):
touch_apitoken_lastused(token)
return None
if expected_key and api_key == expected_key:
if expected_key and api_key == expected_key \
and not _env_fallback_disabled(pluginname):
return None
# Fail-closed only on pure server misconfiguration: no env key AND the
@@ -200,7 +243,7 @@ def generic_collect(pluginname):
# Per-plugin env key (fallback) OR a collector-scoped managed token. Header
# only (querystring fallback dropped, see require_api_key).
error = _check_collector_auth(_plugin_api_key(pluginname))
error = _check_collector_auth(_plugin_api_key(pluginname), pluginname)
if error is not None:
return error

View File

@@ -879,10 +879,10 @@ def build_default_settings():
},
{
'key': 'asset_import_reconcile_threshold',
'value': '0.85',
'value': '0',
'valuetype': 'string',
'category': 'site',
'description': 'Similarity (0 to 1) a fuzzy lookup match must reach for the asset importer to reconcile a near-miss name (e.g. "Dell Inc." to an existing "Dell") onto an existing vendor, model, type, status, business unit, or location. Reconciled matches are applied but reported for review. 0 disables reconciliation. Operating systems and asset rows are never reconciled.'
'description': 'Similarity (0 to 1) a fuzzy lookup match must reach for the asset importer to reconcile a near-miss name (e.g. "Dell Inc." to an existing "Dell") onto an existing vendor, model, type, status, business unit, or location. Reconciled matches are applied but reported for review. 0 (the default) disables fuzzy reconciliation - names must match exactly after normalization. Labels whose digit runs differ (e.g. "Bay 310" vs "Bay 311") are never fuzzy-matched. Operating systems and asset rows are never reconciled.'
},
]