feat: Agent distribution with secure enrollment from the control panel
One universal Windows Agent package per version (agent_releases: version/ platform/architecture/sha256/status DRAFT|PUBLISHED|REVOKED; the binary lives on a local release-storage volume, never in PostgreSQL). An operator creates a pending installation (agent_enrollments) from Agents / Servidores -> + Nova instalação, then either downloads it directly or generates a one-time HTTPS link (short TTL, single use). Either path streams the package plus a freshly minted enrollment.json whose token is never written to storage in plaintext, only its SHA-256 hash. On the client host, gti-agent.ps1 install-auto verifies the bundle's SHA-256, refuses a non- HTTPS Hub, enrolls via POST /v1/agents/enroll (deleting the enrollment token immediately after, success or failure), writes an ACL-restricted .env with the newly issued permanent Agent token, and registers the Windows Service without starting it (Firebird stays unconfigured on purpose). Idempotent, replay-proof, rate-limited per IP, fully audited, never logs a secret. Migration 0022_agent_releases_enrollment (verified fresh-DB == upgrade-from-0021 by pg_dump diff). GTI_FISCAL_VERSION/AGENT_VERSION bumped to 1.1.0 as the single source of truth across code, packages, the release manifest and every published AgentRelease row. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
"""Agent distribution: releases + secure enrollment.
|
||||
|
||||
Covers central/app/agent_distribution.py, enrollment.py, release_storage.py,
|
||||
rate_limit.py, scripts/publish_agent_release.py and the new admin pages in
|
||||
web.py. Security tests mirror the mission's checklist: expired/revoked/
|
||||
second-use/tampered download links, expired/replayed/wrong-installation/
|
||||
already-enrolled enrollment attempts, path traversal, rate limiting, and
|
||||
that no secret ever reaches the audit log.
|
||||
"""
|
||||
import dataclasses
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app import config, enrollment, rate_limit, release_storage
|
||||
from central.app.main import create_app
|
||||
from central.app.models import Agent, AgentEnrollment, AgentRelease, AuditEvent, Installation, Tenant
|
||||
from central.scripts.publish_agent_release import publish
|
||||
from central.tests.test_control_plane import seed, setup_db
|
||||
|
||||
VERSION = "1.2.3"
|
||||
|
||||
|
||||
def make_release_zip(version=VERSION) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as z:
|
||||
z.writestr(f"GTIFiscalAgent-{version}/gti-agent.ps1", "# fake install script\n")
|
||||
z.writestr(f"GTIFiscalAgent-{version}/VERSION", version)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def write_temp_zip(data: bytes, version=VERSION) -> Path:
|
||||
path = Path(tempfile.gettempdir()) / f"GTIFiscalAgent-{version}-{id(data)}.zip"
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_rate_limit():
|
||||
rate_limit._attempts.clear()
|
||||
yield
|
||||
rate_limit._attempts.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub(tmp_path, monkeypatch):
|
||||
replaced = dataclasses.replace(
|
||||
config.settings,
|
||||
agent_release_storage_path=str(tmp_path / "releases"),
|
||||
agent_download_link_ttl_seconds=1800,
|
||||
agent_download_max_uses_default=1,
|
||||
agent_enrollment_ttl_seconds=86400,
|
||||
base_url="https://gtifiscal.example.gti",
|
||||
web_cookie_secure=False,
|
||||
)
|
||||
monkeypatch.setattr(config, "settings", replaced)
|
||||
# publish_agent_release.py is written to also run standalone (`python
|
||||
# central/scripts/publish_agent_release.py`), so it imports via a
|
||||
# sys.path-inserted "app" package (same convention as register_agent.py)
|
||||
# rather than "central.app" — a second, independent copy of every module
|
||||
# it touches, including config. Patch that copy's settings too, or the
|
||||
# release it publishes lands outside this test's tmp_path.
|
||||
if "app.config" in sys.modules:
|
||||
monkeypatch.setattr(sys.modules["app.config"], "settings", replaced)
|
||||
factory = setup_db()
|
||||
seed(factory) # tenant "Acme" + Installation "srv-01" + admin "operator"/"a-secure-password"
|
||||
client = TestClient(create_app(database_probe=lambda: True, session_factory=factory, bootstrap_token=None))
|
||||
return client, factory
|
||||
|
||||
|
||||
def login(client):
|
||||
response = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
|
||||
|
||||
def csrf(client):
|
||||
return client.cookies.get("gti_csrf", "")
|
||||
|
||||
|
||||
def publish_release(factory, version=VERSION):
|
||||
with factory() as session:
|
||||
release, _created = publish(session, write_temp_zip(make_release_zip(version), version), version=version)
|
||||
return release.id
|
||||
|
||||
|
||||
def tenant_id(factory):
|
||||
with factory() as session:
|
||||
return session.scalar(select(Tenant).where(Tenant.name == "Acme")).id
|
||||
|
||||
|
||||
def audits(factory):
|
||||
with factory() as session:
|
||||
return session.scalars(select(AuditEvent)).all()
|
||||
|
||||
|
||||
def bundle_enrollment_fields(content: bytes) -> dict:
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as z:
|
||||
[entry] = [n for n in z.namelist() if n.endswith("enrollment.json")]
|
||||
return json.loads(z.read(entry))
|
||||
|
||||
|
||||
# ---------- publish_agent_release.py ----------
|
||||
|
||||
def test_publish_is_idempotent_and_rejects_hash_conflict(hub, tmp_path):
|
||||
_client, factory = hub
|
||||
with factory() as session:
|
||||
zip_bytes = make_release_zip("9.9.9")
|
||||
release, created = publish(session, write_temp_zip(zip_bytes, "9.9.9"), version="9.9.9")
|
||||
assert created and release.status == "PUBLISHED" and release.version == "9.9.9"
|
||||
release2, created2 = publish(session, write_temp_zip(zip_bytes, "9.9.9"), version="9.9.9")
|
||||
assert not created2 and release2.id == release.id # identical bytes: no-op
|
||||
with factory() as session:
|
||||
with pytest.raises(SystemExit):
|
||||
publish(session, write_temp_zip(make_release_zip("9.9.9") + b"\x00", "9.9.9"), version="9.9.9")
|
||||
|
||||
|
||||
def test_published_release_binary_never_stored_in_postgres(hub):
|
||||
_client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
with factory() as session:
|
||||
release = session.get(AgentRelease, release_id)
|
||||
assert release.storage_path == Path(release.storage_path).name # a bare filename, never a path with directories
|
||||
path = release_storage.safe_path_for(release.storage_path)
|
||||
assert path.exists() and path.read_bytes() # binary lives on disk, outside the DB
|
||||
|
||||
|
||||
# ---------- path traversal ----------
|
||||
|
||||
def test_release_storage_path_traversal_impossible(hub):
|
||||
for hostile in ("../../etc/passwd", "sub/dir.zip", "/etc/passwd", "..\\..\\windows\\system32", ""):
|
||||
with pytest.raises(ValueError):
|
||||
release_storage.safe_path_for(hostile)
|
||||
|
||||
|
||||
# ---------- full happy path: create -> link -> download -> enroll ----------
|
||||
|
||||
def test_full_installation_flow_creates_a_working_agent(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
login(client)
|
||||
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
|
||||
"display_name": "Servidor Principal", "expected_hostname": "SRV-UNIAO",
|
||||
"release_id": release_id}, follow_redirects=False)
|
||||
assert created.status_code == 303
|
||||
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
|
||||
linked = client.post(f"/agents/pending/{installation_uuid}/link", data={"csrf_token": csrf(client)}, follow_redirects=False)
|
||||
assert linked.status_code == 303
|
||||
download_url = linked.headers["location"].split("link=", 1)[1]
|
||||
download_token = download_url.rsplit("/", 1)[-1]
|
||||
|
||||
# A technician on the client machine never has an admin session.
|
||||
anon = TestClient(client.app)
|
||||
downloaded = anon.get(f"/dl/agent/{download_token}")
|
||||
assert downloaded.status_code == 200
|
||||
assert downloaded.headers["content-type"] == "application/octet-stream"
|
||||
assert "attachment" in downloaded.headers["content-disposition"]
|
||||
|
||||
payload = bundle_enrollment_fields(downloaded.content)
|
||||
assert payload["installation_id"] == installation_uuid
|
||||
|
||||
enrolled = anon.post("/v1/agents/enroll", json={
|
||||
"installation_id": payload["installation_id"], "enrollment_token": payload["enrollment_token"],
|
||||
"agent_version": "1.2.3", "hostname": "SRV-UNIAO", "platform": "windows", "machine_fingerprint": "abc123",
|
||||
})
|
||||
assert enrolled.status_code == 200
|
||||
body = enrolled.json()
|
||||
assert body["agent_id"] and body["agent_token"] and body["hostname_mismatch"] is False
|
||||
|
||||
with factory() as session:
|
||||
agent = session.get(Agent, body["agent_id"])
|
||||
assert agent.enabled and agent.agent_version == "1.2.3"
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
assert installation.installation_id == installation_uuid and installation.hostname == "SRV-UNIAO"
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
|
||||
assert enr.status == "ENROLLED" and enr.enrollment_token_hash is None and enr.agent_id == agent.id
|
||||
|
||||
# Replay: the very same enrollment token can never enroll a second Agent.
|
||||
replay = anon.post("/v1/agents/enroll", json={"installation_id": payload["installation_id"], "enrollment_token": payload["enrollment_token"]})
|
||||
assert replay.status_code == 401 and replay.json()["detail"] == "ALREADY_ENROLLED"
|
||||
|
||||
# Second use of the (single-use) download link is also rejected.
|
||||
redownload = anon.get(f"/dl/agent/{download_token}")
|
||||
assert redownload.status_code == 404
|
||||
|
||||
events = {event.action for event in audits(factory)}
|
||||
assert {"INSTALLATION_CREATED", "DOWNLOAD_LINK_CREATED", "PACKAGE_DOWNLOADED", "ENROLLMENT_SUCCEEDED", "AGENT_CREDENTIAL_ISSUED", "ENROLLMENT_REJECTED"} <= events
|
||||
|
||||
secrets_leaked = [download_token, payload["enrollment_token"], body["agent_token"]]
|
||||
for event in audits(factory):
|
||||
haystack = json.dumps({"action": event.action, "target": event.target_id, "context": event.context})
|
||||
for secret in secrets_leaked:
|
||||
assert secret not in haystack
|
||||
|
||||
|
||||
def test_direct_authenticated_download_does_not_need_a_link(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
login(client)
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
|
||||
"display_name": "Loja 2", "release_id": release_id}, follow_redirects=False)
|
||||
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
downloaded = client.get(f"/agents/pending/{installation_uuid}/download")
|
||||
assert downloaded.status_code == 200
|
||||
payload = bundle_enrollment_fields(downloaded.content)
|
||||
assert payload["installation_id"] == installation_uuid
|
||||
|
||||
|
||||
# ---------- download-link security ----------
|
||||
|
||||
def _pending(factory, release_id, **overrides):
|
||||
import uuid as uuid_mod
|
||||
with factory() as session:
|
||||
enr = AgentEnrollment(enrollment_uuid=str(uuid_mod.uuid4()), tenant_id=tenant_id(factory),
|
||||
display_name="Teste", release_id=release_id, status="PENDING", **overrides)
|
||||
session.add(enr); session.commit()
|
||||
return enr.enrollment_uuid
|
||||
|
||||
|
||||
def test_expired_download_link_rejected(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
uid = _pending(factory, release_id)
|
||||
token = enrollment.new_token()
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uid))
|
||||
enr.download_token_hash = enrollment.token_digest(token)
|
||||
enr.download_token_expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
enr.download_max_uses = 1
|
||||
session.commit()
|
||||
assert client.get(f"/dl/agent/{token}").status_code == 404
|
||||
|
||||
|
||||
def test_revoked_installation_link_rejected(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
login(client)
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
|
||||
"display_name": "Teste", "release_id": release_id}, follow_redirects=False)
|
||||
uid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
linked = client.post(f"/agents/pending/{uid}/link", data={"csrf_token": csrf(client)}, follow_redirects=False)
|
||||
token = linked.headers["location"].split("link=", 1)[1].rsplit("/", 1)[-1]
|
||||
client.post(f"/agents/pending/{uid}/revoke", data={"csrf_token": csrf(client)}, follow_redirects=False)
|
||||
assert client.get(f"/dl/agent/{token}").status_code == 404
|
||||
|
||||
|
||||
def test_wrong_or_random_download_token_rejected(hub):
|
||||
client, factory = hub
|
||||
publish_release(factory)
|
||||
assert client.get("/dl/agent/definitely-not-a-real-token").status_code == 404
|
||||
|
||||
|
||||
def test_release_revoked_blocks_download(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
login(client)
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
|
||||
"display_name": "Teste", "release_id": release_id}, follow_redirects=False)
|
||||
uid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
linked = client.post(f"/agents/pending/{uid}/link", data={"csrf_token": csrf(client)}, follow_redirects=False)
|
||||
token = linked.headers["location"].split("link=", 1)[1].rsplit("/", 1)[-1]
|
||||
client.post(f"/agents/releases/{release_id}/revoke", data={"csrf_token": csrf(client)}, follow_redirects=False)
|
||||
assert client.get(f"/dl/agent/{token}").status_code == 404
|
||||
|
||||
|
||||
# ---------- enrollment security ----------
|
||||
|
||||
def _enrollable(factory, release_id, **overrides):
|
||||
token = enrollment.new_token()
|
||||
uid = _pending(factory, release_id)
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uid))
|
||||
enr.enrollment_token_hash = enrollment.token_digest(token)
|
||||
enr.enrollment_token_expires_at = overrides.pop("expires_at", datetime.now(timezone.utc) + timedelta(hours=1))
|
||||
for key, value in overrides.items():
|
||||
setattr(enr, key, value)
|
||||
session.commit()
|
||||
return uid, token
|
||||
|
||||
|
||||
def test_expired_enrollment_token_rejected(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
uid, token = _enrollable(factory, release_id, expires_at=datetime.now(timezone.utc) - timedelta(seconds=1))
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": uid, "enrollment_token": token})
|
||||
assert response.status_code == 401 and response.json()["detail"] == "EXPIRED"
|
||||
|
||||
|
||||
def test_wrong_installation_uuid_rejected(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
_uid, token = _enrollable(factory, release_id)
|
||||
import uuid as uuid_mod
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": str(uuid_mod.uuid4()), "enrollment_token": token})
|
||||
assert response.status_code == 401 and response.json()["detail"] == "INVALID_INSTALLATION"
|
||||
|
||||
|
||||
def test_malformed_installation_id_rejected(hub):
|
||||
client, _factory = hub
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": "not-a-uuid", "enrollment_token": "x"})
|
||||
assert response.status_code == 401 and response.json()["detail"] == "INVALID_INSTALLATION"
|
||||
|
||||
|
||||
def test_random_enrollment_token_rejected(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
uid, _token = _enrollable(factory, release_id)
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": uid, "enrollment_token": enrollment.new_token()})
|
||||
assert response.status_code == 401 and response.json()["detail"] == "INVALID_TOKEN"
|
||||
|
||||
|
||||
def test_revoked_installation_enroll_rejected(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
uid, token = _enrollable(factory, release_id, status="REVOKED")
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": uid, "enrollment_token": token})
|
||||
assert response.status_code == 401 and response.json()["detail"] == "REVOKED"
|
||||
|
||||
|
||||
def test_hostname_mismatch_is_flagged_but_not_blocked(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
uid, token = _enrollable(factory, release_id, expected_hostname="SRV-EXPECTED")
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": uid, "enrollment_token": token, "hostname": "SRV-OUTRO"})
|
||||
assert response.status_code == 200 and response.json()["hostname_mismatch"] is True
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uid))
|
||||
assert enr.hostname_mismatch is True and enr.status == "ENROLLED"
|
||||
|
||||
|
||||
# ---------- rate limiting ----------
|
||||
|
||||
def test_enroll_rate_limited_after_repeated_invalid_attempts(hub):
|
||||
client, _factory = hub
|
||||
for _ in range(rate_limit.MAX_ATTEMPTS):
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": "not-a-uuid", "enrollment_token": "x"})
|
||||
assert response.status_code == 401
|
||||
blocked_response = client.post("/v1/agents/enroll", json={"installation_id": "not-a-uuid", "enrollment_token": "x"})
|
||||
assert blocked_response.status_code == 429
|
||||
|
||||
|
||||
def test_rate_limit_buckets_are_independent_per_ip(hub):
|
||||
# Unit-level proof that the limiter itself keys on (bucket, ip) rather than a
|
||||
# shared global counter — the end-to-end TestClient can't vary the caller's
|
||||
# apparent IP (that requires the real ProxyHeadersMiddleware, wired up only
|
||||
# under uvicorn --proxy-headers in production, per FORWARDED_ALLOW_IPS).
|
||||
for _ in range(rate_limit.MAX_ATTEMPTS):
|
||||
rate_limit.record_attempt("enroll", "198.51.100.1")
|
||||
assert rate_limit.blocked("enroll", "198.51.100.1")
|
||||
assert not rate_limit.blocked("enroll", "198.51.100.2")
|
||||
Reference in New Issue
Block a user