diff --git a/plugins/endpoints/AGENT-CONTRACT.md b/plugins/endpoints/AGENT-CONTRACT.md index cd2dcbad..a25d4b07 100644 --- a/plugins/endpoints/AGENT-CONTRACT.md +++ b/plugins/endpoints/AGENT-CONTRACT.md @@ -599,10 +599,14 @@ credential plus its first 12 characters (`credentialprefix`) for display. ### 9.2 Enroll -`POST {baseurl}/api/endpoints/enroll`, authenticated with `X-API-Key` while -`plugin.endpoints.sharedkeyallowed` is true. With the shared key switched off -the route takes no auth, but then only returns a credential for a request an -admin has approved. +`POST {baseurl}/api/endpoints/enroll`. The route never refuses a call for its +authentication: a PC installed without the shared key still records a pending +request an admin can approve. `X-API-Key` only decides auto-issue: a valid +shared key while `plugin.endpoints.sharedkeyallowed` is true. No key, an invalid +key (logged by the server, never a 401 here) or the shared key switched off all +mean the call is unauthenticated, and it then only returns a credential for a +request an admin has approved for this hostname, serial and nonce. There is one +request row per hostname and serial (case-insensitive); repeated calls update it. ```jsonc { @@ -711,7 +715,12 @@ from the request body. Read needs `endpoints.fleet`; writes need the admin role and are audited. -- `GET /api/endpoints/enrollment` (`page`, `perpage` <= 100, `state`, `search`) +- `GET /api/endpoints/enrollment` (`page`, `perpage` <= 100, `state`, `search`). + `counts.enrolled` and `counts.notenrolled` count computers by `state` + (`notenrolled` includes `revoked`); `counts.pendingcomputers` counts computers + in state `pending`; `counts.pending` is the number of pending enroll requests, + including requests that match no computer (unknown hostname) and requests from + PCs blocked from re-enrolling, so it equals the pending entries in `requests`. - `POST /api/endpoints/enrollment/requests//approve` `{"assetid": n}` - `POST /api/endpoints/enrollment/requests//reject` - `POST /api/endpoints/enrollment/assets//revoke` (un-enroll). Optional @@ -726,7 +735,10 @@ Read needs `endpoints.fleet`; writes need the admin role and are audited. record; a PC that was not blocked returns success unchanged. - `PUT /api/endpoints/enrollment/settings` `{"sharedkeyallowed": bool, "autoenroll": bool}` - `GET /api/endpoints/enrollment/migration` (`page`, `perpage` <= 100, `state`, `search`) -- `POST /api/endpoints/enrollment/migration/wave` `{"assetids": [...]}` or `{"next": n}` (n <= 100) +- `POST /api/endpoints/enrollment/migration/wave` `{"assetids": [...]}` or `{"next": n}` (n <= 100). + Returns `{"waveid", "queued": [...], "skipped": [{"assetid", "reason"}], "retried": [...]}`. + `retried` lists queued PCs whose only blocker was + `server certificate not trusted by this PC` (see below). - `GET /api/endpoints` rows carry `credentialstate` (`enrolled`, `reenrollblocked`, `pending`, `notreporting`, `sharedkey`) and accept a `credentialstate` filter. `GET /api/endpoints/` carries a @@ -737,5 +749,9 @@ Read needs `endpoints.fleet`; writes need the admin role and are audited. `presentedsha1thumbprint` and `matchesfile`, or `{"configured": false}`. - Migration blockers include `server certificate not trusted by this PC`: the PC's last upgrade download failed on the certificate chain (the server only - knows from the failure the PC reported). Trust the certificate - (`Install-ShopDBServerCertificate.ps1`) and queue it again. + knows from the failure the PC reported). It stays shown until a later upgrade + succeeds, so it is retryable: trust the certificate + (`Install-ShopDBServerCertificate.ps1`) and queue the PC again by its id in + `{"assetids": [...]}`. An explicit id is queued when this is the PC's only + blocker and is reported in `retried`; `{"next": n}` never picks such a PC, and + a PC with any other blocker as well is still skipped. diff --git a/plugins/endpoints/api/enrollment.py b/plugins/endpoints/api/enrollment.py index 517aedeb..5552a950 100644 --- a/plugins/endpoints/api/enrollment.py +++ b/plugins/endpoints/api/enrollment.py @@ -28,7 +28,7 @@ import re from collections import Counter from datetime import datetime, timedelta, timezone -from flask import request +from flask import current_app, request from flask_jwt_extended import current_user from shopdb.api import ( @@ -204,16 +204,24 @@ def _text_field(payload, name, maximum, required): def enroll_endpoint(): """A PC asks for its own credential. - Shared-key authenticated while plugin.endpoints.sharedkeyallowed is on. - With it off the route takes no auth and only hands out a credential an - admin already approved for this exact hostname, serial and nonce. + Takes no auth. A PC installed without the shared key must still reach an + admin, so a missing or invalid key never refuses the call: it records or + updates the pending request for this hostname and serial. The shared key + only decides auto-enroll: a valid key, while plugin.endpoints.sharedkeyallowed + and plugin.endpoints.autoenroll are on, may issue on a clean match. Without + one, only a credential an admin already approved for this exact hostname, + serial and nonce is handed out. """ sharedkeyauthenticated = False - if agentauth.shared_key_allowed(): - error = collector_authorized('endpoints') - if error is not None: - return error - sharedkeyauthenticated = True + presented = bool(request.headers.get('X-API-Key') + or request.headers.get('Authorization')) + if presented and agentauth.shared_key_allowed(): + if collector_authorized('endpoints') is None: + sharedkeyauthenticated = True + else: + current_app.logger.warning( + 'endpoint enroll from %s presented an invalid API key; ' + 'treated as unauthenticated', client_ip(request)) payload = request.get_json(silent=True) if not isinstance(payload, dict): @@ -505,11 +513,14 @@ def get_enrollment(): if blocked else None), }) + # Computer counts by state, except pending: that is every pending request, + # including those naming no known computer, so it matches the request list. counts = Counter(row['state'] for row in rows) summary = { 'enrolled': counts['enrolled'], 'notenrolled': counts['notenrolled'] + counts['revoked'], - 'pending': counts['pending'], + 'pendingcomputers': counts['pending'], + 'pending': len(pending), } state = (request.args.get('state') or '').strip().lower() @@ -771,6 +782,16 @@ def _is_tls_trust_failure(command): return any(marker in text for marker in _TLS_TRUST_FAILURES) +def is_trust_retryable(blockers): + """True when nothing blocks the PC but, at most, the certificate trust failure. + + The server only learns about trust from the PC's last failed download, so + that blocker cannot clear by itself. An admin who fixed trust may queue the + PC by id; a wave picking the next ready PCs never does. + """ + return all(blocker == BLOCKER_CERT_UNTRUSTED for blocker in blockers) + + def compute_readiness(assetids=None): """Migration state for every active computer (or just assetids). @@ -847,8 +868,8 @@ def compute_readiness(assetids=None): blockers.append(BLOCKER_CERT_UNTRUSTED) if not _version_below(agentversion, CREDENTIAL_AGENT_VERSION): blockers.append(BLOCKER_AWAITING_ENROLLMENT) - if not blockers and 'run_script' not in ALLOWED_ACTIONS_FOR( - assetid=assetid): + if (is_trust_retryable(blockers) + and 'run_script' not in ALLOWED_ACTIONS_FOR(assetid=assetid)): blockers.append(BLOCKER_POLICY) status = 'blocked' if blockers else 'ready' result.append({ @@ -955,7 +976,9 @@ def create_migration_wave(): Body {"assetids": [...]} or {"next": n}. Readiness is re-checked per PC; the agent is packaged once for the whole wave, then each PC is queued through - the same path as POST //agentupgrade. + the same path as POST //agentupgrade. An explicit id whose only + blocker is the certificate trust failure is queued too and listed in + retried: the admin is saying trust was fixed. """ from .routes import prepare_agent_upgrade, queue_prepared_agent_upgrade @@ -987,10 +1010,15 @@ def create_migration_wave(): readiness = {row['assetid']: row for row in compute_readiness(candidates)} skipped = [] ready = [] + retrying = set() for assetid in candidates: row = readiness.get(assetid) if row is None: skipped.append({'assetid': assetid, 'reason': 'not a computer'}) + elif (requested is not None and row['state'] == 'blocked' + and row['blockers'] and is_trust_retryable(row['blockers'])): + retrying.add(assetid) + ready.append(assetid) elif row['state'] != 'ready': reason = ', '.join(row['blockers']) if row['blockers'] else row['state'] skipped.append({'assetid': assetid, 'reason': reason}) @@ -1027,8 +1055,10 @@ def create_migration_wave(): .format(len(queued), wave.assetids)) db.session.commit() + retried = [assetid for assetid in queued if assetid in retrying] return success_response( - {'waveid': waveid, 'queued': queued, 'skipped': skipped}, + {'waveid': waveid, 'queued': queued, 'skipped': skipped, + 'retried': retried}, message='{} PC(s) queued'.format(len(queued)), http_code=201 if queued else 200) diff --git a/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md b/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md index 559928f0..87e0a4e3 100644 --- a/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md +++ b/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md @@ -158,7 +158,12 @@ nothing. A non-CA leaf in Root trusts only this one server. If a wave reaches a PC that does not trust the certificate, the download fails and the Migration tab marks the PC **Blocked** with `server certificate not -trusted by this PC`. Fix trust with the entry above, then queue the PC again. +trusted by this PC`. The server only knows about trust from that failure, so +the blocker stays until the PC is queued again. Fix trust with the entry above, +then in the Migration tab tick the PC (a PC whose only blocker is this one keeps +a checkbox and shows "Retry after fixing trust") and click **Upgrade selected**. +**Upgrade next 25** never picks it, and a PC with any other blocker as well +stays unselectable until that blocker is fixed. ## Phase 2: re-stamp the share's agent entries @@ -233,7 +238,7 @@ Repeat until **Ready** is 0. in store`: upload 1.20. `agent too old for remote upgrade`: move it via the share (targeted agent entry as in phase 2). `server certificate not trusted by this PC`: add the PC to the certificate entry (phase 1), wait a cycle, - queue it again. `reenroll blocked`: the PC was rolled back on purpose; clear + then tick it and **Upgrade selected** (phase 1 explains the retry). `reenroll blocked`: the PC was rolled back on purpose; clear it with allow-reenroll (see Rollback) only when moving it forward again. - **Not reporting:** the PC has no working agent. It can only move via the share channel: add its hostname to a targeted 1.20 agent entry. It shows as @@ -244,9 +249,13 @@ Repeat until **Ready** is 0. matched computer, search to choose another. The PC collects its credential on its next retry (within 15 minutes). **Reject** anything you do not recognise. - **Newly imaged PCs:** imaging installs 1.20 with the shared key, and the agent - auto-enrolls on first start. After the shared key is turned off (phase 6) a - new PC cannot auto-enroll: its request lands in Pending and an admin approves - it in the UI. That is the steady-state path for new PCs. + auto-enrolls on first start. A new PC can also be installed WITHOUT the + shared key at any time, before or after cutover (the generated installer's + "Include shared key" option off): the enroll call needs no key, so its request + lands in Pending and it waits there until an admin approves it in the UI. An + invalid key behaves the same (the server logs it). After the shared key is + turned off (phase 6) that is the only path, and the steady-state path for new + PCs. ## Phase 6: cutover diff --git a/plugins/endpoints/frontend/views/AgentKeySettings.vue b/plugins/endpoints/frontend/views/AgentKeySettings.vue index 3a0581ac..eedcf221 100644 --- a/plugins/endpoints/frontend/views/AgentKeySettings.vue +++ b/plugins/endpoints/frontend/views/AgentKeySettings.vue @@ -663,7 +663,7 @@ async function loadEnrollment() { const counts = data.counts || {} const enrolled = counts.enrolled || 0 // pending PCs asked but hold no credential yet: still on the shared key. - const notenrolled = (counts.notenrolled || 0) + (counts.pending || 0) + const notenrolled = (counts.notenrolled || 0) + (counts.pendingcomputers || 0) enrollment.value = { enrolled, notenrolled, diff --git a/plugins/endpoints/frontend/views/EnrollmentSettings.vue b/plugins/endpoints/frontend/views/EnrollmentSettings.vue index 4aa8c94f..0c448f09 100644 --- a/plugins/endpoints/frontend/views/EnrollmentSettings.vue +++ b/plugins/endpoints/frontend/views/EnrollmentSettings.vue @@ -125,7 +125,7 @@ {{ stateLabel(row.state) }} {{ row.agentversion || '-' }} {{ formatDate(row.lastreportedat) }} - {{ (row.blockers || []).join(', ') || '-' }} + + {{ (row.blockers || []).join(', ') || '-' }} + + Retry after fixing trust + + @@ -516,6 +521,20 @@ const enrolledPercent = computed(() => { }) const readyRows = computed(() => migrationRows.value.filter(row => row.state === 'ready')) + +// The server cannot see a PC's trust store, so this blocker only clears when +// an admin queues the PC again after fixing trust. Next N never picks it. +const TRUST_BLOCKER = 'server certificate not trusted by this PC' + +function isTrustRetryable(row) { + const blockers = row.blockers || [] + return row.state === 'blocked' && blockers.length > 0 + && blockers.every(blocker => blocker === TRUST_BLOCKER) +} + +function isSelectable(row) { + return row.state === 'ready' || isTrustRetryable(row) +} const allReadySelected = computed(() => readyRows.value.length > 0 && readyRows.value.every(row => selected.value.includes(row.assetid))) @@ -541,9 +560,9 @@ async function loadMigration({ quiet = false } = {}) { migrationRows.value = data.computers || [] waves.value = data.waves || [] migrationTotalPages.value = response.data.meta?.pagination?.totalpages || 1 - // Drop selections that are no longer ready (queued by a wave, went offline). - const readyIds = new Set(readyRows.value.map(row => row.assetid)) - selected.value = selected.value.filter(id => readyIds.has(id)) + // Drop selections that are no longer selectable (queued by a wave, went offline). + const selectableIds = new Set(migrationRows.value.filter(isSelectable).map(row => row.assetid)) + selected.value = selected.value.filter(id => selectableIds.has(id)) migrationError.value = '' } catch (error) { migrationError.value = apiError(error, 'Could not load migration status') @@ -587,6 +606,11 @@ async function startWave(body, count) { const queued = (data.queued || []).length const skipped = data.skipped || [] if (queued) toast.success(`Wave ${data.waveid}: upgrade queued for ${queued} PC(s)`) + const retried = data.retried || [] + if (retried.length) { + toast.info(`${retried.length} queued again despite the certificate trust blocker: ` + + retried.slice(0, 5).join(', ')) + } if (skipped.length) { toast.info(`${skipped.length} skipped: ` + skipped.slice(0, 5).map(item => `${item.assetid} (${item.reason})`).join(', ')) @@ -1045,6 +1069,12 @@ onBeforeUnmount(() => { [data-theme="dark"] .state-chip.pending, [data-theme="dark"] .state-chip.upgrading { color: var(--warning); } +.retry-hint { + display: block; + font-size: 0.8em; + color: var(--text-light); +} + .blockers { font-size: 0.85rem; color: var(--text-light); diff --git a/plugins/endpoints/frontend/views/agentKeySettings.spec.js b/plugins/endpoints/frontend/views/agentKeySettings.spec.js index f37db332..526d4fc6 100644 --- a/plugins/endpoints/frontend/views/agentKeySettings.spec.js +++ b/plugins/endpoints/frontend/views/agentKeySettings.spec.js @@ -23,7 +23,7 @@ function enrollmentPayload(settings = { sharedkeyallowed: true, autoenroll: true data: { data: { settings, - counts: { enrolled: 30, notenrolled: 8, pending: 2 }, + counts: { enrolled: 30, notenrolled: 8, pendingcomputers: 2, pending: 3 }, computers: [], requests: [ { requestid: 1, status: 'pending', reason: 'serial mismatch' }, diff --git a/plugins/endpoints/frontend/views/enrollmentSettings.spec.js b/plugins/endpoints/frontend/views/enrollmentSettings.spec.js index 9c1d59e2..9f755c75 100644 --- a/plugins/endpoints/frontend/views/enrollmentSettings.spec.js +++ b/plugins/endpoints/frontend/views/enrollmentSettings.spec.js @@ -35,7 +35,11 @@ function migrationPayload() { computers: [ { assetid: 11, hostname: 'PC-READY', agentversion: '1.19.2', lastreportedat: null, state: 'ready', blockers: [] }, { assetid: 12, hostname: 'PC-BLOCKED', agentversion: '1.18.0', lastreportedat: null, state: 'blocked', - blockers: ['offline', 'no serial on record'] } + blockers: ['offline', 'no serial on record'] }, + { assetid: 13, hostname: 'PC-TRUST', agentversion: '1.19.2', lastreportedat: null, state: 'blocked', + blockers: ['server certificate not trusted by this PC'] }, + { assetid: 14, hostname: 'PC-TRUST-OFFLINE', agentversion: '1.19.2', lastreportedat: null, state: 'blocked', + blockers: ['offline', 'server certificate not trusted by this PC'] } ], waves: [ { waveid: 3, createdat: null, createdby: 'admin', size: 25, queued: 25, enrolled: 10, failed: 1, done: false } @@ -51,7 +55,7 @@ function enrollmentPayload() { data: { data: { settings: { sharedkeyallowed: true, autoenroll: true }, - counts: { enrolled: 40, notenrolled: 58, pending: 2 }, + counts: { enrolled: 40, notenrolled: 58, pendingcomputers: 1, pending: 2 }, computers: [ { assetid: 21, hostname: 'PC-ENROLLED', serialnumber: 'S1', state: 'enrolled', credentialprefix: 'sdbe_abcdefg', issuedat: null, lastusedat: null, enrolledvia: 'auto', @@ -120,6 +124,29 @@ describe('EnrollmentSettings', () => { expect(rows[1].find('input[type="checkbox"]').exists()).toBe(false) }) + it('offers a trust-only blocked PC for retry, not one with another blocker too', async () => { + const wrapper = await render() + const rows = wrapper.findAll('.migration-row') + const trust = rows.find(r => r.text().includes('PC-TRUST') && !r.text().includes('OFFLINE')) + const mixed = rows.find(r => r.text().includes('PC-TRUST-OFFLINE')) + expect(trust.find('input[type="checkbox"]').exists()).toBe(true) + expect(trust.find('.retry-hint').text()).toBe('Retry after fixing trust') + expect(mixed.find('input[type="checkbox"]').exists()).toBe(false) + expect(mixed.find('.retry-hint').exists()).toBe(false) + }) + + it('includes a trust-blocked PC in Upgrade selected', async () => { + vi.stubGlobal('confirm', vi.fn(() => true)) + const wrapper = await render() + const rows = wrapper.findAll('.migration-row') + await rows[0].find('input[type="checkbox"]').setValue(true) + const trust = rows.find(r => r.text().includes('PC-TRUST') && !r.text().includes('OFFLINE')) + await trust.find('input[type="checkbox"]').setValue(true) + await wrapper.find('.upgrade-selected').trigger('click') + await flushPromises() + expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { assetids: [11, 13] }) + }) + it('posts {"next":25} for Upgrade next 25 after a confirm naming the count', async () => { const confirmSpy = vi.fn(() => true) vi.stubGlobal('confirm', confirmSpy) diff --git a/tests/test_plugins/test_endpoints_credentials.py b/tests/test_plugins/test_endpoints_credentials.py index e8f34387..08c02c6e 100644 --- a/tests/test_plugins/test_endpoints_credentials.py +++ b/tests/test_plugins/test_endpoints_credentials.py @@ -130,11 +130,62 @@ def test_enroll_validates_the_nonce(client, db, app, keyheaders): assert response.status_code == 400, nonce -def test_enroll_requires_the_shared_key_while_it_is_allowed(client, db, app, - keyheaders): - _computer(db, 'BAY-01') - response = client.post(ENROLL_URL, json=_enroll_body()) - assert response.status_code in (401, 500) +@pytest.mark.parametrize('headers', [ + {}, + {'X-API-Key': 'not-the-key'}, +]) +def test_enroll_without_a_valid_key_records_a_pending_request( + client, db, app, keyheaders, auth_headers, caplog, headers): + from plugins.endpoints.models import EndpointCredential, EndpointEnrollRequest + + assetid = _computer(db, 'BAY-01') + response = _enroll(client, headers) + + assert response.status_code == 202, response.get_data(as_text=True) + data = response.get_json()['data'] + assert data['status'] == 'pending' + assert data['reason'] == 'awaiting approval' + assert EndpointCredential.query.filter_by(assetid=assetid).first() is None + assert ('invalid API key' in caplog.text) == bool(headers) + + # Retrying updates the same row; it never piles up rows. + assert _enroll(client, headers).status_code == 202 + assert EndpointEnrollRequest.query.count() == 1 + + listing = client.get('/api/endpoints/enrollment', + headers=auth_headers).get_json()['data'] + assert [(r['requestid'], r['status']) for r in listing['requests']] == [ + (data['requestid'], 'pending')] + assert listing['counts']['pending'] == 1 + + +def test_enroll_with_no_key_for_an_unknown_hostname_is_pending( + client, db, app, keyheaders): + response = _enroll(client, {}, hostname='NEW-PC') + assert response.status_code == 202, response.get_data(as_text=True) + assert response.get_json()['data']['reason'] == 'unknown hostname' + + +def test_approved_request_issues_to_its_nonce_without_a_key( + client, db, app, keyheaders, auth_headers): + from plugins.endpoints.models import EndpointCredential + + assetid = _computer(db, 'BAY-01') + pending = _enroll(client, {}) + requestid = pending.get_json()['data']['requestid'] + 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) + + wrong = _enroll(client, {}, nonce=OTHER_NONCE) + assert wrong.status_code == 403 + assert _code(wrong) == 'ENROLL_NONCE_MISMATCH' + + issued = _enroll(client, {}) + assert issued.status_code == 201, issued.get_data(as_text=True) + assert EndpointCredential.query.filter_by(assetid=assetid).one() \ + .enrolledvia == 'approved' def test_a_second_nonce_on_a_pending_request_stays_pending(client, db, app, @@ -638,7 +689,8 @@ def test_enrollment_listing_and_settings(client, db, app, keyheaders, body = response.get_json() data = body['data'] assert data['settings'] == {'sharedkeyallowed': True, 'autoenroll': True} - assert data['counts'] == {'enrolled': 1, 'notenrolled': 1, 'pending': 1} + assert data['counts'] == {'enrolled': 1, 'notenrolled': 1, + 'pendingcomputers': 1, 'pending': 1} states = {row['hostname']: row['state'] for row in data['computers']} assert states == {'BAY-01': 'enrolled', 'BAY-02': 'notenrolled', 'BAY-03': 'pending'} @@ -663,6 +715,23 @@ def test_enrollment_listing_and_settings(client, db, app, keyheaders, assert bad.status_code == 400 +def test_enrollment_pending_count_includes_unmatched_requests( + client, db, app, keyheaders, auth_headers): + _computer(db, 'BAY-01', serialnumber=None) + _computer(db, 'BAY-02', serialnumber='SER-2') + _enroll(client, keyheaders, hostname='BAY-01', serialnumber='X') + _enroll(client, {}, hostname='NOT-IN-SHOPDB', serialnumber='Y') + + data = client.get('/api/endpoints/enrollment', + headers=auth_headers).get_json()['data'] + pendingrequests = [r for r in data['requests'] if r['status'] == 'pending'] + assert len(pendingrequests) == 2 + assert data['counts'] == {'enrolled': 0, 'notenrolled': 1, + 'pendingcomputers': 1, 'pending': 2} + states = [row['state'] for row in data['computers']] + assert states.count('pending') == data['counts']['pendingcomputers'] + + def test_approve_refuses_an_enrolled_computer(client, db, app, keyheaders, auth_headers): assetid = _computer(db, 'BAY-01') diff --git a/tests/test_plugins/test_endpoints_enrollment_migration.py b/tests/test_plugins/test_endpoints_enrollment_migration.py index 1381b7ab..5d1c6330 100644 --- a/tests/test_plugins/test_endpoints_enrollment_migration.py +++ b/tests/test_plugins/test_endpoints_enrollment_migration.py @@ -372,6 +372,76 @@ def test_a_later_success_clears_the_tls_blocker(client, db, app, auth_headers): assert _by_host(_rows(client, auth_headers))['PC-FIXED']['state'] == 'ready' +def test_a_trust_blocked_pc_is_retried_only_by_explicit_id(client, db, app, + auth_headers): + from plugins.endpoints.models import EndpointCommand, EndpointMigrationWave + + _publish_agent() + trustonly = _pc(db, 'PC-TRUST') + alsooffline = _pc(db, 'PC-TRUST-OFFLINE', reportedago=timedelta(days=2)) + agentregistry.register_current_agent_package() + scriptid = agentupgradescript.seed_upgrade_script()['scriptid'] + for assetid in (trustonly, alsooffline): + db.session.add(EndpointCommand( + assetid=assetid, action='run_script', target=f'script:{scriptid}', + status=EndpointCommand.STATUS_FAILED, + resultmessage='UntrustedRoot', completedat=_now())) + db.session.commit() + + rows = _by_host(_rows(client, auth_headers)) + assert rows['PC-TRUST']['blockers'] == [ + 'server certificate not trusted by this PC'] + assert rows['PC-TRUST-OFFLINE']['blockers'] == [ + 'offline', 'server certificate not trusted by this PC'] + + nextwave = client.post(WAVE_URL, json={'next': 10}, headers=auth_headers) + assert nextwave.status_code == 200, nextwave.get_data(as_text=True) + assert nextwave.get_json()['data']['queued'] == [] + assert nextwave.get_json()['data']['retried'] == [] + assert EndpointMigrationWave.query.count() == 0 + + response = client.post(WAVE_URL, + json={'assetids': [trustonly, alsooffline]}, + headers=auth_headers) + assert response.status_code == 201, response.get_data(as_text=True) + data = response.get_json()['data'] + assert data['queued'] == [trustonly] + assert data['retried'] == [trustonly] + assert data['skipped'] == [{ + 'assetid': alsooffline, + 'reason': 'offline, server certificate not trusted by this PC'}] + assert EndpointMigrationWave.query.one().assetidlist == [trustonly] + assert _by_host(_rows(client, auth_headers))['PC-TRUST']['state'] \ + == 'upgrading' + + +def test_a_trust_blocked_pc_still_needs_run_script_policy(client, db, app, + auth_headers, + monkeypatch): + from plugins.endpoints.api import enrollment + from plugins.endpoints.models import EndpointCommand + + _publish_agent() + assetid = _pc(db, 'PC-TRUST') + agentregistry.register_current_agent_package() + scriptid = agentupgradescript.seed_upgrade_script()['scriptid'] + db.session.add(EndpointCommand( + assetid=assetid, action='run_script', target=f'script:{scriptid}', + status=EndpointCommand.STATUS_FAILED, + resultmessage='UntrustedRoot', completedat=_now())) + db.session.commit() + monkeypatch.setattr(enrollment, 'ALLOWED_ACTIONS_FOR', + lambda assetid=None: set()) + + data = client.post(WAVE_URL, json={'assetids': [assetid]}, + headers=auth_headers).get_json()['data'] + assert data['queued'] == [] + assert data['skipped'] == [{ + 'assetid': assetid, + 'reason': 'server certificate not trusted by this PC, ' + 'run_script not permitted by policy'}] + + def test_wave_failed_forgets_a_pc_a_later_wave_upgraded(client, db, app, auth_headers): from plugins.endpoints.models import EndpointCommand, EndpointState