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:
cproudlock
2026-09-18 13:29:51 -04:00
parent fe4f44f855
commit 3f80425f30
6 changed files with 585 additions and 28 deletions

View File

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

View File

@@ -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,7 +389,12 @@ 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():
# 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(
@@ -358,6 +403,28 @@ def enroll_endpoint():
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():

View File

@@ -229,6 +229,27 @@
on record matches. Anything else waits below for an admin.
</span>
</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">
<span>
<strong>Pending request limit.</strong> How many requests may wait
@@ -670,6 +691,9 @@ const DEFAULT_PENDING_CAP = 500
const enrollmentSettings = ref({
sharedkeyallowed: 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
})
// 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 = {
sharedkeyallowed: data.settings?.sharedkeyallowed !== 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))
? Number(data.settings.maxpendingenrollments)
: 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() {
const wanted = Math.trunc(Number(pendingCapInput.value))
if (!Number.isFinite(wanted) || wanted < 0) {

View File

@@ -4,16 +4,25 @@
One row per asset. Holds only the sha256 of the secret the
agent was issued, plus a short display prefix. Revoking sets
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
that only knows the shared key, or a compromised PC) until an
admin allows it.
EndpointEnrollRequest
One row per (hostname, serial number) that asked to enroll. The
agent's enrollment nonce is recorded 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.
One row per (hostname, serial number) that asked to enroll -
the endpoint's identity, and the reason every contact from one
PC lands on one row. The agent's enrollment nonce is recorded
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
An admin action that pushed the agent upgrade to a set of PCs.

View File

@@ -689,6 +689,7 @@ def test_enrollment_listing_and_settings(client, db, app, keyheaders,
body = response.get_json()
data = body['data']
assert data['settings'] == {'sharedkeyallowed': True, 'autoenroll': True,
'revokeddeniessharedkey': False,
'maxpendingenrollments': 500}
assert data['counts'] == {'enrolled': 1, 'notenrolled': 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.get_json()['data'] == {'sharedkeyallowed': False,
'autoenroll': True,
'revokeddeniessharedkey': False,
'maxpendingenrollments': 500}
bad = client.put('/api/endpoints/enrollment/settings',
json={'autoenroll': 'yes'}, headers=auth_headers)

View 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