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
|
X-API-Key The shared fleet key (or a collector-scoped managed
|
||||||
token). Accepted only while
|
token). Accepted only while
|
||||||
plugin.endpoints.sharedkeyallowed is on, and never for a
|
plugin.endpoints.sharedkeyallowed is on, and never for a
|
||||||
PC that holds an active credential. A PC blocked
|
PC that holds an active credential. A PC whose
|
||||||
from re-enrolling has no active credential, so an
|
credential was REVOKED falls back to the shared key,
|
||||||
older agent on it keeps working on 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
|
Only the sha256 of a credential is stored. The full secret exists once, in the
|
||||||
enroll response.
|
enroll response.
|
||||||
@@ -37,6 +43,12 @@ DISPLAY_PREFIX_LENGTH = 12
|
|||||||
|
|
||||||
SHARED_KEY_SETTING = 'plugin.endpoints.sharedkeyallowed'
|
SHARED_KEY_SETTING = 'plugin.endpoints.sharedkeyallowed'
|
||||||
AUTO_ENROLL_SETTING = 'plugin.endpoints.autoenroll'
|
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_CREDENTIAL_INVALID = 'CREDENTIAL_INVALID'
|
||||||
CODE_SHARED_KEY_REFUSED = 'SHARED_KEY_REFUSED'
|
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
|
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):
|
def active_credential_for(assetid):
|
||||||
from ..models import EndpointCredential
|
from ..models import EndpointCredential
|
||||||
|
|
||||||
@@ -81,6 +99,33 @@ def active_credential_for(assetid):
|
|||||||
.first())
|
.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):
|
def reenroll_blocked(assetid):
|
||||||
"""True when an admin blocked this asset from enrolling again."""
|
"""True when an admin blocked this asset from enrolling again."""
|
||||||
from ..models import EndpointCredential
|
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."""
|
"""True when the shared key may not report for this hostname."""
|
||||||
if not shared_key_allowed():
|
if not shared_key_allowed():
|
||||||
return True
|
return True
|
||||||
assetid = resolve_hostname(hostname)
|
return shared_key_denied_for(resolve_hostname(hostname))
|
||||||
return assetid is not None and active_credential_for(assetid) is not None
|
|
||||||
|
|
||||||
|
|
||||||
def credential_invalid_response():
|
def credential_invalid_response():
|
||||||
@@ -148,6 +192,20 @@ def shared_key_refused_response():
|
|||||||
http_code=401)
|
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():
|
def asset_mismatch_response():
|
||||||
return error_response(
|
return error_response(
|
||||||
CODE_ASSET_MISMATCH,
|
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
|
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.
|
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
|
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()
|
hostname = (hostname or '').strip()
|
||||||
if is_credential_caller():
|
if is_credential_caller():
|
||||||
@@ -225,7 +284,9 @@ def bind_hostname(hostname):
|
|||||||
if not hostname:
|
if not hostname:
|
||||||
return None, None
|
return None, None
|
||||||
assetid = resolve_hostname(hostname)
|
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()
|
return None, shared_key_refused_response()
|
||||||
g.agentassetid = assetid
|
g.agentassetid = assetid
|
||||||
return assetid, None
|
return assetid, None
|
||||||
|
|||||||
@@ -78,6 +78,14 @@ ENROLL_RATELIMIT_WINDOW_SECONDS = 60
|
|||||||
PENDING_CAP_SETTING = 'plugin.endpoints.maxpendingenrollments'
|
PENDING_CAP_SETTING = 'plugin.endpoints.maxpendingenrollments'
|
||||||
DEFAULT_PENDING_CAP = 500
|
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}$')
|
_NONCE_PATTERN = re.compile(r'^[A-Za-z0-9_-]{32,128}$')
|
||||||
|
|
||||||
REASON_UNKNOWN_HOSTNAME = 'unknown hostname'
|
REASON_UNKNOWN_HOSTNAME = 'unknown hostname'
|
||||||
@@ -265,6 +273,38 @@ def _pending_queue_is_full():
|
|||||||
return pending >= cap
|
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):
|
def _pending_queue_full_response(hostname, serialnumber):
|
||||||
"""Refuse a new row, say so plainly in the log and leave an audit trail."""
|
"""Refuse a new row, say so plainly in the log and leave an audit trail."""
|
||||||
cap = pending_cap()
|
cap = pending_cap()
|
||||||
@@ -349,15 +389,42 @@ def enroll_endpoint():
|
|||||||
|
|
||||||
if row is None:
|
if row is None:
|
||||||
# First contact for this hostname and serial. This is the only branch
|
# 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():
|
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(
|
row = EndpointEnrollRequest(
|
||||||
hostname=hostname, serialnumber=serialnumber, enrollnonce=nonce,
|
hostname=hostname, serialnumber=serialnumber, enrollnonce=nonce,
|
||||||
status=EndpointEnrollRequest.STATUS_PENDING, requestedat=now)
|
status=EndpointEnrollRequest.STATUS_PENDING, requestedat=now)
|
||||||
db.session.add(row)
|
db.session.add(row)
|
||||||
else:
|
else:
|
||||||
samenonce = hmac.compare_digest(row.enrollnonce, nonce)
|
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:
|
if row.status == EndpointEnrollRequest.STATUS_APPROVED:
|
||||||
row.lastseenat = now
|
row.lastseenat = now
|
||||||
row.sourceip = sourceip
|
row.sourceip = sourceip
|
||||||
@@ -380,16 +447,10 @@ def enroll_endpoint():
|
|||||||
row.lastseenat = now
|
row.lastseenat = now
|
||||||
row.sourceip = sourceip
|
row.sourceip = sourceip
|
||||||
return _pending(row, REASON_NONCE_CHANGED, row.matchedassetid)
|
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:
|
if row.status != EndpointEnrollRequest.STATUS_PENDING:
|
||||||
# Issued earlier (credential since revoked) or rejected for another
|
# Issued earlier and the credential has since been revoked: a new
|
||||||
# installation: a new enrollment cycle bound to this nonce.
|
# enrollment cycle bound to this nonce. Rejected rows never reach
|
||||||
|
# here - they returned above, whatever nonce they carried.
|
||||||
row.enrollnonce = nonce
|
row.enrollnonce = nonce
|
||||||
row.requestedat = now
|
row.requestedat = now
|
||||||
row.decidedat = None
|
row.decidedat = None
|
||||||
@@ -574,6 +635,7 @@ def _page(rows):
|
|||||||
def _settings_payload():
|
def _settings_payload():
|
||||||
return {'sharedkeyallowed': agentauth.shared_key_allowed(),
|
return {'sharedkeyallowed': agentauth.shared_key_allowed(),
|
||||||
'autoenroll': agentauth.auto_enroll_enabled(),
|
'autoenroll': agentauth.auto_enroll_enabled(),
|
||||||
|
'revokeddeniessharedkey': agentauth.revoked_denies_shared_key(),
|
||||||
'maxpendingenrollments': pending_cap()}
|
'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
|
The pending reason for a one-off PC is by definition `unknown hostname`, so
|
||||||
this is the normal case, not the exception.
|
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
|
Body: {"assetid": 123, "sethostname": false}. A computer with no hostname
|
||||||
is stamped with the request's. A computer carrying a DIFFERENT hostname is
|
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
|
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(
|
details='hostname {0} from enroll request {1}'.format(
|
||||||
hostnameresult, row.requestid))
|
hostnameresult, row.requestid))
|
||||||
|
|
||||||
|
wasrejected = row.status == EndpointEnrollRequest.STATUS_REJECTED
|
||||||
row.status = EndpointEnrollRequest.STATUS_APPROVED
|
row.status = EndpointEnrollRequest.STATUS_APPROVED
|
||||||
row.matchedassetid = assetid
|
row.matchedassetid = assetid
|
||||||
row.reason = None
|
row.reason = None
|
||||||
@@ -741,8 +809,9 @@ def approve_enroll_request(requestid):
|
|||||||
row.decidedby = _username()
|
row.decidedby = _username()
|
||||||
AuditLog.log('updated', 'EndpointEnrollRequest', entityid=row.requestid,
|
AuditLog.log('updated', 'EndpointEnrollRequest', entityid=row.requestid,
|
||||||
entityname=row.hostname,
|
entityname=row.hostname,
|
||||||
details='enroll request approved for asset {0}, hostname {1}'
|
details='enroll request {0} for asset {1}, hostname {2}'
|
||||||
.format(assetid, hostnameresult))
|
.format('approved out of a rejection' if wasrejected
|
||||||
|
else 'approved', assetid, hostnameresult))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
approved = row.to_dict()
|
approved = row.to_dict()
|
||||||
approved['hostnameresult'] = hostnameresult
|
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
|
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
|
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.
|
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 {}
|
payload = request.get_json(silent=True) or {}
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
@@ -853,12 +927,24 @@ def allow_endpoint_reenroll(assetid):
|
|||||||
@endpoints_bp.route('/enrollment/settings', methods=['PUT'])
|
@endpoints_bp.route('/enrollment/settings', methods=['PUT'])
|
||||||
@require_role('admin')
|
@require_role('admin')
|
||||||
def update_enrollment_settings():
|
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
|
from shopdb.api import Setting
|
||||||
|
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
changes = {}
|
changes = {}
|
||||||
for key, settingkey in (('sharedkeyallowed', agentauth.SHARED_KEY_SETTING),
|
for key, settingkey in (
|
||||||
('autoenroll', agentauth.AUTO_ENROLL_SETTING)):
|
('sharedkeyallowed', agentauth.SHARED_KEY_SETTING),
|
||||||
|
('autoenroll', agentauth.AUTO_ENROLL_SETTING),
|
||||||
|
('revokeddeniessharedkey',
|
||||||
|
agentauth.REVOKED_DENIES_SHARED_KEY_SETTING)):
|
||||||
if key not in payload:
|
if key not in payload:
|
||||||
continue
|
continue
|
||||||
if not isinstance(payload[key], bool):
|
if not isinstance(payload[key], bool):
|
||||||
@@ -878,7 +964,8 @@ def update_enrollment_settings():
|
|||||||
if not changes:
|
if not changes:
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCodes.VALIDATION_ERROR,
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
'Send sharedkeyallowed, autoenroll and/or maxpendingenrollments')
|
'Send sharedkeyallowed, autoenroll, revokeddeniessharedkey '
|
||||||
|
'and/or maxpendingenrollments')
|
||||||
|
|
||||||
before = _settings_payload()
|
before = _settings_payload()
|
||||||
for key, (settingkey, value, valuetype) in changes.items():
|
for key, (settingkey, value, valuetype) in changes.items():
|
||||||
|
|||||||
@@ -229,6 +229,27 @@
|
|||||||
on record matches. Anything else waits below for an admin.
|
on record matches. Anything else waits below for an admin.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="toggle-revokeddenies"
|
||||||
|
:checked="enrollmentSettings.revokeddeniessharedkey"
|
||||||
|
:disabled="!isadmin || busy"
|
||||||
|
@change="onRevokedDeniesToggle($event)"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>A revoked PC loses the shared key too.</strong> (setting
|
||||||
|
<code>plugin.endpoints.revokeddeniessharedkey</code>) Off by
|
||||||
|
default, which is what the fleet does today: a PC whose credential
|
||||||
|
was revoked falls back to the shared key and keeps reporting, so a
|
||||||
|
credential rotation does not strand it. Turn this on and a revoke
|
||||||
|
becomes an eviction - that PC reports nothing and receives no
|
||||||
|
commands until it enrolls again - and the documented rollback of
|
||||||
|
revoke plus block re-enrollment onto an older agent that only knows
|
||||||
|
the shared key stops working. Only safe once every PC here is
|
||||||
|
enrolled.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label class="number-row">
|
<label class="number-row">
|
||||||
<span>
|
<span>
|
||||||
<strong>Pending request limit.</strong> How many requests may wait
|
<strong>Pending request limit.</strong> How many requests may wait
|
||||||
@@ -670,6 +691,9 @@ const DEFAULT_PENDING_CAP = 500
|
|||||||
const enrollmentSettings = ref({
|
const enrollmentSettings = ref({
|
||||||
sharedkeyallowed: true,
|
sharedkeyallowed: true,
|
||||||
autoenroll: true,
|
autoenroll: true,
|
||||||
|
// Matches the seeded default (plugin.endpoints.revokeddeniessharedkey is
|
||||||
|
// false), so the box renders unchecked before the first read lands.
|
||||||
|
revokeddeniessharedkey: false,
|
||||||
maxpendingenrollments: DEFAULT_PENDING_CAP
|
maxpendingenrollments: DEFAULT_PENDING_CAP
|
||||||
})
|
})
|
||||||
// Bound with v-model rather than :value so the field holds what the operator
|
// Bound with v-model rather than :value so the field holds what the operator
|
||||||
@@ -697,6 +721,8 @@ async function loadEnrollment({ quiet = false } = {}) {
|
|||||||
enrollmentSettings.value = {
|
enrollmentSettings.value = {
|
||||||
sharedkeyallowed: data.settings?.sharedkeyallowed !== false,
|
sharedkeyallowed: data.settings?.sharedkeyallowed !== false,
|
||||||
autoenroll: data.settings?.autoenroll !== false,
|
autoenroll: data.settings?.autoenroll !== false,
|
||||||
|
// Opt-in, so anything but an explicit true reads as off.
|
||||||
|
revokeddeniessharedkey: data.settings?.revokeddeniessharedkey === true,
|
||||||
maxpendingenrollments: Number.isFinite(Number(data.settings?.maxpendingenrollments))
|
maxpendingenrollments: Number.isFinite(Number(data.settings?.maxpendingenrollments))
|
||||||
? Number(data.settings.maxpendingenrollments)
|
? Number(data.settings.maxpendingenrollments)
|
||||||
: DEFAULT_PENDING_CAP
|
: DEFAULT_PENDING_CAP
|
||||||
@@ -771,6 +797,26 @@ function onAutoEnrollToggle(event) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onRevokedDeniesToggle(event) {
|
||||||
|
const turningOn = event.target.checked
|
||||||
|
if (turningOn) {
|
||||||
|
// Typed confirm, like the shared-key switch: this one silences PCs that
|
||||||
|
// are reporting right now, and the revoke-plus-rollback recipe in the
|
||||||
|
// runbook stops working the moment it is on.
|
||||||
|
const answer = prompt(
|
||||||
|
'Deny the shared key to revoked PCs?\n\n'
|
||||||
|
+ 'Any PC whose credential was revoked stops reporting and stops '
|
||||||
|
+ 'receiving commands until it enrolls again. The revoke + block '
|
||||||
|
+ 're-enrollment rollback onto an older agent no longer works.\n\n'
|
||||||
|
+ 'Type DENY to confirm.')
|
||||||
|
if (!answer || answer.trim().toUpperCase() !== 'DENY') {
|
||||||
|
event.target.checked = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return saveSettings({ revokeddeniessharedkey: turningOn })
|
||||||
|
}
|
||||||
|
|
||||||
function onPendingCapChange() {
|
function onPendingCapChange() {
|
||||||
const wanted = Math.trunc(Number(pendingCapInput.value))
|
const wanted = Math.trunc(Number(pendingCapInput.value))
|
||||||
if (!Number.isFinite(wanted) || wanted < 0) {
|
if (!Number.isFinite(wanted) || wanted < 0) {
|
||||||
|
|||||||
@@ -4,16 +4,25 @@
|
|||||||
One row per asset. Holds only the sha256 of the secret the
|
One row per asset. Holds only the sha256 of the secret the
|
||||||
agent was issued, plus a short display prefix. Revoking sets
|
agent was issued, plus a short display prefix. Revoking sets
|
||||||
revokedat; re-issuing after a revoke UPDATES the same row, so
|
revokedat; re-issuing after a revoke UPDATES the same row, so
|
||||||
an asset never carries two credentials. reenrollblocked keeps
|
an asset never carries two credentials. The row therefore also
|
||||||
|
says that this PC has enrolled at least once, which is how
|
||||||
|
agentauth tells a revoked PC from one that never enrolled:
|
||||||
|
plugin.endpoints.revokeddeniessharedkey refuses the shared key
|
||||||
|
to the first and never to the second. reenrollblocked keeps
|
||||||
a revoked PC from enrolling again (rolling it back to an agent
|
a revoked PC from enrolling again (rolling it back to an agent
|
||||||
that only knows the shared key, or a compromised PC) until an
|
that only knows the shared key, or a compromised PC) until an
|
||||||
admin allows it.
|
admin allows it.
|
||||||
|
|
||||||
EndpointEnrollRequest
|
EndpointEnrollRequest
|
||||||
One row per (hostname, serial number) that asked to enroll. The
|
One row per (hostname, serial number) that asked to enroll -
|
||||||
agent's enrollment nonce is recorded on first sight and is what
|
the endpoint's identity, and the reason every contact from one
|
||||||
an admin approval is bound to, so a second machine claiming the
|
PC lands on one row. The agent's enrollment nonce is recorded
|
||||||
same hostname and serial cannot collect the approved credential.
|
on first sight and is what an admin approval is bound to, so a
|
||||||
|
second machine claiming the same hostname and serial cannot
|
||||||
|
collect the approved credential. A REJECTED row binds to the
|
||||||
|
identity instead: any nonce from that hostname and serial stays
|
||||||
|
rejected, so a reinstall cannot argue its way back into the
|
||||||
|
queue. Only an admin approving the request clears it.
|
||||||
|
|
||||||
EndpointMigrationWave
|
EndpointMigrationWave
|
||||||
An admin action that pushed the agent upgrade to a set of PCs.
|
An admin action that pushed the agent upgrade to a set of PCs.
|
||||||
|
|||||||
@@ -689,6 +689,7 @@ def test_enrollment_listing_and_settings(client, db, app, keyheaders,
|
|||||||
body = response.get_json()
|
body = response.get_json()
|
||||||
data = body['data']
|
data = body['data']
|
||||||
assert data['settings'] == {'sharedkeyallowed': True, 'autoenroll': True,
|
assert data['settings'] == {'sharedkeyallowed': True, 'autoenroll': True,
|
||||||
|
'revokeddeniessharedkey': False,
|
||||||
'maxpendingenrollments': 500}
|
'maxpendingenrollments': 500}
|
||||||
assert data['counts'] == {'enrolled': 1, 'notenrolled': 1,
|
assert data['counts'] == {'enrolled': 1, 'notenrolled': 1,
|
||||||
'pendingcomputers': 1, 'pending': 1}
|
'pendingcomputers': 1, 'pending': 1}
|
||||||
@@ -711,6 +712,7 @@ def test_enrollment_listing_and_settings(client, db, app, keyheaders,
|
|||||||
assert put.status_code == 200
|
assert put.status_code == 200
|
||||||
assert put.get_json()['data'] == {'sharedkeyallowed': False,
|
assert put.get_json()['data'] == {'sharedkeyallowed': False,
|
||||||
'autoenroll': True,
|
'autoenroll': True,
|
||||||
|
'revokeddeniessharedkey': False,
|
||||||
'maxpendingenrollments': 500}
|
'maxpendingenrollments': 500}
|
||||||
bad = client.put('/api/endpoints/enrollment/settings',
|
bad = client.put('/api/endpoints/enrollment/settings',
|
||||||
json={'autoenroll': 'yes'}, headers=auth_headers)
|
json={'autoenroll': 'yes'}, headers=auth_headers)
|
||||||
|
|||||||
352
tests/test_plugins/test_endpoints_enrollment_denials.py
Normal file
352
tests/test_plugins/test_endpoints_enrollment_denials.py
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
"""What a denial means at the enrollment door, and how long it lasts.
|
||||||
|
|
||||||
|
Three denials meet here, and each one used to be undone by the PC on the other
|
||||||
|
side of it:
|
||||||
|
|
||||||
|
reject An admin refuses a request. The agent then sends a new nonce and
|
||||||
|
the row was re-armed as a fresh cycle - which, with the shared key
|
||||||
|
and auto-enroll on, auto-issued the credential the admin had just
|
||||||
|
refused. A rejection binds to the identity (hostname + serial), so
|
||||||
|
it survives any nonce; only an admin approving it clears it.
|
||||||
|
cap The pending queue is bounded, and at the cap a PC nobody has seen
|
||||||
|
before is refused. Abandoned rows come out first, so a queue that
|
||||||
|
filled with junk drains itself instead of blocking every new PC.
|
||||||
|
revoke A revoked credential falls back to the shared key, which is how
|
||||||
|
the un-migrated fleet and the rollback to an older agent keep
|
||||||
|
reporting. plugin.endpoints.revokeddeniessharedkey (off by
|
||||||
|
default) turns a revoke into an eviction instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from shopdb.core.models import Asset, AssetType, Setting
|
||||||
|
|
||||||
|
from plugins.endpoints.api import agentauth
|
||||||
|
from plugins.endpoints.api.enrollment import (
|
||||||
|
PENDING_CAP_SETTING, PENDING_STALE_AFTER,
|
||||||
|
)
|
||||||
|
from plugins.endpoints.models import EndpointCredential, EndpointEnrollRequest
|
||||||
|
|
||||||
|
|
||||||
|
COLLECTOR_KEY = 'testcollectorkey'
|
||||||
|
ENROLL_URL = '/api/endpoints/enroll'
|
||||||
|
ALERTS_URL = '/api/endpoints/alerts'
|
||||||
|
NONCE = 'n' * 40
|
||||||
|
OTHER_NONCE = 'm' * 40
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def keyheaders(app):
|
||||||
|
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
|
||||||
|
return {'X-API-Key': COLLECTOR_KEY}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def nolimiter(app):
|
||||||
|
"""The per-IP limiter counts in a process-wide cache that outlives a test.
|
||||||
|
|
||||||
|
Nothing here is about how FAST a PC asks, and another test in the same
|
||||||
|
worker may already have spent the budget for this window and address, so the
|
||||||
|
limiter is off for this module and put back as it was.
|
||||||
|
"""
|
||||||
|
key = 'ENDPOINTS_ENROLL_RATELIMIT_ENABLED'
|
||||||
|
previous = app.config.get(key)
|
||||||
|
app.config[key] = False
|
||||||
|
yield
|
||||||
|
if previous is None:
|
||||||
|
app.config.pop(key, None)
|
||||||
|
else:
|
||||||
|
app.config[key] = previous
|
||||||
|
|
||||||
|
|
||||||
|
def _computer(db, hostname, serialnumber='SER-1'):
|
||||||
|
from plugins.computers.models import Computer
|
||||||
|
|
||||||
|
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||||
|
if atype is None:
|
||||||
|
atype = AssetType(assettype='computer')
|
||||||
|
db.session.add(atype)
|
||||||
|
db.session.flush()
|
||||||
|
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
|
||||||
|
serialnumber=serialnumber, isactive=True)
|
||||||
|
db.session.add(asset)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||||
|
db.session.commit()
|
||||||
|
return asset.assetid
|
||||||
|
|
||||||
|
|
||||||
|
def _body(hostname='BAY-01', serialnumber='SER-1', nonce=NONCE):
|
||||||
|
return {'hostname': hostname, 'serialnumber': serialnumber,
|
||||||
|
'enrollnonce': nonce, 'agentversion': '1.20.0'}
|
||||||
|
|
||||||
|
|
||||||
|
def _enroll(client, headers, **kwargs):
|
||||||
|
return client.post(ENROLL_URL, json=_body(**kwargs), headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_request(client, db, keyheaders, **kwargs):
|
||||||
|
"""A request waiting for an admin, with auto-enroll back on afterwards.
|
||||||
|
|
||||||
|
Auto-enroll off is what puts a clean match in the queue instead of issuing
|
||||||
|
at once; turning it back on is what made the old code hand the retry a
|
||||||
|
credential, so every rejection test needs both halves.
|
||||||
|
"""
|
||||||
|
_setting(db, 'plugin.endpoints.autoenroll', False)
|
||||||
|
response = _enroll(client, keyheaders, **kwargs)
|
||||||
|
assert response.status_code == 202, response.get_data(as_text=True)
|
||||||
|
_setting(db, 'plugin.endpoints.autoenroll', True)
|
||||||
|
return response.get_json()['data']['requestid']
|
||||||
|
|
||||||
|
|
||||||
|
def _reject(client, auth_headers, requestid):
|
||||||
|
return client.post(
|
||||||
|
f'/api/endpoints/enrollment/requests/{requestid}/reject',
|
||||||
|
headers=auth_headers)
|
||||||
|
|
||||||
|
|
||||||
|
def _setting(db, key, value, valuetype='boolean'):
|
||||||
|
Setting.set(key, value, valuetype=valuetype, category='plugin.endpoints')
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _code(response):
|
||||||
|
return response.get_json()['data']['error']['code']
|
||||||
|
|
||||||
|
|
||||||
|
# -- a rejection binds to the identity ---------------------------------------
|
||||||
|
|
||||||
|
def test_a_new_nonce_does_not_lift_a_rejection(client, db, app, keyheaders,
|
||||||
|
auth_headers):
|
||||||
|
"""The bug: reinstall the agent and the retry was auto-issued the
|
||||||
|
credential an admin had just refused."""
|
||||||
|
_computer(db, 'BAY-01')
|
||||||
|
requestid = _pending_request(client, db, keyheaders)
|
||||||
|
assert _reject(client, auth_headers, requestid).status_code == 200
|
||||||
|
|
||||||
|
retry = _enroll(client, keyheaders, nonce=OTHER_NONCE)
|
||||||
|
|
||||||
|
assert retry.status_code == 202, retry.get_data(as_text=True)
|
||||||
|
assert retry.get_json()['data']['reason'] == 'rejected'
|
||||||
|
assert retry.get_json()['data']['requestid'] == requestid
|
||||||
|
assert EndpointCredential.query.count() == 0
|
||||||
|
row = db.session.get(EndpointEnrollRequest, requestid)
|
||||||
|
assert row.status == EndpointEnrollRequest.STATUS_REJECTED
|
||||||
|
assert row.decidedat is not None
|
||||||
|
# Still one row for this identity: the retry did not queue a second.
|
||||||
|
assert EndpointEnrollRequest.query.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_rejection_survives_however_often_the_pc_asks(client, db, app,
|
||||||
|
keyheaders,
|
||||||
|
auth_headers):
|
||||||
|
_computer(db, 'BAY-01')
|
||||||
|
requestid = _pending_request(client, db, keyheaders)
|
||||||
|
_reject(client, auth_headers, requestid)
|
||||||
|
|
||||||
|
for index in range(5):
|
||||||
|
again = _enroll(client, keyheaders, nonce=('x%d' % index) * 20)
|
||||||
|
assert again.status_code == 202
|
||||||
|
assert again.get_json()['data']['reason'] == 'rejected'
|
||||||
|
|
||||||
|
assert EndpointCredential.query.count() == 0
|
||||||
|
assert EndpointEnrollRequest.query.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_rejection_does_not_touch_a_different_pc(client, db, app,
|
||||||
|
keyheaders, auth_headers):
|
||||||
|
"""Identity is hostname AND serial, so one PC's rejection is its own."""
|
||||||
|
_computer(db, 'BAY-01', serialnumber='SER-1')
|
||||||
|
other = _computer(db, 'BAY-02', serialnumber='SER-2')
|
||||||
|
requestid = _pending_request(client, db, keyheaders)
|
||||||
|
_reject(client, auth_headers, requestid)
|
||||||
|
|
||||||
|
clean = _enroll(client, keyheaders, hostname='BAY-02',
|
||||||
|
serialnumber='SER-2')
|
||||||
|
|
||||||
|
assert clean.status_code == 201, clean.get_data(as_text=True)
|
||||||
|
assert EndpointCredential.query.filter_by(assetid=other).one() \
|
||||||
|
.revokedat is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_admin_clears_a_rejection_by_approving_it(client, db, app,
|
||||||
|
keyheaders, auth_headers):
|
||||||
|
"""The way back is the admin's, and it works for the reinstalled agent that
|
||||||
|
now carries a different nonce."""
|
||||||
|
assetid = _computer(db, 'BAY-01')
|
||||||
|
requestid = _pending_request(client, db, keyheaders)
|
||||||
|
_reject(client, auth_headers, requestid)
|
||||||
|
_enroll(client, keyheaders, nonce=OTHER_NONCE)
|
||||||
|
|
||||||
|
approve = client.post(
|
||||||
|
f'/api/endpoints/enrollment/requests/{requestid}/approve',
|
||||||
|
json={'assetid': assetid}, headers=auth_headers)
|
||||||
|
assert approve.status_code == 200, approve.get_data(as_text=True)
|
||||||
|
|
||||||
|
issued = _enroll(client, keyheaders, nonce=OTHER_NONCE)
|
||||||
|
assert issued.status_code == 201, issued.get_data(as_text=True)
|
||||||
|
assert EndpointCredential.query.filter_by(assetid=assetid).one() \
|
||||||
|
.enrolledvia == 'approved'
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_approval_out_of_a_rejection_still_binds_to_one_installation(
|
||||||
|
client, db, app, keyheaders, auth_headers):
|
||||||
|
"""It binds to the installation the PC was last seen with, so a third
|
||||||
|
machine claiming the same hostname and serial still cannot collect it."""
|
||||||
|
assetid = _computer(db, 'BAY-01')
|
||||||
|
requestid = _pending_request(client, db, keyheaders)
|
||||||
|
_reject(client, auth_headers, requestid)
|
||||||
|
_enroll(client, keyheaders, nonce=OTHER_NONCE)
|
||||||
|
client.post(f'/api/endpoints/enrollment/requests/{requestid}/approve',
|
||||||
|
json={'assetid': assetid}, headers=auth_headers)
|
||||||
|
|
||||||
|
impostor = _enroll(client, keyheaders, nonce='z' * 40)
|
||||||
|
assert impostor.status_code == 403
|
||||||
|
assert _code(impostor) == 'ENROLL_NONCE_MISMATCH'
|
||||||
|
assert EndpointCredential.query.count() == 0
|
||||||
|
|
||||||
|
issued = _enroll(client, keyheaders, nonce=OTHER_NONCE)
|
||||||
|
assert issued.status_code == 201, issued.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
# -- the pending cap ages out abandoned rows ---------------------------------
|
||||||
|
|
||||||
|
def _stale(db, hostname, days):
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
seen = (datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
- timedelta(days=days))
|
||||||
|
row = EndpointEnrollRequest(
|
||||||
|
hostname=hostname, serialnumber=hostname + '-SER',
|
||||||
|
enrollnonce=NONCE, status=EndpointEnrollRequest.STATUS_PENDING,
|
||||||
|
requestedat=seen, lastseenat=seen)
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.commit()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_cap_ages_out_abandoned_rows_before_refusing(client, db, app):
|
||||||
|
"""A queue full of PCs that stopped asking must not block the one that is."""
|
||||||
|
_setting(db, PENDING_CAP_SETTING, 2, valuetype='integer')
|
||||||
|
_stale(db, 'GONE-01', PENDING_STALE_AFTER.days + 1)
|
||||||
|
_stale(db, 'GONE-02', PENDING_STALE_AFTER.days + 3)
|
||||||
|
|
||||||
|
response = client.post(ENROLL_URL, json=_body(hostname='NEW-01',
|
||||||
|
serialnumber='NEW-SER'))
|
||||||
|
|
||||||
|
assert response.status_code == 202, response.get_data(as_text=True)
|
||||||
|
hostnames = {row.hostname for row in EndpointEnrollRequest.query.all()}
|
||||||
|
assert hostnames == {'NEW-01'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_age_out_spares_rows_still_being_asked_for(client, db, app):
|
||||||
|
"""Only rows nothing has touched go, and a decided row never goes: the
|
||||||
|
rejection above has to outlive any queue pressure."""
|
||||||
|
_setting(db, PENDING_CAP_SETTING, 2, valuetype='integer')
|
||||||
|
fresh = _stale(db, 'LIVE-01', 0)
|
||||||
|
stillasking = _stale(db, 'LIVE-02', 0)
|
||||||
|
decided = _stale(db, 'GONE-01', PENDING_STALE_AFTER.days + 1)
|
||||||
|
decided.status = EndpointEnrollRequest.STATUS_REJECTED
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
refused = client.post(ENROLL_URL, json=_body(hostname='NEW-01',
|
||||||
|
serialnumber='NEW-SER'))
|
||||||
|
|
||||||
|
assert refused.status_code == 503
|
||||||
|
assert _code(refused) == 'ENROLL_PENDING_FULL'
|
||||||
|
for row in (fresh, stillasking, decided):
|
||||||
|
assert db.session.get(EndpointEnrollRequest, row.requestid) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# -- revocation and the shared key -------------------------------------------
|
||||||
|
|
||||||
|
def test_a_revoked_pc_keeps_the_shared_key_by_default(client, db, app,
|
||||||
|
keyheaders,
|
||||||
|
auth_headers):
|
||||||
|
"""Today's behaviour, and the setting default: a revoke is a rotation, not
|
||||||
|
an eviction, so the PC keeps reporting while it waits to enroll again."""
|
||||||
|
assetid = _computer(db, 'BAY-01')
|
||||||
|
assert _enroll(client, keyheaders).status_code == 201
|
||||||
|
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||||
|
headers=auth_headers)
|
||||||
|
|
||||||
|
assert client.get(ALERTS_URL + '?hostname=BAY-01',
|
||||||
|
headers=keyheaders).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoked_denies_shared_key_evicts_the_pc(client, db, app, keyheaders,
|
||||||
|
auth_headers):
|
||||||
|
assetid = _computer(db, 'BAY-01')
|
||||||
|
assert _enroll(client, keyheaders).status_code == 201
|
||||||
|
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||||
|
json={'blockreenroll': True, 'reason': 'stolen'},
|
||||||
|
headers=auth_headers)
|
||||||
|
_setting(db, agentauth.REVOKED_DENIES_SHARED_KEY_SETTING, True)
|
||||||
|
|
||||||
|
refused = client.get(ALERTS_URL + '?hostname=BAY-01', headers=keyheaders)
|
||||||
|
|
||||||
|
assert refused.status_code == 401
|
||||||
|
assert _code(refused) == 'SHARED_KEY_REFUSED'
|
||||||
|
assert 'revoked' in refused.get_json()['data']['error']['message']
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pc_that_never_enrolled_is_never_evicted(client, db, app,
|
||||||
|
keyheaders, auth_headers):
|
||||||
|
"""The un-migrated fleet has no credential row at all, so the setting can be
|
||||||
|
turned on without silencing it."""
|
||||||
|
_computer(db, 'BAY-01', serialnumber='SER-1')
|
||||||
|
_computer(db, 'BAY-02', serialnumber='SER-2')
|
||||||
|
assetid = _computer(db, 'BAY-03', serialnumber='SER-3')
|
||||||
|
_enroll(client, keyheaders, hostname='BAY-03', serialnumber='SER-3')
|
||||||
|
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||||
|
headers=auth_headers)
|
||||||
|
_setting(db, agentauth.REVOKED_DENIES_SHARED_KEY_SETTING, True)
|
||||||
|
|
||||||
|
assert client.get(ALERTS_URL + '?hostname=BAY-01',
|
||||||
|
headers=keyheaders).status_code == 200
|
||||||
|
assert client.get(ALERTS_URL + '?hostname=BAY-02',
|
||||||
|
headers=keyheaders).status_code == 200
|
||||||
|
assert client.get(ALERTS_URL + '?hostname=BAY-03',
|
||||||
|
headers=keyheaders).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_collector_contract_follows_the_same_rule(client, db, app,
|
||||||
|
keyheaders,
|
||||||
|
auth_headers):
|
||||||
|
"""The generic collector path asks the plugin, not bind_hostname, so it has
|
||||||
|
to answer the same way."""
|
||||||
|
from plugins.endpoints.plugin import EndpointsPlugin
|
||||||
|
|
||||||
|
assetid = _computer(db, 'BAY-01')
|
||||||
|
assert _enroll(client, keyheaders).status_code == 201
|
||||||
|
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||||
|
headers=auth_headers)
|
||||||
|
refused = EndpointsPlugin().agent_shared_key_refused
|
||||||
|
|
||||||
|
with app.test_request_context():
|
||||||
|
assert refused('BAY-01') is False
|
||||||
|
_setting(db, agentauth.REVOKED_DENIES_SHARED_KEY_SETTING, True)
|
||||||
|
assert refused('BAY-01') is True
|
||||||
|
assert refused('UNKNOWN') is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_eviction_switch_is_editable_and_defaults_to_today(client, db,
|
||||||
|
auth_headers):
|
||||||
|
"""A setting nobody can change is a constant with extra steps."""
|
||||||
|
read = client.get('/api/endpoints/enrollment',
|
||||||
|
headers=auth_headers).get_json()['data']
|
||||||
|
assert read['settings']['revokeddeniessharedkey'] is False
|
||||||
|
|
||||||
|
put = client.put('/api/endpoints/enrollment/settings',
|
||||||
|
json={'revokeddeniessharedkey': True},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert put.status_code == 200, put.get_data(as_text=True)
|
||||||
|
assert put.get_json()['data']['revokeddeniessharedkey'] is True
|
||||||
|
assert Setting.get(agentauth.REVOKED_DENIES_SHARED_KEY_SETTING) is True
|
||||||
|
|
||||||
|
bad = client.put('/api/endpoints/enrollment/settings',
|
||||||
|
json={'revokeddeniessharedkey': 'yes'},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert bad.status_code == 400
|
||||||
Reference in New Issue
Block a user