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