Let a site close the legacy collector routes, once it can afford to
Turning "Shared key allowed" off refused the key on the endpoints plugin and left four core collector routes open to it. Anything still holding the old key could keep rewriting logged-in user, last boot time, serial number and installed app inventory for ANY hostname, while the admin believed the shared key was off. Data integrity only - no command execution, no credential issuance - but it is a hole in a boundary the product advertises as closed. It cannot simply be closed. Both GE-Enforce reporters still authenticate with that key, so refusing it fleet-wide would stop asset and printer reporting everywhere, and on an air-gapped site mid-migration that is the wrong order of operations. collector.legacysharedkeyrefused therefore DEFAULTS TO TODAY'S BEHAVIOUR, and its description names exactly what turning it on breaks and when it should be turned on: after the reporters move to per-PC credentials, not before. A legacy route used with the shared key now logs a warning, so a site can find out who still depends on the key before flipping anything. On a stock Windows deployment that warning may name the proxy rather than the caller; the token prefix in the same line is the identifier to go by. The switch is seeded with the core defaults and has a control on the authentication settings page. Undeclared, it was a row that did not exist, and this UI has no generic key/value editor, so seeding alone would have rendered nothing for an operator to click. Two contract declarations corrected while in here: notifications.alertscope was declared db.JSON in the model while the migration that creates it ships TEXT, and those migrations state the constraint in their own docstrings: no JSON columns. The model now matches the shipped column rather than the schema being bent to the model. usb and printedparts both import a name that arrived in contract 0.35.0 while declaring core_version floors of 0.1.0 and 0.16.0, so the loader would have accepted them on a core that cannot satisfy them - an ImportError at blueprint registration instead of a refusal with a reason. Both floors now say what the plugin actually needs.
This commit is contained in:
@@ -161,14 +161,50 @@ def _declared_default(key: str) -> dict:
|
||||
return _plugin_declared_settings().get(key, {})
|
||||
|
||||
|
||||
def _is_secret(key: str) -> bool:
|
||||
return 'password' in key or 'token' in key or 'secret' in key
|
||||
def _is_secret(key: str, valuetype=None) -> bool:
|
||||
"""True when a key's value must never leave the API in plaintext.
|
||||
|
||||
The test is the key NAME, which is a heuristic, so it is narrowed by the
|
||||
stored type when the caller knows it. A boolean, integer or float setting
|
||||
cannot hold a secret, and masking one breaks the control that renders it:
|
||||
plugin.endpoints.bootstrapquerytoken is a true/false switch whose name
|
||||
contains 'token', and the settings page read back '********' instead of
|
||||
its state, so the checkbox could not show whether it was on.
|
||||
|
||||
valuetype omitted means "judge by name alone", which is the safe direction:
|
||||
an unknown type is treated as a string.
|
||||
"""
|
||||
named = 'password' in key or 'token' in key or 'secret' in key
|
||||
if not named:
|
||||
return False
|
||||
return valuetype is None or str(valuetype).lower() == 'string'
|
||||
|
||||
|
||||
def _inferred_valuetype(value) -> str:
|
||||
"""Valuetype for a key nobody declares, from the JSON type sent.
|
||||
|
||||
Only reached on the upsert path in update_setting. JSON already carries the
|
||||
distinction the row has to keep, so honour it: true/false is a boolean row,
|
||||
a number is integer or float, everything else is a string. Guessing from
|
||||
the TEXT (e.g. the string 'true') is deliberately not done - a genuine
|
||||
string setting whose value happens to read 'true' would then be typed
|
||||
boolean and coerced out from under its reader.
|
||||
|
||||
bool is checked before int because bool subclasses int in Python.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return 'boolean'
|
||||
if isinstance(value, int):
|
||||
return 'integer'
|
||||
if isinstance(value, float):
|
||||
return 'float'
|
||||
return 'string'
|
||||
|
||||
|
||||
def _serialize_setting(setting):
|
||||
"""Serialize a setting, masking secret values so they never leave the API."""
|
||||
data = setting.to_dict()
|
||||
if _is_secret(setting.key):
|
||||
if _is_secret(setting.key, setting.valuetype):
|
||||
data['value'] = SECRET_MASK if setting.value else ''
|
||||
return data
|
||||
|
||||
@@ -795,7 +831,14 @@ def update_setting(key: str):
|
||||
setting = Setting(
|
||||
key=key,
|
||||
value='',
|
||||
valuetype=declared.get('valuetype', 'string'),
|
||||
# An undeclared key used to be born valuetype='string'
|
||||
# unconditionally, and that was a trap: a caller PUTting JSON true
|
||||
# got a string row, get_typed_value then handed every reader the
|
||||
# text 'true', and a reader doing bool(...) or `is True` silently
|
||||
# disagreed with the UI. Infer from the JSON type the client sent
|
||||
# instead, so a boolean key is born boolean.
|
||||
valuetype=declared.get('valuetype') or _inferred_valuetype(
|
||||
data.get('value')),
|
||||
category=declared.get('category', 'plugin'),
|
||||
description=declared.get('description'),
|
||||
)
|
||||
@@ -808,7 +851,7 @@ def update_setting(key: str):
|
||||
|
||||
# A secret submitted as the mask placeholder means "leave unchanged" - the
|
||||
# client only ever received the mask, so don't overwrite the real secret.
|
||||
if _is_secret(key) and value == SECRET_MASK:
|
||||
if _is_secret(key, setting.valuetype) and value == SECRET_MASK:
|
||||
return success_response(_serialize_setting(setting), message='Setting unchanged')
|
||||
|
||||
# The declared valuetype is a promise about what every reader will get, and
|
||||
@@ -846,7 +889,7 @@ def update_setting(key: str):
|
||||
setting.value = str(value) if value is not None else None
|
||||
|
||||
# Audit log (mask sensitive values)
|
||||
is_sensitive = _is_secret(key)
|
||||
is_sensitive = _is_secret(key, setting.valuetype)
|
||||
AuditLog.log('updated', 'Setting', entityname=key, changes={
|
||||
'value': {
|
||||
'old': '***' if is_sensitive else old_value,
|
||||
@@ -915,10 +958,13 @@ def create_setting():
|
||||
else:
|
||||
value_str = str(value) if value is not None else None
|
||||
|
||||
# Same reasoning as the upsert in update_setting: a caller that omits
|
||||
# valuetype and sends JSON true must not get a string row that every
|
||||
# boolean reader then misreads.
|
||||
setting = Setting(
|
||||
key=data['key'],
|
||||
value=value_str,
|
||||
valuetype=data.get('valuetype', 'string'),
|
||||
valuetype=data.get('valuetype') or _inferred_valuetype(value),
|
||||
category=data.get('category', 'general'),
|
||||
description=data.get('description')
|
||||
)
|
||||
@@ -1268,7 +1314,16 @@ def build_default_settings():
|
||||
|
||||
# Collector pc-type -> ComputerType mapping is computers-plugin domain;
|
||||
# the plugin seeds pctypemap_<pxetype> settings on install.
|
||||
# Collector authentication. The wording lives with the code that enforces
|
||||
# it (shopdb/core/api/collector.py); seeding it here is what makes the row
|
||||
# exist, and the row is what the settings UI can render a control for.
|
||||
# Imported inside the function because core.api.__init__ imports collector
|
||||
# and settings in the same package - a module-level import would couple
|
||||
# their import order for no gain.
|
||||
from shopdb.core.api.collector import LEGACY_SHARED_KEY_SETTING_DEFAULT
|
||||
|
||||
defaults = sitedefaults + brandingdefaults + printingdefaults + identifierdefaults + searchdefaults + mapdefaults + [
|
||||
LEGACY_SHARED_KEY_SETTING_DEFAULT,
|
||||
# ServiceNow ticket links. Each instance points these at its own
|
||||
# ServiceNow tenant; blank/disabled renders tickets as plain text.
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user