security(discovery): enforce Zone isolation and retire unsafe legacy writers

Bind Agent observations, review, enrichment and telemetry to current Zone
and inventory authority. Preserve immutable MAC ownership and historical
snapshots with stale-result rejection and transactional security audits.

Retire unbound direct/range/scheduled discovery and one/bulk legacy summary
writes in both modes. Require reconciled exact inventory identity or
explicit Zone-scoped IP selection for CSV metadata imports.

Independent Blocker #1 application-security re-audit: PASS on tree
cf863aadc9. Evidence: 141 focused PostgreSQL/HTTP
tests passed; Chromium and PowerShell checks passed. Two documented baseline
source assertions remain unchanged. Production deployment is not approved.
This commit is contained in:
lussierj committed 2026-10-17 00:45:04 +00:00
1 parent e898f283ab
commit 8b0d32cd6b
26 files changed
+3798 -4092

No files matched your search

+22
View File
@@ -10,6 +10,8 @@ from psycopg.types.json import Jsonb
from app.agent_commands.exceptions import AgentCommandConflict, AgentCommandNotFound
from app.agent_commands.policy import READ_ONLY_COMMAND_TYPES, rejection_execution_outcome, rejection_reason
from app.config import settings
from app.discovery.repository import DiscoveryRepository
from fastapi import HTTPException
ConnectionFactory = Callable[[], psycopg.Connection]
@@ -120,9 +122,29 @@ class AgentCommandRepository:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
DiscoveryRepository.lock(cur)
self._retire_unsupported(cur, agent_id)
discovery = DiscoveryRepository(self._connection_factory)
cur.execute(f"""SELECT q.*, ({NO_DELIVERY_EVIDENCE_SQL}) AS no_delivery_evidence
FROM minerops_agent_command_queue q WHERE agent_id=%s
AND command_status='pending' AND command_type=ANY(%s)
ORDER BY created_at,id FOR UPDATE SKIP LOCKED LIMIT 1000""",
(agent_id,list(READ_ONLY_COMMAND_TYPES)))
pending=list(cur.fetchall())
for command in pending:
try:
discovery.validate_command(cur,agent_id,command)
except HTTPException as exc:
cur.execute("""UPDATE minerops_agent_command_queue SET command_status='cancelled',
completed_at=COALESCE(completed_at,now()), error_message=concat_ws(chr(10),NULLIF(error_message,''),%s::text)
WHERE id=%s""",(exc.detail,command['id']))
DiscoveryRepository.audit(cur,'discovery.command_rejected',None,
{'command_id':command['id'],'agent_id':agent_id,'reason':exc.detail,
'execution_outcome':rejection_execution_outcome({**command,'previous_status':'pending'})})
cur.execute(CLAIM_COMMANDS_SQL, (agent_id, list(READ_ONLY_COMMAND_TYPES), limit))
rows = [dict(row) for row in cur.fetchall()]
for row in rows:
discovery.validate_command(cur,agent_id,row)
connection.commit()
return rows
except Exception:
+5
View File
@@ -108,6 +108,11 @@ class AgentZoneRoutingContext(BaseModel):
routing_reason: str | None = None
class AgentDiscoveryMinerMatch(AgentZoneRoutingContext):
miner_id: UUID | None = None
matched_by: str | None = None
class MinerZoneAssociationResult(BaseModel):
agent_zone_id: UUID | None = None
routing_reason: str | None = None
+19 -9
View File
@@ -204,6 +204,8 @@ class AgentZoneRepository:
ip_address = EXCLUDED.ip_address,
last_checkin_at = now(),
last_payload = EXCLUDED.last_payload
WHERE minerops_site_agents.site_id IS NOT DISTINCT FROM EXCLUDED.site_id
AND minerops_site_agents.agent_zone_id IS NOT DISTINCT FROM EXCLUDED.agent_zone_id
""",
(
agent_id,
@@ -216,6 +218,8 @@ class AgentZoneRepository:
Jsonb(payload),
),
)
if cur.rowcount != 1:
raise AgentZoneConflict("Agent assignment changed concurrently")
connection.commit()
except UniqueViolation as exc:
connection.rollback()
@@ -294,6 +298,20 @@ class AgentZoneRepository:
finally:
connection.close()
def find_miner_in_zone(self, *, agent_id: str, agent_zone_id: UUID,
mac_address: str | None, ip_address: str | None) -> dict | None:
"""Retained helper uses the same atomic authority/collision boundary as HTTP."""
from app.discovery.repository import DiscoveryRepository, reject
repository = DiscoveryRepository(self._connection_factory)
with repository.transaction() as cur:
context = repository.context(cur, agent_id)
if context['agent_zone_id'] != agent_zone_id:
reject('discovery_stale_authority')
if not mac_address and not ip_address:
return None
row = repository.match(cur, context, {'mac_address': mac_address, 'ip_address': ip_address})
return {'id': row['id'], 'matched_by': 'mac' if mac_address else 'ip'} if row else None
def assign_miners_to_zone(
self, miner_ids: Iterable[UUID], agent_zone_id: UUID
) -> dict[UUID, str]:
@@ -326,15 +344,7 @@ class AgentZoneRepository:
elif miner["site_id"] not in {None, zone["site_id"]}:
outcomes[miner_id] = "site_conflict"
else:
cur.execute(
"""
UPDATE miners
SET site_id = %s, agent_zone_id = %s, updated_at = now()
WHERE id = %s AND agent_zone_id IS NULL
""",
(zone["site_id"], agent_zone_id, miner_id),
)
outcomes[miner_id] = "assigned"
outcomes[miner_id] = "miner_zone_unassigned"
connection.commit()
return outcomes
except Exception:
+27
View File
@@ -10,6 +10,7 @@ from app.agent_zones.exceptions import (
from app.agent_zones.models import (
AgentCheckinRequest,
AgentCheckinResponse,
AgentDiscoveryMinerMatch,
AgentZoneRoutingContext,
AgentZoneCreateRequest,
AgentZoneSummary,
@@ -81,6 +82,8 @@ class AgentZoneService:
self.validate_agent_identity(authenticated_agent_id, request.agent_id)
existing = self.repository.get_agent(authenticated_agent_id)
if not existing or existing.get("agent_zone_id") is None:
raise AgentZoneValidationError("Agent requires an administrator-established Zone assignment")
requested_site = request.site_id
requested_zone = request.agent_zone_id
final_site = requested_site or (existing or {}).get("site_id")
@@ -199,6 +202,30 @@ class AgentZoneService:
],
}
def match_discovered_miner(
self,
agent_id: str,
*,
mac_address: str | None,
ip_address: str | None,
recorded_zone_id: UUID | None = None,
) -> AgentDiscoveryMinerMatch:
routing = self.routing_context(agent_id, recorded_zone_id)
if routing.routing_reason or routing.agent_zone_id is None:
return AgentDiscoveryMinerMatch(**routing.model_dump())
match = self.repository.find_miner_in_zone(
agent_id=agent_id,
agent_zone_id=routing.agent_zone_id,
mac_address=mac_address,
ip_address=ip_address,
)
return AgentDiscoveryMinerMatch(
**routing.model_dump(),
miner_id=match["id"] if match else None,
matched_by=match["matched_by"] if match else None,
)
def associate_miners_from_agent(
self, agent_id: str, miner_ids: list[UUID]
) -> MinerZoneAssociationResult:
+1
View File
@@ -0,0 +1 @@
"""Agent discovery isolation; physical miner writes are never authorized here."""
+96
View File
@@ -0,0 +1,96 @@
"""Exact administrative CSV selection, inside the caller's locked transaction.
CSV metadata never establishes ownership or replaces board identity. Every supplied
identity is a constraint; a failed strong identifier never falls back to an IP.
"""
from uuid import UUID
from fastapi import HTTPException
from app.discovery.repository import address, mac
def resolve_inventory_row(cur, row, row_number, selected_zone=None):
def fail(message, status=409):
raise HTTPException(status_code=status, detail=f"CSV row {row_number}: {message}")
def field(names, normalize=lambda v: v):
values = []
for name in names:
value = row.get(name)
if value is not None and str(value).strip():
try:
values.append(normalize(str(value).strip()))
except (ValueError, HTTPException):
fail(f"invalid {names[0]}", 422)
if len(set(values)) > 1:
fail(f"conflicting {names[0]} columns")
return values[0] if values else None
def uuid(value):
return str(UUID(value))
mid = field(["Miner ID", "Miner UUID", "miner_id", "id"], uuid)
zone = field(["Agent Zone ID", "Zone ID", "agent_zone_id", "zone_id"], uuid)
site = field(["Site ID", "site_id"], uuid)
ip = field(["IP", "IP Address", "ip", "ip_address"], address)
current_mac = field(["MAC Addr", "MAC", "Mac", "mac", "mac_address"], mac)
serial = field(["Serial", "Serial Number", "SN", "Miner SN", "miner_sn", "serial_number"])
if selected_zone:
try:
selected_zone = uuid(selected_zone)
except ValueError:
fail("invalid selected Agent Zone ID", 422)
if zone and zone != selected_zone:
fail("row Zone disagrees with selected Zone")
zone = selected_zone
if zone:
cur.execute("SELECT site_id::text FROM agent_zones WHERE id=%s", (zone,))
zone_row = cur.fetchone()
if not zone_row:
fail("selected Zone does not exist", 422)
if site and site != zone_row["site_id"]:
fail("Site and Zone disagree")
if not (mid or current_mac or serial or (ip and zone)):
fail("provide Miner ID, current MAC, serial, or IP plus Agent Zone ID", 422)
selectors = []
if mid:
selectors.append(("id=%s::uuid", mid))
if current_mac:
selectors.append(("regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')=%s", current_mac.replace(":", "").lower()))
if serial:
selectors.append(("miner_sn=%s", serial))
# IP is only a namespace-scoped selector when no stronger identity is supplied.
if not selectors:
selectors.append(("current_ip=%s::inet AND agent_zone_id=%s::uuid", (ip, zone)))
candidates = {}
for predicate, value in selectors:
params = value if isinstance(value, tuple) else (value,)
cur.execute(
"SELECT id::text, site_id::text, agent_zone_id::text, "
"host(current_ip) AS current_ip, current_mac, miner_sn, model, miner_type, firmware_version "
"FROM miners WHERE " + predicate + " ORDER BY id LIMIT 2 FOR UPDATE",
params,
)
matches = cur.fetchall()
if len(matches) > 1:
fail("ambiguous inventory identity; supply exact Miner ID and consistent identifiers")
candidates.update({m["id"]: m for m in matches})
if not candidates:
return None
if len(candidates) != 1:
fail("identifiers select different inventory records")
miner = next(iter(candidates.values()))
if (
(mid and mid != miner["id"])
or (zone and zone != miner["agent_zone_id"])
or (site and site != miner["site_id"])
or (ip and ip != miner["current_ip"])
or (serial and serial != miner["miner_sn"])
or (current_mac and current_mac != mac(miner["current_mac"]))
):
fail("identifiers disagree with current inventory; refresh selection before importing")
return miner
+42
View File
@@ -0,0 +1,42 @@
"""Bounded HTTP adapter; authentication remains on the registered routes."""
import json
from fastapi import HTTPException
from starlette.concurrency import run_in_threadpool
import psycopg
from app.discovery.repository import DiscoveryRepository
MAX_BODY = 1024 * 1024
async def dispatch(method, request=None, **kwargs):
if request is not None:
chunks = []
size = 0
async for chunk in request.stream():
size += len(chunk)
if size > MAX_BODY:
raise HTTPException(413, "discovery_payload_too_large")
chunks.append(chunk)
try:
def invalid_constant(_):
raise ValueError("Non-finite JSON number")
kwargs["body"] = json.loads(
b"".join(chunks), parse_constant=invalid_constant
)
except (ValueError, UnicodeDecodeError, RecursionError):
raise HTTPException(422, "discovery_invalid_json")
try:
return await run_in_threadpool(getattr(DiscoveryRepository(), method), **kwargs)
except (
psycopg.errors.LockNotAvailable,
psycopg.errors.QueryCanceled,
psycopg.errors.DeadlockDetected,
):
raise HTTPException(409, "discovery_concurrent_change_retry")
except psycopg.DataError:
raise HTTPException(422, "discovery_invalid_value")
except psycopg.IntegrityError:
raise HTTPException(409, "discovery_identity_conflict")
File diff suppressed because it is too large. Load diff
+3
View File
@@ -711,6 +711,7 @@ def machine_agent_request(path: str) -> bool:
"/api/agent/commands/pending-disabled-old",
"/api/agent/commands/result-disabled-old",
"/api/agent/discoveries",
"/api/agent/discovery-target",
"/api/agent/miner-enrichment",
"/api/agent/miner-enrichment-v2",
"/api/agent/miner-enrichment-v3",
@@ -826,6 +827,8 @@ def required_api_permission(method: str, path: str) -> str | None:
return "repairs.complete"
if method == "GET" and repair_timeline_request(path):
return "repairs.read"
if method == "GET" and path in {"/api/agent/discoveries/summary", "/api/agent/status-summary"}:
return "agents.view"
if method == "GET":
if path.startswith("/api/system/"):
return "system_health.view"
+199 -3942
View File
File diff suppressed because it is too large. Load diff