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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user