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.
353 lines
15 KiB
Python
353 lines
15 KiB
Python
"""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
|