Make an admin's refusal stick, and let a revoke actually deny
Three holes in the enrollment path, all of the same shape: a decision the admin made did not survive contact with a PC that simply asked again. REJECTION WAS UNDONE BY A NEW NONCE. A rejected request re-armed itself as a fresh cycle when the PC came back with a different nonce, and with the shared key and auto-enroll on it was then issued the credential the admin had just refused. Rejection now binds to the endpoint identity - hostname plus BIOS serial - not to the nonce, so the PC stays refused however often it reinstalls. An admin clears it deliberately, through the approve path that already exists; a genuinely different PC is untouched. THE PENDING QUEUE COULD BE FILLED UNTIL IT REFUSED EVERY NEW PC. /enroll takes no auth by design - a bay that has never spoken to the server has nothing to authenticate with - so anything that can reach the server could sit at the cap and lock out real enrollments fleet-wide. Rows nobody has touched for seven days now age out before the cap refuses anyone, and the count of what went is audited. Per-identity dedup and the cap itself were already there and are left alone. REVOKE DID NOT STOP A LIVE AGENT. It fell back to the shared key and the server admitted it again, so revoking a compromised PC changed nothing while that key still worked. plugin.endpoints.revokeddeniessharedkey makes a revoked host refused on the shared key too. That switch DEFAULTS OFF, and that is the important part. Turning it on would silence any bay revoked for a key rotation, and would break the documented revoke-then-rollback path to an older agent - on an air-gapped site, mid migration, that is a fleet outage. A never-enrolled bay is never affected either way: with no credential row at all the shared key still works, which is the whole un-migrated fleet. The checkbox sits next to the other two enrollment switches and says what turning it on costs, because a setting that can stop the floor should not be reachable only through the API.
This commit is contained in:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user