main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
294 lines
10 KiB
Python
294 lines
10 KiB
Python
"""Agent authentication: per-PC credentials, with the shared key as fallback.
|
|
|
|
Every agent-facing endpoints route authenticates through authenticate_agent()
|
|
and, when the route names a PC, binds the caller through bind_hostname().
|
|
|
|
X-Endpoint-Credential The PC's own secret. The server derives the asset from
|
|
it; a hostname or assetid in the request that names a
|
|
different asset is refused with ASSET_MISMATCH.
|
|
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 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.
|
|
"""
|
|
|
|
import hashlib
|
|
import hmac
|
|
import re
|
|
import secrets
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from flask import g, request
|
|
|
|
from shopdb.api import (
|
|
db, Setting, error_response, ErrorCodes, collector_authorized,
|
|
)
|
|
|
|
CREDENTIAL_HEADER = 'X-Endpoint-Credential'
|
|
CREDENTIAL_PREFIX = 'sdbe_'
|
|
# sdbe_ + 43 chars base64url (32 random bytes, unpadded).
|
|
_CREDENTIAL_PATTERN = re.compile(r'^sdbe_[A-Za-z0-9_-]{43}$')
|
|
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'
|
|
CODE_ASSET_MISMATCH = 'ASSET_MISMATCH'
|
|
|
|
AUTH_CREDENTIAL = 'credential'
|
|
AUTH_SHARED_KEY = 'sharedkey'
|
|
|
|
# lastusedat is a display value, not an audit trail: one write per 5 minutes
|
|
# per PC keeps the long-poll from turning every claim into an UPDATE.
|
|
_TOUCH_INTERVAL = timedelta(minutes=5)
|
|
|
|
|
|
def _naive_utcnow():
|
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
|
|
|
|
def generate_credential():
|
|
return CREDENTIAL_PREFIX + secrets.token_urlsafe(32)
|
|
|
|
|
|
def hash_credential(secret):
|
|
return hashlib.sha256(secret.encode('utf-8')).hexdigest()
|
|
|
|
|
|
def shared_key_allowed():
|
|
return Setting.get(SHARED_KEY_SETTING, default=True) is not False
|
|
|
|
|
|
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
|
|
|
|
if assetid is None:
|
|
return None
|
|
return (EndpointCredential.query
|
|
.filter(EndpointCredential.assetid == assetid,
|
|
EndpointCredential.revokedat.is_(None))
|
|
.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
|
|
|
|
if assetid is None:
|
|
return False
|
|
return (EndpointCredential.query
|
|
.filter(EndpointCredential.assetid == assetid,
|
|
EndpointCredential.reenrollblocked.is_(True))
|
|
.first()) is not None
|
|
|
|
|
|
def resolve_credential(secret, touch=True):
|
|
"""Asset id for an active credential, else None."""
|
|
from ..models import EndpointCredential
|
|
|
|
if not secret or not _CREDENTIAL_PATTERN.match(secret):
|
|
return None
|
|
digest = hash_credential(secret)
|
|
row = EndpointCredential.query.filter_by(credentialhash=digest).first()
|
|
if row is None or row.revokedat is not None:
|
|
return None
|
|
if not hmac.compare_digest(row.credentialhash, digest):
|
|
return None
|
|
if touch:
|
|
now = _naive_utcnow()
|
|
if row.lastusedat is None or now - row.lastusedat >= _TOUCH_INTERVAL:
|
|
row.lastusedat = now
|
|
db.session.commit()
|
|
return row.assetid
|
|
|
|
|
|
def resolve_hostname(hostname):
|
|
"""Asset id of the one computer with this hostname (case-insensitive)."""
|
|
from plugins.computers.models import Computer
|
|
|
|
hostname = (hostname or '').strip()
|
|
if not hostname:
|
|
return None
|
|
computer = (Computer.query
|
|
.filter(db.func.lower(Computer.hostname) == hostname.lower())
|
|
.first())
|
|
return computer.assetid if computer is not None else None
|
|
|
|
|
|
def shared_key_refused_for(hostname):
|
|
"""True when the shared key may not report for this hostname."""
|
|
if not shared_key_allowed():
|
|
return True
|
|
return shared_key_denied_for(resolve_hostname(hostname))
|
|
|
|
|
|
def credential_invalid_response():
|
|
return error_response(
|
|
CODE_CREDENTIAL_INVALID,
|
|
'Endpoint credential is unknown or revoked; enroll again',
|
|
http_code=401)
|
|
|
|
|
|
def shared_key_refused_response():
|
|
return error_response(
|
|
CODE_SHARED_KEY_REFUSED,
|
|
'The shared key is not accepted for this endpoint; use its credential',
|
|
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,
|
|
'The request names a different endpoint than the credential',
|
|
http_code=403)
|
|
|
|
|
|
def hostname_unrecorded_response(hostname):
|
|
"""Refused, but say which of the two failures this actually is.
|
|
|
|
The credential is good and the hostname is simply on no computer record, so
|
|
there is nothing to compare it with. That is a data problem on this server -
|
|
an enrolled PC whose computer record carries no hostname - not a PC claiming
|
|
to be another one. Same code and status, because nothing changes for the
|
|
agent: it keeps retrying, and an admin has to set the hostname.
|
|
"""
|
|
return error_response(
|
|
CODE_ASSET_MISMATCH,
|
|
'No computer record carries the hostname {0}, so this credential '
|
|
'cannot be matched to it. Set the hostname on the enrolled '
|
|
'computer.'.format(hostname),
|
|
http_code=403)
|
|
|
|
|
|
def authenticate_agent():
|
|
"""Authorize an agent request. Returns None when authorized, else a response.
|
|
|
|
Sets g.agentauth ('credential' or 'sharedkey') and g.agentassetid (the
|
|
credential's asset, or None until bind_hostname resolves one).
|
|
"""
|
|
g.agentauth = None
|
|
g.agentassetid = None
|
|
|
|
secret = request.headers.get(CREDENTIAL_HEADER)
|
|
if secret:
|
|
assetid = resolve_credential(secret.strip())
|
|
if assetid is None:
|
|
return credential_invalid_response()
|
|
g.agentauth = AUTH_CREDENTIAL
|
|
g.agentassetid = assetid
|
|
return None
|
|
|
|
error = collector_authorized('endpoints')
|
|
if error is not None:
|
|
return error
|
|
if not shared_key_allowed():
|
|
return shared_key_refused_response()
|
|
g.agentauth = AUTH_SHARED_KEY
|
|
return None
|
|
|
|
|
|
def is_credential_caller():
|
|
return getattr(g, 'agentauth', None) == AUTH_CREDENTIAL
|
|
|
|
|
|
def bind_hostname(hostname):
|
|
"""The asset this request acts for. Returns (assetid, error_response).
|
|
|
|
Credential: the credential's asset; a hostname naming another asset is an
|
|
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, and so is one whose credential
|
|
was revoked while revokeddeniessharedkey is on.
|
|
"""
|
|
hostname = (hostname or '').strip()
|
|
if is_credential_caller():
|
|
if hostname:
|
|
named = resolve_hostname(hostname)
|
|
if named is None:
|
|
return None, hostname_unrecorded_response(hostname)
|
|
if named != g.agentassetid:
|
|
return None, asset_mismatch_response()
|
|
return g.agentassetid, None
|
|
|
|
if not hostname:
|
|
return None, None
|
|
assetid = resolve_hostname(hostname)
|
|
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
|
|
|