feat: add secure agent enrollment
This commit is contained in:
1 parent
82309db79a
commit
d9f0da694b
26 files changed
+2942
-76
No files matched your search
@@ -0,0 +1,136 @@
|
||||
"""Focused tests for per-Agent authentication and legacy compatibility."""
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.agent_auth.exceptions import (
|
||||
AgentAuthenticationFailed,
|
||||
AgentAuthenticationUnavailable,
|
||||
AgentCredentialRevoked,
|
||||
AgentIdentityUnauthorized,
|
||||
)
|
||||
from app.agent_auth.service import AgentAuthService, hash_agent_secret
|
||||
from app.config import settings
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 26, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class FakeAgentAuthRepository:
|
||||
def __init__(self, rows=None):
|
||||
self.rows = rows or {}
|
||||
|
||||
def get_credential(self, agent_id):
|
||||
return self.rows.get(agent_id)
|
||||
|
||||
|
||||
class AgentAuthServiceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.old_id = settings.minerops_agent_id
|
||||
self.old_secret = settings.minerops_agent_shared_secret
|
||||
settings.minerops_agent_id = "legacy-agent"
|
||||
settings.minerops_agent_shared_secret = "legacy-secret"
|
||||
|
||||
def tearDown(self):
|
||||
settings.minerops_agent_id = self.old_id
|
||||
settings.minerops_agent_shared_secret = self.old_secret
|
||||
|
||||
def service(self, rows=None):
|
||||
return AgentAuthService(FakeAgentAuthRepository(rows))
|
||||
|
||||
def test_high_entropy_secret_hash_is_sha256_hex(self):
|
||||
digest = hash_agent_secret("example-secret")
|
||||
self.assertEqual(len(digest), 64)
|
||||
self.assertRegex(digest, r"^[0-9a-f]{64}$")
|
||||
|
||||
def test_legacy_agent_without_registry_row_still_authenticates(self):
|
||||
self.assertEqual(
|
||||
self.service().authenticate("legacy-agent", "legacy-secret"),
|
||||
"legacy-agent",
|
||||
)
|
||||
|
||||
def test_legacy_agent_with_null_credential_still_authenticates(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
"agent_id": "legacy-agent",
|
||||
"agent_secret_hash": None,
|
||||
"credential_created_at": None,
|
||||
"credential_revoked_at": None,
|
||||
}
|
||||
}
|
||||
self.assertEqual(
|
||||
self.service(rows).authenticate("legacy-agent", "legacy-secret"),
|
||||
"legacy-agent",
|
||||
)
|
||||
|
||||
def test_per_agent_credential_authenticates_independently(self):
|
||||
rows = {
|
||||
"agent-new": {
|
||||
"agent_id": "agent-new",
|
||||
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||
"credential_created_at": NOW,
|
||||
"credential_revoked_at": None,
|
||||
}
|
||||
}
|
||||
self.assertEqual(
|
||||
self.service(rows).authenticate("agent-new", "unique-secret"),
|
||||
"agent-new",
|
||||
)
|
||||
|
||||
def test_legacy_secret_never_authenticates_per_agent_identity(self):
|
||||
settings.minerops_agent_id = "agent-new"
|
||||
rows = {
|
||||
"agent-new": {
|
||||
"agent_id": "agent-new",
|
||||
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||
"credential_created_at": NOW,
|
||||
"credential_revoked_at": None,
|
||||
}
|
||||
}
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service(rows).authenticate("agent-new", "legacy-secret")
|
||||
|
||||
def test_wrong_per_agent_secret_is_rejected(self):
|
||||
rows = {
|
||||
"agent-new": {
|
||||
"agent_id": "agent-new",
|
||||
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||
"credential_created_at": NOW,
|
||||
"credential_revoked_at": None,
|
||||
}
|
||||
}
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service(rows).authenticate("agent-new", "wrong-secret")
|
||||
|
||||
def test_revoked_per_agent_credential_never_falls_back(self):
|
||||
settings.minerops_agent_id = "agent-new"
|
||||
rows = {
|
||||
"agent-new": {
|
||||
"agent_id": "agent-new",
|
||||
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||
"credential_created_at": NOW,
|
||||
"credential_revoked_at": NOW,
|
||||
}
|
||||
}
|
||||
for secret in ("unique-secret", "legacy-secret"):
|
||||
with self.subTest(secret=secret), self.assertRaises(AgentCredentialRevoked):
|
||||
self.service(rows).authenticate("agent-new", secret)
|
||||
|
||||
def test_unknown_identity_does_not_use_shared_secret(self):
|
||||
with self.assertRaises(AgentIdentityUnauthorized):
|
||||
self.service().authenticate("unknown-agent", "legacy-secret")
|
||||
|
||||
def test_missing_identity_or_secret_is_rejected(self):
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service().authenticate(None, "legacy-secret")
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service().authenticate("legacy-agent", None)
|
||||
|
||||
def test_legacy_path_reports_unavailable_if_not_configured(self):
|
||||
settings.minerops_agent_shared_secret = ""
|
||||
with self.assertRaises(AgentAuthenticationUnavailable):
|
||||
self.service().authenticate("legacy-agent", "anything")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -7,7 +7,11 @@ from pathlib import Path
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.agent_commands.dependencies import get_agent_command_service
|
||||
from app.agent_auth.service import AgentAuthService
|
||||
from app.agent_commands.dependencies import (
|
||||
get_agent_auth_service,
|
||||
get_agent_command_service,
|
||||
)
|
||||
from app.agent_commands.exceptions import (
|
||||
AgentCommandConflict,
|
||||
AgentResultValidationError,
|
||||
@@ -123,6 +127,11 @@ class AgentCommandServiceTests(unittest.TestCase):
|
||||
AgentCommandService(FakeRepository()).submit_result("test-agent", request)
|
||||
|
||||
|
||||
class FakeAgentAuthRepository:
|
||||
def get_credential(self, agent_id):
|
||||
return None
|
||||
|
||||
|
||||
class AgentCommandRouterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = FastAPI()
|
||||
@@ -131,6 +140,9 @@ class AgentCommandRouterTests(unittest.TestCase):
|
||||
self.app.dependency_overrides[get_agent_command_service] = lambda: AgentCommandService(
|
||||
self.repository
|
||||
)
|
||||
self.app.dependency_overrides[get_agent_auth_service] = lambda: AgentAuthService(
|
||||
FakeAgentAuthRepository()
|
||||
)
|
||||
self.client = TestClient(self.app)
|
||||
self.old_id = settings.minerops_agent_id
|
||||
self.old_secret = settings.minerops_agent_shared_secret
|
||||
@@ -235,3 +247,122 @@ class AgentSourceContractTests(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class AgentTransportIsolationSourceTests(unittest.TestCase):
|
||||
def test_known_miners_and_pool_snapshots_are_agent_zone_scoped(self):
|
||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||
identity = (Path(__file__).parents[1] / "app" / "identity.py").read_text()
|
||||
|
||||
known_start = main.index('@app.get("/api/agent/known-miners")')
|
||||
known_end = main.index("# Phase 5J:", known_start)
|
||||
known = main[known_start:known_end]
|
||||
|
||||
pool_start = main.index('@app.post("/api/agent/pool-snapshots")')
|
||||
pool_end = main.index("\n@app.", pool_start + 1)
|
||||
pool = main[pool_start:pool_end]
|
||||
|
||||
self.assertIn('"/api/agent/known-miners"', identity)
|
||||
|
||||
self.assertIn("Depends(require_agent_identity)", known)
|
||||
self.assertIn("zone_service.routing_context", known)
|
||||
self.assertIn("miners.agent_zone_id = $1::uuid", known)
|
||||
self.assertNotIn("minerops_require_agent_secret", known)
|
||||
|
||||
self.assertIn("Depends(require_agent_identity)", pool)
|
||||
self.assertIn("zone_service.routing_context", pool)
|
||||
self.assertIn("agent_id = authenticated_agent_id", pool)
|
||||
self.assertIn("agent_zone_id = %s", pool)
|
||||
self.assertNotIn('body.get("agent_id")', pool)
|
||||
self.assertNotIn("MINEROPS_AGENT_SHARED_SECRET", pool)
|
||||
|
||||
def test_windows_machine_pollers_send_agent_identity(self):
|
||||
root = agent_source_root()
|
||||
if root is None:
|
||||
self.skipTest("Windows Agent source is not mounted")
|
||||
|
||||
for filename in (
|
||||
"MinerOpsPoolPoller.ps1",
|
||||
"MinerOpsApi3EnrichmentPoller.ps1",
|
||||
"EnrichOneMiner.ps1",
|
||||
):
|
||||
with self.subTest(filename=filename):
|
||||
source = (root / filename).read_text()
|
||||
self.assertIn("X-MinerOps-Agent-ID", source)
|
||||
self.assertIn("X-MinerOps-Agent-Secret", source)
|
||||
|
||||
|
||||
class AgentEnrichmentIsolationSourceTests(unittest.TestCase):
|
||||
def test_enrichment_routes_require_agent_identity_and_zone_scope(self):
|
||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||
|
||||
decorators = (
|
||||
'@app.post("/api/agent/miner-enrichment")',
|
||||
'@app.post("/api/agent/miner-enrichment-v2")',
|
||||
'@app.post("/api/agent/miner-enrichment-v3")',
|
||||
)
|
||||
|
||||
for decorator in decorators:
|
||||
with self.subTest(decorator=decorator):
|
||||
start = main.index(decorator)
|
||||
end = main.index("\n@app.", start + 1)
|
||||
route = main[start:end]
|
||||
|
||||
self.assertIn("Depends(require_agent_identity)", route)
|
||||
self.assertIn("zone_service.routing_context", route)
|
||||
self.assertIn("agent_zone_id", route)
|
||||
self.assertNotIn("minerops_require_agent_secret", route)
|
||||
|
||||
v2_start = main.index('@app.post("/api/agent/miner-enrichment-v2")')
|
||||
v2_end = main.index("\n@app.", v2_start + 1)
|
||||
v2 = main[v2_start:v2_end]
|
||||
self.assertIn(
|
||||
"AND agent_zone_id = $2::uuid",
|
||||
v2,
|
||||
)
|
||||
self.assertIn(
|
||||
"AND agent_zone_id = $3::uuid",
|
||||
v2,
|
||||
)
|
||||
|
||||
v3_start = main.index('@app.post("/api/agent/miner-enrichment-v3")')
|
||||
v3_end = main.index("\n@app.", v3_start + 1)
|
||||
v3 = main[v3_start:v3_end]
|
||||
self.assertIn(
|
||||
"AND agent_zone_id = $8::uuid",
|
||||
v3,
|
||||
)
|
||||
self.assertIn(
|
||||
"AND agent_zone_id = $7::uuid",
|
||||
v3,
|
||||
)
|
||||
|
||||
def test_api3_uses_authenticated_identity_and_never_global_agent_identity(self):
|
||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||
|
||||
start = main.index('@app.post("/api/agent/api3-enrichment")')
|
||||
end = main.index("\n@app.", start + 1)
|
||||
route = main[start:end]
|
||||
|
||||
self.assertIn("Depends(require_agent_identity)", route)
|
||||
self.assertIn("zone_service.routing_context", route)
|
||||
self.assertIn("agent_id = authenticated_agent_id", route)
|
||||
self.assertIn("agent_zone_id = %s", route)
|
||||
self.assertIn("if miner_id is None:", route)
|
||||
self.assertIn("continue", route)
|
||||
|
||||
self.assertNotIn("MINEROPS_AGENT_SHARED_SECRET", route)
|
||||
self.assertNotIn('body.get("agent_id")', route)
|
||||
self.assertNotIn('body.get("site_name")', route)
|
||||
self.assertNotIn('request.headers.get("x-agent-secret")', route)
|
||||
|
||||
def test_legacy_config_remains_explicitly_legacy_only(self):
|
||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||
|
||||
start = main.index('@app.get("/api/agent/config")')
|
||||
end = main.index("\n@app.", start + 1)
|
||||
route = main[start:end]
|
||||
|
||||
self.assertIn("minerops_require_agent_secret(request)", route)
|
||||
self.assertIn('"site_name": "Plexus"', route)
|
||||
self.assertNotIn("Depends(require_agent_identity)", route)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Focused tests for secure Agent enrollment."""
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from app.agent_auth.service import hash_agent_secret
|
||||
from app.agent_enrollment.models import (
|
||||
AgentEnrollmentCreateRequest,
|
||||
AgentEnrollRequest,
|
||||
)
|
||||
from app.agent_enrollment.service import AgentEnrollmentService
|
||||
from app.identity import machine_agent_request, required_api_permission
|
||||
|
||||
|
||||
SITE_ID = UUID("00000000-0000-4000-8000-000000001700")
|
||||
ZONE_ID = UUID("00000000-0000-4000-8000-000000001701")
|
||||
USER_ID = UUID("00000000-0000-4000-8000-000000001702")
|
||||
ENROLLMENT_ID = UUID("00000000-0000-4000-8000-000000001703")
|
||||
NOW = datetime(2026, 8, 26, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class FakeAgentEnrollmentRepository:
|
||||
def __init__(self):
|
||||
self.created = None
|
||||
self.redeemed = None
|
||||
|
||||
def create_enrollment(self, **kwargs):
|
||||
self.created = kwargs
|
||||
return {
|
||||
"id": ENROLLMENT_ID,
|
||||
"site_id": SITE_ID,
|
||||
"site_name": "Test Site",
|
||||
"agent_zone_id": ZONE_ID,
|
||||
"agent_zone_name": "Test Zone",
|
||||
"display_name": kwargs["display_name"],
|
||||
"created_at": NOW,
|
||||
"expires_at": NOW + timedelta(minutes=30),
|
||||
}
|
||||
|
||||
def list_enrollments(self, **kwargs):
|
||||
return []
|
||||
|
||||
def redeem_enrollment(self, **kwargs):
|
||||
self.redeemed = kwargs
|
||||
return {
|
||||
"agent_id": kwargs["agent_id"],
|
||||
"site_id": SITE_ID,
|
||||
"site_name": "Test Site",
|
||||
"agent_zone_id": ZONE_ID,
|
||||
"agent_zone_name": "Test Zone",
|
||||
"display_name": "Test Agent",
|
||||
"ip_ranges": ["10.30.0.0/22"],
|
||||
}
|
||||
|
||||
|
||||
class AgentEnrollmentServiceTests(unittest.TestCase):
|
||||
def test_create_returns_plaintext_token_once_but_repository_gets_only_hash(self):
|
||||
repository = FakeAgentEnrollmentRepository()
|
||||
service = AgentEnrollmentService(repository)
|
||||
|
||||
result = service.create_enrollment(
|
||||
AgentEnrollmentCreateRequest(
|
||||
site_id=SITE_ID,
|
||||
agent_zone_id=ZONE_ID,
|
||||
display_name=" Test Agent ",
|
||||
),
|
||||
actor_user_id=USER_ID,
|
||||
)
|
||||
|
||||
token = result["enrollment_token"]
|
||||
|
||||
self.assertGreaterEqual(len(token), 32)
|
||||
self.assertNotEqual(repository.created["token_hash"], token)
|
||||
self.assertEqual(
|
||||
repository.created["token_hash"],
|
||||
hash_agent_secret(token),
|
||||
)
|
||||
self.assertRegex(
|
||||
repository.created["token_hash"],
|
||||
r"^[0-9a-f]{64}$",
|
||||
)
|
||||
self.assertEqual(
|
||||
repository.created["display_name"],
|
||||
"Test Agent",
|
||||
)
|
||||
|
||||
serialized = repr(repository.created)
|
||||
self.assertNotIn(token, serialized)
|
||||
|
||||
def test_redeem_returns_plaintext_secret_but_repository_gets_only_hash(self):
|
||||
repository = FakeAgentEnrollmentRepository()
|
||||
service = AgentEnrollmentService(repository)
|
||||
|
||||
result = service.redeem(
|
||||
AgentEnrollRequest(
|
||||
token="A" * 43,
|
||||
)
|
||||
)
|
||||
|
||||
secret = result["agent_secret"]
|
||||
|
||||
self.assertGreaterEqual(len(secret), 32)
|
||||
self.assertNotEqual(
|
||||
repository.redeemed["agent_secret_hash"],
|
||||
secret,
|
||||
)
|
||||
self.assertEqual(
|
||||
repository.redeemed["agent_secret_hash"],
|
||||
hash_agent_secret(secret),
|
||||
)
|
||||
self.assertRegex(
|
||||
repository.redeemed["agent_secret_hash"],
|
||||
r"^[0-9a-f]{64}$",
|
||||
)
|
||||
|
||||
self.assertRegex(
|
||||
result["agent_id"],
|
||||
r"^agent-[0-9a-f]{32}$",
|
||||
)
|
||||
|
||||
serialized = repr(repository.redeemed)
|
||||
self.assertNotIn(secret, serialized)
|
||||
|
||||
def test_enrollment_admin_permission_mapping_is_explicit(self):
|
||||
self.assertEqual(
|
||||
required_api_permission("GET", "/api/agent-enrollments"),
|
||||
"agents.view",
|
||||
)
|
||||
self.assertEqual(
|
||||
required_api_permission("POST", "/api/agent-enrollments"),
|
||||
"agents.manage",
|
||||
)
|
||||
|
||||
def test_bootstrap_endpoint_is_machine_exempt_but_admin_endpoint_is_not(self):
|
||||
self.assertTrue(machine_agent_request("/api/agent/enroll"))
|
||||
self.assertFalse(
|
||||
machine_agent_request("/api/agent-enrollments")
|
||||
)
|
||||
|
||||
def test_generated_identifiers_are_bounded(self):
|
||||
repository = FakeAgentEnrollmentRepository()
|
||||
service = AgentEnrollmentService(repository)
|
||||
|
||||
result = service.redeem(
|
||||
AgentEnrollRequest(token="B" * 43)
|
||||
)
|
||||
|
||||
self.assertLessEqual(len(result["agent_id"]), 255)
|
||||
self.assertTrue(
|
||||
re.fullmatch(r"agent-[0-9a-f]{32}", result["agent_id"])
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,625 @@
|
||||
"""PostgreSQL integration tests for secure Agent enrollment."""
|
||||
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from uuid import uuid4
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.agent_auth.exceptions import AgentAuthenticationFailed
|
||||
from app.agent_auth.repository import AgentAuthRepository
|
||||
from app.agent_auth.service import AgentAuthService, hash_agent_secret
|
||||
from app.agent_enrollment.exceptions import (
|
||||
AgentEnrollmentConflict,
|
||||
AgentEnrollmentNotFound,
|
||||
AgentEnrollmentTokenInvalid,
|
||||
)
|
||||
from app.agent_enrollment.models import (
|
||||
AgentEnrollmentCreateRequest,
|
||||
AgentEnrollRequest,
|
||||
)
|
||||
from app.agent_enrollment.repository import (
|
||||
AgentEnrollmentRepository,
|
||||
default_connection_factory,
|
||||
)
|
||||
from app.agent_enrollment.service import AgentEnrollmentService
|
||||
from app.agent_zones.models import AgentCheckinRequest
|
||||
from app.agent_zones.repository import AgentZoneRepository
|
||||
from app.agent_zones.service import AgentZoneService
|
||||
|
||||
|
||||
class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.site_id = uuid4()
|
||||
self.zone_id = uuid4()
|
||||
self.site_name = f"Enrollment Test Site {self.site_id.hex[:8]}"
|
||||
self.zone_name = f"Enrollment Test Zone {self.zone_id.hex[:8]}"
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sites (id, name)
|
||||
VALUES (%s, %s)
|
||||
""",
|
||||
(self.site_id, self.site_name),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zones (
|
||||
id,
|
||||
site_id,
|
||||
name
|
||||
)
|
||||
VALUES (%s, %s, %s)
|
||||
""",
|
||||
(
|
||||
self.zone_id,
|
||||
self.site_id,
|
||||
self.zone_name,
|
||||
),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zone_network_ranges (
|
||||
agent_zone_id,
|
||||
network
|
||||
)
|
||||
VALUES (%s, '10.250.0.0/24'::cidr)
|
||||
""",
|
||||
(self.zone_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.service = AgentEnrollmentService(
|
||||
AgentEnrollmentRepository()
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM audit_logs
|
||||
WHERE entity_type = 'agent_enrollment'
|
||||
AND (
|
||||
new_value_json ->> 'site_id' = %s
|
||||
OR entity_id IN (
|
||||
SELECT id
|
||||
FROM agent_enrollments
|
||||
WHERE site_id = %s
|
||||
)
|
||||
)
|
||||
""",
|
||||
(str(self.site_id), self.site_id),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM agent_enrollments
|
||||
WHERE site_id = %s
|
||||
""",
|
||||
(self.site_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM minerops_site_agents
|
||||
WHERE site_id = %s
|
||||
""",
|
||||
(self.site_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM agent_zone_network_ranges
|
||||
WHERE agent_zone_id = %s
|
||||
""",
|
||||
(self.zone_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM agent_zones
|
||||
WHERE id = %s
|
||||
""",
|
||||
(self.zone_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM sites
|
||||
WHERE id = %s
|
||||
""",
|
||||
(self.site_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def create_enrollment(self, display_name="Integration Agent"):
|
||||
# created_by_user_id is nullable at the persistence layer.
|
||||
# Router-level tests separately prove that the real admin path supplies
|
||||
# the authenticated user's UUID.
|
||||
return self.service.create_enrollment(
|
||||
AgentEnrollmentCreateRequest(
|
||||
site_id=self.site_id,
|
||||
agent_zone_id=self.zone_id,
|
||||
display_name=display_name,
|
||||
),
|
||||
actor_user_id=None,
|
||||
)
|
||||
|
||||
def test_replacement_revokes_previous_open_enrollment(self):
|
||||
first = self.create_enrollment("First Agent")
|
||||
second = self.create_enrollment("Replacement Agent")
|
||||
|
||||
self.assertNotEqual(
|
||||
first["enrollment_token"],
|
||||
second["enrollment_token"],
|
||||
)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
token_hash,
|
||||
used_at,
|
||||
revoked_at
|
||||
FROM agent_enrollments
|
||||
WHERE site_id = %s
|
||||
AND agent_zone_id = %s
|
||||
""",
|
||||
(self.site_id, self.zone_id),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(len(rows), 2)
|
||||
|
||||
by_hash = {
|
||||
row["token_hash"]: row
|
||||
for row in rows
|
||||
}
|
||||
|
||||
first_hash = hash_agent_secret(
|
||||
first["enrollment_token"]
|
||||
)
|
||||
second_hash = hash_agent_secret(
|
||||
second["enrollment_token"]
|
||||
)
|
||||
|
||||
self.assertIsNotNone(by_hash[first_hash]["revoked_at"])
|
||||
self.assertIsNone(by_hash[first_hash]["used_at"])
|
||||
|
||||
self.assertIsNone(by_hash[second_hash]["revoked_at"])
|
||||
self.assertIsNone(by_hash[second_hash]["used_at"])
|
||||
|
||||
with self.assertRaises(AgentEnrollmentTokenInvalid):
|
||||
self.service.redeem(
|
||||
AgentEnrollRequest(
|
||||
token=first["enrollment_token"]
|
||||
)
|
||||
)
|
||||
|
||||
listed = self.service.list_enrollments(
|
||||
site_id=self.site_id,
|
||||
agent_zone_id=self.zone_id,
|
||||
)
|
||||
|
||||
self.assertEqual(len(listed), 2)
|
||||
|
||||
for item in listed:
|
||||
self.assertNotIn("token_hash", item)
|
||||
self.assertNotIn("agent_secret_hash", item)
|
||||
self.assertNotIn("enrollment_token", item)
|
||||
self.assertNotIn("agent_secret", item)
|
||||
|
||||
def test_concurrent_creation_leaves_exactly_one_open_enrollment(self):
|
||||
def create_one(number):
|
||||
service = AgentEnrollmentService(
|
||||
AgentEnrollmentRepository()
|
||||
)
|
||||
return service.create_enrollment(
|
||||
AgentEnrollmentCreateRequest(
|
||||
site_id=self.site_id,
|
||||
agent_zone_id=self.zone_id,
|
||||
display_name=f"Concurrent Agent {number}",
|
||||
),
|
||||
actor_user_id=None,
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(
|
||||
executor.map(create_one, (1, 2))
|
||||
)
|
||||
|
||||
self.assertEqual(len(results), 2)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
count(*) AS total,
|
||||
count(*) FILTER (
|
||||
WHERE used_at IS NULL
|
||||
AND revoked_at IS NULL
|
||||
) AS open_count,
|
||||
count(*) FILTER (
|
||||
WHERE revoked_at IS NOT NULL
|
||||
) AS revoked_count,
|
||||
count(*) FILTER (
|
||||
WHERE revoked_at IS NOT NULL
|
||||
AND revoked_at < created_at
|
||||
) AS invalid_revocation_count
|
||||
FROM agent_enrollments
|
||||
WHERE site_id = %s
|
||||
AND agent_zone_id = %s
|
||||
""",
|
||||
(self.site_id, self.zone_id),
|
||||
)
|
||||
counts = cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(counts["total"], 2)
|
||||
self.assertEqual(counts["open_count"], 1)
|
||||
self.assertEqual(counts["revoked_count"], 1)
|
||||
self.assertEqual(counts["invalid_revocation_count"], 0)
|
||||
|
||||
def test_concurrent_redemption_has_exactly_one_winner(self):
|
||||
enrollment = self.create_enrollment()
|
||||
token = enrollment["enrollment_token"]
|
||||
|
||||
def redeem_once():
|
||||
service = AgentEnrollmentService(
|
||||
AgentEnrollmentRepository()
|
||||
)
|
||||
try:
|
||||
return (
|
||||
"success",
|
||||
service.redeem(
|
||||
AgentEnrollRequest(token=token)
|
||||
),
|
||||
)
|
||||
except AgentEnrollmentTokenInvalid:
|
||||
return ("invalid", None)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(
|
||||
executor.map(lambda _: redeem_once(), range(2))
|
||||
)
|
||||
|
||||
successes = [
|
||||
result
|
||||
for status, result in results
|
||||
if status == "success"
|
||||
]
|
||||
failures = [
|
||||
status
|
||||
for status, _ in results
|
||||
if status == "invalid"
|
||||
]
|
||||
|
||||
self.assertEqual(len(successes), 1)
|
||||
self.assertEqual(len(failures), 1)
|
||||
|
||||
credentials = successes[0]
|
||||
agent_id = credentials["agent_id"]
|
||||
agent_secret = credentials["agent_secret"]
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
agent_id,
|
||||
agent_secret_hash,
|
||||
credential_created_at,
|
||||
credential_revoked_at,
|
||||
last_payload::text AS last_payload
|
||||
FROM minerops_site_agents
|
||||
WHERE site_id = %s
|
||||
AND agent_zone_id = %s
|
||||
""",
|
||||
(self.site_id, self.zone_id),
|
||||
)
|
||||
agents = cur.fetchall()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
used_at,
|
||||
revoked_at,
|
||||
enrolled_agent_id,
|
||||
token_hash
|
||||
FROM agent_enrollments
|
||||
WHERE id = %s
|
||||
""",
|
||||
(enrollment["id"],),
|
||||
)
|
||||
enrollment_row = cur.fetchone()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT new_value_json::text AS payload
|
||||
FROM audit_logs
|
||||
WHERE entity_type = 'agent_enrollment'
|
||||
AND entity_id = %s
|
||||
ORDER BY created_at
|
||||
""",
|
||||
(enrollment["id"],),
|
||||
)
|
||||
audit_rows = cur.fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(len(agents), 1)
|
||||
agent = agents[0]
|
||||
|
||||
self.assertEqual(agent["agent_id"], agent_id)
|
||||
self.assertEqual(
|
||||
agent["agent_secret_hash"],
|
||||
hash_agent_secret(agent_secret),
|
||||
)
|
||||
self.assertIsNotNone(agent["credential_created_at"])
|
||||
self.assertIsNone(agent["credential_revoked_at"])
|
||||
|
||||
self.assertIsNotNone(enrollment_row["used_at"])
|
||||
self.assertIsNone(enrollment_row["revoked_at"])
|
||||
self.assertEqual(
|
||||
enrollment_row["enrolled_agent_id"],
|
||||
agent_id,
|
||||
)
|
||||
|
||||
# Neither plaintext credential may persist in operational state.
|
||||
persisted_text = "\n".join(
|
||||
[
|
||||
str(enrollment_row),
|
||||
str(agent),
|
||||
*(row["payload"] for row in audit_rows),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertNotIn(token, persisted_text)
|
||||
self.assertNotIn(agent_secret, persisted_text)
|
||||
self.assertNotIn(agent_secret, agent["last_payload"])
|
||||
|
||||
# The newly issued per-Agent credential must immediately authenticate.
|
||||
auth_service = AgentAuthService(
|
||||
AgentAuthRepository()
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
auth_service.authenticate(
|
||||
agent_id,
|
||||
agent_secret,
|
||||
),
|
||||
agent_id,
|
||||
)
|
||||
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
auth_service.authenticate(
|
||||
agent_id,
|
||||
"definitely-not-the-issued-secret",
|
||||
)
|
||||
|
||||
# Normal check-in must never mutate the credential.
|
||||
secret_hash_before = agent["agent_secret_hash"]
|
||||
|
||||
AgentZoneService(
|
||||
AgentZoneRepository()
|
||||
).checkin(
|
||||
agent_id,
|
||||
AgentCheckinRequest(
|
||||
site_id=self.site_id,
|
||||
agent_zone_id=self.zone_id,
|
||||
site_name=self.site_name,
|
||||
hostname="integration-test-agent",
|
||||
version="integration-test",
|
||||
),
|
||||
)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
agent_secret_hash,
|
||||
credential_created_at,
|
||||
credential_revoked_at
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
after_checkin = cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(
|
||||
after_checkin["agent_secret_hash"],
|
||||
secret_hash_before,
|
||||
)
|
||||
self.assertIsNotNone(
|
||||
after_checkin["credential_created_at"]
|
||||
)
|
||||
self.assertIsNone(
|
||||
after_checkin["credential_revoked_at"]
|
||||
)
|
||||
|
||||
def test_expired_token_is_rejected_without_side_effects(self):
|
||||
enrollment = self.create_enrollment()
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE agent_enrollments
|
||||
SET created_at = now() - interval '2 hours',
|
||||
expires_at = now() - interval '1 hour'
|
||||
WHERE id = %s
|
||||
""",
|
||||
(enrollment["id"],),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
with self.assertRaises(AgentEnrollmentTokenInvalid):
|
||||
self.service.redeem(
|
||||
AgentEnrollRequest(
|
||||
token=enrollment["enrollment_token"]
|
||||
)
|
||||
)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT used_at, revoked_at, enrolled_agent_id
|
||||
FROM agent_enrollments
|
||||
WHERE id = %s
|
||||
""",
|
||||
(enrollment["id"],),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*) AS count
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_zone_id = %s
|
||||
""",
|
||||
(self.zone_id,),
|
||||
)
|
||||
agent_count = cur.fetchone()["count"]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertIsNone(row["used_at"])
|
||||
self.assertIsNone(row["revoked_at"])
|
||||
self.assertIsNone(row["enrolled_agent_id"])
|
||||
self.assertEqual(agent_count, 0)
|
||||
|
||||
def test_disabled_and_occupied_zone_do_not_consume_token(self):
|
||||
enrollment = self.create_enrollment()
|
||||
token = enrollment["enrollment_token"]
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE agent_zones
|
||||
SET is_enabled = false
|
||||
WHERE id = %s
|
||||
""",
|
||||
(self.zone_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.service.redeem(
|
||||
AgentEnrollRequest(token=token)
|
||||
)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE agent_zones
|
||||
SET is_enabled = true
|
||||
WHERE id = %s
|
||||
""",
|
||||
(self.zone_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
site_name
|
||||
)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
f"legacy-{uuid4().hex}",
|
||||
self.site_id,
|
||||
self.zone_id,
|
||||
self.site_name,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.service.redeem(
|
||||
AgentEnrollRequest(token=token)
|
||||
)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT used_at, revoked_at, enrolled_agent_id
|
||||
FROM agent_enrollments
|
||||
WHERE id = %s
|
||||
""",
|
||||
(enrollment["id"],),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertIsNone(row["used_at"])
|
||||
self.assertIsNone(row["revoked_at"])
|
||||
self.assertIsNone(row["enrolled_agent_id"])
|
||||
|
||||
def test_site_zone_mismatch_is_rejected_before_creation(self):
|
||||
wrong_site_id = uuid4()
|
||||
|
||||
with self.assertRaises(AgentEnrollmentNotFound):
|
||||
self.service.create_enrollment(
|
||||
AgentEnrollmentCreateRequest(
|
||||
site_id=wrong_site_id,
|
||||
agent_zone_id=self.zone_id,
|
||||
display_name="Wrong Site Agent",
|
||||
),
|
||||
actor_user_id=None,
|
||||
)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*) AS count
|
||||
FROM agent_enrollments
|
||||
WHERE agent_zone_id = %s
|
||||
""",
|
||||
(self.zone_id,),
|
||||
)
|
||||
count = cur.fetchone()["count"]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -19,7 +19,7 @@ from app.system_health.service import SystemHealthService
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).parents[1] / "app"
|
||||
EXPECTED_HEAD = "0016_agent_zones"
|
||||
EXPECTED_HEAD = "0017_agent_enrollment"
|
||||
|
||||
|
||||
def database_payload(revision=EXPECTED_HEAD):
|
||||
|
||||
Reference in new issue
Block a user