diff --git a/nginx.conf b/nginx.conf index 2b4409d3..8893d93e 100644 --- a/nginx.conf +++ b/nginx.conf @@ -15,11 +15,6 @@ http { ssl_certificate /etc/nginx/certs/server.crt; ssl_certificate_key /etc/nginx/certs/server.key; - # Client certificate validation (optional during rollout) - # optional_no_ca: accept cert but don't validate against CA - # Flask validates fingerprint against database - ssl_verify_client optional_no_ca; - # Increase timeouts for long-polling agents proxy_connect_timeout 90s; proxy_send_timeout 90s; @@ -36,19 +31,8 @@ http { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; - - # Pass client certificate fingerprint to Flask - # SECURITY NOTE: $ssl_client_fingerprint outputs SHA1 (40 hex chars), - # not SHA256. This is a known nginx limitation. SHA1 is acceptable for - # certificate fingerprinting (not signing) in air-gapped environments - # with short-lived certs and revocation support. - # Format: "AB:CD:EF:..." (colon-separated hex, normalized in Flask) - # Future SHA256: requires custom nginx module or migrate to IIS - proxy_set_header X-Client-Cert-Fingerprint $ssl_client_fingerprint; - proxy_set_header X-Client-Cert-S-DN $ssl_client_s_dn; } - # Access log includes client cert status access_log /dev/stdout combined; error_log /dev/stderr warn; } diff --git a/plugins/applications/frontend/views/ApplicationForm.vue b/plugins/applications/frontend/views/ApplicationForm.vue index d4e0b6a6..fe9bf213 100644 --- a/plugins/applications/frontend/views/ApplicationForm.vue +++ b/plugins/applications/frontend/views/ApplicationForm.vue @@ -118,17 +118,19 @@ was already required has an existing policy, so re-saving it is not a new deployment and does not need re-acknowledging. -->
- This will install software on your fleet. + This creates a software install policy.

Marking {{ form.appname || 'this application' }} as required on all PCs creates a configuration policy that installs it on every - non-server endpoint it applies to. Installation happens - automatically, without further prompting, on each machine as it - checks in. + non-server endpoint it applies to. The policy is not added to a + business policy automatically: it installs nothing until an admin + adds it to an active business policy. From then on, installation + happens without further prompting on each machine as it checks in.

diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index 6b27a3f4..8f74e6f7 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -6,7 +6,7 @@ import logging from pathlib import Path from typing import List, Dict, Optional, Type -from flask import Flask, Blueprint +from flask import Flask, Blueprint, g import click from shopdb.plugins.base import BasePlugin, PluginMeta @@ -454,6 +454,7 @@ class ComputersPlugin(BasePlugin): """ return { 'identityfield': 'hostname', + 'acceptsagentcredential': True, 'fields': { 'hostname': {'type': 'string', 'required': True}, 'machinenumber': {'type': 'string'}, @@ -724,6 +725,13 @@ class ComputersPlugin(BasePlugin): comp = (Computer.query.join(Asset, Asset.assetid == Computer.assetid) .filter(Asset.assetnumber.ilike(hostname)).first()) + # A per-PC credential reports for its own PC only, and never creates one. + if (getattr(g, 'agentauth', None) == 'credential' + and (comp is None + or comp.assetid != getattr(g, 'agentassetid', None))): + raise PermissionError( + 'The payload names a different endpoint than the credential') + action = 'updated' if not comp: atype = AssetType.query.filter_by(assettype='computer').first() diff --git a/plugins/computers/services/apppolicy.py b/plugins/computers/services/apppolicy.py index b04b87ce..8bb99d9e 100644 --- a/plugins/computers/services/apppolicy.py +++ b/plugins/computers/services/apppolicy.py @@ -15,6 +15,14 @@ kept. Deleting someone's customised enforcement rule because a checkbox changed on a different screen would be a genuinely destructive surprise, and the box is not a reliable signal that they wanted that. +Not attached to a Business Policy +--------------------------------- +The generated policy is NOT added to any Business Policy. The resolver only +applies Configuration Policies reached through an active Business Policy, so +until an admin adds it to one, the generated policy installs nothing. The API +response and the acknowledgement text say so rather than promising a fleet +install that will not happen. + Scope ----- Generated policies use the `allpcs` node, not `true`: "required on all PCs" @@ -48,13 +56,58 @@ def auto_policy_name(application): return '{} {}'.format(AUTO_POLICY_PREFIX, application.appname) +def _action_appid(action_row): + """The appid an action's params name, or None.""" + params = action_row.params + if not isinstance(params, dict): + return None + try: + return int(params.get('appid')) + except (TypeError, ValueError): + return None + + +def policies_referencing_app(appid): + """Every ConfigurationPolicy with an action whose params name this appid. + + Params are a JSON text column (MySQL 5.6 has no JSON type to query), so the + match is done in Python over the actions that carry params at all. + """ + if appid is None: + return [] + found = {} + rows = (ConfigurationPolicyAction.query + .filter(ConfigurationPolicyAction.params.isnot(None)) + .all()) + for action_row in rows: + if _action_appid(action_row) == int(appid): + policy = action_row.configurationpolicy + if policy is not None: + found[policy.configurationpolicyid] = policy + return [found[key] for key in sorted(found)] + + def find_auto_policy(application): """Return the policy generated for this app, whoever owns it now. - Matches on name, since that is the only durable link - there is no FK from - a policy back to an application. Returns rows that have since been flipped - to 'local' too, so callers can tell "customised" from "absent". + Keyed on the appid in the generated update_app action's params, not on the + name: the name embeds appname, so renaming the application used to orphan + its policy (the next untick could not find it, and a re-tick generated a + second one). A policy counts as generated when it still carries the autoapp + source or the generated name prefix, so a row flipped to 'local' by an edit + is still found and callers can tell "customised" from "absent". The name + lookup stays as a fallback for a row whose params were edited away. """ + candidates = [ + policy for policy in policies_referencing_app(application.appid) + if policy.source == POLICY_SOURCE_AUTOAPP + or (policy.name or '').startswith(AUTO_POLICY_PREFIX) + ] + if candidates: + candidates.sort(key=lambda policy: ( + policy.source != POLICY_SOURCE_AUTOAPP, + policy.configurationpolicyid)) + return candidates[0] return ConfigurationPolicy.query.filter_by( name=auto_policy_name(application) ).first() @@ -75,7 +128,13 @@ def sync_required_app_policy(application): if existing is not None: # Already enforced. Deliberately does NOT rewrite a customised row # back to our defaults; re-ticking a box should not silently revert - # an admin's edits. + # an admin's edits. A still-generated row does follow an app rename + # so its name keeps describing what it installs. + wanted = auto_policy_name(application) + if (existing.source == POLICY_SOURCE_AUTOAPP + and existing.name != wanted + and ConfigurationPolicy.query.filter_by(name=wanted).first() is None): + existing.name = wanted return 'unchanged' policy = ConfigurationPolicy( @@ -122,3 +181,22 @@ def disown_if_generated(policy): policy.source = POLICY_SOURCE_LOCAL return True return False + + +def deactivate_policies_for_app(appid): + """Deactivate every remaining policy whose actions name a deleted appid. + + Called when an application is deleted, after the generated policy has been + withdrawn. What is left is either the customised ('kept') generated policy + or a hand-authored one; both would keep trying to install an application + that no longer exists. They are deactivated rather than deleted - they are + someone's work - and returned so the API can name them. + """ + deactivated = [] + for policy in policies_referencing_app(appid): + if policy in db.session.deleted: + continue + if policy.isactive: + policy.isactive = False + deactivated.append(policy.name) + return deactivated diff --git a/plugins/endpoints/api/__init__.py b/plugins/endpoints/api/__init__.py index 608ce97c..f6f64882 100644 --- a/plugins/endpoints/api/__init__.py +++ b/plugins/endpoints/api/__init__.py @@ -9,5 +9,7 @@ from . import siteagent # noqa: F401 from . import agentupload # noqa: F401 # Attaches the monitor catalog CRUD and resolve routes to endpoints_bp. from . import monitors # noqa: F401 +# Attaches per-PC credential enrollment, admin and migration routes. +from . import enrollment # noqa: F401 __all__ = ['endpoints_bp'] diff --git a/plugins/endpoints/api/agentauth.py b/plugins/endpoints/api/agentauth.py new file mode 100644 index 00000000..863e3c7e --- /dev/null +++ b/plugins/endpoints/api/agentauth.py @@ -0,0 +1,196 @@ +"""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. + +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' + +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 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 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 + assetid = resolve_hostname(hostname) + return assetid is not None and active_credential_for(assetid) is not None + + +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 asset_mismatch_response(): + return error_response( + CODE_ASSET_MISMATCH, + 'The request names a different endpoint than the credential', + 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, 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. + """ + hostname = (hostname or '').strip() + if is_credential_caller(): + if hostname and resolve_hostname(hostname) != g.agentassetid: + return None, asset_mismatch_response() + return g.agentassetid, None + + if not hostname: + return None, None + assetid = resolve_hostname(hostname) + if assetid is not None and active_credential_for(assetid) is not None: + return None, shared_key_refused_response() + g.agentassetid = assetid + return assetid, None + diff --git a/plugins/endpoints/api/agentregistry.py b/plugins/endpoints/api/agentregistry.py index a15bf964..9c471b9b 100644 --- a/plugins/endpoints/api/agentregistry.py +++ b/plugins/endpoints/api/agentregistry.py @@ -14,6 +14,7 @@ hand-maintained; an operator never edits these rows. """ import os +import uuid from shopdb.api import Application, application_package_location, db @@ -91,11 +92,19 @@ def register_current_agent_package(): os.makedirs(os.path.dirname(path), exist_ok=True) # Write to a temp name and replace, so an interrupted write cannot leave a # truncated archive at the path the row already advertises. os.replace is - # atomic within a filesystem. - temppath = path + '.tmp' - with open(temppath, 'wb') as handle: - handle.write(payload) - os.replace(temppath, path) + # atomic within a filesystem. The temp name is unique per call so two + # concurrent pushes (a batch wave) cannot interleave writes into one file. + temppath = '{}.{}.tmp'.format(path, uuid.uuid4().hex) + try: + with open(temppath, 'wb') as handle: + handle.write(payload) + os.replace(temppath, path) + except BaseException: + try: + os.remove(temppath) + except OSError: + pass + raise _directory, urlprefix = application_package_location() app.installpath = '{}{}/{}'.format( diff --git a/plugins/endpoints/api/agentupgradescript.py b/plugins/endpoints/api/agentupgradescript.py index 8895a62c..3fb65d63 100644 --- a/plugins/endpoints/api/agentupgradescript.py +++ b/plugins/endpoints/api/agentupgradescript.py @@ -23,6 +23,7 @@ substitution done after hashing would fail the agent's verification. import hashlib import os +import uuid from shopdb.api import Script, db, script_body_location @@ -115,10 +116,17 @@ def seed_upgrade_script(): os.makedirs(_script_dir(), exist_ok=True) # Temp-then-replace so an interrupted write cannot leave a truncated # body at the path the row's digest already claims to describe. - temppath = path + '.tmp' - with open(temppath, 'wb') as handle: - handle.write(body) - os.replace(temppath, path) + temppath = '{}.{}.tmp'.format(path, uuid.uuid4().hex) + try: + with open(temppath, 'wb') as handle: + handle.write(body) + os.replace(temppath, path) + except BaseException: + try: + os.remove(temppath) + except OSError: + pass + raise script.filename = filename script.sha256 = digest diff --git a/plugins/endpoints/api/certificate_renewal.py b/plugins/endpoints/api/certificate_renewal.py deleted file mode 100644 index 218dcd61..00000000 --- a/plugins/endpoints/api/certificate_renewal.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Server-side certificate generation for agent self-renewal. - -Agents check certificate expiry on startup and request renewal when < 30 days -remaining. Server generates new self-signed certificate, computes fingerprint, -updates database, and returns certificate as password-protected PFX to agent. -""" - -import hashlib -import secrets -from datetime import datetime, timedelta, timezone -from flask import current_app - -from cryptography import x509 -from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.hazmat.backends import default_backend - - -def generate_client_certificate(hostname, validity_days=730): - """Generate self-signed client certificate for endpoint agent. - - Args: - hostname: Computer hostname (used as CN) - validity_days: Certificate validity period (default 730 = 2 years) - - Returns: - tuple: (pfx_bytes, pfx_password, sha1_fingerprint, expires_at) - - pfx_bytes: Password-protected PFX/P12 certificate with private key - - pfx_password: Random password for PFX (agent discards after import) - - sha1_fingerprint: 40-character lowercase hex SHA1 fingerprint - - expires_at: Certificate expiration datetime (UTC) - """ - # Generate RSA private key (2048-bit for compatibility with Windows) - private_key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048, - backend=default_backend() - ) - - # Certificate subject and issuer (self-signed, so both are the same) - subject = issuer = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, hostname), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'ShopDB Endpoint Fleet'), - x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, 'Endpoint Agents'), - ]) - - # Certificate validity period - now = datetime.now(timezone.utc) - not_valid_before = now - not_valid_after = now + timedelta(days=validity_days) - - # Build certificate - cert = x509.CertificateBuilder().subject_name( - subject - ).issuer_name( - issuer - ).public_key( - private_key.public_key() - ).serial_number( - x509.random_serial_number() - ).not_valid_before( - not_valid_before - ).not_valid_after( - not_valid_after - ).add_extension( - # Client authentication extended key usage - x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), - critical=True, - ).add_extension( - # Subject Alternative Name (hostname as DNS name) - x509.SubjectAlternativeName([x509.DNSName(hostname)]), - critical=False, - ).add_extension( - # Basic constraints: not a CA - x509.BasicConstraints(ca=False, path_length=None), - critical=True, - ).sign(private_key, hashes.SHA256(), default_backend()) - - # Compute SHA1 fingerprint (matches nginx $ssl_client_fingerprint) - cert_der = cert.public_bytes(serialization.Encoding.DER) - sha1_hash = hashlib.sha1(cert_der).hexdigest() - - # Generate random PFX password (agent uses once during import, then discards) - pfx_password = secrets.token_urlsafe(32) - - # Serialize to PFX/P12 format with password protection - # Windows Certificate Store imports PFX format - pfx_bytes = serialization.pkcs12.serialize_key_and_certificates( - name=hostname.encode('utf-8'), - key=private_key, - cert=cert, - cas=None, # No CA chain for self-signed - encryption_algorithm=serialization.BestAvailableEncryption( - pfx_password.encode('utf-8') - ) - ) - - current_app.logger.info( - f'[cert_renewal] Generated certificate for {hostname}: ' - f'fingerprint={sha1_hash[:16]}..., expires={not_valid_after}') - - return (pfx_bytes, pfx_password, sha1_hash, not_valid_after.replace(tzinfo=None)) diff --git a/plugins/endpoints/api/enrollment.py b/plugins/endpoints/api/enrollment.py new file mode 100644 index 00000000..3e557212 --- /dev/null +++ b/plugins/endpoints/api/enrollment.py @@ -0,0 +1,789 @@ +"""Per-PC endpoint credentials: enrollment, administration and fleet migration. + +Agent-facing: + POST /enroll issue a credential or record a pending request + POST /collect the generic collector ingest, under this prefix + +Admin (endpoints.fleet to read, admin role to change): + GET /enrollment credential state per computer + requests + POST /enrollment/requests//approve | reject + POST /enrollment/assets//revoke + PUT /enrollment/settings + GET /enrollment/migration readiness of every computer for agent 1.20 + POST /enrollment/migration/wave push the agent upgrade to a batch of PCs + +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 +single-asset agentupgrade route uses. +""" + +import hmac +import re +from collections import Counter +from datetime import datetime, timedelta, timezone + +from flask import request +from flask_jwt_extended import current_user + +from shopdb.api import ( + db, Asset, AuditLog, Script, error_response, success_response, ErrorCodes, + require_permission, require_role, collector_authorized, collector_ingest, + client_ip, get_pagination_params, +) + +from ..commands import ( + ACTIVE_LOCK_FOR, ALLOWED_ACTIONS_FOR, agent_meets_minimum, + minimum_agent_version_for, parse_agent_version, +) +from ..models import ( + EndpointCommand, EndpointCredential, EndpointEnrollRequest, + EndpointMigrationWave, EndpointState, +) +from . import agentauth +from .routes import endpoints_bp + +# First agent that enrolls itself. +CREDENTIAL_AGENT_VERSION = '1.20.0' + +CODE_ENROLL_NONCE_MISMATCH = 'ENROLL_NONCE_MISMATCH' + +_NONCE_PATTERN = re.compile(r'^[A-Za-z0-9_-]{32,128}$') + +REASON_UNKNOWN_HOSTNAME = 'unknown hostname' +REASON_DUPLICATE_HOSTNAME = 'duplicate hostname' +REASON_NO_SERIAL = 'no serial on record' +REASON_SERIAL_MISMATCH = 'serial mismatch' +REASON_ALREADY_ENROLLED = 'already enrolled' +REASON_AWAITING_APPROVAL = 'awaiting approval' +REASON_NONCE_CHANGED = 'nonce changed' +REASON_REJECTED = 'rejected' + +MIGRATION_STATES = ('enrolled', 'pending', 'upgrading', 'ready', 'blocked', + 'notreporting') +ENROLLMENT_STATES = ('enrolled', 'notenrolled', 'pending', 'revoked') + +BLOCKER_OFFLINE = 'offline' +BLOCKER_NO_SERIAL = 'no serial on record' +BLOCKER_DUPLICATE_HOSTNAME = 'duplicate hostname' +BLOCKER_LOCKED = 'commands locked' +BLOCKER_NO_PACKAGE = 'no agent package in store' +BLOCKER_TOO_OLD = 'agent too old for remote upgrade' +BLOCKER_POLICY = 'run_script not permitted by policy' +BLOCKER_AWAITING_ENROLLMENT = 'awaiting enrollment' + +_OFFLINE_AFTER = timedelta(hours=24) +_WAVE_MAXIMUM = 100 +_RECENT_REQUESTS = 50 + + +def _naive_utcnow(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _utciso(value): + return value.isoformat() + 'Z' if value else None + + +def _username(): + return getattr(current_user, 'username', None) + + +def _computer_model(): + from plugins.computers.models import Computer + return Computer + + +# --------------------------------------------------------------------------- +# Agent-facing +# --------------------------------------------------------------------------- + +@endpoints_bp.route('/collect', methods=['POST']) +def agent_collect(): + """Agent telemetry ingest. Same handler as POST /api/collector/endpoints.""" + return collector_ingest('endpoints') + + +def _pending(row, reason, matchedassetid=None): + row.status = EndpointEnrollRequest.STATUS_PENDING + row.reason = reason + row.matchedassetid = matchedassetid + db.session.commit() + return success_response( + {'status': 'pending', 'requestid': row.requestid, 'reason': reason}, + http_code=202) + + +def issue_credential(assetid, enrolledvia): + """Issue (or re-issue after revoke) the credential for one asset. + + Returns the secret. Only its hash is stored. Caller commits. + """ + secret = agentauth.generate_credential() + row = EndpointCredential.query.filter_by(assetid=assetid).first() + if row is None: + row = EndpointCredential(assetid=assetid) + db.session.add(row) + row.credentialhash = agentauth.hash_credential(secret) + row.credentialprefix = secret[:agentauth.DISPLAY_PREFIX_LENGTH] + row.enrolledvia = enrolledvia + row.issuedat = _naive_utcnow() + row.lastusedat = None + row.revokedat = None + row.revokedby = None + return secret, row + + +def _issue(row, assetid, enrolledvia): + secret, credential = issue_credential(assetid, enrolledvia) + row.status = EndpointEnrollRequest.STATUS_ISSUED + row.matchedassetid = assetid + row.reason = None + AuditLog.log('created', 'EndpointCredential', entityid=assetid, + entityname=row.hostname, + details='endpoint credential {} issued ({}), request {}'.format( + credential.credentialprefix, enrolledvia, row.requestid)) + db.session.commit() + return success_response( + {'status': 'enrolled', 'credential': secret, 'assetid': assetid}, + message='Endpoint enrolled', http_code=201) + + +def _text_field(payload, name, maximum, required): + value = payload.get(name) + if value is None: + value = '' + if not isinstance(value, str): + return None, f'{name} must be a string' + value = value.strip() + if required and not value: + return None, f'{name} is required' + if len(value) > maximum: + return None, f'{name} must be at most {maximum} characters' + return value, None + + +@endpoints_bp.route('/enroll', methods=['POST']) +def enroll_endpoint(): + """A PC asks for its own credential. + + Shared-key authenticated while plugin.endpoints.sharedkeyallowed is on. + With it off the route takes no auth and only hands out a credential an + admin already approved for this exact hostname, serial and nonce. + """ + sharedkeyauthenticated = False + if agentauth.shared_key_allowed(): + error = collector_authorized('endpoints') + if error is not None: + return error + sharedkeyauthenticated = True + + payload = request.get_json(silent=True) + if not isinstance(payload, dict): + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + hostname, problem = _text_field(payload, 'hostname', 255, True) + if problem is None: + serialnumber, problem = _text_field(payload, 'serialnumber', 128, True) + if problem is None: + deviceid, problem = _text_field(payload, 'deviceid', 64, False) + if problem is None: + agentversion, problem = _text_field(payload, 'agentversion', 32, False) + if problem is None: + nonce = payload.get('enrollnonce') + if not isinstance(nonce, str) or not _NONCE_PATTERN.match(nonce): + problem = 'enrollnonce must be 32-128 characters of A-Z a-z 0-9 _ -' + if problem is not None: + return error_response(ErrorCodes.VALIDATION_ERROR, problem) + + now = _naive_utcnow() + sourceip = client_ip(request) + + row = (EndpointEnrollRequest.query + .filter(db.func.lower(EndpointEnrollRequest.hostname) == hostname.lower(), + db.func.lower(EndpointEnrollRequest.serialnumber) + == serialnumber.lower()) + .order_by(EndpointEnrollRequest.requestid.desc()) + .first()) + + if row is None: + row = EndpointEnrollRequest( + hostname=hostname, serialnumber=serialnumber, enrollnonce=nonce, + status=EndpointEnrollRequest.STATUS_PENDING, requestedat=now) + db.session.add(row) + else: + samenonce = hmac.compare_digest(row.enrollnonce, nonce) + if row.status == EndpointEnrollRequest.STATUS_APPROVED: + row.lastseenat = now + row.sourceip = sourceip + if not samenonce: + db.session.commit() + return error_response( + CODE_ENROLL_NONCE_MISMATCH, + 'This enrollment was approved for a different agent ' + 'installation', http_code=403) + if agentauth.active_credential_for(row.matchedassetid) is not None: + return _pending(row, REASON_ALREADY_ENROLLED, row.matchedassetid) + if row.matchedassetid is None or db.session.get(Asset, row.matchedassetid) is None: + return _pending(row, REASON_UNKNOWN_HOSTNAME) + return _issue(row, row.matchedassetid, + EndpointCredential.ENROLLED_APPROVED) + if row.status == EndpointEnrollRequest.STATUS_PENDING and not samenonce: + # First nonce wins; a second installation needs an admin. + row.lastseenat = now + row.sourceip = sourceip + return _pending(row, REASON_NONCE_CHANGED, row.matchedassetid) + if row.status == EndpointEnrollRequest.STATUS_REJECTED and samenonce: + row.lastseenat = now + row.sourceip = sourceip + db.session.commit() + return success_response( + {'status': 'pending', 'requestid': row.requestid, + 'reason': REASON_REJECTED}, http_code=202) + if row.status != EndpointEnrollRequest.STATUS_PENDING: + # Issued earlier (credential since revoked) or rejected for another + # installation: a new enrollment cycle bound to this nonce. + row.enrollnonce = nonce + row.requestedat = now + row.decidedat = None + row.decidedby = None + + row.lastseenat = now + row.sourceip = sourceip + if deviceid: + row.deviceid = deviceid + if agentversion: + row.agentversion = agentversion + db.session.flush() + + Computer = _computer_model() + matches = (Computer.query + .filter(db.func.lower(Computer.hostname) == hostname.lower()) + .all()) + if not matches: + return _pending(row, REASON_UNKNOWN_HOSTNAME) + if len(matches) > 1: + return _pending(row, REASON_DUPLICATE_HOSTNAME) + + assetid = matches[0].assetid + asset = db.session.get(Asset, assetid) + recorded = ((asset.serialnumber if asset is not None else None) or '').strip() + if not recorded: + return _pending(row, REASON_NO_SERIAL, assetid) + if recorded.lower() != serialnumber.lower(): + return _pending(row, REASON_SERIAL_MISMATCH, assetid) + if agentauth.active_credential_for(assetid) is not None: + return _pending(row, REASON_ALREADY_ENROLLED, assetid) + if sharedkeyauthenticated and agentauth.auto_enroll_enabled(): + return _issue(row, assetid, EndpointCredential.ENROLLED_AUTO) + return _pending(row, REASON_AWAITING_APPROVAL, assetid) + + +# --------------------------------------------------------------------------- +# Admin: credentials and requests +# --------------------------------------------------------------------------- + +def _computers(): + """(assetid, hostname, serialnumber) for every active computer.""" + Computer = _computer_model() + return (db.session.query(Computer.assetid, Computer.hostname, + Asset.serialnumber) + .join(Asset, Asset.assetid == Computer.assetid) + .filter(Asset.isactive.is_(True)) + .order_by(db.func.lower(Computer.hostname)) + .all()) + + +def _pending_requests(): + return (EndpointEnrollRequest.query + .filter_by(status=EndpointEnrollRequest.STATUS_PENDING) + .all()) + + +def _pending_index(requests): + assetids = {r.matchedassetid for r in requests if r.matchedassetid} + hostnames = {(r.hostname or '').lower() for r in requests} + return assetids, hostnames + + +def _is_pending(assetid, hostname, index): + assetids, hostnames = index + return assetid in assetids or (hostname or '').lower() in hostnames + + +def _search_filter(rows, search, *keys): + if not search: + return rows + needle = search.lower() + return [row for row in rows + if any(needle in str(row.get(key) or '').lower() for key in keys)] + + +def _page(rows): + page, perpage = get_pagination_params() + total = len(rows) + start = (page - 1) * perpage + totalpages = (total + perpage - 1) // perpage if perpage else 0 + return rows[start:start + perpage], { + 'page': page, 'perpage': perpage, 'total': total, + 'totalpages': totalpages, 'hasnext': page < totalpages, + 'hasprev': page > 1, + } + + +def _settings_payload(): + return {'sharedkeyallowed': agentauth.shared_key_allowed(), + 'autoenroll': agentauth.auto_enroll_enabled()} + + +@endpoints_bp.route('/enrollment', methods=['GET']) +@require_permission('endpoints.fleet') +def get_enrollment(): + """Credential state of every computer, plus pending and recent requests.""" + credentials = {c.assetid: c for c in EndpointCredential.query.all()} + pending = _pending_requests() + index = _pending_index(pending) + + rows = [] + for assetid, hostname, serialnumber in _computers(): + credential = credentials.get(assetid) + if credential is not None and credential.revokedat is None: + state = 'enrolled' + elif _is_pending(assetid, hostname, index): + state = 'pending' + elif credential is not None: + state = 'revoked' + else: + state = 'notenrolled' + rows.append({ + 'assetid': assetid, + 'hostname': hostname, + 'serialnumber': serialnumber, + 'state': state, + 'credentialprefix': credential.credentialprefix if credential else None, + 'issuedat': _utciso(credential.issuedat) if credential else None, + 'lastusedat': _utciso(credential.lastusedat) if credential else None, + 'enrolledvia': credential.enrolledvia if credential else None, + }) + + counts = Counter(row['state'] for row in rows) + summary = { + 'enrolled': counts['enrolled'], + 'notenrolled': counts['notenrolled'] + counts['revoked'], + 'pending': counts['pending'], + } + + state = (request.args.get('state') or '').strip().lower() + if state: + if state not in ENROLLMENT_STATES: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'Unknown state filter: {state!r}') + rows = [row for row in rows if row['state'] == state] + rows = _search_filter(rows, (request.args.get('search') or '').strip(), + 'hostname', 'serialnumber') + pagerows, pagination = _page(rows) + + recent = (EndpointEnrollRequest.query + .filter(EndpointEnrollRequest.status + != EndpointEnrollRequest.STATUS_PENDING) + .order_by(EndpointEnrollRequest.lastseenat.desc()) + .limit(_RECENT_REQUESTS) + .all()) + requests_payload = [ + r.to_dict() + for r in sorted(pending, key=lambda r: r.lastseenat or r.requestedat, + reverse=True) + recent + ] + + return success_response({ + 'settings': _settings_payload(), + 'counts': summary, + 'computers': pagerows, + 'requests': requests_payload, + }, meta={'pagination': pagination}) + + +@endpoints_bp.route('/enrollment/requests//approve', + methods=['POST']) +@require_role('admin') +def approve_enroll_request(requestid): + """Bind a pending request to the asset the admin picked.""" + row = db.session.get(EndpointEnrollRequest, requestid) + if row is None: + return error_response(ErrorCodes.NOT_FOUND, + f'No enroll request {requestid}', http_code=404) + if row.status == EndpointEnrollRequest.STATUS_ISSUED: + return error_response(ErrorCodes.CONFLICT, + 'A credential was already issued for this request', + http_code=409) + + payload = request.get_json(silent=True) or {} + assetid = payload.get('assetid') + if isinstance(assetid, bool) or not isinstance(assetid, int): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'assetid must be an integer') + Computer = _computer_model() + if Computer.query.filter_by(assetid=assetid).first() is None: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'Asset {assetid} is not a computer') + if agentauth.active_credential_for(assetid) is not None: + return error_response(ErrorCodes.CONFLICT, + 'That computer is already enrolled; revoke its ' + 'credential first', http_code=409) + + row.status = EndpointEnrollRequest.STATUS_APPROVED + row.matchedassetid = assetid + row.reason = None + row.decidedat = _naive_utcnow() + row.decidedby = _username() + AuditLog.log('updated', 'EndpointEnrollRequest', entityid=row.requestid, + entityname=row.hostname, + details='enroll request approved for asset {}'.format(assetid)) + db.session.commit() + return success_response(row.to_dict(), message='Enroll request approved') + + +@endpoints_bp.route('/enrollment/requests//reject', + methods=['POST']) +@require_role('admin') +def reject_enroll_request(requestid): + row = db.session.get(EndpointEnrollRequest, requestid) + if row is None: + return error_response(ErrorCodes.NOT_FOUND, + f'No enroll request {requestid}', http_code=404) + if row.status == EndpointEnrollRequest.STATUS_ISSUED: + return error_response(ErrorCodes.CONFLICT, + 'A credential was already issued for this request; ' + 'revoke the credential instead', http_code=409) + row.status = EndpointEnrollRequest.STATUS_REJECTED + row.decidedat = _naive_utcnow() + row.decidedby = _username() + AuditLog.log('updated', 'EndpointEnrollRequest', entityid=row.requestid, + entityname=row.hostname, details='enroll request rejected') + db.session.commit() + return success_response(row.to_dict(), message='Enroll request rejected') + + +@endpoints_bp.route('/enrollment/assets//revoke', methods=['POST']) +@require_role('admin') +def revoke_endpoint_credential(assetid): + """Un-enroll a PC. Its agent gets CREDENTIAL_INVALID and enrolls again.""" + credential = agentauth.active_credential_for(assetid) + if credential is None: + return error_response(ErrorCodes.NOT_FOUND, + 'This computer has no active credential', + http_code=404) + credential.revokedat = _naive_utcnow() + credential.revokedby = _username() + AuditLog.log('deleted', 'EndpointCredential', entityid=assetid, + entityname=credential.credentialprefix, + details='endpoint credential {} revoked'.format( + credential.credentialprefix)) + db.session.commit() + return success_response(credential.to_dict(), + message='Endpoint credential revoked') + + +@endpoints_bp.route('/enrollment/settings', methods=['PUT']) +@require_role('admin') +def update_enrollment_settings(): + from shopdb.api import Setting + + payload = request.get_json(silent=True) or {} + changes = {} + for key, settingkey in (('sharedkeyallowed', agentauth.SHARED_KEY_SETTING), + ('autoenroll', agentauth.AUTO_ENROLL_SETTING)): + if key not in payload: + continue + if not isinstance(payload[key], bool): + return error_response(ErrorCodes.VALIDATION_ERROR, + f'{key} must be true or false') + changes[key] = (settingkey, payload[key]) + if not changes: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'Send sharedkeyallowed and/or autoenroll') + + before = _settings_payload() + for key, (settingkey, value) in changes.items(): + Setting.set(settingkey, value, valuetype='boolean', + category='plugin.endpoints') + after = _settings_payload() + AuditLog.log('updated', 'Setting', entityname='endpoint enrollment', + changes={key: {'old': before[key], 'new': after[key]} + for key in changes}, + details='endpoint enrollment settings changed') + db.session.commit() + return success_response(after, message='Enrollment settings saved') + + +# --------------------------------------------------------------------------- +# Migration of PCs already in the fleet +# --------------------------------------------------------------------------- + +def _version_below(reported, target): + parsed = parse_agent_version(reported) + wanted = parse_agent_version(target) + if parsed is None or wanted is None: + return True + width = max(len(parsed), len(wanted)) + return (tuple(parsed) + (0,) * (width - len(parsed)) + < tuple(wanted) + (0,) * (width - len(wanted))) + + +def _upgrade_scriptid(): + from .agentupgradescript import UPGRADE_SCRIPT_NAME + + script = Script.query.filter_by(scriptname=UPGRADE_SCRIPT_NAME).first() + return script.scriptid if script is not None else None + + +def _store_has_package(): + from . import agentstore + + current = agentstore.current_version('agent') + if current is None: + return agentstore.current_path('agent') is not None + return not _version_below(current, CREDENTIAL_AGENT_VERSION) + + +def compute_readiness(assetids=None): + """Migration state for every active computer (or just assetids). + + Returns a list of dicts ordered by hostname: assetid, hostname, + agentversion, lastreportedat, state, blockers. + """ + computers = _computers() + hostnamecounts = Counter((hostname or '').lower() + for _assetid, hostname, _serial in computers) + if assetids is not None: + wanted = set(assetids) + computers = [row for row in computers if row[0] in wanted] + if not computers: + return [] + + ids = [row[0] for row in computers] + active = {c.assetid for c in EndpointCredential.query + .filter(EndpointCredential.revokedat.is_(None)).all()} + index = _pending_index(_pending_requests()) + states = {s.assetid: s for s in + EndpointState.query.filter(EndpointState.assetid.in_(ids)).all()} + + scriptid = _upgrade_scriptid() + upgrading = set() + if scriptid is not None: + upgrading = {row[0] for row in db.session.query(EndpointCommand.assetid) + .filter(EndpointCommand.assetid.in_(ids), + EndpointCommand.action == 'run_script', + EndpointCommand.target == f'script:{scriptid}', + EndpointCommand.status.in_( + (EndpointCommand.STATUS_PENDING, + EndpointCommand.STATUS_RUNNING)))} + + haspackage = _store_has_package() + minimum = minimum_agent_version_for('run_script') + offlinecutoff = _naive_utcnow() - _OFFLINE_AFTER + + result = [] + for assetid, hostname, serialnumber in computers: + state = states.get(assetid) + agentversion = state.agentversion if state is not None else None + lastreportedat = state.lastreportedat if state is not None else None + blockers = [] + if assetid in active: + status = 'enrolled' + elif _is_pending(assetid, hostname, index): + status = 'pending' + elif assetid in upgrading: + status = 'upgrading' + elif state is None: + status = 'notreporting' + else: + if lastreportedat is None or lastreportedat < offlinecutoff: + blockers.append(BLOCKER_OFFLINE) + if not (serialnumber or '').strip(): + blockers.append(BLOCKER_NO_SERIAL) + if hostnamecounts[(hostname or '').lower()] > 1: + blockers.append(BLOCKER_DUPLICATE_HOSTNAME) + if ACTIVE_LOCK_FOR(assetid) is not None: + blockers.append(BLOCKER_LOCKED) + if not haspackage: + blockers.append(BLOCKER_NO_PACKAGE) + if minimum and not agent_meets_minimum(agentversion, minimum): + blockers.append(BLOCKER_TOO_OLD) + if not _version_below(agentversion, CREDENTIAL_AGENT_VERSION): + blockers.append(BLOCKER_AWAITING_ENROLLMENT) + if not blockers and 'run_script' not in ALLOWED_ACTIONS_FOR( + assetid=assetid): + blockers.append(BLOCKER_POLICY) + status = 'blocked' if blockers else 'ready' + result.append({ + 'assetid': assetid, + 'hostname': hostname, + 'agentversion': agentversion, + 'lastreportedat': _utciso(lastreportedat), + 'state': status, + 'blockers': blockers, + }) + return result + + +def _wave_progress(wave, scriptid): + members = wave.assetidlist + enrolled = set() + failed = set() + queued = set() + if members: + enrolled = {c.assetid for c in EndpointCredential.query.filter( + EndpointCredential.assetid.in_(members), + EndpointCredential.revokedat.is_(None)).all()} + if scriptid is not None: + commands = (EndpointCommand.query + .filter(EndpointCommand.assetid.in_(members), + EndpointCommand.action == 'run_script', + EndpointCommand.target == f'script:{scriptid}', + EndpointCommand.createdat >= wave.createdat) + .order_by(EndpointCommand.createdat.asc()) + .all()) + latest = {} + for command in commands: + queued.add(command.assetid) + latest[command.assetid] = command + failed = {assetid for assetid, command in latest.items() + if assetid not in enrolled + and command.status in (EndpointCommand.STATUS_FAILED, + EndpointCommand.STATUS_EXPIRED)} + return { + 'waveid': wave.waveid, + 'createdat': _utciso(wave.createdat), + 'createdby': wave.createdby, + 'size': len(members), + 'queued': len(queued), + 'enrolled': len(enrolled), + 'failed': len(failed), + 'done': len(enrolled) + len(failed) >= len(members), + } + + +@endpoints_bp.route('/enrollment/migration', methods=['GET']) +@require_permission('endpoints.fleet') +def get_enrollment_migration(): + """Per-computer readiness to move onto per-PC credentials, plus waves.""" + rows = compute_readiness() + counts = Counter(row['state'] for row in rows) + + state = (request.args.get('state') or '').strip().lower() + if state: + if state not in MIGRATION_STATES: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'Unknown state filter: {state!r}') + rows = [row for row in rows if row['state'] == state] + rows = _search_filter(rows, (request.args.get('search') or '').strip(), + 'hostname', 'agentversion') + pagerows, pagination = _page(rows) + + scriptid = _upgrade_scriptid() + waves = (EndpointMigrationWave.query + .order_by(EndpointMigrationWave.createdat.desc()) + .limit(_RECENT_REQUESTS) + .all()) + + return success_response({ + 'target': CREDENTIAL_AGENT_VERSION, + 'counts': {name: counts[name] for name in MIGRATION_STATES}, + 'computers': pagerows, + 'waves': [_wave_progress(wave, scriptid) for wave in waves], + }, meta={'pagination': pagination}) + + +def _response_message(response): + try: + body = response.get_json() or {} + except Exception: # noqa: BLE001 - reason text only + return 'enqueue refused' + error = ((body.get('data') or {}).get('error') or {}) + return error.get('message') or body.get('message') or 'enqueue refused' + + +@endpoints_bp.route('/enrollment/migration/wave', methods=['POST']) +@require_role('admin') +def create_migration_wave(): + """Push the agent upgrade to a batch of READY PCs. + + Body {"assetids": [...]} or {"next": n}. Readiness is re-checked per PC; the + agent is packaged once for the whole wave, then each PC is queued through + the same path as POST //agentupgrade. + """ + from .routes import prepare_agent_upgrade, queue_prepared_agent_upgrade + + payload = request.get_json(silent=True) or {} + requested = payload.get('assetids') + nextcount = payload.get('next') + + if requested is not None: + if (not isinstance(requested, list) or not requested + or not all(isinstance(v, int) and not isinstance(v, bool) + for v in requested)): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'assetids must be a non-empty list of integers') + if len(requested) > _WAVE_MAXIMUM: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'A wave holds at most {_WAVE_MAXIMUM} PCs') + candidates = list(dict.fromkeys(requested)) + elif nextcount is not None: + if (isinstance(nextcount, bool) or not isinstance(nextcount, int) + or nextcount < 1 or nextcount > _WAVE_MAXIMUM): + return error_response(ErrorCodes.VALIDATION_ERROR, + f'next must be an integer from 1 to {_WAVE_MAXIMUM}') + candidates = [row['assetid'] for row in compute_readiness() + if row['state'] == 'ready'][:nextcount] + else: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'Send assetids or next') + + readiness = {row['assetid']: row for row in compute_readiness(candidates)} + skipped = [] + ready = [] + for assetid in candidates: + row = readiness.get(assetid) + if row is None: + skipped.append({'assetid': assetid, 'reason': 'not a computer'}) + elif row['state'] != 'ready': + reason = ', '.join(row['blockers']) if row['blockers'] else row['state'] + skipped.append({'assetid': assetid, 'reason': reason}) + else: + ready.append(assetid) + + queued = [] + waveid = None + if ready: + startedat = _naive_utcnow().replace(microsecond=0) + registration, seeded, error = prepare_agent_upgrade() + if error is not None: + return error + for assetid in ready: + response = queue_prepared_agent_upgrade(assetid, registration, + seeded, {}) + if getattr(response, 'status_code', None) == 201: + queued.append(assetid) + else: + db.session.rollback() + skipped.append({'assetid': assetid, + 'reason': _response_message(response)}) + + if queued: + wave = EndpointMigrationWave( + createdat=startedat, createdby=_username(), + assetids=','.join(str(v) for v in queued)) + db.session.add(wave) + db.session.flush() + waveid = wave.waveid + AuditLog.log('created', 'EndpointMigrationWave', entityid=waveid, + entityname='agent {}'.format(registration['agentversion']), + details='migration wave queued agent upgrade for {} PC(s): {}' + .format(len(queued), wave.assetids)) + db.session.commit() + + return success_response( + {'waveid': waveid, 'queued': queued, 'skipped': skipped}, + message='{} PC(s) queued'.format(len(queued)), + http_code=201 if queued else 200) diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index 1ce721be..7003baeb 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -14,7 +14,7 @@ import re import time from datetime import datetime, timedelta, timezone -from flask import Blueprint, request, current_app +from flask import Blueprint, request, current_app, g from flask_jwt_extended import current_user from shopdb.api import ( @@ -33,60 +33,13 @@ from ..models import ( ) from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, minimum_agent_version_for, action_minimum_versions, parse_agent_version, agent_meets_minimum, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK from . import agentstore +from . import agentauth from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title from markupsafe import escape endpoints_bp = Blueprint('endpoints', __name__) -# TEST ENDPOINT: Client cert debugging (remove after testing) -@endpoints_bp.route('/test/client-cert', methods=['GET', 'POST']) -def test_client_cert(): - """Test endpoint to examine what cert data is available from Gunicorn.""" - environ = request.environ - cert_keys = {k: v for k, v in environ.items() if 'cert' in k.lower() or 'ssl' in k.lower() or 'tls' in k.lower()} - - # Check for common WSGI cert keys - peercert = environ.get('peercert') - ssl_client_cert = environ.get('SSL_CLIENT_CERT') - ssl_client_s_dn = environ.get('SSL_CLIENT_S_DN') - - # Check for nginx-passed headers - nginx_fp = request.headers.get('X-Client-Cert-Fingerprint') - nginx_dn = request.headers.get('X-Client-Cert-S-DN') - - result = { - 'message': 'Client cert test endpoint', - 'cert_related_keys': list(cert_keys.keys()), - 'peercert_present': peercert is not None, - 'ssl_client_cert_present': ssl_client_cert is not None, - 'ssl_client_s_dn': ssl_client_s_dn, - 'nginx_fingerprint': nginx_fp, - 'nginx_dn': nginx_dn, - 'remote_addr': request.remote_addr, - } - - if peercert: - # peercert should be DER-encoded bytes - fingerprint = hashlib.sha256(peercert).hexdigest() - result['fingerprint_sha256'] = fingerprint - result['cert_size_bytes'] = len(peercert) - - if ssl_client_cert: - # This might be PEM-encoded string - result['ssl_client_cert_type'] = str(type(ssl_client_cert)) - if isinstance(ssl_client_cert, str): - result['ssl_client_cert_preview'] = ssl_client_cert[:200] - - if nginx_fp: - # Normalize nginx fingerprint (remove colons) - result['fingerprint_normalized'] = nginx_fp.replace(':', '').lower() - - current_app.logger.info(f'[test/client-cert] nginx_fp={nginx_fp} remote_addr={request.remote_addr}') - - return success_response(result) - - def _naive_utcnow(): return datetime.now(timezone.utc).replace(tzinfo=None) @@ -2023,38 +1976,46 @@ def _reap_stale_commands(asset_id): return stale -def _collector_auth_or_error(): - """Authorize an agent-facing command route via client cert OR collector token. +def _agent_auth_or_error(): + """Authorize an agent-facing route: per-PC credential or the shared key. - Dual-auth during rollout: - 1. Try client certificate authentication first (per-endpoint mTLS) - 2. Fall back to collector API key (shared fleet-wide key) - - Reuses the collector auth path so the command channel shares the endpoint - agent key (kill switch) and introduces no second secret. + See agentauth. Sets g.agentauth and g.agentassetid. """ - from shopdb.core.auth.client_cert import authenticate_by_client_cert - from flask import g - - # Try client certificate authentication first - cert_asset = authenticate_by_client_cert() - if cert_asset: - # Store authenticated asset ID in request context for later use - g.cert_authenticated_asset_id = cert_asset.assetid - return None # Success - no error - - # Fall back to collector API key - return collector_authorized('endpoints') + return agentauth.authenticate_agent() def _resolve_asset_for_hostname(hostname): """Resolve a reported hostname to its asset id via the computers plugin.""" + return agentauth.resolve_hostname(hostname) + + +def _hostname_for_asset(assetid): from plugins.computers.models import Computer - computer = (Computer.query - .filter(db.func.lower(Computer.hostname) == hostname.lower()) - .first()) - return computer.assetid if computer is not None else None + computer = Computer.query.filter_by(assetid=assetid).first() + return computer.hostname if computer is not None else None + + +def _bind_agent_hostname(rawhostname): + """The asset an agent request acts for. Returns (assetid, hostname, error). + + A credential caller may omit the hostname; a shared-key caller must send one + that resolves. hostname comes back filled from the asset when omitted. + """ + hostname = (rawhostname or '').strip() + asset_id, error = agentauth.bind_hostname(hostname) + if error is not None: + return None, hostname, error + if asset_id is None: + if not hostname: + return None, hostname, error_response( + ErrorCodes.VALIDATION_ERROR, 'hostname is required') + return None, hostname, error_response( + ErrorCodes.NOT_FOUND, f'Unknown hostname: {hostname!r}', + http_code=404) + if not hostname: + hostname = _hostname_for_asset(asset_id) or '' + return asset_id, hostname, None _HOSTED_PACKAGE_PREFIX = '/api/applications/package/' @@ -2220,6 +2181,70 @@ LOG_PATH_EXTENSIONS = frozenset({ # cannot be opened by the agent anyway, so refusing here is honest. LOG_PATH_MAX_LENGTH = 260 +# Mirror of the agent's DeniedLogPathReason (CommandExecutor.cs): files SYSTEM +# can read that must never be pulled over the wire - deployment answer files, +# the agent's own install and state, key material. The server cannot know which +# drive Windows lives on, so roots match under any drive letter, and the common +# environment variables are expanded textually to those same roots. +LOG_PATH_ENVIRONMENT_ROOTS = { + '%systemroot%': 'c:\\windows', + '%windir%': 'c:\\windows', + '%programdata%': 'c:\\programdata', + '%allusersprofile%': 'c:\\programdata', + '%programfiles%': 'c:\\program files', + '%programw6432%': 'c:\\program files', + '%programfiles(x86)%': 'c:\\program files (x86)', + '%systemdrive%': 'c:', +} + +LOG_PATH_DENIED_ROOTS = ( + ('windows\\panther', 'Windows setup answer files (Panther)'), + ('windows\\system32\\sysprep', 'Sysprep files'), + ('programdata\\shopdb', 'ShopDB agent state (ProgramData\\ShopDB)'), + ('program files\\shopdb', 'the ShopDB agent install directory'), + ('program files (x86)\\shopdb', 'the ShopDB agent install directory'), + ('progra~1\\shopdb', 'the ShopDB agent install directory'), + ('progra~2\\shopdb', 'the ShopDB agent install directory'), + ('progra~3\\shopdb', 'ShopDB agent state (ProgramData\\ShopDB)'), +) + +LOG_PATH_DENIED_EXTENSIONS = frozenset({'.pfx', '.p12', '.key'}) + + +def _denied_log_path_reason(path): + """Return why a collect_log path is on the sensitive-file denylist, or None. + + Case-insensitive; slashes, env vars, repeated separators and '.' segments + are normalised textually first so trivial respellings do not slip past. + """ + lowered = (path or '').strip().replace('/', '\\').lower() + for variable, root in LOG_PATH_ENVIRONMENT_ROOTS.items(): + lowered = lowered.replace(variable, root) + parts = [part for part in lowered.split('\\') if part not in ('', '.')] + if not parts: + return None + + # Drop a leading drive letter (or \\?\ style prefix) so roots match on any drive. + body = list(parts) + while body and re.match(r'^([a-z]:|\?)$', body[0]): + body.pop(0) + joined = '\\'.join(body) + for root, label in LOG_PATH_DENIED_ROOTS: + if joined == root or joined.startswith(root + '\\'): + return f'Collecting files under {label} is not permitted' + + filename = parts[-1] + extension = os.path.splitext(filename)[1] + if filename.startswith('unattend') and extension == '.xml': + return f'Collecting {filename} (a Windows answer file) is not permitted' + if filename == 'autounattend.xml': + return f'Collecting {filename} (a Windows answer file) is not permitted' + if filename.startswith('appsettings') and extension == '.json': + return f'Collecting {filename} (application configuration) is not permitted' + if extension in LOG_PATH_DENIED_EXTENSIONS: + return f'Collecting {extension} key material is not permitted' + return None + def _validate_log_path(target): """Reject a collect_log path that cannot be a readable local log file. @@ -2249,6 +2274,12 @@ def _validate_log_path(target): normalized = path.replace('/', '\\') + # Denylist before the shape checks so an env-var spelling of a sensitive + # path is refused with the real reason, not "needs a drive letter". + denied_reason = _denied_log_path_reason(normalized) + if denied_reason is not None: + return denied_reason + # A UNC path would have the agent read from a network share as the machine # account. That is a different capability from "read a log off this PC" and # is not what this permission was granted for. @@ -2951,34 +2982,11 @@ def enqueue_endpoint_command(asset_id: int): return error_response( ErrorCodes.VALIDATION_ERROR, 'An override requires overridereason explaining why') - - # Audited before the command is enqueued: if the enqueue fails after - # this point, the record of the attempt still survives. A break-glass - # that is only logged on success hides exactly the cases worth reviewing. - # Structured context goes in changes (JSON-serialized by the model); - # details is a plain Text column, so it carries the readable summary. - audit_log( - action='policyoverridden', - entitytype='Asset', - entityid=asset_id, - changes={ - 'action': action, - 'reason': overridereason, - 'overriddenby': getattr(current_user, 'username', None), - }, - details=f'Policy denial overridden for {action}: {overridereason}', - ) - - # A gate-mode override is a one-shot; a desired-mode override is a fight - # with the reconciler, which would re-enqueue the opposite command on the - # next check-in and make the override look broken. Hold it open. - PLACE_POLICY_HOLD( - assetid=asset_id, - action=action, - reason=overridereason, - userid=getattr(current_user, 'userid', None), - username=getattr(current_user, 'username', None), - ) + # The audit row and the hold are written further down, only once every + # remaining validation has passed: a refused command must leave neither. + policyoverridden = True + else: + policyoverridden = False # Generic actions (service_* / process_*) must carry the subject to act on; # fixed-target actions (mtconnect_*) must not. Validate both directions so a @@ -3077,6 +3085,33 @@ def enqueue_endpoint_command(asset_id: int): 'A command is already in flight for this endpoint', http_code=409) + if policyoverridden: + # Written after every validation above, so a refused command leaves no + # hold and no policyoverridden row. Structured context goes in changes + # (JSON-serialized by the model); details is a plain Text column. + audit_log( + action='policyoverridden', + entitytype='Asset', + entityid=asset_id, + changes={ + 'action': action, + 'reason': overridereason, + 'overriddenby': getattr(current_user, 'username', None), + }, + details=f'Policy denial overridden for {action}: {overridereason}', + ) + + # A gate-mode override is a one-shot; a desired-mode override is a fight + # with the reconciler, which would re-enqueue the opposite command on the + # next check-in and make the override look broken. Hold it open. + PLACE_POLICY_HOLD( + assetid=asset_id, + action=action, + reason=overridereason, + userid=getattr(current_user, 'userid', None), + username=getattr(current_user, 'username', None), + ) + command = EndpointCommand( assetid=asset_id, action=action, @@ -3120,6 +3155,36 @@ def enqueue_agent_upgrade(asset_id: int): That is deliberate: this delivers arbitrary SYSTEM PowerShell by the same mechanism as any other script push. """ + # Cheap refusals first. The delegate enforces these again, but repackaging + # and reseeding rewrite a shared package file and script row, which a caller + # who is locked out or lacks endpoints.runscript must not be able to trigger. + lock = ACTIVE_LOCK_FOR(asset_id) + if lock is not None and not caller_is_role('admin'): + return error_response( + ErrorCodes.FORBIDDEN, + f'Endpoint locked by {lock.lockedbyusername or "a deleted user"}: ' + f'{lock.reason}', + http_code=403) + elevated = elevated_permission_for('run_script') + if elevated and not caller_holds(elevated): + return error_response( + ErrorCodes.FORBIDDEN, + f'Action \'run_script\' requires the {elevated} permission', + http_code=403) + + registration, seeded, error = prepare_agent_upgrade() + if error is not None: + return error + payload = request.get_json(silent=True) or {} + return queue_prepared_agent_upgrade(asset_id, registration, seeded, payload) + + +def prepare_agent_upgrade(): + """Package the current agent build and seed the upgrade script, once. + + Returns (registration, seeded, error_response). A migration wave calls this + once and then queue_prepared_agent_upgrade per PC. + """ from . import agentregistry, agentupgradescript # Refresh the package and the script body from the CURRENT staged build @@ -3129,10 +3194,11 @@ def enqueue_agent_upgrade(asset_id: int): registration = agentregistry.register_current_agent_package() except Exception as exc: # noqa: BLE001 - surfaced, not swallowed db.session.rollback() - return error_response(ErrorCodes.VALIDATION_ERROR, - f'Could not package the agent build: {exc}') + return None, None, error_response( + ErrorCodes.VALIDATION_ERROR, + f'Could not package the agent build: {exc}') if registration is None: - return error_response( + return None, None, error_response( ErrorCodes.VALIDATION_ERROR, 'No agent build is published. Upload an agent binary before ' 'pushing an upgrade.') @@ -3141,17 +3207,26 @@ def enqueue_agent_upgrade(asset_id: int): seeded = agentupgradescript.seed_upgrade_script() except Exception as exc: # noqa: BLE001 - surfaced, not swallowed db.session.rollback() - return error_response(ErrorCodes.VALIDATION_ERROR, - f'Could not prepare the upgrade script: {exc}') + return None, None, error_response( + ErrorCodes.VALIDATION_ERROR, + f'Could not prepare the upgrade script: {exc}') if seeded is None: - return error_response(ErrorCodes.VALIDATION_ERROR, - 'The agent upgrade script could not be prepared.') + return None, None, error_response( + ErrorCodes.VALIDATION_ERROR, + 'The agent upgrade script could not be prepared.') + return registration, seeded, None + + +def queue_prepared_agent_upgrade(asset_id, registration, seeded, payload): + """Queue the prepared upgrade for one endpoint through the real enqueue route. + + Returns the delegate's response; audits only a push that queued. + """ # Rewrite the request body to the equivalent run_script push and hand it to # the real enqueue route. The policyoverride/overridereason fields are # forwarded untouched so break-glass keeps behaving exactly as it does for # a normal script push, including its admin-role and reason requirements. - payload = request.get_json(silent=True) or {} forwarded = { 'action': 'run_script', 'target': 'script:{}'.format(seeded['scriptid']), @@ -3195,19 +3270,14 @@ def resolved_monitors_for_agent(): key), app is the operator label. An unknown hostname or a PC type with no monitors returns empty lists, which the agent merges as "watch nothing extra". """ - error = _collector_auth_or_error() + error = _agent_auth_or_error() if error is not None: return error - hostname = (request.args.get('hostname') or '').strip() - if not hostname: - return error_response(ErrorCodes.VALIDATION_ERROR, - 'hostname is required') - - asset_id = _resolve_asset_for_hostname(hostname) - if asset_id is None: - return error_response(ErrorCodes.NOT_FOUND, - f'Unknown hostname: {hostname!r}', http_code=404) + asset_id, hostname, error = _bind_agent_hostname( + request.args.get('hostname')) + if error is not None: + return error processes = [] services = [] @@ -3248,19 +3318,14 @@ def claim_next_command(): marking it running so no other poll claims it. Returns 204 when nothing is queued within the hold window - the agent simply re-polls. """ - error = _collector_auth_or_error() + error = _agent_auth_or_error() if error is not None: return error - hostname = (request.args.get('hostname') or '').strip() - if not hostname: - return error_response(ErrorCodes.VALIDATION_ERROR, - 'hostname is required') - - asset_id = _resolve_asset_for_hostname(hostname) - if asset_id is None: - return error_response(ErrorCodes.NOT_FOUND, - f'Unknown hostname: {hostname!r}', http_code=404) + asset_id, hostname, error = _bind_agent_hostname( + request.args.get('hostname')) + if error is not None: + return error deadline = time.monotonic() + _CLAIM_HOLD_SECONDS while True: @@ -3321,7 +3386,7 @@ def report_command_result(command_id: int): state, caches it on the EndpointState so the WebUI toggle reflects reality between polls. Both writes land in one commit. """ - error = _collector_auth_or_error() + error = _agent_auth_or_error() if error is not None: return error @@ -3335,23 +3400,20 @@ def report_command_result(command_id: int): # command id - marking an install succeeded on a machine that never ran it, # which writes a false compliance record. # - # Prefer cert-authenticated asset ID (per-endpoint mTLS) when available. Fall - # back to hostname query parameter for agents not yet upgraded to certs. The - # hostname binding is a floor, not a fix: the shared collector key does not - # prove which endpoint is calling. Per-endpoint client certificates close that. - if hasattr(g, 'cert_authenticated_asset_id'): - # Strong authentication via client certificate - caller_asset_id = g.cert_authenticated_asset_id - else: - # Weak authentication via hostname query parameter (fallback during rollout) - hostname = (request.args.get('hostname') or '').strip() - if not hostname: - # No cert and no hostname: cannot verify caller identity - return error_response( - ErrorCodes.FORBIDDEN, - 'Command result requires client certificate or hostname parameter', - http_code=403) - caller_asset_id = _resolve_asset_for_hostname(hostname) + # A credential proves which endpoint is calling. The shared key does not, so + # its hostname binding is a floor, not a fix. + hostname = (request.args.get('hostname') or '').strip() + caller_asset_id, error = agentauth.bind_hostname(hostname) + if error is not None: + return error + if agentauth.is_credential_caller(): + if caller_asset_id != command.assetid: + return agentauth.asset_mismatch_response() + elif not hostname: + return error_response( + ErrorCodes.FORBIDDEN, + 'Command result requires an endpoint credential or hostname parameter', + http_code=403) if caller_asset_id != command.assetid: return error_response( @@ -3418,7 +3480,7 @@ def download_app_package(appversionid: int): """ from shopdb.api import send_upload - error = _collector_auth_or_error() + error = _agent_auth_or_error() if error is not None: return error @@ -3445,186 +3507,6 @@ def download_app_package(appversionid: int): return send_upload(package_dir, filename, as_attachment=True) -@endpoints_bp.route('/certificate/renew', methods=['POST']) -def renew_client_certificate(): - """Agent self-renewal: generate new cert when expiring < 30 days. - - Dual-auth: Prefers client certificate (even if expiring), falls back to API key - + hostname for expired certs. This allows agents to renew expired certificates - that would otherwise fail at TLS handshake. - - Request: {"hostname": "MACHINE-NAME"} (required when using API key fallback) - Response: { - "pfx": "base64-encoded PFX/P12 certificate with private key", - "password": "random password for PFX import (use once, discard)", - "thumbprint": "new certificate thumbprint (uppercase, no colons)", - "expires_at": "2028-09-14T12:34:56" (ISO format, UTC) - } - - Security: - - Prefers client certificate authentication (even expired certs) - - Falls back to API key + hostname when cert missing or nginx rejects expired cert - - Rate limited: 1 renewal per asset per 24 hours - - Logs all renewal operations to AuditLog - - New cert valid for 2 years from generation time - """ - from flask import g - import base64 - - # Dual-auth: Try client certificate first (preferred), fall back to API key + hostname - asset_id = None - asset = None - - if hasattr(g, 'cert_authenticated_asset_id'): - # Strong authentication: client certificate (may be expiring but still valid for renewal) - asset_id = g.cert_authenticated_asset_id - # Row-level lock to prevent concurrent renewal requests from racing - asset = db.session.query(Asset).with_for_update().filter_by(assetid=asset_id).first() - else: - # Fallback: API key + hostname (for expired certs that nginx rejected at handshake) - error = _collector_auth_or_error() - if error is not None: - return error - - # Get hostname from request body - payload = request.get_json(silent=True) or {} - hostname = (payload.get('hostname') or '').strip() - if not hostname: - return error_response( - ErrorCodes.VALIDATION_ERROR, - 'Certificate renewal without client cert requires hostname in request body', - http_code=400) - - # Resolve hostname to asset - from ..models import EndpointState - state = EndpointState.query.filter_by(hostname=hostname).first() - if not state: - return error_response( - ErrorCodes.NOT_FOUND, - f'Unknown hostname: {hostname}', - http_code=404) - - asset_id = state.assetid - # Row-level lock to prevent concurrent renewal requests from racing - asset = db.session.query(Asset).with_for_update().filter_by(assetid=asset_id).first() - - if not asset: - return error_response(ErrorCodes.NOT_FOUND, - f'Asset {asset_id} not found', http_code=404) - - # Get hostname from computers plugin - from ..models import EndpointState - state = EndpointState.query.filter_by(assetid=asset_id).first() - if not state or not state.hostname: - return error_response( - ErrorCodes.VALIDATION_ERROR, - 'Cannot renew certificate: hostname not known', - http_code=400) - - hostname = state.hostname - - # Validate hostname before using in certificate generation - # CN field max length is 64 characters; allow only safe hostname characters - import re - if not hostname or len(hostname) > 64 or not re.match(r'^[a-zA-Z0-9._-]+$', hostname): - return error_response( - ErrorCodes.VALIDATION_ERROR, - f'Invalid hostname format: {hostname}. Must be 1-64 characters, alphanumeric plus dot/hyphen/underscore.', - http_code=400) - - # Rate limit: Only allow renewal once per 24 hours per asset - # (Prevents abuse if agent gets stuck in renewal loop) - # Use timezone-aware comparison to prevent clock manipulation bypass - if asset.cert_issued_at: - from datetime import timedelta - now = datetime.now(timezone.utc) - # Ensure cert_issued_at is timezone-aware UTC - issued_at = asset.cert_issued_at.replace(tzinfo=timezone.utc) \ - if asset.cert_issued_at.tzinfo is None \ - else asset.cert_issued_at.astimezone(timezone.utc) - time_since_issue = now - issued_at - - if time_since_issue < timedelta(hours=24): - hours_since = time_since_issue.total_seconds() / 3600 - return error_response( - ErrorCodes.VALIDATION_ERROR, - f'Certificate was issued {hours_since:.1f} hours ago. ' - f'Renewal only allowed after 24 hours.', - http_code=429) - - # Generate new certificate - from .certificate_renewal import generate_client_certificate - try: - pfx_bytes, pfx_password, fingerprint, expires_at = \ - generate_client_certificate(hostname, validity_days=730) - except Exception as e: - current_app.logger.error( - f'[cert_renewal] Failed to generate certificate for asset {asset_id}: {e}') - # Audit: Certificate generation failure - try: - AuditLog.log( - action='endpoints.cert.renewal_failed', - userid=None, - entityid=asset_id, - entityname=asset.assetnumber, - details=f'Certificate generation failed: {str(e)[:500]}') - except Exception: - pass # Don't fail the error response on audit failure - return error_response( - ErrorCodes.INTERNAL_ERROR, - 'Certificate generation failed', - http_code=500) - - # Update database with new certificate fingerprint and expiration - old_fingerprint = asset.client_cert_fingerprint - asset.client_cert_fingerprint = fingerprint - asset.cert_issued_at = datetime.now(timezone.utc).replace(tzinfo=None) - asset.cert_expires_at = expires_at - - try: - db.session.commit() - except Exception as e: - db.session.rollback() - current_app.logger.error( - f'[cert_renewal] Failed to update database for asset {asset_id}: {e}') - # Audit: Database update failure - try: - AuditLog.log( - action='endpoints.cert.db_update_failed', - userid=None, - entityid=asset_id, - entityname=asset.assetnumber, - details=f'Failed to update database with new certificate: {str(e)[:500]}') - except Exception: - pass # Don't fail the error response on audit failure - return error_response( - ErrorCodes.INTERNAL_ERROR, - 'Database update failed', - http_code=500) - - # Audit log - AuditLog.log( - action='endpoints.cert.renewed', - userid=None, # Agent action, not user action - entityid=asset_id, - entityname=asset.assetnumber, - details=f'Certificate renewed: old={old_fingerprint[:16] if old_fingerprint else "none"}..., ' - f'new={fingerprint[:16]}..., expires={expires_at}') - - current_app.logger.info( - f'[cert_renewal] Asset {asset_id} ({asset.assetnumber}) renewed certificate: ' - f'{fingerprint[:16]}... expires {expires_at}') - - # Return certificate to agent - # Thumbprint uppercase with no separators (matches Windows cert store format) - return success_response({ - 'pfx': base64.b64encode(pfx_bytes).decode('ascii'), - 'password': pfx_password, - 'thumbprint': fingerprint.upper(), - 'expires_at': expires_at.isoformat() - }) - - @endpoints_bp.route('/apppackage/app/', methods=['GET']) @endpoints_bp.route('/apppackage/app//', methods=['GET']) def download_app_package_by_app(app_id: int, filename: str = None): @@ -3641,7 +3523,7 @@ def download_app_package_by_app(app_id: int, filename: str = None): """ from shopdb.api import send_upload - error = _collector_auth_or_error() + error = _agent_auth_or_error() if error is not None: return error @@ -3676,7 +3558,7 @@ def download_script_body(script_id: int): """ from shopdb.api import send_upload - error = _collector_auth_or_error() + error = _agent_auth_or_error() if error is not None: return error @@ -3873,7 +3755,7 @@ def upload_operator_report(): """ import uuid - error = _collector_auth_or_error() + error = _agent_auth_or_error() if error is not None: return error @@ -3892,11 +3774,15 @@ def upload_operator_report(): OPERATOR_REPORT_MAX_IMAGE_BYTES)) hostname = (request.form.get('hostname') or '').strip() + asset_id, error = agentauth.bind_hostname(hostname) + if error is not None: + return error + if not hostname and asset_id is not None: + hostname = _hostname_for_asset(asset_id) or '' if not hostname: return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required') - asset_id = _resolve_asset_for_hostname(hostname) if asset_id is None: return error_response( ErrorCodes.VALIDATION_ERROR, @@ -4068,8 +3954,12 @@ def _endpoint_scope_facts(hostname): def _alert_targets_endpoint(alertscope, facts): """Does an alert's scope include this endpoint? - An absent, empty or unusable scope means the whole fleet - that is the - default and the historical behaviour. A scope with keys narrows the alert: + An absent (None) or empty scope means the whole fleet - that is the default + and the historical behaviour. A scope that is present but not an object + (a list, a number, unparseable text) fails CLOSED: nobody can tell what it + was meant to target, and create/update now refuse it, so such a row is + corrupt rather than a fleet-wide intent. A JSON string holding an object + (legacy TEXT column rows) is parsed. A scope with keys narrows the alert: every recognised key must match, and a key may hold a single value or a list of values, any of which matches. Matching is case-insensitive because these are operator-typed names, not identifiers. @@ -4080,7 +3970,18 @@ def _alert_targets_endpoint(alertscope, facts): sends the alert wider than intended, which is the safe direction for something whose purpose is to warn people. """ - if not isinstance(alertscope, dict) or not alertscope: + if isinstance(alertscope, str): + if not alertscope.strip(): + return True + try: + alertscope = json.loads(alertscope) + except ValueError: + return False + if alertscope is None: + return True + if not isinstance(alertscope, dict): + return False + if not alertscope: return True for key in _ALERT_SCOPE_KEYS: @@ -4120,19 +4021,14 @@ def get_fleet_alerts(): Acknowledgement is terminal, so an acknowledged alert is filtered out here permanently and a later server-side close is a no-op for that asset. """ - error = _collector_auth_or_error() + error = _agent_auth_or_error() if error is not None: return error - hostname = (request.args.get('hostname') or '').strip() - if not hostname: - return error_response(ErrorCodes.VALIDATION_ERROR, - 'hostname is required') - - asset_id = _resolve_asset_for_hostname(hostname) - if asset_id is None: - return error_response(ErrorCodes.NOT_FOUND, - f'Unknown hostname: {hostname!r}', http_code=404) + asset_id, hostname, error = _bind_agent_hostname( + request.args.get('hostname')) + if error is not None: + return error from plugins.notifications.models import Notification, NotificationType, NotificationAcknowledgement @@ -4213,7 +4109,7 @@ def acknowledge_fleet_alert(notification_id: int): out on screen is NOT an acknowledgement and the agent does not post it - the alert stays pending so it can be surfaced again. """ - error = _collector_auth_or_error() + error = _agent_auth_or_error() if error is not None: return error @@ -4221,20 +4117,32 @@ def acknowledge_fleet_alert(notification_id: int): hostname = (payload.get('hostname') or '').strip() assetid = payload.get('assetid') - if not hostname: - return error_response(ErrorCodes.VALIDATION_ERROR, - 'hostname is required') - - if assetid is None: - return error_response(ErrorCodes.VALIDATION_ERROR, - 'assetid is required') - + # assetid 0 or absent means "the caller". try: - assetid = int(assetid) + assetid = int(assetid) if assetid not in (None, '') else 0 except (TypeError, ValueError): return error_response(ErrorCodes.VALIDATION_ERROR, 'assetid must be an integer') + caller_assetid, error = agentauth.bind_hostname(hostname) + if error is not None: + return error + if agentauth.is_credential_caller(): + if assetid and assetid != caller_assetid: + return agentauth.asset_mismatch_response() + assetid = caller_assetid + hostname = hostname or _hostname_for_asset(assetid) or '' + else: + if not hostname: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'hostname is required') + if not assetid: + if caller_assetid is None: + return error_response( + ErrorCodes.NOT_FOUND, f'Unknown hostname: {hostname!r}', + http_code=404) + assetid = caller_assetid + from plugins.notifications.models import Notification, NotificationAcknowledgement notification = db.session.get(Notification, notification_id) @@ -4275,7 +4183,7 @@ def acknowledge_fleet_alert(notification_id: int): db.session.commit() except Exception as exc: db.session.rollback() - logger.warning(f'Failed to commit acknowledgement for notification {notification_id}: {exc}') + current_app.logger.warning(f'Failed to commit acknowledgement for notification {notification_id}: {exc}') return error_response(ErrorCodes.INTERNAL_ERROR, 'Failed to record acknowledgement', http_code=500) @@ -4288,6 +4196,24 @@ def acknowledge_fleet_alert(notification_id: int): # Server certificate management # ============================================================================= +def _spki_sha256(pembytes): + """Lowercase hex SHA-256 of a PEM certificate's SubjectPublicKeyInfo. + + The value agents pin. None when the file does not parse. + """ + from cryptography import x509 + from cryptography.hazmat.primitives import serialization + + try: + certificate = x509.load_pem_x509_certificate(pembytes) + except ValueError: + return None + spki = certificate.public_key().public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo) + return hashlib.sha256(spki).hexdigest() + + @endpoints_bp.route('/settings/server-cert', methods=['GET']) @require_role('admin') def get_server_certificate(): @@ -4372,6 +4298,7 @@ def get_server_certificate(): 'not_before': not_before, 'not_after': not_after, 'thumbprint': thumbprint, + 'spkisha256': _spki_sha256(cert_path.read_bytes()), 'san_list': san_list, 'expires_in_days': expires_in_days, 'is_self_signed': is_self_signed @@ -4389,6 +4316,177 @@ def get_server_certificate(): http_code=500) +_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])?)*$') + + +def _server_cert_dir(): + """Directory nginx reads server.crt / server.key from.""" + from pathlib import Path + return Path('certs') + + +def _is_cert_hostname_or_ip(value): + """True when value is a plain hostname (optionally *.wildcard) or an IP. + + Anything else - newlines, '=', brackets, spaces - is refused rather than + escaped: these values end up in the certificate and in audit text. + """ + import ipaddress + if not isinstance(value, str) or not value: + return False + try: + ipaddress.ip_address(value) + return True + except ValueError: + pass + return bool(_CERT_HOSTNAME_PATTERN.match(value)) + + +def _parse_cert_san(san): + """Split 'DNS:a,IP:b' into (dnsnames, ipaddresses) or raise ValueError.""" + import ipaddress + dnsnames, ipaddresses = [], [] + if not san: + return dnsnames, ipaddresses + for item in (part.strip() for part in san.split(',')): + if not item: + continue + if item[:4].upper() == 'DNS:': + name = item[4:].strip() + if not _is_cert_hostname_or_ip(name): + raise ValueError(f'Invalid DNS name in san: {name!r}') + dnsnames.append(name) + elif item[:3].upper() == 'IP:': + address = item[3:].strip() + try: + ipaddress.ip_address(address) + except ValueError: + raise ValueError(f'Invalid IP address in san: {address!r}') + ipaddresses.append(address) + else: + raise ValueError( + f'Invalid san entry {item!r}; use DNS:name or IP:address') + return dnsnames, ipaddresses + + +def _validate_certificate_pair(cert_bytes, key_bytes): + """Parse a PEM certificate and unencrypted PEM key; check they belong together. + + Returns an error string, or None when the pair is usable. + """ + from cryptography import x509 + from cryptography.hazmat.primitives import serialization + + try: + certificate = x509.load_pem_x509_certificate(cert_bytes) + except Exception: + return 'Invalid certificate (must be a PEM-encoded X.509 certificate)' + try: + private_key = serialization.load_pem_private_key(key_bytes, password=None) + except TypeError: + return 'Private key must be unencrypted (no passphrase)' + except Exception: + return 'Invalid private key (must be a PEM-encoded private key)' + + public_format = serialization.PublicFormat.SubjectPublicKeyInfo + encoding = serialization.Encoding.DER + try: + matches = (certificate.public_key().public_bytes(encoding, public_format) + == private_key.public_key().public_bytes(encoding, public_format)) + except Exception: + matches = False + if not matches: + return 'The private key does not match the certificate public key' + return None + + +def _install_server_certificate(cert_bytes, key_bytes): + """Back up the current pair, then atomically replace it with the new one. + + Only called with a pair that has already been validated. The new files are + written to unique temp names in the same directory first, so a failure + before the swap leaves the existing certificate untouched. + """ + import shutil + import uuid + + cert_dir = _server_cert_dir() + cert_dir.mkdir(parents=True, exist_ok=True) + cert_path = cert_dir / 'server.crt' + key_path = cert_dir / 'server.key' + token = uuid.uuid4().hex + cert_temp = cert_dir / f'server.crt.{token}.tmp' + key_temp = cert_dir / f'server.key.{token}.tmp' + try: + cert_temp.write_bytes(cert_bytes) + key_temp.write_bytes(key_bytes) + key_temp.chmod(0o600) + + stamp = int(time.time()) + if cert_path.exists(): + shutil.copy2(cert_path, cert_dir / f'server.crt.backup.{stamp}') + if key_path.exists(): + shutil.copy2(key_path, cert_dir / f'server.key.backup.{stamp}') + + os.replace(key_temp, key_path) + os.replace(cert_temp, cert_path) + finally: + for leftover in (cert_temp, key_temp): + try: + leftover.unlink() + except OSError: + pass + + +def _build_self_signed_certificate(cn, dnsnames, ipaddresses, days): + """Return (cert_pem, key_pem) for a new RSA-2048 self-signed certificate.""" + import ipaddress + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)]) + now = datetime.now(timezone.utc) + builder = (x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=5)) + .not_valid_after(now + timedelta(days=days))) + alternatives = ([x509.DNSName(value) for value in dnsnames] + + [x509.IPAddress(ipaddress.ip_address(value)) + for value in ipaddresses]) + if alternatives: + builder = builder.add_extension( + x509.SubjectAlternativeName(alternatives), critical=False) + certificate = builder.sign(key, hashes.SHA256()) + cert_pem = certificate.public_bytes(serialization.Encoding.PEM) + key_pem = key.private_bytes(serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption()) + return cert_pem, key_pem + + +def _restart_nginx(): + """Ask compose to restart nginx; failures are logged, not raised.""" + import subprocess + try: + result = subprocess.run(['docker-compose', 'restart', 'nginx'], + capture_output=True, text=True, timeout=30) + if result.returncode == 0: + current_app.logger.info('Nginx restarted with the new certificate') + else: + current_app.logger.warning( + f'Nginx restart returned {result.returncode}: {result.stderr}') + except Exception as restart_error: + current_app.logger.error(f'Failed to restart nginx: {restart_error}') + + @endpoints_bp.route('/settings/server-cert/generate', methods=['POST']) @require_role('admin') def generate_server_certificate(): @@ -4401,125 +4499,78 @@ def generate_server_certificate(): "days": 365 } + cn and every san entry must be a plain hostname or IP address; anything + else is refused. The pair is built in memory (no openssl config file for a + crafted value to rewrite) and validated before the existing certificate is + backed up and replaced. + Admin-only - generates new TLS certificate and restarts nginx. """ - import subprocess - from pathlib import Path - import tempfile - payload = request.get_json(silent=True) or {} - cn = (payload.get('cn') or '').strip() - san = (payload.get('san') or '').strip() + cn = payload.get('cn') if isinstance(payload.get('cn'), str) else '' + cn = cn.strip() + san = payload.get('san') if isinstance(payload.get('san'), str) else '' + san = san.strip() days = payload.get('days', 365) if not cn: return error_response(ErrorCodes.VALIDATION_ERROR, 'Common Name (cn) is required', http_code=400) + if len(cn) > 64 or not _is_cert_hostname_or_ip(cn): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'Common Name (cn) must be a hostname or IP address ' + 'of at most 64 characters', + http_code=400) + try: + dnsnames, ipaddresses = _parse_cert_san(san) + except ValueError as exc: + return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), + http_code=400) - if not isinstance(days, int) or days < 1 or days > 3650: + if isinstance(days, bool) or not isinstance(days, int) or days < 1 or days > 3650: return error_response(ErrorCodes.VALIDATION_ERROR, 'days must be an integer between 1 and 3650', http_code=400) - cert_dir = Path('certs') - cert_path = cert_dir / 'server.crt' - key_path = cert_dir / 'server.key' - - # Backup existing certificate - if cert_path.exists(): - backup_path = cert_dir / f'server.crt.backup.{int(time.time())}' - try: - cert_path.rename(backup_path) - key_path.rename(cert_dir / f'server.key.backup.{int(time.time())}') - current_app.logger.info(f'Backed up old certificate to {backup_path}') - except Exception as e: - current_app.logger.warning(f'Failed to backup old certificate: {e}') - - # Create temporary OpenSSL config - with tempfile.NamedTemporaryFile(mode='w', suffix='.cnf', delete=False) as config_file: - config_content = f"""[req] -distinguished_name = req_distinguished_name -x509_extensions = v3_req -prompt = no - -[req_distinguished_name] -CN = {cn} - -[v3_req] -subjectAltName = @alt_names - -[alt_names] -""" - # Parse SANs and add to config - if san: - san_items = [s.strip() for s in san.split(',')] - dns_count = 1 - ip_count = 1 - for item in san_items: - if item.startswith('DNS:'): - config_content += f'DNS.{dns_count} = {item[4:]}\n' - dns_count += 1 - elif item.startswith('IP:'): - config_content += f'IP.{ip_count} = {item[3:]}\n' - ip_count += 1 - - config_file.write(config_content) - config_path = config_file.name - try: - # Generate certificate - result = subprocess.run([ - 'openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', - '-keyout', str(key_path), - '-out', str(cert_path), - '-days', str(days), - '-config', config_path - ], capture_output=True, text=True, check=True) - - current_app.logger.info(f'Generated new server certificate: CN={cn}, days={days}') - - # Restart nginx to pick up new certificate - try: - restart_result = subprocess.run([ - 'docker-compose', 'restart', 'nginx' - ], capture_output=True, text=True, timeout=30) - - if restart_result.returncode == 0: - current_app.logger.info('Nginx restarted successfully with new certificate') - else: - current_app.logger.warning(f'Nginx restart returned {restart_result.returncode}: {restart_result.stderr}') - except Exception as restart_error: - current_app.logger.error(f'Failed to restart nginx: {restart_error}') - - # Audit log the certificate generation - try: - AuditLog.log(action='endpoints.server_cert.generated', userid=current_user.userid if current_user else None, - details=f'Generated self-signed certificate: CN={cn}, SANs={san}, validity={days} days') - except Exception: - pass - - return success_response({ - 'message': 'Certificate generated successfully. Nginx is restarting.' - }) - - except subprocess.CalledProcessError as e: - current_app.logger.error(f'Failed to generate certificate: {e.stderr}') - return error_response(ErrorCodes.INTERNAL_ERROR, - f'Failed to generate certificate: {e.stderr}', - http_code=500) + cert_bytes, key_bytes = _build_self_signed_certificate( + cn, dnsnames, ipaddresses, days) except Exception as e: current_app.logger.error(f'Unexpected error generating certificate: {e}') return error_response(ErrorCodes.INTERNAL_ERROR, f'Failed to generate certificate: {str(e)}', http_code=500) - finally: - # Clean up temp config - try: - Path(config_path).unlink() - except Exception: - pass + + pair_error = _validate_certificate_pair(cert_bytes, key_bytes) + if pair_error is not None: + return error_response(ErrorCodes.INTERNAL_ERROR, + f'Generated certificate failed validation: {pair_error}', + http_code=500) + + try: + _install_server_certificate(cert_bytes, key_bytes) + except Exception as e: + current_app.logger.error(f'Failed to install generated certificate: {e}') + return error_response(ErrorCodes.INTERNAL_ERROR, + f'Failed to write certificate files: {str(e)}', + http_code=500) + + current_app.logger.info(f'Generated new server certificate: CN={cn}, days={days}') + _restart_nginx() + + AuditLog.log('endpoints.server_cert.generated', 'ServerCertificate', + entityname=cn, + changes={'cn': cn, 'dns': dnsnames, 'ip': ipaddresses, + 'days': days}, + details=f'Generated self-signed certificate: CN={cn}, ' + f'SANs={san}, validity={days} days') + db.session.commit() + + return success_response({ + 'message': 'Certificate generated successfully. Nginx is restarting.' + }) @endpoints_bp.route('/settings/server-cert/upload', methods=['POST']) @@ -4531,11 +4582,12 @@ def upload_server_certificate(): - cert: Certificate file (.crt or .pem) - key: Private key file (.key or .pem) + Both are parsed and the key is checked against the certificate public key + BEFORE anything on disk is touched; only a valid, matching pair causes the + existing files to be backed up and replaced. + Admin-only - replaces TLS certificate and restarts nginx. """ - import subprocess - from pathlib import Path - if 'cert' not in request.files or 'key' not in request.files: return error_response(ErrorCodes.VALIDATION_ERROR, 'Both cert and key files are required', @@ -4549,89 +4601,33 @@ def upload_server_certificate(): 'Both cert and key files must be provided', http_code=400) - cert_dir = Path('certs') - cert_path = cert_dir / 'server.crt' - key_path = cert_dir / 'server.key' + cert_bytes = cert_file.read() + key_bytes = key_file.read() - # Validate certificate format before writing - cert_content = cert_file.read().decode('utf-8') - key_content = key_file.read().decode('utf-8') - - if '-----BEGIN CERTIFICATE-----' not in cert_content: - return error_response(ErrorCodes.VALIDATION_ERROR, - 'Invalid certificate format (must be PEM-encoded)', + pair_error = _validate_certificate_pair(cert_bytes, key_bytes) + if pair_error is not None: + return error_response(ErrorCodes.VALIDATION_ERROR, pair_error, http_code=400) - if '-----BEGIN' not in key_content or 'PRIVATE KEY-----' not in key_content: - return error_response(ErrorCodes.VALIDATION_ERROR, - 'Invalid private key format (must be PEM-encoded)', - http_code=400) - - # Check if private key is encrypted - if 'ENCRYPTED' in key_content: - return error_response(ErrorCodes.VALIDATION_ERROR, - 'Private key must be unencrypted (no passphrase)', - http_code=400) - - # Backup existing certificate - if cert_path.exists(): - backup_path = cert_dir / f'server.crt.backup.{int(time.time())}' - try: - cert_path.rename(backup_path) - key_path.rename(cert_dir / f'server.key.backup.{int(time.time())}') - current_app.logger.info(f'Backed up old certificate to {backup_path}') - except Exception as e: - current_app.logger.warning(f'Failed to backup old certificate: {e}') - - # Write new certificate and key try: - cert_path.write_text(cert_content) - key_path.write_text(key_content) - - # Set restrictive permissions on private key - key_path.chmod(0o600) - - current_app.logger.info('Uploaded new server certificate and private key') - - # Verify certificate is valid - verify_result = subprocess.run([ - 'openssl', 'x509', '-in', str(cert_path), '-noout', '-text' - ], capture_output=True, text=True) - - if verify_result.returncode != 0: - current_app.logger.error(f'Uploaded certificate is invalid: {verify_result.stderr}') - return error_response(ErrorCodes.VALIDATION_ERROR, - 'Uploaded certificate is not valid', - http_code=400) - - # Restart nginx to pick up new certificate - try: - restart_result = subprocess.run([ - 'docker-compose', 'restart', 'nginx' - ], capture_output=True, text=True, timeout=30) - - if restart_result.returncode == 0: - current_app.logger.info('Nginx restarted successfully with uploaded certificate') - else: - current_app.logger.warning(f'Nginx restart returned {restart_result.returncode}: {restart_result.stderr}') - except Exception as restart_error: - current_app.logger.error(f'Failed to restart nginx: {restart_error}') - - # Audit log the certificate upload - try: - AuditLog.log(action='endpoints.server_cert.uploaded', userid=current_user.userid if current_user else None, - details=f'Uploaded CA-signed certificate from files: {cert_file.filename}, {key_file.filename}') - except Exception: - pass - - return success_response({ - 'message': 'Certificate uploaded successfully. Nginx is restarting.' - }) - + _install_server_certificate(cert_bytes, key_bytes) except Exception as e: current_app.logger.error(f'Failed to write certificate files: {e}') return error_response(ErrorCodes.INTERNAL_ERROR, f'Failed to write certificate files: {str(e)}', http_code=500) + current_app.logger.info('Uploaded new server certificate and private key') + _restart_nginx() + + AuditLog.log('endpoints.server_cert.uploaded', 'ServerCertificate', + entityname=cert_file.filename, + details=f'Uploaded certificate from files: ' + f'{cert_file.filename}, {key_file.filename}') + db.session.commit() + + return success_response({ + 'message': 'Certificate uploaded successfully. Nginx is restarting.' + }) + diff --git a/plugins/endpoints/migrations/versions/0020_app_delta_fingerprints.py b/plugins/endpoints/migrations/versions/0020_app_delta_fingerprints.py index 36ba615d..d2d3f0f2 100644 --- a/plugins/endpoints/migrations/versions/0020_app_delta_fingerprints.py +++ b/plugins/endpoints/migrations/versions/0020_app_delta_fingerprints.py @@ -1,10 +1,10 @@ """Add fingerprint column for delta-based application updates -Revision ID: 0020 -Revises: 0019 +Revision ID: endpoints0020appfingerprints +Revises: endpoints0019splitpolicies Create Date: 2026-09-14 -Performance optimization: Add app_fingerprint column to endpointapplications +Performance optimization: Add appfingerprint column to endpointapplications table to enable delta-based updates instead of delete-all-insert-all pattern. Impact: @@ -35,6 +35,10 @@ SAFETY FEATURES: - Delete count verification detects silent failures - Memory limits prevent pathological cases (>5000 apps) - Metrics logged for monitoring + +Idempotent: a database that already has the column (create_all built it from +the model, or an earlier build of this revision added it as app_fingerprint) +is left alone. 0023 renames the old spelling. """ from alembic import op @@ -47,26 +51,40 @@ branch_labels = None depends_on = None plugin_name = 'endpoints' +TABLE = 'endpointapplications' +INDEX = 'idx_endpointapplications_fingerprint' + + +def _columns(): + from sqlalchemy import inspect + + return {c['name'] for c in inspect(op.get_bind()).get_columns(TABLE)} + + +def _indexes(): + from sqlalchemy import inspect + + return {ix['name'] for ix in inspect(op.get_bind()).get_indexes(TABLE)} + def upgrade(): - # Add app_fingerprint column - nullable for backward compatibility during rollout - op.add_column('endpointapplications', - sa.Column('app_fingerprint', sa.String(64), nullable=True)) - - # Add UNIQUE index for fast fingerprint lookups (assetid, app_fingerprint) - # This index enables efficient delta detection: "which fingerprints exist for this asset?" - # UNIQUE constraint ensures one fingerprint per asset (delta logic requirement) - op.create_index( - 'idx_endpointapplications_fingerprint', - 'endpointapplications', - ['assetid', 'app_fingerprint'], - unique=True - ) + columns = _columns() + # Old spelling present: leave it for 0023 to rename. + if 'appfingerprint' in columns or 'app_fingerprint' in columns: + return + op.add_column(TABLE, sa.Column('appfingerprint', sa.String(64), + nullable=True)) + if INDEX not in _indexes(): + # Unique per asset: the delta logic keys rows on it. + op.create_index(INDEX, TABLE, ['assetid', 'appfingerprint'], + unique=True) def downgrade(): - # Remove index first - op.drop_index('idx_endpointapplications_fingerprint', 'endpointapplications') - - # Remove column - op.drop_column('endpointapplications', 'app_fingerprint') + if INDEX in _indexes(): + op.drop_index(INDEX, table_name=TABLE) + columns = _columns() + for name in ('appfingerprint', 'app_fingerprint'): + if name in columns: + with op.batch_alter_table(TABLE) as batch: + batch.drop_column(name) diff --git a/plugins/endpoints/migrations/versions/0021_client_certificates.py b/plugins/endpoints/migrations/versions/0021_client_certificates.py index e572e72b..bddd77c2 100644 --- a/plugins/endpoints/migrations/versions/0021_client_certificates.py +++ b/plugins/endpoints/migrations/versions/0021_client_certificates.py @@ -1,42 +1,26 @@ -"""Add client certificate authentication columns +"""Retired: client certificate columns on assets. Revision ID: endpoints0021clientcerts Revises: endpoints0020appfingerprints Create Date: 2026-09-14 + +This revision once added client certificate columns to the core assets table. +Client certificate authentication was replaced by per-PC endpoint credentials +(0023), so the revision is kept only so databases already stamped with it +still resolve. It does nothing; 0023 drops the columns wherever an earlier +build of this revision created them. """ -from alembic import op -import sqlalchemy as sa - revision = 'endpoints0021clientcerts' -down_revision = 'endpoints0020appfingerprints' # Will resolve to actual revision ID +down_revision = 'endpoints0020appfingerprints' branch_labels = None depends_on = None plugin_name = 'endpoints' def upgrade(): - # Add client cert columns to assets table - op.add_column('assets', - sa.Column('client_cert_fingerprint', sa.String(64), nullable=True)) - - op.add_column('assets', - sa.Column('cert_issued_at', sa.DateTime, nullable=True)) - - op.add_column('assets', - sa.Column('cert_expires_at', sa.DateTime, nullable=True)) - - # Add index for fast fingerprint lookup during authentication - op.create_index( - 'idx_assets_cert_fingerprint', - 'assets', - ['client_cert_fingerprint'], - unique=False - ) + pass def downgrade(): - op.drop_index('idx_assets_cert_fingerprint', 'assets') - op.drop_column('assets', 'cert_expires_at') - op.drop_column('assets', 'cert_issued_at') - op.drop_column('assets', 'client_cert_fingerprint') + pass diff --git a/plugins/endpoints/migrations/versions/0022_client_cert_revocation.py b/plugins/endpoints/migrations/versions/0022_client_cert_revocation.py index a95d3933..5678a504 100644 --- a/plugins/endpoints/migrations/versions/0022_client_cert_revocation.py +++ b/plugins/endpoints/migrations/versions/0022_client_cert_revocation.py @@ -1,12 +1,13 @@ -"""Add certificate revocation columns to assets +"""Retired: client certificate revocation columns on assets. Revision ID: endpoints0022certrevocation Revises: endpoints0021clientcerts Create Date: 2026-09-14 -""" -from alembic import op -import sqlalchemy as sa +Kept only so databases already stamped with this revision still resolve. It +does nothing; 0023 drops the columns wherever an earlier build of this +revision created them. +""" revision = 'endpoints0022certrevocation' down_revision = 'endpoints0021clientcerts' @@ -16,16 +17,8 @@ plugin_name = 'endpoints' def upgrade(): - # Add revocation tracking columns to assets table - op.add_column('assets', - sa.Column('cert_revoked_at', sa.DateTime, nullable=True, - comment='When the certificate was revoked (null = not revoked)')) - - op.add_column('assets', - sa.Column('cert_revocation_reason', sa.String(255), nullable=True, - comment='Why the certificate was revoked (admin note)')) + pass def downgrade(): - op.drop_column('assets', 'cert_revocation_reason') - op.drop_column('assets', 'cert_revoked_at') + pass diff --git a/plugins/endpoints/migrations/versions/0023_endpoint_credentials.py b/plugins/endpoints/migrations/versions/0023_endpoint_credentials.py new file mode 100644 index 00000000..32d1b8e3 --- /dev/null +++ b/plugins/endpoints/migrations/versions/0023_endpoint_credentials.py @@ -0,0 +1,167 @@ +"""Per-PC endpoint credentials; remove the client certificate leftovers. + +Revision ID: endpoints0023credentials +Revises: endpoints0022certrevocation +Create Date: 2026-09-15 + +Three things, each guarded against the live schema so the revision is safe on +a database built by create_all, on one that ran the earlier client certificate +builds of 0021/0022, and on a re-run: + +1. Drop the client certificate columns (and their index) from the core assets + table where an earlier build of 0021/0022 created them. +2. Rename endpointapplications.app_fingerprint to appfingerprint where an + earlier build of 0020 used the underscore spelling. +3. Create endpointcredentials, endpointenrollrequests and + endpointmigrationwaves. + +MySQL 5.6 safe: no JSON columns, and the enroll request lookup index uses +prefix lengths so the key stays under 767 bytes on utf8mb4. + +Downgrade drops the three tables. The certificate columns are not restored: +the code that read them is gone. +""" +from alembic import op + +import sqlalchemy as sa + + +revision = 'endpoints0023credentials' +down_revision = 'endpoints0022certrevocation' +branch_labels = None +depends_on = None +plugin_name = 'endpoints' + +CREDENTIALS = 'endpointcredentials' +REQUESTS = 'endpointenrollrequests' +WAVES = 'endpointmigrationwaves' + +_CERT_COLUMNS = ('client_cert_fingerprint', 'cert_issued_at', 'cert_expires_at', + 'cert_revoked_at', 'cert_revocation_reason') +_APPS = 'endpointapplications' +_FINGERPRINT_INDEX = 'idx_endpointapplications_fingerprint' + + +def _inspector(): + from sqlalchemy import inspect + + return inspect(op.get_bind()) + + +def _has_table(name): + return _inspector().has_table(name) + + +def _columns(table): + return {c['name'] for c in _inspector().get_columns(table)} + + +def _indexes(table): + return _inspector().get_indexes(table) + + +def _drop_certificate_columns(): + if not _has_table('assets'): + return + present = _columns('assets') & set(_CERT_COLUMNS) + if not present: + return + # Any index over a certificate column goes first: 0021 named its own, + # create_all names by convention, and SQLite refuses to drop an indexed column. + for index in _indexes('assets'): + if set(index.get('column_names') or ()) & present: + op.drop_index(index['name'], table_name='assets') + for column in _CERT_COLUMNS: + if column in present: + op.drop_column('assets', column) + + +def _rename_fingerprint(): + if not _has_table(_APPS): + return + columns = _columns(_APPS) + if 'app_fingerprint' not in columns: + return + if 'appfingerprint' in columns: + return + for index in _indexes(_APPS): + if 'app_fingerprint' in (index.get('column_names') or ()): + op.drop_index(index['name'], table_name=_APPS) + op.alter_column(_APPS, 'app_fingerprint', new_column_name='appfingerprint', + existing_type=sa.String(64), existing_nullable=True) + op.create_index(_FINGERPRINT_INDEX, _APPS, ['assetid', 'appfingerprint'], + unique=True) + + +def upgrade(): + _drop_certificate_columns() + _rename_fingerprint() + + if not _has_table(CREDENTIALS): + op.create_table( + CREDENTIALS, + sa.Column('credentialid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('credentialhash', sa.CHAR(length=64), nullable=False), + sa.Column('credentialprefix', sa.String(length=12), nullable=False), + sa.Column('enrolledvia', sa.String(length=16), nullable=False), + sa.Column('issuedat', sa.DateTime(), nullable=False), + sa.Column('lastusedat', sa.DateTime(), nullable=True), + sa.Column('revokedat', sa.DateTime(), nullable=True), + sa.Column('revokedby', sa.String(length=64), nullable=True), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('credentialid'), + sa.UniqueConstraint('assetid'), + sa.UniqueConstraint('credentialhash'), + ) + + if not _has_table(REQUESTS): + op.create_table( + REQUESTS, + sa.Column('requestid', sa.Integer(), nullable=False), + sa.Column('hostname', sa.String(length=255), nullable=False), + sa.Column('serialnumber', sa.String(length=128), nullable=False), + sa.Column('deviceid', sa.String(length=64), nullable=True), + sa.Column('enrollnonce', sa.String(length=128), nullable=False), + sa.Column('agentversion', sa.String(length=32), nullable=True), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('reason', sa.String(length=64), nullable=True), + sa.Column('matchedassetid', sa.Integer(), nullable=True), + sa.Column('sourceip', sa.String(length=45), nullable=True), + sa.Column('requestedat', sa.DateTime(), nullable=False), + sa.Column('lastseenat', sa.DateTime(), nullable=False), + sa.Column('decidedat', sa.DateTime(), nullable=True), + sa.Column('decidedby', sa.String(length=64), nullable=True), + sa.ForeignKeyConstraint(['matchedassetid'], ['assets.assetid'], + ondelete='SET NULL'), + sa.PrimaryKeyConstraint('requestid'), + ) + existing = {tuple(ix.get('column_names') or ()) for ix in _indexes(REQUESTS)} + if ('hostname', 'serialnumber') not in existing: + op.create_index('idx_endpointenrollrequests_hostserial', REQUESTS, + ['hostname', 'serialnumber'], + mysql_length={'hostname': 128, 'serialnumber': 63}) + + if not _has_table(WAVES): + op.create_table( + WAVES, + sa.Column('waveid', sa.Integer(), nullable=False), + sa.Column('createdat', sa.DateTime(), nullable=False), + sa.Column('createdby', sa.String(length=64), nullable=True), + sa.Column('assetids', sa.Text(), nullable=False), + sa.Column('notes', sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint('waveid'), + ) + + +def downgrade(): + if _has_table(WAVES): + op.drop_table(WAVES) + if _has_table(REQUESTS): + for index in _indexes(REQUESTS): + if index['name'] == 'idx_endpointenrollrequests_hostserial': + op.drop_index(index['name'], table_name=REQUESTS) + op.drop_table(REQUESTS) + if _has_table(CREDENTIALS): + op.drop_table(CREDENTIALS) diff --git a/plugins/endpoints/models/__init__.py b/plugins/endpoints/models/__init__.py index 53c6527b..bfe7af35 100644 --- a/plugins/endpoints/models/__init__.py +++ b/plugins/endpoints/models/__init__.py @@ -6,6 +6,9 @@ from .endpoint import ( ) from .monitor import EndpointMonitor from .operatorreport import OperatorReport +from .credential import ( + EndpointCredential, EndpointEnrollRequest, EndpointMigrationWave, +) from .health import ( EndpointHealthState, EndpointHealthTransition, EndpointMonitorImpact, ) @@ -14,5 +17,6 @@ __all__ = [ 'EndpointState', 'EndpointEvent', 'EndpointCommand', 'EndpointApplication', 'EndpointLock', 'EndpointLogCollection', 'EndpointMonitor', 'OperatorReport', + 'EndpointCredential', 'EndpointEnrollRequest', 'EndpointMigrationWave', 'EndpointHealthState', 'EndpointHealthTransition', 'EndpointMonitorImpact', ] diff --git a/plugins/endpoints/models/credential.py b/plugins/endpoints/models/credential.py new file mode 100644 index 00000000..58080d3b --- /dev/null +++ b/plugins/endpoints/models/credential.py @@ -0,0 +1,148 @@ +"""Per-PC endpoint credentials, enrollment requests and migration waves. + + EndpointCredential + One row per asset. Holds only the sha256 of the secret the + agent was issued, plus a short display prefix. Revoking sets + revokedat; re-issuing after a revoke UPDATES the same row, so + an asset never carries two credentials. + + EndpointEnrollRequest + One row per (hostname, serial number) that asked to enroll. The + agent's enrollment nonce is recorded on first sight and is what + an admin approval is bound to, so a second machine claiming the + same hostname and serial cannot collect the approved credential. + + EndpointMigrationWave + An admin action that pushed the agent upgrade to a set of PCs. + Progress is computed live from credentials and commands; only + the membership is stored. +""" + +from datetime import datetime + +from shopdb.api import db + + +def _utciso(value): + return value.isoformat() + 'Z' if value else None + + +class EndpointCredential(db.Model): + """The credential issued to one endpoint (hash only).""" + + __tablename__ = 'endpointcredentials' + + ENROLLED_AUTO = 'auto' + ENROLLED_APPROVED = 'approved' + + credentialid = db.Column(db.Integer, primary_key=True) + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + nullable=False, + unique=True, + ) + credentialhash = db.Column(db.CHAR(64), nullable=False, unique=True) + credentialprefix = db.Column(db.String(12), nullable=False) + enrolledvia = db.Column(db.String(16), nullable=False) + issuedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + lastusedat = db.Column(db.DateTime, nullable=True) + revokedat = db.Column(db.DateTime, nullable=True) + revokedby = db.Column(db.String(64), nullable=True) + + @property + def isactive(self): + return self.revokedat is None + + def to_dict(self): + return { + 'credentialid': self.credentialid, + 'assetid': self.assetid, + 'credentialprefix': self.credentialprefix, + 'enrolledvia': self.enrolledvia, + 'issuedat': _utciso(self.issuedat), + 'lastusedat': _utciso(self.lastusedat), + 'revokedat': _utciso(self.revokedat), + 'revokedby': self.revokedby, + } + + def __repr__(self): + return f'' + + +class EndpointEnrollRequest(db.Model): + """A PC asking for a credential.""" + + __tablename__ = 'endpointenrollrequests' + + STATUS_PENDING = 'pending' + STATUS_APPROVED = 'approved' + STATUS_REJECTED = 'rejected' + STATUS_ISSUED = 'issued' + + requestid = db.Column(db.Integer, primary_key=True) + hostname = db.Column(db.String(255), nullable=False) + serialnumber = db.Column(db.String(128), nullable=False) + deviceid = db.Column(db.String(64), nullable=True) + enrollnonce = db.Column(db.String(128), nullable=False) + agentversion = db.Column(db.String(32), nullable=True) + status = db.Column(db.String(16), nullable=False, default=STATUS_PENDING) + reason = db.Column(db.String(64), nullable=True) + matchedassetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='SET NULL'), + nullable=True, + ) + sourceip = db.Column(db.String(45), nullable=True) + requestedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + lastseenat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + decidedat = db.Column(db.DateTime, nullable=True) + decidedby = db.Column(db.String(64), nullable=True) + + # Prefix lengths keep the key under 767 bytes on utf8mb4 (4 bytes a char). + __table_args__ = ( + db.Index('idx_endpointenrollrequests_hostserial', 'hostname', + 'serialnumber', + mysql_length={'hostname': 128, 'serialnumber': 63}), + ) + + def to_dict(self): + return { + 'requestid': self.requestid, + 'hostname': self.hostname, + 'serialnumber': self.serialnumber, + 'deviceid': self.deviceid, + 'agentversion': self.agentversion, + 'reason': self.reason, + 'status': self.status, + 'matchedassetid': self.matchedassetid, + 'requestedat': _utciso(self.requestedat), + 'lastseenat': _utciso(self.lastseenat), + 'decidedat': _utciso(self.decidedat), + 'decidedby': self.decidedby, + 'sourceip': self.sourceip, + } + + def __repr__(self): + return f'' + + +class EndpointMigrationWave(db.Model): + """One batch of agent upgrades pushed to move PCs onto credentials.""" + + __tablename__ = 'endpointmigrationwaves' + + waveid = db.Column(db.Integer, primary_key=True) + createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + createdby = db.Column(db.String(64), nullable=True) + # Comma list of asset ids. TEXT, not JSON: MySQL 5.6. + assetids = db.Column(db.Text, nullable=False, default='') + notes = db.Column(db.String(255), nullable=True) + + @property + def assetidlist(self): + return [int(part) for part in (self.assetids or '').split(',') + if part.strip().isdigit()] + + def __repr__(self): + return f'' diff --git a/plugins/endpoints/models/endpoint.py b/plugins/endpoints/models/endpoint.py index f0a4cd36..19258e9c 100644 --- a/plugins/endpoints/models/endpoint.py +++ b/plugins/endpoints/models/endpoint.py @@ -468,11 +468,11 @@ class EndpointApplication(db.Model): # publisher. Allows efficient comparison to detect changes without comparing all # columns. Computed by agent and sent in payload. Nullable for backward compatibility # during migration - backfill script will populate existing rows. - app_fingerprint = db.Column(db.String(64), nullable=True) + appfingerprint = db.Column(db.String(64), nullable=True) __table_args__ = ( db.Index('idx_endpointapplications_assetname', 'assetid', 'displayname'), - db.Index('idx_endpointapplications_fingerprint', 'assetid', 'app_fingerprint', unique=True), + db.Index('idx_endpointapplications_fingerprint', 'assetid', 'appfingerprint', unique=True), ) def __repr__(self): diff --git a/plugins/endpoints/plugin.py b/plugins/endpoints/plugin.py index 03118454..939a07ca 100644 --- a/plugins/endpoints/plugin.py +++ b/plugins/endpoints/plugin.py @@ -20,7 +20,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Dict, List, Optional, Type -from flask import Flask, Blueprint +from flask import Flask, Blueprint, g from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.api import db @@ -298,6 +298,25 @@ class EndpointsPlugin(BasePlugin): 'key is provisioned so revocation is a real kill ' 'switch.', }, + { + 'key': 'plugin.endpoints.sharedkeyallowed', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'plugin.endpoints', + 'description': 'Accept the shared fleet collector key from PCs ' + 'that have not enrolled. Enrolled PCs are always ' + 'refused it. Turn off once every PC has a ' + 'per-PC credential.', + }, + { + 'key': 'plugin.endpoints.autoenroll', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'plugin.endpoints', + 'description': 'Issue a per-PC credential without admin ' + 'approval when the hostname and serial number ' + 'match exactly one computer.', + }, { 'key': 'plugin.endpoints.agentkeyprovisioned', 'value': 'false', @@ -351,6 +370,15 @@ class EndpointsPlugin(BasePlugin): 'Windows trusted roots.', 'position': 29, }, + { + 'group': 'Endpoints', + 'to': '/settings/endpoints/enrollment', + 'icon': 'key', + 'title': 'Enrollment', + 'description': 'Move agents to per-PC credentials, approve ' + 'enrollment requests, and retire the shared key.', + 'position': 30, + }, ] def get_navigation_items(self) -> List[Dict]: @@ -804,9 +832,18 @@ class EndpointsPlugin(BasePlugin): # ---- ADR-006 collector contract ------------------------------------- + def resolve_agent_credential(self, secret: str) -> Optional[int]: + from .api import agentauth + return agentauth.resolve_credential(secret) + + def agent_shared_key_refused(self, hostname: str) -> bool: + from .api import agentauth + return agentauth.shared_key_refused_for(hostname) + def get_collector_schema(self) -> Optional[Dict]: return { 'identityfield': 'hostname', + 'acceptsagentcredential': True, 'fields': { 'type': 'object', 'required': ['hostname'], @@ -1093,7 +1130,7 @@ class EndpointsPlugin(BasePlugin): displayname=displayname[:255], displayversion=_clamp(item.get('displayversion'), 128), publisher=_clamp(item.get('publisher'), 255), - app_fingerprint=None, # Agent didn't send it + appfingerprint=None, # Agent didn't send it )) stored += 1 @@ -1171,7 +1208,7 @@ class EndpointsPlugin(BasePlugin): displayname=app_data['displayname'], displayversion=app_data['displayversion'], publisher=app_data['publisher'], - app_fingerprint=fp, + appfingerprint=fp, )) stored += 1 @@ -1184,14 +1221,14 @@ class EndpointsPlugin(BasePlugin): return stored - current_fps = {app.app_fingerprint for app in current_apps - if app.app_fingerprint} # Skip NULL fingerprints from migration + current_fps = {app.appfingerprint for app in current_apps + if app.appfingerprint} # Skip NULL fingerprints from migration # TRANSITION: If we have apps without fingerprints, this is the first report # after agent upgrade. Delete all NULL-fingerprint apps to prevent duplicates. # SAFETY: Validate incoming apps BEFORE deleting legacy apps to prevent # leaving asset with zero apps if transaction aborts. - null_fp_apps = [app for app in current_apps if app.app_fingerprint is None] + null_fp_apps = [app for app in current_apps if app.appfingerprint is None] if null_fp_apps: if not incoming_fps: current_app.logger.error( @@ -1218,7 +1255,7 @@ class EndpointsPlugin(BasePlugin): if to_remove: deleted_count = EndpointApplication.query.filter( EndpointApplication.assetid == assetid, - EndpointApplication.app_fingerprint.in_(list(to_remove)) + EndpointApplication.appfingerprint.in_(list(to_remove)) ).delete(synchronize_session=False) # SAFETY: Verify delete count matches expected. Log as WARNING @@ -1241,7 +1278,7 @@ class EndpointsPlugin(BasePlugin): displayname=app_data['displayname'], displayversion=app_data['displayversion'], publisher=app_data['publisher'], - app_fingerprint=fp, + appfingerprint=fp, )) # SAFETY: Flush to detect constraint violations before continuing @@ -1271,6 +1308,14 @@ class EndpointsPlugin(BasePlugin): if not hostname: raise ValueError('hostname is required') + from .api import agentauth + if agentauth.is_credential_caller(): + # A credentialed agent reports only for its own asset, and never + # adopts: its asset already exists. + if agentauth.resolve_hostname(hostname) != g.agentassetid: + raise PermissionError( + 'The payload names a different endpoint than the credential') + computer = (Computer.query .filter(db.func.lower(Computer.hostname) == hostname.lower()) .first()) @@ -1301,21 +1346,6 @@ class EndpointsPlugin(BasePlugin): assetid = computer.assetid - # Authorization: if authenticated via client certificate, verify the - # certificate's asset matches the payload's hostname asset. Without this - # check an agent with a valid cert for asset A can submit telemetry for - # asset B by changing the hostname field. - from flask import g - if hasattr(g, 'cert_authenticated_asset_id'): - if g.cert_authenticated_asset_id != assetid: - self.logger.warning( - '[collector] AUTHORIZATION DENIED: Certificate authenticates ' - 'asset %d but payload hostname %s resolves to asset %d', - g.cert_authenticated_asset_id, hostname, assetid) - raise PermissionError( - f'Certificate authenticates asset {g.cert_authenticated_asset_id} ' - f'but payload reports for asset {assetid}') - state = EndpointState.query.filter_by(assetid=assetid).first() action = 'updated' if state is None: @@ -1541,12 +1571,18 @@ class EndpointsPlugin(BasePlugin): # Fail-open, matching the reconciler above: a health error must never # fail an otherwise-good check-in. The previous verdict simply stands, # which is correct - a failed evaluation is not evidence of health. + # + # SAVEPOINT: a failed flush (IntegrityError and friends) otherwise + # leaves the session in a rolled-back-needed state, and the caught + # exception would still sink the outer commit of the whole check-in. + # Rolling back only the savepoint keeps the telemetry flushed above. try: from .services.health import evaluate_endpoint_health - evaluate_endpoint_health( - assetid, state, - computertypeid=getattr(computer, 'computertypeid', None)) - db.session.flush() + with db.session.begin_nested(): + evaluate_endpoint_health( + assetid, state, + computertypeid=getattr(computer, 'computertypeid', None)) + db.session.flush() except Exception: logger.exception( 'health evaluation failed for asset %s; leaving verdict ' diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py index 9f2b800e..edbcb95a 100644 --- a/plugins/notifications/api/routes.py +++ b/plugins/notifications/api/routes.py @@ -11,7 +11,7 @@ from flask_jwt_extended import jwt_required from shopdb.api import db, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query from ..models import Notification, NotificationType -from ..models.notification import _utc_iso +from ..models.notification import _utc_iso, ALERT_SCOPE_KEYS from shopdb.api import require_permission, Setting, intarg @@ -405,6 +405,69 @@ def get_notification(notification_id: int): return success_response(n.to_dict()) +def _clean_alert_fields(data): + """Validate and normalise alertfleet / alertscope / alerttimeout. + + Returns (fields, error). fields holds only the keys present in data, with + null coerced to the column default. alertscope must be a JSON object (or a + string holding one) whose keys the endpoint matcher understands; empty or + null means the whole fleet. + """ + import json + + fields = {} + if 'alertfleet' in data: + value = data['alertfleet'] + if value is None: + value = False + if not isinstance(value, bool): + return None, 'alertfleet must be true or false' + fields['alertfleet'] = value + + if 'alerttimeout' in data: + value = data['alerttimeout'] + if value is None or value == '': + value = 0 + if isinstance(value, bool): + return None, 'alerttimeout must be a whole number of seconds' + try: + value = int(value) + except (TypeError, ValueError): + return None, 'alerttimeout must be a whole number of seconds' + if value < 0: + return None, 'alerttimeout must not be negative' + fields['alerttimeout'] = value + + if 'alertscope' in data: + scope = data['alertscope'] + if isinstance(scope, str): + if not scope.strip(): + scope = None + else: + try: + scope = json.loads(scope) + except ValueError: + return None, 'alertscope must be a JSON object' + if scope is not None: + if not isinstance(scope, dict): + return None, 'alertscope must be a JSON object' + unknown = sorted(set(scope) - ALERT_SCOPE_KEYS) + if unknown: + return None, (f'alertscope has unknown key(s): {", ".join(unknown)}. ' + f'Allowed: {", ".join(sorted(ALERT_SCOPE_KEYS))}') + for key, wanted in scope.items(): + values = wanted if isinstance(wanted, list) else [wanted] + if any(v is not None and not isinstance(v, (str, int, float)) + or isinstance(v, bool) for v in values): + return None, (f'alertscope.{key} must be a string or a list ' + f'of strings') + if not scope: + scope = None + fields['alertscope'] = scope + + return fields, None + + @notifications_bp.route('', methods=['POST']) @require_permission('notifications.create') def create_notification(): @@ -419,6 +482,10 @@ def create_notification(): if not notification_text: return error_response(ErrorCodes.VALIDATION_ERROR, 'notification/message is required') + alert_fields, alert_error = _clean_alert_fields(data) + if alert_error: + return error_response(ErrorCodes.VALIDATION_ERROR, alert_error) + # Parse dates starttime = datetime.now(timezone.utc).replace(tzinfo=None) if data.get('starttime') or data.get('startdate'): @@ -456,9 +523,9 @@ def create_notification(): isshopfloor=data.get('isshopfloor', False), employeesso=data.get('employeesso'), employeename=data.get('employeename'), - alertfleet=data.get('alertfleet', False), - alertscope=data.get('alertscope'), - alerttimeout=data.get('alerttimeout', 0) + alertfleet=alert_fields.get('alertfleet', False), + alertscope=alert_fields.get('alertscope'), + alerttimeout=alert_fields.get('alerttimeout', 0) ) db.session.add(n) @@ -507,12 +574,12 @@ def update_notification(notification_id: int): n.employeesso = data['employeesso'] if 'employeename' in data: n.employeename = data['employeename'] - if 'alertfleet' in data: - n.alertfleet = data['alertfleet'] - if 'alertscope' in data: - n.alertscope = data['alertscope'] - if 'alerttimeout' in data: - n.alerttimeout = data['alerttimeout'] + alert_fields, alert_error = _clean_alert_fields(data) + if alert_error: + db.session.rollback() + return error_response(ErrorCodes.VALIDATION_ERROR, alert_error) + for field, value in alert_fields.items(): + setattr(n, field, value) # Parse and update dates if 'starttime' in data or 'startdate' in data: diff --git a/plugins/notifications/models/notification.py b/plugins/notifications/models/notification.py index 9142ec88..d0d30dba 100644 --- a/plugins/notifications/models/notification.py +++ b/plugins/notifications/models/notification.py @@ -7,6 +7,12 @@ from shopdb.api import db _DEFAULT_TZ = 'America/New_York' +# Keys a fleet alert's alertscope may use. Must stay equal to the set the +# endpoints matcher (_ALERT_SCOPE_KEYS in plugins/endpoints/api/routes.py) +# understands; a test pins the two together. +ALERT_SCOPE_KEYS = frozenset({'hostname', 'assettype', 'computertype', + 'location', 'businessunit'}) + def _site_zone(): """Site-configured IANA zone (settings key site_timezone) for calendar day @@ -146,9 +152,12 @@ class Notification(db.Model): # ~11 people are listed. employeesso = db.Column(db.Text, nullable=True) employeename = db.Column(db.Text, nullable=True) - alertfleet = db.Column(db.Boolean, default=False) + # Matches migration 0006: NOT NULL with server defaults. + alertfleet = db.Column(db.Boolean, nullable=False, default=False, + server_default=db.false()) alertscope = db.Column(db.JSON, nullable=True) - alerttimeout = db.Column(db.Integer, nullable=True) + alerttimeout = db.Column(db.Integer, nullable=False, default=0, + server_default='0') # Relationships notificationtype = db.relationship('NotificationType', backref='notifications') diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py index 100c2fed..ae58200a 100644 --- a/plugins/printers/plugin.py +++ b/plugins/printers/plugin.py @@ -7,7 +7,7 @@ import re from typing import List, Dict, Optional, Type -from flask import Flask, Blueprint +from flask import Flask, Blueprint, g import click from shopdb.plugins.base import BasePlugin, PluginMeta @@ -439,6 +439,7 @@ class PrintersPlugin(BasePlugin): """ return { 'identityfield': 'hostname', + 'acceptsagentcredential': True, 'fields': { 'type': 'object', 'required': ['hostname', 'queues'], @@ -551,6 +552,11 @@ class PrintersPlugin(BasePlugin): # the read paths, and hostname stays the identity that the replace # keys on, so an unresolved host still records everything it reported. assetid = self._observed_assetid(hostname, warnings) + # A per-PC credential reports for its own PC only. + if (getattr(g, 'agentauth', None) == 'credential' + and assetid != getattr(g, 'agentassetid', None)): + raise PermissionError( + 'The payload names a different endpoint than the credential') # Case-folded on both sides: one script sends COMPUTERNAME uppercase # and another the lowercase FQDN, and MySQL forgives that while SQLite diff --git a/run_dev_sqlite.py b/run_dev_sqlite.py deleted file mode 100644 index 8dad99b8..00000000 --- a/run_dev_sqlite.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python -"""Development server with SQLite support (patches engine options).""" -import os -import sys - -# Set environment for development with SQLite -os.environ['FLASK_ENV'] = 'development' -os.environ['DATABASE_URL'] = f"sqlite:///{os.path.abspath('instance/shopdb.db')}" - -# Import and patch config before app creation -from shopdb.config import DevelopmentConfig -from sqlalchemy.pool import StaticPool - -# Override engine options for SQLite (no charset parameter) -DevelopmentConfig.SQLALCHEMY_ENGINE_OPTIONS = { - 'connect_args': {'check_same_thread': False}, - 'poolclass': StaticPool, -} - -# Now import and run the app -from shopdb import create_app - -app = create_app('development') - -if __name__ == '__main__': - app.run(debug=True, host='127.0.0.1', port=5000) diff --git a/scripts/backfill_app_fingerprints.py b/scripts/backfill_app_fingerprints.py index 5d34af18..5faf05a2 100644 --- a/scripts/backfill_app_fingerprints.py +++ b/scripts/backfill_app_fingerprints.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Backfill app_fingerprint column for existing EndpointApplication records. +"""Backfill appfingerprint column for existing EndpointApplication records. Run this ONCE after deploying the 0020_app_delta_fingerprints migration and before deploying the updated C# agent that sends fingerprints. This populates @@ -20,6 +20,13 @@ from pathlib import Path # Add project root to Python path sys.path.insert(0, str(Path(__file__).parent.parent)) +from dotenv import load_dotenv + +# Load .env before anything reads config: a plain `python scripts/...` run +# otherwise falls back to the hardcoded root@localhost URL and could write the +# wrong database. +load_dotenv(Path(__file__).resolve().parent.parent / '.env') + from shopdb.extensions import db from plugins.endpoints.models import EndpointApplication @@ -50,103 +57,122 @@ def compute_fingerprint(displayname: str, displayversion: str, publisher: str) - def verify_fingerprint_algorithm(): """Verify backend fingerprint algorithm matches expected behavior. - Uses test vectors to ensure consistency with agent implementation. - Any mismatch indicates algorithm drift and will cause delta logic to fail. + Fixed test vectors: SHA256 of the literal 'name|version|publisher' strings, + which is what the agent's ComputeApplicationFingerprint hashes. Any + mismatch means compute_fingerprint drifted from the agent, which would make + the delta logic delete and re-add every app on every check-in. """ print("Verifying fingerprint algorithm...") - # Test vectors: known inputs and their expected SHA256 outputs - # These verify the algorithm matches the C# agent implementation test_cases = [ - { - 'name': 'Google Chrome', - 'version': '117.0.5938.132', - 'publisher': 'Google LLC', - # SHA256("Google Chrome|117.0.5938.132|Google LLC") - 'expected_fp': None # Prints computed value for verification - }, - { - 'name': 'Microsoft Office', - 'version': '16.0.16827.20166', - 'publisher': 'Microsoft Corporation', - # SHA256("Microsoft Office|16.0.16827.20166|Microsoft Corporation") - 'expected_fp': None # Prints computed value for verification - }, - { - 'name': 'Test App No Version', - 'version': None, - 'publisher': 'Test Publisher', - # SHA256("Test App No Version||Test Publisher") - 'expected_fp': None # Prints computed value for verification - }, - { - 'name': 'Test App Empty Strings', - 'version': '', - 'publisher': '', - # SHA256("Test App Empty Strings||") - 'expected_fp': None # Prints computed value for verification - }, + ('Google Chrome', '117.0.5938.132', 'Google LLC', + 'a22de9ae7e641954872208b21aa52ac948898b78bcc8d7de5746af3a324f49ae'), + ('Microsoft Office', '16.0.16827.20166', 'Microsoft Corporation', + 'a180bfd16f34c52c94d01317044f7d3112bd9b88c6be570ee5cca4eff9199ae4'), + ('Test App No Version', None, 'Test Publisher', + 'aae1e046f90b39da80b2356713d8e6dc8579746d378ce7bc515800a4fbb85cda'), + ('Test App Empty Strings', '', '', + '1a29cd2e3bd856ac3143d2675376b92eedf1563f5e8eb78f235d252943580ceb'), ] - all_passed = True - for tc in test_cases: - computed = compute_fingerprint(tc['name'], tc['version'], tc['publisher']) - - # Validate format - if len(computed) != 64 or not all(c in '0123456789abcdef' for c in computed): - print(f" FAIL INVALID FORMAT: {tc['name']}") - print(f" Got: {computed}") - all_passed = False - continue - - # Check against expected (if we have one) - if tc['expected_fp']: - if computed == tc['expected_fp']: - print(f" OK {tc['name']}: {computed[:16]}...") - else: - print(f" FAIL MISMATCH: {tc['name']}") - print(f" Expected: {tc['expected_fp']}") - print(f" Got: {computed}") - all_passed = False + failures = [] + for name, version, publisher, expected in test_cases: + computed = compute_fingerprint(name, version, publisher) + if computed == expected: + print(f" OK {name}: {computed[:16]}...") else: - # First run - show computed value for test vector creation - print(f" ? {tc['name']}: {computed[:16]}... (computed, no expected value)") + print(f" FAIL MISMATCH: {name}") + print(f" Expected: {expected}") + print(f" Got: {computed}") + failures.append(name) - if not all_passed: + if failures: raise ValueError( - "Fingerprint algorithm verification FAILED! " - "Backend algorithm does not match expected behavior. " - "This will cause delta logic to delete+re-add all apps on every check-in." + "Fingerprint algorithm verification FAILED for: " + + ", ".join(failures) + + ". Backend algorithm does not match the agent; the delta logic " + "would delete+re-add all apps on every check-in." ) print("OK Fingerprint algorithm verified\n") + return True + + +def _fingerprint_batch(apps): + """Assign fingerprints to one batch, removing duplicate (assetid, fingerprint) rows. + + The unique index on (assetid, appfingerprint) means two rows for the same + install on one asset cannot both carry the fingerprint. Of each duplicate + set the lowest endpointapplicationid is kept (fingerprinted rows already in + the table included) and the rest are deleted. + + Returns the number of duplicate rows deleted. + """ + duplicates = 0 + claimed = {} + for app in sorted(apps, key=lambda row: row.endpointapplicationid): + fingerprint = compute_fingerprint(app.displayname, app.displayversion, + app.publisher) + key = (app.assetid, fingerprint) + + holder = claimed.get(key) + if holder is None: + holder = (EndpointApplication.query + .filter(EndpointApplication.assetid == app.assetid, + EndpointApplication.appfingerprint == fingerprint, + EndpointApplication.endpointapplicationid + != app.endpointapplicationid) + .first()) + + if holder is None: + app.appfingerprint = fingerprint + claimed[key] = app + elif holder.endpointapplicationid < app.endpointapplicationid: + db.session.delete(app) + duplicates += 1 + else: + # An already-fingerprinted row with a HIGHER id: drop it and let the + # older row carry the fingerprint. Flush the delete first so the + # unique index is free before the update lands. + db.session.delete(holder) + db.session.flush() + duplicates += 1 + app.appfingerprint = fingerprint + claimed[key] = app + return duplicates def backfill_fingerprints(batch_size: int = 1000, dry_run: bool = False): """Backfill fingerprints for existing EndpointApplication records. + Each batch commits on its own. A batch that fails is rolled back and the run + stops, so nothing half-applied is left in the session. + Args: batch_size: Number of records to update per commit dry_run: If True, show what would be done without making changes + + Returns: + dict with processed, duplicatesremoved and failed counts. """ - # SAFETY: Verify algorithm before touching database verify_fingerprint_algorithm() - # Count total records needing backfill + summary = {'processed': 0, 'duplicatesremoved': 0, 'failed': 0} + total = EndpointApplication.query.filter( - EndpointApplication.app_fingerprint.is_(None) + EndpointApplication.appfingerprint.is_(None) ).count() if total == 0: print("No records need backfilling (all have fingerprints).") - return + return summary print(f"Found {total:,} records without fingerprints") if dry_run: print("\nDRY RUN MODE - showing sample records:") sample = EndpointApplication.query.filter( - EndpointApplication.app_fingerprint.is_(None) + EndpointApplication.appfingerprint.is_(None) ).limit(5).all() for app in sample: @@ -154,49 +180,54 @@ def backfill_fingerprints(batch_size: int = 1000, dry_run: bool = False): print(f" {app.displayname:50s} -> {fp[:16]}...") print(f"\nWould backfill {total:,} records in batches of {batch_size}") - return + return summary print(f"Backfilling in batches of {batch_size}...") - processed = 0 batch = 0 - while True: - # Fetch next batch apps = (EndpointApplication.query - .filter(EndpointApplication.app_fingerprint.is_(None)) + .filter(EndpointApplication.appfingerprint.is_(None)) + .order_by(EndpointApplication.endpointapplicationid) .limit(batch_size) .all()) if not apps: - break # No more records - - # Update fingerprints - for app in apps: - app.app_fingerprint = compute_fingerprint( - app.displayname, - app.displayversion, - app.publisher - ) - - # Commit batch - db.session.commit() + break batch += 1 - processed += len(apps) - print(f" Batch {batch}: {processed:,} / {total:,} ({processed*100//total}%)") + try: + duplicates = _fingerprint_batch(apps) + db.session.commit() + except Exception as error: + db.session.rollback() + summary['failed'] = len(apps) + print(f" Batch {batch} FAILED and was rolled back: {error}") + break + + summary['processed'] += len(apps) + summary['duplicatesremoved'] += duplicates + print(f" Batch {batch}: {summary['processed']:,} / {total:,} " + f"({summary['processed'] * 100 // total}%), " + f"{duplicates} duplicate(s) removed") + + print(f"\nBackfill finished. Processed {summary['processed']:,} records; " + f"removed {summary['duplicatesremoved']:,} duplicate row(s).") + if summary['failed']: + print("A batch failed; fix the error above and re-run (completed batches " + "are kept, the run resumes from the remaining null rows).") + return summary - print(f"\nBackfill complete! Updated {processed:,} records.") print("\nNext steps:") print("1. Deploy updated C# agent that computes and sends fingerprints") print("2. Monitor collector warnings for agents without fingerprint support") - print("3. After all agents updated, consider making app_fingerprint NOT NULL") - + print("3. After all agents updated, consider making appfingerprint NOT NULL") + return summary if __name__ == '__main__': import argparse - parser = argparse.ArgumentParser(description='Backfill app_fingerprint column') + parser = argparse.ArgumentParser(description='Backfill appfingerprint column') parser.add_argument('--dry-run', action='store_true', help='Show what would be done without making changes') parser.add_argument('--batch-size', type=int, default=1000, @@ -209,10 +240,12 @@ if __name__ == '__main__': from shopdb import create_app app = create_app() with app.app_context(): - backfill_fingerprints( + result = backfill_fingerprints( batch_size=args.batch_size, dry_run=args.dry_run ) except Exception as e: print(f"Error: {e}") sys.exit(1) + if result['failed']: + sys.exit(1) diff --git a/scripts/check-naming-and-style.sh b/scripts/check-naming-and-style.sh index 08144b86..0ecaeea4 100755 --- a/scripts/check-naming-and-style.sh +++ b/scripts/check-naming-and-style.sh @@ -25,7 +25,9 @@ USE_GIT_LS_FILES=${USE_GIT_LS_FILES:-1} if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then # Filter out paths we don't want to scan even if tracked - GIT_FILES_FILTER="grep -v '^migrations/versions/' | grep -v '^frontend/dist/' | grep -v '^staticdocs/' | grep -v '\.plugins-staged/'" + # NUL-delimited end to end (git ls-files -z | grep -z | xargs -0) so a path + # with a space or newline is scanned as one file, not split into several. + GIT_FILES_FILTER="grep -zv '^migrations/versions/' | grep -zv '^frontend/dist/' | grep -zv '^staticdocs/' | grep -zv '\.plugins-staged/'" else # Fallback to recursive grep with excludes for non-git directories GIT_FILES_FILTER="cat" @@ -113,7 +115,9 @@ drop_scratch() { grep -v '^\./devnotes/' || true; } # lists it sat beside. drop_generated() { # Match with or without leading ./ for git ls-files compatibility - grep -vP '^(\./)?(.*/)?(findings\.sarif|report\.md|scan\.log|\.env|.*\.standalone\.js|.*\.min\.js):' || true + # [^:]* rather than .*: on a long minified hit line the greedy form blows + # PCRE's backtracking limit, and grep then errors instead of filtering. + grep -vP '^(\./)?([^:]*/)?(findings\.sarif|report\.md|scan\.log|\.env|[^:/]*\.standalone\.js|[^:/]*\.min\.js):' || true } # CONTRIBUTING.md documents the conventions by SHOWING violations as examples, @@ -153,10 +157,10 @@ echo "==> Checking for non-ASCII characters..." # wj wrote this as --binary-files=without-match, which is the same behaviour # spelled long-hand; -I is kept for consistency with the rest of the file. if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then - NON_ASCII=$(git ls-files \ + NON_ASCII=$(git ls-files -z \ '*.py' '*.vue' '*.js' '*.ts' '*.json' '*.md' '*.yaml' '*.yml' \ | eval "$GIT_FILES_FILTER" \ - | xargs -r grep -PnI '[^\x00-\x7F]' 2>/dev/null \ + | xargs -0 -r grep -PnI '[^\x00-\x7F]' 2>/dev/null \ | drop_scratch | drop_generated || true) else NON_ASCII=$(grep -rPnI '[^\x00-\x7F]' "${EXCLUDES[@]}" "${INCLUDES_ALL[@]}" . 2>/dev/null | drop_scratch | drop_generated || true) @@ -177,11 +181,14 @@ echo "==> Checking for banned shorthand (standalone)..." # Exclude C# client code (.cs files) - they're not Python and have different conventions for word in cfg ctx mgr req res; do if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then - HITS=$(git ls-files 'shopdb/**/*.py' 'shopdb/**/*.vue' 'shopdb/**/*.js' 'shopdb/**/*.ts' \ - 'plugins/**/*.py' 'plugins/**/*.vue' 'plugins/**/*.js' 'plugins/**/*.ts' \ - 'frontend/src/**/*.py' 'frontend/src/**/*.vue' 'frontend/src/**/*.js' 'frontend/src/**/*.ts' \ - | grep -v '/tests\?/' | grep -v '\.spec\.js' | grep -v '\.plugins-staged' \ - | xargs -r grep -Pn "(^|[^a-zA-Z0-9_])${word}([^a-zA-Z0-9_]|\$)" 2>/dev/null \ + # :(glob) is required: without it git matches pathspecs with fnmatch, + # where 'shopdb/**/*.py' needs a second slash and silently skips + # top-level files such as shopdb/__init__.py and frontend/src/main.js. + HITS=$(git ls-files -z ':(glob)shopdb/**/*.py' ':(glob)shopdb/**/*.vue' ':(glob)shopdb/**/*.js' ':(glob)shopdb/**/*.ts' \ + ':(glob)plugins/**/*.py' ':(glob)plugins/**/*.vue' ':(glob)plugins/**/*.js' ':(glob)plugins/**/*.ts' \ + ':(glob)frontend/src/**/*.py' ':(glob)frontend/src/**/*.vue' ':(glob)frontend/src/**/*.js' ':(glob)frontend/src/**/*.ts' \ + | grep -zv '/tests\?/' | grep -zv '\.spec\.js' | grep -zv '\.plugins-staged' \ + | xargs -0 -r grep -Pn "(^|[^a-zA-Z0-9_])${word}([^a-zA-Z0-9_]|\$)" 2>/dev/null \ | grep -v '\.cs:' \ | grep -vP "(^|[^a-zA-Z0-9_])(request_obj|response_obj)" \ | drop_scratch | drop_generated || true) @@ -209,8 +216,8 @@ done echo "==> Checking for snake_case DB tablenames..." if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then - SNAKE_TABLES=$(git ls-files '*.py' | eval "$GIT_FILES_FILTER" \ - | xargs -r grep -Pn "__tablename__\s*=\s*['\"][^'\"]*_" 2>/dev/null \ + SNAKE_TABLES=$(git ls-files -z '*.py' | eval "$GIT_FILES_FILTER" \ + | xargs -0 -r grep -Pn "__tablename__\s*=\s*['\"][^'\"]*_" 2>/dev/null \ | drop_scratch | drop_contributing || true) else SNAKE_TABLES=$(grep -rPn "__tablename__\s*=\s*['\"][^'\"]*_" "${EXCLUDES[@]}" \ @@ -225,8 +232,8 @@ fi echo "==> Checking for snake_case DB column attrs..." if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then - SNAKE_COLS=$(git ls-files '*.py' | eval "$GIT_FILES_FILTER" \ - | xargs -r grep -Pn "^\s+[a-z]+_[a-z_]+\s*=\s*db\.Column" 2>/dev/null \ + SNAKE_COLS=$(git ls-files -z '*.py' | eval "$GIT_FILES_FILTER" \ + | xargs -0 -r grep -Pn "^\s+[a-z]+_[a-z_]+\s*=\s*db\.Column" 2>/dev/null \ | drop_scratch | drop_contributing || true) else SNAKE_COLS=$(grep -rPn "^\s+[a-z]+_[a-z_]+\s*=\s*db\.Column" "${EXCLUDES[@]}" \ @@ -241,8 +248,8 @@ fi echo "==> Checking for snake_case ForeignKey targets..." if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then - SNAKE_FK=$(git ls-files '*.py' | eval "$GIT_FILES_FILTER" \ - | xargs -r grep -Pn "ForeignKey\(['\"][^'\"]*_[^'\"]*['\"]" 2>/dev/null \ + SNAKE_FK=$(git ls-files -z '*.py' | eval "$GIT_FILES_FILTER" \ + | xargs -0 -r grep -Pn "ForeignKey\(['\"][^'\"]*_[^'\"]*['\"]" 2>/dev/null \ | drop_scratch || true) else SNAKE_FK=$(grep -rPn "ForeignKey\(['\"][^'\"]*_[^'\"]*['\"]" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null | drop_scratch || true) @@ -256,9 +263,9 @@ fi echo "==> Checking for snake_case API params in frontend (DB-mirrored fields)..." if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then - SNAKE_FE=$(git ls-files '*.vue' '*.js' '*.ts' | eval "$GIT_FILES_FILTER" \ - | grep -v 'DEVELOPMENT-STANDARDS.md' \ - | xargs -r grep -Pn "params\.(machine_id|location_id|vendor_id|type_id|business_unit_id|model_id|status_id|operating_system_id|asset_id|user_id|is_active|is_shopfloor)" 2>/dev/null \ + SNAKE_FE=$(git ls-files -z '*.vue' '*.js' '*.ts' | eval "$GIT_FILES_FILTER" \ + | grep -zv 'DEVELOPMENT-STANDARDS.md' \ + | xargs -0 -r grep -Pn "params\.(machine_id|location_id|vendor_id|type_id|business_unit_id|model_id|status_id|operating_system_id|asset_id|user_id|is_active|is_shopfloor)" 2>/dev/null \ | drop_scratch || true) else SNAKE_FE=$(grep -rPn "params\.(machine_id|location_id|vendor_id|type_id|business_unit_id|model_id|status_id|operating_system_id|asset_id|user_id|is_active|is_shopfloor)" "${EXCLUDES[@]}" \ @@ -278,8 +285,8 @@ fi echo "==> Checking for cross-plugin / escaping imports in plugin frontends..." if [ -d plugins ]; then if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then - PLUGIN_FE_IMPORTS=$(git ls-files 'plugins/*/frontend/**/*.vue' 'plugins/*/frontend/**/*.js' \ - | xargs -r grep -Pn "(import|from)\s+['\"]([^'\"]*\.\./\.\./|[^'\"]*/plugins/)" 2>/dev/null || true) + PLUGIN_FE_IMPORTS=$(git ls-files -z ':(glob)plugins/*/frontend/**/*.vue' ':(glob)plugins/*/frontend/**/*.js' \ + | xargs -0 -r grep -Pn "(import|from)\s+['\"]([^'\"]*\.\./\.\./|[^'\"]*/plugins/)" 2>/dev/null || true) else PLUGIN_FE_IMPORTS=$(grep -rPn "(import|from)\s+['\"]([^'\"]*\.\./\.\./|[^'\"]*/plugins/)" \ --include='*.vue' --include='*.js' plugins/*/frontend/ 2>/dev/null || true) @@ -305,9 +312,9 @@ fi # passes what is specific to its label and nothing else. echo "==> Checking that only the shared module imports the code libraries..." if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then - CODE_LIB_IMPORTS=$(git ls-files 'frontend/src/**/*.vue' 'frontend/src/**/*.js' 'plugins/**/*.vue' 'plugins/**/*.js' \ - | grep -v '\.plugins-staged/' \ - | xargs -r grep -n "from ['\"]\(qrcode\|jsbarcode\)['\"]" 2>/dev/null \ + CODE_LIB_IMPORTS=$(git ls-files -z ':(glob)frontend/src/**/*.vue' ':(glob)frontend/src/**/*.js' ':(glob)plugins/**/*.vue' ':(glob)plugins/**/*.js' \ + | grep -zv '\.plugins-staged/' \ + | xargs -0 -r grep -n "from ['\"]\(qrcode\|jsbarcode\)['\"]" 2>/dev/null \ | grep -v 'frontend/src/utils/codes\.js' || true) else CODE_LIB_IMPORTS=$(grep -rn "from ['\"]\(qrcode\|jsbarcode\)['\"]" \ diff --git a/scripts/generate-agent-cert.ps1 b/scripts/generate-agent-cert.ps1 deleted file mode 100644 index 77dbaa9c..00000000 --- a/scripts/generate-agent-cert.ps1 +++ /dev/null @@ -1,133 +0,0 @@ -param( - [Parameter(Mandatory=$false)] - [string]$Hostname = $env:COMPUTERNAME, - - [Parameter(Mandatory=$false)] - [int]$ValidityYears = 2 -) - -# SECURITY: Validate hostname matches actual machine name to prevent cert for wrong host -$actualHostname = [System.Net.Dns]::GetHostName() -if ($Hostname -ne $actualHostname) { - Write-Warning "Hostname mismatch detected!" - Write-Warning " Requested: $Hostname" - Write-Warning " Actual machine: $actualHostname" - Write-Warning "Using actual hostname to prevent certificate mismatch." - $Hostname = $actualHostname -} - -# Generate self-signed cert for this endpoint -$cert = New-SelfSignedCertificate ` - -Subject "CN=$Hostname" ` - -DnsName $Hostname ` - -CertStoreLocation "Cert:\LocalMachine\My" ` - -KeyExportPolicy NonExportable ` - -KeyUsage DigitalSignature, KeyEncipherment ` - -NotAfter (Get-Date).AddYears($ValidityYears) ` - -HashAlgorithm SHA256 - -# SECURITY WARNING: Grant Users group read access to the private key so the tray -# (running as interactive user) can use it when shelling out to shopdbagent.exe --once. -# The service already works because it runs as SYSTEM. -# -# RISK: This grants ALL interactive users on the machine read access to the private -# key, which means any user with console/RDP access can extract the key and impersonate -# this endpoint. Only acceptable in environments where ALL interactive users are trusted. -# -# MITIGATION OPTIONS: -# 1. Best: Remove tray dependency on cert (tray calls service API, service makes HTTP) -# 2. Acceptable: Grant only to specific user account running the tray -# 3. Document: Only deploy in environments where all console users are trusted operators -try { - $rsaKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert) - $keyPath = $rsaKey.Key.UniqueName - - # CNG keys are in SystemKeys, not MachineKeys - $keyFile = Join-Path "$env:ProgramData\Microsoft\Crypto\SystemKeys" $keyPath - if (-not (Test-Path $keyFile)) { - $keyFile = Join-Path "$env:ProgramData\Microsoft\Crypto\Keys" $keyPath - } - if (-not (Test-Path $keyFile)) { - $keyFile = Join-Path "$env:ProgramData\Microsoft\Crypto\RSA\MachineKeys" $keyPath - } - - if (Test-Path $keyFile) { - # Grant Read + Execute to BUILTIN\Users - $acl = Get-Acl $keyFile - $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( - "BUILTIN\Users", "Read,ReadAndExecute", "Allow") - $acl.AddAccessRule($rule) - Set-Acl -Path $keyFile -AclObject $acl - Write-Host " Granted Users group access to private key" - } else { - Write-Host " WARNING: Could not find private key file to set permissions" - } -} catch { - Write-Host " WARNING: Could not set private key permissions: $($_.Exception.Message)" -} - -Write-Host "Generated certificate:" -Write-Host " Thumbprint: $($cert.Thumbprint)" -Write-Host " Subject: $($cert.Subject)" -Write-Host " Expires: $($cert.NotAfter)" - -# Compute SHA1 fingerprint to match nginx $ssl_client_fingerprint variable -# SECURITY NOTE: Nginx only provides SHA1 via $ssl_client_fingerprint variable. -# SHA1 is deprecated for cryptographic signing but remains acceptable for certificate -# fingerprinting in air-gapped environments with: -# - Short-lived certificates (2 years max) -# - Certificate revocation support (cert_revoked_at column) -# - No external CA trust (self-signed only) -# Future migration to SHA256 requires: nginx module for SHA256, or IIS deployment -# which exposes full PEM cert for server-side hashing. -$certBytes = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert) -$sha1 = [System.Security.Cryptography.SHA1]::Create() -$fingerprintBytes = $sha1.ComputeHash($certBytes) -$fingerprint = ($fingerprintBytes | ForEach-Object { $_.ToString("x2") }) -join "" - -Write-Host "" -Write-Host "=========================================" -Write-Host "CERTIFICATE GENERATED SUCCESSFULLY" -Write-Host "=========================================" -Write-Host "Fingerprint (SHA1): $fingerprint" -Write-Host "Length: 40 hex chars (nginx limitation)" -Write-Host "" -Write-Host "Register this certificate in ShopDB database:" -Write-Host "" -Write-Host " UPDATE assets" -Write-Host " SET client_cert_fingerprint = '$fingerprint'," -Write-Host " cert_issued_at = CURRENT_TIMESTAMP," -Write-Host " cert_expires_at = '$($cert.NotAfter.ToString("yyyy-MM-dd HH:mm:ss"))'," -Write-Host " cert_revoked_at = NULL," -Write-Host " cert_revocation_reason = NULL" -Write-Host " WHERE assetnumber = '(REPLACE_WITH_ASSET_NUMBER)';" -Write-Host "" -Write-Host "To revoke this certificate later (compromised/lost endpoint):" -Write-Host "" -Write-Host " UPDATE assets" -Write-Host " SET cert_revoked_at = CURRENT_TIMESTAMP," -Write-Host " cert_revocation_reason = 'Compromised - TICKET-123'" -Write-Host " WHERE client_cert_fingerprint = '$fingerprint';" -Write-Host "" -Write-Host "=========================================" - -# Update agent config -$configPath = "C:\ProgramData\ShopDB\config.json" -if (Test-Path $configPath) { - $config = Get-Content $configPath | ConvertFrom-Json - $config | Add-Member -NotePropertyName clientCertThumbprint -NotePropertyValue $cert.Thumbprint -Force - $config | ConvertTo-Json | Set-Content $configPath - Write-Host "" - Write-Host "Updated $configPath with thumbprint" -} else { - Write-Host "" - Write-Host "WARNING: $configPath not found - agent may not be configured" -} - -# Output for automation -Write-Host "" -Write-Host "---MACHINE-READABLE---" -Write-Host "THUMBPRINT=$($cert.Thumbprint)" -Write-Host "FINGERPRINT=$fingerprint" -Write-Host "HOSTNAME=$Hostname" -Write-Host "EXPIRES=$($cert.NotAfter.ToString('yyyy-MM-dd HH:mm:ss'))" diff --git a/shopdb/__init__.py b/shopdb/__init__.py index a499270c..4800ed4c 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -121,7 +121,16 @@ from .plugins import plugin_manager # tokens are always refused by it, matching require_role, so a plugin cannot # accidentally let a scoped token inherit role authority from its owner. # Additive name on the surface, minor bump. -__contract_version__ = '0.32.0' +# 0.33.0 adds per-PC agent credentials to the collector contract. BasePlugin +# gains two optional hooks, resolve_agent_credential(secret) and +# agent_shared_key_refused(hostname), and shopdb.api exports +# resolve_agent_credential, agent_shared_key_refused and collector_ingest. A +# collector schema may declare "acceptsagentcredential": true; the generic +# collector then accepts X-Endpoint-Credential for it, refuses the shared key +# for an enrolled PC (401 SHARED_KEY_REFUSED), and maps a PermissionError from +# apply_collector_payload to 403 ASSET_MISMATCH. All optional, so a plugin that +# declares none of it is unaffected. Additive, minor bump. +__contract_version__ = '0.33.0' # Product release version (see ADR-007). The product version and the # plugin-contract version above are distinct series with independent @@ -210,10 +219,12 @@ def create_app(config_name: str = None) -> Flask: # Initialize plugin manager with app.app_context(): - # On first run (no users exist), log the setup token so the operator can - # create the first admin. The token gates POST /api/setup/create-admin to - # prevent a network race during the install window (API starts serving - # before the admin exists in every documented deploy path). + # On first run (no users exist), tell the operator a setup token is + # active and how to read it. The token gates POST /api/setup/create-admin + # to prevent a network race during the install window (API starts + # serving before the admin exists in every documented deploy path). The + # secret itself is never logged: logs are shipped and retained far more + # widely than the .env it came from. from shopdb.core.models import User try: if User.query.count() == 0: @@ -221,7 +232,8 @@ def create_app(config_name: str = None) -> Flask: if token: app.logger.warning('=' * 60) app.logger.warning('FIRST RUN: No users exist.') - app.logger.warning('Setup token: %s', token) + app.logger.warning('A setup token is active (SETUP_TOKEN). ' + 'Read it with: flask seed show-setup-token') app.logger.warning('Create admin: POST /api/setup/create-admin') app.logger.warning(' with header: X-Setup-Token: ') app.logger.warning('OR use CLI: docker exec shopdb-api flask seed admin') diff --git a/shopdb/api/__init__.py b/shopdb/api/__init__.py index 88abaade..f32ae3d3 100644 --- a/shopdb/api/__init__.py +++ b/shopdb/api/__init__.py @@ -100,6 +100,49 @@ def collector_authorized(pluginname: str): return _check_collector_auth(_plugin_api_key(pluginname), pluginname) +def _enabled_plugins(): + from flask import current_app + + manager = current_app.extensions.get('plugin_manager') + if not manager: + return [] + return [plugin for name, plugin in manager.get_all_plugins().items() + if manager.registry.is_enabled(name)] + + +def resolve_agent_credential(secret: str) -> Optional[int]: + """Asset id for an active per-PC agent credential, or None. + + Asks every enabled plugin's resolve_agent_credential hook and returns the + first non-None answer. + """ + if not secret: + return None + for plugin in _enabled_plugins(): + assetid = plugin.resolve_agent_credential(secret) + if assetid is not None: + return assetid + return None + + +def agent_shared_key_refused(hostname: str) -> bool: + """True when any enabled plugin refuses the shared collector key for hostname.""" + return any(plugin.agent_shared_key_refused(hostname) + for plugin in _enabled_plugins()) + + +def collector_ingest(pluginname: str): + """Run the generic collector ingest for a plugin on the current request. + + Lets a plugin serve the same ingest under its own prefix (the endpoints + agent posts to /api/endpoints/collect) without importing core collector + internals. Auth, validation, audit and response are exactly those of + POST /api/collector/. + """ + from shopdb.core.api.collector import generic_collect + return generic_collect(pluginname) + + def application_package_location(): """Where application installers live, and the URL prefix that serves them. @@ -421,6 +464,9 @@ __all__ = [ 'list_service_tokens', 'revoke_service_tokens', 'collector_authorized', + 'resolve_agent_credential', + 'agent_shared_key_refused', + 'collector_ingest', 'application_package_location', 'script_body_location', 'SupportTeam', diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py index 43516389..47d749d3 100644 --- a/shopdb/cli/__init__.py +++ b/shopdb/cli/__init__.py @@ -205,8 +205,8 @@ def show_setup_token(): """Display the setup token for first-admin creation. The token gates POST /api/setup/create-admin so a network race cannot claim - the instance during the install window. Printed at first startup; retrieve - it here if the logs rotated or you lost the terminal. + the instance during the install window. Startup only logs that a token is + active (never the secret itself); this command is where it is read. """ import os from flask import current_app diff --git a/shopdb/core/api/applications.py b/shopdb/core/api/applications.py index e2289150..00cc01ea 100644 --- a/shopdb/core/api/applications.py +++ b/shopdb/core/api/applications.py @@ -60,8 +60,18 @@ def _requires_policy_ack(app, data): POLICY_ACK_MESSAGE = ( 'Marking this application as required on all PCs creates a configuration ' - 'policy that will install it on every non-server endpoint it matches. ' - 'Confirm to create the policy.' + 'policy that installs it on every non-server endpoint it matches, once that ' + 'policy is added to an active business policy. It is not added to one ' + 'automatically. Confirm to create the policy.' +) + +# Returned alongside policyresult='created'. The generated policy is not +# attached to any BusinessPolicy, and the resolver only applies policies reached +# through one, so saying "this will install everywhere" would be untrue. +POLICY_CREATED_NOTE = ( + 'A configuration policy was created for this application, but it is not ' + 'attached to any business policy and installs nothing until an admin adds ' + 'it to one.' ) # Deleting an application is irreversible and takes its versions and uploaded @@ -120,6 +130,24 @@ def _validate_monitorable(data, existing=None): return None +def _policy_payload(payload, policyresult): + """Attach the policy sync outcome (and the not-attached note) to a response.""" + if policyresult in ('created', 'removed', 'kept'): + payload['policyresult'] = policyresult + if policyresult == 'created': + payload['policynote'] = POLICY_CREATED_NOTE + return payload + + +def _deactivate_policies_for_app(app_id): + """Deactivate remaining policies that reference a deleted appid, if available.""" + try: + from plugins.computers.services.apppolicy import deactivate_policies_for_app + except ImportError: + return [] + return deactivate_policies_for_app(app_id) + + def _sync_required_app_policy(app): """Sync the auto-generated "required on all PCs" policy, if available. @@ -397,9 +425,7 @@ def create_application(): db.session.commit() - payload = app.to_dict() - if policyresult in ('created', 'removed', 'kept'): - payload['policyresult'] = policyresult + payload = _policy_payload(app.to_dict(), policyresult) return success_response(payload, message='Application created', http_code=201) @@ -439,6 +465,20 @@ def update_application(app_id: int): 'ismonitorable', 'monitorlaunchpath', 'monitorprocessname' ] + # Only touch policy when the checkbox itself moved. Syncing on every save + # would let an unrelated edit resurrect a policy an admin had deleted. + # + # Unticking is synced BEFORE any field is applied, so a PUT that renames and + # unticks in one go withdraws the policy that belongs to the app as it was, + # not one looked up under the new name. + policyresult = None + unticking = ('isrequired' in data and bool(app.isrequired) + and not data['isrequired']) + if unticking: + app.isrequired = False + policyresult = _sync_required_app_policy(app) + app.isrequired = True + changes = {} for key in fields: if key in data: @@ -452,18 +492,16 @@ def update_application(app_id: int): AuditLog.log('updated', 'Application', entityid=app.appid, entityname=app.appname, changes=changes) - # Only touch policy when the checkbox itself moved. Syncing on every save - # would let an unrelated edit resurrect a policy an admin had deleted. - policyresult = None - if 'isrequired' in changes: + if 'isrequired' in changes and not unticking: policyresult = _sync_required_app_policy(app) + elif 'appname' in changes and app.isrequired: + # A rename of a required app keeps its generated policy's name current. + _sync_required_app_policy(app) apply_import_timestamps(app, data) db.session.commit() - payload = app.to_dict() - if policyresult in ('created', 'removed', 'kept'): - payload['policyresult'] = policyresult + payload = _policy_payload(app.to_dict(), policyresult) return success_response(payload, message='Application updated') @@ -561,7 +599,13 @@ def delete_application(app_id: int): # application would keep installing something that no longer exists. appname = app.appname app.isrequired = False - _sync_required_app_policy(app) + policyresult = _sync_required_app_policy(app) + + # 'kept' means a customised generated policy survived the sync, and other + # hand-authored policies may name this appid too. Every one of them would + # keep trying to install an application that no longer exists, so they are + # deactivated (not deleted - they are someone's work) and reported. + deactivated = _deactivate_policies_for_app(app_id) _clear_application_references(app_id) removed = _remove_upload_files(app_id) @@ -570,12 +614,26 @@ def delete_application(app_id: int): # The audit row is the only record left that this application existed, so # it names the files that went with it. + auditchanges = {} + if removed: + auditchanges['removedfiles'] = removed + if deactivated: + auditchanges['deactivatedpolicies'] = deactivated AuditLog.log('deleted', 'Application', entityid=app_id, entityname=appname, - changes={'removedfiles': removed} if removed else None) + changes=auditchanges or None) db.session.commit() - return success_response(message='Application deleted') + payload = {} + if policyresult in ('removed', 'kept'): + payload['policyresult'] = policyresult + if deactivated: + payload['deactivatedpolicies'] = deactivated + message = 'Application deleted' + if deactivated: + message += ('; deactivated {} policy(ies) that referenced it: {}' + .format(len(deactivated), ', '.join(deactivated))) + return success_response(payload or None, message=message) REVERT_CONFIRM_MESSAGE = ( diff --git a/shopdb/core/api/collector.py b/shopdb/core/api/collector.py index af877f78..e6c5f5f0 100644 --- a/shopdb/core/api/collector.py +++ b/shopdb/core/api/collector.py @@ -13,7 +13,6 @@ from flask import Blueprint, request, current_app, g from shopdb.extensions import db from shopdb.core.models import Asset, Application from shopdb.core.models.apitoken import ApiToken, TOKEN_SECRET_PREFIX -from shopdb.core.auth.client_cert import authenticate_by_client_cert from shopdb.utils.responses import success_response, error_response, ErrorCodes @@ -24,6 +23,10 @@ collector_bp = Blueprint('collector', __name__) # identity - no require_permission on these routes). COLLECTOR_SCOPE = 'collector.ingest' +# Per-PC agent credential (contract 0.33.0). Accepted only by collector schemas +# declaring acceptsagentcredential. +AGENT_CREDENTIAL_HEADER = 'X-Endpoint-Credential' + def _computer_models(): """Lazily import the computers plugin models. @@ -242,21 +245,28 @@ def generic_collect(pluginname): plugin, schema = plugins[pluginname] - # Dual-auth: try client certificate first (per-endpoint mTLS), fall back to - # per-plugin env key or collector-scoped managed token. Header only - # (querystring fallback dropped, see require_api_key). - from shopdb.core.auth.client_cert import authenticate_by_client_cert - cert_asset = authenticate_by_client_cert() - if cert_asset: - g.cert_authenticated_asset_id = cert_asset.assetid - current_app.logger.debug( - '[collector] Authenticated via client cert for asset %d', - cert_asset.assetid) + # A schema that accepts agent credentials takes X-Endpoint-Credential and + # derives the calling asset from it. Otherwise: per-plugin env key or a + # collector-scoped managed token, header only (see require_api_key). + from shopdb.api import resolve_agent_credential, agent_shared_key_refused + + acceptscredential = bool(schema.get('acceptsagentcredential')) + g.agentauth = None + g.agentassetid = None + secret = (request.headers.get(AGENT_CREDENTIAL_HEADER) or '').strip() + if secret and acceptscredential: + assetid = resolve_agent_credential(secret) + if assetid is None: + return error_response('CREDENTIAL_INVALID', + 'Endpoint credential is unknown or revoked; ' + 'enroll again', http_code=401) + g.agentauth = 'credential' + g.agentassetid = assetid else: - # No cert or cert invalid: fall back to API key error = _check_collector_auth(_plugin_api_key(pluginname), pluginname) if error is not None: return error + g.agentauth = 'sharedkey' payload = request.get_json(silent=True) if not payload or not isinstance(payload, dict): @@ -268,6 +278,12 @@ def generic_collect(pluginname): return error_response(ErrorCodes.VALIDATION_ERROR, f'{identityfield} is required') + if (acceptscredential and g.agentauth == 'sharedkey' + and agent_shared_key_refused(identityvalue)): + return error_response('SHARED_KEY_REFUSED', + 'The shared key is not accepted for this ' + 'endpoint; use its credential', http_code=401) + try: outcome = plugin.apply_collector_payload(payload) except NotImplementedError: @@ -275,6 +291,12 @@ def generic_collect(pluginname): ErrorCodes.INTERNAL_ERROR, f'Plugin {pluginname} does not implement apply_collector_payload', http_code=500) + except PermissionError as exc: + # The payload names a different asset than the agent credential. + db.session.rollback() + return error_response('ASSET_MISMATCH', str(exc) or + 'The payload names a different endpoint than ' + 'the credential', http_code=403) except ValueError as exc: # ValueError is the plugin's controlled validation signal; its message # is safe to return to the caller. diff --git a/shopdb/core/api/plugins.py b/shopdb/core/api/plugins.py index e8b62b01..cde27666 100644 --- a/shopdb/core/api/plugins.py +++ b/shopdb/core/api/plugins.py @@ -61,10 +61,18 @@ def get_plugin_settings_routes(): # Only include routes from enabled plugins if not pm.registry.is_enabled(plugin_name): continue - if hasattr(plugin, 'get_settings_routes'): + if not hasattr(plugin, 'get_settings_routes'): + continue + # One broken plugin must not take every settings hub down with it. + try: plugin_routes = plugin.get_settings_routes() - if plugin_routes: - routes.extend(plugin_routes) + except Exception: + current_app.logger.exception( + 'get_settings_routes failed for plugin %s; skipping it', + plugin_name) + continue + if plugin_routes: + routes.extend(plugin_routes) return success_response({'routes': routes}) diff --git a/shopdb/core/auth/__init__.py b/shopdb/core/auth/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/shopdb/core/auth/client_cert.py b/shopdb/core/auth/client_cert.py deleted file mode 100644 index 48a65843..00000000 --- a/shopdb/core/auth/client_cert.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Client certificate authentication helpers. - -Extracts client certificate fingerprints from various deployment types: -- IIS 2019: SSL_CLIENT_CERT environ (PEM format) -- Nginx: X-Client-Cert-Fingerprint header (SHA1 hex, colon-separated) -- IIS with ARR: X-ARR-ClientCert header (base64 DER) - -SECURITY: Nginx $ssl_client_fingerprint outputs SHA1 (40 hex chars), not SHA256. -This is a known Nginx limitation. SHA1 is deprecated for signing but remains -acceptable for fingerprinting self-signed certificates in air-gapped environments -with short-lived certs and revocation support. -""" - -import hashlib -import base64 -import hmac -import re -from flask import request, current_app - - -def get_client_cert_fingerprint(): - """Extract client certificate fingerprint from request. - - Returns: - str: 40-character lowercase hex SHA1 fingerprint, or None if no cert - - Supports multiple deployment types: - - Nginx: reads X-Client-Cert-Fingerprint header (SHA1, colon-separated) - - IIS direct: reads SSL_CLIENT_CERT environ (PEM format, computes SHA256) - - IIS with ARR: reads X-ARR-ClientCert header (base64 DER, computes SHA256) - - SECURITY: Nginx returns SHA1 (40 hex chars). IIS paths compute SHA256 (64 hex - chars). The database client_cert_fingerprint column holds 64 chars to support - future SHA256 migration but currently stores SHA1 from Nginx deployments. - """ - - # Option 1: Nginx passes pre-computed SHA1 fingerprint - # Format: "AB:CD:EF:..." (colon-separated uppercase hex) - nginx_fp = request.headers.get('X-Client-Cert-Fingerprint') - if nginx_fp: - # Normalize: remove colons, lowercase - normalized = nginx_fp.replace(':', '').lower() - - # Validate format: must be 40 hex chars (SHA1) or 64 hex chars (SHA256) - if len(normalized) == 40: - # SHA1 from nginx $ssl_client_fingerprint - if not re.match(r'^[0-9a-f]{40}$', normalized): - current_app.logger.warning( - f'[client_cert] Invalid SHA1 fingerprint format: {normalized[:16]}...') - return None - return normalized - elif len(normalized) == 64: - # SHA256 from custom nginx config or future migration - if not re.match(r'^[0-9a-f]{64}$', normalized): - current_app.logger.warning( - f'[client_cert] Invalid SHA256 fingerprint format: {normalized[:16]}...') - return None - return normalized - else: - current_app.logger.warning( - f'[client_cert] Invalid fingerprint length: {len(normalized)} ' - f'(expected 40 for SHA1 or 64 for SHA256)') - return None - - # Option 2: IIS passes full cert in PEM format via WSGI environ - iis_cert_pem = request.environ.get('SSL_CLIENT_CERT') - if iis_cert_pem: - try: - from cryptography import x509 - from cryptography.hazmat.backends import default_backend - from cryptography.hazmat.primitives import serialization - - # Parse PEM certificate - if isinstance(iis_cert_pem, str): - iis_cert_pem = iis_cert_pem.encode('utf-8') - - cert = x509.load_pem_x509_certificate(iis_cert_pem, default_backend()) - - # Convert to DER and hash - cert_der = cert.public_bytes(serialization.Encoding.DER) - fingerprint = hashlib.sha256(cert_der).hexdigest() - - current_app.logger.debug( - f'[client_cert] Extracted from IIS SSL_CLIENT_CERT: {fingerprint[:16]}...') - return fingerprint - except Exception as e: - current_app.logger.warning(f'[client_cert] Failed to parse IIS cert: {e}') - return None - - # Option 3: IIS with ARR passes base64-encoded DER - arr_cert_b64 = request.headers.get('X-ARR-ClientCert') - if arr_cert_b64: - try: - # Decode base64 to get DER bytes - cert_der = base64.b64decode(arr_cert_b64) - fingerprint = hashlib.sha256(cert_der).hexdigest() - - current_app.logger.debug( - f'[client_cert] Extracted from ARR header: {fingerprint[:16]}...') - return fingerprint - except Exception as e: - current_app.logger.warning(f'[client_cert] Failed to parse ARR cert: {e}') - return None - - # No client certificate present - return None - - -def authenticate_by_client_cert(): - """Authenticate request by client certificate. - - Returns: - Asset: The authenticated asset, or None if cert auth fails - - Checks: - 1. Client cert present in request? - 2. Fingerprint matches an asset in database? - 3. Certificate not revoked? - 4. Certificate not expired? - - Does NOT raise exceptions - returns None on any failure. - Caller decides whether to fall back to other auth methods. - - AUDIT: Logs all authentication attempts (success and failure) to AuditLog. - """ - from shopdb.api import Asset, AuditLog - from datetime import datetime, timezone - - fingerprint = get_client_cert_fingerprint() - if not fingerprint: - return None - - # Look up asset by cert fingerprint - asset = Asset.query.filter_by(client_cert_fingerprint=fingerprint).first() - if not asset: - current_app.logger.warning( - f'[client_cert] Unknown fingerprint: {fingerprint[:16]}... ' - f'(not associated with any asset)') - # Audit: Failed authentication attempt with unknown fingerprint - try: - AuditLog.log( - action='auth.cert.unknown', - userid=None, - details=f'Unknown fingerprint: {fingerprint[:16]}...') - except Exception: - pass # Don't fail authentication on audit failure - return None - - # Constant-time comparison to prevent timing attacks. Database lookup already - # happened (timing leak there is unavoidable with indexed lookups), but this - # prevents attackers from using response timing to confirm partial fingerprint - # matches during the final validation. - if not hmac.compare_digest(asset.client_cert_fingerprint, fingerprint): - current_app.logger.warning( - f'[client_cert] Fingerprint mismatch for asset {asset.assetid} ' - f'(database vs request)') - return None - - # Check certificate revocation (cert_revoked_at is set when admin revokes) - # Fail closed: if column missing (should never happen after migration 0022), - # treat as database error and reject authentication - if asset.cert_revoked_at: - current_app.logger.warning( - f'[client_cert] Revoked certificate for asset {asset.assetid} ' - f'(revoked {asset.cert_revoked_at}: {asset.cert_revocation_reason or "no reason"})') - # Audit: Attempt to use revoked certificate - try: - AuditLog.log( - action='auth.cert.revoked', - userid=None, - entityid=asset.assetid, - entityname=asset.assetnumber, - details=f'Revoked at {asset.cert_revoked_at}: ' - f'{asset.cert_revocation_reason or "no reason"}') - except Exception: - pass # Don't fail authentication on audit failure - return None - - # Check certificate expiration - if asset.cert_expires_at: - now = datetime.now(timezone.utc).replace(tzinfo=None) - if asset.cert_expires_at < now: - current_app.logger.warning( - f'[client_cert] Expired certificate for asset {asset.assetid} ' - f'(expired {asset.cert_expires_at})') - # Audit: Attempt to use expired certificate - try: - AuditLog.log( - action='auth.cert.expired', - userid=None, - entityid=asset.assetid, - entityname=asset.assetnumber, - details=f'Expired {asset.cert_expires_at}') - except Exception: - pass - return None - - current_app.logger.info( - f'[client_cert] Authenticated asset {asset.assetid} ' - f'(number={asset.assetnumber}) via certificate {fingerprint[:16]}...') - - # Audit: Successful certificate authentication - try: - AuditLog.log( - action='auth.cert.success', - userid=None, - entityid=asset.assetid, - entityname=asset.assetnumber, - details=f'Cert fingerprint: {fingerprint[:16]}...') - except Exception: - pass - - return asset diff --git a/shopdb/core/models/asset.py b/shopdb/core/models/asset.py index 368cfd19..e89ef279 100644 --- a/shopdb/core/models/asset.py +++ b/shopdb/core/models/asset.py @@ -139,34 +139,6 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin): # Notes notes = db.Column(db.Text, nullable=True) - # Client certificate authentication (endpoints plugin) - client_cert_fingerprint = db.Column( - db.String(64), - nullable=True, - index=True, - comment='SHA1 or SHA256 fingerprint of client certificate for mTLS auth' - ) - cert_issued_at = db.Column( - db.DateTime, - nullable=True, - comment='When the client certificate was issued' - ) - cert_expires_at = db.Column( - db.DateTime, - nullable=True, - comment='When the certificate expires' - ) - cert_revoked_at = db.Column( - db.DateTime, - nullable=True, - comment='When the certificate was revoked (null = not revoked)' - ) - cert_revocation_reason = db.Column( - db.String(255), - nullable=True, - comment='Why the certificate was revoked (admin note)' - ) - # Relationships assettype = db.relationship('AssetType', backref='assets') status = db.relationship('AssetStatus', backref='assets') diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py index b1a5292b..54036777 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -58,7 +58,9 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = { 'endpointapplications', 'endpointmonitors', 'operatorreports', 'endpointhealthstates', 'endpointhealthtransitions', 'endpointmonitorimpacts', - 'endpointlocks', 'endpointlogcollections'), + 'endpointlocks', 'endpointlogcollections', + 'endpointcredentials', 'endpointenrollrequests', + 'endpointmigrationwaves'), 'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes', 'manifestentryhostnames', 'manifestentrymachinenumbers', 'manifestinusechecks', 'manifestinusecheckprocesses', diff --git a/shopdb/plugins/base.py b/shopdb/plugins/base.py index 2b47da69..cdb3f1ba 100644 --- a/shopdb/plugins/base.py +++ b/shopdb/plugins/base.py @@ -236,6 +236,27 @@ class BasePlugin(ABC): f"implement apply_collector_payload" ) + def resolve_agent_credential(self, secret: str) -> Optional[int]: + """Asset id for an active per-PC agent credential, else None. + + Optional (contract 0.33.0). A plugin that issues endpoint credentials + answers here so the generic collector can accept them on any schema + declaring 'acceptsagentcredential'. Called via + shopdb.api.resolve_agent_credential, which asks enabled plugins and + takes the first non-None answer. + """ + return None + + def agent_shared_key_refused(self, hostname: str) -> bool: + """True when the shared collector key may not report for this hostname. + + Optional (contract 0.33.0). True when the shared key is switched off or + the hostname's asset holds an active per-PC credential. Called via + shopdb.api.agent_shared_key_refused, which is True if any enabled + plugin says so. + """ + return False + def on_install(self, app: Flask) -> None: """Called when plugin is installed via CLI.""" pass diff --git a/test-cert-renewal.ps1 b/test-cert-renewal.ps1 deleted file mode 100644 index 74ad8753..00000000 --- a/test-cert-renewal.ps1 +++ /dev/null @@ -1,182 +0,0 @@ -# Test certificate renewal process -# This script validates end-to-end certificate renewal - -$ErrorActionPreference = "Stop" - -Write-Host "=== Certificate Renewal Test ===" -ForegroundColor Cyan -Write-Host "" - -# 1. Check current certificate -Write-Host "Step 1: Check current certificate" -ForegroundColor Yellow -$assetId = 2528 -$query = @" -SELECT - assetid, - assetnumber, - client_cert_fingerprint, - cert_issued_at, - cert_expires_at, - DATEDIFF(cert_expires_at, NOW()) as days_remaining -FROM assets -WHERE assetid = $assetId -"@ - -Write-Host "Querying database for current cert status..." -$result = docker-compose exec -T db mysql -u root -proot-dev-password-not-for-production shopdb_flask -sN -e $query 2>$null - -if ($result) { - $fields = $result -split "`t" - Write-Host " Asset: $($fields[1])" -ForegroundColor Green - Write-Host " Current Fingerprint: $($fields[2])" -ForegroundColor Green - Write-Host " Issued: $($fields[3])" -ForegroundColor Green - Write-Host " Expires: $($fields[4])" -ForegroundColor Green - Write-Host " Days Remaining: $($fields[5])" -ForegroundColor Green - $oldFingerprint = $fields[2] - $daysRemaining = [int]$fields[5] -} else { - Write-Host " ERROR: Could not query database" -ForegroundColor Red - exit 1 -} - -Write-Host "" - -# 2. Test renewal endpoint -Write-Host "Step 2: Test renewal endpoint (API key fallback)" -ForegroundColor Yellow - -$apiKey = (Get-Content .env | Select-String "COLLECTOR_API_KEY_ENDPOINTS" | ForEach-Object { $_.ToString().Split('=')[1] }) -if (-not $apiKey) { - Write-Host " ERROR: Could not get API key from .env" -ForegroundColor Red - exit 1 -} - -$body = @{ - hostname = "DESKTOP-HF1K9M4" -} | ConvertTo-Json - -Write-Host " Calling POST https://localhost:5001/api/endpoints/certificate/renew" -Write-Host " (This may take a few seconds...)" - -try { - $response = Invoke-RestMethod ` - -Uri "https://localhost:5001/api/endpoints/certificate/renew" ` - -Method POST ` - -Headers @{ - "X-API-Key" = $apiKey - "Content-Type" = "application/json" - } ` - -Body $body ` - -SkipCertificateCheck ` - -ErrorAction Stop - - if ($response.status -eq "success") { - Write-Host " ✓ Renewal succeeded!" -ForegroundColor Green - $newThumbprint = $response.data.thumbprint - $newExpires = $response.data.expires_at - $pfxSize = [System.Convert]::FromBase64String($response.data.pfx).Length - - Write-Host " New Thumbprint: $newThumbprint" -ForegroundColor Green - Write-Host " New Expires: $newExpires" -ForegroundColor Green - Write-Host " PFX Size: $pfxSize bytes" -ForegroundColor Green - Write-Host " Password Length: $($response.data.password.Length) chars" -ForegroundColor Green - } else { - Write-Host " × Renewal failed: $($response.data.error.message)" -ForegroundColor Red - - if ($response.data.error.code -eq "VALIDATION_ERROR" -and - $response.data.error.message -like "*24 hours*") { - Write-Host "" - Write-Host " NOTE: Certificate was renewed < 24 hours ago (rate limited)" -ForegroundColor Yellow - Write-Host " This is expected behavior and the security fix is working correctly." -ForegroundColor Yellow - Write-Host "" - Write-Host " To test renewal:" -ForegroundColor Cyan - Write-Host " 1. Wait 24 hours, OR" -ForegroundColor Cyan - Write-Host " 2. Manually update cert_issued_at in database:" -ForegroundColor Cyan - Write-Host " UPDATE assets SET cert_issued_at = DATE_SUB(NOW(), INTERVAL 25 HOUR) WHERE assetid = $assetId;" -ForegroundColor Gray - exit 0 - } - } -} catch { - Write-Host " × Request failed: $($_.Exception.Message)" -ForegroundColor Red - if ($_.ErrorDetails.Message) { - $errorData = $_.ErrorDetails.Message | ConvertFrom-Json - Write-Host " Error: $($errorData.data.error.message)" -ForegroundColor Red - } - exit 1 -} - -Write-Host "" - -# 3. Verify database was updated -Write-Host "Step 3: Verify database was updated" -ForegroundColor Yellow -$newResult = docker-compose exec -T db mysql -u root -proot-dev-password-not-for-production shopdb_flask -sN -e $query 2>$null - -if ($newResult) { - $newFields = $newResult -split "`t" - $dbFingerprint = $newFields[2] - $dbExpires = $newFields[4] - - if ($dbFingerprint -eq $oldFingerprint) { - Write-Host " × Database not updated (fingerprint unchanged)" -ForegroundColor Red - exit 1 - } else { - Write-Host " ✓ Database updated successfully" -ForegroundColor Green - Write-Host " Old Fingerprint: $oldFingerprint" -ForegroundColor Gray - Write-Host " New Fingerprint: $dbFingerprint" -ForegroundColor Green - Write-Host " New Expires: $dbExpires" -ForegroundColor Green - } -} - -Write-Host "" - -# 4. Test hostname validation (security fix #4) -Write-Host "Step 4: Test hostname validation (Security Fix #4)" -ForegroundColor Yellow - -$testCases = @( - @{ Hostname = ""; Description = "Empty hostname"; ExpectedStatus = 400 } - @{ Hostname = "x" * 65; Description = "Too long (65 chars)"; ExpectedStatus = 400 } - @{ Hostname = "test;DROP TABLE"; Description = "SQL injection"; ExpectedStatus = 400 } - @{ Hostname = "../../etc/passwd"; Description = "Path traversal"; ExpectedStatus = 400 } -) - -$passed = 0 -foreach ($test in $testCases) { - $testBody = @{ hostname = $test.Hostname } | ConvertTo-Json - - try { - $testResponse = Invoke-RestMethod ` - -Uri "https://localhost:5001/api/endpoints/certificate/renew" ` - -Method POST ` - -Headers @{ - "X-API-Key" = $apiKey - "Content-Type" = "application/json" - } ` - -Body $testBody ` - -SkipCertificateCheck ` - -ErrorAction Stop - - Write-Host " × $($test.Description): Got 200, expected $($test.ExpectedStatus)" -ForegroundColor Red - } catch { - $statusCode = $_.Exception.Response.StatusCode.value__ - if ($statusCode -eq $test.ExpectedStatus) { - Write-Host " ✓ $($test.Description): Rejected correctly ($statusCode)" -ForegroundColor Green - $passed++ - } else { - Write-Host " × $($test.Description): Got $statusCode, expected $($test.ExpectedStatus)" -ForegroundColor Red - } - } -} - -Write-Host " Validation tests: $passed/$($testCases.Count) passed" -ForegroundColor $(if ($passed -eq $testCases.Count) { "Green" } else { "Yellow" }) - -Write-Host "" - -# Summary -Write-Host "=== Test Summary ===" -ForegroundColor Cyan -Write-Host "✓ Certificate renewal endpoint operational" -ForegroundColor Green -Write-Host "✓ Database updates working" -ForegroundColor Green -Write-Host "✓ Hostname validation enforced" -ForegroundColor Green -Write-Host "✓ Security fixes validated" -ForegroundColor Green -Write-Host "" -Write-Host "NOTE: Agent must be rebuilt and deployed to test:" -ForegroundColor Yellow -Write-Host " - Periodic expiry monitoring (24-hour checks)" -ForegroundColor Yellow -Write-Host " - Graceful service restart after renewal" -ForegroundColor Yellow -Write-Host " - Server certificate validation" -ForegroundColor Yellow diff --git a/test_cert_renewal.py b/test_cert_renewal.py deleted file mode 100644 index fda44184..00000000 --- a/test_cert_renewal.py +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env python3 -"""Test certificate renewal endpoint. - -Tests all security fixes: -- Hostname validation -- Rate limiting -- API key fallback authentication -- Certificate generation -- Database updates -""" - -import requests -import json -import base64 -from datetime import datetime - -# Test configuration -BASE_URL = "https://localhost:5001" -API_KEY = "shopdb_pat_b4a3fe2a01abc6bc1cb685280e65a8c35d52ec43" -HOSTNAME = "DESKTOP-HF1K9M4" - -# Disable SSL warnings for self-signed cert -import urllib3 -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - -def test_renewal_with_api_key(): - """Test renewal using API key + hostname fallback (for expired certs).""" - print("\n=== Test 1: Renewal with API Key + Hostname ===") - - url = f"{BASE_URL}/api/endpoints/certificate/renew" - headers = { - "X-API-Key": API_KEY, - "Content-Type": "application/json" - } - payload = {"hostname": HOSTNAME} - - print(f"POST {url}") - print(f"Payload: {payload}") - - response = requests.post(url, headers=headers, json=payload, verify=False) - - print(f"Status: {response.status_code}") - print(f"Response: {response.text[:500]}") - - if response.status_code == 200: - data = response.json()["data"] - print(f"\n✅ SUCCESS!") - print(f" Thumbprint: {data['thumbprint'][:16]}...") - print(f" Expires: {data['expires_at']}") - print(f" PFX size: {len(base64.b64decode(data['pfx']))} bytes") - print(f" Password length: {len(data['password'])} chars") - return True - elif response.status_code == 429: - print(f"\n⚠️ RATE LIMITED (expected if cert renewed < 24h ago)") - return True - else: - print(f"\n❌ FAILED") - return False - - -def test_hostname_validation(): - """Test hostname validation (Fix #4).""" - print("\n=== Test 2: Hostname Validation ===") - - url = f"{BASE_URL}/api/endpoints/certificate/renew" - headers = { - "X-API-Key": API_KEY, - "Content-Type": "application/json" - } - - test_cases = [ - ("", "empty hostname", 400), - ("x" * 65, "hostname too long", 400), - ("test;DROP TABLE", "SQL injection attempt", 400), - ("../../etc/passwd", "path traversal", 400), - ("test\x00null", "null byte", 400), - ("VALID-HOST", "valid hostname", 200 or 429 or 404), # 404 if hostname unknown - ] - - passed = 0 - for hostname, description, expected in test_cases: - payload = {"hostname": hostname} - response = requests.post(url, headers=headers, json=payload, verify=False) - - if isinstance(expected, tuple): - success = response.status_code in expected - else: - success = response.status_code == expected - - status = "✅" if success else "❌" - print(f" {status} {description}: {response.status_code}") - if success: - passed += 1 - - print(f"\nValidation Tests: {passed}/{len(test_cases)} passed") - return passed == len(test_cases) - - -def test_rate_limiting(): - """Test rate limiting (Fix #5).""" - print("\n=== Test 3: Rate Limiting ===") - - url = f"{BASE_URL}/api/endpoints/certificate/renew" - headers = { - "X-API-Key": API_KEY, - "Content-Type": "application/json" - } - payload = {"hostname": HOSTNAME} - - print("Making first renewal request...") - response1 = requests.post(url, headers=headers, json=payload, verify=False) - print(f" First request: {response1.status_code}") - - if response1.status_code == 200: - print("\nMaking immediate second request (should be rate limited)...") - response2 = requests.post(url, headers=headers, json=payload, verify=False) - print(f" Second request: {response2.status_code}") - - if response2.status_code == 429: - print("✅ Rate limiting works! Certificate can only be renewed once per 24 hours.") - return True - else: - print("❌ Rate limiting failed - second request should return 429") - return False - elif response1.status_code == 429: - print("✅ Already rate limited (cert renewed < 24h ago)") - return True - else: - print(f"⚠️ Cannot test rate limiting - first request returned {response1.status_code}") - return False - - -def test_certificate_info(): - """Check current certificate status in database.""" - print("\n=== Test 4: Current Certificate Status ===") - - # Query asset info via API (requires JWT auth, so this is a basic check) - url = f"{BASE_URL}/api/endpoints/test/client-cert" - - try: - response = requests.get(url, verify=False) - if response.status_code == 200: - data = response.json() - print(f" Fingerprint in DB: {data.get('fingerprint', 'N/A')}") - print(f" Issued: {data.get('issued_at', 'N/A')}") - print(f" Expires: {data.get('expires_at', 'N/A')}") - print(f" Revoked: {data.get('revoked_at', 'N/A') or 'No'}") - return True - except Exception as e: - print(f" ℹ️ Cannot query cert status (requires auth): {e}") - - return True - - -def test_audit_logging(): - """Verify audit logs are created (Fix #8).""" - print("\n=== Test 5: Audit Logging ===") - print(" ℹ️ Check database auditlogs table for:") - print(" - action='endpoints.cert.renewed' on success") - print(" - action='endpoints.cert.renewal_failed' on cert generation failure") - print(" - action='endpoints.cert.db_update_failed' on database failure") - print(" Manual verification required (needs DB access)") - return True - - -def main(): - """Run all tests.""" - print("=" * 60) - print("Certificate Renewal Security Test Suite") - print("=" * 60) - print(f"\nTarget: {BASE_URL}") - print(f"Hostname: {HOSTNAME}") - print(f"Testing fixes from Fable security review") - - # Check if API key is configured - if API_KEY == "your_collector_api_key_here": - print("\n❌ ERROR: Update API_KEY in script with actual collector key") - print(" Get it from: HKLM\\SOFTWARE\\GE\\ShopDB\\CollectorKey") - print(" Or from: docker-compose.yml COLLECTOR_API_KEY_ENDPOINTS") - return - - results = [] - - # Run tests - results.append(("Renewal with API Key", test_renewal_with_api_key())) - results.append(("Hostname Validation", test_hostname_validation())) - results.append(("Rate Limiting", test_rate_limiting())) - results.append(("Certificate Info", test_certificate_info())) - results.append(("Audit Logging", test_audit_logging())) - - # Summary - print("\n" + "=" * 60) - print("TEST SUMMARY") - print("=" * 60) - - for name, passed in results: - status = "✅ PASS" if passed else "❌ FAIL" - print(f"{status:12} {name}") - - passed_count = sum(1 for _, p in results if p) - total_count = len(results) - - print(f"\nTotal: {passed_count}/{total_count} tests passed") - - if passed_count == total_count: - print("\n✅ All security fixes validated!") - else: - print(f"\n⚠️ {total_count - passed_count} test(s) failed") - - -if __name__ == "__main__": - main() diff --git a/test_renewal_simple.sh b/test_renewal_simple.sh deleted file mode 100644 index 027f06e1..00000000 --- a/test_renewal_simple.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash -echo "=== Certificate Renewal Security Tests ===" -echo "" - -API_KEY="shopdb_pat_b4a3fe2a01abc6bc1cb685280e65a8c35d52ec43" -BASE_URL="https://localhost:5001" - -echo "Test 1: Hostname Validation - Empty hostname" -curl -k -X POST "$BASE_URL/api/endpoints/certificate/renew" \ - -H "X-API-Key: $API_KEY" \ - -H "Content-Type: application/json" \ - -d '{"hostname":""}' \ - -s -w "\nStatus: %{http_code}\n\n" | grep -E "error|Status" - -echo "Test 2: Hostname Validation - SQL injection attempt" -curl -k -X POST "$BASE_URL/api/endpoints/certificate/renew" \ - -H "X-API-Key: $API_KEY" \ - -H "Content-Type: application/json" \ - -d '{"hostname":"test;DROP TABLE"}' \ - -s -w "\nStatus: %{http_code}\n\n" | grep -E "error|Status" - -echo "Test 3: Hostname Validation - Path traversal" -curl -k -X POST "$BASE_URL/api/endpoints/certificate/renew" \ - -H "X-API-Key: $API_KEY" \ - -H "Content-Type: application/json" \ - -d '{"hostname":"../../etc/passwd"}' \ - -s -w "\nStatus: %{http_code}\n\n" | grep -E "error|Status" - -echo "Test 4: Hostname Validation - Too long (65 chars)" -curl -k -X POST "$BASE_URL/api/endpoints/certificate/renew" \ - -H "X-API-Key: $API_KEY" \ - -H "Content-Type: application/json" \ - -d '{"hostname":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}' \ - -s -w "\nStatus: %{http_code}\n\n" | grep -E "error|Status" - -echo "Test 5: Valid hostname (should work or rate limit)" -curl -k -X POST "$BASE_URL/api/endpoints/certificate/renew" \ - -H "X-API-Key: $API_KEY" \ - -H "Content-Type: application/json" \ - -d '{"hostname":"DESKTOP-HF1K9M4"}' \ - -s -w "\nStatus: %{http_code}\n" | head -20 - -echo "" -echo "=== Check Agent Cert Authentication ===" -docker-compose logs --tail=500 api 2>&1 | grep "Authenticated asset 2528" | tail -3 diff --git a/tests/test_backfill_app_fingerprints.py b/tests/test_backfill_app_fingerprints.py new file mode 100644 index 00000000..dd29f272 --- /dev/null +++ b/tests/test_backfill_app_fingerprints.py @@ -0,0 +1,117 @@ +"""scripts/backfill_app_fingerprints.py against a real (SQLite) session. + +The unique index on (assetid, appfingerprint) turns two identical inventory rows +on one asset into an IntegrityError mid-backfill. The script must keep the +lowest id, delete the rest and report how many, and a failing batch must be +rolled back rather than left half-applied. +""" + +import importlib.util +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / 'scripts' / 'backfill_app_fingerprints.py' + + +@pytest.fixture +def backfill(app): + spec = importlib.util.spec_from_file_location('backfillfingerprints', SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _asset(db, number): + from shopdb.core.models import Asset, AssetType + + atype = AssetType.query.filter_by(assettype='computer').first() + if not atype: + atype = AssetType(assettype='computer') + db.session.add(atype) + db.session.flush() + asset = Asset(assetnumber=number, assettypeid=atype.assettypeid, + isactive=True) + db.session.add(asset) + db.session.commit() + return asset.assetid + + +def _row(db, assetid, name, version='1.0', publisher='Vendor', + fingerprint=None): + from plugins.endpoints.models import EndpointApplication + + row = EndpointApplication(assetid=assetid, displayname=name, + displayversion=version, publisher=publisher, + **{'appfingerprint': fingerprint}) + db.session.add(row) + db.session.commit() + return row.endpointapplicationid + + +def test_self_check_uses_fixed_expected_values(backfill, monkeypatch): + assert backfill.verify_fingerprint_algorithm() is True + monkeypatch.setattr(backfill, 'compute_fingerprint', + lambda name, version, publisher: '0' * 64) + with pytest.raises(ValueError): + backfill.verify_fingerprint_algorithm() + + +def test_duplicates_keep_the_lowest_id(backfill, db): + from plugins.endpoints.models import EndpointApplication + + first = _asset(db, 'FP01') + second = _asset(db, 'FP02') + keep = _row(db, first, 'Chrome') + _row(db, first, 'Chrome') + _row(db, first, 'Chrome') + other = _row(db, first, 'Office') + elsewhere = _row(db, second, 'Chrome') + + summary = backfill.backfill_fingerprints(batch_size=2) + + assert summary['duplicatesremoved'] == 2 + assert summary['failed'] == 0 + rows = EndpointApplication.query.order_by( + EndpointApplication.endpointapplicationid).all() + assert [r.endpointapplicationid for r in rows] == [keep, other, elsewhere] + assert all(r.appfingerprint for r in rows) + + +def test_an_existing_higher_id_holder_gives_way(backfill, db): + from plugins.endpoints.models import EndpointApplication + + assetid = _asset(db, 'FP03') + fingerprint = backfill.compute_fingerprint('Chrome', '1.0', 'Vendor') + older = _row(db, assetid, 'Chrome') + _row(db, assetid, 'Chrome', fingerprint=fingerprint) + + summary = backfill.backfill_fingerprints() + + assert summary['duplicatesremoved'] == 1 + rows = EndpointApplication.query.all() + assert [(r.endpointapplicationid, r.appfingerprint) for r in rows] == \ + [(older, fingerprint)] + + +def test_a_failing_batch_is_rolled_back(backfill, db, monkeypatch): + from plugins.endpoints.models import EndpointApplication + + assetid = _asset(db, 'FP04') + _row(db, assetid, 'Chrome') + _row(db, assetid, 'Office') + + def broken(apps): + for app in apps: + app.appfingerprint = 'x' * 64 + raise RuntimeError('simulated failure') + + monkeypatch.setattr(backfill, '_fingerprint_batch', broken) + + summary = backfill.backfill_fingerprints() + + assert summary['failed'] == 2 + assert summary['processed'] == 0 + db.session.expire_all() + assert EndpointApplication.query.filter( + EndpointApplication.appfingerprint.isnot(None)).count() == 0 diff --git a/tests/test_core/test_application_required_policy_sync.py b/tests/test_core/test_application_required_policy_sync.py new file mode 100644 index 00000000..cce0acea --- /dev/null +++ b/tests/test_core/test_application_required_policy_sync.py @@ -0,0 +1,109 @@ +"""The generated "required on all PCs" policy follows the app by appid. + +The generated policy name embeds appname, so a name lookup orphaned the policy +on rename. These pin the appid lookup, rename+untick in one PUT, delete handling +of a customised ('kept') policy and of other policies naming the appid, and the +truthful note that the generated policy is not attached to a business policy. +""" + +from shopdb.core.models import Application + + +def _policies(): + from plugins.computers.models import ConfigurationPolicy + return ConfigurationPolicy.query + + +def _create_required(client, headers, name='Fleet App'): + response = client.post('/api/applications', + json={'appname': name, 'isrequired': True, + 'acknowledgepolicy': True}, + headers=headers) + assert response.status_code == 201, response.get_json() + return response.get_json()['data'] + + +def test_created_response_says_the_policy_is_not_attached(client, db, auth_headers): + data = _create_required(client, auth_headers) + assert data['policyresult'] == 'created' + assert 'business policy' in data['policynote'] + + +def test_ack_message_mentions_business_policy(client, db, auth_headers): + response = client.post('/api/applications', + json={'appname': 'Unacked', 'isrequired': True}, + headers=auth_headers) + assert response.status_code == 400 + assert 'business policy' in response.get_json()['data']['error']['message'] + + +def test_rename_keeps_the_policy_and_renames_it(client, db, auth_headers): + data = _create_required(client, auth_headers, 'Old Name') + + response = client.put(f"/api/applications/{data['appid']}", + json={'appname': 'New Name'}, headers=auth_headers) + assert response.status_code == 200, response.get_json() + + names = [policy.name for policy in _policies().all()] + assert names == ['Required app: New Name'] + + +def test_untick_after_rename_removes_the_policy(client, db, auth_headers): + data = _create_required(client, auth_headers, 'Before') + from plugins.computers.models import ConfigurationPolicy + # Simulate a rename that bypassed the sync (older data): name is stale. + policy = ConfigurationPolicy.query.one() + application = db.session.get(Application, data['appid']) + application.appname = 'After' + db.session.commit() + assert policy.name == 'Required app: Before' + + response = client.put(f"/api/applications/{data['appid']}", + json={'isrequired': False}, headers=auth_headers) + assert response.status_code == 200, response.get_json() + assert response.get_json()['data']['policyresult'] == 'removed' + assert _policies().count() == 0 + + +def test_rename_and_untick_in_one_put_removes_the_policy(client, db, auth_headers): + data = _create_required(client, auth_headers, 'Combo') + + response = client.put(f"/api/applications/{data['appid']}", + json={'appname': 'Combo Renamed', 'isrequired': False}, + headers=auth_headers) + + assert response.status_code == 200, response.get_json() + assert response.get_json()['data']['policyresult'] == 'removed' + assert _policies().count() == 0 + + +def test_delete_deactivates_kept_and_referencing_policies(client, db, auth_headers): + from plugins.computers.models import (ConfigurationPolicy, + ConfigurationPolicyAction, + POLICY_SOURCE_LOCAL) + + data = _create_required(client, auth_headers, 'Doomed') + generated = ConfigurationPolicy.query.one() + generated.source = POLICY_SOURCE_LOCAL # admin customised it + handmade = ConfigurationPolicy(name='Hand made push', scopejson=None) + db.session.add(handmade) + db.session.flush() + db.session.add(ConfigurationPolicyAction( + configurationpolicyid=handmade.configurationpolicyid, + action='update_app', params={'appid': data['appid']})) + unrelated = ConfigurationPolicy(name='Unrelated', scopejson=None) + db.session.add(unrelated) + db.session.commit() + + response = client.delete(f"/api/applications/{data['appid']}", + json={'confirm': 'OK'}, headers=auth_headers) + + assert response.status_code == 200, response.get_json() + body = response.get_json() + assert body['data']['policyresult'] == 'kept' + assert sorted(body['data']['deactivatedpolicies']) == \ + ['Hand made push', 'Required app: Doomed'] + db.session.expire_all() + states = {policy.name: policy.isactive for policy in _policies().all()} + assert states == {'Required app: Doomed': False, 'Hand made push': False, + 'Unrelated': True} diff --git a/tests/test_core/test_authz.py b/tests/test_core/test_authz.py index a21c35e4..95414093 100644 --- a/tests/test_core/test_authz.py +++ b/tests/test_core/test_authz.py @@ -121,7 +121,20 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user', # resolves to, so the key cannot ack another machine's alert. # AUTHORING a fleet alert is the actual authority and stays # permission-gated in the notifications plugin. - 'endpoints.acknowledge_fleet_alert'} + 'endpoints.acknowledge_fleet_alert', + # Agent enrollment. A PC with no credential asks for one on + # the shared collector key (same shape as the exempt + # collector blueprint); with the shared key switched off it + # takes no auth and only collects a credential an admin + # already approved for that hostname, serial and nonce. + # Approving, rejecting and revoking ARE admin-gated and + # ARE swept. + 'endpoints.enroll_endpoint', + # Agent telemetry ingest under the endpoints prefix. The + # same handler as POST /api/collector/endpoints, whose + # blueprint is exempt: authenticated by the per-PC + # credential or the shared collector key. + 'endpoints.agent_collect'} @pytest.fixture(autouse=True) diff --git a/tests/test_core/test_plugin_settings_routes.py b/tests/test_core/test_plugin_settings_routes.py new file mode 100644 index 00000000..c0351052 --- /dev/null +++ b/tests/test_core/test_plugin_settings_routes.py @@ -0,0 +1,28 @@ +"""GET /api/plugins/settings-routes survives a plugin whose hook raises.""" + + +def test_a_raising_plugin_is_skipped_not_fatal(app, client, db, monkeypatch): + manager = app.extensions.get('plugin_manager') + plugins = manager.get_all_plugins() + enabled = [name for name in plugins if manager.registry.is_enabled(name)] + assert len(enabled) >= 2 + + broken, healthy = enabled[0], enabled[1] + + def explode(): + raise RuntimeError('broken settings hook') + + def contribute(): + return [{'hub': 'model-catalog', 'path': 'probe', 'name': 'probe-route', + 'tab': 'Probe', 'component': 'probe.vue'}] + + monkeypatch.setattr(plugins[broken], 'get_settings_routes', explode, + raising=False) + monkeypatch.setattr(plugins[healthy], 'get_settings_routes', contribute, + raising=False) + + response = client.get('/api/plugins/settings-routes') + + assert response.status_code == 200, response.get_json() + names = [route['name'] for route in response.get_json()['data']['routes']] + assert 'probe-route' in names diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index a9c01449..67795e5e 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -116,7 +116,11 @@ EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof' # on one endpoint, a caller-authority axis separate from the policy hold. # 0019 splits the 0007 bundle into one shipped Configuration Policy per monitor # and drops the shipped Business Policy, so sites compose their own. -EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0019splitpolicies' +# 0020 adds endpointapplications.appfingerprint for delta app updates. 0021 and +# 0022 are retired no-ops (client certificates, removed). 0023 drops their +# leftover asset columns, renames app_fingerprint, and adds per-PC credentials: +# endpointcredentials, endpointenrollrequests and endpointmigrationwaves. +EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0023credentials' # Plugins built after the cutover: their 0001 baseline really creates tables the # core chain never owned. diff --git a/tests/test_plugin_settings_reachable.py b/tests/test_plugin_settings_reachable.py index a5889136..38201f1d 100644 --- a/tests/test_plugin_settings_reachable.py +++ b/tests/test_plugin_settings_reachable.py @@ -30,6 +30,12 @@ EXEMPT = { # Written by the app itself when the agent key is minted, not typed. 'plugin.endpoints.agentkeyprovisioned', 'plugin.endpoints.envkeyfallback', + # Edited on the endpoints Enrollment page through + # PUT /api/endpoints/enrollment/settings, not the generic settings view: + # turning the shared key off needs a confirm that shows how many PCs are + # not enrolled yet, which a plain settings field cannot give. + 'plugin.endpoints.sharedkeyallowed', + 'plugin.endpoints.autoenroll', } diff --git a/tests/test_plugins/test_endpoints_agent_upgrade_route.py b/tests/test_plugins/test_endpoints_agent_upgrade_route.py index 96de99ee..cfcc1277 100644 --- a/tests/test_plugins/test_endpoints_agent_upgrade_route.py +++ b/tests/test_plugins/test_endpoints_agent_upgrade_route.py @@ -192,6 +192,54 @@ def test_an_endpoint_lock_still_blocks_the_upgrade(client, db, app): assert EndpointCommand.query.filter_by(assetid=assetid).count() == 0 +def test_refused_callers_never_trigger_repackaging(client, db, app, + monkeypatch): + """Lock and runscript checks run BEFORE the package and script are rebuilt. + + Repackaging rewrites a shared file and script row; a caller who will be + refused anyway must not be able to cause that side effect. + """ + calls = [] + monkeypatch.setattr(agentregistry, 'register_current_agent_package', + lambda: calls.append('package')) + monkeypatch.setattr(agentupgradescript, 'seed_upgrade_script', + lambda: calls.append('script')) + + _publish_agent() + assetid = _endpoint(db, 'UPGRADE09') + + noscript = _login(db, client, 'noscriptoperator', + permissions=['endpoints.view', 'endpoints.command', + 'endpoints.agentupdate']) + response = client.post(UPGRADE_URL.format(assetid), json={}, + headers=noscript) + assert response.status_code == 403 + assert 'endpoints.runscript' in \ + response.get_json()['data']['error']['message'] + + owner = _login(db, client, 'lockowner2', admin=True) + client.post(f'/api/endpoints/{assetid}/lock', + json={'reason': 'bench repair'}, headers=owner) + locked = _login(db, client, 'lockedoperator', + permissions=['endpoints.view', 'endpoints.command', + 'endpoints.agentupdate', + 'endpoints.runscript']) + response = client.post(UPGRADE_URL.format(assetid), json={}, + headers=locked) + assert response.status_code == 403 + assert calls == [] + + +def test_package_write_leaves_no_temp_files(client, db, app, isolate_store): + _publish_agent() + agentregistry.register_current_agent_package() + agentregistry.register_current_agent_package() + leftovers = [name for _root, _dirs, files in + os.walk(isolate_store / 'applicationpackages') + for name in files if name.endswith('.tmp')] + assert leftovers == [] + + def test_a_second_upgrade_cannot_stack_on_an_in_flight_command(client, db, app): """at-most-one-in-flight must still hold through this route.""" from plugins.endpoints.models import EndpointCommand diff --git a/tests/test_plugins/test_endpoints_app_delta.py b/tests/test_plugins/test_endpoints_app_delta.py index b30d030a..d9b1a16b 100644 --- a/tests/test_plugins/test_endpoints_app_delta.py +++ b/tests/test_plugins/test_endpoints_app_delta.py @@ -5,11 +5,10 @@ including safety features: fingerprint validation, delete count verification, memory limits, and concurrent update protection. """ -import pytest import hashlib -from datetime import datetime -from shopdb.extensions import db +import pytest + from plugins.endpoints.models import EndpointApplication from plugins.endpoints.plugin import EndpointsPlugin @@ -23,6 +22,22 @@ def compute_test_fingerprint(name, version, publisher): return hashlib.sha256(data.encode('utf-8')).hexdigest() +def _make_asset(db, assetnumber): + """A bare computer-type Asset; _replace_applications needs only the id.""" + from shopdb.core.models import Asset, AssetType + + assettype = AssetType.query.filter_by(assettype='computer').first() + if assettype is None: + assettype = AssetType(assettype='computer') + db.session.add(assettype) + db.session.flush() + asset = Asset(assetnumber=assetnumber, name=assetnumber, + assettypeid=assettype.assettypeid, isactive=True) + db.session.add(asset) + db.session.commit() + return asset.assetid + + class TestApplicationDelta: """Test delta-based application inventory updates.""" @@ -31,13 +46,9 @@ class TestApplicationDelta: return EndpointsPlugin() @pytest.fixture - def asset_id(self, db_session): + def asset_id(self, db): """Create a test asset.""" - from shopdb.core.models import Asset - asset = Asset(assettag='TEST-001') - db_session.add(asset) - db_session.commit() - return asset.assetid + return _make_asset(db, 'TEST-001') def test_fingerprint_validation_valid(self, plugin): """Valid fingerprints (64 lowercase hex chars) pass validation.""" @@ -65,7 +76,7 @@ class TestApplicationDelta: for fp in invalid_fps: assert not plugin._is_valid_fingerprint(fp), f"Should reject: {fp}" - def test_delta_no_changes(self, plugin, asset_id, db_session): + def test_delta_no_changes(self, plugin, asset_id, db): """No apps added/removed when inventory unchanged.""" # Initial insert: 3 apps apps_v1 = [ @@ -91,7 +102,7 @@ class TestApplicationDelta: warnings = [] result = plugin._replace_applications(asset_id, apps_v1, warnings) - db_session.commit() + db.session.commit() assert result == 3 assert len(warnings) == 0 @@ -100,13 +111,13 @@ class TestApplicationDelta: # Second report: same apps (no changes) warnings = [] result = plugin._replace_applications(asset_id, apps_v1, warnings) - db_session.commit() + db.session.commit() assert result == 0 # 0 added assert len(warnings) == 0 assert EndpointApplication.query.filter_by(assetid=asset_id).count() == 3 # Still 3 - def test_delta_add_remove(self, plugin, asset_id, db_session): + def test_delta_add_remove(self, plugin, asset_id, db): """Detect adds and removes correctly.""" # Initial: 2 apps apps_v1 = [ @@ -126,7 +137,7 @@ class TestApplicationDelta: warnings = [] plugin._replace_applications(asset_id, apps_v1, warnings) - db_session.commit() + db.session.commit() assert EndpointApplication.query.filter_by(assetid=asset_id).count() == 2 @@ -148,7 +159,7 @@ class TestApplicationDelta: warnings = [] result = plugin._replace_applications(asset_id, apps_v2, warnings) - db_session.commit() + db.session.commit() assert result == 1 # 1 added (Office) assert len(warnings) == 0 @@ -158,7 +169,7 @@ class TestApplicationDelta: app_names = {app.displayname for app in apps} assert app_names == {'Chrome', 'Office'} - def test_delta_version_change(self, plugin, asset_id, db_session): + def test_delta_version_change(self, plugin, asset_id, db): """Version change detected (different fingerprint).""" # Initial: Chrome 117.0 apps_v1 = [ @@ -172,7 +183,7 @@ class TestApplicationDelta: warnings = [] plugin._replace_applications(asset_id, apps_v1, warnings) - db_session.commit() + db.session.commit() # Update: Chrome 118.0 (different fingerprint) apps_v2 = [ @@ -186,14 +197,14 @@ class TestApplicationDelta: warnings = [] result = plugin._replace_applications(asset_id, apps_v2, warnings) - db_session.commit() + db.session.commit() assert result == 1 # 1 added (new version) apps = EndpointApplication.query.filter_by(assetid=asset_id).all() assert len(apps) == 1 assert apps[0].displayversion == '118.0' - def test_invalid_fingerprint_skipped(self, plugin, asset_id, db_session): + def test_invalid_fingerprint_skipped(self, plugin, asset_id, db): """Apps with invalid fingerprints are skipped with warning.""" apps = [ { @@ -212,7 +223,7 @@ class TestApplicationDelta: warnings = [] result = plugin._replace_applications(asset_id, apps, warnings) - db_session.commit() + db.session.commit() assert result == 1 # Only Firefox added assert len(warnings) == 1 @@ -223,7 +234,7 @@ class TestApplicationDelta: assert len(apps) == 1 assert apps[0].displayname == 'Firefox' - def test_legacy_fallback_no_fingerprints(self, plugin, asset_id, db_session): + def test_legacy_fallback_no_fingerprints(self, plugin, asset_id, db): """Legacy mode works when agent doesn't send fingerprints.""" apps = [ { @@ -242,16 +253,16 @@ class TestApplicationDelta: warnings = [] result = plugin._replace_applications(asset_id, apps, warnings) - db_session.commit() + db.session.commit() assert result == 2 assert len(warnings) == 0 apps = EndpointApplication.query.filter_by(assetid=asset_id).all() assert len(apps) == 2 - assert all(app.app_fingerprint is None for app in apps) + assert all(app.appfingerprint is None for app in apps) - def test_empty_inventory(self, plugin, asset_id, db_session): + def test_empty_inventory(self, plugin, asset_id, db): """Empty inventory removes all apps.""" # Initial: 2 apps apps_v1 = [ @@ -271,19 +282,19 @@ class TestApplicationDelta: warnings = [] plugin._replace_applications(asset_id, apps_v1, warnings) - db_session.commit() + db.session.commit() assert EndpointApplication.query.filter_by(assetid=asset_id).count() == 2 # Update: Empty list warnings = [] result = plugin._replace_applications(asset_id, [], warnings) - db_session.commit() + db.session.commit() assert result == 0 assert EndpointApplication.query.filter_by(assetid=asset_id).count() == 0 - def test_missing_displayname_skipped(self, plugin, asset_id, db_session): + def test_missing_displayname_skipped(self, plugin, asset_id, db): """Apps without displayname are skipped.""" apps = [ { @@ -302,13 +313,13 @@ class TestApplicationDelta: warnings = [] result = plugin._replace_applications(asset_id, apps, warnings) - db_session.commit() + db.session.commit() assert result == 1 # Only Firefox added assert len(warnings) == 1 assert 'no displayname' in warnings[0].lower() - def test_string_truncation(self, plugin, asset_id, db_session): + def test_string_truncation(self, plugin, asset_id, db): """Long strings are truncated to column limits.""" apps = [ { @@ -321,7 +332,7 @@ class TestApplicationDelta: warnings = [] result = plugin._replace_applications(asset_id, apps, warnings) - db_session.commit() + db.session.commit() assert result == 1 app = EndpointApplication.query.filter_by(assetid=asset_id).first() @@ -338,13 +349,9 @@ class TestApplicationDeltaSafety: return EndpointsPlugin() @pytest.fixture - def asset_id(self, db_session): + def asset_id(self, db): """Create a test asset.""" - from shopdb.core.models import Asset - asset = Asset(assettag='TEST-SAFETY-001') - db_session.add(asset) - db_session.commit() - return asset.assetid + return _make_asset(db, 'TEST-SAFETY-001') def test_compute_fingerprint_consistency(self): """Fingerprint algorithm is deterministic.""" diff --git a/tests/test_plugins/test_endpoints_collect_log_denylist.py b/tests/test_plugins/test_endpoints_collect_log_denylist.py new file mode 100644 index 00000000..0fd32509 --- /dev/null +++ b/tests/test_plugins/test_endpoints_collect_log_denylist.py @@ -0,0 +1,107 @@ +"""collect_log sensitive-path denylist at enqueue. + +The agent refuses answer files, its own install/state and key material. The +server refuses the same set at enqueue so the operator gets a 400 naming the +reason instead of a queued command that fails on the endpoint. +""" + +import pytest +from werkzeug.security import generate_password_hash + +from plugins.endpoints.api.routes import _validate_log_path + + +@pytest.mark.parametrize('path', [ + r'C:\Windows\Panther\setupact.log', + r'c:/windows/panther/UnattendGC/setupact.log', + r'%SystemRoot%\Panther\setupact.log', + r'%windir%\System32\Sysprep\Panther\setupact.log', + r'D:\WINDOWS\System32\sysprep\x.log', + r'C:\Windows\\Panther\.\setupact.log', + r'C:\ProgramData\ShopDB\state.json', + r'%ProgramData%\shopdb\agent.log', + r'C:\Program Files\ShopDB\EndpointAgent\agent.log', + r'C:\PROGRA~1\ShopDB\EndpointAgent\agent.log', + r'C:\temp\Unattend.xml', + r'C:\temp\unattend-original.xml', + r'C:\temp\AutoUnattend.xml', + r'C:\app\appsettings.json', + r'C:\app\AppSettings.Production.json', + r'C:\certs\server.KEY', + r'C:\certs\client.pfx', + r'C:\certs\client.p12', +]) +def test_sensitive_paths_are_refused_with_a_reason(path): + error = _validate_log_path(path) + assert error is not None + assert 'not permitted' in error + + +@pytest.mark.parametrize('path', [ + r'C:\Windows\Logs\CBS\CBS.log', + r'C:\ProgramData\Vendor\app.log', + r'C:\Program Files\Other\settings.json', + r'C:\temp\notunattend.xml', +]) +def test_ordinary_log_paths_still_pass(path): + assert _validate_log_path(path) is None + + +def _endpoint(db, hostname): + from shopdb.api import Asset, AssetType + from plugins.computers.models import Computer + from plugins.endpoints.models import EndpointState + + assettype = AssetType.query.filter_by(assettype='computer').first() + if assettype is None: + assettype = AssetType(assettype='computer', pluginname='computers', + tablename='computers') + db.session.add(assettype) + db.session.flush() + asset = Asset(assetnumber=hostname, name=hostname, + assettypeid=assettype.assettypeid, isactive=True) + db.session.add(asset) + db.session.flush() + db.session.add(Computer(assetid=asset.assetid, hostname=hostname)) + db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname, + agentversion='1.16.0.0')) + db.session.commit() + return asset.assetid + + +def _admin(db, client, username='denylistadmin'): + from shopdb.core.models import User, Role, Permission + + Permission.seed() + db.session.flush() + role = Role.query.filter_by(rolename='admin').first() + if role is None: + role = Role(rolename='admin') + db.session.add(role) + db.session.commit() + user = User(username=username, email=username + '@test.local', + passwordhash=generate_password_hash('testpass')) + user.roles.append(role) + db.session.add(user) + db.session.commit() + response = client.post('/api/auth/login', + json={'username': username, 'password': 'testpass'}) + token = response.get_json()['data']['access_token'] + return {'Authorization': f'Bearer {token}'} + + +def test_enqueue_refuses_an_answer_file_and_queues_nothing(app, client, db): + from plugins.endpoints.models import EndpointCommand + + assetid = _endpoint(db, 'denylistbox') + headers = _admin(db, client) + + response = client.post(f'/api/endpoints/{assetid}/commands', + json={'action': 'collect_log', + 'target': r'%SystemRoot%\Panther\unattend.xml'}, + headers=headers) + + assert response.status_code == 400 + message = response.get_json()['data']['error']['message'] + assert 'Panther' in message + assert EndpointCommand.query.filter_by(assetid=assetid).count() == 0 diff --git a/tests/test_plugins/test_endpoints_credentials.py b/tests/test_plugins/test_endpoints_credentials.py new file mode 100644 index 00000000..d73a4e29 --- /dev/null +++ b/tests/test_plugins/test_endpoints_credentials.py @@ -0,0 +1,589 @@ +"""Per-PC endpoint credentials: enrollment, authentication and identity binding. + +A PC enrolls once on the shared fleet key and gets its own secret. From then on +the server derives the calling asset from that secret, so a request naming a +different PC is refused (ASSET_MISMATCH), the shared key is refused for that PC +(SHARED_KEY_REFUSED), and a revoked secret is refused outright +(CREDENTIAL_INVALID) so the agent knows to enroll again. +""" + +import pytest + +from shopdb.core.models import Asset, AssetType, Setting + + +COLLECTOR_KEY = 'testcollectorkey' +ENROLL_URL = '/api/endpoints/enroll' +NONCE = 'n' * 40 +OTHER_NONCE = 'm' * 40 + + +@pytest.fixture +def keyheaders(app): + app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY + return {'X-API-Key': COLLECTOR_KEY} + + +def _computer(db, hostname, serialnumber='SER-1'): + from plugins.computers.models import Computer + + atype = AssetType.query.filter_by(assettype='computer').first() + if atype is None: + atype = AssetType(assettype='computer') + db.session.add(atype) + db.session.flush() + asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid, + serialnumber=serialnumber, isactive=True) + db.session.add(asset) + db.session.flush() + db.session.add(Computer(assetid=asset.assetid, hostname=hostname)) + db.session.commit() + return asset.assetid + + +def _enroll_body(hostname='BAY-01', serialnumber='SER-1', nonce=NONCE): + return {'hostname': hostname, 'serialnumber': serialnumber, + 'deviceid': None, 'enrollnonce': nonce, 'agentversion': '1.20.0'} + + +def _enroll(client, headers, **kwargs): + return client.post(ENROLL_URL, json=_enroll_body(**kwargs), headers=headers) + + +def _credential(client, keyheaders, **kwargs): + response = _enroll(client, keyheaders, **kwargs) + assert response.status_code == 201, response.get_data(as_text=True) + return response.get_json()['data']['credential'] + + +def _setting(db, key, value): + Setting.set(key, value, valuetype='boolean', category='plugin.endpoints') + db.session.commit() + + +def _code(response): + return response.get_json()['data']['error']['code'] + + +# -- enroll ------------------------------------------------------------------ + +def test_auto_enroll_issues_a_credential_once(client, db, app, keyheaders): + from plugins.endpoints.models import EndpointCredential + + assetid = _computer(db, 'BAY-01') + response = _enroll(client, keyheaders) + + assert response.status_code == 201, response.get_data(as_text=True) + data = response.get_json()['data'] + assert data['status'] == 'enrolled' + assert data['assetid'] == assetid + assert data['credential'].startswith('sdbe_') + assert len(data['credential']) == 48 + + row = EndpointCredential.query.filter_by(assetid=assetid).one() + assert row.enrolledvia == 'auto' + assert row.credentialprefix == data['credential'][:12] + # Hash only, never the secret. + assert data['credential'] not in (row.credentialhash, row.credentialprefix) + + again = _enroll(client, keyheaders) + assert again.status_code == 202 + assert again.get_json()['data']['reason'] == 'already enrolled' + + +@pytest.mark.parametrize('setup,body,reason', [ + ('none', {}, 'unknown hostname'), + ('duplicate', {}, 'duplicate hostname'), + ('noserial', {}, 'no serial on record'), + ('serial', {'serialnumber': 'OTHER'}, 'serial mismatch'), + ('noauto', {}, 'awaiting approval'), +]) +def test_each_pending_reason(client, db, app, keyheaders, setup, body, reason): + if setup == 'duplicate': + _computer(db, 'BAY-01') + _computer(db, 'bay-01') + elif setup == 'noserial': + _computer(db, 'BAY-01', serialnumber=None) + elif setup in ('serial', 'noauto'): + _computer(db, 'BAY-01') + if setup == 'noauto': + _setting(db, 'plugin.endpoints.autoenroll', False) + + response = _enroll(client, keyheaders, **body) + + assert response.status_code == 202, response.get_data(as_text=True) + data = response.get_json()['data'] + assert data['status'] == 'pending' + assert data['reason'] == reason + assert data['requestid'] + + +def test_serial_compare_ignores_case_and_whitespace(client, db, app, keyheaders): + _computer(db, 'BAY-01', serialnumber=' ser-1 ') + assert _enroll(client, keyheaders, serialnumber='SER-1').status_code == 201 + + +def test_enroll_validates_the_nonce(client, db, app, keyheaders): + _computer(db, 'BAY-01') + for nonce in ('short', 'x' * 129, 'bad nonce ' * 4): + response = _enroll(client, keyheaders, nonce=nonce) + assert response.status_code == 400, nonce + + +def test_enroll_requires_the_shared_key_while_it_is_allowed(client, db, app, + keyheaders): + _computer(db, 'BAY-01') + response = client.post(ENROLL_URL, json=_enroll_body()) + assert response.status_code in (401, 500) + + +def test_a_second_nonce_on_a_pending_request_stays_pending(client, db, app, + keyheaders): + from plugins.endpoints.models import EndpointEnrollRequest + + _computer(db, 'BAY-01') + _setting(db, 'plugin.endpoints.autoenroll', False) + first = _enroll(client, keyheaders) + assert first.status_code == 202 + + # Auto-enroll back on: a different installation still does not get through. + _setting(db, 'plugin.endpoints.autoenroll', True) + second = _enroll(client, keyheaders, nonce=OTHER_NONCE) + assert second.status_code == 202 + assert second.get_json()['data']['reason'] == 'nonce changed' + row = db.session.get(EndpointEnrollRequest, + first.get_json()['data']['requestid']) + assert row.enrollnonce == NONCE + + +def test_approved_request_issues_only_to_its_nonce(client, db, app, keyheaders, + auth_headers): + from plugins.endpoints.models import EndpointCredential + + assetid = _computer(db, 'BAY-01') + _setting(db, 'plugin.endpoints.autoenroll', False) + pending = _enroll(client, keyheaders) + requestid = pending.get_json()['data']['requestid'] + + approve = client.post( + f'/api/endpoints/enrollment/requests/{requestid}/approve', + json={'assetid': assetid}, headers=auth_headers) + assert approve.status_code == 200, approve.get_data(as_text=True) + + wrong = _enroll(client, keyheaders, nonce=OTHER_NONCE) + assert wrong.status_code == 403 + assert _code(wrong) == 'ENROLL_NONCE_MISMATCH' + + right = _enroll(client, keyheaders) + assert right.status_code == 201, right.get_data(as_text=True) + assert EndpointCredential.query.filter_by(assetid=assetid).one() \ + .enrolledvia == 'approved' + + +def test_shared_key_off_enroll_takes_no_auth_but_never_auto_enrolls( + client, db, app, keyheaders, auth_headers): + assetid = _computer(db, 'BAY-01') + _setting(db, 'plugin.endpoints.sharedkeyallowed', False) + + pending = client.post(ENROLL_URL, json=_enroll_body()) + assert pending.status_code == 202, pending.get_data(as_text=True) + assert pending.get_json()['data']['reason'] == 'awaiting approval' + + requestid = pending.get_json()['data']['requestid'] + client.post(f'/api/endpoints/enrollment/requests/{requestid}/approve', + json={'assetid': assetid}, headers=auth_headers) + issued = client.post(ENROLL_URL, json=_enroll_body()) + assert issued.status_code == 201 + + +def test_rejected_request_stays_rejected_for_the_same_nonce( + client, db, app, keyheaders, auth_headers): + _computer(db, 'BAY-01', serialnumber='SER-9') + pending = _enroll(client, keyheaders) + requestid = pending.get_json()['data']['requestid'] + client.post(f'/api/endpoints/enrollment/requests/{requestid}/reject', + headers=auth_headers) + + again = _enroll(client, keyheaders) + assert again.status_code == 202 + assert again.get_json()['data']['reason'] == 'rejected' + + +# -- agent auth -------------------------------------------------------------- + +def test_shared_key_is_refused_after_enrollment(client, db, app, keyheaders): + _computer(db, 'BAY-01') + secret = _credential(client, keyheaders) + + refused = client.get('/api/endpoints/alerts?hostname=BAY-01', + headers=keyheaders) + assert refused.status_code == 401 + assert _code(refused) == 'SHARED_KEY_REFUSED' + + ok = client.get('/api/endpoints/alerts', + headers={'X-Endpoint-Credential': secret}) + assert ok.status_code == 200, ok.get_data(as_text=True) + + +def test_shared_key_still_works_for_a_pc_that_has_not_enrolled( + client, db, app, keyheaders): + _computer(db, 'BAY-01') + _computer(db, 'BAY-02') + _credential(client, keyheaders) + + response = client.get('/api/endpoints/alerts?hostname=BAY-02', + headers=keyheaders) + assert response.status_code == 200 + + +def test_shared_key_switched_off_is_refused(client, db, app, keyheaders): + _computer(db, 'BAY-01') + _setting(db, 'plugin.endpoints.sharedkeyallowed', False) + + response = client.get('/api/endpoints/alerts?hostname=BAY-01', + headers=keyheaders) + assert response.status_code == 401 + assert _code(response) == 'SHARED_KEY_REFUSED' + + +def test_credential_is_invalid_after_revoke(client, db, app, keyheaders, + auth_headers): + assetid = _computer(db, 'BAY-01') + secret = _credential(client, keyheaders) + + revoke = client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke', + headers=auth_headers) + assert revoke.status_code == 200, revoke.get_data(as_text=True) + + response = client.get('/api/endpoints/alerts', + headers={'X-Endpoint-Credential': secret}) + assert response.status_code == 401 + assert _code(response) == 'CREDENTIAL_INVALID' + + # Revoked: the shared key is admitted again and the PC can re-enroll. + assert client.get('/api/endpoints/alerts?hostname=BAY-01', + headers=keyheaders).status_code == 200 + reissued = _credential(client, keyheaders) + assert reissued != secret + + +def test_garbage_credential_is_invalid(client, db, app): + response = client.get('/api/endpoints/commands/next', + headers={'X-Endpoint-Credential': 'sdbe_nope'}) + assert response.status_code == 401 + assert _code(response) == 'CREDENTIAL_INVALID' + + +def test_package_downloads_need_no_identity(client, db, app, keyheaders): + _computer(db, 'BAY-01') + secret = _credential(client, keyheaders) + response = client.get('/api/endpoints/scriptbody/999', + headers={'X-Endpoint-Credential': secret}) + assert response.status_code == 404 + + +# -- ASSET_MISMATCH ---------------------------------------------------------- + +def test_commands_next_refuses_another_hostname(client, db, app, keyheaders): + _computer(db, 'BAY-01') + _computer(db, 'BAY-02', serialnumber='SER-2') + secret = _credential(client, keyheaders) + + response = client.get('/api/endpoints/commands/next?hostname=BAY-02', + headers={'X-Endpoint-Credential': secret}) + assert response.status_code == 403 + assert _code(response) == 'ASSET_MISMATCH' + + +def test_commands_next_claims_for_the_credential_asset(client, db, app, + keyheaders): + from plugins.endpoints.models import EndpointCommand + + assetid = _computer(db, 'BAY-01') + secret = _credential(client, keyheaders) + db.session.add(EndpointCommand(assetid=assetid, action='mtconnect_status', + status=EndpointCommand.STATUS_PENDING)) + db.session.commit() + + response = client.get('/api/endpoints/commands/next', + headers={'X-Endpoint-Credential': secret}) + assert response.status_code == 200, response.get_data(as_text=True) + assert response.get_json()['data']['action'] == 'mtconnect_status' + + +def test_command_result_refuses_another_assets_command(client, db, app, + keyheaders): + from plugins.endpoints.models import EndpointCommand + + _computer(db, 'BAY-01') + other = _computer(db, 'BAY-02', serialnumber='SER-2') + secret = _credential(client, keyheaders) + command = EndpointCommand(assetid=other, action='mtconnect_status', + status=EndpointCommand.STATUS_RUNNING) + db.session.add(command) + db.session.commit() + + response = client.post( + f'/api/endpoints/commands/{command.endpointcommandid}/result', + json={'succeeded': True}, + headers={'X-Endpoint-Credential': secret}) + assert response.status_code == 403 + assert _code(response) == 'ASSET_MISMATCH' + + +def test_command_result_for_own_command_needs_no_hostname(client, db, app, + keyheaders): + from plugins.endpoints.models import EndpointCommand + + assetid = _computer(db, 'BAY-01') + secret = _credential(client, keyheaders) + command = EndpointCommand(assetid=assetid, action='mtconnect_status', + status=EndpointCommand.STATUS_RUNNING) + db.session.add(command) + db.session.commit() + + response = client.post( + f'/api/endpoints/commands/{command.endpointcommandid}/result', + json={'succeeded': True}, + headers={'X-Endpoint-Credential': secret}) + assert response.status_code == 200, response.get_data(as_text=True) + + +def _fleet_alert(db): + from plugins.notifications.models import Notification, NotificationType + + ntype = NotificationType(typename='Fleet Alert', typecolor='#d93f3f', + isactive=True) + db.session.add(ntype) + db.session.flush() + note = Notification(notificationtypeid=ntype.notificationtypeid, + notification='Line 3 is down', alertfleet=True, + alerttimeout=60, isactive=True) + db.session.add(note) + db.session.commit() + return note.notificationid + + +def test_alert_ack_refuses_another_asset(client, db, app, keyheaders): + _computer(db, 'BAY-01') + other = _computer(db, 'BAY-02', serialnumber='SER-2') + secret = _credential(client, keyheaders) + notificationid = _fleet_alert(db) + + response = client.post( + f'/api/endpoints/alerts/{notificationid}/acknowledge', + json={'assetid': other}, + headers={'X-Endpoint-Credential': secret}) + assert response.status_code == 403 + assert _code(response) == 'ASSET_MISMATCH' + + byhostname = client.post( + f'/api/endpoints/alerts/{notificationid}/acknowledge', + json={'hostname': 'BAY-02'}, + headers={'X-Endpoint-Credential': secret}) + assert byhostname.status_code == 403 + assert _code(byhostname) == 'ASSET_MISMATCH' + + +def test_alert_ack_with_no_assetid_means_the_caller(client, db, app, + keyheaders): + from plugins.notifications.models import NotificationAcknowledgement + + assetid = _computer(db, 'BAY-01') + secret = _credential(client, keyheaders) + notificationid = _fleet_alert(db) + + response = client.post( + f'/api/endpoints/alerts/{notificationid}/acknowledge', + json={'assetid': 0}, + headers={'X-Endpoint-Credential': secret}) + assert response.status_code == 201, response.get_data(as_text=True) + assert NotificationAcknowledgement.query.filter_by( + notificationid=notificationid, assetid=assetid).count() == 1 + + +def test_collector_computers_refuses_another_hostname(client, db, app, + keyheaders): + _computer(db, 'BAY-01') + _computer(db, 'BAY-02', serialnumber='SER-2') + secret = _credential(client, keyheaders) + headers = {'X-Endpoint-Credential': secret} + + mismatch = client.post('/api/collector/computers', + json={'hostname': 'BAY-02'}, headers=headers) + assert mismatch.status_code == 403, mismatch.get_data(as_text=True) + assert _code(mismatch) == 'ASSET_MISMATCH' + + # A credential never creates a PC. + unknown = client.post('/api/collector/computers', + json={'hostname': 'NEW-BAY'}, headers=headers) + assert unknown.status_code == 403 + assert _code(unknown) == 'ASSET_MISMATCH' + + own = client.post('/api/collector/computers', + json={'hostname': 'BAY-01'}, headers=headers) + assert own.status_code == 200, own.get_data(as_text=True) + + +def test_collector_refuses_shared_key_for_an_enrolled_pc(client, db, app, + keyheaders): + _computer(db, 'BAY-01') + _credential(client, keyheaders) + + response = client.post('/api/collector/computers', + json={'hostname': 'BAY-01'}, headers=keyheaders) + assert response.status_code == 401 + assert _code(response) == 'SHARED_KEY_REFUSED' + + +def test_collector_invalid_credential(client, db, app): + response = client.post('/api/collector/printers', + json={'hostname': 'BAY-01', 'queues': []}, + headers={'X-Endpoint-Credential': 'sdbe_' + 'a' * 43}) + assert response.status_code == 401 + assert _code(response) == 'CREDENTIAL_INVALID' + + +def test_endpoints_collect_route_is_the_collector(client, db, app, keyheaders): + from plugins.endpoints.models import EndpointState + + assetid = _computer(db, 'BAY-01') + secret = _credential(client, keyheaders) + + response = client.post('/api/endpoints/collect', + json={'hostname': 'BAY-01', 'agentversion': '1.20.0'}, + headers={'X-Endpoint-Credential': secret}) + assert response.status_code == 200, response.get_data(as_text=True) + assert EndpointState.query.filter_by(assetid=assetid).one() \ + .agentversion == '1.20.0' + + _computer(db, 'BAY-02', serialnumber='SER-2') + mismatch = client.post('/api/endpoints/collect', + json={'hostname': 'BAY-02'}, + headers={'X-Endpoint-Credential': secret}) + assert mismatch.status_code == 403 + assert _code(mismatch) == 'ASSET_MISMATCH' + + +def test_contract_exports_answer_through_the_endpoints_plugin(client, db, app, + keyheaders): + from shopdb.api import agent_shared_key_refused, resolve_agent_credential + + assetid = _computer(db, 'BAY-01') + assert agent_shared_key_refused('BAY-01') is False + secret = _credential(client, keyheaders) + with app.test_request_context(): + assert resolve_agent_credential(secret) == assetid + assert resolve_agent_credential('sdbe_' + 'z' * 43) is None + assert agent_shared_key_refused('BAY-01') is True + assert agent_shared_key_refused('UNKNOWN') is False + + +# -- admin gating ------------------------------------------------------------ + +@pytest.mark.parametrize('method,url', [ + ('get', '/api/endpoints/enrollment'), + ('get', '/api/endpoints/enrollment/migration'), + ('post', '/api/endpoints/enrollment/requests/1/approve'), + ('post', '/api/endpoints/enrollment/requests/1/reject'), + ('post', '/api/endpoints/enrollment/assets/1/revoke'), + ('put', '/api/endpoints/enrollment/settings'), + ('post', '/api/endpoints/enrollment/migration/wave'), +]) +def test_member_without_a_role_is_forbidden(client, db, member_headers, method, + url): + response = getattr(client, method)(url, json={}, headers=member_headers) + assert response.status_code == 403 + + +def test_enrollment_listing_and_settings(client, db, app, keyheaders, + auth_headers): + enrolled = _computer(db, 'BAY-01') + _computer(db, 'BAY-02', serialnumber='SER-2') + _computer(db, 'BAY-03', serialnumber=None) + _credential(client, keyheaders) + _enroll(client, keyheaders, hostname='BAY-03', serialnumber='X') + + response = client.get('/api/endpoints/enrollment', headers=auth_headers) + assert response.status_code == 200, response.get_data(as_text=True) + body = response.get_json() + data = body['data'] + assert data['settings'] == {'sharedkeyallowed': True, 'autoenroll': True} + assert data['counts'] == {'enrolled': 1, 'notenrolled': 1, 'pending': 1} + states = {row['hostname']: row['state'] for row in data['computers']} + assert states == {'BAY-01': 'enrolled', 'BAY-02': 'notenrolled', + 'BAY-03': 'pending'} + row = next(r for r in data['computers'] if r['assetid'] == enrolled) + assert row['credentialprefix'].startswith('sdbe_') + assert row['enrolledvia'] == 'auto' + assert [r['reason'] for r in data['requests'] + if r['status'] == 'pending'] == ['no serial on record'] + assert body['meta']['pagination']['total'] == 3 + + filtered = client.get('/api/endpoints/enrollment?state=enrolled', + headers=auth_headers).get_json()['data'] + assert [r['hostname'] for r in filtered['computers']] == ['BAY-01'] + + put = client.put('/api/endpoints/enrollment/settings', + json={'sharedkeyallowed': False}, headers=auth_headers) + assert put.status_code == 200 + assert put.get_json()['data'] == {'sharedkeyallowed': False, + 'autoenroll': True} + bad = client.put('/api/endpoints/enrollment/settings', + json={'autoenroll': 'yes'}, headers=auth_headers) + assert bad.status_code == 400 + + +def test_approve_refuses_an_enrolled_computer(client, db, app, keyheaders, + auth_headers): + assetid = _computer(db, 'BAY-01') + _credential(client, keyheaders) + pending = _enroll(client, keyheaders, nonce=OTHER_NONCE) + requestid = pending.get_json()['data']['requestid'] + + response = client.post( + f'/api/endpoints/enrollment/requests/{requestid}/approve', + json={'assetid': assetid}, headers=auth_headers) + assert response.status_code == 409 + + +# -- server certificate pin -------------------------------------------------- + +def test_server_cert_reports_the_spki_pin(client, db, auth_headers, tmp_path, + monkeypatch): + import hashlib + from datetime import datetime, timedelta, timezone + + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.x509.oid import NameOID + + from plugins.endpoints.api import routes as endpoint_routes + + key = ec.generate_private_key(ec.SECP256R1()) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'pin.example.test')]) + now = datetime.now(timezone.utc) + certificate = (x509.CertificateBuilder() + .subject_name(name).issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(days=1)) + .not_valid_after(now + timedelta(days=30)) + .sign(key, hashes.SHA256())) + certdir = tmp_path / 'certs' + certdir.mkdir() + (certdir / 'server.crt').write_bytes( + certificate.public_bytes(serialization.Encoding.PEM)) + monkeypatch.chdir(tmp_path) + if hasattr(endpoint_routes, '_server_cert_dir'): + monkeypatch.setattr(endpoint_routes, '_server_cert_dir', lambda: certdir) + + expected = hashlib.sha256(key.public_key().public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo)).hexdigest() + + response = client.get('/api/endpoints/settings/server-cert', + headers=auth_headers) + assert response.status_code == 200, response.get_data(as_text=True) + assert response.get_json()['data']['spkisha256'] == expected diff --git a/tests/test_plugins/test_endpoints_credentials_migration.py b/tests/test_plugins/test_endpoints_credentials_migration.py new file mode 100644 index 00000000..dd28ac2f --- /dev/null +++ b/tests/test_plugins/test_endpoints_credentials_migration.py @@ -0,0 +1,139 @@ +"""Migration 0023: per-PC credential tables, and the client certificate cleanup. + +Two starting points matter. A database that ran the 2026-09-14 builds of +0020-0022 carries app_fingerprint and five certificate columns on assets; a +fresh one carries neither. Both must reach the same schema, twice over. +""" + +import importlib.util +from pathlib import Path + +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import create_engine, inspect, text + + +_VERSIONS = (Path(__file__).resolve().parent.parent.parent + / 'plugins' / 'endpoints' / 'migrations' / 'versions') +_CHAIN = ('0020_app_delta_fingerprints.py', '0021_client_certificates.py', + '0022_client_cert_revocation.py', '0023_endpoint_credentials.py') +_CERT_COLUMNS = {'client_cert_fingerprint', 'cert_issued_at', 'cert_expires_at', + 'cert_revoked_at', 'cert_revocation_reason'} + + +def _load(filename): + path = _VERSIONS / filename + spec = importlib.util.spec_from_file_location('endpoints_mig_' + path.stem, + str(path)) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _run(connection, step='upgrade', files=_CHAIN): + operations = Operations(MigrationContext.configure(connection)) + operations._install_proxy() + try: + for filename in files: + getattr(_load(filename), step)() + finally: + operations._remove_proxy() + connection.commit() + + +def _base_schema(connection, legacy): + certcolumns = '' + if legacy: + certcolumns = (', client_cert_fingerprint VARCHAR(64), ' + 'cert_issued_at DATETIME, cert_expires_at DATETIME, ' + 'cert_revoked_at DATETIME, ' + 'cert_revocation_reason VARCHAR(255)') + connection.execute(text( + 'CREATE TABLE assets (assetid INTEGER PRIMARY KEY, ' + 'assetnumber VARCHAR(50)' + certcolumns + ')')) + fingerprint = ', app_fingerprint VARCHAR(64)' if legacy else '' + connection.execute(text( + 'CREATE TABLE endpointapplications (endpointapplicationid INTEGER ' + 'PRIMARY KEY, assetid INTEGER, displayname VARCHAR(255)' + + fingerprint + ')')) + if legacy: + connection.execute(text( + 'CREATE INDEX idx_assets_cert_fingerprint ON assets ' + '(client_cert_fingerprint)')) + connection.execute(text( + 'CREATE UNIQUE INDEX idx_endpointapplications_fingerprint ON ' + 'endpointapplications (assetid, app_fingerprint)')) + connection.execute(text( + "INSERT INTO endpointapplications VALUES (1, 7, 'App', '" + + 'a' * 64 + "')")) + connection.commit() + + +def _assert_head_schema(connection): + inspector = inspect(connection) + assetcolumns = {c['name'] for c in inspector.get_columns('assets')} + assert not assetcolumns & _CERT_COLUMNS + appcolumns = {c['name'] for c in inspector.get_columns('endpointapplications')} + assert 'appfingerprint' in appcolumns + assert 'app_fingerprint' not in appcolumns + indexed = {tuple(ix['column_names']): ix['unique'] for ix in + inspector.get_indexes('endpointapplications')} + assert indexed.get(('assetid', 'appfingerprint')) in (True, 1) + + for table in ('endpointcredentials', 'endpointenrollrequests', + 'endpointmigrationwaves'): + assert inspector.has_table(table), table + credentialcolumns = {c['name'] for c in inspector.get_columns('endpointcredentials')} + assert credentialcolumns == { + 'credentialid', 'assetid', 'credentialhash', 'credentialprefix', + 'enrolledvia', 'issuedat', 'lastusedat', 'revokedat', 'revokedby'} + requestindexes = [tuple(ix['column_names']) for ix in + inspector.get_indexes('endpointenrollrequests')] + assert requestindexes.count(('hostname', 'serialnumber')) == 1 + + +def test_upgrade_from_the_client_certificate_builds(): + engine = create_engine('sqlite://') + with engine.connect() as connection: + _base_schema(connection, legacy=True) + _run(connection) + _assert_head_schema(connection) + # Data survives the rename. + assert connection.execute(text( + 'SELECT appfingerprint FROM endpointapplications')).scalar() == 'a' * 64 + + _run(connection) + _assert_head_schema(connection) + + +def test_upgrade_on_a_fresh_database(): + engine = create_engine('sqlite://') + with engine.connect() as connection: + _base_schema(connection, legacy=False) + _run(connection) + _assert_head_schema(connection) + _run(connection) + _assert_head_schema(connection) + + +def test_retired_certificate_revisions_are_no_ops(): + engine = create_engine('sqlite://') + with engine.connect() as connection: + _base_schema(connection, legacy=False) + _run(connection, files=_CHAIN[1:3]) + columns = {c['name'] for c in inspect(connection).get_columns('assets')} + assert not columns & _CERT_COLUMNS + + +def test_downgrade_drops_the_credential_tables(): + engine = create_engine('sqlite://') + with engine.connect() as connection: + _base_schema(connection, legacy=False) + _run(connection) + _run(connection, step='downgrade', files=_CHAIN[3:]) + inspector = inspect(connection) + for table in ('endpointcredentials', 'endpointenrollrequests', + 'endpointmigrationwaves'): + assert not inspector.has_table(table), table + _run(connection, files=_CHAIN[3:]) + _assert_head_schema(connection) diff --git a/tests/test_plugins/test_endpoints_enrollment_migration.py b/tests/test_plugins/test_endpoints_enrollment_migration.py new file mode 100644 index 00000000..3f3d94e8 --- /dev/null +++ b/tests/test_plugins/test_endpoints_enrollment_migration.py @@ -0,0 +1,278 @@ +"""Moving the existing fleet onto per-PC credentials. + +A PC on agent 1.19 or older holds only the shared key. It moves by receiving +agent 1.20, which enrolls itself. The migration surface computes, per computer, +whether that upgrade can be pushed, and pushes it in waves through the same +enqueue path as the single-asset agentupgrade route, packaging once per wave. + +The agent store is isolated per test, as in test_endpoints_agent_upgrade_route. +""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from plugins.endpoints.api import agentregistry, agentstore, agentupgradescript + +MIGRATION_URL = '/api/endpoints/enrollment/migration' +WAVE_URL = '/api/endpoints/enrollment/migration/wave' + +_PE_BYTES = b'MZ' + b'\x00' * 4096 + + +@pytest.fixture(autouse=True) +def isolate_store(tmp_path, monkeypatch): + siteagent = tmp_path / 'siteagent' + packages = tmp_path / 'applicationpackages' + scripts = tmp_path / 'scripts' + for directory in (siteagent, packages, scripts): + directory.mkdir() + monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(siteagent)) + monkeypatch.setattr(agentregistry, 'application_package_location', + lambda: (str(packages), '/api/applications/package/')) + monkeypatch.setattr(agentupgradescript, 'script_body_location', + lambda: (str(scripts), '/api/scripts/body/')) + return tmp_path + + +def _now(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _publish_agent(version='1.20.0'): + agentstore.store_version('agent', version, _PE_BYTES, make_current=True) + + +def _pc(db, hostname, agentversion='1.19.0', serialnumber='SER', + reportedago=timedelta(hours=1), reports=True): + from shopdb.api import Asset, AssetType + from plugins.computers.models import Computer + from plugins.endpoints.models import EndpointState + + atype = AssetType.query.filter_by(assettype='computer').first() + if atype is None: + atype = AssetType(assettype='computer') + db.session.add(atype) + db.session.flush() + asset = Asset(assetnumber=hostname + '-asset', name=hostname, + assettypeid=atype.assettypeid, isactive=True, + serialnumber=serialnumber) + db.session.add(asset) + db.session.flush() + db.session.add(Computer(assetid=asset.assetid, hostname=hostname)) + if reports: + db.session.add(EndpointState( + assetid=asset.assetid, hostname=hostname, agentversion=agentversion, + lastreportedat=_now() - reportedago if reportedago else None)) + db.session.commit() + return asset.assetid + + +def _rows(client, headers, query=''): + response = client.get(MIGRATION_URL + query, headers=headers) + assert response.status_code == 200, response.get_data(as_text=True) + return response.get_json()['data'] + + +def _by_host(data): + return {row['hostname']: row for row in data['computers']} + + +def test_readiness_states(client, db, app, auth_headers): + from plugins.endpoints.api.agentauth import generate_credential + from plugins.endpoints.api.enrollment import issue_credential + from plugins.endpoints.models import EndpointCommand, EndpointEnrollRequest + + _publish_agent() + enrolled = _pc(db, 'PC-ENROLLED') + issue_credential(enrolled, 'auto') + pending = _pc(db, 'PC-PENDING') + db.session.add(EndpointEnrollRequest( + hostname='pc-pending', serialnumber='SER', enrollnonce=generate_credential(), + status='pending', reason='awaiting approval', matchedassetid=pending, + requestedat=_now(), lastseenat=_now())) + upgrading = _pc(db, 'PC-UPGRADING') + _pc(db, 'PC-READY') + _pc(db, 'PC-BLOCKED', serialnumber=None, reportedago=timedelta(days=3)) + _pc(db, 'PC-SHAREONLY', reports=False) + _pc(db, 'PC-NEW', agentversion='1.20.0.0') + db.session.commit() + + seeded_app = agentregistry.register_current_agent_package() + assert seeded_app is not None + scriptid = agentupgradescript.seed_upgrade_script()['scriptid'] + db.session.add(EndpointCommand(assetid=upgrading, action='run_script', + target=f'script:{scriptid}', + status=EndpointCommand.STATUS_PENDING)) + db.session.commit() + + data = _rows(client, auth_headers) + rows = _by_host(data) + assert data['target'] == '1.20.0' + assert rows['PC-ENROLLED']['state'] == 'enrolled' + assert rows['PC-PENDING']['state'] == 'pending' + assert rows['PC-UPGRADING']['state'] == 'upgrading' + assert rows['PC-READY']['state'] == 'ready' + assert rows['PC-READY']['blockers'] == [] + assert rows['PC-BLOCKED']['state'] == 'blocked' + assert rows['PC-BLOCKED']['blockers'] == ['offline', 'no serial on record'] + assert rows['PC-SHAREONLY']['state'] == 'notreporting' + assert rows['PC-NEW']['state'] == 'blocked' + assert rows['PC-NEW']['blockers'] == ['awaiting enrollment'] + assert data['counts'] == {'enrolled': 1, 'pending': 1, 'upgrading': 1, + 'ready': 1, 'blocked': 2, 'notreporting': 1} + + only = _rows(client, auth_headers, '?state=ready') + assert [row['hostname'] for row in only['computers']] == ['PC-READY'] + assert client.get(MIGRATION_URL + '?state=bogus', + headers=auth_headers).status_code == 400 + + +def test_every_blocker_is_listed(client, db, app, auth_headers, monkeypatch): + from plugins.endpoints import commands + from plugins.endpoints.api import enrollment + + # No package in the store at all. + _pc(db, 'DUP') + _pc(db, 'dup', agentversion='1.5.0') + lockedid = _pc(db, 'LOCKED') + commands.PLACE_ENDPOINT_LOCK(assetid=lockedid, reason='bench repair') + db.session.commit() + monkeypatch.setattr(enrollment, 'minimum_agent_version_for', + lambda action: '1.10.0') + + rows = {} + for row in _rows(client, auth_headers)['computers']: + rows.setdefault(row['hostname'], []).append(row) + assert rows['dup'][0]['blockers'] == ['duplicate hostname', + 'no agent package in store', + 'agent too old for remote upgrade'] + assert rows['LOCKED'][0]['blockers'] == ['commands locked', + 'no agent package in store'] + + +def test_an_old_store_build_counts_as_no_package(client, db, app, auth_headers): + _publish_agent('1.19.5') + _pc(db, 'PC-A') + row = _by_host(_rows(client, auth_headers))['PC-A'] + assert row['blockers'] == ['no agent package in store'] + + +def test_version_compare_is_numeric(client, db, app, auth_headers): + _publish_agent('1.20.0') + _pc(db, 'PC-NINE', agentversion='1.9.0') + _pc(db, 'PC-HUNDRED', agentversion='1.100.0') + rows = _by_host(_rows(client, auth_headers)) + assert rows['PC-NINE']['state'] == 'ready' + assert rows['PC-HUNDRED']['blockers'] == ['awaiting enrollment'] + + +def test_wave_next_n_queues_ready_pcs_in_hostname_order(client, db, app, + auth_headers): + from plugins.endpoints.models import EndpointCommand, EndpointMigrationWave + + _publish_agent() + third = _pc(db, 'PC-C') + first = _pc(db, 'PC-A') + second = _pc(db, 'PC-B') + _pc(db, 'PC-OFFLINE', reportedago=timedelta(days=2)) + + response = client.post(WAVE_URL, json={'next': 2}, headers=auth_headers) + assert response.status_code == 201, response.get_data(as_text=True) + data = response.get_json()['data'] + assert data['queued'] == [first, second] + assert data['skipped'] == [] + + commands = EndpointCommand.query.filter_by(action='run_script').all() + assert sorted(c.assetid for c in commands) == [first, second] + wave = EndpointMigrationWave.query.one() + assert wave.waveid == data['waveid'] + assert wave.assetidlist == [first, second] + + migration = _rows(client, auth_headers) + rows = _by_host(migration) + assert rows['PC-A']['state'] == 'upgrading' + assert rows['PC-C']['state'] == 'ready' + assert migration['waves'] == [{ + 'waveid': wave.waveid, 'createdat': migration['waves'][0]['createdat'], + 'createdby': 'testadmin', 'size': 2, 'queued': 2, 'enrolled': 0, + 'failed': 0, 'done': False}] + assert third not in data['queued'] + + +def test_wave_explicit_ids_rechecks_readiness(client, db, app, auth_headers): + _publish_agent() + ready = _pc(db, 'PC-READY') + offline = _pc(db, 'PC-OFFLINE', reportedago=timedelta(days=2)) + noagent = _pc(db, 'PC-NOAGENT', reports=False) + + response = client.post(WAVE_URL, + json={'assetids': [ready, offline, noagent, 999999]}, + headers=auth_headers) + assert response.status_code == 201, response.get_data(as_text=True) + data = response.get_json()['data'] + assert data['queued'] == [ready] + assert data['skipped'] == [ + {'assetid': offline, 'reason': 'offline'}, + {'assetid': noagent, 'reason': 'notreporting'}, + {'assetid': 999999, 'reason': 'not a computer'}, + ] + + +def test_wave_packages_once(client, db, app, auth_headers, monkeypatch): + from plugins.endpoints.api import routes + + _publish_agent() + ids = [_pc(db, f'PC-{n}') for n in range(3)] + calls = [] + real = routes.prepare_agent_upgrade + + def counting(): + calls.append(1) + return real() + + monkeypatch.setattr(routes, 'prepare_agent_upgrade', counting) + response = client.post(WAVE_URL, json={'assetids': ids}, + headers=auth_headers) + assert response.status_code == 201 + assert response.get_json()['data']['queued'] == ids + assert calls == [1] + + +def test_wave_progress_counts_enrolled_and_failed(client, db, app, + auth_headers): + from plugins.endpoints.api.enrollment import issue_credential + from plugins.endpoints.models import EndpointCommand + + _publish_agent() + good = _pc(db, 'PC-GOOD') + bad = _pc(db, 'PC-BAD') + client.post(WAVE_URL, json={'assetids': [good, bad]}, headers=auth_headers) + + issue_credential(good, 'auto') + EndpointCommand.query.filter_by(assetid=bad).one().status = \ + EndpointCommand.STATUS_FAILED + db.session.commit() + + wave = _rows(client, auth_headers)['waves'][0] + assert (wave['size'], wave['enrolled'], wave['failed'], wave['done']) == \ + (2, 1, 1, True) + + +def test_wave_without_ready_pcs_creates_nothing(client, db, app, auth_headers): + from plugins.endpoints.models import EndpointMigrationWave + + offline = _pc(db, 'PC-OFFLINE', reportedago=timedelta(days=2)) + response = client.post(WAVE_URL, json={'assetids': [offline]}, + headers=auth_headers) + assert response.status_code == 200 + assert response.get_json()['data']['waveid'] is None + assert EndpointMigrationWave.query.count() == 0 + + +@pytest.mark.parametrize('body', [{}, {'next': 0}, {'next': 101}, + {'assetids': []}, {'assetids': ['1']}, + {'assetids': list(range(1, 102))}]) +def test_wave_validation(client, db, app, auth_headers, body): + assert client.post(WAVE_URL, json=body, + headers=auth_headers).status_code == 400 diff --git a/tests/test_plugins/test_endpoints_fleet_alert_scheduling.py b/tests/test_plugins/test_endpoints_fleet_alert_scheduling.py index b034332a..43da234f 100644 --- a/tests/test_plugins/test_endpoints_fleet_alert_scheduling.py +++ b/tests/test_plugins/test_endpoints_fleet_alert_scheduling.py @@ -1,254 +1,114 @@ -"""Test fleet alert scheduling respects starttime. +"""Fleet alert scheduling respects starttime and endtime. -Regression test for bug where fleet alerts with future starttime were sent -immediately instead of waiting for the scheduled time. +Regression test for a bug where fleet alerts with a future starttime were sent +immediately instead of waiting for the scheduled time. The agent polls on the +collector credential, and the hostname must resolve to a Computer, so the +fixtures mirror test_endpoints_fleet_alerts.py. """ -import pytest from datetime import datetime, timedelta, timezone +from shopdb.core.models import Asset, AssetType -def test_fleet_alert_respects_future_starttime(client, auth_headers, sample_asset): - """Fleet alerts with future starttime should not appear until that time.""" + +ALERTS_URL = '/api/endpoints/alerts' +COLLECTOR_KEY = 'testcollectorkey' + + +def _collector_headers(app): + app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY + return {'X-API-Key': COLLECTOR_KEY} + + +def _computer(db, hostname): + from plugins.computers.models import Computer + + atype = AssetType.query.filter_by(assettype='computer').first() + if not atype: + atype = AssetType(assettype='computer') + db.session.add(atype) + db.session.flush() + asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid, + isactive=True) + db.session.add(asset) + db.session.flush() + db.session.add(Computer(assetid=asset.assetid, hostname=hostname)) + db.session.commit() + return asset + + +def _fleet_alert(db, message, starttime=None, endtime=None): from plugins.notifications.models import Notification, NotificationType - from plugins.endpoints.models import EndpointState - from shopdb.api import db - # Create notification type - ntype = NotificationType( - typename='System Maintenance', - typecolor='#ffc107', - isactive=True - ) - db.session.add(ntype) - db.session.flush() + ntype = NotificationType.query.filter_by(typename='Fleet Alert').first() + if not ntype: + ntype = NotificationType(typename='Fleet Alert', typecolor='#ffc107', + isactive=True) + db.session.add(ntype) + db.session.flush() + note = Notification(notification=message, + notificationtypeid=ntype.notificationtypeid, + starttime=starttime, endtime=endtime, isactive=True, + alertfleet=True, alertscope=None, alerttimeout=0) + db.session.add(note) + db.session.commit() + return note - # Create endpoint state for the asset - state = EndpointState( - assetid=sample_asset.assetid, - hostname='TEST-PC-01', - agentversion='1.0.0', - lastreportedat=datetime.utcnow() - ) - db.session.add(state) - db.session.flush() - now = datetime.now(timezone.utc).replace(tzinfo=None) - future_time = now + timedelta(minutes=20) # 20 minutes from now - end_time = future_time + timedelta(hours=1) +def _now(): + return datetime.now(timezone.utc).replace(tzinfo=None) - # Create fleet alert scheduled for 20 minutes in the future - notification = Notification( - notification='Scheduled maintenance at 3:30 PM', - notificationtypeid=ntype.notificationtypeid, - starttime=future_time, - endtime=end_time, - isactive=True, - alertfleet=True, - alertscope=None, # All endpoints - alerttimeout=0 - ) - db.session.add(notification) + +def _poll(client, app, hostname): + response = client.get(f'{ALERTS_URL}?hostname={hostname}', + headers=_collector_headers(app)) + assert response.status_code == 200, response.get_data(as_text=True) + body = response.get_json() + assert body['status'] == 'success' + return body['data'] + + +def test_fleet_alert_respects_future_starttime(client, db, app): + """A future starttime keeps the alert off the endpoint until it passes.""" + _computer(db, 'TEST-PC-01') + now = _now() + note = _fleet_alert(db, 'Scheduled maintenance at 3:30 PM', + starttime=now + timedelta(minutes=20), + endtime=now + timedelta(hours=2)) + + assert _poll(client, app, 'TEST-PC-01') == [] + + note.starttime = now - timedelta(minutes=5) db.session.commit() - # Agent polls for fleet alerts NOW (before starttime) - response = client.get( - '/api/endpoints/alerts?hostname=TEST-PC-01', - headers=auth_headers - ) - - assert response.status_code == 200 - data = response.get_json() - assert data['status'] == 'success' - - # BUG FIX: Alert should NOT appear because starttime is in the future - alerts = data['data'] - assert len(alerts) == 0, 'Fleet alert with future starttime should not appear yet' - - # Simulate time passing - set starttime to the past - notification.starttime = now - timedelta(minutes=5) - db.session.commit() - - # Agent polls again (now starttime has passed) - response = client.get( - '/api/endpoints/alerts?hostname=TEST-PC-01', - headers=auth_headers - ) - - assert response.status_code == 200 - data = response.get_json() - assert data['status'] == 'success' - - # Now the alert SHOULD appear - alerts = data['data'] - assert len(alerts) == 1 - assert alerts[0]['notificationId'] == notification.notificationid + alerts = _poll(client, app, 'TEST-PC-01') + assert [a['notificationId'] for a in alerts] == [note.notificationid] assert alerts[0]['title'] == 'Scheduled maintenance at 3:30 PM' -def test_fleet_alert_with_no_starttime_appears_immediately(client, auth_headers, sample_asset): - """Fleet alerts with no starttime should appear immediately.""" - from plugins.notifications.models import Notification, NotificationType - from plugins.endpoints.models import EndpointState - from shopdb.api import db +def test_fleet_alert_with_no_starttime_appears_immediately(client, db, app): + _computer(db, 'TEST-PC-02') + note = _fleet_alert(db, 'Immediate alert - urgent') - # Create notification type - ntype = NotificationType( - typename='System Alert', - typecolor='#dc3545', - isactive=True - ) - db.session.add(ntype) - db.session.flush() - - # Create endpoint state - state = EndpointState( - assetid=sample_asset.assetid, - hostname='TEST-PC-02', - agentversion='1.0.0', - lastreportedat=datetime.utcnow() - ) - db.session.add(state) - db.session.flush() - - # Create fleet alert with no starttime (immediate) - notification = Notification( - notification='Immediate alert - urgent', - notificationtypeid=ntype.notificationtypeid, - starttime=None, # No starttime = immediate - endtime=None, - isactive=True, - alertfleet=True, - alertscope=None, - alerttimeout=0 - ) - db.session.add(notification) - db.session.commit() - - # Agent polls for fleet alerts - response = client.get( - '/api/endpoints/alerts?hostname=TEST-PC-02', - headers=auth_headers - ) - - assert response.status_code == 200 - data = response.get_json() - assert data['status'] == 'success' - - # Alert should appear immediately - alerts = data['data'] - assert len(alerts) == 1 - assert alerts[0]['notificationId'] == notification.notificationid + alerts = _poll(client, app, 'TEST-PC-02') + assert [a['notificationId'] for a in alerts] == [note.notificationid] assert alerts[0]['title'] == 'Immediate alert - urgent' -def test_fleet_alert_past_starttime_appears(client, auth_headers, sample_asset): - """Fleet alerts with past starttime should appear.""" - from plugins.notifications.models import Notification, NotificationType - from plugins.endpoints.models import EndpointState - from shopdb.api import db +def test_fleet_alert_past_starttime_appears(client, db, app): + _computer(db, 'TEST-PC-03') + note = _fleet_alert(db, 'Alert started 1 hour ago', + starttime=_now() - timedelta(hours=1)) - ntype = NotificationType( - typename='Update', - typecolor='#17a2b8', - isactive=True - ) - db.session.add(ntype) - db.session.flush() - - state = EndpointState( - assetid=sample_asset.assetid, - hostname='TEST-PC-03', - agentversion='1.0.0', - lastreportedat=datetime.utcnow() - ) - db.session.add(state) - db.session.flush() - - now = datetime.now(timezone.utc).replace(tzinfo=None) - past_time = now - timedelta(hours=1) - - # Create fleet alert that started 1 hour ago - notification = Notification( - notification='Alert started 1 hour ago', - notificationtypeid=ntype.notificationtypeid, - starttime=past_time, - endtime=None, - isactive=True, - alertfleet=True, - alertscope=None, - alerttimeout=0 - ) - db.session.add(notification) - db.session.commit() - - # Agent polls for fleet alerts - response = client.get( - '/api/endpoints/alerts?hostname=TEST-PC-03', - headers=auth_headers - ) - - assert response.status_code == 200 - data = response.get_json() - assert data['status'] == 'success' - - # Alert should appear (starttime is in the past) - alerts = data['data'] - assert len(alerts) == 1 - assert alerts[0]['notificationId'] == notification.notificationid + alerts = _poll(client, app, 'TEST-PC-03') + assert [a['notificationId'] for a in alerts] == [note.notificationid] -def test_fleet_alert_expired_does_not_appear(client, auth_headers, sample_asset): - """Fleet alerts with past endtime should not appear.""" - from plugins.notifications.models import Notification, NotificationType - from plugins.endpoints.models import EndpointState - from shopdb.api import db +def test_fleet_alert_expired_does_not_appear(client, db, app): + _computer(db, 'TEST-PC-04') + now = _now() + _fleet_alert(db, 'This alert already ended', + starttime=now - timedelta(hours=2), + endtime=now - timedelta(hours=1)) - ntype = NotificationType( - typename='Expired', - typecolor='#6c757d', - isactive=True - ) - db.session.add(ntype) - db.session.flush() - - state = EndpointState( - assetid=sample_asset.assetid, - hostname='TEST-PC-04', - agentversion='1.0.0', - lastreportedat=datetime.utcnow() - ) - db.session.add(state) - db.session.flush() - - now = datetime.now(timezone.utc).replace(tzinfo=None) - past_start = now - timedelta(hours=2) - past_end = now - timedelta(hours=1) - - # Create expired fleet alert - notification = Notification( - notification='This alert already ended', - notificationtypeid=ntype.notificationtypeid, - starttime=past_start, - endtime=past_end, # Ended 1 hour ago - isactive=True, - alertfleet=True, - alertscope=None, - alerttimeout=0 - ) - db.session.add(notification) - db.session.commit() - - # Agent polls for fleet alerts - response = client.get( - '/api/endpoints/alerts?hostname=TEST-PC-04', - headers=auth_headers - ) - - assert response.status_code == 200 - data = response.get_json() - assert data['status'] == 'success' - - # Alert should NOT appear (endtime is in the past) - alerts = data['data'] - assert len(alerts) == 0 + assert _poll(client, app, 'TEST-PC-04') == [] diff --git a/tests/test_plugins/test_endpoints_fleet_alerts.py b/tests/test_plugins/test_endpoints_fleet_alerts.py index 681ac8ed..8335e162 100644 --- a/tests/test_plugins/test_endpoints_fleet_alerts.py +++ b/tests/test_plugins/test_endpoints_fleet_alerts.py @@ -427,14 +427,42 @@ def test_an_unknown_scope_key_is_ignored_not_fatal(client, db, app): assert _ids(client, app, 'BAY-01') == [alert.notificationid] -def test_a_non_dict_scope_falls_back_to_the_whole_fleet(client, db, app): - """Garbled scope JSON must not stop an alert reaching the floor.""" +def test_a_non_dict_scope_fails_closed(client, db, app): + """A scope that is present but not an object is corrupt, not fleet-wide. + + Create/update refuse such a scope, so a row carrying one did not come from + an operator's intent to reach everyone. Broadcasting it could put a + one-bay alert on every screen, so it reaches nobody. + """ _computer(db, 'BAY-01') alert = _fleet_alert(db) alert.alertscope = 'BAY-01' db.session.commit() + assert _ids(client, app, 'BAY-01') == [] + + alert.alertscope = ['BAY-01'] + db.session.commit() + assert _ids(client, app, 'BAY-01') == [] + + +def test_a_json_string_scope_is_parsed(client, db, app): + """Legacy rows written through the TEXT column hold the object as text.""" + _computer(db, 'BAY-01') + _computer(db, 'BAY-02') + alert = _fleet_alert(db) + alert.alertscope = '{"hostname": "BAY-01"}' + db.session.commit() assert _ids(client, app, 'BAY-01') == [alert.notificationid] + assert _ids(client, app, 'BAY-02') == [] + + +def test_scope_keys_match_the_notifications_validator(): + """Create/update validation and the matcher must agree on the key set.""" + from plugins.endpoints.api.routes import _ALERT_SCOPE_KEYS + from plugins.notifications.models.notification import ALERT_SCOPE_KEYS + + assert set(_ALERT_SCOPE_KEYS) == set(ALERT_SCOPE_KEYS) def test_scoping_does_not_leak_into_acknowledgement(client, db, app): diff --git a/tests/test_plugins/test_endpoints_health_savepoint.py b/tests/test_plugins/test_endpoints_health_savepoint.py new file mode 100644 index 00000000..59e6f8d4 --- /dev/null +++ b/tests/test_plugins/test_endpoints_health_savepoint.py @@ -0,0 +1,53 @@ +"""Health evaluation failing with a database error must not sink the check-in. + +The health rollup is fail-open: a caught exception leaves the previous verdict +standing. A caught IntegrityError from a flush, though, leaves the session +needing a rollback, and the outer commit of the whole check-in would then fail. +The evaluation runs inside a SAVEPOINT so only its own writes are discarded. +""" + +from shopdb.core.models import Asset, AssetType + + +def _computer(db, hostname): + from plugins.computers.models import Computer + + atype = AssetType.query.filter_by(assettype='computer').first() + if not atype: + atype = AssetType(assettype='computer') + db.session.add(atype) + db.session.flush() + asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid, + isactive=True) + db.session.add(asset) + db.session.flush() + db.session.add(Computer(assetid=asset.assetid, hostname=hostname)) + db.session.commit() + return asset + + +def test_integrity_error_in_health_keeps_the_checkin(client, db, app, + monkeypatch): + from plugins.endpoints.plugin import EndpointsPlugin + from plugins.endpoints.models import EndpointState + from plugins.endpoints.services import health + + calls = [] + + def exploding_health(assetid, state, computertypeid=None): + calls.append(assetid) + # A duplicate unique value: the flush raises IntegrityError. + db.session.add(AssetType(assettype='computer')) + db.session.flush() + + monkeypatch.setattr(health, 'evaluate_endpoint_health', exploding_health) + + asset = _computer(db, 'SAVEPT01') + plugin = EndpointsPlugin() + plugin.apply_collector_payload({'hostname': 'SAVEPT01', 'services': []}) + db.session.commit() + + assert calls == [asset.assetid] + state = EndpointState.query.filter_by(assetid=asset.assetid).first() + assert state is not None + assert AssetType.query.filter_by(assettype='computer').count() == 1 diff --git a/tests/test_plugins/test_endpoints_policy_override.py b/tests/test_plugins/test_endpoints_policy_override.py index 5c600378..3539809c 100644 --- a/tests/test_plugins/test_endpoints_policy_override.py +++ b/tests/test_plugins/test_endpoints_policy_override.py @@ -175,6 +175,44 @@ def test_an_admin_override_is_accepted_and_audited(client, db): assert entry is not None, 'break-glass must leave an audit record' +def test_a_refused_override_leaves_no_hold_and_no_audit_row(client, db): + """Validation after the override branch must run before anything is written. + + A targetless service action, and a second command while one is in flight, + are both refused. Neither may leave a policyoverridden audit row or a + PolicyActionHold behind, or the record claims a break-glass that never ran. + """ + from shopdb.api import AuditLog + from plugins.computers.models import PolicyActionHold + from plugins.endpoints.models import EndpointCommand + + _enable_policies(db) + assetid = _endpoint(db, 'refusedbox') + _deny_everything(db, assetid) + headers = _user(db, client, 'refusedadmin', + ['endpoints.view', 'endpoints.command'], admin=True) + + missing_target = client.post(_url(assetid), headers=headers, + json={'action': 'service_status', + 'policyoverride': True, + 'overridereason': 'no target given'}) + assert missing_target.status_code == 400, missing_target.get_json() + + db.session.add(EndpointCommand(assetid=assetid, action='collect_now', + status=EndpointCommand.STATUS_PENDING)) + db.session.commit() + in_flight = client.post(_url(assetid), headers=headers, + json={'action': 'service_status', + 'target': 'spooler', + 'policyoverride': True, + 'overridereason': 'queue is busy'}) + assert in_flight.status_code == 409, in_flight.get_json() + + db.session.expire_all() + assert AuditLog.query.filter_by(action='policyoverridden').count() == 0 + assert PolicyActionHold.query.filter_by(assetid=assetid).count() == 0 + + def test_override_is_checked_after_the_elevated_permission_gate(client, db): """The override widens WHERE an action may run, never WHICH actions the caller may use. diff --git a/tests/test_plugins/test_endpoints_server_certificate.py b/tests/test_plugins/test_endpoints_server_certificate.py new file mode 100644 index 00000000..3bbc90c2 --- /dev/null +++ b/tests/test_plugins/test_endpoints_server_certificate.py @@ -0,0 +1,157 @@ +"""Server TLS certificate upload and generate routes. + +The new pair must be parsed, and the key proven to match the certificate, +BEFORE the existing files are backed up or replaced: a bad upload must leave +nginx's current certificate exactly where it was. cn and san must be plain +hostname/IP syntax; anything else (newlines, '=', brackets) is refused. +""" + +import io +from datetime import datetime, timedelta, timezone + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import NameOID + +from plugins.endpoints.api import routes as endpoint_routes + +UPLOAD_URL = '/api/endpoints/settings/server-cert/upload' +GENERATE_URL = '/api/endpoints/settings/server-cert/generate' + + +@pytest.fixture(autouse=True) +def cert_dir(tmp_path, monkeypatch): + directory = tmp_path / 'certs' + directory.mkdir() + monkeypatch.setattr(endpoint_routes, '_server_cert_dir', lambda: directory) + monkeypatch.setattr(endpoint_routes, '_restart_nginx', lambda: None) + return directory + + +def _pair(cn='old.example.test'): + key = ec.generate_private_key(ec.SECP256R1()) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)]) + now = datetime.now(timezone.utc) + certificate = (x509.CertificateBuilder() + .subject_name(name).issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(days=1)) + .not_valid_after(now + timedelta(days=30)) + .sign(key, hashes.SHA256())) + cert_pem = certificate.public_bytes(serialization.Encoding.PEM) + key_pem = key.private_bytes(serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption()) + return cert_pem, key_pem + + +def _seed_existing(cert_dir): + cert_pem, key_pem = _pair('existing.example.test') + (cert_dir / 'server.crt').write_bytes(cert_pem) + (cert_dir / 'server.key').write_bytes(key_pem) + return cert_pem, key_pem + + +def _upload(client, headers, cert_bytes, key_bytes): + return client.post(UPLOAD_URL, headers=headers, + content_type='multipart/form-data', + data={'cert': (io.BytesIO(cert_bytes), 'server.crt'), + 'key': (io.BytesIO(key_bytes), 'server.key')}) + + +def test_a_matching_pair_replaces_and_backs_up(client, db, auth_headers, cert_dir): + from shopdb.api import AuditLog + + old_cert, old_key = _seed_existing(cert_dir) + new_cert, new_key = _pair('new.example.test') + + response = _upload(client, auth_headers, new_cert, new_key) + + assert response.status_code == 200, response.get_json() + assert (cert_dir / 'server.crt').read_bytes() == new_cert + assert (cert_dir / 'server.key').read_bytes() == new_key + backups = sorted(p.name for p in cert_dir.iterdir() if '.backup.' in p.name) + assert len(backups) == 2 + assert not [p for p in cert_dir.iterdir() if p.name.endswith('.tmp')] + assert AuditLog.query.filter_by( + action='endpoints.server_cert.uploaded').count() == 1 + + +def test_a_mismatched_key_touches_nothing(client, db, auth_headers, cert_dir): + old_cert, old_key = _seed_existing(cert_dir) + new_cert, _new_key = _pair('new.example.test') + _other_cert, other_key = _pair('other.example.test') + + response = _upload(client, auth_headers, new_cert, other_key) + + assert response.status_code == 400 + assert 'does not match' in response.get_json()['data']['error']['message'] + assert (cert_dir / 'server.crt').read_bytes() == old_cert + assert (cert_dir / 'server.key').read_bytes() == old_key + assert sorted(p.name for p in cert_dir.iterdir()) == ['server.crt', 'server.key'] + + +def test_garbage_certificate_touches_nothing(client, db, auth_headers, cert_dir): + old_cert, _old_key = _seed_existing(cert_dir) + _cert, key = _pair() + garbage = b'-----BEGIN CERTIFICATE-----\nnope\n-----END CERTIFICATE-----\n' + + response = _upload(client, auth_headers, garbage, key) + + assert response.status_code == 400 + assert (cert_dir / 'server.crt').read_bytes() == old_cert + assert len(list(cert_dir.iterdir())) == 2 + + +@pytest.mark.parametrize('cn', [ + 'host\nprompt = yes', + 'bad host', + 'a=b', + '[alt_names]', + 'a' * 65, +]) +def test_generate_refuses_unsafe_cn(client, db, auth_headers, cert_dir, cn): + _seed_existing(cert_dir) + response = client.post(GENERATE_URL, headers=auth_headers, + json={'cn': cn, 'days': 30}) + assert response.status_code == 400 + assert len(list(cert_dir.iterdir())) == 2 + + +@pytest.mark.parametrize('san', [ + 'DNS:ok.example.test,DNS:bad\nname', + 'IP:999.1.1.1', + 'URI:http://x', + 'DNS:a b', +]) +def test_generate_refuses_unsafe_san(client, db, auth_headers, cert_dir, san): + _seed_existing(cert_dir) + response = client.post(GENERATE_URL, headers=auth_headers, + json={'cn': 'ok.example.test', 'san': san, + 'days': 30}) + assert response.status_code == 400 + assert len(list(cert_dir.iterdir())) == 2 + + +def test_generate_installs_a_valid_pair(client, db, auth_headers, cert_dir): + from shopdb.api import AuditLog + + _seed_existing(cert_dir) + response = client.post(GENERATE_URL, headers=auth_headers, + json={'cn': 'shopdb.example.test', + 'san': 'DNS:shopdb.example.test,IP:10.0.0.5', + 'days': 30}) + + assert response.status_code == 200, response.get_json() + certificate = x509.load_pem_x509_certificate( + (cert_dir / 'server.crt').read_bytes()) + assert certificate.subject.get_attributes_for_oid( + NameOID.COMMON_NAME)[0].value == 'shopdb.example.test' + assert endpoint_routes._validate_certificate_pair( + (cert_dir / 'server.crt').read_bytes(), + (cert_dir / 'server.key').read_bytes()) is None + assert AuditLog.query.filter_by( + action='endpoints.server_cert.generated').count() == 1 diff --git a/tests/test_plugins/test_notifications_fleet_alert_fields.py b/tests/test_plugins/test_notifications_fleet_alert_fields.py new file mode 100644 index 00000000..210c5db1 --- /dev/null +++ b/tests/test_plugins/test_notifications_fleet_alert_fields.py @@ -0,0 +1,101 @@ +"""Fleet alert fields on notification create/update. + +alertfleet and alerttimeout are NOT NULL in migration 0006, so a null from the +form is coerced to the column default rather than reaching the database. +alertscope must be an object whose keys the endpoint matcher understands; a +typo or a non-object is refused at the API instead of being stored and +silently re-interpreted at delivery time. +""" + +from plugins.notifications.models import Notification, NotificationType + + +def _type(db): + ntype = NotificationType(typename='Fleet Alert', typecolor='#d93f3f', + isactive=True) + db.session.add(ntype) + db.session.commit() + return ntype.notificationtypeid + + +def _create(client, headers, typeid, **fields): + body = {'notification': 'Line 3 is down', 'notificationtypeid': typeid} + body.update(fields) + return client.post('/api/notifications', json=body, headers=headers) + + +def test_null_timeout_and_fleet_are_coerced_to_defaults(client, db, auth_headers): + typeid = _type(db) + resp = _create(client, auth_headers, typeid, alertfleet=None, + alerttimeout=None, alertscope=None) + assert resp.status_code == 201, resp.get_json() + row = db.session.get(Notification, resp.get_json()['data']['notificationid']) + assert row.alertfleet is False + assert row.alerttimeout == 0 + assert row.alertscope is None + + +def test_update_coerces_null_timeout(client, db, auth_headers): + typeid = _type(db) + created = _create(client, auth_headers, typeid, alertfleet=True, + alerttimeout=60).get_json()['data'] + resp = client.put(f"/api/notifications/{created['notificationid']}", + json={'alerttimeout': None, 'alertfleet': None}, + headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + row = db.session.get(Notification, created['notificationid']) + assert row.alerttimeout == 0 + assert row.alertfleet is False + + +def test_a_valid_scope_is_stored(client, db, auth_headers): + typeid = _type(db) + resp = _create(client, auth_headers, typeid, alertfleet=True, + alertscope={'hostname': ['BAY-01', 'BAY-02'], + 'computertype': 'CMM PC'}) + assert resp.status_code == 201, resp.get_json() + assert resp.get_json()['data']['alertscope']['computertype'] == 'CMM PC' + + +def test_an_empty_scope_means_whole_fleet(client, db, auth_headers): + typeid = _type(db) + resp = _create(client, auth_headers, typeid, alertfleet=True, alertscope='') + assert resp.status_code == 201, resp.get_json() + assert resp.get_json()['data']['alertscope'] is None + + +def test_unknown_scope_key_is_refused(client, db, auth_headers): + typeid = _type(db) + resp = _create(client, auth_headers, typeid, alertfleet=True, + alertscope={'hostnam': 'BAY-01'}) + assert resp.status_code == 400 + assert 'hostnam' in resp.get_json()['data']['error']['message'] + assert Notification.query.count() == 0 + + +def test_non_object_scope_is_refused(client, db, auth_headers): + typeid = _type(db) + for scope in (['BAY-01'], 'BAY-01', '{not json', 5): + resp = _create(client, auth_headers, typeid, alertfleet=True, + alertscope=scope) + assert resp.status_code == 400, scope + assert Notification.query.count() == 0 + + +def test_update_refuses_bad_scope_and_keeps_the_old_one(client, db, auth_headers): + typeid = _type(db) + created = _create(client, auth_headers, typeid, alertfleet=True, + alertscope={'hostname': 'BAY-01'}).get_json()['data'] + resp = client.put(f"/api/notifications/{created['notificationid']}", + json={'alertscope': {'building': 'X'}}, + headers=auth_headers) + assert resp.status_code == 400 + db.session.expire_all() + row = db.session.get(Notification, created['notificationid']) + assert row.alertscope == {'hostname': 'BAY-01'} + + +def test_bad_timeout_is_refused(client, db, auth_headers): + typeid = _type(db) + assert _create(client, auth_headers, typeid, alerttimeout=-1).status_code == 400 + assert _create(client, auth_headers, typeid, alerttimeout='soon').status_code == 400