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:
cproudlock
2026-09-15 12:03:53 -04:00
parent f484be8a33
commit 7480e4fe3a
58 changed files with 4485 additions and 2032 deletions

View File

@@ -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 = (

View File

@@ -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.

View File

@@ -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})