diff --git a/plugins/endpoints/api/agentauth.py b/plugins/endpoints/api/agentauth.py
index 6f23b118..52dcdf23 100644
--- a/plugins/endpoints/api/agentauth.py
+++ b/plugins/endpoints/api/agentauth.py
@@ -9,9 +9,15 @@ and, when the route names a PC, binds the caller through bind_hostname().
X-API-Key The shared fleet key (or a collector-scoped managed
token). Accepted only while
plugin.endpoints.sharedkeyallowed is on, and never for a
- PC that holds an active credential. A PC blocked
- from re-enrolling has no active credential, so an
- older agent on it keeps working on the shared key.
+ PC that holds an active credential. A PC whose
+ credential was REVOKED falls back to the shared key,
+ which is how a rollback to an older agent works and how
+ the un-migrated fleet keeps reporting; a site that has
+ finished migrating turns
+ plugin.endpoints.revokeddeniessharedkey on and a revoked
+ PC is then refused until it enrolls again. A PC that
+ never enrolled has no credential row at all and is
+ never touched by either rule.
Only the sha256 of a credential is stored. The full secret exists once, in the
enroll response.
@@ -37,6 +43,12 @@ DISPLAY_PREFIX_LENGTH = 12
SHARED_KEY_SETTING = 'plugin.endpoints.sharedkeyallowed'
AUTO_ENROLL_SETTING = 'plugin.endpoints.autoenroll'
+# Off by default, which is what this site does today: revoking a credential
+# drops the PC back to the shared key. Turning it on makes a revoke a real
+# eviction - the PC reports nothing until it enrolls again - so it is only safe
+# once the fleet is off the shared key, and it also closes the rollback path
+# that revoke + blockreenroll exists for.
+REVOKED_DENIES_SHARED_KEY_SETTING = 'plugin.endpoints.revokeddeniessharedkey'
CODE_CREDENTIAL_INVALID = 'CREDENTIAL_INVALID'
CODE_SHARED_KEY_REFUSED = 'SHARED_KEY_REFUSED'
@@ -70,6 +82,12 @@ def auto_enroll_enabled():
return Setting.get(AUTO_ENROLL_SETTING, default=True) is not False
+def revoked_denies_shared_key():
+ """True when a revoked credential also bars the PC from the shared key."""
+ return Setting.get(REVOKED_DENIES_SHARED_KEY_SETTING,
+ default=False) is True
+
+
def active_credential_for(assetid):
from ..models import EndpointCredential
@@ -81,6 +99,33 @@ def active_credential_for(assetid):
.first())
+def shared_key_denied_for(assetid):
+ """True when this asset may not report on the shared key.
+
+ An active credential always denies it: the PC has its own secret and a
+ shared-key call for it is either an older agent or somebody else.
+
+ A revoked credential denies it only while revokeddeniessharedkey is on.
+ Today it does not, on purpose: revoke is also how an admin rotates a
+ credential or rolls a PC back to an agent that only knows the shared key
+ (revoke + blockreenroll), and denying by default would silence that PC
+ instead. A PC that never enrolled has no credential row and is never denied,
+ which is what keeps the un-migrated fleet reporting.
+ """
+ from ..models import EndpointCredential
+
+ if assetid is None:
+ return False
+ row = (EndpointCredential.query
+ .filter(EndpointCredential.assetid == assetid)
+ .first())
+ if row is None:
+ return False
+ if row.revokedat is None:
+ return True
+ return revoked_denies_shared_key()
+
+
def reenroll_blocked(assetid):
"""True when an admin blocked this asset from enrolling again."""
from ..models import EndpointCredential
@@ -130,8 +175,7 @@ def shared_key_refused_for(hostname):
"""True when the shared key may not report for this hostname."""
if not shared_key_allowed():
return True
- assetid = resolve_hostname(hostname)
- return assetid is not None and active_credential_for(assetid) is not None
+ return shared_key_denied_for(resolve_hostname(hostname))
def credential_invalid_response():
@@ -148,6 +192,20 @@ def shared_key_refused_response():
http_code=401)
+def shared_key_revoked_response():
+ """Same code and status; the operator needs to know which refusal it is.
+
+ The PC holds no credential and cannot use the shared key either, so an
+ admin has to let it enroll again. Nothing changes for the agent, which
+ keeps retrying either way.
+ """
+ return error_response(
+ CODE_SHARED_KEY_REFUSED,
+ 'This endpoint had its credential revoked, so the shared key is not '
+ 'accepted for it; let it enroll again',
+ http_code=401)
+
+
def asset_mismatch_response():
return error_response(
CODE_ASSET_MISMATCH,
@@ -210,7 +268,8 @@ def bind_hostname(hostname):
ASSET_MISMATCH, a hostname on no computer record at all is refused with the
same code but its own message, and a missing hostname is fine.
Shared key: the hostname's asset (None when absent or unknown, for the
- route to answer); an enrolled PC is refused.
+ route to answer); an enrolled PC is refused, and so is one whose credential
+ was revoked while revokeddeniessharedkey is on.
"""
hostname = (hostname or '').strip()
if is_credential_caller():
@@ -225,7 +284,9 @@ def bind_hostname(hostname):
if not hostname:
return None, None
assetid = resolve_hostname(hostname)
- if assetid is not None and active_credential_for(assetid) is not None:
+ if shared_key_denied_for(assetid):
+ if active_credential_for(assetid) is None:
+ return None, shared_key_revoked_response()
return None, shared_key_refused_response()
g.agentassetid = assetid
return assetid, None
diff --git a/plugins/endpoints/api/enrollment.py b/plugins/endpoints/api/enrollment.py
index 972d6ebd..cf4e3ee9 100644
--- a/plugins/endpoints/api/enrollment.py
+++ b/plugins/endpoints/api/enrollment.py
@@ -78,6 +78,14 @@ ENROLL_RATELIMIT_WINDOW_SECONDS = 60
PENDING_CAP_SETTING = 'plugin.endpoints.maxpendingenrollments'
DEFAULT_PENDING_CAP = 500
+# How long a pending request may go untouched before the cap treats it as
+# abandoned. An agent waiting for approval re-asks about once a minute, so a row
+# nothing has touched for a week is a PC that was re-imaged, renamed or scrapped,
+# or a junk row somebody wrote at the open door. At the cap those are dropped to
+# make room before a PC nobody has seen before is refused, so a queue that filled
+# with abandoned rows drains itself instead of blocking every new PC on site.
+PENDING_STALE_AFTER = timedelta(days=7)
+
_NONCE_PATTERN = re.compile(r'^[A-Za-z0-9_-]{32,128}$')
REASON_UNKNOWN_HOSTNAME = 'unknown hostname'
@@ -265,6 +273,38 @@ def _pending_queue_is_full():
return pending >= cap
+def _drop_stale_pending():
+ """Delete pending requests nothing has touched in PENDING_STALE_AFTER.
+
+ Only PENDING rows: an admin decision (approved, rejected, issued) is never
+ aged out, so a rejection keeps standing however long the PC argues with it.
+ Returns how many rows went, for the caller to decide whether the cap is
+ still reached.
+ """
+ cutoff = _naive_utcnow() - PENDING_STALE_AFTER
+ stale = (EndpointEnrollRequest.query
+ .filter(EndpointEnrollRequest.status
+ == EndpointEnrollRequest.STATUS_PENDING,
+ EndpointEnrollRequest.lastseenat < cutoff)
+ .order_by(EndpointEnrollRequest.lastseenat.asc())
+ .all())
+ if not stale:
+ return 0
+ for row in stale:
+ db.session.delete(row)
+ current_app.logger.info(
+ 'endpoint enroll: aged out %d pending requests untouched for %d days',
+ len(stale), PENDING_STALE_AFTER.days)
+ AuditLog.log(
+ 'deleted', 'EndpointEnrollRequest',
+ entityname=stale[0].hostname,
+ details='aged out {0} pending enroll requests untouched for {1} '
+ 'days, to make room under the pending cap'.format(
+ len(stale), PENDING_STALE_AFTER.days))
+ db.session.commit()
+ return len(stale)
+
+
def _pending_queue_full_response(hostname, serialnumber):
"""Refuse a new row, say so plainly in the log and leave an audit trail."""
cap = pending_cap()
@@ -349,15 +389,42 @@ def enroll_endpoint():
if row is None:
# First contact for this hostname and serial. This is the only branch
- # that grows the table, so it is the only one the cap guards.
+ # that grows the table, so it is the only one the cap guards. Every
+ # other contact reuses the one row this identity already owns, which is
+ # what keeps a single PC from holding a queue's worth of them.
if _pending_queue_is_full():
- return _pending_queue_full_response(hostname, serialnumber)
+ # Abandoned rows come out before a real PC is turned away.
+ _drop_stale_pending()
+ if _pending_queue_is_full():
+ return _pending_queue_full_response(hostname, serialnumber)
row = EndpointEnrollRequest(
hostname=hostname, serialnumber=serialnumber, enrollnonce=nonce,
status=EndpointEnrollRequest.STATUS_PENDING, requestedat=now)
db.session.add(row)
else:
samenonce = hmac.compare_digest(row.enrollnonce, nonce)
+ if row.status == EndpointEnrollRequest.STATUS_REJECTED:
+ # A rejection binds to the IDENTITY - this hostname and serial - not
+ # to the nonce it was rejected with. A new installation on the same
+ # PC brings a new nonce, and re-arming the row on it would put the
+ # request back in the queue and, with the shared key and auto-enroll
+ # on, hand the retry the credential an admin had just refused.
+ # Nothing the PC sends clears a rejection; an admin approving the
+ # request does.
+ #
+ # The nonce is still recorded, because a rejected row hands nothing
+ # out and an admin who does approve it later has to bind that
+ # approval to the installation asking NOW, not to the one that was
+ # refused weeks ago.
+ row.enrollnonce = nonce
+ row.lastseenat = now
+ row.sourceip = sourceip
+ if agentversion:
+ row.agentversion = agentversion
+ db.session.commit()
+ return success_response(
+ {'status': 'pending', 'requestid': row.requestid,
+ 'reason': REASON_REJECTED}, http_code=202)
if row.status == EndpointEnrollRequest.STATUS_APPROVED:
row.lastseenat = now
row.sourceip = sourceip
@@ -380,16 +447,10 @@ def enroll_endpoint():
row.lastseenat = now
row.sourceip = sourceip
return _pending(row, REASON_NONCE_CHANGED, row.matchedassetid)
- if row.status == EndpointEnrollRequest.STATUS_REJECTED and samenonce:
- row.lastseenat = now
- row.sourceip = sourceip
- db.session.commit()
- return success_response(
- {'status': 'pending', 'requestid': row.requestid,
- 'reason': REASON_REJECTED}, http_code=202)
if row.status != EndpointEnrollRequest.STATUS_PENDING:
- # Issued earlier (credential since revoked) or rejected for another
- # installation: a new enrollment cycle bound to this nonce.
+ # Issued earlier and the credential has since been revoked: a new
+ # enrollment cycle bound to this nonce. Rejected rows never reach
+ # here - they returned above, whatever nonce they carried.
row.enrollnonce = nonce
row.requestedat = now
row.decidedat = None
@@ -574,6 +635,7 @@ def _page(rows):
def _settings_payload():
return {'sharedkeyallowed': agentauth.shared_key_allowed(),
'autoenroll': agentauth.auto_enroll_enabled(),
+ 'revokeddeniessharedkey': agentauth.revoked_denies_shared_key(),
'maxpendingenrollments': pending_cap()}
@@ -667,6 +729,11 @@ def approve_enroll_request(requestid):
The pending reason for a one-off PC is by definition `unknown hostname`, so
this is the normal case, not the exception.
+ Approving is also the only way out of a rejection: a rejected request keeps
+ refusing its PC whatever nonce it sends, and this is the deliberate admin
+ step that lifts it. The approval binds to the nonce of that PC's most recent
+ contact, so an agent reinstalled while it was refused still collects it.
+
Body: {"assetid": 123, "sethostname": false}. A computer with no hostname
is stamped with the request's. A computer carrying a DIFFERENT hostname is
refused with 409 naming both, until the caller repeats the approval with
@@ -734,6 +801,7 @@ def approve_enroll_request(requestid):
details='hostname {0} from enroll request {1}'.format(
hostnameresult, row.requestid))
+ wasrejected = row.status == EndpointEnrollRequest.STATUS_REJECTED
row.status = EndpointEnrollRequest.STATUS_APPROVED
row.matchedassetid = assetid
row.reason = None
@@ -741,8 +809,9 @@ def approve_enroll_request(requestid):
row.decidedby = _username()
AuditLog.log('updated', 'EndpointEnrollRequest', entityid=row.requestid,
entityname=row.hostname,
- details='enroll request approved for asset {0}, hostname {1}'
- .format(assetid, hostnameresult))
+ details='enroll request {0} for asset {1}, hostname {2}'
+ .format('approved out of a rejection' if wasrejected
+ else 'approved', assetid, hostnameresult))
db.session.commit()
approved = row.to_dict()
approved['hostnameresult'] = hostnameresult
@@ -780,6 +849,11 @@ def revoke_endpoint_credential(assetid):
before rolling a PC back to an agent that only knows the shared key: without
it the current agent re-enrolls within seconds and the older agent is then
refused. A PC whose credential is already revoked can still be blocked.
+
+ Whether a revoked PC may still report on the shared key is the site setting
+ plugin.endpoints.revokeddeniessharedkey. It is off here, so today it can -
+ which is what makes the rollback above work, and what keeps a rotation from
+ silencing the PC while it waits for approval.
"""
payload = request.get_json(silent=True) or {}
if not isinstance(payload, dict):
@@ -853,12 +927,24 @@ def allow_endpoint_reenroll(assetid):
@endpoints_bp.route('/enrollment/settings', methods=['PUT'])
@require_role('admin')
def update_enrollment_settings():
+ """Change the enrollment settings.
+
+ revokeddeniessharedkey is off by default, which is what the fleet does
+ today: a PC whose credential was revoked falls back to the shared key, so a
+ credential rotation, and the rollback to an older agent that revoke +
+ blockreenroll exists for, both keep reporting. Turning it on makes a revoke
+ an eviction - that PC reports nothing until it is allowed to enroll again -
+ and is only safe once every PC here holds its own credential.
+ """
from shopdb.api import Setting
payload = request.get_json(silent=True) or {}
changes = {}
- for key, settingkey in (('sharedkeyallowed', agentauth.SHARED_KEY_SETTING),
- ('autoenroll', agentauth.AUTO_ENROLL_SETTING)):
+ for key, settingkey in (
+ ('sharedkeyallowed', agentauth.SHARED_KEY_SETTING),
+ ('autoenroll', agentauth.AUTO_ENROLL_SETTING),
+ ('revokeddeniessharedkey',
+ agentauth.REVOKED_DENIES_SHARED_KEY_SETTING)):
if key not in payload:
continue
if not isinstance(payload[key], bool):
@@ -878,7 +964,8 @@ def update_enrollment_settings():
if not changes:
return error_response(
ErrorCodes.VALIDATION_ERROR,
- 'Send sharedkeyallowed, autoenroll and/or maxpendingenrollments')
+ 'Send sharedkeyallowed, autoenroll, revokeddeniessharedkey '
+ 'and/or maxpendingenrollments')
before = _settings_payload()
for key, (settingkey, value, valuetype) in changes.items():
diff --git a/plugins/endpoints/frontend/views/EnrollmentSettings.vue b/plugins/endpoints/frontend/views/EnrollmentSettings.vue
index d0da4eca..0915b6c0 100644
--- a/plugins/endpoints/frontend/views/EnrollmentSettings.vue
+++ b/plugins/endpoints/frontend/views/EnrollmentSettings.vue
@@ -229,6 +229,27 @@
on record matches. Anything else waits below for an admin.
+