feat: add secure agent enrollment

This commit is contained in:
lussierj committed 2026-08-26 21:31:09 +00:00
1 parent 82309db79a
commit d9f0da694b
26 files changed
+2942 -76

No files matched your search

+2
View File
@@ -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" `
@@ -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
} }
+1
View File
@@ -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
} }
@@ -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")
+1
View File
@@ -0,0 +1 @@
"""Per-Agent authentication support."""
+21
View File
@@ -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."""
+47
View File
@@ -0,0 +1,47 @@
"""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 agent_id,
agent_secret_hash,
credential_created_at,
credential_revoked_at
FROM minerops_site_agents
WHERE agent_id = %s
""",
(agent_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
connection.close()
+66
View File
@@ -0,0 +1,66 @@
"""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 identity:
raise AgentAuthenticationFailed("Missing Agent identity")
if not secret:
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
credential = self.repository.get_credential(identity)
# 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"):
if credential.get("credential_revoked_at") is not None:
raise AgentCredentialRevoked("Agent credential has been revoked")
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
# Temporary compatibility for the single pre-enrollment Agent.
legacy_id = settings.minerops_agent_id.strip()
legacy_secret = settings.minerops_agent_shared_secret.strip()
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
+27 -18
View File
@@ -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
+1
View File
@@ -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."""
+94
View File
@@ -0,0 +1,94 @@
"""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
enrolled_agent_id: str | None = None
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
+506
View File
@@ -0,0 +1,506 @@
"""PostgreSQL persistence for secure Agent enrollment."""
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"
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 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.enrolled_agent_id,
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
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
):
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"
)
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id,
site_id,
agent_zone_id,
site_name,
display_name,
agent_secret_hash,
credential_created_at
)
VALUES (
%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,
),
)
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()
+108
View File
@@ -0,0 +1,108 @@
"""HTTP routes for Agent enrollment and bootstrap."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from app.agent_enrollment.dependencies import get_agent_enrollment_service
from app.agent_enrollment.exceptions import (
AgentEnrollmentConflict,
AgentEnrollmentNotFound,
AgentEnrollmentTokenInvalid,
)
from app.agent_enrollment.models import (
AgentEnrollmentCreateRequest,
AgentEnrollmentCreateResponse,
AgentEnrollmentListResponse,
AgentEnrollRequest,
AgentEnrollResponse,
)
from app.agent_enrollment.service import AgentEnrollmentService
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.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/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
+98
View File
@@ -0,0 +1,98 @@
"""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 (
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 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"
)
+4
View File
@@ -699,8 +699,10 @@ 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/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",
@@ -779,6 +781,8 @@ def required_api_permission(method: str, path: str) -> str | None:
return "components.create" if method == "POST" else "components.read" return "components.create" if method == "POST" else "components.read"
if path == "/api/agent-zones": if path == "/api/agent-zones":
return "agents.view" if method == "GET" else "agents.manage" return "agents.view" if method == "GET" else "agents.manage"
if path == "/api/agent-enrollments":
return "agents.view" if method == "GET" else "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/"):
+154 -55
View File
@@ -17,6 +17,7 @@ from app.agent_zones.dependencies import get_agent_zone_service
from app.agent_zones.models import MinerZoneAssociationResult from app.agent_zones.models import MinerZoneAssociationResult
from app.agent_zones.service import AgentZoneService from app.agent_zones.service import AgentZoneService
from app.agent_zones.router import router as agent_zones_router from app.agent_zones.router import router as agent_zones_router
from app.agent_enrollment.router import router as agent_enrollment_router
from app.miner_control.router import router as miner_control_router from app.miner_control.router import router as miner_control_router
from app.dashboard.router import router as dashboard_router from app.dashboard.router import router as dashboard_router
from app.identity import ( from app.identity import (
@@ -73,6 +74,7 @@ app.include_router(inventory_router)
app.include_router(miner_control_router) app.include_router(miner_control_router)
app.include_router(agent_commands_router) app.include_router(agent_commands_router)
app.include_router(agent_zones_router) app.include_router(agent_zones_router)
app.include_router(agent_enrollment_router)
app.include_router(components_router) app.include_router(components_router)
app.include_router(locations_router) app.include_router(locations_router)
app.include_router(dashboard_router) app.include_router(dashboard_router)
@@ -4167,24 +4169,27 @@ def minerops_ensure_pool_info_table():
@app.post("/api/agent/pool-snapshots") @app.post("/api/agent/pool-snapshots")
async def agent_pool_snapshots(request: Request): async def agent_pool_snapshots(
request: Request,
authenticated_agent_id: str = Depends(require_agent_identity),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
import json as _json import json as _json
import os as _os
import uuid as _uuid import uuid as _uuid
expected_secret = (_os.getenv("MINEROPS_AGENT_SHARED_SECRET") or "").strip() routing = zone_service.routing_context(authenticated_agent_id)
provided_secret = ( if routing.routing_reason or routing.agent_zone_id is None:
request.headers.get("X-MinerOps-Agent-Secret") raise HTTPException(
or request.headers.get("x-minerops-agent-secret") status_code=409,
or "" detail=(
).strip() "Agent routing unavailable: "
+ (routing.routing_reason or "agent_zone_unassigned")
if expected_secret and provided_secret != expected_secret: ),
raise HTTPException(status_code=401, detail="Invalid agent secret.") )
body = await request.json() body = await request.json()
agent_id = body.get("agent_id") or "" agent_id = authenticated_agent_id
snapshots = body.get("pool_snapshots") or body.get("snapshots") or [] snapshots = body.get("pool_snapshots") or body.get("snapshots") or []
minerops_ensure_pool_info_table() minerops_ensure_pool_info_table()
@@ -4236,28 +4241,34 @@ async def agent_pool_snapshots(request: Request):
""" """
SELECT id SELECT id
FROM miners FROM miners
WHERE upper(current_mac) = upper(%s) WHERE agent_zone_id = %s
OR host(current_ip) = %s AND (
upper(current_mac) = upper(%s)
OR host(current_ip) = %s
)
LIMIT 1; LIMIT 1;
""", """,
(mac, ip), (routing.agent_zone_id, mac, ip),
) )
else: else:
cur.execute( cur.execute(
""" """
SELECT id SELECT id
FROM miners FROM miners
WHERE host(current_ip) = %s WHERE agent_zone_id = %s
AND host(current_ip) = %s
LIMIT 1; LIMIT 1;
""", """,
(ip,), (routing.agent_zone_id, ip),
) )
row = cur.fetchone() row = cur.fetchone()
if row: if not row:
miner_id = row["id"] continue
matched += 1
miner_id = row["id"]
matched += 1
for pool in pools: for pool in pools:
if not isinstance(pool, dict): if not isinstance(pool, dict):
@@ -25402,8 +25413,19 @@ document.addEventListener("DOMContentLoaded", loadSetup);
# ----------------------------- # -----------------------------
@app.get("/api/agent/known-miners") @app.get("/api/agent/known-miners")
async def minerops_agent_known_miners(request: Request): async def minerops_agent_known_miners(
minerops_require_agent_secret(request) authenticated_agent_id: str = Depends(require_agent_identity),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
routing = zone_service.routing_context(authenticated_agent_id)
if routing.routing_reason or routing.agent_zone_id is None:
raise HTTPException(
status_code=409,
detail=(
"Agent routing unavailable: "
+ (routing.routing_reason or "agent_zone_unassigned")
),
)
conn = await minerops_command_db_connect() conn = await minerops_command_db_connect()
@@ -25420,8 +25442,10 @@ async def minerops_agent_known_miners(request: Request):
FROM miners FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id LEFT JOIN rooms ON rooms.id = miners.room_id
WHERE miners.current_ip IS NOT NULL WHERE miners.current_ip IS NOT NULL
AND miners.agent_zone_id = $1::uuid
ORDER BY rooms.sort_order NULLS LAST, rooms.name NULLS LAST, miners.current_ip ORDER BY rooms.sort_order NULLS LAST, rooms.name NULLS LAST, miners.current_ip
""" """,
str(routing.agent_zone_id),
) )
finally: finally:
await conn.close() await conn.close()
@@ -26964,10 +26988,22 @@ async def minerops_agent_discovery_review(
@app.post("/api/agent/miner-enrichment") @app.post("/api/agent/miner-enrichment")
async def minerops_agent_miner_enrichment(request: Request): async def minerops_agent_miner_enrichment(
request: Request,
authenticated_agent_id: str = Depends(require_agent_identity),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
import json import json
await minerops_require_agent_secret(request) routing = zone_service.routing_context(authenticated_agent_id)
if routing.routing_reason or routing.agent_zone_id is None:
raise HTTPException(
status_code=409,
detail=(
"Agent routing unavailable: "
+ (routing.routing_reason or "agent_zone_unassigned")
),
)
payload = await request.json() payload = await request.json()
@@ -27013,16 +27049,18 @@ async def minerops_agent_miner_enrichment(request: Request):
SELECT id::text AS id SELECT id::text AS id
FROM miners FROM miners
WHERE id = $1::uuid WHERE id = $1::uuid
AND agent_zone_id = $2::uuid
LIMIT 1 LIMIT 1
""", miner_id) """, miner_id, str(routing.agent_zone_id))
if miner is None and ip_address: if miner is None and ip_address:
miner = await conn.fetchrow(""" miner = await conn.fetchrow("""
SELECT id::text AS id SELECT id::text AS id
FROM miners FROM miners
WHERE current_ip = $1::inet WHERE current_ip = $1::inet
AND agent_zone_id = $2::uuid
LIMIT 1 LIMIT 1
""", ip_address) """, ip_address, str(routing.agent_zone_id))
if miner is None: if miner is None:
raise HTTPException(status_code=404, detail="Miner not found.") raise HTTPException(status_code=404, detail="Miner not found.")
@@ -27110,10 +27148,22 @@ async def minerops_agent_miner_enrichment(request: Request):
@app.post("/api/agent/miner-enrichment-v2") @app.post("/api/agent/miner-enrichment-v2")
async def minerops_agent_miner_enrichment_v2(request: Request): async def minerops_agent_miner_enrichment_v2(
request: Request,
authenticated_agent_id: str = Depends(require_agent_identity),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
import json import json
await minerops_require_agent_secret(request) routing = zone_service.routing_context(authenticated_agent_id)
if routing.routing_reason or routing.agent_zone_id is None:
raise HTTPException(
status_code=409,
detail=(
"Agent routing unavailable: "
+ (routing.routing_reason or "agent_zone_unassigned")
),
)
payload = await request.json() payload = await request.json()
@@ -27175,16 +27225,18 @@ async def minerops_agent_miner_enrichment_v2(request: Request):
SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes
FROM miners FROM miners
WHERE id = $1::uuid WHERE id = $1::uuid
AND agent_zone_id = $2::uuid
LIMIT 1 LIMIT 1
""", miner_id) """, miner_id, str(routing.agent_zone_id))
if source is None and ip_address: if source is None and ip_address:
source = await conn.fetchrow(""" source = await conn.fetchrow("""
SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes
FROM miners FROM miners
WHERE current_ip = $1::inet WHERE current_ip = $1::inet
AND agent_zone_id = $2::uuid
LIMIT 1 LIMIT 1
""", ip_address) """, ip_address, str(routing.agent_zone_id))
if source is None: if source is None:
raise HTTPException(status_code=404, detail="Source miner not found.") raise HTTPException(status_code=404, detail="Source miner not found.")
@@ -27199,8 +27251,9 @@ async def minerops_agent_miner_enrichment_v2(request: Request):
SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes
FROM miners FROM miners
WHERE upper(current_mac) = upper($1) WHERE upper(current_mac) = upper($1)
AND agent_zone_id = $2::uuid
LIMIT 1 LIMIT 1
""", mac) """, mac, str(routing.agent_zone_id))
if existing_mac_miner and existing_mac_miner["id"] != source_id: if existing_mac_miner and existing_mac_miner["id"] != source_id:
target_id = existing_mac_miner["id"] target_id = existing_mac_miner["id"]
@@ -27285,7 +27338,8 @@ async def minerops_agent_miner_enrichment_v2(request: Request):
matched_miner_id = $2::uuid, matched_miner_id = $2::uuid,
updated_at = now() updated_at = now()
WHERE ip_address = $1::inet WHERE ip_address = $1::inet
""", ip_address, target_id) AND agent_zone_id = $3::uuid
""", ip_address, target_id, str(routing.agent_zone_id))
except Exception: except Exception:
pass pass
@@ -27324,10 +27378,22 @@ async def minerops_agent_miner_enrichment_v2(request: Request):
@app.post("/api/agent/miner-enrichment-v3") @app.post("/api/agent/miner-enrichment-v3")
async def minerops_agent_miner_enrichment_v3(request: Request): async def minerops_agent_miner_enrichment_v3(
request: Request,
authenticated_agent_id: str = Depends(require_agent_identity),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
import json import json
await minerops_require_agent_secret(request) routing = zone_service.routing_context(authenticated_agent_id)
if routing.routing_reason or routing.agent_zone_id is None:
raise HTTPException(
status_code=409,
detail=(
"Agent routing unavailable: "
+ (routing.routing_reason or "agent_zone_unassigned")
),
)
payload = await request.json() payload = await request.json()
@@ -27384,8 +27450,9 @@ async def minerops_agent_miner_enrichment_v3(request: Request):
SELECT id::text AS id SELECT id::text AS id
FROM miners FROM miners
WHERE upper(current_mac) = upper($1) WHERE upper(current_mac) = upper($1)
AND agent_zone_id = $2::uuid
LIMIT 1 LIMIT 1
""", mac) """, mac, str(routing.agent_zone_id))
if row: if row:
target_id = row["id"] target_id = row["id"]
@@ -27396,8 +27463,9 @@ async def minerops_agent_miner_enrichment_v3(request: Request):
SELECT id::text AS id SELECT id::text AS id
FROM miners FROM miners
WHERE id = $1::uuid WHERE id = $1::uuid
AND agent_zone_id = $2::uuid
LIMIT 1 LIMIT 1
""", miner_id) """, miner_id, str(routing.agent_zone_id))
if row: if row:
target_id = row["id"] target_id = row["id"]
@@ -27408,8 +27476,9 @@ async def minerops_agent_miner_enrichment_v3(request: Request):
SELECT id::text AS id SELECT id::text AS id
FROM miners FROM miners
WHERE current_ip = $1::inet WHERE current_ip = $1::inet
AND agent_zone_id = $2::uuid
LIMIT 1 LIMIT 1
""", ip_address) """, ip_address, str(routing.agent_zone_id))
if row: if row:
target_id = row["id"] target_id = row["id"]
@@ -27473,6 +27542,7 @@ async def minerops_agent_miner_enrichment_v3(request: Request):
raw_payload = COALESCE(raw_payload, '{}'::jsonb) || $7::jsonb, raw_payload = COALESCE(raw_payload, '{}'::jsonb) || $7::jsonb,
updated_at = now() updated_at = now()
WHERE ip_address = $1::inet WHERE ip_address = $1::inet
AND agent_zone_id = $8::uuid
""", """,
ip_address, ip_address,
mac, mac,
@@ -27480,7 +27550,8 @@ async def minerops_agent_miner_enrichment_v3(request: Request):
hostname, hostname,
miner_sn, miner_sn,
target_id, target_id,
json.dumps({"enrichment": payload, "matched_by": matched_by}) json.dumps({"enrichment": payload, "matched_by": matched_by}),
str(routing.agent_zone_id)
) )
updated = await conn.fetchrow(""" updated = await conn.fetchrow("""
@@ -27521,13 +27592,15 @@ async def minerops_agent_miner_enrichment_v3(request: Request):
raw_payload = COALESCE(raw_payload, '{}'::jsonb) || $6::jsonb, raw_payload = COALESCE(raw_payload, '{}'::jsonb) || $6::jsonb,
updated_at = now() updated_at = now()
WHERE ip_address = $1::inet WHERE ip_address = $1::inet
AND agent_zone_id = $7::uuid
""", """,
ip_address, ip_address,
mac, mac,
model, model,
hostname, hostname,
miner_sn, miner_sn,
json.dumps({"enrichment": payload, "reason": "no_inventory_match"}) json.dumps({"enrichment": payload, "reason": "no_inventory_match"}),
str(routing.agent_zone_id)
) )
return { return {
@@ -29520,8 +29593,11 @@ async def minerops_inject_clean_detail_v3(request, call_next):
from fastapi import Request as MinerOpsApi3Request, HTTPException as MinerOpsApi3HTTPException from fastapi import Request as MinerOpsApi3Request, HTTPException as MinerOpsApi3HTTPException
@app.post("/api/agent/api3-enrichment") @app.post("/api/agent/api3-enrichment")
async def minerops_agent_api3_enrichment(request: MinerOpsApi3Request): async def minerops_agent_api3_enrichment(
import os request: MinerOpsApi3Request,
authenticated_agent_id: str = Depends(require_agent_identity),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
import json import json
import uuid import uuid
import psycopg import psycopg
@@ -29559,19 +29635,23 @@ async def minerops_agent_api3_enrichment(request: MinerOpsApi3Request):
return value return value
return [value] return [value]
expected_secret = os.getenv("MINEROPS_AGENT_SHARED_SECRET", "") routing = zone_service.routing_context(authenticated_agent_id)
supplied_secret = ( if (
request.headers.get("x-minerops-agent-secret") routing.routing_reason
or request.headers.get("x-agent-secret") or routing.agent_zone_id is None
or "" or routing.site_id is None
) ):
raise MinerOpsApi3HTTPException(
if expected_secret and supplied_secret != expected_secret: status_code=409,
raise MinerOpsApi3HTTPException(status_code=401, detail="Invalid agent secret.") detail=(
"Agent routing unavailable: "
+ (routing.routing_reason or "agent_zone_unassigned")
),
)
body = await request.json() body = await request.json()
agent_id = as_text(body.get("agent_id")) or "unknown-agent" agent_id = authenticated_agent_id
site_name = as_text(body.get("site_name")) or "unknown-site" site_name = None
items = body.get("items") or [] items = body.get("items") or []
if not isinstance(items, list): if not isinstance(items, list):
@@ -29592,6 +29672,20 @@ async def minerops_agent_api3_enrichment(request: MinerOpsApi3Request):
conn = get_db_connection() conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"SELECT name FROM sites WHERE id = %s",
(routing.site_id,),
)
site_row = cur.fetchone()
if not site_row:
raise MinerOpsApi3HTTPException(
status_code=409,
detail="Agent Site assignment no longer exists.",
)
site_name = str(site_row["name"])
cur.execute( cur.execute(
""" """
ALTER TABLE miners ALTER TABLE miners
@@ -29772,10 +29866,11 @@ async def minerops_agent_api3_enrichment(request: MinerOpsApi3Request):
SELECT id SELECT id
FROM miners FROM miners
WHERE upper(current_mac) = upper(%s) WHERE upper(current_mac) = upper(%s)
AND agent_zone_id = %s
ORDER BY updated_at DESC NULLS LAST ORDER BY updated_at DESC NULLS LAST
LIMIT 1; LIMIT 1;
""", """,
(mac,), (mac, routing.agent_zone_id),
) )
row = cur.fetchone() row = cur.fetchone()
if row: if row:
@@ -29787,15 +29882,19 @@ async def minerops_agent_api3_enrichment(request: MinerOpsApi3Request):
SELECT id SELECT id
FROM miners FROM miners
WHERE current_ip = %s::inet WHERE current_ip = %s::inet
AND agent_zone_id = %s
ORDER BY updated_at DESC NULLS LAST ORDER BY updated_at DESC NULLS LAST
LIMIT 1; LIMIT 1;
""", """,
(ip,), (ip, routing.agent_zone_id),
) )
row = cur.fetchone() row = cur.fetchone()
if row: if row:
miner_id = row["id"] miner_id = row["id"]
if miner_id is None:
continue
if miner_id is not None: if miner_id is not None:
matched += 1 matched += 1
+136
View File
@@ -0,0 +1,136 @@
"""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_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_unknown_identity_does_not_use_shared_secret(self):
with self.assertRaises(AgentIdentityUnauthorized):
self.service().authenticate("unknown-agent", "legacy-secret")
def test_missing_identity_or_secret_is_rejected(self):
with self.assertRaises(AgentAuthenticationFailed):
self.service().authenticate(None, "legacy-secret")
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")
if __name__ == "__main__":
unittest.main()
+132 -1
View File
@@ -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
@@ -235,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)
+157
View File
@@ -0,0 +1,157 @@
"""Focused tests for secure Agent enrollment."""
import re
import unittest
from datetime import datetime, timedelta, timezone
from uuid import UUID
from app.agent_auth.service import hash_agent_secret
from app.agent_enrollment.models import (
AgentEnrollmentCreateRequest,
AgentEnrollRequest,
)
from app.agent_enrollment.service import AgentEnrollmentService
from app.identity import 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
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 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"],
}
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_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",
)
def test_bootstrap_endpoint_is_machine_exempt_but_admin_endpoint_is_not(self):
self.assertTrue(machine_agent_request("/api/agent/enroll"))
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"])
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,625 @@
"""PostgreSQL integration tests for secure Agent enrollment."""
import unittest
from concurrent.futures import ThreadPoolExecutor
from uuid import uuid4
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,
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
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,
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 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.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",
)
# 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
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"]
)
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()
+1 -1
View File
@@ -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 = "0016_agent_zones" EXPECTED_HEAD = "0017_agent_enrollment"
def database_payload(revision=EXPECTED_HEAD): def database_payload(revision=EXPECTED_HEAD):
+327
View File
@@ -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."
+69
View File
@@ -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."
+2 -1
View File
@@ -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