security(discovery): enforce Zone isolation and retire unsafe legacy writers
Bind Agent observations, review, enrichment and telemetry to current Zone
and inventory authority. Preserve immutable MAC ownership and historical
snapshots with stale-result rejection and transactional security audits.
Retire unbound direct/range/scheduled discovery and one/bulk legacy summary
writes in both modes. Require reconciled exact inventory identity or
explicit Zone-scoped IP selection for CSV metadata imports.
Independent Blocker #1 application-security re-audit: PASS on tree
cf863aadc9. Evidence: 141 focused PostgreSQL/HTTP
tests passed; Chromium and PowerShell checks passed. Two documented baseline
source assertions remain unchanged. Production deployment is not approved.
This commit is contained in:
1 parent
e898f283ab
commit
8b0d32cd6b
26 files changed
+3798
-4092
No files matched your search
@@ -10,6 +10,8 @@ from psycopg.types.json import Jsonb
|
||||
from app.agent_commands.exceptions import AgentCommandConflict, AgentCommandNotFound
|
||||
from app.agent_commands.policy import READ_ONLY_COMMAND_TYPES, rejection_execution_outcome, rejection_reason
|
||||
from app.config import settings
|
||||
from app.discovery.repository import DiscoveryRepository
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
@@ -120,9 +122,29 @@ class AgentCommandRepository:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
DiscoveryRepository.lock(cur)
|
||||
self._retire_unsupported(cur, agent_id)
|
||||
discovery = DiscoveryRepository(self._connection_factory)
|
||||
cur.execute(f"""SELECT q.*, ({NO_DELIVERY_EVIDENCE_SQL}) AS no_delivery_evidence
|
||||
FROM minerops_agent_command_queue q WHERE agent_id=%s
|
||||
AND command_status='pending' AND command_type=ANY(%s)
|
||||
ORDER BY created_at,id FOR UPDATE SKIP LOCKED LIMIT 1000""",
|
||||
(agent_id,list(READ_ONLY_COMMAND_TYPES)))
|
||||
pending=list(cur.fetchall())
|
||||
for command in pending:
|
||||
try:
|
||||
discovery.validate_command(cur,agent_id,command)
|
||||
except HTTPException as exc:
|
||||
cur.execute("""UPDATE minerops_agent_command_queue SET command_status='cancelled',
|
||||
completed_at=COALESCE(completed_at,now()), error_message=concat_ws(chr(10),NULLIF(error_message,''),%s::text)
|
||||
WHERE id=%s""",(exc.detail,command['id']))
|
||||
DiscoveryRepository.audit(cur,'discovery.command_rejected',None,
|
||||
{'command_id':command['id'],'agent_id':agent_id,'reason':exc.detail,
|
||||
'execution_outcome':rejection_execution_outcome({**command,'previous_status':'pending'})})
|
||||
cur.execute(CLAIM_COMMANDS_SQL, (agent_id, list(READ_ONLY_COMMAND_TYPES), limit))
|
||||
rows = [dict(row) for row in cur.fetchall()]
|
||||
for row in rows:
|
||||
discovery.validate_command(cur,agent_id,row)
|
||||
connection.commit()
|
||||
return rows
|
||||
except Exception:
|
||||
|
||||
@@ -108,6 +108,11 @@ class AgentZoneRoutingContext(BaseModel):
|
||||
routing_reason: str | None = None
|
||||
|
||||
|
||||
class AgentDiscoveryMinerMatch(AgentZoneRoutingContext):
|
||||
miner_id: UUID | None = None
|
||||
matched_by: str | None = None
|
||||
|
||||
|
||||
class MinerZoneAssociationResult(BaseModel):
|
||||
agent_zone_id: UUID | None = None
|
||||
routing_reason: str | None = None
|
||||
|
||||
@@ -204,6 +204,8 @@ class AgentZoneRepository:
|
||||
ip_address = EXCLUDED.ip_address,
|
||||
last_checkin_at = now(),
|
||||
last_payload = EXCLUDED.last_payload
|
||||
WHERE minerops_site_agents.site_id IS NOT DISTINCT FROM EXCLUDED.site_id
|
||||
AND minerops_site_agents.agent_zone_id IS NOT DISTINCT FROM EXCLUDED.agent_zone_id
|
||||
""",
|
||||
(
|
||||
agent_id,
|
||||
@@ -216,6 +218,8 @@ class AgentZoneRepository:
|
||||
Jsonb(payload),
|
||||
),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
raise AgentZoneConflict("Agent assignment changed concurrently")
|
||||
connection.commit()
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
@@ -294,6 +298,20 @@ class AgentZoneRepository:
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def find_miner_in_zone(self, *, agent_id: str, agent_zone_id: UUID,
|
||||
mac_address: str | None, ip_address: str | None) -> dict | None:
|
||||
"""Retained helper uses the same atomic authority/collision boundary as HTTP."""
|
||||
from app.discovery.repository import DiscoveryRepository, reject
|
||||
repository = DiscoveryRepository(self._connection_factory)
|
||||
with repository.transaction() as cur:
|
||||
context = repository.context(cur, agent_id)
|
||||
if context['agent_zone_id'] != agent_zone_id:
|
||||
reject('discovery_stale_authority')
|
||||
if not mac_address and not ip_address:
|
||||
return None
|
||||
row = repository.match(cur, context, {'mac_address': mac_address, 'ip_address': ip_address})
|
||||
return {'id': row['id'], 'matched_by': 'mac' if mac_address else 'ip'} if row else None
|
||||
|
||||
def assign_miners_to_zone(
|
||||
self, miner_ids: Iterable[UUID], agent_zone_id: UUID
|
||||
) -> dict[UUID, str]:
|
||||
@@ -326,15 +344,7 @@ class AgentZoneRepository:
|
||||
elif miner["site_id"] not in {None, zone["site_id"]}:
|
||||
outcomes[miner_id] = "site_conflict"
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE miners
|
||||
SET site_id = %s, agent_zone_id = %s, updated_at = now()
|
||||
WHERE id = %s AND agent_zone_id IS NULL
|
||||
""",
|
||||
(zone["site_id"], agent_zone_id, miner_id),
|
||||
)
|
||||
outcomes[miner_id] = "assigned"
|
||||
outcomes[miner_id] = "miner_zone_unassigned"
|
||||
connection.commit()
|
||||
return outcomes
|
||||
except Exception:
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.agent_zones.exceptions import (
|
||||
from app.agent_zones.models import (
|
||||
AgentCheckinRequest,
|
||||
AgentCheckinResponse,
|
||||
AgentDiscoveryMinerMatch,
|
||||
AgentZoneRoutingContext,
|
||||
AgentZoneCreateRequest,
|
||||
AgentZoneSummary,
|
||||
@@ -81,6 +82,8 @@ class AgentZoneService:
|
||||
self.validate_agent_identity(authenticated_agent_id, request.agent_id)
|
||||
|
||||
existing = self.repository.get_agent(authenticated_agent_id)
|
||||
if not existing or existing.get("agent_zone_id") is None:
|
||||
raise AgentZoneValidationError("Agent requires an administrator-established Zone assignment")
|
||||
requested_site = request.site_id
|
||||
requested_zone = request.agent_zone_id
|
||||
final_site = requested_site or (existing or {}).get("site_id")
|
||||
@@ -199,6 +202,30 @@ class AgentZoneService:
|
||||
],
|
||||
}
|
||||
|
||||
def match_discovered_miner(
|
||||
self,
|
||||
agent_id: str,
|
||||
*,
|
||||
mac_address: str | None,
|
||||
ip_address: str | None,
|
||||
recorded_zone_id: UUID | None = None,
|
||||
) -> AgentDiscoveryMinerMatch:
|
||||
routing = self.routing_context(agent_id, recorded_zone_id)
|
||||
if routing.routing_reason or routing.agent_zone_id is None:
|
||||
return AgentDiscoveryMinerMatch(**routing.model_dump())
|
||||
|
||||
match = self.repository.find_miner_in_zone(
|
||||
agent_id=agent_id,
|
||||
agent_zone_id=routing.agent_zone_id,
|
||||
mac_address=mac_address,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
return AgentDiscoveryMinerMatch(
|
||||
**routing.model_dump(),
|
||||
miner_id=match["id"] if match else None,
|
||||
matched_by=match["matched_by"] if match else None,
|
||||
)
|
||||
|
||||
def associate_miners_from_agent(
|
||||
self, agent_id: str, miner_ids: list[UUID]
|
||||
) -> MinerZoneAssociationResult:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Agent discovery isolation; physical miner writes are never authorized here."""
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Exact administrative CSV selection, inside the caller's locked transaction.
|
||||
|
||||
CSV metadata never establishes ownership or replaces board identity. Every supplied
|
||||
identity is a constraint; a failed strong identifier never falls back to an IP.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.discovery.repository import address, mac
|
||||
|
||||
|
||||
def resolve_inventory_row(cur, row, row_number, selected_zone=None):
|
||||
def fail(message, status=409):
|
||||
raise HTTPException(status_code=status, detail=f"CSV row {row_number}: {message}")
|
||||
|
||||
def field(names, normalize=lambda v: v):
|
||||
values = []
|
||||
for name in names:
|
||||
value = row.get(name)
|
||||
if value is not None and str(value).strip():
|
||||
try:
|
||||
values.append(normalize(str(value).strip()))
|
||||
except (ValueError, HTTPException):
|
||||
fail(f"invalid {names[0]}", 422)
|
||||
if len(set(values)) > 1:
|
||||
fail(f"conflicting {names[0]} columns")
|
||||
return values[0] if values else None
|
||||
|
||||
def uuid(value):
|
||||
return str(UUID(value))
|
||||
|
||||
mid = field(["Miner ID", "Miner UUID", "miner_id", "id"], uuid)
|
||||
zone = field(["Agent Zone ID", "Zone ID", "agent_zone_id", "zone_id"], uuid)
|
||||
site = field(["Site ID", "site_id"], uuid)
|
||||
ip = field(["IP", "IP Address", "ip", "ip_address"], address)
|
||||
current_mac = field(["MAC Addr", "MAC", "Mac", "mac", "mac_address"], mac)
|
||||
serial = field(["Serial", "Serial Number", "SN", "Miner SN", "miner_sn", "serial_number"])
|
||||
if selected_zone:
|
||||
try:
|
||||
selected_zone = uuid(selected_zone)
|
||||
except ValueError:
|
||||
fail("invalid selected Agent Zone ID", 422)
|
||||
if zone and zone != selected_zone:
|
||||
fail("row Zone disagrees with selected Zone")
|
||||
zone = selected_zone
|
||||
if zone:
|
||||
cur.execute("SELECT site_id::text FROM agent_zones WHERE id=%s", (zone,))
|
||||
zone_row = cur.fetchone()
|
||||
if not zone_row:
|
||||
fail("selected Zone does not exist", 422)
|
||||
if site and site != zone_row["site_id"]:
|
||||
fail("Site and Zone disagree")
|
||||
if not (mid or current_mac or serial or (ip and zone)):
|
||||
fail("provide Miner ID, current MAC, serial, or IP plus Agent Zone ID", 422)
|
||||
|
||||
selectors = []
|
||||
if mid:
|
||||
selectors.append(("id=%s::uuid", mid))
|
||||
if current_mac:
|
||||
selectors.append(("regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')=%s", current_mac.replace(":", "").lower()))
|
||||
if serial:
|
||||
selectors.append(("miner_sn=%s", serial))
|
||||
# IP is only a namespace-scoped selector when no stronger identity is supplied.
|
||||
if not selectors:
|
||||
selectors.append(("current_ip=%s::inet AND agent_zone_id=%s::uuid", (ip, zone)))
|
||||
|
||||
candidates = {}
|
||||
for predicate, value in selectors:
|
||||
params = value if isinstance(value, tuple) else (value,)
|
||||
cur.execute(
|
||||
"SELECT id::text, site_id::text, agent_zone_id::text, "
|
||||
"host(current_ip) AS current_ip, current_mac, miner_sn, model, miner_type, firmware_version "
|
||||
"FROM miners WHERE " + predicate + " ORDER BY id LIMIT 2 FOR UPDATE",
|
||||
params,
|
||||
)
|
||||
matches = cur.fetchall()
|
||||
if len(matches) > 1:
|
||||
fail("ambiguous inventory identity; supply exact Miner ID and consistent identifiers")
|
||||
candidates.update({m["id"]: m for m in matches})
|
||||
if not candidates:
|
||||
return None
|
||||
if len(candidates) != 1:
|
||||
fail("identifiers select different inventory records")
|
||||
miner = next(iter(candidates.values()))
|
||||
if (
|
||||
(mid and mid != miner["id"])
|
||||
or (zone and zone != miner["agent_zone_id"])
|
||||
or (site and site != miner["site_id"])
|
||||
or (ip and ip != miner["current_ip"])
|
||||
or (serial and serial != miner["miner_sn"])
|
||||
or (current_mac and current_mac != mac(miner["current_mac"]))
|
||||
):
|
||||
fail("identifiers disagree with current inventory; refresh selection before importing")
|
||||
return miner
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Bounded HTTP adapter; authentication remains on the registered routes."""
|
||||
|
||||
import json
|
||||
from fastapi import HTTPException
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
import psycopg
|
||||
from app.discovery.repository import DiscoveryRepository
|
||||
|
||||
MAX_BODY = 1024 * 1024
|
||||
|
||||
|
||||
async def dispatch(method, request=None, **kwargs):
|
||||
if request is not None:
|
||||
chunks = []
|
||||
size = 0
|
||||
async for chunk in request.stream():
|
||||
size += len(chunk)
|
||||
if size > MAX_BODY:
|
||||
raise HTTPException(413, "discovery_payload_too_large")
|
||||
chunks.append(chunk)
|
||||
try:
|
||||
|
||||
def invalid_constant(_):
|
||||
raise ValueError("Non-finite JSON number")
|
||||
|
||||
kwargs["body"] = json.loads(
|
||||
b"".join(chunks), parse_constant=invalid_constant
|
||||
)
|
||||
except (ValueError, UnicodeDecodeError, RecursionError):
|
||||
raise HTTPException(422, "discovery_invalid_json")
|
||||
try:
|
||||
return await run_in_threadpool(getattr(DiscoveryRepository(), method), **kwargs)
|
||||
except (
|
||||
psycopg.errors.LockNotAvailable,
|
||||
psycopg.errors.QueryCanceled,
|
||||
psycopg.errors.DeadlockDetected,
|
||||
):
|
||||
raise HTTPException(409, "discovery_concurrent_change_retry")
|
||||
except psycopg.DataError:
|
||||
raise HTTPException(422, "discovery_invalid_value")
|
||||
except psycopg.IntegrityError:
|
||||
raise HTTPException(409, "discovery_identity_conflict")
|
||||
File diff suppressed because it is too large.
Load diff
@@ -711,6 +711,7 @@ def machine_agent_request(path: str) -> bool:
|
||||
"/api/agent/commands/pending-disabled-old",
|
||||
"/api/agent/commands/result-disabled-old",
|
||||
"/api/agent/discoveries",
|
||||
"/api/agent/discovery-target",
|
||||
"/api/agent/miner-enrichment",
|
||||
"/api/agent/miner-enrichment-v2",
|
||||
"/api/agent/miner-enrichment-v3",
|
||||
@@ -826,6 +827,8 @@ def required_api_permission(method: str, path: str) -> str | None:
|
||||
return "repairs.complete"
|
||||
if method == "GET" and repair_timeline_request(path):
|
||||
return "repairs.read"
|
||||
if method == "GET" and path in {"/api/agent/discoveries/summary", "/api/agent/status-summary"}:
|
||||
return "agents.view"
|
||||
if method == "GET":
|
||||
if path.startswith("/api/system/"):
|
||||
return "system_health.view"
|
||||
|
||||
+199
-3942
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,49 @@
|
||||
// Actual Chromium; every HTTP request is fulfilled or aborted, with disposable data only.
|
||||
const {chromium}=require('playwright');
|
||||
const fs=require('fs');const assert=require('node:assert/strict');
|
||||
(async()=>{
|
||||
const browser=await chromium.launch({headless:true,args:['--no-sandbox','--disable-background-networking']});
|
||||
try {
|
||||
const context=await browser.newContext({serviceWorkers:'block'});
|
||||
const page=await context.newPage();
|
||||
const marker='<img src="data:," onerror="window.__discoveryAudit=1">';
|
||||
const commands=[];const unexpected=[];
|
||||
const observations=[0,1].map(n=>({id:'00000000-0000-4000-8000-00000000000'+(n+1),agent_id:'Agent '+n,agent_zone_id:'Zone '+n,version:'2026-01-01T00:00:00.00000'+n+'+00:00',created_at:'2026-01-01T00:00:00Z',ip_address:'198.51.100.10',room_name:marker,model:marker,mac_address:marker,discovery_status:'new'}));
|
||||
await page.route('**/*',async route=>{
|
||||
const url=new URL(route.request().url());
|
||||
if(url.origin!=='http://minerops.invalid'){unexpected.push(url.href);return route.abort();}
|
||||
const path=url.pathname;
|
||||
if(path==='/agent/control')return route.fulfill({contentType:'text/html',body:fs.readFileSync(process.argv[2],'utf8')});
|
||||
if(path==='/api/agent/status-summary')return route.fulfill({json:{live_database_counts:{total:2,online:2},latest_snapshot:{},agent:{agent_id:marker,hostname:marker,version:marker},recent_commands:[{command_type:marker,command_status:'failed',requested_by_username:marker,error_message:marker}]}});
|
||||
if(path==='/api/agent/discoveries')return route.fulfill({json:{discoveries:observations}});
|
||||
if(path==='/api/agent/discoveries/summary')return route.fulfill({json:{total:2,counts:{new:2}}});
|
||||
if(path==='/api/agent/discoveries/review'||path==='/api/agent/discoveries/enrich-match'){
|
||||
commands.push({path,body:route.request().postDataJSON()});return route.fulfill({json:{ok:true,message:'Saved'}});
|
||||
}
|
||||
return route.abort();
|
||||
});
|
||||
await context.addInitScript(()=>localStorage.setItem('minerops_token','disposable-browser-token'));
|
||||
page.on('dialog',dialog=>dialog.accept());
|
||||
await page.goto('http://minerops.invalid/agent/control');
|
||||
await page.waitForSelector('#discoveriesBody tr button');
|
||||
if(process.argv[3]==='baseline'){
|
||||
await page.waitForFunction(()=>window.__discoveryAudit===1);
|
||||
console.log('Baseline positive control: harmless stored event handler executed.');
|
||||
} else {
|
||||
assert.equal(await page.evaluate(()=>window.__discoveryAudit),undefined);
|
||||
assert.equal(await page.locator('#discoveriesBody img,#commandsBody img,[onerror]').count(),0);
|
||||
assert.ok((await page.locator('#discoveriesBody').innerText()).includes(marker));
|
||||
await page.locator('#discoveriesBody tr').nth(0).getByRole('button',{name:'Add as New',exact:true}).click();
|
||||
await page.waitForFunction(()=>document.getElementById('statusBox').textContent==='Agent status loaded.');
|
||||
await page.locator('#discoveriesBody tr').nth(1).getByRole('button',{name:'Enrich / Match',exact:true}).click();
|
||||
assert.equal(commands.length,2);
|
||||
assert.equal(commands[0].body.discovery_id,observations[0].id);
|
||||
assert.equal(commands[0].body.version,observations[0].version);
|
||||
assert.equal(commands[1].body.discovery_id,observations[1].id);
|
||||
assert.equal(commands[1].body.version,observations[1].version);
|
||||
assert.ok(commands.every(c=>!Object.hasOwn(c.body,'ip_address')));
|
||||
assert.deepEqual(unexpected,[]);
|
||||
console.log('Chromium passed: stored markup inert; Agent/status/error data escaped; same-IP actions retain exact observation IDs/versions; no external requests.');
|
||||
}
|
||||
} finally {await browser.close();}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Export the literal production page without importing/startup or network access."""
|
||||
|
||||
import ast, json, sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
tree = ast.parse((root / "backend/app/main.py").read_text())
|
||||
node = next(
|
||||
n
|
||||
for n in tree.body
|
||||
if isinstance(n, ast.AsyncFunctionDef) and n.name == "minerops_agent_control_page"
|
||||
)
|
||||
html = next(
|
||||
n.value
|
||||
for n in ast.walk(node)
|
||||
if isinstance(n, ast.Constant)
|
||||
and isinstance(n.value, str)
|
||||
and "<!doctype html>" in n.value
|
||||
)
|
||||
Path(sys.argv[2]).write_text(html)
|
||||
@@ -264,30 +264,13 @@ if __name__ == "__main__":
|
||||
|
||||
class AgentTransportIsolationSourceTests(unittest.TestCase):
|
||||
def test_known_miners_and_pool_snapshots_are_agent_zone_scoped(self):
|
||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||
identity = (Path(__file__).parents[1] / "app" / "identity.py").read_text()
|
||||
|
||||
known_start = main.index('@app.get("/api/agent/known-miners")')
|
||||
known_end = main.index("# Phase 5J:", known_start)
|
||||
known = main[known_start:known_end]
|
||||
|
||||
pool_start = main.index('@app.post("/api/agent/pool-snapshots")')
|
||||
pool_end = main.index("\n@app.", pool_start + 1)
|
||||
pool = main[pool_start:pool_end]
|
||||
|
||||
self.assertIn('"/api/agent/known-miners"', identity)
|
||||
|
||||
self.assertIn("Depends(require_agent_identity)", known)
|
||||
self.assertIn("zone_service.routing_context", known)
|
||||
self.assertIn("miners.agent_zone_id = $1::uuid", known)
|
||||
self.assertNotIn("minerops_require_agent_secret", known)
|
||||
|
||||
self.assertIn("Depends(require_agent_identity)", pool)
|
||||
self.assertIn("zone_service.routing_context", pool)
|
||||
self.assertIn("agent_id = authenticated_agent_id", pool)
|
||||
self.assertIn("agent_zone_id = %s", pool)
|
||||
self.assertNotIn('body.get("agent_id")', pool)
|
||||
self.assertNotIn("MINEROPS_AGENT_SHARED_SECRET", pool)
|
||||
from app.main import minerops_agent_known_miners, agent_pool_snapshots
|
||||
from inspect import getsource
|
||||
for fn,method in [(minerops_agent_known_miners,"known"),(agent_pool_snapshots,"snapshots")]:
|
||||
source=getsource(fn)
|
||||
self.assertIn("Depends(require_agent_identity)",source)
|
||||
self.assertIn("discovery_dispatch",source)
|
||||
self.assertIn("agent_id=authenticated_agent_id",source)
|
||||
|
||||
def test_windows_machine_pollers_send_agent_identity(self):
|
||||
root = agent_source_root()
|
||||
@@ -307,67 +290,21 @@ class AgentTransportIsolationSourceTests(unittest.TestCase):
|
||||
|
||||
class AgentEnrichmentIsolationSourceTests(unittest.TestCase):
|
||||
def test_enrichment_routes_require_agent_identity_and_zone_scope(self):
|
||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||
|
||||
decorators = (
|
||||
'@app.post("/api/agent/miner-enrichment")',
|
||||
'@app.post("/api/agent/miner-enrichment-v2")',
|
||||
'@app.post("/api/agent/miner-enrichment-v3")',
|
||||
)
|
||||
|
||||
for decorator in decorators:
|
||||
with self.subTest(decorator=decorator):
|
||||
start = main.index(decorator)
|
||||
end = main.index("\n@app.", start + 1)
|
||||
route = main[start:end]
|
||||
|
||||
self.assertIn("Depends(require_agent_identity)", route)
|
||||
self.assertIn("zone_service.routing_context", route)
|
||||
self.assertIn("agent_zone_id", route)
|
||||
self.assertNotIn("minerops_require_agent_secret", route)
|
||||
|
||||
v2_start = main.index('@app.post("/api/agent/miner-enrichment-v2")')
|
||||
v2_end = main.index("\n@app.", v2_start + 1)
|
||||
v2 = main[v2_start:v2_end]
|
||||
self.assertIn(
|
||||
"AND agent_zone_id = $2::uuid",
|
||||
v2,
|
||||
)
|
||||
self.assertIn(
|
||||
"AND agent_zone_id = $3::uuid",
|
||||
v2,
|
||||
)
|
||||
|
||||
v3_start = main.index('@app.post("/api/agent/miner-enrichment-v3")')
|
||||
v3_end = main.index("\n@app.", v3_start + 1)
|
||||
v3 = main[v3_start:v3_end]
|
||||
self.assertIn(
|
||||
"AND agent_zone_id = $8::uuid",
|
||||
v3,
|
||||
)
|
||||
self.assertIn(
|
||||
"AND agent_zone_id = $7::uuid",
|
||||
v3,
|
||||
)
|
||||
from app import main
|
||||
from inspect import getsource
|
||||
for fn in [main.minerops_agent_miner_enrichment,main.minerops_agent_miner_enrichment_v2,main.minerops_agent_miner_enrichment_v3]:
|
||||
source=getsource(fn)
|
||||
self.assertIn("Depends(require_agent_identity)",source)
|
||||
self.assertIn("discovery_dispatch",source)
|
||||
self.assertIn("agent_id=authenticated_agent_id",source)
|
||||
|
||||
def test_api3_uses_authenticated_identity_and_never_global_agent_identity(self):
|
||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||
|
||||
start = main.index('@app.post("/api/agent/api3-enrichment")')
|
||||
end = main.index("\n@app.", start + 1)
|
||||
route = main[start:end]
|
||||
|
||||
self.assertIn("Depends(require_agent_identity)", route)
|
||||
self.assertIn("zone_service.routing_context", route)
|
||||
self.assertIn("agent_id = authenticated_agent_id", route)
|
||||
self.assertIn("agent_zone_id = %s", route)
|
||||
self.assertIn("if miner_id is None:", route)
|
||||
self.assertIn("continue", route)
|
||||
|
||||
self.assertNotIn("MINEROPS_AGENT_SHARED_SECRET", route)
|
||||
self.assertNotIn('body.get("agent_id")', route)
|
||||
self.assertNotIn('body.get("site_name")', route)
|
||||
self.assertNotIn('request.headers.get("x-agent-secret")', route)
|
||||
from app.main import minerops_agent_api3_enrichment
|
||||
from inspect import getsource
|
||||
source=getsource(minerops_agent_api3_enrichment)
|
||||
self.assertIn("Depends(require_agent_identity)",source)
|
||||
self.assertIn("discovery_dispatch",source)
|
||||
self.assertIn("agent_id=authenticated_agent_id",source)
|
||||
|
||||
def test_legacy_config_remains_explicitly_legacy_only(self):
|
||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Reused discovery isolation scenarios exercised through real HTTP authority."""
|
||||
|
||||
import unittest
|
||||
from tests.test_discovery_security_postgres import DiscoveryFixtures
|
||||
|
||||
|
||||
class AgentDiscoveryIsolationPostgresTests(DiscoveryFixtures, unittest.TestCase):
|
||||
def test_cross_zone_mac_and_ip_are_unmatched_without_id_disclosure(self):
|
||||
mid, mac = self.miner(1)
|
||||
before = self.durable()
|
||||
row = self.observe(mac=mac)
|
||||
self.assertEqual(row["discovery_status"], "zone_conflict")
|
||||
self.assertIsNone(row["matched_miner_id"])
|
||||
self.assertNotIn(str(mid), str(row))
|
||||
self.assertEqual(before["miners"], self.durable()["miners"])
|
||||
|
||||
def test_same_zone_mac_and_ip_matches_are_preserved(self):
|
||||
mid, mac = self.miner()
|
||||
row = self.observe(mac=mac)
|
||||
self.assertEqual(row["matched_miner_id"], str(mid))
|
||||
self.assertEqual(row["discovery_status"], "known")
|
||||
|
||||
def test_unassigned_miner_is_not_claimed_by_discovery_matching(self):
|
||||
mid, mac = self.miner()
|
||||
with __import__(
|
||||
"app.agent_zones.repository", fromlist=["default_connection_factory"]
|
||||
).default_connection_factory() as conn:
|
||||
conn.execute("UPDATE miners SET agent_zone_id=NULL WHERE id=%s", (mid,))
|
||||
row = self.observe(mac=mac)
|
||||
self.assertIsNone(row["matched_miner_id"])
|
||||
self.assertIsNone(
|
||||
self.scalar("SELECT agent_zone_id FROM miners WHERE id=%s", (mid,))
|
||||
)
|
||||
|
||||
def test_claimed_agent_identity_cannot_override_authentication(self):
|
||||
response = self.post(
|
||||
"/api/agent/discoveries", {"agent_id": self.agents[1], "discoveries": []}
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
@@ -42,6 +42,7 @@ class FakeRepository:
|
||||
self.agent = None
|
||||
self.saved = []
|
||||
self.assign_result = {MINER_A: "assigned"}
|
||||
self.discovery_match = None
|
||||
|
||||
def get_agent(self, agent_id):
|
||||
return self.agent if self.agent and self.agent["agent_id"] == agent_id else None
|
||||
@@ -107,6 +108,11 @@ class FakeRepository:
|
||||
],
|
||||
}
|
||||
|
||||
def find_miner_in_zone(self, *, agent_id, agent_zone_id, mac_address, ip_address):
|
||||
self.discovery_match_request = {'agent_id':agent_id,'agent_zone_id':agent_zone_id,
|
||||
'mac_address':mac_address,'ip_address':ip_address}
|
||||
return self.discovery_match
|
||||
|
||||
def assign_miners_to_zone(self, miner_ids, agent_zone_id):
|
||||
self.assigned = (list(miner_ids), agent_zone_id)
|
||||
return {miner_id: self.assign_result.get(miner_id, "miner_not_found")
|
||||
@@ -136,6 +142,7 @@ class AgentZoneModelTests(unittest.TestCase):
|
||||
class AgentZoneServiceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = FakeRepository()
|
||||
self.repository.agent = {"agent_id":"agent-a","site_id":SITE_A,"agent_zone_id":ZONE_A,"site_name":"North"}
|
||||
self.service = AgentZoneService(self.repository)
|
||||
|
||||
def checkin(self, **kwargs):
|
||||
@@ -144,14 +151,14 @@ class AgentZoneServiceTests(unittest.TestCase):
|
||||
AgentCheckinRequest(agent_id="agent-a", site_name="North", **kwargs),
|
||||
)
|
||||
|
||||
def test_explicit_first_assignment_and_same_assignment_are_allowed(self):
|
||||
def test_established_assignment_and_same_assignment_are_allowed(self):
|
||||
first = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||
second = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||
self.assertTrue(first.routing_available)
|
||||
self.assertEqual(second.agent_zone_id, ZONE_A)
|
||||
|
||||
def test_explicit_zone_site_mismatch_and_identity_spoof_are_rejected(self):
|
||||
with self.assertRaisesRegex(AgentZoneValidationError, "does not belong"):
|
||||
with self.assertRaisesRegex(AgentZoneValidationError, "cannot change Sites"):
|
||||
self.checkin(site_id=SITE_B, agent_zone_id=ZONE_A)
|
||||
with self.assertRaisesRegex(AgentZoneValidationError, "authenticated"):
|
||||
self.service.checkin(
|
||||
@@ -181,34 +188,20 @@ class AgentZoneServiceTests(unittest.TestCase):
|
||||
self.assertEqual(result.agent_zone_id, ZONE_A)
|
||||
|
||||
def test_transitional_resolution_requires_exactly_one_enabled_zone(self):
|
||||
single = self.checkin(site_id=SITE_A)
|
||||
self.assertEqual(single.agent_zone_id, ZONE_A)
|
||||
self.repository.agent = None
|
||||
self.repository.zones = {}
|
||||
zero = self.checkin(site_id=SITE_A)
|
||||
self.assertEqual(zero.routing_reason, "agent_zone_unavailable")
|
||||
self.repository.agent = None
|
||||
self.repository.zones = {
|
||||
ZONE_A: zone(),
|
||||
ZONE_B: zone(ZONE_B, SITE_A, name="Room 2"),
|
||||
}
|
||||
many = self.checkin(site_id=SITE_A)
|
||||
self.assertEqual(many.routing_reason, "agent_zone_ambiguous")
|
||||
self.assertIsNone(many.agent_zone_id)
|
||||
for zones in [{}, {ZONE_A: zone()}, {ZONE_A:zone(),ZONE_B:zone(ZONE_B,SITE_A)}]:
|
||||
self.repository.zones=zones
|
||||
self.repository.agent=None
|
||||
with self.assertRaisesRegex(AgentZoneValidationError,"administrator-established"):
|
||||
self.checkin(site_id=SITE_A)
|
||||
|
||||
def test_disabled_zone_is_not_selected_and_existing_assignment_is_factual(self):
|
||||
self.repository.zones[ZONE_A]["is_enabled"] = False
|
||||
unassigned = self.checkin(site_id=SITE_A)
|
||||
self.assertEqual(unassigned.routing_reason, "agent_zone_unavailable")
|
||||
self.repository.agent = {
|
||||
"agent_id": "agent-a", "site_id": SITE_A,
|
||||
"agent_zone_id": ZONE_A, "site_name": "North",
|
||||
}
|
||||
existing = self.service.checkin(
|
||||
"agent-a", AgentCheckinRequest(agent_id="agent-a")
|
||||
)
|
||||
self.assertEqual(existing.routing_reason, "agent_zone_disabled")
|
||||
self.assertEqual(existing.agent_zone_id, ZONE_A)
|
||||
self.repository.zones[ZONE_A]["is_enabled"]=False
|
||||
existing=self.service.checkin("agent-a",AgentCheckinRequest(agent_id="agent-a"))
|
||||
self.assertEqual(existing.routing_reason,"agent_zone_disabled")
|
||||
self.assertEqual(existing.agent_zone_id,ZONE_A)
|
||||
self.repository.agent=None
|
||||
with self.assertRaisesRegex(AgentZoneValidationError,"administrator-established"):
|
||||
self.checkin(site_id=SITE_A)
|
||||
|
||||
def test_payload_is_sanitized_before_persistence(self):
|
||||
request = AgentCheckinRequest.model_validate({
|
||||
@@ -255,9 +248,41 @@ class AgentZoneServiceTests(unittest.TestCase):
|
||||
self.assertNotIn("10.80.0.0/24", str(configuration))
|
||||
|
||||
|
||||
def test_discovery_match_uses_only_authenticated_agent_zone(self):
|
||||
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||
self.repository.discovery_match = {"id": MINER_A, "matched_by": "mac"}
|
||||
|
||||
result = self.service.match_discovered_miner(
|
||||
"agent-a",
|
||||
mac_address="02:00:00:00:16:21",
|
||||
ip_address="192.0.2.21",
|
||||
)
|
||||
|
||||
self.assertEqual(result.miner_id, MINER_A)
|
||||
self.assertEqual(result.matched_by, "mac")
|
||||
self.assertEqual(
|
||||
self.repository.discovery_match_request["agent_zone_id"], ZONE_A
|
||||
)
|
||||
|
||||
def test_discovery_match_rejects_stale_recorded_zone_without_querying(self):
|
||||
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||
|
||||
result = self.service.match_discovered_miner(
|
||||
"agent-a",
|
||||
recorded_zone_id=ZONE_B,
|
||||
mac_address="02:00:00:00:16:21",
|
||||
ip_address="192.0.2.21",
|
||||
)
|
||||
|
||||
self.assertIsNone(result.miner_id)
|
||||
self.assertEqual(result.routing_reason, "agent_zone_conflict")
|
||||
self.assertFalse(hasattr(self.repository, "discovery_match_request"))
|
||||
|
||||
|
||||
class AgentZoneRouterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = FakeRepository()
|
||||
self.repository.agent = {"agent_id":"agent-a","site_id":SITE_A,"agent_zone_id":ZONE_A,"site_name":"North"}
|
||||
self.service = AgentZoneService(self.repository)
|
||||
self.app = FastAPI()
|
||||
self.app.include_router(router)
|
||||
|
||||
@@ -196,12 +196,12 @@ class AgentZonePostgresTests(unittest.TestCase):
|
||||
connection.rollback()
|
||||
connection.close()
|
||||
|
||||
def test_unassigned_miner_inherits_zone_and_existing_other_zone_wins(self):
|
||||
def test_unassigned_miner_stays_unassigned_and_existing_other_zone_wins(self):
|
||||
repository = AgentZoneRepository(connect)
|
||||
outcomes = repository.assign_miners_to_zone(
|
||||
[MINER_UNASSIGNED, MINER_OTHER], ZONE_A
|
||||
)
|
||||
self.assertEqual(outcomes[MINER_UNASSIGNED], "assigned")
|
||||
self.assertEqual(outcomes[MINER_UNASSIGNED], "miner_zone_unassigned")
|
||||
self.assertEqual(outcomes[MINER_OTHER], "agent_zone_conflict")
|
||||
connection = connect()
|
||||
try:
|
||||
@@ -213,7 +213,7 @@ class AgentZonePostgresTests(unittest.TestCase):
|
||||
assignments = dict(cur.fetchall())
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertEqual(assignments[MINER_UNASSIGNED], ZONE_A)
|
||||
self.assertIsNone(assignments[MINER_UNASSIGNED])
|
||||
self.assertEqual(assignments[MINER_OTHER], ZONE_B)
|
||||
|
||||
def test_disabled_zone_blocks_control_without_site_fallback(self):
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,338 @@
|
||||
"""Audit F1-F3 reversals: real registered HTTP/transactions, no physical devices."""
|
||||
import ast
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
import app.main as main
|
||||
from app.identity import create_access_token
|
||||
from tests.test_discovery_security_postgres import DiscoveryFixtures, connect
|
||||
|
||||
|
||||
class LegacyInventorySecurityTests(DiscoveryFixtures, unittest.TestCase):
|
||||
def upload(self, text, query='', headers=None):
|
||||
return self.client.post(
|
||||
'/api/imports/whatsminer-tool-scan?dry_run=false&update_existing=true' + query,
|
||||
headers=self.admin if headers is None else headers,
|
||||
files={'file': ('simulated.csv', text.encode(), 'text/csv')},
|
||||
)
|
||||
|
||||
def denied(self, response, before, status):
|
||||
self.assertEqual(response.status_code, status, response.text)
|
||||
self.assertEqual(before, self.durable())
|
||||
self.transport.assert_not_called()
|
||||
|
||||
def test_discovery_audit_reproductions_all_rejected_without_reads(self):
|
||||
self.miner(1, ip='198.51.100.111')
|
||||
self.miner(ip='198.51.100.125')
|
||||
self.miner(1, ip='198.51.100.125')
|
||||
for mode in ['true', 'false']:
|
||||
for ip in ['198.51.100.111', '198.51.100.125', '203.0.113.209']:
|
||||
with self.subTest(mode=mode, ip=ip), patch.dict(os.environ, MINEROPS_HUB_MODE=mode):
|
||||
before = self.durable()
|
||||
response = self.client.post('/api/miners/discover-one', headers=self.admin, json={'ip_address': ip})
|
||||
self.denied(response, before, 410)
|
||||
|
||||
def test_missing_disabled_agent_and_zone_cannot_restore_legacy_discovery(self):
|
||||
with connect() as c:
|
||||
c.execute('UPDATE agent_zones SET is_enabled=false WHERE id=%s', (self.zones[0],))
|
||||
c.execute('UPDATE minerops_site_agents SET credential_revoked_at=now() WHERE agent_id=%s', (self.agents[0],))
|
||||
c.execute('DELETE FROM minerops_site_agents WHERE agent_id=%s', (self.agents[1],))
|
||||
before = self.durable()
|
||||
r = self.client.post('/api/miners/discover-one', headers=self.admin, json={'ip_address': '198.51.100.111'})
|
||||
self.denied(r, before, 410)
|
||||
|
||||
def test_inventory_only_permission_cannot_replace_mac(self):
|
||||
role, uid = uuid4(), uuid4()
|
||||
with connect() as c:
|
||||
c.execute('INSERT INTO roles(id,name) VALUES (%s,%s)', (role, str(role)))
|
||||
c.execute("INSERT INTO role_permissions(role_id,permission_id) SELECT %s,id FROM permissions WHERE key='miners.update'", (role,))
|
||||
c.execute("INSERT INTO users(id,username,display_name,password_hash,role_id) VALUES (%s,%s,'Inventory only','unused',%s)", (uid, str(uid), role))
|
||||
headers = {'Authorization': 'Bearer ' + create_access_token(str(uid), str(uid), str(role))}
|
||||
mid, _ = self.miner(ip='198.51.100.127')
|
||||
before = self.durable()
|
||||
for path, body, status in [
|
||||
('/api/agent/discoveries/review', {'discovery_id': str(uuid4()), 'version': 'x', 'action': 'ignore'}, 403),
|
||||
(f'/api/miners/{mid}/replace-control-board', {}, 403),
|
||||
('/api/miners/discover-one', {'ip_address': '198.51.100.127'}, 410),
|
||||
]:
|
||||
self.denied(self.client.post(path, headers=headers, json=body), before, status)
|
||||
|
||||
def test_range_and_manual_scheduler_retired_in_both_modes(self):
|
||||
for mode in ['true', 'false']:
|
||||
with patch.dict(os.environ, MINEROPS_HUB_MODE=mode):
|
||||
for path in [f'/api/scan-ranges/{uuid4()}/discover-now', '/api/scanner/run-known-now']:
|
||||
before = self.durable()
|
||||
self.denied(self.client.post(path, headers=self.admin, json={}), before, 410)
|
||||
with patch('app.main.threading.Thread') as thread:
|
||||
main.start_scheduled_scanner()
|
||||
thread.assert_not_called()
|
||||
status = self.client.get('/api/scanner/status', headers=self.admin).json()['data']
|
||||
self.assertFalse(status['enabled'])
|
||||
self.assertTrue(status['scanner_disabled'])
|
||||
|
||||
def test_all_legacy_helper_definitions_and_saved_aliases_are_retired(self):
|
||||
# Execute every original definition, including shadowed functions; decorators
|
||||
# are omitted so this cannot mutate the application's registered routes.
|
||||
tree = ast.parse(Path(main.__file__).read_text())
|
||||
names = {'find_existing_miner', 'discover_one_miner', 'discover_scan_range_now',
|
||||
'call_discover_one_miner_flexible', 'refresh_one_known_miner_for_schedule',
|
||||
'run_scheduled_known_miner_scan', 'scheduled_scanner_loop',
|
||||
'apply_legacy_summary_to_miner', 'legacy_find_miner_id_by_ip'}
|
||||
import inspect
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name in names:
|
||||
with self.subTest(helper=node.name, line=node.lineno):
|
||||
node.decorator_list = []
|
||||
namespace = dict(vars(main))
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), '<retired-helper>', 'exec'), namespace)
|
||||
fn = namespace[node.name]
|
||||
kwargs = {name: None for name, p in inspect.signature(fn).parameters.items() if p.default is inspect.Parameter.empty}
|
||||
with patch('app.main.get_db_connection', side_effect=AssertionError('No legacy database access')):
|
||||
with self.assertRaises(HTTPException) as exc:
|
||||
fn(**kwargs)
|
||||
self.assertEqual(exc.exception.status_code, 410)
|
||||
with self.assertRaises(HTTPException) as exc:
|
||||
main._minerops_original_discover_one_miner_legacy_summary(None)
|
||||
self.assertEqual(exc.exception.status_code, 410)
|
||||
|
||||
def test_summary_id_ip_disagreement_and_bulk_retired(self):
|
||||
a, _ = self.miner(ip='198.51.100.121')
|
||||
self.miner(1, ip='198.51.100.122')
|
||||
with patch('app.main.read_legacy_whatsminer_summary', side_effect=AssertionError('No summary transport')) as read:
|
||||
for mode in ['true', 'false']:
|
||||
with patch.dict(os.environ, MINEROPS_HUB_MODE=mode):
|
||||
for path, body in [
|
||||
('/api/tools/legacy-summary-refresh-one', {'miner_id': str(a), 'ip_address': '198.51.100.122'}),
|
||||
('/api/tools/legacy-summary-refresh-one', {'miner_id': str(a)}),
|
||||
('/api/tools/legacy-summary-refresh-missing', {}),
|
||||
]:
|
||||
before = self.durable()
|
||||
self.denied(self.client.post(path, headers=self.admin, json=body), before, 410)
|
||||
read.assert_not_called()
|
||||
|
||||
def test_summary_cannot_start_inflight_read_or_overwrite_newer_owner(self):
|
||||
mid, _ = self.miner(ip='198.51.100.123')
|
||||
with patch('app.main.read_legacy_whatsminer_summary') as read, ThreadPoolExecutor(2) as pool:
|
||||
future = pool.submit(lambda: self.client.post('/api/tools/legacy-summary-refresh-one', headers=self.admin, json={'miner_id': str(mid)}))
|
||||
with connect() as c:
|
||||
c.execute("UPDATE miners SET site_id=%s,agent_zone_id=%s,current_ip='198.51.100.124',updated_at=clock_timestamp() WHERE id=%s", (self.sites[1], self.zones[1], mid))
|
||||
self.assertEqual(future.result(timeout=8).status_code, 410)
|
||||
before = self.durable()
|
||||
with self.assertRaises(HTTPException):
|
||||
main.apply_legacy_summary_to_miner(str(mid), '198.51.100.123')
|
||||
self.assertEqual(before, self.durable())
|
||||
self.assertEqual(self.scalar('SELECT host(current_ip) FROM miners WHERE id=%s', (mid,)), '198.51.100.124')
|
||||
read.assert_not_called()
|
||||
|
||||
def test_legacy_human_auth_still_required(self):
|
||||
for headers in [{}, self.headers()]:
|
||||
for path in ['/api/miners/discover-one', '/api/tools/legacy-summary-refresh-one']:
|
||||
before = self.durable()
|
||||
self.denied(self.client.post(path, headers=headers, json={'ip_address': '198.51.100.111'}), before, 401)
|
||||
|
||||
def test_csv_ip_only_requires_zone_even_with_one_global_match(self):
|
||||
self.miner(1, ip='198.51.100.112')
|
||||
before = self.durable()
|
||||
self.denied(self.upload('IP,Miner Type\n198.51.100.112,WRONG\n'), before, 422)
|
||||
|
||||
def test_csv_same_ip_requires_namespace_and_exact_zone_updates_one(self):
|
||||
a, _ = self.miner(ip='198.51.100.126')
|
||||
b, _ = self.miner(1, ip='198.51.100.126')
|
||||
before = self.durable()
|
||||
self.denied(self.upload('IP,Miner Type\n198.51.100.126,WRONG\n'), before, 422)
|
||||
r = self.upload(f'IP,Agent Zone ID,Miner Type\n198.51.100.126,{self.zones[1]},VALID\n')
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertIsNone(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (a,)))
|
||||
self.assertEqual(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (b,)), 'VALID')
|
||||
self.assertEqual(before['miner_status_snapshots'], self.durable()['miner_status_snapshots'])
|
||||
|
||||
def test_csv_duplicate_ip_inside_zone_rejected(self):
|
||||
self.miner(ip='198.51.100.130'); self.miner(ip='198.51.100.130')
|
||||
before = self.durable()
|
||||
self.denied(self.upload('IP,Miner Type\n198.51.100.130,WRONG\n', '&agent_zone_id=' + str(self.zones[0])), before, 409)
|
||||
|
||||
def test_csv_exact_id_mac_serial_and_query_zone_legitimate(self):
|
||||
mid, mac = self.miner(ip='198.51.100.131')
|
||||
serial = uuid4().hex
|
||||
with connect() as c:
|
||||
c.execute('UPDATE miners SET miner_sn=%s WHERE id=%s', (serial, mid))
|
||||
for heading, value in [('Miner ID', str(mid)), ('MAC', mac), ('Serial', serial), ('IP', '198.51.100.131')]:
|
||||
r = self.upload(f'{heading},Miner Type\n{value},VALID\n', '&agent_zone_id=' + str(self.zones[0]))
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertEqual(r.json()['data']['matched_count'], 1)
|
||||
|
||||
def test_csv_conflicting_and_unknown_mac_never_fall_back_to_ip(self):
|
||||
a, _ = self.miner(ip='198.51.100.132')
|
||||
b, bm = self.miner(1, ip='198.51.100.133')
|
||||
for mac in [bm, '02:AA:BB:CC:DD:EF']:
|
||||
before = self.durable()
|
||||
self.denied(self.upload(f'Miner ID,IP,MAC,Miner Type\n{a},198.51.100.132,{mac},WRONG\n'), before, 409)
|
||||
before = self.durable()
|
||||
self.denied(self.upload(f'Miner ID,IP,Miner Type\n{a},198.51.100.133,WRONG\n'), before, 409)
|
||||
|
||||
def test_csv_historical_mac_and_contradictory_namespace_serial_denied(self):
|
||||
mid, oldmac = self.miner(ip='198.51.100.134')
|
||||
with connect() as c:
|
||||
c.execute('UPDATE miners SET current_mac=%s,miner_sn=%s WHERE id=%s', ('02:' + ':'.join(f'{v:02X}' for v in uuid4().bytes[:5]), 'serial-' + str(mid), mid))
|
||||
for columns, values in [
|
||||
('MAC', oldmac), ('Serial', 'unknown'), ('Site ID', str(self.sites[1])),
|
||||
('Agent Zone ID', str(self.zones[1])), ('miner_id', str(uuid4())),
|
||||
]:
|
||||
before = self.durable()
|
||||
self.denied(self.upload(f'Miner ID,{columns},Miner Type\n{mid},{values},WRONG\n'), before, 409)
|
||||
|
||||
def test_csv_invalid_identifiers_and_query_row_disagreement(self):
|
||||
mid, _ = self.miner(ip='198.51.100.135')
|
||||
for header, value in [('MAC', 'bad-mac'), ('IP', '198.51.100.135/24'), ('Miner ID', 'bad-id'), ('Agent Zone ID', 'bad-zone')]:
|
||||
before = self.durable()
|
||||
self.denied(self.upload(f'{header},Miner Type\n{value},WRONG\n'), before, 422)
|
||||
before = self.durable()
|
||||
self.denied(self.upload(f'Miner ID,Agent Zone ID,Miner Type\n{mid},{self.zones[1]},WRONG\n', '&agent_zone_id=' + str(self.zones[0])), before, 409)
|
||||
|
||||
def test_csv_unmatched_strong_identity_does_not_select_ip(self):
|
||||
self.miner(ip='198.51.100.136')
|
||||
before = self.durable()
|
||||
r = self.upload('MAC,IP,Miner Type\n02:AA:BB:CC:DD:EF,198.51.100.136,WRONG\n')
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertEqual(r.json()['data']['unmatched_count'], 1)
|
||||
self.assertEqual(before['miners'], self.durable()['miners'])
|
||||
|
||||
def test_csv_batch_conflict_rolls_back_prior_valid_row(self):
|
||||
a, _ = self.miner(ip='198.51.100.137')
|
||||
b, _ = self.miner(1, ip='198.51.100.138')
|
||||
before = self.durable()
|
||||
self.denied(self.upload(f'Miner ID,IP,Miner Type\n{a},198.51.100.137,VALID\n{b},198.51.100.137,WRONG\n'), before, 409)
|
||||
|
||||
def test_csv_preview_repeat_and_history_preservation(self):
|
||||
mid, _ = self.miner(ip='198.51.100.139')
|
||||
with connect() as c:
|
||||
c.execute('INSERT INTO miner_status_snapshots(id,miner_id,observed_at,mhs_av) VALUES (%s,%s,now(),123)', (uuid4(), mid))
|
||||
before = self.durable()
|
||||
text = f'Miner ID,Miner Type\n{mid},VALID\n'
|
||||
r = self.client.post('/api/imports/whatsminer-tool-scan?dry_run=true&update_existing=true', headers=self.admin, files={'file': ('simulated.csv', text.encode(), 'text/csv')})
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertEqual(before, self.durable())
|
||||
for _ in range(2):
|
||||
self.assertEqual(self.upload(text).status_code, 200)
|
||||
self.assertEqual(before['miner_status_snapshots'], self.durable()['miner_status_snapshots'])
|
||||
self.assertEqual(before['miner_mac_ownerships'], self.durable()['miner_mac_ownerships'])
|
||||
|
||||
def test_csv_actual_audit_permission_failure_rolls_back(self):
|
||||
mid, _ = self.miner(ip='198.51.100.140')
|
||||
role = 'b1_csv_' + uuid4().hex
|
||||
with connect() as c:
|
||||
c.execute('CREATE ROLE ' + role)
|
||||
c.execute('GRANT USAGE ON SCHEMA public TO ' + role)
|
||||
c.execute('GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA public TO ' + role)
|
||||
c.execute('REVOKE INSERT ON audit_logs FROM ' + role)
|
||||
def limited():
|
||||
c = connect(); c.execute('SET ROLE ' + role); c.commit(); return c
|
||||
before = self.durable()
|
||||
try:
|
||||
with patch('app.main.get_db_connection', side_effect=limited):
|
||||
r = self.upload(f'Miner ID,Miner Type\n{mid},ROLLBACK\n')
|
||||
self.denied(r, before, 500)
|
||||
self.assertIn('audit_logs', r.text)
|
||||
finally:
|
||||
with connect() as c:
|
||||
c.execute('DROP OWNED BY ' + role); c.execute('DROP ROLE ' + role)
|
||||
|
||||
def test_csv_concurrent_ownership_move_is_revalidated_after_real_lock_wait(self):
|
||||
mid, _ = self.miner(ip='198.51.100.141')
|
||||
owner = connect()
|
||||
owner.execute('UPDATE miners SET site_id=%s,agent_zone_id=%s,updated_at=clock_timestamp() WHERE id=%s', (self.sites[1], self.zones[1], mid))
|
||||
with ThreadPoolExecutor(1) as pool:
|
||||
future = pool.submit(lambda: self.upload(f'Miner ID,Agent Zone ID,Miner Type\n{mid},{self.zones[0]},WRONG\n'))
|
||||
try:
|
||||
deadline = time.monotonic() + 4
|
||||
waiting = 0
|
||||
while time.monotonic() < deadline:
|
||||
waiting = self.scalar("SELECT count(*) FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'")
|
||||
if waiting: break
|
||||
time.sleep(.02)
|
||||
self.assertGreater(waiting, 0)
|
||||
owner.commit()
|
||||
r = future.result(timeout=8)
|
||||
self.assertEqual(r.status_code, 409, r.text)
|
||||
self.assertEqual(self.scalar('SELECT agent_zone_id FROM miners WHERE id=%s', (mid,)), self.zones[1])
|
||||
self.assertIsNone(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (mid,)))
|
||||
finally:
|
||||
owner.rollback(); owner.close()
|
||||
|
||||
def test_csv_real_update_lock_prevents_mid_transaction_ownership_change(self):
|
||||
import app.discovery.csv_import as module
|
||||
mid, _ = self.miner(ip='198.51.100.142')
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
original = module.resolve_inventory_row
|
||||
def gate(*args, **kwargs):
|
||||
row = original(*args, **kwargs); entered.set()
|
||||
self.assertTrue(release.wait(4)); return row
|
||||
def move():
|
||||
with connect() as c:
|
||||
c.execute('UPDATE miners SET site_id=%s,agent_zone_id=%s,updated_at=clock_timestamp() WHERE id=%s', (self.sites[1], self.zones[1], mid))
|
||||
with patch.object(module, 'resolve_inventory_row', side_effect=gate), ThreadPoolExecutor(2) as pool:
|
||||
upload = pool.submit(lambda: self.upload(f'Miner ID,Agent Zone ID,Miner Type\n{mid},{self.zones[0]},VALID\n'))
|
||||
self.assertTrue(entered.wait(4)); moving = pool.submit(move)
|
||||
try:
|
||||
deadline = time.monotonic() + 3
|
||||
waiting = 0
|
||||
while time.monotonic() < deadline:
|
||||
waiting = self.scalar("SELECT count(*) FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'")
|
||||
if waiting: break
|
||||
time.sleep(.02)
|
||||
self.assertGreater(waiting, 0)
|
||||
finally:
|
||||
release.set()
|
||||
self.assertEqual(upload.result(timeout=8).status_code, 200)
|
||||
moving.result(timeout=8)
|
||||
self.assertEqual(self.scalar('SELECT agent_zone_id FROM miners WHERE id=%s', (mid,)), self.zones[1])
|
||||
|
||||
def test_csv_duplicate_headers_and_conflicting_aliases_rejected(self):
|
||||
mid, _ = self.miner(ip='198.51.100.143')
|
||||
for text, code in [
|
||||
(f'Miner ID,Miner ID,Miner Type\n{mid},{mid},WRONG\n', 422),
|
||||
(f'Miner ID,miner_id,Miner Type\n{mid},{uuid4()},WRONG\n', 409),
|
||||
(f'Miner ID,Site ID,Agent Zone ID,Miner Type\n{mid},{self.sites[1]},{self.zones[0]},WRONG\n', 409),
|
||||
]:
|
||||
before = self.durable()
|
||||
self.denied(self.upload(text), before, code)
|
||||
|
||||
def test_csv_duplicate_serial_is_ambiguous(self):
|
||||
a, _ = self.miner(ip='198.51.100.144')
|
||||
b, _ = self.miner(1, ip='198.51.100.145')
|
||||
serial = 'duplicate-' + uuid4().hex
|
||||
with connect() as c:
|
||||
c.execute('UPDATE miners SET miner_sn=%s WHERE id IN (%s,%s)', (serial, a, b))
|
||||
before = self.durable()
|
||||
self.denied(self.upload(f'Serial,Miner Type\n{serial},WRONG\n'), before, 409)
|
||||
|
||||
def test_csv_global_mac_selection_with_overlapping_ip_is_valid(self):
|
||||
a, _ = self.miner(ip='198.51.100.146')
|
||||
b, mac = self.miner(1, ip='198.51.100.146')
|
||||
before = self.durable()
|
||||
r = self.upload(f'MAC,IP,Miner Type\n{mac},198.51.100.146,VALID\n')
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertIsNone(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (a,)))
|
||||
self.assertEqual(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (b,)), 'VALID')
|
||||
after = self.durable()
|
||||
for table in ['miner_status_snapshots', 'miner_mac_ownerships', 'miner_network_identities']:
|
||||
self.assertEqual(before[table], after[table])
|
||||
audit = self.scalar("SELECT new_value_json FROM audit_logs WHERE actor_user_id=%s AND action='import.whatsminertool_csv.applied' ORDER BY created_at DESC LIMIT 1", (self.uid,))
|
||||
self.assertEqual(audit['updates'][0]['miner_id'], str(b))
|
||||
self.assertEqual(audit['updates'][0]['agent_zone_id'], str(self.zones[1]))
|
||||
|
||||
def test_csv_read_only_user_cannot_apply_metadata(self):
|
||||
uid = uuid4()
|
||||
with connect() as c:
|
||||
c.execute("INSERT INTO users(id,username,display_name,password_hash,role_id) SELECT %s,%s,'Read only','unused',id FROM roles WHERE name='Read Only'", (uid, str(uid)))
|
||||
headers = {'Authorization': 'Bearer ' + create_access_token(str(uid), str(uid), 'Read Only')}
|
||||
mid, _ = self.miner(ip='198.51.100.147')
|
||||
before = self.durable()
|
||||
self.denied(self.upload(f'Miner ID,Miner Type\n{mid},WRONG\n', headers=headers), before, 403)
|
||||
@@ -146,8 +146,23 @@ class StageAQueueContainmentTests(unittest.TestCase):
|
||||
self.assertEqual(self.audits(command_id), [])
|
||||
|
||||
def test_read_only_commands_still_claim_and_complete(self):
|
||||
for kind in ("poll_known_miners_now", "discover_new_miners", "enrich_match_discovery"):
|
||||
self.seed(f"stage-a-read-{kind}", command_type=kind, payload={"ip_address": "192.0.2.1"})
|
||||
from uuid import uuid4
|
||||
from app.discovery.repository import DiscoveryRepository
|
||||
site, zone, actor = uuid4(), uuid4(), uuid4()
|
||||
with connect() as connection, connection.cursor() as cur:
|
||||
cur.execute("INSERT INTO sites(id,name) VALUES (%s,%s)",(site,str(site)))
|
||||
cur.execute("INSERT INTO agent_zones(id,site_id,name) VALUES (%s,%s,'Stage A reader')",(zone,site))
|
||||
cur.execute("INSERT INTO agent_zone_network_ranges(agent_zone_id,network) VALUES (%s,'192.0.2.0/24')",(zone,))
|
||||
cur.execute("""INSERT INTO minerops_site_agents(agent_id,site_id,agent_zone_id,site_name)
|
||||
VALUES (%s,%s,%s,'Stage A reader') ON CONFLICT(agent_id) DO UPDATE SET
|
||||
site_id=EXCLUDED.site_id,agent_zone_id=EXCLUDED.agent_zone_id""",(AGENT,site,zone))
|
||||
cur.execute("INSERT INTO users(id,username,display_name,password_hash,role_id) SELECT %s,%s,'Reader','unused',id FROM roles WHERE name='Admin'",(actor,str(actor)))
|
||||
discovery=DiscoveryRepository(connect)
|
||||
observation=discovery.ingest(AGENT,{'discoveries':[{'ip_address':'192.0.2.1'}]})['discoveries'][0]
|
||||
user={'id':str(actor),'username':'stage-a-reader'}
|
||||
for kind in ('poll_known_miners_now','discover_new_miners'):
|
||||
discovery.queue({'agent_id':AGENT},user,kind)
|
||||
discovery.queue({'discovery_id':observation['id'],'version':observation['version']},user)
|
||||
commands = self.service.claim(AGENT)
|
||||
self.assertEqual(len(commands), 3)
|
||||
for command in commands:
|
||||
|
||||
Reference in new issue
Block a user