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

@@ -121,7 +121,16 @@ from .plugins import plugin_manager
# tokens are always refused by it, matching require_role, so a plugin cannot
# accidentally let a scoped token inherit role authority from its owner.
# Additive name on the surface, minor bump.
__contract_version__ = '0.32.0'
# 0.33.0 adds per-PC agent credentials to the collector contract. BasePlugin
# gains two optional hooks, resolve_agent_credential(secret) and
# agent_shared_key_refused(hostname), and shopdb.api exports
# resolve_agent_credential, agent_shared_key_refused and collector_ingest. A
# collector schema may declare "acceptsagentcredential": true; the generic
# collector then accepts X-Endpoint-Credential for it, refuses the shared key
# for an enrolled PC (401 SHARED_KEY_REFUSED), and maps a PermissionError from
# apply_collector_payload to 403 ASSET_MISMATCH. All optional, so a plugin that
# declares none of it is unaffected. Additive, minor bump.
__contract_version__ = '0.33.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent
@@ -210,10 +219,12 @@ def create_app(config_name: str = None) -> Flask:
# Initialize plugin manager
with app.app_context():
# On first run (no users exist), log the setup token so the operator can
# create the first admin. The token gates POST /api/setup/create-admin to
# prevent a network race during the install window (API starts serving
# before the admin exists in every documented deploy path).
# On first run (no users exist), tell the operator a setup token is
# active and how to read it. The token gates POST /api/setup/create-admin
# to prevent a network race during the install window (API starts
# serving before the admin exists in every documented deploy path). The
# secret itself is never logged: logs are shipped and retained far more
# widely than the .env it came from.
from shopdb.core.models import User
try:
if User.query.count() == 0:
@@ -221,7 +232,8 @@ def create_app(config_name: str = None) -> Flask:
if token:
app.logger.warning('=' * 60)
app.logger.warning('FIRST RUN: No users exist.')
app.logger.warning('Setup token: %s', token)
app.logger.warning('A setup token is active (SETUP_TOKEN). '
'Read it with: flask seed show-setup-token')
app.logger.warning('Create admin: POST /api/setup/create-admin')
app.logger.warning(' with header: X-Setup-Token: <token>')
app.logger.warning('OR use CLI: docker exec shopdb-api flask seed admin')

View File

@@ -100,6 +100,49 @@ def collector_authorized(pluginname: str):
return _check_collector_auth(_plugin_api_key(pluginname), pluginname)
def _enabled_plugins():
from flask import current_app
manager = current_app.extensions.get('plugin_manager')
if not manager:
return []
return [plugin for name, plugin in manager.get_all_plugins().items()
if manager.registry.is_enabled(name)]
def resolve_agent_credential(secret: str) -> Optional[int]:
"""Asset id for an active per-PC agent credential, or None.
Asks every enabled plugin's resolve_agent_credential hook and returns the
first non-None answer.
"""
if not secret:
return None
for plugin in _enabled_plugins():
assetid = plugin.resolve_agent_credential(secret)
if assetid is not None:
return assetid
return None
def agent_shared_key_refused(hostname: str) -> bool:
"""True when any enabled plugin refuses the shared collector key for hostname."""
return any(plugin.agent_shared_key_refused(hostname)
for plugin in _enabled_plugins())
def collector_ingest(pluginname: str):
"""Run the generic collector ingest for a plugin on the current request.
Lets a plugin serve the same ingest under its own prefix (the endpoints
agent posts to /api/endpoints/collect) without importing core collector
internals. Auth, validation, audit and response are exactly those of
POST /api/collector/<pluginname>.
"""
from shopdb.core.api.collector import generic_collect
return generic_collect(pluginname)
def application_package_location():
"""Where application installers live, and the URL prefix that serves them.
@@ -421,6 +464,9 @@ __all__ = [
'list_service_tokens',
'revoke_service_tokens',
'collector_authorized',
'resolve_agent_credential',
'agent_shared_key_refused',
'collector_ingest',
'application_package_location',
'script_body_location',
'SupportTeam',

View File

@@ -205,8 +205,8 @@ def show_setup_token():
"""Display the setup token for first-admin creation.
The token gates POST /api/setup/create-admin so a network race cannot claim
the instance during the install window. Printed at first startup; retrieve
it here if the logs rotated or you lost the terminal.
the instance during the install window. Startup only logs that a token is
active (never the secret itself); this command is where it is read.
"""
import os
from flask import current_app

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

View File

@@ -1,213 +0,0 @@
"""Client certificate authentication helpers.
Extracts client certificate fingerprints from various deployment types:
- IIS 2019: SSL_CLIENT_CERT environ (PEM format)
- Nginx: X-Client-Cert-Fingerprint header (SHA1 hex, colon-separated)
- IIS with ARR: X-ARR-ClientCert header (base64 DER)
SECURITY: Nginx $ssl_client_fingerprint outputs SHA1 (40 hex chars), not SHA256.
This is a known Nginx limitation. SHA1 is deprecated for signing but remains
acceptable for fingerprinting self-signed certificates in air-gapped environments
with short-lived certs and revocation support.
"""
import hashlib
import base64
import hmac
import re
from flask import request, current_app
def get_client_cert_fingerprint():
"""Extract client certificate fingerprint from request.
Returns:
str: 40-character lowercase hex SHA1 fingerprint, or None if no cert
Supports multiple deployment types:
- Nginx: reads X-Client-Cert-Fingerprint header (SHA1, colon-separated)
- IIS direct: reads SSL_CLIENT_CERT environ (PEM format, computes SHA256)
- IIS with ARR: reads X-ARR-ClientCert header (base64 DER, computes SHA256)
SECURITY: Nginx returns SHA1 (40 hex chars). IIS paths compute SHA256 (64 hex
chars). The database client_cert_fingerprint column holds 64 chars to support
future SHA256 migration but currently stores SHA1 from Nginx deployments.
"""
# Option 1: Nginx passes pre-computed SHA1 fingerprint
# Format: "AB:CD:EF:..." (colon-separated uppercase hex)
nginx_fp = request.headers.get('X-Client-Cert-Fingerprint')
if nginx_fp:
# Normalize: remove colons, lowercase
normalized = nginx_fp.replace(':', '').lower()
# Validate format: must be 40 hex chars (SHA1) or 64 hex chars (SHA256)
if len(normalized) == 40:
# SHA1 from nginx $ssl_client_fingerprint
if not re.match(r'^[0-9a-f]{40}$', normalized):
current_app.logger.warning(
f'[client_cert] Invalid SHA1 fingerprint format: {normalized[:16]}...')
return None
return normalized
elif len(normalized) == 64:
# SHA256 from custom nginx config or future migration
if not re.match(r'^[0-9a-f]{64}$', normalized):
current_app.logger.warning(
f'[client_cert] Invalid SHA256 fingerprint format: {normalized[:16]}...')
return None
return normalized
else:
current_app.logger.warning(
f'[client_cert] Invalid fingerprint length: {len(normalized)} '
f'(expected 40 for SHA1 or 64 for SHA256)')
return None
# Option 2: IIS passes full cert in PEM format via WSGI environ
iis_cert_pem = request.environ.get('SSL_CLIENT_CERT')
if iis_cert_pem:
try:
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
# Parse PEM certificate
if isinstance(iis_cert_pem, str):
iis_cert_pem = iis_cert_pem.encode('utf-8')
cert = x509.load_pem_x509_certificate(iis_cert_pem, default_backend())
# Convert to DER and hash
cert_der = cert.public_bytes(serialization.Encoding.DER)
fingerprint = hashlib.sha256(cert_der).hexdigest()
current_app.logger.debug(
f'[client_cert] Extracted from IIS SSL_CLIENT_CERT: {fingerprint[:16]}...')
return fingerprint
except Exception as e:
current_app.logger.warning(f'[client_cert] Failed to parse IIS cert: {e}')
return None
# Option 3: IIS with ARR passes base64-encoded DER
arr_cert_b64 = request.headers.get('X-ARR-ClientCert')
if arr_cert_b64:
try:
# Decode base64 to get DER bytes
cert_der = base64.b64decode(arr_cert_b64)
fingerprint = hashlib.sha256(cert_der).hexdigest()
current_app.logger.debug(
f'[client_cert] Extracted from ARR header: {fingerprint[:16]}...')
return fingerprint
except Exception as e:
current_app.logger.warning(f'[client_cert] Failed to parse ARR cert: {e}')
return None
# No client certificate present
return None
def authenticate_by_client_cert():
"""Authenticate request by client certificate.
Returns:
Asset: The authenticated asset, or None if cert auth fails
Checks:
1. Client cert present in request?
2. Fingerprint matches an asset in database?
3. Certificate not revoked?
4. Certificate not expired?
Does NOT raise exceptions - returns None on any failure.
Caller decides whether to fall back to other auth methods.
AUDIT: Logs all authentication attempts (success and failure) to AuditLog.
"""
from shopdb.api import Asset, AuditLog
from datetime import datetime, timezone
fingerprint = get_client_cert_fingerprint()
if not fingerprint:
return None
# Look up asset by cert fingerprint
asset = Asset.query.filter_by(client_cert_fingerprint=fingerprint).first()
if not asset:
current_app.logger.warning(
f'[client_cert] Unknown fingerprint: {fingerprint[:16]}... '
f'(not associated with any asset)')
# Audit: Failed authentication attempt with unknown fingerprint
try:
AuditLog.log(
action='auth.cert.unknown',
userid=None,
details=f'Unknown fingerprint: {fingerprint[:16]}...')
except Exception:
pass # Don't fail authentication on audit failure
return None
# Constant-time comparison to prevent timing attacks. Database lookup already
# happened (timing leak there is unavoidable with indexed lookups), but this
# prevents attackers from using response timing to confirm partial fingerprint
# matches during the final validation.
if not hmac.compare_digest(asset.client_cert_fingerprint, fingerprint):
current_app.logger.warning(
f'[client_cert] Fingerprint mismatch for asset {asset.assetid} '
f'(database vs request)')
return None
# Check certificate revocation (cert_revoked_at is set when admin revokes)
# Fail closed: if column missing (should never happen after migration 0022),
# treat as database error and reject authentication
if asset.cert_revoked_at:
current_app.logger.warning(
f'[client_cert] Revoked certificate for asset {asset.assetid} '
f'(revoked {asset.cert_revoked_at}: {asset.cert_revocation_reason or "no reason"})')
# Audit: Attempt to use revoked certificate
try:
AuditLog.log(
action='auth.cert.revoked',
userid=None,
entityid=asset.assetid,
entityname=asset.assetnumber,
details=f'Revoked at {asset.cert_revoked_at}: '
f'{asset.cert_revocation_reason or "no reason"}')
except Exception:
pass # Don't fail authentication on audit failure
return None
# Check certificate expiration
if asset.cert_expires_at:
now = datetime.now(timezone.utc).replace(tzinfo=None)
if asset.cert_expires_at < now:
current_app.logger.warning(
f'[client_cert] Expired certificate for asset {asset.assetid} '
f'(expired {asset.cert_expires_at})')
# Audit: Attempt to use expired certificate
try:
AuditLog.log(
action='auth.cert.expired',
userid=None,
entityid=asset.assetid,
entityname=asset.assetnumber,
details=f'Expired {asset.cert_expires_at}')
except Exception:
pass
return None
current_app.logger.info(
f'[client_cert] Authenticated asset {asset.assetid} '
f'(number={asset.assetnumber}) via certificate {fingerprint[:16]}...')
# Audit: Successful certificate authentication
try:
AuditLog.log(
action='auth.cert.success',
userid=None,
entityid=asset.assetid,
entityname=asset.assetnumber,
details=f'Cert fingerprint: {fingerprint[:16]}...')
except Exception:
pass
return asset

View File

@@ -139,34 +139,6 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
# Notes
notes = db.Column(db.Text, nullable=True)
# Client certificate authentication (endpoints plugin)
client_cert_fingerprint = db.Column(
db.String(64),
nullable=True,
index=True,
comment='SHA1 or SHA256 fingerprint of client certificate for mTLS auth'
)
cert_issued_at = db.Column(
db.DateTime,
nullable=True,
comment='When the client certificate was issued'
)
cert_expires_at = db.Column(
db.DateTime,
nullable=True,
comment='When the certificate expires'
)
cert_revoked_at = db.Column(
db.DateTime,
nullable=True,
comment='When the certificate was revoked (null = not revoked)'
)
cert_revocation_reason = db.Column(
db.String(255),
nullable=True,
comment='Why the certificate was revoked (admin note)'
)
# Relationships
assettype = db.relationship('AssetType', backref='assets')
status = db.relationship('AssetStatus', backref='assets')

View File

@@ -58,7 +58,9 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'endpointapplications', 'endpointmonitors',
'operatorreports', 'endpointhealthstates',
'endpointhealthtransitions', 'endpointmonitorimpacts',
'endpointlocks', 'endpointlogcollections'),
'endpointlocks', 'endpointlogcollections',
'endpointcredentials', 'endpointenrollrequests',
'endpointmigrationwaves'),
'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes',
'manifestentryhostnames', 'manifestentrymachinenumbers',
'manifestinusechecks', 'manifestinusecheckprocesses',

View File

@@ -236,6 +236,27 @@ class BasePlugin(ABC):
f"implement apply_collector_payload"
)
def resolve_agent_credential(self, secret: str) -> Optional[int]:
"""Asset id for an active per-PC agent credential, else None.
Optional (contract 0.33.0). A plugin that issues endpoint credentials
answers here so the generic collector can accept them on any schema
declaring 'acceptsagentcredential'. Called via
shopdb.api.resolve_agent_credential, which asks enabled plugins and
takes the first non-None answer.
"""
return None
def agent_shared_key_refused(self, hostname: str) -> bool:
"""True when the shared collector key may not report for this hostname.
Optional (contract 0.33.0). True when the shared key is switched off or
the hostname's asset holds an active per-PC credential. Called via
shopdb.api.agent_shared_key_refused, which is True if any enabled
plugin says so.
"""
return False
def on_install(self, app: Flask) -> None:
"""Called when plugin is installed via CLI."""
pass