main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
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
|