Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d89e80410 | ||
|
|
97a0139e3d | ||
|
|
3cfb0d1abe | ||
|
|
abf5654a25 | ||
|
|
b9b5e5e36f | ||
|
|
d9f0da694b | ||
|
|
82309db79a | ||
|
|
d8cd7ad058 |
No files matched your search
@@ -0,0 +1,12 @@
|
|||||||
|
# MinerOps repository-root Docker build context
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.dev.example
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
|
**/*.pyo
|
||||||
|
**/*.dump
|
||||||
|
**/*.sql.gz
|
||||||
|
backend/app/main.py.bak*
|
||||||
@@ -144,6 +144,7 @@ function Find-MacAddress {
|
|||||||
$config = Read-AgentConfig
|
$config = Read-AgentConfig
|
||||||
$hubUrl = ([string]$config.hub_url).TrimEnd("/")
|
$hubUrl = ([string]$config.hub_url).TrimEnd("/")
|
||||||
$secret = [string]$config.agent_secret
|
$secret = [string]$config.agent_secret
|
||||||
|
$agentId = [string]$config.agent_id
|
||||||
|
|
||||||
$commands = @(
|
$commands = @(
|
||||||
"summary",
|
"summary",
|
||||||
@@ -237,6 +238,7 @@ $response = Invoke-RestMethod `
|
|||||||
-Method Post `
|
-Method Post `
|
||||||
-Uri "$hubUrl/api/agent/miner-enrichment-v3" `
|
-Uri "$hubUrl/api/agent/miner-enrichment-v3" `
|
||||||
-Headers @{
|
-Headers @{
|
||||||
|
"X-MinerOps-Agent-ID" = $agentId
|
||||||
"X-MinerOps-Agent-Secret" = $secret
|
"X-MinerOps-Agent-Secret" = $secret
|
||||||
} `
|
} `
|
||||||
-ContentType "application/json" `
|
-ContentType "application/json" `
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -204,9 +204,11 @@ $testButton.Add_Click({
|
|||||||
}
|
}
|
||||||
|
|
||||||
$hub = [string]$config.hub_url
|
$hub = [string]$config.hub_url
|
||||||
|
$agentId = [string]$config.agent_id
|
||||||
$secret = [string]$config.agent_secret
|
$secret = [string]$config.agent_secret
|
||||||
|
|
||||||
$headers = @{
|
$headers = @{
|
||||||
|
"X-MinerOps-Agent-ID" = $agentId
|
||||||
"X-MinerOps-Agent-Secret" = $secret
|
"X-MinerOps-Agent-Secret" = $secret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,14 @@ function Send-AgentCheckin {
|
|||||||
local_time = (Get-Date).ToString("o")
|
local_time = (Get-Date).ToString("o")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace([string]$config.site_id)) {
|
||||||
|
$body["site_id"] = [string]$config.site_id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace([string]$config.agent_zone_id)) {
|
||||||
|
$body["agent_zone_id"] = [string]$config.agent_zone_id
|
||||||
|
}
|
||||||
|
|
||||||
return Invoke-AgentApi -Method "POST" -Path "/api/agent/checkin" -Body $body
|
return Invoke-AgentApi -Method "POST" -Path "/api/agent/checkin" -Body $body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ $SiteName = [string]$Config.site_name
|
|||||||
$Secret = [string]$Config.agent_secret
|
$Secret = [string]$Config.agent_secret
|
||||||
|
|
||||||
$Headers = @{
|
$Headers = @{
|
||||||
|
"X-MinerOps-Agent-ID" = $AgentId
|
||||||
"X-MinerOps-Agent-Secret" = $Secret
|
"X-MinerOps-Agent-Secret" = $Secret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ $AgentId = $Config.agent_id
|
|||||||
$AgentSecret = $Config.agent_secret
|
$AgentSecret = $Config.agent_secret
|
||||||
|
|
||||||
$Headers = @{
|
$Headers = @{
|
||||||
|
"X-MinerOps-Agent-ID" = $AgentId
|
||||||
"X-MinerOps-Agent-Secret" = $AgentSecret
|
"X-MinerOps-Agent-Secret" = $AgentSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+191
-24
@@ -15,8 +15,11 @@ probe output are intentionally excluded.
|
|||||||
## Local Files
|
## Local Files
|
||||||
|
|
||||||
Create `agent-config.json` from `config.example.json` on the Agent host. Never
|
Create `agent-config.json` from `config.example.json` on the Agent host. Never
|
||||||
commit that file. `site_id` is the durable MinerOps site UUID; `site_name` is
|
commit that file. `site_id` is the durable MinerOps site UUID. `agent_zone_id` is the optional
|
||||||
retained only for transitional display compatibility.
|
durable network-reachability Zone UUID; older configurations may omit it only
|
||||||
|
when the Hub can preserve an established assignment or resolve exactly one
|
||||||
|
enabled Zone at the Site. `site_name` is retained only for transitional display
|
||||||
|
compatibility.
|
||||||
|
|
||||||
The Agent writes runtime state to `agent-state.json` and logs under `logs/`.
|
The Agent writes runtime state to `agent-state.json` and logs under `logs/`.
|
||||||
Installation and upgrades preserve all three paths.
|
Installation and upgrades preserve all three paths.
|
||||||
@@ -27,9 +30,12 @@ API3 account and password values are local secrets. Missing credentials make
|
|||||||
miner control unavailable. Passwords, API tokens, salts, and the Agent secret
|
miner control unavailable. Passwords, API tokens, salts, and the Agent secret
|
||||||
must not be written to normal logs or returned to MinerOps command history.
|
must not be written to normal logs or returned to MinerOps command history.
|
||||||
|
|
||||||
The Internal Alpha supports one Agent identity protected by the deployment
|
New Agent identities use per-Agent credentials created through secure
|
||||||
shared secret. A second active Agent requires per-Agent credentials before it
|
one-time enrollment. The permanent Agent secret is returned only to the
|
||||||
may be enabled.
|
enrolling Agent and is stored locally in `agent-config.json`; MinerOps stores
|
||||||
|
only its hash. The legacy deployment shared secret remains only for explicitly
|
||||||
|
retained compatibility behavior and must not authenticate a newly enrolled
|
||||||
|
per-Agent identity.
|
||||||
|
|
||||||
## Legacy Compatibility
|
## Legacy Compatibility
|
||||||
|
|
||||||
@@ -40,26 +46,187 @@ Agent queue; the modular control backend does not connect directly to miners.
|
|||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Deploy Agent compatibility before enabling Hub enforcement of the
|
### New Agent enrollment
|
||||||
`X-MinerOps-Agent-ID` header:
|
|
||||||
|
|
||||||
1. Copy this directory to a temporary folder on the Windows Agent host.
|
For a new Agent, use Agent Management in MinerOps:
|
||||||
2. Create or update `C:\MinerOpsAgent\agent-config.json` locally. Do not pass
|
|
||||||
secrets as installer or scheduled-task arguments.
|
|
||||||
3. Run `install.ps1` from an elevated Windows PowerShell session.
|
|
||||||
4. Confirm the four scheduled tasks are registered and that Agent check-in
|
|
||||||
reports the expected non-secret `VERSION`.
|
|
||||||
5. Enable the matching Agent ID and shared secret on the Hub.
|
|
||||||
|
|
||||||
The installer copies runtime code to `C:\MinerOpsAgent`, preserves
|
1. Choose the Site and an enabled, unassigned Agent Zone.
|
||||||
`agent-config.json`, `agent-state.json`, and `logs\`, and registers or
|
2. Enter a descriptive Agent name.
|
||||||
updates `MinerOpsAgent`, `MinerOpsAgentTray`,
|
3. Create a Windows enrollment.
|
||||||
`MinerOpsApi3Enrichment`, and `MinerOpsPoolPoller`.
|
4. Download MinerOpsAgent-Windows.zip before the enrollment expires.
|
||||||
|
5. Copy the ZIP to the intended Windows machine and extract it.
|
||||||
|
6. Open Windows PowerShell as Administrator in the extracted
|
||||||
|
MinerOpsAgent-Windows directory.
|
||||||
|
7. Run:
|
||||||
|
|
||||||
Before replacing existing runtime files, the installer copies them to a
|
.\Enroll-MinerOpsAgent.ps1
|
||||||
timestamped folder under `C:\MinerOpsAgent\backups`. To roll back, stop the
|
|
||||||
Agent tasks, copy the selected backup's runtime files back into
|
|
||||||
`C:\MinerOpsAgent`, and restart the tasks. `uninstall.ps1` unregisters the
|
|
||||||
four tasks and removes runtime code while preserving local configuration,
|
|
||||||
state, logs, and backups.
|
|
||||||
|
|
||||||
|
8. Enter the local WhatsMiner API3 account/password when prompted, or leave
|
||||||
|
the account blank if API3 functionality is not being configured yet.
|
||||||
|
9. The bootstrapper exchanges the short-lived token with POST /api/agent/enroll,
|
||||||
|
saves the resulting permanent Agent identity locally, and hands off to the
|
||||||
|
safe runtime installer.
|
||||||
|
10. Verify the Agent appears in Agent Management and checks in successfully.
|
||||||
|
11. Delete the downloaded ZIP and extracted setup directory.
|
||||||
|
|
||||||
|
Do not place enrollment.json, enrollment tokens, Agent secrets, or API3
|
||||||
|
credentials in screenshots, chat, tickets, logs, or source control.
|
||||||
|
|
||||||
|
### Existing Agent upgrade
|
||||||
|
|
||||||
|
An existing legacy Agent must receive a per-Agent credential before the global
|
||||||
|
shared-secret compatibility path can be retired for its identity. In Agent
|
||||||
|
Management, use **Upgrade credential** on that Agent. Download the resulting
|
||||||
|
Windows package, transfer it only to that Agent computer, and run:
|
||||||
|
|
||||||
|
.\Upgrade-MinerOpsAgentCredential.ps1
|
||||||
|
|
||||||
|
The authorization is bound server-side to the existing Agent ID and its
|
||||||
|
current Site and Agent Zone. The script validates the local Agent ID, upgrades
|
||||||
|
the canonical runtime, stops the main Agent task, generates the permanent
|
||||||
|
secret locally, and recoverably replaces only the credential and durable routing
|
||||||
|
fields in `agent-config.json` using protected same-directory pending and prior
|
||||||
|
names. Existing API3 credentials, polling settings,
|
||||||
|
network ranges, state, logs, and backups are preserved. Before any secret is
|
||||||
|
written, the script creates and verifies a protected recovery file containing
|
||||||
|
the original Scheduled Task state, exact transition token, and exact locally
|
||||||
|
generated credential. The Hub accepts an ambiguous retry only for that same
|
||||||
|
token and credential.
|
||||||
|
|
||||||
|
The Agent directory is a filesystem security boundary. The transition rejects
|
||||||
|
junctions/reparse points, verifies trusted ancestors, denies standard users
|
||||||
|
create/delete/rename rights, and revalidates the boundary before each rename.
|
||||||
|
If an existing Agent directory is discovered insecure, the script first writes
|
||||||
|
`C:\MinerOpsAgent.minerops-filesystem-trust.blocked-<random>.state` under the trusted
|
||||||
|
parent, then hardens the directory only to contain the incident. The blocker is
|
||||||
|
outside the contaminated namespace because changing a DACL does not revoke
|
||||||
|
file or directory handles opened before repair. The same process never redeems
|
||||||
|
a token, creates or activates a permanent credential, runs the installer, or
|
||||||
|
removes that blocker. Ordinary second, third, and later invocations remain
|
||||||
|
blocked even when every current ACL looks correct.
|
||||||
|
|
||||||
|
Recovery, pending, replacement remnants, and late-arriving artifacts are
|
||||||
|
non-authoritative while the external blocker exists. Known transition state is
|
||||||
|
quarantined without parsing. A correct filename, syntax, or ACL cannot promote
|
||||||
|
it on a later run. Invalidate any possibly exposed authorization and retain or
|
||||||
|
remove evidence only under the approved incident procedure.
|
||||||
|
|
||||||
|
### Filesystem trust-epoch recovery
|
||||||
|
|
||||||
|
Use this procedure only after confirming the blocker was created by the
|
||||||
|
containment event and preserving required evidence:
|
||||||
|
|
||||||
|
1. Do not delete or edit the external blocker. Do not run an ordinary retry.
|
||||||
|
2. Use **Restart** to perform a full Windows kernel boot. Shutdown with Fast
|
||||||
|
Startup is not sufficient: Microsoft documents Fast Startup as hibernating
|
||||||
|
and restoring kernel session 0. The script identifies the kernel epoch by
|
||||||
|
the Event Log record anchor of the newest
|
||||||
|
`Microsoft-Windows-Kernel-Boot` event 27 whose `BootType` is exactly `0`.
|
||||||
|
Provider, event ID, record ID, and supported event version come from typed
|
||||||
|
EventRecord properties; namespace-aware XML selection reads only the
|
||||||
|
`BootType` payload. The new full-boot record ID must be strictly greater
|
||||||
|
than the stored record ID. Equality, an older/restored record, log clearing
|
||||||
|
or numbering reset, missing history, duplication, malformed XML, and an
|
||||||
|
unsupported provider schema all fail closed and require safe operator
|
||||||
|
intervention. There is no timestamp fallback. The anchor is unchanged by
|
||||||
|
clock or timezone changes, sleep, hibernation, and Fast Startup. See
|
||||||
|
Microsoft's [system power-state documentation](https://learn.microsoft.com/windows/win32/power/system-power-states)
|
||||||
|
and [Fast Startup support guidance](https://learn.microsoft.com/troubleshoot/windows-client/setup-upgrade-and-drivers/fast-startup-causes-system-hibernation-shutdown-fail).
|
||||||
|
3. For a credential transition, use a fresh transition package if any recovery,
|
||||||
|
pending, remnant, quarantine, or internal blocker existed. Remove those
|
||||||
|
artifacts only under the approved incident procedure after invalidating the
|
||||||
|
old authorization.
|
||||||
|
4. From the reviewed package, run:
|
||||||
|
|
||||||
|
.\Upgrade-MinerOpsAgentCredential.ps1 `
|
||||||
|
-ApproveFilesystemTrustResetAfterReboot
|
||||||
|
|
||||||
|
The script verifies that Windows boot identity changed, rescans the protected
|
||||||
|
namespace, positively verifies and stops the expected Scheduled Task, and asks the
|
||||||
|
installer to replace managed runtime without backing up or restoring any
|
||||||
|
pre-reset runtime. The installer leaves both the Agent task and external
|
||||||
|
blocker untouched after publication; it has no blocker-deletion path. Before
|
||||||
|
its first fallible stop/install step, the parent durably records the strictly
|
||||||
|
validated original task intent as `reset-authorized`; after installer
|
||||||
|
success it advances the blocker to `runtime-published`. It then verifies the
|
||||||
|
config against the package/server identity, commits the credential, and
|
||||||
|
restores the recorded task intent. Only the parent removes the blocker,
|
||||||
|
after active config and final task state have both been positively verified
|
||||||
|
and non-recovery transition cleanup has succeeded. Protected recovery stays
|
||||||
|
until after blocker deletion so an interrupted final cleanup remains safe.
|
||||||
|
5. For a genuinely new enrollment, preserve evidence and use the approved
|
||||||
|
protected-recreation incident procedure to remove the contaminated namespace
|
||||||
|
and its external blocker, then let `Enroll-MinerOpsAgent.ps1` stage a new
|
||||||
|
protected directory. Enrollment has no in-script blocker-deletion path; its
|
||||||
|
explicit compatibility switch cannot override a retained blocker.
|
||||||
|
|
||||||
|
The explicit switch without a strictly newer canonical full-kernel-boot anchor
|
||||||
|
fails.
|
||||||
|
Any suspicious transition artifact, failed runtime publication, failed
|
||||||
|
credential convergence, invalid final task, or interrupted cleanup retains the
|
||||||
|
blocker. Runtime publication alone never authorizes deletion. Protected
|
||||||
|
directory recreation is the alternative when the retained namespace cannot be
|
||||||
|
verified.
|
||||||
|
|
||||||
|
If the script is interrupted after the Hub commits, rerun the same extracted
|
||||||
|
package without deleting or editing `agent-credential-transition.recovery.json`
|
||||||
|
or `agent-config.json.pending`. A protected, matching pending config is resumed;
|
||||||
|
an insecure, malformed, or mismatched artifact fails closed. The original task
|
||||||
|
intent is durable: an Agent which was running before the first attempt ends
|
||||||
|
running, while an intentionally stopped Agent remains stopped. Success is not
|
||||||
|
reported and transition artifacts are not removed until both the active config
|
||||||
|
and expected final task state are verified. The verifier requires exactly one
|
||||||
|
enabled root `MinerOpsAgent` task, the SYSTEM service-account/highest principal,
|
||||||
|
the installed Windows PowerShell action and service-script arguments, and an
|
||||||
|
exact final state of `Running` for running intent or `Ready` for stopped intent.
|
||||||
|
Missing, query failure, access denied, malformed, multiple, disabled, queued,
|
||||||
|
unknown, wrong-action, and wrong-principal results all fail closed and retain
|
||||||
|
recovery.
|
||||||
|
|
||||||
|
After a successful transition, the existing Agent ID, Site, and Zone are
|
||||||
|
unchanged. Explicit Agent ID plus the new per-Agent secret is required, and the
|
||||||
|
legacy shared secret is permanently disabled for that identity.
|
||||||
|
|
||||||
|
For later code-only upgrades of an Agent that already has a per-Agent
|
||||||
|
credential, copy the new canonical Agent files to a temporary directory and run
|
||||||
|
install.ps1 from an elevated Windows PowerShell session.
|
||||||
|
|
||||||
|
The installer copies only managed runtime code to C:\MinerOpsAgent and
|
||||||
|
preserves agent-config.json, agent-state.json, logs\, backups, probe output,
|
||||||
|
and other runtime data. Existing Scheduled Task definitions are authoritative
|
||||||
|
and are not rewritten. Missing tasks are created for a fresh or incomplete
|
||||||
|
installation without hard-coded user names.
|
||||||
|
|
||||||
|
The per-user `MinerOpsAgentTray` task is optional UI and is not credential or
|
||||||
|
core Agent authority. During an explicit filesystem trust reset, runtime
|
||||||
|
publication may run noninteractively as `NT AUTHORITY\SYSTEM`, where no safe
|
||||||
|
interactive user principal can be inferred. The installer still publishes
|
||||||
|
`MinerOpsAgentTray.ps1`, but deliberately defers creation of a missing tray task
|
||||||
|
until a later interactive maintenance install. It never creates the tray task
|
||||||
|
as SYSTEM or invents a username. An existing tray task remains authoritative
|
||||||
|
and is preserved unchanged rather than silently repaired under SYSTEM; inspect
|
||||||
|
and remediate a suspected malformed definition separately. The core
|
||||||
|
`MinerOpsAgent` task remains mandatory and strictly verified by credential
|
||||||
|
recovery. `MinerOpsApi3Enrichment` and `MinerOpsPoolPoller` are non-interactive
|
||||||
|
background tasks with explicit SYSTEM principals, so missing instances continue
|
||||||
|
to be created during trust-reset publication.
|
||||||
|
|
||||||
|
If MinerOpsAgent was running before an upgrade, the installer stops only that
|
||||||
|
Scheduled Task, waits for it to stop, replaces the managed files, and starts it
|
||||||
|
again. A previously stopped Agent remains stopped. Before replacing runtime
|
||||||
|
files, the installer copies them to a timestamped backup under
|
||||||
|
C:\MinerOpsAgent\backups.
|
||||||
|
|
||||||
|
Runtime sources, installed destinations, active config, and rollback copies are
|
||||||
|
rejected when they are reparse points or have more than one hard link. A normal
|
||||||
|
trusted upgrade creates a fresh random backup directory and never adopts an
|
||||||
|
existing backup destination. During an explicit post-reboot trust reset, old
|
||||||
|
runtime is never copied into backup or restored; fresh package content replaces
|
||||||
|
it while the external blocker remains, and the Agent task stays stopped for
|
||||||
|
credential verification.
|
||||||
|
|
||||||
|
A failed deployment restores managed files, removes tasks created by that
|
||||||
|
failed attempt, and restores the main Agent's prior running state.
|
||||||
|
|
||||||
|
uninstall.ps1 unregisters the four MinerOps Scheduled Tasks and removes runtime
|
||||||
|
code while preserving local configuration, state, logs, and backups.
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,7 @@
|
|||||||
"hub_url": "https://minerops.example.invalid",
|
"hub_url": "https://minerops.example.invalid",
|
||||||
"agent_id": "replace-with-agent-id",
|
"agent_id": "replace-with-agent-id",
|
||||||
"site_id": "00000000-0000-0000-0000-000000000000",
|
"site_id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"agent_zone_id": "00000000-0000-0000-0000-000000000000",
|
||||||
"site_name": "Example Site",
|
"site_name": "Example Site",
|
||||||
"agent_secret": "replace-with-agent-secret",
|
"agent_secret": "replace-with-agent-secret",
|
||||||
"poll_interval_seconds": 120,
|
"poll_interval_seconds": 120,
|
||||||
|
|||||||
+1215
-24
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,247 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Operation,
|
||||||
|
[Parameter(Mandatory = $true)][string]$InputPath,
|
||||||
|
[string]$DestinationPath = "",
|
||||||
|
[ValidateSet("None", "Path")][string]$BackupMode = "None",
|
||||||
|
[string]$BackupPath = "",
|
||||||
|
[Parameter(Mandatory = $true)][string]$ResultPath,
|
||||||
|
[Parameter(Mandatory = $true)][string]$SupportPath
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
. $SupportPath
|
||||||
|
|
||||||
|
$sourceCreatedByCaller = $false
|
||||||
|
$sourceReadVerified = $false
|
||||||
|
$sourceModifyVerified = $false
|
||||||
|
$sourceRenameVerified = $false
|
||||||
|
$sourceDeleteAccessVerified = $false
|
||||||
|
$sourceReplaceAccessVerified = $false
|
||||||
|
$replaceInvoked = $false
|
||||||
|
$sourceExistedBeforeAttack = $null
|
||||||
|
$destinationExistedBeforeAttack = $null
|
||||||
|
$backupExistedBeforeAttack = $null
|
||||||
|
$inputFullPath = ""
|
||||||
|
$destinationFullPath = ""
|
||||||
|
$backupFullPath = ""
|
||||||
|
$backupArgumentWasNativeNull = $false
|
||||||
|
|
||||||
|
function Get-ProbeMetadata {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Status,
|
||||||
|
[Exception]$Exception
|
||||||
|
)
|
||||||
|
|
||||||
|
$inputOwnedByCaller = $false
|
||||||
|
if ([IO.File]::Exists($InputPath)) {
|
||||||
|
try {
|
||||||
|
$callerSid = [Security.Principal.WindowsIdentity]::GetCurrent().User
|
||||||
|
$ownerSid = [IO.File]::GetAccessControl($InputPath).GetOwner(
|
||||||
|
[Security.Principal.SecurityIdentifier]
|
||||||
|
)
|
||||||
|
$inputOwnedByCaller = $callerSid.Equals($ownerSid)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$inputOwnedByCaller = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$details = if ($null -ne $Exception) {
|
||||||
|
Get-MinerOpsNativeExceptionDetails -Exception $Exception
|
||||||
|
}
|
||||||
|
else { $null }
|
||||||
|
return [ordered]@{
|
||||||
|
status = $Status
|
||||||
|
operation = $Operation
|
||||||
|
# Retain these two outer-exception fields for compatibility with
|
||||||
|
# earlier native-harness result readers.
|
||||||
|
exception_type = if ($null -ne $Exception) {
|
||||||
|
$Exception.GetType().FullName
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
hresult = if ($null -ne $details) { $details.OuterHResult } else { 0 }
|
||||||
|
outer_exception_type = if ($null -ne $details) {
|
||||||
|
$details.OuterExceptionType
|
||||||
|
}
|
||||||
|
else { "" }
|
||||||
|
outer_hresult = if ($null -ne $details) {
|
||||||
|
$details.OuterHResult
|
||||||
|
}
|
||||||
|
else { 0 }
|
||||||
|
leaf_exception_type = if ($null -ne $details) {
|
||||||
|
$details.LeafExceptionType
|
||||||
|
}
|
||||||
|
else { "" }
|
||||||
|
leaf_hresult = if ($null -ne $details) {
|
||||||
|
$details.LeafHResult
|
||||||
|
}
|
||||||
|
else { 0 }
|
||||||
|
native_error = if ($null -ne $details) {
|
||||||
|
$details.NativeError
|
||||||
|
}
|
||||||
|
else { $null }
|
||||||
|
exception_chain_types = if ($null -ne $details) {
|
||||||
|
@($details.ExceptionChainTypes)
|
||||||
|
}
|
||||||
|
else { @() }
|
||||||
|
exception_chain_truncated = if ($null -ne $details) {
|
||||||
|
[bool]$details.ExceptionChainTruncated
|
||||||
|
}
|
||||||
|
else { $false }
|
||||||
|
source_exists = [IO.File]::Exists($InputPath)
|
||||||
|
destination_exists = if (
|
||||||
|
[string]::IsNullOrWhiteSpace($DestinationPath)
|
||||||
|
) {
|
||||||
|
$null
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
[IO.File]::Exists($DestinationPath)
|
||||||
|
}
|
||||||
|
input_owned_by_caller = $inputOwnedByCaller
|
||||||
|
source_created_by_caller = $sourceCreatedByCaller
|
||||||
|
source_read_verified = $sourceReadVerified
|
||||||
|
source_modify_verified = $sourceModifyVerified
|
||||||
|
source_rename_verified = $sourceRenameVerified
|
||||||
|
source_delete_access_verified = $sourceDeleteAccessVerified
|
||||||
|
source_replace_access_verified = $sourceReplaceAccessVerified
|
||||||
|
input_full_path = $inputFullPath
|
||||||
|
destination_full_path = $destinationFullPath
|
||||||
|
backup_mode = $BackupMode
|
||||||
|
backup_full_path = $backupFullPath
|
||||||
|
backup_argument_was_native_null = $backupArgumentWasNativeNull
|
||||||
|
source_existed_before_attack = $sourceExistedBeforeAttack
|
||||||
|
destination_existed_before_attack = $destinationExistedBeforeAttack
|
||||||
|
backup_existed_before_attack = $backupExistedBeforeAttack
|
||||||
|
replace_overload = "System.IO.File.Replace(String,String,String)"
|
||||||
|
replace_invoked = $replaceInvoked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-ProbeResult {
|
||||||
|
param([Parameter(Mandatory = $true)]$Value)
|
||||||
|
|
||||||
|
$json = $Value | ConvertTo-Json -Compress
|
||||||
|
[IO.File]::WriteAllText($ResultPath, $json)
|
||||||
|
}
|
||||||
|
|
||||||
|
$exception = $null
|
||||||
|
$status = "succeeded"
|
||||||
|
try {
|
||||||
|
switch ($Operation) {
|
||||||
|
"read" { [void][IO.File]::ReadAllText($InputPath) }
|
||||||
|
"create" { [IO.File]::WriteAllText($InputPath, "untrusted") }
|
||||||
|
"modify" { [IO.File]::WriteAllText($InputPath, "untrusted") }
|
||||||
|
"delete" { [IO.File]::Delete($InputPath) }
|
||||||
|
"rename" { [IO.File]::Move($InputPath, $DestinationPath) }
|
||||||
|
"prepare_swap" {
|
||||||
|
if ([IO.File]::Exists($InputPath)) {
|
||||||
|
throw [ArgumentException]::new(
|
||||||
|
"swap source unexpectedly existed before caller creation"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
$renameProof = "$InputPath.rename-proof-$([guid]::NewGuid().ToString('N'))"
|
||||||
|
[IO.File]::WriteAllText(
|
||||||
|
$InputPath,
|
||||||
|
"attacker-controlled-swap-source"
|
||||||
|
)
|
||||||
|
$sourceCreatedByCaller = $true
|
||||||
|
$sourceReadVerified = [IO.File]::ReadAllText($InputPath) -ceq
|
||||||
|
"attacker-controlled-swap-source"
|
||||||
|
[IO.File]::AppendAllText($InputPath, "-modified")
|
||||||
|
$sourceModifyVerified = [IO.File]::ReadAllText($InputPath) -ceq
|
||||||
|
"attacker-controlled-swap-source-modified"
|
||||||
|
[IO.File]::Move($InputPath, $renameProof)
|
||||||
|
[IO.File]::Move($renameProof, $InputPath)
|
||||||
|
$sourceRenameVerified = [IO.File]::Exists($InputPath) -and
|
||||||
|
-not [IO.File]::Exists($renameProof)
|
||||||
|
|
||||||
|
$requiredRights =
|
||||||
|
[Security.AccessControl.FileSystemRights]::ReadData -bor
|
||||||
|
[Security.AccessControl.FileSystemRights]::WriteData -bor
|
||||||
|
[Security.AccessControl.FileSystemRights]::Delete -bor
|
||||||
|
[Security.AccessControl.FileSystemRights]::ChangePermissions -bor
|
||||||
|
[Security.AccessControl.FileSystemRights]::Synchronize
|
||||||
|
$rightsProbe = [IO.FileStream]::new(
|
||||||
|
$InputPath,
|
||||||
|
[IO.FileMode]::Open,
|
||||||
|
$requiredRights,
|
||||||
|
[IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete,
|
||||||
|
4096,
|
||||||
|
[IO.FileOptions]::None
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
$sourceDeleteAccessVerified = $true
|
||||||
|
$sourceReplaceAccessVerified = $true
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$rightsProbe.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"swap" {
|
||||||
|
$inputFullPath = [IO.Path]::GetFullPath($InputPath)
|
||||||
|
$destinationFullPath = [IO.Path]::GetFullPath($DestinationPath)
|
||||||
|
if ($inputFullPath.Equals(
|
||||||
|
$destinationFullPath,
|
||||||
|
[StringComparison]::OrdinalIgnoreCase
|
||||||
|
)) {
|
||||||
|
throw [ArgumentException]::new(
|
||||||
|
"swap source and destination are the same path"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceExistedBeforeAttack = [IO.File]::Exists($inputFullPath)
|
||||||
|
$destinationExistedBeforeAttack =
|
||||||
|
[IO.File]::Exists($destinationFullPath)
|
||||||
|
if (-not $sourceExistedBeforeAttack) {
|
||||||
|
throw [IO.FileNotFoundException]::new(
|
||||||
|
"swap source does not exist",
|
||||||
|
$inputFullPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (-not $destinationExistedBeforeAttack) {
|
||||||
|
throw [IO.FileNotFoundException]::new(
|
||||||
|
"swap destination does not exist",
|
||||||
|
$destinationFullPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($BackupMode -ceq "None") {
|
||||||
|
if (-not [string]::IsNullOrEmpty($BackupPath)) {
|
||||||
|
throw [ArgumentException]::new(
|
||||||
|
"backup path was supplied while backup mode is None"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
# A bare PowerShell $null is coerced to String.Empty for this
|
||||||
|
# .NET string parameter. NullString is the explicit native
|
||||||
|
# null required to request no ReplaceFile backup.
|
||||||
|
$backupArgumentWasNativeNull = $true
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw [ArgumentException]::new(
|
||||||
|
"backup-producing swap fixtures are prohibited because " +
|
||||||
|
"they can expose protected destination content"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
$replaceInvoked = $true
|
||||||
|
[IO.File]::Replace(
|
||||||
|
$inputFullPath,
|
||||||
|
$destinationFullPath,
|
||||||
|
[NullString]::Value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
default { throw [ArgumentException]::new("unknown operation") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$exception = $_.Exception
|
||||||
|
$status = Get-MinerOpsNativeExceptionStatus `
|
||||||
|
-Operation $Operation -Exception $exception
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = Get-ProbeMetadata -Status $status -Exception $exception
|
||||||
|
Write-ProbeResult -Value $result
|
||||||
|
exit (Get-MinerOpsNativeStatusExitCode -Status $status)
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Get-MinerOpsNativeGetItemState {
|
||||||
|
$effective = Get-Command Get-Item -ErrorAction Stop
|
||||||
|
$function = if (
|
||||||
|
$effective.CommandType -eq
|
||||||
|
[Management.Automation.CommandTypes]::Function
|
||||||
|
) {
|
||||||
|
$effective
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$null
|
||||||
|
}
|
||||||
|
|
||||||
|
return [pscustomobject]@{
|
||||||
|
FunctionExisted = $null -ne $function
|
||||||
|
FunctionScriptBlock = if ($null -ne $function) {
|
||||||
|
$function.ScriptBlock
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$null
|
||||||
|
}
|
||||||
|
EffectiveCommandType = [string]$effective.CommandType
|
||||||
|
EffectiveName = [string]$effective.Name
|
||||||
|
EffectiveModuleName = [string]$effective.ModuleName
|
||||||
|
EffectiveSource = [string]$effective.Source
|
||||||
|
EffectiveDefinition = [string]$effective.Definition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-MinerOpsNativeGetItemState {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]$Expected,
|
||||||
|
[string]$Context = "Get-Item state check"
|
||||||
|
)
|
||||||
|
|
||||||
|
$actual = Get-MinerOpsNativeGetItemState
|
||||||
|
if ([bool]$actual.FunctionExisted -ne [bool]$Expected.FunctionExisted) {
|
||||||
|
throw "$Context changed the global Get-Item function's existence."
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
[bool]$Expected.FunctionExisted -and
|
||||||
|
(
|
||||||
|
-not [object]::ReferenceEquals(
|
||||||
|
$actual.FunctionScriptBlock,
|
||||||
|
$Expected.FunctionScriptBlock
|
||||||
|
) -or
|
||||||
|
$actual.FunctionScriptBlock.ToString() -cne
|
||||||
|
$Expected.FunctionScriptBlock.ToString()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw "$Context changed the global Get-Item function's ScriptBlock."
|
||||||
|
}
|
||||||
|
foreach ($property in @(
|
||||||
|
"EffectiveCommandType",
|
||||||
|
"EffectiveName",
|
||||||
|
"EffectiveModuleName",
|
||||||
|
"EffectiveSource",
|
||||||
|
"EffectiveDefinition"
|
||||||
|
)) {
|
||||||
|
if ([string]$actual.$property -cne [string]$Expected.$property) {
|
||||||
|
throw "$Context changed effective Get-Item resolution ($property)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-MinerOpsNativeGetItemTrace {
|
||||||
|
param([Parameter(Mandatory = $true)][scriptblock]$Action)
|
||||||
|
|
||||||
|
$before = Get-MinerOpsNativeGetItemState
|
||||||
|
$observedGetItemPaths = [System.Collections.Generic.List[string]]::new()
|
||||||
|
$invokeWithLocalHook = {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][scriptblock]$TraceAction,
|
||||||
|
[Parameter(Mandatory = $true)]$ObservedPaths
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use a child scope because PowerShell can continue resolving a
|
||||||
|
# removed active function until the scope that defined it exits.
|
||||||
|
# Exiting this child scope makes cleanup deterministic before the
|
||||||
|
# caller-state assertion runs.
|
||||||
|
Set-Item -LiteralPath "Function:\local:Get-Item" -Value {
|
||||||
|
[CmdletBinding(DefaultParameterSetName = "Path")]
|
||||||
|
param(
|
||||||
|
[Parameter(Position = 0, ParameterSetName = "Path")]
|
||||||
|
[string[]]$Path,
|
||||||
|
[Parameter(Mandatory = $true, ParameterSetName = "LiteralPath")]
|
||||||
|
[Alias("PSPath")]
|
||||||
|
[string[]]$LiteralPath,
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$observed = if ($PSCmdlet.ParameterSetName -eq "LiteralPath") {
|
||||||
|
$LiteralPath
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$Path
|
||||||
|
}
|
||||||
|
foreach ($candidate in @($observed)) {
|
||||||
|
$ObservedPaths.Add([string]$candidate)
|
||||||
|
}
|
||||||
|
Microsoft.PowerShell.Management\Get-Item @PSBoundParameters
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
& $TraceAction | Out-Null
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Remove-Item -LiteralPath "Function:\local:Get-Item" -Force `
|
||||||
|
-ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
& $invokeWithLocalHook $Action $observedGetItemPaths
|
||||||
|
return @($observedGetItemPaths)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||||
|
-Context "Invoke-MinerOpsNativeGetItemTrace cleanup"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-MinerOpsNativeWin32Error {
|
||||||
|
param([Parameter(Mandatory = $true)][int]$HResult)
|
||||||
|
|
||||||
|
# HRESULT_FROM_WIN32 values have the 0x8007 facility prefix. Do not treat
|
||||||
|
# the low word of an arbitrary CLR or PowerShell HRESULT as a Win32 error.
|
||||||
|
$unsignedHResult = [int64]$HResult -band 4294967295L
|
||||||
|
if (
|
||||||
|
($unsignedHResult -band 4294901760L) -eq 2147942400L
|
||||||
|
) {
|
||||||
|
return [int]($unsignedHResult -band 65535L)
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-MinerOpsNativeExceptionDetails {
|
||||||
|
param([Parameter(Mandatory = $true)][Exception]$Exception)
|
||||||
|
|
||||||
|
$maximumDepth = 32
|
||||||
|
$visited = [System.Collections.Generic.List[Exception]]::new()
|
||||||
|
$chainTypes = [System.Collections.Generic.List[string]]::new()
|
||||||
|
$current = $Exception
|
||||||
|
$leaf = $Exception
|
||||||
|
$truncated = $false
|
||||||
|
|
||||||
|
for ($depth = 0; $depth -lt $maximumDepth; $depth++) {
|
||||||
|
if ($null -eq $current) { break }
|
||||||
|
|
||||||
|
$alreadyVisited = $false
|
||||||
|
foreach ($candidate in $visited) {
|
||||||
|
if ([object]::ReferenceEquals($candidate, $current)) {
|
||||||
|
$alreadyVisited = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($alreadyVisited) {
|
||||||
|
$truncated = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
$visited.Add($current)
|
||||||
|
$chainTypes.Add($current.GetType().FullName)
|
||||||
|
$leaf = $current
|
||||||
|
$current = $current.InnerException
|
||||||
|
}
|
||||||
|
if ($null -ne $current) {
|
||||||
|
$truncated = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$nativeError = Get-MinerOpsNativeWin32Error -HResult ([int]$leaf.HResult)
|
||||||
|
return [pscustomobject]@{
|
||||||
|
OuterException = $Exception
|
||||||
|
OuterExceptionType = $Exception.GetType().FullName
|
||||||
|
OuterHResult = [int]$Exception.HResult
|
||||||
|
LeafException = $leaf
|
||||||
|
LeafExceptionType = $leaf.GetType().FullName
|
||||||
|
LeafHResult = [int]$leaf.HResult
|
||||||
|
NativeError = $nativeError
|
||||||
|
ExceptionChainTypes = @($chainTypes)
|
||||||
|
ExceptionChainTruncated = $truncated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-MinerOpsNativeExceptionStatus {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Operation,
|
||||||
|
[Parameter(Mandatory = $true)][Exception]$Exception
|
||||||
|
)
|
||||||
|
|
||||||
|
$details = Get-MinerOpsNativeExceptionDetails -Exception $Exception
|
||||||
|
$meaningfulException = $details.LeafException
|
||||||
|
$nativeError = $details.NativeError
|
||||||
|
|
||||||
|
if ($details.ExceptionChainTruncated) {
|
||||||
|
return "unexpected_failure"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($meaningfulException -is [UnauthorizedAccessException]) {
|
||||||
|
return "security_denied"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$meaningfulException -is [ArgumentException] -or
|
||||||
|
$meaningfulException -is [IO.FileNotFoundException] -or
|
||||||
|
$meaningfulException -is [IO.DirectoryNotFoundException] -or
|
||||||
|
$meaningfulException -is [IO.DriveNotFoundException] -or
|
||||||
|
$meaningfulException -is [IO.PathTooLongException] -or
|
||||||
|
$meaningfulException -is [NotSupportedException]
|
||||||
|
) {
|
||||||
|
return "fixture_error"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$meaningfulException -is [IO.IOException] -and
|
||||||
|
$null -ne $nativeError
|
||||||
|
) {
|
||||||
|
if ([int]$nativeError -eq 5) {
|
||||||
|
return "security_denied"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Operation -ceq "swap" -and [int]$nativeError -eq 1175) {
|
||||||
|
# ReplaceFileW documents ERROR_UNABLE_TO_REMOVE_REPLACED as
|
||||||
|
# retaining both original names. The caller must still prove every
|
||||||
|
# security and identity postcondition before treating this as a
|
||||||
|
# passing denial.
|
||||||
|
return "security_denied_requires_postconditions"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([int]$nativeError -eq 17) {
|
||||||
|
# ReplaceFile requires source and destination on the same volume.
|
||||||
|
# This says nothing about the protected target's access boundary.
|
||||||
|
return "api_precondition_failure"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unexpected_failure"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-MinerOpsNativeStatusExitCode {
|
||||||
|
param([Parameter(Mandatory = $true)][string]$Status)
|
||||||
|
|
||||||
|
switch ($Status) {
|
||||||
|
"succeeded" { return 0 }
|
||||||
|
"security_denied" { return 5 }
|
||||||
|
"fixture_error" { return 6 }
|
||||||
|
"unexpected_failure" { return 7 }
|
||||||
|
"security_denied_requires_postconditions" { return 8 }
|
||||||
|
"api_precondition_failure" { return 9 }
|
||||||
|
default { throw "Unknown standard-user helper status '$Status'." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-MinerOpsNativeSwapFixtureStatus {
|
||||||
|
param([Parameter(Mandatory = $true)]$Fixture)
|
||||||
|
|
||||||
|
$fixtureFailures = [System.Collections.Generic.List[string]]::new()
|
||||||
|
$requiredFixtureChecks = [ordered]@{
|
||||||
|
SourceExists = "source is missing"
|
||||||
|
TargetExists = "target is missing"
|
||||||
|
PathsDistinct = "source and target are the same path"
|
||||||
|
SourceCreatedByStandardUser = `
|
||||||
|
"source was not created by the standard-user process"
|
||||||
|
SourceOwnedByStandardUser = `
|
||||||
|
"standard user does not own the source"
|
||||||
|
SourceReadable = "standard user cannot read the source"
|
||||||
|
SourceModifiable = "standard user cannot modify the source"
|
||||||
|
SourceRenameable = "standard user cannot rename the source"
|
||||||
|
SourceDeleteCapable = `
|
||||||
|
"standard user does not have required delete access to the source"
|
||||||
|
SourceReplaceAccessVerified = `
|
||||||
|
"standard user lacks source access required by ReplaceFile"
|
||||||
|
TargetAclTrusted = "target ACL was not trusted before attack"
|
||||||
|
TargetOwnerTrusted = "target owner was not trusted before attack"
|
||||||
|
TargetIdentityCaptured = "target identity was not captured"
|
||||||
|
SourceIdentityCaptured = "source identity was not captured"
|
||||||
|
FileIdentitiesDistinct = "source and target resolve to the same file"
|
||||||
|
BackupArgumentValid = "backup argument is invalid"
|
||||||
|
BackupPathsDistinct = `
|
||||||
|
"backup path is not distinct from source and target"
|
||||||
|
BackupExpectedStateVerified = `
|
||||||
|
"backup pre-attack state was not verified"
|
||||||
|
BackupConfidentialitySafe = `
|
||||||
|
"backup choice could expose protected target content"
|
||||||
|
}
|
||||||
|
foreach ($entry in $requiredFixtureChecks.GetEnumerator()) {
|
||||||
|
if (-not [bool]$Fixture.($entry.Key)) {
|
||||||
|
$fixtureFailures.Add([string]$entry.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($fixtureFailures.Count -gt 0) {
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Status = "fixture_error"
|
||||||
|
Failures = @($fixtureFailures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiFailures = [System.Collections.Generic.List[string]]::new()
|
||||||
|
if (-not [bool]$Fixture.SameVolume) {
|
||||||
|
$apiFailures.Add("source, target, and backup are not on one volume")
|
||||||
|
}
|
||||||
|
if (-not [bool]$Fixture.NtfsVolume) {
|
||||||
|
$apiFailures.Add("attack fixture is not on NTFS")
|
||||||
|
}
|
||||||
|
if ($apiFailures.Count -gt 0) {
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Status = "api_precondition_failure"
|
||||||
|
Failures = @($apiFailures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Status = "ready"
|
||||||
|
Failures = @()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-MinerOpsNativeSwapFixtureReady {
|
||||||
|
param([Parameter(Mandatory = $true)]$Fixture)
|
||||||
|
|
||||||
|
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $Fixture
|
||||||
|
switch ([string]$classification.Status) {
|
||||||
|
"ready" { return }
|
||||||
|
"fixture_error" {
|
||||||
|
throw "Standard-user swap fixture is invalid: " +
|
||||||
|
"$(@($classification.Failures) -join '; ')."
|
||||||
|
}
|
||||||
|
"api_precondition_failure" {
|
||||||
|
throw "Standard-user swap has an API precondition failure: " +
|
||||||
|
"$(@($classification.Failures) -join '; ')."
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
throw "Standard-user swap fixture classification failed closed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-MinerOpsNativeSwapSecurityOutcome {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]$Result,
|
||||||
|
[Parameter(Mandatory = $true)]$Postconditions
|
||||||
|
)
|
||||||
|
|
||||||
|
if ([string]$Result.operation -cne "swap") {
|
||||||
|
throw "Swap result recorded the wrong operation."
|
||||||
|
}
|
||||||
|
$detail = "outer=$([string]$Result.outer_exception_type), " +
|
||||||
|
"outer_hresult=$([string]$Result.outer_hresult), " +
|
||||||
|
"leaf=$([string]$Result.leaf_exception_type), " +
|
||||||
|
"leaf_hresult=$([string]$Result.leaf_hresult), " +
|
||||||
|
"native=$([string]$Result.native_error), " +
|
||||||
|
"chain=$(@($Result.exception_chain_types) -join ' -> ')"
|
||||||
|
switch ([string]$Result.status) {
|
||||||
|
"succeeded" {
|
||||||
|
if (-not [bool]$Result.replace_invoked) {
|
||||||
|
throw "Successful swap result did not record File.Replace invocation."
|
||||||
|
}
|
||||||
|
throw "Standard user completed the protected replacement swap."
|
||||||
|
}
|
||||||
|
"security_denied" { }
|
||||||
|
"security_denied_requires_postconditions" { }
|
||||||
|
"fixture_error" {
|
||||||
|
throw "Standard-user swap fixture was invalid ($detail)."
|
||||||
|
}
|
||||||
|
"api_precondition_failure" {
|
||||||
|
throw "Standard-user swap hit an unrelated API precondition ($detail)."
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
throw "Standard-user swap failed unexpectedly ($detail)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not [bool]$Result.replace_invoked) {
|
||||||
|
throw "Standard-user swap did not reach System.IO.File.Replace."
|
||||||
|
}
|
||||||
|
|
||||||
|
$required = [ordered]@{
|
||||||
|
TargetExists = "protected target is missing"
|
||||||
|
TargetIdentityUnchanged = "protected target identity changed"
|
||||||
|
TargetContentUnchanged = "protected target content changed"
|
||||||
|
TargetAclTrusted = "protected target ACL is not trusted"
|
||||||
|
TargetAclUnchanged = "protected target ACL changed"
|
||||||
|
TargetOwnerTrusted = "protected target owner is not trusted"
|
||||||
|
TargetOwnerUnchanged = "protected target owner changed"
|
||||||
|
TargetSingleLink = "protected target is not single-link"
|
||||||
|
SourceExists = "attacker source is missing"
|
||||||
|
SourceIdentityUnchanged = "attacker source identity changed"
|
||||||
|
SourceContentUnchanged = "attacker source content changed"
|
||||||
|
AttackerSourceNotPromoted = "attacker source was promoted to authority"
|
||||||
|
TargetDeniedStandardUserRead = `
|
||||||
|
"protected content became readable by the standard user"
|
||||||
|
TargetDeniedStandardUserModify = `
|
||||||
|
"protected target became modifiable by the standard user"
|
||||||
|
TargetDeniedStandardUserDelete = `
|
||||||
|
"protected target became deletable by the standard user"
|
||||||
|
TargetDeniedStandardUserRename = `
|
||||||
|
"protected target became renameable by the standard user"
|
||||||
|
NoAttackerReadableBackup = `
|
||||||
|
"replacement exposed protected content through a backup"
|
||||||
|
}
|
||||||
|
$failures = [System.Collections.Generic.List[string]]::new()
|
||||||
|
foreach ($entry in $required.GetEnumerator()) {
|
||||||
|
if (-not [bool]$Postconditions.($entry.Key)) {
|
||||||
|
$failures.Add([string]$entry.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($failures.Count -gt 0) {
|
||||||
|
throw "Swap security postconditions failed ($detail): " +
|
||||||
|
"$($failures -join '; ')."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-MinerOpsNativeFileIdentity {
|
||||||
|
param([Parameter(Mandatory = $true)]$Stream)
|
||||||
|
|
||||||
|
if (-not ("MinerOpsNativeHarnessFileIdentity" -as [type])) {
|
||||||
|
Add-Type -TypeDefinition @'
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Microsoft.Win32.SafeHandles;
|
||||||
|
|
||||||
|
public sealed class MinerOpsNativeHarnessFileIdentityResult
|
||||||
|
{
|
||||||
|
public string Identity { get; set; }
|
||||||
|
public uint VolumeSerialNumber { get; set; }
|
||||||
|
public ulong FileIndex { get; set; }
|
||||||
|
public uint NumberOfLinks { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class MinerOpsNativeHarnessFileIdentity
|
||||||
|
{
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct ByHandleFileInformation
|
||||||
|
{
|
||||||
|
public uint FileAttributes;
|
||||||
|
public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
|
||||||
|
public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
|
||||||
|
public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
|
||||||
|
public uint VolumeSerialNumber;
|
||||||
|
public uint FileSizeHigh;
|
||||||
|
public uint FileSizeLow;
|
||||||
|
public uint NumberOfLinks;
|
||||||
|
public uint FileIndexHigh;
|
||||||
|
public uint FileIndexLow;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern bool GetFileInformationByHandle(
|
||||||
|
SafeFileHandle file, out ByHandleFileInformation information);
|
||||||
|
|
||||||
|
public static MinerOpsNativeHarnessFileIdentityResult Get(
|
||||||
|
SafeFileHandle file)
|
||||||
|
{
|
||||||
|
ByHandleFileInformation information;
|
||||||
|
if (!GetFileInformationByHandle(file, out information))
|
||||||
|
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||||
|
ulong fileIndex = ((ulong)information.FileIndexHigh << 32) |
|
||||||
|
information.FileIndexLow;
|
||||||
|
return new MinerOpsNativeHarnessFileIdentityResult {
|
||||||
|
Identity = String.Format(
|
||||||
|
"{0:X8}:{1:X8}{2:X8}",
|
||||||
|
information.VolumeSerialNumber,
|
||||||
|
information.FileIndexHigh,
|
||||||
|
information.FileIndexLow),
|
||||||
|
VolumeSerialNumber = information.VolumeSerialNumber,
|
||||||
|
FileIndex = fileIndex,
|
||||||
|
NumberOfLinks = information.NumberOfLinks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
}
|
||||||
|
|
||||||
|
return [MinerOpsNativeHarnessFileIdentity]::Get($Stream.SafeFileHandle)
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$root = Split-Path $PSScriptRoot -Parent
|
||||||
|
$scriptPath = Join-Path $root "Upgrade-MinerOpsAgentCredential.ps1"
|
||||||
|
. $scriptPath
|
||||||
|
|
||||||
|
$script:Passed = 0
|
||||||
|
$script:BootEventRecords = @()
|
||||||
|
$script:ToXmlCalls = 0
|
||||||
|
|
||||||
|
function Assert-True {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][bool]$Condition,
|
||||||
|
[Parameter(Mandatory)][string]$Message
|
||||||
|
)
|
||||||
|
if (-not $Condition) { throw $Message }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Throws {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][scriptblock]$Action,
|
||||||
|
[string]$Pattern = ""
|
||||||
|
)
|
||||||
|
try { & $Action | Out-Null }
|
||||||
|
catch {
|
||||||
|
if ($Pattern -and $_.Exception.Message -notmatch $Pattern) {
|
||||||
|
throw "Expected error '$Pattern', got '$($_.Exception.Message)'."
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw "Expected action to fail."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-ParserTest {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Name,
|
||||||
|
[Parameter(Mandatory)][scriptblock]$Action
|
||||||
|
)
|
||||||
|
& $Action
|
||||||
|
$script:Passed++
|
||||||
|
Write-Host "PASS: $Name"
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-TestKernelBootXml {
|
||||||
|
param(
|
||||||
|
[string]$Namespace =
|
||||||
|
"http://schemas.microsoft.com/win/2004/08/events/event",
|
||||||
|
[string]$BootTypeContent = '<Data Name="BootType">0</Data>',
|
||||||
|
[switch]$OmitSystem,
|
||||||
|
[switch]$OmitEventData,
|
||||||
|
[switch]$DuplicateEventData
|
||||||
|
)
|
||||||
|
|
||||||
|
$system = if ($OmitSystem) { "" } else {
|
||||||
|
@"
|
||||||
|
<System>
|
||||||
|
<Provider Name="Microsoft-Windows-Kernel-Boot" />
|
||||||
|
<EventID>27</EventID>
|
||||||
|
<Version>1</Version>
|
||||||
|
<Level>4</Level>
|
||||||
|
<Task>33</Task>
|
||||||
|
<Opcode>0</Opcode>
|
||||||
|
<EventRecordID>42</EventRecordID>
|
||||||
|
<Channel>System</Channel>
|
||||||
|
</System>
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
$eventData = if ($OmitEventData) { "" } else {
|
||||||
|
" <EventData>$BootTypeContent</EventData>"
|
||||||
|
}
|
||||||
|
if ($DuplicateEventData) {
|
||||||
|
$eventData += "`n <EventData>$BootTypeContent</EventData>"
|
||||||
|
}
|
||||||
|
return "<Event xmlns=`"$Namespace`">`n$system$eventData`n</Event>"
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-TestEventRecord {
|
||||||
|
param(
|
||||||
|
[AllowNull()]$ProviderName = "Microsoft-Windows-Kernel-Boot",
|
||||||
|
[AllowNull()]$Id = [int]27,
|
||||||
|
[AllowNull()]$RecordId = [long]42,
|
||||||
|
[AllowNull()]$Version = [byte]1,
|
||||||
|
[AllowNull()]$Xml = (New-TestKernelBootXml)
|
||||||
|
)
|
||||||
|
|
||||||
|
$record = [pscustomobject]@{
|
||||||
|
ProviderName = $ProviderName
|
||||||
|
Id = $Id
|
||||||
|
RecordId = $RecordId
|
||||||
|
Version = $Version
|
||||||
|
XmlPayload = $Xml
|
||||||
|
}
|
||||||
|
$record | Add-Member -MemberType ScriptMethod -Name ToXml -Value {
|
||||||
|
$script:ToXmlCalls++
|
||||||
|
return $this.XmlPayload
|
||||||
|
}
|
||||||
|
return $record
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-WinEvent {
|
||||||
|
param($FilterHashtable, $ErrorAction)
|
||||||
|
return $script:BootEventRecords
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "conventional namespaced Event XML and typed metadata parse" {
|
||||||
|
$parsed = ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||||
|
-EventRecord (New-TestEventRecord)
|
||||||
|
Assert-True ($parsed.ProviderName -ceq
|
||||||
|
"Microsoft-Windows-Kernel-Boot") "Typed provider was not retained."
|
||||||
|
Assert-True ($parsed.EventId -is [int] -and $parsed.EventId -eq 27) `
|
||||||
|
"Typed event ID was not retained."
|
||||||
|
Assert-True ($parsed.RecordId -is [long] -and $parsed.RecordId -eq 42) `
|
||||||
|
"Typed record ID was not retained."
|
||||||
|
Assert-True ($parsed.Version -is [byte] -and $parsed.Version -eq 1) `
|
||||||
|
"Typed event version was not retained."
|
||||||
|
Assert-True ($parsed.BootType -is [UInt32] -and $parsed.BootType -eq 0) `
|
||||||
|
"BootType payload was not parsed."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "supported event version zero parses" {
|
||||||
|
$parsed = ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||||
|
-EventRecord (New-TestEventRecord -Version ([byte]0))
|
||||||
|
Assert-True ($parsed.Version -eq 0) "Version zero was not accepted."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "production Get-WinEvent path invokes ToXml parser" {
|
||||||
|
$script:ToXmlCalls = 0
|
||||||
|
$script:BootEventRecords = @(
|
||||||
|
New-TestEventRecord -RecordId ([long]43)
|
||||||
|
)
|
||||||
|
$identifier = Get-MinerOpsBootIdentifier
|
||||||
|
Assert-True ($identifier -ceq
|
||||||
|
"windows-full-boot-event-v1:000000000000002b") `
|
||||||
|
"Production event path returned the wrong identifier."
|
||||||
|
Assert-True ($script:ToXmlCalls -eq 1) `
|
||||||
|
"Production event path did not invoke ToXml exactly once."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "typed metadata is authoritative over XML scalar metadata" {
|
||||||
|
$xml = (New-TestKernelBootXml).Replace(
|
||||||
|
"<EventID>27</EventID>", "<EventID>999</EventID>"
|
||||||
|
).Replace(
|
||||||
|
"<EventRecordID>42</EventRecordID>",
|
||||||
|
"<EventRecordID>999</EventRecordID>"
|
||||||
|
)
|
||||||
|
$parsed = ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||||
|
-EventRecord (New-TestEventRecord -Xml $xml)
|
||||||
|
Assert-True ($parsed.EventId -eq 27 -and $parsed.RecordId -eq 42) `
|
||||||
|
"XML scalar metadata overrode typed EventRecord properties."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "null typed RecordId fails closed" {
|
||||||
|
Assert-Throws {
|
||||||
|
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||||
|
-EventRecord (New-TestEventRecord -RecordId $null)
|
||||||
|
} "metadata"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "wrong typed provider fails closed" {
|
||||||
|
Assert-Throws {
|
||||||
|
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||||
|
(New-TestEventRecord -ProviderName "Unexpected-Provider")
|
||||||
|
} "metadata"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "wrong typed event ID fails closed" {
|
||||||
|
Assert-Throws {
|
||||||
|
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||||
|
-EventRecord (New-TestEventRecord -Id ([int]28))
|
||||||
|
} "metadata"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "wrong RecordId type fails closed" {
|
||||||
|
Assert-Throws {
|
||||||
|
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||||
|
-EventRecord (New-TestEventRecord -RecordId ([UInt64]42))
|
||||||
|
} "metadata"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "null event version fails closed" {
|
||||||
|
Assert-Throws {
|
||||||
|
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||||
|
-EventRecord (New-TestEventRecord -Version $null)
|
||||||
|
} "metadata"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "unsupported event version fails closed" {
|
||||||
|
Assert-Throws {
|
||||||
|
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||||
|
-EventRecord (New-TestEventRecord -Version ([byte]2))
|
||||||
|
} "unsupported"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "unexpected Event XML namespace fails closed" {
|
||||||
|
Assert-Throws {
|
||||||
|
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||||
|
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||||
|
-Namespace "urn:unexpected"))
|
||||||
|
} "root"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "malformed Event XML fails closed" {
|
||||||
|
Assert-Throws {
|
||||||
|
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||||
|
-EventRecord (New-TestEventRecord -Xml "<Event>")
|
||||||
|
} "malformed"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "missing System element fails closed" {
|
||||||
|
Assert-Throws {
|
||||||
|
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||||
|
(New-TestEventRecord -Xml (New-TestKernelBootXml -OmitSystem))
|
||||||
|
} "schema"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "missing EventData fails closed" {
|
||||||
|
Assert-Throws {
|
||||||
|
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||||
|
(New-TestEventRecord -Xml (New-TestKernelBootXml -OmitEventData))
|
||||||
|
} "schema"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "duplicated EventData fails closed" {
|
||||||
|
Assert-Throws {
|
||||||
|
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||||
|
(New-TestEventRecord -Xml `
|
||||||
|
(New-TestKernelBootXml -DuplicateEventData))
|
||||||
|
} "schema"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "missing BootType fails closed" {
|
||||||
|
Assert-Throws {
|
||||||
|
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||||
|
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||||
|
-BootTypeContent '<Data Name="LoadOptions"></Data>'))
|
||||||
|
} "schema"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "duplicate BootType fails closed" {
|
||||||
|
$payload = '<Data Name="BootType">0</Data>' +
|
||||||
|
'<Data Name="BootType">0</Data>'
|
||||||
|
Assert-Throws {
|
||||||
|
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||||
|
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||||
|
-BootTypeContent $payload))
|
||||||
|
} "ambiguous"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "nonnumeric BootType fails closed" {
|
||||||
|
Assert-Throws {
|
||||||
|
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||||
|
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||||
|
-BootTypeContent '<Data Name="BootType">full</Data>'))
|
||||||
|
} "canonical"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "noncanonical BootType fails closed" {
|
||||||
|
Assert-Throws {
|
||||||
|
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||||
|
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||||
|
-BootTypeContent '<Data Name="BootType">00</Data>'))
|
||||||
|
} "canonical"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "structured BootType content fails closed" {
|
||||||
|
Assert-Throws {
|
||||||
|
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||||
|
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||||
|
-BootTypeContent `
|
||||||
|
'<Data Name="BootType"><Value>0</Value></Data>'))
|
||||||
|
} "schema"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "BootType outside UInt32 fails closed" {
|
||||||
|
Assert-Throws {
|
||||||
|
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||||
|
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||||
|
-BootTypeContent `
|
||||||
|
'<Data Name="BootType">4294967296</Data>'))
|
||||||
|
} "invalid"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "nonzero BootType is not a full-boot anchor" {
|
||||||
|
$script:BootEventRecords = @(
|
||||||
|
New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||||
|
-BootTypeContent '<Data Name="BootType">1</Data>')
|
||||||
|
)
|
||||||
|
Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "empty event query fails closed" {
|
||||||
|
$script:BootEventRecords = @()
|
||||||
|
Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "duplicate qualifying record IDs fail closed" {
|
||||||
|
$script:BootEventRecords = @(
|
||||||
|
New-TestEventRecord -RecordId ([long]42)
|
||||||
|
New-TestEventRecord -RecordId ([long]43)
|
||||||
|
New-TestEventRecord -RecordId ([long]42)
|
||||||
|
)
|
||||||
|
Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "equal stored and current anchors deny reset" {
|
||||||
|
Assert-Throws {
|
||||||
|
Assert-MinerOpsBootEpochChanged `
|
||||||
|
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
|
||||||
|
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002a"
|
||||||
|
} "without a verified full Restart"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "strictly greater current anchor passes ordering" {
|
||||||
|
Assert-MinerOpsBootEpochChanged `
|
||||||
|
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
|
||||||
|
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002b"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "smaller current anchor fails closed" {
|
||||||
|
Assert-Throws {
|
||||||
|
Assert-MinerOpsBootEpochChanged `
|
||||||
|
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
|
||||||
|
-CurrentBootIdentifier "windows-full-boot-event-v1:0000000000000029"
|
||||||
|
} "older than the"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-ParserTest "malformed stored anchor fails closed" {
|
||||||
|
Assert-Throws {
|
||||||
|
Assert-MinerOpsBootEpochChanged `
|
||||||
|
-StoredBootIdentifier "windows-full-boot-event-v1:not-a-record" `
|
||||||
|
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002b"
|
||||||
|
} "canonical"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "$script:Passed boot-event parser tests passed."
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,265 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$root = Split-Path $PSScriptRoot -Parent
|
||||||
|
$bootstrap = Join-Path $root "Enroll-MinerOpsAgent.ps1"
|
||||||
|
|
||||||
|
$tokens = $null
|
||||||
|
$parseErrors = $null
|
||||||
|
|
||||||
|
[System.Management.Automation.Language.Parser]::ParseFile(
|
||||||
|
$bootstrap,
|
||||||
|
[ref] $tokens,
|
||||||
|
[ref] $parseErrors
|
||||||
|
) | Out-Null
|
||||||
|
|
||||||
|
if ($parseErrors.Count -gt 0) {
|
||||||
|
throw "Enroll-MinerOpsAgent.ps1 parser errors: $($parseErrors.Message -join '; ')"
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = Get-Content -LiteralPath $bootstrap -Raw
|
||||||
|
|
||||||
|
function Assert-SourceContains {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string] $Pattern,
|
||||||
|
[Parameter(Mandatory)][string] $Message
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($source -notmatch $Pattern) {
|
||||||
|
throw $Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-SourceContains 'enrollment\.json' `
|
||||||
|
"Bootstrap enrollment file is not explicit."
|
||||||
|
|
||||||
|
Assert-SourceContains 'workflow\s*-ne\s*"new_agent"' `
|
||||||
|
"New-Agent bootstrap does not reject transition packages."
|
||||||
|
|
||||||
|
Assert-SourceContains '/api/agent/enroll' `
|
||||||
|
"Bootstrap does not use the secure enrollment endpoint."
|
||||||
|
|
||||||
|
Assert-SourceContains 'Read-Host[\s\S]*-AsSecureString' `
|
||||||
|
"API3 password is not prompted as a SecureString."
|
||||||
|
|
||||||
|
Assert-SourceContains 'agent-config\.json\.pending' `
|
||||||
|
"Atomic pending config path is missing."
|
||||||
|
|
||||||
|
Assert-SourceContains 'agent-config\.recovery\.json' `
|
||||||
|
"Post-redemption credential recovery path is missing."
|
||||||
|
|
||||||
|
Assert-SourceContains 'Open-MinerOpsEnrollmentCredentialFileCreateNew' `
|
||||||
|
"Handle-based credential creation is missing."
|
||||||
|
|
||||||
|
Assert-SourceContains 'FileMode\]::CreateNew' `
|
||||||
|
"Credential files are not created with fail-if-present semantics."
|
||||||
|
|
||||||
|
Assert-SourceContains 'FileShare\]::None' `
|
||||||
|
"Credential files are not held exclusively while being created."
|
||||||
|
|
||||||
|
Assert-SourceContains 'Flush\(\$true\)' `
|
||||||
|
"Credential writes are not durably flushed through the trusted handle."
|
||||||
|
|
||||||
|
Assert-SourceContains 'GetNumberOfLinks' `
|
||||||
|
"Enrollment does not reject hard-linked credential targets."
|
||||||
|
|
||||||
|
Assert-SourceContains 'S-1-5-18' `
|
||||||
|
"SYSTEM ACL entry is missing."
|
||||||
|
|
||||||
|
Assert-SourceContains 'S-1-5-32-544' `
|
||||||
|
"Administrators ACL entry is missing."
|
||||||
|
|
||||||
|
Assert-SourceContains 'SetOwner\(\$administrators\)' `
|
||||||
|
"Stable Administrators ownership is missing from credential ACLs."
|
||||||
|
|
||||||
|
Assert-SourceContains '\[IO\.File\]::Move\(\$PendingConfigPath, \$ConfigPath\)' `
|
||||||
|
"Atomic final config move is missing."
|
||||||
|
|
||||||
|
Assert-SourceContains 'Assert-MinerOpsEnrollmentDirectory' `
|
||||||
|
"Enrollment does not revalidate the directory trust boundary."
|
||||||
|
|
||||||
|
Assert-SourceContains 'minerops-filesystem-trust\.blocked-' `
|
||||||
|
"Enrollment directory repair lacks an external durable trust blocker."
|
||||||
|
|
||||||
|
Assert-SourceContains 'ApproveFilesystemTrustResetAfterReboot' `
|
||||||
|
"Enrollment lacks an explicit post-reboot trust-reset gate."
|
||||||
|
|
||||||
|
Assert-SourceContains 'windows-full-boot-event-v1:' `
|
||||||
|
"Enrollment blockers do not use canonical full-kernel-boot records."
|
||||||
|
|
||||||
|
if ($source -match 'LastBootUpTime') {
|
||||||
|
throw "Enrollment must not use a wall-clock boot timestamp."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($source -match 'Remove-MinerOpsEnrollmentTrustBlocker') {
|
||||||
|
throw "Enrollment must not have independent trust-blocker deletion authority."
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-SourceContains 'Enrollment cannot clear an existing filesystem trust blocker' `
|
||||||
|
"Enrollment does not fail closed on retained filesystem trust blockers."
|
||||||
|
|
||||||
|
Assert-SourceContains 'Assert-MinerOpsEnrollmentTrustedAncestors' `
|
||||||
|
"Enrollment does not validate ancestor namespace protection."
|
||||||
|
|
||||||
|
foreach ($functionName in @(
|
||||||
|
"Assert-MinerOpsEnrollmentNoReparsePath",
|
||||||
|
"Assert-MinerOpsEnrollmentTrustedAncestors"
|
||||||
|
)) {
|
||||||
|
$functionSource = [regex]::Match(
|
||||||
|
$source,
|
||||||
|
"(?s)function $functionName \{.*?\n\}"
|
||||||
|
).Value
|
||||||
|
if ([string]::IsNullOrWhiteSpace($functionSource)) {
|
||||||
|
throw "$functionName source was not found."
|
||||||
|
}
|
||||||
|
if ($functionSource -notmatch '\$current\s*=\s*\$root') {
|
||||||
|
throw "$functionName does not preserve the filesystem root."
|
||||||
|
}
|
||||||
|
if ($functionSource -notmatch '\$fullPath\.Equals\(\s*\$root') {
|
||||||
|
throw "$functionName does not protect a root-valued full path from trimming."
|
||||||
|
}
|
||||||
|
if ($functionSource -match '\$root\.TrimEnd\(') {
|
||||||
|
throw "$functionName converts a root-qualified path to drive-relative form."
|
||||||
|
}
|
||||||
|
if ($functionSource -match 'GetFullPath\(\$Path\)\.TrimEnd\(') {
|
||||||
|
throw "$functionName trims the filesystem root before identifying it."
|
||||||
|
}
|
||||||
|
if ($functionName -eq "Assert-MinerOpsEnrollmentTrustedAncestors" -and
|
||||||
|
$functionSource -notmatch '\$parent\.Equals\(\s*\$root') {
|
||||||
|
throw "$functionName does not protect a root-valued parent from trimming."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-SourceContains 'Open-MinerOpsEnrollmentDirectoryLock' `
|
||||||
|
"Enrollment does not lock an insecure target during repair."
|
||||||
|
|
||||||
|
Assert-SourceContains 'DirectoryInfo\(\$staging\)' `
|
||||||
|
"Enrollment does not securely stage a new directory."
|
||||||
|
|
||||||
|
Assert-SourceContains '& \$Installer' `
|
||||||
|
"Bootstrap does not hand off to the safe installer."
|
||||||
|
|
||||||
|
Assert-SourceContains 'Start-ScheduledTask -TaskName "MinerOpsAgent"' `
|
||||||
|
"Fresh Agent startup is missing."
|
||||||
|
|
||||||
|
Assert-SourceContains 'Remove-Item -LiteralPath \$EnrollmentPath' `
|
||||||
|
"Consumed extracted enrollment token is not removed."
|
||||||
|
|
||||||
|
if ($source -match '\[IO\.File\]::WriteAllText\(\s*\$(Pending|Recovery)ConfigPath') {
|
||||||
|
throw "Enrollment must not truncate or adopt a credential pathname."
|
||||||
|
}
|
||||||
|
|
||||||
|
$createFunction = [regex]::Match(
|
||||||
|
$source,
|
||||||
|
'(?s)function Open-MinerOpsEnrollmentCredentialFileCreateNew \{.*?\n\}'
|
||||||
|
).Value
|
||||||
|
$createIndex = $createFunction.IndexOf(
|
||||||
|
'New-MinerOpsEnrollmentProtectedFileCreateNewStream'
|
||||||
|
)
|
||||||
|
$verifyIndex = $createFunction.IndexOf('Test-MinerOpsEnrollmentCredentialAcl')
|
||||||
|
$linkIndex = $createFunction.IndexOf('Assert-MinerOpsEnrollmentSingleLinkStream')
|
||||||
|
if (
|
||||||
|
$createIndex -lt 0 -or $verifyIndex -le $createIndex -or
|
||||||
|
$linkIndex -le $verifyIndex -or
|
||||||
|
$createFunction -match 'Set-MinerOpsEnrollmentStreamAcl'
|
||||||
|
) {
|
||||||
|
throw "Enrollment credential creation must apply FileSecurity at CreateNew, verify it, then reject links before content."
|
||||||
|
}
|
||||||
|
|
||||||
|
$createStreamFunction = [regex]::Match(
|
||||||
|
$source,
|
||||||
|
'(?s)function New-MinerOpsEnrollmentProtectedFileCreateNewStream \{.*?\n\}'
|
||||||
|
).Value
|
||||||
|
foreach ($requiredCreateContract in @(
|
||||||
|
'\[IO\.FileStream\]::new',
|
||||||
|
'FileMode\]::CreateNew',
|
||||||
|
'FileSystemRights\]::WriteData',
|
||||||
|
'FileSystemRights\]::ReadPermissions',
|
||||||
|
'FileSystemRights\]::Synchronize',
|
||||||
|
'FileShare\]::None',
|
||||||
|
'\$Acl'
|
||||||
|
)) {
|
||||||
|
if ($createStreamFunction -notmatch $requiredCreateContract) {
|
||||||
|
throw "Enrollment protected CreateNew stream contract is incomplete."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($createStreamFunction -match 'FullControl|SetAccessControl|Set-Acl') {
|
||||||
|
throw "Enrollment protected creation requests excess rights or repairs by path."
|
||||||
|
}
|
||||||
|
|
||||||
|
$repairFunction = [regex]::Match(
|
||||||
|
$source,
|
||||||
|
'(?s)function Protect-MinerOpsEnrollmentExistingConfig \{.*?\n\}'
|
||||||
|
).Value
|
||||||
|
foreach ($requiredRepairRight in @(
|
||||||
|
'ReadPermissions',
|
||||||
|
'ChangePermissions',
|
||||||
|
'TakeOwnership',
|
||||||
|
'Synchronize'
|
||||||
|
)) {
|
||||||
|
if ($repairFunction -notmatch $requiredRepairRight) {
|
||||||
|
throw "Enrollment existing-config repair is missing $requiredRepairRight."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($repairFunction -match 'Set-Acl|FileAccess') {
|
||||||
|
throw "Enrollment existing-config repair must remain handle-bound."
|
||||||
|
}
|
||||||
|
|
||||||
|
$writeFunction = [regex]::Match(
|
||||||
|
$source,
|
||||||
|
'(?s)function Write-MinerOpsEnrollmentCredentialJson \{.*?\n\}'
|
||||||
|
).Value
|
||||||
|
if (
|
||||||
|
$writeFunction -notmatch 'Assert-MinerOpsEnrollmentSingleLinkStream' -or
|
||||||
|
$writeFunction -notmatch '\$Stream\.Write' -or
|
||||||
|
$writeFunction -notmatch '\$Stream\.Flush\(\$true\)'
|
||||||
|
) {
|
||||||
|
throw "Enrollment credential content is not written and flushed through the verified handle."
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingConfigGuard = $source.IndexOf(
|
||||||
|
'Test-Path -LiteralPath $ConfigPath'
|
||||||
|
)
|
||||||
|
|
||||||
|
$redeemCall = $source.IndexOf(
|
||||||
|
'"$HubUrl/api/agent/enroll"'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
$existingConfigGuard -lt 0 -or
|
||||||
|
$redeemCall -lt 0 -or
|
||||||
|
$existingConfigGuard -gt $redeemCall
|
||||||
|
) {
|
||||||
|
throw "Existing Agent identity must be rejected before token redemption."
|
||||||
|
}
|
||||||
|
|
||||||
|
$configMove = $source.IndexOf(
|
||||||
|
'[IO.File]::Move($PendingConfigPath, $ConfigPath)'
|
||||||
|
)
|
||||||
|
|
||||||
|
$installerCall = $source.IndexOf(
|
||||||
|
'& $Installer'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
$configMove -lt 0 -or
|
||||||
|
$installerCall -lt 0 -or
|
||||||
|
$configMove -gt $installerCall
|
||||||
|
) {
|
||||||
|
throw "Permanent Agent credentials must be persisted before installation."
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($sensitiveVariable in @(
|
||||||
|
'EnrollmentToken',
|
||||||
|
'Api3PasswordPlain'
|
||||||
|
)) {
|
||||||
|
if (
|
||||||
|
$source -match (
|
||||||
|
'(?im)Write-(Host|Output|Verbose|Information).*?\$' +
|
||||||
|
$sensitiveVariable
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw "$sensitiveVariable must not be written to output."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "MinerOps enrollment bootstrap source safety tests passed."
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$installer = Join-Path (Split-Path $PSScriptRoot -Parent) "install.ps1"
|
||||||
|
$tokens = $null
|
||||||
|
$parseErrors = $null
|
||||||
|
[System.Management.Automation.Language.Parser]::ParseFile(
|
||||||
|
$installer, [ref]$tokens, [ref]$parseErrors
|
||||||
|
) | Out-Null
|
||||||
|
if ($parseErrors.Count -gt 0) {
|
||||||
|
throw "install.ps1 parser errors: $($parseErrors.Message -join '; ')"
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = Get-Content -LiteralPath $installer -Raw
|
||||||
|
function Assert-SourceContains {
|
||||||
|
param([Parameter(Mandatory)][string]$Pattern, [Parameter(Mandatory)][string]$Message)
|
||||||
|
if ($source -notmatch $Pattern) { throw $Message }
|
||||||
|
}
|
||||||
|
|
||||||
|
$runtimeMatch = [regex]::Match(
|
||||||
|
$source,
|
||||||
|
'(?s)\$RuntimeFiles\s*=\s*@\((?<files>.*?)\)'
|
||||||
|
)
|
||||||
|
if (!$runtimeMatch.Success) { throw "Runtime file allowlist was not found." }
|
||||||
|
$runtimeFiles = $runtimeMatch.Groups["files"].Value
|
||||||
|
foreach ($forbidden in @("agent-config.json", "agent-state.json", "logs", "backups")) {
|
||||||
|
if ($runtimeFiles -match [regex]::Escape($forbidden)) {
|
||||||
|
throw "$forbidden must not be a managed deployment target."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$preflight = $source.IndexOf('# Validate the complete source')
|
||||||
|
$firstTargetChange = $source.IndexOf('New-Item -ItemType Directory')
|
||||||
|
if ($preflight -lt 0 -or $preflight -gt $firstTargetChange) {
|
||||||
|
throw "Source preflight must happen before target changes."
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-SourceContains '\$existingTask\s*=\s*Get-MinerOpsScheduledTask' `
|
||||||
|
"Existing task preservation guard is missing."
|
||||||
|
Assert-SourceContains 'Preserving existing Scheduled Task' `
|
||||||
|
"Existing task preservation is not explicit."
|
||||||
|
Assert-SourceContains 'Test-MinerOpsInstallerShouldCreateMissingTask' `
|
||||||
|
"Missing-task trust-reset policy helper is absent."
|
||||||
|
Assert-SourceContains 'Deferring missing per-user Scheduled Task' `
|
||||||
|
"Missing per-user task deferral is not explicit."
|
||||||
|
Assert-SourceContains 'ExtraArguments\s*=\s*"-Limit 0 -TimeoutMs 8000"' `
|
||||||
|
"Fresh enrichment task arguments are missing."
|
||||||
|
Assert-SourceContains 'Stop-MinerOpsScheduledTaskForUpgrade' `
|
||||||
|
"Bounded main Agent stop behavior is missing."
|
||||||
|
Assert-SourceContains '\$mainTaskWasRunning' `
|
||||||
|
"Main Agent prior running state is not represented."
|
||||||
|
Assert-SourceContains 'Copy-Item[^\r\n]*\(Join-Path \$Backup \$name\)' `
|
||||||
|
"Rollback does not restore managed files from backup."
|
||||||
|
Assert-SourceContains 'Unregister-ScheduledTask' `
|
||||||
|
"Rollback does not remove tasks created by a failed install."
|
||||||
|
Assert-SourceContains 'could not restart restored MinerOpsAgent' `
|
||||||
|
"Rollback restart failure is not surfaced."
|
||||||
|
Assert-SourceContains 'Assert-MinerOpsInstallerTrustedAncestors' `
|
||||||
|
"Installer does not validate ancestor namespace protection."
|
||||||
|
|
||||||
|
$taskDefinitionsStart = $source.IndexOf('$definitions = @(')
|
||||||
|
$taskLoopStart = $source.IndexOf(
|
||||||
|
'foreach ($definition in $definitions)', $taskDefinitionsStart
|
||||||
|
)
|
||||||
|
if ($taskDefinitionsStart -lt 0 -or $taskLoopStart -le $taskDefinitionsStart) {
|
||||||
|
throw "Scheduled Task definitions or reconciliation loop were not found."
|
||||||
|
}
|
||||||
|
$taskDefinitions = $source.Substring(
|
||||||
|
$taskDefinitionsStart, $taskLoopStart - $taskDefinitionsStart
|
||||||
|
)
|
||||||
|
foreach ($policy in @(
|
||||||
|
@{ Name = "MinerOpsAgent"; Expected = '\$true' },
|
||||||
|
@{ Name = "MinerOpsAgentTray"; Expected = '\$false' },
|
||||||
|
@{ Name = "MinerOpsApi3Enrichment"; Expected = '\$true' },
|
||||||
|
@{ Name = "MinerOpsPoolPoller"; Expected = '\$true' }
|
||||||
|
)) {
|
||||||
|
$definition = [regex]::Match(
|
||||||
|
$taskDefinitions,
|
||||||
|
('(?s)@\{{.*?Name\s*=\s*"{0}".*?\}}' -f
|
||||||
|
[regex]::Escape($policy.Name))
|
||||||
|
).Value
|
||||||
|
if ([string]::IsNullOrWhiteSpace($definition) -or
|
||||||
|
$definition -notmatch (
|
||||||
|
'CreateWhenMissingDuringTrustReset\s*=\s*{0}' -f
|
||||||
|
$policy.Expected
|
||||||
|
)) {
|
||||||
|
throw "Scheduled Task $($policy.Name) has the wrong trust-reset creation policy."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$taskLoopEnd = $source.IndexOf(
|
||||||
|
'if ($mainTaskWasRunning', $taskLoopStart
|
||||||
|
)
|
||||||
|
if ($taskLoopEnd -le $taskLoopStart) {
|
||||||
|
throw "Scheduled Task reconciliation loop end was not found."
|
||||||
|
}
|
||||||
|
$taskLoop = $source.Substring($taskLoopStart, $taskLoopEnd - $taskLoopStart)
|
||||||
|
$preserveIndex = $taskLoop.IndexOf('$null -ne $existingTask')
|
||||||
|
$deferIndex = $taskLoop.IndexOf(
|
||||||
|
'Test-MinerOpsInstallerShouldCreateMissingTask'
|
||||||
|
)
|
||||||
|
$actionIndex = $taskLoop.IndexOf('New-ScheduledTaskAction')
|
||||||
|
if (
|
||||||
|
$preserveIndex -lt 0 -or
|
||||||
|
$deferIndex -le $preserveIndex -or
|
||||||
|
$actionIndex -le $deferIndex
|
||||||
|
) {
|
||||||
|
throw "Existing-task preservation and missing-task deferral are ordered unsafely."
|
||||||
|
}
|
||||||
|
if ($taskLoop -match '80070534|No mapping between account names') {
|
||||||
|
throw "Missing per-user task handling must be policy-based, not HRESULT-based."
|
||||||
|
}
|
||||||
|
|
||||||
|
$taskPolicyFunction = [regex]::Match(
|
||||||
|
$source,
|
||||||
|
'(?s)function Test-MinerOpsInstallerShouldCreateMissingTask \{.*?\n\}'
|
||||||
|
).Value
|
||||||
|
if ([string]::IsNullOrWhiteSpace($taskPolicyFunction)) {
|
||||||
|
throw "Missing-task trust-reset policy function source was not found."
|
||||||
|
}
|
||||||
|
Invoke-Expression $taskPolicyFunction
|
||||||
|
$trayPolicy = [pscustomobject]@{
|
||||||
|
CreateWhenMissingDuringTrustReset = $false
|
||||||
|
}
|
||||||
|
$systemTaskPolicy = [pscustomobject]@{
|
||||||
|
CreateWhenMissingDuringTrustReset = $true
|
||||||
|
}
|
||||||
|
if (Test-MinerOpsInstallerShouldCreateMissingTask `
|
||||||
|
-Definition $trayPolicy -TrustResetMode $true) {
|
||||||
|
throw "Trust reset would create a missing per-user tray task."
|
||||||
|
}
|
||||||
|
if (-not (Test-MinerOpsInstallerShouldCreateMissingTask `
|
||||||
|
-Definition $trayPolicy -TrustResetMode $false)) {
|
||||||
|
throw "Normal interactive installation would not repair a missing tray task."
|
||||||
|
}
|
||||||
|
if (-not (Test-MinerOpsInstallerShouldCreateMissingTask `
|
||||||
|
-Definition $systemTaskPolicy -TrustResetMode $true)) {
|
||||||
|
throw "Trust reset would skip a missing explicit-SYSTEM task."
|
||||||
|
}
|
||||||
|
$missingPolicyRejected = $false
|
||||||
|
try {
|
||||||
|
[void](Test-MinerOpsInstallerShouldCreateMissingTask `
|
||||||
|
-Definition ([pscustomobject]@{}) -TrustResetMode $true)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$missingPolicyRejected = $_.Exception.Message -match
|
||||||
|
'trust-reset creation policy is missing'
|
||||||
|
}
|
||||||
|
if (-not $missingPolicyRejected) {
|
||||||
|
throw "Trust reset accepted a task definition without explicit policy."
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($functionName in @(
|
||||||
|
"Assert-MinerOpsInstallerNoReparsePath",
|
||||||
|
"Assert-MinerOpsInstallerTrustedAncestors"
|
||||||
|
)) {
|
||||||
|
$functionSource = [regex]::Match(
|
||||||
|
$source,
|
||||||
|
"(?s)function $functionName \{.*?\n\}"
|
||||||
|
).Value
|
||||||
|
if ([string]::IsNullOrWhiteSpace($functionSource)) {
|
||||||
|
throw "$functionName source was not found."
|
||||||
|
}
|
||||||
|
if ($functionSource -notmatch '\$current\s*=\s*\$root') {
|
||||||
|
throw "$functionName does not preserve the filesystem root."
|
||||||
|
}
|
||||||
|
if ($functionSource -notmatch '\$fullPath\.Equals\(\s*\$root') {
|
||||||
|
throw "$functionName does not protect a root-valued full path from trimming."
|
||||||
|
}
|
||||||
|
if ($functionSource -match '\$root\.TrimEnd\(') {
|
||||||
|
throw "$functionName converts a root-qualified path to drive-relative form."
|
||||||
|
}
|
||||||
|
if ($functionSource -match 'GetFullPath\(\$Path\)\.TrimEnd\(') {
|
||||||
|
throw "$functionName trims the filesystem root before identifying it."
|
||||||
|
}
|
||||||
|
if ($functionName -eq "Assert-MinerOpsInstallerTrustedAncestors" -and
|
||||||
|
$functionSource -notmatch '\$parent\.Equals\(\s*\$root') {
|
||||||
|
throw "$functionName does not protect a root-valued parent from trimming."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-SourceContains 'Open-MinerOpsInstallerDirectoryLock' `
|
||||||
|
"Installer does not lock an insecure target against replacement."
|
||||||
|
Assert-SourceContains 'minerops-filesystem-trust\.blocked-' `
|
||||||
|
"Installer directory repair lacks an external durable provenance blocker."
|
||||||
|
Assert-SourceContains 'ApproveFilesystemTrustResetAfterReboot' `
|
||||||
|
"Installer lacks an explicit post-reboot trust-reset gate."
|
||||||
|
Assert-SourceContains 'DirectoryInfo\(\$staging\)' `
|
||||||
|
"Installer does not stage a protected new directory before publication."
|
||||||
|
Assert-SourceContains 'Assert-MinerOpsInstallerDirectory' `
|
||||||
|
"Installer does not read back the final directory security invariant."
|
||||||
|
Assert-SourceContains 'Protect-MinerOpsInstallerExistingConfig' `
|
||||||
|
"Installer does not contain the existing active-config ACL."
|
||||||
|
Assert-SourceContains 'GetNumberOfLinks' `
|
||||||
|
"Installer does not reject hard-linked runtime, config, or backup files."
|
||||||
|
Assert-SourceContains 'Assert-MinerOpsInstallerTrustedRuntimeFile' `
|
||||||
|
"Installer does not verify existing runtime rollback provenance."
|
||||||
|
Assert-SourceContains 'Protect-MinerOpsInstallerRuntimeFile' `
|
||||||
|
"Installer does not secure and verify published runtime and rollback copies."
|
||||||
|
Assert-SourceContains 'Pre-reset runtime is evidence, never rollback authority' `
|
||||||
|
"Installer trust reset can adopt pre-reset runtime as rollback authority."
|
||||||
|
Assert-SourceContains 'parent-owned filesystem trust blocker remains' `
|
||||||
|
"Installer does not explicitly retain the parent-owned trust blocker."
|
||||||
|
Assert-SourceContains 'windows-full-boot-event-v1:' `
|
||||||
|
"Installer blockers do not use canonical full-kernel-boot records."
|
||||||
|
if ($source -match 'Remove-MinerOpsInstallerTrustBlocker|Remove-Item[^\r\n]*TrustBlocker') {
|
||||||
|
throw "Installer must have no filesystem trust-blocker deletion path."
|
||||||
|
}
|
||||||
|
if ($source -match 'LastBootUpTime') {
|
||||||
|
throw "Installer must not create wall-clock boot-epoch records."
|
||||||
|
}
|
||||||
|
|
||||||
|
$createStreamFunction = [regex]::Match(
|
||||||
|
$source,
|
||||||
|
'(?s)function New-MinerOpsInstallerProtectedFileCreateNewStream \{.*?\n\}'
|
||||||
|
).Value
|
||||||
|
foreach ($requiredCreateContract in @(
|
||||||
|
'\[IO\.FileStream\]::new',
|
||||||
|
'FileMode\]::CreateNew',
|
||||||
|
'FileSystemRights\]::WriteData',
|
||||||
|
'FileSystemRights\]::ReadPermissions',
|
||||||
|
'FileSystemRights\]::Synchronize',
|
||||||
|
'FileShare\]::None',
|
||||||
|
'\$Acl'
|
||||||
|
)) {
|
||||||
|
if ($createStreamFunction -notmatch $requiredCreateContract) {
|
||||||
|
throw "Installer protected CreateNew stream contract is incomplete."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($createStreamFunction -match 'FullControl|SetAccessControl|Set-Acl') {
|
||||||
|
throw "Installer protected creation requests excess rights or repairs by path."
|
||||||
|
}
|
||||||
|
|
||||||
|
$trustBlockerFunction = [regex]::Match(
|
||||||
|
$source,
|
||||||
|
'(?s)function New-MinerOpsInstallerTrustBlocker \{.*?\n\}'
|
||||||
|
).Value
|
||||||
|
$trustCreateIndex = $trustBlockerFunction.IndexOf(
|
||||||
|
'New-MinerOpsInstallerProtectedFileCreateNewStream'
|
||||||
|
)
|
||||||
|
$trustVerifyIndex = $trustBlockerFunction.IndexOf(
|
||||||
|
'Test-MinerOpsInstallerProtectedFileAcl'
|
||||||
|
)
|
||||||
|
$trustContentIndex = $trustBlockerFunction.IndexOf('$record =')
|
||||||
|
if (
|
||||||
|
$trustCreateIndex -lt 0 -or
|
||||||
|
$trustVerifyIndex -le $trustCreateIndex -or
|
||||||
|
$trustContentIndex -le $trustVerifyIndex
|
||||||
|
) {
|
||||||
|
throw "Installer trust blockers must be protected and verified before content."
|
||||||
|
}
|
||||||
|
|
||||||
|
$configRepairFunction = [regex]::Match(
|
||||||
|
$source,
|
||||||
|
'(?s)function Protect-MinerOpsInstallerExistingConfig \{.*?\n\}'
|
||||||
|
).Value
|
||||||
|
foreach ($requiredRepairRight in @(
|
||||||
|
'ReadPermissions',
|
||||||
|
'ChangePermissions',
|
||||||
|
'TakeOwnership',
|
||||||
|
'Synchronize'
|
||||||
|
)) {
|
||||||
|
if ($configRepairFunction -notmatch $requiredRepairRight) {
|
||||||
|
throw "Installer existing-config repair is missing $requiredRepairRight."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($configRepairFunction -match 'Set-Acl|FileAccess') {
|
||||||
|
throw "Installer existing-config repair must remain handle-bound."
|
||||||
|
}
|
||||||
|
|
||||||
|
$repairFunction = [regex]::Match(
|
||||||
|
$source,
|
||||||
|
'(?s)function Initialize-MinerOpsInstallerDirectory \{.*?\n\}'
|
||||||
|
).Value
|
||||||
|
$repairIndex = $repairFunction.IndexOf('Set-Acl -LiteralPath $fullPath')
|
||||||
|
$abortIndex = $repairFunction.IndexOf('this invocation will not back up')
|
||||||
|
if ($repairIndex -lt 0 -or $abortIndex -le $repairIndex) {
|
||||||
|
throw "Insecure existing-directory containment must abort before publication."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($source -match 'Register-ScheduledTask[^\r\n]*-Force') {
|
||||||
|
throw "Installer must not force-overwrite Scheduled Tasks."
|
||||||
|
}
|
||||||
|
if ($source -match '(?i)agent_secret|api3_password|api3_account') {
|
||||||
|
throw "Installer must not inspect or print credentials."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "MinerOps installer source safety tests passed."
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$supportPath = Join-Path $PSScriptRoot "MinerOpsNativeHarnessSupport.ps1"
|
||||||
|
. $supportPath
|
||||||
|
|
||||||
|
function Assert-True {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][bool]$Condition,
|
||||||
|
[Parameter(Mandatory = $true)][string]$Message
|
||||||
|
)
|
||||||
|
if (-not $Condition) { throw $Message }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Throws {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][scriptblock]$Action,
|
||||||
|
[Parameter(Mandatory = $true)][string]$Pattern
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
& $Action | Out-Null
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
if ($_.Exception.Message -notmatch $Pattern) {
|
||||||
|
throw "Expected '$Pattern', got '$($_.Exception.Message)'."
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw "Expected action to fail with '$Pattern'."
|
||||||
|
}
|
||||||
|
|
||||||
|
$script:Passed = 0
|
||||||
|
function Invoke-HarnessTest {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Name,
|
||||||
|
[Parameter(Mandatory = $true)][scriptblock]$Action
|
||||||
|
)
|
||||||
|
|
||||||
|
& $Action
|
||||||
|
$script:Passed++
|
||||||
|
Write-Host "PASS: $Name"
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-PassingSwapPostconditions {
|
||||||
|
return [pscustomobject]@{
|
||||||
|
TargetExists = $true
|
||||||
|
TargetIdentityUnchanged = $true
|
||||||
|
TargetContentUnchanged = $true
|
||||||
|
TargetAclTrusted = $true
|
||||||
|
TargetAclUnchanged = $true
|
||||||
|
TargetOwnerTrusted = $true
|
||||||
|
TargetOwnerUnchanged = $true
|
||||||
|
TargetSingleLink = $true
|
||||||
|
SourceExists = $true
|
||||||
|
SourceIdentityUnchanged = $true
|
||||||
|
SourceContentUnchanged = $true
|
||||||
|
AttackerSourceNotPromoted = $true
|
||||||
|
TargetDeniedStandardUserRead = $true
|
||||||
|
TargetDeniedStandardUserModify = $true
|
||||||
|
TargetDeniedStandardUserDelete = $true
|
||||||
|
TargetDeniedStandardUserRename = $true
|
||||||
|
NoAttackerReadableBackup = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-ReadySwapFixture {
|
||||||
|
return [pscustomobject]@{
|
||||||
|
SourceExists = $true
|
||||||
|
TargetExists = $true
|
||||||
|
PathsDistinct = $true
|
||||||
|
SourceCreatedByStandardUser = $true
|
||||||
|
SourceOwnedByStandardUser = $true
|
||||||
|
SourceReadable = $true
|
||||||
|
SourceModifiable = $true
|
||||||
|
SourceRenameable = $true
|
||||||
|
SourceDeleteCapable = $true
|
||||||
|
SourceReplaceAccessVerified = $true
|
||||||
|
TargetAclTrusted = $true
|
||||||
|
TargetOwnerTrusted = $true
|
||||||
|
TargetIdentityCaptured = $true
|
||||||
|
SourceIdentityCaptured = $true
|
||||||
|
FileIdentitiesDistinct = $true
|
||||||
|
BackupArgumentValid = $true
|
||||||
|
BackupPathsDistinct = $true
|
||||||
|
BackupExpectedStateVerified = $true
|
||||||
|
BackupConfidentialitySafe = $true
|
||||||
|
SameVolume = $true
|
||||||
|
NtfsVolume = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-SwapResult {
|
||||||
|
param([Parameter(Mandatory = $true)][string]$Status)
|
||||||
|
return [pscustomobject]@{
|
||||||
|
operation = "swap"
|
||||||
|
status = $Status
|
||||||
|
exception_type = ""
|
||||||
|
hresult = 0
|
||||||
|
outer_exception_type = ""
|
||||||
|
outer_hresult = 0
|
||||||
|
leaf_exception_type = ""
|
||||||
|
leaf_hresult = 0
|
||||||
|
native_error = $null
|
||||||
|
exception_chain_types = @()
|
||||||
|
replace_invoked = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "helper classifies success and status exit codes" {
|
||||||
|
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "succeeded") -eq 0) `
|
||||||
|
"Success exit code changed."
|
||||||
|
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "security_denied") -eq 5) `
|
||||||
|
"Security-denial exit code changed."
|
||||||
|
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "fixture_error") -eq 6) `
|
||||||
|
"Fixture-error exit code changed."
|
||||||
|
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "unexpected_failure") -eq 7) `
|
||||||
|
"Unexpected-failure exit code changed."
|
||||||
|
Assert-True ((Get-MinerOpsNativeStatusExitCode `
|
||||||
|
-Status "security_denied_requires_postconditions") -eq 8) `
|
||||||
|
"Postcondition-gated security exit code changed."
|
||||||
|
Assert-True ((Get-MinerOpsNativeStatusExitCode `
|
||||||
|
-Status "api_precondition_failure") -eq 9) `
|
||||||
|
"API-precondition exit code changed."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "helper distinguishes security, fixture, and unexpected failures" {
|
||||||
|
$denied = [UnauthorizedAccessException]::new("denied")
|
||||||
|
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||||
|
-Operation read -Exception $denied) -ceq "security_denied") `
|
||||||
|
"UnauthorizedAccessException was not a security denial."
|
||||||
|
|
||||||
|
$unableToRemove = [IO.IOException]::new("expected", -2147023721)
|
||||||
|
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||||
|
-Operation swap -Exception $unableToRemove) -ceq
|
||||||
|
"security_denied_requires_postconditions") `
|
||||||
|
"ERROR_UNABLE_TO_REMOVE_REPLACED was not postcondition-gated."
|
||||||
|
|
||||||
|
$missing = [IO.FileNotFoundException]::new("missing")
|
||||||
|
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||||
|
-Operation swap -Exception $missing) -ceq "fixture_error") `
|
||||||
|
"Missing swap source was not a fixture error."
|
||||||
|
|
||||||
|
$invalidArgument = [ArgumentException]::new("illegal backup path")
|
||||||
|
Assert-True ($invalidArgument.HResult -eq -2147024809) `
|
||||||
|
"ArgumentException no longer carries 0x80070057."
|
||||||
|
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||||
|
-Operation swap -Exception $invalidArgument) -ceq "fixture_error") `
|
||||||
|
"ERROR_INVALID_PARAMETER-like argument failure was not a fixture error."
|
||||||
|
|
||||||
|
$crossVolume = [IO.IOException]::new("different volume", -2147024879)
|
||||||
|
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||||
|
-Operation swap -Exception $crossVolume) -ceq
|
||||||
|
"api_precondition_failure") `
|
||||||
|
"Cross-volume swap was not an API-precondition failure."
|
||||||
|
|
||||||
|
$partialMove = [IO.IOException]::new("unexpected", -2147023720)
|
||||||
|
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||||
|
-Operation swap -Exception $partialMove) -ceq "unexpected_failure") `
|
||||||
|
"Partial ReplaceFile failure was accepted."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "helper unwraps MethodInvocationException security denial" {
|
||||||
|
$denied = [UnauthorizedAccessException]::new("denied")
|
||||||
|
$wrapped = [Management.Automation.MethodInvocationException]::new(
|
||||||
|
"method invocation failed",
|
||||||
|
$denied
|
||||||
|
)
|
||||||
|
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||||
|
-Operation read -Exception $wrapped) -ceq "security_denied") `
|
||||||
|
"Wrapped UnauthorizedAccessException was not a security denial."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "helper unwraps multiple PowerShell exception layers" {
|
||||||
|
$denied = [UnauthorizedAccessException]::new("denied")
|
||||||
|
$methodWrapper = [Management.Automation.MethodInvocationException]::new(
|
||||||
|
"method invocation failed",
|
||||||
|
$denied
|
||||||
|
)
|
||||||
|
$runtimeWrapper = [Management.Automation.RuntimeException]::new(
|
||||||
|
"runtime failed",
|
||||||
|
$methodWrapper
|
||||||
|
)
|
||||||
|
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||||
|
-Operation read -Exception $runtimeWrapper) -ceq "security_denied") `
|
||||||
|
"Nested PowerShell wrappers were not unwrapped."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "helper decodes only HRESULT_FROM_WIN32 values" {
|
||||||
|
Assert-True ((Get-MinerOpsNativeWin32Error -HResult -2147024891) -eq 5) `
|
||||||
|
"0x80070005 did not decode as ERROR_ACCESS_DENIED."
|
||||||
|
$accessDenied = [IO.IOException]::new("access denied", -2147024891)
|
||||||
|
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||||
|
-Operation read -Exception $accessDenied) -ceq "security_denied") `
|
||||||
|
"Win32-derived ERROR_ACCESS_DENIED was not security evidence."
|
||||||
|
Assert-True ($null -eq (
|
||||||
|
Get-MinerOpsNativeWin32Error -HResult -2146233087
|
||||||
|
)) "CLR HRESULT 0x80131501 produced a bogus native error."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "unrelated wrapped IOException is not a security denial" {
|
||||||
|
$ioFailure = [IO.IOException]::new("unrelated")
|
||||||
|
$wrapped = [Management.Automation.MethodInvocationException]::new(
|
||||||
|
"method invocation failed",
|
||||||
|
$ioFailure
|
||||||
|
)
|
||||||
|
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||||
|
-Operation read -Exception $wrapped) -ceq "unexpected_failure") `
|
||||||
|
"Unrelated wrapped IOException was accepted as security evidence."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "wrapped fixture and precondition failures stay distinct" {
|
||||||
|
$missing = [IO.FileNotFoundException]::new("missing")
|
||||||
|
$wrappedMissing = [Management.Automation.MethodInvocationException]::new(
|
||||||
|
"method invocation failed",
|
||||||
|
$missing
|
||||||
|
)
|
||||||
|
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||||
|
-Operation swap -Exception $wrappedMissing) -ceq "fixture_error") `
|
||||||
|
"Wrapped fixture error changed classification."
|
||||||
|
|
||||||
|
$crossVolume = [IO.IOException]::new("different volume", -2147024879)
|
||||||
|
$wrappedCrossVolume = [Reflection.TargetInvocationException]::new(
|
||||||
|
"target invocation failed",
|
||||||
|
$crossVolume
|
||||||
|
)
|
||||||
|
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||||
|
-Operation swap -Exception $wrappedCrossVolume) -ceq
|
||||||
|
"api_precondition_failure") `
|
||||||
|
"Wrapped API precondition changed classification."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "exception diagnostics report outer leaf and chain types" {
|
||||||
|
$denied = [UnauthorizedAccessException]::new("denied")
|
||||||
|
$wrapped = [Management.Automation.MethodInvocationException]::new(
|
||||||
|
"method invocation failed",
|
||||||
|
$denied
|
||||||
|
)
|
||||||
|
$details = Get-MinerOpsNativeExceptionDetails -Exception $wrapped
|
||||||
|
Assert-True ($details.OuterExceptionType -ceq
|
||||||
|
"System.Management.Automation.MethodInvocationException") `
|
||||||
|
"Exception diagnostics recorded the wrong outer type."
|
||||||
|
Assert-True ($details.LeafExceptionType -ceq
|
||||||
|
"System.UnauthorizedAccessException") `
|
||||||
|
"Exception diagnostics recorded the wrong leaf type."
|
||||||
|
Assert-True ($details.ExceptionChainTypes.Count -eq 2) `
|
||||||
|
"Exception diagnostics recorded the wrong chain length."
|
||||||
|
Assert-True (-not $details.ExceptionChainTruncated) `
|
||||||
|
"A short exception chain was reported as truncated."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "exception diagnostics handle null InnerException" {
|
||||||
|
$wrapperOnly = [Management.Automation.RuntimeException]::new(
|
||||||
|
"wrapper without inner exception"
|
||||||
|
)
|
||||||
|
$details = Get-MinerOpsNativeExceptionDetails -Exception $wrapperOnly
|
||||||
|
Assert-True ($details.OuterExceptionType -ceq $details.LeafExceptionType) `
|
||||||
|
"Null InnerException did not leave the wrapper as the leaf."
|
||||||
|
Assert-True ($details.ExceptionChainTypes.Count -eq 1) `
|
||||||
|
"Null InnerException produced an invalid chain."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "exception-chain traversal is bounded defensively" {
|
||||||
|
[Exception]$deep = [UnauthorizedAccessException]::new("denied")
|
||||||
|
foreach ($level in 1..40) {
|
||||||
|
$deep = [Exception]::new("wrapper $level", $deep)
|
||||||
|
}
|
||||||
|
$details = Get-MinerOpsNativeExceptionDetails -Exception $deep
|
||||||
|
Assert-True ($details.ExceptionChainTypes.Count -eq 32) `
|
||||||
|
"Deep exception traversal did not stop at its bound."
|
||||||
|
Assert-True ([bool]$details.ExceptionChainTruncated) `
|
||||||
|
"Deep exception traversal did not report truncation."
|
||||||
|
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||||
|
-Operation read -Exception $deep) -ceq "unexpected_failure") `
|
||||||
|
"Truncated exception evidence did not fail closed."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "swap probe parses and passes an explicit native null" {
|
||||||
|
$probePath = Join-Path $PSScriptRoot `
|
||||||
|
"Invoke-MinerOpsStandardUserProbe.ps1"
|
||||||
|
$tokens = $null
|
||||||
|
$parseErrors = $null
|
||||||
|
[Management.Automation.Language.Parser]::ParseFile(
|
||||||
|
$probePath,
|
||||||
|
[ref]$tokens,
|
||||||
|
[ref]$parseErrors
|
||||||
|
) | Out-Null
|
||||||
|
Assert-True ($parseErrors.Count -eq 0) `
|
||||||
|
"Standard-user probe has parser errors."
|
||||||
|
$probeSource = [IO.File]::ReadAllText($probePath)
|
||||||
|
Assert-True ($probeSource -match '\[NullString\]::Value') `
|
||||||
|
"Swap probe does not pass an explicit native null backup argument."
|
||||||
|
Assert-True ($probeSource -notmatch
|
||||||
|
'File\]::Replace\([^\)]*\$null') `
|
||||||
|
"Swap probe regressed to a bare PowerShell null backup argument."
|
||||||
|
Assert-True ($probeSource -match
|
||||||
|
'backup-producing swap fixtures are prohibited') `
|
||||||
|
"Swap probe no longer prohibits confidentiality-unsafe backups."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "NullString preserves native null for a .NET string" {
|
||||||
|
if (-not ("MinerOpsNativeHarnessNullStringProbe" -as [type])) {
|
||||||
|
Add-Type -TypeDefinition @'
|
||||||
|
public static class MinerOpsNativeHarnessNullStringProbe
|
||||||
|
{
|
||||||
|
public static bool IsNull(string value)
|
||||||
|
{
|
||||||
|
return value == null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
}
|
||||||
|
Assert-True (-not (
|
||||||
|
[MinerOpsNativeHarnessNullStringProbe]::IsNull($null)
|
||||||
|
)) "A bare PowerShell null was not coerced to String.Empty."
|
||||||
|
Assert-True (
|
||||||
|
[MinerOpsNativeHarnessNullStringProbe]::IsNull([NullString]::Value)
|
||||||
|
) "NullString did not preserve native null for a .NET string."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "same source and destination is a fixture failure" {
|
||||||
|
$fixture = New-ReadySwapFixture
|
||||||
|
$fixture.PathsDistinct = $false
|
||||||
|
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
|
||||||
|
Assert-True ($classification.Status -ceq "fixture_error") `
|
||||||
|
"Equal source and destination paths were accepted."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "missing source is a fixture failure" {
|
||||||
|
$fixture = New-ReadySwapFixture
|
||||||
|
$fixture.SourceExists = $false
|
||||||
|
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
|
||||||
|
Assert-True ($classification.Status -ceq "fixture_error") `
|
||||||
|
"Missing source was accepted."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "missing target is a fixture failure" {
|
||||||
|
$fixture = New-ReadySwapFixture
|
||||||
|
$fixture.TargetExists = $false
|
||||||
|
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
|
||||||
|
Assert-True ($classification.Status -ceq "fixture_error") `
|
||||||
|
"Missing target was accepted."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "invalid backup argument is a fixture failure" {
|
||||||
|
foreach ($property in @(
|
||||||
|
"BackupArgumentValid",
|
||||||
|
"BackupPathsDistinct",
|
||||||
|
"BackupExpectedStateVerified",
|
||||||
|
"BackupConfidentialitySafe"
|
||||||
|
)) {
|
||||||
|
$fixture = New-ReadySwapFixture
|
||||||
|
$fixture.$property = $false
|
||||||
|
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
|
||||||
|
Assert-True ($classification.Status -ceq "fixture_error") `
|
||||||
|
"Invalid backup property $property was accepted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "cross-volume limitation is not a security pass" {
|
||||||
|
$fixture = New-ReadySwapFixture
|
||||||
|
$fixture.SameVolume = $false
|
||||||
|
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
|
||||||
|
Assert-True ($classification.Status -ceq "api_precondition_failure") `
|
||||||
|
"Cross-volume fixture was not an API-precondition failure."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "valid controlled same-volume source is attack-ready" {
|
||||||
|
$classification = Get-MinerOpsNativeSwapFixtureStatus `
|
||||||
|
-Fixture (New-ReadySwapFixture)
|
||||||
|
Assert-True ($classification.Status -ceq "ready") `
|
||||||
|
"Valid same-volume fixture was not attack-ready."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "source ownership and control are mandatory" {
|
||||||
|
foreach ($property in @(
|
||||||
|
"SourceCreatedByStandardUser",
|
||||||
|
"SourceOwnedByStandardUser",
|
||||||
|
"SourceReadable",
|
||||||
|
"SourceModifiable",
|
||||||
|
"SourceRenameable",
|
||||||
|
"SourceDeleteCapable",
|
||||||
|
"SourceReplaceAccessVerified"
|
||||||
|
)) {
|
||||||
|
$fixture = New-ReadySwapFixture
|
||||||
|
$fixture.$property = $false
|
||||||
|
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
|
||||||
|
Assert-True ($classification.Status -ceq "fixture_error") `
|
||||||
|
"Missing source-control proof $property was accepted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "invalid swap fixture cannot pass as security" {
|
||||||
|
Assert-Throws -Pattern "fixture was invalid" -Action {
|
||||||
|
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||||
|
-Result (New-SwapResult -Status "fixture_error") `
|
||||||
|
-Postconditions (New-PassingSwapPostconditions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "unrelated swap API precondition cannot pass as security" {
|
||||||
|
Assert-Throws -Pattern "unrelated API precondition" -Action {
|
||||||
|
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||||
|
-Result (New-SwapResult -Status "api_precondition_failure") `
|
||||||
|
-Postconditions (New-PassingSwapPostconditions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "successful replacement is a security failure" {
|
||||||
|
Assert-Throws -Pattern "completed the protected replacement" -Action {
|
||||||
|
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||||
|
-Result (New-SwapResult -Status "succeeded") `
|
||||||
|
-Postconditions (New-PassingSwapPostconditions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "security denial passes only with all postconditions" {
|
||||||
|
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||||
|
-Result (New-SwapResult -Status "security_denied") `
|
||||||
|
-Postconditions (New-PassingSwapPostconditions)
|
||||||
|
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||||
|
-Result (New-SwapResult `
|
||||||
|
-Status "security_denied_requires_postconditions") `
|
||||||
|
-Postconditions (New-PassingSwapPostconditions)
|
||||||
|
|
||||||
|
$failed = New-PassingSwapPostconditions
|
||||||
|
$failed.TargetAclTrusted = $false
|
||||||
|
Assert-Throws -Pattern "target ACL is not trusted" -Action {
|
||||||
|
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||||
|
-Result (New-SwapResult -Status "security_denied") `
|
||||||
|
-Postconditions $failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "denial with changed target identity is a security failure" {
|
||||||
|
$failed = New-PassingSwapPostconditions
|
||||||
|
$failed.TargetIdentityUnchanged = $false
|
||||||
|
Assert-Throws -Pattern "target identity changed" -Action {
|
||||||
|
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||||
|
-Result (New-SwapResult -Status "security_denied") `
|
||||||
|
-Postconditions $failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "denial with changed target content is a security failure" {
|
||||||
|
$failed = New-PassingSwapPostconditions
|
||||||
|
$failed.TargetContentUnchanged = $false
|
||||||
|
Assert-Throws -Pattern "target content changed" -Action {
|
||||||
|
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||||
|
-Result (New-SwapResult -Status "security_denied") `
|
||||||
|
-Postconditions $failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "denial with changed ACL or owner is a security failure" {
|
||||||
|
foreach ($property in @(
|
||||||
|
"TargetAclTrusted",
|
||||||
|
"TargetAclUnchanged",
|
||||||
|
"TargetOwnerTrusted",
|
||||||
|
"TargetOwnerUnchanged"
|
||||||
|
)) {
|
||||||
|
$failed = New-PassingSwapPostconditions
|
||||||
|
$failed.$property = $false
|
||||||
|
Assert-Throws -Pattern "target (ACL|owner)" -Action {
|
||||||
|
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||||
|
-Result (New-SwapResult -Status "security_denied") `
|
||||||
|
-Postconditions $failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "attacker-readable protected backup is a security failure" {
|
||||||
|
$failed = New-PassingSwapPostconditions
|
||||||
|
$failed.NoAttackerReadableBackup = $false
|
||||||
|
Assert-Throws -Pattern "through a backup" -Action {
|
||||||
|
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||||
|
-Result (New-SwapResult -Status "security_denied") `
|
||||||
|
-Postconditions $failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "denial must record that File.Replace was reached" {
|
||||||
|
$result = New-SwapResult -Status "security_denied"
|
||||||
|
$result.replace_invoked = $false
|
||||||
|
Assert-Throws -Pattern "did not reach System.IO.File.Replace" -Action {
|
||||||
|
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||||
|
-Result $result -Postconditions (New-PassingSwapPostconditions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$initialState = Get-MinerOpsNativeGetItemState
|
||||||
|
try {
|
||||||
|
Invoke-HarnessTest "trace leaves no global function when none existed" {
|
||||||
|
$childScript = Join-Path $PSScriptRoot `
|
||||||
|
"Test-MinerOpsNativeHarnessGetItemState.ps1"
|
||||||
|
$powerShell = (Get-Process -Id $PID).Path
|
||||||
|
& $powerShell -NoLogo -NoProfile -File $childScript `
|
||||||
|
-Mode NoExistingFunction -SupportPath $supportPath
|
||||||
|
Assert-True ($LASTEXITCODE -eq 0) `
|
||||||
|
"No-existing-function child test failed."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "trace restores an existing global function exactly" {
|
||||||
|
$childScript = Join-Path $PSScriptRoot `
|
||||||
|
"Test-MinerOpsNativeHarnessGetItemState.ps1"
|
||||||
|
$powerShell = (Get-Process -Id $PID).Path
|
||||||
|
& $powerShell -NoLogo -NoProfile -File $childScript `
|
||||||
|
-Mode ExistingFunction -SupportPath $supportPath
|
||||||
|
Assert-True ($LASTEXITCODE -eq 0) `
|
||||||
|
"Existing-function child test failed."
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "trace cleanup survives Action failure" {
|
||||||
|
$before = Get-MinerOpsNativeGetItemState
|
||||||
|
Assert-Throws -Pattern "injected trace failure" -Action {
|
||||||
|
Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||||
|
Get-Item -LiteralPath $supportPath | Out-Null
|
||||||
|
throw "injected trace failure"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||||
|
-Context "throwing trace action"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "trace cleanup survives a later assertion failure" {
|
||||||
|
$before = Get-MinerOpsNativeGetItemState
|
||||||
|
try {
|
||||||
|
[void](Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||||
|
Get-Item -LiteralPath $supportPath
|
||||||
|
})
|
||||||
|
throw "injected post-trace assertion failure"
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Assert-True (
|
||||||
|
$_.Exception.Message -match "post-trace assertion failure"
|
||||||
|
) "Unexpected post-trace failure."
|
||||||
|
}
|
||||||
|
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||||
|
-Context "post-trace assertion failure"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "multiple sequential traces preserve command state" {
|
||||||
|
$before = Get-MinerOpsNativeGetItemState
|
||||||
|
foreach ($iteration in 1..3) {
|
||||||
|
$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||||
|
Get-Item -LiteralPath $supportPath
|
||||||
|
})
|
||||||
|
Assert-True ($observed.Count -eq 1) `
|
||||||
|
"Sequential trace $iteration recorded the wrong count."
|
||||||
|
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||||
|
-Context "sequential trace $iteration"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-HarnessTest "simulated top-level failure restores Get-Item" {
|
||||||
|
$before = Get-MinerOpsNativeGetItemState
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
[void](Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||||
|
Get-Item -LiteralPath $supportPath
|
||||||
|
})
|
||||||
|
throw "injected top-level harness failure"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||||
|
-Context "simulated harness final cleanup"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Assert-True (
|
||||||
|
$_.Exception.Message -match "top-level harness failure"
|
||||||
|
) "Unexpected simulated top-level failure."
|
||||||
|
}
|
||||||
|
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||||
|
-Context "after simulated top-level failure"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Assert-MinerOpsNativeGetItemState -Expected $initialState `
|
||||||
|
-Context "portable native-harness tests final cleanup"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "$Passed native-harness regression tests passed."
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateSet("NoExistingFunction", "ExistingFunction")]
|
||||||
|
[string]$Mode,
|
||||||
|
[Parameter(Mandatory = $true)][string]$SupportPath
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
. $SupportPath
|
||||||
|
|
||||||
|
if ($Mode -ceq "NoExistingFunction") {
|
||||||
|
$before = Get-MinerOpsNativeGetItemState
|
||||||
|
if ([bool]$before.FunctionExisted) {
|
||||||
|
throw "Child process unexpectedly began with a global Get-Item function."
|
||||||
|
}
|
||||||
|
$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||||
|
Get-Item -LiteralPath $SupportPath
|
||||||
|
})
|
||||||
|
if ($observed -cnotcontains $SupportPath) {
|
||||||
|
throw "Trace did not observe Get-Item."
|
||||||
|
}
|
||||||
|
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||||
|
-Context "no-preexisting-function child test"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = { param($LiteralPath) "preexisting:$LiteralPath" }
|
||||||
|
Set-Item -LiteralPath "Function:\global:Get-Item" -Value $existing -Force
|
||||||
|
$before = Get-MinerOpsNativeGetItemState
|
||||||
|
$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||||
|
Get-Item -LiteralPath $SupportPath
|
||||||
|
})
|
||||||
|
if ($observed -cnotcontains $SupportPath) {
|
||||||
|
throw "Trace did not observe Get-Item over an existing global function."
|
||||||
|
}
|
||||||
|
$after = Get-MinerOpsNativeGetItemState
|
||||||
|
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||||
|
-Context "preexisting-function child test"
|
||||||
|
if ($after.FunctionScriptBlock.ToString() -cne $existing.ToString()) {
|
||||||
|
throw "Existing Get-Item ScriptBlock was not restored exactly."
|
||||||
|
}
|
||||||
|
if (-not [object]::ReferenceEquals(
|
||||||
|
$after.FunctionScriptBlock,
|
||||||
|
$before.FunctionScriptBlock
|
||||||
|
)) {
|
||||||
|
throw "Existing Get-Item ScriptBlock instance changed."
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
# Disposable Windows filesystem-trust validation
|
||||||
|
|
||||||
|
Native Windows evidence is required before deployment. The cross-platform
|
||||||
|
behavioral harness proves deterministic state-machine decisions; it cannot
|
||||||
|
prove NTFS access checks, directory-handle sharing, junction behavior,
|
||||||
|
same-directory rename semantics, or Scheduled Task behavior.
|
||||||
|
|
||||||
|
## Isolation
|
||||||
|
|
||||||
|
Use a newly created, disposable Windows 10/11 or Windows Server VM. Do not join
|
||||||
|
it to a production domain or connect it to a production MinerOps Hub, database,
|
||||||
|
or miner network. Never use a production package, URL, Agent ID, transition
|
||||||
|
token, shared secret, or API3 credential. Copy only the candidate
|
||||||
|
`agents\windows` directory to the VM and take a snapshot first.
|
||||||
|
|
||||||
|
Run 64-bit Windows PowerShell 5.1 as Administrator:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-ExecutionPolicy -Scope Process Bypass
|
||||||
|
.\tests\Test-MinerOpsCredentialTransition.Windows.ps1 `
|
||||||
|
-AcknowledgeDisposableVm
|
||||||
|
```
|
||||||
|
|
||||||
|
Record the exact pass count printed by the candidate harness. Do not claim the
|
||||||
|
native suite passed unless this command actually completed on Windows. Save the
|
||||||
|
complete transcript plus:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-ComputerInfo | Select-Object WindowsProductName, WindowsVersion, OsBuildNumber
|
||||||
|
$PSVersionTable
|
||||||
|
```
|
||||||
|
|
||||||
|
The native primitive suite verifies:
|
||||||
|
|
||||||
|
- protected credential, enrollment, installer, and trust-blocker files are
|
||||||
|
created with their final `FileSecurity`, held exclusively, and read back
|
||||||
|
through the same still-empty handle before any content is written;
|
||||||
|
- the directory and credential-file DACLs deny standard-user read, create,
|
||||||
|
delete, rename, and replacement-swap operations;
|
||||||
|
- ACL setup or read-back failure cannot leave secret-bearing content;
|
||||||
|
- a pre-created pathname remains unchanged when `CreateNew` fails;
|
||||||
|
- an insecure artifact is repaired through an exclusive handle with explicit
|
||||||
|
security rights, then rejected; inability to acquire those rights fails
|
||||||
|
closed without a path-based fallback;
|
||||||
|
- the durable blocker exists before an ACL-repaired artifact could be
|
||||||
|
reconsidered, and remains effective on the next check;
|
||||||
|
- quarantine evidence is unreadable to the standard user;
|
||||||
|
- the two-rename install leaves the new active file and a non-authoritative
|
||||||
|
prior remnant, and an active-missing install does not invent prior authority;
|
||||||
|
- a directory junction is rejected;
|
||||||
|
- a permissive directory is hardened, gets an external durable blocker, and
|
||||||
|
cannot continue in that process or an ordinary rerun;
|
||||||
|
- pre-repair standard-user file and directory handles are exercised after ACL
|
||||||
|
repair while automatic transition remains blocked regardless of whether the
|
||||||
|
handle operation succeeds;
|
||||||
|
- hard-linked enrollment, runtime, and backup targets are rejected; and
|
||||||
|
- a real harmless expected Scheduled Task is positively verified for exact
|
||||||
|
`Running` and `Ready`, while missing, query failure, disabled, malformed,
|
||||||
|
wrong-action, and wrong-principal cases fail closed.
|
||||||
|
|
||||||
|
## Full-kernel-boot epoch matrix
|
||||||
|
|
||||||
|
The source uses the canonical value
|
||||||
|
`windows-full-boot-event-v1:<16 lowercase hex digits>`. The payload is the
|
||||||
|
Event Log `RecordId` anchor of the newest System-log event from
|
||||||
|
`Microsoft-Windows-Kernel-Boot`, event ID 27, whose namespace-aware XML payload
|
||||||
|
contains exactly one canonical `BootType` value equal to `0`. `ProviderName`,
|
||||||
|
`Id`, nullable `RecordId`, and nullable `Version` must have the real typed
|
||||||
|
EventLogRecord shapes; supported versions are 0 and 1. The record ID is not an
|
||||||
|
immutable boot GUID. A reset requires a valid current anchor strictly greater
|
||||||
|
than the stored anchor. Equal, lower/restored/reused, missing, and ambiguous
|
||||||
|
anchors fail closed. The implementation deliberately does not use wall-clock
|
||||||
|
time or the Windows `BootId` counter: Fast Startup is a hibernated kernel
|
||||||
|
restoration and must not authorize reset merely because Windows reports a new
|
||||||
|
startup sequence.
|
||||||
|
|
||||||
|
On a disposable VM, dot-source the transition script and record
|
||||||
|
`Get-MinerOpsBootIdentifier` before and after every row. Preserve the raw event
|
||||||
|
object and `ToXml()` output from `Get-WinEvent`. For each event, record the
|
||||||
|
runtime types and values of `ProviderName`, `Id`, `RecordId`, and `Version`, the
|
||||||
|
root namespace, and the `System/EventData/Data[@Name='BootType']` shape.
|
||||||
|
|
||||||
|
| Transition | Required canonical identity |
|
||||||
|
| --- | --- |
|
||||||
|
| Change system clock within the running session | unchanged |
|
||||||
|
| Change timezone within the running session | unchanged |
|
||||||
|
| Sleep and resume | unchanged |
|
||||||
|
| Hibernate and resume, where supported | unchanged |
|
||||||
|
| Shutdown and power-on with Fast Startup, where supported | unchanged |
|
||||||
|
| Restart from the Windows power menu | strictly greater record anchor |
|
||||||
|
|
||||||
|
For the Fast Startup row, independently confirm that Windows restored kernel
|
||||||
|
session 0 and that the latest event 27 has a nonzero `BootType`; the newest
|
||||||
|
full-boot (`BootType=0`) event record must remain the same. For the Restart row,
|
||||||
|
confirm a new exact `BootType=0` event and a new canonical identity. Also test a
|
||||||
|
missing System log/provider event, duplicated `BootType`, malformed XML value,
|
||||||
|
wrong namespace, unsupported/null version, wrong provider/event ID,
|
||||||
|
zero/malformed record ID, and query access failure; every case must fail
|
||||||
|
closed.
|
||||||
|
|
||||||
|
Use a separate disposable snapshot for log-retention behavior. Preserve the
|
||||||
|
pre-clear stored anchor and evidence first, then clear only the disposable VM's
|
||||||
|
System log. Verify that missing history cannot authorize reset. After a full
|
||||||
|
Restart, if Event Log numbering restarts at or below the stored anchor, verify
|
||||||
|
that automatic reset remains blocked and requires operator intervention. Also
|
||||||
|
restore a snapshot exposing only an older valid full-boot event and confirm the
|
||||||
|
lower record ID is rejected. Do not add a timestamp fallback for any row. A
|
||||||
|
native result is required because EventLogRecord property types, provider
|
||||||
|
namespace/version, log-clear numbering, and power-state behavior cannot be
|
||||||
|
proven by the Linux harness.
|
||||||
|
|
||||||
|
## Loopback workflow fixture
|
||||||
|
|
||||||
|
Use a fresh snapshot for full workflow testing. All values must be disposable.
|
||||||
|
|
||||||
|
1. Create a test `C:\MinerOpsAgent\agent-config.json` containing random Agent,
|
||||||
|
Site, and Zone UUIDs plus placeholder secrets.
|
||||||
|
2. Place a harmless `MinerOpsAgentService.ps1` in the target and register
|
||||||
|
`MinerOpsAgent` with the exact installer-controlled SYSTEM/highest action:
|
||||||
|
`powershell.exe -NoProfile -ExecutionPolicy Bypass -File
|
||||||
|
"C:\MinerOpsAgent\MinerOpsAgentService.ps1"`.
|
||||||
|
3. Serve a loopback-only mock at `http://127.0.0.1:<port>`. Its
|
||||||
|
`POST /api/agent/credential-transition` handler records only the token hash
|
||||||
|
and submitted `agent_secret_hash`, returns the fixed disposable
|
||||||
|
Agent/Site/Zone identity, returns the same response for the same token and
|
||||||
|
hash, and rejects a changed hash.
|
||||||
|
4. Build a local package with that loopback origin and a random disposable
|
||||||
|
43-character token. Never download from or call a real Hub.
|
||||||
|
5. For each case, restore the snapshot or recreate the fixture. Run once with
|
||||||
|
the task initially running and once with it intentionally stopped.
|
||||||
|
|
||||||
|
The child-process shape for hard termination is:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$checkpoint = 'pending-written'
|
||||||
|
$code = @"
|
||||||
|
. 'C:\validation\agents\windows\Upgrade-MinerOpsAgentCredential.ps1'
|
||||||
|
Invoke-MinerOpsAgentCredentialTransition `
|
||||||
|
-TargetRoot 'C:\MinerOpsAgent' `
|
||||||
|
-PackageRoot 'C:\validation\agents\windows' `
|
||||||
|
-ScheduledTaskName 'MinerOpsAgent' `
|
||||||
|
-CheckpointAction { param(`$name) if (`$name -eq '$checkpoint') { Stop-Process -Id `$PID -Force } }
|
||||||
|
"@
|
||||||
|
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($code))
|
||||||
|
Start-Process powershell.exe `
|
||||||
|
-ArgumentList '-NoProfile','-EncodedCommand',$encoded -Wait
|
||||||
|
```
|
||||||
|
|
||||||
|
After termination, rerun normally without deleting, repairing, or editing any
|
||||||
|
artifact. Every retry request must use the same token and
|
||||||
|
`agent_secret_hash`; no retry may generate a replacement secret.
|
||||||
|
|
||||||
|
## Hard-termination matrix
|
||||||
|
|
||||||
|
| Checkpoint | State before rerun | Required rerun result |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `directory-repair-blocker-written` | external protected blocker durable before repair | no artifact is trusted; every ordinary invocation remains blocked |
|
||||||
|
| `directory-trust-established` | hardened directory; external blocker retained | pre-repair recovery/pending/remnants are quarantined without parsing; even a clean repair cannot continue in this process |
|
||||||
|
| `directory-containment-scan-complete` | initial known-name scan finished; external blocker retained | a late recovery/pending file never becomes authority on this or later ordinary invocations |
|
||||||
|
| `trust-reset-authorized` | changed full-boot epoch and strict original task intent are durable; blocker retained | approved retry preserves that intent and repeats publication; it cannot delete early |
|
||||||
|
| `trust-reset-runtime-published` | fresh package runtime is durable; blocker is `runtime-published` | approved retry resumes credentials/config/task convergence with the blocker retained |
|
||||||
|
| `quarantine-blocker-written` | blocker durable; insecure authoritative artifact may remain | second run never parses or redeems it |
|
||||||
|
| `artifact-moved-to-quarantine` | authority name absent; evidence is beneath quarantine | evidence remains non-authoritative and the quarantine directory is reverified |
|
||||||
|
| `quarantine-directory-verified` | evidence is under the protected non-traversable quarantine directory | rerun remains blocked and never reads or ACL-repairs evidence contents |
|
||||||
|
| `recovery-written` | protected recovery; original task state retained | exact saved token/secret is reused |
|
||||||
|
| `task-stopped` | recovery protected; task stopped; no request yet | transition completes and restores original task intent |
|
||||||
|
| `server-committed` | server may have committed; task stopped | idempotent same-hash redemption completes |
|
||||||
|
| `pending-written` | protected matching recovery and pending | pending is validated and reused, not overwritten |
|
||||||
|
| `before-replace` | active legacy config plus pending | first rename may proceed; rerun converges |
|
||||||
|
| `active-moved-to-prior` | active missing; matching pending and legacy prior present | prior is never promoted; pending becomes active |
|
||||||
|
| `pending-moved-to-active` | committed active plus prior; pending absent | active is verified; prior is cleanup-only |
|
||||||
|
| `after-replace` | same as prior row | active is verified before task restoration |
|
||||||
|
| `task-restored` | active and final task state verified | retry verifies again and cleans up |
|
||||||
|
| `before-trust-blocker-removal` | active config and final strict task state verified; external blocker still present | only an explicitly approved parent retry may verify again and remove the blocker |
|
||||||
|
| `before-recovery-cleanup` | active/task verified; recovery remains last | retry uses recovery and completes cleanup |
|
||||||
|
|
||||||
|
For each row inspect ACLs and task state without displaying secret contents:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-Acl C:\MinerOpsAgent | Format-List
|
||||||
|
Get-Acl C:\MinerOpsAgent\agent-credential-transition.recovery.json `
|
||||||
|
-ErrorAction SilentlyContinue | Format-List
|
||||||
|
Get-Acl C:\MinerOpsAgent\agent-config.json.pending `
|
||||||
|
-ErrorAction SilentlyContinue | Format-List
|
||||||
|
Get-ScheduledTask -TaskName MinerOpsAgent | Select-Object TaskName, State
|
||||||
|
```
|
||||||
|
|
||||||
|
## Provenance and quarantine cases
|
||||||
|
|
||||||
|
Run these as separate restored-snapshot cases:
|
||||||
|
|
||||||
|
1. **Malicious recovery, three invocations.** Stop after `recovery-written`, add
|
||||||
|
a Users read/write ACE to recovery, replace its secret and
|
||||||
|
`original_task_running`, and run twice. The first run must create a blocker,
|
||||||
|
quarantine without parsing, and send no request. The second must remain
|
||||||
|
blocked, send no request, keep the real original task state, and omit the
|
||||||
|
attacker secret from all output. A third ordinary run must remain blocked.
|
||||||
|
2. **ACL-repair laundering attempt.** Terminate at
|
||||||
|
`quarantine-blocker-written`, then repair the still-authoritative recovery
|
||||||
|
file ACL by hand without deleting the blocker. The next run must quarantine
|
||||||
|
it without reading or further ACL mutation and must not redeem it. This
|
||||||
|
proves ACL correctness cannot override the durable provenance decision.
|
||||||
|
3. **Malicious pending, three invocations.** Stop after `pending-written`, add a
|
||||||
|
Users ACE and alter the credential/Agent data. All runs must send no new
|
||||||
|
request and must never promote pending.
|
||||||
|
4. **Permissive directory with attacker state.** Add Users Modify to the target
|
||||||
|
directory, place recovery and pending before repair, and run. Both artifacts
|
||||||
|
must be quarantined, a blocker retained, and no request sent. A hard stop at
|
||||||
|
`directory-trust-established` must have the same second-run result.
|
||||||
|
5. **Quarantine preservation.** A later successful, separately authorized
|
||||||
|
transition must not silently delete unresolved blocker or evidence files.
|
||||||
|
Operator review must remove them only after invalidating the exposed token
|
||||||
|
and obtaining a new package.
|
||||||
|
6. **Late arrival after scan.** At `directory-containment-scan-complete`, use a
|
||||||
|
handle opened before repair to attempt creation/rename of recovery and
|
||||||
|
pending names, including a file whose ACL now looks correct. The current,
|
||||||
|
second, and third ordinary runs must send no request and perform no
|
||||||
|
activation regardless of the handle operation's Windows result.
|
||||||
|
7. **Trust-epoch reset.** Use Restart, confirm the canonical full-kernel-boot
|
||||||
|
event identity changed, clear suspicious transition state only under the
|
||||||
|
approved procedure, and run with
|
||||||
|
`-ApproveFilesystemTrustResetAfterReboot`. Confirm fresh runtime publication
|
||||||
|
occurs while the external blocker exists, old runtime is never a rollback
|
||||||
|
source, the task remains stopped until transition verification, and the
|
||||||
|
blocker remains through credential/config convergence and final strict task
|
||||||
|
verification. It may disappear only after all of those parent checks pass.
|
||||||
|
The same switch before Restart must fail. Repeat after clock/timezone change,
|
||||||
|
sleep, hibernate, and Fast Startup; each must still fail as the same epoch.
|
||||||
|
8. **Direct installer reset attempts.** With a real external blocker present,
|
||||||
|
invoke `install.ps1 -ApproveFilesystemTrustResetAfterReboot` directly for a
|
||||||
|
successful publication and an injected publication failure. Repeat with the
|
||||||
|
task disabled, `Unknown`, `Queued`, wrong action, and wrong principal. The
|
||||||
|
blocker must remain in every case. Then run the complete parent workflow and
|
||||||
|
prove deletion occurs only after its final convergence checks.
|
||||||
|
|
||||||
|
In every quarantine case, test standard-user reads against every
|
||||||
|
`minerops-quarantine-credential-transition-*.evidence` file. Access must be
|
||||||
|
denied, and neither console nor error transcripts may contain its contents.
|
||||||
|
|
||||||
|
## Hard-link cases
|
||||||
|
|
||||||
|
On NTFS, create a second name for each candidate with `New-Item -ItemType
|
||||||
|
HardLink`. Test attacker-created enrollment pending, recovery, and final config
|
||||||
|
paths; installed runtime destinations; and rollback sources/destinations. Every
|
||||||
|
case must fail before secret write, redemption, publication, or restore. Verify
|
||||||
|
that no attacker-readable alias contains a permanent credential and that a
|
||||||
|
failed `CreateNew` does not delete or adopt the pre-existing file.
|
||||||
|
|
||||||
|
## Strict Scheduled Task matrix
|
||||||
|
|
||||||
|
Use the exact installer-controlled action and principal as the valid fixture.
|
||||||
|
Run both durable intents through `Running`, `Ready`, missing, provider/query
|
||||||
|
exception, access denied where practical, null/empty state, `Disabled`,
|
||||||
|
`Unknown`, `Queued`, malformed object/definition, multiple results, wrong
|
||||||
|
executable/script arguments, and wrong principal. Only running plus `Running`
|
||||||
|
and stopped plus `Ready` succeed. Stopped plus `Running` must issue stop and
|
||||||
|
then positively verify `Ready`.
|
||||||
|
|
||||||
|
Capture the actual provider types for `TaskPath`, `State`, `Settings.Enabled`,
|
||||||
|
`Actions`, action `Execute`/`Arguments`, and principal `UserId`, `LogonType`, and
|
||||||
|
`RunLevel`. Verify root `TaskPath` is an actual string equal to `\` and Enabled
|
||||||
|
is an actual Boolean `$true`. Missing, null, empty, array, numeric, and truthy
|
||||||
|
string substitutions must fail; provider enum normalization is allowed only for
|
||||||
|
`State`, `LogonType`, and `RunLevel`.
|
||||||
|
|
||||||
|
After the server-commit checkpoint, inject final task verification failure.
|
||||||
|
Recovery must remain and no success output or cleanup may occur. Repair the task
|
||||||
|
and rerun; the recorded token and `agent_secret_hash` must be identical and the
|
||||||
|
retry must converge without generating a new secret.
|
||||||
|
|
||||||
|
## Active-missing and replacement cases
|
||||||
|
|
||||||
|
Run all of these with a cryptographically matching protected recovery record:
|
||||||
|
|
||||||
|
- active missing, protected matching pending present, no prior (legacy
|
||||||
|
`ReplaceFile` partial state): converges with the same token and secret;
|
||||||
|
- active missing, matching pending and valid legacy prior present (new first
|
||||||
|
rename completed): converges through pending; prior is never authority;
|
||||||
|
- committed active and valid prior present, pending absent (new second rename
|
||||||
|
completed): verifies active and cleans prior last;
|
||||||
|
- failure before the first rename: active and pending remain, rerun converges;
|
||||||
|
- failure between the two renames: active is absent, pending and prior remain,
|
||||||
|
rerun converges;
|
||||||
|
- failure immediately after pending becomes active: active and prior remain,
|
||||||
|
rerun verifies active before cleanup;
|
||||||
|
- active missing with no recovery or with unknown pending: fails closed;
|
||||||
|
- active missing with malformed pending: fails before redemption;
|
||||||
|
- active missing with mismatched Agent, secret, or secret hash: fails before
|
||||||
|
redemption; and
|
||||||
|
- active missing with only a stale prior: fails closed and never promotes it.
|
||||||
|
|
||||||
|
For every successful active-missing case, confirm exact Agent/Hub/Site/Zone
|
||||||
|
identity, the same token and secret hash on every request, active-file DACL,
|
||||||
|
and final Scheduled Task intent. Initially running must end running;
|
||||||
|
intentionally stopped must remain stopped.
|
||||||
|
|
||||||
|
## Completion and cleanup
|
||||||
|
|
||||||
|
At successful termination, recovery, pending, prior, and consumed enrollment
|
||||||
|
files are absent; unresolved quarantine/blocker evidence is never silently
|
||||||
|
removed. Console/error transcripts contain neither token nor generated secret.
|
||||||
|
|
||||||
|
Record any disposable account, Scheduled Task, junction, directory, or mock
|
||||||
|
service left by an interrupted validation, remove only those validation
|
||||||
|
resources, then restore the snapshot or destroy the VM.
|
||||||
+7
-4
@@ -5,14 +5,17 @@ WORKDIR /app
|
|||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
COPY requirements.txt /app/requirements.txt
|
COPY backend/requirements.txt /app/requirements.txt
|
||||||
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip \
|
RUN pip install --no-cache-dir --upgrade pip \
|
||||||
&& pip install --no-cache-dir -r /app/requirements.txt
|
&& pip install --no-cache-dir -r /app/requirements.txt
|
||||||
|
|
||||||
COPY app /app/app
|
COPY backend/app /app/app
|
||||||
COPY alembic.ini /app/alembic.ini
|
COPY backend/alembic.ini /app/alembic.ini
|
||||||
COPY alembic /app/alembic
|
COPY backend/alembic /app/alembic
|
||||||
|
|
||||||
|
# Canonical Agent source used to construct short-lived enrollment packages.
|
||||||
|
COPY agents/windows /app/agents/windows
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
"""add agent zones
|
||||||
|
|
||||||
|
Revision ID: 0016_agent_zones
|
||||||
|
Revises: 0015_miner_control_foundation
|
||||||
|
Create Date: 2026-08-25
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0016_agent_zones"
|
||||||
|
down_revision: Union[str, None] = "0015_miner_control_foundation"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
DEFAULT_ZONE_NAME = "Default Zone"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_rows(rows: list[tuple]) -> str:
|
||||||
|
return ", ".join("/".join(str(value) for value in row) for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"agent_zones",
|
||||||
|
sa.Column(
|
||||||
|
"id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
server_default=sa.text("gen_random_uuid()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"site_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("sites.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"name = trim(name) AND trim(name) <> '' AND char_length(name) <= 120",
|
||||||
|
name="ck_agent_zones_name",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("id", "site_id", name="uq_agent_zones_id_site_id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_agent_zones_site_id", "agent_zones", ["site_id"])
|
||||||
|
op.create_index(
|
||||||
|
"uq_agent_zones_active_site_name",
|
||||||
|
"agent_zones",
|
||||||
|
["site_id", sa.text("lower(name)")],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("is_enabled"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"agent_zone_network_ranges",
|
||||||
|
sa.Column(
|
||||||
|
"id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
server_default=sa.text("gen_random_uuid()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"agent_zone_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("agent_zones.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("network", postgresql.CIDR(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"agent_zone_id", "network", name="uq_agent_zone_network_ranges_zone_network"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_agent_zone_network_ranges_zone_id",
|
||||||
|
"agent_zone_network_ranges",
|
||||||
|
["agent_zone_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"miners",
|
||||||
|
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_miners_agent_zone_requires_site",
|
||||||
|
"miners",
|
||||||
|
"agent_zone_id IS NULL OR site_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_miners_agent_zone_site",
|
||||||
|
"miners",
|
||||||
|
"agent_zones",
|
||||||
|
["agent_zone_id", "site_id"],
|
||||||
|
["id", "site_id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
)
|
||||||
|
op.create_index("idx_miners_agent_zone_id", "miners", ["agent_zone_id"])
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"minerops_site_agents",
|
||||||
|
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_minerops_site_agents_zone_requires_site",
|
||||||
|
"minerops_site_agents",
|
||||||
|
"agent_zone_id IS NULL OR site_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_minerops_site_agents_zone_site",
|
||||||
|
"minerops_site_agents",
|
||||||
|
"agent_zones",
|
||||||
|
["agent_zone_id", "site_id"],
|
||||||
|
["id", "site_id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"minerops_agent_snapshots",
|
||||||
|
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_minerops_agent_snapshots_agent_zone_id",
|
||||||
|
"minerops_agent_snapshots",
|
||||||
|
"agent_zones",
|
||||||
|
["agent_zone_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_minerops_agent_snapshots_agent_zone_id",
|
||||||
|
"minerops_agent_snapshots",
|
||||||
|
["agent_zone_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"minerops_agent_discoveries",
|
||||||
|
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_minerops_agent_discoveries_agent_zone_id",
|
||||||
|
"minerops_agent_discoveries",
|
||||||
|
"agent_zones",
|
||||||
|
["agent_zone_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_minerops_agent_discoveries_agent_zone_id",
|
||||||
|
"minerops_agent_discoveries",
|
||||||
|
["agent_zone_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = op.get_bind()
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zones (site_id, name)
|
||||||
|
SELECT sites.id, :default_name
|
||||||
|
FROM sites
|
||||||
|
ORDER BY sites.id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"default_name": DEFAULT_ZONE_NAME},
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE miners AS miner
|
||||||
|
SET agent_zone_id = zone.id
|
||||||
|
FROM agent_zones AS zone
|
||||||
|
WHERE miner.site_id = zone.site_id
|
||||||
|
AND zone.name = :default_name
|
||||||
|
AND miner.agent_zone_id IS NULL
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"default_name": DEFAULT_ZONE_NAME},
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE minerops_site_agents AS agent
|
||||||
|
SET agent_zone_id = zone.id
|
||||||
|
FROM agent_zones AS zone
|
||||||
|
WHERE agent.site_id = zone.site_id
|
||||||
|
AND zone.name = :default_name
|
||||||
|
AND agent.agent_zone_id IS NULL
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"default_name": DEFAULT_ZONE_NAME},
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE minerops_agent_snapshots AS snapshot
|
||||||
|
SET agent_zone_id = agent.agent_zone_id
|
||||||
|
FROM minerops_site_agents AS agent
|
||||||
|
WHERE snapshot.agent_id = agent.agent_id
|
||||||
|
AND agent.agent_zone_id IS NOT NULL
|
||||||
|
AND snapshot.agent_zone_id IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE minerops_agent_discoveries AS discovery
|
||||||
|
SET agent_zone_id = agent.agent_zone_id
|
||||||
|
FROM minerops_site_agents AS agent
|
||||||
|
WHERE discovery.agent_id = agent.agent_id
|
||||||
|
AND agent.agent_zone_id IS NOT NULL
|
||||||
|
AND discovery.agent_zone_id IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
"uq_minerops_site_agents_agent_zone_id",
|
||||||
|
"minerops_site_agents",
|
||||||
|
["agent_zone_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("agent_zone_id IS NOT NULL"),
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"uq_minerops_site_agents_site_id", table_name="minerops_site_agents"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
duplicate_site_agents = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT site_id::text, count(*)
|
||||||
|
FROM minerops_site_agents
|
||||||
|
WHERE site_id IS NOT NULL
|
||||||
|
GROUP BY site_id
|
||||||
|
HAVING count(*) > 1
|
||||||
|
ORDER BY site_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
if duplicate_site_agents:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot downgrade 0016 to 0015: multiple Agent installations are "
|
||||||
|
"assigned within Site(s): "
|
||||||
|
f"{_format_rows(list(duplicate_site_agents))}. Move or unassign Agents "
|
||||||
|
"until each Site has at most one Agent before retrying; no Agent Zone "
|
||||||
|
"data was changed."
|
||||||
|
)
|
||||||
|
|
||||||
|
non_reducible_zones = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT site_id::text, count(*)
|
||||||
|
FROM agent_zones
|
||||||
|
GROUP BY site_id
|
||||||
|
HAVING count(*) > 1
|
||||||
|
OR bool_or(name <> :default_name OR description IS NOT NULL OR NOT is_enabled)
|
||||||
|
ORDER BY site_id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"default_name": DEFAULT_ZONE_NAME},
|
||||||
|
).all()
|
||||||
|
range_count = connection.execute(
|
||||||
|
sa.text("SELECT count(*) FROM agent_zone_network_ranges")
|
||||||
|
).scalar_one()
|
||||||
|
if non_reducible_zones or range_count:
|
||||||
|
detail = _format_rows(list(non_reducible_zones)) or "network ranges exist"
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot downgrade 0016 to 0015: Agent Zone configuration cannot be "
|
||||||
|
f"represented by the one-Agent-per-Site model ({detail}). Remove "
|
||||||
|
"additional/custom Zones and Zone network ranges before retrying; no "
|
||||||
|
"Agent Zone data was changed."
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
"uq_minerops_site_agents_site_id",
|
||||||
|
"minerops_site_agents",
|
||||||
|
["site_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("site_id IS NOT NULL"),
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"uq_minerops_site_agents_agent_zone_id",
|
||||||
|
table_name="minerops_site_agents",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_index(
|
||||||
|
"idx_minerops_agent_discoveries_agent_zone_id",
|
||||||
|
table_name="minerops_agent_discoveries",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_minerops_agent_discoveries_agent_zone_id",
|
||||||
|
"minerops_agent_discoveries",
|
||||||
|
type_="foreignkey",
|
||||||
|
)
|
||||||
|
op.drop_column("minerops_agent_discoveries", "agent_zone_id")
|
||||||
|
|
||||||
|
op.drop_index(
|
||||||
|
"idx_minerops_agent_snapshots_agent_zone_id",
|
||||||
|
table_name="minerops_agent_snapshots",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_minerops_agent_snapshots_agent_zone_id",
|
||||||
|
"minerops_agent_snapshots",
|
||||||
|
type_="foreignkey",
|
||||||
|
)
|
||||||
|
op.drop_column("minerops_agent_snapshots", "agent_zone_id")
|
||||||
|
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_minerops_site_agents_zone_site",
|
||||||
|
"minerops_site_agents",
|
||||||
|
type_="foreignkey",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_minerops_site_agents_zone_requires_site",
|
||||||
|
"minerops_site_agents",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_column("minerops_site_agents", "agent_zone_id")
|
||||||
|
|
||||||
|
op.drop_index("idx_miners_agent_zone_id", table_name="miners")
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_miners_agent_zone_site", "miners", type_="foreignkey"
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_miners_agent_zone_requires_site", "miners", type_="check"
|
||||||
|
)
|
||||||
|
op.drop_column("miners", "agent_zone_id")
|
||||||
|
|
||||||
|
op.drop_index(
|
||||||
|
"idx_agent_zone_network_ranges_zone_id",
|
||||||
|
table_name="agent_zone_network_ranges",
|
||||||
|
)
|
||||||
|
op.drop_table("agent_zone_network_ranges")
|
||||||
|
op.drop_index("uq_agent_zones_active_site_name", table_name="agent_zones")
|
||||||
|
op.drop_index("idx_agent_zones_site_id", table_name="agent_zones")
|
||||||
|
op.drop_table("agent_zones")
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"""add secure Agent enrollment and per-Agent credential storage
|
||||||
|
|
||||||
|
Revision ID: 0017_agent_enrollment
|
||||||
|
Revises: 0016_agent_zones
|
||||||
|
Create Date: 2026-08-26
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0017_agent_enrollment"
|
||||||
|
down_revision: Union[str, None] = "0016_agent_zones"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Per-Agent credentials are additive. Existing Agents retain NULL credential
|
||||||
|
# fields and therefore remain eligible for the temporary legacy shared-secret
|
||||||
|
# authentication path until explicitly migrated.
|
||||||
|
op.add_column(
|
||||||
|
"minerops_site_agents",
|
||||||
|
sa.Column("display_name", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"minerops_site_agents",
|
||||||
|
sa.Column("agent_secret_hash", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"minerops_site_agents",
|
||||||
|
sa.Column(
|
||||||
|
"credential_created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"minerops_site_agents",
|
||||||
|
sa.Column(
|
||||||
|
"credential_revoked_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_minerops_site_agents_display_name",
|
||||||
|
"minerops_site_agents",
|
||||||
|
"""
|
||||||
|
display_name IS NULL
|
||||||
|
OR (
|
||||||
|
btrim(display_name) <> ''
|
||||||
|
AND char_length(display_name) <= 120
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_minerops_site_agents_secret_hash",
|
||||||
|
"minerops_site_agents",
|
||||||
|
"""
|
||||||
|
agent_secret_hash IS NULL
|
||||||
|
OR agent_secret_hash ~ '^[0-9a-f]{64}$'
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_minerops_site_agents_credential_pair",
|
||||||
|
"minerops_site_agents",
|
||||||
|
"""
|
||||||
|
(
|
||||||
|
agent_secret_hash IS NULL
|
||||||
|
AND credential_created_at IS NULL
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
agent_secret_hash IS NOT NULL
|
||||||
|
AND credential_created_at IS NOT NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_minerops_site_agents_credential_revoked_time",
|
||||||
|
"minerops_site_agents",
|
||||||
|
"""
|
||||||
|
credential_revoked_at IS NULL
|
||||||
|
OR (
|
||||||
|
credential_created_at IS NOT NULL
|
||||||
|
AND credential_revoked_at >= credential_created_at
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
"uq_minerops_site_agents_agent_secret_hash",
|
||||||
|
"minerops_site_agents",
|
||||||
|
["agent_secret_hash"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("agent_secret_hash IS NOT NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"agent_enrollments",
|
||||||
|
sa.Column(
|
||||||
|
"id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
server_default=sa.text("gen_random_uuid()"),
|
||||||
|
),
|
||||||
|
sa.Column("token_hash", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"site_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"agent_zone_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("display_name", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_by_user_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"expires_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"used_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"revoked_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("enrolled_agent_id", sa.Text(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["agent_zone_id", "site_id"],
|
||||||
|
["agent_zones.id", "agent_zones.site_id"],
|
||||||
|
name="fk_agent_enrollments_zone_site",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["enrolled_agent_id"],
|
||||||
|
["minerops_site_agents.agent_id"],
|
||||||
|
name="fk_agent_enrollments_agent",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"token_hash ~ '^[0-9a-f]{64}$'",
|
||||||
|
name="ck_agent_enrollments_token_hash",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"btrim(display_name) <> '' AND char_length(display_name) <= 120",
|
||||||
|
name="ck_agent_enrollments_display_name",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"expires_at > created_at",
|
||||||
|
name="ck_agent_enrollments_expiry",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"used_at IS NULL OR used_at >= created_at",
|
||||||
|
name="ck_agent_enrollments_used_time",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"used_at IS NULL OR used_at <= expires_at",
|
||||||
|
name="ck_agent_enrollments_used_before_expiry",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"revoked_at IS NULL OR revoked_at >= created_at",
|
||||||
|
name="ck_agent_enrollments_revoked_time",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"NOT (used_at IS NOT NULL AND revoked_at IS NOT NULL)",
|
||||||
|
name="ck_agent_enrollments_terminal_state",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"""
|
||||||
|
(
|
||||||
|
used_at IS NULL
|
||||||
|
AND enrolled_agent_id IS NULL
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
used_at IS NOT NULL
|
||||||
|
AND enrolled_agent_id IS NOT NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
name="ck_agent_enrollments_agent_when_used",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
"uq_agent_enrollments_token_hash",
|
||||||
|
"agent_enrollments",
|
||||||
|
["token_hash"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# PostgreSQL cannot use now() in a partial-index predicate because now() is
|
||||||
|
# not immutable. Therefore "open" means unused and unrevoked. Creation code
|
||||||
|
# will revoke an old expired/open enrollment before inserting a replacement.
|
||||||
|
op.create_index(
|
||||||
|
"uq_agent_enrollments_open_zone",
|
||||||
|
"agent_enrollments",
|
||||||
|
["agent_zone_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text(
|
||||||
|
"used_at IS NULL AND revoked_at IS NULL"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
"uq_agent_enrollments_enrolled_agent",
|
||||||
|
"agent_enrollments",
|
||||||
|
["enrolled_agent_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("enrolled_agent_id IS NOT NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
"idx_agent_enrollments_site_id",
|
||||||
|
"agent_enrollments",
|
||||||
|
["site_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
"idx_agent_enrollments_created_at",
|
||||||
|
"agent_enrollments",
|
||||||
|
["created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 0016 has nowhere to represent enrollment history or per-Agent
|
||||||
|
# credentials. Refuse before destructive DDL if either has been used.
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
enrollment_count = connection.execute(
|
||||||
|
sa.text("SELECT COUNT(*) FROM agent_enrollments")
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
credential_count = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM minerops_site_agents
|
||||||
|
WHERE display_name IS NOT NULL
|
||||||
|
OR agent_secret_hash IS NOT NULL
|
||||||
|
OR credential_created_at IS NOT NULL
|
||||||
|
OR credential_revoked_at IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
if enrollment_count:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot downgrade 0017: Agent enrollment history exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
if credential_count:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot downgrade 0017: per-Agent credentials exist"
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_index(
|
||||||
|
"idx_agent_enrollments_created_at",
|
||||||
|
table_name="agent_enrollments",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"idx_agent_enrollments_site_id",
|
||||||
|
table_name="agent_enrollments",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"uq_agent_enrollments_enrolled_agent",
|
||||||
|
table_name="agent_enrollments",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"uq_agent_enrollments_open_zone",
|
||||||
|
table_name="agent_enrollments",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"uq_agent_enrollments_token_hash",
|
||||||
|
table_name="agent_enrollments",
|
||||||
|
)
|
||||||
|
op.drop_table("agent_enrollments")
|
||||||
|
|
||||||
|
op.drop_index(
|
||||||
|
"uq_minerops_site_agents_agent_secret_hash",
|
||||||
|
table_name="minerops_site_agents",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_minerops_site_agents_credential_revoked_time",
|
||||||
|
"minerops_site_agents",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_minerops_site_agents_credential_pair",
|
||||||
|
"minerops_site_agents",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_minerops_site_agents_secret_hash",
|
||||||
|
"minerops_site_agents",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_minerops_site_agents_display_name",
|
||||||
|
"minerops_site_agents",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_column("minerops_site_agents", "credential_revoked_at")
|
||||||
|
op.drop_column("minerops_site_agents", "credential_created_at")
|
||||||
|
op.drop_column("minerops_site_agents", "agent_secret_hash")
|
||||||
|
op.drop_column("minerops_site_agents", "display_name")
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""route pre-Zone Agent installations without rewriting ownership history
|
||||||
|
|
||||||
|
Revision ID: 0018_legacy_agent_routing
|
||||||
|
Revises: 0017_agent_enrollment
|
||||||
|
Create Date: 2026-08-28
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0018_legacy_agent_routing"
|
||||||
|
down_revision: Union[str, None] = "0017_agent_enrollment"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
DEFAULT_ZONE_NAME = "Default Zone"
|
||||||
|
|
||||||
|
# Every preflight and the eventual Site UPDATE use this exact resolver. An
|
||||||
|
# exact trim/case-folded name match wins. The sole-Site fallback applies only
|
||||||
|
# when there are zero exact matches and exactly one durable Site in the whole
|
||||||
|
# database. Ambiguous exact matches and unresolved multi-Site installations
|
||||||
|
# deliberately produce NULL resolved_site_id and fail before any UPDATE.
|
||||||
|
RESOLVED_AGENT_SITES_CTE = """
|
||||||
|
site_catalog AS (
|
||||||
|
SELECT count(*) AS site_count,
|
||||||
|
(array_agg(id ORDER BY id))[1] AS sole_site_id
|
||||||
|
FROM sites
|
||||||
|
),
|
||||||
|
unassigned_exact_matches AS (
|
||||||
|
SELECT agent.agent_id,
|
||||||
|
agent.site_name,
|
||||||
|
count(site.id) AS exact_match_count,
|
||||||
|
(
|
||||||
|
array_agg(site.id ORDER BY site.id)
|
||||||
|
FILTER (WHERE site.id IS NOT NULL)
|
||||||
|
)[1] AS exact_site_id
|
||||||
|
FROM minerops_site_agents AS agent
|
||||||
|
LEFT JOIN sites AS site
|
||||||
|
ON lower(btrim(site.name)) = lower(btrim(agent.site_name))
|
||||||
|
WHERE agent.site_id IS NULL
|
||||||
|
GROUP BY agent.agent_id, agent.site_name
|
||||||
|
),
|
||||||
|
resolved_unassigned_agents AS (
|
||||||
|
SELECT match.agent_id,
|
||||||
|
match.site_name,
|
||||||
|
match.exact_match_count,
|
||||||
|
catalog.site_count,
|
||||||
|
CASE
|
||||||
|
WHEN match.exact_match_count = 1
|
||||||
|
THEN match.exact_site_id
|
||||||
|
WHEN match.exact_match_count = 0
|
||||||
|
AND catalog.site_count = 1
|
||||||
|
THEN catalog.sole_site_id
|
||||||
|
ELSE NULL
|
||||||
|
END AS resolved_site_id
|
||||||
|
FROM unassigned_exact_matches AS match
|
||||||
|
CROSS JOIN site_catalog AS catalog
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _format_rows(rows: list[tuple]) -> str:
|
||||||
|
return ", ".join("/".join(repr(str(value)) for value in row) for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
# Keep every preflight result stable until the corresponding routing and
|
||||||
|
# history updates commit. Production must still take the Hub and all queue
|
||||||
|
# producers offline before migration; these locks are the database-level
|
||||||
|
# backstop against an accidental concurrent Site, Zone, Agent, snapshot, or
|
||||||
|
# discovery write changing an already-checked assumption.
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
LOCK TABLE
|
||||||
|
agent_zones,
|
||||||
|
minerops_agent_discoveries,
|
||||||
|
minerops_agent_snapshots,
|
||||||
|
minerops_site_agents,
|
||||||
|
sites
|
||||||
|
IN SHARE ROW EXCLUSIVE MODE
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run every fail-closed preflight before the first UPDATE. Site resolution
|
||||||
|
# is intentionally limited to legacy rows without an explicit Site
|
||||||
|
# assignment; explicit site_id values are never candidates for replacement.
|
||||||
|
site_match_failures = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
f"""
|
||||||
|
WITH {RESOLVED_AGENT_SITES_CTE}
|
||||||
|
SELECT agent_id, site_name, exact_match_count, site_count
|
||||||
|
FROM resolved_unassigned_agents
|
||||||
|
WHERE resolved_site_id IS NULL
|
||||||
|
ORDER BY agent_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
if site_match_failures:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot route legacy Agent installations: each unassigned Agent "
|
||||||
|
"must have exactly one Site name match after trim/case folding, "
|
||||||
|
"or zero exact matches while the database contains exactly one "
|
||||||
|
"Site. Ambiguous exact matches and unmatched multi-Site "
|
||||||
|
"installations fail closed. Failing "
|
||||||
|
"agent/site/exact-match-count/site-count rows: "
|
||||||
|
f"{_format_rows(list(site_match_failures))}. Assign site_id "
|
||||||
|
"explicitly, make Site names unambiguous, or identify the correct "
|
||||||
|
"Site before retrying; no "
|
||||||
|
"Agent, miner, MAC, queue, snapshot, or discovery data was changed."
|
||||||
|
)
|
||||||
|
|
||||||
|
default_zone_failures = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
f"""
|
||||||
|
WITH {RESOLVED_AGENT_SITES_CTE},
|
||||||
|
effective_agents AS (
|
||||||
|
SELECT agent.agent_id,
|
||||||
|
coalesce(
|
||||||
|
agent.site_id,
|
||||||
|
resolved.resolved_site_id
|
||||||
|
) AS site_id
|
||||||
|
FROM minerops_site_agents AS agent
|
||||||
|
LEFT JOIN resolved_unassigned_agents AS resolved
|
||||||
|
ON resolved.agent_id = agent.agent_id
|
||||||
|
WHERE agent.agent_zone_id IS NULL
|
||||||
|
)
|
||||||
|
SELECT agent.agent_id, agent.site_id::text, count(zone.id)
|
||||||
|
FROM effective_agents AS agent
|
||||||
|
LEFT JOIN agent_zones AS zone
|
||||||
|
ON zone.site_id = agent.site_id
|
||||||
|
AND zone.is_enabled
|
||||||
|
AND lower(btrim(zone.name)) = lower(:default_name)
|
||||||
|
GROUP BY agent.agent_id, agent.site_id
|
||||||
|
HAVING count(zone.id) <> 1
|
||||||
|
ORDER BY agent.agent_id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"default_name": DEFAULT_ZONE_NAME},
|
||||||
|
).all()
|
||||||
|
if default_zone_failures:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot route legacy Agent installations: each Agent without an "
|
||||||
|
"explicit Zone must have exactly one enabled Default Zone in its "
|
||||||
|
"effective Site. Failing agent/site/count rows: "
|
||||||
|
f"{_format_rows(list(default_zone_failures))}. Repair the Site's "
|
||||||
|
"Default Zone or assign agent_zone_id explicitly before retrying; "
|
||||||
|
"no data was changed."
|
||||||
|
)
|
||||||
|
|
||||||
|
zone_conflicts = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
f"""
|
||||||
|
WITH {RESOLVED_AGENT_SITES_CTE},
|
||||||
|
targets AS (
|
||||||
|
SELECT agent.agent_id, zone.id AS agent_zone_id
|
||||||
|
FROM minerops_site_agents AS agent
|
||||||
|
LEFT JOIN resolved_unassigned_agents AS resolved
|
||||||
|
ON resolved.agent_id = agent.agent_id
|
||||||
|
JOIN agent_zones AS zone
|
||||||
|
ON zone.site_id = coalesce(
|
||||||
|
agent.site_id,
|
||||||
|
resolved.resolved_site_id
|
||||||
|
)
|
||||||
|
AND zone.is_enabled
|
||||||
|
AND lower(btrim(zone.name)) = lower(:default_name)
|
||||||
|
WHERE agent.agent_zone_id IS NULL
|
||||||
|
)
|
||||||
|
SELECT target.agent_zone_id::text,
|
||||||
|
string_agg(target.agent_id, ',' ORDER BY target.agent_id),
|
||||||
|
coalesce(string_agg(occupant.agent_id, ','
|
||||||
|
ORDER BY occupant.agent_id), '')
|
||||||
|
FROM targets AS target
|
||||||
|
LEFT JOIN minerops_site_agents AS occupant
|
||||||
|
ON occupant.agent_zone_id = target.agent_zone_id
|
||||||
|
GROUP BY target.agent_zone_id
|
||||||
|
HAVING count(DISTINCT target.agent_id) > 1
|
||||||
|
OR count(occupant.agent_id) > 0
|
||||||
|
ORDER BY target.agent_zone_id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"default_name": DEFAULT_ZONE_NAME},
|
||||||
|
).all()
|
||||||
|
if zone_conflicts:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot route legacy Agent installations: one-Agent-per-Zone would "
|
||||||
|
"be violated. Failing zone/target Agents/current Agent rows: "
|
||||||
|
f"{_format_rows(list(zone_conflicts))}. Assign distinct enabled "
|
||||||
|
"Zones explicitly before retrying; no data was changed."
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
f"""
|
||||||
|
WITH {RESOLVED_AGENT_SITES_CTE}
|
||||||
|
UPDATE minerops_site_agents AS agent
|
||||||
|
SET site_id = resolved.resolved_site_id
|
||||||
|
FROM resolved_unassigned_agents AS resolved
|
||||||
|
WHERE agent.agent_id = resolved.agent_id
|
||||||
|
AND agent.site_id IS NULL
|
||||||
|
AND resolved.resolved_site_id IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE minerops_site_agents AS agent
|
||||||
|
SET agent_zone_id = zone.id
|
||||||
|
FROM agent_zones AS zone
|
||||||
|
WHERE agent.site_id = zone.site_id
|
||||||
|
AND zone.is_enabled
|
||||||
|
AND lower(btrim(zone.name)) = lower(:default_name)
|
||||||
|
AND agent.agent_zone_id IS NULL
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"default_name": DEFAULT_ZONE_NAME},
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE minerops_agent_snapshots AS snapshot
|
||||||
|
SET agent_zone_id = agent.agent_zone_id
|
||||||
|
FROM minerops_site_agents AS agent
|
||||||
|
WHERE snapshot.agent_id = agent.agent_id
|
||||||
|
AND snapshot.agent_zone_id IS NULL
|
||||||
|
AND agent.agent_zone_id IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE minerops_agent_discoveries AS discovery
|
||||||
|
SET agent_zone_id = agent.agent_zone_id
|
||||||
|
FROM minerops_site_agents AS agent
|
||||||
|
WHERE discovery.agent_id = agent.agent_id
|
||||||
|
AND discovery.agent_zone_id IS NULL
|
||||||
|
AND agent.agent_zone_id IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# There is no durable, trustworthy way to distinguish values assigned here
|
||||||
|
# from assignments made by an operator after upgrade. Refuse instead of
|
||||||
|
# erasing potentially explicit production routing or historical attribution.
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot downgrade 0018 automatically: legacy Site/Zone routing and "
|
||||||
|
"snapshot/discovery attribution may now be production data. Restore the "
|
||||||
|
"pre-upgrade database backup for rollback; no data was changed."
|
||||||
|
)
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"""make legacy Agent authentication retirement durable and one-way
|
||||||
|
|
||||||
|
Revision ID: 0019_agent_legacy_auth_retire
|
||||||
|
Revises: 0018_legacy_agent_routing
|
||||||
|
Create Date: 2026-08-31
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0019_agent_legacy_auth_retire"
|
||||||
|
down_revision: Union[str, None] = "0018_legacy_agent_routing"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Keep the row-level value as a projection for operators and defense in
|
||||||
|
# depth. The independent identity table below is the authoritative record:
|
||||||
|
# it deliberately has no foreign key to the deletable current Agent row.
|
||||||
|
op.add_column(
|
||||||
|
"minerops_site_agents",
|
||||||
|
sa.Column(
|
||||||
|
"legacy_auth_disabled_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"agent_legacy_auth_retirements",
|
||||||
|
sa.Column("agent_id", sa.Text(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"retired_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("clock_timestamp()"),
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"btrim(agent_id) <> ''",
|
||||||
|
name="ck_agent_legacy_auth_retirements_agent_id",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = op.get_bind()
|
||||||
|
# Stabilize both credential sources until backfill and all enforcement
|
||||||
|
# triggers are installed. This preserves a single atomic migration view.
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
LOCK TABLE minerops_site_agents, agent_enrollments
|
||||||
|
IN SHARE ROW EXCLUSIVE MODE
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backfill every reliable indication that a per-Agent secret was issued.
|
||||||
|
# A used enrollment remains evidence even if credential columns were later
|
||||||
|
# cleared. Merely existing as an uncredentialed Agent is not evidence, so
|
||||||
|
# the production-shaped legacy Plexus identity remains eligible.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
WITH credential_evidence AS (
|
||||||
|
SELECT
|
||||||
|
agent_id,
|
||||||
|
coalesce(
|
||||||
|
credential_created_at,
|
||||||
|
credential_revoked_at,
|
||||||
|
clock_timestamp()
|
||||||
|
) AS retired_at
|
||||||
|
FROM minerops_site_agents
|
||||||
|
WHERE agent_secret_hash IS NOT NULL
|
||||||
|
OR credential_created_at IS NOT NULL
|
||||||
|
OR credential_revoked_at IS NOT NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT enrolled_agent_id AS agent_id, used_at AS retired_at
|
||||||
|
FROM agent_enrollments
|
||||||
|
WHERE enrolled_agent_id IS NOT NULL
|
||||||
|
AND used_at IS NOT NULL
|
||||||
|
)
|
||||||
|
INSERT INTO agent_legacy_auth_retirements (agent_id, retired_at)
|
||||||
|
SELECT agent_id, min(retired_at)
|
||||||
|
FROM credential_evidence
|
||||||
|
GROUP BY agent_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE minerops_site_agents AS agent
|
||||||
|
SET legacy_auth_disabled_at = retirement.retired_at
|
||||||
|
FROM agent_legacy_auth_retirements AS retirement
|
||||||
|
WHERE retirement.agent_id = agent.agent_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_minerops_site_agents_credential_retires_legacy_auth",
|
||||||
|
"minerops_site_agents",
|
||||||
|
"""
|
||||||
|
legacy_auth_disabled_at IS NOT NULL
|
||||||
|
OR (
|
||||||
|
agent_secret_hash IS NULL
|
||||||
|
AND credential_created_at IS NULL
|
||||||
|
AND credential_revoked_at IS NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
# The retirement ledger is append-only for ordinary database operations.
|
||||||
|
# Migration rollback can still DROP the empty table, and a DBA retains the
|
||||||
|
# usual PostgreSQL superuser disaster-recovery capabilities.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE FUNCTION minerops_prevent_legacy_auth_retirement_mutation()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'legacy Agent authentication retirement history is append-only'
|
||||||
|
USING ERRCODE = '23514';
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER trg_agent_legacy_auth_retirements_immutable
|
||||||
|
BEFORE UPDATE OR DELETE
|
||||||
|
ON agent_legacy_auth_retirements
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION minerops_prevent_legacy_auth_retirement_mutation()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER trg_agent_legacy_auth_retirements_no_truncate
|
||||||
|
BEFORE TRUNCATE
|
||||||
|
ON agent_legacy_auth_retirements
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION minerops_prevent_legacy_auth_retirement_mutation()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Any database path that stores credential evidence also creates the
|
||||||
|
# identity tombstone in the same transaction. The row marker is always
|
||||||
|
# synchronized from that tombstone, including on recreation of an Agent
|
||||||
|
# row whose identity was previously retired.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE FUNCTION minerops_sync_agent_legacy_auth_retirement()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
identity_retired_at timestamptz;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.agent_secret_hash IS NOT NULL
|
||||||
|
OR NEW.credential_created_at IS NOT NULL
|
||||||
|
OR NEW.credential_revoked_at IS NOT NULL
|
||||||
|
OR NEW.legacy_auth_disabled_at IS NOT NULL THEN
|
||||||
|
INSERT INTO agent_legacy_auth_retirements (
|
||||||
|
agent_id,
|
||||||
|
retired_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
NEW.agent_id,
|
||||||
|
coalesce(
|
||||||
|
NEW.credential_created_at,
|
||||||
|
NEW.legacy_auth_disabled_at,
|
||||||
|
NEW.credential_revoked_at,
|
||||||
|
clock_timestamp()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ON CONFLICT (agent_id) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT retired_at
|
||||||
|
INTO identity_retired_at
|
||||||
|
FROM agent_legacy_auth_retirements
|
||||||
|
WHERE agent_id = NEW.agent_id;
|
||||||
|
|
||||||
|
IF identity_retired_at IS NOT NULL THEN
|
||||||
|
IF TG_OP = 'UPDATE'
|
||||||
|
AND NEW.agent_id = OLD.agent_id
|
||||||
|
AND NEW.legacy_auth_disabled_at
|
||||||
|
IS DISTINCT FROM OLD.legacy_auth_disabled_at
|
||||||
|
AND NEW.legacy_auth_disabled_at
|
||||||
|
IS DISTINCT FROM identity_retired_at THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'legacy Agent authentication retirement is permanent'
|
||||||
|
USING ERRCODE = '23514';
|
||||||
|
END IF;
|
||||||
|
NEW.legacy_auth_disabled_at := identity_retired_at;
|
||||||
|
ELSE
|
||||||
|
NEW.legacy_auth_disabled_at := NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER trg_minerops_site_agents_legacy_auth_retirement
|
||||||
|
BEFORE INSERT OR UPDATE OF
|
||||||
|
agent_id,
|
||||||
|
agent_secret_hash,
|
||||||
|
credential_created_at,
|
||||||
|
credential_revoked_at,
|
||||||
|
legacy_auth_disabled_at
|
||||||
|
ON minerops_site_agents
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION minerops_sync_agent_legacy_auth_retirement()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Direct, one-way ledger inserts also refresh the optional row projection.
|
||||||
|
# Skip the nested case where the Agent trigger itself inserted the ledger
|
||||||
|
# row; that outer trigger already has NEW and will set the projection.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE FUNCTION minerops_project_agent_legacy_auth_retirement()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF pg_trigger_depth() = 1 THEN
|
||||||
|
UPDATE minerops_site_agents
|
||||||
|
SET legacy_auth_disabled_at = NEW.retired_at
|
||||||
|
WHERE agent_id = NEW.agent_id;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER trg_agent_legacy_auth_retirements_project
|
||||||
|
AFTER INSERT
|
||||||
|
ON agent_legacy_auth_retirements
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION minerops_project_agent_legacy_auth_retirement()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
retired_count = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT count(*)
|
||||||
|
FROM agent_legacy_auth_retirements
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
if retired_count:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot downgrade 0019: durable identity-level legacy-auth "
|
||||||
|
"retirement history exists. Restore the pre-upgrade database "
|
||||||
|
"backup rather than making retired Agent identities eligible for "
|
||||||
|
"legacy auth again."
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DROP TRIGGER trg_agent_legacy_auth_retirements_project
|
||||||
|
ON agent_legacy_auth_retirements
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute("DROP FUNCTION minerops_project_agent_legacy_auth_retirement()")
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DROP TRIGGER trg_minerops_site_agents_legacy_auth_retirement
|
||||||
|
ON minerops_site_agents
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute("DROP FUNCTION minerops_sync_agent_legacy_auth_retirement()")
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DROP TRIGGER trg_agent_legacy_auth_retirements_no_truncate
|
||||||
|
ON agent_legacy_auth_retirements
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DROP TRIGGER trg_agent_legacy_auth_retirements_immutable
|
||||||
|
ON agent_legacy_auth_retirements
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"DROP FUNCTION minerops_prevent_legacy_auth_retirement_mutation()"
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_minerops_site_agents_credential_retires_legacy_auth",
|
||||||
|
"minerops_site_agents",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_column("minerops_site_agents", "legacy_auth_disabled_at")
|
||||||
|
op.drop_table("agent_legacy_auth_retirements")
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""bind one-time credential transitions to existing Agent identities
|
||||||
|
|
||||||
|
Revision ID: 0020_existing_agent_transition
|
||||||
|
Revises: 0019_agent_legacy_auth_retire
|
||||||
|
Create Date: 2026-09-18
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0020_existing_agent_transition"
|
||||||
|
down_revision: Union[str, None] = "0019_agent_legacy_auth_retire"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# NULL continues to mean a normal new-Agent enrollment. A non-NULL value
|
||||||
|
# is server-derived from the current Agent row and binds the one-time token
|
||||||
|
# to that exact durable identity without accepting browser routing input.
|
||||||
|
op.add_column(
|
||||||
|
"agent_enrollments",
|
||||||
|
sa.Column("target_agent_id", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_agent_enrollments_target_agent",
|
||||||
|
"agent_enrollments",
|
||||||
|
"minerops_site_agents",
|
||||||
|
["target_agent_id"],
|
||||||
|
["agent_id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_agent_enrollments_target_agent",
|
||||||
|
"agent_enrollments",
|
||||||
|
"target_agent_id IS NULL OR btrim(target_agent_id) <> ''",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_agent_enrollments_target_result",
|
||||||
|
"agent_enrollments",
|
||||||
|
"""
|
||||||
|
target_agent_id IS NULL
|
||||||
|
OR enrolled_agent_id IS NULL
|
||||||
|
OR enrolled_agent_id = target_agent_id
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_agent_enrollments_open_target_agent",
|
||||||
|
"agent_enrollments",
|
||||||
|
["target_agent_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text(
|
||||||
|
"target_agent_id IS NOT NULL "
|
||||||
|
"AND used_at IS NULL AND revoked_at IS NULL"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
transition_count = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT count(*)
|
||||||
|
FROM agent_enrollments
|
||||||
|
WHERE target_agent_id IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
if transition_count:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot downgrade 0020: existing-Agent credential transition "
|
||||||
|
"history exists. Restore the pre-upgrade database backup rather "
|
||||||
|
"than discarding the identity binding."
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_index(
|
||||||
|
"uq_agent_enrollments_open_target_agent",
|
||||||
|
table_name="agent_enrollments",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_agent_enrollments_target_result",
|
||||||
|
"agent_enrollments",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_agent_enrollments_target_agent",
|
||||||
|
"agent_enrollments",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_agent_enrollments_target_agent",
|
||||||
|
"agent_enrollments",
|
||||||
|
type_="foreignkey",
|
||||||
|
)
|
||||||
|
op.drop_column("agent_enrollments", "target_agent_id")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Per-Agent authentication support."""
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Domain errors for Agent authentication."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAuthenticationError(Exception):
|
||||||
|
"""Base Agent authentication error."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAuthenticationUnavailable(AgentAuthenticationError):
|
||||||
|
"""Agent authentication cannot currently be performed."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAuthenticationFailed(AgentAuthenticationError):
|
||||||
|
"""The supplied Agent secret is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentIdentityUnauthorized(AgentAuthenticationError):
|
||||||
|
"""The supplied Agent identity is not authorized."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCredentialRevoked(AgentAuthenticationError):
|
||||||
|
"""The Agent's permanent credential has been revoked."""
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""PostgreSQL persistence for Agent authentication."""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||||
|
|
||||||
|
|
||||||
|
def default_connection_factory() -> psycopg.Connection:
|
||||||
|
return psycopg.connect(
|
||||||
|
host=settings.postgres_host,
|
||||||
|
port=settings.postgres_port,
|
||||||
|
dbname=settings.postgres_db,
|
||||||
|
user=settings.postgres_user,
|
||||||
|
password=settings.postgres_password,
|
||||||
|
connect_timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAuthRepository:
|
||||||
|
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||||
|
self._connection_factory = connection_factory or default_connection_factory
|
||||||
|
|
||||||
|
def get_credential(self, agent_id: str) -> dict | None:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT identity.agent_id,
|
||||||
|
agent.agent_secret_hash,
|
||||||
|
agent.credential_created_at,
|
||||||
|
agent.credential_revoked_at,
|
||||||
|
retirement.retired_at AS legacy_auth_disabled_at
|
||||||
|
FROM (VALUES (%s::text)) AS identity(agent_id)
|
||||||
|
LEFT JOIN minerops_site_agents AS agent
|
||||||
|
ON agent.agent_id = identity.agent_id
|
||||||
|
LEFT JOIN agent_legacy_auth_retirements AS retirement
|
||||||
|
ON retirement.agent_id = identity.agent_id
|
||||||
|
WHERE agent.agent_id IS NOT NULL
|
||||||
|
OR retirement.agent_id IS NOT NULL
|
||||||
|
""",
|
||||||
|
(agent_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Per-Agent authentication with bounded legacy compatibility."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
from app.agent_auth.exceptions import (
|
||||||
|
AgentAuthenticationFailed,
|
||||||
|
AgentAuthenticationUnavailable,
|
||||||
|
AgentCredentialRevoked,
|
||||||
|
AgentIdentityUnauthorized,
|
||||||
|
)
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def hash_agent_secret(secret: str) -> str:
|
||||||
|
"""Hash a high-entropy machine-generated Agent secret for persistence."""
|
||||||
|
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAuthService:
|
||||||
|
def __init__(self, repository):
|
||||||
|
self.repository = repository
|
||||||
|
|
||||||
|
def authenticate(self, agent_id: str | None, provided_secret: str | None) -> str:
|
||||||
|
identity = (agent_id or "").strip()
|
||||||
|
secret = (provided_secret or "").strip()
|
||||||
|
|
||||||
|
if not secret:
|
||||||
|
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||||
|
|
||||||
|
# The production Alpha Agent predates the identity header. Missing-ID
|
||||||
|
# compatibility is therefore bound to exactly the configured legacy
|
||||||
|
# identity; no request value is allowed to select another Agent.
|
||||||
|
legacy_id = settings.minerops_agent_id.strip()
|
||||||
|
legacy_secret = settings.minerops_agent_shared_secret.strip()
|
||||||
|
missing_identity = not identity
|
||||||
|
if missing_identity:
|
||||||
|
if not legacy_id:
|
||||||
|
raise AgentAuthenticationUnavailable(
|
||||||
|
"Legacy Agent authentication is not configured"
|
||||||
|
)
|
||||||
|
identity = legacy_id
|
||||||
|
|
||||||
|
credential = self.repository.get_credential(identity)
|
||||||
|
|
||||||
|
# Revocation is authoritative regardless of which credential path
|
||||||
|
# would otherwise apply. Never allow malformed or historical
|
||||||
|
# credential state to fall through to the legacy shared secret.
|
||||||
|
if credential and credential.get("credential_revoked_at") is not None:
|
||||||
|
raise AgentCredentialRevoked("Agent credential has been revoked")
|
||||||
|
|
||||||
|
# This value comes from the identity-level retirement ledger, not the
|
||||||
|
# deletable current Agent row. It is therefore authoritative even when
|
||||||
|
# the Agent was deleted and has not yet been recreated.
|
||||||
|
legacy_auth_retired = bool(
|
||||||
|
credential
|
||||||
|
and credential.get("legacy_auth_disabled_at") is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Any Agent with a per-Agent credential is permanently on the new
|
||||||
|
# authentication path. The legacy shared secret must never authenticate
|
||||||
|
# that identity, even if the Agent later becomes revoked.
|
||||||
|
if credential and credential.get("agent_secret_hash"):
|
||||||
|
# Once the configured legacy identity has ever received a
|
||||||
|
# per-Agent credential, secret-only fallback is permanently
|
||||||
|
# disabled. The legacy global secret cannot authenticate it and a
|
||||||
|
# per-Agent secret cannot be used without an explicit identity.
|
||||||
|
if missing_identity:
|
||||||
|
raise AgentAuthenticationFailed(
|
||||||
|
"Explicit Agent identity is required for enrolled Agents"
|
||||||
|
)
|
||||||
|
|
||||||
|
supplied_hash = hash_agent_secret(secret)
|
||||||
|
if not hmac.compare_digest(
|
||||||
|
supplied_hash,
|
||||||
|
str(credential["agent_secret_hash"]),
|
||||||
|
):
|
||||||
|
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||||
|
|
||||||
|
return identity
|
||||||
|
|
||||||
|
# The durable identity retirement survives Agent-row deletion,
|
||||||
|
# credential-field corruption, administrative mistakes, and future
|
||||||
|
# credential rotation workflows.
|
||||||
|
if legacy_auth_retired:
|
||||||
|
raise AgentAuthenticationFailed(
|
||||||
|
"Legacy Agent authentication has been permanently disabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Temporary compatibility for the single pre-enrollment Agent.
|
||||||
|
if not legacy_id or not legacy_secret:
|
||||||
|
raise AgentAuthenticationUnavailable(
|
||||||
|
"Legacy Agent authentication is not configured"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hmac.compare_digest(identity, legacy_id):
|
||||||
|
raise AgentIdentityUnauthorized("Agent identity is not authorized")
|
||||||
|
|
||||||
|
if not hmac.compare_digest(secret, legacy_secret):
|
||||||
|
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||||
|
|
||||||
|
return identity
|
||||||
@@ -1,37 +1,46 @@
|
|||||||
"""Dependencies for the authenticated Agent queue protocol."""
|
"""Dependencies for the authenticated Agent queue protocol."""
|
||||||
|
|
||||||
import hmac
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Header, HTTPException
|
from fastapi import Depends, Header, HTTPException
|
||||||
|
|
||||||
|
from app.agent_auth.exceptions import (
|
||||||
|
AgentAuthenticationFailed,
|
||||||
|
AgentAuthenticationUnavailable,
|
||||||
|
AgentCredentialRevoked,
|
||||||
|
AgentIdentityUnauthorized,
|
||||||
|
)
|
||||||
|
from app.agent_auth.repository import AgentAuthRepository
|
||||||
|
from app.agent_auth.service import AgentAuthService
|
||||||
from app.agent_commands.repository import AgentCommandRepository
|
from app.agent_commands.repository import AgentCommandRepository
|
||||||
from app.agent_commands.service import AgentCommandService
|
from app.agent_commands.service import AgentCommandService
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
|
|
||||||
def get_agent_command_service() -> AgentCommandService:
|
def get_agent_command_service() -> AgentCommandService:
|
||||||
return AgentCommandService(AgentCommandRepository())
|
return AgentCommandService(AgentCommandRepository())
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_auth_service() -> AgentAuthService:
|
||||||
|
return AgentAuthService(AgentAuthRepository())
|
||||||
|
|
||||||
|
|
||||||
def require_agent_identity(
|
def require_agent_identity(
|
||||||
agent_id: Annotated[str | None, Header(alias="X-MinerOps-Agent-ID")] = None,
|
agent_id: Annotated[str | None, Header(alias="X-MinerOps-Agent-ID")] = None,
|
||||||
provided_secret: Annotated[
|
provided_secret: Annotated[
|
||||||
str | None, Header(alias="X-MinerOps-Agent-Secret")
|
str | None, Header(alias="X-MinerOps-Agent-Secret")
|
||||||
] = None,
|
] = None,
|
||||||
|
service: AgentAuthService = Depends(get_agent_auth_service),
|
||||||
) -> str:
|
) -> str:
|
||||||
expected_id = settings.minerops_agent_id.strip()
|
try:
|
||||||
expected_secret = settings.minerops_agent_shared_secret.strip()
|
return service.authenticate(agent_id, provided_secret)
|
||||||
if not expected_id or not expected_secret:
|
except AgentAuthenticationUnavailable as exc:
|
||||||
raise HTTPException(status_code=503, detail="Agent authentication is not configured")
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
secret = (provided_secret or "").strip()
|
detail="Agent authentication is not configured",
|
||||||
if not secret or not hmac.compare_digest(secret, expected_secret):
|
) from exc
|
||||||
raise HTTPException(status_code=401, detail="Invalid or missing Agent secret")
|
except AgentAuthenticationFailed as exc:
|
||||||
|
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
||||||
identity = (agent_id or "").strip()
|
except AgentCredentialRevoked as exc:
|
||||||
if not identity:
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||||
raise HTTPException(status_code=401, detail="Missing Agent identity")
|
except AgentIdentityUnauthorized as exc:
|
||||||
if not hmac.compare_digest(identity, expected_id):
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||||
raise HTTPException(status_code=403, detail="Agent identity is not authorized")
|
|
||||||
return identity
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Secure one-time Agent enrollment."""
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Dependency wiring for secure Agent enrollment."""
|
||||||
|
|
||||||
|
from app.agent_enrollment.repository import AgentEnrollmentRepository
|
||||||
|
from app.agent_enrollment.service import AgentEnrollmentService
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_enrollment_service() -> AgentEnrollmentService:
|
||||||
|
return AgentEnrollmentService(AgentEnrollmentRepository())
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Domain exceptions for secure Agent enrollment."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentError(Exception):
|
||||||
|
"""Base Agent enrollment error."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentNotFound(AgentEnrollmentError):
|
||||||
|
"""The requested Site or Agent Zone does not exist."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentConflict(AgentEnrollmentError):
|
||||||
|
"""The requested enrollment conflicts with current Agent routing state."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentTokenInvalid(AgentEnrollmentError):
|
||||||
|
"""The supplied bootstrap token cannot be redeemed."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentCredentialCollision(AgentEnrollmentError):
|
||||||
|
"""A generated machine credential collided with an existing credential."""
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Typed contracts for secure Agent enrollment."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
site_id: UUID
|
||||||
|
agent_zone_id: UUID
|
||||||
|
display_name: str = Field(min_length=1, max_length=120)
|
||||||
|
|
||||||
|
@field_validator("display_name")
|
||||||
|
@classmethod
|
||||||
|
def normalize_display_name(cls, value: str) -> str:
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("display_name cannot be blank")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentCreated(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
site_id: UUID
|
||||||
|
site_name: str
|
||||||
|
agent_zone_id: UUID
|
||||||
|
agent_zone_name: str
|
||||||
|
display_name: str
|
||||||
|
created_at: datetime
|
||||||
|
expires_at: datetime
|
||||||
|
enrollment_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentCreateResponse(BaseModel):
|
||||||
|
data: AgentEnrollmentCreated
|
||||||
|
meta: dict[str, object] = Field(default_factory=dict)
|
||||||
|
error: None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentSummary(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
site_id: UUID
|
||||||
|
site_name: str
|
||||||
|
agent_zone_id: UUID
|
||||||
|
agent_zone_name: str
|
||||||
|
display_name: str
|
||||||
|
created_by_user_id: UUID | None = None
|
||||||
|
created_at: datetime
|
||||||
|
expires_at: datetime
|
||||||
|
used_at: datetime | None = None
|
||||||
|
revoked_at: datetime | None = None
|
||||||
|
target_agent_id: str | None = None
|
||||||
|
enrolled_agent_id: str | None = None
|
||||||
|
purpose: Literal["new_agent", "existing_agent_credential_transition"]
|
||||||
|
status: Literal["waiting", "used", "revoked", "expired"]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentListResponse(BaseModel):
|
||||||
|
data: list[AgentEnrollmentSummary]
|
||||||
|
meta: dict[str, object] = Field(default_factory=dict)
|
||||||
|
error: None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
token: str = Field(min_length=32, max_length=256)
|
||||||
|
|
||||||
|
@field_validator("token")
|
||||||
|
@classmethod
|
||||||
|
def normalize_token(cls, value: str) -> str:
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("token cannot be blank")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollCredentials(BaseModel):
|
||||||
|
agent_id: str
|
||||||
|
agent_secret: str
|
||||||
|
site_id: UUID
|
||||||
|
site_name: str
|
||||||
|
agent_zone_id: UUID
|
||||||
|
agent_zone_name: str
|
||||||
|
display_name: str
|
||||||
|
ip_ranges: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollResponse(BaseModel):
|
||||||
|
data: AgentEnrollCredentials
|
||||||
|
meta: dict[str, object] = Field(default_factory=dict)
|
||||||
|
error: None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentWindowsPackageRequest(AgentEnrollRequest):
|
||||||
|
"""Short-lived token used to construct a Windows bootstrap package."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCredentialTransitionCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
agent_id: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("agent_id")
|
||||||
|
@classmethod
|
||||||
|
def normalize_agent_id(cls, value: str) -> str:
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("agent_id cannot be blank")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCredentialTransitionCreated(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
target_agent_id: str
|
||||||
|
site_id: UUID
|
||||||
|
site_name: str
|
||||||
|
agent_zone_id: UUID
|
||||||
|
agent_zone_name: str
|
||||||
|
display_name: str
|
||||||
|
created_at: datetime
|
||||||
|
expires_at: datetime
|
||||||
|
transition_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCredentialTransitionCreateResponse(BaseModel):
|
||||||
|
data: AgentCredentialTransitionCreated
|
||||||
|
meta: dict[str, object] = Field(default_factory=dict)
|
||||||
|
error: None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCredentialTransitionRedeemRequest(AgentEnrollRequest):
|
||||||
|
agent_secret_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCredentialTransitionCredentials(BaseModel):
|
||||||
|
agent_id: str
|
||||||
|
site_id: UUID
|
||||||
|
site_name: str
|
||||||
|
agent_zone_id: UUID
|
||||||
|
agent_zone_name: str
|
||||||
|
display_name: str
|
||||||
|
ip_ranges: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCredentialTransitionResponse(BaseModel):
|
||||||
|
data: AgentCredentialTransitionCredentials
|
||||||
|
meta: dict[str, object] = Field(default_factory=dict)
|
||||||
|
error: None = None
|
||||||
@@ -0,0 +1,968 @@
|
|||||||
|
"""PostgreSQL persistence for secure Agent enrollment."""
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
from collections.abc import Callable
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.errors import UniqueViolation
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from app.agent_enrollment.exceptions import (
|
||||||
|
AgentEnrollmentConflict,
|
||||||
|
AgentEnrollmentCredentialCollision,
|
||||||
|
AgentEnrollmentNotFound,
|
||||||
|
AgentEnrollmentTokenInvalid,
|
||||||
|
)
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||||
|
|
||||||
|
TOKEN_HASH_CONSTRAINT = "uq_agent_enrollments_token_hash"
|
||||||
|
OPEN_ZONE_CONSTRAINT = "uq_agent_enrollments_open_zone"
|
||||||
|
OPEN_TARGET_CONSTRAINT = "uq_agent_enrollments_open_target_agent"
|
||||||
|
AGENT_SECRET_HASH_CONSTRAINT = "uq_minerops_site_agents_agent_secret_hash"
|
||||||
|
AGENT_ZONE_CONSTRAINT = "uq_minerops_site_agents_agent_zone_id"
|
||||||
|
AGENT_ID_CONSTRAINT = "minerops_site_agents_pkey"
|
||||||
|
|
||||||
|
|
||||||
|
def default_connection_factory() -> psycopg.Connection:
|
||||||
|
return psycopg.connect(
|
||||||
|
host=settings.postgres_host,
|
||||||
|
port=settings.postgres_port,
|
||||||
|
dbname=settings.postgres_db,
|
||||||
|
user=settings.postgres_user,
|
||||||
|
password=settings.postgres_password,
|
||||||
|
connect_timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentRepository:
|
||||||
|
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||||
|
self._connection_factory = connection_factory or default_connection_factory
|
||||||
|
|
||||||
|
def create_enrollment(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
token_hash: str,
|
||||||
|
site_id: UUID,
|
||||||
|
agent_zone_id: UUID,
|
||||||
|
display_name: str,
|
||||||
|
actor_user_id: UUID,
|
||||||
|
) -> dict:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT zone.id,
|
||||||
|
zone.site_id,
|
||||||
|
zone.name AS agent_zone_name,
|
||||||
|
zone.is_enabled,
|
||||||
|
site.name AS site_name,
|
||||||
|
agent.agent_id AS assigned_agent_id
|
||||||
|
FROM agent_zones AS zone
|
||||||
|
JOIN sites AS site ON site.id = zone.site_id
|
||||||
|
LEFT JOIN minerops_site_agents AS agent
|
||||||
|
ON agent.agent_zone_id = zone.id
|
||||||
|
WHERE zone.id = %s
|
||||||
|
AND zone.site_id = %s
|
||||||
|
FOR UPDATE OF zone
|
||||||
|
""",
|
||||||
|
(agent_zone_id, site_id),
|
||||||
|
)
|
||||||
|
zone = cur.fetchone()
|
||||||
|
|
||||||
|
if not zone:
|
||||||
|
raise AgentEnrollmentNotFound(
|
||||||
|
"Agent Zone was not found for the selected Site"
|
||||||
|
)
|
||||||
|
|
||||||
|
if zone["is_enabled"] is not True:
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Agent Zone is disabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
if zone["assigned_agent_id"]:
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Agent Zone already has an assigned Agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT clock_timestamp() AS operation_now"
|
||||||
|
)
|
||||||
|
operation_now = cur.fetchone()["operation_now"]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE agent_enrollments
|
||||||
|
SET revoked_at = %s
|
||||||
|
WHERE agent_zone_id = %s
|
||||||
|
AND used_at IS NULL
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(operation_now, agent_zone_id),
|
||||||
|
)
|
||||||
|
replaced_ids = [
|
||||||
|
str(row["id"]) for row in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_enrollments (
|
||||||
|
token_hash,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
display_name,
|
||||||
|
created_by_user_id,
|
||||||
|
created_at,
|
||||||
|
expires_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s + interval '30 minutes'
|
||||||
|
)
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
display_name,
|
||||||
|
created_at,
|
||||||
|
expires_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
token_hash,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
display_name,
|
||||||
|
actor_user_id,
|
||||||
|
operation_now,
|
||||||
|
operation_now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
enrollment = dict(cur.fetchone())
|
||||||
|
|
||||||
|
enrollment["site_name"] = zone["site_name"]
|
||||||
|
enrollment["agent_zone_name"] = zone["agent_zone_name"]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
actor_user_id,
|
||||||
|
action,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
new_value_json,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s,
|
||||||
|
'agent.enrollment.created',
|
||||||
|
'agent_enrollment',
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
actor_user_id,
|
||||||
|
enrollment["id"],
|
||||||
|
Jsonb(
|
||||||
|
{
|
||||||
|
"site_id": str(site_id),
|
||||||
|
"agent_zone_id": str(agent_zone_id),
|
||||||
|
"display_name": display_name,
|
||||||
|
"expires_at": enrollment[
|
||||||
|
"expires_at"
|
||||||
|
].isoformat(),
|
||||||
|
"replaced_enrollment_ids": replaced_ids,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
operation_now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
return enrollment
|
||||||
|
|
||||||
|
except UniqueViolation as exc:
|
||||||
|
connection.rollback()
|
||||||
|
constraint = exc.diag.constraint_name
|
||||||
|
|
||||||
|
if constraint == TOKEN_HASH_CONSTRAINT:
|
||||||
|
raise AgentEnrollmentCredentialCollision(
|
||||||
|
"Generated enrollment token collided"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if constraint == OPEN_ZONE_CONSTRAINT:
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"An active enrollment already exists for this Agent Zone"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def create_credential_transition(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
token_hash: str,
|
||||||
|
target_agent_id: str,
|
||||||
|
actor_user_id: UUID,
|
||||||
|
) -> dict:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
agent.agent_id,
|
||||||
|
agent.site_id,
|
||||||
|
agent.agent_zone_id,
|
||||||
|
agent.display_name,
|
||||||
|
agent.agent_secret_hash,
|
||||||
|
agent.credential_created_at,
|
||||||
|
agent.credential_revoked_at,
|
||||||
|
agent.legacy_auth_disabled_at,
|
||||||
|
site.name AS site_name,
|
||||||
|
zone.name AS agent_zone_name,
|
||||||
|
zone.is_enabled AS agent_zone_enabled,
|
||||||
|
retirement.retired_at
|
||||||
|
FROM minerops_site_agents AS agent
|
||||||
|
JOIN sites AS site ON site.id = agent.site_id
|
||||||
|
JOIN agent_zones AS zone
|
||||||
|
ON zone.id = agent.agent_zone_id
|
||||||
|
AND zone.site_id = agent.site_id
|
||||||
|
LEFT JOIN agent_legacy_auth_retirements AS retirement
|
||||||
|
ON retirement.agent_id = agent.agent_id
|
||||||
|
WHERE agent.agent_id = %s
|
||||||
|
FOR UPDATE OF agent, zone
|
||||||
|
""",
|
||||||
|
(target_agent_id,),
|
||||||
|
)
|
||||||
|
agent = cur.fetchone()
|
||||||
|
|
||||||
|
if not agent:
|
||||||
|
raise AgentEnrollmentNotFound(
|
||||||
|
"Existing Agent with durable Site and Zone routing was not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if agent["agent_zone_enabled"] is not True:
|
||||||
|
raise AgentEnrollmentConflict("Agent Zone is disabled")
|
||||||
|
|
||||||
|
if any(
|
||||||
|
agent[field] is not None
|
||||||
|
for field in (
|
||||||
|
"agent_secret_hash",
|
||||||
|
"credential_created_at",
|
||||||
|
"credential_revoked_at",
|
||||||
|
"legacy_auth_disabled_at",
|
||||||
|
"retired_at",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Existing Agent is not eligible for legacy credential transition"
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute("SELECT clock_timestamp() AS operation_now")
|
||||||
|
operation_now = cur.fetchone()["operation_now"]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE agent_enrollments
|
||||||
|
SET revoked_at = %s
|
||||||
|
WHERE agent_zone_id = %s
|
||||||
|
AND used_at IS NULL
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(operation_now, agent["agent_zone_id"]),
|
||||||
|
)
|
||||||
|
replaced_ids = [str(row["id"]) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
display_name = (
|
||||||
|
str(agent["display_name"] or agent["agent_id"]).strip()[:120]
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_enrollments (
|
||||||
|
token_hash,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
display_name,
|
||||||
|
created_by_user_id,
|
||||||
|
created_at,
|
||||||
|
expires_at,
|
||||||
|
target_agent_id
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s + interval '30 minutes', %s)
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
display_name,
|
||||||
|
created_at,
|
||||||
|
expires_at,
|
||||||
|
target_agent_id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
token_hash,
|
||||||
|
agent["site_id"],
|
||||||
|
agent["agent_zone_id"],
|
||||||
|
display_name,
|
||||||
|
actor_user_id,
|
||||||
|
operation_now,
|
||||||
|
operation_now,
|
||||||
|
agent["agent_id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
transition = dict(cur.fetchone())
|
||||||
|
transition["site_name"] = agent["site_name"]
|
||||||
|
transition["agent_zone_name"] = agent["agent_zone_name"]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
actor_user_id,
|
||||||
|
action,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
new_value_json,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s,
|
||||||
|
'agent.credential_transition.created',
|
||||||
|
'agent_enrollment',
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
actor_user_id,
|
||||||
|
transition["id"],
|
||||||
|
Jsonb(
|
||||||
|
{
|
||||||
|
"target_agent_id": agent["agent_id"],
|
||||||
|
"site_id": str(agent["site_id"]),
|
||||||
|
"agent_zone_id": str(agent["agent_zone_id"]),
|
||||||
|
"expires_at": transition["expires_at"].isoformat(),
|
||||||
|
"replaced_enrollment_ids": replaced_ids,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
operation_now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
return transition
|
||||||
|
|
||||||
|
except UniqueViolation as exc:
|
||||||
|
connection.rollback()
|
||||||
|
constraint = exc.diag.constraint_name
|
||||||
|
|
||||||
|
if constraint == TOKEN_HASH_CONSTRAINT:
|
||||||
|
raise AgentEnrollmentCredentialCollision(
|
||||||
|
"Generated transition token collided"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if constraint in {OPEN_ZONE_CONSTRAINT, OPEN_TARGET_CONSTRAINT}:
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"An active credential transition already exists for this Agent"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def list_enrollments(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
site_id: UUID | None = None,
|
||||||
|
agent_zone_id: UUID | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[dict]:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
enrollment.id,
|
||||||
|
enrollment.site_id,
|
||||||
|
site.name AS site_name,
|
||||||
|
enrollment.agent_zone_id,
|
||||||
|
zone.name AS agent_zone_name,
|
||||||
|
enrollment.display_name,
|
||||||
|
enrollment.created_by_user_id,
|
||||||
|
enrollment.created_at,
|
||||||
|
enrollment.expires_at,
|
||||||
|
enrollment.used_at,
|
||||||
|
enrollment.revoked_at,
|
||||||
|
enrollment.target_agent_id,
|
||||||
|
enrollment.enrolled_agent_id,
|
||||||
|
CASE
|
||||||
|
WHEN enrollment.target_agent_id IS NULL
|
||||||
|
THEN 'new_agent'
|
||||||
|
ELSE 'existing_agent_credential_transition'
|
||||||
|
END AS purpose,
|
||||||
|
CASE
|
||||||
|
WHEN enrollment.used_at IS NOT NULL
|
||||||
|
THEN 'used'
|
||||||
|
WHEN enrollment.revoked_at IS NOT NULL
|
||||||
|
THEN 'revoked'
|
||||||
|
WHEN enrollment.expires_at <= now()
|
||||||
|
THEN 'expired'
|
||||||
|
ELSE 'waiting'
|
||||||
|
END AS status
|
||||||
|
FROM agent_enrollments AS enrollment
|
||||||
|
JOIN sites AS site
|
||||||
|
ON site.id = enrollment.site_id
|
||||||
|
JOIN agent_zones AS zone
|
||||||
|
ON zone.id = enrollment.agent_zone_id
|
||||||
|
WHERE (%s::uuid IS NULL OR enrollment.site_id = %s::uuid)
|
||||||
|
AND (
|
||||||
|
%s::uuid IS NULL
|
||||||
|
OR enrollment.agent_zone_id = %s::uuid
|
||||||
|
)
|
||||||
|
ORDER BY enrollment.created_at DESC, enrollment.id DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
site_id,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
agent_zone_id,
|
||||||
|
limit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def redeem_enrollment(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
token_hash: str,
|
||||||
|
agent_id: str,
|
||||||
|
agent_secret_hash: str,
|
||||||
|
) -> dict:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
display_name,
|
||||||
|
expires_at,
|
||||||
|
used_at,
|
||||||
|
revoked_at,
|
||||||
|
target_agent_id
|
||||||
|
FROM agent_enrollments
|
||||||
|
WHERE token_hash = %s
|
||||||
|
FOR UPDATE
|
||||||
|
""",
|
||||||
|
(token_hash,),
|
||||||
|
)
|
||||||
|
enrollment = cur.fetchone()
|
||||||
|
|
||||||
|
if not enrollment:
|
||||||
|
raise AgentEnrollmentTokenInvalid(
|
||||||
|
"Invalid or expired enrollment token"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
enrollment["used_at"] is not None
|
||||||
|
or enrollment["revoked_at"] is not None
|
||||||
|
or enrollment["target_agent_id"] is not None
|
||||||
|
):
|
||||||
|
raise AgentEnrollmentTokenInvalid(
|
||||||
|
"Invalid or expired enrollment token"
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
zone.id,
|
||||||
|
zone.site_id,
|
||||||
|
zone.name AS agent_zone_name,
|
||||||
|
zone.is_enabled,
|
||||||
|
site.name AS site_name
|
||||||
|
FROM agent_zones AS zone
|
||||||
|
JOIN sites AS site ON site.id = zone.site_id
|
||||||
|
WHERE zone.id = %s
|
||||||
|
AND zone.site_id = %s
|
||||||
|
FOR UPDATE OF zone
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
enrollment["agent_zone_id"],
|
||||||
|
enrollment["site_id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
zone = cur.fetchone()
|
||||||
|
|
||||||
|
if not zone:
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Enrollment Agent Zone no longer exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
if zone["is_enabled"] is not True:
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Agent Zone is disabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT agent_id
|
||||||
|
FROM minerops_site_agents
|
||||||
|
WHERE agent_zone_id = %s
|
||||||
|
FOR UPDATE
|
||||||
|
""",
|
||||||
|
(enrollment["agent_zone_id"],),
|
||||||
|
)
|
||||||
|
existing_agent = cur.fetchone()
|
||||||
|
|
||||||
|
if existing_agent:
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Agent Zone already has an assigned Agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT clock_timestamp() AS operation_now"
|
||||||
|
)
|
||||||
|
operation_now = cur.fetchone()["operation_now"]
|
||||||
|
|
||||||
|
if enrollment["expires_at"] <= operation_now:
|
||||||
|
raise AgentEnrollmentTokenInvalid(
|
||||||
|
"Invalid or expired enrollment token"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retire the identity before storing its credential. Both
|
||||||
|
# writes use this transaction, so a failed issuance cannot
|
||||||
|
# leave a new misleading tombstone. ON CONFLICT preserves an
|
||||||
|
# earlier retirement if this identity has credential history.
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_legacy_auth_retirements (
|
||||||
|
agent_id,
|
||||||
|
retired_at
|
||||||
|
)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
ON CONFLICT (agent_id) DO NOTHING
|
||||||
|
""",
|
||||||
|
(agent_id, operation_now),
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
site_name,
|
||||||
|
display_name,
|
||||||
|
agent_secret_hash,
|
||||||
|
credential_created_at,
|
||||||
|
legacy_auth_disabled_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
agent_id,
|
||||||
|
enrollment["site_id"],
|
||||||
|
enrollment["agent_zone_id"],
|
||||||
|
zone["site_name"],
|
||||||
|
enrollment["display_name"],
|
||||||
|
agent_secret_hash,
|
||||||
|
operation_now,
|
||||||
|
operation_now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE agent_enrollments
|
||||||
|
SET used_at = %s,
|
||||||
|
enrolled_agent_id = %s
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING used_at
|
||||||
|
""",
|
||||||
|
(operation_now, agent_id, enrollment["id"]),
|
||||||
|
)
|
||||||
|
used_at = cur.fetchone()["used_at"]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT network::text AS network
|
||||||
|
FROM agent_zone_network_ranges
|
||||||
|
WHERE agent_zone_id = %s
|
||||||
|
ORDER BY network, id
|
||||||
|
""",
|
||||||
|
(enrollment["agent_zone_id"],),
|
||||||
|
)
|
||||||
|
networks = [
|
||||||
|
row["network"] for row in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
actor_user_id,
|
||||||
|
action,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
new_value_json,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
NULL,
|
||||||
|
'agent.enrollment.redeemed',
|
||||||
|
'agent_enrollment',
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
enrollment["id"],
|
||||||
|
Jsonb(
|
||||||
|
{
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"site_id": str(enrollment["site_id"]),
|
||||||
|
"agent_zone_id": str(
|
||||||
|
enrollment["agent_zone_id"]
|
||||||
|
),
|
||||||
|
"display_name": enrollment[
|
||||||
|
"display_name"
|
||||||
|
],
|
||||||
|
"used_at": used_at.isoformat(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
operation_now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"site_id": enrollment["site_id"],
|
||||||
|
"site_name": zone["site_name"],
|
||||||
|
"agent_zone_id": enrollment["agent_zone_id"],
|
||||||
|
"agent_zone_name": zone["agent_zone_name"],
|
||||||
|
"display_name": enrollment["display_name"],
|
||||||
|
"ip_ranges": networks,
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except UniqueViolation as exc:
|
||||||
|
connection.rollback()
|
||||||
|
constraint = exc.diag.constraint_name
|
||||||
|
|
||||||
|
if constraint in {
|
||||||
|
AGENT_ID_CONSTRAINT,
|
||||||
|
AGENT_SECRET_HASH_CONSTRAINT,
|
||||||
|
}:
|
||||||
|
raise AgentEnrollmentCredentialCollision(
|
||||||
|
"Generated Agent credential collided"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if constraint == AGENT_ZONE_CONSTRAINT:
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Agent Zone already has an assigned Agent"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def redeem_credential_transition(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
token_hash: str,
|
||||||
|
agent_secret_hash: str,
|
||||||
|
) -> dict:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
display_name,
|
||||||
|
expires_at,
|
||||||
|
used_at,
|
||||||
|
revoked_at,
|
||||||
|
target_agent_id,
|
||||||
|
enrolled_agent_id
|
||||||
|
FROM agent_enrollments
|
||||||
|
WHERE token_hash = %s
|
||||||
|
FOR UPDATE
|
||||||
|
""",
|
||||||
|
(token_hash,),
|
||||||
|
)
|
||||||
|
enrollment = cur.fetchone()
|
||||||
|
|
||||||
|
if (
|
||||||
|
not enrollment
|
||||||
|
or enrollment["target_agent_id"] is None
|
||||||
|
or enrollment["revoked_at"] is not None
|
||||||
|
):
|
||||||
|
raise AgentEnrollmentTokenInvalid(
|
||||||
|
"Invalid or expired credential transition token"
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
agent.agent_id,
|
||||||
|
agent.site_id,
|
||||||
|
agent.agent_zone_id,
|
||||||
|
agent.display_name,
|
||||||
|
agent.agent_secret_hash,
|
||||||
|
agent.credential_created_at,
|
||||||
|
agent.credential_revoked_at,
|
||||||
|
agent.legacy_auth_disabled_at,
|
||||||
|
site.name AS site_name,
|
||||||
|
zone.name AS agent_zone_name,
|
||||||
|
zone.is_enabled AS agent_zone_enabled,
|
||||||
|
retirement.retired_at
|
||||||
|
FROM minerops_site_agents AS agent
|
||||||
|
JOIN sites AS site ON site.id = agent.site_id
|
||||||
|
JOIN agent_zones AS zone
|
||||||
|
ON zone.id = agent.agent_zone_id
|
||||||
|
AND zone.site_id = agent.site_id
|
||||||
|
LEFT JOIN agent_legacy_auth_retirements AS retirement
|
||||||
|
ON retirement.agent_id = agent.agent_id
|
||||||
|
WHERE agent.agent_id = %s
|
||||||
|
FOR UPDATE OF agent, zone
|
||||||
|
""",
|
||||||
|
(enrollment["target_agent_id"],),
|
||||||
|
)
|
||||||
|
agent = cur.fetchone()
|
||||||
|
|
||||||
|
if not agent:
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Credential transition target no longer exists with durable routing"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
agent["site_id"] != enrollment["site_id"]
|
||||||
|
or agent["agent_zone_id"] != enrollment["agent_zone_id"]
|
||||||
|
):
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Existing Agent routing changed after transition authorization"
|
||||||
|
)
|
||||||
|
|
||||||
|
if agent["agent_zone_enabled"] is not True:
|
||||||
|
raise AgentEnrollmentConflict("Agent Zone is disabled")
|
||||||
|
|
||||||
|
def transition_result() -> dict:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT network::text AS network
|
||||||
|
FROM agent_zone_network_ranges
|
||||||
|
WHERE agent_zone_id = %s
|
||||||
|
ORDER BY network, id
|
||||||
|
""",
|
||||||
|
(enrollment["agent_zone_id"],),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"agent_id": agent["agent_id"],
|
||||||
|
"site_id": agent["site_id"],
|
||||||
|
"site_name": agent["site_name"],
|
||||||
|
"agent_zone_id": agent["agent_zone_id"],
|
||||||
|
"agent_zone_name": agent["agent_zone_name"],
|
||||||
|
"display_name": str(
|
||||||
|
agent["display_name"] or enrollment["display_name"]
|
||||||
|
),
|
||||||
|
"ip_ranges": [row["network"] for row in cur.fetchall()],
|
||||||
|
}
|
||||||
|
|
||||||
|
# A retry after an ambiguous network response is successful only
|
||||||
|
# for the exact locally generated credential that won the first
|
||||||
|
# transaction. It never rotates or reveals a credential.
|
||||||
|
if enrollment["used_at"] is not None:
|
||||||
|
stored_hash = str(agent["agent_secret_hash"] or "")
|
||||||
|
if (
|
||||||
|
enrollment["enrolled_agent_id"]
|
||||||
|
== enrollment["target_agent_id"]
|
||||||
|
and stored_hash
|
||||||
|
and hmac.compare_digest(stored_hash, agent_secret_hash)
|
||||||
|
and agent["credential_created_at"] is not None
|
||||||
|
and agent["credential_revoked_at"] is None
|
||||||
|
and agent["legacy_auth_disabled_at"] is not None
|
||||||
|
and agent["retired_at"] is not None
|
||||||
|
):
|
||||||
|
result = transition_result()
|
||||||
|
connection.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
raise AgentEnrollmentTokenInvalid(
|
||||||
|
"Credential transition token has already been consumed"
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute("SELECT clock_timestamp() AS operation_now")
|
||||||
|
operation_now = cur.fetchone()["operation_now"]
|
||||||
|
|
||||||
|
if enrollment["expires_at"] <= operation_now:
|
||||||
|
raise AgentEnrollmentTokenInvalid(
|
||||||
|
"Invalid or expired credential transition token"
|
||||||
|
)
|
||||||
|
|
||||||
|
if any(
|
||||||
|
agent[field] is not None
|
||||||
|
for field in (
|
||||||
|
"agent_secret_hash",
|
||||||
|
"credential_created_at",
|
||||||
|
"credential_revoked_at",
|
||||||
|
"legacy_auth_disabled_at",
|
||||||
|
"retired_at",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Existing Agent is no longer eligible for credential transition"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The durable retirement, Agent credential, enrollment terminal
|
||||||
|
# state, and audit record commit together. Any later failure
|
||||||
|
# rolls all of them back.
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_legacy_auth_retirements (
|
||||||
|
agent_id,
|
||||||
|
retired_at
|
||||||
|
)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
""",
|
||||||
|
(agent["agent_id"], operation_now),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE minerops_site_agents
|
||||||
|
SET agent_secret_hash = %s,
|
||||||
|
credential_created_at = %s,
|
||||||
|
legacy_auth_disabled_at = %s
|
||||||
|
WHERE agent_id = %s
|
||||||
|
RETURNING agent_id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
agent_secret_hash,
|
||||||
|
operation_now,
|
||||||
|
operation_now,
|
||||||
|
agent["agent_id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Credential transition target disappeared"
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE agent_enrollments
|
||||||
|
SET used_at = %s,
|
||||||
|
enrolled_agent_id = target_agent_id
|
||||||
|
WHERE id = %s
|
||||||
|
AND used_at IS NULL
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
RETURNING used_at, enrolled_agent_id
|
||||||
|
""",
|
||||||
|
(operation_now, enrollment["id"]),
|
||||||
|
)
|
||||||
|
completed = cur.fetchone()
|
||||||
|
if not completed:
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Credential transition state changed concurrently"
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
actor_user_id,
|
||||||
|
action,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
new_value_json,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
NULL,
|
||||||
|
'agent.credential_transition.redeemed',
|
||||||
|
'agent_enrollment',
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
enrollment["id"],
|
||||||
|
Jsonb(
|
||||||
|
{
|
||||||
|
"agent_id": agent["agent_id"],
|
||||||
|
"site_id": str(agent["site_id"]),
|
||||||
|
"agent_zone_id": str(agent["agent_zone_id"]),
|
||||||
|
"used_at": completed["used_at"].isoformat(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
operation_now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = transition_result()
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except UniqueViolation as exc:
|
||||||
|
connection.rollback()
|
||||||
|
if exc.diag.constraint_name == AGENT_SECRET_HASH_CONSTRAINT:
|
||||||
|
raise AgentEnrollmentCredentialCollision(
|
||||||
|
"Agent credential is already in use"
|
||||||
|
) from exc
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""HTTP routes for Agent enrollment and bootstrap."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||||
|
|
||||||
|
from app.agent_enrollment.dependencies import get_agent_enrollment_service
|
||||||
|
from app.agent_enrollment.exceptions import (
|
||||||
|
AgentEnrollmentConflict,
|
||||||
|
AgentEnrollmentCredentialCollision,
|
||||||
|
AgentEnrollmentNotFound,
|
||||||
|
AgentEnrollmentTokenInvalid,
|
||||||
|
)
|
||||||
|
from app.agent_enrollment.models import (
|
||||||
|
AgentCredentialTransitionCreateRequest,
|
||||||
|
AgentCredentialTransitionCreateResponse,
|
||||||
|
AgentCredentialTransitionRedeemRequest,
|
||||||
|
AgentCredentialTransitionResponse,
|
||||||
|
AgentEnrollmentCreateRequest,
|
||||||
|
AgentEnrollmentCreateResponse,
|
||||||
|
AgentEnrollmentListResponse,
|
||||||
|
AgentEnrollRequest,
|
||||||
|
AgentEnrollResponse,
|
||||||
|
AgentWindowsPackageRequest,
|
||||||
|
)
|
||||||
|
from app.agent_enrollment.service import AgentEnrollmentService
|
||||||
|
from app.agent_packages.windows import (
|
||||||
|
AgentWindowsPackageError,
|
||||||
|
AgentWindowsPackageSourceError,
|
||||||
|
build_windows_agent_package,
|
||||||
|
normalize_hub_url,
|
||||||
|
)
|
||||||
|
from app.identity import require_permission
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["agent-enrollment"])
|
||||||
|
|
||||||
|
EnrollmentService = Annotated[
|
||||||
|
AgentEnrollmentService,
|
||||||
|
Depends(get_agent_enrollment_service),
|
||||||
|
]
|
||||||
|
EnrollmentViewer = Annotated[
|
||||||
|
dict,
|
||||||
|
Depends(require_permission("agents.view")),
|
||||||
|
]
|
||||||
|
EnrollmentManager = Annotated[
|
||||||
|
dict,
|
||||||
|
Depends(require_permission("agents.manage")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/agent-enrollments",
|
||||||
|
response_model=AgentEnrollmentCreateResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_agent_enrollment(
|
||||||
|
request: AgentEnrollmentCreateRequest,
|
||||||
|
response: Response,
|
||||||
|
current_user: EnrollmentManager,
|
||||||
|
service: EnrollmentService,
|
||||||
|
) -> AgentEnrollmentCreateResponse:
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
|
||||||
|
try:
|
||||||
|
enrollment = service.create_enrollment(
|
||||||
|
request,
|
||||||
|
actor_user_id=UUID(str(current_user["id"])),
|
||||||
|
)
|
||||||
|
return AgentEnrollmentCreateResponse(data=enrollment)
|
||||||
|
except AgentEnrollmentNotFound as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except AgentEnrollmentConflict as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/agent-credential-transitions",
|
||||||
|
response_model=AgentCredentialTransitionCreateResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_agent_credential_transition(
|
||||||
|
request: AgentCredentialTransitionCreateRequest,
|
||||||
|
response: Response,
|
||||||
|
current_user: EnrollmentManager,
|
||||||
|
service: EnrollmentService,
|
||||||
|
) -> AgentCredentialTransitionCreateResponse:
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
|
||||||
|
try:
|
||||||
|
transition = service.create_credential_transition(
|
||||||
|
request,
|
||||||
|
actor_user_id=UUID(str(current_user["id"])),
|
||||||
|
)
|
||||||
|
return AgentCredentialTransitionCreateResponse(data=transition)
|
||||||
|
except AgentEnrollmentNotFound as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except AgentEnrollmentConflict as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/agent-enrollments",
|
||||||
|
response_model=AgentEnrollmentListResponse,
|
||||||
|
)
|
||||||
|
def list_agent_enrollments(
|
||||||
|
current_user: EnrollmentViewer,
|
||||||
|
service: EnrollmentService,
|
||||||
|
site_id: UUID | None = Query(default=None),
|
||||||
|
agent_zone_id: UUID | None = Query(default=None),
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
) -> AgentEnrollmentListResponse:
|
||||||
|
del current_user
|
||||||
|
|
||||||
|
return AgentEnrollmentListResponse(
|
||||||
|
data=service.list_enrollments(
|
||||||
|
site_id=site_id,
|
||||||
|
agent_zone_id=agent_zone_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/agent-enrollments/windows-package")
|
||||||
|
def download_windows_agent_package(
|
||||||
|
package_request: AgentWindowsPackageRequest,
|
||||||
|
http_request: Request,
|
||||||
|
current_user: EnrollmentManager,
|
||||||
|
) -> Response:
|
||||||
|
del current_user
|
||||||
|
|
||||||
|
browser_origin = http_request.headers.get("origin", "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
hub_url = normalize_hub_url(browser_origin)
|
||||||
|
except AgentWindowsPackageError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
"Windows Agent package downloads require "
|
||||||
|
"a valid browser Origin"
|
||||||
|
),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
package = build_windows_agent_package(
|
||||||
|
enrollment_token=package_request.token,
|
||||||
|
hub_url=hub_url,
|
||||||
|
workflow="new_agent",
|
||||||
|
)
|
||||||
|
except AgentWindowsPackageSourceError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
except AgentWindowsPackageError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=package,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Content-Disposition": (
|
||||||
|
'attachment; filename="MinerOpsAgent-Windows.zip"'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/agent-credential-transitions/windows-package")
|
||||||
|
def download_windows_agent_credential_transition_package(
|
||||||
|
package_request: AgentWindowsPackageRequest,
|
||||||
|
http_request: Request,
|
||||||
|
current_user: EnrollmentManager,
|
||||||
|
) -> Response:
|
||||||
|
del current_user
|
||||||
|
|
||||||
|
browser_origin = http_request.headers.get("origin", "").strip()
|
||||||
|
try:
|
||||||
|
hub_url = normalize_hub_url(browser_origin)
|
||||||
|
except AgentWindowsPackageError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
"Windows Agent package downloads require "
|
||||||
|
"a valid browser Origin"
|
||||||
|
),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
package = build_windows_agent_package(
|
||||||
|
enrollment_token=package_request.token,
|
||||||
|
hub_url=hub_url,
|
||||||
|
workflow="existing_agent_credential_transition",
|
||||||
|
)
|
||||||
|
except AgentWindowsPackageSourceError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
except AgentWindowsPackageError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=package,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Content-Disposition": (
|
||||||
|
'attachment; filename="MinerOpsAgent-Credential-Upgrade-Windows.zip"'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/agent/enroll",
|
||||||
|
response_model=AgentEnrollResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def enroll_agent(
|
||||||
|
request: AgentEnrollRequest,
|
||||||
|
response: Response,
|
||||||
|
service: EnrollmentService,
|
||||||
|
) -> AgentEnrollResponse:
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
|
||||||
|
try:
|
||||||
|
return AgentEnrollResponse(data=service.redeem(request))
|
||||||
|
except AgentEnrollmentTokenInvalid as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Invalid or expired enrollment token",
|
||||||
|
) from exc
|
||||||
|
except AgentEnrollmentConflict as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/agent/credential-transition",
|
||||||
|
response_model=AgentCredentialTransitionResponse,
|
||||||
|
)
|
||||||
|
def transition_agent_credential(
|
||||||
|
request: AgentCredentialTransitionRedeemRequest,
|
||||||
|
response: Response,
|
||||||
|
service: EnrollmentService,
|
||||||
|
) -> AgentCredentialTransitionResponse:
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
|
||||||
|
try:
|
||||||
|
return AgentCredentialTransitionResponse(
|
||||||
|
data=service.redeem_credential_transition(request)
|
||||||
|
)
|
||||||
|
except AgentEnrollmentTokenInvalid as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Invalid or expired credential transition token",
|
||||||
|
) from exc
|
||||||
|
except (AgentEnrollmentConflict, AgentEnrollmentCredentialCollision) as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Application service for secure one-time Agent enrollment."""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from app.agent_auth.service import hash_agent_secret
|
||||||
|
from app.agent_enrollment.exceptions import (
|
||||||
|
AgentEnrollmentConflict,
|
||||||
|
AgentEnrollmentCredentialCollision,
|
||||||
|
)
|
||||||
|
from app.agent_enrollment.models import (
|
||||||
|
AgentCredentialTransitionCreateRequest,
|
||||||
|
AgentCredentialTransitionRedeemRequest,
|
||||||
|
AgentEnrollmentCreateRequest,
|
||||||
|
AgentEnrollRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MAX_GENERATION_ATTEMPTS = 3
|
||||||
|
|
||||||
|
|
||||||
|
def generate_enrollment_token() -> str:
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_agent_secret() -> str:
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_agent_id() -> str:
|
||||||
|
return f"agent-{uuid4().hex}"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentService:
|
||||||
|
def __init__(self, repository):
|
||||||
|
self.repository = repository
|
||||||
|
|
||||||
|
def create_enrollment(
|
||||||
|
self,
|
||||||
|
request: AgentEnrollmentCreateRequest,
|
||||||
|
*,
|
||||||
|
actor_user_id: UUID,
|
||||||
|
) -> dict:
|
||||||
|
for _ in range(MAX_GENERATION_ATTEMPTS):
|
||||||
|
token = generate_enrollment_token()
|
||||||
|
try:
|
||||||
|
enrollment = self.repository.create_enrollment(
|
||||||
|
token_hash=hash_agent_secret(token),
|
||||||
|
site_id=request.site_id,
|
||||||
|
agent_zone_id=request.agent_zone_id,
|
||||||
|
display_name=request.display_name,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
**enrollment,
|
||||||
|
"enrollment_token": token,
|
||||||
|
}
|
||||||
|
except AgentEnrollmentCredentialCollision:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Unable to generate a unique enrollment token"
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_enrollments(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
site_id: UUID | None = None,
|
||||||
|
agent_zone_id: UUID | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[dict]:
|
||||||
|
return self.repository.list_enrollments(
|
||||||
|
site_id=site_id,
|
||||||
|
agent_zone_id=agent_zone_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_credential_transition(
|
||||||
|
self,
|
||||||
|
request: AgentCredentialTransitionCreateRequest,
|
||||||
|
*,
|
||||||
|
actor_user_id: UUID,
|
||||||
|
) -> dict:
|
||||||
|
for _ in range(MAX_GENERATION_ATTEMPTS):
|
||||||
|
token = generate_enrollment_token()
|
||||||
|
try:
|
||||||
|
transition = self.repository.create_credential_transition(
|
||||||
|
token_hash=hash_agent_secret(token),
|
||||||
|
target_agent_id=request.agent_id,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
**transition,
|
||||||
|
"transition_token": token,
|
||||||
|
}
|
||||||
|
except AgentEnrollmentCredentialCollision:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Unable to generate a unique credential transition token"
|
||||||
|
)
|
||||||
|
|
||||||
|
def redeem(self, request: AgentEnrollRequest) -> dict:
|
||||||
|
token_hash = hash_agent_secret(request.token)
|
||||||
|
|
||||||
|
for _ in range(MAX_GENERATION_ATTEMPTS):
|
||||||
|
agent_id = generate_agent_id()
|
||||||
|
agent_secret = generate_agent_secret()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.repository.redeem_enrollment(
|
||||||
|
token_hash=token_hash,
|
||||||
|
agent_id=agent_id,
|
||||||
|
agent_secret_hash=hash_agent_secret(agent_secret),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
**result,
|
||||||
|
"agent_secret": agent_secret,
|
||||||
|
}
|
||||||
|
except AgentEnrollmentCredentialCollision:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Unable to generate unique Agent credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
def redeem_credential_transition(
|
||||||
|
self,
|
||||||
|
request: AgentCredentialTransitionRedeemRequest,
|
||||||
|
) -> dict:
|
||||||
|
return self.repository.redeem_credential_transition(
|
||||||
|
token_hash=hash_agent_secret(request.token),
|
||||||
|
agent_secret_hash=request.agent_secret_hash,
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Downloadable MinerOps Agent deployment packages."""
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Build short-lived Windows Agent enrollment packages in memory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
WINDOWS_PACKAGE_ROOT = "MinerOpsAgent-Windows"
|
||||||
|
|
||||||
|
WINDOWS_PACKAGE_FILES = (
|
||||||
|
"Enroll-MinerOpsAgent.ps1",
|
||||||
|
"Upgrade-MinerOpsAgentCredential.ps1",
|
||||||
|
"install.ps1",
|
||||||
|
"uninstall.ps1",
|
||||||
|
"MinerOpsAgentService.ps1",
|
||||||
|
"MinerOpsAgentTray.ps1",
|
||||||
|
"MinerOpsAgentGUI.ps1",
|
||||||
|
"MinerOpsApi3EnrichmentPoller.ps1",
|
||||||
|
"MinerOpsPoolPoller.ps1",
|
||||||
|
"MinerOpsPoolPollerTask.ps1",
|
||||||
|
"EnrichOneMiner.ps1",
|
||||||
|
"MinerOpsControl.ps1",
|
||||||
|
"VERSION",
|
||||||
|
"README.md",
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_WINDOWS_AGENT_SOURCE = Path(
|
||||||
|
os.environ.get(
|
||||||
|
"MINEROPS_WINDOWS_AGENT_SOURCE",
|
||||||
|
"/app/agents/windows",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentWindowsPackageError(RuntimeError):
|
||||||
|
"""Base Windows package construction error."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentWindowsPackageSourceError(AgentWindowsPackageError):
|
||||||
|
"""Canonical Windows Agent source is unavailable or incomplete."""
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_hub_url(value: str) -> str:
|
||||||
|
candidate = value.strip()
|
||||||
|
parsed = urlsplit(candidate)
|
||||||
|
|
||||||
|
if parsed.scheme not in {"http", "https"}:
|
||||||
|
raise AgentWindowsPackageError(
|
||||||
|
"MinerOps Hub URL must use http or https"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not parsed.netloc:
|
||||||
|
raise AgentWindowsPackageError(
|
||||||
|
"MinerOps Hub URL must include a host"
|
||||||
|
)
|
||||||
|
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
raise AgentWindowsPackageError(
|
||||||
|
"MinerOps Hub URL must not contain credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
if parsed.query or parsed.fragment:
|
||||||
|
raise AgentWindowsPackageError(
|
||||||
|
"MinerOps Hub URL must not contain query or fragment data"
|
||||||
|
)
|
||||||
|
|
||||||
|
if parsed.path not in {"", "/"}:
|
||||||
|
raise AgentWindowsPackageError(
|
||||||
|
"MinerOps Hub URL must be an origin without a path"
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_windows_agent_package(
|
||||||
|
*,
|
||||||
|
enrollment_token: str,
|
||||||
|
hub_url: str,
|
||||||
|
workflow: str = "new_agent",
|
||||||
|
source_root: Path | None = None,
|
||||||
|
) -> bytes:
|
||||||
|
token = enrollment_token.strip()
|
||||||
|
|
||||||
|
if not 32 <= len(token) <= 256:
|
||||||
|
raise AgentWindowsPackageError(
|
||||||
|
"Enrollment token has an invalid length"
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_hub_url = normalize_hub_url(hub_url)
|
||||||
|
|
||||||
|
if workflow not in {
|
||||||
|
"new_agent",
|
||||||
|
"existing_agent_credential_transition",
|
||||||
|
}:
|
||||||
|
raise AgentWindowsPackageError("Unsupported Windows Agent workflow")
|
||||||
|
|
||||||
|
root = (
|
||||||
|
Path(source_root)
|
||||||
|
if source_root is not None
|
||||||
|
else DEFAULT_WINDOWS_AGENT_SOURCE
|
||||||
|
)
|
||||||
|
|
||||||
|
if not root.is_dir():
|
||||||
|
raise AgentWindowsPackageSourceError(
|
||||||
|
"Canonical Windows Agent source is unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name in WINDOWS_PACKAGE_FILES
|
||||||
|
if not (root / name).is_file()
|
||||||
|
]
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
raise AgentWindowsPackageSourceError(
|
||||||
|
"Canonical Windows Agent source is incomplete: "
|
||||||
|
+ ", ".join(missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
enrollment = {
|
||||||
|
"hub_url": normalized_hub_url,
|
||||||
|
"token": token,
|
||||||
|
"workflow": workflow,
|
||||||
|
}
|
||||||
|
|
||||||
|
if workflow == "new_agent":
|
||||||
|
command = ".\\Enroll-MinerOpsAgent.ps1"
|
||||||
|
description = "enrollment"
|
||||||
|
else:
|
||||||
|
command = ".\\Upgrade-MinerOpsAgentCredential.ps1"
|
||||||
|
description = "existing-Agent credential transition"
|
||||||
|
|
||||||
|
instructions = f"""MinerOps Windows Agent
|
||||||
|
|
||||||
|
1. Extract this ZIP on the target Windows machine.
|
||||||
|
2. Open Windows PowerShell as Administrator.
|
||||||
|
3. Change to the extracted MinerOpsAgent-Windows folder.
|
||||||
|
4. Run:
|
||||||
|
|
||||||
|
{command}
|
||||||
|
|
||||||
|
This package is for the {description} workflow. The credential is short-lived
|
||||||
|
and single-use. Do not use the other bootstrap script with this package.
|
||||||
|
Do not post enrollment.json in chat, tickets, screenshots, or logs.
|
||||||
|
Delete the ZIP and extracted setup folder after successful installation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
|
||||||
|
with zipfile.ZipFile(
|
||||||
|
buffer,
|
||||||
|
mode="w",
|
||||||
|
compression=zipfile.ZIP_DEFLATED,
|
||||||
|
) as archive:
|
||||||
|
for name in WINDOWS_PACKAGE_FILES:
|
||||||
|
archive.write(
|
||||||
|
root / name,
|
||||||
|
arcname=f"{WINDOWS_PACKAGE_ROOT}/{name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
archive.writestr(
|
||||||
|
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json",
|
||||||
|
json.dumps(
|
||||||
|
enrollment,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
archive.writestr(
|
||||||
|
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt",
|
||||||
|
instructions,
|
||||||
|
)
|
||||||
|
|
||||||
|
return buffer.getvalue()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Read-only Agent registry projection."""
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Dependencies for the Agent registry read model."""
|
||||||
|
|
||||||
|
from app.agent_registry.repository import AgentRegistryRepository
|
||||||
|
from app.agent_registry.service import AgentRegistryService
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_registry_service() -> AgentRegistryService:
|
||||||
|
return AgentRegistryService(AgentRegistryRepository())
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Typed read models for Agent Management."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
CredentialState = Literal["legacy", "active", "revoked", "retired"]
|
||||||
|
RoutingState = Literal["assigned", "unassigned", "disabled"]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRegistrySite(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRegistryZone(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
site_id: UUID
|
||||||
|
site_name: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
is_enabled: bool
|
||||||
|
assigned_agent_id: str | None = None
|
||||||
|
networks: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRegistryAgent(BaseModel):
|
||||||
|
agent_id: str
|
||||||
|
display_name: str | None = None
|
||||||
|
|
||||||
|
site_id: UUID | None = None
|
||||||
|
site_name: str
|
||||||
|
|
||||||
|
agent_zone_id: UUID | None = None
|
||||||
|
agent_zone_name: str | None = None
|
||||||
|
agent_zone_enabled: bool | None = None
|
||||||
|
|
||||||
|
hostname: str | None = None
|
||||||
|
version: str | None = None
|
||||||
|
ip_address: str | None = None
|
||||||
|
last_checkin_at: datetime
|
||||||
|
|
||||||
|
credential_state: CredentialState
|
||||||
|
credential_created_at: datetime | None = None
|
||||||
|
credential_revoked_at: datetime | None = None
|
||||||
|
|
||||||
|
routing_state: RoutingState
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRegistryData(BaseModel):
|
||||||
|
agents: list[AgentRegistryAgent] = Field(default_factory=list)
|
||||||
|
sites: list[AgentRegistrySite] = Field(default_factory=list)
|
||||||
|
zones: list[AgentRegistryZone] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRegistryResponse(BaseModel):
|
||||||
|
data: AgentRegistryData
|
||||||
|
meta: dict[str, object] = Field(default_factory=dict)
|
||||||
|
error: None = None
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""PostgreSQL reads for the durable Agent registry."""
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def default_connection_factory():
|
||||||
|
return psycopg.connect(
|
||||||
|
host=settings.postgres_host,
|
||||||
|
port=settings.postgres_port,
|
||||||
|
dbname=settings.postgres_db,
|
||||||
|
user=settings.postgres_user,
|
||||||
|
password=settings.postgres_password,
|
||||||
|
row_factory=dict_row,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRegistryRepository:
|
||||||
|
def __init__(self, connection_factory=default_connection_factory):
|
||||||
|
self.connection_factory = connection_factory
|
||||||
|
|
||||||
|
def list_registry(self) -> dict[str, list[dict]]:
|
||||||
|
connection = self.connection_factory()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
site.id,
|
||||||
|
site.name
|
||||||
|
FROM sites AS site
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM agent_zones AS zone
|
||||||
|
WHERE zone.site_id = site.id
|
||||||
|
)
|
||||||
|
ORDER BY lower(site.name), site.id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
sites = cur.fetchall()
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
zone.id,
|
||||||
|
zone.site_id,
|
||||||
|
site.name AS site_name,
|
||||||
|
zone.name,
|
||||||
|
zone.description,
|
||||||
|
zone.is_enabled,
|
||||||
|
assigned.agent_id AS assigned_agent_id,
|
||||||
|
COALESCE(
|
||||||
|
array_agg(
|
||||||
|
network.network::text
|
||||||
|
ORDER BY network.network::text
|
||||||
|
) FILTER (WHERE network.id IS NOT NULL),
|
||||||
|
ARRAY[]::text[]
|
||||||
|
) AS networks
|
||||||
|
FROM agent_zones AS zone
|
||||||
|
JOIN sites AS site
|
||||||
|
ON site.id = zone.site_id
|
||||||
|
LEFT JOIN minerops_site_agents AS assigned
|
||||||
|
ON assigned.agent_zone_id = zone.id
|
||||||
|
LEFT JOIN agent_zone_network_ranges AS network
|
||||||
|
ON network.agent_zone_id = zone.id
|
||||||
|
GROUP BY
|
||||||
|
zone.id,
|
||||||
|
zone.site_id,
|
||||||
|
site.name,
|
||||||
|
zone.name,
|
||||||
|
zone.description,
|
||||||
|
zone.is_enabled,
|
||||||
|
assigned.agent_id
|
||||||
|
ORDER BY
|
||||||
|
lower(site.name),
|
||||||
|
lower(zone.name),
|
||||||
|
zone.id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
zones = cur.fetchall()
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
agent.agent_id,
|
||||||
|
agent.display_name,
|
||||||
|
agent.site_id,
|
||||||
|
COALESCE(site.name, agent.site_name) AS site_name,
|
||||||
|
agent.agent_zone_id,
|
||||||
|
zone.name AS agent_zone_name,
|
||||||
|
zone.is_enabled AS agent_zone_enabled,
|
||||||
|
agent.hostname,
|
||||||
|
agent.version,
|
||||||
|
agent.ip_address,
|
||||||
|
agent.last_checkin_at,
|
||||||
|
agent.credential_created_at,
|
||||||
|
agent.credential_revoked_at,
|
||||||
|
CASE
|
||||||
|
WHEN agent.credential_revoked_at IS NOT NULL
|
||||||
|
THEN 'revoked'
|
||||||
|
WHEN agent.agent_secret_hash IS NOT NULL
|
||||||
|
THEN 'active'
|
||||||
|
WHEN retirement.retired_at IS NOT NULL
|
||||||
|
THEN 'retired'
|
||||||
|
ELSE 'legacy'
|
||||||
|
END AS credential_state
|
||||||
|
FROM minerops_site_agents AS agent
|
||||||
|
LEFT JOIN agent_legacy_auth_retirements AS retirement
|
||||||
|
ON retirement.agent_id = agent.agent_id
|
||||||
|
LEFT JOIN sites AS site
|
||||||
|
ON site.id = agent.site_id
|
||||||
|
LEFT JOIN agent_zones AS zone
|
||||||
|
ON zone.id = agent.agent_zone_id
|
||||||
|
ORDER BY
|
||||||
|
lower(COALESCE(site.name, agent.site_name)),
|
||||||
|
lower(COALESCE(agent.display_name, agent.agent_id)),
|
||||||
|
agent.agent_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
agents = cur.fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sites": sites,
|
||||||
|
"zones": zones,
|
||||||
|
"agents": agents,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Read-only Agent Management API."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Response
|
||||||
|
|
||||||
|
from app.agent_registry.dependencies import get_agent_registry_service
|
||||||
|
from app.agent_registry.models import AgentRegistryResponse
|
||||||
|
from app.agent_registry.service import AgentRegistryService
|
||||||
|
from app.identity import require_permission
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["agents"])
|
||||||
|
|
||||||
|
RegistryService = Annotated[
|
||||||
|
AgentRegistryService,
|
||||||
|
Depends(get_agent_registry_service),
|
||||||
|
]
|
||||||
|
|
||||||
|
AgentViewer = Annotated[
|
||||||
|
dict,
|
||||||
|
Depends(require_permission("agents.view")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/agents",
|
||||||
|
response_model=AgentRegistryResponse,
|
||||||
|
)
|
||||||
|
def list_agents(
|
||||||
|
response: Response,
|
||||||
|
current_user: AgentViewer,
|
||||||
|
service: RegistryService,
|
||||||
|
) -> AgentRegistryResponse:
|
||||||
|
del current_user
|
||||||
|
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
|
||||||
|
data = service.list_registry()
|
||||||
|
|
||||||
|
return AgentRegistryResponse(
|
||||||
|
data=data,
|
||||||
|
meta={
|
||||||
|
"agent_count": len(data.agents),
|
||||||
|
"site_count": len(data.sites),
|
||||||
|
"zone_count": len(data.zones),
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Application service for the Agent Management read model."""
|
||||||
|
|
||||||
|
from app.agent_registry.models import (
|
||||||
|
AgentRegistryAgent,
|
||||||
|
AgentRegistryData,
|
||||||
|
AgentRegistrySite,
|
||||||
|
AgentRegistryZone,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRegistryService:
|
||||||
|
def __init__(self, repository):
|
||||||
|
self.repository = repository
|
||||||
|
|
||||||
|
def list_registry(self) -> AgentRegistryData:
|
||||||
|
rows = self.repository.list_registry()
|
||||||
|
|
||||||
|
sites = [
|
||||||
|
AgentRegistrySite(**row)
|
||||||
|
for row in rows["sites"]
|
||||||
|
]
|
||||||
|
|
||||||
|
zones = [
|
||||||
|
AgentRegistryZone(**row)
|
||||||
|
for row in rows["zones"]
|
||||||
|
]
|
||||||
|
|
||||||
|
agents: list[AgentRegistryAgent] = []
|
||||||
|
|
||||||
|
for row in rows["agents"]:
|
||||||
|
zone_id = row.get("agent_zone_id")
|
||||||
|
zone_enabled = row.get("agent_zone_enabled")
|
||||||
|
|
||||||
|
if zone_id is None:
|
||||||
|
routing_state = "unassigned"
|
||||||
|
elif zone_enabled is False:
|
||||||
|
routing_state = "disabled"
|
||||||
|
else:
|
||||||
|
routing_state = "assigned"
|
||||||
|
|
||||||
|
agents.append(
|
||||||
|
AgentRegistryAgent(
|
||||||
|
**row,
|
||||||
|
routing_state=routing_state,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AgentRegistryData(
|
||||||
|
agents=agents,
|
||||||
|
sites=sites,
|
||||||
|
zones=zones,
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Agent Zone routing domain."""
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Agent Zone dependency wiring."""
|
||||||
|
|
||||||
|
from app.agent_zones.repository import AgentZoneRepository
|
||||||
|
from app.agent_zones.service import AgentZoneService
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_zone_service() -> AgentZoneService:
|
||||||
|
return AgentZoneService(AgentZoneRepository())
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Agent Zone domain exceptions."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneError(Exception):
|
||||||
|
"""Base Agent Zone error."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneNotFound(AgentZoneError):
|
||||||
|
"""A Site or Agent Zone does not exist."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneValidationError(AgentZoneError):
|
||||||
|
"""Agent Zone input or assignment is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneConflict(AgentZoneError):
|
||||||
|
"""An Agent Zone uniqueness or assignment invariant conflicts."""
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Typed Agent Zone and check-in contracts."""
|
||||||
|
|
||||||
|
from ipaddress import ip_network
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
site_id: UUID
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
description: str | None = Field(default=None, max_length=1000)
|
||||||
|
networks: list[str] = Field(default_factory=list, max_length=64)
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def normalize_name(cls, value: str) -> str:
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("name cannot be blank")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@field_validator("description")
|
||||||
|
@classmethod
|
||||||
|
def normalize_description(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.strip() or None
|
||||||
|
|
||||||
|
@field_validator("networks")
|
||||||
|
@classmethod
|
||||||
|
def normalize_networks(cls, values: list[str]) -> list[str]:
|
||||||
|
normalized: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
try:
|
||||||
|
network = str(ip_network(value.strip(), strict=True))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"invalid CIDR network: {value}") from exc
|
||||||
|
if network in normalized:
|
||||||
|
raise ValueError(f"duplicate CIDR network: {network}")
|
||||||
|
normalized.append(network)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneNetwork(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
network: str
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneSummary(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
site_id: UUID
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
is_enabled: bool
|
||||||
|
assigned_agent_id: str | None = None
|
||||||
|
networks: list[AgentZoneNetwork] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneListResponse(BaseModel):
|
||||||
|
data: list[AgentZoneSummary]
|
||||||
|
meta: dict[str, object] = Field(default_factory=dict)
|
||||||
|
error: None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneCreateResponse(BaseModel):
|
||||||
|
data: AgentZoneSummary
|
||||||
|
meta: dict[str, object] = Field(default_factory=dict)
|
||||||
|
error: None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCheckinRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
agent_id: str | None = Field(default=None, max_length=255)
|
||||||
|
site_id: UUID | None = None
|
||||||
|
agent_zone_id: UUID | None = None
|
||||||
|
site_name: str | None = Field(default=None, max_length=255)
|
||||||
|
hostname: str | None = Field(default=None, max_length=255)
|
||||||
|
version: str | None = Field(default=None, max_length=120)
|
||||||
|
ip_address: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def require_site_for_explicit_zone(self) -> "AgentCheckinRequest":
|
||||||
|
if self.agent_zone_id is not None and self.site_id is None:
|
||||||
|
raise ValueError("site_id is required when agent_zone_id is supplied")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCheckinResponse(BaseModel):
|
||||||
|
ok: bool = True
|
||||||
|
hub_mode: bool = True
|
||||||
|
agent_id: str
|
||||||
|
site_id: UUID | None = None
|
||||||
|
agent_zone_id: UUID | None = None
|
||||||
|
routing_available: bool
|
||||||
|
routing_reason: str | None = None
|
||||||
|
site_name: str
|
||||||
|
message: str = "Agent check-in received."
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneRoutingContext(BaseModel):
|
||||||
|
site_id: UUID | None = None
|
||||||
|
agent_zone_id: UUID | None = None
|
||||||
|
routing_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MinerZoneAssociationResult(BaseModel):
|
||||||
|
agent_zone_id: UUID | None = None
|
||||||
|
routing_reason: str | None = None
|
||||||
|
outcomes: dict[UUID, str] = Field(default_factory=dict)
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
"""PostgreSQL persistence for Agent Zones and ownership assignments."""
|
||||||
|
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.errors import ForeignKeyViolation, UniqueViolation
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from app.agent_zones.exceptions import (
|
||||||
|
AgentZoneConflict,
|
||||||
|
AgentZoneNotFound,
|
||||||
|
)
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||||
|
|
||||||
|
|
||||||
|
def default_connection_factory() -> psycopg.Connection:
|
||||||
|
return psycopg.connect(
|
||||||
|
host=settings.postgres_host,
|
||||||
|
port=settings.postgres_port,
|
||||||
|
dbname=settings.postgres_db,
|
||||||
|
user=settings.postgres_user,
|
||||||
|
password=settings.postgres_password,
|
||||||
|
connect_timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneRepository:
|
||||||
|
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||||
|
self._connection_factory = connection_factory or default_connection_factory
|
||||||
|
|
||||||
|
def get_agent(self, agent_id: str) -> dict | None:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT agent_id, site_id, agent_zone_id, site_name, hostname,
|
||||||
|
version, ip_address, last_checkin_at, last_payload
|
||||||
|
FROM minerops_site_agents
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
(agent_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def get_zone(self, zone_id: UUID) -> dict | None:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, site_id, name, description, is_enabled,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM agent_zones
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(zone_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def list_usable_zones(self, site_id: UUID) -> list[dict]:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, site_id, name, description, is_enabled
|
||||||
|
FROM agent_zones
|
||||||
|
WHERE site_id = %s AND is_enabled
|
||||||
|
ORDER BY lower(name), id
|
||||||
|
""",
|
||||||
|
(site_id,),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def list_zones(self, site_id: UUID | None = None) -> list[dict]:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT zone.id, zone.site_id, zone.name, zone.description,
|
||||||
|
zone.is_enabled, agent.agent_id AS assigned_agent_id,
|
||||||
|
range.id AS range_id, range.network::text AS network
|
||||||
|
FROM agent_zones AS zone
|
||||||
|
LEFT JOIN minerops_site_agents AS agent
|
||||||
|
ON agent.agent_zone_id = zone.id
|
||||||
|
LEFT JOIN agent_zone_network_ranges AS range
|
||||||
|
ON range.agent_zone_id = zone.id
|
||||||
|
WHERE (%s::uuid IS NULL OR zone.site_id = %s::uuid)
|
||||||
|
ORDER BY lower(zone.name), zone.id, range.network, range.id
|
||||||
|
""",
|
||||||
|
(site_id, site_id),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
zones: dict[UUID, dict] = {}
|
||||||
|
for row in rows:
|
||||||
|
zone = zones.setdefault(
|
||||||
|
row["id"],
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"site_id": row["site_id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"description": row["description"],
|
||||||
|
"is_enabled": row["is_enabled"],
|
||||||
|
"assigned_agent_id": row["assigned_agent_id"],
|
||||||
|
"networks": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if row["range_id"] is not None:
|
||||||
|
zone["networks"].append(
|
||||||
|
{"id": row["range_id"], "network": row["network"]}
|
||||||
|
)
|
||||||
|
return list(zones.values())
|
||||||
|
|
||||||
|
def create_zone(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
site_id: UUID,
|
||||||
|
name: str,
|
||||||
|
description: str | None,
|
||||||
|
networks: list[str],
|
||||||
|
) -> UUID:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zones (site_id, name, description)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(site_id, name, description),
|
||||||
|
)
|
||||||
|
zone_id = cur.fetchone()["id"]
|
||||||
|
for network in networks:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||||
|
VALUES (%s, %s::cidr)
|
||||||
|
""",
|
||||||
|
(zone_id, network),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
return zone_id
|
||||||
|
except ForeignKeyViolation as exc:
|
||||||
|
connection.rollback()
|
||||||
|
raise AgentZoneNotFound("Site not found") from exc
|
||||||
|
except UniqueViolation as exc:
|
||||||
|
connection.rollback()
|
||||||
|
raise AgentZoneConflict(
|
||||||
|
"An enabled Agent Zone with this name already exists at the Site"
|
||||||
|
) from exc
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def save_checkin(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
site_id: UUID | None,
|
||||||
|
agent_zone_id: UUID | None,
|
||||||
|
site_name: str,
|
||||||
|
hostname: str | None,
|
||||||
|
version: str | None,
|
||||||
|
ip_address: str | None,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id, site_id, agent_zone_id, site_name, hostname,
|
||||||
|
version, ip_address, last_checkin_at, last_payload
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, now(), %s)
|
||||||
|
ON CONFLICT (agent_id) DO UPDATE SET
|
||||||
|
site_id = EXCLUDED.site_id,
|
||||||
|
agent_zone_id = EXCLUDED.agent_zone_id,
|
||||||
|
site_name = EXCLUDED.site_name,
|
||||||
|
hostname = EXCLUDED.hostname,
|
||||||
|
version = EXCLUDED.version,
|
||||||
|
ip_address = EXCLUDED.ip_address,
|
||||||
|
last_checkin_at = now(),
|
||||||
|
last_payload = EXCLUDED.last_payload
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
agent_id,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
site_name,
|
||||||
|
hostname,
|
||||||
|
version,
|
||||||
|
ip_address,
|
||||||
|
Jsonb(payload),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
except UniqueViolation as exc:
|
||||||
|
connection.rollback()
|
||||||
|
raise AgentZoneConflict(
|
||||||
|
"The Agent Zone already has an assigned Agent"
|
||||||
|
) from exc
|
||||||
|
except (ForeignKeyViolation, psycopg.errors.CheckViolation) as exc:
|
||||||
|
connection.rollback()
|
||||||
|
raise AgentZoneConflict("Agent Zone and Site assignment conflict") from exc
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def get_agent_assignment(self, agent_id: str) -> dict | None:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT agent.agent_id, agent.site_id, agent.agent_zone_id,
|
||||||
|
zone.is_enabled AS zone_enabled
|
||||||
|
FROM minerops_site_agents AS agent
|
||||||
|
LEFT JOIN agent_zones AS zone ON zone.id = agent.agent_zone_id
|
||||||
|
WHERE agent.agent_id = %s
|
||||||
|
""",
|
||||||
|
(agent_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def get_agent_configuration(self, agent_id: str) -> dict | None:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
agent.agent_id,
|
||||||
|
agent.site_id,
|
||||||
|
agent.agent_zone_id,
|
||||||
|
coalesce(site.name, agent.site_name) AS site_name,
|
||||||
|
zone.name AS agent_zone_name,
|
||||||
|
zone.is_enabled AS agent_zone_enabled,
|
||||||
|
coalesce(
|
||||||
|
array_agg(
|
||||||
|
network.network::text
|
||||||
|
ORDER BY network.network::text, network.id
|
||||||
|
) FILTER (WHERE network.id IS NOT NULL),
|
||||||
|
ARRAY[]::text[]
|
||||||
|
) AS networks
|
||||||
|
FROM minerops_site_agents AS agent
|
||||||
|
LEFT JOIN sites AS site
|
||||||
|
ON site.id = agent.site_id
|
||||||
|
LEFT JOIN agent_zones AS zone
|
||||||
|
ON zone.id = agent.agent_zone_id
|
||||||
|
LEFT JOIN agent_zone_network_ranges AS network
|
||||||
|
ON network.agent_zone_id = zone.id
|
||||||
|
WHERE agent.agent_id = %s
|
||||||
|
GROUP BY
|
||||||
|
agent.agent_id,
|
||||||
|
agent.site_id,
|
||||||
|
agent.agent_zone_id,
|
||||||
|
site.name,
|
||||||
|
agent.site_name,
|
||||||
|
zone.name,
|
||||||
|
zone.is_enabled
|
||||||
|
""",
|
||||||
|
(agent_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def assign_miners_to_zone(
|
||||||
|
self, miner_ids: Iterable[UUID], agent_zone_id: UUID
|
||||||
|
) -> dict[UUID, str]:
|
||||||
|
unique_ids = list(dict.fromkeys(miner_ids))
|
||||||
|
if not unique_ids:
|
||||||
|
return {}
|
||||||
|
connection = self._connection_factory()
|
||||||
|
outcomes: dict[UUID, str] = {}
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, site_id FROM agent_zones WHERE id = %s",
|
||||||
|
(agent_zone_id,),
|
||||||
|
)
|
||||||
|
zone = cur.fetchone()
|
||||||
|
if not zone:
|
||||||
|
raise AgentZoneNotFound("Agent Zone not found")
|
||||||
|
for miner_id in unique_ids:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, site_id, agent_zone_id FROM miners WHERE id = %s FOR UPDATE",
|
||||||
|
(miner_id,),
|
||||||
|
)
|
||||||
|
miner = cur.fetchone()
|
||||||
|
if not miner:
|
||||||
|
outcomes[miner_id] = "miner_not_found"
|
||||||
|
elif miner["agent_zone_id"] == agent_zone_id:
|
||||||
|
outcomes[miner_id] = "preserved"
|
||||||
|
elif miner["agent_zone_id"] is not None:
|
||||||
|
outcomes[miner_id] = "agent_zone_conflict"
|
||||||
|
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"
|
||||||
|
connection.commit()
|
||||||
|
return outcomes
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""HTTP mapping for Agent Zones and Agent check-in."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
|
||||||
|
from app.agent_commands.dependencies import require_agent_identity
|
||||||
|
from app.agent_zones.dependencies import get_agent_zone_service
|
||||||
|
from app.agent_zones.exceptions import (
|
||||||
|
AgentZoneConflict,
|
||||||
|
AgentZoneNotFound,
|
||||||
|
AgentZoneValidationError,
|
||||||
|
)
|
||||||
|
from app.agent_zones.models import (
|
||||||
|
AgentCheckinRequest,
|
||||||
|
AgentCheckinResponse,
|
||||||
|
AgentZoneCreateRequest,
|
||||||
|
AgentZoneCreateResponse,
|
||||||
|
AgentZoneListResponse,
|
||||||
|
)
|
||||||
|
from app.agent_zones.service import AgentZoneService
|
||||||
|
from app.identity import require_permission
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["agent-zones"])
|
||||||
|
ZoneService = Annotated[AgentZoneService, Depends(get_agent_zone_service)]
|
||||||
|
AgentIdentity = Annotated[str, Depends(require_agent_identity)]
|
||||||
|
ZoneViewer = Annotated[dict, Depends(require_permission("agents.view"))]
|
||||||
|
ZoneManager = Annotated[dict, Depends(require_permission("agents.manage"))]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/agent-zones", response_model=AgentZoneListResponse)
|
||||||
|
def list_agent_zones(
|
||||||
|
current_user: ZoneViewer,
|
||||||
|
service: ZoneService,
|
||||||
|
site_id: UUID | None = Query(default=None),
|
||||||
|
) -> AgentZoneListResponse:
|
||||||
|
del current_user
|
||||||
|
return AgentZoneListResponse(data=service.list_zones(site_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/agent-zones",
|
||||||
|
response_model=AgentZoneCreateResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_agent_zone(
|
||||||
|
request: AgentZoneCreateRequest,
|
||||||
|
current_user: ZoneManager,
|
||||||
|
service: ZoneService,
|
||||||
|
) -> AgentZoneCreateResponse:
|
||||||
|
del current_user
|
||||||
|
try:
|
||||||
|
return AgentZoneCreateResponse(data=service.create_zone(request))
|
||||||
|
except AgentZoneNotFound as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except AgentZoneConflict as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/agent/checkin", response_model=AgentCheckinResponse)
|
||||||
|
def agent_checkin(
|
||||||
|
request: AgentCheckinRequest,
|
||||||
|
agent_id: AgentIdentity,
|
||||||
|
service: ZoneService,
|
||||||
|
) -> AgentCheckinResponse:
|
||||||
|
try:
|
||||||
|
return service.checkin(agent_id, request)
|
||||||
|
except AgentZoneNotFound as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except AgentZoneConflict as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
except AgentZoneValidationError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Business rules for Agent Zone routing and transitional check-in."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.agent_zones.exceptions import (
|
||||||
|
AgentZoneNotFound,
|
||||||
|
AgentZoneValidationError,
|
||||||
|
)
|
||||||
|
from app.agent_zones.models import (
|
||||||
|
AgentCheckinRequest,
|
||||||
|
AgentCheckinResponse,
|
||||||
|
AgentZoneRoutingContext,
|
||||||
|
AgentZoneCreateRequest,
|
||||||
|
AgentZoneSummary,
|
||||||
|
MinerZoneAssociationResult,
|
||||||
|
)
|
||||||
|
from app.agent_zones.repository import AgentZoneRepository
|
||||||
|
|
||||||
|
|
||||||
|
SENSITIVE_KEYS = {
|
||||||
|
"agent_secret",
|
||||||
|
"api3_account",
|
||||||
|
"api3_password",
|
||||||
|
"password",
|
||||||
|
"salt",
|
||||||
|
"token",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize(value: Any) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
key: _sanitize(item)
|
||||||
|
for key, item in value.items()
|
||||||
|
if key.lower() not in SENSITIVE_KEYS
|
||||||
|
and not any(
|
||||||
|
marker in key.lower()
|
||||||
|
for marker in ("secret", "password", "token", "salt")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_sanitize(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneService:
|
||||||
|
@staticmethod
|
||||||
|
def validate_agent_identity(
|
||||||
|
authenticated_agent_id: str, claimed_agent_id: str | None
|
||||||
|
) -> None:
|
||||||
|
if claimed_agent_id and claimed_agent_id.strip() != authenticated_agent_id:
|
||||||
|
raise AgentZoneValidationError(
|
||||||
|
"Payload agent_id does not match authenticated Agent identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, repository: AgentZoneRepository):
|
||||||
|
self.repository = repository
|
||||||
|
|
||||||
|
def list_zones(self, site_id: UUID | None = None) -> list[AgentZoneSummary]:
|
||||||
|
return [
|
||||||
|
AgentZoneSummary.model_validate(row)
|
||||||
|
for row in self.repository.list_zones(site_id)
|
||||||
|
]
|
||||||
|
|
||||||
|
def create_zone(self, request: AgentZoneCreateRequest) -> AgentZoneSummary:
|
||||||
|
zone_id = self.repository.create_zone(
|
||||||
|
site_id=request.site_id,
|
||||||
|
name=request.name,
|
||||||
|
description=request.description,
|
||||||
|
networks=request.networks,
|
||||||
|
)
|
||||||
|
for zone in self.list_zones(request.site_id):
|
||||||
|
if zone.id == zone_id:
|
||||||
|
return zone
|
||||||
|
raise AgentZoneNotFound("Created Agent Zone was not found")
|
||||||
|
|
||||||
|
def checkin(
|
||||||
|
self, authenticated_agent_id: str, request: AgentCheckinRequest
|
||||||
|
) -> AgentCheckinResponse:
|
||||||
|
self.validate_agent_identity(authenticated_agent_id, request.agent_id)
|
||||||
|
|
||||||
|
existing = self.repository.get_agent(authenticated_agent_id)
|
||||||
|
requested_site = request.site_id
|
||||||
|
requested_zone = request.agent_zone_id
|
||||||
|
final_site = requested_site or (existing or {}).get("site_id")
|
||||||
|
final_zone: UUID | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
established_zone = None
|
||||||
|
established_zone_id = (existing or {}).get("agent_zone_id")
|
||||||
|
if established_zone_id is not None:
|
||||||
|
established_zone = self.repository.get_zone(established_zone_id)
|
||||||
|
if not established_zone:
|
||||||
|
raise AgentZoneValidationError(
|
||||||
|
"Existing Agent Zone assignment is invalid"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
requested_site is not None
|
||||||
|
and established_zone["site_id"] != requested_site
|
||||||
|
):
|
||||||
|
raise AgentZoneValidationError(
|
||||||
|
"Agent cannot change Sites while assigned to an Agent Zone"
|
||||||
|
)
|
||||||
|
|
||||||
|
if requested_zone is not None:
|
||||||
|
zone = self.repository.get_zone(requested_zone)
|
||||||
|
if not zone:
|
||||||
|
raise AgentZoneNotFound("Agent Zone not found")
|
||||||
|
if zone["site_id"] != requested_site:
|
||||||
|
raise AgentZoneValidationError(
|
||||||
|
"Agent Zone does not belong to the supplied Site"
|
||||||
|
)
|
||||||
|
if not zone["is_enabled"]:
|
||||||
|
raise AgentZoneValidationError("Agent Zone is disabled")
|
||||||
|
if established_zone_id is not None and established_zone_id != requested_zone:
|
||||||
|
raise AgentZoneValidationError(
|
||||||
|
"Agent is already assigned to a different Agent Zone"
|
||||||
|
)
|
||||||
|
final_zone = requested_zone
|
||||||
|
elif established_zone is not None:
|
||||||
|
final_site = established_zone["site_id"]
|
||||||
|
final_zone = established_zone["id"]
|
||||||
|
if not established_zone["is_enabled"]:
|
||||||
|
reason = "agent_zone_disabled"
|
||||||
|
elif final_site is not None:
|
||||||
|
usable = self.repository.list_usable_zones(final_site)
|
||||||
|
if len(usable) == 1:
|
||||||
|
final_zone = usable[0]["id"]
|
||||||
|
elif not usable:
|
||||||
|
reason = "agent_zone_unavailable"
|
||||||
|
else:
|
||||||
|
reason = "agent_zone_ambiguous"
|
||||||
|
else:
|
||||||
|
reason = "site_unassigned"
|
||||||
|
|
||||||
|
site_name = (request.site_name or "").strip()
|
||||||
|
if not site_name:
|
||||||
|
site_name = str((existing or {}).get("site_name") or "Unassigned")
|
||||||
|
payload = _sanitize(request.model_dump(mode="json"))
|
||||||
|
self.repository.save_checkin(
|
||||||
|
agent_id=authenticated_agent_id,
|
||||||
|
site_id=final_site,
|
||||||
|
agent_zone_id=final_zone,
|
||||||
|
site_name=site_name,
|
||||||
|
hostname=(request.hostname or "").strip() or None,
|
||||||
|
version=(request.version or "").strip() or None,
|
||||||
|
ip_address=(request.ip_address or "").strip() or None,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
routing_available = final_zone is not None and reason is None
|
||||||
|
return AgentCheckinResponse(
|
||||||
|
agent_id=authenticated_agent_id,
|
||||||
|
site_id=final_site,
|
||||||
|
agent_zone_id=final_zone,
|
||||||
|
routing_available=routing_available,
|
||||||
|
routing_reason=reason,
|
||||||
|
site_name=site_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def routing_context(
|
||||||
|
self, agent_id: str, recorded_zone_id: UUID | None = None
|
||||||
|
) -> AgentZoneRoutingContext:
|
||||||
|
assignment = self.repository.get_agent_assignment(agent_id)
|
||||||
|
if not assignment or assignment.get("agent_zone_id") is None:
|
||||||
|
return AgentZoneRoutingContext(routing_reason="agent_zone_unassigned")
|
||||||
|
zone_id = assignment["agent_zone_id"]
|
||||||
|
if recorded_zone_id is not None and recorded_zone_id != zone_id:
|
||||||
|
return AgentZoneRoutingContext(routing_reason="agent_zone_conflict")
|
||||||
|
if assignment.get("zone_enabled") is not True:
|
||||||
|
return AgentZoneRoutingContext(
|
||||||
|
site_id=assignment.get("site_id"),
|
||||||
|
agent_zone_id=zone_id,
|
||||||
|
routing_reason="agent_zone_disabled",
|
||||||
|
)
|
||||||
|
return AgentZoneRoutingContext(
|
||||||
|
site_id=assignment.get("site_id"), agent_zone_id=zone_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def configuration(self, agent_id: str) -> dict[str, Any] | None:
|
||||||
|
row = self.repository.get_agent_configuration(agent_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"agent_id": row["agent_id"],
|
||||||
|
"site_id": row["site_id"],
|
||||||
|
"site_name": row["site_name"],
|
||||||
|
"agent_zone_id": row["agent_zone_id"],
|
||||||
|
"agent_zone_name": row["agent_zone_name"],
|
||||||
|
"ip_ranges": [
|
||||||
|
{
|
||||||
|
"name": row["agent_zone_name"],
|
||||||
|
"cidr": network,
|
||||||
|
"enabled": row["agent_zone_enabled"] is True,
|
||||||
|
"api_port": 4028,
|
||||||
|
"api3_port": 4433,
|
||||||
|
}
|
||||||
|
for network in row.get("networks") or []
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def associate_miners_from_agent(
|
||||||
|
self, agent_id: str, miner_ids: list[UUID]
|
||||||
|
) -> MinerZoneAssociationResult:
|
||||||
|
routing = self.routing_context(agent_id)
|
||||||
|
if routing.routing_reason:
|
||||||
|
return MinerZoneAssociationResult(
|
||||||
|
agent_zone_id=routing.agent_zone_id,
|
||||||
|
routing_reason=routing.routing_reason,
|
||||||
|
)
|
||||||
|
return MinerZoneAssociationResult(
|
||||||
|
agent_zone_id=routing.agent_zone_id,
|
||||||
|
outcomes=self.repository.assign_miners_to_zone(
|
||||||
|
miner_ids, routing.agent_zone_id
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""Semantic HTML for the Internal Alpha Agent Management page."""
|
||||||
|
|
||||||
|
|
||||||
|
def agent_management_content(can_manage_agents: bool) -> str:
|
||||||
|
management = """
|
||||||
|
<section class="panel agent-enrollment-panel"
|
||||||
|
aria-labelledby="agentEnrollmentCreateTitle">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Agent deployment</p>
|
||||||
|
<h2 id="agentEnrollmentCreateTitle">Add Agent</h2>
|
||||||
|
<p class="panel-copy">
|
||||||
|
Create a short-lived one-time enrollment for an Agent Zone.
|
||||||
|
Windows is the currently supported deployment platform.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="agentCreateForm" class="agent-create-form">
|
||||||
|
<div class="field">
|
||||||
|
<label for="agentSite">Site</label>
|
||||||
|
<select id="agentSite" required>
|
||||||
|
<option value="">Choose a site</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="agentZone">Agent Zone</label>
|
||||||
|
<select id="agentZone" required disabled>
|
||||||
|
<option value="">Choose a site first</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="agentDisplayName">Agent name</label>
|
||||||
|
<input id="agentDisplayName"
|
||||||
|
type="text"
|
||||||
|
maxlength="120"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Example: Plexus Unit 4 Agent"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="agentPlatform">Platform</label>
|
||||||
|
<select id="agentPlatform">
|
||||||
|
<option value="windows">Windows</option>
|
||||||
|
<option value="linux-x64" disabled>Linux x64 — coming later</option>
|
||||||
|
<option value="linux-arm64" disabled>Linux ARM64 / Raspberry Pi — coming later</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent-form-actions">
|
||||||
|
<button id="agentCreateSubmit"
|
||||||
|
class="command-button"
|
||||||
|
type="submit">
|
||||||
|
Create Windows enrollment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="agentCreateStatus"
|
||||||
|
class="freshness"
|
||||||
|
aria-live="polite"></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section id="agentEnrollmentResult"
|
||||||
|
class="agent-enrollment-result"
|
||||||
|
aria-labelledby="agentEnrollmentResultTitle"
|
||||||
|
hidden>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">One-time credential</p>
|
||||||
|
<h3 id="agentEnrollmentResultTitle">
|
||||||
|
Enrollment created
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="agentEnrollmentResultCopy">
|
||||||
|
This token is displayed only for this enrollment step.
|
||||||
|
Keep it private. MinerOps does not store the plaintext token.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="agent-token-box">
|
||||||
|
<code id="agentEnrollmentToken"></code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="agentEnrollmentExpires" class="freshness"></p>
|
||||||
|
|
||||||
|
<div class="agent-form-actions">
|
||||||
|
<button id="agentEnrollmentDownload"
|
||||||
|
class="command-button"
|
||||||
|
type="button">
|
||||||
|
Download Windows package
|
||||||
|
</button>
|
||||||
|
<button id="agentEnrollmentCopy"
|
||||||
|
class="quiet-button"
|
||||||
|
type="button">
|
||||||
|
Copy token
|
||||||
|
</button>
|
||||||
|
<button id="agentEnrollmentDismiss"
|
||||||
|
class="quiet-button"
|
||||||
|
type="button">
|
||||||
|
Clear token from screen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="agentEnrollmentCopyStatus"
|
||||||
|
class="freshness"
|
||||||
|
aria-live="polite"></p>
|
||||||
|
|
||||||
|
<p class="panel-copy">
|
||||||
|
The Windows package contains the short-lived enrollment
|
||||||
|
credential and generic MinerOps Agent runtime. Transfer it
|
||||||
|
only to the intended Agent computer and delete the ZIP after
|
||||||
|
successful installation.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
""" if can_manage_agents else """
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Agent deployment</p>
|
||||||
|
<h2>Agent enrollment</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="permission-note">
|
||||||
|
Your account can view Agent status but cannot create Agent
|
||||||
|
enrollments.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div id="agentManagementLoading" class="state-message" role="status">
|
||||||
|
<strong>Loading Agents</strong>
|
||||||
|
<span>Requesting Agent registry and enrollment information.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="agentManagementError"
|
||||||
|
class="state-message"
|
||||||
|
data-kind="error"
|
||||||
|
role="alert"
|
||||||
|
hidden>
|
||||||
|
<strong>Agent Management unavailable</strong>
|
||||||
|
<span id="agentManagementErrorText">
|
||||||
|
Agent information could not be loaded.
|
||||||
|
</span>
|
||||||
|
<button id="agentManagementRetry"
|
||||||
|
class="command-button"
|
||||||
|
type="button">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="agentManagementContent"
|
||||||
|
data-can-manage-agents="{str(can_manage_agents).lower()}"
|
||||||
|
hidden>
|
||||||
|
<section class="agent-summary-grid" aria-label="Agent Management summary">
|
||||||
|
<div class="panel agent-summary-card">
|
||||||
|
<span>Registered Agents</span>
|
||||||
|
<strong id="agentCount">-</strong>
|
||||||
|
</div>
|
||||||
|
<div class="panel agent-summary-card">
|
||||||
|
<span>Agent Zones</span>
|
||||||
|
<strong id="agentZoneCount">-</strong>
|
||||||
|
</div>
|
||||||
|
<div class="panel agent-summary-card">
|
||||||
|
<span>Waiting enrollments</span>
|
||||||
|
<strong id="agentWaitingEnrollmentCount">-</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="agent-management-columns">
|
||||||
|
<section class="panel" aria-labelledby="registeredAgentsTitle">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Durable identities</p>
|
||||||
|
<h2 id="registeredAgentsTitle">Agents</h2>
|
||||||
|
<p class="panel-copy">
|
||||||
|
Registered Agent identities and their latest factual
|
||||||
|
check-in information.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button id="agentManagementRefresh"
|
||||||
|
class="quiet-button"
|
||||||
|
type="button">
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="agent-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Agent</th>
|
||||||
|
<th>Site / Zone</th>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Version</th>
|
||||||
|
<th>Last check-in</th>
|
||||||
|
<th>Credential</th>
|
||||||
|
<th>Routing</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="agentRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="agentEmpty" class="panel-copy" hidden>
|
||||||
|
No registered Agents.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" aria-labelledby="agentZonesTitle">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Network routing boundaries</p>
|
||||||
|
<h2 id="agentZonesTitle">Agent Zones</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="agent-zone-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Site</th>
|
||||||
|
<th>Zone</th>
|
||||||
|
<th>Networks</th>
|
||||||
|
<th>Assigned Agent</th>
|
||||||
|
<th>State</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="agentZoneRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="agentZoneEmpty" class="panel-copy" hidden>
|
||||||
|
No Agent Zones.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{management}
|
||||||
|
|
||||||
|
<section class="panel" aria-labelledby="agentEnrollmentHistoryTitle">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Bootstrap history</p>
|
||||||
|
<h2 id="agentEnrollmentHistoryTitle">Enrollment history</h2>
|
||||||
|
<p class="panel-copy">
|
||||||
|
Metadata only. Plaintext enrollment tokens are never
|
||||||
|
returned by this history view.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="agent-enrollment-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Agent name</th>
|
||||||
|
<th>Site / Zone</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Enrolled Agent</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="agentEnrollmentRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="agentEnrollmentEmpty" class="panel-copy" hidden>
|
||||||
|
No Agent enrollment records.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/assets/minerops-agent-management.js" defer></script>
|
||||||
|
"""
|
||||||
@@ -18,6 +18,7 @@ DashboardUser = Annotated[dict, Depends(require_permission("dashboard.view"))]
|
|||||||
MapUser = Annotated[dict, Depends(require_permission("locations.view"))]
|
MapUser = Annotated[dict, Depends(require_permission("locations.view"))]
|
||||||
RepairUser = Annotated[dict, Depends(require_permission("repairs.view"))]
|
RepairUser = Annotated[dict, Depends(require_permission("repairs.view"))]
|
||||||
MinerUser = Annotated[dict, Depends(require_permission("miners.view"))]
|
MinerUser = Annotated[dict, Depends(require_permission("miners.view"))]
|
||||||
|
AgentUser = Annotated[dict, Depends(require_permission("agents.view"))]
|
||||||
|
|
||||||
|
|
||||||
def render_page(view: str, current_user: dict, **context) -> str:
|
def render_page(view: str, current_user: dict, **context) -> str:
|
||||||
@@ -92,6 +93,27 @@ def miner_detail_script() -> Response:
|
|||||||
headers={"Cache-Control": "no-store"},
|
headers={"Cache-Control": "no-store"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/assets/minerops-agent-management.js",
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
def agent_management_script() -> Response:
|
||||||
|
return Response(
|
||||||
|
(STATIC_ROOT / "agent-management.js").read_text(encoding="utf-8"),
|
||||||
|
media_type="text/javascript",
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/agent/management",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
def agent_management_page(current_user: AgentUser) -> str:
|
||||||
|
return render_page("agent_management", current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
|
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
|
||||||
def dashboard_page(current_user: DashboardUser) -> str:
|
def dashboard_page(current_user: DashboardUser) -> str:
|
||||||
return render_page("dashboard", current_user)
|
return render_page("dashboard", current_user)
|
||||||
|
|||||||
@@ -0,0 +1,567 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
if (document.body.dataset.view !== "agent_management") return;
|
||||||
|
|
||||||
|
const ids = [
|
||||||
|
"agentManagementLoading",
|
||||||
|
"agentManagementError",
|
||||||
|
"agentManagementErrorText",
|
||||||
|
"agentManagementRetry",
|
||||||
|
"agentManagementContent",
|
||||||
|
"agentManagementRefresh",
|
||||||
|
"agentCount",
|
||||||
|
"agentZoneCount",
|
||||||
|
"agentWaitingEnrollmentCount",
|
||||||
|
"agentRows",
|
||||||
|
"agentEmpty",
|
||||||
|
"agentZoneRows",
|
||||||
|
"agentZoneEmpty",
|
||||||
|
"agentEnrollmentRows",
|
||||||
|
"agentEnrollmentEmpty",
|
||||||
|
"agentCreateForm",
|
||||||
|
"agentSite",
|
||||||
|
"agentZone",
|
||||||
|
"agentDisplayName",
|
||||||
|
"agentPlatform",
|
||||||
|
"agentCreateSubmit",
|
||||||
|
"agentCreateStatus",
|
||||||
|
"agentEnrollmentResult",
|
||||||
|
"agentEnrollmentResultTitle",
|
||||||
|
"agentEnrollmentResultCopy",
|
||||||
|
"agentEnrollmentToken",
|
||||||
|
"agentEnrollmentExpires",
|
||||||
|
"agentEnrollmentDownload",
|
||||||
|
"agentEnrollmentCopy",
|
||||||
|
"agentEnrollmentDismiss",
|
||||||
|
"agentEnrollmentCopyStatus"
|
||||||
|
];
|
||||||
|
|
||||||
|
const el = {};
|
||||||
|
|
||||||
|
ids.forEach(function (id) {
|
||||||
|
el[id] = document.getElementById(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
let registry = {
|
||||||
|
agents: [],
|
||||||
|
sites: [],
|
||||||
|
zones: []
|
||||||
|
};
|
||||||
|
|
||||||
|
let enrollments = [];
|
||||||
|
let packageEndpoint = "/api/agent-enrollments/windows-package";
|
||||||
|
let packageFilename = "MinerOpsAgent-Windows.zip";
|
||||||
|
|
||||||
|
function text(value, fallback) {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return fallback || "Unavailable";
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateTime(value) {
|
||||||
|
if (!value) return "Never";
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.valueOf())) return "Unavailable";
|
||||||
|
return parsed.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function words(value) {
|
||||||
|
return text(value, "Unknown")
|
||||||
|
.replaceAll("_", " ")
|
||||||
|
.replace(/\b\w/g, function (letter) {
|
||||||
|
return letter.toUpperCase();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cell(value, className) {
|
||||||
|
const node = document.createElement("td");
|
||||||
|
if (className) node.className = className;
|
||||||
|
node.textContent = value;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setState(name) {
|
||||||
|
el.agentManagementLoading.hidden = name !== "loading";
|
||||||
|
el.agentManagementError.hidden = name !== "error";
|
||||||
|
el.agentManagementContent.hidden = name !== "ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options) {
|
||||||
|
const response = await fetch(path, Object.assign({
|
||||||
|
credentials: "same-origin",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
}, options || {}));
|
||||||
|
|
||||||
|
const payload = await response.json().catch(function () {
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = payload && payload.detail
|
||||||
|
? String(payload.detail)
|
||||||
|
: "Request failed with status " + response.status + ".";
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgents() {
|
||||||
|
el.agentRows.replaceChildren();
|
||||||
|
|
||||||
|
registry.agents.forEach(function (agent) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
|
||||||
|
const identity = document.createElement("td");
|
||||||
|
const name = document.createElement("strong");
|
||||||
|
const id = document.createElement("span");
|
||||||
|
|
||||||
|
name.textContent = text(agent.display_name, "Unnamed Agent");
|
||||||
|
id.className = "secondary";
|
||||||
|
id.textContent = agent.agent_id;
|
||||||
|
|
||||||
|
identity.append(name, id);
|
||||||
|
|
||||||
|
const location = [
|
||||||
|
text(agent.site_name, "Unknown site"),
|
||||||
|
text(agent.agent_zone_name, "No Agent Zone")
|
||||||
|
].join(" / ");
|
||||||
|
|
||||||
|
const host = [
|
||||||
|
text(agent.hostname, "Unknown host"),
|
||||||
|
text(agent.ip_address, "No IP")
|
||||||
|
].join(" / ");
|
||||||
|
|
||||||
|
row.append(
|
||||||
|
identity,
|
||||||
|
cell(location),
|
||||||
|
cell(host),
|
||||||
|
cell(text(agent.version, "Unknown")),
|
||||||
|
cell(dateTime(agent.last_checkin_at)),
|
||||||
|
cell(words(agent.credential_state)),
|
||||||
|
cell(words(agent.routing_state))
|
||||||
|
);
|
||||||
|
|
||||||
|
const action = document.createElement("td");
|
||||||
|
const canManage =
|
||||||
|
el.agentManagementContent.dataset.canManageAgents === "true";
|
||||||
|
if (
|
||||||
|
canManage &&
|
||||||
|
agent.credential_state === "legacy" &&
|
||||||
|
agent.routing_state === "assigned"
|
||||||
|
) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "quiet-button";
|
||||||
|
button.textContent = "Upgrade credential";
|
||||||
|
button.addEventListener("click", function () {
|
||||||
|
createCredentialTransition(agent);
|
||||||
|
});
|
||||||
|
action.appendChild(button);
|
||||||
|
} else {
|
||||||
|
action.textContent = "—";
|
||||||
|
}
|
||||||
|
row.appendChild(action);
|
||||||
|
|
||||||
|
el.agentRows.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
el.agentEmpty.hidden = registry.agents.length !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderZones() {
|
||||||
|
el.agentZoneRows.replaceChildren();
|
||||||
|
|
||||||
|
registry.zones.forEach(function (zone) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
|
||||||
|
const networks = Array.isArray(zone.networks) && zone.networks.length
|
||||||
|
? zone.networks.join(", ")
|
||||||
|
: "No networks recorded";
|
||||||
|
|
||||||
|
row.append(
|
||||||
|
cell(zone.site_name),
|
||||||
|
cell(zone.name),
|
||||||
|
cell(networks),
|
||||||
|
cell(text(zone.assigned_agent_id, "Unassigned")),
|
||||||
|
cell(zone.is_enabled ? "Enabled" : "Disabled")
|
||||||
|
);
|
||||||
|
|
||||||
|
el.agentZoneRows.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
el.agentZoneEmpty.hidden = registry.zones.length !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEnrollments() {
|
||||||
|
el.agentEnrollmentRows.replaceChildren();
|
||||||
|
|
||||||
|
enrollments.forEach(function (enrollment) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
|
||||||
|
row.append(
|
||||||
|
cell(enrollment.display_name),
|
||||||
|
cell(
|
||||||
|
enrollment.site_name + " / " +
|
||||||
|
enrollment.agent_zone_name
|
||||||
|
),
|
||||||
|
cell(words(enrollment.status)),
|
||||||
|
cell(dateTime(enrollment.created_at)),
|
||||||
|
cell(dateTime(enrollment.expires_at)),
|
||||||
|
cell(text(
|
||||||
|
enrollment.enrolled_agent_id || enrollment.target_agent_id,
|
||||||
|
"Not enrolled"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
el.agentEnrollmentRows.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
el.agentEnrollmentEmpty.hidden = enrollments.length !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateSites() {
|
||||||
|
if (!el.agentSite) return;
|
||||||
|
|
||||||
|
const selected = el.agentSite.value;
|
||||||
|
|
||||||
|
el.agentSite.replaceChildren();
|
||||||
|
|
||||||
|
const placeholder = document.createElement("option");
|
||||||
|
placeholder.value = "";
|
||||||
|
placeholder.textContent = "Choose a site";
|
||||||
|
el.agentSite.appendChild(placeholder);
|
||||||
|
|
||||||
|
registry.sites.forEach(function (site) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = site.id;
|
||||||
|
option.textContent = site.name;
|
||||||
|
el.agentSite.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
if ([...el.agentSite.options].some(function (option) {
|
||||||
|
return option.value === selected;
|
||||||
|
})) {
|
||||||
|
el.agentSite.value = selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
populateZones();
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateZones() {
|
||||||
|
if (!el.agentZone || !el.agentSite) return;
|
||||||
|
|
||||||
|
const siteId = el.agentSite.value;
|
||||||
|
const selected = el.agentZone.value;
|
||||||
|
|
||||||
|
el.agentZone.replaceChildren();
|
||||||
|
|
||||||
|
const placeholder = document.createElement("option");
|
||||||
|
placeholder.value = "";
|
||||||
|
|
||||||
|
if (!siteId) {
|
||||||
|
placeholder.textContent = "Choose a site first";
|
||||||
|
el.agentZone.disabled = true;
|
||||||
|
el.agentZone.appendChild(placeholder);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholder.textContent = "Choose an Agent Zone";
|
||||||
|
el.agentZone.appendChild(placeholder);
|
||||||
|
|
||||||
|
const available = registry.zones.filter(function (zone) {
|
||||||
|
return (
|
||||||
|
zone.site_id === siteId &&
|
||||||
|
zone.is_enabled === true &&
|
||||||
|
!zone.assigned_agent_id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
available.forEach(function (zone) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = zone.id;
|
||||||
|
option.textContent = zone.name;
|
||||||
|
el.agentZone.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
el.agentZone.disabled = available.length === 0;
|
||||||
|
|
||||||
|
if ([...el.agentZone.options].some(function (option) {
|
||||||
|
return option.value === selected;
|
||||||
|
})) {
|
||||||
|
el.agentZone.value = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary() {
|
||||||
|
el.agentCount.textContent = String(registry.agents.length);
|
||||||
|
el.agentZoneCount.textContent = String(registry.zones.length);
|
||||||
|
|
||||||
|
const waiting = enrollments.filter(function (item) {
|
||||||
|
return item.status === "waiting";
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
el.agentWaitingEnrollmentCount.textContent = String(waiting);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
renderSummary();
|
||||||
|
renderAgents();
|
||||||
|
renderZones();
|
||||||
|
renderEnrollments();
|
||||||
|
populateSites();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshData(showLoading) {
|
||||||
|
if (showLoading !== false) setState("loading");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.all([
|
||||||
|
api("/api/agents"),
|
||||||
|
api("/api/agent-enrollments")
|
||||||
|
]);
|
||||||
|
|
||||||
|
registry = results[0] && results[0].data
|
||||||
|
? results[0].data
|
||||||
|
: { agents: [], sites: [], zones: [] };
|
||||||
|
|
||||||
|
enrollments = results[1] && Array.isArray(results[1].data)
|
||||||
|
? results[1].data
|
||||||
|
: [];
|
||||||
|
|
||||||
|
render();
|
||||||
|
setState("ready");
|
||||||
|
} catch (error) {
|
||||||
|
el.agentManagementErrorText.textContent =
|
||||||
|
error.message || "Agent information could not be loaded.";
|
||||||
|
setState("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEnrollment(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!el.agentCreateForm) return;
|
||||||
|
|
||||||
|
el.agentCreateSubmit.disabled = true;
|
||||||
|
el.agentCreateStatus.textContent = "Creating enrollment...";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await api("/api/agent-enrollments", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
site_id: el.agentSite.value,
|
||||||
|
agent_zone_id: el.agentZone.value,
|
||||||
|
display_name: el.agentDisplayName.value.trim()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrollment = payload.data;
|
||||||
|
showOneTimeToken({
|
||||||
|
token: enrollment.enrollment_token,
|
||||||
|
expiresAt: enrollment.expires_at,
|
||||||
|
title: "Enrollment created",
|
||||||
|
copy: "This token creates a new Agent identity. Keep it private. " +
|
||||||
|
"MinerOps does not store the plaintext token.",
|
||||||
|
endpoint: "/api/agent-enrollments/windows-package",
|
||||||
|
filename: "MinerOpsAgent-Windows.zip"
|
||||||
|
});
|
||||||
|
el.agentCreateStatus.textContent = "Enrollment created.";
|
||||||
|
|
||||||
|
el.agentDisplayName.value = "";
|
||||||
|
|
||||||
|
await refreshData(false);
|
||||||
|
} catch (error) {
|
||||||
|
el.agentCreateStatus.textContent =
|
||||||
|
error.message || "Enrollment could not be created.";
|
||||||
|
} finally {
|
||||||
|
el.agentCreateSubmit.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showOneTimeToken(options) {
|
||||||
|
el.agentEnrollmentToken.textContent = options.token;
|
||||||
|
el.agentEnrollmentExpires.textContent =
|
||||||
|
"Expires " + dateTime(options.expiresAt);
|
||||||
|
el.agentEnrollmentResultTitle.textContent = options.title;
|
||||||
|
el.agentEnrollmentResultCopy.textContent = options.copy;
|
||||||
|
el.agentEnrollmentCopyStatus.textContent = "";
|
||||||
|
el.agentEnrollmentResult.hidden = false;
|
||||||
|
packageEndpoint = options.endpoint;
|
||||||
|
packageFilename = options.filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCredentialTransition(agent) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
"Create a one-time credential upgrade for " + agent.agent_id +
|
||||||
|
"? Its Agent identity, Site, and Zone will remain unchanged."
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await api("/api/agent-credential-transitions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({agent_id: agent.agent_id})
|
||||||
|
});
|
||||||
|
const transition = payload.data;
|
||||||
|
showOneTimeToken({
|
||||||
|
token: transition.transition_token,
|
||||||
|
expiresAt: transition.expires_at,
|
||||||
|
title: "Existing Agent credential upgrade created",
|
||||||
|
copy: "This token is bound to the existing Agent identity " +
|
||||||
|
transition.target_agent_id + ". It cannot enroll a new " +
|
||||||
|
"Agent or transfer its Zone.",
|
||||||
|
endpoint: "/api/agent-credential-transitions/windows-package",
|
||||||
|
filename: "MinerOpsAgent-Credential-Upgrade-Windows.zip"
|
||||||
|
});
|
||||||
|
el.agentCreateStatus.textContent =
|
||||||
|
"Credential upgrade created for " + transition.target_agent_id + ".";
|
||||||
|
await refreshData(false);
|
||||||
|
} catch (error) {
|
||||||
|
el.agentCreateStatus.textContent =
|
||||||
|
error.message || "Credential upgrade could not be created.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadWindowsPackage() {
|
||||||
|
const token = el.agentEnrollmentToken.textContent;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
el.agentEnrollmentCopyStatus.textContent =
|
||||||
|
"No enrollment token is available to download.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.agentEnrollmentDownload.disabled = true;
|
||||||
|
el.agentEnrollmentCopyStatus.textContent =
|
||||||
|
"Building Windows Agent package...";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
packageEndpoint,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/zip",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: token
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(function () {
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
payload && payload.detail
|
||||||
|
? String(payload.detail)
|
||||||
|
: "Package request failed with status " +
|
||||||
|
response.status + "."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const downloadUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
|
||||||
|
link.href = downloadUrl;
|
||||||
|
link.download = packageFilename;
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
|
||||||
|
window.URL.revokeObjectURL(downloadUrl);
|
||||||
|
|
||||||
|
dismissToken();
|
||||||
|
|
||||||
|
el.agentCreateStatus.textContent =
|
||||||
|
"Windows Agent package downloaded. " +
|
||||||
|
"The on-screen token has been cleared.";
|
||||||
|
} catch (error) {
|
||||||
|
el.agentEnrollmentCopyStatus.textContent =
|
||||||
|
error.message || "Windows package could not be downloaded.";
|
||||||
|
} finally {
|
||||||
|
el.agentEnrollmentDownload.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToken() {
|
||||||
|
const token = el.agentEnrollmentToken.textContent;
|
||||||
|
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(token);
|
||||||
|
el.agentEnrollmentCopyStatus.textContent =
|
||||||
|
"Enrollment token copied.";
|
||||||
|
} catch (error) {
|
||||||
|
el.agentEnrollmentCopyStatus.textContent =
|
||||||
|
"Copy was unavailable. Select and copy the token manually.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissToken() {
|
||||||
|
el.agentEnrollmentToken.textContent = "";
|
||||||
|
el.agentEnrollmentExpires.textContent = "";
|
||||||
|
el.agentEnrollmentCopyStatus.textContent = "";
|
||||||
|
el.agentEnrollmentResult.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.agentManagementRetry.addEventListener("click", function () {
|
||||||
|
refreshData(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (el.agentManagementRefresh) {
|
||||||
|
el.agentManagementRefresh.addEventListener("click", function () {
|
||||||
|
refreshData(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.agentSite) {
|
||||||
|
el.agentSite.addEventListener("change", populateZones);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.agentCreateForm) {
|
||||||
|
el.agentCreateForm.addEventListener("submit", createEnrollment);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.agentEnrollmentDownload) {
|
||||||
|
el.agentEnrollmentDownload.addEventListener(
|
||||||
|
"click",
|
||||||
|
downloadWindowsPackage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.agentEnrollmentCopy) {
|
||||||
|
el.agentEnrollmentCopy.addEventListener("click", copyToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.agentEnrollmentDismiss) {
|
||||||
|
el.agentEnrollmentDismiss.addEventListener(
|
||||||
|
"click",
|
||||||
|
dismissToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshData(true);
|
||||||
|
}());
|
||||||
@@ -1771,3 +1771,86 @@ button.map-position:hover {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Agent Management v1 */
|
||||||
|
|
||||||
|
.agent-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-summary-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-summary-card span {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-summary-card strong {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-management-columns {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-table td:first-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-create-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-create-form .agent-form-actions,
|
||||||
|
.agent-create-form #agentCreateStatus {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-enrollment-result {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-token-box {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
padding: 14px;
|
||||||
|
margin: 12px 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-token-box code {
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.agent-summary-grid,
|
||||||
|
.agent-create-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent Management action visibility */
|
||||||
|
.agent-management-columns .quiet-button,
|
||||||
|
.agent-enrollment-panel .quiet-button {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ from typing import Callable
|
|||||||
from app.frontend.miner_detail_view import miner_detail_content
|
from app.frontend.miner_detail_view import miner_detail_content
|
||||||
from app.frontend.monitoring_trends_view import monitoring_trends_content
|
from app.frontend.monitoring_trends_view import monitoring_trends_content
|
||||||
from app.frontend.system_health_view import system_health_content
|
from app.frontend.system_health_view import system_health_content
|
||||||
|
from app.frontend.agent_management_view import agent_management_content
|
||||||
|
|
||||||
|
|
||||||
NAVIGATION = (
|
NAVIGATION = (
|
||||||
@@ -15,6 +16,7 @@ NAVIGATION = (
|
|||||||
("repairs", "Repair Center", "/operations/repairs", "repairs.view"),
|
("repairs", "Repair Center", "/operations/repairs", "repairs.view"),
|
||||||
("miners", "Miners", "/assets/miners", "miners.view"),
|
("miners", "Miners", "/assets/miners", "miners.view"),
|
||||||
("system_health", "System Health", "/system-health", "system_health.view"),
|
("system_health", "System Health", "/system-health", "system_health.view"),
|
||||||
|
("agent_management", "Agents", "/agent/management", "agents.view"),
|
||||||
)
|
)
|
||||||
|
|
||||||
PAGE_DETAILS = {
|
PAGE_DETAILS = {
|
||||||
@@ -53,6 +55,11 @@ PAGE_DETAILS = {
|
|||||||
"System Health",
|
"System Health",
|
||||||
"Inspect application services, operational data, and maintenance facts.",
|
"Inspect application services, operational data, and maintenance facts.",
|
||||||
),
|
),
|
||||||
|
"agent_management": (
|
||||||
|
"Administration / Agents",
|
||||||
|
"Agent Management",
|
||||||
|
"Inspect Agent identities, routing boundaries, and secure enrollment.",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -462,7 +469,9 @@ def render_app_page(
|
|||||||
else '<div class="global-search-unavailable" aria-hidden="true"></div>'
|
else '<div class="global-search-unavailable" aria-hidden="true"></div>'
|
||||||
)
|
)
|
||||||
content = (
|
content = (
|
||||||
system_health_content()
|
agent_management_content(has_permission("agents.manage"))
|
||||||
|
if view == "agent_management"
|
||||||
|
else system_health_content()
|
||||||
if view == "system_health"
|
if view == "system_health"
|
||||||
else miner_detail_content()
|
else miner_detail_content()
|
||||||
if view == "miner_detail"
|
if view == "miner_detail"
|
||||||
|
|||||||
@@ -699,8 +699,11 @@ def required_page_permission(path: str) -> str:
|
|||||||
|
|
||||||
def machine_agent_request(path: str) -> bool:
|
def machine_agent_request(path: str) -> bool:
|
||||||
exact = {
|
exact = {
|
||||||
|
"/api/agent/enroll",
|
||||||
|
"/api/agent/credential-transition",
|
||||||
"/api/agent/checkin",
|
"/api/agent/checkin",
|
||||||
"/api/agent/config",
|
"/api/agent/config",
|
||||||
|
"/api/agent/known-miners",
|
||||||
"/api/agent/miner-snapshots",
|
"/api/agent/miner-snapshots",
|
||||||
"/api/agent/pool-snapshots",
|
"/api/agent/pool-snapshots",
|
||||||
"/api/agent/commands/pending",
|
"/api/agent/commands/pending",
|
||||||
@@ -777,6 +780,18 @@ def required_api_permission(method: str, path: str) -> str | None:
|
|||||||
return "repairs.read"
|
return "repairs.read"
|
||||||
if component_request(path):
|
if component_request(path):
|
||||||
return "components.create" if method == "POST" else "components.read"
|
return "components.create" if method == "POST" else "components.read"
|
||||||
|
if path == "/api/agents":
|
||||||
|
return "agents.view"
|
||||||
|
if path == "/api/agent-zones":
|
||||||
|
return "agents.view" if method == "GET" else "agents.manage"
|
||||||
|
if path == "/api/agent-enrollments":
|
||||||
|
return "agents.view" if method == "GET" else "agents.manage"
|
||||||
|
if path == "/api/agent-enrollments/windows-package":
|
||||||
|
return "agents.manage"
|
||||||
|
if path == "/api/agent-credential-transitions":
|
||||||
|
return "agents.manage"
|
||||||
|
if path == "/api/agent-credential-transitions/windows-package":
|
||||||
|
return "agents.manage"
|
||||||
if repair_parts_request(path):
|
if repair_parts_request(path):
|
||||||
return "repairs.read" if method == "GET" else "repairs.parts.manage"
|
return "repairs.read" if method == "GET" else "repairs.parts.manage"
|
||||||
if path.startswith("/api/admin/"):
|
if path.startswith("/api/admin/"):
|
||||||
|
|||||||
+322
-309
File diff suppressed because it is too large.
Load diff
@@ -40,15 +40,20 @@ class MinerControlRepository:
|
|||||||
with connection.cursor(row_factory=dict_row) as cur:
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT miners.id, miners.site_id, host(miners.current_ip) AS current_ip,
|
SELECT miners.id, miners.site_id, miners.agent_zone_id,
|
||||||
|
zones.id IS NOT NULL AS agent_zone_exists,
|
||||||
|
zones.is_enabled AS agent_zone_enabled,
|
||||||
|
host(miners.current_ip) AS current_ip,
|
||||||
miners.status AS runtime_state,
|
miners.status AS runtime_state,
|
||||||
miners.configured_performance_mode,
|
miners.configured_performance_mode,
|
||||||
miners.api3_supported, miners.api3_api_switch,
|
miners.api3_supported, miners.api3_api_switch,
|
||||||
agents.agent_id, agents.last_checkin_at,
|
agents.agent_id, agents.last_checkin_at,
|
||||||
agents.version AS agent_version, agents.last_payload
|
agents.version AS agent_version, agents.last_payload
|
||||||
FROM miners
|
FROM miners
|
||||||
|
LEFT JOIN agent_zones AS zones
|
||||||
|
ON zones.id = miners.agent_zone_id
|
||||||
LEFT JOIN minerops_site_agents AS agents
|
LEFT JOIN minerops_site_agents AS agents
|
||||||
ON agents.site_id = miners.site_id
|
ON agents.agent_zone_id = miners.agent_zone_id
|
||||||
WHERE miners.id = %s
|
WHERE miners.id = %s
|
||||||
""",
|
""",
|
||||||
(miner_id,),
|
(miner_id,),
|
||||||
|
|||||||
@@ -29,10 +29,14 @@ class MinerControlService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _capability(context: dict) -> ControlCapability:
|
def _capability(context: dict) -> ControlCapability:
|
||||||
if not context.get("site_id"):
|
if not context.get("agent_zone_id"):
|
||||||
return ControlCapability(state="unavailable", reason="site_unassigned")
|
return ControlCapability(state="unavailable", reason="agent_zone_unassigned")
|
||||||
|
if context.get("agent_zone_exists") is not True:
|
||||||
|
return ControlCapability(state="unavailable", reason="agent_zone_missing")
|
||||||
|
if context.get("agent_zone_enabled") is not True:
|
||||||
|
return ControlCapability(state="unavailable", reason="agent_zone_disabled")
|
||||||
if not context.get("agent_id"):
|
if not context.get("agent_id"):
|
||||||
return ControlCapability(state="unavailable", reason="agent_unassigned")
|
return ControlCapability(state="unavailable", reason="agent_unavailable")
|
||||||
if not context.get("current_ip"):
|
if not context.get("current_ip"):
|
||||||
return ControlCapability(state="unavailable", reason="current_ip_missing")
|
return ControlCapability(state="unavailable", reason="current_ip_missing")
|
||||||
if context.get("api3_supported") is False:
|
if context.get("api3_supported") is False:
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""Focused tests for per-Agent authentication and legacy compatibility."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.agent_auth.exceptions import (
|
||||||
|
AgentAuthenticationFailed,
|
||||||
|
AgentAuthenticationUnavailable,
|
||||||
|
AgentCredentialRevoked,
|
||||||
|
AgentIdentityUnauthorized,
|
||||||
|
)
|
||||||
|
from app.agent_auth.service import AgentAuthService, hash_agent_secret
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 26, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAgentAuthRepository:
|
||||||
|
def __init__(self, rows=None):
|
||||||
|
self.rows = rows or {}
|
||||||
|
|
||||||
|
def get_credential(self, agent_id):
|
||||||
|
return self.rows.get(agent_id)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAuthServiceTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.old_id = settings.minerops_agent_id
|
||||||
|
self.old_secret = settings.minerops_agent_shared_secret
|
||||||
|
settings.minerops_agent_id = "legacy-agent"
|
||||||
|
settings.minerops_agent_shared_secret = "legacy-secret"
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
settings.minerops_agent_id = self.old_id
|
||||||
|
settings.minerops_agent_shared_secret = self.old_secret
|
||||||
|
|
||||||
|
def service(self, rows=None):
|
||||||
|
return AgentAuthService(FakeAgentAuthRepository(rows))
|
||||||
|
|
||||||
|
def test_high_entropy_secret_hash_is_sha256_hex(self):
|
||||||
|
digest = hash_agent_secret("example-secret")
|
||||||
|
self.assertEqual(len(digest), 64)
|
||||||
|
self.assertRegex(digest, r"^[0-9a-f]{64}$")
|
||||||
|
|
||||||
|
def test_legacy_agent_without_registry_row_still_authenticates(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.service().authenticate("legacy-agent", "legacy-secret"),
|
||||||
|
"legacy-agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_secret_only_legacy_agent_maps_to_configured_identity(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.service().authenticate(None, "legacy-secret"),
|
||||||
|
"legacy-agent",
|
||||||
|
)
|
||||||
|
with self.assertRaises(AgentAuthenticationFailed):
|
||||||
|
self.service().authenticate(None, "wrong-secret")
|
||||||
|
|
||||||
|
def test_legacy_agent_with_null_credential_still_authenticates(self):
|
||||||
|
rows = {
|
||||||
|
"legacy-agent": {
|
||||||
|
"agent_id": "legacy-agent",
|
||||||
|
"agent_secret_hash": None,
|
||||||
|
"credential_created_at": None,
|
||||||
|
"credential_revoked_at": None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
self.service(rows).authenticate("legacy-agent", "legacy-secret"),
|
||||||
|
"legacy-agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_per_agent_credential_authenticates_independently(self):
|
||||||
|
rows = {
|
||||||
|
"agent-new": {
|
||||||
|
"agent_id": "agent-new",
|
||||||
|
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||||
|
"credential_created_at": NOW,
|
||||||
|
"credential_revoked_at": None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
self.service(rows).authenticate("agent-new", "unique-secret"),
|
||||||
|
"agent-new",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_legacy_secret_never_authenticates_per_agent_identity(self):
|
||||||
|
settings.minerops_agent_id = "agent-new"
|
||||||
|
rows = {
|
||||||
|
"agent-new": {
|
||||||
|
"agent_id": "agent-new",
|
||||||
|
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||||
|
"credential_created_at": NOW,
|
||||||
|
"credential_revoked_at": None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with self.assertRaises(AgentAuthenticationFailed):
|
||||||
|
self.service(rows).authenticate("agent-new", "legacy-secret")
|
||||||
|
|
||||||
|
def test_wrong_per_agent_secret_is_rejected(self):
|
||||||
|
rows = {
|
||||||
|
"agent-new": {
|
||||||
|
"agent_id": "agent-new",
|
||||||
|
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||||
|
"credential_created_at": NOW,
|
||||||
|
"credential_revoked_at": None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with self.assertRaises(AgentAuthenticationFailed):
|
||||||
|
self.service(rows).authenticate("agent-new", "wrong-secret")
|
||||||
|
|
||||||
|
def test_revoked_per_agent_credential_never_falls_back(self):
|
||||||
|
settings.minerops_agent_id = "agent-new"
|
||||||
|
rows = {
|
||||||
|
"agent-new": {
|
||||||
|
"agent_id": "agent-new",
|
||||||
|
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||||
|
"credential_created_at": NOW,
|
||||||
|
"credential_revoked_at": NOW,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for secret in ("unique-secret", "legacy-secret"):
|
||||||
|
with self.subTest(secret=secret), self.assertRaises(AgentCredentialRevoked):
|
||||||
|
self.service(rows).authenticate("agent-new", secret)
|
||||||
|
|
||||||
|
def test_revoked_registry_row_never_falls_back_even_without_hash(self):
|
||||||
|
rows = {
|
||||||
|
"legacy-agent": {
|
||||||
|
"agent_id": "legacy-agent",
|
||||||
|
"agent_secret_hash": None,
|
||||||
|
"credential_created_at": None,
|
||||||
|
"credential_revoked_at": NOW,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for agent_id in ("legacy-agent", None):
|
||||||
|
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||||
|
AgentCredentialRevoked
|
||||||
|
):
|
||||||
|
self.service(rows).authenticate(agent_id, "legacy-secret")
|
||||||
|
|
||||||
|
def test_missing_identity_never_authenticates_enrolled_legacy_agent(self):
|
||||||
|
rows = {
|
||||||
|
"legacy-agent": {
|
||||||
|
"agent_id": "legacy-agent",
|
||||||
|
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||||
|
"credential_created_at": NOW,
|
||||||
|
"credential_revoked_at": None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for secret in ("unique-secret", "legacy-secret"):
|
||||||
|
with self.subTest(secret=secret), self.assertRaises(AgentAuthenticationFailed):
|
||||||
|
self.service(rows).authenticate(None, secret)
|
||||||
|
|
||||||
|
def test_missing_identity_reports_revoked_legacy_credential(self):
|
||||||
|
rows = {
|
||||||
|
"legacy-agent": {
|
||||||
|
"agent_id": "legacy-agent",
|
||||||
|
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||||
|
"credential_created_at": NOW,
|
||||||
|
"credential_revoked_at": NOW,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with self.assertRaises(AgentCredentialRevoked):
|
||||||
|
self.service(rows).authenticate(None, "legacy-secret")
|
||||||
|
|
||||||
|
def test_durable_retirement_rejects_fallback_after_credentials_are_cleared(self):
|
||||||
|
rows = {
|
||||||
|
"legacy-agent": {
|
||||||
|
"agent_id": "legacy-agent",
|
||||||
|
"agent_secret_hash": None,
|
||||||
|
"credential_created_at": None,
|
||||||
|
"credential_revoked_at": None,
|
||||||
|
"legacy_auth_disabled_at": NOW,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for agent_id in ("legacy-agent", None):
|
||||||
|
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||||
|
AgentAuthenticationFailed
|
||||||
|
):
|
||||||
|
self.service(rows).authenticate(agent_id, "legacy-secret")
|
||||||
|
|
||||||
|
def test_identity_retirement_rejects_fallback_without_current_agent_row(self):
|
||||||
|
rows = {
|
||||||
|
"legacy-agent": {
|
||||||
|
"agent_id": "legacy-agent",
|
||||||
|
"agent_secret_hash": None,
|
||||||
|
"credential_created_at": None,
|
||||||
|
"credential_revoked_at": None,
|
||||||
|
"legacy_auth_disabled_at": NOW,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for agent_id in ("legacy-agent", None):
|
||||||
|
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||||
|
AgentAuthenticationFailed
|
||||||
|
):
|
||||||
|
self.service(rows).authenticate(agent_id, "legacy-secret")
|
||||||
|
|
||||||
|
def test_unknown_identity_does_not_use_shared_secret(self):
|
||||||
|
with self.assertRaises(AgentIdentityUnauthorized):
|
||||||
|
self.service().authenticate("unknown-agent", "legacy-secret")
|
||||||
|
|
||||||
|
def test_missing_secret_is_rejected(self):
|
||||||
|
with self.assertRaises(AgentAuthenticationFailed):
|
||||||
|
self.service().authenticate("legacy-agent", None)
|
||||||
|
|
||||||
|
def test_legacy_path_reports_unavailable_if_not_configured(self):
|
||||||
|
settings.minerops_agent_shared_secret = ""
|
||||||
|
with self.assertRaises(AgentAuthenticationUnavailable):
|
||||||
|
self.service().authenticate("legacy-agent", "anything")
|
||||||
|
|
||||||
|
def test_secret_only_path_requires_configured_legacy_identity(self):
|
||||||
|
settings.minerops_agent_id = ""
|
||||||
|
with self.assertRaises(AgentAuthenticationUnavailable):
|
||||||
|
self.service().authenticate(None, "legacy-secret")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -7,7 +7,11 @@ from pathlib import Path
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.agent_commands.dependencies import get_agent_command_service
|
from app.agent_auth.service import AgentAuthService
|
||||||
|
from app.agent_commands.dependencies import (
|
||||||
|
get_agent_auth_service,
|
||||||
|
get_agent_command_service,
|
||||||
|
)
|
||||||
from app.agent_commands.exceptions import (
|
from app.agent_commands.exceptions import (
|
||||||
AgentCommandConflict,
|
AgentCommandConflict,
|
||||||
AgentResultValidationError,
|
AgentResultValidationError,
|
||||||
@@ -123,6 +127,11 @@ class AgentCommandServiceTests(unittest.TestCase):
|
|||||||
AgentCommandService(FakeRepository()).submit_result("test-agent", request)
|
AgentCommandService(FakeRepository()).submit_result("test-agent", request)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAgentAuthRepository:
|
||||||
|
def get_credential(self, agent_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class AgentCommandRouterTests(unittest.TestCase):
|
class AgentCommandRouterTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.app = FastAPI()
|
self.app = FastAPI()
|
||||||
@@ -131,6 +140,9 @@ class AgentCommandRouterTests(unittest.TestCase):
|
|||||||
self.app.dependency_overrides[get_agent_command_service] = lambda: AgentCommandService(
|
self.app.dependency_overrides[get_agent_command_service] = lambda: AgentCommandService(
|
||||||
self.repository
|
self.repository
|
||||||
)
|
)
|
||||||
|
self.app.dependency_overrides[get_agent_auth_service] = lambda: AgentAuthService(
|
||||||
|
FakeAgentAuthRepository()
|
||||||
|
)
|
||||||
self.client = TestClient(self.app)
|
self.client = TestClient(self.app)
|
||||||
self.old_id = settings.minerops_agent_id
|
self.old_id = settings.minerops_agent_id
|
||||||
self.old_secret = settings.minerops_agent_shared_secret
|
self.old_secret = settings.minerops_agent_shared_secret
|
||||||
@@ -199,6 +211,8 @@ class AgentSourceContractTests(unittest.TestCase):
|
|||||||
self.assertNotIn('DefaultValue "super"', service)
|
self.assertNotIn('DefaultValue "super"', service)
|
||||||
self.assertNotIn('DefaultValue "super"', powershell)
|
self.assertNotIn('DefaultValue "super"', powershell)
|
||||||
self.assertIn('"X-MinerOps-Agent-ID"', service)
|
self.assertIn('"X-MinerOps-Agent-ID"', service)
|
||||||
|
self.assertIn('.agent_zone_id', service)
|
||||||
|
self.assertIn('"agent_zone_id"', (root / "config.example.json").read_text())
|
||||||
self.assertNotIn("?agent_id=", service)
|
self.assertNotIn("?agent_id=", service)
|
||||||
self.assertIn('"get.miner.setting"', control)
|
self.assertIn('"get.miner.setting"', control)
|
||||||
self.assertIn("$attempt -le 3", control)
|
self.assertIn("$attempt -le 3", control)
|
||||||
@@ -233,3 +247,122 @@ class AgentSourceContractTests(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.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)
|
||||||
|
|
||||||
|
def test_windows_machine_pollers_send_agent_identity(self):
|
||||||
|
root = agent_source_root()
|
||||||
|
if root is None:
|
||||||
|
self.skipTest("Windows Agent source is not mounted")
|
||||||
|
|
||||||
|
for filename in (
|
||||||
|
"MinerOpsPoolPoller.ps1",
|
||||||
|
"MinerOpsApi3EnrichmentPoller.ps1",
|
||||||
|
"EnrichOneMiner.ps1",
|
||||||
|
):
|
||||||
|
with self.subTest(filename=filename):
|
||||||
|
source = (root / filename).read_text()
|
||||||
|
self.assertIn("X-MinerOps-Agent-ID", source)
|
||||||
|
self.assertIn("X-MinerOps-Agent-Secret", source)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def test_legacy_config_remains_explicitly_legacy_only(self):
|
||||||
|
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||||
|
|
||||||
|
start = main.index('@app.get("/api/agent/config")')
|
||||||
|
end = main.index("\n@app.", start + 1)
|
||||||
|
route = main[start:end]
|
||||||
|
|
||||||
|
self.assertIn("minerops_require_agent_secret(request)", route)
|
||||||
|
self.assertIn('"site_name": "Plexus"', route)
|
||||||
|
self.assertNotIn("Depends(require_agent_identity)", route)
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
"""PostgreSQL security contract for existing-Agent credential transition."""
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
from app.agent_auth.exceptions import AgentAuthenticationFailed
|
||||||
|
from app.agent_auth.repository import AgentAuthRepository
|
||||||
|
from app.agent_auth.service import AgentAuthService, hash_agent_secret
|
||||||
|
from app.agent_enrollment.exceptions import (
|
||||||
|
AgentEnrollmentConflict,
|
||||||
|
AgentEnrollmentCredentialCollision,
|
||||||
|
AgentEnrollmentTokenInvalid,
|
||||||
|
)
|
||||||
|
from app.agent_enrollment.models import (
|
||||||
|
AgentCredentialTransitionCreateRequest,
|
||||||
|
AgentCredentialTransitionRedeemRequest,
|
||||||
|
AgentEnrollmentCreateRequest,
|
||||||
|
AgentEnrollRequest,
|
||||||
|
)
|
||||||
|
from app.agent_enrollment.repository import (
|
||||||
|
AgentEnrollmentRepository,
|
||||||
|
default_connection_factory,
|
||||||
|
)
|
||||||
|
from app.agent_enrollment.service import AgentEnrollmentService
|
||||||
|
from app.agent_registry.repository import AgentRegistryRepository
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
PRODUCTION_AGENT_ID = "plexus-windows-agent"
|
||||||
|
PRODUCTION_SITE_ID = UUID("00000000-0000-4000-8000-000000002001")
|
||||||
|
PRODUCTION_ZONE_ID = UUID("00000000-0000-4000-8000-000000002002")
|
||||||
|
PRODUCTION_MINER_ID = UUID("00000000-0000-4000-8000-000000002003")
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
return default_connection_factory()
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCredentialTransitionPostgresTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
if os.environ.get("MINEROPS_CREDENTIAL_TRANSITION_DISPOSABLE") != "1":
|
||||||
|
raise unittest.SkipTest(
|
||||||
|
"credential transition requires its disposable PostgreSQL runner"
|
||||||
|
)
|
||||||
|
if settings.minerops_agent_id != PRODUCTION_AGENT_ID:
|
||||||
|
raise AssertionError(
|
||||||
|
"runner must bind the production-shaped legacy Agent identity"
|
||||||
|
)
|
||||||
|
cls.service = AgentEnrollmentService(AgentEnrollmentRepository(connect))
|
||||||
|
cls.auth = AgentAuthService(AgentAuthRepository(connect))
|
||||||
|
|
||||||
|
def create_legacy_agent(
|
||||||
|
self,
|
||||||
|
agent_id,
|
||||||
|
*,
|
||||||
|
site_id=None,
|
||||||
|
zone_id=None,
|
||||||
|
enabled=True,
|
||||||
|
secret_hash=None,
|
||||||
|
revoked=False,
|
||||||
|
):
|
||||||
|
site_id = site_id or uuid4()
|
||||||
|
zone_id = zone_id or uuid4()
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO sites (id, name) VALUES (%s, %s)",
|
||||||
|
(site_id, f"Transition Site {site_id.hex[:8]}"),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zones (id, site_id, name, is_enabled)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(zone_id, site_id, "Default Zone", enabled),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||||
|
VALUES (%s, '10.220.0.0/24'::cidr)
|
||||||
|
""",
|
||||||
|
(zone_id,),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id, site_id, agent_zone_id, site_name,
|
||||||
|
display_name, agent_secret_hash,
|
||||||
|
credential_created_at, credential_revoked_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s, %s, %s, %s, %s, %s,
|
||||||
|
CASE WHEN %s::boolean THEN now() ELSE NULL END,
|
||||||
|
CASE WHEN %s::boolean THEN now() ELSE NULL END
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
agent_id,
|
||||||
|
site_id,
|
||||||
|
zone_id,
|
||||||
|
f"Transition Site {site_id.hex[:8]}",
|
||||||
|
f"Agent {agent_id}"[:120],
|
||||||
|
secret_hash,
|
||||||
|
secret_hash is not None,
|
||||||
|
revoked,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
return site_id, zone_id
|
||||||
|
|
||||||
|
def create_transition(self, agent_id):
|
||||||
|
return self.service.create_credential_transition(
|
||||||
|
AgentCredentialTransitionCreateRequest(agent_id=agent_id),
|
||||||
|
actor_user_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def redeem_transition(self, token, secret):
|
||||||
|
return AgentEnrollmentService(
|
||||||
|
AgentEnrollmentRepository(connect)
|
||||||
|
).redeem_credential_transition(
|
||||||
|
AgentCredentialTransitionRedeemRequest(
|
||||||
|
token=token,
|
||||||
|
agent_secret_hash=hash_agent_secret(secret),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def agent_row(self, agent_id):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT agent_id, site_id, agent_zone_id, agent_secret_hash,
|
||||||
|
credential_created_at, credential_revoked_at,
|
||||||
|
legacy_auth_disabled_at
|
||||||
|
FROM minerops_site_agents
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
(agent_id,),
|
||||||
|
)
|
||||||
|
return cur.fetchone()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def test_01_production_identity_transitions_without_routing_change(self):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sites (id, name)
|
||||||
|
VALUES (%s, 'Plexus Mining Site')
|
||||||
|
""",
|
||||||
|
(PRODUCTION_SITE_ID,),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zones (id, site_id, name, is_enabled)
|
||||||
|
VALUES (%s, %s, 'Default Zone', true)
|
||||||
|
""",
|
||||||
|
(PRODUCTION_ZONE_ID, PRODUCTION_SITE_ID),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||||
|
VALUES (%s, '192.0.2.0/24'::cidr)
|
||||||
|
""",
|
||||||
|
(PRODUCTION_ZONE_ID,),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id, site_id, agent_zone_id, site_name, display_name
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, 'Plexus', 'Plexus Windows Agent')
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
PRODUCTION_AGENT_ID,
|
||||||
|
PRODUCTION_SITE_ID,
|
||||||
|
PRODUCTION_ZONE_ID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO miners (
|
||||||
|
id, site_id, agent_zone_id, asset_tag,
|
||||||
|
current_ip, current_mac, status
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s, %s, %s, 'TRANSITION-PRODUCTION-MINER',
|
||||||
|
'192.0.2.20', '02:00:00:00:20:03', 'online'
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
PRODUCTION_MINER_ID,
|
||||||
|
PRODUCTION_SITE_ID,
|
||||||
|
PRODUCTION_ZONE_ID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
legacy_secret = settings.minerops_agent_shared_secret
|
||||||
|
self.assertEqual(
|
||||||
|
self.auth.authenticate(None, legacy_secret),
|
||||||
|
PRODUCTION_AGENT_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
transition = self.create_transition(PRODUCTION_AGENT_ID)
|
||||||
|
token = transition["transition_token"]
|
||||||
|
self.assertEqual(transition["target_agent_id"], PRODUCTION_AGENT_ID)
|
||||||
|
self.assertEqual(transition["site_id"], PRODUCTION_SITE_ID)
|
||||||
|
self.assertEqual(transition["agent_zone_id"], PRODUCTION_ZONE_ID)
|
||||||
|
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT token_hash, target_agent_id, used_at,
|
||||||
|
enrolled_agent_id, site_id, agent_zone_id
|
||||||
|
FROM agent_enrollments WHERE id = %s
|
||||||
|
""",
|
||||||
|
(transition["id"],),
|
||||||
|
)
|
||||||
|
authorization = cur.fetchone()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
self.assertEqual(authorization["token_hash"], hash_agent_secret(token))
|
||||||
|
self.assertNotEqual(authorization["token_hash"], token)
|
||||||
|
self.assertEqual(authorization["target_agent_id"], PRODUCTION_AGENT_ID)
|
||||||
|
self.assertIsNone(authorization["used_at"])
|
||||||
|
|
||||||
|
new_secret = "production-shaped-per-agent-secret"
|
||||||
|
result = self.redeem_transition(token, new_secret)
|
||||||
|
self.assertEqual(result["agent_id"], PRODUCTION_AGENT_ID)
|
||||||
|
self.assertNotIn("agent_secret", result)
|
||||||
|
self.assertEqual(result["site_id"], PRODUCTION_SITE_ID)
|
||||||
|
self.assertEqual(result["agent_zone_id"], PRODUCTION_ZONE_ID)
|
||||||
|
|
||||||
|
# An ambiguous response is safely retryable only with the same locally
|
||||||
|
# generated credential; no second transition or audit event is created.
|
||||||
|
retried = self.redeem_transition(token, new_secret)
|
||||||
|
self.assertEqual(retried, result)
|
||||||
|
with self.assertRaises(AgentEnrollmentTokenInvalid):
|
||||||
|
self.redeem_transition(token, "different-secret-cannot-rotate")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self.auth.authenticate(PRODUCTION_AGENT_ID, new_secret),
|
||||||
|
PRODUCTION_AGENT_ID,
|
||||||
|
)
|
||||||
|
for supplied_id in (None, PRODUCTION_AGENT_ID):
|
||||||
|
with self.subTest(supplied_id=supplied_id), self.assertRaises(
|
||||||
|
AgentAuthenticationFailed
|
||||||
|
):
|
||||||
|
self.auth.authenticate(supplied_id, legacy_secret)
|
||||||
|
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT site_id, agent_zone_id, agent_secret_hash,
|
||||||
|
credential_created_at, legacy_auth_disabled_at
|
||||||
|
FROM minerops_site_agents WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
(PRODUCTION_AGENT_ID,),
|
||||||
|
)
|
||||||
|
agent = cur.fetchone()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT site_id, agent_zone_id, current_mac::text,
|
||||||
|
host(current_ip) AS current_ip
|
||||||
|
FROM miners WHERE id = %s
|
||||||
|
""",
|
||||||
|
(PRODUCTION_MINER_ID,),
|
||||||
|
)
|
||||||
|
miner = cur.fetchone()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT count(*) AS retirement_count
|
||||||
|
FROM agent_legacy_auth_retirements
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
(PRODUCTION_AGENT_ID,),
|
||||||
|
)
|
||||||
|
retirement_count = cur.fetchone()["retirement_count"]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT count(*) AS redeemed_audits
|
||||||
|
FROM audit_logs
|
||||||
|
WHERE action = 'agent.credential_transition.redeemed'
|
||||||
|
AND entity_id = %s
|
||||||
|
""",
|
||||||
|
(transition["id"],),
|
||||||
|
)
|
||||||
|
redeemed_audits = cur.fetchone()["redeemed_audits"]
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
self.assertEqual(agent["site_id"], PRODUCTION_SITE_ID)
|
||||||
|
self.assertEqual(agent["agent_zone_id"], PRODUCTION_ZONE_ID)
|
||||||
|
self.assertEqual(agent["agent_secret_hash"], hash_agent_secret(new_secret))
|
||||||
|
self.assertIsNotNone(agent["credential_created_at"])
|
||||||
|
self.assertIsNotNone(agent["legacy_auth_disabled_at"])
|
||||||
|
self.assertEqual(miner["site_id"], PRODUCTION_SITE_ID)
|
||||||
|
self.assertEqual(miner["agent_zone_id"], PRODUCTION_ZONE_ID)
|
||||||
|
self.assertEqual(miner["current_mac"], "02:00:00:00:20:03")
|
||||||
|
self.assertEqual(miner["current_ip"], "192.0.2.20")
|
||||||
|
self.assertEqual(retirement_count, 1)
|
||||||
|
self.assertEqual(redeemed_audits, 1)
|
||||||
|
|
||||||
|
# Remove deletable enrollment/current state to prove that the separate
|
||||||
|
# append-only retirement identity still defeats fallback on recreation.
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM agent_enrollments WHERE id = %s",
|
||||||
|
(transition["id"],),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||||
|
(PRODUCTION_AGENT_ID,),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id, site_id, agent_zone_id, site_name
|
||||||
|
) VALUES (%s, %s, %s, 'Recreated Plexus')
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
PRODUCTION_AGENT_ID,
|
||||||
|
PRODUCTION_SITE_ID,
|
||||||
|
PRODUCTION_ZONE_ID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
recreated = self.agent_row(PRODUCTION_AGENT_ID)
|
||||||
|
self.assertIsNone(recreated["agent_secret_hash"])
|
||||||
|
self.assertIsNotNone(recreated["legacy_auth_disabled_at"])
|
||||||
|
with self.assertRaises(AgentAuthenticationFailed):
|
||||||
|
self.auth.authenticate(PRODUCTION_AGENT_ID, legacy_secret)
|
||||||
|
|
||||||
|
def test_02_concurrent_redemption_has_one_credential_winner(self):
|
||||||
|
agent_id = "transition-concurrent-agent"
|
||||||
|
self.create_legacy_agent(agent_id)
|
||||||
|
token = self.create_transition(agent_id)["transition_token"]
|
||||||
|
secrets = ("concurrent-secret-a", "concurrent-secret-b")
|
||||||
|
|
||||||
|
def redeem(secret):
|
||||||
|
try:
|
||||||
|
return ("success", secret, self.redeem_transition(token, secret))
|
||||||
|
except AgentEnrollmentTokenInvalid:
|
||||||
|
return ("invalid", secret, None)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
results = list(executor.map(redeem, secrets))
|
||||||
|
|
||||||
|
winners = [item for item in results if item[0] == "success"]
|
||||||
|
losers = [item for item in results if item[0] == "invalid"]
|
||||||
|
self.assertEqual(len(winners), 1)
|
||||||
|
self.assertEqual(len(losers), 1)
|
||||||
|
row = self.agent_row(agent_id)
|
||||||
|
self.assertEqual(row["agent_secret_hash"], hash_agent_secret(winners[0][1]))
|
||||||
|
|
||||||
|
def test_03_authorization_cannot_substitute_identity_or_steal_zone(self):
|
||||||
|
agent_a = "transition-bound-agent-a"
|
||||||
|
agent_b = "transition-bound-agent-b"
|
||||||
|
site_a, zone_a = self.create_legacy_agent(agent_a)
|
||||||
|
site_b, zone_b = self.create_legacy_agent(agent_b)
|
||||||
|
token = self.create_transition(agent_a)["transition_token"]
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
AgentCredentialTransitionRedeemRequest.model_validate(
|
||||||
|
{
|
||||||
|
"token": token,
|
||||||
|
"agent_secret_hash": hash_agent_secret("bound-secret"),
|
||||||
|
"agent_id": agent_b,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.redeem_transition(token, "bound-secret")
|
||||||
|
self.assertEqual(result["agent_id"], agent_a)
|
||||||
|
self.assertEqual((result["site_id"], result["agent_zone_id"]), (site_a, zone_a))
|
||||||
|
other = self.agent_row(agent_b)
|
||||||
|
self.assertEqual((other["site_id"], other["agent_zone_id"]), (site_b, zone_b))
|
||||||
|
self.assertIsNone(other["agent_secret_hash"])
|
||||||
|
|
||||||
|
def test_04_revoked_retired_and_disabled_targets_fail_closed(self):
|
||||||
|
revoked = "transition-revoked-agent"
|
||||||
|
self.create_legacy_agent(
|
||||||
|
revoked,
|
||||||
|
secret_hash=hash_agent_secret("revoked-secret"),
|
||||||
|
revoked=True,
|
||||||
|
)
|
||||||
|
with self.assertRaises(AgentEnrollmentConflict):
|
||||||
|
self.create_transition(revoked)
|
||||||
|
|
||||||
|
retired = "transition-retired-agent"
|
||||||
|
self.create_legacy_agent(retired)
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO agent_legacy_auth_retirements (agent_id) VALUES (%s)",
|
||||||
|
(retired,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
with self.assertRaises(AgentEnrollmentConflict):
|
||||||
|
self.create_transition(retired)
|
||||||
|
|
||||||
|
disabled = "transition-disabled-agent"
|
||||||
|
self.create_legacy_agent(disabled, enabled=False)
|
||||||
|
with self.assertRaises(AgentEnrollmentConflict):
|
||||||
|
self.create_transition(disabled)
|
||||||
|
|
||||||
|
changed = "transition-zone-disabled-after-authorization"
|
||||||
|
_, changed_zone = self.create_legacy_agent(changed)
|
||||||
|
authorization = self.create_transition(changed)
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE agent_zones SET is_enabled = false WHERE id = %s",
|
||||||
|
(changed_zone,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
with self.assertRaises(AgentEnrollmentConflict):
|
||||||
|
self.redeem_transition(
|
||||||
|
authorization["transition_token"], "disabled-zone-secret"
|
||||||
|
)
|
||||||
|
self.assertIsNone(self.agent_row(changed)["agent_secret_hash"])
|
||||||
|
|
||||||
|
def test_05_credential_collision_rolls_back_retirement_and_token_use(self):
|
||||||
|
collision_hash = hash_agent_secret("already-issued-secret")
|
||||||
|
self.create_legacy_agent(
|
||||||
|
"transition-existing-credential-holder",
|
||||||
|
secret_hash=collision_hash,
|
||||||
|
)
|
||||||
|
target = "transition-rollback-target"
|
||||||
|
self.create_legacy_agent(target)
|
||||||
|
transition = self.create_transition(target)
|
||||||
|
|
||||||
|
with self.assertRaises(AgentEnrollmentCredentialCollision):
|
||||||
|
self.service.redeem_credential_transition(
|
||||||
|
AgentCredentialTransitionRedeemRequest(
|
||||||
|
token=transition["transition_token"],
|
||||||
|
agent_secret_hash=collision_hash,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
row = self.agent_row(target)
|
||||||
|
self.assertIsNone(row["agent_secret_hash"])
|
||||||
|
self.assertIsNone(row["credential_created_at"])
|
||||||
|
self.assertIsNone(row["legacy_auth_disabled_at"])
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT count(*) FROM agent_legacy_auth_retirements
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
(target,),
|
||||||
|
)
|
||||||
|
self.assertEqual(cur.fetchone()[0], 0)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT used_at, enrolled_agent_id
|
||||||
|
FROM agent_enrollments WHERE id = %s
|
||||||
|
""",
|
||||||
|
(transition["id"],),
|
||||||
|
)
|
||||||
|
self.assertEqual(cur.fetchone(), (None, None))
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def test_06_normal_enrollment_still_works_and_occupied_zone_is_protected(self):
|
||||||
|
site_id, occupied_zone = self.create_legacy_agent(
|
||||||
|
"transition-ordinary-occupied"
|
||||||
|
)
|
||||||
|
with self.assertRaises(AgentEnrollmentConflict):
|
||||||
|
self.service.create_enrollment(
|
||||||
|
AgentEnrollmentCreateRequest(
|
||||||
|
site_id=site_id,
|
||||||
|
agent_zone_id=occupied_zone,
|
||||||
|
display_name="Must Not Steal Occupied Zone",
|
||||||
|
),
|
||||||
|
actor_user_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
free_zone = uuid4()
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zones (id, site_id, name, is_enabled)
|
||||||
|
VALUES (%s, %s, 'Free Enrollment Zone', true)
|
||||||
|
""",
|
||||||
|
(free_zone, site_id),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
enrollment = self.service.create_enrollment(
|
||||||
|
AgentEnrollmentCreateRequest(
|
||||||
|
site_id=site_id,
|
||||||
|
agent_zone_id=free_zone,
|
||||||
|
display_name="Ordinary New Agent",
|
||||||
|
),
|
||||||
|
actor_user_id=None,
|
||||||
|
)
|
||||||
|
enrolled = self.service.redeem(
|
||||||
|
AgentEnrollRequest(token=enrollment["enrollment_token"])
|
||||||
|
)
|
||||||
|
self.assertRegex(enrolled["agent_id"], r"^agent-[0-9a-f]{32}$")
|
||||||
|
self.assertEqual(enrolled["agent_zone_id"], free_zone)
|
||||||
|
|
||||||
|
def test_07_registry_history_and_audit_never_expose_plaintext(self):
|
||||||
|
agent_id = "transition-privacy-agent"
|
||||||
|
self.create_legacy_agent(agent_id)
|
||||||
|
transition = self.create_transition(agent_id)
|
||||||
|
token = transition["transition_token"]
|
||||||
|
|
||||||
|
listed = self.service.list_enrollments()
|
||||||
|
item = next(row for row in listed if row["id"] == transition["id"])
|
||||||
|
self.assertEqual(item["target_agent_id"], agent_id)
|
||||||
|
self.assertEqual(item["purpose"], "existing_agent_credential_transition")
|
||||||
|
serialized_item = repr(item)
|
||||||
|
self.assertNotIn(token, serialized_item)
|
||||||
|
for forbidden in (
|
||||||
|
"token_hash",
|
||||||
|
"transition_token",
|
||||||
|
"agent_secret",
|
||||||
|
"agent_secret_hash",
|
||||||
|
):
|
||||||
|
self.assertNotIn(forbidden, item)
|
||||||
|
|
||||||
|
registry = AgentRegistryRepository().list_registry()
|
||||||
|
registry_agent = next(
|
||||||
|
row for row in registry["agents"] if row["agent_id"] == agent_id
|
||||||
|
)
|
||||||
|
for forbidden in ("agent_secret", "agent_secret_hash", "token_hash"):
|
||||||
|
self.assertNotIn(forbidden, registry_agent)
|
||||||
|
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT new_value_json::text
|
||||||
|
FROM audit_logs
|
||||||
|
WHERE entity_id = %s
|
||||||
|
""",
|
||||||
|
(transition["id"],),
|
||||||
|
)
|
||||||
|
audit_text = "\n".join(row[0] for row in cur.fetchall())
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
self.assertNotIn(token, audit_text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""Focused tests for secure Agent enrollment."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.agent_enrollment.dependencies import get_agent_enrollment_service
|
||||||
|
from app.agent_auth.service import hash_agent_secret
|
||||||
|
from app.agent_enrollment.models import (
|
||||||
|
AgentCredentialTransitionCreateRequest,
|
||||||
|
AgentCredentialTransitionRedeemRequest,
|
||||||
|
AgentEnrollmentCreateRequest,
|
||||||
|
AgentEnrollRequest,
|
||||||
|
)
|
||||||
|
from app.agent_enrollment.service import AgentEnrollmentService
|
||||||
|
from app.agent_enrollment.router import router
|
||||||
|
from app.identity import (
|
||||||
|
get_current_user,
|
||||||
|
machine_agent_request,
|
||||||
|
required_api_permission,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SITE_ID = UUID("00000000-0000-4000-8000-000000001700")
|
||||||
|
ZONE_ID = UUID("00000000-0000-4000-8000-000000001701")
|
||||||
|
USER_ID = UUID("00000000-0000-4000-8000-000000001702")
|
||||||
|
ENROLLMENT_ID = UUID("00000000-0000-4000-8000-000000001703")
|
||||||
|
NOW = datetime(2026, 8, 26, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAgentEnrollmentRepository:
|
||||||
|
def __init__(self):
|
||||||
|
self.created = None
|
||||||
|
self.redeemed = None
|
||||||
|
self.transition_created = None
|
||||||
|
self.transition_redeemed = None
|
||||||
|
|
||||||
|
def create_enrollment(self, **kwargs):
|
||||||
|
self.created = kwargs
|
||||||
|
return {
|
||||||
|
"id": ENROLLMENT_ID,
|
||||||
|
"site_id": SITE_ID,
|
||||||
|
"site_name": "Test Site",
|
||||||
|
"agent_zone_id": ZONE_ID,
|
||||||
|
"agent_zone_name": "Test Zone",
|
||||||
|
"display_name": kwargs["display_name"],
|
||||||
|
"created_at": NOW,
|
||||||
|
"expires_at": NOW + timedelta(minutes=30),
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_enrollments(self, **kwargs):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_credential_transition(self, **kwargs):
|
||||||
|
self.transition_created = kwargs
|
||||||
|
return {
|
||||||
|
"id": ENROLLMENT_ID,
|
||||||
|
"target_agent_id": kwargs["target_agent_id"],
|
||||||
|
"site_id": SITE_ID,
|
||||||
|
"site_name": "Test Site",
|
||||||
|
"agent_zone_id": ZONE_ID,
|
||||||
|
"agent_zone_name": "Test Zone",
|
||||||
|
"display_name": "Existing Agent",
|
||||||
|
"created_at": NOW,
|
||||||
|
"expires_at": NOW + timedelta(minutes=30),
|
||||||
|
}
|
||||||
|
|
||||||
|
def redeem_enrollment(self, **kwargs):
|
||||||
|
self.redeemed = kwargs
|
||||||
|
return {
|
||||||
|
"agent_id": kwargs["agent_id"],
|
||||||
|
"site_id": SITE_ID,
|
||||||
|
"site_name": "Test Site",
|
||||||
|
"agent_zone_id": ZONE_ID,
|
||||||
|
"agent_zone_name": "Test Zone",
|
||||||
|
"display_name": "Test Agent",
|
||||||
|
"ip_ranges": ["10.30.0.0/22"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def redeem_credential_transition(self, **kwargs):
|
||||||
|
self.transition_redeemed = kwargs
|
||||||
|
return {
|
||||||
|
"agent_id": "existing-agent",
|
||||||
|
"site_id": SITE_ID,
|
||||||
|
"site_name": "Test Site",
|
||||||
|
"agent_zone_id": ZONE_ID,
|
||||||
|
"agent_zone_name": "Test Zone",
|
||||||
|
"display_name": "Existing Agent",
|
||||||
|
"ip_ranges": ["10.30.0.0/22"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentServiceTests(unittest.TestCase):
|
||||||
|
def test_create_returns_plaintext_token_once_but_repository_gets_only_hash(self):
|
||||||
|
repository = FakeAgentEnrollmentRepository()
|
||||||
|
service = AgentEnrollmentService(repository)
|
||||||
|
|
||||||
|
result = service.create_enrollment(
|
||||||
|
AgentEnrollmentCreateRequest(
|
||||||
|
site_id=SITE_ID,
|
||||||
|
agent_zone_id=ZONE_ID,
|
||||||
|
display_name=" Test Agent ",
|
||||||
|
),
|
||||||
|
actor_user_id=USER_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
token = result["enrollment_token"]
|
||||||
|
|
||||||
|
self.assertGreaterEqual(len(token), 32)
|
||||||
|
self.assertNotEqual(repository.created["token_hash"], token)
|
||||||
|
self.assertEqual(
|
||||||
|
repository.created["token_hash"],
|
||||||
|
hash_agent_secret(token),
|
||||||
|
)
|
||||||
|
self.assertRegex(
|
||||||
|
repository.created["token_hash"],
|
||||||
|
r"^[0-9a-f]{64}$",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
repository.created["display_name"],
|
||||||
|
"Test Agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
serialized = repr(repository.created)
|
||||||
|
self.assertNotIn(token, serialized)
|
||||||
|
|
||||||
|
def test_redeem_returns_plaintext_secret_but_repository_gets_only_hash(self):
|
||||||
|
repository = FakeAgentEnrollmentRepository()
|
||||||
|
service = AgentEnrollmentService(repository)
|
||||||
|
|
||||||
|
result = service.redeem(
|
||||||
|
AgentEnrollRequest(
|
||||||
|
token="A" * 43,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
secret = result["agent_secret"]
|
||||||
|
|
||||||
|
self.assertGreaterEqual(len(secret), 32)
|
||||||
|
self.assertNotEqual(
|
||||||
|
repository.redeemed["agent_secret_hash"],
|
||||||
|
secret,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
repository.redeemed["agent_secret_hash"],
|
||||||
|
hash_agent_secret(secret),
|
||||||
|
)
|
||||||
|
self.assertRegex(
|
||||||
|
repository.redeemed["agent_secret_hash"],
|
||||||
|
r"^[0-9a-f]{64}$",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRegex(
|
||||||
|
result["agent_id"],
|
||||||
|
r"^agent-[0-9a-f]{32}$",
|
||||||
|
)
|
||||||
|
|
||||||
|
serialized = repr(repository.redeemed)
|
||||||
|
self.assertNotIn(secret, serialized)
|
||||||
|
|
||||||
|
def test_existing_transition_is_server_bound_and_accepts_only_secret_hash(self):
|
||||||
|
repository = FakeAgentEnrollmentRepository()
|
||||||
|
service = AgentEnrollmentService(repository)
|
||||||
|
|
||||||
|
created = service.create_credential_transition(
|
||||||
|
AgentCredentialTransitionCreateRequest(
|
||||||
|
agent_id=" existing-agent "
|
||||||
|
),
|
||||||
|
actor_user_id=USER_ID,
|
||||||
|
)
|
||||||
|
token = created["transition_token"]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
repository.transition_created["target_agent_id"],
|
||||||
|
"existing-agent",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
repository.transition_created["token_hash"],
|
||||||
|
hash_agent_secret(token),
|
||||||
|
)
|
||||||
|
self.assertNotIn(token, repr(repository.transition_created))
|
||||||
|
|
||||||
|
credential_hash = hash_agent_secret("locally-generated-secret")
|
||||||
|
result = service.redeem_credential_transition(
|
||||||
|
AgentCredentialTransitionRedeemRequest(
|
||||||
|
token=token,
|
||||||
|
agent_secret_hash=credential_hash,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(result["agent_id"], "existing-agent")
|
||||||
|
self.assertEqual(
|
||||||
|
repository.transition_redeemed,
|
||||||
|
{
|
||||||
|
"token_hash": hash_agent_secret(token),
|
||||||
|
"agent_secret_hash": credential_hash,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertNotIn("agent_secret", result)
|
||||||
|
|
||||||
|
def test_enrollment_admin_permission_mapping_is_explicit(self):
|
||||||
|
self.assertEqual(
|
||||||
|
required_api_permission("GET", "/api/agent-enrollments"),
|
||||||
|
"agents.view",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
required_api_permission("POST", "/api/agent-enrollments"),
|
||||||
|
"agents.manage",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
required_api_permission(
|
||||||
|
"POST", "/api/agent-credential-transitions"
|
||||||
|
),
|
||||||
|
"agents.manage",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bootstrap_endpoint_is_machine_exempt_but_admin_endpoint_is_not(self):
|
||||||
|
self.assertTrue(machine_agent_request("/api/agent/enroll"))
|
||||||
|
self.assertTrue(
|
||||||
|
machine_agent_request("/api/agent/credential-transition")
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
machine_agent_request("/api/agent-enrollments")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_generated_identifiers_are_bounded(self):
|
||||||
|
repository = FakeAgentEnrollmentRepository()
|
||||||
|
service = AgentEnrollmentService(repository)
|
||||||
|
|
||||||
|
result = service.redeem(
|
||||||
|
AgentEnrollRequest(token="B" * 43)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertLessEqual(len(result["agent_id"]), 255)
|
||||||
|
self.assertTrue(
|
||||||
|
re.fullmatch(r"agent-[0-9a-f]{32}", result["agent_id"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCredentialTransitionRouterTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.repository = FakeAgentEnrollmentRepository()
|
||||||
|
self.service = AgentEnrollmentService(self.repository)
|
||||||
|
self.app = FastAPI()
|
||||||
|
self.app.include_router(router)
|
||||||
|
self.app.dependency_overrides[get_agent_enrollment_service] = (
|
||||||
|
lambda: self.service
|
||||||
|
)
|
||||||
|
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||||
|
"id": str(USER_ID),
|
||||||
|
"role": "Test",
|
||||||
|
"permissions": ("agents.view", "agents.manage"),
|
||||||
|
}
|
||||||
|
self.client = TestClient(self.app)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
def test_admin_creation_is_distinct_no_store_and_identity_only(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/agent-credential-transitions",
|
||||||
|
json={"agent_id": "existing-agent"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||||
|
payload = response.json()["data"]
|
||||||
|
self.assertEqual(payload["target_agent_id"], "existing-agent")
|
||||||
|
self.assertIn("transition_token", payload)
|
||||||
|
self.assertNotIn("site_id", self.repository.transition_created)
|
||||||
|
self.assertNotIn("agent_zone_id", self.repository.transition_created)
|
||||||
|
|
||||||
|
def test_machine_redemption_returns_no_permanent_plaintext_secret(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/agent/credential-transition",
|
||||||
|
json={
|
||||||
|
"token": "T" * 43,
|
||||||
|
"agent_secret_hash": hash_agent_secret(
|
||||||
|
"locally-generated-router-secret"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||||
|
payload = response.json()["data"]
|
||||||
|
self.assertEqual(payload["agent_id"], "existing-agent")
|
||||||
|
self.assertNotIn("agent_secret", payload)
|
||||||
|
|
||||||
|
def test_view_only_admin_cannot_create_transition(self):
|
||||||
|
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||||
|
"id": str(USER_ID),
|
||||||
|
"role": "Test",
|
||||||
|
"permissions": ("agents.view",),
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/agent-credential-transitions",
|
||||||
|
json={"agent_id": "existing-agent"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,804 @@
|
|||||||
|
"""PostgreSQL integration tests for secure Agent enrollment."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from psycopg.errors import ForeignKeyViolation
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
from app.agent_auth.exceptions import AgentAuthenticationFailed
|
||||||
|
from app.agent_auth.repository import AgentAuthRepository
|
||||||
|
from app.agent_auth.service import AgentAuthService, hash_agent_secret
|
||||||
|
from app.agent_enrollment.exceptions import (
|
||||||
|
AgentEnrollmentConflict,
|
||||||
|
AgentEnrollmentCredentialCollision,
|
||||||
|
AgentEnrollmentNotFound,
|
||||||
|
AgentEnrollmentTokenInvalid,
|
||||||
|
)
|
||||||
|
from app.agent_enrollment.models import (
|
||||||
|
AgentEnrollmentCreateRequest,
|
||||||
|
AgentEnrollRequest,
|
||||||
|
)
|
||||||
|
from app.agent_enrollment.repository import (
|
||||||
|
AgentEnrollmentRepository,
|
||||||
|
default_connection_factory,
|
||||||
|
)
|
||||||
|
from app.agent_enrollment.service import AgentEnrollmentService
|
||||||
|
from app.agent_zones.models import AgentCheckinRequest
|
||||||
|
from app.agent_zones.repository import AgentZoneRepository
|
||||||
|
from app.agent_zones.service import AgentZoneService
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.site_id = uuid4()
|
||||||
|
self.zone_id = uuid4()
|
||||||
|
self.site_name = f"Enrollment Test Site {self.site_id.hex[:8]}"
|
||||||
|
self.zone_name = f"Enrollment Test Zone {self.zone_id.hex[:8]}"
|
||||||
|
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sites (id, name)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
""",
|
||||||
|
(self.site_id, self.site_name),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zones (
|
||||||
|
id,
|
||||||
|
site_id,
|
||||||
|
name
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
self.zone_id,
|
||||||
|
self.site_id,
|
||||||
|
self.zone_name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zone_network_ranges (
|
||||||
|
agent_zone_id,
|
||||||
|
network
|
||||||
|
)
|
||||||
|
VALUES (%s, '10.250.0.0/24'::cidr)
|
||||||
|
""",
|
||||||
|
(self.zone_id,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
self.service = AgentEnrollmentService(
|
||||||
|
AgentEnrollmentRepository()
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM audit_logs
|
||||||
|
WHERE entity_type = 'agent_enrollment'
|
||||||
|
AND (
|
||||||
|
new_value_json ->> 'site_id' = %s
|
||||||
|
OR entity_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM agent_enrollments
|
||||||
|
WHERE site_id = %s
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(str(self.site_id), self.site_id),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM agent_enrollments
|
||||||
|
WHERE site_id = %s
|
||||||
|
""",
|
||||||
|
(self.site_id,),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM minerops_site_agents
|
||||||
|
WHERE site_id = %s
|
||||||
|
""",
|
||||||
|
(self.site_id,),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM agent_zone_network_ranges
|
||||||
|
WHERE agent_zone_id = %s
|
||||||
|
""",
|
||||||
|
(self.zone_id,),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM agent_zones
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(self.zone_id,),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM sites
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(self.site_id,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def create_enrollment(self, display_name="Integration Agent"):
|
||||||
|
# created_by_user_id is nullable at the persistence layer.
|
||||||
|
# Router-level tests separately prove that the real admin path supplies
|
||||||
|
# the authenticated user's UUID.
|
||||||
|
return self.service.create_enrollment(
|
||||||
|
AgentEnrollmentCreateRequest(
|
||||||
|
site_id=self.site_id,
|
||||||
|
agent_zone_id=self.zone_id,
|
||||||
|
display_name=display_name,
|
||||||
|
),
|
||||||
|
actor_user_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_replacement_revokes_previous_open_enrollment(self):
|
||||||
|
first = self.create_enrollment("First Agent")
|
||||||
|
second = self.create_enrollment("Replacement Agent")
|
||||||
|
|
||||||
|
self.assertNotEqual(
|
||||||
|
first["enrollment_token"],
|
||||||
|
second["enrollment_token"],
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
token_hash,
|
||||||
|
used_at,
|
||||||
|
revoked_at
|
||||||
|
FROM agent_enrollments
|
||||||
|
WHERE site_id = %s
|
||||||
|
AND agent_zone_id = %s
|
||||||
|
""",
|
||||||
|
(self.site_id, self.zone_id),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
|
||||||
|
by_hash = {
|
||||||
|
row["token_hash"]: row
|
||||||
|
for row in rows
|
||||||
|
}
|
||||||
|
|
||||||
|
first_hash = hash_agent_secret(
|
||||||
|
first["enrollment_token"]
|
||||||
|
)
|
||||||
|
second_hash = hash_agent_secret(
|
||||||
|
second["enrollment_token"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(by_hash[first_hash]["revoked_at"])
|
||||||
|
self.assertIsNone(by_hash[first_hash]["used_at"])
|
||||||
|
|
||||||
|
self.assertIsNone(by_hash[second_hash]["revoked_at"])
|
||||||
|
self.assertIsNone(by_hash[second_hash]["used_at"])
|
||||||
|
|
||||||
|
with self.assertRaises(AgentEnrollmentTokenInvalid):
|
||||||
|
self.service.redeem(
|
||||||
|
AgentEnrollRequest(
|
||||||
|
token=first["enrollment_token"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
listed = self.service.list_enrollments(
|
||||||
|
site_id=self.site_id,
|
||||||
|
agent_zone_id=self.zone_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(listed), 2)
|
||||||
|
|
||||||
|
for item in listed:
|
||||||
|
self.assertNotIn("token_hash", item)
|
||||||
|
self.assertNotIn("agent_secret_hash", item)
|
||||||
|
self.assertNotIn("enrollment_token", item)
|
||||||
|
self.assertNotIn("agent_secret", item)
|
||||||
|
|
||||||
|
def test_concurrent_creation_leaves_exactly_one_open_enrollment(self):
|
||||||
|
def create_one(number):
|
||||||
|
service = AgentEnrollmentService(
|
||||||
|
AgentEnrollmentRepository()
|
||||||
|
)
|
||||||
|
return service.create_enrollment(
|
||||||
|
AgentEnrollmentCreateRequest(
|
||||||
|
site_id=self.site_id,
|
||||||
|
agent_zone_id=self.zone_id,
|
||||||
|
display_name=f"Concurrent Agent {number}",
|
||||||
|
),
|
||||||
|
actor_user_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
results = list(
|
||||||
|
executor.map(create_one, (1, 2))
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(results), 2)
|
||||||
|
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
count(*) AS total,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE used_at IS NULL
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
) AS open_count,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE revoked_at IS NOT NULL
|
||||||
|
) AS revoked_count,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE revoked_at IS NOT NULL
|
||||||
|
AND revoked_at < created_at
|
||||||
|
) AS invalid_revocation_count
|
||||||
|
FROM agent_enrollments
|
||||||
|
WHERE site_id = %s
|
||||||
|
AND agent_zone_id = %s
|
||||||
|
""",
|
||||||
|
(self.site_id, self.zone_id),
|
||||||
|
)
|
||||||
|
counts = cur.fetchone()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
self.assertEqual(counts["total"], 2)
|
||||||
|
self.assertEqual(counts["open_count"], 1)
|
||||||
|
self.assertEqual(counts["revoked_count"], 1)
|
||||||
|
self.assertEqual(counts["invalid_revocation_count"], 0)
|
||||||
|
|
||||||
|
def test_concurrent_redemption_has_exactly_one_winner(self):
|
||||||
|
enrollment = self.create_enrollment()
|
||||||
|
token = enrollment["enrollment_token"]
|
||||||
|
|
||||||
|
def redeem_once():
|
||||||
|
service = AgentEnrollmentService(
|
||||||
|
AgentEnrollmentRepository()
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
"success",
|
||||||
|
service.redeem(
|
||||||
|
AgentEnrollRequest(token=token)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except AgentEnrollmentTokenInvalid:
|
||||||
|
return ("invalid", None)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
results = list(
|
||||||
|
executor.map(lambda _: redeem_once(), range(2))
|
||||||
|
)
|
||||||
|
|
||||||
|
successes = [
|
||||||
|
result
|
||||||
|
for status, result in results
|
||||||
|
if status == "success"
|
||||||
|
]
|
||||||
|
failures = [
|
||||||
|
status
|
||||||
|
for status, _ in results
|
||||||
|
if status == "invalid"
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertEqual(len(successes), 1)
|
||||||
|
self.assertEqual(len(failures), 1)
|
||||||
|
|
||||||
|
credentials = successes[0]
|
||||||
|
agent_id = credentials["agent_id"]
|
||||||
|
agent_secret = credentials["agent_secret"]
|
||||||
|
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
agent_id,
|
||||||
|
agent_secret_hash,
|
||||||
|
credential_created_at,
|
||||||
|
credential_revoked_at,
|
||||||
|
legacy_auth_disabled_at,
|
||||||
|
last_payload::text AS last_payload
|
||||||
|
FROM minerops_site_agents
|
||||||
|
WHERE site_id = %s
|
||||||
|
AND agent_zone_id = %s
|
||||||
|
""",
|
||||||
|
(self.site_id, self.zone_id),
|
||||||
|
)
|
||||||
|
agents = cur.fetchall()
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
used_at,
|
||||||
|
revoked_at,
|
||||||
|
enrolled_agent_id,
|
||||||
|
token_hash
|
||||||
|
FROM agent_enrollments
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(enrollment["id"],),
|
||||||
|
)
|
||||||
|
enrollment_row = cur.fetchone()
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT retired_at
|
||||||
|
FROM agent_legacy_auth_retirements
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
(agent_id,),
|
||||||
|
)
|
||||||
|
retirement_row = cur.fetchone()
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT new_value_json::text AS payload
|
||||||
|
FROM audit_logs
|
||||||
|
WHERE entity_type = 'agent_enrollment'
|
||||||
|
AND entity_id = %s
|
||||||
|
ORDER BY created_at
|
||||||
|
""",
|
||||||
|
(enrollment["id"],),
|
||||||
|
)
|
||||||
|
audit_rows = cur.fetchall()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
self.assertEqual(len(agents), 1)
|
||||||
|
agent = agents[0]
|
||||||
|
|
||||||
|
self.assertEqual(agent["agent_id"], agent_id)
|
||||||
|
self.assertEqual(
|
||||||
|
agent["agent_secret_hash"],
|
||||||
|
hash_agent_secret(agent_secret),
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(agent["credential_created_at"])
|
||||||
|
self.assertIsNone(agent["credential_revoked_at"])
|
||||||
|
self.assertEqual(
|
||||||
|
agent["legacy_auth_disabled_at"],
|
||||||
|
agent["credential_created_at"],
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(retirement_row)
|
||||||
|
self.assertEqual(
|
||||||
|
retirement_row["retired_at"],
|
||||||
|
agent["credential_created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(enrollment_row["used_at"])
|
||||||
|
self.assertIsNone(enrollment_row["revoked_at"])
|
||||||
|
self.assertEqual(
|
||||||
|
enrollment_row["enrolled_agent_id"],
|
||||||
|
agent_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Neither plaintext credential may persist in operational state.
|
||||||
|
persisted_text = "\n".join(
|
||||||
|
[
|
||||||
|
str(enrollment_row),
|
||||||
|
str(agent),
|
||||||
|
*(row["payload"] for row in audit_rows),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotIn(token, persisted_text)
|
||||||
|
self.assertNotIn(agent_secret, persisted_text)
|
||||||
|
self.assertNotIn(agent_secret, agent["last_payload"])
|
||||||
|
|
||||||
|
# The newly issued per-Agent credential must immediately authenticate.
|
||||||
|
auth_service = AgentAuthService(
|
||||||
|
AgentAuthRepository()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
auth_service.authenticate(
|
||||||
|
agent_id,
|
||||||
|
agent_secret,
|
||||||
|
),
|
||||||
|
agent_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(AgentAuthenticationFailed):
|
||||||
|
auth_service.authenticate(
|
||||||
|
agent_id,
|
||||||
|
"definitely-not-the-issued-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
original_legacy_id = settings.minerops_agent_id
|
||||||
|
original_legacy_secret = settings.minerops_agent_shared_secret
|
||||||
|
settings.minerops_agent_id = agent_id
|
||||||
|
settings.minerops_agent_shared_secret = "global-secret-must-not-work"
|
||||||
|
try:
|
||||||
|
with self.assertRaises(AgentAuthenticationFailed):
|
||||||
|
auth_service.authenticate(None, "global-secret-must-not-work")
|
||||||
|
finally:
|
||||||
|
settings.minerops_agent_id = original_legacy_id
|
||||||
|
settings.minerops_agent_shared_secret = original_legacy_secret
|
||||||
|
|
||||||
|
# Normal check-in must never mutate the credential.
|
||||||
|
secret_hash_before = agent["agent_secret_hash"]
|
||||||
|
|
||||||
|
AgentZoneService(
|
||||||
|
AgentZoneRepository()
|
||||||
|
).checkin(
|
||||||
|
agent_id,
|
||||||
|
AgentCheckinRequest(
|
||||||
|
site_id=self.site_id,
|
||||||
|
agent_zone_id=self.zone_id,
|
||||||
|
site_name=self.site_name,
|
||||||
|
hostname="integration-test-agent",
|
||||||
|
version="integration-test",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
agent_secret_hash,
|
||||||
|
credential_created_at,
|
||||||
|
credential_revoked_at,
|
||||||
|
legacy_auth_disabled_at
|
||||||
|
FROM minerops_site_agents
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
(agent_id,),
|
||||||
|
)
|
||||||
|
after_checkin = cur.fetchone()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
after_checkin["agent_secret_hash"],
|
||||||
|
secret_hash_before,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(
|
||||||
|
after_checkin["credential_created_at"]
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
after_checkin["credential_revoked_at"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
after_checkin["legacy_auth_disabled_at"],
|
||||||
|
agent["legacy_auth_disabled_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 0017 enrollment integrity remains RESTRICT: the current Agent row
|
||||||
|
# cannot be deleted while its enrollment relationship exists.
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with self.assertRaises(ForeignKeyViolation):
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||||
|
(agent_id,),
|
||||||
|
)
|
||||||
|
connection.rollback()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
# After an operator deliberately removes the enrollment history, the
|
||||||
|
# independent retirement ledger must outlive deletion and recreation
|
||||||
|
# of the current Agent row with the identical identity.
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM agent_enrollments WHERE id = %s",
|
||||||
|
(enrollment["id"],),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||||
|
(agent_id,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
original_legacy_id = settings.minerops_agent_id
|
||||||
|
original_legacy_secret = settings.minerops_agent_shared_secret
|
||||||
|
settings.minerops_agent_id = agent_id
|
||||||
|
settings.minerops_agent_shared_secret = "recreation-global-secret"
|
||||||
|
try:
|
||||||
|
with self.assertRaises(AgentAuthenticationFailed):
|
||||||
|
auth_service.authenticate(None, "recreation-global-secret")
|
||||||
|
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
site_name
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
RETURNING legacy_auth_disabled_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
agent_id,
|
||||||
|
self.site_id,
|
||||||
|
self.zone_id,
|
||||||
|
self.site_name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
recreated = cur.fetchone()
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
recreated["legacy_auth_disabled_at"],
|
||||||
|
retirement_row["retired_at"],
|
||||||
|
)
|
||||||
|
with self.assertRaises(AgentAuthenticationFailed):
|
||||||
|
auth_service.authenticate(agent_id, "recreation-global-secret")
|
||||||
|
finally:
|
||||||
|
settings.minerops_agent_id = original_legacy_id
|
||||||
|
settings.minerops_agent_shared_secret = original_legacy_secret
|
||||||
|
|
||||||
|
def test_failed_credential_issuance_rolls_back_new_retirement(self):
|
||||||
|
enrollment = self.create_enrollment("Collision Agent")
|
||||||
|
collision_id = f"agent-collision-{uuid4().hex}"
|
||||||
|
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||||
|
VALUES (%s, 'Existing uncredentialed identity')
|
||||||
|
""",
|
||||||
|
(collision_id,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self.assertRaises(AgentEnrollmentCredentialCollision):
|
||||||
|
AgentEnrollmentRepository().redeem_enrollment(
|
||||||
|
token_hash=hash_agent_secret(enrollment["enrollment_token"]),
|
||||||
|
agent_id=collision_id,
|
||||||
|
agent_secret_hash=hash_agent_secret("never-issued-secret"),
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT count(*) AS count
|
||||||
|
FROM agent_legacy_auth_retirements
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
(collision_id,),
|
||||||
|
)
|
||||||
|
self.assertEqual(cur.fetchone()["count"], 0)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT used_at, enrolled_agent_id
|
||||||
|
FROM agent_enrollments
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(enrollment["id"],),
|
||||||
|
)
|
||||||
|
enrollment_row = cur.fetchone()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
self.assertIsNone(enrollment_row["used_at"])
|
||||||
|
self.assertIsNone(enrollment_row["enrolled_agent_id"])
|
||||||
|
finally:
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||||
|
(collision_id,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def test_expired_token_is_rejected_without_side_effects(self):
|
||||||
|
enrollment = self.create_enrollment()
|
||||||
|
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE agent_enrollments
|
||||||
|
SET created_at = now() - interval '2 hours',
|
||||||
|
expires_at = now() - interval '1 hour'
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(enrollment["id"],),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
with self.assertRaises(AgentEnrollmentTokenInvalid):
|
||||||
|
self.service.redeem(
|
||||||
|
AgentEnrollRequest(
|
||||||
|
token=enrollment["enrollment_token"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT used_at, revoked_at, enrolled_agent_id
|
||||||
|
FROM agent_enrollments
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(enrollment["id"],),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT count(*) AS count
|
||||||
|
FROM minerops_site_agents
|
||||||
|
WHERE agent_zone_id = %s
|
||||||
|
""",
|
||||||
|
(self.zone_id,),
|
||||||
|
)
|
||||||
|
agent_count = cur.fetchone()["count"]
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
self.assertIsNone(row["used_at"])
|
||||||
|
self.assertIsNone(row["revoked_at"])
|
||||||
|
self.assertIsNone(row["enrolled_agent_id"])
|
||||||
|
self.assertEqual(agent_count, 0)
|
||||||
|
|
||||||
|
def test_disabled_and_occupied_zone_do_not_consume_token(self):
|
||||||
|
enrollment = self.create_enrollment()
|
||||||
|
token = enrollment["enrollment_token"]
|
||||||
|
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE agent_zones
|
||||||
|
SET is_enabled = false
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(self.zone_id,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
with self.assertRaises(AgentEnrollmentConflict):
|
||||||
|
self.service.redeem(
|
||||||
|
AgentEnrollRequest(token=token)
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE agent_zones
|
||||||
|
SET is_enabled = true
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(self.zone_id,),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
site_name
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
f"legacy-{uuid4().hex}",
|
||||||
|
self.site_id,
|
||||||
|
self.zone_id,
|
||||||
|
self.site_name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
with self.assertRaises(AgentEnrollmentConflict):
|
||||||
|
self.service.redeem(
|
||||||
|
AgentEnrollRequest(token=token)
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT used_at, revoked_at, enrolled_agent_id
|
||||||
|
FROM agent_enrollments
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(enrollment["id"],),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
self.assertIsNone(row["used_at"])
|
||||||
|
self.assertIsNone(row["revoked_at"])
|
||||||
|
self.assertIsNone(row["enrolled_agent_id"])
|
||||||
|
|
||||||
|
def test_site_zone_mismatch_is_rejected_before_creation(self):
|
||||||
|
wrong_site_id = uuid4()
|
||||||
|
|
||||||
|
with self.assertRaises(AgentEnrollmentNotFound):
|
||||||
|
self.service.create_enrollment(
|
||||||
|
AgentEnrollmentCreateRequest(
|
||||||
|
site_id=wrong_site_id,
|
||||||
|
agent_zone_id=self.zone_id,
|
||||||
|
display_name="Wrong Site Agent",
|
||||||
|
),
|
||||||
|
actor_user_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT count(*) AS count
|
||||||
|
FROM agent_enrollments
|
||||||
|
WHERE agent_zone_id = %s
|
||||||
|
""",
|
||||||
|
(self.zone_id,),
|
||||||
|
)
|
||||||
|
count = cur.fetchone()["count"]
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
self.assertEqual(count, 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""Disposable PostgreSQL contract for durable legacy-auth retirement."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from psycopg.errors import CheckViolation
|
||||||
|
|
||||||
|
from app.agent_auth.exceptions import (
|
||||||
|
AgentAuthenticationFailed,
|
||||||
|
AgentCredentialRevoked,
|
||||||
|
)
|
||||||
|
from app.agent_auth.repository import AgentAuthRepository, default_connection_factory
|
||||||
|
from app.agent_auth.service import AgentAuthService
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class AgentLegacyAuthRetirementPostgresTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
if os.environ.get("MINEROPS_LEGACY_RETIREMENT_DISPOSABLE") != "1":
|
||||||
|
raise unittest.SkipTest(
|
||||||
|
"legacy-auth retirement requires its disposable PostgreSQL runner"
|
||||||
|
)
|
||||||
|
cls.case = os.environ["MINEROPS_LEGACY_RETIREMENT_CASE"]
|
||||||
|
cls.agent_id = settings.minerops_agent_id
|
||||||
|
cls.auth = AgentAuthService(AgentAuthRepository())
|
||||||
|
|
||||||
|
def row(self):
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT agent_secret_hash, credential_created_at,
|
||||||
|
credential_revoked_at, legacy_auth_disabled_at
|
||||||
|
FROM minerops_site_agents
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
(self.agent_id,),
|
||||||
|
)
|
||||||
|
return cur.fetchone()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def retirement_row(self, agent_id=None):
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT agent_id, retired_at
|
||||||
|
FROM agent_legacy_auth_retirements
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
(agent_id or self.agent_id,),
|
||||||
|
)
|
||||||
|
return cur.fetchone()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def test_01_migration_backfill_is_selective(self):
|
||||||
|
row = self.row()
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
if self.case == "legacy":
|
||||||
|
self.assertEqual(row, (None, None, None, None))
|
||||||
|
self.assertIsNone(self.retirement_row())
|
||||||
|
else:
|
||||||
|
self.assertIsNotNone(row[3])
|
||||||
|
retirement = self.retirement_row()
|
||||||
|
self.assertIsNotNone(retirement)
|
||||||
|
self.assertEqual(retirement[1], row[3])
|
||||||
|
|
||||||
|
def test_02_real_repository_authentication_obeys_retirement(self):
|
||||||
|
secret = settings.minerops_agent_shared_secret
|
||||||
|
if self.case == "legacy":
|
||||||
|
self.assertEqual(self.auth.authenticate(None, secret), self.agent_id)
|
||||||
|
self.assertEqual(
|
||||||
|
self.auth.authenticate(self.agent_id, secret), self.agent_id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
for agent_id in (None, self.agent_id):
|
||||||
|
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||||
|
AgentCredentialRevoked
|
||||||
|
):
|
||||||
|
self.auth.authenticate(agent_id, secret)
|
||||||
|
|
||||||
|
def test_03_clearing_credentials_cannot_restore_global_fallback(self):
|
||||||
|
if self.case == "legacy":
|
||||||
|
self.skipTest("uncredentialed legacy case must remain eligible")
|
||||||
|
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE minerops_site_agents
|
||||||
|
SET agent_secret_hash = NULL,
|
||||||
|
credential_created_at = NULL,
|
||||||
|
credential_revoked_at = NULL
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
(self.agent_id,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
row = self.row()
|
||||||
|
self.assertEqual(row[:3], (None, None, None))
|
||||||
|
self.assertIsNotNone(row[3])
|
||||||
|
for agent_id in (None, self.agent_id):
|
||||||
|
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||||
|
AgentAuthenticationFailed
|
||||||
|
):
|
||||||
|
self.auth.authenticate(
|
||||||
|
agent_id, settings.minerops_agent_shared_secret
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_04_deletion_and_recreation_cannot_restore_fallback(self):
|
||||||
|
if self.case == "legacy":
|
||||||
|
self.skipTest("uncredentialed legacy case has no retirement history")
|
||||||
|
|
||||||
|
retirement = self.retirement_row()
|
||||||
|
self.assertIsNotNone(retirement)
|
||||||
|
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||||
|
(self.agent_id,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
self.assertIsNone(self.row())
|
||||||
|
self.assertEqual(self.retirement_row(), retirement)
|
||||||
|
for agent_id in (None, self.agent_id):
|
||||||
|
with self.subTest(state="deleted", agent_id=agent_id), self.assertRaises(
|
||||||
|
AgentAuthenticationFailed
|
||||||
|
):
|
||||||
|
self.auth.authenticate(
|
||||||
|
agent_id, settings.minerops_agent_shared_secret
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||||
|
VALUES (%s, 'Recreated')
|
||||||
|
""",
|
||||||
|
(self.agent_id,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
recreated = self.row()
|
||||||
|
self.assertEqual(recreated[:3], (None, None, None))
|
||||||
|
self.assertEqual(recreated[3], retirement[1])
|
||||||
|
for agent_id in (None, self.agent_id):
|
||||||
|
with self.subTest(state="recreated", agent_id=agent_id), self.assertRaises(
|
||||||
|
AgentAuthenticationFailed
|
||||||
|
):
|
||||||
|
self.auth.authenticate(
|
||||||
|
agent_id, settings.minerops_agent_shared_secret
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_05_retirement_ledger_and_projection_are_one_way(self):
|
||||||
|
if self.case == "legacy":
|
||||||
|
self.skipTest("uncredentialed legacy case has no retirement history")
|
||||||
|
|
||||||
|
statements = (
|
||||||
|
"""
|
||||||
|
UPDATE agent_legacy_auth_retirements
|
||||||
|
SET retired_at = retired_at + interval '1 second'
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
DELETE FROM agent_legacy_auth_retirements
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
"TRUNCATE agent_legacy_auth_retirements",
|
||||||
|
"""
|
||||||
|
UPDATE minerops_site_agents
|
||||||
|
SET legacy_auth_disabled_at = NULL
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
for statement in statements:
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with self.subTest(statement=" ".join(statement.split())):
|
||||||
|
with self.assertRaises(CheckViolation):
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
if "%s" in statement:
|
||||||
|
cur.execute(statement, (self.agent_id,))
|
||||||
|
else:
|
||||||
|
cur.execute(statement)
|
||||||
|
connection.rollback()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
self.assertIsNotNone(self.retirement_row())
|
||||||
|
self.assertIsNotNone(self.row()[3])
|
||||||
|
|
||||||
|
def test_06_other_legacy_identity_is_not_accidentally_retired(self):
|
||||||
|
if self.case == "legacy":
|
||||||
|
self.skipTest("cross-identity check runs in the retired case")
|
||||||
|
|
||||||
|
other_id = self.agent_id + "-untouched"
|
||||||
|
connection = default_connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||||
|
VALUES (%s, 'Untouched')
|
||||||
|
""",
|
||||||
|
(other_id,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
self.assertIsNone(self.retirement_row(other_id))
|
||||||
|
original_legacy_id = settings.minerops_agent_id
|
||||||
|
settings.minerops_agent_id = other_id
|
||||||
|
try:
|
||||||
|
self.assertEqual(
|
||||||
|
self.auth.authenticate(
|
||||||
|
other_id, settings.minerops_agent_shared_secret
|
||||||
|
),
|
||||||
|
other_id,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
settings.minerops_agent_id = original_legacy_id
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""Frontend contract tests for Agent Management."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.frontend.router import router
|
||||||
|
from app.identity import get_current_user, required_page_permission
|
||||||
|
|
||||||
|
|
||||||
|
STATIC_ROOT = Path(__file__).parents[1] / "app" / "frontend" / "static"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentManagementFrontendTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.app = FastAPI()
|
||||||
|
self.app.include_router(router)
|
||||||
|
self.client = TestClient(self.app)
|
||||||
|
|
||||||
|
def authenticate(self, permissions):
|
||||||
|
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||||
|
"id": "00000000-0000-4000-8000-000000009001",
|
||||||
|
"username": "agent-test-user",
|
||||||
|
"display_name": "Agent Test User",
|
||||||
|
"role": "Test Role",
|
||||||
|
"permissions": tuple(permissions),
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_page_requires_agents_view(self):
|
||||||
|
self.authenticate(("unrelated.permission",))
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.get("/agent/management").status_code,
|
||||||
|
403,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.authenticate(("agents.view",))
|
||||||
|
response = self.client.get("/agent/management")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("Agent Management", response.text)
|
||||||
|
self.assertIn('href="/agent/management"', response.text)
|
||||||
|
|
||||||
|
def test_manage_controls_follow_agents_manage_permission(self):
|
||||||
|
self.authenticate(("agents.view",))
|
||||||
|
|
||||||
|
restricted = self.client.get("/agent/management").text
|
||||||
|
|
||||||
|
self.assertNotIn('id="agentCreateForm"', restricted)
|
||||||
|
restricted_text = " ".join(restricted.split())
|
||||||
|
self.assertIn(
|
||||||
|
"cannot create Agent enrollments.",
|
||||||
|
restricted_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.authenticate(("agents.view", "agents.manage"))
|
||||||
|
|
||||||
|
managed = self.client.get("/agent/management").text
|
||||||
|
|
||||||
|
self.assertIn('id="agentCreateForm"', managed)
|
||||||
|
self.assertIn('id="agentSite"', managed)
|
||||||
|
self.assertIn('id="agentZone"', managed)
|
||||||
|
self.assertIn('id="agentDisplayName"', managed)
|
||||||
|
self.assertIn('data-can-manage-agents="true"', managed)
|
||||||
|
self.assertIn("Linux x64 — coming later", managed)
|
||||||
|
self.assertIn("Linux ARM64 / Raspberry Pi — coming later", managed)
|
||||||
|
|
||||||
|
def test_route_and_asset_contract(self):
|
||||||
|
self.assertEqual(
|
||||||
|
required_page_permission("/agent/management"),
|
||||||
|
"agents.view",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.authenticate(("agents.view",))
|
||||||
|
|
||||||
|
page = self.client.get("/agent/management")
|
||||||
|
self.assertIn(
|
||||||
|
"/assets/minerops-agent-management.js",
|
||||||
|
page.text,
|
||||||
|
)
|
||||||
|
|
||||||
|
asset = self.client.get(
|
||||||
|
"/assets/minerops-agent-management.js"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(asset.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
asset.headers.get("cache-control"),
|
||||||
|
"no-store",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_javascript_is_secret_conscious_and_safe_dom_only(self):
|
||||||
|
source = (
|
||||||
|
STATIC_ROOT / "agent-management.js"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn('api("/api/agents")', source)
|
||||||
|
self.assertIn('api("/api/agent-enrollments")', source)
|
||||||
|
self.assertIn(
|
||||||
|
'api("/api/agent-enrollments", {',
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'api("/api/agent-credential-transitions", {',
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn("Upgrade credential", source)
|
||||||
|
|
||||||
|
for unsafe in (
|
||||||
|
"innerHTML",
|
||||||
|
"outerHTML",
|
||||||
|
"insertAdjacentHTML",
|
||||||
|
"localStorage",
|
||||||
|
"sessionStorage",
|
||||||
|
"console.log",
|
||||||
|
):
|
||||||
|
self.assertNotIn(unsafe, source)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"el.agentEnrollmentToken.textContent",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"el.agentEnrollmentToken.textContent = \"\"",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_agent_management_quiet_actions_have_visible_text(self):
|
||||||
|
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
".agent-management-columns .quiet-button,",
|
||||||
|
css,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
".agent-enrollment-panel .quiet-button {",
|
||||||
|
css,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"color: var(--ink);",
|
||||||
|
css,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_windows_package_download_is_ephemeral(self):
|
||||||
|
self.authenticate(("agents.view", "agents.manage"))
|
||||||
|
|
||||||
|
page = self.client.get("/agent/management").text
|
||||||
|
|
||||||
|
self.assertIn('id="agentEnrollmentDownload"', page)
|
||||||
|
self.assertIn("Download Windows package", page)
|
||||||
|
|
||||||
|
source = (
|
||||||
|
STATIC_ROOT / "agent-management.js"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
'"/api/agent-enrollments/windows-package"',
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'packageFilename = "MinerOpsAgent-Windows.zip"',
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn("link.download = packageFilename", source)
|
||||||
|
self.assertIn(
|
||||||
|
"window.URL.revokeObjectURL(downloadUrl)",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn("dismissToken();", source)
|
||||||
|
|
||||||
|
for forbidden in (
|
||||||
|
"localStorage",
|
||||||
|
"sessionStorage",
|
||||||
|
"agent_secret",
|
||||||
|
"api3_password",
|
||||||
|
):
|
||||||
|
self.assertNotIn(forbidden, source)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Focused tests for the Agent Management read model."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.agent_registry.service import AgentRegistryService
|
||||||
|
from app.identity import required_api_permission
|
||||||
|
|
||||||
|
|
||||||
|
SITE_ID = UUID("00000000-0000-4000-8000-000000001800")
|
||||||
|
ZONE_ID = UUID("00000000-0000-4000-8000-000000001801")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAgentRegistryRepository:
|
||||||
|
def list_registry(self):
|
||||||
|
return {
|
||||||
|
"sites": [
|
||||||
|
{
|
||||||
|
"id": SITE_ID,
|
||||||
|
"name": "Test Site",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"zones": [
|
||||||
|
{
|
||||||
|
"id": ZONE_ID,
|
||||||
|
"site_id": SITE_ID,
|
||||||
|
"site_name": "Test Site",
|
||||||
|
"name": "Network A",
|
||||||
|
"description": None,
|
||||||
|
"is_enabled": True,
|
||||||
|
"assigned_agent_id": "agent-test",
|
||||||
|
"networks": ["10.50.0.0/24"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"agents": [
|
||||||
|
{
|
||||||
|
"agent_id": "agent-test",
|
||||||
|
"display_name": "Test Agent",
|
||||||
|
"site_id": SITE_ID,
|
||||||
|
"site_name": "Test Site",
|
||||||
|
"agent_zone_id": ZONE_ID,
|
||||||
|
"agent_zone_name": "Network A",
|
||||||
|
"agent_zone_enabled": True,
|
||||||
|
"hostname": "test-agent",
|
||||||
|
"version": "1.0.0-alpha",
|
||||||
|
"ip_address": "10.50.0.10",
|
||||||
|
"last_checkin_at": "2026-08-26T20:00:00+00:00",
|
||||||
|
"credential_state": "active",
|
||||||
|
"credential_created_at": "2026-08-26T19:00:00+00:00",
|
||||||
|
"credential_revoked_at": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRegistryServiceTests(unittest.TestCase):
|
||||||
|
def test_projection_is_factual_and_zone_aware(self):
|
||||||
|
data = AgentRegistryService(
|
||||||
|
FakeAgentRegistryRepository()
|
||||||
|
).list_registry()
|
||||||
|
|
||||||
|
self.assertEqual(len(data.agents), 1)
|
||||||
|
self.assertEqual(len(data.sites), 1)
|
||||||
|
self.assertEqual(len(data.zones), 1)
|
||||||
|
|
||||||
|
agent = data.agents[0]
|
||||||
|
self.assertEqual(agent.agent_id, "agent-test")
|
||||||
|
self.assertEqual(agent.credential_state, "active")
|
||||||
|
self.assertEqual(agent.routing_state, "assigned")
|
||||||
|
self.assertEqual(
|
||||||
|
data.zones[0].networks,
|
||||||
|
["10.50.0.0/24"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unassigned_and_disabled_routing_are_not_fabricated_online_states(self):
|
||||||
|
repository = FakeAgentRegistryRepository()
|
||||||
|
|
||||||
|
repository.list_registry = lambda: {
|
||||||
|
"sites": [],
|
||||||
|
"zones": [],
|
||||||
|
"agents": [
|
||||||
|
{
|
||||||
|
"agent_id": "legacy",
|
||||||
|
"display_name": None,
|
||||||
|
"site_id": None,
|
||||||
|
"site_name": "Legacy Site",
|
||||||
|
"agent_zone_id": None,
|
||||||
|
"agent_zone_name": None,
|
||||||
|
"agent_zone_enabled": None,
|
||||||
|
"hostname": None,
|
||||||
|
"version": None,
|
||||||
|
"ip_address": None,
|
||||||
|
"last_checkin_at": "2026-08-26T20:00:00+00:00",
|
||||||
|
"credential_state": "legacy",
|
||||||
|
"credential_created_at": None,
|
||||||
|
"credential_revoked_at": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
data = AgentRegistryService(repository).list_registry()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
data.agents[0].routing_state,
|
||||||
|
"unassigned",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
data.agents[0].credential_state,
|
||||||
|
"legacy",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_api_permission_mapping_is_explicit(self):
|
||||||
|
self.assertEqual(
|
||||||
|
required_api_permission("GET", "/api/agents"),
|
||||||
|
"agents.view",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""PostgreSQL integration test for the Agent registry projection."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.agent_registry.repository import (
|
||||||
|
AgentRegistryRepository,
|
||||||
|
default_connection_factory,
|
||||||
|
)
|
||||||
|
from app.agent_registry.service import AgentRegistryService
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRegistryPostgresTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.site_id = uuid4()
|
||||||
|
self.zone_id = uuid4()
|
||||||
|
self.agent_id = f"agent-registry-{uuid4().hex}"
|
||||||
|
|
||||||
|
connection = default_connection_factory()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sites (id, name)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
self.site_id,
|
||||||
|
"Registry Integration Site",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zones (
|
||||||
|
id,
|
||||||
|
site_id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
is_enabled
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, true)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
self.zone_id,
|
||||||
|
self.site_id,
|
||||||
|
"Registry Network",
|
||||||
|
"Integration-test network",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zone_network_ranges (
|
||||||
|
agent_zone_id,
|
||||||
|
network
|
||||||
|
)
|
||||||
|
VALUES (%s, '10.251.0.0/24'::cidr)
|
||||||
|
""",
|
||||||
|
(self.zone_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id,
|
||||||
|
site_name,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
display_name,
|
||||||
|
hostname,
|
||||||
|
version,
|
||||||
|
ip_address,
|
||||||
|
agent_secret_hash,
|
||||||
|
credential_created_at,
|
||||||
|
legacy_auth_disabled_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
now(),
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
self.agent_id,
|
||||||
|
"Registry Integration Site",
|
||||||
|
self.site_id,
|
||||||
|
self.zone_id,
|
||||||
|
"Registry Test Agent",
|
||||||
|
"registry-agent",
|
||||||
|
"1.0.0-alpha",
|
||||||
|
"10.251.0.10",
|
||||||
|
"a" * 64,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
connection = default_connection_factory()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM minerops_site_agents
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
(self.agent_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM agent_zone_network_ranges
|
||||||
|
WHERE agent_zone_id = %s
|
||||||
|
""",
|
||||||
|
(self.zone_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM agent_zones
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(self.zone_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM sites
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(self.site_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def test_registry_joins_site_zone_network_and_redacts_secret_hash(self):
|
||||||
|
data = AgentRegistryService(
|
||||||
|
AgentRegistryRepository()
|
||||||
|
).list_registry()
|
||||||
|
|
||||||
|
agents = [
|
||||||
|
item
|
||||||
|
for item in data.agents
|
||||||
|
if item.agent_id == self.agent_id
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertEqual(len(agents), 1)
|
||||||
|
|
||||||
|
agent = agents[0]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
agent.site_name,
|
||||||
|
"Registry Integration Site",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
agent.agent_zone_name,
|
||||||
|
"Registry Network",
|
||||||
|
)
|
||||||
|
self.assertEqual(agent.credential_state, "active")
|
||||||
|
self.assertEqual(agent.routing_state, "assigned")
|
||||||
|
|
||||||
|
zones = [
|
||||||
|
zone
|
||||||
|
for zone in data.zones
|
||||||
|
if zone.id == self.zone_id
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertEqual(len(zones), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
zones[0].networks,
|
||||||
|
["10.251.0.0/24"],
|
||||||
|
)
|
||||||
|
|
||||||
|
serialized = data.model_dump_json()
|
||||||
|
|
||||||
|
self.assertNotIn("agent_secret_hash", serialized)
|
||||||
|
self.assertNotIn("a" * 64, serialized)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,633 @@
|
|||||||
|
"""Runtime PostgreSQL isolation tests for Agent-owned machine transport."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.agent_zones.repository import AgentZoneRepository
|
||||||
|
from app.agent_zones.service import AgentZoneService
|
||||||
|
from app.config import settings
|
||||||
|
from app.main import (
|
||||||
|
minerops_agent_api3_enrichment,
|
||||||
|
minerops_agent_miner_enrichment_v2,
|
||||||
|
minerops_agent_miner_enrichment_v3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SITE_A = UUID("00000000-0000-4000-8000-000000001710")
|
||||||
|
SITE_B = UUID("00000000-0000-4000-8000-000000001711")
|
||||||
|
ZONE_A = UUID("00000000-0000-4000-8000-000000001712")
|
||||||
|
ZONE_B = UUID("00000000-0000-4000-8000-000000001713")
|
||||||
|
MINER_A = UUID("00000000-0000-4000-8000-000000001714")
|
||||||
|
MINER_B = UUID("00000000-0000-4000-8000-000000001715")
|
||||||
|
|
||||||
|
AGENT_A = "agent-transport-isolation-a"
|
||||||
|
AGENT_B = "agent-transport-isolation-b"
|
||||||
|
|
||||||
|
IP_A = "198.51.100.171"
|
||||||
|
IP_B = "198.51.100.172"
|
||||||
|
|
||||||
|
MAC_A = "02:00:00:00:17:A1"
|
||||||
|
MAC_B = "02:00:00:00:17:B1"
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
return psycopg.connect(
|
||||||
|
host=settings.postgres_host,
|
||||||
|
port=settings.postgres_port,
|
||||||
|
dbname=settings.postgres_db,
|
||||||
|
user=settings.postgres_user,
|
||||||
|
password=settings.postgres_password,
|
||||||
|
connect_timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def json_request(path, payload):
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
delivered = False
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
nonlocal delivered
|
||||||
|
|
||||||
|
if delivered:
|
||||||
|
return {
|
||||||
|
"type": "http.request",
|
||||||
|
"body": b"",
|
||||||
|
"more_body": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
delivered = True
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "http.request",
|
||||||
|
"body": body,
|
||||||
|
"more_body": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"asgi": {"version": "3.0"},
|
||||||
|
"http_version": "1.1",
|
||||||
|
"method": "POST",
|
||||||
|
"scheme": "http",
|
||||||
|
"path": path,
|
||||||
|
"raw_path": path.encode("ascii"),
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": [(b"content-type", b"application/json")],
|
||||||
|
"client": ("testclient", 12345),
|
||||||
|
"server": ("testserver", 80),
|
||||||
|
}
|
||||||
|
|
||||||
|
return Request(scope, receive)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentTransportIsolationPostgresTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
if os.environ.get(
|
||||||
|
"MINEROPS_TRANSPORT_ISOLATION_DISPOSABLE"
|
||||||
|
) != "1":
|
||||||
|
raise unittest.SkipTest(
|
||||||
|
"Runtime Agent transport isolation is executed "
|
||||||
|
"by its dedicated disposable PostgreSQL runner."
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = connect()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sites (id, name)
|
||||||
|
VALUES
|
||||||
|
(%s, 'Transport Isolation Site A'),
|
||||||
|
(%s, 'Transport Isolation Site B')
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""",
|
||||||
|
(SITE_A, SITE_B),
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zones (
|
||||||
|
id,
|
||||||
|
site_id,
|
||||||
|
name,
|
||||||
|
is_enabled
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
'Transport Isolation Zone A',
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
'Transport Isolation Zone B',
|
||||||
|
true
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO UPDATE
|
||||||
|
SET is_enabled = true
|
||||||
|
""",
|
||||||
|
(ZONE_A, SITE_A, ZONE_B, SITE_B),
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
site_name,
|
||||||
|
last_payload
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
'Transport Isolation Site A',
|
||||||
|
'{}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
'Transport Isolation Site B',
|
||||||
|
'{}'::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (agent_id) DO UPDATE
|
||||||
|
SET
|
||||||
|
site_id = EXCLUDED.site_id,
|
||||||
|
agent_zone_id = EXCLUDED.agent_zone_id,
|
||||||
|
site_name = EXCLUDED.site_name,
|
||||||
|
last_payload = EXCLUDED.last_payload
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
AGENT_A,
|
||||||
|
SITE_A,
|
||||||
|
ZONE_A,
|
||||||
|
AGENT_B,
|
||||||
|
SITE_B,
|
||||||
|
ZONE_B,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
# Immutable identity/history rows intentionally are not deleted.
|
||||||
|
# The disposable Compose runner destroys the database volume.
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _delete_optional_rows(cur, table, predicate, params):
|
||||||
|
cur.execute(
|
||||||
|
"SELECT to_regclass(%s)",
|
||||||
|
(f"public.{table}",),
|
||||||
|
)
|
||||||
|
|
||||||
|
if cur.fetchone()[0] is not None:
|
||||||
|
cur.execute(
|
||||||
|
f"DELETE FROM {table} WHERE {predicate}",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.zone_service = AgentZoneService(
|
||||||
|
AgentZoneRepository(connect)
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = connect()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO miners (
|
||||||
|
id,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
asset_tag,
|
||||||
|
current_mac,
|
||||||
|
current_ip,
|
||||||
|
hostname,
|
||||||
|
miner_sn,
|
||||||
|
model,
|
||||||
|
miner_type,
|
||||||
|
api_version,
|
||||||
|
firmware_version,
|
||||||
|
platform,
|
||||||
|
status,
|
||||||
|
notes,
|
||||||
|
api3_supported
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
'TRANSPORT-ZONE-A',
|
||||||
|
%s,
|
||||||
|
%s::inet,
|
||||||
|
'zone-a-before',
|
||||||
|
'ZONE-A-SERIAL',
|
||||||
|
'Zone A Model',
|
||||||
|
'Zone A Model',
|
||||||
|
'1.0-a',
|
||||||
|
'fw-a',
|
||||||
|
'platform-a',
|
||||||
|
'online',
|
||||||
|
'Zone A baseline',
|
||||||
|
false
|
||||||
|
),
|
||||||
|
(
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
'TRANSPORT-ZONE-B',
|
||||||
|
%s,
|
||||||
|
%s::inet,
|
||||||
|
'zone-b-before',
|
||||||
|
'ZONE-B-SERIAL',
|
||||||
|
'Zone B Model',
|
||||||
|
'Zone B Model',
|
||||||
|
'1.0-b',
|
||||||
|
'fw-b',
|
||||||
|
'platform-b',
|
||||||
|
'online',
|
||||||
|
'Zone B baseline',
|
||||||
|
false
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
MINER_A,
|
||||||
|
SITE_A,
|
||||||
|
ZONE_A,
|
||||||
|
MAC_A,
|
||||||
|
IP_A,
|
||||||
|
MINER_B,
|
||||||
|
SITE_B,
|
||||||
|
ZONE_B,
|
||||||
|
MAC_B,
|
||||||
|
IP_B,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def _table_exists(self, cur, table):
|
||||||
|
cur.execute(
|
||||||
|
"SELECT to_regclass(%s)",
|
||||||
|
(f"public.{table}",),
|
||||||
|
)
|
||||||
|
return cur.fetchone()[0] is not None
|
||||||
|
|
||||||
|
def _snapshot_rows(self, cur, table):
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT to_jsonb(t)
|
||||||
|
FROM {table} AS t
|
||||||
|
WHERE miner_id IN (%s, %s)
|
||||||
|
ORDER BY to_jsonb(t)::text
|
||||||
|
""",
|
||||||
|
(MINER_A, MINER_B),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [row[0] for row in cur.fetchall()]
|
||||||
|
|
||||||
|
def _count_optional_miner_rows(self, cur, table, miner_id):
|
||||||
|
if not self._table_exists(cur, table):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM {table}
|
||||||
|
WHERE miner_id = %s
|
||||||
|
""",
|
||||||
|
(miner_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cur.fetchone()[0]
|
||||||
|
|
||||||
|
def _capture_inventory_state(self):
|
||||||
|
connection = connect()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id::text, to_jsonb(m)
|
||||||
|
FROM miners AS m
|
||||||
|
WHERE id IN (%s, %s)
|
||||||
|
ORDER BY id
|
||||||
|
""",
|
||||||
|
(MINER_A, MINER_B),
|
||||||
|
)
|
||||||
|
|
||||||
|
miners = {
|
||||||
|
row[0]: row[1]
|
||||||
|
for row in cur.fetchall()
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerships = self._snapshot_rows(
|
||||||
|
cur,
|
||||||
|
"miner_mac_ownerships",
|
||||||
|
)
|
||||||
|
|
||||||
|
identities = self._snapshot_rows(
|
||||||
|
cur,
|
||||||
|
"miner_network_identities",
|
||||||
|
)
|
||||||
|
|
||||||
|
counts = {}
|
||||||
|
|
||||||
|
for table in (
|
||||||
|
"miner_status_snapshots",
|
||||||
|
"miner_api3_enrichment_snapshots",
|
||||||
|
"miner_api3_hashboard_snapshots",
|
||||||
|
"miner_pool_snapshots",
|
||||||
|
):
|
||||||
|
counts[(table, "A")] = (
|
||||||
|
self._count_optional_miner_rows(
|
||||||
|
cur,
|
||||||
|
table,
|
||||||
|
MINER_A,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
counts[(table, "B")] = (
|
||||||
|
self._count_optional_miner_rows(
|
||||||
|
cur,
|
||||||
|
table,
|
||||||
|
MINER_B,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"miners": miners,
|
||||||
|
"mac_ownerships": ownerships,
|
||||||
|
"network_identities": identities,
|
||||||
|
"snapshot_counts": counts,
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def _cross_zone_api3_probe_count(self):
|
||||||
|
connection = connect()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
if not self._table_exists(
|
||||||
|
cur,
|
||||||
|
"miner_api3_enrichment_snapshots",
|
||||||
|
):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM miner_api3_enrichment_snapshots
|
||||||
|
WHERE agent_id = %s
|
||||||
|
AND (
|
||||||
|
host(ip_address) = %s
|
||||||
|
OR upper(mac_address) = upper(%s)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(AGENT_A, IP_B, MAC_B),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cur.fetchone()[0]
|
||||||
|
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def test_v2_cannot_address_other_zone_miner_directly(self):
|
||||||
|
before = self._capture_inventory_state()
|
||||||
|
|
||||||
|
request = json_request(
|
||||||
|
"/api/agent/miner-enrichment-v2",
|
||||||
|
{
|
||||||
|
"miner_id": str(MINER_B),
|
||||||
|
"mac_address": MAC_B,
|
||||||
|
"hostname": "zone-a-must-not-write-this",
|
||||||
|
"model": "cross-zone-v2-probe",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as context:
|
||||||
|
asyncio.run(
|
||||||
|
minerops_agent_miner_enrichment_v2(
|
||||||
|
request,
|
||||||
|
AGENT_A,
|
||||||
|
self.zone_service,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(context.exception.status_code, 404)
|
||||||
|
|
||||||
|
after = self._capture_inventory_state()
|
||||||
|
|
||||||
|
self.assertEqual(after, before)
|
||||||
|
|
||||||
|
def test_v2_cross_zone_mac_merge_cannot_mutate_either_miner(self):
|
||||||
|
before = self._capture_inventory_state()
|
||||||
|
|
||||||
|
request = json_request(
|
||||||
|
"/api/agent/miner-enrichment-v2",
|
||||||
|
{
|
||||||
|
"miner_id": str(MINER_A),
|
||||||
|
"ip_address": IP_A,
|
||||||
|
"mac_address": MAC_B,
|
||||||
|
"hostname": "cross-zone-merge-probe",
|
||||||
|
"miner_sn": "MUST-NOT-CROSS-ZONES",
|
||||||
|
"model": "Cross Zone Merge Probe",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
error = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(
|
||||||
|
minerops_agent_miner_enrichment_v2(
|
||||||
|
request,
|
||||||
|
AGENT_A,
|
||||||
|
self.zone_service,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
error = exc
|
||||||
|
|
||||||
|
self.assertIsNotNone(
|
||||||
|
error,
|
||||||
|
"A Zone A miner must not successfully claim a Zone B MAC",
|
||||||
|
)
|
||||||
|
|
||||||
|
after = self._capture_inventory_state()
|
||||||
|
|
||||||
|
self.assertEqual(after, before)
|
||||||
|
|
||||||
|
def test_v3_cannot_match_or_mutate_other_zone_miner(self):
|
||||||
|
before = self._capture_inventory_state()
|
||||||
|
|
||||||
|
request = json_request(
|
||||||
|
"/api/agent/miner-enrichment-v3",
|
||||||
|
{
|
||||||
|
"miner_id": str(MINER_B),
|
||||||
|
"ip_address": IP_B,
|
||||||
|
"mac_address": MAC_B,
|
||||||
|
"hostname": "zone-a-v3-cross-zone-probe",
|
||||||
|
"miner_sn": "V3-MUST-NOT-MUTATE",
|
||||||
|
"model": "V3 Cross Zone Probe",
|
||||||
|
"api_version": "99.99",
|
||||||
|
"firmware_version": "cross-zone",
|
||||||
|
"platform": "cross-zone",
|
||||||
|
"raw_version": {"cross_zone": True},
|
||||||
|
"raw_miner_info": {"cross_zone": True},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
minerops_agent_miner_enrichment_v3(
|
||||||
|
request,
|
||||||
|
AGENT_A,
|
||||||
|
self.zone_service,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result["matched"])
|
||||||
|
|
||||||
|
after = self._capture_inventory_state()
|
||||||
|
|
||||||
|
self.assertEqual(after, before)
|
||||||
|
|
||||||
|
def test_api3_ingestion_cannot_associate_or_mutate_other_zone_miner(self):
|
||||||
|
before = self._capture_inventory_state()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self._cross_zone_api3_probe_count(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = json_request(
|
||||||
|
"/api/agent/api3-enrichment",
|
||||||
|
{
|
||||||
|
"agent_id": AGENT_B,
|
||||||
|
"site_name": "spoofed-zone-b",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"ip": IP_B,
|
||||||
|
"mac": MAC_B,
|
||||||
|
"api3_supported": True,
|
||||||
|
"api3_error": None,
|
||||||
|
"device_info": {
|
||||||
|
"msg": {
|
||||||
|
"network": {
|
||||||
|
"ip": IP_B,
|
||||||
|
"mac": MAC_B,
|
||||||
|
},
|
||||||
|
"miner": {
|
||||||
|
"type": "Cross Zone API3 Probe",
|
||||||
|
"miner-sn": "API3-MUST-NOT-MUTATE",
|
||||||
|
},
|
||||||
|
"system": {
|
||||||
|
"api": "99.99",
|
||||||
|
"apiswitch": "on",
|
||||||
|
"fwversion": "cross-zone-fw",
|
||||||
|
"platform": "cross-zone-platform",
|
||||||
|
},
|
||||||
|
"power": {
|
||||||
|
"sn": "cross-zone-power",
|
||||||
|
"model": "cross-zone-psu",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"miner_setting": {
|
||||||
|
"msg": {
|
||||||
|
"power-mode": "high",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"miner_status": {
|
||||||
|
"msg": {
|
||||||
|
"summary": {
|
||||||
|
"hash-average": 999,
|
||||||
|
"power-realtime": 9999,
|
||||||
|
"power-rate": 99,
|
||||||
|
"chip-temp-avg": 99,
|
||||||
|
"chip-temp-max": 100,
|
||||||
|
},
|
||||||
|
"pools": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"status": "Alive",
|
||||||
|
"account": "cross-zone-worker",
|
||||||
|
"url": "stratum+tcp://invalid.example:3333",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edevs": [
|
||||||
|
{
|
||||||
|
"slot": 0,
|
||||||
|
"hash-average": 999,
|
||||||
|
"factory-hash": 999,
|
||||||
|
"freq": 999,
|
||||||
|
"effective-chips": 999,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fan_setting": {
|
||||||
|
"msg": {
|
||||||
|
"mode": "cross-zone",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"custom_data": {
|
||||||
|
"msg": {
|
||||||
|
"CustomerSn": "CROSS-ZONE-CUSTOMER",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
minerops_agent_api3_enrichment(
|
||||||
|
request,
|
||||||
|
AGENT_A,
|
||||||
|
self.zone_service,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
after = self._capture_inventory_state()
|
||||||
|
|
||||||
|
self.assertEqual(after, before)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self._cross_zone_api3_probe_count(),
|
||||||
|
0,
|
||||||
|
(
|
||||||
|
"Agent A must not persist API3 enrichment associated "
|
||||||
|
"with Zone B identity data"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""Tests for downloadable Windows Agent enrollment packages."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.agent_enrollment.router import router
|
||||||
|
from app.agent_packages.windows import (
|
||||||
|
AgentWindowsPackageError,
|
||||||
|
AgentWindowsPackageSourceError,
|
||||||
|
WINDOWS_PACKAGE_FILES,
|
||||||
|
WINDOWS_PACKAGE_ROOT,
|
||||||
|
build_windows_agent_package,
|
||||||
|
normalize_hub_url,
|
||||||
|
)
|
||||||
|
from app.identity import get_current_user, required_api_permission
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN = "t" * 43
|
||||||
|
|
||||||
|
|
||||||
|
class WindowsPackageBuilderTests(unittest.TestCase):
|
||||||
|
def source_tree(self, root: Path):
|
||||||
|
for name in WINDOWS_PACKAGE_FILES:
|
||||||
|
(root / name).write_text(f"source:{name}\n")
|
||||||
|
|
||||||
|
def test_package_contains_only_generic_source_plus_bootstrap_material(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
self.source_tree(root)
|
||||||
|
|
||||||
|
package = build_windows_agent_package(
|
||||||
|
enrollment_token=TOKEN,
|
||||||
|
hub_url="https://minerops.example.test",
|
||||||
|
source_root=root,
|
||||||
|
)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(package)) as archive:
|
||||||
|
names = set(archive.namelist())
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
f"{WINDOWS_PACKAGE_ROOT}/{name}"
|
||||||
|
for name in WINDOWS_PACKAGE_FILES
|
||||||
|
}
|
||||||
|
|
||||||
|
expected.update(
|
||||||
|
{
|
||||||
|
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json",
|
||||||
|
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(names, expected)
|
||||||
|
|
||||||
|
enrollment = json.loads(
|
||||||
|
archive.read(
|
||||||
|
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(enrollment["token"], TOKEN)
|
||||||
|
self.assertEqual(enrollment["workflow"], "new_agent")
|
||||||
|
self.assertEqual(
|
||||||
|
enrollment["hub_url"],
|
||||||
|
"https://minerops.example.test",
|
||||||
|
)
|
||||||
|
|
||||||
|
serialized = json.dumps(enrollment)
|
||||||
|
|
||||||
|
self.assertNotIn("agent_secret", serialized)
|
||||||
|
self.assertNotIn("api3_password", serialized)
|
||||||
|
self.assertNotIn("api3_account", serialized)
|
||||||
|
self.assertNotIn("agent-config.json", "\n".join(names))
|
||||||
|
|
||||||
|
def test_existing_agent_transition_package_is_generic_and_distinct(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
self.source_tree(root)
|
||||||
|
package = build_windows_agent_package(
|
||||||
|
enrollment_token=TOKEN,
|
||||||
|
hub_url="https://minerops.example.test",
|
||||||
|
workflow="existing_agent_credential_transition",
|
||||||
|
source_root=root,
|
||||||
|
)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(package)) as archive:
|
||||||
|
enrollment = json.loads(
|
||||||
|
archive.read(
|
||||||
|
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
instructions = archive.read(
|
||||||
|
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt"
|
||||||
|
).decode("utf-8")
|
||||||
|
names = set(archive.namelist())
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
enrollment,
|
||||||
|
{
|
||||||
|
"hub_url": "https://minerops.example.test",
|
||||||
|
"token": TOKEN,
|
||||||
|
"workflow": "existing_agent_credential_transition",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertIn("Upgrade-MinerOpsAgentCredential.ps1", instructions)
|
||||||
|
self.assertIn(
|
||||||
|
f"{WINDOWS_PACKAGE_ROOT}/Upgrade-MinerOpsAgentCredential.ps1",
|
||||||
|
names,
|
||||||
|
)
|
||||||
|
serialized = json.dumps(enrollment)
|
||||||
|
for forbidden in (
|
||||||
|
"plexus-windows-agent",
|
||||||
|
"site_id",
|
||||||
|
"agent_zone_id",
|
||||||
|
"agent_secret",
|
||||||
|
):
|
||||||
|
self.assertNotIn(forbidden, serialized)
|
||||||
|
|
||||||
|
def test_missing_canonical_source_fails_closed(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
self.source_tree(root)
|
||||||
|
|
||||||
|
(root / WINDOWS_PACKAGE_FILES[0]).unlink()
|
||||||
|
|
||||||
|
with self.assertRaises(AgentWindowsPackageSourceError):
|
||||||
|
build_windows_agent_package(
|
||||||
|
enrollment_token=TOKEN,
|
||||||
|
hub_url="https://minerops.example.test",
|
||||||
|
source_root=root,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hub_url_is_origin_only(self):
|
||||||
|
self.assertEqual(
|
||||||
|
normalize_hub_url("https://minerops.example.test/"),
|
||||||
|
"https://minerops.example.test",
|
||||||
|
)
|
||||||
|
|
||||||
|
for invalid in (
|
||||||
|
"ftp://minerops.example.test",
|
||||||
|
"https://user:pass@minerops.example.test",
|
||||||
|
"https://minerops.example.test/path",
|
||||||
|
"https://minerops.example.test/?token=x",
|
||||||
|
):
|
||||||
|
with self.subTest(value=invalid):
|
||||||
|
with self.assertRaises(AgentWindowsPackageError):
|
||||||
|
normalize_hub_url(invalid)
|
||||||
|
|
||||||
|
|
||||||
|
class WindowsPackageRouteTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.app = FastAPI()
|
||||||
|
self.app.include_router(router)
|
||||||
|
self.client = TestClient(self.app)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
def authenticate(self, permissions):
|
||||||
|
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||||
|
"id": "00000000-0000-4000-8000-000000009100",
|
||||||
|
"username": "package-test",
|
||||||
|
"display_name": "Package Test",
|
||||||
|
"role": "Test",
|
||||||
|
"permissions": tuple(permissions),
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_permission_mapping_is_manage_only(self):
|
||||||
|
self.assertEqual(
|
||||||
|
required_api_permission(
|
||||||
|
"POST",
|
||||||
|
"/api/agent-enrollments/windows-package",
|
||||||
|
),
|
||||||
|
"agents.manage",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
required_api_permission(
|
||||||
|
"POST",
|
||||||
|
"/api/agent-credential-transitions/windows-package",
|
||||||
|
),
|
||||||
|
"agents.manage",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_view_only_user_cannot_download_package(self):
|
||||||
|
self.authenticate(("agents.view",))
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/agent-enrollments/windows-package",
|
||||||
|
headers={"Origin": "http://testserver"},
|
||||||
|
json={
|
||||||
|
"token": TOKEN,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_manager_download_is_same_origin_and_not_cacheable(self):
|
||||||
|
self.authenticate(("agents.view", "agents.manage"))
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/agent-enrollments/windows-package",
|
||||||
|
headers={"Origin": "http://testserver"},
|
||||||
|
json={
|
||||||
|
"token": TOKEN,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
response.headers["content-type"],
|
||||||
|
"application/zip",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
response.headers["cache-control"],
|
||||||
|
"no-store",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'filename="MinerOpsAgent-Windows.zip"',
|
||||||
|
response.headers["content-disposition"],
|
||||||
|
)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
||||||
|
enrollment = json.loads(
|
||||||
|
archive.read(
|
||||||
|
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(enrollment["token"], TOKEN)
|
||||||
|
self.assertEqual(enrollment["workflow"], "new_agent")
|
||||||
|
|
||||||
|
def test_manager_downloads_distinct_existing_agent_upgrade_package(self):
|
||||||
|
self.authenticate(("agents.view", "agents.manage"))
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/agent-credential-transitions/windows-package",
|
||||||
|
headers={"Origin": "http://testserver"},
|
||||||
|
json={"token": TOKEN},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||||
|
self.assertIn(
|
||||||
|
"MinerOpsAgent-Credential-Upgrade-Windows.zip",
|
||||||
|
response.headers["content-disposition"],
|
||||||
|
)
|
||||||
|
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
||||||
|
enrollment = json.loads(
|
||||||
|
archive.read(f"{WINDOWS_PACKAGE_ROOT}/enrollment.json")
|
||||||
|
)
|
||||||
|
instructions = archive.read(
|
||||||
|
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt"
|
||||||
|
).decode("utf-8")
|
||||||
|
self.assertEqual(
|
||||||
|
enrollment["workflow"],
|
||||||
|
"existing_agent_credential_transition",
|
||||||
|
)
|
||||||
|
self.assertIn("Upgrade-MinerOpsAgentCredential.ps1", instructions)
|
||||||
|
|
||||||
|
def test_package_requires_browser_origin(self):
|
||||||
|
self.authenticate(("agents.view", "agents.manage"))
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/agent-enrollments/windows-package",
|
||||||
|
json={
|
||||||
|
"token": TOKEN,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
"""Focused model, service, router, and architecture tests for Agent Zones v1."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.agent_commands.dependencies import require_agent_identity
|
||||||
|
from app.agent_zones.dependencies import get_agent_zone_service
|
||||||
|
from app.agent_zones.exceptions import AgentZoneValidationError
|
||||||
|
from app.agent_zones.models import AgentCheckinRequest, AgentZoneCreateRequest
|
||||||
|
from app.agent_zones.router import router
|
||||||
|
from app.agent_zones.service import AgentZoneService
|
||||||
|
from app.identity import get_current_user, required_api_permission
|
||||||
|
|
||||||
|
|
||||||
|
SITE_A = UUID("00000000-0000-4000-8000-000000001601")
|
||||||
|
SITE_B = UUID("00000000-0000-4000-8000-000000001602")
|
||||||
|
ZONE_A = UUID("00000000-0000-4000-8000-000000001611")
|
||||||
|
ZONE_B = UUID("00000000-0000-4000-8000-000000001612")
|
||||||
|
MINER_A = UUID("00000000-0000-4000-8000-000000001621")
|
||||||
|
APP_ROOT = Path(__file__).parents[1] / "app"
|
||||||
|
|
||||||
|
|
||||||
|
def zone(zone_id=ZONE_A, site_id=SITE_A, *, enabled=True, name="Main Room"):
|
||||||
|
return {
|
||||||
|
"id": zone_id,
|
||||||
|
"site_id": site_id,
|
||||||
|
"name": name,
|
||||||
|
"description": None,
|
||||||
|
"is_enabled": enabled,
|
||||||
|
"assigned_agent_id": None,
|
||||||
|
"networks": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRepository:
|
||||||
|
def __init__(self):
|
||||||
|
self.zones = {ZONE_A: zone()}
|
||||||
|
self.agent = None
|
||||||
|
self.saved = []
|
||||||
|
self.assign_result = {MINER_A: "assigned"}
|
||||||
|
|
||||||
|
def get_agent(self, agent_id):
|
||||||
|
return self.agent if self.agent and self.agent["agent_id"] == agent_id else None
|
||||||
|
|
||||||
|
def get_zone(self, zone_id):
|
||||||
|
return self.zones.get(zone_id)
|
||||||
|
|
||||||
|
def list_usable_zones(self, site_id):
|
||||||
|
return [
|
||||||
|
value for value in self.zones.values()
|
||||||
|
if value["site_id"] == site_id and value["is_enabled"]
|
||||||
|
]
|
||||||
|
|
||||||
|
def list_zones(self, site_id=None):
|
||||||
|
return [
|
||||||
|
value for value in self.zones.values()
|
||||||
|
if site_id is None or value["site_id"] == site_id
|
||||||
|
]
|
||||||
|
|
||||||
|
def create_zone(self, *, site_id, name, description, networks):
|
||||||
|
created_id = uuid4()
|
||||||
|
self.zones[created_id] = {
|
||||||
|
**zone(created_id, site_id, name=name),
|
||||||
|
"description": description,
|
||||||
|
"networks": [
|
||||||
|
{"id": uuid4(), "network": network} for network in networks
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return created_id
|
||||||
|
|
||||||
|
def save_checkin(self, **kwargs):
|
||||||
|
self.saved.append(kwargs)
|
||||||
|
self.agent = {
|
||||||
|
"agent_id": kwargs["agent_id"],
|
||||||
|
"site_id": kwargs["site_id"],
|
||||||
|
"agent_zone_id": kwargs["agent_zone_id"],
|
||||||
|
"site_name": kwargs["site_name"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_agent_assignment(self, agent_id):
|
||||||
|
if not self.agent or self.agent["agent_id"] != agent_id:
|
||||||
|
return None
|
||||||
|
zone_row = self.zones.get(self.agent.get("agent_zone_id"))
|
||||||
|
return {
|
||||||
|
**self.agent,
|
||||||
|
"zone_enabled": zone_row["is_enabled"] if zone_row else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_agent_configuration(self, agent_id):
|
||||||
|
assignment = self.get_agent_assignment(agent_id)
|
||||||
|
if not assignment:
|
||||||
|
return None
|
||||||
|
zone_row = self.zones.get(assignment.get("agent_zone_id"))
|
||||||
|
return {
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"site_id": assignment.get("site_id"),
|
||||||
|
"agent_zone_id": assignment.get("agent_zone_id"),
|
||||||
|
"site_name": assignment.get("site_name"),
|
||||||
|
"agent_zone_name": zone_row["name"] if zone_row else None,
|
||||||
|
"agent_zone_enabled": zone_row["is_enabled"] if zone_row else None,
|
||||||
|
"networks": [
|
||||||
|
item["network"] for item in (zone_row or {}).get("networks", [])
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
for miner_id in miner_ids}
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneModelTests(unittest.TestCase):
|
||||||
|
def test_names_and_networks_are_normalized(self):
|
||||||
|
request = AgentZoneCreateRequest(
|
||||||
|
site_id=SITE_A,
|
||||||
|
name=" Main Room ",
|
||||||
|
description=" Local VLAN ",
|
||||||
|
networks=["10.30.0.0/24", "2001:db8::/64"],
|
||||||
|
)
|
||||||
|
self.assertEqual(request.name, "Main Room")
|
||||||
|
self.assertEqual(request.description, "Local VLAN")
|
||||||
|
self.assertEqual(request.networks, ["10.30.0.0/24", "2001:db8::/64"])
|
||||||
|
|
||||||
|
def test_invalid_host_network_duplicate_and_extra_fields_are_rejected(self):
|
||||||
|
for networks in (["10.30.0.7/24"], ["10.30.0.0/24", "10.30.0.0/24"]):
|
||||||
|
with self.subTest(networks=networks), self.assertRaises(Exception):
|
||||||
|
AgentZoneCreateRequest(site_id=SITE_A, name="Zone", networks=networks)
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
AgentZoneCreateRequest(site_id=SITE_A, name="Zone", unknown=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneServiceTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.repository = FakeRepository()
|
||||||
|
self.service = AgentZoneService(self.repository)
|
||||||
|
|
||||||
|
def checkin(self, **kwargs):
|
||||||
|
return self.service.checkin(
|
||||||
|
"agent-a",
|
||||||
|
AgentCheckinRequest(agent_id="agent-a", site_name="North", **kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_explicit_first_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"):
|
||||||
|
self.checkin(site_id=SITE_B, agent_zone_id=ZONE_A)
|
||||||
|
with self.assertRaisesRegex(AgentZoneValidationError, "authenticated"):
|
||||||
|
self.service.checkin(
|
||||||
|
"agent-a", AgentCheckinRequest(agent_id="agent-b", site_id=SITE_A)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_established_agent_cannot_change_zone_within_site(self):
|
||||||
|
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||||
|
self.repository.zones[ZONE_B] = zone(ZONE_B, SITE_A, name="Room 2")
|
||||||
|
with self.assertRaisesRegex(AgentZoneValidationError, "different Agent Zone"):
|
||||||
|
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_B)
|
||||||
|
self.assertEqual(self.repository.agent["agent_zone_id"], ZONE_A)
|
||||||
|
|
||||||
|
def test_established_agent_site_conflict_precedes_zone_conflict(self):
|
||||||
|
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||||
|
self.repository.zones[ZONE_B] = zone(ZONE_B, SITE_B, name="Remote")
|
||||||
|
with self.assertRaisesRegex(AgentZoneValidationError, "cannot change Sites"):
|
||||||
|
self.checkin(site_id=SITE_B, agent_zone_id=ZONE_B)
|
||||||
|
self.assertEqual(self.repository.agent["agent_zone_id"], ZONE_A)
|
||||||
|
|
||||||
|
def test_omitted_zone_preserves_established_assignment_and_site(self):
|
||||||
|
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||||
|
result = self.service.checkin(
|
||||||
|
"agent-a", AgentCheckinRequest(agent_id="agent-a", site_name="North")
|
||||||
|
)
|
||||||
|
self.assertEqual(result.site_id, SITE_A)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def test_payload_is_sanitized_before_persistence(self):
|
||||||
|
request = AgentCheckinRequest.model_validate({
|
||||||
|
"agent_id": "agent-a", "site_id": str(SITE_A),
|
||||||
|
"api3_password": "secret", "nested": {"access_token": "secret"},
|
||||||
|
"control_available": True,
|
||||||
|
})
|
||||||
|
self.service.checkin("agent-a", request)
|
||||||
|
payload = self.repository.saved[-1]["payload"]
|
||||||
|
self.assertNotIn("password", str(payload).lower())
|
||||||
|
self.assertNotIn("token", str(payload).lower())
|
||||||
|
self.assertTrue(payload["control_available"])
|
||||||
|
|
||||||
|
def test_miner_zone_assignment_surfaces_conflicts_without_rehoming(self):
|
||||||
|
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||||
|
self.repository.assign_result[MINER_A] = "agent_zone_conflict"
|
||||||
|
result = self.service.associate_miners_from_agent("agent-a", [MINER_A])
|
||||||
|
self.assertEqual(result.outcomes[MINER_A], "agent_zone_conflict")
|
||||||
|
self.assertEqual(self.repository.assigned, ([MINER_A], ZONE_A))
|
||||||
|
|
||||||
|
def test_routing_context_rejects_stale_recorded_zone(self):
|
||||||
|
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||||
|
result = self.service.routing_context("agent-a", ZONE_B)
|
||||||
|
self.assertEqual(result.routing_reason, "agent_zone_conflict")
|
||||||
|
self.assertIsNone(result.site_id)
|
||||||
|
|
||||||
|
def test_configuration_uses_only_authenticated_agent_zone_networks(self):
|
||||||
|
self.repository.zones[ZONE_A]["networks"] = [
|
||||||
|
{"id": uuid4(), "network": "10.30.0.0/24"}
|
||||||
|
]
|
||||||
|
self.repository.zones[ZONE_B] = zone(ZONE_B, SITE_B, name="Remote")
|
||||||
|
self.repository.zones[ZONE_B]["networks"] = [
|
||||||
|
{"id": uuid4(), "network": "10.80.0.0/24"}
|
||||||
|
]
|
||||||
|
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||||
|
|
||||||
|
configuration = self.service.configuration("agent-a")
|
||||||
|
|
||||||
|
self.assertEqual(configuration["agent_zone_id"], ZONE_A)
|
||||||
|
self.assertEqual(
|
||||||
|
[item["cidr"] for item in configuration["ip_ranges"]],
|
||||||
|
["10.30.0.0/24"],
|
||||||
|
)
|
||||||
|
self.assertNotIn("10.80.0.0/24", str(configuration))
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneRouterTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.repository = FakeRepository()
|
||||||
|
self.service = AgentZoneService(self.repository)
|
||||||
|
self.app = FastAPI()
|
||||||
|
self.app.include_router(router)
|
||||||
|
self.app.dependency_overrides[get_agent_zone_service] = lambda: self.service
|
||||||
|
self.app.dependency_overrides[require_agent_identity] = lambda: "agent-a"
|
||||||
|
self.client = TestClient(self.app)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
def authenticate(self, permissions):
|
||||||
|
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||||
|
"id": str(uuid4()), "username": "operator", "role": "Custom",
|
||||||
|
"permissions": list(permissions),
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_list_and_create_use_existing_agent_permissions(self):
|
||||||
|
self.assertEqual(self.client.get("/api/agent-zones").status_code, 401)
|
||||||
|
self.authenticate(("agents.view",))
|
||||||
|
self.assertEqual(self.client.get("/api/agent-zones").status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.post(
|
||||||
|
"/api/agent-zones", json={"site_id": str(SITE_A), "name": "Room 2"}
|
||||||
|
).status_code,
|
||||||
|
403,
|
||||||
|
)
|
||||||
|
self.authenticate(("agents.manage",))
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/agent-zones",
|
||||||
|
json={"site_id": str(SITE_A), "name": "Room 2", "networks": ["10.40.0.0/24"]},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertEqual(response.json()["data"]["name"], "Room 2")
|
||||||
|
|
||||||
|
def test_checkin_uses_authenticated_agent_identity(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/agent/checkin",
|
||||||
|
json={"agent_id": "agent-a", "site_id": str(SITE_A),
|
||||||
|
"agent_zone_id": str(ZONE_A)},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["agent_id"], "agent-a")
|
||||||
|
self.assertNotIn("secret", response.text.lower())
|
||||||
|
|
||||||
|
def test_permission_and_architecture_audits(self):
|
||||||
|
self.assertEqual(required_api_permission("GET", "/api/agent-zones"), "agents.view")
|
||||||
|
self.assertEqual(required_api_permission("POST", "/api/agent-zones"), "agents.manage")
|
||||||
|
router_source = (APP_ROOT / "agent_zones" / "router.py").read_text()
|
||||||
|
service_source = (APP_ROOT / "agent_zones" / "service.py").read_text()
|
||||||
|
repository_source = (APP_ROOT / "agent_zones" / "repository.py").read_text()
|
||||||
|
self.assertNotIn("psycopg", router_source)
|
||||||
|
self.assertNotIn("SELECT ", router_source)
|
||||||
|
self.assertNotIn("fastapi", service_source.lower())
|
||||||
|
self.assertNotIn("fastapi", repository_source.lower())
|
||||||
|
self.assertNotIn('@app.post("/api/agent/checkin")', (APP_ROOT / "main.py").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"""Real PostgreSQL integrity and routing tests for Agent Zones v1."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.errors import ForeignKeyViolation, UniqueViolation
|
||||||
|
|
||||||
|
from app.agent_zones.repository import AgentZoneRepository
|
||||||
|
from app.config import settings
|
||||||
|
from app.miner_control.repository import MinerControlRepository
|
||||||
|
from app.miner_control.service import MinerControlService
|
||||||
|
|
||||||
|
|
||||||
|
SITE_A = UUID("00000000-0000-4000-8000-000000001630")
|
||||||
|
SITE_B = UUID("00000000-0000-4000-8000-000000001631")
|
||||||
|
ZONE_A = UUID("00000000-0000-4000-8000-000000001632")
|
||||||
|
ZONE_B = UUID("00000000-0000-4000-8000-000000001633")
|
||||||
|
ZONE_C = UUID("00000000-0000-4000-8000-000000001636")
|
||||||
|
MINER_UNASSIGNED = UUID("00000000-0000-4000-8000-000000001634")
|
||||||
|
MINER_OTHER = UUID("00000000-0000-4000-8000-000000001635")
|
||||||
|
AGENT_A = "agent-zone-postgres-a"
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
return psycopg.connect(
|
||||||
|
host=settings.postgres_host,
|
||||||
|
port=settings.postgres_port,
|
||||||
|
dbname=settings.postgres_db,
|
||||||
|
user=settings.postgres_user,
|
||||||
|
password=settings.postgres_password,
|
||||||
|
connect_timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZonePostgresTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO sites (id, name) VALUES (%s, 'Zone Test A'), "
|
||||||
|
"(%s, 'Zone Test B') ON CONFLICT (id) DO NOTHING",
|
||||||
|
(SITE_A, SITE_B),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO agent_zones (id, site_id, name) VALUES "
|
||||||
|
"(%s, %s, 'Main Room'), (%s, %s, 'Remote Room'), "
|
||||||
|
"(%s, %s, 'Unstaffed Zone') "
|
||||||
|
"ON CONFLICT (id) DO UPDATE SET is_enabled = true",
|
||||||
|
(ZONE_A, SITE_A, ZONE_B, SITE_B, ZONE_C, SITE_A),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO miners (id, site_id, asset_tag, current_ip, status, "
|
||||||
|
"api3_supported, first_seen_at, last_seen_at) VALUES "
|
||||||
|
"(%s, %s, 'ZONE-UNASSIGNED', '192.0.2.31', 'online', true, NULL, NULL), "
|
||||||
|
"(%s, %s, 'ZONE-OTHER', '192.0.2.32', 'online', true, NULL, NULL) "
|
||||||
|
"ON CONFLICT (id) DO UPDATE SET agent_zone_id = NULL",
|
||||||
|
(MINER_UNASSIGNED, SITE_A, MINER_OTHER, SITE_B),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
|
||||||
|
(ZONE_B, MINER_OTHER),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO minerops_site_agents ("
|
||||||
|
"agent_id, site_id, agent_zone_id, site_name, last_payload) "
|
||||||
|
"VALUES (%s, %s, %s, 'Zone Test A', '{\"control_available\":true}') "
|
||||||
|
"ON CONFLICT (agent_id) DO UPDATE SET site_id = EXCLUDED.site_id, "
|
||||||
|
"agent_zone_id = EXCLUDED.agent_zone_id, last_payload = EXCLUDED.last_payload",
|
||||||
|
(AGENT_A, SITE_A, ZONE_A),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM minerops_site_agents WHERE agent_id LIKE 'agent-zone-postgres-%'")
|
||||||
|
cur.execute("DELETE FROM miners WHERE id IN (%s, %s)", (MINER_UNASSIGNED, MINER_OTHER))
|
||||||
|
cur.execute("DELETE FROM agent_zone_network_ranges WHERE agent_zone_id IN (%s, %s, %s)", (ZONE_A, ZONE_B, ZONE_C))
|
||||||
|
cur.execute("DELETE FROM agent_zones WHERE id IN (%s, %s, %s)", (ZONE_A, ZONE_B, ZONE_C))
|
||||||
|
cur.execute("DELETE FROM sites WHERE id IN (%s, %s)", (SITE_A, SITE_B))
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE agent_zones SET is_enabled = true "
|
||||||
|
"WHERE id IN (%s, %s, %s)",
|
||||||
|
(ZONE_A, ZONE_B, ZONE_C),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE miners SET agent_zone_id = NULL WHERE id = %s",
|
||||||
|
(MINER_UNASSIGNED,),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE miners SET site_id = %s, agent_zone_id = %s WHERE id = %s",
|
||||||
|
(SITE_B, ZONE_B, MINER_OTHER),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE minerops_site_agents SET site_id = %s, agent_zone_id = %s "
|
||||||
|
"WHERE agent_id = %s",
|
||||||
|
(SITE_A, ZONE_A, AGENT_A),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def test_repository_lists_multiple_canonical_networks(self):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO agent_zone_network_ranges (agent_zone_id, network) "
|
||||||
|
"VALUES (%s, '10.30.0.0/24'), (%s, '10.31.0.0/24') "
|
||||||
|
"ON CONFLICT DO NOTHING",
|
||||||
|
(ZONE_A, ZONE_A),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
listed = AgentZoneRepository(connect).list_zones(SITE_A)
|
||||||
|
self.assertEqual([item["network"] for item in listed[0]["networks"]],
|
||||||
|
["10.30.0.0/24", "10.31.0.0/24"])
|
||||||
|
|
||||||
|
def test_active_names_are_case_insensitively_unique_per_site(self):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with self.assertRaises(UniqueViolation), connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO agent_zones (site_id, name) VALUES (%s, 'main room')",
|
||||||
|
(SITE_A,),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
connection.rollback()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def test_one_agent_per_zone_but_multiple_zones_per_site(self):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with self.assertRaises(UniqueViolation), connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO minerops_site_agents "
|
||||||
|
"(agent_id, site_id, agent_zone_id, site_name) "
|
||||||
|
"VALUES ('agent-zone-postgres-duplicate', %s, %s, 'Duplicate')",
|
||||||
|
(SITE_A, ZONE_A),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
connection.rollback()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def test_composite_foreign_keys_reject_cross_site_assignment(self):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with self.assertRaises(ForeignKeyViolation), connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
|
||||||
|
(ZONE_A, MINER_OTHER),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
connection.rollback()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def test_unassigned_miner_inherits_zone_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_OTHER], "agent_zone_conflict")
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, agent_zone_id FROM miners WHERE id IN (%s, %s)",
|
||||||
|
(MINER_UNASSIGNED, MINER_OTHER),
|
||||||
|
)
|
||||||
|
assignments = dict(cur.fetchall())
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
self.assertEqual(assignments[MINER_UNASSIGNED], ZONE_A)
|
||||||
|
self.assertEqual(assignments[MINER_OTHER], ZONE_B)
|
||||||
|
|
||||||
|
def test_disabled_zone_blocks_control_without_site_fallback(self):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
|
||||||
|
(ZONE_A, MINER_UNASSIGNED),
|
||||||
|
)
|
||||||
|
cur.execute("UPDATE agent_zones SET is_enabled = false WHERE id = %s", (ZONE_A,))
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
try:
|
||||||
|
projection = MinerControlService(MinerControlRepository(connect)).get_controls(
|
||||||
|
MINER_UNASSIGNED
|
||||||
|
)
|
||||||
|
self.assertEqual(projection.service_control.reason, "agent_zone_disabled")
|
||||||
|
finally:
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute("UPDATE agent_zones SET is_enabled = true WHERE id = %s", (ZONE_A,))
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabled_zone_without_agent_does_not_fall_back_within_site(self):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
|
||||||
|
(ZONE_C, MINER_UNASSIGNED),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
projection = MinerControlService(MinerControlRepository(connect)).get_controls(
|
||||||
|
MINER_UNASSIGNED
|
||||||
|
)
|
||||||
|
self.assertEqual(projection.service_control.reason, "agent_unavailable")
|
||||||
|
|
||||||
|
def test_null_zone_does_not_fall_back_to_site_agent(self):
|
||||||
|
projection = MinerControlService(MinerControlRepository(connect)).get_controls(
|
||||||
|
MINER_UNASSIGNED
|
||||||
|
)
|
||||||
|
self.assertEqual(projection.service_control.reason, "agent_zone_unassigned")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""Full HTTP/FastAPI plus PostgreSQL contract for the unchanged legacy Agent."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.agent_auth.service import hash_agent_secret
|
||||||
|
from app.config import settings
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
SITE_A = UUID("00000000-0000-4000-8000-000000001a01")
|
||||||
|
SITE_B = UUID("00000000-0000-4000-8000-000000001a02")
|
||||||
|
ZONE_C = UUID("00000000-0000-4000-8000-000000001a03")
|
||||||
|
MINER_A = UUID("00000000-0000-4000-8000-000000001a11")
|
||||||
|
MINER_B = UUID("00000000-0000-4000-8000-000000001a12")
|
||||||
|
LEGACY_AGENT = "legacy-plexus-http"
|
||||||
|
ENROLLED_AGENT = "enrolled-http-agent"
|
||||||
|
REVOKED_AGENT = "revoked-http-agent"
|
||||||
|
ENROLLED_SECRET = "enrolled-http-agent-secret"
|
||||||
|
REVOKED_SECRET = "revoked-http-agent-secret"
|
||||||
|
ZONE_A_NETWORK = "10.31.0.0/24"
|
||||||
|
ZONE_B_NETWORK = "10.81.0.0/24"
|
||||||
|
ZONE_C_NETWORK = "10.82.0.0/24"
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
from app.agent_auth.repository import default_connection_factory
|
||||||
|
|
||||||
|
return default_connection_factory()
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyAgentHttpPostgresTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
if os.environ.get("MINEROPS_LEGACY_HTTP_POSTGRES_DISPOSABLE") != "1":
|
||||||
|
raise unittest.SkipTest(
|
||||||
|
"full-stack legacy contract requires its disposable runner"
|
||||||
|
)
|
||||||
|
if settings.minerops_agent_id != LEGACY_AGENT:
|
||||||
|
raise AssertionError("runner did not bind the configured legacy identity")
|
||||||
|
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT agent_zone_id
|
||||||
|
FROM minerops_site_agents
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
(LEGACY_AGENT,),
|
||||||
|
)
|
||||||
|
cls.zone_a = cur.fetchone()[0]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM agent_zones
|
||||||
|
WHERE site_id = %s AND name = 'Default Zone'
|
||||||
|
""",
|
||||||
|
(SITE_B,),
|
||||||
|
)
|
||||||
|
cls.zone_b = cur.fetchone()[0]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||||
|
VALUES
|
||||||
|
(%s, %s::cidr),
|
||||||
|
(%s, %s::cidr)
|
||||||
|
""",
|
||||||
|
(cls.zone_a, ZONE_A_NETWORK, cls.zone_b, ZONE_B_NETWORK),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zones (id, site_id, name)
|
||||||
|
VALUES (%s, %s, 'Revoked Contract Zone')
|
||||||
|
""",
|
||||||
|
(ZONE_C, SITE_B),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||||
|
VALUES (%s, %s::cidr)
|
||||||
|
""",
|
||||||
|
(ZONE_C, ZONE_C_NETWORK),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id, site_id, agent_zone_id, site_name, display_name,
|
||||||
|
agent_secret_hash, credential_created_at,
|
||||||
|
credential_revoked_at, legacy_auth_disabled_at
|
||||||
|
) VALUES
|
||||||
|
(%s, %s, %s, 'Contract Site B', 'Enrolled Agent',
|
||||||
|
%s, now(), NULL, now()),
|
||||||
|
(%s, %s, %s, 'Contract Site B', 'Revoked Agent',
|
||||||
|
%s, now() - interval '1 hour', now(),
|
||||||
|
now() - interval '1 hour')
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
ENROLLED_AGENT,
|
||||||
|
SITE_B,
|
||||||
|
cls.zone_b,
|
||||||
|
hash_agent_secret(ENROLLED_SECRET),
|
||||||
|
REVOKED_AGENT,
|
||||||
|
SITE_B,
|
||||||
|
ZONE_C,
|
||||||
|
hash_agent_secret(REVOKED_SECRET),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
# Deliberately do not enter TestClient as a context manager. That keeps
|
||||||
|
# unrelated application startup jobs out of this focused HTTP contract
|
||||||
|
# while exercising the real ASGI middleware, dependencies, and routes.
|
||||||
|
cls.client = TestClient(app)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.client.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def legacy_headers():
|
||||||
|
return {"X-MinerOps-Agent-Secret": settings.minerops_agent_shared_secret}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def agent_headers(agent_id, secret):
|
||||||
|
return {
|
||||||
|
"X-MinerOps-Agent-ID": agent_id,
|
||||||
|
"X-MinerOps-Agent-Secret": secret,
|
||||||
|
}
|
||||||
|
|
||||||
|
def assert_no_credentials(self, response):
|
||||||
|
lowered = response.text.lower()
|
||||||
|
for forbidden in (
|
||||||
|
"agent_secret_hash", "credential_created_at", "token_hash",
|
||||||
|
settings.minerops_agent_shared_secret.lower(),
|
||||||
|
ENROLLED_SECRET.lower(), REVOKED_SECRET.lower(),
|
||||||
|
):
|
||||||
|
self.assertNotIn(forbidden, lowered)
|
||||||
|
|
||||||
|
def test_10_secret_only_config_is_legacy_identity_and_zone_bound(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/agent/config?agent_id=" + ENROLLED_AGENT
|
||||||
|
+ "&agent_zone_id=" + str(self.zone_b),
|
||||||
|
headers=self.legacy_headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(payload["agent_id"], LEGACY_AGENT)
|
||||||
|
self.assertEqual(payload["agent_zone_id"], str(self.zone_a))
|
||||||
|
self.assertEqual(
|
||||||
|
[item["cidr"] for item in payload["ip_ranges"]],
|
||||||
|
[ZONE_A_NETWORK],
|
||||||
|
)
|
||||||
|
self.assertNotIn(ZONE_B_NETWORK, response.text)
|
||||||
|
self.assertNotIn(ZONE_C_NETWORK, response.text)
|
||||||
|
self.assert_no_credentials(response)
|
||||||
|
|
||||||
|
def test_20_known_miners_returns_only_migrated_legacy_zone(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/agent/known-miners", headers=self.legacy_headers()
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
self.assertEqual(
|
||||||
|
[item["miner_id"] for item in response.json()["miners"]],
|
||||||
|
[str(MINER_A)],
|
||||||
|
)
|
||||||
|
self.assertNotIn(str(MINER_B), response.text)
|
||||||
|
self.assert_no_credentials(response)
|
||||||
|
|
||||||
|
def test_30_exact_old_checkin_shape_succeeds_and_spoofing_fails(self):
|
||||||
|
old_body = {
|
||||||
|
"agent_id": LEGACY_AGENT,
|
||||||
|
"site_name": "Plexus",
|
||||||
|
"hostname": "legacy-contract-host",
|
||||||
|
"version": "0.4-command-queue",
|
||||||
|
"ip_address": "",
|
||||||
|
"local_time": "2026-08-31T00:00:00Z",
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/agent/checkin", headers=self.legacy_headers(), json=old_body
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
self.assertEqual(response.json()["agent_id"], LEGACY_AGENT)
|
||||||
|
self.assertEqual(response.json()["agent_zone_id"], str(self.zone_a))
|
||||||
|
self.assert_no_credentials(response)
|
||||||
|
|
||||||
|
spoofed = self.client.post(
|
||||||
|
"/api/agent/checkin?agent_id=" + ENROLLED_AGENT,
|
||||||
|
headers=self.legacy_headers(),
|
||||||
|
json={**old_body, "agent_id": "body-spoof-agent"},
|
||||||
|
)
|
||||||
|
self.assertEqual(spoofed.status_code, 422, spoofed.text)
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT count(*) FROM minerops_site_agents WHERE agent_id = %s",
|
||||||
|
("body-spoof-agent",),
|
||||||
|
)
|
||||||
|
self.assertEqual(cur.fetchone()[0], 0)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def test_40_global_secret_rejects_unknown_and_enrolled_explicit_ids(self):
|
||||||
|
for agent_id, expected_status in (
|
||||||
|
("unknown-agent", 403),
|
||||||
|
(ENROLLED_AGENT, 401),
|
||||||
|
):
|
||||||
|
with self.subTest(agent_id=agent_id):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/agent/config",
|
||||||
|
headers=self.agent_headers(
|
||||||
|
agent_id, settings.minerops_agent_shared_secret
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, expected_status, response.text)
|
||||||
|
self.assert_no_credentials(response)
|
||||||
|
|
||||||
|
def test_50_enrolled_agent_uses_per_agent_secret_and_own_zone(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/agent/config",
|
||||||
|
headers=self.agent_headers(ENROLLED_AGENT, ENROLLED_SECRET),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(payload["agent_id"], ENROLLED_AGENT)
|
||||||
|
self.assertEqual(payload["agent_zone_id"], str(self.zone_b))
|
||||||
|
self.assertEqual(
|
||||||
|
[item["cidr"] for item in payload["ip_ranges"]],
|
||||||
|
[ZONE_B_NETWORK],
|
||||||
|
)
|
||||||
|
self.assertNotIn(ZONE_A_NETWORK, response.text)
|
||||||
|
self.assertNotIn(ZONE_C_NETWORK, response.text)
|
||||||
|
self.assert_no_credentials(response)
|
||||||
|
|
||||||
|
def test_60_revoked_credential_cannot_use_per_agent_or_global_secret(self):
|
||||||
|
for secret in (REVOKED_SECRET, settings.minerops_agent_shared_secret):
|
||||||
|
with self.subTest(secret_kind="global" if secret != REVOKED_SECRET else "agent"):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/agent/config",
|
||||||
|
headers=self.agent_headers(REVOKED_AGENT, secret),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403, response.text)
|
||||||
|
self.assert_no_credentials(response)
|
||||||
|
|
||||||
|
def test_90_durably_retired_legacy_identity_loses_secret_only_fallback(self):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE minerops_site_agents
|
||||||
|
SET legacy_auth_disabled_at = now()
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
(LEGACY_AGENT,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/agent/config", headers=self.legacy_headers()
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 401, response.text)
|
||||||
|
self.assert_no_credentials(response)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Focused source-level contracts for legacy Agent migrations."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
VERSIONS = Path(__file__).parents[1] / "alembic" / "versions"
|
||||||
|
|
||||||
|
|
||||||
|
def load_migration(filename):
|
||||||
|
path = VERSIONS / filename
|
||||||
|
spec = importlib.util.spec_from_file_location(filename.removesuffix(".py"), path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyResult:
|
||||||
|
def all(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingConnection:
|
||||||
|
def __init__(self):
|
||||||
|
self.statements = []
|
||||||
|
|
||||||
|
def execute(self, statement, parameters=None):
|
||||||
|
del parameters
|
||||||
|
self.statements.append(str(statement))
|
||||||
|
return EmptyResult()
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyRoutingMigrationTests(unittest.TestCase):
|
||||||
|
def test_0018_locks_every_preflight_and_update_table_before_reads(self):
|
||||||
|
migration = load_migration("0018_legacy_agent_routing.py")
|
||||||
|
connection = RecordingConnection()
|
||||||
|
with patch.object(migration.op, "get_bind", return_value=connection):
|
||||||
|
migration.upgrade()
|
||||||
|
|
||||||
|
first = " ".join(connection.statements[0].split()).lower()
|
||||||
|
self.assertTrue(first.startswith("lock table"))
|
||||||
|
self.assertIn("in share row exclusive mode", first)
|
||||||
|
for table in (
|
||||||
|
"agent_zones", "minerops_agent_discoveries",
|
||||||
|
"minerops_agent_snapshots", "minerops_site_agents", "sites",
|
||||||
|
):
|
||||||
|
with self.subTest(table=table):
|
||||||
|
self.assertIn(table, first)
|
||||||
|
|
||||||
|
first_update = next(
|
||||||
|
index for index, statement in enumerate(connection.statements)
|
||||||
|
if "UPDATE " in statement
|
||||||
|
)
|
||||||
|
self.assertEqual(first_update, 4)
|
||||||
|
|
||||||
|
def test_0019_is_the_direct_forward_only_successor(self):
|
||||||
|
migration = load_migration("0019_agent_legacy_auth_retire.py")
|
||||||
|
self.assertEqual(migration.revision, "0019_agent_legacy_auth_retire")
|
||||||
|
self.assertEqual(migration.down_revision, "0018_legacy_agent_routing")
|
||||||
|
|
||||||
|
def test_0020_adds_only_existing_identity_transition_binding(self):
|
||||||
|
migration = load_migration(
|
||||||
|
"0020_existing_agent_credential_transition.py"
|
||||||
|
)
|
||||||
|
self.assertEqual(migration.revision, "0020_existing_agent_transition")
|
||||||
|
self.assertEqual(
|
||||||
|
migration.down_revision,
|
||||||
|
"0019_agent_legacy_auth_retire",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""Fresh-chain production compatibility contract for the legacy Plexus Agent."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.agent_auth.exceptions import (
|
||||||
|
AgentAuthenticationFailed,
|
||||||
|
AgentIdentityUnauthorized,
|
||||||
|
)
|
||||||
|
from app.agent_auth.repository import AgentAuthRepository
|
||||||
|
from app.agent_auth.service import AgentAuthService, hash_agent_secret
|
||||||
|
from app.agent_zones.repository import AgentZoneRepository
|
||||||
|
from app.agent_zones.service import AgentZoneService
|
||||||
|
from app.config import settings
|
||||||
|
from app.main import (
|
||||||
|
minerops_agent_known_miners,
|
||||||
|
minerops_agent_miner_snapshots,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SITE_A = UUID("00000000-0000-4000-8000-000000001901")
|
||||||
|
SITE_B = UUID("00000000-0000-4000-8000-000000001902")
|
||||||
|
MINER_A = UUID("00000000-0000-4000-8000-000000001911")
|
||||||
|
MINER_B = UUID("00000000-0000-4000-8000-000000001912")
|
||||||
|
LEGACY_AGENT = "legacy-plexus-production"
|
||||||
|
NEW_AGENT = "enrolled-agent-production-contract"
|
||||||
|
NEW_SECRET = "new-agent-production-contract-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
return psycopg.connect(
|
||||||
|
host=settings.postgres_host,
|
||||||
|
port=settings.postgres_port,
|
||||||
|
dbname=settings.postgres_db,
|
||||||
|
user=settings.postgres_user,
|
||||||
|
password=settings.postgres_password,
|
||||||
|
connect_timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def json_request(payload):
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
delivered = False
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
nonlocal delivered
|
||||||
|
if delivered:
|
||||||
|
return {"type": "http.request", "body": b"", "more_body": False}
|
||||||
|
delivered = True
|
||||||
|
return {"type": "http.request", "body": body, "more_body": False}
|
||||||
|
|
||||||
|
return Request(
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"asgi": {"version": "3.0"},
|
||||||
|
"http_version": "1.1",
|
||||||
|
"method": "POST",
|
||||||
|
"scheme": "http",
|
||||||
|
"path": "/api/agent/miner-snapshots",
|
||||||
|
"raw_path": b"/api/agent/miner-snapshots",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": [(b"content-type", b"application/json")],
|
||||||
|
"client": ("testclient", 12345),
|
||||||
|
"server": ("testserver", 80),
|
||||||
|
},
|
||||||
|
receive,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyAgentProductionPostgresTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
if os.environ.get("MINEROPS_LEGACY_PRODUCTION_DISPOSABLE") != "1":
|
||||||
|
raise unittest.SkipTest(
|
||||||
|
"Legacy production contract requires its disposable PostgreSQL runner"
|
||||||
|
)
|
||||||
|
if settings.minerops_agent_id != LEGACY_AGENT:
|
||||||
|
raise AssertionError("Runner did not bind the configured legacy identity")
|
||||||
|
|
||||||
|
cls.auth = AgentAuthService(AgentAuthRepository(connect))
|
||||||
|
cls.zones = AgentZoneService(AgentZoneRepository(connect))
|
||||||
|
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT agent_zone_id
|
||||||
|
FROM minerops_site_agents
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
(LEGACY_AGENT,),
|
||||||
|
)
|
||||||
|
cls.zone_a = cur.fetchone()[0]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM agent_zones
|
||||||
|
WHERE site_id = %s AND name = 'Default Zone'
|
||||||
|
""",
|
||||||
|
(SITE_B,),
|
||||||
|
)
|
||||||
|
cls.zone_b = cur.fetchone()[0]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id, site_id, agent_zone_id, site_name,
|
||||||
|
agent_secret_hash, credential_created_at,
|
||||||
|
legacy_auth_disabled_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s, %s, %s, 'Enrolled Contract Agent', %s, now(), now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
NEW_AGENT,
|
||||||
|
SITE_B,
|
||||||
|
cls.zone_b,
|
||||||
|
hash_agent_secret(NEW_SECRET),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def test_secret_only_auth_is_bounded_to_legacy_identity(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.auth.authenticate(None, settings.minerops_agent_shared_secret),
|
||||||
|
LEGACY_AGENT,
|
||||||
|
)
|
||||||
|
with self.assertRaises(AgentIdentityUnauthorized):
|
||||||
|
self.auth.authenticate(
|
||||||
|
"unknown-agent", settings.minerops_agent_shared_secret
|
||||||
|
)
|
||||||
|
with self.assertRaises(AgentAuthenticationFailed):
|
||||||
|
self.auth.authenticate(
|
||||||
|
NEW_AGENT, settings.minerops_agent_shared_secret
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.auth.authenticate(NEW_AGENT, NEW_SECRET),
|
||||||
|
NEW_AGENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_migrated_legacy_agent_has_correct_site_and_zone(self):
|
||||||
|
routing = self.zones.routing_context(LEGACY_AGENT)
|
||||||
|
self.assertIsNone(routing.routing_reason)
|
||||||
|
self.assertEqual(routing.site_id, SITE_A)
|
||||||
|
self.assertEqual(routing.agent_zone_id, self.zone_a)
|
||||||
|
|
||||||
|
def test_known_miners_and_telemetry_remain_zone_scoped(self):
|
||||||
|
known = asyncio.run(
|
||||||
|
minerops_agent_known_miners(LEGACY_AGENT, self.zones)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[row["miner_id"] for row in known["miners"]],
|
||||||
|
[str(MINER_A)],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
minerops_agent_miner_snapshots(
|
||||||
|
json_request(
|
||||||
|
{
|
||||||
|
"agent_id": LEGACY_AGENT,
|
||||||
|
"site_name": "Plexus",
|
||||||
|
"miners": [
|
||||||
|
{
|
||||||
|
"miner_id": str(MINER_A),
|
||||||
|
"ip_address": "192.0.2.191",
|
||||||
|
"online": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"miner_id": str(MINER_B),
|
||||||
|
"ip_address": "192.0.2.192",
|
||||||
|
"online": False,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
LEGACY_AGENT,
|
||||||
|
self.zones,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(result["agent_zone_id"], str(self.zone_a))
|
||||||
|
self.assertEqual(result["apply_result"]["applied"], 1)
|
||||||
|
self.assertIn(
|
||||||
|
{"miner_id": str(MINER_B), "error": "agent_zone_conflict"},
|
||||||
|
result["apply_result"]["errors"],
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT status FROM miners WHERE id = %s",
|
||||||
|
(MINER_A,),
|
||||||
|
)
|
||||||
|
self.assertEqual(cur.fetchone()[0], "offline")
|
||||||
|
cur.execute(
|
||||||
|
"SELECT status FROM miners WHERE id = %s",
|
||||||
|
(MINER_B,),
|
||||||
|
)
|
||||||
|
self.assertEqual(cur.fetchone()[0], "online")
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT count(*)
|
||||||
|
FROM minerops_agent_snapshots
|
||||||
|
WHERE agent_id = %s AND agent_zone_id = %s
|
||||||
|
""",
|
||||||
|
(LEGACY_AGENT, self.zone_a),
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(cur.fetchone()[0], 1)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -26,6 +26,9 @@ def context(**overrides):
|
|||||||
result = {
|
result = {
|
||||||
"id": MINER_ID,
|
"id": MINER_ID,
|
||||||
"site_id": uuid4(),
|
"site_id": uuid4(),
|
||||||
|
"agent_zone_id": uuid4(),
|
||||||
|
"agent_zone_exists": True,
|
||||||
|
"agent_zone_enabled": True,
|
||||||
"current_ip": "192.0.2.10",
|
"current_ip": "192.0.2.10",
|
||||||
"runtime_state": "online",
|
"runtime_state": "online",
|
||||||
"configured_performance_mode": "normal",
|
"configured_performance_mode": "normal",
|
||||||
@@ -78,8 +81,10 @@ class MinerControlServiceTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_capability_missing_facts_is_not_optimistic(self):
|
def test_capability_missing_facts_is_not_optimistic(self):
|
||||||
cases = (
|
cases = (
|
||||||
({"site_id": None}, "unavailable", "site_unassigned"),
|
({"agent_zone_id": None}, "unavailable", "agent_zone_unassigned"),
|
||||||
({"agent_id": None}, "unavailable", "agent_unassigned"),
|
({"agent_zone_exists": False}, "unavailable", "agent_zone_missing"),
|
||||||
|
({"agent_zone_enabled": False}, "unavailable", "agent_zone_disabled"),
|
||||||
|
({"agent_id": None}, "unavailable", "agent_unavailable"),
|
||||||
({"current_ip": None}, "unavailable", "current_ip_missing"),
|
({"current_ip": None}, "unavailable", "current_ip_missing"),
|
||||||
({"api3_supported": False}, "unavailable", "api3_unsupported"),
|
({"api3_supported": False}, "unavailable", "api3_unsupported"),
|
||||||
({"api3_supported": None}, "unknown", "api3_support_unknown"),
|
({"api3_supported": None}, "unknown", "api3_support_unknown"),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from app.miner_control.service import MinerControlService
|
|||||||
|
|
||||||
|
|
||||||
SITE_ID = UUID("00000000-0000-4000-8000-000000001510")
|
SITE_ID = UUID("00000000-0000-4000-8000-000000001510")
|
||||||
|
ZONE_ID = UUID("00000000-0000-4000-8000-000000001514")
|
||||||
MINER_ID = UUID("00000000-0000-4000-8000-000000001511")
|
MINER_ID = UUID("00000000-0000-4000-8000-000000001511")
|
||||||
USER_ID = UUID("00000000-0000-4000-8000-000000001512")
|
USER_ID = UUID("00000000-0000-4000-8000-000000001512")
|
||||||
OTHER_MINER_ID = UUID("00000000-0000-4000-8000-000000001513")
|
OTHER_MINER_ID = UUID("00000000-0000-4000-8000-000000001513")
|
||||||
@@ -54,6 +55,12 @@ class MinerControlPostgresTests(unittest.TestCase):
|
|||||||
"INSERT INTO sites (id, name) VALUES (%s, 'Control Test Site') "
|
"INSERT INTO sites (id, name) VALUES (%s, 'Control Test Site') "
|
||||||
"ON CONFLICT (id) DO NOTHING", (SITE_ID,)
|
"ON CONFLICT (id) DO NOTHING", (SITE_ID,)
|
||||||
)
|
)
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO agent_zones (id, site_id, name) "
|
||||||
|
"VALUES (%s, %s, 'Control Test Zone') "
|
||||||
|
"ON CONFLICT (id) DO UPDATE SET is_enabled = true",
|
||||||
|
(ZONE_ID, SITE_ID),
|
||||||
|
)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO users (id, username, display_name, password_hash, role_id)
|
INSERT INTO users (id, username, display_name, password_hash, role_id)
|
||||||
@@ -64,23 +71,25 @@ class MinerControlPostgresTests(unittest.TestCase):
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO miners (
|
INSERT INTO miners (
|
||||||
id, site_id, asset_tag, current_ip, status, api3_supported,
|
id, site_id, agent_zone_id, asset_tag, current_ip, status, api3_supported,
|
||||||
configured_performance_mode, first_seen_at, last_seen_at
|
configured_performance_mode, first_seen_at, last_seen_at
|
||||||
) VALUES (%s, %s, 'CONTROL-TEST', '192.0.2.10', 'online', true,
|
) VALUES (%s, %s, %s, 'CONTROL-TEST', '192.0.2.10', 'online', true,
|
||||||
'normal', NULL, NULL)
|
'normal', NULL, NULL)
|
||||||
ON CONFLICT (id) DO UPDATE SET site_id = EXCLUDED.site_id,
|
ON CONFLICT (id) DO UPDATE SET site_id = EXCLUDED.site_id,
|
||||||
|
agent_zone_id = EXCLUDED.agent_zone_id,
|
||||||
current_ip = EXCLUDED.current_ip, status = EXCLUDED.status,
|
current_ip = EXCLUDED.current_ip, status = EXCLUDED.status,
|
||||||
api3_supported = true
|
api3_supported = true
|
||||||
""", (MINER_ID, SITE_ID)
|
""", (MINER_ID, SITE_ID, ZONE_ID)
|
||||||
)
|
)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO minerops_site_agents (
|
INSERT INTO minerops_site_agents (
|
||||||
agent_id, site_id, site_name, last_payload
|
agent_id, site_id, agent_zone_id, site_name, last_payload
|
||||||
) VALUES (%s, %s, 'Control Test Site', %s)
|
) VALUES (%s, %s, %s, 'Control Test Site', %s)
|
||||||
ON CONFLICT (agent_id) DO UPDATE SET site_id = EXCLUDED.site_id,
|
ON CONFLICT (agent_id) DO UPDATE SET site_id = EXCLUDED.site_id,
|
||||||
|
agent_zone_id = EXCLUDED.agent_zone_id,
|
||||||
last_payload = EXCLUDED.last_payload
|
last_payload = EXCLUDED.last_payload
|
||||||
""", (AGENT_ID, SITE_ID, Jsonb({"control_available": True}))
|
""", (AGENT_ID, SITE_ID, ZONE_ID, Jsonb({"control_available": True}))
|
||||||
)
|
)
|
||||||
connection.commit()
|
connection.commit()
|
||||||
finally:
|
finally:
|
||||||
@@ -100,6 +109,7 @@ class MinerControlPostgresTests(unittest.TestCase):
|
|||||||
cur.execute("DELETE FROM minerops_site_agents WHERE agent_id = %s", (AGENT_ID,))
|
cur.execute("DELETE FROM minerops_site_agents WHERE agent_id = %s", (AGENT_ID,))
|
||||||
cur.execute("DELETE FROM miners WHERE id = %s", (MINER_ID,))
|
cur.execute("DELETE FROM miners WHERE id = %s", (MINER_ID,))
|
||||||
cur.execute("DELETE FROM users WHERE id = %s", (USER_ID,))
|
cur.execute("DELETE FROM users WHERE id = %s", (USER_ID,))
|
||||||
|
cur.execute("DELETE FROM agent_zones WHERE id = %s", (ZONE_ID,))
|
||||||
cur.execute("DELETE FROM sites WHERE id = %s", (SITE_ID,))
|
cur.execute("DELETE FROM sites WHERE id = %s", (SITE_ID,))
|
||||||
connection.commit()
|
connection.commit()
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from app.system_health.service import SystemHealthService
|
|||||||
|
|
||||||
|
|
||||||
APP_ROOT = Path(__file__).parents[1] / "app"
|
APP_ROOT = Path(__file__).parents[1] / "app"
|
||||||
EXPECTED_HEAD = "0015_miner_control_foundation"
|
EXPECTED_HEAD = "0017_agent_enrollment"
|
||||||
|
|
||||||
|
|
||||||
def database_payload(revision=EXPECTED_HEAD):
|
def database_payload(revision=EXPECTED_HEAD):
|
||||||
|
|||||||
+2
-1
@@ -32,7 +32,8 @@ services:
|
|||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
env_file:
|
env_file:
|
||||||
- .env.dev
|
- .env.dev
|
||||||
command:
|
command:
|
||||||
|
|||||||
+2
-1
@@ -34,7 +34,8 @@
|
|||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
container_name: minerops-backend
|
container_name: minerops-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Agent Zones
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
An Agent Zone is MinerOps' durable network-reachability boundary. A Site is an
|
||||||
|
operational location and may contain networks that cannot be reached by one
|
||||||
|
Agent computer. Agent Zones let those networks remain within one Site without
|
||||||
|
making Site identity determine control routing.
|
||||||
|
|
||||||
|
The implemented relationship is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Site
|
||||||
|
-> Agent Zone
|
||||||
|
-> Agent installation
|
||||||
|
-> miners reachable through that Zone
|
||||||
|
```
|
||||||
|
|
||||||
|
`agent_id` remains the durable Agent-installation identity. Agent Zone identity
|
||||||
|
is a UUID independent of the Agent computer assigned to it.
|
||||||
|
|
||||||
|
## Site, Zone, And Agent
|
||||||
|
|
||||||
|
A Site groups rooms and operational records. An Agent Zone groups one or more
|
||||||
|
CIDR network ranges that are reachable from an Agent installation. The ranges
|
||||||
|
are factual Zone configuration for future enrollment and do not implement an
|
||||||
|
arbitrary routing table.
|
||||||
|
|
||||||
|
A Site can contain multiple Zones. For example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Plexus Mining Site
|
||||||
|
Main Room 10.30.0.0/24 -> Agent A
|
||||||
|
Room 2 10.40.0.0/24 -> Agent B
|
||||||
|
Immersion Room 10.50.0.0/24 -> Agent C
|
||||||
|
```
|
||||||
|
|
||||||
|
Zone names are unique case-insensitively among enabled Zones at the same Site.
|
||||||
|
They are not globally unique. A Zone can contain multiple canonical PostgreSQL
|
||||||
|
`CIDR` ranges.
|
||||||
|
|
||||||
|
## Routing Authority
|
||||||
|
|
||||||
|
Miner control resolves only from Hub-owned facts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
miner UUID
|
||||||
|
-> miners.agent_zone_id
|
||||||
|
-> Agent assigned to that Zone
|
||||||
|
-> miner current IP recorded by MinerOps
|
||||||
|
-> local API3 operation
|
||||||
|
```
|
||||||
|
|
||||||
|
A control request from a browser contains none of the routing inputs. It cannot
|
||||||
|
select an IP address, Agent, Agent Zone, raw vendor command, credentials, or a
|
||||||
|
dry-run mode. MinerOps never falls back to another Agent merely because it is
|
||||||
|
assigned to the same Site.
|
||||||
|
|
||||||
|
A miner without a Zone is unavailable with `agent_zone_unassigned`. A missing
|
||||||
|
or disabled Zone is unavailable with `agent_zone_disabled`. A Zone without an
|
||||||
|
assigned Agent is unavailable with `agent_unavailable`.
|
||||||
|
|
||||||
|
## Durable Miner Ownership
|
||||||
|
|
||||||
|
`miners.agent_zone_id` is the authoritative reachability assignment. Snapshot
|
||||||
|
and discovery ingestion can assign a currently unassigned miner when the
|
||||||
|
authenticated Agent has a valid enabled Zone and Site boundaries agree.
|
||||||
|
|
||||||
|
An observation from a different Zone does not move an assigned miner. MinerOps
|
||||||
|
records or returns `agent_zone_conflict` or `site_conflict` while preserving the
|
||||||
|
existing assignment. Snapshot and discovery payloads may still be staged as
|
||||||
|
factual observations. Explicit administrative reassignment is deferred.
|
||||||
|
|
||||||
|
## Agent Assignment
|
||||||
|
|
||||||
|
Agent Zones v1 permits at most one Agent installation per non-null Zone. A Site
|
||||||
|
can therefore have multiple Agents when they occupy different Zones. Primary
|
||||||
|
and standby Agents, automatic failover, and Agent high availability are not
|
||||||
|
implemented.
|
||||||
|
|
||||||
|
An enabled Zone is usable for transitional check-in and control routing. A
|
||||||
|
disabled Zone preserves its durable associations but cannot be selected for a
|
||||||
|
new check-in and cannot route miner control.
|
||||||
|
|
||||||
|
## Check-In Compatibility
|
||||||
|
|
||||||
|
The Windows Agent may send `site_id` and optional `agent_zone_id` during
|
||||||
|
check-in. Existing configurations that omit `agent_zone_id` remain compatible:
|
||||||
|
|
||||||
|
- An established valid assignment is preserved.
|
||||||
|
- An unassigned Agent can resolve automatically only when the supplied Site has
|
||||||
|
exactly one enabled Zone.
|
||||||
|
- Zero enabled Zones returns `agent_zone_unavailable`.
|
||||||
|
- Multiple enabled Zones returns `agent_zone_ambiguous`.
|
||||||
|
- MinerOps never chooses the first Zone.
|
||||||
|
|
||||||
|
An explicit Zone must exist, be enabled, and belong to the supplied Site. An
|
||||||
|
Agent cannot move its established Zone or Site through check-in. Later
|
||||||
|
administrative Agent management will own reassignment.
|
||||||
|
|
||||||
|
## Permissions And APIs
|
||||||
|
|
||||||
|
The implemented management foundation reuses existing permissions:
|
||||||
|
|
||||||
|
- `GET /api/agent-zones` requires `agents.view`.
|
||||||
|
- `POST /api/agent-zones` requires `agents.manage`.
|
||||||
|
- `POST /api/agent/checkin` requires authenticated Agent identity.
|
||||||
|
|
||||||
|
The existing role matrix grants `agents.view` to Admin, Manager, Technician,
|
||||||
|
and Read Only. `agents.manage` is granted to Admin and Manager. No Agent Zone UI
|
||||||
|
is implemented in this milestone.
|
||||||
|
|
||||||
|
## Migration Compatibility
|
||||||
|
|
||||||
|
Migration 0016 creates one deterministic `Default Zone` for each pre-existing
|
||||||
|
Site. Existing miners and Agent assignments with a durable `site_id` are
|
||||||
|
backfilled to that Site's default Zone. Existing snapshots and discoveries are
|
||||||
|
associated when their durable Agent assignment makes that association factual.
|
||||||
|
Rows without a safe Site association remain unassigned.
|
||||||
|
|
||||||
|
Downgrade to migration 0015 is allowed only when the Zone configuration can be
|
||||||
|
represented by its one-Agent-per-Site model. Downgrade refuses before
|
||||||
|
structural changes when a Site has multiple Agents, non-default Zone topology,
|
||||||
|
custom Zone state, or network ranges.
|
||||||
|
|
||||||
|
## Future Relationship
|
||||||
|
|
||||||
|
One-time Agent enrollment tokens, Settings -> Agents, installer downloads,
|
||||||
|
Linux Agents, update management, administrative reassignment, and primary or
|
||||||
|
standby Agents are future work. An enrollment flow can use a Zone's configured
|
||||||
|
network ranges, but enrollment tokens and automatic range delivery are not
|
||||||
|
implemented by Agent Zones v1.
|
||||||
@@ -78,8 +78,12 @@ migration_head="$(
|
|||||||
"${compose[@]}" exec -T postgres sh -ec \
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
'psql -v ON_ERROR_STOP=1 -At -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT version_num FROM alembic_version"'
|
'psql -v ON_ERROR_STOP=1 -At -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT version_num FROM alembic_version"'
|
||||||
)"
|
)"
|
||||||
if [[ "${migration_head}" != "0015_miner_control_foundation" ]]; then
|
expected_head="$(
|
||||||
echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0015_miner_control_foundation." >&2
|
"${compose[@]}" run --rm --no-deps backend alembic heads 2>/dev/null |
|
||||||
|
awk 'NR == 1 { print $1 }'
|
||||||
|
)"
|
||||||
|
if [[ -z "${expected_head}" || "${migration_head}" != "${expected_head}" ]]; then
|
||||||
|
echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected ${expected_head:-unresolved}." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
project_name="${MINEROPS_CREDENTIAL_TRANSITION_PROJECT:-minerops-credential-transition-$$}"
|
||||||
|
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
if (( status != 0 )); then
|
||||||
|
"${compose[@]}" logs --no-color --tail 160 postgres || true
|
||||||
|
fi
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||||
|
for source_file in \
|
||||||
|
backend/tests/test_agent_credential_transition_postgres.py \
|
||||||
|
agents/windows/Upgrade-MinerOpsAgentCredential.ps1; do
|
||||||
|
permissions="$(stat -c '%a' "${source_file}")"
|
||||||
|
(( (10#${permissions} % 10) & 4 )) || {
|
||||||
|
echo "${source_file} is not readable by the non-root test container" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||||
|
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
expected_head="$("${compose[@]}" run --rm --no-deps backend alembic heads 2>/dev/null | awk 'NR == 1 { print $1 }')"
|
||||||
|
[[ "${expected_head}" == "0020_existing_agent_transition" ]]
|
||||||
|
|
||||||
|
echo "Testing the additive 0019 -> 0020 transition-binding migration..."
|
||||||
|
"${compose[@]}" run --rm --no-deps backend alembic upgrade 0019_agent_legacy_auth_retire
|
||||||
|
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
|
||||||
|
|
||||||
|
echo "Testing production-shaped transactional credential transition..."
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
--volume "${repo_root}/backend/tests:/app/tests:ro" \
|
||||||
|
--env MINEROPS_CREDENTIAL_TRANSITION_DISPOSABLE=1 \
|
||||||
|
--env MINEROPS_AGENT_ID=plexus-windows-agent \
|
||||||
|
--env MINEROPS_AGENT_SHARED_SECRET=credential-transition-legacy-secret \
|
||||||
|
backend python -m unittest -v \
|
||||||
|
tests.test_agent_credential_transition_postgres
|
||||||
|
|
||||||
|
echo "Testing downgrade refusal after transition history exists..."
|
||||||
|
if output="$("${compose[@]}" run --rm --no-deps backend \
|
||||||
|
alembic downgrade 0019_agent_legacy_auth_retire 2>&1)"; then
|
||||||
|
echo "0020 downgrade unexpectedly discarded transition history" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ "${output}" == *"existing-Agent credential transition history exists"* ]]
|
||||||
|
|
||||||
|
echo "Existing-Agent credential transition checks passed."
|
||||||
Executable
+327
@@ -0,0 +1,327 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
|
||||||
|
project="minerops-agent-enrollment-migration-$$"
|
||||||
|
compose=(
|
||||||
|
docker compose
|
||||||
|
--project-name "${project}"
|
||||||
|
--env-file .env.dev
|
||||||
|
-f compose.dev.yaml
|
||||||
|
)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
"${compose[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source .env.dev
|
||||||
|
set +a
|
||||||
|
|
||||||
|
echo "Starting disposable PostgreSQL..."
|
||||||
|
"${compose[@]}" up -d postgres
|
||||||
|
|
||||||
|
echo "Waiting for PostgreSQL..."
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
if "${compose[@]}" exec -T postgres \
|
||||||
|
pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ "${attempt}" -eq 60 ]]; then
|
||||||
|
echo "PostgreSQL did not become ready" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Building backend with migration 0017..."
|
||||||
|
"${compose[@]}" build backend >/dev/null
|
||||||
|
|
||||||
|
run_backend() {
|
||||||
|
"${compose[@]}" run --rm --no-deps backend "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
psql_exec() {
|
||||||
|
"${compose[@]}" exec -T postgres \
|
||||||
|
psql -v ON_ERROR_STOP=1 \
|
||||||
|
-U "${POSTGRES_USER}" \
|
||||||
|
-d "${POSTGRES_DB}" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Testing upgrade through 0016..."
|
||||||
|
run_backend alembic upgrade 0016_agent_zones
|
||||||
|
|
||||||
|
revision="$(
|
||||||
|
psql_exec -Atc "SELECT version_num FROM alembic_version"
|
||||||
|
)"
|
||||||
|
[[ "${revision}" == "0016_agent_zones" ]]
|
||||||
|
|
||||||
|
echo "Testing 0016 -> 0017..."
|
||||||
|
run_backend alembic upgrade 0017_agent_enrollment
|
||||||
|
|
||||||
|
revision="$(
|
||||||
|
psql_exec -Atc "SELECT version_num FROM alembic_version"
|
||||||
|
)"
|
||||||
|
[[ "${revision}" == "0017_agent_enrollment" ]]
|
||||||
|
|
||||||
|
echo "Checking new Agent credential columns..."
|
||||||
|
for column in \
|
||||||
|
display_name \
|
||||||
|
agent_secret_hash \
|
||||||
|
credential_created_at \
|
||||||
|
credential_revoked_at
|
||||||
|
do
|
||||||
|
count="$(
|
||||||
|
psql_exec -Atc "
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'minerops_site_agents'
|
||||||
|
AND column_name = '${column}'
|
||||||
|
"
|
||||||
|
)"
|
||||||
|
[[ "${count}" == "1" ]]
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Checking enrollment table..."
|
||||||
|
count="$(
|
||||||
|
psql_exec -Atc "
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'agent_enrollments'
|
||||||
|
"
|
||||||
|
)"
|
||||||
|
[[ "${count}" == "1" ]]
|
||||||
|
|
||||||
|
echo "Checking expected indexes..."
|
||||||
|
for index in \
|
||||||
|
uq_minerops_site_agents_agent_secret_hash \
|
||||||
|
uq_agent_enrollments_token_hash \
|
||||||
|
uq_agent_enrollments_open_zone \
|
||||||
|
uq_agent_enrollments_enrolled_agent \
|
||||||
|
idx_agent_enrollments_site_id \
|
||||||
|
idx_agent_enrollments_created_at
|
||||||
|
do
|
||||||
|
count="$(
|
||||||
|
psql_exec -Atc "
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
AND indexname = '${index}'
|
||||||
|
"
|
||||||
|
)"
|
||||||
|
[[ "${count}" == "1" ]]
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Testing immediate legal downgrade..."
|
||||||
|
run_backend alembic downgrade 0016_agent_zones
|
||||||
|
|
||||||
|
revision="$(
|
||||||
|
psql_exec -Atc "SELECT version_num FROM alembic_version"
|
||||||
|
)"
|
||||||
|
[[ "${revision}" == "0016_agent_zones" ]]
|
||||||
|
|
||||||
|
count="$(
|
||||||
|
psql_exec -Atc "
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'agent_enrollments'
|
||||||
|
"
|
||||||
|
)"
|
||||||
|
[[ "${count}" == "0" ]]
|
||||||
|
|
||||||
|
echo "Re-upgrading to 0017..."
|
||||||
|
run_backend alembic upgrade 0017_agent_enrollment
|
||||||
|
|
||||||
|
echo "Creating isolated Site and Agent Zone fixtures..."
|
||||||
|
psql_exec <<'SQL'
|
||||||
|
INSERT INTO sites (id, name)
|
||||||
|
VALUES (
|
||||||
|
'00000000-0000-4000-8000-000000001700',
|
||||||
|
'Enrollment Migration Test Site'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO agent_zones (
|
||||||
|
id,
|
||||||
|
site_id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
is_enabled
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'00000000-0000-4000-8000-000000001701',
|
||||||
|
'00000000-0000-4000-8000-000000001700',
|
||||||
|
'Enrollment Test Zone',
|
||||||
|
'Disposable migration test',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "Testing database constraints..."
|
||||||
|
|
||||||
|
psql_exec <<'SQL'
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO agent_enrollments (
|
||||||
|
token_hash,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
display_name,
|
||||||
|
expires_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'not-a-sha256-hash',
|
||||||
|
'00000000-0000-4000-8000-000000001700',
|
||||||
|
'00000000-0000-4000-8000-000000001701',
|
||||||
|
'Invalid Hash',
|
||||||
|
now() + interval '30 minutes'
|
||||||
|
);
|
||||||
|
RAISE EXCEPTION 'invalid token hash was accepted';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN check_violation THEN NULL;
|
||||||
|
END;
|
||||||
|
END $$;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
psql_exec <<'SQL'
|
||||||
|
INSERT INTO agent_enrollments (
|
||||||
|
id,
|
||||||
|
token_hash,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
display_name,
|
||||||
|
expires_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'00000000-0000-4000-8000-000000001710',
|
||||||
|
repeat('a', 64),
|
||||||
|
'00000000-0000-4000-8000-000000001700',
|
||||||
|
'00000000-0000-4000-8000-000000001701',
|
||||||
|
'Enrollment Test Agent',
|
||||||
|
now() + interval '30 minutes'
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO agent_enrollments (
|
||||||
|
token_hash,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
display_name,
|
||||||
|
expires_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
repeat('b', 64),
|
||||||
|
'00000000-0000-4000-8000-000000001700',
|
||||||
|
'00000000-0000-4000-8000-000000001701',
|
||||||
|
'Second Open Enrollment',
|
||||||
|
now() + interval '30 minutes'
|
||||||
|
);
|
||||||
|
RAISE EXCEPTION 'second open enrollment was accepted';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN unique_violation THEN NULL;
|
||||||
|
END;
|
||||||
|
END $$;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "Testing credential hash integrity and uniqueness..."
|
||||||
|
|
||||||
|
psql_exec <<'SQL'
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
site_name,
|
||||||
|
display_name,
|
||||||
|
agent_secret_hash,
|
||||||
|
credential_created_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'agent-enrollment-test',
|
||||||
|
'00000000-0000-4000-8000-000000001700',
|
||||||
|
'00000000-0000-4000-8000-000000001701',
|
||||||
|
'Enrollment Migration Test Site',
|
||||||
|
'Enrollment Test Agent',
|
||||||
|
repeat('c', 64),
|
||||||
|
now()
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id,
|
||||||
|
site_name,
|
||||||
|
display_name,
|
||||||
|
agent_secret_hash,
|
||||||
|
credential_created_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'agent-enrollment-duplicate-secret',
|
||||||
|
'Enrollment Migration Test Site',
|
||||||
|
'Duplicate Secret',
|
||||||
|
repeat('c', 64),
|
||||||
|
now()
|
||||||
|
);
|
||||||
|
RAISE EXCEPTION 'duplicate Agent secret hash was accepted';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN unique_violation THEN NULL;
|
||||||
|
END;
|
||||||
|
END $$;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "Testing downgrade refusal after feature use..."
|
||||||
|
|
||||||
|
set +e
|
||||||
|
downgrade_output="$(
|
||||||
|
run_backend alembic downgrade 0016_agent_zones 2>&1
|
||||||
|
)"
|
||||||
|
downgrade_status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [[ "${downgrade_status}" -eq 0 ]]; then
|
||||||
|
echo "Downgrade unexpectedly succeeded after enrollment/credential use" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
grep -Eq \
|
||||||
|
'Cannot downgrade 0017: Agent enrollment history exists|Cannot downgrade 0017: per-Agent credentials exist' \
|
||||||
|
<<<"${downgrade_output}"
|
||||||
|
|
||||||
|
revision="$(
|
||||||
|
psql_exec -Atc "SELECT version_num FROM alembic_version"
|
||||||
|
)"
|
||||||
|
[[ "${revision}" == "0017_agent_enrollment" ]]
|
||||||
|
|
||||||
|
echo "Confirming refusal happened before destructive schema changes..."
|
||||||
|
|
||||||
|
count="$(
|
||||||
|
psql_exec -Atc "
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'agent_enrollments'
|
||||||
|
"
|
||||||
|
)"
|
||||||
|
[[ "${count}" == "1" ]]
|
||||||
|
|
||||||
|
count="$(
|
||||||
|
psql_exec -Atc "
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'minerops_site_agents'
|
||||||
|
AND column_name = 'agent_secret_hash'
|
||||||
|
"
|
||||||
|
)"
|
||||||
|
[[ "${count}" == "1" ]]
|
||||||
|
|
||||||
|
echo "Agent enrollment migration lifecycle checks passed."
|
||||||
Executable
+69
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
|
||||||
|
project_name="${MINEROPS_AGENT_ENROLLMENT_TEST_PROJECT:-minerops-agent-enrollment-test-$$}"
|
||||||
|
compose=(
|
||||||
|
docker compose
|
||||||
|
--project-name "${project_name}"
|
||||||
|
--env-file .env.dev
|
||||||
|
-f compose.dev.yaml
|
||||||
|
)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
if (( status != 0 )); then
|
||||||
|
"${compose[@]}" logs --no-color --tail 120 postgres || true
|
||||||
|
fi
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
[[ -f .env.dev ]] || {
|
||||||
|
echo "Missing .env.dev" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for source_file in \
|
||||||
|
backend/tests/test_agent_enrollment.py \
|
||||||
|
backend/tests/test_agent_enrollment_postgres.py \
|
||||||
|
backend/tests/test_agent_auth.py
|
||||||
|
do
|
||||||
|
permissions="$(stat -c '%a' "${source_file}")"
|
||||||
|
(( (10#${permissions} % 10) & 4 )) || {
|
||||||
|
echo "${source_file} is not readable by the non-root test container" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
>/dev/null 2>&1 && break
|
||||||
|
|
||||||
|
(( attempt < 60 )) || {
|
||||||
|
echo "PostgreSQL did not become ready" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
backend alembic upgrade head
|
||||||
|
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
||||||
|
backend python -m unittest -v \
|
||||||
|
tests.test_agent_auth \
|
||||||
|
tests.test_agent_enrollment \
|
||||||
|
tests.test_agent_enrollment_postgres
|
||||||
|
|
||||||
|
echo "Secure Agent enrollment focused tests passed."
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
project_name="${MINEROPS_LEGACY_RETIREMENT_PROJECT:-minerops-legacy-retirement-$$}"
|
||||||
|
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
if (( status != 0 )); then
|
||||||
|
"${compose[@]}" logs --no-color --tail 160 postgres || true
|
||||||
|
fi
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
run_db_alembic() {
|
||||||
|
local database="$1"
|
||||||
|
shift
|
||||||
|
"${compose[@]}" run --rm --no-deps -e POSTGRES_DB="${database}" backend alembic "$@"
|
||||||
|
}
|
||||||
|
psql_named() {
|
||||||
|
local database="$1"
|
||||||
|
shift
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'database="$1"; shift; exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$database" "$@"' \
|
||||||
|
sh "${database}" "$@"
|
||||||
|
}
|
||||||
|
create_database() {
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'exec createdb -U "$POSTGRES_USER" "$1"' sh "$1"
|
||||||
|
}
|
||||||
|
run_contract() {
|
||||||
|
local database="$1"
|
||||||
|
local case_name="$2"
|
||||||
|
local agent_id="$3"
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
--volume "${repo_root}/backend/tests:/app/tests:ro" \
|
||||||
|
--env POSTGRES_DB="${database}" \
|
||||||
|
--env MINEROPS_LEGACY_RETIREMENT_DISPOSABLE=1 \
|
||||||
|
--env MINEROPS_LEGACY_RETIREMENT_CASE="${case_name}" \
|
||||||
|
--env MINEROPS_AGENT_ID="${agent_id}" \
|
||||||
|
--env MINEROPS_AGENT_SHARED_SECRET=legacy-retirement-contract-secret \
|
||||||
|
backend python -m unittest -v \
|
||||||
|
tests.test_agent_legacy_auth_retirement_postgres
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||||
|
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
expected_head="$(run_db_alembic postgres heads 2>/dev/null | awk 'NR == 1 { print $1 }')"
|
||||||
|
[[ "${expected_head}" == "0020_existing_agent_transition" ]]
|
||||||
|
|
||||||
|
echo "Testing selective 0019 backfill from the 0018 schema..."
|
||||||
|
create_database legacy_retirement_uncredentialed
|
||||||
|
run_db_alembic legacy_retirement_uncredentialed upgrade 0018_legacy_agent_routing
|
||||||
|
psql_named legacy_retirement_uncredentialed -c \
|
||||||
|
"INSERT INTO minerops_site_agents (agent_id, site_name) VALUES ('legacy-uncredentialed', 'Legacy')"
|
||||||
|
run_db_alembic legacy_retirement_uncredentialed upgrade head
|
||||||
|
[[ "$(psql_named legacy_retirement_uncredentialed -Atc "SELECT legacy_auth_disabled_at IS NULL FROM minerops_site_agents WHERE agent_id='legacy-uncredentialed'")" == "t" ]]
|
||||||
|
[[ "$(psql_named legacy_retirement_uncredentialed -Atc "SELECT count(*) FROM agent_legacy_auth_retirements WHERE agent_id='legacy-uncredentialed'")" == "0" ]]
|
||||||
|
|
||||||
|
create_database legacy_retirement_credentialed
|
||||||
|
run_db_alembic legacy_retirement_credentialed upgrade 0018_legacy_agent_routing
|
||||||
|
psql_named legacy_retirement_credentialed <<'SQL'
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id, site_name, agent_secret_hash,
|
||||||
|
credential_created_at, credential_revoked_at
|
||||||
|
) VALUES (
|
||||||
|
'legacy-credentialed', 'Legacy', repeat('a', 64),
|
||||||
|
now() - interval '1 hour', now()
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
run_db_alembic legacy_retirement_credentialed upgrade head
|
||||||
|
[[ "$(psql_named legacy_retirement_credentialed -Atc "SELECT legacy_auth_disabled_at = credential_created_at FROM minerops_site_agents WHERE agent_id='legacy-credentialed'")" == "t" ]]
|
||||||
|
[[ "$(psql_named legacy_retirement_credentialed -Atc "SELECT retired_at = (SELECT credential_created_at FROM minerops_site_agents WHERE agent_id='legacy-credentialed') FROM agent_legacy_auth_retirements WHERE agent_id='legacy-credentialed'")" == "t" ]]
|
||||||
|
|
||||||
|
echo "Testing backfill from used enrollment history after credential clearing..."
|
||||||
|
create_database legacy_retirement_enrollment
|
||||||
|
run_db_alembic legacy_retirement_enrollment upgrade 0018_legacy_agent_routing
|
||||||
|
psql_named legacy_retirement_enrollment <<'SQL'
|
||||||
|
INSERT INTO sites (id, name)
|
||||||
|
VALUES ('00000000-0000-4000-8000-000000001901', 'Enrollment Evidence');
|
||||||
|
INSERT INTO agent_zones (id, site_id, name)
|
||||||
|
VALUES (
|
||||||
|
'00000000-0000-4000-8000-000000001902',
|
||||||
|
'00000000-0000-4000-8000-000000001901',
|
||||||
|
'Enrollment Evidence Zone'
|
||||||
|
);
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id, site_name, site_id, agent_zone_id
|
||||||
|
) VALUES (
|
||||||
|
'enrollment-evidence-cleared',
|
||||||
|
'Enrollment Evidence',
|
||||||
|
'00000000-0000-4000-8000-000000001901',
|
||||||
|
'00000000-0000-4000-8000-000000001902'
|
||||||
|
);
|
||||||
|
INSERT INTO agent_enrollments (
|
||||||
|
token_hash, site_id, agent_zone_id, display_name,
|
||||||
|
created_at, expires_at, used_at, enrolled_agent_id
|
||||||
|
) VALUES (
|
||||||
|
repeat('c', 64),
|
||||||
|
'00000000-0000-4000-8000-000000001901',
|
||||||
|
'00000000-0000-4000-8000-000000001902',
|
||||||
|
'Enrollment Evidence Agent',
|
||||||
|
now() - interval '2 hours',
|
||||||
|
now() + interval '1 hour',
|
||||||
|
now() - interval '1 hour',
|
||||||
|
'enrollment-evidence-cleared'
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
run_db_alembic legacy_retirement_enrollment upgrade head
|
||||||
|
[[ "$(psql_named legacy_retirement_enrollment -Atc "SELECT count(*) FROM agent_legacy_auth_retirements WHERE agent_id='enrollment-evidence-cleared'")" == "1" ]]
|
||||||
|
[[ "$(psql_named legacy_retirement_enrollment -Atc "SELECT legacy_auth_disabled_at IS NOT NULL AND agent_secret_hash IS NULL AND credential_created_at IS NULL AND credential_revoked_at IS NULL FROM minerops_site_agents WHERE agent_id='enrollment-evidence-cleared'")" == "t" ]]
|
||||||
|
|
||||||
|
echo "Testing real PostgreSQL authentication before and after credential clearing..."
|
||||||
|
run_contract legacy_retirement_uncredentialed legacy legacy-uncredentialed
|
||||||
|
run_contract legacy_retirement_credentialed retired legacy-credentialed
|
||||||
|
|
||||||
|
echo "Testing database-enforced atomic retirement and ledger immutability..."
|
||||||
|
psql_named legacy_retirement_credentialed -c \
|
||||||
|
"INSERT INTO minerops_site_agents (agent_id, site_name, agent_secret_hash, credential_created_at) VALUES ('database-issued', 'Issued', repeat('b', 64), now())"
|
||||||
|
[[ "$(psql_named legacy_retirement_credentialed -Atc "SELECT count(*) FROM agent_legacy_auth_retirements WHERE agent_id='database-issued'")" == "1" ]]
|
||||||
|
[[ "$(psql_named legacy_retirement_credentialed -Atc "SELECT legacy_auth_disabled_at IS NOT NULL FROM minerops_site_agents WHERE agent_id='database-issued'")" == "t" ]]
|
||||||
|
if psql_named legacy_retirement_credentialed -c \
|
||||||
|
"UPDATE minerops_site_agents SET legacy_auth_disabled_at = NULL WHERE agent_id='legacy-credentialed'"; then
|
||||||
|
echo "Permanent retirement marker was unexpectedly cleared" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if psql_named legacy_retirement_credentialed -c \
|
||||||
|
"UPDATE agent_legacy_auth_retirements SET retired_at = now() WHERE agent_id='legacy-credentialed'"; then
|
||||||
|
echo "Retirement history was unexpectedly updated" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if psql_named legacy_retirement_credentialed -c \
|
||||||
|
"DELETE FROM agent_legacy_auth_retirements WHERE agent_id='legacy-credentialed'"; then
|
||||||
|
echo "Retirement history was unexpectedly deleted" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if psql_named legacy_retirement_credentialed -c \
|
||||||
|
"TRUNCATE agent_legacy_auth_retirements"; then
|
||||||
|
echo "Retirement history was unexpectedly truncated" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Testing downgrade decision points..."
|
||||||
|
run_db_alembic legacy_retirement_uncredentialed downgrade 0018_legacy_agent_routing
|
||||||
|
[[ "$(psql_named legacy_retirement_uncredentialed -Atc 'SELECT version_num FROM alembic_version')" == "0018_legacy_agent_routing" ]]
|
||||||
|
if output="$(run_db_alembic legacy_retirement_credentialed downgrade 0018_legacy_agent_routing 2>&1)"; then
|
||||||
|
echo "0019 downgrade unexpectedly discarded retirement history" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ "${output}" == *"durable identity-level legacy-auth retirement history exists"* ]]
|
||||||
|
[[ "$(psql_named legacy_retirement_credentialed -Atc 'SELECT version_num FROM alembic_version')" == "${expected_head}" ]]
|
||||||
|
|
||||||
|
echo "Durable legacy Agent authentication retirement checks passed."
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
|
||||||
|
project_name="${MINEROPS_AGENT_MANAGEMENT_TEST_PROJECT:-minerops-agent-management-test-$$}"
|
||||||
|
|
||||||
|
compose=(
|
||||||
|
docker compose
|
||||||
|
--project-name "${project_name}"
|
||||||
|
--env-file .env.dev
|
||||||
|
-f compose.dev.yaml
|
||||||
|
)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
|
||||||
|
if (( status != 0 )); then
|
||||||
|
"${compose[@]}" logs \
|
||||||
|
--no-color \
|
||||||
|
--tail 120 \
|
||||||
|
postgres || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
"${compose[@]}" down \
|
||||||
|
--volumes \
|
||||||
|
--remove-orphans \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
[[ -f .env.dev ]] || {
|
||||||
|
echo "Missing .env.dev" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for source_file in \
|
||||||
|
backend/app/agent_registry/__init__.py \
|
||||||
|
backend/app/agent_registry/models.py \
|
||||||
|
backend/app/agent_registry/repository.py \
|
||||||
|
backend/app/agent_registry/service.py \
|
||||||
|
backend/app/agent_registry/dependencies.py \
|
||||||
|
backend/app/agent_registry/router.py \
|
||||||
|
backend/tests/test_agent_registry.py \
|
||||||
|
backend/tests/test_agent_registry_postgres.py \
|
||||||
|
backend/tests/test_agent_management_frontend.py \
|
||||||
|
backend/app/frontend/agent_management_view.py \
|
||||||
|
backend/app/frontend/static/agent-management.js
|
||||||
|
do
|
||||||
|
[[ -r "${source_file}" ]] || {
|
||||||
|
echo "Missing ${source_file}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
if "${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
>/dev/null 2>&1
|
||||||
|
then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
(( attempt < 60 )) || {
|
||||||
|
echo "PostgreSQL did not become ready" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
backend alembic upgrade head
|
||||||
|
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
||||||
|
backend python -m unittest -v \
|
||||||
|
tests.test_agent_registry \
|
||||||
|
tests.test_agent_registry_postgres \
|
||||||
|
tests.test_agent_management_frontend
|
||||||
|
|
||||||
|
echo "Agent Management tests passed."
|
||||||
Executable
+68
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
PROJECT="minerops-agent-transport-isolation-test-$$"
|
||||||
|
|
||||||
|
compose=(
|
||||||
|
docker compose
|
||||||
|
--project-name "$PROJECT"
|
||||||
|
--env-file .env.dev
|
||||||
|
-f compose.dev.yaml
|
||||||
|
)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
"${compose[@]}" down \
|
||||||
|
--volumes \
|
||||||
|
--remove-orphans \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "=== START DISPOSABLE POSTGRES ==="
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== WAIT FOR POSTGRES ==="
|
||||||
|
|
||||||
|
ready=0
|
||||||
|
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if "${compose[@]}" exec -T postgres \
|
||||||
|
sh -c 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
>/dev/null 2>&1
|
||||||
|
then
|
||||||
|
ready=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$ready" -ne 1 ]; then
|
||||||
|
echo "PostgreSQL did not become ready."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== BUILD BACKEND ==="
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== MIGRATE DATABASE ==="
|
||||||
|
"${compose[@]}" run --rm backend \
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== RUNTIME AGENT ZONE ISOLATION ==="
|
||||||
|
"${compose[@]}" run --rm \
|
||||||
|
--volume "$PWD/backend/tests:/app/tests:ro" \
|
||||||
|
--env MINEROPS_TRANSPORT_ISOLATION_DISPOSABLE=1 \
|
||||||
|
backend \
|
||||||
|
python -m unittest -v \
|
||||||
|
tests.test_agent_transport_isolation_postgres
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Runtime Agent transport Zone-isolation tests passed."
|
||||||
Executable
+187
@@ -0,0 +1,187 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
project_name="${MINEROPS_AGENT_ZONES_MIGRATION_PROJECT:-minerops-agent-zones-migration-$$}"
|
||||||
|
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
if (( status != 0 )); then
|
||||||
|
"${compose[@]}" logs --no-color --tail 160 postgres || true
|
||||||
|
fi
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
run_alembic() { "${compose[@]}" run --rm --no-deps backend alembic "$@"; }
|
||||||
|
psql_db() {
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" "$@"' sh "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||||
|
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
|
||||||
|
site_a="00000000-0000-4000-8000-000000001690"
|
||||||
|
site_b="00000000-0000-4000-8000-000000001691"
|
||||||
|
miner_a="00000000-0000-4000-8000-000000001692"
|
||||||
|
|
||||||
|
echo "Testing production path 0014 -> 0015 -> 0016 and truthful unassigned data..."
|
||||||
|
run_alembic upgrade 0014_repair_parts_tracking
|
||||||
|
psql_db -c "INSERT INTO minerops_site_agents (agent_id, site_name) VALUES ('legacy-zone-unassigned', 'Legacy Display')"
|
||||||
|
run_alembic upgrade 0015_miner_control_foundation
|
||||||
|
psql_db <<SQL
|
||||||
|
INSERT INTO sites (id, name) VALUES
|
||||||
|
('${site_a}', 'Agent Zone Site A'),
|
||||||
|
('${site_b}', 'Agent Zone Site B');
|
||||||
|
INSERT INTO miners (id, site_id, asset_tag, first_seen_at, last_seen_at)
|
||||||
|
VALUES ('${miner_a}', '${site_a}', 'AGENT-ZONE-MIGRATION', NULL, NULL);
|
||||||
|
INSERT INTO minerops_site_agents (agent_id, site_id, site_name)
|
||||||
|
VALUES ('zone-agent-a', '${site_a}', 'Agent Zone Site A');
|
||||||
|
INSERT INTO minerops_agent_snapshots (agent_id, site_name, miner_count, payload)
|
||||||
|
VALUES ('zone-agent-a', 'Agent Zone Site A', 0, '{}');
|
||||||
|
INSERT INTO minerops_agent_discoveries (
|
||||||
|
id, agent_id, site_name, ip_address, raw_payload, discovery_status
|
||||||
|
) VALUES (
|
||||||
|
'agent-zone-discovery', 'zone-agent-a', 'Agent Zone Site A',
|
||||||
|
'192.0.2.90', '{}', 'new'
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
run_alembic upgrade 0016_agent_zones
|
||||||
|
[[ "$(run_alembic current 2>/dev/null)" == *"0016_agent_zones"* ]]
|
||||||
|
|
||||||
|
psql_db <<SQL
|
||||||
|
DO \$\$
|
||||||
|
DECLARE zone_a uuid;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO zone_a FROM agent_zones
|
||||||
|
WHERE site_id = '${site_a}' AND name = 'Default Zone';
|
||||||
|
IF zone_a IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Default Zone was not created for Site A';
|
||||||
|
END IF;
|
||||||
|
IF (SELECT agent_zone_id FROM miners WHERE id = '${miner_a}') IS DISTINCT FROM zone_a THEN
|
||||||
|
RAISE EXCEPTION 'Miner was not backfilled to its Site default Zone';
|
||||||
|
END IF;
|
||||||
|
IF (SELECT agent_zone_id FROM minerops_site_agents WHERE agent_id = 'zone-agent-a')
|
||||||
|
IS DISTINCT FROM zone_a THEN
|
||||||
|
RAISE EXCEPTION 'Agent was not backfilled to its Site default Zone';
|
||||||
|
END IF;
|
||||||
|
IF (SELECT agent_zone_id FROM minerops_agent_snapshots WHERE agent_id = 'zone-agent-a' LIMIT 1)
|
||||||
|
IS DISTINCT FROM zone_a THEN
|
||||||
|
RAISE EXCEPTION 'Snapshot Zone backfill failed';
|
||||||
|
END IF;
|
||||||
|
IF (SELECT agent_zone_id FROM minerops_agent_discoveries WHERE id = 'agent-zone-discovery')
|
||||||
|
IS DISTINCT FROM zone_a THEN
|
||||||
|
RAISE EXCEPTION 'Discovery Zone backfill failed';
|
||||||
|
END IF;
|
||||||
|
IF (SELECT agent_zone_id FROM minerops_site_agents
|
||||||
|
WHERE agent_id = 'legacy-zone-unassigned') IS NOT NULL THEN
|
||||||
|
RAISE EXCEPTION 'Legacy Agent without a durable Site was guessed into a Zone';
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_indexes
|
||||||
|
WHERE indexname = 'uq_minerops_site_agents_agent_zone_id') THEN
|
||||||
|
RAISE EXCEPTION 'One-Agent-per-Zone index missing';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_indexes
|
||||||
|
WHERE indexname = 'uq_minerops_site_agents_site_id') THEN
|
||||||
|
RAISE EXCEPTION 'One-Agent-per-Site index was not removed';
|
||||||
|
END IF;
|
||||||
|
END \$\$;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "Testing CIDR, active-name, cross-Site, and one-Agent-per-Zone integrity..."
|
||||||
|
psql_db <<SQL
|
||||||
|
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||||
|
SELECT id, '10.30.0.0/24'::cidr FROM agent_zones
|
||||||
|
WHERE site_id = '${site_a}' AND name = 'Default Zone';
|
||||||
|
DO \$\$
|
||||||
|
DECLARE zone_a uuid;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO zone_a FROM agent_zones
|
||||||
|
WHERE site_id = '${site_a}' AND name = 'Default Zone';
|
||||||
|
IF (SELECT network::text FROM agent_zone_network_ranges
|
||||||
|
WHERE agent_zone_id = zone_a) <> '10.30.0.0/24' THEN
|
||||||
|
RAISE EXCEPTION 'CIDR was not stored canonically';
|
||||||
|
END IF;
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO agent_zones (site_id, name) VALUES ('${site_a}', 'default zone');
|
||||||
|
RAISE EXCEPTION 'Case-insensitive active Zone duplicate was accepted';
|
||||||
|
EXCEPTION WHEN unique_violation THEN NULL;
|
||||||
|
END;
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO minerops_site_agents (agent_id, site_id, agent_zone_id, site_name)
|
||||||
|
VALUES ('duplicate-zone-agent', '${site_a}', zone_a, 'Duplicate');
|
||||||
|
RAISE EXCEPTION 'Second Agent in one Zone was accepted';
|
||||||
|
EXCEPTION WHEN unique_violation THEN NULL;
|
||||||
|
END;
|
||||||
|
BEGIN
|
||||||
|
UPDATE miners SET site_id = '${site_b}' WHERE id = '${miner_a}';
|
||||||
|
RAISE EXCEPTION 'Cross-Site miner Zone combination was accepted';
|
||||||
|
EXCEPTION WHEN foreign_key_violation THEN NULL;
|
||||||
|
END;
|
||||||
|
END \$\$;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "Confirming CIDR values with host bits are rejected..."
|
||||||
|
if psql_db -c "INSERT INTO agent_zone_network_ranges (agent_zone_id, network) SELECT id, '10.30.0.7/24'::cidr FROM agent_zones WHERE site_id = '${site_a}' AND name = 'Default Zone'"; then
|
||||||
|
echo "Host-address CIDR unexpectedly passed PostgreSQL validation" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
echo "Testing downgrade refusal for irreducible network configuration..."
|
||||||
|
if output="$(run_alembic downgrade 0015_miner_control_foundation 2>&1)"; then
|
||||||
|
echo "Downgrade unexpectedly accepted Agent Zone network ranges" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ "${output}" == *"cannot be represented by the one-Agent-per-Site model"* ]]
|
||||||
|
[[ "$(run_alembic current 2>/dev/null)" == *"0016_agent_zones"* ]]
|
||||||
|
[[ "$(psql_db -Atc "SELECT count(*) FROM agent_zone_network_ranges")" == "1" ]]
|
||||||
|
psql_db -c "DELETE FROM agent_zone_network_ranges"
|
||||||
|
|
||||||
|
echo "Testing downgrade refusal with multiple Agents at one Site..."
|
||||||
|
psql_db <<SQL
|
||||||
|
WITH created AS (
|
||||||
|
INSERT INTO agent_zones (site_id, name)
|
||||||
|
VALUES ('${site_a}', 'Room 2') RETURNING id
|
||||||
|
)
|
||||||
|
INSERT INTO minerops_site_agents (agent_id, site_id, agent_zone_id, site_name)
|
||||||
|
SELECT 'zone-agent-b', '${site_a}', id, 'Agent Zone Site A' FROM created;
|
||||||
|
SQL
|
||||||
|
if output="$(run_alembic downgrade 0015_miner_control_foundation 2>&1)"; then
|
||||||
|
echo "Downgrade unexpectedly accepted multiple Agents at one Site" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ "${output}" == *"multiple Agent installations are assigned"* ]]
|
||||||
|
[[ "${output}" == *"${site_a}"* ]]
|
||||||
|
[[ "$(run_alembic current 2>/dev/null)" == *"0016_agent_zones"* ]]
|
||||||
|
[[ "$(psql_db -Atc "SELECT count(*) FROM minerops_site_agents WHERE site_id='${site_a}'")" == "2" ]]
|
||||||
|
psql_db -c "DELETE FROM minerops_site_agents WHERE agent_id = 'zone-agent-b'; DELETE FROM agent_zones WHERE site_id = '${site_a}' AND name = 'Room 2'"
|
||||||
|
|
||||||
|
echo "Testing legal downgrade, data preservation, and re-upgrade..."
|
||||||
|
run_alembic downgrade 0015_miner_control_foundation
|
||||||
|
[[ "$(run_alembic current 2>/dev/null)" == *"0015_miner_control_foundation"* ]]
|
||||||
|
[[ "$(psql_db -Atc "SELECT count(*) FROM miners WHERE id='${miner_a}'")" == "1" ]]
|
||||||
|
[[ "$(psql_db -Atc "SELECT count(*) FROM minerops_site_agents WHERE agent_id='zone-agent-a'")" == "1" ]]
|
||||||
|
[[ "$(psql_db -Atc "SELECT count(*) FROM information_schema.tables WHERE table_name='agent_zones'")" == "0" ]]
|
||||||
|
run_alembic upgrade 0016_agent_zones
|
||||||
|
[[ "$(run_alembic current 2>/dev/null)" == *"0016_agent_zones"* ]]
|
||||||
|
|
||||||
|
echo "Testing empty database through head..."
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_agent_zones_empty'
|
||||||
|
"${compose[@]}" run --rm --no-deps -e POSTGRES_DB=minerops_agent_zones_empty backend alembic upgrade head
|
||||||
|
expected_head="$(run_alembic heads 2>/dev/null | awk 'NR == 1 { print $1 }')"
|
||||||
|
empty_revision="$("${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'psql -U "$POSTGRES_USER" -d minerops_agent_zones_empty -Atc "SELECT version_num FROM alembic_version"')"
|
||||||
|
[[ -n "${expected_head}" && "${empty_revision}" == "${expected_head}" ]]
|
||||||
|
|
||||||
|
echo "Agent Zones migration lifecycle checks passed."
|
||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
project_name="${MINEROPS_AGENT_ZONES_TEST_PROJECT:-minerops-agent-zones-test-$$}"
|
||||||
|
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
if (( status != 0 )); then
|
||||||
|
"${compose[@]}" logs --no-color --tail 160 postgres || true
|
||||||
|
fi
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||||
|
for source_file in \
|
||||||
|
backend/tests/test_agent_zones.py \
|
||||||
|
backend/tests/test_agent_zones_postgres.py \
|
||||||
|
backend/tests/test_miner_control.py \
|
||||||
|
backend/tests/test_miner_control_postgres.py \
|
||||||
|
backend/tests/test_agent_commands.py; do
|
||||||
|
permissions="$(stat -c '%a' "${source_file}")"
|
||||||
|
(( (10#${permissions} % 10) & 4 )) || {
|
||||||
|
echo "${source_file} is not readable by the non-root test container" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||||
|
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
||||||
|
-v "${repo_root}/agents:/agents:ro" \
|
||||||
|
backend python -m unittest -v \
|
||||||
|
tests.test_agent_zones \
|
||||||
|
tests.test_agent_zones_postgres \
|
||||||
|
tests.test_miner_control \
|
||||||
|
tests.test_miner_control_postgres \
|
||||||
|
tests.test_agent_commands
|
||||||
|
|
||||||
|
scripts/test-windows-agent-source.sh
|
||||||
|
echo "Agent Zones focused tests passed."
|
||||||
@@ -132,7 +132,9 @@ health_json="$(cat "${health_file}")"
|
|||||||
|
|
||||||
echo "Checking migrations and required schema..."
|
echo "Checking migrations and required schema..."
|
||||||
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
|
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
|
||||||
[[ "${current_revision}" == *"0015_miner_control_foundation"* ]]
|
expected_revision="$("${compose[@]}" exec -T backend alembic heads 2>/dev/null | awk 'NR == 1 { print $1 }')"
|
||||||
|
[[ -n "${expected_revision}" ]]
|
||||||
|
[[ "${current_revision}" == *"${expected_revision}"* ]]
|
||||||
|
|
||||||
missing_tables="$(
|
missing_tables="$(
|
||||||
"${compose[@]}" exec -T postgres sh -c \
|
"${compose[@]}" exec -T postgres sh -c \
|
||||||
|
|||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
project_name="${MINEROPS_LEGACY_HTTP_PROJECT:-minerops-legacy-http-$$}"
|
||||||
|
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
if (( status != 0 )); then
|
||||||
|
"${compose[@]}" logs --no-color --tail 160 postgres || true
|
||||||
|
fi
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||||
|
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
|
||||||
|
echo "Building a production-shaped 0014 database..."
|
||||||
|
"${compose[@]}" run --rm --no-deps backend \
|
||||||
|
alembic upgrade 0014_repair_parts_tracking
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||||
|
INSERT INTO sites (id, name) VALUES
|
||||||
|
('00000000-0000-4000-8000-000000001a01', 'Plexus'),
|
||||||
|
('00000000-0000-4000-8000-000000001a02', 'Contract Site B');
|
||||||
|
INSERT INTO miners (
|
||||||
|
id, site_id, asset_tag, current_ip, current_mac, status,
|
||||||
|
first_seen_at, last_seen_at
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
'00000000-0000-4000-8000-000000001a11',
|
||||||
|
'00000000-0000-4000-8000-000000001a01',
|
||||||
|
'LEGACY-HTTP-A', '192.0.2.201', '02:00:00:00:1a:11',
|
||||||
|
'online', NULL, NULL
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'00000000-0000-4000-8000-000000001a12',
|
||||||
|
'00000000-0000-4000-8000-000000001a02',
|
||||||
|
'LEGACY-HTTP-B', '192.0.2.202', '02:00:00:00:1a:12',
|
||||||
|
'online', NULL, NULL
|
||||||
|
);
|
||||||
|
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||||
|
VALUES ('legacy-plexus-http', ' plexus ');
|
||||||
|
SQL
|
||||||
|
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
|
||||||
|
|
||||||
|
echo "Running real ASGI authentication and PostgreSQL routing checks..."
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
--volume "${repo_root}/backend/tests:/app/tests:ro" \
|
||||||
|
--env MINEROPS_LEGACY_HTTP_POSTGRES_DISPOSABLE=1 \
|
||||||
|
--env MINEROPS_AGENT_ID=legacy-plexus-http \
|
||||||
|
--env MINEROPS_AGENT_SHARED_SECRET=legacy-http-contract-secret \
|
||||||
|
backend python -m unittest -v \
|
||||||
|
tests.test_legacy_agent_http_postgres
|
||||||
|
|
||||||
|
echo "Full-stack legacy Agent HTTP/PostgreSQL contract passed."
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
project_name="${MINEROPS_LEGACY_PRODUCTION_PROJECT:-minerops-legacy-production-$$}"
|
||||||
|
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
if (( status != 0 )); then
|
||||||
|
"${compose[@]}" logs --no-color --tail 160 postgres || true
|
||||||
|
fi
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||||
|
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
|
||||||
|
echo "Building the production-shaped database from the 0014 baseline..."
|
||||||
|
"${compose[@]}" run --rm --no-deps backend \
|
||||||
|
alembic upgrade 0014_repair_parts_tracking
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||||
|
INSERT INTO sites (id, name) VALUES
|
||||||
|
('00000000-0000-4000-8000-000000001901', 'Plexus'),
|
||||||
|
('00000000-0000-4000-8000-000000001902', 'Contract Site B');
|
||||||
|
INSERT INTO miners (
|
||||||
|
id, site_id, asset_tag, current_ip, current_mac, status,
|
||||||
|
first_seen_at, last_seen_at
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
'00000000-0000-4000-8000-000000001911',
|
||||||
|
'00000000-0000-4000-8000-000000001901',
|
||||||
|
'LEGACY-CONTRACT-A', '192.0.2.191', '02:00:00:00:19:11',
|
||||||
|
'online', NULL, NULL
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'00000000-0000-4000-8000-000000001912',
|
||||||
|
'00000000-0000-4000-8000-000000001902',
|
||||||
|
'LEGACY-CONTRACT-B', '192.0.2.192', '02:00:00:00:19:12',
|
||||||
|
'online', NULL, NULL
|
||||||
|
);
|
||||||
|
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||||
|
VALUES ('legacy-plexus-production', ' plexus ');
|
||||||
|
SQL
|
||||||
|
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
|
||||||
|
|
||||||
|
echo "Running legacy authentication, routing, and inert telemetry contract..."
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
--volume "${repo_root}/backend/tests:/app/tests:ro" \
|
||||||
|
--env MINEROPS_LEGACY_PRODUCTION_DISPOSABLE=1 \
|
||||||
|
--env MINEROPS_AGENT_ID=legacy-plexus-production \
|
||||||
|
--env MINEROPS_AGENT_SHARED_SECRET=legacy-production-contract-secret \
|
||||||
|
backend python -m unittest -v \
|
||||||
|
tests.test_legacy_agent_production_postgres
|
||||||
|
|
||||||
|
echo "Legacy production compatibility contract passed."
|
||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
project_name="${MINEROPS_LEGACY_ROUTING_PROJECT:-minerops-legacy-routing-$$}"
|
||||||
|
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
if (( status != 0 )); then
|
||||||
|
"${compose[@]}" logs --no-color --tail 160 postgres || true
|
||||||
|
fi
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
run_db_alembic() {
|
||||||
|
local database="$1"
|
||||||
|
shift
|
||||||
|
"${compose[@]}" run --rm --no-deps -e POSTGRES_DB="${database}" backend alembic "$@"
|
||||||
|
}
|
||||||
|
psql_named() {
|
||||||
|
local database="$1"
|
||||||
|
shift
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'database="$1"; shift; exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$database" "$@"' \
|
||||||
|
sh "${database}" "$@"
|
||||||
|
}
|
||||||
|
create_database() {
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'exec createdb -U "$POSTGRES_USER" "$1"' sh "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||||
|
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
expected_revision="0018_legacy_agent_routing"
|
||||||
|
|
||||||
|
echo "Testing the real single-Site production shape from 0014 -> 0018..."
|
||||||
|
create_database legacy_routing_happy
|
||||||
|
run_db_alembic legacy_routing_happy upgrade 0014_repair_parts_tracking
|
||||||
|
psql_named legacy_routing_happy <<'SQL'
|
||||||
|
DELETE FROM sites;
|
||||||
|
INSERT INTO sites (id, name)
|
||||||
|
VALUES (
|
||||||
|
'191e6d8f-ce40-4488-a8f1-ad6ce1790266',
|
||||||
|
'Plexus Mining Site'
|
||||||
|
);
|
||||||
|
INSERT INTO miners (id, site_id, asset_tag, first_seen_at, last_seen_at, current_mac)
|
||||||
|
VALUES (
|
||||||
|
'00000000-0000-4000-8000-000000001811',
|
||||||
|
'191e6d8f-ce40-4488-a8f1-ad6ce1790266',
|
||||||
|
'LEGACY-ROUTING-MINER', NULL, NULL, '02:00:00:00:18:11'
|
||||||
|
);
|
||||||
|
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||||
|
VALUES ('plexus-windows-agent', 'Plexus');
|
||||||
|
INSERT INTO minerops_agent_snapshots (agent_id, site_name, miner_count, payload)
|
||||||
|
VALUES ('plexus-windows-agent', 'Plexus', 1, '{}');
|
||||||
|
INSERT INTO minerops_agent_discoveries (
|
||||||
|
id, agent_id, site_name, ip_address, raw_payload, discovery_status
|
||||||
|
) VALUES (
|
||||||
|
'legacy-routing-discovery', 'plexus-windows-agent', 'Plexus',
|
||||||
|
'192.0.2.181', '{}', 'new'
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
run_db_alembic legacy_routing_happy upgrade "${expected_revision}"
|
||||||
|
[[ "$(psql_named legacy_routing_happy -Atc 'SELECT version_num FROM alembic_version')" == "${expected_revision}" ]]
|
||||||
|
psql_named legacy_routing_happy <<'SQL'
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
expected_site uuid := '191e6d8f-ce40-4488-a8f1-ad6ce1790266';
|
||||||
|
expected_zone uuid;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO expected_zone FROM agent_zones
|
||||||
|
WHERE site_id = expected_site AND lower(name) = 'default zone' AND is_enabled;
|
||||||
|
IF expected_zone IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Expected enabled Default Zone is missing';
|
||||||
|
END IF;
|
||||||
|
IF (SELECT site_id FROM minerops_site_agents
|
||||||
|
WHERE agent_id = 'plexus-windows-agent')
|
||||||
|
IS DISTINCT FROM expected_site THEN
|
||||||
|
RAISE EXCEPTION 'Sole-Site legacy Agent resolution failed';
|
||||||
|
END IF;
|
||||||
|
IF (SELECT agent_zone_id FROM minerops_site_agents
|
||||||
|
WHERE agent_id = 'plexus-windows-agent')
|
||||||
|
IS DISTINCT FROM expected_zone THEN
|
||||||
|
RAISE EXCEPTION 'Legacy Agent Zone routing failed';
|
||||||
|
END IF;
|
||||||
|
IF (SELECT agent_zone_id FROM miners
|
||||||
|
WHERE id = '00000000-0000-4000-8000-000000001811')
|
||||||
|
IS DISTINCT FROM expected_zone THEN
|
||||||
|
RAISE EXCEPTION 'Existing miner ownership was not preserved in its Site default Zone';
|
||||||
|
END IF;
|
||||||
|
IF (SELECT current_mac FROM miners
|
||||||
|
WHERE id = '00000000-0000-4000-8000-000000001811')
|
||||||
|
IS DISTINCT FROM '02:00:00:00:18:11' THEN
|
||||||
|
RAISE EXCEPTION 'Miner identity/history projection was rewritten';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM minerops_agent_snapshots
|
||||||
|
WHERE agent_id = 'plexus-windows-agent'
|
||||||
|
AND agent_zone_id IS DISTINCT FROM expected_zone
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Legacy snapshots were not attributed to the Agent Zone';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM minerops_agent_discoveries
|
||||||
|
WHERE agent_id = 'plexus-windows-agent'
|
||||||
|
AND agent_zone_id IS DISTINCT FROM expected_zone
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Legacy discoveries were not attributed to the Agent Zone';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "Testing unmatched multi-Site data fails closed before routing writes..."
|
||||||
|
create_database legacy_routing_missing
|
||||||
|
run_db_alembic legacy_routing_missing upgrade 0014_repair_parts_tracking
|
||||||
|
psql_named legacy_routing_missing <<'SQL'
|
||||||
|
DELETE FROM sites;
|
||||||
|
INSERT INTO sites (id, name) VALUES
|
||||||
|
('00000000-0000-4000-8000-000000001801', 'Candidate Site A'),
|
||||||
|
('00000000-0000-4000-8000-000000001802', 'Candidate Site B');
|
||||||
|
SQL
|
||||||
|
psql_named legacy_routing_missing -c \
|
||||||
|
"INSERT INTO minerops_site_agents (agent_id, site_name) VALUES ('missing-agent', 'No Such Site')"
|
||||||
|
run_db_alembic legacy_routing_missing upgrade 0017_agent_enrollment
|
||||||
|
if output="$(run_db_alembic legacy_routing_missing upgrade "${expected_revision}" 2>&1)"; then
|
||||||
|
echo "Missing Site match unexpectedly passed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ "${output}" == *"unmatched multi-Site installations fail closed"* ]]
|
||||||
|
[[ "${output}" == *"missing-agent"* && "${output}" == *"/'0'/'2'"* ]]
|
||||||
|
[[ "$(psql_named legacy_routing_missing -Atc 'SELECT version_num FROM alembic_version')" == "0017_agent_enrollment" ]]
|
||||||
|
[[ "$(psql_named legacy_routing_missing -Atc "SELECT site_id IS NULL FROM minerops_site_agents WHERE agent_id='missing-agent'")" == "t" ]]
|
||||||
|
[[ "$(psql_named legacy_routing_missing -Atc "SELECT agent_zone_id IS NULL FROM minerops_site_agents WHERE agent_id='missing-agent'")" == "t" ]]
|
||||||
|
|
||||||
|
echo "Testing ambiguous normalized Site match fails closed..."
|
||||||
|
create_database legacy_routing_ambiguous
|
||||||
|
run_db_alembic legacy_routing_ambiguous upgrade 0014_repair_parts_tracking
|
||||||
|
psql_named legacy_routing_ambiguous <<'SQL'
|
||||||
|
DELETE FROM sites;
|
||||||
|
INSERT INTO sites (id, name) VALUES
|
||||||
|
('00000000-0000-4000-8000-000000001821', 'Ambiguous Site'),
|
||||||
|
('00000000-0000-4000-8000-000000001822', 'ambiguous site');
|
||||||
|
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||||
|
VALUES ('ambiguous-agent', ' AMBIGUOUS SITE ');
|
||||||
|
SQL
|
||||||
|
run_db_alembic legacy_routing_ambiguous upgrade 0017_agent_enrollment
|
||||||
|
if output="$(run_db_alembic legacy_routing_ambiguous upgrade "${expected_revision}" 2>&1)"; then
|
||||||
|
echo "Ambiguous Site match unexpectedly passed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ "${output}" == *"ambiguous-agent"* && "${output}" == *"/'2'"* ]]
|
||||||
|
[[ "$(psql_named legacy_routing_ambiguous -Atc 'SELECT version_num FROM alembic_version')" == "0017_agent_enrollment" ]]
|
||||||
|
[[ "$(psql_named legacy_routing_ambiguous -Atc "SELECT site_id IS NULL AND agent_zone_id IS NULL FROM minerops_site_agents WHERE agent_id='ambiguous-agent'")" == "t" ]]
|
||||||
|
|
||||||
|
echo "Testing an exact normalized match wins in a multi-Site database..."
|
||||||
|
create_database legacy_routing_exact_multi
|
||||||
|
run_db_alembic legacy_routing_exact_multi upgrade 0014_repair_parts_tracking
|
||||||
|
psql_named legacy_routing_exact_multi <<'SQL'
|
||||||
|
DELETE FROM sites;
|
||||||
|
INSERT INTO sites (id, name) VALUES
|
||||||
|
('00000000-0000-4000-8000-000000001825', 'Exact Site'),
|
||||||
|
('00000000-0000-4000-8000-000000001826', 'Unrelated Site');
|
||||||
|
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||||
|
VALUES ('exact-multi-agent', ' eXaCt SiTe ');
|
||||||
|
SQL
|
||||||
|
run_db_alembic legacy_routing_exact_multi upgrade 0017_agent_enrollment
|
||||||
|
run_db_alembic legacy_routing_exact_multi upgrade "${expected_revision}"
|
||||||
|
[[ "$(psql_named legacy_routing_exact_multi -Atc "SELECT site_id FROM minerops_site_agents WHERE agent_id='exact-multi-agent'")" == "00000000-0000-4000-8000-000000001825" ]]
|
||||||
|
[[ "$(psql_named legacy_routing_exact_multi -Atc "SELECT agent.agent_zone_id = zone.id FROM minerops_site_agents AS agent JOIN agent_zones AS zone ON zone.site_id=agent.site_id AND zone.name='Default Zone' WHERE agent.agent_id='exact-multi-agent'")" == "t" ]]
|
||||||
|
|
||||||
|
echo "Testing two legacy Agents targeting one Default Zone fails closed..."
|
||||||
|
create_database legacy_routing_conflict
|
||||||
|
run_db_alembic legacy_routing_conflict upgrade 0014_repair_parts_tracking
|
||||||
|
psql_named legacy_routing_conflict <<'SQL'
|
||||||
|
DELETE FROM sites;
|
||||||
|
INSERT INTO sites (id, name)
|
||||||
|
VALUES ('00000000-0000-4000-8000-000000001831', 'Sole Durable Site');
|
||||||
|
INSERT INTO minerops_site_agents (agent_id, site_name) VALUES
|
||||||
|
('conflict-agent-a', 'Legacy Display A'),
|
||||||
|
('conflict-agent-b', 'Legacy Display B');
|
||||||
|
SQL
|
||||||
|
run_db_alembic legacy_routing_conflict upgrade 0017_agent_enrollment
|
||||||
|
if output="$(run_db_alembic legacy_routing_conflict upgrade "${expected_revision}" 2>&1)"; then
|
||||||
|
echo "One-Agent-per-Zone conflict unexpectedly passed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ "${output}" == *"one-Agent-per-Zone would be violated"* ]]
|
||||||
|
[[ "$(psql_named legacy_routing_conflict -Atc 'SELECT version_num FROM alembic_version')" == "0017_agent_enrollment" ]]
|
||||||
|
[[ "$(psql_named legacy_routing_conflict -Atc 'SELECT count(*) FROM minerops_site_agents WHERE site_id IS NOT NULL')" == "0" ]]
|
||||||
|
[[ "$(psql_named legacy_routing_conflict -Atc 'SELECT count(*) FROM minerops_site_agents WHERE agent_zone_id IS NOT NULL')" == "0" ]]
|
||||||
|
|
||||||
|
echo "Testing explicit Site and Zone assignments win..."
|
||||||
|
create_database legacy_routing_explicit
|
||||||
|
run_db_alembic legacy_routing_explicit upgrade 0017_agent_enrollment
|
||||||
|
psql_named legacy_routing_explicit <<'SQL'
|
||||||
|
INSERT INTO sites (id, name)
|
||||||
|
VALUES ('00000000-0000-4000-8000-000000001841', 'Explicit Site');
|
||||||
|
INSERT INTO agent_zones (
|
||||||
|
id, site_id, name
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-4000-8000-000000001842',
|
||||||
|
'00000000-0000-4000-8000-000000001841',
|
||||||
|
'Explicit Zone'
|
||||||
|
);
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id, site_id, agent_zone_id, site_name
|
||||||
|
) VALUES (
|
||||||
|
'explicit-agent',
|
||||||
|
'00000000-0000-4000-8000-000000001841',
|
||||||
|
'00000000-0000-4000-8000-000000001842',
|
||||||
|
'A Different Display Name'
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
run_db_alembic legacy_routing_explicit upgrade "${expected_revision}"
|
||||||
|
[[ "$(psql_named legacy_routing_explicit -Atc "SELECT site_id || '/' || agent_zone_id FROM minerops_site_agents WHERE agent_id='explicit-agent'")" == "00000000-0000-4000-8000-000000001841/00000000-0000-4000-8000-000000001842" ]]
|
||||||
|
|
||||||
|
echo "Testing conservative downgrade refusal..."
|
||||||
|
if output="$(run_db_alembic legacy_routing_happy downgrade 0017_agent_enrollment 2>&1)"; then
|
||||||
|
echo "0018 downgrade unexpectedly discarded routing attribution" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ "${output}" == *"Restore the pre-upgrade database backup"* ]]
|
||||||
|
[[ "$(psql_named legacy_routing_happy -Atc 'SELECT version_num FROM alembic_version')" == "${expected_revision}" ]]
|
||||||
|
|
||||||
|
echo "Legacy Agent routing migration lifecycle checks passed."
|
||||||
@@ -130,8 +130,9 @@ run_alembic upgrade 0015_miner_control_foundation
|
|||||||
echo "Testing empty database through head..."
|
echo "Testing empty database through head..."
|
||||||
"${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_control_empty'
|
"${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_control_empty'
|
||||||
"${compose[@]}" run --rm --no-deps -e POSTGRES_DB=minerops_control_empty backend alembic upgrade head
|
"${compose[@]}" run --rm --no-deps -e POSTGRES_DB=minerops_control_empty backend alembic upgrade head
|
||||||
|
expected_head="$(run_alembic heads 2>/dev/null | awk 'NR == 1 { print $1 }')"
|
||||||
empty_revision="$("${compose[@]}" exec -T postgres sh -ec \
|
empty_revision="$("${compose[@]}" exec -T postgres sh -ec \
|
||||||
'psql -U "$POSTGRES_USER" -d minerops_control_empty -Atc "SELECT version_num FROM alembic_version"')"
|
'psql -U "$POSTGRES_USER" -d minerops_control_empty -Atc "SELECT version_num FROM alembic_version"')"
|
||||||
[[ "${empty_revision}" == "0015_miner_control_foundation" ]]
|
[[ -n "${expected_head}" && "${empty_revision}" == "${expected_head}" ]]
|
||||||
|
|
||||||
echo "Miner Control Foundation migration tests passed."
|
echo "Miner Control Foundation migration tests passed."
|
||||||
@@ -16,7 +16,7 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||||
for source_file in backend/tests/test_miner_control.py backend/tests/test_agent_commands.py backend/tests/test_miner_control_postgres.py; do
|
for source_file in backend/tests/test_agent_auth.py backend/tests/test_miner_control.py backend/tests/test_agent_commands.py backend/tests/test_miner_control_postgres.py; do
|
||||||
permissions="$(stat -c '%a' "${source_file}")"
|
permissions="$(stat -c '%a' "${source_file}")"
|
||||||
(( (10#${permissions} % 10) & 4 )) || {
|
(( (10#${permissions} % 10) & 4 )) || {
|
||||||
echo "${source_file} is not readable by the non-root test container" >&2
|
echo "${source_file} is not readable by the non-root test container" >&2
|
||||||
@@ -44,6 +44,7 @@ done
|
|||||||
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
||||||
-v "${repo_root}/agents:/agents:ro" \
|
-v "${repo_root}/agents:/agents:ro" \
|
||||||
backend python -m unittest -v \
|
backend python -m unittest -v \
|
||||||
|
tests.test_agent_auth \
|
||||||
tests.test_miner_control \
|
tests.test_miner_control \
|
||||||
tests.test_agent_commands \
|
tests.test_agent_commands \
|
||||||
tests.test_miner_control_postgres
|
tests.test_miner_control_postgres
|
||||||
|
|||||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user