Make an admin's refusal stick, and let a revoke actually deny

Three holes in the enrollment path, all of the same shape: a decision the admin
made did not survive contact with a PC that simply asked again.

REJECTION WAS UNDONE BY A NEW NONCE. A rejected request re-armed itself as a
fresh cycle when the PC came back with a different nonce, and with the shared
key and auto-enroll on it was then issued the credential the admin had just
refused. Rejection now binds to the endpoint identity - hostname plus BIOS
serial - not to the nonce, so the PC stays refused however often it reinstalls.
An admin clears it deliberately, through the approve path that already exists; a
genuinely different PC is untouched.

THE PENDING QUEUE COULD BE FILLED UNTIL IT REFUSED EVERY NEW PC. /enroll takes
no auth by design - a bay that has never spoken to the server has nothing to
authenticate with - so anything that can reach the server could sit at the cap
and lock out real enrollments fleet-wide. Rows nobody has touched for seven days
now age out before the cap refuses anyone, and the count of what went is
audited. Per-identity dedup and the cap itself were already there and are left
alone.

REVOKE DID NOT STOP A LIVE AGENT. It fell back to the shared key and the server
admitted it again, so revoking a compromised PC changed nothing while that key
still worked. plugin.endpoints.revokeddeniessharedkey makes a revoked host
refused on the shared key too.

That switch DEFAULTS OFF, and that is the important part. Turning it on would
silence any bay revoked for a key rotation, and would break the documented
revoke-then-rollback path to an older agent - on an air-gapped site, mid
migration, that is a fleet outage. A never-enrolled bay is never affected either
way: with no credential row at all the shared key still works, which is the
whole un-migrated fleet.

The checkbox sits next to the other two enrollment switches and says what
turning it on costs, because a setting that can stop the floor should not be
reachable only through the API.
This commit is contained in:
cproudlock
2026-09-18 13:29:51 -04:00
parent fe4f44f855
commit 3f80425f30
6 changed files with 585 additions and 28 deletions

View File

@@ -9,9 +9,15 @@ and, when the route names a PC, binds the caller through bind_hostname().
X-API-Key The shared fleet key (or a collector-scoped managed
token). Accepted only while
plugin.endpoints.sharedkeyallowed is on, and never for a
PC that holds an active credential. A PC blocked
from re-enrolling has no active credential, so an
older agent on it keeps working on the shared key.
PC that holds an active credential. A PC whose
credential was REVOKED falls back to the shared key,
which is how a rollback to an older agent works and how
the un-migrated fleet keeps reporting; a site that has
finished migrating turns
plugin.endpoints.revokeddeniessharedkey on and a revoked
PC is then refused until it enrolls again. A PC that
never enrolled has no credential row at all and is
never touched by either rule.
Only the sha256 of a credential is stored. The full secret exists once, in the
enroll response.
@@ -37,6 +43,12 @@ DISPLAY_PREFIX_LENGTH = 12
SHARED_KEY_SETTING = 'plugin.endpoints.sharedkeyallowed'
AUTO_ENROLL_SETTING = 'plugin.endpoints.autoenroll'
# Off by default, which is what this site does today: revoking a credential
# drops the PC back to the shared key. Turning it on makes a revoke a real
# eviction - the PC reports nothing until it enrolls again - so it is only safe
# once the fleet is off the shared key, and it also closes the rollback path
# that revoke + blockreenroll exists for.
REVOKED_DENIES_SHARED_KEY_SETTING = 'plugin.endpoints.revokeddeniessharedkey'
CODE_CREDENTIAL_INVALID = 'CREDENTIAL_INVALID'
CODE_SHARED_KEY_REFUSED = 'SHARED_KEY_REFUSED'
@@ -70,6 +82,12 @@ def auto_enroll_enabled():
return Setting.get(AUTO_ENROLL_SETTING, default=True) is not False
def revoked_denies_shared_key():
"""True when a revoked credential also bars the PC from the shared key."""
return Setting.get(REVOKED_DENIES_SHARED_KEY_SETTING,
default=False) is True
def active_credential_for(assetid):
from ..models import EndpointCredential
@@ -81,6 +99,33 @@ def active_credential_for(assetid):
.first())
def shared_key_denied_for(assetid):
"""True when this asset may not report on the shared key.
An active credential always denies it: the PC has its own secret and a
shared-key call for it is either an older agent or somebody else.
A revoked credential denies it only while revokeddeniessharedkey is on.
Today it does not, on purpose: revoke is also how an admin rotates a
credential or rolls a PC back to an agent that only knows the shared key
(revoke + blockreenroll), and denying by default would silence that PC
instead. A PC that never enrolled has no credential row and is never denied,
which is what keeps the un-migrated fleet reporting.
"""
from ..models import EndpointCredential
if assetid is None:
return False
row = (EndpointCredential.query
.filter(EndpointCredential.assetid == assetid)
.first())
if row is None:
return False
if row.revokedat is None:
return True
return revoked_denies_shared_key()
def reenroll_blocked(assetid):
"""True when an admin blocked this asset from enrolling again."""
from ..models import EndpointCredential
@@ -130,8 +175,7 @@ def shared_key_refused_for(hostname):
"""True when the shared key may not report for this hostname."""
if not shared_key_allowed():
return True
assetid = resolve_hostname(hostname)
return assetid is not None and active_credential_for(assetid) is not None
return shared_key_denied_for(resolve_hostname(hostname))
def credential_invalid_response():
@@ -148,6 +192,20 @@ def shared_key_refused_response():
http_code=401)
def shared_key_revoked_response():
"""Same code and status; the operator needs to know which refusal it is.
The PC holds no credential and cannot use the shared key either, so an
admin has to let it enroll again. Nothing changes for the agent, which
keeps retrying either way.
"""
return error_response(
CODE_SHARED_KEY_REFUSED,
'This endpoint had its credential revoked, so the shared key is not '
'accepted for it; let it enroll again',
http_code=401)
def asset_mismatch_response():
return error_response(
CODE_ASSET_MISMATCH,
@@ -210,7 +268,8 @@ def bind_hostname(hostname):
ASSET_MISMATCH, a hostname on no computer record at all is refused with the
same code but its own message, and a missing hostname is fine.
Shared key: the hostname's asset (None when absent or unknown, for the
route to answer); an enrolled PC is refused.
route to answer); an enrolled PC is refused, and so is one whose credential
was revoked while revokeddeniessharedkey is on.
"""
hostname = (hostname or '').strip()
if is_credential_caller():
@@ -225,7 +284,9 @@ def bind_hostname(hostname):
if not hostname:
return None, None
assetid = resolve_hostname(hostname)
if assetid is not None and active_credential_for(assetid) is not None:
if shared_key_denied_for(assetid):
if active_credential_for(assetid) is None:
return None, shared_key_revoked_response()
return None, shared_key_refused_response()
g.agentassetid = assetid
return assetid, None