Give each endpoint its own credential and fix the review findings on the server
Per-PC credentials (replaces the 09-14 client-certificate attempt): - POST /api/endpoints/enroll issues a credential to a PC that matches exactly one computer by hostname and BIOS serial and has none yet. Anything else (unknown or duplicate hostname, serial mismatch, already enrolled) waits as a request an admin approves or rejects. The agent's persisted nonce binds an approved request to the PC that asked. - One auth helper serves every agent route. With X-Endpoint-Credential the asset comes from the credential and a request naming another PC gets 403 ASSET_MISMATCH. The shared key still works for PCs that have not enrolled, is refused (401 SHARED_KEY_REFUSED) for any PC that has, and can be switched off. A revoked credential gets 401 CREDENTIAL_INVALID so the agent re-enrolls. - The core collector accepts the credential for the computers, printers and endpoints collectors through two new plugin hooks exposed on shopdb.api (contract 0.33.0), and refuses the shared key for enrolled PCs. - Admin API: enrollment list, approve, reject, revoke (un-enroll), settings. Migration API: per-PC readiness with blockers, and upgrade waves that package the agent once and queue the existing agent upgrade per PC. - Migration 0023 creates endpointcredentials, endpointenrollrequests and endpointmigrationwaves, drops the certificate columns from assets where test databases have them, and renames app_fingerprint to appfingerprint. 0021 and 0022 keep their ids but no longer alter the core assets table. - Removed: client_cert.py, certificate renewal, the unauthenticated /test/client-cert route, nginx client certificate forwarding and the agent certificate script. The client-cert header could be forged on IIS, the key was readable by users, and the shared key remained a fallback. - GET /settings/server-cert returns the SPKI SHA-256 pin for GE-Enforce. Review fixes: - report_command_result used g without importing it; every result was a 500. - collect_log refuses setup answer files, appsettings, agent directories and key files at enqueue. - Fleet alerts: alerttimeout and alertfleet agree with the migration, the scope is validated and a malformed scope no longer reaches the whole fleet, and the ack race no longer raises NameError. - A refused break-glass override leaves no hold and no audit row. - Agent upgrade checks locks and permissions before repackaging, and writes the package atomically. - Server certificate upload validates the pair before replacing files; generation no longer builds an openssl config from user input; audit rows are actually written. - Health evaluation runs in a savepoint so a caught error cannot lose the check-in. - Required-app policies are found by appid, survive renames, and deleting an app deactivates policies that still name it. The UI and API now say such a policy only takes effect inside a business policy. - Backfill handles duplicate fingerprints and rolls back a failed batch. - Plugin settings routes skip a plugin whose hook raises. - The setup token is no longer printed to the log. - The naming gate checks top-level files again and handles odd file names. - Removed root scratch scripts, one of which carried a token. - Rewrote the fleet alert scheduling and app delta tests on real fixtures.
This commit is contained in:
@@ -60,8 +60,18 @@ def _requires_policy_ack(app, data):
|
||||
|
||||
POLICY_ACK_MESSAGE = (
|
||||
'Marking this application as required on all PCs creates a configuration '
|
||||
'policy that will install it on every non-server endpoint it matches. '
|
||||
'Confirm to create the policy.'
|
||||
'policy that installs it on every non-server endpoint it matches, once that '
|
||||
'policy is added to an active business policy. It is not added to one '
|
||||
'automatically. Confirm to create the policy.'
|
||||
)
|
||||
|
||||
# Returned alongside policyresult='created'. The generated policy is not
|
||||
# attached to any BusinessPolicy, and the resolver only applies policies reached
|
||||
# through one, so saying "this will install everywhere" would be untrue.
|
||||
POLICY_CREATED_NOTE = (
|
||||
'A configuration policy was created for this application, but it is not '
|
||||
'attached to any business policy and installs nothing until an admin adds '
|
||||
'it to one.'
|
||||
)
|
||||
|
||||
# Deleting an application is irreversible and takes its versions and uploaded
|
||||
@@ -120,6 +130,24 @@ def _validate_monitorable(data, existing=None):
|
||||
return None
|
||||
|
||||
|
||||
def _policy_payload(payload, policyresult):
|
||||
"""Attach the policy sync outcome (and the not-attached note) to a response."""
|
||||
if policyresult in ('created', 'removed', 'kept'):
|
||||
payload['policyresult'] = policyresult
|
||||
if policyresult == 'created':
|
||||
payload['policynote'] = POLICY_CREATED_NOTE
|
||||
return payload
|
||||
|
||||
|
||||
def _deactivate_policies_for_app(app_id):
|
||||
"""Deactivate remaining policies that reference a deleted appid, if available."""
|
||||
try:
|
||||
from plugins.computers.services.apppolicy import deactivate_policies_for_app
|
||||
except ImportError:
|
||||
return []
|
||||
return deactivate_policies_for_app(app_id)
|
||||
|
||||
|
||||
def _sync_required_app_policy(app):
|
||||
"""Sync the auto-generated "required on all PCs" policy, if available.
|
||||
|
||||
@@ -397,9 +425,7 @@ def create_application():
|
||||
|
||||
db.session.commit()
|
||||
|
||||
payload = app.to_dict()
|
||||
if policyresult in ('created', 'removed', 'kept'):
|
||||
payload['policyresult'] = policyresult
|
||||
payload = _policy_payload(app.to_dict(), policyresult)
|
||||
|
||||
return success_response(payload, message='Application created', http_code=201)
|
||||
|
||||
@@ -439,6 +465,20 @@ def update_application(app_id: int):
|
||||
'ismonitorable', 'monitorlaunchpath', 'monitorprocessname'
|
||||
]
|
||||
|
||||
# Only touch policy when the checkbox itself moved. Syncing on every save
|
||||
# would let an unrelated edit resurrect a policy an admin had deleted.
|
||||
#
|
||||
# Unticking is synced BEFORE any field is applied, so a PUT that renames and
|
||||
# unticks in one go withdraws the policy that belongs to the app as it was,
|
||||
# not one looked up under the new name.
|
||||
policyresult = None
|
||||
unticking = ('isrequired' in data and bool(app.isrequired)
|
||||
and not data['isrequired'])
|
||||
if unticking:
|
||||
app.isrequired = False
|
||||
policyresult = _sync_required_app_policy(app)
|
||||
app.isrequired = True
|
||||
|
||||
changes = {}
|
||||
for key in fields:
|
||||
if key in data:
|
||||
@@ -452,18 +492,16 @@ def update_application(app_id: int):
|
||||
AuditLog.log('updated', 'Application', entityid=app.appid,
|
||||
entityname=app.appname, changes=changes)
|
||||
|
||||
# Only touch policy when the checkbox itself moved. Syncing on every save
|
||||
# would let an unrelated edit resurrect a policy an admin had deleted.
|
||||
policyresult = None
|
||||
if 'isrequired' in changes:
|
||||
if 'isrequired' in changes and not unticking:
|
||||
policyresult = _sync_required_app_policy(app)
|
||||
elif 'appname' in changes and app.isrequired:
|
||||
# A rename of a required app keeps its generated policy's name current.
|
||||
_sync_required_app_policy(app)
|
||||
|
||||
apply_import_timestamps(app, data)
|
||||
db.session.commit()
|
||||
|
||||
payload = app.to_dict()
|
||||
if policyresult in ('created', 'removed', 'kept'):
|
||||
payload['policyresult'] = policyresult
|
||||
payload = _policy_payload(app.to_dict(), policyresult)
|
||||
|
||||
return success_response(payload, message='Application updated')
|
||||
|
||||
@@ -561,7 +599,13 @@ def delete_application(app_id: int):
|
||||
# application would keep installing something that no longer exists.
|
||||
appname = app.appname
|
||||
app.isrequired = False
|
||||
_sync_required_app_policy(app)
|
||||
policyresult = _sync_required_app_policy(app)
|
||||
|
||||
# 'kept' means a customised generated policy survived the sync, and other
|
||||
# hand-authored policies may name this appid too. Every one of them would
|
||||
# keep trying to install an application that no longer exists, so they are
|
||||
# deactivated (not deleted - they are someone's work) and reported.
|
||||
deactivated = _deactivate_policies_for_app(app_id)
|
||||
|
||||
_clear_application_references(app_id)
|
||||
removed = _remove_upload_files(app_id)
|
||||
@@ -570,12 +614,26 @@ def delete_application(app_id: int):
|
||||
|
||||
# The audit row is the only record left that this application existed, so
|
||||
# it names the files that went with it.
|
||||
auditchanges = {}
|
||||
if removed:
|
||||
auditchanges['removedfiles'] = removed
|
||||
if deactivated:
|
||||
auditchanges['deactivatedpolicies'] = deactivated
|
||||
AuditLog.log('deleted', 'Application', entityid=app_id, entityname=appname,
|
||||
changes={'removedfiles': removed} if removed else None)
|
||||
changes=auditchanges or None)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return success_response(message='Application deleted')
|
||||
payload = {}
|
||||
if policyresult in ('removed', 'kept'):
|
||||
payload['policyresult'] = policyresult
|
||||
if deactivated:
|
||||
payload['deactivatedpolicies'] = deactivated
|
||||
message = 'Application deleted'
|
||||
if deactivated:
|
||||
message += ('; deactivated {} policy(ies) that referenced it: {}'
|
||||
.format(len(deactivated), ', '.join(deactivated)))
|
||||
return success_response(payload or None, message=message)
|
||||
|
||||
|
||||
REVERT_CONFIRM_MESSAGE = (
|
||||
|
||||
@@ -13,7 +13,6 @@ from flask import Blueprint, request, current_app, g
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Asset, Application
|
||||
from shopdb.core.models.apitoken import ApiToken, TOKEN_SECRET_PREFIX
|
||||
from shopdb.core.auth.client_cert import authenticate_by_client_cert
|
||||
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
|
||||
@@ -24,6 +23,10 @@ collector_bp = Blueprint('collector', __name__)
|
||||
# identity - no require_permission on these routes).
|
||||
COLLECTOR_SCOPE = 'collector.ingest'
|
||||
|
||||
# Per-PC agent credential (contract 0.33.0). Accepted only by collector schemas
|
||||
# declaring acceptsagentcredential.
|
||||
AGENT_CREDENTIAL_HEADER = 'X-Endpoint-Credential'
|
||||
|
||||
|
||||
def _computer_models():
|
||||
"""Lazily import the computers plugin models.
|
||||
@@ -242,21 +245,28 @@ def generic_collect(pluginname):
|
||||
|
||||
plugin, schema = plugins[pluginname]
|
||||
|
||||
# Dual-auth: try client certificate first (per-endpoint mTLS), fall back to
|
||||
# per-plugin env key or collector-scoped managed token. Header only
|
||||
# (querystring fallback dropped, see require_api_key).
|
||||
from shopdb.core.auth.client_cert import authenticate_by_client_cert
|
||||
cert_asset = authenticate_by_client_cert()
|
||||
if cert_asset:
|
||||
g.cert_authenticated_asset_id = cert_asset.assetid
|
||||
current_app.logger.debug(
|
||||
'[collector] Authenticated via client cert for asset %d',
|
||||
cert_asset.assetid)
|
||||
# A schema that accepts agent credentials takes X-Endpoint-Credential and
|
||||
# derives the calling asset from it. Otherwise: per-plugin env key or a
|
||||
# collector-scoped managed token, header only (see require_api_key).
|
||||
from shopdb.api import resolve_agent_credential, agent_shared_key_refused
|
||||
|
||||
acceptscredential = bool(schema.get('acceptsagentcredential'))
|
||||
g.agentauth = None
|
||||
g.agentassetid = None
|
||||
secret = (request.headers.get(AGENT_CREDENTIAL_HEADER) or '').strip()
|
||||
if secret and acceptscredential:
|
||||
assetid = resolve_agent_credential(secret)
|
||||
if assetid is None:
|
||||
return error_response('CREDENTIAL_INVALID',
|
||||
'Endpoint credential is unknown or revoked; '
|
||||
'enroll again', http_code=401)
|
||||
g.agentauth = 'credential'
|
||||
g.agentassetid = assetid
|
||||
else:
|
||||
# No cert or cert invalid: fall back to API key
|
||||
error = _check_collector_auth(_plugin_api_key(pluginname), pluginname)
|
||||
if error is not None:
|
||||
return error
|
||||
g.agentauth = 'sharedkey'
|
||||
|
||||
payload = request.get_json(silent=True)
|
||||
if not payload or not isinstance(payload, dict):
|
||||
@@ -268,6 +278,12 @@ def generic_collect(pluginname):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'{identityfield} is required')
|
||||
|
||||
if (acceptscredential and g.agentauth == 'sharedkey'
|
||||
and agent_shared_key_refused(identityvalue)):
|
||||
return error_response('SHARED_KEY_REFUSED',
|
||||
'The shared key is not accepted for this '
|
||||
'endpoint; use its credential', http_code=401)
|
||||
|
||||
try:
|
||||
outcome = plugin.apply_collector_payload(payload)
|
||||
except NotImplementedError:
|
||||
@@ -275,6 +291,12 @@ def generic_collect(pluginname):
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
f'Plugin {pluginname} does not implement apply_collector_payload',
|
||||
http_code=500)
|
||||
except PermissionError as exc:
|
||||
# The payload names a different asset than the agent credential.
|
||||
db.session.rollback()
|
||||
return error_response('ASSET_MISMATCH', str(exc) or
|
||||
'The payload names a different endpoint than '
|
||||
'the credential', http_code=403)
|
||||
except ValueError as exc:
|
||||
# ValueError is the plugin's controlled validation signal; its message
|
||||
# is safe to return to the caller.
|
||||
|
||||
@@ -61,10 +61,18 @@ def get_plugin_settings_routes():
|
||||
# Only include routes from enabled plugins
|
||||
if not pm.registry.is_enabled(plugin_name):
|
||||
continue
|
||||
if hasattr(plugin, 'get_settings_routes'):
|
||||
if not hasattr(plugin, 'get_settings_routes'):
|
||||
continue
|
||||
# One broken plugin must not take every settings hub down with it.
|
||||
try:
|
||||
plugin_routes = plugin.get_settings_routes()
|
||||
if plugin_routes:
|
||||
routes.extend(plugin_routes)
|
||||
except Exception:
|
||||
current_app.logger.exception(
|
||||
'get_settings_routes failed for plugin %s; skipping it',
|
||||
plugin_name)
|
||||
continue
|
||||
if plugin_routes:
|
||||
routes.extend(plugin_routes)
|
||||
|
||||
return success_response({'routes': routes})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user