Show per-PC credentials across the admin interface
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled

- 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.
This commit is contained in:
cproudlock
2026-09-15 14:09:08 -04:00
parent da4b224ac1
commit c2ffc591bb
17 changed files with 1732 additions and 61 deletions

View File

@@ -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])