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