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:
gti
2026-09-23 13:19:31 -03:00
co-authored by Claude Sonnet 5
parent b1c496fc1b
commit 8d6122c096
36 changed files with 1515 additions and 47 deletions
+359
View File
@@ -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")