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
+3791
-4085
No files matched your search
@@ -2,7 +2,10 @@
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$IpAddress,
|
||||
|
||||
[string]$MinerId = ""
|
||||
[string]$MinerId = "",
|
||||
[string]$DiscoveryId = "",
|
||||
[string]$ObservationVersion = "",
|
||||
[string]$CommandId = ""
|
||||
)
|
||||
|
||||
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
|
||||
@@ -164,6 +167,14 @@ $hubUrl = ([string]$config.hub_url).TrimEnd("/")
|
||||
$secret = [string]$config.agent_secret
|
||||
$agentId = [string]$config.agent_id
|
||||
|
||||
# Revalidate the claimed command against current Hub authority before miner sockets.
|
||||
if (!$DiscoveryId -or !$ObservationVersion -or !$CommandId) { throw "discovery_observation_required" }
|
||||
$target = Invoke-RestMethod -Method Post -Uri "$hubUrl/api/agent/discovery-target" `
|
||||
-Headers @{ "X-MinerOps-Agent-ID" = $agentId; "X-MinerOps-Agent-Secret" = $secret } `
|
||||
-ContentType "application/json" -Body (@{ discovery_id=$DiscoveryId; version=$ObservationVersion; command_id=$CommandId } | ConvertTo-Json)
|
||||
$IpAddress = [string]$target.ip_address
|
||||
if (!$target.ok -or !$IpAddress) { throw "discovery_target_denied" }
|
||||
|
||||
$commands = @(
|
||||
"summary",
|
||||
"devs",
|
||||
@@ -219,6 +230,9 @@ if (!$model) {
|
||||
$payload = [ordered]@{
|
||||
ip_address = $IpAddress
|
||||
miner_id = $MinerId
|
||||
discovery_id = $DiscoveryId
|
||||
version = $ObservationVersion
|
||||
command_id = $CommandId
|
||||
mac_address = $mac
|
||||
hostname = $hostname
|
||||
miner_sn = $minerSn
|
||||
|
||||
@@ -58,6 +58,21 @@ function Invoke-AgentApi {
|
||||
"X-MinerOps-Agent-ID" = [string]$config.agent_id
|
||||
}
|
||||
|
||||
$batchField = $null
|
||||
if ($Path -eq "/api/agent/miner-snapshots") { $batchField = "miners" }
|
||||
elseif ($Path -eq "/api/agent/discoveries") { $batchField = "discoveries" }
|
||||
if ($batchField -and $Body -ne $null -and @($Body[$batchField]).Count -gt 128) {
|
||||
$items = @($Body[$batchField])
|
||||
$results = @()
|
||||
for ($offset = 0; $offset -lt $items.Count; $offset += 128) {
|
||||
$batch = @{}
|
||||
foreach ($key in $Body.Keys) { $batch[$key] = $Body[$key] }
|
||||
$last = [Math]::Min($offset + 127, $items.Count - 1)
|
||||
$batch[$batchField] = @($items[$offset..$last])
|
||||
$results += Invoke-AgentApi -Method $Method -Path $Path -Body $batch
|
||||
}
|
||||
return @{ ok = $true; batches = $results; processed = $items.Count }
|
||||
}
|
||||
if ($Body -ne $null) {
|
||||
$json = $Body | ConvertTo-Json -Depth 100 -Compress
|
||||
return Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers -ContentType "application/json" -Body $json -TimeoutSec 180
|
||||
@@ -252,6 +267,7 @@ function Poll-KnownMiners {
|
||||
|
||||
$result = [ordered]@{
|
||||
miner_id = [string]$Miner.miner_id
|
||||
miner_version = [string]$Miner.miner_version
|
||||
ip_address = [string]$Miner.ip_address
|
||||
mac_address = [string]$Miner.mac_address
|
||||
room_name = [string]$Miner.room_name
|
||||
@@ -592,7 +608,7 @@ public static class MinerOpsFastScanner {
|
||||
}
|
||||
|
||||
function Discover-MinersInRanges {
|
||||
$config = Read-AgentConfig
|
||||
$config = Invoke-AgentApi -Method "GET" -Path "/api/agent/config"
|
||||
$ranges = @($config.ip_ranges | Where-Object { $_.enabled -eq $true })
|
||||
|
||||
Ensure-FastScannerLoaded
|
||||
@@ -1015,7 +1031,12 @@ function Process-PendingCommands {
|
||||
|
||||
Write-AgentLog "Running Enrich / Match for discovery IP $ipAddress"
|
||||
|
||||
$out = powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\MinerOpsAgent\EnrichOneMiner.ps1 -IpAddress $ipAddress 2>&1 | Out-String
|
||||
$hubConfig = Invoke-AgentApi -Method "GET" -Path "/api/agent/config"
|
||||
if (-not [string]::Equals([string]$payloadObj.agent_zone_id, [string]$hubConfig.agent_zone_id, [StringComparison]::Ordinal)) {
|
||||
throw "discovery_command_stale_authority"
|
||||
}
|
||||
if (-not $payloadObj.discovery_id -or -not $payloadObj.version) { throw "discovery_observation_required" }
|
||||
$out = powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\MinerOpsAgent\EnrichOneMiner.ps1 -IpAddress $ipAddress -DiscoveryId ([string]$payloadObj.discovery_id) -ObservationVersion ([string]$payloadObj.version) -CommandId $commandId 2>&1 | Out-String
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "EnrichOneMiner failed for $ipAddress. Output: $out"
|
||||
|
||||
@@ -224,6 +224,8 @@ foreach ($miner in $miners) {
|
||||
}
|
||||
|
||||
$items.Add([pscustomobject]@{
|
||||
miner_id = [string]$miner.miner_id
|
||||
miner_version = [string]$miner.miner_version
|
||||
ip = $ip
|
||||
mac = $mac
|
||||
api3_supported = $false
|
||||
@@ -257,6 +259,8 @@ foreach ($miner in $miners) {
|
||||
-Request @{ cmd = "get.device.custom_data" }
|
||||
|
||||
$items.Add([pscustomobject]@{
|
||||
miner_id = [string]$miner.miner_id
|
||||
miner_version = [string]$miner.miner_version
|
||||
ip = $ip
|
||||
mac = $mac
|
||||
api3_supported = $true
|
||||
@@ -275,15 +279,14 @@ $payload = @{
|
||||
items = @($items.ToArray())
|
||||
}
|
||||
|
||||
$json = $payload | ConvertTo-Json -Depth 80
|
||||
|
||||
Write-Host "Posting API3 enrichment results to hub..."
|
||||
$result = Invoke-RestMethod `
|
||||
-Uri "$HubUrl/api/agent/api3-enrichment" `
|
||||
-Method Post `
|
||||
-Headers $Headers `
|
||||
-ContentType "application/json" `
|
||||
-Body $json
|
||||
$allItems = @($items.ToArray())
|
||||
$result = @()
|
||||
for ($offset = 0; $offset -lt $allItems.Count; $offset += 128) {
|
||||
$last = [Math]::Min($offset + 127, $allItems.Count - 1)
|
||||
$payload.items = @($allItems[$offset..$last])
|
||||
$result += Invoke-RestMethod -Uri "$HubUrl/api/agent/api3-enrichment" -Method Post `
|
||||
-Headers $Headers -ContentType "application/json" -Body ($payload | ConvertTo-Json -Depth 80)
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Local checked: $checked"
|
||||
|
||||
@@ -120,6 +120,7 @@ foreach ($miner in $knownMiners) {
|
||||
if ($pools.Count -gt 0) {
|
||||
$poolSnapshots.Add([pscustomobject]@{
|
||||
miner_id = $miner.miner_id
|
||||
miner_version = $miner.miner_version
|
||||
ip_address = $ip
|
||||
mac_address = $miner.mac_address
|
||||
model = $miner.model
|
||||
@@ -130,18 +131,14 @@ foreach ($miner in $knownMiners) {
|
||||
|
||||
Write-Host "Pool snapshots collected: $($poolSnapshots.Count)"
|
||||
|
||||
$body = @{
|
||||
agent_id = $AgentId
|
||||
pool_snapshots = $poolSnapshots
|
||||
} | ConvertTo-Json -Depth 20
|
||||
|
||||
$result = Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri "$HubUrl/api/agent/pool-snapshots" `
|
||||
-Headers $Headers `
|
||||
-Body $body `
|
||||
-ContentType "application/json" `
|
||||
-TimeoutSec 180
|
||||
$allItems = @($poolSnapshots.ToArray())
|
||||
$result = @()
|
||||
for ($offset = 0; $offset -lt $allItems.Count; $offset += 128) {
|
||||
$last = [Math]::Min($offset + 127, $allItems.Count - 1)
|
||||
$body = @{agent_id=$AgentId; pool_snapshots=@($allItems[$offset..$last])} | ConvertTo-Json -Depth 80
|
||||
$result += Invoke-RestMethod -Method Post -Uri "$HubUrl/api/agent/pool-snapshots" `
|
||||
-Headers $Headers -Body $body -ContentType "application/json" -TimeoutSec 180
|
||||
}
|
||||
|
||||
$result | ConvertTo-Json -Depth 10
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
param([string]$AgentRoot = (Join-Path $PSScriptRoot '..'))
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$checks=0
|
||||
foreach ($file in @('MinerOpsAgentService.ps1','EnrichOneMiner.ps1','MinerOpsApi3EnrichmentPoller.ps1','MinerOpsPoolPoller.ps1')) {
|
||||
$tokens=$null; $errors=$null
|
||||
$ast=[System.Management.Automation.Language.Parser]::ParseFile((Join-Path $AgentRoot $file),[ref]$tokens,[ref]$errors)
|
||||
if ($errors.Count) { throw "Parse errors in ${file}: $errors" }
|
||||
$checks++
|
||||
if ($file -eq 'MinerOpsAgentService.ps1') {
|
||||
$function=$ast.Find({param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Invoke-AgentApi'},$true)
|
||||
Invoke-Expression $function.Extent.Text
|
||||
}
|
||||
}
|
||||
function Read-AgentConfig { return @{hub_url='https://hub.invalid';agent_id='simulated-agent';agent_secret='disposable-only'} }
|
||||
$script:requests=@()
|
||||
function Invoke-RestMethod {
|
||||
param($Method,$Uri,$Headers,$ContentType,$Body,$TimeoutSec)
|
||||
$parsed=$Body | ConvertFrom-Json
|
||||
$script:requests+=@{body=$parsed;headers=$Headers}
|
||||
return @{ok=$true}
|
||||
}
|
||||
foreach ($selection in @(@{path='/api/agent/discoveries';field='discoveries'},@{path='/api/agent/miner-snapshots';field='miners'})) {
|
||||
$script:requests=@();$body=@{agent_id='simulated-agent'}
|
||||
$body[$selection.field]=@(1..257 | ForEach-Object { @{sample=$_;miner_version='signed-version'} })
|
||||
$result=Invoke-AgentApi -Method POST -Path $selection.path -Body $body
|
||||
$sizes=@($script:requests | ForEach-Object { @($_.body.($selection.field)).Count })
|
||||
if (($sizes -join ',') -ne '128,128,1') { throw "Unsafe batching: $sizes" }
|
||||
foreach ($request in $script:requests) {
|
||||
if ($request.headers['X-MinerOps-Agent-ID'] -ne 'simulated-agent') { throw 'Identity lost' }
|
||||
}
|
||||
if ($result.processed -ne 257) { throw 'Samples lost' }
|
||||
$checks+=3
|
||||
}
|
||||
# Execute the actual target-validation prefix only; transport functions/loops are never loaded.
|
||||
$source=Get-Content -LiteralPath (Join-Path $AgentRoot 'EnrichOneMiner.ps1') -Raw
|
||||
$start=$source.IndexOf('$config = Read-AgentConfig')
|
||||
$finish=$source.IndexOf('$commands = @(',$start)
|
||||
$prefix=$source.Substring($start,$finish-$start)
|
||||
$script:targetRequests=0
|
||||
function Invoke-RestMethod {
|
||||
param($Method,$Uri,$Headers,$ContentType,$Body)
|
||||
$script:targetRequests++
|
||||
if ($Uri -notlike '*/api/agent/discovery-target') { throw 'Unexpected request' }
|
||||
return @{ok=$true;ip_address='198.51.100.10'}
|
||||
}
|
||||
$DiscoveryId='';$ObservationVersion='';$CommandId='';$IpAddress='203.0.113.99'
|
||||
$denied=$false
|
||||
try { Invoke-Expression $prefix } catch { $denied=$_.Exception.Message -eq 'discovery_observation_required' }
|
||||
if (!$denied -or $script:targetRequests -ne 0) { throw 'Missing identity was not rejected before I/O' }
|
||||
$checks++
|
||||
$DiscoveryId='00000000-0000-4000-8000-000000000001';$ObservationVersion='version';$CommandId='command'
|
||||
Invoke-Expression $prefix
|
||||
if ($IpAddress -ne '198.51.100.10' -or $script:targetRequests -ne 1) { throw 'Unvalidated target retained' }
|
||||
$checks++
|
||||
Write-Output "Discovery PowerShell checks passed: $checks; mocked Hub only; zero miner sockets."
|
||||
@@ -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"
|
||||
|
||||
+193
-3936
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,49 @@
|
||||
// Actual Chromium; every HTTP request is fulfilled or aborted, with disposable data only.
|
||||
const {chromium}=require('playwright');
|
||||
const fs=require('fs');const assert=require('node:assert/strict');
|
||||
(async()=>{
|
||||
const browser=await chromium.launch({headless:true,args:['--no-sandbox','--disable-background-networking']});
|
||||
try {
|
||||
const context=await browser.newContext({serviceWorkers:'block'});
|
||||
const page=await context.newPage();
|
||||
const marker='<img src="data:," onerror="window.__discoveryAudit=1">';
|
||||
const commands=[];const unexpected=[];
|
||||
const observations=[0,1].map(n=>({id:'00000000-0000-4000-8000-00000000000'+(n+1),agent_id:'Agent '+n,agent_zone_id:'Zone '+n,version:'2026-01-01T00:00:00.00000'+n+'+00:00',created_at:'2026-01-01T00:00:00Z',ip_address:'198.51.100.10',room_name:marker,model:marker,mac_address:marker,discovery_status:'new'}));
|
||||
await page.route('**/*',async route=>{
|
||||
const url=new URL(route.request().url());
|
||||
if(url.origin!=='http://minerops.invalid'){unexpected.push(url.href);return route.abort();}
|
||||
const path=url.pathname;
|
||||
if(path==='/agent/control')return route.fulfill({contentType:'text/html',body:fs.readFileSync(process.argv[2],'utf8')});
|
||||
if(path==='/api/agent/status-summary')return route.fulfill({json:{live_database_counts:{total:2,online:2},latest_snapshot:{},agent:{agent_id:marker,hostname:marker,version:marker},recent_commands:[{command_type:marker,command_status:'failed',requested_by_username:marker,error_message:marker}]}});
|
||||
if(path==='/api/agent/discoveries')return route.fulfill({json:{discoveries:observations}});
|
||||
if(path==='/api/agent/discoveries/summary')return route.fulfill({json:{total:2,counts:{new:2}}});
|
||||
if(path==='/api/agent/discoveries/review'||path==='/api/agent/discoveries/enrich-match'){
|
||||
commands.push({path,body:route.request().postDataJSON()});return route.fulfill({json:{ok:true,message:'Saved'}});
|
||||
}
|
||||
return route.abort();
|
||||
});
|
||||
await context.addInitScript(()=>localStorage.setItem('minerops_token','disposable-browser-token'));
|
||||
page.on('dialog',dialog=>dialog.accept());
|
||||
await page.goto('http://minerops.invalid/agent/control');
|
||||
await page.waitForSelector('#discoveriesBody tr button');
|
||||
if(process.argv[3]==='baseline'){
|
||||
await page.waitForFunction(()=>window.__discoveryAudit===1);
|
||||
console.log('Baseline positive control: harmless stored event handler executed.');
|
||||
} else {
|
||||
assert.equal(await page.evaluate(()=>window.__discoveryAudit),undefined);
|
||||
assert.equal(await page.locator('#discoveriesBody img,#commandsBody img,[onerror]').count(),0);
|
||||
assert.ok((await page.locator('#discoveriesBody').innerText()).includes(marker));
|
||||
await page.locator('#discoveriesBody tr').nth(0).getByRole('button',{name:'Add as New',exact:true}).click();
|
||||
await page.waitForFunction(()=>document.getElementById('statusBox').textContent==='Agent status loaded.');
|
||||
await page.locator('#discoveriesBody tr').nth(1).getByRole('button',{name:'Enrich / Match',exact:true}).click();
|
||||
assert.equal(commands.length,2);
|
||||
assert.equal(commands[0].body.discovery_id,observations[0].id);
|
||||
assert.equal(commands[0].body.version,observations[0].version);
|
||||
assert.equal(commands[1].body.discovery_id,observations[1].id);
|
||||
assert.equal(commands[1].body.version,observations[1].version);
|
||||
assert.ok(commands.every(c=>!Object.hasOwn(c.body,'ip_address')));
|
||||
assert.deepEqual(unexpected,[]);
|
||||
console.log('Chromium passed: stored markup inert; Agent/status/error data escaped; same-IP actions retain exact observation IDs/versions; no external requests.');
|
||||
}
|
||||
} finally {await browser.close();}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Export the literal production page without importing/startup or network access."""
|
||||
|
||||
import ast, json, sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
tree = ast.parse((root / "backend/app/main.py").read_text())
|
||||
node = next(
|
||||
n
|
||||
for n in tree.body
|
||||
if isinstance(n, ast.AsyncFunctionDef) and n.name == "minerops_agent_control_page"
|
||||
)
|
||||
html = next(
|
||||
n.value
|
||||
for n in ast.walk(node)
|
||||
if isinstance(n, ast.Constant)
|
||||
and isinstance(n.value, str)
|
||||
and "<!doctype html>" in n.value
|
||||
)
|
||||
Path(sys.argv[2]).write_text(html)
|
||||
@@ -264,30 +264,13 @@ if __name__ == "__main__":
|
||||
|
||||
class AgentTransportIsolationSourceTests(unittest.TestCase):
|
||||
def test_known_miners_and_pool_snapshots_are_agent_zone_scoped(self):
|
||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||
identity = (Path(__file__).parents[1] / "app" / "identity.py").read_text()
|
||||
|
||||
known_start = main.index('@app.get("/api/agent/known-miners")')
|
||||
known_end = main.index("# Phase 5J:", known_start)
|
||||
known = main[known_start:known_end]
|
||||
|
||||
pool_start = main.index('@app.post("/api/agent/pool-snapshots")')
|
||||
pool_end = main.index("\n@app.", pool_start + 1)
|
||||
pool = main[pool_start:pool_end]
|
||||
|
||||
self.assertIn('"/api/agent/known-miners"', identity)
|
||||
|
||||
self.assertIn("Depends(require_agent_identity)", known)
|
||||
self.assertIn("zone_service.routing_context", known)
|
||||
self.assertIn("miners.agent_zone_id = $1::uuid", known)
|
||||
self.assertNotIn("minerops_require_agent_secret", known)
|
||||
|
||||
self.assertIn("Depends(require_agent_identity)", pool)
|
||||
self.assertIn("zone_service.routing_context", pool)
|
||||
self.assertIn("agent_id = authenticated_agent_id", pool)
|
||||
self.assertIn("agent_zone_id = %s", pool)
|
||||
self.assertNotIn('body.get("agent_id")', pool)
|
||||
self.assertNotIn("MINEROPS_AGENT_SHARED_SECRET", pool)
|
||||
from app.main import minerops_agent_known_miners, agent_pool_snapshots
|
||||
from inspect import getsource
|
||||
for fn,method in [(minerops_agent_known_miners,"known"),(agent_pool_snapshots,"snapshots")]:
|
||||
source=getsource(fn)
|
||||
self.assertIn("Depends(require_agent_identity)",source)
|
||||
self.assertIn("discovery_dispatch",source)
|
||||
self.assertIn("agent_id=authenticated_agent_id",source)
|
||||
|
||||
def test_windows_machine_pollers_send_agent_identity(self):
|
||||
root = agent_source_root()
|
||||
@@ -307,67 +290,21 @@ class AgentTransportIsolationSourceTests(unittest.TestCase):
|
||||
|
||||
class AgentEnrichmentIsolationSourceTests(unittest.TestCase):
|
||||
def test_enrichment_routes_require_agent_identity_and_zone_scope(self):
|
||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||
|
||||
decorators = (
|
||||
'@app.post("/api/agent/miner-enrichment")',
|
||||
'@app.post("/api/agent/miner-enrichment-v2")',
|
||||
'@app.post("/api/agent/miner-enrichment-v3")',
|
||||
)
|
||||
|
||||
for decorator in decorators:
|
||||
with self.subTest(decorator=decorator):
|
||||
start = main.index(decorator)
|
||||
end = main.index("\n@app.", start + 1)
|
||||
route = main[start:end]
|
||||
|
||||
self.assertIn("Depends(require_agent_identity)", route)
|
||||
self.assertIn("zone_service.routing_context", route)
|
||||
self.assertIn("agent_zone_id", route)
|
||||
self.assertNotIn("minerops_require_agent_secret", route)
|
||||
|
||||
v2_start = main.index('@app.post("/api/agent/miner-enrichment-v2")')
|
||||
v2_end = main.index("\n@app.", v2_start + 1)
|
||||
v2 = main[v2_start:v2_end]
|
||||
self.assertIn(
|
||||
"AND agent_zone_id = $2::uuid",
|
||||
v2,
|
||||
)
|
||||
self.assertIn(
|
||||
"AND agent_zone_id = $3::uuid",
|
||||
v2,
|
||||
)
|
||||
|
||||
v3_start = main.index('@app.post("/api/agent/miner-enrichment-v3")')
|
||||
v3_end = main.index("\n@app.", v3_start + 1)
|
||||
v3 = main[v3_start:v3_end]
|
||||
self.assertIn(
|
||||
"AND agent_zone_id = $8::uuid",
|
||||
v3,
|
||||
)
|
||||
self.assertIn(
|
||||
"AND agent_zone_id = $7::uuid",
|
||||
v3,
|
||||
)
|
||||
from app import main
|
||||
from inspect import getsource
|
||||
for fn in [main.minerops_agent_miner_enrichment,main.minerops_agent_miner_enrichment_v2,main.minerops_agent_miner_enrichment_v3]:
|
||||
source=getsource(fn)
|
||||
self.assertIn("Depends(require_agent_identity)",source)
|
||||
self.assertIn("discovery_dispatch",source)
|
||||
self.assertIn("agent_id=authenticated_agent_id",source)
|
||||
|
||||
def test_api3_uses_authenticated_identity_and_never_global_agent_identity(self):
|
||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||
|
||||
start = main.index('@app.post("/api/agent/api3-enrichment")')
|
||||
end = main.index("\n@app.", start + 1)
|
||||
route = main[start:end]
|
||||
|
||||
self.assertIn("Depends(require_agent_identity)", route)
|
||||
self.assertIn("zone_service.routing_context", route)
|
||||
self.assertIn("agent_id = authenticated_agent_id", route)
|
||||
self.assertIn("agent_zone_id = %s", route)
|
||||
self.assertIn("if miner_id is None:", route)
|
||||
self.assertIn("continue", route)
|
||||
|
||||
self.assertNotIn("MINEROPS_AGENT_SHARED_SECRET", route)
|
||||
self.assertNotIn('body.get("agent_id")', route)
|
||||
self.assertNotIn('body.get("site_name")', route)
|
||||
self.assertNotIn('request.headers.get("x-agent-secret")', route)
|
||||
from app.main import minerops_agent_api3_enrichment
|
||||
from inspect import getsource
|
||||
source=getsource(minerops_agent_api3_enrichment)
|
||||
self.assertIn("Depends(require_agent_identity)",source)
|
||||
self.assertIn("discovery_dispatch",source)
|
||||
self.assertIn("agent_id=authenticated_agent_id",source)
|
||||
|
||||
def test_legacy_config_remains_explicitly_legacy_only(self):
|
||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Reused discovery isolation scenarios exercised through real HTTP authority."""
|
||||
|
||||
import unittest
|
||||
from tests.test_discovery_security_postgres import DiscoveryFixtures
|
||||
|
||||
|
||||
class AgentDiscoveryIsolationPostgresTests(DiscoveryFixtures, unittest.TestCase):
|
||||
def test_cross_zone_mac_and_ip_are_unmatched_without_id_disclosure(self):
|
||||
mid, mac = self.miner(1)
|
||||
before = self.durable()
|
||||
row = self.observe(mac=mac)
|
||||
self.assertEqual(row["discovery_status"], "zone_conflict")
|
||||
self.assertIsNone(row["matched_miner_id"])
|
||||
self.assertNotIn(str(mid), str(row))
|
||||
self.assertEqual(before["miners"], self.durable()["miners"])
|
||||
|
||||
def test_same_zone_mac_and_ip_matches_are_preserved(self):
|
||||
mid, mac = self.miner()
|
||||
row = self.observe(mac=mac)
|
||||
self.assertEqual(row["matched_miner_id"], str(mid))
|
||||
self.assertEqual(row["discovery_status"], "known")
|
||||
|
||||
def test_unassigned_miner_is_not_claimed_by_discovery_matching(self):
|
||||
mid, mac = self.miner()
|
||||
with __import__(
|
||||
"app.agent_zones.repository", fromlist=["default_connection_factory"]
|
||||
).default_connection_factory() as conn:
|
||||
conn.execute("UPDATE miners SET agent_zone_id=NULL WHERE id=%s", (mid,))
|
||||
row = self.observe(mac=mac)
|
||||
self.assertIsNone(row["matched_miner_id"])
|
||||
self.assertIsNone(
|
||||
self.scalar("SELECT agent_zone_id FROM miners WHERE id=%s", (mid,))
|
||||
)
|
||||
|
||||
def test_claimed_agent_identity_cannot_override_authentication(self):
|
||||
response = self.post(
|
||||
"/api/agent/discoveries", {"agent_id": self.agents[1], "discoveries": []}
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
@@ -42,6 +42,7 @@ class FakeRepository:
|
||||
self.agent = None
|
||||
self.saved = []
|
||||
self.assign_result = {MINER_A: "assigned"}
|
||||
self.discovery_match = None
|
||||
|
||||
def get_agent(self, agent_id):
|
||||
return self.agent if self.agent and self.agent["agent_id"] == agent_id else None
|
||||
@@ -107,6 +108,11 @@ class FakeRepository:
|
||||
],
|
||||
}
|
||||
|
||||
def find_miner_in_zone(self, *, agent_id, agent_zone_id, mac_address, ip_address):
|
||||
self.discovery_match_request = {'agent_id':agent_id,'agent_zone_id':agent_zone_id,
|
||||
'mac_address':mac_address,'ip_address':ip_address}
|
||||
return self.discovery_match
|
||||
|
||||
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")
|
||||
@@ -136,6 +142,7 @@ class AgentZoneModelTests(unittest.TestCase):
|
||||
class AgentZoneServiceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = FakeRepository()
|
||||
self.repository.agent = {"agent_id":"agent-a","site_id":SITE_A,"agent_zone_id":ZONE_A,"site_name":"North"}
|
||||
self.service = AgentZoneService(self.repository)
|
||||
|
||||
def checkin(self, **kwargs):
|
||||
@@ -144,14 +151,14 @@ class AgentZoneServiceTests(unittest.TestCase):
|
||||
AgentCheckinRequest(agent_id="agent-a", site_name="North", **kwargs),
|
||||
)
|
||||
|
||||
def test_explicit_first_assignment_and_same_assignment_are_allowed(self):
|
||||
def test_established_assignment_and_same_assignment_are_allowed(self):
|
||||
first = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||
second = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||
self.assertTrue(first.routing_available)
|
||||
self.assertEqual(second.agent_zone_id, ZONE_A)
|
||||
|
||||
def test_explicit_zone_site_mismatch_and_identity_spoof_are_rejected(self):
|
||||
with self.assertRaisesRegex(AgentZoneValidationError, "does not belong"):
|
||||
with self.assertRaisesRegex(AgentZoneValidationError, "cannot change Sites"):
|
||||
self.checkin(site_id=SITE_B, agent_zone_id=ZONE_A)
|
||||
with self.assertRaisesRegex(AgentZoneValidationError, "authenticated"):
|
||||
self.service.checkin(
|
||||
@@ -181,34 +188,20 @@ class AgentZoneServiceTests(unittest.TestCase):
|
||||
self.assertEqual(result.agent_zone_id, ZONE_A)
|
||||
|
||||
def test_transitional_resolution_requires_exactly_one_enabled_zone(self):
|
||||
single = self.checkin(site_id=SITE_A)
|
||||
self.assertEqual(single.agent_zone_id, ZONE_A)
|
||||
self.repository.agent = None
|
||||
self.repository.zones = {}
|
||||
zero = self.checkin(site_id=SITE_A)
|
||||
self.assertEqual(zero.routing_reason, "agent_zone_unavailable")
|
||||
self.repository.agent = None
|
||||
self.repository.zones = {
|
||||
ZONE_A: zone(),
|
||||
ZONE_B: zone(ZONE_B, SITE_A, name="Room 2"),
|
||||
}
|
||||
many = self.checkin(site_id=SITE_A)
|
||||
self.assertEqual(many.routing_reason, "agent_zone_ambiguous")
|
||||
self.assertIsNone(many.agent_zone_id)
|
||||
for zones in [{}, {ZONE_A: zone()}, {ZONE_A:zone(),ZONE_B:zone(ZONE_B,SITE_A)}]:
|
||||
self.repository.zones=zones
|
||||
self.repository.agent=None
|
||||
with self.assertRaisesRegex(AgentZoneValidationError,"administrator-established"):
|
||||
self.checkin(site_id=SITE_A)
|
||||
|
||||
def test_disabled_zone_is_not_selected_and_existing_assignment_is_factual(self):
|
||||
self.repository.zones[ZONE_A]["is_enabled"] = False
|
||||
unassigned = self.checkin(site_id=SITE_A)
|
||||
self.assertEqual(unassigned.routing_reason, "agent_zone_unavailable")
|
||||
self.repository.agent = {
|
||||
"agent_id": "agent-a", "site_id": SITE_A,
|
||||
"agent_zone_id": ZONE_A, "site_name": "North",
|
||||
}
|
||||
existing = self.service.checkin(
|
||||
"agent-a", AgentCheckinRequest(agent_id="agent-a")
|
||||
)
|
||||
self.assertEqual(existing.routing_reason, "agent_zone_disabled")
|
||||
self.assertEqual(existing.agent_zone_id, ZONE_A)
|
||||
self.repository.zones[ZONE_A]["is_enabled"]=False
|
||||
existing=self.service.checkin("agent-a",AgentCheckinRequest(agent_id="agent-a"))
|
||||
self.assertEqual(existing.routing_reason,"agent_zone_disabled")
|
||||
self.assertEqual(existing.agent_zone_id,ZONE_A)
|
||||
self.repository.agent=None
|
||||
with self.assertRaisesRegex(AgentZoneValidationError,"administrator-established"):
|
||||
self.checkin(site_id=SITE_A)
|
||||
|
||||
def test_payload_is_sanitized_before_persistence(self):
|
||||
request = AgentCheckinRequest.model_validate({
|
||||
@@ -255,9 +248,41 @@ class AgentZoneServiceTests(unittest.TestCase):
|
||||
self.assertNotIn("10.80.0.0/24", str(configuration))
|
||||
|
||||
|
||||
def test_discovery_match_uses_only_authenticated_agent_zone(self):
|
||||
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||
self.repository.discovery_match = {"id": MINER_A, "matched_by": "mac"}
|
||||
|
||||
result = self.service.match_discovered_miner(
|
||||
"agent-a",
|
||||
mac_address="02:00:00:00:16:21",
|
||||
ip_address="192.0.2.21",
|
||||
)
|
||||
|
||||
self.assertEqual(result.miner_id, MINER_A)
|
||||
self.assertEqual(result.matched_by, "mac")
|
||||
self.assertEqual(
|
||||
self.repository.discovery_match_request["agent_zone_id"], ZONE_A
|
||||
)
|
||||
|
||||
def test_discovery_match_rejects_stale_recorded_zone_without_querying(self):
|
||||
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||
|
||||
result = self.service.match_discovered_miner(
|
||||
"agent-a",
|
||||
recorded_zone_id=ZONE_B,
|
||||
mac_address="02:00:00:00:16:21",
|
||||
ip_address="192.0.2.21",
|
||||
)
|
||||
|
||||
self.assertIsNone(result.miner_id)
|
||||
self.assertEqual(result.routing_reason, "agent_zone_conflict")
|
||||
self.assertFalse(hasattr(self.repository, "discovery_match_request"))
|
||||
|
||||
|
||||
class AgentZoneRouterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = FakeRepository()
|
||||
self.repository.agent = {"agent_id":"agent-a","site_id":SITE_A,"agent_zone_id":ZONE_A,"site_name":"North"}
|
||||
self.service = AgentZoneService(self.repository)
|
||||
self.app = FastAPI()
|
||||
self.app.include_router(router)
|
||||
|
||||
@@ -196,12 +196,12 @@ class AgentZonePostgresTests(unittest.TestCase):
|
||||
connection.rollback()
|
||||
connection.close()
|
||||
|
||||
def test_unassigned_miner_inherits_zone_and_existing_other_zone_wins(self):
|
||||
def test_unassigned_miner_stays_unassigned_and_existing_other_zone_wins(self):
|
||||
repository = AgentZoneRepository(connect)
|
||||
outcomes = repository.assign_miners_to_zone(
|
||||
[MINER_UNASSIGNED, MINER_OTHER], ZONE_A
|
||||
)
|
||||
self.assertEqual(outcomes[MINER_UNASSIGNED], "assigned")
|
||||
self.assertEqual(outcomes[MINER_UNASSIGNED], "miner_zone_unassigned")
|
||||
self.assertEqual(outcomes[MINER_OTHER], "agent_zone_conflict")
|
||||
connection = connect()
|
||||
try:
|
||||
@@ -213,7 +213,7 @@ class AgentZonePostgresTests(unittest.TestCase):
|
||||
assignments = dict(cur.fetchall())
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertEqual(assignments[MINER_UNASSIGNED], ZONE_A)
|
||||
self.assertIsNone(assignments[MINER_UNASSIGNED])
|
||||
self.assertEqual(assignments[MINER_OTHER], ZONE_B)
|
||||
|
||||
def test_disabled_zone_blocks_control_without_site_fallback(self):
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,338 @@
|
||||
"""Audit F1-F3 reversals: real registered HTTP/transactions, no physical devices."""
|
||||
import ast
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
import app.main as main
|
||||
from app.identity import create_access_token
|
||||
from tests.test_discovery_security_postgres import DiscoveryFixtures, connect
|
||||
|
||||
|
||||
class LegacyInventorySecurityTests(DiscoveryFixtures, unittest.TestCase):
|
||||
def upload(self, text, query='', headers=None):
|
||||
return self.client.post(
|
||||
'/api/imports/whatsminer-tool-scan?dry_run=false&update_existing=true' + query,
|
||||
headers=self.admin if headers is None else headers,
|
||||
files={'file': ('simulated.csv', text.encode(), 'text/csv')},
|
||||
)
|
||||
|
||||
def denied(self, response, before, status):
|
||||
self.assertEqual(response.status_code, status, response.text)
|
||||
self.assertEqual(before, self.durable())
|
||||
self.transport.assert_not_called()
|
||||
|
||||
def test_discovery_audit_reproductions_all_rejected_without_reads(self):
|
||||
self.miner(1, ip='198.51.100.111')
|
||||
self.miner(ip='198.51.100.125')
|
||||
self.miner(1, ip='198.51.100.125')
|
||||
for mode in ['true', 'false']:
|
||||
for ip in ['198.51.100.111', '198.51.100.125', '203.0.113.209']:
|
||||
with self.subTest(mode=mode, ip=ip), patch.dict(os.environ, MINEROPS_HUB_MODE=mode):
|
||||
before = self.durable()
|
||||
response = self.client.post('/api/miners/discover-one', headers=self.admin, json={'ip_address': ip})
|
||||
self.denied(response, before, 410)
|
||||
|
||||
def test_missing_disabled_agent_and_zone_cannot_restore_legacy_discovery(self):
|
||||
with connect() as c:
|
||||
c.execute('UPDATE agent_zones SET is_enabled=false WHERE id=%s', (self.zones[0],))
|
||||
c.execute('UPDATE minerops_site_agents SET credential_revoked_at=now() WHERE agent_id=%s', (self.agents[0],))
|
||||
c.execute('DELETE FROM minerops_site_agents WHERE agent_id=%s', (self.agents[1],))
|
||||
before = self.durable()
|
||||
r = self.client.post('/api/miners/discover-one', headers=self.admin, json={'ip_address': '198.51.100.111'})
|
||||
self.denied(r, before, 410)
|
||||
|
||||
def test_inventory_only_permission_cannot_replace_mac(self):
|
||||
role, uid = uuid4(), uuid4()
|
||||
with connect() as c:
|
||||
c.execute('INSERT INTO roles(id,name) VALUES (%s,%s)', (role, str(role)))
|
||||
c.execute("INSERT INTO role_permissions(role_id,permission_id) SELECT %s,id FROM permissions WHERE key='miners.update'", (role,))
|
||||
c.execute("INSERT INTO users(id,username,display_name,password_hash,role_id) VALUES (%s,%s,'Inventory only','unused',%s)", (uid, str(uid), role))
|
||||
headers = {'Authorization': 'Bearer ' + create_access_token(str(uid), str(uid), str(role))}
|
||||
mid, _ = self.miner(ip='198.51.100.127')
|
||||
before = self.durable()
|
||||
for path, body, status in [
|
||||
('/api/agent/discoveries/review', {'discovery_id': str(uuid4()), 'version': 'x', 'action': 'ignore'}, 403),
|
||||
(f'/api/miners/{mid}/replace-control-board', {}, 403),
|
||||
('/api/miners/discover-one', {'ip_address': '198.51.100.127'}, 410),
|
||||
]:
|
||||
self.denied(self.client.post(path, headers=headers, json=body), before, status)
|
||||
|
||||
def test_range_and_manual_scheduler_retired_in_both_modes(self):
|
||||
for mode in ['true', 'false']:
|
||||
with patch.dict(os.environ, MINEROPS_HUB_MODE=mode):
|
||||
for path in [f'/api/scan-ranges/{uuid4()}/discover-now', '/api/scanner/run-known-now']:
|
||||
before = self.durable()
|
||||
self.denied(self.client.post(path, headers=self.admin, json={}), before, 410)
|
||||
with patch('app.main.threading.Thread') as thread:
|
||||
main.start_scheduled_scanner()
|
||||
thread.assert_not_called()
|
||||
status = self.client.get('/api/scanner/status', headers=self.admin).json()['data']
|
||||
self.assertFalse(status['enabled'])
|
||||
self.assertTrue(status['scanner_disabled'])
|
||||
|
||||
def test_all_legacy_helper_definitions_and_saved_aliases_are_retired(self):
|
||||
# Execute every original definition, including shadowed functions; decorators
|
||||
# are omitted so this cannot mutate the application's registered routes.
|
||||
tree = ast.parse(Path(main.__file__).read_text())
|
||||
names = {'find_existing_miner', 'discover_one_miner', 'discover_scan_range_now',
|
||||
'call_discover_one_miner_flexible', 'refresh_one_known_miner_for_schedule',
|
||||
'run_scheduled_known_miner_scan', 'scheduled_scanner_loop',
|
||||
'apply_legacy_summary_to_miner', 'legacy_find_miner_id_by_ip'}
|
||||
import inspect
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name in names:
|
||||
with self.subTest(helper=node.name, line=node.lineno):
|
||||
node.decorator_list = []
|
||||
namespace = dict(vars(main))
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), '<retired-helper>', 'exec'), namespace)
|
||||
fn = namespace[node.name]
|
||||
kwargs = {name: None for name, p in inspect.signature(fn).parameters.items() if p.default is inspect.Parameter.empty}
|
||||
with patch('app.main.get_db_connection', side_effect=AssertionError('No legacy database access')):
|
||||
with self.assertRaises(HTTPException) as exc:
|
||||
fn(**kwargs)
|
||||
self.assertEqual(exc.exception.status_code, 410)
|
||||
with self.assertRaises(HTTPException) as exc:
|
||||
main._minerops_original_discover_one_miner_legacy_summary(None)
|
||||
self.assertEqual(exc.exception.status_code, 410)
|
||||
|
||||
def test_summary_id_ip_disagreement_and_bulk_retired(self):
|
||||
a, _ = self.miner(ip='198.51.100.121')
|
||||
self.miner(1, ip='198.51.100.122')
|
||||
with patch('app.main.read_legacy_whatsminer_summary', side_effect=AssertionError('No summary transport')) as read:
|
||||
for mode in ['true', 'false']:
|
||||
with patch.dict(os.environ, MINEROPS_HUB_MODE=mode):
|
||||
for path, body in [
|
||||
('/api/tools/legacy-summary-refresh-one', {'miner_id': str(a), 'ip_address': '198.51.100.122'}),
|
||||
('/api/tools/legacy-summary-refresh-one', {'miner_id': str(a)}),
|
||||
('/api/tools/legacy-summary-refresh-missing', {}),
|
||||
]:
|
||||
before = self.durable()
|
||||
self.denied(self.client.post(path, headers=self.admin, json=body), before, 410)
|
||||
read.assert_not_called()
|
||||
|
||||
def test_summary_cannot_start_inflight_read_or_overwrite_newer_owner(self):
|
||||
mid, _ = self.miner(ip='198.51.100.123')
|
||||
with patch('app.main.read_legacy_whatsminer_summary') as read, ThreadPoolExecutor(2) as pool:
|
||||
future = pool.submit(lambda: self.client.post('/api/tools/legacy-summary-refresh-one', headers=self.admin, json={'miner_id': str(mid)}))
|
||||
with connect() as c:
|
||||
c.execute("UPDATE miners SET site_id=%s,agent_zone_id=%s,current_ip='198.51.100.124',updated_at=clock_timestamp() WHERE id=%s", (self.sites[1], self.zones[1], mid))
|
||||
self.assertEqual(future.result(timeout=8).status_code, 410)
|
||||
before = self.durable()
|
||||
with self.assertRaises(HTTPException):
|
||||
main.apply_legacy_summary_to_miner(str(mid), '198.51.100.123')
|
||||
self.assertEqual(before, self.durable())
|
||||
self.assertEqual(self.scalar('SELECT host(current_ip) FROM miners WHERE id=%s', (mid,)), '198.51.100.124')
|
||||
read.assert_not_called()
|
||||
|
||||
def test_legacy_human_auth_still_required(self):
|
||||
for headers in [{}, self.headers()]:
|
||||
for path in ['/api/miners/discover-one', '/api/tools/legacy-summary-refresh-one']:
|
||||
before = self.durable()
|
||||
self.denied(self.client.post(path, headers=headers, json={'ip_address': '198.51.100.111'}), before, 401)
|
||||
|
||||
def test_csv_ip_only_requires_zone_even_with_one_global_match(self):
|
||||
self.miner(1, ip='198.51.100.112')
|
||||
before = self.durable()
|
||||
self.denied(self.upload('IP,Miner Type\n198.51.100.112,WRONG\n'), before, 422)
|
||||
|
||||
def test_csv_same_ip_requires_namespace_and_exact_zone_updates_one(self):
|
||||
a, _ = self.miner(ip='198.51.100.126')
|
||||
b, _ = self.miner(1, ip='198.51.100.126')
|
||||
before = self.durable()
|
||||
self.denied(self.upload('IP,Miner Type\n198.51.100.126,WRONG\n'), before, 422)
|
||||
r = self.upload(f'IP,Agent Zone ID,Miner Type\n198.51.100.126,{self.zones[1]},VALID\n')
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertIsNone(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (a,)))
|
||||
self.assertEqual(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (b,)), 'VALID')
|
||||
self.assertEqual(before['miner_status_snapshots'], self.durable()['miner_status_snapshots'])
|
||||
|
||||
def test_csv_duplicate_ip_inside_zone_rejected(self):
|
||||
self.miner(ip='198.51.100.130'); self.miner(ip='198.51.100.130')
|
||||
before = self.durable()
|
||||
self.denied(self.upload('IP,Miner Type\n198.51.100.130,WRONG\n', '&agent_zone_id=' + str(self.zones[0])), before, 409)
|
||||
|
||||
def test_csv_exact_id_mac_serial_and_query_zone_legitimate(self):
|
||||
mid, mac = self.miner(ip='198.51.100.131')
|
||||
serial = uuid4().hex
|
||||
with connect() as c:
|
||||
c.execute('UPDATE miners SET miner_sn=%s WHERE id=%s', (serial, mid))
|
||||
for heading, value in [('Miner ID', str(mid)), ('MAC', mac), ('Serial', serial), ('IP', '198.51.100.131')]:
|
||||
r = self.upload(f'{heading},Miner Type\n{value},VALID\n', '&agent_zone_id=' + str(self.zones[0]))
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertEqual(r.json()['data']['matched_count'], 1)
|
||||
|
||||
def test_csv_conflicting_and_unknown_mac_never_fall_back_to_ip(self):
|
||||
a, _ = self.miner(ip='198.51.100.132')
|
||||
b, bm = self.miner(1, ip='198.51.100.133')
|
||||
for mac in [bm, '02:AA:BB:CC:DD:EF']:
|
||||
before = self.durable()
|
||||
self.denied(self.upload(f'Miner ID,IP,MAC,Miner Type\n{a},198.51.100.132,{mac},WRONG\n'), before, 409)
|
||||
before = self.durable()
|
||||
self.denied(self.upload(f'Miner ID,IP,Miner Type\n{a},198.51.100.133,WRONG\n'), before, 409)
|
||||
|
||||
def test_csv_historical_mac_and_contradictory_namespace_serial_denied(self):
|
||||
mid, oldmac = self.miner(ip='198.51.100.134')
|
||||
with connect() as c:
|
||||
c.execute('UPDATE miners SET current_mac=%s,miner_sn=%s WHERE id=%s', ('02:' + ':'.join(f'{v:02X}' for v in uuid4().bytes[:5]), 'serial-' + str(mid), mid))
|
||||
for columns, values in [
|
||||
('MAC', oldmac), ('Serial', 'unknown'), ('Site ID', str(self.sites[1])),
|
||||
('Agent Zone ID', str(self.zones[1])), ('miner_id', str(uuid4())),
|
||||
]:
|
||||
before = self.durable()
|
||||
self.denied(self.upload(f'Miner ID,{columns},Miner Type\n{mid},{values},WRONG\n'), before, 409)
|
||||
|
||||
def test_csv_invalid_identifiers_and_query_row_disagreement(self):
|
||||
mid, _ = self.miner(ip='198.51.100.135')
|
||||
for header, value in [('MAC', 'bad-mac'), ('IP', '198.51.100.135/24'), ('Miner ID', 'bad-id'), ('Agent Zone ID', 'bad-zone')]:
|
||||
before = self.durable()
|
||||
self.denied(self.upload(f'{header},Miner Type\n{value},WRONG\n'), before, 422)
|
||||
before = self.durable()
|
||||
self.denied(self.upload(f'Miner ID,Agent Zone ID,Miner Type\n{mid},{self.zones[1]},WRONG\n', '&agent_zone_id=' + str(self.zones[0])), before, 409)
|
||||
|
||||
def test_csv_unmatched_strong_identity_does_not_select_ip(self):
|
||||
self.miner(ip='198.51.100.136')
|
||||
before = self.durable()
|
||||
r = self.upload('MAC,IP,Miner Type\n02:AA:BB:CC:DD:EF,198.51.100.136,WRONG\n')
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertEqual(r.json()['data']['unmatched_count'], 1)
|
||||
self.assertEqual(before['miners'], self.durable()['miners'])
|
||||
|
||||
def test_csv_batch_conflict_rolls_back_prior_valid_row(self):
|
||||
a, _ = self.miner(ip='198.51.100.137')
|
||||
b, _ = self.miner(1, ip='198.51.100.138')
|
||||
before = self.durable()
|
||||
self.denied(self.upload(f'Miner ID,IP,Miner Type\n{a},198.51.100.137,VALID\n{b},198.51.100.137,WRONG\n'), before, 409)
|
||||
|
||||
def test_csv_preview_repeat_and_history_preservation(self):
|
||||
mid, _ = self.miner(ip='198.51.100.139')
|
||||
with connect() as c:
|
||||
c.execute('INSERT INTO miner_status_snapshots(id,miner_id,observed_at,mhs_av) VALUES (%s,%s,now(),123)', (uuid4(), mid))
|
||||
before = self.durable()
|
||||
text = f'Miner ID,Miner Type\n{mid},VALID\n'
|
||||
r = self.client.post('/api/imports/whatsminer-tool-scan?dry_run=true&update_existing=true', headers=self.admin, files={'file': ('simulated.csv', text.encode(), 'text/csv')})
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertEqual(before, self.durable())
|
||||
for _ in range(2):
|
||||
self.assertEqual(self.upload(text).status_code, 200)
|
||||
self.assertEqual(before['miner_status_snapshots'], self.durable()['miner_status_snapshots'])
|
||||
self.assertEqual(before['miner_mac_ownerships'], self.durable()['miner_mac_ownerships'])
|
||||
|
||||
def test_csv_actual_audit_permission_failure_rolls_back(self):
|
||||
mid, _ = self.miner(ip='198.51.100.140')
|
||||
role = 'b1_csv_' + uuid4().hex
|
||||
with connect() as c:
|
||||
c.execute('CREATE ROLE ' + role)
|
||||
c.execute('GRANT USAGE ON SCHEMA public TO ' + role)
|
||||
c.execute('GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA public TO ' + role)
|
||||
c.execute('REVOKE INSERT ON audit_logs FROM ' + role)
|
||||
def limited():
|
||||
c = connect(); c.execute('SET ROLE ' + role); c.commit(); return c
|
||||
before = self.durable()
|
||||
try:
|
||||
with patch('app.main.get_db_connection', side_effect=limited):
|
||||
r = self.upload(f'Miner ID,Miner Type\n{mid},ROLLBACK\n')
|
||||
self.denied(r, before, 500)
|
||||
self.assertIn('audit_logs', r.text)
|
||||
finally:
|
||||
with connect() as c:
|
||||
c.execute('DROP OWNED BY ' + role); c.execute('DROP ROLE ' + role)
|
||||
|
||||
def test_csv_concurrent_ownership_move_is_revalidated_after_real_lock_wait(self):
|
||||
mid, _ = self.miner(ip='198.51.100.141')
|
||||
owner = connect()
|
||||
owner.execute('UPDATE miners SET site_id=%s,agent_zone_id=%s,updated_at=clock_timestamp() WHERE id=%s', (self.sites[1], self.zones[1], mid))
|
||||
with ThreadPoolExecutor(1) as pool:
|
||||
future = pool.submit(lambda: self.upload(f'Miner ID,Agent Zone ID,Miner Type\n{mid},{self.zones[0]},WRONG\n'))
|
||||
try:
|
||||
deadline = time.monotonic() + 4
|
||||
waiting = 0
|
||||
while time.monotonic() < deadline:
|
||||
waiting = self.scalar("SELECT count(*) FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'")
|
||||
if waiting: break
|
||||
time.sleep(.02)
|
||||
self.assertGreater(waiting, 0)
|
||||
owner.commit()
|
||||
r = future.result(timeout=8)
|
||||
self.assertEqual(r.status_code, 409, r.text)
|
||||
self.assertEqual(self.scalar('SELECT agent_zone_id FROM miners WHERE id=%s', (mid,)), self.zones[1])
|
||||
self.assertIsNone(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (mid,)))
|
||||
finally:
|
||||
owner.rollback(); owner.close()
|
||||
|
||||
def test_csv_real_update_lock_prevents_mid_transaction_ownership_change(self):
|
||||
import app.discovery.csv_import as module
|
||||
mid, _ = self.miner(ip='198.51.100.142')
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
original = module.resolve_inventory_row
|
||||
def gate(*args, **kwargs):
|
||||
row = original(*args, **kwargs); entered.set()
|
||||
self.assertTrue(release.wait(4)); return row
|
||||
def move():
|
||||
with connect() as c:
|
||||
c.execute('UPDATE miners SET site_id=%s,agent_zone_id=%s,updated_at=clock_timestamp() WHERE id=%s', (self.sites[1], self.zones[1], mid))
|
||||
with patch.object(module, 'resolve_inventory_row', side_effect=gate), ThreadPoolExecutor(2) as pool:
|
||||
upload = pool.submit(lambda: self.upload(f'Miner ID,Agent Zone ID,Miner Type\n{mid},{self.zones[0]},VALID\n'))
|
||||
self.assertTrue(entered.wait(4)); moving = pool.submit(move)
|
||||
try:
|
||||
deadline = time.monotonic() + 3
|
||||
waiting = 0
|
||||
while time.monotonic() < deadline:
|
||||
waiting = self.scalar("SELECT count(*) FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'")
|
||||
if waiting: break
|
||||
time.sleep(.02)
|
||||
self.assertGreater(waiting, 0)
|
||||
finally:
|
||||
release.set()
|
||||
self.assertEqual(upload.result(timeout=8).status_code, 200)
|
||||
moving.result(timeout=8)
|
||||
self.assertEqual(self.scalar('SELECT agent_zone_id FROM miners WHERE id=%s', (mid,)), self.zones[1])
|
||||
|
||||
def test_csv_duplicate_headers_and_conflicting_aliases_rejected(self):
|
||||
mid, _ = self.miner(ip='198.51.100.143')
|
||||
for text, code in [
|
||||
(f'Miner ID,Miner ID,Miner Type\n{mid},{mid},WRONG\n', 422),
|
||||
(f'Miner ID,miner_id,Miner Type\n{mid},{uuid4()},WRONG\n', 409),
|
||||
(f'Miner ID,Site ID,Agent Zone ID,Miner Type\n{mid},{self.sites[1]},{self.zones[0]},WRONG\n', 409),
|
||||
]:
|
||||
before = self.durable()
|
||||
self.denied(self.upload(text), before, code)
|
||||
|
||||
def test_csv_duplicate_serial_is_ambiguous(self):
|
||||
a, _ = self.miner(ip='198.51.100.144')
|
||||
b, _ = self.miner(1, ip='198.51.100.145')
|
||||
serial = 'duplicate-' + uuid4().hex
|
||||
with connect() as c:
|
||||
c.execute('UPDATE miners SET miner_sn=%s WHERE id IN (%s,%s)', (serial, a, b))
|
||||
before = self.durable()
|
||||
self.denied(self.upload(f'Serial,Miner Type\n{serial},WRONG\n'), before, 409)
|
||||
|
||||
def test_csv_global_mac_selection_with_overlapping_ip_is_valid(self):
|
||||
a, _ = self.miner(ip='198.51.100.146')
|
||||
b, mac = self.miner(1, ip='198.51.100.146')
|
||||
before = self.durable()
|
||||
r = self.upload(f'MAC,IP,Miner Type\n{mac},198.51.100.146,VALID\n')
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertIsNone(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (a,)))
|
||||
self.assertEqual(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (b,)), 'VALID')
|
||||
after = self.durable()
|
||||
for table in ['miner_status_snapshots', 'miner_mac_ownerships', 'miner_network_identities']:
|
||||
self.assertEqual(before[table], after[table])
|
||||
audit = self.scalar("SELECT new_value_json FROM audit_logs WHERE actor_user_id=%s AND action='import.whatsminertool_csv.applied' ORDER BY created_at DESC LIMIT 1", (self.uid,))
|
||||
self.assertEqual(audit['updates'][0]['miner_id'], str(b))
|
||||
self.assertEqual(audit['updates'][0]['agent_zone_id'], str(self.zones[1]))
|
||||
|
||||
def test_csv_read_only_user_cannot_apply_metadata(self):
|
||||
uid = uuid4()
|
||||
with connect() as c:
|
||||
c.execute("INSERT INTO users(id,username,display_name,password_hash,role_id) SELECT %s,%s,'Read only','unused',id FROM roles WHERE name='Read Only'", (uid, str(uid)))
|
||||
headers = {'Authorization': 'Bearer ' + create_access_token(str(uid), str(uid), 'Read Only')}
|
||||
mid, _ = self.miner(ip='198.51.100.147')
|
||||
before = self.durable()
|
||||
self.denied(self.upload(f'Miner ID,Miner Type\n{mid},WRONG\n', headers=headers), before, 403)
|
||||
@@ -146,8 +146,23 @@ class StageAQueueContainmentTests(unittest.TestCase):
|
||||
self.assertEqual(self.audits(command_id), [])
|
||||
|
||||
def test_read_only_commands_still_claim_and_complete(self):
|
||||
for kind in ("poll_known_miners_now", "discover_new_miners", "enrich_match_discovery"):
|
||||
self.seed(f"stage-a-read-{kind}", command_type=kind, payload={"ip_address": "192.0.2.1"})
|
||||
from uuid import uuid4
|
||||
from app.discovery.repository import DiscoveryRepository
|
||||
site, zone, actor = uuid4(), uuid4(), uuid4()
|
||||
with connect() as connection, connection.cursor() as cur:
|
||||
cur.execute("INSERT INTO sites(id,name) VALUES (%s,%s)",(site,str(site)))
|
||||
cur.execute("INSERT INTO agent_zones(id,site_id,name) VALUES (%s,%s,'Stage A reader')",(zone,site))
|
||||
cur.execute("INSERT INTO agent_zone_network_ranges(agent_zone_id,network) VALUES (%s,'192.0.2.0/24')",(zone,))
|
||||
cur.execute("""INSERT INTO minerops_site_agents(agent_id,site_id,agent_zone_id,site_name)
|
||||
VALUES (%s,%s,%s,'Stage A reader') ON CONFLICT(agent_id) DO UPDATE SET
|
||||
site_id=EXCLUDED.site_id,agent_zone_id=EXCLUDED.agent_zone_id""",(AGENT,site,zone))
|
||||
cur.execute("INSERT INTO users(id,username,display_name,password_hash,role_id) SELECT %s,%s,'Reader','unused',id FROM roles WHERE name='Admin'",(actor,str(actor)))
|
||||
discovery=DiscoveryRepository(connect)
|
||||
observation=discovery.ingest(AGENT,{'discoveries':[{'ip_address':'192.0.2.1'}]})['discoveries'][0]
|
||||
user={'id':str(actor),'username':'stage-a-reader'}
|
||||
for kind in ('poll_known_miners_now','discover_new_miners'):
|
||||
discovery.queue({'agent_id':AGENT},user,kind)
|
||||
discovery.queue({'discovery_id':observation['id'],'version':observation['version']},user)
|
||||
commands = self.service.claim(AGENT)
|
||||
self.assertEqual(len(commands), 3)
|
||||
for command in commands:
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
Blocker #1 discovery isolation: candidate review and compatibility contract
|
||||
=======================================================================
|
||||
Review candidate only. Blocker #1 is NOT declared PASS. No production cutover is
|
||||
authorized. Base: e898f283ab0fc21bac77b63db1c4a9ad35e21562 (Stage A / draft PR #6).
|
||||
No schema migration, new constraints, backfill, or runtime DDL is introduced.
|
||||
|
||||
Authority and observations
|
||||
-------------------------
|
||||
All machine discovery/telemetry routes retain per-Agent HTTP authentication.
|
||||
Authority comes from the persisted Agent -> Site/Zone/network assignment, never
|
||||
client Site, room, Zone, target-Agent names, or a body-supplied Agent identity.
|
||||
Conflicting body Agent IDs are rejected. Disabled/unassigned/revoked routing fails
|
||||
closed. Check-in cannot establish or change an Agent's initial assignment: use
|
||||
existing authenticated enrollment or the separately reviewed legacy transition.
|
||||
Unassigned inventory is not implicitly claimed by check-in or snapshots.
|
||||
|
||||
Discovery reports are bounded (128 items, 1 MiB body; 1,024 observations/minute per
|
||||
Agent). IP literals must be in the current Zone networks. MACs must be canonical
|
||||
ASCII unicast addresses. Explicit IDs, IP aliases, MAC aliases and supplied serial
|
||||
must agree with current inventory and immutable historical MAC ownership. Global
|
||||
historical MAC/serial collision checks reveal no foreign inventory IDs. Duplicate
|
||||
current IPs within a Zone are conflicts; distinct Zones may use the same IP.
|
||||
|
||||
Each observation has a stable server ID and version. Raw reports and original
|
||||
provenance are retained, with an atomic discovery.observed audit recording the
|
||||
current authority, raw hash, inventory fingerprint and observation version. Exact
|
||||
retries deduplicate; discovery clients must send discovered_at for a fresh scan
|
||||
(the supplied Windows scanner does). Approval requires a current, proven,
|
||||
non-superseded observation less than 15 minutes old. Legacy/null/mislinked rows
|
||||
without this evidence remain visible but cannot authorize approval or enrichment.
|
||||
There is no automatic evidence backfill, deletion, identity merge or ownership
|
||||
transfer. An authorized operator must investigate conflicts and obtain a fresh
|
||||
observation. Discovery does not implement an ownership-transfer workflow.
|
||||
|
||||
Human policy remains global administration: agents.view for discovery display;
|
||||
agents.manage for stable-ID review/enrichment; miners.control for scan/poll
|
||||
requests. The UI displays authoritative Agent/Zone and escapes all untrusted text.
|
||||
IP-only approval is unsupported. Submit discovery_id, version and one of
|
||||
add_to_inventory / ignore / non_miner. All three revalidate current authority and
|
||||
identity under locks; only that observation changes. Adding new inventory also
|
||||
requires a valid MAC. Repeat the same accepted decision/version to receive its
|
||||
recorded response without another audit or insertion. Stale/different decisions
|
||||
conflict; refresh before retrying. Audit actor is the authenticated human.
|
||||
|
||||
Read commands and enrichment
|
||||
----------------------------
|
||||
Enrichment is selected by discovery_id/version, routed to its actual observing
|
||||
Agent, and bound to the current authority and IP. No arbitrary-IP or hardcoded
|
||||
Plexus fallback exists. Scan/poll requests require an explicit registered Agent ID;
|
||||
optional UUID request_id provides explicit retry idempotency. New requests without
|
||||
request_id receive distinct IDs. Queue claim rejects legacy/unscoped/stale reads
|
||||
with atomic cancellation/audit and preserves unknown delivery outcome for any
|
||||
previous claim evidence. Existing Stage A physical rejection remains in force.
|
||||
|
||||
Before enrichment miner I/O, the upgraded Windows child submits command_id,
|
||||
discovery_id and version to authenticated POST /api/agent/discovery-target. Only
|
||||
a current claimed enrichment command returns a canonical IP. Enrichment submission
|
||||
revalidates the same binding and appends a NEW child observation for human review;
|
||||
it never resets the parent's decision or reparents history. V1/V2/V3 share the
|
||||
same boundary. Replaying old V2 identity merge inputs cannot merge inventory.
|
||||
|
||||
Known-miners returns a short-lived signed miner_version bound to Agent authority
|
||||
and the current miner ID/IP/MAC/updated_at fingerprint. Every miner/pool/API3
|
||||
snapshot or known-miner enrichment must carry this token and matching identifiers.
|
||||
All Windows polling clients propagate it and batch at 128. A concurrent update,
|
||||
revocation or reassignment may invalidate in-flight telemetry: discard the stale
|
||||
response, refetch known-miners and poll again. Do not fall back to IP matching.
|
||||
Descriptive enrichment does not change MAC/IP/serial/ownership. Raw diagnostics
|
||||
are retained in append-only audits, not empty status rows that hide measurements.
|
||||
API3 retains raw snapshots, safe descriptive metadata, actual summary/pool/board
|
||||
measurements and compatibility counters. Failed/metadata-only API3 responses do
|
||||
not replace the most recent measurements. Nested identity contradictions fail.
|
||||
|
||||
Transactions and database roles
|
||||
------------------------------
|
||||
All discovery writers/read-command claims take bounded locks in a shared order:
|
||||
advisory transaction lock 743190031, SHARE on Agent/Zone/network tables, SHARE ROW
|
||||
EXCLUSIVE on miners, then row locks. These table locks prevent phantom inserts
|
||||
from legacy writers which do not use the advisory lock. Agent routing/check-in
|
||||
uses guarded updates. No miner network I/O occurs while these transactions run.
|
||||
Audit insertion, inventory mutation and the observation decision commit together;
|
||||
any error rolls back all changes. Audit-denial and real UPDATE-trigger failure,
|
||||
concurrent approvals, cross-Zone MAC races, Zone disable and claim-vs-review are
|
||||
covered with real PostgreSQL connections and registered HTTP authorization routes.
|
||||
Lock timeout is 5 seconds; statement timeout 15 seconds. Controlled contention
|
||||
returns conflict/retry. Security favors correctness over fleet throughput here.
|
||||
|
||||
Required release review: validate privileges/lock behavior for the actual deployed
|
||||
DB role in a disposable replica and measure expected fleet load. Tests use an
|
||||
isolated database owner plus a limited role with audit INSERT denied. They do not
|
||||
certify the production role. No new uniqueness constraint is proposed, so do not
|
||||
remove the transaction/table locks without a separately reviewed replacement.
|
||||
|
||||
Reproduction
|
||||
------------
|
||||
Use a disposable checkout of this candidate with an already available backend
|
||||
image containing backend/requirements.txt. Never mount production credentials or
|
||||
.env files. From the candidate root:
|
||||
|
||||
MINEROPS_REVIEW_BACKEND_IMAGE=<existing-image> scripts/test-discovery-security.sh
|
||||
|
||||
The runner creates a unique internal Docker network, tmpfs PostgreSQL 16, no
|
||||
published ports, applies the unchanged existing Alembic chain and runs the two
|
||||
PostgreSQL discovery suites. A trap destroys only its own resources. Tests use
|
||||
simulated Agents and FastAPI's registered routes, without app startup/schedulers.
|
||||
|
||||
PowerShell (isolated PowerShell 7 host/container with simulated/loopback I/O):
|
||||
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsDiscovery.ps1
|
||||
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsControl.ps1
|
||||
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsDiagnosticTransport.ps1
|
||||
|
||||
Browser (Python 3, Node, Playwright and its Chromium/runtime installed externally):
|
||||
python3 backend/tests/browser/export_discovery_page.py . /tmp/discovery-page.html
|
||||
node backend/tests/browser/discovery-security.cjs /tmp/discovery-page.html
|
||||
|
||||
The browser test intercepts all requests, supplies harmless event-marker input,
|
||||
and verifies inert rendering and exact same-IP discovery_id/version selection.
|
||||
Its trailing baseline argument demonstrates the baseline stored-markup defect.
|
||||
|
||||
Other regression suites (explicit disposable DB guards apply):
|
||||
tests.test_stage_a_containment_postgres
|
||||
tests.test_stage_a_delivery_evidence_postgres
|
||||
tests.test_stage_a_schedule_cancellation_postgres
|
||||
tests.test_agent_zones_postgres
|
||||
tests.test_agent_credential_transition_postgres
|
||||
tests.test_agent_enrollment_postgres
|
||||
|
||||
Baseline source-test exceptions retained unchanged
|
||||
-------------------------------------------------
|
||||
backend/tests/test_agent_commands.py:
|
||||
AgentEnrichmentIsolationSourceTests.test_legacy_config_remains_explicitly_legacy_only
|
||||
requires the old minerops_require_agent_secret source string / fixed Plexus config.
|
||||
The authenticated config route already changed before this candidate. Replace the
|
||||
obsolete source assertion with real legacy/authenticated route tests in separate
|
||||
work; do not weaken credential authentication to satisfy the string assertion.
|
||||
|
||||
AgentSourceContractTests.test_deployment_preserves_local_state_and_registers_established_tasks
|
||||
blanket-bans the quoted agent-config.json filename in installer source, although
|
||||
the existing credential-recovery installer legitimately preserves/recovers local
|
||||
configuration. Follow up with behavioral installation/state-preservation tests on
|
||||
Windows. Do not remove the recovery path to satisfy the blanket source assertion.
|
||||
|
||||
Both failures reproduce on unchanged 2d89e80 and unchanged e898f28; both method
|
||||
bodies remain identical. They are not silent skips or weakened assertions here.
|
||||
The external-fixture legacy-retirement PostgreSQL test class also requires its
|
||||
own migration-case setup and is not certified by the discovery test runner.
|
||||
|
||||
Outstanding gates
|
||||
-----------------
|
||||
Independent security review of this exact isolated candidate is required before
|
||||
any Blocker #1 PASS or commit approval. Native Windows PowerShell 5.1/SYSTEM and
|
||||
fleet-scale/production-role validation remain outstanding. Unsupported older
|
||||
Agent payloads fail closed and require coordinated upgrade; this is an intentional
|
||||
protocol compatibility change, not transparent mixed-version support.
|
||||
|
||||
Stage A application containment remains a separate audited boundary. Physical
|
||||
execution is disabled; device identity binding and Blocker #3 Stages B-E remain
|
||||
outstanding. Preserve STAGE_A_OLD_AGENT_CUTOVER.txt: verified network fencing must
|
||||
precede stopping/draining/upgrading old Agents. Cached commands cannot be revoked
|
||||
by server cancellation alone. Offline/unreachable Agents stay blocked; restart,
|
||||
reconnection and rollback must not restore an unsafe executor. This candidate
|
||||
neither performs nor authorizes that cutover or any production deployment.
|
||||
|
||||
Independent-audit remediation addendum (October 16, 2026)
|
||||
-------------------------------------------------------
|
||||
The original candidate (99f515924dfc85fe1e1e98361688f1060329c090) failed independent
|
||||
review for F1-F3 in retained legacy callers. This corrected candidate requires a
|
||||
new independent audit. It does not claim Blocker #1 PASS.
|
||||
|
||||
Retired in Hub AND non-Hub modes, with HTTP 410 after existing authentication and
|
||||
permission checks:
|
||||
POST /api/miners/discover-one
|
||||
POST /api/scan-ranges/{scan_range_id}/discover-now
|
||||
POST /api/scanner/run-known-now
|
||||
POST /api/tools/legacy-summary-refresh-one
|
||||
POST /api/tools/legacy-summary-refresh-missing
|
||||
All definitions/aliases of their identity matcher, discovery writer, scheduler
|
||||
adapters, summary writer and global-IP helper reject before transport/database
|
||||
access. Startup creates no local scanner thread; status reports retired/disabled.
|
||||
Ordinary miners.update therefore cannot use discovery to replace a control board
|
||||
or clear a MAC. The dedicated board workflow and immutable MAC ownership remain.
|
||||
|
||||
Functional implication: legacy local discovery, automatic local refresh, and
|
||||
legacy one/bulk summary backfill are unavailable even outside Hub mode. Operators
|
||||
must use authenticated Agent observation/review and inventory-bound Agent polling.
|
||||
Legacy summary replies lack trustworthy identity; there is no retained unversioned
|
||||
write or in-flight read to revalidate. No denied request mutates inventory, audit,
|
||||
or telemetry. The new shared discovery architecture is preserved.
|
||||
|
||||
CSV remains an explicit global administrative workflow protected by miners.update.
|
||||
Each row requires Miner ID, current MAC, serial, or IP plus Agent Zone ID. Zone may
|
||||
come from the query/UI input or a row column; conflicting selections are rejected.
|
||||
All supplied ID/MAC/serial/IP/Site/Zone evidence must agree with current inventory.
|
||||
A failed MAC/serial/ID never falls back to global IP. Missing inventory is reported
|
||||
unmatched; contradictory or ambiguous selection returns row-specific 409, malformed
|
||||
or insufficient identification 422. A failure rolls back the entire import.
|
||||
IP-only rows need a Zone even when only one global match currently exists. Exact
|
||||
MAC/ID/serial selection remains usable by global administrators across Sites.
|
||||
CSV updates only model/type/firmware, never ownership, IP, MAC or telemetry.
|
||||
Duplicate columns are rejected rather than silently using the last value.
|
||||
|
||||
CSV acquires the existing discovery lock order, including the miners table lock,
|
||||
before resolving any row. It holds the locks through metadata changes and one
|
||||
atomic audit containing every changed inventory ID/namespace and old/new values.
|
||||
Lock contention is bounded by existing discovery timeouts; large fleet throughput
|
||||
and actual deployed-role privileges still require independent release validation.
|
||||
Preview uses identical identification checks and rolls back. Repeated imports do
|
||||
not create inventory or telemetry; each applied request retains its import audit.
|
||||
No migration or containment/control policy changes are part of this remediation.
|
||||
|
||||
Regression entrypoint: scripts/test-discovery-security.sh now includes
|
||||
tests.test_legacy_inventory_security_postgres
|
||||
with registered HTTP requests, actual PostgreSQL locks/concurrent ownership moves,
|
||||
actual audit INSERT denial rollback, retired helper/alias coverage, both modes,
|
||||
permission boundaries, ambiguous/conflicting CSV selection, legitimate imports,
|
||||
preview/replay, and retained historical snapshots. All devices are simulated or
|
||||
transport-patched; the runner uses an internal network and disposable database.
|
||||
Full execution evidence and integrity manifest are retained outside the candidate
|
||||
in the remediation evidence directory referenced by the final report.
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run ONLY from the isolated review candidate. No .env files or published DB ports.
|
||||
set -euo pipefail
|
||||
review_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
: "${MINEROPS_REVIEW_BACKEND_IMAGE:?Set an existing Python backend image with requirements installed}"
|
||||
docker image inspect "$MINEROPS_REVIEW_BACKEND_IMAGE" >/dev/null
|
||||
review_id="minerops-discovery-review-$$-$RANDOM"
|
||||
review_network="$review_id-network"
|
||||
review_db="$review_id-postgres"
|
||||
cleanup() {
|
||||
docker rm -f "$review_db" >/dev/null 2>&1 || true
|
||||
docker network rm "$review_network" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
docker network create --internal "$review_network" >/dev/null
|
||||
docker run -d --name "$review_db" --network "$review_network" --network-alias postgres \
|
||||
--tmpfs /var/lib/postgresql/data \
|
||||
-e POSTGRES_DB=discovery_review -e POSTGRES_USER=reviewer \
|
||||
-e POSTGRES_PASSWORD=disposable-review-only postgres:16-alpine >/dev/null
|
||||
review_ready=0
|
||||
for _ in $(seq 1 40); do
|
||||
if docker exec "$review_db" pg_isready -U reviewer -d discovery_review >/dev/null 2>&1; then review_ready=1; break; fi
|
||||
sleep 0.5
|
||||
done
|
||||
[[ "$review_ready" == 1 ]]
|
||||
run_backend() {
|
||||
docker run --rm --network "$review_network" -v "$review_root:/review:ro" \
|
||||
-w /review/backend -e PYTHONDONTWRITEBYTECODE=1 -e PYTHONPATH=/review/backend \
|
||||
-e POSTGRES_HOST=postgres -e POSTGRES_DB=discovery_review -e POSTGRES_USER=reviewer \
|
||||
-e POSTGRES_PASSWORD=disposable-review-only -e JWT_SECRET_KEY=disposable-review-only \
|
||||
-e MINEROPS_DISCOVERY_DISPOSABLE=1 -e MINEROPS_HUB_MODE=true \
|
||||
-e SCHEDULED_SCAN_ENABLED=false -e MINEROPS_WINDOWS_AGENT_SOURCE=/review/agents/windows \
|
||||
"$MINEROPS_REVIEW_BACKEND_IMAGE" "$@"
|
||||
}
|
||||
run_backend alembic upgrade head
|
||||
run_backend python -m unittest -v tests.test_discovery_security_postgres \
|
||||
tests.test_agent_discovery_isolation_postgres \
|
||||
tests.test_legacy_inventory_security_postgres
|
||||
# Browser and PowerShell suites use their own intercepted/loopback transports.
|
||||
# See docs/operations/BLOCKER_1_DISCOVERY_REVIEW.txt for exact commands and baseline exceptions.
|
||||
Reference in new issue
Block a user