feat: add secure agent enrollment
This commit is contained in:
1 parent
82309db79a
commit
d9f0da694b
26 files changed
+2942
-76
No files matched your search
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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,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()
|
||||||
@@ -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
|
||||||
@@ -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,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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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):
|
||||||
|
|||||||
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."
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user