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