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:
1 parent
e898f283ab
commit
8b0d32cd6b
26 files changed
+3798
-4092
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Agent discovery isolation; physical miner writes are never authorized here."""
|
||||
@@ -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
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user