From c2ffc591bbfb4c850f794abe0f84b191dfaf83c5 Mon Sep 17 00:00:00 2001
From: cproudlock
Date: Tue, 15 Sep 2026 14:09:08 -0400
Subject: [PATCH] Show per-PC credentials across the admin interface
- Endpoints (shared key) page: the key is presented as what PCs use until
they enroll, with enrollment progress, a link to Enrollment, rewritten
guidance, and rotate/revoke confirmations that say how many PCs are not
enrolled yet.
- Settings cards describe the server certificate as what PCs actually
receive, on IIS or Docker; Enrollment has its own icon (the icon set lacked
the one the Server Certificate card already asked for).
- Server Certificate page checks the certificate the site presents at its
public address and compares its pin with the certificate file, warning when
they differ. The host comes from site settings only; the check is a TLS
handshake, never an HTTP request.
- Fleet list: Credential column and filter (enrolled, pending, re-enroll
blocked, shared key, not reporting), computed in the list query.
- PC detail: credential state, prefix and dates for fleet readers, state only
for others, with admin Revoke (optionally blocking re-enrollment) and Allow
re-enrollment.
- Dashboard: pending enrollment requests, and PCs blocked from re-enrolling
for more than seven days.
- Runbook: where to find each of these in the app.
---
frontend/src/api/index.js | 13 +
frontend/src/composables/settingsCatalog.js | 5 +-
plugins/computers/frontend/views/PCDetail.vue | 131 ++++-
.../frontend/views/pcDetailCredential.spec.js | 58 ++
plugins/endpoints/AGENT-CONTRACT.md | 8 +
plugins/endpoints/api/enrollment.py | 170 ++++++
plugins/endpoints/api/routes.py | 135 ++++-
.../docs/PER-PC-CREDENTIALS-ROLLOUT.md | 24 +
.../frontend/views/AgentKeySettings.vue | 187 +++++--
.../frontend/views/EndpointsList.vue | 48 +-
.../frontend/views/ServerCertSettings.vue | 104 +++-
.../frontend/views/agentKeySettings.spec.js | 133 +++++
.../frontend/views/endpointsList.spec.js | 99 ++++
.../frontend/views/serverCertSettings.spec.js | 98 ++++
plugins/endpoints/plugin.py | 53 +-
tests/test_core/test_dashboard_widgets.py | 2 +
.../test_endpoints_credential_admin_ui.py | 525 ++++++++++++++++++
17 files changed, 1732 insertions(+), 61 deletions(-)
create mode 100644 plugins/computers/frontend/views/pcDetailCredential.spec.js
create mode 100644 plugins/endpoints/frontend/views/agentKeySettings.spec.js
create mode 100644 plugins/endpoints/frontend/views/endpointsList.spec.js
create mode 100644 plugins/endpoints/frontend/views/serverCertSettings.spec.js
create mode 100644 tests/test_plugins/test_endpoints_credential_admin_ui.py
diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js
index ec8cdba1..685d40c0 100644
--- a/frontend/src/api/index.js
+++ b/frontend/src/api/index.js
@@ -240,6 +240,19 @@ export const endpointsApi = {
}
return api.post(`/endpoints/${assetId}/agentupgrade`, body)
},
+ // Per-PC credential administration (admin role, re-checked server-side).
+ // blockreenroll keeps the PC from enrolling again until allowReenroll.
+ revokeCredential(assetId, opts = {}) {
+ const body = {}
+ if (opts.blockreenroll) {
+ body.blockreenroll = true
+ if (opts.reason) body.reason = opts.reason
+ }
+ return api.post(`/endpoints/enrollment/assets/${assetId}/revoke`, body)
+ },
+ allowReenroll(assetId) {
+ return api.post(`/endpoints/enrollment/assets/${assetId}/allow-reenroll`)
+ },
listCommands(assetId) {
return api.get(`/endpoints/${assetId}/commands`)
},
diff --git a/frontend/src/composables/settingsCatalog.js b/frontend/src/composables/settingsCatalog.js
index eae99a18..88d6cbc4 100644
--- a/frontend/src/composables/settingsCatalog.js
+++ b/frontend/src/composables/settingsCatalog.js
@@ -4,7 +4,7 @@
// (landing) read the merged catalog so plugins add settings cards with no core
// edit. Fetched once into a module-level ref and shared across callers.
import { ref, computed } from 'vue'
-import { Ruler, Wrench, Cog, Package, Settings, Puzzle, SlidersHorizontal, Palette, Bell, Network, Printer, Droplets, Terminal, KeyRound } from 'lucide-vue-next'
+import { Ruler, Wrench, Cog, Package, Settings, Puzzle, SlidersHorizontal, Palette, Bell, Network, Printer, Droplets, Terminal, KeyRound, ShieldCheck, BadgeCheck } from 'lucide-vue-next'
import api from '../api'
import router from '../router'
import { settingsGroups } from '../views/settings/settingsNav'
@@ -28,6 +28,9 @@ const iconMap = {
// The endpoints agent-key card has asked for 'key' since it shipped and has
// been silently drawing a puzzle piece the whole time.
key: KeyRound,
+ // Server Certificate asked for 'shield-check' and drew the puzzle fallback.
+ 'shield-check': ShieldCheck,
+ 'badge-check': BadgeCheck,
}
function resolveIcon(key) {
diff --git a/plugins/computers/frontend/views/PCDetail.vue b/plugins/computers/frontend/views/PCDetail.vue
index ea144433..d376eeab 100644
--- a/plugins/computers/frontend/views/PCDetail.vue
+++ b/plugins/computers/frontend/views/PCDetail.vue
@@ -83,6 +83,25 @@
{{ liveness.label }}
+
+
+ Credential
+
+ {{ credential.statelabel }}
+
+
+
+
+
+
+
+
+
+ The credential for
+ {{ computer?.assetnumber || computer?.name || 'this PC' }}
+ stops working immediately. {{ revokeCredentialOutcome }}
+
+
+
+
+ Use before rolling the PC back to an older agent, or for a compromised
+ PC. It stays off per-PC credentials until someone allows re-enrollment
+ here or on the Enrollment page.
+
+
+
+
+
+
+
+
+
+
+
@@ -1061,6 +1113,9 @@ const agentversion = ref(null)
// floor on the server cannot leave this page disagreeing with the API.
const agentcurrent = ref(null)
const actionminimums = ref({})
+// Per-PC credential state from the same endpoint read. The server withholds
+// the prefix and timestamps from readers below endpoints.fleet.
+const credential = ref(null)
const installedApps = ref([])
const assetPanels = ref(null)
@@ -1502,6 +1557,7 @@ async function loadLiveness(assetid) {
agentversion.value = state.agentversion || null
agentcurrent.value = state.agentcurrent || null
actionminimums.value = state.actionminimums || {}
+ credential.value = state.credential || null
} catch (error) {
// 404 = no agent on this PC, 403 = the viewer cannot read telemetry.
// Neither is a page error; the chip just does not appear.
@@ -1509,6 +1565,62 @@ async function loadLiveness(assetid) {
agentversion.value = null
agentcurrent.value = null
actionminimums.value = {}
+ credential.value = null
+ }
+}
+
+// Revoke and allow-reenroll are require_role('admin') on the server.
+const canManageCredential = computed(() => authStore.isAdmin)
+const credentialbusy = ref(false)
+const showrevokecredential = ref(false)
+const revokeblockreenroll = ref(false)
+const revokereason = ref('')
+
+const revokeCredentialOutcome = computed(() => (revokeblockreenroll.value
+ ? 'It keeps reporting on the shared key while that is allowed, and does not enroll again.'
+ : 'Its agent falls back to the shared key and enrolls again on its own, if the shared key is allowed.'))
+
+function openRevokeCredential() {
+ revokeblockreenroll.value = false
+ revokereason.value = ''
+ showrevokecredential.value = true
+}
+
+async function submitRevokeCredential() {
+ if (!computer.value?.assetid) return
+ const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
+ credentialbusy.value = true
+ try {
+ await endpointsApi.revokeCredential(computer.value.assetid, {
+ blockreenroll: revokeblockreenroll.value,
+ reason: revokereason.value.trim()
+ })
+ toast.success(`Credential revoked for ${label}`)
+ showrevokecredential.value = false
+ await loadLiveness(computer.value.assetid)
+ await assetPanels.value?.refresh()
+ } catch (error) {
+ toast.error(apiError(error, `Could not revoke the credential for ${label}`))
+ } finally {
+ credentialbusy.value = false
+ }
+}
+
+async function confirmAllowReenroll() {
+ if (!computer.value?.assetid) return
+ const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
+ if (!confirm(`Allow ${label} to enroll again?\n\n`
+ + 'Its agent enrolls on its next attempt if it runs agent 1.20 or later.')) return
+ credentialbusy.value = true
+ try {
+ await endpointsApi.allowReenroll(computer.value.assetid)
+ toast.success(`${label} may enroll again`)
+ await loadLiveness(computer.value.assetid)
+ await assetPanels.value?.refresh()
+ } catch (error) {
+ toast.error(apiError(error, `Could not allow re-enrollment for ${label}`))
+ } finally {
+ credentialbusy.value = false
}
}
@@ -2393,6 +2505,22 @@ async function confirmRetire() {
justify-content: flex-end;
gap: 0.5rem;
}
+.hero-credential {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 0.45rem;
+}
+.credential-state {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--text-light);
+}
+.credential-state.enrolled { color: var(--success-dark); }
+.credential-state.pending { color: var(--warning-dark); }
+.credential-state.reenrollblocked { color: var(--danger); }
+[data-theme="dark"] .credential-state.enrolled { color: var(--success); }
+[data-theme="dark"] .credential-state.pending { color: var(--warning); }
/* Warning-toned, not error-toned: a hold is a deliberate, audited state, not a
fault. It should read as "someone did this on purpose" at a glance. */
.policy-holds {
@@ -3052,7 +3180,8 @@ async function confirmRetire() {
align-items: flex-start;
width: 100%;
}
- .hero-status { align-items: flex-start; }
+ .hero-status,
+ .hero-credential { align-items: flex-start; }
.hero-endpoint-actions { justify-content: flex-start; }
}
.copy-btn:hover {
diff --git a/plugins/computers/frontend/views/pcDetailCredential.spec.js b/plugins/computers/frontend/views/pcDetailCredential.spec.js
new file mode 100644
index 00000000..a8b47f0e
--- /dev/null
+++ b/plugins/computers/frontend/views/pcDetailCredential.spec.js
@@ -0,0 +1,58 @@
+/**
+ * Per-PC credential controls on PC Details.
+ *
+ * Revoke and allow-reenroll are admin-role routes on the server. The page reads
+ * the credential from the same endpoint state call the liveness chip uses, and
+ * offers the two actions to admins only; every other reader gets the state in
+ * the Endpoint status card, where the server has already redacted the details.
+ *
+ * Asserted against the file text, matching pcDetailRunScripts.spec.js.
+ */
+import { describe, it, expect } from 'vitest'
+import { readFileSync } from 'fs'
+import { join, dirname } from 'path'
+import { fileURLToPath } from 'url'
+
+const here = dirname(fileURLToPath(import.meta.url))
+const source = readFileSync(join(here, 'PCDetail.vue'), 'utf8')
+// Relative to the staged copy under src/.plugins-staged/, which is what runs.
+const api = readFileSync(join(here, '..', '..', '..', 'api', 'index.js'), 'utf8')
+
+describe('PCDetail credential controls', () => {
+ it('gates the controls on the admin role', () => {
+ expect(source).toMatch(/const canManageCredential = computed\(\(\) => authStore\.isAdmin\)/)
+ expect(source).toMatch(/v-if="canManageCredential && credential"/)
+ })
+
+ it('reads the credential from the endpoint state response', () => {
+ const loader = source.slice(source.indexOf('async function loadLiveness'))
+ const body = loader.slice(0, loader.indexOf('\n}\n'))
+ expect(body).toMatch(/credential\.value = state\.credential \|\| null/)
+ })
+
+ it('offers revoke only for an enrolled PC and allow only for a blocked one', () => {
+ expect(source).toMatch(/v-if="credential\.state === 'enrolled'"[\s\S]{0,120}credential-revoke/)
+ expect(source).toMatch(/v-if="credential\.reenrollblocked"[\s\S]{0,120}credential-allow/)
+ })
+
+ it('offers the keep-from-enrolling option and sends it with the reason', () => {
+ expect(source).toMatch(/v-model="revokeblockreenroll"/)
+ expect(source).toMatch(/Keep this PC from enrolling again/)
+ const handler = source.slice(source.indexOf('async function submitRevokeCredential'))
+ const body = handler.slice(0, handler.indexOf('\n}\n'))
+ expect(body).toMatch(/blockreenroll: revokeblockreenroll\.value/)
+ expect(body).toMatch(/reason: revokereason\.value\.trim\(\)/)
+ })
+
+ it('calls the existing enrollment routes', () => {
+ expect(api).toMatch(/`\/endpoints\/enrollment\/assets\/\$\{assetId\}\/revoke`/)
+ expect(api).toMatch(/`\/endpoints\/enrollment\/assets\/\$\{assetId\}\/allow-reenroll`/)
+ expect(api).toMatch(/if \(opts\.blockreenroll\) \{\s*body\.blockreenroll = true/)
+ })
+
+ it('confirms before allowing re-enrollment', () => {
+ const handler = source.slice(source.indexOf('async function confirmAllowReenroll'))
+ const body = handler.slice(0, handler.indexOf('\n}\n'))
+ expect(body).toMatch(/if \(!confirm\(/)
+ })
+})
diff --git a/plugins/endpoints/AGENT-CONTRACT.md b/plugins/endpoints/AGENT-CONTRACT.md
index 3885699f..cd2dcbad 100644
--- a/plugins/endpoints/AGENT-CONTRACT.md
+++ b/plugins/endpoints/AGENT-CONTRACT.md
@@ -727,6 +727,14 @@ Read needs `endpoints.fleet`; writes need the admin role and are audited.
- `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)
+- `GET /api/endpoints` rows carry `credentialstate` (`enrolled`,
+ `reenrollblocked`, `pending`, `notreporting`, `sharedkey`) and accept a
+ `credentialstate` filter. `GET /api/endpoints/` carries a
+ `credential` object in the same vocabulary; below `endpoints.fleet` only its
+ state is filled in.
+- `GET /api/endpoints/settings/server-cert/live` (`endpoints.fleet`) reads the
+ certificate presented at `site_base_url` and returns `presentedspkisha256`,
+ `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
diff --git a/plugins/endpoints/api/enrollment.py b/plugins/endpoints/api/enrollment.py
index d5737cbc..517aedeb 100644
--- a/plugins/endpoints/api/enrollment.py
+++ b/plugins/endpoints/api/enrollment.py
@@ -13,6 +13,10 @@ Admin (endpoints.fleet to read, admin role to change):
GET /enrollment/migration readiness of every computer for agent 1.20
POST /enrollment/migration/wave push the agent upgrade to a batch of PCs
+Dashboard (endpoints.fleet):
+ GET /dashboard/enrollmentpending one row while enroll requests await an admin
+ GET /dashboard/reenrollblocked PCs blocked from re-enrolling over 7 days
+
A PC moves onto its own credential by running an agent that enrolls. The
migration surface computes, per computer, whether the existing agent upgrade
can be pushed to it, and pushes it in waves through the same enqueue path the
@@ -89,6 +93,25 @@ _TLS_TRUST_FAILURES = (
_OFFLINE_AFTER = timedelta(hours=24)
_WAVE_MAXIMUM = 100
_RECENT_REQUESTS = 50
+_BLOCKED_ATTENTION_AFTER = timedelta(days=7)
+
+# Per-PC credential state as the fleet list and PC detail show it. First match
+# wins, in this order.
+CREDENTIAL_ENROLLED = 'enrolled'
+CREDENTIAL_REENROLL_BLOCKED = 'reenrollblocked'
+CREDENTIAL_PENDING = 'pending'
+CREDENTIAL_NOT_REPORTING = 'notreporting'
+CREDENTIAL_SHARED_KEY = 'sharedkey'
+CREDENTIAL_STATES = (CREDENTIAL_ENROLLED, CREDENTIAL_REENROLL_BLOCKED,
+ CREDENTIAL_PENDING, CREDENTIAL_NOT_REPORTING,
+ CREDENTIAL_SHARED_KEY)
+CREDENTIAL_STATE_LABELS = {
+ CREDENTIAL_ENROLLED: 'Enrolled',
+ CREDENTIAL_REENROLL_BLOCKED: 'Re-enroll blocked',
+ CREDENTIAL_PENDING: 'Pending',
+ CREDENTIAL_NOT_REPORTING: 'Not reporting',
+ CREDENTIAL_SHARED_KEY: 'Shared key (not enrolled)',
+}
def _naive_utcnow():
@@ -329,6 +352,97 @@ def _is_pending(assetid, hostname, index):
return assetid in assetids or (hostname or '').lower() in hostnames
+def _awaiting_admin_filter():
+ """Pending requests an admin can act on.
+
+ A PC blocked from re-enrolling keeps asking on purpose; those rows are
+ expected, not work.
+ """
+ return db.and_(
+ EndpointEnrollRequest.status == EndpointEnrollRequest.STATUS_PENDING,
+ db.or_(EndpointEnrollRequest.reason.is_(None),
+ EndpointEnrollRequest.reason != REASON_REENROLL_BLOCKED))
+
+
+def credential_state_expression(assetid, hostname, lastreportedat):
+ """SQL CASE giving CREDENTIAL_STATES for a row, evaluated in the query.
+
+ Correlated EXISTS on endpointcredentials and endpointenrollrequests, so a
+ paged list filters and labels in one statement instead of per row.
+ """
+ active = (db.select(EndpointCredential.credentialid)
+ .where(EndpointCredential.assetid == assetid,
+ EndpointCredential.revokedat.is_(None))
+ .exists())
+ blocked = (db.select(EndpointCredential.credentialid)
+ .where(EndpointCredential.assetid == assetid,
+ EndpointCredential.reenrollblocked.is_(True))
+ .exists())
+ pending = (db.select(EndpointEnrollRequest.requestid)
+ .where(EndpointEnrollRequest.status
+ == EndpointEnrollRequest.STATUS_PENDING,
+ db.or_(EndpointEnrollRequest.matchedassetid == assetid,
+ db.func.lower(EndpointEnrollRequest.hostname)
+ == db.func.lower(hostname)))
+ .exists())
+ return db.case(
+ (active, CREDENTIAL_ENROLLED),
+ (blocked, CREDENTIAL_REENROLL_BLOCKED),
+ (pending, CREDENTIAL_PENDING),
+ (lastreportedat.is_(None), CREDENTIAL_NOT_REPORTING),
+ else_=CREDENTIAL_SHARED_KEY,
+ )
+
+
+def credential_detail(assetid, hostname, lastreportedat, detailed):
+ """Credential state of one PC, same rules as credential_state_expression.
+
+ detailed False withholds the prefix, timestamps, enrollment path and block
+ reason, leaving the state alone.
+ """
+ credential = EndpointCredential.query.filter_by(assetid=assetid).first()
+ if credential is not None and credential.revokedat is None:
+ state = CREDENTIAL_ENROLLED
+ elif credential is not None and credential.reenrollblocked:
+ state = CREDENTIAL_REENROLL_BLOCKED
+ elif (EndpointEnrollRequest.query
+ .filter(EndpointEnrollRequest.status
+ == EndpointEnrollRequest.STATUS_PENDING,
+ db.or_(EndpointEnrollRequest.matchedassetid == assetid,
+ db.func.lower(EndpointEnrollRequest.hostname)
+ == (hostname or '').lower()))
+ .first()) is not None:
+ state = CREDENTIAL_PENDING
+ elif lastreportedat is None:
+ state = CREDENTIAL_NOT_REPORTING
+ else:
+ state = CREDENTIAL_SHARED_KEY
+
+ detail = {
+ 'state': state,
+ 'statelabel': CREDENTIAL_STATE_LABELS[state],
+ 'credentialprefix': None,
+ 'issuedat': None,
+ 'lastusedat': None,
+ 'revokedat': None,
+ 'enrolledvia': None,
+ 'reenrollblocked': bool(credential is not None
+ and credential.reenrollblocked),
+ 'reenrollblockedreason': None,
+ }
+ if detailed and credential is not None:
+ detail.update({
+ 'credentialprefix': credential.credentialprefix,
+ 'issuedat': _utciso(credential.issuedat),
+ 'lastusedat': _utciso(credential.lastusedat),
+ 'revokedat': _utciso(credential.revokedat),
+ 'enrolledvia': credential.enrolledvia,
+ 'reenrollblockedreason': (credential.reenrollblockedreason
+ if credential.reenrollblocked else None),
+ })
+ return detail
+
+
def _search_filter(rows, search, *keys):
if not search:
return rows
@@ -917,3 +1031,59 @@ def create_migration_wave():
{'waveid': waveid, 'queued': queued, 'skipped': skipped},
message='{} PC(s) queued'.format(len(queued)),
http_code=201 if queued else 200)
+
+
+# ---------------------------------------------------------------------------
+# Dashboard
+# ---------------------------------------------------------------------------
+
+@endpoints_bp.route('/dashboard/enrollmentpending', methods=['GET'])
+@require_permission('endpoints.fleet')
+def dashboard_enrollment_pending():
+ """One queue row while enroll requests wait for an admin, else nothing.
+
+ A count rather than a row per PC: the decision is made on the Enrollment
+ page, where the asset picker is, so the dashboard only has to say go there.
+ """
+ count = (db.session.query(db.func.count(EndpointEnrollRequest.requestid))
+ .filter(_awaiting_admin_filter())
+ .scalar()) or 0
+ if not count:
+ return success_response([])
+ return success_response([{
+ 'summary': '{} PC{} waiting for enrollment approval'.format(
+ count, '' if count == 1 else 's'),
+ 'pendingcount': count,
+ }])
+
+
+@endpoints_bp.route('/dashboard/reenrollblocked', methods=['GET'])
+@require_permission('endpoints.fleet')
+def dashboard_reenroll_blocked():
+ """PCs kept from re-enrolling for more than 7 days.
+
+ A block is the first step of a rollback and is meant to be lifted again.
+ There is no separate block timestamp: revokedat is when the credential
+ stopped working, which is when the rollback started.
+ """
+ Computer = _computer_model()
+ cutoff = _naive_utcnow() - _BLOCKED_ATTENTION_AFTER
+ now = _naive_utcnow()
+ rows = (db.session.query(EndpointCredential, Computer.computerid,
+ Computer.hostname)
+ .join(Computer, Computer.assetid == EndpointCredential.assetid)
+ .join(Asset, Asset.assetid == EndpointCredential.assetid)
+ .filter(Asset.isactive.is_(True),
+ EndpointCredential.reenrollblocked.is_(True),
+ EndpointCredential.revokedat.isnot(None),
+ EndpointCredential.revokedat < cutoff)
+ .order_by(EndpointCredential.revokedat.asc())
+ .limit(50)
+ .all())
+ return success_response([{
+ 'computerid': computerid,
+ 'assetid': credential.assetid,
+ 'hostname': hostname,
+ 'blockeddays': (now - credential.revokedat).days,
+ 'reason': credential.reenrollblockedreason,
+ } for credential, computerid, hostname in rows])
diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py
index b97545cb..6e7277b0 100644
--- a/plugins/endpoints/api/routes.py
+++ b/plugins/endpoints/api/routes.py
@@ -233,6 +233,7 @@ def list_endpoints():
operator reads first.
"""
from plugins.computers.models import Computer, ComputerType
+ from .enrollment import CREDENTIAL_STATES, credential_state_expression
page, per_page = get_pagination_params(request)
cutoff = _naive_utcnow() - timedelta(hours=_quiet_hours())
@@ -245,10 +246,16 @@ def list_endpoints():
else_=2,
)
+ # Evaluated in the same statement as correlated EXISTS, so the column and
+ # its filter cost no query per row and survive pagination.
+ credentialstate = credential_state_expression(
+ EndpointState.assetid, Computer.hostname, EndpointState.lastreportedat)
+
query = (db.session.query(EndpointState, Computer.computerid,
Computer.computertypeid, ComputerType.computertype,
Asset.assetnumber, Asset.name,
- statusrank.label('statusrank'))
+ statusrank.label('statusrank'),
+ credentialstate.label('credentialstate'))
.join(Asset, Asset.assetid == EndpointState.assetid)
.join(Computer, Computer.assetid == EndpointState.assetid)
.outerjoin(ComputerType,
@@ -271,6 +278,10 @@ def list_endpoints():
if wanted is not None:
query = query.filter(statusrank == wanted)
+ wantedcredential = (request.args.get('credentialstate') or '').strip().lower()
+ if wantedcredential in CREDENTIAL_STATES:
+ query = query.filter(credentialstate == wantedcredential)
+
if computertypeid := request.args.get('computertypeid'):
query = query.filter(Computer.computertypeid == computertypeid)
if locationid := request.args.get('locationid'):
@@ -300,7 +311,7 @@ def list_endpoints():
data = []
for (state, computerid, computertypeid, computertype, assetnumber, alias,
- rank) in items:
+ rank, credential) in items:
row = state.to_dict()
row['computerid'] = computerid
row['assetnumber'] = assetnumber
@@ -313,6 +324,7 @@ def list_endpoints():
row['status'] = ('never', 'quiet', 'reporting')[rank]
row['machines'] = machines.get(state.assetid, [])
row['health'] = health.get(state.assetid)
+ row['credentialstate'] = credential
data.append(row)
return paginated_response(data, page, per_page, total)
@@ -431,6 +443,21 @@ def _health_for_assets(states, computertypes):
return out
+def _credential_for_state(state):
+ """Per-PC credential state for one endpoint, redacted below endpoints.fleet.
+
+ The state itself is as public as the rest of the status panel. The prefix,
+ timestamps, enrollment path and block reason are fleet administration, so a
+ reader holding only endpoints.view (a guest, where a site grants that) gets
+ the state and nothing else.
+ """
+ from .enrollment import credential_detail
+
+ hostname = _hostname_for_asset(state.assetid) or state.hostname
+ return credential_detail(state.assetid, hostname, state.lastreportedat,
+ detailed=caller_holds('endpoints.fleet'))
+
+
@endpoints_bp.route('/', methods=['GET'])
@require_permission('endpoints.view')
def get_endpoint(asset_id: int):
@@ -469,6 +496,7 @@ def get_endpoint(asset_id: int):
# the enqueue guard refuses against these same numbers, so a copy in the
# frontend would silently disagree the moment a floor was raised here.
data['actionminimums'] = action_minimum_versions()
+ data['credential'] = _credential_for_state(state)
# Localize timestamps to site timezone
for ts_field in ['lastreportedat', 'lasteventat', 'created_at', 'updated_at']:
@@ -625,6 +653,17 @@ def panel_status(asset_id: int):
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None,
fmt='datetime'),
]
+ credential = _credential_for_state(state)
+ fields.extend([
+ field('Credential', credential['statelabel']),
+ field('Credential prefix',
+ credential['credentialprefix'] + '...'
+ if credential['credentialprefix'] else None, mono=True),
+ field('Credential issued', credential['issuedat'], fmt='datetime'),
+ field('Credential last used', credential['lastusedat'], fmt='datetime'),
+ field('Enrolled via', credential['enrolledvia']),
+ field('Re-enroll block reason', credential['reenrollblockedreason']),
+ ])
fields = [f for f in fields if f['value'] not in (None, '')]
# Informational only, and appended after the null-filter above because it
@@ -654,6 +693,7 @@ def panel_status(asset_id: int):
'agentbehind': bool(agentbehind),
'agentversion': state.agentversion,
'agentcurrent': published,
+ 'credential': credential,
})
@@ -4370,6 +4410,97 @@ def download_server_certificate():
return response
+_LIVE_CHECK_TIMEOUT_SECONDS = 5
+
+
+def _presented_certificate(host, port):
+ """DER bytes of the leaf certificate a TLS server presents, never trusted.
+
+ Verification is off ONLY so a self-signed or internal-CA certificate can be
+ read at all; nothing is sent over the connection and nothing it returns is
+ believed except the certificate bytes, which are hashed and compared.
+ """
+ import ipaddress
+ import socket
+ import ssl
+
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+ context.check_hostname = False
+ context.verify_mode = ssl.CERT_NONE
+ try:
+ ipaddress.ip_address(host)
+ servername = None
+ except ValueError:
+ servername = host
+ with socket.create_connection((host, port),
+ timeout=_LIVE_CHECK_TIMEOUT_SECONDS) as raw:
+ with context.wrap_socket(raw, server_hostname=servername) as secured:
+ return secured.getpeercert(binary_form=True)
+
+
+@endpoints_bp.route('/settings/server-cert/live', methods=['GET'])
+@require_permission('endpoints.fleet')
+def live_server_certificate():
+ """The certificate PCs actually receive from this site's public address.
+
+ The file on disk is what this app manages, but a PC pins whatever the site
+ presents: an IIS binding, a reverse proxy or a load balancer in front of the
+ app can serve a different certificate. The host comes from the
+ site_base_url setting and never from the request, and only a TLS handshake
+ is made (no HTTP request), so there is no redirect to follow.
+ """
+ from urllib.parse import urlsplit
+ from cryptography import x509
+ from cryptography.hazmat.primitives import hashes, serialization
+
+ baseurl = str(Setting.get('site_base_url') or '').strip()
+ if not baseurl:
+ return success_response({'configured': False})
+
+ result = {
+ 'configured': True,
+ 'url': baseurl,
+ 'presentedspkisha256': None,
+ 'presentedsha1thumbprint': None,
+ 'matchesfile': False,
+ 'error': None,
+ }
+ try:
+ parts = urlsplit(baseurl)
+ host = parts.hostname
+ port = parts.port or 443
+ except ValueError:
+ host = None
+ if not host:
+ result['error'] = 'site_base_url is not a valid URL'
+ return success_response(result)
+ if (parts.scheme or '').lower() != 'https':
+ result['error'] = 'site_base_url does not use https, so no certificate is presented'
+ return success_response(result)
+
+ try:
+ der = _presented_certificate(host, port)
+ certificate = x509.load_der_x509_certificate(der)
+ except Exception as exc: # noqa: BLE001 - reported to the admin, not raised
+ result['error'] = 'Could not read the certificate from {}:{} ({})'.format(
+ host, port, (str(exc) or exc.__class__.__name__)[:200])
+ return success_response(result)
+
+ spki = certificate.public_key().public_bytes(
+ serialization.Encoding.DER,
+ serialization.PublicFormat.SubjectPublicKeyInfo)
+ result['presentedspkisha256'] = hashlib.sha256(spki).hexdigest()
+ result['presentedsha1thumbprint'] = certificate.fingerprint(
+ hashes.SHA1()).hex().upper()
+
+ certpath = _server_cert_dir() / 'server.crt'
+ if certpath.exists():
+ filepin = _spki_sha256(certpath.read_bytes())
+ result['matchesfile'] = bool(
+ filepin and hmac.compare_digest(filepin, result['presentedspkisha256']))
+ return success_response(result)
+
+
_CERT_HOSTNAME_PATTERN = re.compile(
r'^(\*\.)?(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
r'(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$')
diff --git a/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md b/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md
index 3d49b980..559928f0 100644
--- a/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md
+++ b/plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md
@@ -21,6 +21,30 @@ root, `` a server key pin, `` / `` the pilot PC.
turns it off. An enrolled PC is always refused the shared key
(`401 SHARED_KEY_REFUSED`).
+## Where to look in the app
+
+- **Settings -> Endpoints -> Enrollment**: Migration tab (readiness, waves) and
+ Credentials tab (enrolled PCs, pending requests, un-enroll, allow
+ re-enrollment, the shared key and auto-enroll switches).
+- **Settings -> Endpoints** (the agent key page): the shared key PCs use until
+ they enroll, with an enrolled-of-total summary and the pending request count.
+ Regenerate and revoke there say how many PCs are not enrolled and would be
+ affected.
+- **Settings -> Endpoints -> Server Certificate**: the certificate file's pin
+ and thumbprint, and **What PCs receive**, a live check of the certificate the
+ site's base URL (`site_base_url`) actually presents. When the two differ, pin
+ the presented one.
+- **Fleet** (sidebar, under Assets): the **Credential** column (Enrolled,
+ Pending, Re-enroll blocked, Shared key (not enrolled), Not reporting) and a
+ filter on it.
+- **PC detail**: the Endpoint status card shows the PC's credential state (with
+ prefix, issued, last used, enrolled via and block reason for
+ `endpoints.fleet` holders). Admins get **Revoke credential** (with **Keep this
+ PC from enrolling again**) and **Allow re-enrollment** beside the status chip.
+- **Dashboard**: "N PCs waiting for enrollment approval" (warning, links to
+ Enrollment) and "PCs blocked from re-enrolling over 7 days" (info, counted
+ from when the credential was revoked).
+
## Before you start
1. Server deployed with the endpoints migration `endpoints0023credentials`
diff --git a/plugins/endpoints/frontend/views/AgentKeySettings.vue b/plugins/endpoints/frontend/views/AgentKeySettings.vue
index 9350bc48..3a0581ac 100644
--- a/plugins/endpoints/frontend/views/AgentKeySettings.vue
+++ b/plugins/endpoints/frontend/views/AgentKeySettings.vue
@@ -5,15 +5,17 @@
- Manage how shop-floor endpoint agents authenticate to this server, build
- the agent for this site, and set what it reports and how often.
+ Manage the shared key PCs use until they enroll, build the agent for this
+ site, and set what it reports and how often. Each enrolled PC
+ authenticates with its own credential instead; see Settings > Endpoints
+ > Enrollment.
-
Key management
+
Shared key (used until PCs enroll)
STORE YOUR KEY SECURELY. IT IS SHOWN ONLY ONCE AND CANNOT BE RECOVERED.
@@ -25,12 +27,40 @@
paste the raw key into a chat message or a plain file.
- The endpoint agent reports with a single managed key scoped to endpoint
- telemetry only. It is separate from every other collector, so rotating
- or revoking it stops all endpoint-agent traffic immediately without
- touching the PC or GE-Enforce collectors.
+ Every PC installed with the shared key holds this same key. It lets a
+ PC that has not enrolled yet report and ask for its own credential. An enrolled PC is refused it and
+ uses its credential instead, so rotating or revoking the key only
+ affects PCs that have not enrolled. It is scoped to endpoint telemetry
+ and separate from every other collector.
+
+ The shared key is switched off in Enrollment, so PCs
+ that have not enrolled already cannot report with it.
+
+ Approve requests, move PCs over and retire the shared key in
+
+ Settings > Endpoints > Enrollment.
+
+
+
{{ message }}
{{ error }}
@@ -49,8 +79,8 @@
Not used yet.
- No endpoint agent key is active. Generate one to let agents report
- with a managed credential.
+ No shared key is active. PCs that have not enrolled cannot report
+ or auto-enroll until one is generated.
@@ -85,9 +115,10 @@
Save this key now
- Anyone with this key can report as an endpoint agent for this site.
- Treat it like a password. If it is lost you must regenerate, which
- revokes the old key and forces every agent to be reconfigured.
+ Anyone with this key can report as a not-yet-enrolled endpoint agent
+ for this site and ask to enroll. Treat it like a password. If it is
+ lost you must regenerate, which revokes the old key; PCs that have not
+ enrolled then need the new one.
Recommended: before you leave this page, open the
@@ -110,53 +141,57 @@
How it works
- Generate mints a fresh key and, on the first
- generation, turns off the bootstrap .env fallback so
- the managed key becomes the only accepted credential. The full
- secret is shown once - store it in the agent config as the
- X-API-Key header.
+ Until a PC enrolls it reports with this shared key.
+ Agents from 1.20 use it once more to ask for their own credential,
+ and from then on send that credential instead.
- Regenerate is a hard cutover: the previous key is
- revoked in the same moment the new one is minted. Every deployed
- agent must be reconfigured with the new key before it can report
- again.
+ Enrolled PCs ignore it. The server refuses the shared
+ key for any PC that holds a credential, so regenerating or revoking
+ it does not touch them.
- Revoke deactivates the key and leaves it off. All
- endpoint-agent reporting stops until you generate a new key. This is
- the kill switch.
+ Generate mints a key and, the first time, turns off
+ the bootstrap .env fallback. The full secret is shown
+ once; it goes into the agent config as the X-API-Key
+ header.
+
+
+ Regenerate revokes the previous key in the same
+ moment. PCs that have not enrolled must be given the new key before
+ they can report or enroll again.
+
+
+ Revoke leaves no shared key. PCs that have not
+ enrolled stop reporting and can no longer auto-enroll; enrolled PCs
+ carry on.
-
When to rotate
+
When to rotate or retire
- An installer or agent left the secure environment.
- If a build carrying the key is copied off the shop floor - onto an
- engineer's laptop, a USB stick, or a machine that leaves the site -
- treat the key as compromised and regenerate immediately.
+ At cutover. Once every PC has enrolled, switch the
+ shared key off in Enrollment, clear it from every place it lives,
+ then regenerate so any copy you missed is dead. The steps are in the
+ per-PC credentials rollout runbook.
- A device is lost, stolen, or decommissioned while
- still holding the key, and you cannot confirm the key was wiped.
+ An installer or key left the secure environment. A
+ build or file carrying the key copied onto a laptop, a USB stick or a
+ machine that leaves the site. Regenerate; only PCs that have not
+ enrolled need the new key.
Staff offboarding. Someone who could read the key
- from a config or build pipeline is leaving; rotate as part of the
- offboarding checklist.
+ from a config or build pipeline is leaving.
- Scheduled hygiene. Rotate on a routine cadence
- (for example quarterly) so a silently leaked key has a bounded
- lifetime.
-
-
- Suspicious traffic. The Fleet view shows reports
- from a host you do not recognize - revoke now, investigate, then
- regenerate and redeploy to known-good endpoints.
+ Suspicious traffic. The Fleet view shows reports from
+ a host you do not recognize. Revoke now, investigate, then regenerate
+ for the PCs that still need it.