feat(agent): add durable existing-agent credential transitions
This commit is contained in:
1 parent
abf5654a25
commit
3cfb0d1abe
33 files changed
+3828
-54
No files matched your search
@@ -49,6 +49,14 @@ class AgentAuthServiceTests(unittest.TestCase):
|
||||
"legacy-agent",
|
||||
)
|
||||
|
||||
def test_secret_only_legacy_agent_maps_to_configured_identity(self):
|
||||
self.assertEqual(
|
||||
self.service().authenticate(None, "legacy-secret"),
|
||||
"legacy-agent",
|
||||
)
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service().authenticate(None, "wrong-secret")
|
||||
|
||||
def test_legacy_agent_with_null_credential_still_authenticates(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
@@ -116,13 +124,83 @@ class AgentAuthServiceTests(unittest.TestCase):
|
||||
with self.subTest(secret=secret), self.assertRaises(AgentCredentialRevoked):
|
||||
self.service(rows).authenticate("agent-new", secret)
|
||||
|
||||
def test_revoked_registry_row_never_falls_back_even_without_hash(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
"agent_id": "legacy-agent",
|
||||
"agent_secret_hash": None,
|
||||
"credential_created_at": None,
|
||||
"credential_revoked_at": NOW,
|
||||
}
|
||||
}
|
||||
for agent_id in ("legacy-agent", None):
|
||||
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||
AgentCredentialRevoked
|
||||
):
|
||||
self.service(rows).authenticate(agent_id, "legacy-secret")
|
||||
|
||||
def test_missing_identity_never_authenticates_enrolled_legacy_agent(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
"agent_id": "legacy-agent",
|
||||
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||
"credential_created_at": NOW,
|
||||
"credential_revoked_at": None,
|
||||
}
|
||||
}
|
||||
for secret in ("unique-secret", "legacy-secret"):
|
||||
with self.subTest(secret=secret), self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service(rows).authenticate(None, secret)
|
||||
|
||||
def test_missing_identity_reports_revoked_legacy_credential(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
"agent_id": "legacy-agent",
|
||||
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||
"credential_created_at": NOW,
|
||||
"credential_revoked_at": NOW,
|
||||
}
|
||||
}
|
||||
with self.assertRaises(AgentCredentialRevoked):
|
||||
self.service(rows).authenticate(None, "legacy-secret")
|
||||
|
||||
def test_durable_retirement_rejects_fallback_after_credentials_are_cleared(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
"agent_id": "legacy-agent",
|
||||
"agent_secret_hash": None,
|
||||
"credential_created_at": None,
|
||||
"credential_revoked_at": None,
|
||||
"legacy_auth_disabled_at": NOW,
|
||||
}
|
||||
}
|
||||
for agent_id in ("legacy-agent", None):
|
||||
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||
AgentAuthenticationFailed
|
||||
):
|
||||
self.service(rows).authenticate(agent_id, "legacy-secret")
|
||||
|
||||
def test_identity_retirement_rejects_fallback_without_current_agent_row(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
"agent_id": "legacy-agent",
|
||||
"agent_secret_hash": None,
|
||||
"credential_created_at": None,
|
||||
"credential_revoked_at": None,
|
||||
"legacy_auth_disabled_at": NOW,
|
||||
}
|
||||
}
|
||||
for agent_id in ("legacy-agent", None):
|
||||
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||
AgentAuthenticationFailed
|
||||
):
|
||||
self.service(rows).authenticate(agent_id, "legacy-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")
|
||||
def test_missing_secret_is_rejected(self):
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service().authenticate("legacy-agent", None)
|
||||
|
||||
@@ -131,6 +209,11 @@ class AgentAuthServiceTests(unittest.TestCase):
|
||||
with self.assertRaises(AgentAuthenticationUnavailable):
|
||||
self.service().authenticate("legacy-agent", "anything")
|
||||
|
||||
def test_secret_only_path_requires_configured_legacy_identity(self):
|
||||
settings.minerops_agent_id = ""
|
||||
with self.assertRaises(AgentAuthenticationUnavailable):
|
||||
self.service().authenticate(None, "legacy-secret")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,584 @@
|
||||
"""PostgreSQL security contract for existing-Agent credential transition."""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import os
|
||||
import unittest
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import ValidationError
|
||||
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,
|
||||
AgentEnrollmentCredentialCollision,
|
||||
AgentEnrollmentTokenInvalid,
|
||||
)
|
||||
from app.agent_enrollment.models import (
|
||||
AgentCredentialTransitionCreateRequest,
|
||||
AgentCredentialTransitionRedeemRequest,
|
||||
AgentEnrollmentCreateRequest,
|
||||
AgentEnrollRequest,
|
||||
)
|
||||
from app.agent_enrollment.repository import (
|
||||
AgentEnrollmentRepository,
|
||||
default_connection_factory,
|
||||
)
|
||||
from app.agent_enrollment.service import AgentEnrollmentService
|
||||
from app.agent_registry.repository import AgentRegistryRepository
|
||||
from app.config import settings
|
||||
|
||||
|
||||
PRODUCTION_AGENT_ID = "plexus-windows-agent"
|
||||
PRODUCTION_SITE_ID = UUID("00000000-0000-4000-8000-000000002001")
|
||||
PRODUCTION_ZONE_ID = UUID("00000000-0000-4000-8000-000000002002")
|
||||
PRODUCTION_MINER_ID = UUID("00000000-0000-4000-8000-000000002003")
|
||||
|
||||
|
||||
def connect():
|
||||
return default_connection_factory()
|
||||
|
||||
|
||||
class AgentCredentialTransitionPostgresTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if os.environ.get("MINEROPS_CREDENTIAL_TRANSITION_DISPOSABLE") != "1":
|
||||
raise unittest.SkipTest(
|
||||
"credential transition requires its disposable PostgreSQL runner"
|
||||
)
|
||||
if settings.minerops_agent_id != PRODUCTION_AGENT_ID:
|
||||
raise AssertionError(
|
||||
"runner must bind the production-shaped legacy Agent identity"
|
||||
)
|
||||
cls.service = AgentEnrollmentService(AgentEnrollmentRepository(connect))
|
||||
cls.auth = AgentAuthService(AgentAuthRepository(connect))
|
||||
|
||||
def create_legacy_agent(
|
||||
self,
|
||||
agent_id,
|
||||
*,
|
||||
site_id=None,
|
||||
zone_id=None,
|
||||
enabled=True,
|
||||
secret_hash=None,
|
||||
revoked=False,
|
||||
):
|
||||
site_id = site_id or uuid4()
|
||||
zone_id = zone_id or uuid4()
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO sites (id, name) VALUES (%s, %s)",
|
||||
(site_id, f"Transition Site {site_id.hex[:8]}"),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zones (id, site_id, name, is_enabled)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(zone_id, site_id, "Default Zone", enabled),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||
VALUES (%s, '10.220.0.0/24'::cidr)
|
||||
""",
|
||||
(zone_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, agent_zone_id, site_name,
|
||||
display_name, agent_secret_hash,
|
||||
credential_created_at, credential_revoked_at
|
||||
)
|
||||
VALUES (
|
||||
%s, %s, %s, %s, %s, %s,
|
||||
CASE WHEN %s::boolean THEN now() ELSE NULL END,
|
||||
CASE WHEN %s::boolean THEN now() ELSE NULL END
|
||||
)
|
||||
""",
|
||||
(
|
||||
agent_id,
|
||||
site_id,
|
||||
zone_id,
|
||||
f"Transition Site {site_id.hex[:8]}",
|
||||
f"Agent {agent_id}"[:120],
|
||||
secret_hash,
|
||||
secret_hash is not None,
|
||||
revoked,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
return site_id, zone_id
|
||||
|
||||
def create_transition(self, agent_id):
|
||||
return self.service.create_credential_transition(
|
||||
AgentCredentialTransitionCreateRequest(agent_id=agent_id),
|
||||
actor_user_id=None,
|
||||
)
|
||||
|
||||
def redeem_transition(self, token, secret):
|
||||
return AgentEnrollmentService(
|
||||
AgentEnrollmentRepository(connect)
|
||||
).redeem_credential_transition(
|
||||
AgentCredentialTransitionRedeemRequest(
|
||||
token=token,
|
||||
agent_secret_hash=hash_agent_secret(secret),
|
||||
)
|
||||
)
|
||||
|
||||
def agent_row(self, agent_id):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_id, site_id, agent_zone_id, agent_secret_hash,
|
||||
credential_created_at, credential_revoked_at,
|
||||
legacy_auth_disabled_at
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_01_production_identity_transitions_without_routing_change(self):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sites (id, name)
|
||||
VALUES (%s, 'Plexus Mining Site')
|
||||
""",
|
||||
(PRODUCTION_SITE_ID,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zones (id, site_id, name, is_enabled)
|
||||
VALUES (%s, %s, 'Default Zone', true)
|
||||
""",
|
||||
(PRODUCTION_ZONE_ID, PRODUCTION_SITE_ID),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||
VALUES (%s, '192.0.2.0/24'::cidr)
|
||||
""",
|
||||
(PRODUCTION_ZONE_ID,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, agent_zone_id, site_name, display_name
|
||||
)
|
||||
VALUES (%s, %s, %s, 'Plexus', 'Plexus Windows Agent')
|
||||
""",
|
||||
(
|
||||
PRODUCTION_AGENT_ID,
|
||||
PRODUCTION_SITE_ID,
|
||||
PRODUCTION_ZONE_ID,
|
||||
),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO miners (
|
||||
id, site_id, agent_zone_id, asset_tag,
|
||||
current_ip, current_mac, status
|
||||
)
|
||||
VALUES (
|
||||
%s, %s, %s, 'TRANSITION-PRODUCTION-MINER',
|
||||
'192.0.2.20', '02:00:00:00:20:03', 'online'
|
||||
)
|
||||
""",
|
||||
(
|
||||
PRODUCTION_MINER_ID,
|
||||
PRODUCTION_SITE_ID,
|
||||
PRODUCTION_ZONE_ID,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
legacy_secret = settings.minerops_agent_shared_secret
|
||||
self.assertEqual(
|
||||
self.auth.authenticate(None, legacy_secret),
|
||||
PRODUCTION_AGENT_ID,
|
||||
)
|
||||
|
||||
transition = self.create_transition(PRODUCTION_AGENT_ID)
|
||||
token = transition["transition_token"]
|
||||
self.assertEqual(transition["target_agent_id"], PRODUCTION_AGENT_ID)
|
||||
self.assertEqual(transition["site_id"], PRODUCTION_SITE_ID)
|
||||
self.assertEqual(transition["agent_zone_id"], PRODUCTION_ZONE_ID)
|
||||
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT token_hash, target_agent_id, used_at,
|
||||
enrolled_agent_id, site_id, agent_zone_id
|
||||
FROM agent_enrollments WHERE id = %s
|
||||
""",
|
||||
(transition["id"],),
|
||||
)
|
||||
authorization = cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertEqual(authorization["token_hash"], hash_agent_secret(token))
|
||||
self.assertNotEqual(authorization["token_hash"], token)
|
||||
self.assertEqual(authorization["target_agent_id"], PRODUCTION_AGENT_ID)
|
||||
self.assertIsNone(authorization["used_at"])
|
||||
|
||||
new_secret = "production-shaped-per-agent-secret"
|
||||
result = self.redeem_transition(token, new_secret)
|
||||
self.assertEqual(result["agent_id"], PRODUCTION_AGENT_ID)
|
||||
self.assertNotIn("agent_secret", result)
|
||||
self.assertEqual(result["site_id"], PRODUCTION_SITE_ID)
|
||||
self.assertEqual(result["agent_zone_id"], PRODUCTION_ZONE_ID)
|
||||
|
||||
# An ambiguous response is safely retryable only with the same locally
|
||||
# generated credential; no second transition or audit event is created.
|
||||
retried = self.redeem_transition(token, new_secret)
|
||||
self.assertEqual(retried, result)
|
||||
with self.assertRaises(AgentEnrollmentTokenInvalid):
|
||||
self.redeem_transition(token, "different-secret-cannot-rotate")
|
||||
|
||||
self.assertEqual(
|
||||
self.auth.authenticate(PRODUCTION_AGENT_ID, new_secret),
|
||||
PRODUCTION_AGENT_ID,
|
||||
)
|
||||
for supplied_id in (None, PRODUCTION_AGENT_ID):
|
||||
with self.subTest(supplied_id=supplied_id), self.assertRaises(
|
||||
AgentAuthenticationFailed
|
||||
):
|
||||
self.auth.authenticate(supplied_id, legacy_secret)
|
||||
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT site_id, agent_zone_id, agent_secret_hash,
|
||||
credential_created_at, legacy_auth_disabled_at
|
||||
FROM minerops_site_agents WHERE agent_id = %s
|
||||
""",
|
||||
(PRODUCTION_AGENT_ID,),
|
||||
)
|
||||
agent = cur.fetchone()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT site_id, agent_zone_id, current_mac::text,
|
||||
host(current_ip) AS current_ip
|
||||
FROM miners WHERE id = %s
|
||||
""",
|
||||
(PRODUCTION_MINER_ID,),
|
||||
)
|
||||
miner = cur.fetchone()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*) AS retirement_count
|
||||
FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(PRODUCTION_AGENT_ID,),
|
||||
)
|
||||
retirement_count = cur.fetchone()["retirement_count"]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*) AS redeemed_audits
|
||||
FROM audit_logs
|
||||
WHERE action = 'agent.credential_transition.redeemed'
|
||||
AND entity_id = %s
|
||||
""",
|
||||
(transition["id"],),
|
||||
)
|
||||
redeemed_audits = cur.fetchone()["redeemed_audits"]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(agent["site_id"], PRODUCTION_SITE_ID)
|
||||
self.assertEqual(agent["agent_zone_id"], PRODUCTION_ZONE_ID)
|
||||
self.assertEqual(agent["agent_secret_hash"], hash_agent_secret(new_secret))
|
||||
self.assertIsNotNone(agent["credential_created_at"])
|
||||
self.assertIsNotNone(agent["legacy_auth_disabled_at"])
|
||||
self.assertEqual(miner["site_id"], PRODUCTION_SITE_ID)
|
||||
self.assertEqual(miner["agent_zone_id"], PRODUCTION_ZONE_ID)
|
||||
self.assertEqual(miner["current_mac"], "02:00:00:00:20:03")
|
||||
self.assertEqual(miner["current_ip"], "192.0.2.20")
|
||||
self.assertEqual(retirement_count, 1)
|
||||
self.assertEqual(redeemed_audits, 1)
|
||||
|
||||
# Remove deletable enrollment/current state to prove that the separate
|
||||
# append-only retirement identity still defeats fallback on recreation.
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM agent_enrollments WHERE id = %s",
|
||||
(transition["id"],),
|
||||
)
|
||||
cur.execute(
|
||||
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||
(PRODUCTION_AGENT_ID,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, agent_zone_id, site_name
|
||||
) VALUES (%s, %s, %s, 'Recreated Plexus')
|
||||
""",
|
||||
(
|
||||
PRODUCTION_AGENT_ID,
|
||||
PRODUCTION_SITE_ID,
|
||||
PRODUCTION_ZONE_ID,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
recreated = self.agent_row(PRODUCTION_AGENT_ID)
|
||||
self.assertIsNone(recreated["agent_secret_hash"])
|
||||
self.assertIsNotNone(recreated["legacy_auth_disabled_at"])
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.auth.authenticate(PRODUCTION_AGENT_ID, legacy_secret)
|
||||
|
||||
def test_02_concurrent_redemption_has_one_credential_winner(self):
|
||||
agent_id = "transition-concurrent-agent"
|
||||
self.create_legacy_agent(agent_id)
|
||||
token = self.create_transition(agent_id)["transition_token"]
|
||||
secrets = ("concurrent-secret-a", "concurrent-secret-b")
|
||||
|
||||
def redeem(secret):
|
||||
try:
|
||||
return ("success", secret, self.redeem_transition(token, secret))
|
||||
except AgentEnrollmentTokenInvalid:
|
||||
return ("invalid", secret, None)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(executor.map(redeem, secrets))
|
||||
|
||||
winners = [item for item in results if item[0] == "success"]
|
||||
losers = [item for item in results if item[0] == "invalid"]
|
||||
self.assertEqual(len(winners), 1)
|
||||
self.assertEqual(len(losers), 1)
|
||||
row = self.agent_row(agent_id)
|
||||
self.assertEqual(row["agent_secret_hash"], hash_agent_secret(winners[0][1]))
|
||||
|
||||
def test_03_authorization_cannot_substitute_identity_or_steal_zone(self):
|
||||
agent_a = "transition-bound-agent-a"
|
||||
agent_b = "transition-bound-agent-b"
|
||||
site_a, zone_a = self.create_legacy_agent(agent_a)
|
||||
site_b, zone_b = self.create_legacy_agent(agent_b)
|
||||
token = self.create_transition(agent_a)["transition_token"]
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
AgentCredentialTransitionRedeemRequest.model_validate(
|
||||
{
|
||||
"token": token,
|
||||
"agent_secret_hash": hash_agent_secret("bound-secret"),
|
||||
"agent_id": agent_b,
|
||||
}
|
||||
)
|
||||
|
||||
result = self.redeem_transition(token, "bound-secret")
|
||||
self.assertEqual(result["agent_id"], agent_a)
|
||||
self.assertEqual((result["site_id"], result["agent_zone_id"]), (site_a, zone_a))
|
||||
other = self.agent_row(agent_b)
|
||||
self.assertEqual((other["site_id"], other["agent_zone_id"]), (site_b, zone_b))
|
||||
self.assertIsNone(other["agent_secret_hash"])
|
||||
|
||||
def test_04_revoked_retired_and_disabled_targets_fail_closed(self):
|
||||
revoked = "transition-revoked-agent"
|
||||
self.create_legacy_agent(
|
||||
revoked,
|
||||
secret_hash=hash_agent_secret("revoked-secret"),
|
||||
revoked=True,
|
||||
)
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.create_transition(revoked)
|
||||
|
||||
retired = "transition-retired-agent"
|
||||
self.create_legacy_agent(retired)
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO agent_legacy_auth_retirements (agent_id) VALUES (%s)",
|
||||
(retired,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.create_transition(retired)
|
||||
|
||||
disabled = "transition-disabled-agent"
|
||||
self.create_legacy_agent(disabled, enabled=False)
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.create_transition(disabled)
|
||||
|
||||
changed = "transition-zone-disabled-after-authorization"
|
||||
_, changed_zone = self.create_legacy_agent(changed)
|
||||
authorization = self.create_transition(changed)
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE agent_zones SET is_enabled = false WHERE id = %s",
|
||||
(changed_zone,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.redeem_transition(
|
||||
authorization["transition_token"], "disabled-zone-secret"
|
||||
)
|
||||
self.assertIsNone(self.agent_row(changed)["agent_secret_hash"])
|
||||
|
||||
def test_05_credential_collision_rolls_back_retirement_and_token_use(self):
|
||||
collision_hash = hash_agent_secret("already-issued-secret")
|
||||
self.create_legacy_agent(
|
||||
"transition-existing-credential-holder",
|
||||
secret_hash=collision_hash,
|
||||
)
|
||||
target = "transition-rollback-target"
|
||||
self.create_legacy_agent(target)
|
||||
transition = self.create_transition(target)
|
||||
|
||||
with self.assertRaises(AgentEnrollmentCredentialCollision):
|
||||
self.service.redeem_credential_transition(
|
||||
AgentCredentialTransitionRedeemRequest(
|
||||
token=transition["transition_token"],
|
||||
agent_secret_hash=collision_hash,
|
||||
)
|
||||
)
|
||||
|
||||
row = self.agent_row(target)
|
||||
self.assertIsNone(row["agent_secret_hash"])
|
||||
self.assertIsNone(row["credential_created_at"])
|
||||
self.assertIsNone(row["legacy_auth_disabled_at"])
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*) FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(target,),
|
||||
)
|
||||
self.assertEqual(cur.fetchone()[0], 0)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT used_at, enrolled_agent_id
|
||||
FROM agent_enrollments WHERE id = %s
|
||||
""",
|
||||
(transition["id"],),
|
||||
)
|
||||
self.assertEqual(cur.fetchone(), (None, None))
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_06_normal_enrollment_still_works_and_occupied_zone_is_protected(self):
|
||||
site_id, occupied_zone = self.create_legacy_agent(
|
||||
"transition-ordinary-occupied"
|
||||
)
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.service.create_enrollment(
|
||||
AgentEnrollmentCreateRequest(
|
||||
site_id=site_id,
|
||||
agent_zone_id=occupied_zone,
|
||||
display_name="Must Not Steal Occupied Zone",
|
||||
),
|
||||
actor_user_id=None,
|
||||
)
|
||||
|
||||
free_zone = uuid4()
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zones (id, site_id, name, is_enabled)
|
||||
VALUES (%s, %s, 'Free Enrollment Zone', true)
|
||||
""",
|
||||
(free_zone, site_id),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
enrollment = self.service.create_enrollment(
|
||||
AgentEnrollmentCreateRequest(
|
||||
site_id=site_id,
|
||||
agent_zone_id=free_zone,
|
||||
display_name="Ordinary New Agent",
|
||||
),
|
||||
actor_user_id=None,
|
||||
)
|
||||
enrolled = self.service.redeem(
|
||||
AgentEnrollRequest(token=enrollment["enrollment_token"])
|
||||
)
|
||||
self.assertRegex(enrolled["agent_id"], r"^agent-[0-9a-f]{32}$")
|
||||
self.assertEqual(enrolled["agent_zone_id"], free_zone)
|
||||
|
||||
def test_07_registry_history_and_audit_never_expose_plaintext(self):
|
||||
agent_id = "transition-privacy-agent"
|
||||
self.create_legacy_agent(agent_id)
|
||||
transition = self.create_transition(agent_id)
|
||||
token = transition["transition_token"]
|
||||
|
||||
listed = self.service.list_enrollments()
|
||||
item = next(row for row in listed if row["id"] == transition["id"])
|
||||
self.assertEqual(item["target_agent_id"], agent_id)
|
||||
self.assertEqual(item["purpose"], "existing_agent_credential_transition")
|
||||
serialized_item = repr(item)
|
||||
self.assertNotIn(token, serialized_item)
|
||||
for forbidden in (
|
||||
"token_hash",
|
||||
"transition_token",
|
||||
"agent_secret",
|
||||
"agent_secret_hash",
|
||||
):
|
||||
self.assertNotIn(forbidden, item)
|
||||
|
||||
registry = AgentRegistryRepository().list_registry()
|
||||
registry_agent = next(
|
||||
row for row in registry["agents"] if row["agent_id"] == agent_id
|
||||
)
|
||||
for forbidden in ("agent_secret", "agent_secret_hash", "token_hash"):
|
||||
self.assertNotIn(forbidden, registry_agent)
|
||||
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT new_value_json::text
|
||||
FROM audit_logs
|
||||
WHERE entity_id = %s
|
||||
""",
|
||||
(transition["id"],),
|
||||
)
|
||||
audit_text = "\n".join(row[0] for row in cur.fetchall())
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertNotIn(token, audit_text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -5,13 +5,24 @@ import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.agent_enrollment.dependencies import get_agent_enrollment_service
|
||||
from app.agent_auth.service import hash_agent_secret
|
||||
from app.agent_enrollment.models import (
|
||||
AgentCredentialTransitionCreateRequest,
|
||||
AgentCredentialTransitionRedeemRequest,
|
||||
AgentEnrollmentCreateRequest,
|
||||
AgentEnrollRequest,
|
||||
)
|
||||
from app.agent_enrollment.service import AgentEnrollmentService
|
||||
from app.identity import machine_agent_request, required_api_permission
|
||||
from app.agent_enrollment.router import router
|
||||
from app.identity import (
|
||||
get_current_user,
|
||||
machine_agent_request,
|
||||
required_api_permission,
|
||||
)
|
||||
|
||||
|
||||
SITE_ID = UUID("00000000-0000-4000-8000-000000001700")
|
||||
@@ -25,6 +36,8 @@ class FakeAgentEnrollmentRepository:
|
||||
def __init__(self):
|
||||
self.created = None
|
||||
self.redeemed = None
|
||||
self.transition_created = None
|
||||
self.transition_redeemed = None
|
||||
|
||||
def create_enrollment(self, **kwargs):
|
||||
self.created = kwargs
|
||||
@@ -42,6 +55,20 @@ class FakeAgentEnrollmentRepository:
|
||||
def list_enrollments(self, **kwargs):
|
||||
return []
|
||||
|
||||
def create_credential_transition(self, **kwargs):
|
||||
self.transition_created = kwargs
|
||||
return {
|
||||
"id": ENROLLMENT_ID,
|
||||
"target_agent_id": kwargs["target_agent_id"],
|
||||
"site_id": SITE_ID,
|
||||
"site_name": "Test Site",
|
||||
"agent_zone_id": ZONE_ID,
|
||||
"agent_zone_name": "Test Zone",
|
||||
"display_name": "Existing Agent",
|
||||
"created_at": NOW,
|
||||
"expires_at": NOW + timedelta(minutes=30),
|
||||
}
|
||||
|
||||
def redeem_enrollment(self, **kwargs):
|
||||
self.redeemed = kwargs
|
||||
return {
|
||||
@@ -54,6 +81,18 @@ class FakeAgentEnrollmentRepository:
|
||||
"ip_ranges": ["10.30.0.0/22"],
|
||||
}
|
||||
|
||||
def redeem_credential_transition(self, **kwargs):
|
||||
self.transition_redeemed = kwargs
|
||||
return {
|
||||
"agent_id": "existing-agent",
|
||||
"site_id": SITE_ID,
|
||||
"site_name": "Test Site",
|
||||
"agent_zone_id": ZONE_ID,
|
||||
"agent_zone_name": "Test Zone",
|
||||
"display_name": "Existing 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):
|
||||
@@ -123,6 +162,45 @@ class AgentEnrollmentServiceTests(unittest.TestCase):
|
||||
serialized = repr(repository.redeemed)
|
||||
self.assertNotIn(secret, serialized)
|
||||
|
||||
def test_existing_transition_is_server_bound_and_accepts_only_secret_hash(self):
|
||||
repository = FakeAgentEnrollmentRepository()
|
||||
service = AgentEnrollmentService(repository)
|
||||
|
||||
created = service.create_credential_transition(
|
||||
AgentCredentialTransitionCreateRequest(
|
||||
agent_id=" existing-agent "
|
||||
),
|
||||
actor_user_id=USER_ID,
|
||||
)
|
||||
token = created["transition_token"]
|
||||
|
||||
self.assertEqual(
|
||||
repository.transition_created["target_agent_id"],
|
||||
"existing-agent",
|
||||
)
|
||||
self.assertEqual(
|
||||
repository.transition_created["token_hash"],
|
||||
hash_agent_secret(token),
|
||||
)
|
||||
self.assertNotIn(token, repr(repository.transition_created))
|
||||
|
||||
credential_hash = hash_agent_secret("locally-generated-secret")
|
||||
result = service.redeem_credential_transition(
|
||||
AgentCredentialTransitionRedeemRequest(
|
||||
token=token,
|
||||
agent_secret_hash=credential_hash,
|
||||
)
|
||||
)
|
||||
self.assertEqual(result["agent_id"], "existing-agent")
|
||||
self.assertEqual(
|
||||
repository.transition_redeemed,
|
||||
{
|
||||
"token_hash": hash_agent_secret(token),
|
||||
"agent_secret_hash": credential_hash,
|
||||
},
|
||||
)
|
||||
self.assertNotIn("agent_secret", result)
|
||||
|
||||
def test_enrollment_admin_permission_mapping_is_explicit(self):
|
||||
self.assertEqual(
|
||||
required_api_permission("GET", "/api/agent-enrollments"),
|
||||
@@ -132,9 +210,18 @@ class AgentEnrollmentServiceTests(unittest.TestCase):
|
||||
required_api_permission("POST", "/api/agent-enrollments"),
|
||||
"agents.manage",
|
||||
)
|
||||
self.assertEqual(
|
||||
required_api_permission(
|
||||
"POST", "/api/agent-credential-transitions"
|
||||
),
|
||||
"agents.manage",
|
||||
)
|
||||
|
||||
def test_bootstrap_endpoint_is_machine_exempt_but_admin_endpoint_is_not(self):
|
||||
self.assertTrue(machine_agent_request("/api/agent/enroll"))
|
||||
self.assertTrue(
|
||||
machine_agent_request("/api/agent/credential-transition")
|
||||
)
|
||||
self.assertFalse(
|
||||
machine_agent_request("/api/agent-enrollments")
|
||||
)
|
||||
@@ -153,5 +240,66 @@ class AgentEnrollmentServiceTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class AgentCredentialTransitionRouterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = FakeAgentEnrollmentRepository()
|
||||
self.service = AgentEnrollmentService(self.repository)
|
||||
self.app = FastAPI()
|
||||
self.app.include_router(router)
|
||||
self.app.dependency_overrides[get_agent_enrollment_service] = (
|
||||
lambda: self.service
|
||||
)
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||
"id": str(USER_ID),
|
||||
"role": "Test",
|
||||
"permissions": ("agents.view", "agents.manage"),
|
||||
}
|
||||
self.client = TestClient(self.app)
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
|
||||
def test_admin_creation_is_distinct_no_store_and_identity_only(self):
|
||||
response = self.client.post(
|
||||
"/api/agent-credential-transitions",
|
||||
json={"agent_id": "existing-agent"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
payload = response.json()["data"]
|
||||
self.assertEqual(payload["target_agent_id"], "existing-agent")
|
||||
self.assertIn("transition_token", payload)
|
||||
self.assertNotIn("site_id", self.repository.transition_created)
|
||||
self.assertNotIn("agent_zone_id", self.repository.transition_created)
|
||||
|
||||
def test_machine_redemption_returns_no_permanent_plaintext_secret(self):
|
||||
response = self.client.post(
|
||||
"/api/agent/credential-transition",
|
||||
json={
|
||||
"token": "T" * 43,
|
||||
"agent_secret_hash": hash_agent_secret(
|
||||
"locally-generated-router-secret"
|
||||
),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
payload = response.json()["data"]
|
||||
self.assertEqual(payload["agent_id"], "existing-agent")
|
||||
self.assertNotIn("agent_secret", payload)
|
||||
|
||||
def test_view_only_admin_cannot_create_transition(self):
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||
"id": str(USER_ID),
|
||||
"role": "Test",
|
||||
"permissions": ("agents.view",),
|
||||
}
|
||||
response = self.client.post(
|
||||
"/api/agent-credential-transitions",
|
||||
json={"agent_id": "existing-agent"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from uuid import uuid4
|
||||
|
||||
from psycopg.errors import ForeignKeyViolation
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.agent_auth.exceptions import AgentAuthenticationFailed
|
||||
@@ -11,6 +12,7 @@ from app.agent_auth.repository import AgentAuthRepository
|
||||
from app.agent_auth.service import AgentAuthService, hash_agent_secret
|
||||
from app.agent_enrollment.exceptions import (
|
||||
AgentEnrollmentConflict,
|
||||
AgentEnrollmentCredentialCollision,
|
||||
AgentEnrollmentNotFound,
|
||||
AgentEnrollmentTokenInvalid,
|
||||
)
|
||||
@@ -26,6 +28,7 @@ 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
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
@@ -322,6 +325,7 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
agent_secret_hash,
|
||||
credential_created_at,
|
||||
credential_revoked_at,
|
||||
legacy_auth_disabled_at,
|
||||
last_payload::text AS last_payload
|
||||
FROM minerops_site_agents
|
||||
WHERE site_id = %s
|
||||
@@ -345,6 +349,16 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
)
|
||||
enrollment_row = cur.fetchone()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT retired_at
|
||||
FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
retirement_row = cur.fetchone()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT new_value_json::text AS payload
|
||||
@@ -369,6 +383,15 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
)
|
||||
self.assertIsNotNone(agent["credential_created_at"])
|
||||
self.assertIsNone(agent["credential_revoked_at"])
|
||||
self.assertEqual(
|
||||
agent["legacy_auth_disabled_at"],
|
||||
agent["credential_created_at"],
|
||||
)
|
||||
self.assertIsNotNone(retirement_row)
|
||||
self.assertEqual(
|
||||
retirement_row["retired_at"],
|
||||
agent["credential_created_at"],
|
||||
)
|
||||
|
||||
self.assertIsNotNone(enrollment_row["used_at"])
|
||||
self.assertIsNone(enrollment_row["revoked_at"])
|
||||
@@ -409,6 +432,17 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
"definitely-not-the-issued-secret",
|
||||
)
|
||||
|
||||
original_legacy_id = settings.minerops_agent_id
|
||||
original_legacy_secret = settings.minerops_agent_shared_secret
|
||||
settings.minerops_agent_id = agent_id
|
||||
settings.minerops_agent_shared_secret = "global-secret-must-not-work"
|
||||
try:
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
auth_service.authenticate(None, "global-secret-must-not-work")
|
||||
finally:
|
||||
settings.minerops_agent_id = original_legacy_id
|
||||
settings.minerops_agent_shared_secret = original_legacy_secret
|
||||
|
||||
# Normal check-in must never mutate the credential.
|
||||
secret_hash_before = agent["agent_secret_hash"]
|
||||
|
||||
@@ -433,7 +467,8 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
SELECT
|
||||
agent_secret_hash,
|
||||
credential_created_at,
|
||||
credential_revoked_at
|
||||
credential_revoked_at,
|
||||
legacy_auth_disabled_at
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
@@ -453,6 +488,150 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
self.assertIsNone(
|
||||
after_checkin["credential_revoked_at"]
|
||||
)
|
||||
self.assertEqual(
|
||||
after_checkin["legacy_auth_disabled_at"],
|
||||
agent["legacy_auth_disabled_at"],
|
||||
)
|
||||
|
||||
# 0017 enrollment integrity remains RESTRICT: the current Agent row
|
||||
# cannot be deleted while its enrollment relationship exists.
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with self.assertRaises(ForeignKeyViolation):
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||
(agent_id,),
|
||||
)
|
||||
connection.rollback()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
# After an operator deliberately removes the enrollment history, the
|
||||
# independent retirement ledger must outlive deletion and recreation
|
||||
# of the current Agent row with the identical identity.
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM agent_enrollments WHERE id = %s",
|
||||
(enrollment["id"],),
|
||||
)
|
||||
cur.execute(
|
||||
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||
(agent_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
original_legacy_id = settings.minerops_agent_id
|
||||
original_legacy_secret = settings.minerops_agent_shared_secret
|
||||
settings.minerops_agent_id = agent_id
|
||||
settings.minerops_agent_shared_secret = "recreation-global-secret"
|
||||
try:
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
auth_service.authenticate(None, "recreation-global-secret")
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
site_name
|
||||
)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING legacy_auth_disabled_at
|
||||
""",
|
||||
(
|
||||
agent_id,
|
||||
self.site_id,
|
||||
self.zone_id,
|
||||
self.site_name,
|
||||
),
|
||||
)
|
||||
recreated = cur.fetchone()
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(
|
||||
recreated["legacy_auth_disabled_at"],
|
||||
retirement_row["retired_at"],
|
||||
)
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
auth_service.authenticate(agent_id, "recreation-global-secret")
|
||||
finally:
|
||||
settings.minerops_agent_id = original_legacy_id
|
||||
settings.minerops_agent_shared_secret = original_legacy_secret
|
||||
|
||||
def test_failed_credential_issuance_rolls_back_new_retirement(self):
|
||||
enrollment = self.create_enrollment("Collision Agent")
|
||||
collision_id = f"agent-collision-{uuid4().hex}"
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||
VALUES (%s, 'Existing uncredentialed identity')
|
||||
""",
|
||||
(collision_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
try:
|
||||
with self.assertRaises(AgentEnrollmentCredentialCollision):
|
||||
AgentEnrollmentRepository().redeem_enrollment(
|
||||
token_hash=hash_agent_secret(enrollment["enrollment_token"]),
|
||||
agent_id=collision_id,
|
||||
agent_secret_hash=hash_agent_secret("never-issued-secret"),
|
||||
)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*) AS count
|
||||
FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(collision_id,),
|
||||
)
|
||||
self.assertEqual(cur.fetchone()["count"], 0)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT used_at, enrolled_agent_id
|
||||
FROM agent_enrollments
|
||||
WHERE id = %s
|
||||
""",
|
||||
(enrollment["id"],),
|
||||
)
|
||||
enrollment_row = cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertIsNone(enrollment_row["used_at"])
|
||||
self.assertIsNone(enrollment_row["enrolled_agent_id"])
|
||||
finally:
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||
(collision_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_expired_token_is_rejected_without_side_effects(self):
|
||||
enrollment = self.create_enrollment()
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Disposable PostgreSQL contract for durable legacy-auth retirement."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from psycopg.errors import CheckViolation
|
||||
|
||||
from app.agent_auth.exceptions import (
|
||||
AgentAuthenticationFailed,
|
||||
AgentCredentialRevoked,
|
||||
)
|
||||
from app.agent_auth.repository import AgentAuthRepository, default_connection_factory
|
||||
from app.agent_auth.service import AgentAuthService
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class AgentLegacyAuthRetirementPostgresTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if os.environ.get("MINEROPS_LEGACY_RETIREMENT_DISPOSABLE") != "1":
|
||||
raise unittest.SkipTest(
|
||||
"legacy-auth retirement requires its disposable PostgreSQL runner"
|
||||
)
|
||||
cls.case = os.environ["MINEROPS_LEGACY_RETIREMENT_CASE"]
|
||||
cls.agent_id = settings.minerops_agent_id
|
||||
cls.auth = AgentAuthService(AgentAuthRepository())
|
||||
|
||||
def row(self):
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_secret_hash, credential_created_at,
|
||||
credential_revoked_at, legacy_auth_disabled_at
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(self.agent_id,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def retirement_row(self, agent_id=None):
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_id, retired_at
|
||||
FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(agent_id or self.agent_id,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_01_migration_backfill_is_selective(self):
|
||||
row = self.row()
|
||||
self.assertIsNotNone(row)
|
||||
if self.case == "legacy":
|
||||
self.assertEqual(row, (None, None, None, None))
|
||||
self.assertIsNone(self.retirement_row())
|
||||
else:
|
||||
self.assertIsNotNone(row[3])
|
||||
retirement = self.retirement_row()
|
||||
self.assertIsNotNone(retirement)
|
||||
self.assertEqual(retirement[1], row[3])
|
||||
|
||||
def test_02_real_repository_authentication_obeys_retirement(self):
|
||||
secret = settings.minerops_agent_shared_secret
|
||||
if self.case == "legacy":
|
||||
self.assertEqual(self.auth.authenticate(None, secret), self.agent_id)
|
||||
self.assertEqual(
|
||||
self.auth.authenticate(self.agent_id, secret), self.agent_id
|
||||
)
|
||||
return
|
||||
|
||||
for agent_id in (None, self.agent_id):
|
||||
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||
AgentCredentialRevoked
|
||||
):
|
||||
self.auth.authenticate(agent_id, secret)
|
||||
|
||||
def test_03_clearing_credentials_cannot_restore_global_fallback(self):
|
||||
if self.case == "legacy":
|
||||
self.skipTest("uncredentialed legacy case must remain eligible")
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE minerops_site_agents
|
||||
SET agent_secret_hash = NULL,
|
||||
credential_created_at = NULL,
|
||||
credential_revoked_at = NULL
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(self.agent_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
row = self.row()
|
||||
self.assertEqual(row[:3], (None, None, None))
|
||||
self.assertIsNotNone(row[3])
|
||||
for agent_id in (None, self.agent_id):
|
||||
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||
AgentAuthenticationFailed
|
||||
):
|
||||
self.auth.authenticate(
|
||||
agent_id, settings.minerops_agent_shared_secret
|
||||
)
|
||||
|
||||
def test_04_deletion_and_recreation_cannot_restore_fallback(self):
|
||||
if self.case == "legacy":
|
||||
self.skipTest("uncredentialed legacy case has no retirement history")
|
||||
|
||||
retirement = self.retirement_row()
|
||||
self.assertIsNotNone(retirement)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||
(self.agent_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertIsNone(self.row())
|
||||
self.assertEqual(self.retirement_row(), retirement)
|
||||
for agent_id in (None, self.agent_id):
|
||||
with self.subTest(state="deleted", agent_id=agent_id), self.assertRaises(
|
||||
AgentAuthenticationFailed
|
||||
):
|
||||
self.auth.authenticate(
|
||||
agent_id, settings.minerops_agent_shared_secret
|
||||
)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||
VALUES (%s, 'Recreated')
|
||||
""",
|
||||
(self.agent_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
recreated = self.row()
|
||||
self.assertEqual(recreated[:3], (None, None, None))
|
||||
self.assertEqual(recreated[3], retirement[1])
|
||||
for agent_id in (None, self.agent_id):
|
||||
with self.subTest(state="recreated", agent_id=agent_id), self.assertRaises(
|
||||
AgentAuthenticationFailed
|
||||
):
|
||||
self.auth.authenticate(
|
||||
agent_id, settings.minerops_agent_shared_secret
|
||||
)
|
||||
|
||||
def test_05_retirement_ledger_and_projection_are_one_way(self):
|
||||
if self.case == "legacy":
|
||||
self.skipTest("uncredentialed legacy case has no retirement history")
|
||||
|
||||
statements = (
|
||||
"""
|
||||
UPDATE agent_legacy_auth_retirements
|
||||
SET retired_at = retired_at + interval '1 second'
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
"""
|
||||
DELETE FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
"TRUNCATE agent_legacy_auth_retirements",
|
||||
"""
|
||||
UPDATE minerops_site_agents
|
||||
SET legacy_auth_disabled_at = NULL
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
)
|
||||
for statement in statements:
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with self.subTest(statement=" ".join(statement.split())):
|
||||
with self.assertRaises(CheckViolation):
|
||||
with connection.cursor() as cur:
|
||||
if "%s" in statement:
|
||||
cur.execute(statement, (self.agent_id,))
|
||||
else:
|
||||
cur.execute(statement)
|
||||
connection.rollback()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertIsNotNone(self.retirement_row())
|
||||
self.assertIsNotNone(self.row()[3])
|
||||
|
||||
def test_06_other_legacy_identity_is_not_accidentally_retired(self):
|
||||
if self.case == "legacy":
|
||||
self.skipTest("cross-identity check runs in the retired case")
|
||||
|
||||
other_id = self.agent_id + "-untouched"
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||
VALUES (%s, 'Untouched')
|
||||
""",
|
||||
(other_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertIsNone(self.retirement_row(other_id))
|
||||
original_legacy_id = settings.minerops_agent_id
|
||||
settings.minerops_agent_id = other_id
|
||||
try:
|
||||
self.assertEqual(
|
||||
self.auth.authenticate(
|
||||
other_id, settings.minerops_agent_shared_secret
|
||||
),
|
||||
other_id,
|
||||
)
|
||||
finally:
|
||||
settings.minerops_agent_id = original_legacy_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -73,7 +73,8 @@ class AgentRegistryPostgresTests(unittest.TestCase):
|
||||
version,
|
||||
ip_address,
|
||||
agent_secret_hash,
|
||||
credential_created_at
|
||||
credential_created_at,
|
||||
legacy_auth_disabled_at
|
||||
)
|
||||
VALUES (
|
||||
%s,
|
||||
@@ -85,6 +86,7 @@ class AgentRegistryPostgresTests(unittest.TestCase):
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
""",
|
||||
|
||||
@@ -90,6 +90,23 @@ class FakeRepository:
|
||||
"zone_enabled": zone_row["is_enabled"] if zone_row else None,
|
||||
}
|
||||
|
||||
def get_agent_configuration(self, agent_id):
|
||||
assignment = self.get_agent_assignment(agent_id)
|
||||
if not assignment:
|
||||
return None
|
||||
zone_row = self.zones.get(assignment.get("agent_zone_id"))
|
||||
return {
|
||||
"agent_id": agent_id,
|
||||
"site_id": assignment.get("site_id"),
|
||||
"agent_zone_id": assignment.get("agent_zone_id"),
|
||||
"site_name": assignment.get("site_name"),
|
||||
"agent_zone_name": zone_row["name"] if zone_row else None,
|
||||
"agent_zone_enabled": zone_row["is_enabled"] if zone_row else None,
|
||||
"networks": [
|
||||
item["network"] for item in (zone_row or {}).get("networks", [])
|
||||
],
|
||||
}
|
||||
|
||||
def assign_miners_to_zone(self, miner_ids, agent_zone_id):
|
||||
self.assigned = (list(miner_ids), agent_zone_id)
|
||||
return {miner_id: self.assign_result.get(miner_id, "miner_not_found")
|
||||
@@ -218,6 +235,25 @@ class AgentZoneServiceTests(unittest.TestCase):
|
||||
self.assertEqual(result.routing_reason, "agent_zone_conflict")
|
||||
self.assertIsNone(result.site_id)
|
||||
|
||||
def test_configuration_uses_only_authenticated_agent_zone_networks(self):
|
||||
self.repository.zones[ZONE_A]["networks"] = [
|
||||
{"id": uuid4(), "network": "10.30.0.0/24"}
|
||||
]
|
||||
self.repository.zones[ZONE_B] = zone(ZONE_B, SITE_B, name="Remote")
|
||||
self.repository.zones[ZONE_B]["networks"] = [
|
||||
{"id": uuid4(), "network": "10.80.0.0/24"}
|
||||
]
|
||||
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||
|
||||
configuration = self.service.configuration("agent-a")
|
||||
|
||||
self.assertEqual(configuration["agent_zone_id"], ZONE_A)
|
||||
self.assertEqual(
|
||||
[item["cidr"] for item in configuration["ip_ranges"]],
|
||||
["10.30.0.0/24"],
|
||||
)
|
||||
self.assertNotIn("10.80.0.0/24", str(configuration))
|
||||
|
||||
|
||||
class AgentZoneRouterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Full HTTP/FastAPI plus PostgreSQL contract for the unchanged legacy Agent."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.agent_auth.service import hash_agent_secret
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
|
||||
|
||||
SITE_A = UUID("00000000-0000-4000-8000-000000001a01")
|
||||
SITE_B = UUID("00000000-0000-4000-8000-000000001a02")
|
||||
ZONE_C = UUID("00000000-0000-4000-8000-000000001a03")
|
||||
MINER_A = UUID("00000000-0000-4000-8000-000000001a11")
|
||||
MINER_B = UUID("00000000-0000-4000-8000-000000001a12")
|
||||
LEGACY_AGENT = "legacy-plexus-http"
|
||||
ENROLLED_AGENT = "enrolled-http-agent"
|
||||
REVOKED_AGENT = "revoked-http-agent"
|
||||
ENROLLED_SECRET = "enrolled-http-agent-secret"
|
||||
REVOKED_SECRET = "revoked-http-agent-secret"
|
||||
ZONE_A_NETWORK = "10.31.0.0/24"
|
||||
ZONE_B_NETWORK = "10.81.0.0/24"
|
||||
ZONE_C_NETWORK = "10.82.0.0/24"
|
||||
|
||||
|
||||
def connect():
|
||||
from app.agent_auth.repository import default_connection_factory
|
||||
|
||||
return default_connection_factory()
|
||||
|
||||
|
||||
class LegacyAgentHttpPostgresTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if os.environ.get("MINEROPS_LEGACY_HTTP_POSTGRES_DISPOSABLE") != "1":
|
||||
raise unittest.SkipTest(
|
||||
"full-stack legacy contract requires its disposable runner"
|
||||
)
|
||||
if settings.minerops_agent_id != LEGACY_AGENT:
|
||||
raise AssertionError("runner did not bind the configured legacy identity")
|
||||
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_zone_id
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(LEGACY_AGENT,),
|
||||
)
|
||||
cls.zone_a = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM agent_zones
|
||||
WHERE site_id = %s AND name = 'Default Zone'
|
||||
""",
|
||||
(SITE_B,),
|
||||
)
|
||||
cls.zone_b = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||
VALUES
|
||||
(%s, %s::cidr),
|
||||
(%s, %s::cidr)
|
||||
""",
|
||||
(cls.zone_a, ZONE_A_NETWORK, cls.zone_b, ZONE_B_NETWORK),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zones (id, site_id, name)
|
||||
VALUES (%s, %s, 'Revoked Contract Zone')
|
||||
""",
|
||||
(ZONE_C, SITE_B),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||
VALUES (%s, %s::cidr)
|
||||
""",
|
||||
(ZONE_C, ZONE_C_NETWORK),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, agent_zone_id, site_name, display_name,
|
||||
agent_secret_hash, credential_created_at,
|
||||
credential_revoked_at, legacy_auth_disabled_at
|
||||
) VALUES
|
||||
(%s, %s, %s, 'Contract Site B', 'Enrolled Agent',
|
||||
%s, now(), NULL, now()),
|
||||
(%s, %s, %s, 'Contract Site B', 'Revoked Agent',
|
||||
%s, now() - interval '1 hour', now(),
|
||||
now() - interval '1 hour')
|
||||
""",
|
||||
(
|
||||
ENROLLED_AGENT,
|
||||
SITE_B,
|
||||
cls.zone_b,
|
||||
hash_agent_secret(ENROLLED_SECRET),
|
||||
REVOKED_AGENT,
|
||||
SITE_B,
|
||||
ZONE_C,
|
||||
hash_agent_secret(REVOKED_SECRET),
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
# Deliberately do not enter TestClient as a context manager. That keeps
|
||||
# unrelated application startup jobs out of this focused HTTP contract
|
||||
# while exercising the real ASGI middleware, dependencies, and routes.
|
||||
cls.client = TestClient(app)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.client.close()
|
||||
|
||||
@staticmethod
|
||||
def legacy_headers():
|
||||
return {"X-MinerOps-Agent-Secret": settings.minerops_agent_shared_secret}
|
||||
|
||||
@staticmethod
|
||||
def agent_headers(agent_id, secret):
|
||||
return {
|
||||
"X-MinerOps-Agent-ID": agent_id,
|
||||
"X-MinerOps-Agent-Secret": secret,
|
||||
}
|
||||
|
||||
def assert_no_credentials(self, response):
|
||||
lowered = response.text.lower()
|
||||
for forbidden in (
|
||||
"agent_secret_hash", "credential_created_at", "token_hash",
|
||||
settings.minerops_agent_shared_secret.lower(),
|
||||
ENROLLED_SECRET.lower(), REVOKED_SECRET.lower(),
|
||||
):
|
||||
self.assertNotIn(forbidden, lowered)
|
||||
|
||||
def test_10_secret_only_config_is_legacy_identity_and_zone_bound(self):
|
||||
response = self.client.get(
|
||||
"/api/agent/config?agent_id=" + ENROLLED_AGENT
|
||||
+ "&agent_zone_id=" + str(self.zone_b),
|
||||
headers=self.legacy_headers(),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["agent_id"], LEGACY_AGENT)
|
||||
self.assertEqual(payload["agent_zone_id"], str(self.zone_a))
|
||||
self.assertEqual(
|
||||
[item["cidr"] for item in payload["ip_ranges"]],
|
||||
[ZONE_A_NETWORK],
|
||||
)
|
||||
self.assertNotIn(ZONE_B_NETWORK, response.text)
|
||||
self.assertNotIn(ZONE_C_NETWORK, response.text)
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
def test_20_known_miners_returns_only_migrated_legacy_zone(self):
|
||||
response = self.client.get(
|
||||
"/api/agent/known-miners", headers=self.legacy_headers()
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(
|
||||
[item["miner_id"] for item in response.json()["miners"]],
|
||||
[str(MINER_A)],
|
||||
)
|
||||
self.assertNotIn(str(MINER_B), response.text)
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
def test_30_exact_old_checkin_shape_succeeds_and_spoofing_fails(self):
|
||||
old_body = {
|
||||
"agent_id": LEGACY_AGENT,
|
||||
"site_name": "Plexus",
|
||||
"hostname": "legacy-contract-host",
|
||||
"version": "0.4-command-queue",
|
||||
"ip_address": "",
|
||||
"local_time": "2026-08-31T00:00:00Z",
|
||||
}
|
||||
response = self.client.post(
|
||||
"/api/agent/checkin", headers=self.legacy_headers(), json=old_body
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(response.json()["agent_id"], LEGACY_AGENT)
|
||||
self.assertEqual(response.json()["agent_zone_id"], str(self.zone_a))
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
spoofed = self.client.post(
|
||||
"/api/agent/checkin?agent_id=" + ENROLLED_AGENT,
|
||||
headers=self.legacy_headers(),
|
||||
json={**old_body, "agent_id": "body-spoof-agent"},
|
||||
)
|
||||
self.assertEqual(spoofed.status_code, 422, spoofed.text)
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT count(*) FROM minerops_site_agents WHERE agent_id = %s",
|
||||
("body-spoof-agent",),
|
||||
)
|
||||
self.assertEqual(cur.fetchone()[0], 0)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_40_global_secret_rejects_unknown_and_enrolled_explicit_ids(self):
|
||||
for agent_id, expected_status in (
|
||||
("unknown-agent", 403),
|
||||
(ENROLLED_AGENT, 401),
|
||||
):
|
||||
with self.subTest(agent_id=agent_id):
|
||||
response = self.client.get(
|
||||
"/api/agent/config",
|
||||
headers=self.agent_headers(
|
||||
agent_id, settings.minerops_agent_shared_secret
|
||||
),
|
||||
)
|
||||
self.assertEqual(response.status_code, expected_status, response.text)
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
def test_50_enrolled_agent_uses_per_agent_secret_and_own_zone(self):
|
||||
response = self.client.get(
|
||||
"/api/agent/config",
|
||||
headers=self.agent_headers(ENROLLED_AGENT, ENROLLED_SECRET),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["agent_id"], ENROLLED_AGENT)
|
||||
self.assertEqual(payload["agent_zone_id"], str(self.zone_b))
|
||||
self.assertEqual(
|
||||
[item["cidr"] for item in payload["ip_ranges"]],
|
||||
[ZONE_B_NETWORK],
|
||||
)
|
||||
self.assertNotIn(ZONE_A_NETWORK, response.text)
|
||||
self.assertNotIn(ZONE_C_NETWORK, response.text)
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
def test_60_revoked_credential_cannot_use_per_agent_or_global_secret(self):
|
||||
for secret in (REVOKED_SECRET, settings.minerops_agent_shared_secret):
|
||||
with self.subTest(secret_kind="global" if secret != REVOKED_SECRET else "agent"):
|
||||
response = self.client.get(
|
||||
"/api/agent/config",
|
||||
headers=self.agent_headers(REVOKED_AGENT, secret),
|
||||
)
|
||||
self.assertEqual(response.status_code, 403, response.text)
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
def test_90_durably_retired_legacy_identity_loses_secret_only_fallback(self):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE minerops_site_agents
|
||||
SET legacy_auth_disabled_at = now()
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(LEGACY_AGENT,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
response = self.client.get(
|
||||
"/api/agent/config", headers=self.legacy_headers()
|
||||
)
|
||||
self.assertEqual(response.status_code, 401, response.text)
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Focused source-level contracts for legacy Agent migrations."""
|
||||
|
||||
import importlib.util
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
VERSIONS = Path(__file__).parents[1] / "alembic" / "versions"
|
||||
|
||||
|
||||
def load_migration(filename):
|
||||
path = VERSIONS / filename
|
||||
spec = importlib.util.spec_from_file_location(filename.removesuffix(".py"), path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class EmptyResult:
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
|
||||
class RecordingConnection:
|
||||
def __init__(self):
|
||||
self.statements = []
|
||||
|
||||
def execute(self, statement, parameters=None):
|
||||
del parameters
|
||||
self.statements.append(str(statement))
|
||||
return EmptyResult()
|
||||
|
||||
|
||||
class LegacyRoutingMigrationTests(unittest.TestCase):
|
||||
def test_0018_locks_every_preflight_and_update_table_before_reads(self):
|
||||
migration = load_migration("0018_legacy_agent_routing.py")
|
||||
connection = RecordingConnection()
|
||||
with patch.object(migration.op, "get_bind", return_value=connection):
|
||||
migration.upgrade()
|
||||
|
||||
first = " ".join(connection.statements[0].split()).lower()
|
||||
self.assertTrue(first.startswith("lock table"))
|
||||
self.assertIn("in share row exclusive mode", first)
|
||||
for table in (
|
||||
"agent_zones", "minerops_agent_discoveries",
|
||||
"minerops_agent_snapshots", "minerops_site_agents", "sites",
|
||||
):
|
||||
with self.subTest(table=table):
|
||||
self.assertIn(table, first)
|
||||
|
||||
first_update = next(
|
||||
index for index, statement in enumerate(connection.statements)
|
||||
if "UPDATE " in statement
|
||||
)
|
||||
self.assertEqual(first_update, 4)
|
||||
|
||||
def test_0019_is_the_direct_forward_only_successor(self):
|
||||
migration = load_migration("0019_agent_legacy_auth_retire.py")
|
||||
self.assertEqual(migration.revision, "0019_agent_legacy_auth_retire")
|
||||
self.assertEqual(migration.down_revision, "0018_legacy_agent_routing")
|
||||
|
||||
def test_0020_adds_only_existing_identity_transition_binding(self):
|
||||
migration = load_migration(
|
||||
"0020_existing_agent_credential_transition.py"
|
||||
)
|
||||
self.assertEqual(migration.revision, "0020_existing_agent_transition")
|
||||
self.assertEqual(
|
||||
migration.down_revision,
|
||||
"0019_agent_legacy_auth_retire",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Fresh-chain production compatibility contract for the legacy Plexus Agent."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.agent_auth.exceptions import (
|
||||
AgentAuthenticationFailed,
|
||||
AgentIdentityUnauthorized,
|
||||
)
|
||||
from app.agent_auth.repository import AgentAuthRepository
|
||||
from app.agent_auth.service import AgentAuthService, hash_agent_secret
|
||||
from app.agent_zones.repository import AgentZoneRepository
|
||||
from app.agent_zones.service import AgentZoneService
|
||||
from app.config import settings
|
||||
from app.main import (
|
||||
minerops_agent_known_miners,
|
||||
minerops_agent_miner_snapshots,
|
||||
)
|
||||
|
||||
|
||||
SITE_A = UUID("00000000-0000-4000-8000-000000001901")
|
||||
SITE_B = UUID("00000000-0000-4000-8000-000000001902")
|
||||
MINER_A = UUID("00000000-0000-4000-8000-000000001911")
|
||||
MINER_B = UUID("00000000-0000-4000-8000-000000001912")
|
||||
LEGACY_AGENT = "legacy-plexus-production"
|
||||
NEW_AGENT = "enrolled-agent-production-contract"
|
||||
NEW_SECRET = "new-agent-production-contract-secret"
|
||||
|
||||
|
||||
def connect():
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
connect_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
def json_request(payload):
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
delivered = False
|
||||
|
||||
async def receive():
|
||||
nonlocal delivered
|
||||
if delivered:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
delivered = True
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"http_version": "1.1",
|
||||
"method": "POST",
|
||||
"scheme": "http",
|
||||
"path": "/api/agent/miner-snapshots",
|
||||
"raw_path": b"/api/agent/miner-snapshots",
|
||||
"query_string": b"",
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"client": ("testclient", 12345),
|
||||
"server": ("testserver", 80),
|
||||
},
|
||||
receive,
|
||||
)
|
||||
|
||||
|
||||
class LegacyAgentProductionPostgresTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if os.environ.get("MINEROPS_LEGACY_PRODUCTION_DISPOSABLE") != "1":
|
||||
raise unittest.SkipTest(
|
||||
"Legacy production contract requires its disposable PostgreSQL runner"
|
||||
)
|
||||
if settings.minerops_agent_id != LEGACY_AGENT:
|
||||
raise AssertionError("Runner did not bind the configured legacy identity")
|
||||
|
||||
cls.auth = AgentAuthService(AgentAuthRepository(connect))
|
||||
cls.zones = AgentZoneService(AgentZoneRepository(connect))
|
||||
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_zone_id
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(LEGACY_AGENT,),
|
||||
)
|
||||
cls.zone_a = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM agent_zones
|
||||
WHERE site_id = %s AND name = 'Default Zone'
|
||||
""",
|
||||
(SITE_B,),
|
||||
)
|
||||
cls.zone_b = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, agent_zone_id, site_name,
|
||||
agent_secret_hash, credential_created_at,
|
||||
legacy_auth_disabled_at
|
||||
)
|
||||
VALUES (
|
||||
%s, %s, %s, 'Enrolled Contract Agent', %s, now(), now()
|
||||
)
|
||||
""",
|
||||
(
|
||||
NEW_AGENT,
|
||||
SITE_B,
|
||||
cls.zone_b,
|
||||
hash_agent_secret(NEW_SECRET),
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_secret_only_auth_is_bounded_to_legacy_identity(self):
|
||||
self.assertEqual(
|
||||
self.auth.authenticate(None, settings.minerops_agent_shared_secret),
|
||||
LEGACY_AGENT,
|
||||
)
|
||||
with self.assertRaises(AgentIdentityUnauthorized):
|
||||
self.auth.authenticate(
|
||||
"unknown-agent", settings.minerops_agent_shared_secret
|
||||
)
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.auth.authenticate(
|
||||
NEW_AGENT, settings.minerops_agent_shared_secret
|
||||
)
|
||||
self.assertEqual(
|
||||
self.auth.authenticate(NEW_AGENT, NEW_SECRET),
|
||||
NEW_AGENT,
|
||||
)
|
||||
|
||||
def test_migrated_legacy_agent_has_correct_site_and_zone(self):
|
||||
routing = self.zones.routing_context(LEGACY_AGENT)
|
||||
self.assertIsNone(routing.routing_reason)
|
||||
self.assertEqual(routing.site_id, SITE_A)
|
||||
self.assertEqual(routing.agent_zone_id, self.zone_a)
|
||||
|
||||
def test_known_miners_and_telemetry_remain_zone_scoped(self):
|
||||
known = asyncio.run(
|
||||
minerops_agent_known_miners(LEGACY_AGENT, self.zones)
|
||||
)
|
||||
self.assertEqual(
|
||||
[row["miner_id"] for row in known["miners"]],
|
||||
[str(MINER_A)],
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
minerops_agent_miner_snapshots(
|
||||
json_request(
|
||||
{
|
||||
"agent_id": LEGACY_AGENT,
|
||||
"site_name": "Plexus",
|
||||
"miners": [
|
||||
{
|
||||
"miner_id": str(MINER_A),
|
||||
"ip_address": "192.0.2.191",
|
||||
"online": False,
|
||||
},
|
||||
{
|
||||
"miner_id": str(MINER_B),
|
||||
"ip_address": "192.0.2.192",
|
||||
"online": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
),
|
||||
LEGACY_AGENT,
|
||||
self.zones,
|
||||
)
|
||||
)
|
||||
self.assertEqual(result["agent_zone_id"], str(self.zone_a))
|
||||
self.assertEqual(result["apply_result"]["applied"], 1)
|
||||
self.assertIn(
|
||||
{"miner_id": str(MINER_B), "error": "agent_zone_conflict"},
|
||||
result["apply_result"]["errors"],
|
||||
)
|
||||
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT status FROM miners WHERE id = %s",
|
||||
(MINER_A,),
|
||||
)
|
||||
self.assertEqual(cur.fetchone()[0], "offline")
|
||||
cur.execute(
|
||||
"SELECT status FROM miners WHERE id = %s",
|
||||
(MINER_B,),
|
||||
)
|
||||
self.assertEqual(cur.fetchone()[0], "online")
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM minerops_agent_snapshots
|
||||
WHERE agent_id = %s AND agent_zone_id = %s
|
||||
""",
|
||||
(LEGACY_AGENT, self.zone_a),
|
||||
)
|
||||
self.assertGreaterEqual(cur.fetchone()[0], 1)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user