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
@@ -2,7 +2,10 @@
|
|||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
[string]$IpAddress,
|
[string]$IpAddress,
|
||||||
|
|
||||||
[string]$MinerId = ""
|
[string]$MinerId = "",
|
||||||
|
[string]$DiscoveryId = "",
|
||||||
|
[string]$ObservationVersion = "",
|
||||||
|
[string]$CommandId = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
|
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
|
||||||
@@ -164,6 +167,14 @@ $hubUrl = ([string]$config.hub_url).TrimEnd("/")
|
|||||||
$secret = [string]$config.agent_secret
|
$secret = [string]$config.agent_secret
|
||||||
$agentId = [string]$config.agent_id
|
$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 = @(
|
$commands = @(
|
||||||
"summary",
|
"summary",
|
||||||
"devs",
|
"devs",
|
||||||
@@ -219,6 +230,9 @@ if (!$model) {
|
|||||||
$payload = [ordered]@{
|
$payload = [ordered]@{
|
||||||
ip_address = $IpAddress
|
ip_address = $IpAddress
|
||||||
miner_id = $MinerId
|
miner_id = $MinerId
|
||||||
|
discovery_id = $DiscoveryId
|
||||||
|
version = $ObservationVersion
|
||||||
|
command_id = $CommandId
|
||||||
mac_address = $mac
|
mac_address = $mac
|
||||||
hostname = $hostname
|
hostname = $hostname
|
||||||
miner_sn = $minerSn
|
miner_sn = $minerSn
|
||||||
|
|||||||
@@ -58,6 +58,21 @@ function Invoke-AgentApi {
|
|||||||
"X-MinerOps-Agent-ID" = [string]$config.agent_id
|
"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) {
|
if ($Body -ne $null) {
|
||||||
$json = $Body | ConvertTo-Json -Depth 100 -Compress
|
$json = $Body | ConvertTo-Json -Depth 100 -Compress
|
||||||
return Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers -ContentType "application/json" -Body $json -TimeoutSec 180
|
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]@{
|
$result = [ordered]@{
|
||||||
miner_id = [string]$Miner.miner_id
|
miner_id = [string]$Miner.miner_id
|
||||||
|
miner_version = [string]$Miner.miner_version
|
||||||
ip_address = [string]$Miner.ip_address
|
ip_address = [string]$Miner.ip_address
|
||||||
mac_address = [string]$Miner.mac_address
|
mac_address = [string]$Miner.mac_address
|
||||||
room_name = [string]$Miner.room_name
|
room_name = [string]$Miner.room_name
|
||||||
@@ -592,7 +608,7 @@ public static class MinerOpsFastScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Discover-MinersInRanges {
|
function Discover-MinersInRanges {
|
||||||
$config = Read-AgentConfig
|
$config = Invoke-AgentApi -Method "GET" -Path "/api/agent/config"
|
||||||
$ranges = @($config.ip_ranges | Where-Object { $_.enabled -eq $true })
|
$ranges = @($config.ip_ranges | Where-Object { $_.enabled -eq $true })
|
||||||
|
|
||||||
Ensure-FastScannerLoaded
|
Ensure-FastScannerLoaded
|
||||||
@@ -1015,7 +1031,12 @@ function Process-PendingCommands {
|
|||||||
|
|
||||||
Write-AgentLog "Running Enrich / Match for discovery IP $ipAddress"
|
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) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "EnrichOneMiner failed for $ipAddress. Output: $out"
|
throw "EnrichOneMiner failed for $ipAddress. Output: $out"
|
||||||
|
|||||||
@@ -224,7 +224,9 @@ foreach ($miner in $miners) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$items.Add([pscustomobject]@{
|
$items.Add([pscustomobject]@{
|
||||||
ip = $ip
|
miner_id = [string]$miner.miner_id
|
||||||
|
miner_version = [string]$miner.miner_version
|
||||||
|
ip = $ip
|
||||||
mac = $mac
|
mac = $mac
|
||||||
api3_supported = $false
|
api3_supported = $false
|
||||||
api3_error = $err
|
api3_error = $err
|
||||||
@@ -257,6 +259,8 @@ foreach ($miner in $miners) {
|
|||||||
-Request @{ cmd = "get.device.custom_data" }
|
-Request @{ cmd = "get.device.custom_data" }
|
||||||
|
|
||||||
$items.Add([pscustomobject]@{
|
$items.Add([pscustomobject]@{
|
||||||
|
miner_id = [string]$miner.miner_id
|
||||||
|
miner_version = [string]$miner.miner_version
|
||||||
ip = $ip
|
ip = $ip
|
||||||
mac = $mac
|
mac = $mac
|
||||||
api3_supported = $true
|
api3_supported = $true
|
||||||
@@ -275,15 +279,14 @@ $payload = @{
|
|||||||
items = @($items.ToArray())
|
items = @($items.ToArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
$json = $payload | ConvertTo-Json -Depth 80
|
$allItems = @($items.ToArray())
|
||||||
|
$result = @()
|
||||||
Write-Host "Posting API3 enrichment results to hub..."
|
for ($offset = 0; $offset -lt $allItems.Count; $offset += 128) {
|
||||||
$result = Invoke-RestMethod `
|
$last = [Math]::Min($offset + 127, $allItems.Count - 1)
|
||||||
-Uri "$HubUrl/api/agent/api3-enrichment" `
|
$payload.items = @($allItems[$offset..$last])
|
||||||
-Method Post `
|
$result += Invoke-RestMethod -Uri "$HubUrl/api/agent/api3-enrichment" -Method Post `
|
||||||
-Headers $Headers `
|
-Headers $Headers -ContentType "application/json" -Body ($payload | ConvertTo-Json -Depth 80)
|
||||||
-ContentType "application/json" `
|
}
|
||||||
-Body $json
|
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Local checked: $checked"
|
Write-Host "Local checked: $checked"
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ foreach ($miner in $knownMiners) {
|
|||||||
if ($pools.Count -gt 0) {
|
if ($pools.Count -gt 0) {
|
||||||
$poolSnapshots.Add([pscustomobject]@{
|
$poolSnapshots.Add([pscustomobject]@{
|
||||||
miner_id = $miner.miner_id
|
miner_id = $miner.miner_id
|
||||||
|
miner_version = $miner.miner_version
|
||||||
ip_address = $ip
|
ip_address = $ip
|
||||||
mac_address = $miner.mac_address
|
mac_address = $miner.mac_address
|
||||||
model = $miner.model
|
model = $miner.model
|
||||||
@@ -130,18 +131,14 @@ foreach ($miner in $knownMiners) {
|
|||||||
|
|
||||||
Write-Host "Pool snapshots collected: $($poolSnapshots.Count)"
|
Write-Host "Pool snapshots collected: $($poolSnapshots.Count)"
|
||||||
|
|
||||||
$body = @{
|
$allItems = @($poolSnapshots.ToArray())
|
||||||
agent_id = $AgentId
|
$result = @()
|
||||||
pool_snapshots = $poolSnapshots
|
for ($offset = 0; $offset -lt $allItems.Count; $offset += 128) {
|
||||||
} | ConvertTo-Json -Depth 20
|
$last = [Math]::Min($offset + 127, $allItems.Count - 1)
|
||||||
|
$body = @{agent_id=$AgentId; pool_snapshots=@($allItems[$offset..$last])} | ConvertTo-Json -Depth 80
|
||||||
$result = Invoke-RestMethod `
|
$result += Invoke-RestMethod -Method Post -Uri "$HubUrl/api/agent/pool-snapshots" `
|
||||||
-Method Post `
|
-Headers $Headers -Body $body -ContentType "application/json" -TimeoutSec 180
|
||||||
-Uri "$HubUrl/api/agent/pool-snapshots" `
|
}
|
||||||
-Headers $Headers `
|
|
||||||
-Body $body `
|
|
||||||
-ContentType "application/json" `
|
|
||||||
-TimeoutSec 180
|
|
||||||
|
|
||||||
$result | ConvertTo-Json -Depth 10
|
$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.exceptions import AgentCommandConflict, AgentCommandNotFound
|
||||||
from app.agent_commands.policy import READ_ONLY_COMMAND_TYPES, rejection_execution_outcome, rejection_reason
|
from app.agent_commands.policy import READ_ONLY_COMMAND_TYPES, rejection_execution_outcome, rejection_reason
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.discovery.repository import DiscoveryRepository
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
||||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||||
@@ -120,9 +122,29 @@ class AgentCommandRepository:
|
|||||||
connection = self._connection_factory()
|
connection = self._connection_factory()
|
||||||
try:
|
try:
|
||||||
with connection.cursor(row_factory=dict_row) as cur:
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
DiscoveryRepository.lock(cur)
|
||||||
self._retire_unsupported(cur, agent_id)
|
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))
|
cur.execute(CLAIM_COMMANDS_SQL, (agent_id, list(READ_ONLY_COMMAND_TYPES), limit))
|
||||||
rows = [dict(row) for row in cur.fetchall()]
|
rows = [dict(row) for row in cur.fetchall()]
|
||||||
|
for row in rows:
|
||||||
|
discovery.validate_command(cur,agent_id,row)
|
||||||
connection.commit()
|
connection.commit()
|
||||||
return rows
|
return rows
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ class AgentZoneRoutingContext(BaseModel):
|
|||||||
routing_reason: str | None = None
|
routing_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentDiscoveryMinerMatch(AgentZoneRoutingContext):
|
||||||
|
miner_id: UUID | None = None
|
||||||
|
matched_by: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class MinerZoneAssociationResult(BaseModel):
|
class MinerZoneAssociationResult(BaseModel):
|
||||||
agent_zone_id: UUID | None = None
|
agent_zone_id: UUID | None = None
|
||||||
routing_reason: str | None = None
|
routing_reason: str | None = None
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ class AgentZoneRepository:
|
|||||||
ip_address = EXCLUDED.ip_address,
|
ip_address = EXCLUDED.ip_address,
|
||||||
last_checkin_at = now(),
|
last_checkin_at = now(),
|
||||||
last_payload = EXCLUDED.last_payload
|
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,
|
agent_id,
|
||||||
@@ -216,6 +218,8 @@ class AgentZoneRepository:
|
|||||||
Jsonb(payload),
|
Jsonb(payload),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if cur.rowcount != 1:
|
||||||
|
raise AgentZoneConflict("Agent assignment changed concurrently")
|
||||||
connection.commit()
|
connection.commit()
|
||||||
except UniqueViolation as exc:
|
except UniqueViolation as exc:
|
||||||
connection.rollback()
|
connection.rollback()
|
||||||
@@ -294,6 +298,20 @@ class AgentZoneRepository:
|
|||||||
finally:
|
finally:
|
||||||
connection.close()
|
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(
|
def assign_miners_to_zone(
|
||||||
self, miner_ids: Iterable[UUID], agent_zone_id: UUID
|
self, miner_ids: Iterable[UUID], agent_zone_id: UUID
|
||||||
) -> dict[UUID, str]:
|
) -> dict[UUID, str]:
|
||||||
@@ -326,15 +344,7 @@ class AgentZoneRepository:
|
|||||||
elif miner["site_id"] not in {None, zone["site_id"]}:
|
elif miner["site_id"] not in {None, zone["site_id"]}:
|
||||||
outcomes[miner_id] = "site_conflict"
|
outcomes[miner_id] = "site_conflict"
|
||||||
else:
|
else:
|
||||||
cur.execute(
|
outcomes[miner_id] = "miner_zone_unassigned"
|
||||||
"""
|
|
||||||
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"
|
|
||||||
connection.commit()
|
connection.commit()
|
||||||
return outcomes
|
return outcomes
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from app.agent_zones.exceptions import (
|
|||||||
from app.agent_zones.models import (
|
from app.agent_zones.models import (
|
||||||
AgentCheckinRequest,
|
AgentCheckinRequest,
|
||||||
AgentCheckinResponse,
|
AgentCheckinResponse,
|
||||||
|
AgentDiscoveryMinerMatch,
|
||||||
AgentZoneRoutingContext,
|
AgentZoneRoutingContext,
|
||||||
AgentZoneCreateRequest,
|
AgentZoneCreateRequest,
|
||||||
AgentZoneSummary,
|
AgentZoneSummary,
|
||||||
@@ -81,6 +82,8 @@ class AgentZoneService:
|
|||||||
self.validate_agent_identity(authenticated_agent_id, request.agent_id)
|
self.validate_agent_identity(authenticated_agent_id, request.agent_id)
|
||||||
|
|
||||||
existing = self.repository.get_agent(authenticated_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_site = request.site_id
|
||||||
requested_zone = request.agent_zone_id
|
requested_zone = request.agent_zone_id
|
||||||
final_site = requested_site or (existing or {}).get("site_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(
|
def associate_miners_from_agent(
|
||||||
self, agent_id: str, miner_ids: list[UUID]
|
self, agent_id: str, miner_ids: list[UUID]
|
||||||
) -> MinerZoneAssociationResult:
|
) -> 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/pending-disabled-old",
|
||||||
"/api/agent/commands/result-disabled-old",
|
"/api/agent/commands/result-disabled-old",
|
||||||
"/api/agent/discoveries",
|
"/api/agent/discoveries",
|
||||||
|
"/api/agent/discovery-target",
|
||||||
"/api/agent/miner-enrichment",
|
"/api/agent/miner-enrichment",
|
||||||
"/api/agent/miner-enrichment-v2",
|
"/api/agent/miner-enrichment-v2",
|
||||||
"/api/agent/miner-enrichment-v3",
|
"/api/agent/miner-enrichment-v3",
|
||||||
@@ -826,6 +827,8 @@ def required_api_permission(method: str, path: str) -> str | None:
|
|||||||
return "repairs.complete"
|
return "repairs.complete"
|
||||||
if method == "GET" and repair_timeline_request(path):
|
if method == "GET" and repair_timeline_request(path):
|
||||||
return "repairs.read"
|
return "repairs.read"
|
||||||
|
if method == "GET" and path in {"/api/agent/discoveries/summary", "/api/agent/status-summary"}:
|
||||||
|
return "agents.view"
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
if path.startswith("/api/system/"):
|
if path.startswith("/api/system/"):
|
||||||
return "system_health.view"
|
return "system_health.view"
|
||||||
|
|||||||
+199
-3942
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):
|
class AgentTransportIsolationSourceTests(unittest.TestCase):
|
||||||
def test_known_miners_and_pool_snapshots_are_agent_zone_scoped(self):
|
def test_known_miners_and_pool_snapshots_are_agent_zone_scoped(self):
|
||||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
from app.main import minerops_agent_known_miners, agent_pool_snapshots
|
||||||
identity = (Path(__file__).parents[1] / "app" / "identity.py").read_text()
|
from inspect import getsource
|
||||||
|
for fn,method in [(minerops_agent_known_miners,"known"),(agent_pool_snapshots,"snapshots")]:
|
||||||
known_start = main.index('@app.get("/api/agent/known-miners")')
|
source=getsource(fn)
|
||||||
known_end = main.index("# Phase 5J:", known_start)
|
self.assertIn("Depends(require_agent_identity)",source)
|
||||||
known = main[known_start:known_end]
|
self.assertIn("discovery_dispatch",source)
|
||||||
|
self.assertIn("agent_id=authenticated_agent_id",source)
|
||||||
pool_start = main.index('@app.post("/api/agent/pool-snapshots")')
|
|
||||||
pool_end = main.index("\n@app.", pool_start + 1)
|
|
||||||
pool = main[pool_start:pool_end]
|
|
||||||
|
|
||||||
self.assertIn('"/api/agent/known-miners"', identity)
|
|
||||||
|
|
||||||
self.assertIn("Depends(require_agent_identity)", known)
|
|
||||||
self.assertIn("zone_service.routing_context", known)
|
|
||||||
self.assertIn("miners.agent_zone_id = $1::uuid", known)
|
|
||||||
self.assertNotIn("minerops_require_agent_secret", known)
|
|
||||||
|
|
||||||
self.assertIn("Depends(require_agent_identity)", pool)
|
|
||||||
self.assertIn("zone_service.routing_context", pool)
|
|
||||||
self.assertIn("agent_id = authenticated_agent_id", pool)
|
|
||||||
self.assertIn("agent_zone_id = %s", pool)
|
|
||||||
self.assertNotIn('body.get("agent_id")', pool)
|
|
||||||
self.assertNotIn("MINEROPS_AGENT_SHARED_SECRET", pool)
|
|
||||||
|
|
||||||
def test_windows_machine_pollers_send_agent_identity(self):
|
def test_windows_machine_pollers_send_agent_identity(self):
|
||||||
root = agent_source_root()
|
root = agent_source_root()
|
||||||
@@ -307,67 +290,21 @@ class AgentTransportIsolationSourceTests(unittest.TestCase):
|
|||||||
|
|
||||||
class AgentEnrichmentIsolationSourceTests(unittest.TestCase):
|
class AgentEnrichmentIsolationSourceTests(unittest.TestCase):
|
||||||
def test_enrichment_routes_require_agent_identity_and_zone_scope(self):
|
def test_enrichment_routes_require_agent_identity_and_zone_scope(self):
|
||||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
from app import main
|
||||||
|
from inspect import getsource
|
||||||
decorators = (
|
for fn in [main.minerops_agent_miner_enrichment,main.minerops_agent_miner_enrichment_v2,main.minerops_agent_miner_enrichment_v3]:
|
||||||
'@app.post("/api/agent/miner-enrichment")',
|
source=getsource(fn)
|
||||||
'@app.post("/api/agent/miner-enrichment-v2")',
|
self.assertIn("Depends(require_agent_identity)",source)
|
||||||
'@app.post("/api/agent/miner-enrichment-v3")',
|
self.assertIn("discovery_dispatch",source)
|
||||||
)
|
self.assertIn("agent_id=authenticated_agent_id",source)
|
||||||
|
|
||||||
for decorator in decorators:
|
|
||||||
with self.subTest(decorator=decorator):
|
|
||||||
start = main.index(decorator)
|
|
||||||
end = main.index("\n@app.", start + 1)
|
|
||||||
route = main[start:end]
|
|
||||||
|
|
||||||
self.assertIn("Depends(require_agent_identity)", route)
|
|
||||||
self.assertIn("zone_service.routing_context", route)
|
|
||||||
self.assertIn("agent_zone_id", route)
|
|
||||||
self.assertNotIn("minerops_require_agent_secret", route)
|
|
||||||
|
|
||||||
v2_start = main.index('@app.post("/api/agent/miner-enrichment-v2")')
|
|
||||||
v2_end = main.index("\n@app.", v2_start + 1)
|
|
||||||
v2 = main[v2_start:v2_end]
|
|
||||||
self.assertIn(
|
|
||||||
"AND agent_zone_id = $2::uuid",
|
|
||||||
v2,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
"AND agent_zone_id = $3::uuid",
|
|
||||||
v2,
|
|
||||||
)
|
|
||||||
|
|
||||||
v3_start = main.index('@app.post("/api/agent/miner-enrichment-v3")')
|
|
||||||
v3_end = main.index("\n@app.", v3_start + 1)
|
|
||||||
v3 = main[v3_start:v3_end]
|
|
||||||
self.assertIn(
|
|
||||||
"AND agent_zone_id = $8::uuid",
|
|
||||||
v3,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
"AND agent_zone_id = $7::uuid",
|
|
||||||
v3,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_api3_uses_authenticated_identity_and_never_global_agent_identity(self):
|
def test_api3_uses_authenticated_identity_and_never_global_agent_identity(self):
|
||||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
from app.main import minerops_agent_api3_enrichment
|
||||||
|
from inspect import getsource
|
||||||
start = main.index('@app.post("/api/agent/api3-enrichment")')
|
source=getsource(minerops_agent_api3_enrichment)
|
||||||
end = main.index("\n@app.", start + 1)
|
self.assertIn("Depends(require_agent_identity)",source)
|
||||||
route = main[start:end]
|
self.assertIn("discovery_dispatch",source)
|
||||||
|
self.assertIn("agent_id=authenticated_agent_id",source)
|
||||||
self.assertIn("Depends(require_agent_identity)", route)
|
|
||||||
self.assertIn("zone_service.routing_context", route)
|
|
||||||
self.assertIn("agent_id = authenticated_agent_id", route)
|
|
||||||
self.assertIn("agent_zone_id = %s", route)
|
|
||||||
self.assertIn("if miner_id is None:", route)
|
|
||||||
self.assertIn("continue", route)
|
|
||||||
|
|
||||||
self.assertNotIn("MINEROPS_AGENT_SHARED_SECRET", route)
|
|
||||||
self.assertNotIn('body.get("agent_id")', route)
|
|
||||||
self.assertNotIn('body.get("site_name")', route)
|
|
||||||
self.assertNotIn('request.headers.get("x-agent-secret")', route)
|
|
||||||
|
|
||||||
def test_legacy_config_remains_explicitly_legacy_only(self):
|
def test_legacy_config_remains_explicitly_legacy_only(self):
|
||||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
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.agent = None
|
||||||
self.saved = []
|
self.saved = []
|
||||||
self.assign_result = {MINER_A: "assigned"}
|
self.assign_result = {MINER_A: "assigned"}
|
||||||
|
self.discovery_match = None
|
||||||
|
|
||||||
def get_agent(self, agent_id):
|
def get_agent(self, agent_id):
|
||||||
return self.agent if self.agent and self.agent["agent_id"] == agent_id else None
|
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):
|
def assign_miners_to_zone(self, miner_ids, agent_zone_id):
|
||||||
self.assigned = (list(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")
|
return {miner_id: self.assign_result.get(miner_id, "miner_not_found")
|
||||||
@@ -136,6 +142,7 @@ class AgentZoneModelTests(unittest.TestCase):
|
|||||||
class AgentZoneServiceTests(unittest.TestCase):
|
class AgentZoneServiceTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.repository = FakeRepository()
|
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.service = AgentZoneService(self.repository)
|
||||||
|
|
||||||
def checkin(self, **kwargs):
|
def checkin(self, **kwargs):
|
||||||
@@ -144,14 +151,14 @@ class AgentZoneServiceTests(unittest.TestCase):
|
|||||||
AgentCheckinRequest(agent_id="agent-a", site_name="North", **kwargs),
|
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)
|
first = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||||
second = 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.assertTrue(first.routing_available)
|
||||||
self.assertEqual(second.agent_zone_id, ZONE_A)
|
self.assertEqual(second.agent_zone_id, ZONE_A)
|
||||||
|
|
||||||
def test_explicit_zone_site_mismatch_and_identity_spoof_are_rejected(self):
|
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)
|
self.checkin(site_id=SITE_B, agent_zone_id=ZONE_A)
|
||||||
with self.assertRaisesRegex(AgentZoneValidationError, "authenticated"):
|
with self.assertRaisesRegex(AgentZoneValidationError, "authenticated"):
|
||||||
self.service.checkin(
|
self.service.checkin(
|
||||||
@@ -181,34 +188,20 @@ class AgentZoneServiceTests(unittest.TestCase):
|
|||||||
self.assertEqual(result.agent_zone_id, ZONE_A)
|
self.assertEqual(result.agent_zone_id, ZONE_A)
|
||||||
|
|
||||||
def test_transitional_resolution_requires_exactly_one_enabled_zone(self):
|
def test_transitional_resolution_requires_exactly_one_enabled_zone(self):
|
||||||
single = self.checkin(site_id=SITE_A)
|
for zones in [{}, {ZONE_A: zone()}, {ZONE_A:zone(),ZONE_B:zone(ZONE_B,SITE_A)}]:
|
||||||
self.assertEqual(single.agent_zone_id, ZONE_A)
|
self.repository.zones=zones
|
||||||
self.repository.agent = None
|
self.repository.agent=None
|
||||||
self.repository.zones = {}
|
with self.assertRaisesRegex(AgentZoneValidationError,"administrator-established"):
|
||||||
zero = self.checkin(site_id=SITE_A)
|
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)
|
|
||||||
|
|
||||||
def test_disabled_zone_is_not_selected_and_existing_assignment_is_factual(self):
|
def test_disabled_zone_is_not_selected_and_existing_assignment_is_factual(self):
|
||||||
self.repository.zones[ZONE_A]["is_enabled"] = False
|
self.repository.zones[ZONE_A]["is_enabled"]=False
|
||||||
unassigned = self.checkin(site_id=SITE_A)
|
existing=self.service.checkin("agent-a",AgentCheckinRequest(agent_id="agent-a"))
|
||||||
self.assertEqual(unassigned.routing_reason, "agent_zone_unavailable")
|
self.assertEqual(existing.routing_reason,"agent_zone_disabled")
|
||||||
self.repository.agent = {
|
self.assertEqual(existing.agent_zone_id,ZONE_A)
|
||||||
"agent_id": "agent-a", "site_id": SITE_A,
|
self.repository.agent=None
|
||||||
"agent_zone_id": ZONE_A, "site_name": "North",
|
with self.assertRaisesRegex(AgentZoneValidationError,"administrator-established"):
|
||||||
}
|
self.checkin(site_id=SITE_A)
|
||||||
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)
|
|
||||||
|
|
||||||
def test_payload_is_sanitized_before_persistence(self):
|
def test_payload_is_sanitized_before_persistence(self):
|
||||||
request = AgentCheckinRequest.model_validate({
|
request = AgentCheckinRequest.model_validate({
|
||||||
@@ -255,9 +248,41 @@ class AgentZoneServiceTests(unittest.TestCase):
|
|||||||
self.assertNotIn("10.80.0.0/24", str(configuration))
|
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):
|
class AgentZoneRouterTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.repository = FakeRepository()
|
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.service = AgentZoneService(self.repository)
|
||||||
self.app = FastAPI()
|
self.app = FastAPI()
|
||||||
self.app.include_router(router)
|
self.app.include_router(router)
|
||||||
|
|||||||
@@ -196,12 +196,12 @@ class AgentZonePostgresTests(unittest.TestCase):
|
|||||||
connection.rollback()
|
connection.rollback()
|
||||||
connection.close()
|
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)
|
repository = AgentZoneRepository(connect)
|
||||||
outcomes = repository.assign_miners_to_zone(
|
outcomes = repository.assign_miners_to_zone(
|
||||||
[MINER_UNASSIGNED, MINER_OTHER], ZONE_A
|
[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")
|
self.assertEqual(outcomes[MINER_OTHER], "agent_zone_conflict")
|
||||||
connection = connect()
|
connection = connect()
|
||||||
try:
|
try:
|
||||||
@@ -213,7 +213,7 @@ class AgentZonePostgresTests(unittest.TestCase):
|
|||||||
assignments = dict(cur.fetchall())
|
assignments = dict(cur.fetchall())
|
||||||
finally:
|
finally:
|
||||||
connection.close()
|
connection.close()
|
||||||
self.assertEqual(assignments[MINER_UNASSIGNED], ZONE_A)
|
self.assertIsNone(assignments[MINER_UNASSIGNED])
|
||||||
self.assertEqual(assignments[MINER_OTHER], ZONE_B)
|
self.assertEqual(assignments[MINER_OTHER], ZONE_B)
|
||||||
|
|
||||||
def test_disabled_zone_blocks_control_without_site_fallback(self):
|
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), [])
|
self.assertEqual(self.audits(command_id), [])
|
||||||
|
|
||||||
def test_read_only_commands_still_claim_and_complete(self):
|
def test_read_only_commands_still_claim_and_complete(self):
|
||||||
for kind in ("poll_known_miners_now", "discover_new_miners", "enrich_match_discovery"):
|
from uuid import uuid4
|
||||||
self.seed(f"stage-a-read-{kind}", command_type=kind, payload={"ip_address": "192.0.2.1"})
|
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)
|
commands = self.service.claim(AGENT)
|
||||||
self.assertEqual(len(commands), 3)
|
self.assertEqual(len(commands), 3)
|
||||||
for command in commands:
|
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