diff --git a/agents/windows/EnrichOneMiner.ps1 b/agents/windows/EnrichOneMiner.ps1 index 749eb0e..90af2a2 100644 --- a/agents/windows/EnrichOneMiner.ps1 +++ b/agents/windows/EnrichOneMiner.ps1 @@ -144,6 +144,7 @@ function Find-MacAddress { $config = Read-AgentConfig $hubUrl = ([string]$config.hub_url).TrimEnd("/") $secret = [string]$config.agent_secret +$agentId = [string]$config.agent_id $commands = @( "summary", @@ -237,6 +238,7 @@ $response = Invoke-RestMethod ` -Method Post ` -Uri "$hubUrl/api/agent/miner-enrichment-v3" ` -Headers @{ + "X-MinerOps-Agent-ID" = $agentId "X-MinerOps-Agent-Secret" = $secret } ` -ContentType "application/json" ` diff --git a/agents/windows/MinerOpsAgentService.ps1 b/agents/windows/MinerOpsAgentService.ps1 index 8d9e97f..f7d9cf6 100644 --- a/agents/windows/MinerOpsAgentService.ps1 +++ b/agents/windows/MinerOpsAgentService.ps1 @@ -78,6 +78,14 @@ function Send-AgentCheckin { local_time = (Get-Date).ToString("o") } + if (-not [string]::IsNullOrWhiteSpace([string]$config.site_id)) { + $body["site_id"] = [string]$config.site_id + } + + if (-not [string]::IsNullOrWhiteSpace([string]$config.agent_zone_id)) { + $body["agent_zone_id"] = [string]$config.agent_zone_id + } + return Invoke-AgentApi -Method "POST" -Path "/api/agent/checkin" -Body $body } diff --git a/agents/windows/MinerOpsApi3EnrichmentPoller.ps1 b/agents/windows/MinerOpsApi3EnrichmentPoller.ps1 index 1ca18e6..c2e3f78 100644 --- a/agents/windows/MinerOpsApi3EnrichmentPoller.ps1 +++ b/agents/windows/MinerOpsApi3EnrichmentPoller.ps1 @@ -19,6 +19,7 @@ $SiteName = [string]$Config.site_name $Secret = [string]$Config.agent_secret $Headers = @{ + "X-MinerOps-Agent-ID" = $AgentId "X-MinerOps-Agent-Secret" = $Secret } diff --git a/agents/windows/MinerOpsPoolPoller.ps1 b/agents/windows/MinerOpsPoolPoller.ps1 index 503d420..3773e8f 100644 --- a/agents/windows/MinerOpsPoolPoller.ps1 +++ b/agents/windows/MinerOpsPoolPoller.ps1 @@ -8,6 +8,7 @@ $AgentId = $Config.agent_id $AgentSecret = $Config.agent_secret $Headers = @{ + "X-MinerOps-Agent-ID" = $AgentId "X-MinerOps-Agent-Secret" = $AgentSecret } diff --git a/agents/windows/README.md b/agents/windows/README.md index 5bbc9d5..9a4a3c0 100644 --- a/agents/windows/README.md +++ b/agents/windows/README.md @@ -15,8 +15,11 @@ probe output are intentionally excluded. ## Local Files Create `agent-config.json` from `config.example.json` on the Agent host. Never -commit that file. `site_id` is the durable MinerOps site UUID; `site_name` is -retained only for transitional display compatibility. +commit that file. `site_id` is the durable MinerOps site UUID. `agent_zone_id` is the optional +durable network-reachability Zone UUID; older configurations may omit it only +when the Hub can preserve an established assignment or resolve exactly one +enabled Zone at the Site. `site_name` is retained only for transitional display +compatibility. The Agent writes runtime state to `agent-state.json` and logs under `logs/`. Installation and upgrades preserve all three paths. diff --git a/agents/windows/config.example.json b/agents/windows/config.example.json index ead5687..b4f27b6 100644 --- a/agents/windows/config.example.json +++ b/agents/windows/config.example.json @@ -2,6 +2,7 @@ "hub_url": "https://minerops.example.invalid", "agent_id": "replace-with-agent-id", "site_id": "00000000-0000-0000-0000-000000000000", + "agent_zone_id": "00000000-0000-0000-0000-000000000000", "site_name": "Example Site", "agent_secret": "replace-with-agent-secret", "poll_interval_seconds": 120, diff --git a/backend/alembic/versions/0016_agent_zones.py b/backend/alembic/versions/0016_agent_zones.py new file mode 100644 index 0000000..1e544c6 --- /dev/null +++ b/backend/alembic/versions/0016_agent_zones.py @@ -0,0 +1,365 @@ +"""add agent zones + +Revision ID: 0016_agent_zones +Revises: 0015_miner_control_foundation +Create Date: 2026-08-25 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision: str = "0016_agent_zones" +down_revision: Union[str, None] = "0015_miner_control_foundation" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +DEFAULT_ZONE_NAME = "Default Zone" + + +def _format_rows(rows: list[tuple]) -> str: + return ", ".join("/".join(str(value) for value in row) for row in rows) + + +def upgrade() -> None: + op.create_table( + "agent_zones", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "site_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("sites.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column( + "is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true") + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.CheckConstraint( + "name = trim(name) AND trim(name) <> '' AND char_length(name) <= 120", + name="ck_agent_zones_name", + ), + sa.UniqueConstraint("id", "site_id", name="uq_agent_zones_id_site_id"), + ) + op.create_index("idx_agent_zones_site_id", "agent_zones", ["site_id"]) + op.create_index( + "uq_agent_zones_active_site_name", + "agent_zones", + ["site_id", sa.text("lower(name)")], + unique=True, + postgresql_where=sa.text("is_enabled"), + ) + + op.create_table( + "agent_zone_network_ranges", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "agent_zone_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("agent_zones.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("network", postgresql.CIDR(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.UniqueConstraint( + "agent_zone_id", "network", name="uq_agent_zone_network_ranges_zone_network" + ), + ) + op.create_index( + "idx_agent_zone_network_ranges_zone_id", + "agent_zone_network_ranges", + ["agent_zone_id"], + ) + + op.add_column( + "miners", + sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.create_check_constraint( + "ck_miners_agent_zone_requires_site", + "miners", + "agent_zone_id IS NULL OR site_id IS NOT NULL", + ) + op.create_foreign_key( + "fk_miners_agent_zone_site", + "miners", + "agent_zones", + ["agent_zone_id", "site_id"], + ["id", "site_id"], + ondelete="RESTRICT", + ) + op.create_index("idx_miners_agent_zone_id", "miners", ["agent_zone_id"]) + + op.add_column( + "minerops_site_agents", + sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.create_check_constraint( + "ck_minerops_site_agents_zone_requires_site", + "minerops_site_agents", + "agent_zone_id IS NULL OR site_id IS NOT NULL", + ) + op.create_foreign_key( + "fk_minerops_site_agents_zone_site", + "minerops_site_agents", + "agent_zones", + ["agent_zone_id", "site_id"], + ["id", "site_id"], + ondelete="RESTRICT", + ) + + op.add_column( + "minerops_agent_snapshots", + sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.create_foreign_key( + "fk_minerops_agent_snapshots_agent_zone_id", + "minerops_agent_snapshots", + "agent_zones", + ["agent_zone_id"], + ["id"], + ondelete="RESTRICT", + ) + op.create_index( + "idx_minerops_agent_snapshots_agent_zone_id", + "minerops_agent_snapshots", + ["agent_zone_id"], + ) + + op.add_column( + "minerops_agent_discoveries", + sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.create_foreign_key( + "fk_minerops_agent_discoveries_agent_zone_id", + "minerops_agent_discoveries", + "agent_zones", + ["agent_zone_id"], + ["id"], + ondelete="RESTRICT", + ) + op.create_index( + "idx_minerops_agent_discoveries_agent_zone_id", + "minerops_agent_discoveries", + ["agent_zone_id"], + ) + + connection = op.get_bind() + connection.execute( + sa.text( + """ + INSERT INTO agent_zones (site_id, name) + SELECT sites.id, :default_name + FROM sites + ORDER BY sites.id + """ + ), + {"default_name": DEFAULT_ZONE_NAME}, + ) + connection.execute( + sa.text( + """ + UPDATE miners AS miner + SET agent_zone_id = zone.id + FROM agent_zones AS zone + WHERE miner.site_id = zone.site_id + AND zone.name = :default_name + AND miner.agent_zone_id IS NULL + """ + ), + {"default_name": DEFAULT_ZONE_NAME}, + ) + connection.execute( + sa.text( + """ + UPDATE minerops_site_agents AS agent + SET agent_zone_id = zone.id + FROM agent_zones AS zone + WHERE agent.site_id = zone.site_id + AND zone.name = :default_name + AND agent.agent_zone_id IS NULL + """ + ), + {"default_name": DEFAULT_ZONE_NAME}, + ) + + connection.execute( + sa.text( + """ + UPDATE minerops_agent_snapshots AS snapshot + SET agent_zone_id = agent.agent_zone_id + FROM minerops_site_agents AS agent + WHERE snapshot.agent_id = agent.agent_id + AND agent.agent_zone_id IS NOT NULL + AND snapshot.agent_zone_id IS NULL + """ + ) + ) + connection.execute( + sa.text( + """ + UPDATE minerops_agent_discoveries AS discovery + SET agent_zone_id = agent.agent_zone_id + FROM minerops_site_agents AS agent + WHERE discovery.agent_id = agent.agent_id + AND agent.agent_zone_id IS NOT NULL + AND discovery.agent_zone_id IS NULL + """ + ) + ) + + op.create_index( + "uq_minerops_site_agents_agent_zone_id", + "minerops_site_agents", + ["agent_zone_id"], + unique=True, + postgresql_where=sa.text("agent_zone_id IS NOT NULL"), + ) + op.drop_index( + "uq_minerops_site_agents_site_id", table_name="minerops_site_agents" + ) + + +def downgrade() -> None: + connection = op.get_bind() + + duplicate_site_agents = connection.execute( + sa.text( + """ + SELECT site_id::text, count(*) + FROM minerops_site_agents + WHERE site_id IS NOT NULL + GROUP BY site_id + HAVING count(*) > 1 + ORDER BY site_id + """ + ) + ).all() + if duplicate_site_agents: + raise RuntimeError( + "Cannot downgrade 0016 to 0015: multiple Agent installations are " + "assigned within Site(s): " + f"{_format_rows(list(duplicate_site_agents))}. Move or unassign Agents " + "until each Site has at most one Agent before retrying; no Agent Zone " + "data was changed." + ) + + non_reducible_zones = connection.execute( + sa.text( + """ + SELECT site_id::text, count(*) + FROM agent_zones + GROUP BY site_id + HAVING count(*) > 1 + OR bool_or(name <> :default_name OR description IS NOT NULL OR NOT is_enabled) + ORDER BY site_id + """ + ), + {"default_name": DEFAULT_ZONE_NAME}, + ).all() + range_count = connection.execute( + sa.text("SELECT count(*) FROM agent_zone_network_ranges") + ).scalar_one() + if non_reducible_zones or range_count: + detail = _format_rows(list(non_reducible_zones)) or "network ranges exist" + raise RuntimeError( + "Cannot downgrade 0016 to 0015: Agent Zone configuration cannot be " + f"represented by the one-Agent-per-Site model ({detail}). Remove " + "additional/custom Zones and Zone network ranges before retrying; no " + "Agent Zone data was changed." + ) + + op.create_index( + "uq_minerops_site_agents_site_id", + "minerops_site_agents", + ["site_id"], + unique=True, + postgresql_where=sa.text("site_id IS NOT NULL"), + ) + op.drop_index( + "uq_minerops_site_agents_agent_zone_id", + table_name="minerops_site_agents", + ) + + op.drop_index( + "idx_minerops_agent_discoveries_agent_zone_id", + table_name="minerops_agent_discoveries", + ) + op.drop_constraint( + "fk_minerops_agent_discoveries_agent_zone_id", + "minerops_agent_discoveries", + type_="foreignkey", + ) + op.drop_column("minerops_agent_discoveries", "agent_zone_id") + + op.drop_index( + "idx_minerops_agent_snapshots_agent_zone_id", + table_name="minerops_agent_snapshots", + ) + op.drop_constraint( + "fk_minerops_agent_snapshots_agent_zone_id", + "minerops_agent_snapshots", + type_="foreignkey", + ) + op.drop_column("minerops_agent_snapshots", "agent_zone_id") + + op.drop_constraint( + "fk_minerops_site_agents_zone_site", + "minerops_site_agents", + type_="foreignkey", + ) + op.drop_constraint( + "ck_minerops_site_agents_zone_requires_site", + "minerops_site_agents", + type_="check", + ) + op.drop_column("minerops_site_agents", "agent_zone_id") + + op.drop_index("idx_miners_agent_zone_id", table_name="miners") + op.drop_constraint( + "fk_miners_agent_zone_site", "miners", type_="foreignkey" + ) + op.drop_constraint( + "ck_miners_agent_zone_requires_site", "miners", type_="check" + ) + op.drop_column("miners", "agent_zone_id") + + op.drop_index( + "idx_agent_zone_network_ranges_zone_id", + table_name="agent_zone_network_ranges", + ) + op.drop_table("agent_zone_network_ranges") + op.drop_index("uq_agent_zones_active_site_name", table_name="agent_zones") + op.drop_index("idx_agent_zones_site_id", table_name="agent_zones") + op.drop_table("agent_zones") diff --git a/backend/alembic/versions/0017_agent_enrollment.py b/backend/alembic/versions/0017_agent_enrollment.py new file mode 100644 index 0000000..08324c9 --- /dev/null +++ b/backend/alembic/versions/0017_agent_enrollment.py @@ -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") diff --git a/backend/app/agent_auth/__init__.py b/backend/app/agent_auth/__init__.py new file mode 100644 index 0000000..e1658cd --- /dev/null +++ b/backend/app/agent_auth/__init__.py @@ -0,0 +1 @@ +"""Per-Agent authentication support.""" diff --git a/backend/app/agent_auth/exceptions.py b/backend/app/agent_auth/exceptions.py new file mode 100644 index 0000000..31191d6 --- /dev/null +++ b/backend/app/agent_auth/exceptions.py @@ -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.""" diff --git a/backend/app/agent_auth/repository.py b/backend/app/agent_auth/repository.py new file mode 100644 index 0000000..e9e19df --- /dev/null +++ b/backend/app/agent_auth/repository.py @@ -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() diff --git a/backend/app/agent_auth/service.py b/backend/app/agent_auth/service.py new file mode 100644 index 0000000..2103fde --- /dev/null +++ b/backend/app/agent_auth/service.py @@ -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 diff --git a/backend/app/agent_commands/dependencies.py b/backend/app/agent_commands/dependencies.py index 94c1bd7..c3aaa06 100644 --- a/backend/app/agent_commands/dependencies.py +++ b/backend/app/agent_commands/dependencies.py @@ -1,37 +1,46 @@ """Dependencies for the authenticated Agent queue protocol.""" -import hmac 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.service import AgentCommandService -from app.config import settings def get_agent_command_service() -> AgentCommandService: return AgentCommandService(AgentCommandRepository()) +def get_agent_auth_service() -> AgentAuthService: + return AgentAuthService(AgentAuthRepository()) + + def require_agent_identity( agent_id: Annotated[str | None, Header(alias="X-MinerOps-Agent-ID")] = None, provided_secret: Annotated[ str | None, Header(alias="X-MinerOps-Agent-Secret") ] = None, + service: AgentAuthService = Depends(get_agent_auth_service), ) -> str: - expected_id = settings.minerops_agent_id.strip() - expected_secret = settings.minerops_agent_shared_secret.strip() - if not expected_id or not expected_secret: - raise HTTPException(status_code=503, detail="Agent authentication is not configured") - - secret = (provided_secret or "").strip() - if not secret or not hmac.compare_digest(secret, expected_secret): - raise HTTPException(status_code=401, detail="Invalid or missing Agent secret") - - identity = (agent_id or "").strip() - if not identity: - raise HTTPException(status_code=401, detail="Missing Agent identity") - if not hmac.compare_digest(identity, expected_id): - raise HTTPException(status_code=403, detail="Agent identity is not authorized") - return identity + try: + return service.authenticate(agent_id, provided_secret) + except AgentAuthenticationUnavailable as exc: + raise HTTPException( + status_code=503, + detail="Agent authentication is not configured", + ) from exc + except AgentAuthenticationFailed as exc: + raise HTTPException(status_code=401, detail=str(exc)) from exc + except AgentCredentialRevoked as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except AgentIdentityUnauthorized as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc diff --git a/backend/app/agent_enrollment/__init__.py b/backend/app/agent_enrollment/__init__.py new file mode 100644 index 0000000..95c1593 --- /dev/null +++ b/backend/app/agent_enrollment/__init__.py @@ -0,0 +1 @@ +"""Secure one-time Agent enrollment.""" diff --git a/backend/app/agent_enrollment/dependencies.py b/backend/app/agent_enrollment/dependencies.py new file mode 100644 index 0000000..929dcee --- /dev/null +++ b/backend/app/agent_enrollment/dependencies.py @@ -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()) diff --git a/backend/app/agent_enrollment/exceptions.py b/backend/app/agent_enrollment/exceptions.py new file mode 100644 index 0000000..0f0acb6 --- /dev/null +++ b/backend/app/agent_enrollment/exceptions.py @@ -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.""" diff --git a/backend/app/agent_enrollment/models.py b/backend/app/agent_enrollment/models.py new file mode 100644 index 0000000..4186840 --- /dev/null +++ b/backend/app/agent_enrollment/models.py @@ -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 diff --git a/backend/app/agent_enrollment/repository.py b/backend/app/agent_enrollment/repository.py new file mode 100644 index 0000000..4861ef5 --- /dev/null +++ b/backend/app/agent_enrollment/repository.py @@ -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() diff --git a/backend/app/agent_enrollment/router.py b/backend/app/agent_enrollment/router.py new file mode 100644 index 0000000..ee58989 --- /dev/null +++ b/backend/app/agent_enrollment/router.py @@ -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 diff --git a/backend/app/agent_enrollment/service.py b/backend/app/agent_enrollment/service.py new file mode 100644 index 0000000..0bc0192 --- /dev/null +++ b/backend/app/agent_enrollment/service.py @@ -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" + ) diff --git a/backend/app/agent_zones/__init__.py b/backend/app/agent_zones/__init__.py new file mode 100644 index 0000000..12c105a --- /dev/null +++ b/backend/app/agent_zones/__init__.py @@ -0,0 +1 @@ +"""Agent Zone routing domain.""" diff --git a/backend/app/agent_zones/dependencies.py b/backend/app/agent_zones/dependencies.py new file mode 100644 index 0000000..23b1396 --- /dev/null +++ b/backend/app/agent_zones/dependencies.py @@ -0,0 +1,8 @@ +"""Agent Zone dependency wiring.""" + +from app.agent_zones.repository import AgentZoneRepository +from app.agent_zones.service import AgentZoneService + + +def get_agent_zone_service() -> AgentZoneService: + return AgentZoneService(AgentZoneRepository()) diff --git a/backend/app/agent_zones/exceptions.py b/backend/app/agent_zones/exceptions.py new file mode 100644 index 0000000..6821fb0 --- /dev/null +++ b/backend/app/agent_zones/exceptions.py @@ -0,0 +1,17 @@ +"""Agent Zone domain exceptions.""" + + +class AgentZoneError(Exception): + """Base Agent Zone error.""" + + +class AgentZoneNotFound(AgentZoneError): + """A Site or Agent Zone does not exist.""" + + +class AgentZoneValidationError(AgentZoneError): + """Agent Zone input or assignment is invalid.""" + + +class AgentZoneConflict(AgentZoneError): + """An Agent Zone uniqueness or assignment invariant conflicts.""" diff --git a/backend/app/agent_zones/models.py b/backend/app/agent_zones/models.py new file mode 100644 index 0000000..987774c --- /dev/null +++ b/backend/app/agent_zones/models.py @@ -0,0 +1,114 @@ +"""Typed Agent Zone and check-in contracts.""" + +from ipaddress import ip_network +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +class AgentZoneCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + site_id: UUID + name: str = Field(min_length=1, max_length=120) + description: str | None = Field(default=None, max_length=1000) + networks: list[str] = Field(default_factory=list, max_length=64) + + @field_validator("name") + @classmethod + def normalize_name(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("name cannot be blank") + return normalized + + @field_validator("description") + @classmethod + def normalize_description(cls, value: str | None) -> str | None: + if value is None: + return None + return value.strip() or None + + @field_validator("networks") + @classmethod + def normalize_networks(cls, values: list[str]) -> list[str]: + normalized: list[str] = [] + for value in values: + try: + network = str(ip_network(value.strip(), strict=True)) + except ValueError as exc: + raise ValueError(f"invalid CIDR network: {value}") from exc + if network in normalized: + raise ValueError(f"duplicate CIDR network: {network}") + normalized.append(network) + return normalized + + +class AgentZoneNetwork(BaseModel): + id: UUID + network: str + + +class AgentZoneSummary(BaseModel): + id: UUID + site_id: UUID + name: str + description: str | None = None + is_enabled: bool + assigned_agent_id: str | None = None + networks: list[AgentZoneNetwork] = Field(default_factory=list) + + +class AgentZoneListResponse(BaseModel): + data: list[AgentZoneSummary] + meta: dict[str, object] = Field(default_factory=dict) + error: None = None + + +class AgentZoneCreateResponse(BaseModel): + data: AgentZoneSummary + meta: dict[str, object] = Field(default_factory=dict) + error: None = None + + +class AgentCheckinRequest(BaseModel): + model_config = ConfigDict(extra="allow") + + agent_id: str | None = Field(default=None, max_length=255) + site_id: UUID | None = None + agent_zone_id: UUID | None = None + site_name: str | None = Field(default=None, max_length=255) + hostname: str | None = Field(default=None, max_length=255) + version: str | None = Field(default=None, max_length=120) + ip_address: str | None = Field(default=None, max_length=255) + + @model_validator(mode="after") + def require_site_for_explicit_zone(self) -> "AgentCheckinRequest": + if self.agent_zone_id is not None and self.site_id is None: + raise ValueError("site_id is required when agent_zone_id is supplied") + return self + + +class AgentCheckinResponse(BaseModel): + ok: bool = True + hub_mode: bool = True + agent_id: str + site_id: UUID | None = None + agent_zone_id: UUID | None = None + routing_available: bool + routing_reason: str | None = None + site_name: str + message: str = "Agent check-in received." + + +class AgentZoneRoutingContext(BaseModel): + site_id: UUID | None = None + agent_zone_id: UUID | None = None + routing_reason: str | None = None + + +class MinerZoneAssociationResult(BaseModel): + agent_zone_id: UUID | None = None + routing_reason: str | None = None + outcomes: dict[UUID, str] = Field(default_factory=dict) diff --git a/backend/app/agent_zones/repository.py b/backend/app/agent_zones/repository.py new file mode 100644 index 0000000..afc34d7 --- /dev/null +++ b/backend/app/agent_zones/repository.py @@ -0,0 +1,300 @@ +"""PostgreSQL persistence for Agent Zones and ownership assignments.""" + +from collections.abc import Callable, Iterable +from typing import Any +from uuid import UUID + +import psycopg +from psycopg.errors import ForeignKeyViolation, UniqueViolation +from psycopg.rows import dict_row +from psycopg.types.json import Jsonb + +from app.agent_zones.exceptions import ( + AgentZoneConflict, + AgentZoneNotFound, +) +from app.config import settings + + +ConnectionFactory = Callable[[], psycopg.Connection] + + +def default_connection_factory() -> psycopg.Connection: + return psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + connect_timeout=5, + ) + + +class AgentZoneRepository: + def __init__(self, connection_factory: ConnectionFactory | None = None): + self._connection_factory = connection_factory or default_connection_factory + + def get_agent(self, agent_id: str) -> dict | None: + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT agent_id, site_id, agent_zone_id, site_name, hostname, + version, ip_address, last_checkin_at, last_payload + FROM minerops_site_agents + WHERE agent_id = %s + """, + (agent_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + finally: + connection.close() + + def get_zone(self, zone_id: UUID) -> dict | None: + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT id, site_id, name, description, is_enabled, + created_at, updated_at + FROM agent_zones + WHERE id = %s + """, + (zone_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + finally: + connection.close() + + def list_usable_zones(self, site_id: UUID) -> list[dict]: + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT id, site_id, name, description, is_enabled + FROM agent_zones + WHERE site_id = %s AND is_enabled + ORDER BY lower(name), id + """, + (site_id,), + ) + return [dict(row) for row in cur.fetchall()] + finally: + connection.close() + + def list_zones(self, site_id: UUID | None = None) -> list[dict]: + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT zone.id, zone.site_id, zone.name, zone.description, + zone.is_enabled, agent.agent_id AS assigned_agent_id, + range.id AS range_id, range.network::text AS network + FROM agent_zones AS zone + LEFT JOIN minerops_site_agents AS agent + ON agent.agent_zone_id = zone.id + LEFT JOIN agent_zone_network_ranges AS range + ON range.agent_zone_id = zone.id + WHERE (%s::uuid IS NULL OR zone.site_id = %s::uuid) + ORDER BY lower(zone.name), zone.id, range.network, range.id + """, + (site_id, site_id), + ) + rows = cur.fetchall() + finally: + connection.close() + + zones: dict[UUID, dict] = {} + for row in rows: + zone = zones.setdefault( + row["id"], + { + "id": row["id"], + "site_id": row["site_id"], + "name": row["name"], + "description": row["description"], + "is_enabled": row["is_enabled"], + "assigned_agent_id": row["assigned_agent_id"], + "networks": [], + }, + ) + if row["range_id"] is not None: + zone["networks"].append( + {"id": row["range_id"], "network": row["network"]} + ) + return list(zones.values()) + + def create_zone( + self, + *, + site_id: UUID, + name: str, + description: str | None, + networks: list[str], + ) -> UUID: + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + INSERT INTO agent_zones (site_id, name, description) + VALUES (%s, %s, %s) + RETURNING id + """, + (site_id, name, description), + ) + zone_id = cur.fetchone()["id"] + for network in networks: + cur.execute( + """ + INSERT INTO agent_zone_network_ranges (agent_zone_id, network) + VALUES (%s, %s::cidr) + """, + (zone_id, network), + ) + connection.commit() + return zone_id + except ForeignKeyViolation as exc: + connection.rollback() + raise AgentZoneNotFound("Site not found") from exc + except UniqueViolation as exc: + connection.rollback() + raise AgentZoneConflict( + "An enabled Agent Zone with this name already exists at the Site" + ) from exc + except Exception: + connection.rollback() + raise + finally: + connection.close() + + def save_checkin( + self, + *, + agent_id: str, + site_id: UUID | None, + agent_zone_id: UUID | None, + site_name: str, + hostname: str | None, + version: str | None, + ip_address: str | None, + payload: dict[str, Any], + ) -> None: + connection = self._connection_factory() + try: + with connection.cursor() as cur: + cur.execute( + """ + INSERT INTO minerops_site_agents ( + agent_id, site_id, agent_zone_id, site_name, hostname, + version, ip_address, last_checkin_at, last_payload + ) VALUES (%s, %s, %s, %s, %s, %s, %s, now(), %s) + ON CONFLICT (agent_id) DO UPDATE SET + site_id = EXCLUDED.site_id, + agent_zone_id = EXCLUDED.agent_zone_id, + site_name = EXCLUDED.site_name, + hostname = EXCLUDED.hostname, + version = EXCLUDED.version, + ip_address = EXCLUDED.ip_address, + last_checkin_at = now(), + last_payload = EXCLUDED.last_payload + """, + ( + agent_id, + site_id, + agent_zone_id, + site_name, + hostname, + version, + ip_address, + Jsonb(payload), + ), + ) + connection.commit() + except UniqueViolation as exc: + connection.rollback() + raise AgentZoneConflict( + "The Agent Zone already has an assigned Agent" + ) from exc + except (ForeignKeyViolation, psycopg.errors.CheckViolation) as exc: + connection.rollback() + raise AgentZoneConflict("Agent Zone and Site assignment conflict") from exc + except Exception: + connection.rollback() + raise + finally: + connection.close() + + def get_agent_assignment(self, agent_id: str) -> dict | None: + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT agent.agent_id, agent.site_id, agent.agent_zone_id, + zone.is_enabled AS zone_enabled + FROM minerops_site_agents AS agent + LEFT JOIN agent_zones AS zone ON zone.id = agent.agent_zone_id + WHERE agent.agent_id = %s + """, + (agent_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + finally: + connection.close() + + def assign_miners_to_zone( + self, miner_ids: Iterable[UUID], agent_zone_id: UUID + ) -> dict[UUID, str]: + unique_ids = list(dict.fromkeys(miner_ids)) + if not unique_ids: + return {} + connection = self._connection_factory() + outcomes: dict[UUID, str] = {} + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + "SELECT id, site_id FROM agent_zones WHERE id = %s", + (agent_zone_id,), + ) + zone = cur.fetchone() + if not zone: + raise AgentZoneNotFound("Agent Zone not found") + for miner_id in unique_ids: + cur.execute( + "SELECT id, site_id, agent_zone_id FROM miners WHERE id = %s FOR UPDATE", + (miner_id,), + ) + miner = cur.fetchone() + if not miner: + outcomes[miner_id] = "miner_not_found" + elif miner["agent_zone_id"] == agent_zone_id: + outcomes[miner_id] = "preserved" + elif miner["agent_zone_id"] is not None: + outcomes[miner_id] = "agent_zone_conflict" + elif miner["site_id"] not in {None, zone["site_id"]}: + outcomes[miner_id] = "site_conflict" + else: + cur.execute( + """ + UPDATE miners + SET site_id = %s, agent_zone_id = %s, updated_at = now() + WHERE id = %s AND agent_zone_id IS NULL + """, + (zone["site_id"], agent_zone_id, miner_id), + ) + outcomes[miner_id] = "assigned" + connection.commit() + return outcomes + except Exception: + connection.rollback() + raise + finally: + connection.close() diff --git a/backend/app/agent_zones/router.py b/backend/app/agent_zones/router.py new file mode 100644 index 0000000..771351d --- /dev/null +++ b/backend/app/agent_zones/router.py @@ -0,0 +1,75 @@ +"""HTTP mapping for Agent Zones and Agent check-in.""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from app.agent_commands.dependencies import require_agent_identity +from app.agent_zones.dependencies import get_agent_zone_service +from app.agent_zones.exceptions import ( + AgentZoneConflict, + AgentZoneNotFound, + AgentZoneValidationError, +) +from app.agent_zones.models import ( + AgentCheckinRequest, + AgentCheckinResponse, + AgentZoneCreateRequest, + AgentZoneCreateResponse, + AgentZoneListResponse, +) +from app.agent_zones.service import AgentZoneService +from app.identity import require_permission + + +router = APIRouter(tags=["agent-zones"]) +ZoneService = Annotated[AgentZoneService, Depends(get_agent_zone_service)] +AgentIdentity = Annotated[str, Depends(require_agent_identity)] +ZoneViewer = Annotated[dict, Depends(require_permission("agents.view"))] +ZoneManager = Annotated[dict, Depends(require_permission("agents.manage"))] + + +@router.get("/api/agent-zones", response_model=AgentZoneListResponse) +def list_agent_zones( + current_user: ZoneViewer, + service: ZoneService, + site_id: UUID | None = Query(default=None), +) -> AgentZoneListResponse: + del current_user + return AgentZoneListResponse(data=service.list_zones(site_id)) + + +@router.post( + "/api/agent-zones", + response_model=AgentZoneCreateResponse, + status_code=status.HTTP_201_CREATED, +) +def create_agent_zone( + request: AgentZoneCreateRequest, + current_user: ZoneManager, + service: ZoneService, +) -> AgentZoneCreateResponse: + del current_user + try: + return AgentZoneCreateResponse(data=service.create_zone(request)) + except AgentZoneNotFound as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except AgentZoneConflict as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.post("/api/agent/checkin", response_model=AgentCheckinResponse) +def agent_checkin( + request: AgentCheckinRequest, + agent_id: AgentIdentity, + service: ZoneService, +) -> AgentCheckinResponse: + try: + return service.checkin(agent_id, request) + except AgentZoneNotFound as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except AgentZoneConflict as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except AgentZoneValidationError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc diff --git a/backend/app/agent_zones/service.py b/backend/app/agent_zones/service.py new file mode 100644 index 0000000..01cd57b --- /dev/null +++ b/backend/app/agent_zones/service.py @@ -0,0 +1,194 @@ +"""Business rules for Agent Zone routing and transitional check-in.""" + +from typing import Any +from uuid import UUID + +from app.agent_zones.exceptions import ( + AgentZoneNotFound, + AgentZoneValidationError, +) +from app.agent_zones.models import ( + AgentCheckinRequest, + AgentCheckinResponse, + AgentZoneRoutingContext, + AgentZoneCreateRequest, + AgentZoneSummary, + MinerZoneAssociationResult, +) +from app.agent_zones.repository import AgentZoneRepository + + +SENSITIVE_KEYS = { + "agent_secret", + "api3_account", + "api3_password", + "password", + "salt", + "token", +} + + +def _sanitize(value: Any) -> Any: + if isinstance(value, dict): + return { + key: _sanitize(item) + for key, item in value.items() + if key.lower() not in SENSITIVE_KEYS + and not any( + marker in key.lower() + for marker in ("secret", "password", "token", "salt") + ) + } + if isinstance(value, list): + return [_sanitize(item) for item in value] + return value + + +class AgentZoneService: + @staticmethod + def validate_agent_identity( + authenticated_agent_id: str, claimed_agent_id: str | None + ) -> None: + if claimed_agent_id and claimed_agent_id.strip() != authenticated_agent_id: + raise AgentZoneValidationError( + "Payload agent_id does not match authenticated Agent identity" + ) + + def __init__(self, repository: AgentZoneRepository): + self.repository = repository + + def list_zones(self, site_id: UUID | None = None) -> list[AgentZoneSummary]: + return [ + AgentZoneSummary.model_validate(row) + for row in self.repository.list_zones(site_id) + ] + + def create_zone(self, request: AgentZoneCreateRequest) -> AgentZoneSummary: + zone_id = self.repository.create_zone( + site_id=request.site_id, + name=request.name, + description=request.description, + networks=request.networks, + ) + for zone in self.list_zones(request.site_id): + if zone.id == zone_id: + return zone + raise AgentZoneNotFound("Created Agent Zone was not found") + + def checkin( + self, authenticated_agent_id: str, request: AgentCheckinRequest + ) -> AgentCheckinResponse: + self.validate_agent_identity(authenticated_agent_id, request.agent_id) + + existing = self.repository.get_agent(authenticated_agent_id) + requested_site = request.site_id + requested_zone = request.agent_zone_id + final_site = requested_site or (existing or {}).get("site_id") + final_zone: UUID | None = None + reason: str | None = None + + established_zone = None + established_zone_id = (existing or {}).get("agent_zone_id") + if established_zone_id is not None: + established_zone = self.repository.get_zone(established_zone_id) + if not established_zone: + raise AgentZoneValidationError( + "Existing Agent Zone assignment is invalid" + ) + if ( + requested_site is not None + and established_zone["site_id"] != requested_site + ): + raise AgentZoneValidationError( + "Agent cannot change Sites while assigned to an Agent Zone" + ) + + if requested_zone is not None: + zone = self.repository.get_zone(requested_zone) + if not zone: + raise AgentZoneNotFound("Agent Zone not found") + if zone["site_id"] != requested_site: + raise AgentZoneValidationError( + "Agent Zone does not belong to the supplied Site" + ) + if not zone["is_enabled"]: + raise AgentZoneValidationError("Agent Zone is disabled") + if established_zone_id is not None and established_zone_id != requested_zone: + raise AgentZoneValidationError( + "Agent is already assigned to a different Agent Zone" + ) + final_zone = requested_zone + elif established_zone is not None: + final_site = established_zone["site_id"] + final_zone = established_zone["id"] + if not established_zone["is_enabled"]: + reason = "agent_zone_disabled" + elif final_site is not None: + usable = self.repository.list_usable_zones(final_site) + if len(usable) == 1: + final_zone = usable[0]["id"] + elif not usable: + reason = "agent_zone_unavailable" + else: + reason = "agent_zone_ambiguous" + else: + reason = "site_unassigned" + + site_name = (request.site_name or "").strip() + if not site_name: + site_name = str((existing or {}).get("site_name") or "Unassigned") + payload = _sanitize(request.model_dump(mode="json")) + self.repository.save_checkin( + agent_id=authenticated_agent_id, + site_id=final_site, + agent_zone_id=final_zone, + site_name=site_name, + hostname=(request.hostname or "").strip() or None, + version=(request.version or "").strip() or None, + ip_address=(request.ip_address or "").strip() or None, + payload=payload, + ) + routing_available = final_zone is not None and reason is None + return AgentCheckinResponse( + agent_id=authenticated_agent_id, + site_id=final_site, + agent_zone_id=final_zone, + routing_available=routing_available, + routing_reason=reason, + site_name=site_name, + ) + + def routing_context( + self, agent_id: str, recorded_zone_id: UUID | None = None + ) -> AgentZoneRoutingContext: + assignment = self.repository.get_agent_assignment(agent_id) + if not assignment or assignment.get("agent_zone_id") is None: + return AgentZoneRoutingContext(routing_reason="agent_zone_unassigned") + zone_id = assignment["agent_zone_id"] + if recorded_zone_id is not None and recorded_zone_id != zone_id: + return AgentZoneRoutingContext(routing_reason="agent_zone_conflict") + if assignment.get("zone_enabled") is not True: + return AgentZoneRoutingContext( + site_id=assignment.get("site_id"), + agent_zone_id=zone_id, + routing_reason="agent_zone_disabled", + ) + return AgentZoneRoutingContext( + site_id=assignment.get("site_id"), agent_zone_id=zone_id + ) + + def associate_miners_from_agent( + self, agent_id: str, miner_ids: list[UUID] + ) -> MinerZoneAssociationResult: + routing = self.routing_context(agent_id) + if routing.routing_reason: + return MinerZoneAssociationResult( + agent_zone_id=routing.agent_zone_id, + routing_reason=routing.routing_reason, + ) + return MinerZoneAssociationResult( + agent_zone_id=routing.agent_zone_id, + outcomes=self.repository.assign_miners_to_zone( + miner_ids, routing.agent_zone_id + ), + ) diff --git a/backend/app/identity.py b/backend/app/identity.py index c6c8323..10a687d 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -699,8 +699,10 @@ def required_page_permission(path: str) -> str: def machine_agent_request(path: str) -> bool: exact = { + "/api/agent/enroll", "/api/agent/checkin", "/api/agent/config", + "/api/agent/known-miners", "/api/agent/miner-snapshots", "/api/agent/pool-snapshots", "/api/agent/commands/pending", @@ -777,6 +779,10 @@ def required_api_permission(method: str, path: str) -> str | None: return "repairs.read" if component_request(path): return "components.create" if method == "POST" else "components.read" + if path == "/api/agent-zones": + return "agents.view" if method == "GET" else "agents.manage" + if path == "/api/agent-enrollments": + return "agents.view" if method == "GET" else "agents.manage" if repair_parts_request(path): return "repairs.read" if method == "GET" else "repairs.parts.manage" if path.startswith("/api/admin/"): diff --git a/backend/app/main.py b/backend/app/main.py index 8c5ab70..509d8f8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,7 +11,13 @@ from fastapi import Request, Depends, FastAPI, Header, HTTPException, Query, sta from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError from app.config import settings +from app.agent_commands.dependencies import require_agent_identity from app.agent_commands.router import router as agent_commands_router +from app.agent_zones.dependencies import get_agent_zone_service +from app.agent_zones.models import MinerZoneAssociationResult +from app.agent_zones.service import AgentZoneService +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.dashboard.router import router as dashboard_router from app.identity import ( @@ -67,6 +73,8 @@ app.include_router(frontend_router) app.include_router(inventory_router) app.include_router(miner_control_router) app.include_router(agent_commands_router) +app.include_router(agent_zones_router) +app.include_router(agent_enrollment_router) app.include_router(components_router) app.include_router(locations_router) app.include_router(dashboard_router) @@ -4161,24 +4169,27 @@ def minerops_ensure_pool_info_table(): @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 os as _os import uuid as _uuid - expected_secret = (_os.getenv("MINEROPS_AGENT_SHARED_SECRET") or "").strip() - provided_secret = ( - request.headers.get("X-MinerOps-Agent-Secret") - or request.headers.get("x-minerops-agent-secret") - or "" - ).strip() - - if expected_secret and provided_secret != expected_secret: - raise HTTPException(status_code=401, detail="Invalid agent secret.") + 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") + ), + ) 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 [] minerops_ensure_pool_info_table() @@ -4230,28 +4241,34 @@ async def agent_pool_snapshots(request: Request): """ SELECT id FROM miners - WHERE upper(current_mac) = upper(%s) - OR host(current_ip) = %s + WHERE agent_zone_id = %s + AND ( + upper(current_mac) = upper(%s) + OR host(current_ip) = %s + ) LIMIT 1; """, - (mac, ip), + (routing.agent_zone_id, mac, ip), ) else: cur.execute( """ SELECT id FROM miners - WHERE host(current_ip) = %s + WHERE agent_zone_id = %s + AND host(current_ip) = %s LIMIT 1; """, - (ip,), + (routing.agent_zone_id, ip), ) row = cur.fetchone() - if row: - miner_id = row["id"] - matched += 1 + if not row: + continue + + miner_id = row["id"] + matched += 1 for pool in pools: if not isinstance(pool, dict): @@ -25081,70 +25098,6 @@ async def startup_ensure_minerops_agent_tables(): await ensure_minerops_agent_tables() -@app.post("/api/agent/checkin") -async def minerops_agent_checkin(request: Request): - import json - - minerops_require_agent_secret(request) - await ensure_minerops_agent_tables() - - try: - body = await request.json() - except Exception: - body = {} - - agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip() - site_id = str(body.get("site_id") or "").strip() - site_name = str(body.get("site_name") or "Plexus").strip() - hostname = str(body.get("hostname") or "").strip() - version = str(body.get("version") or "").strip() - ip_address = str(body.get("ip_address") or "").strip() - - conn = await minerops_command_db_connect() - - try: - await conn.execute( - """ - INSERT INTO minerops_site_agents ( - agent_id, - site_id, - site_name, - hostname, - version, - ip_address, - last_checkin_at, - last_payload - ) - VALUES ($1, NULLIF($2, '')::uuid, $3, $4, $5, $6, now(), $7::jsonb) - ON CONFLICT (agent_id) - DO UPDATE SET - site_id = COALESCE(EXCLUDED.site_id, minerops_site_agents.site_id), - site_name = EXCLUDED.site_name, - hostname = EXCLUDED.hostname, - version = EXCLUDED.version, - ip_address = EXCLUDED.ip_address, - last_checkin_at = now(), - last_payload = EXCLUDED.last_payload - """, - agent_id, - site_id, - site_name, - hostname, - version, - ip_address, - json.dumps(body), - ) - finally: - await conn.close() - - return { - "ok": True, - "hub_mode": True, - "agent_id": agent_id, - "site_name": site_name, - "message": "Agent check-in received.", - } - @app.get("/api/agent/config") async def minerops_agent_config(request: Request): @@ -25183,10 +25136,13 @@ async def minerops_agent_config(request: Request): @app.post("/api/agent/miner-snapshots") -async def minerops_agent_miner_snapshots(request: Request): +async def minerops_agent_miner_snapshots( + request: Request, + authenticated_agent_id: str = Depends(require_agent_identity), + zone_service: AgentZoneService = Depends(get_agent_zone_service), +): import json - minerops_require_agent_secret(request) await ensure_minerops_agent_tables() try: @@ -25194,30 +25150,39 @@ async def minerops_agent_miner_snapshots(request: Request): except Exception: body = {} - agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip() - site_name = str(body.get("site_name") or "Plexus").strip() - + zone_service.validate_agent_identity( + authenticated_agent_id, str(body.get("agent_id") or "").strip() or None + ) + site_name = str(body.get("site_name") or "Unassigned").strip() miners = body.get("miners") - if not isinstance(miners, list): miners = [] - conn = await minerops_command_db_connect() + miner_ids = [] + for item in miners: + if not isinstance(item, dict): + continue + try: + miner_ids.append(UUID(str(item.get("miner_id") or ""))) + except ValueError: + continue + ownership = zone_service.associate_miners_from_agent( + authenticated_agent_id, miner_ids + ) + conn = await minerops_command_db_connect() try: row = await conn.fetchrow( """ INSERT INTO minerops_agent_snapshots ( - agent_id, - site_name, - snapshot_kind, - miner_count, - payload + agent_id, agent_zone_id, site_name, snapshot_kind, + miner_count, payload ) - VALUES ($1, $2, 'miner_poll', $3, $4::jsonb) + VALUES ($1, $2::uuid, $3, 'miner_poll', $4, $5::jsonb) RETURNING id, received_at """, - agent_id, + authenticated_agent_id, + str(ownership.agent_zone_id) if ownership.agent_zone_id else None, site_name, len(miners), json.dumps(body), @@ -25225,13 +25190,16 @@ async def minerops_agent_miner_snapshots(request: Request): finally: await conn.close() - apply_result = await minerops_apply_agent_snapshot_to_live_tables(body) - + apply_result = await minerops_apply_agent_snapshot_to_live_tables( + body, ownership + ) return { "ok": True, "snapshot_id": row["id"] if row else None, "received_at": row["received_at"].isoformat() if row and row["received_at"] else None, "miner_count": len(miners), + "agent_zone_id": str(ownership.agent_zone_id) if ownership.agent_zone_id else None, + "routing_reason": ownership.routing_reason, "apply_result": apply_result, "message": "Miner snapshot received, staged, and applied to live tables.", } @@ -25445,8 +25413,19 @@ document.addEventListener("DOMContentLoaded", loadSetup); # ----------------------------- @app.get("/api/agent/known-miners") -async def minerops_agent_known_miners(request: Request): - minerops_require_agent_secret(request) +async def minerops_agent_known_miners( + 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() @@ -25463,8 +25442,10 @@ async def minerops_agent_known_miners(request: Request): FROM miners LEFT JOIN rooms ON rooms.id = miners.room_id 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 - """ + """, + str(routing.agent_zone_id), ) finally: await conn.close() @@ -25570,23 +25551,22 @@ def minerops_agent_parse_summary(miner_payload: dict) -> dict: } -async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> dict: +async def minerops_apply_agent_snapshot_to_live_tables( + snapshot_body: dict, ownership: MinerZoneAssociationResult +) -> dict: import json import uuid from datetime import datetime, timezone miners = snapshot_body.get("miners") - if not isinstance(miners, list): miners = [] now = datetime.now(timezone.utc) - applied = 0 online_count = 0 offline_count = 0 errors = [] - conn = await minerops_command_db_connect() try: @@ -25597,29 +25577,38 @@ async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> d miner_id = str(item.get("miner_id") or "").strip() ip_address = str(item.get("ip_address") or "").strip() online = bool(item.get("online")) - if not miner_id: continue + try: + miner_uuid = UUID(miner_id) + except ValueError: + errors.append({"miner_id": miner_id, "error": "invalid_miner_id"}) + continue + + if ownership.routing_reason: + errors.append( + {"miner_id": miner_id, "error": ownership.routing_reason} + ) + continue + zone_outcome = ownership.outcomes.get(miner_uuid, "miner_not_found") + if zone_outcome not in {"assigned", "preserved"}: + errors.append({"miner_id": miner_id, "error": zone_outcome}) + continue parsed = minerops_agent_parse_summary(item) raw_summary = parsed["raw"] msg = parsed["msg"] - status = "online" if online else "offline" - mhs_av = minerops_agent_to_float(msg.get("MHS av")) mhs_1m = minerops_agent_to_float(msg.get("MHS 1m")) mhs_15m = minerops_agent_to_float(msg.get("MHS 15m")) hs_rt = minerops_agent_to_float(msg.get("HS RT")) - power_w = minerops_agent_to_float(msg.get("Power")) power_rate = minerops_agent_to_float(msg.get("Power Rate")) - env_temp = minerops_agent_to_float(msg.get("Env Temp")) chip_temp_min = minerops_agent_to_float(msg.get("Chip Temp Min")) chip_temp_max = minerops_agent_to_float(msg.get("Chip Temp Max")) chip_temp_avg = minerops_agent_to_float(msg.get("Chip Temp Avg")) - fan_in = minerops_agent_to_int(msg.get("Fan Speed In")) fan_out = minerops_agent_to_int(msg.get("Fan Speed Out")) uptime = minerops_agent_to_int(msg.get("Uptime")) @@ -25629,12 +25618,10 @@ async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> d try: if online: online_count += 1 - await conn.execute( """ UPDATE miners - SET - status = $2, + SET status = $2, current_ip = COALESCE(NULLIF($3, '')::inet, current_ip), last_seen_at = $4, updated_at = now() @@ -25645,54 +25632,20 @@ async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> d ip_address, now, ) - await conn.execute( """ INSERT INTO miner_status_snapshots ( - id, - miner_id, - observed_ip, - observed_at, - mhs_av, - mhs_1m, - mhs_15m, - hs_rt, - power_w, - power_rate_w_per_th, - env_temp_c, - chip_temp_min_c, - chip_temp_max_c, - chip_temp_avg_c, - fan_speed_in_rpm, - fan_speed_out_rpm, - uptime_seconds, - hash_stable, - pool_rejected_percent, - raw_summary_json, - created_at + id, miner_id, observed_ip, observed_at, mhs_av, + mhs_1m, mhs_15m, hs_rt, power_w, + power_rate_w_per_th, env_temp_c, chip_temp_min_c, + chip_temp_max_c, chip_temp_avg_c, fan_speed_in_rpm, + fan_speed_out_rpm, uptime_seconds, hash_stable, + pool_rejected_percent, raw_summary_json, created_at ) VALUES ( - $1::uuid, - $2::uuid, - NULLIF($3, '')::inet, - $4, - $5, - $6, - $7, - $8, - $9, - $10, - $11, - $12, - $13, - $14, - $15, - $16, - $17, - $18, - $19, - $20::jsonb, - now() + $1::uuid, $2::uuid, NULLIF($3, '')::inet, $4, + $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, + $15, $16, $17, $18, $19, $20::jsonb, now() ) """, str(uuid.uuid4()), @@ -25718,12 +25671,10 @@ async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> d ) else: offline_count += 1 - await conn.execute( """ UPDATE miners - SET - status = $2, + SET status = $2, current_ip = COALESCE(NULLIF($3, '')::inet, current_ip), updated_at = now() WHERE id = $1::uuid @@ -25732,19 +25683,12 @@ async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> d status, ip_address, ) - applied += 1 - except Exception as exc: if len(errors) < 20: errors.append( - { - "miner_id": miner_id, - "ip_address": ip_address, - "error": str(exc), - } + {"miner_id": miner_id, "ip_address": ip_address, "error": str(exc)} ) - finally: await conn.close() @@ -25756,7 +25700,6 @@ async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> d } - # ----------------------------- # Phase 5K: Agent command queue # ----------------------------- @@ -26639,24 +26582,28 @@ async def minerops_queue_agent_scan_new_miners(current_user: dict = Depends(get_ @app.post("/api/agent/discoveries") -async def minerops_agent_post_discoveries(request: Request): +async def minerops_agent_post_discoveries( + request: Request, + authenticated_agent_id: str = Depends(require_agent_identity), + zone_service: AgentZoneService = Depends(get_agent_zone_service), +): import json import uuid - minerops_require_agent_secret(request) await minerops_ensure_agent_discovery_table() - body = await request.json() - - agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip() - site_name = str(body.get("site_name") or "Plexus").strip() + zone_service.validate_agent_identity( + authenticated_agent_id, str(body.get("agent_id") or "").strip() or None + ) + site_name = str(body.get("site_name") or "Unassigned").strip() discoveries = body.get("discoveries") or [] - if not isinstance(discoveries, list): discoveries = [] + ownership_context = zone_service.associate_miners_from_agent( + authenticated_agent_id, [] + ) conn = await minerops_command_db_connect() - inserted = 0 matched_existing = 0 staged_new = 0 @@ -26666,42 +26613,38 @@ async def minerops_agent_post_discoveries(request: Request): for item in discoveries: if not isinstance(item, dict): continue - ip_address = str(item.get("ip_address") or "").strip() mac_address = str(item.get("mac_address") or "").strip().upper() room_name = str(item.get("room_name") or "").strip() model = str(item.get("model") or "").strip() hostname = str(item.get("hostname") or "").strip() miner_sn = str(item.get("miner_sn") or "").strip() - matched = None if mac_address: matched = await conn.fetchrow( - """ - SELECT id - FROM miners - WHERE upper(current_mac) = upper($1) - LIMIT 1 - """, + "SELECT id FROM miners WHERE upper(current_mac) = upper($1) LIMIT 1", mac_address, ) - if not matched and ip_address: matched = await conn.fetchrow( - """ - SELECT id - FROM miners - WHERE current_ip = NULLIF($1, '')::inet - LIMIT 1 - """, + "SELECT id FROM miners WHERE current_ip = NULLIF($1, '')::inet LIMIT 1", ip_address, ) status = "known" if matched else "new" - if matched: matched_existing += 1 + association = zone_service.associate_miners_from_agent( + authenticated_agent_id, [matched["id"]] + ) + outcome = association.outcomes.get(matched["id"]) + if outcome not in {"assigned", "preserved"}: + status = "zone_conflict" if outcome else "known" + if outcome: + errors.append( + {"miner_id": str(matched["id"]), "error": outcome} + ) else: staged_new += 1 @@ -26709,45 +26652,23 @@ async def minerops_agent_post_discoveries(request: Request): await conn.execute( """ INSERT INTO minerops_agent_discoveries ( - id, - agent_id, - site_name, - room_name, - ip_address, - mac_address, - model, - hostname, - miner_sn, - raw_payload, - discovery_status, - matched_miner_id, - first_seen_at, - last_seen_at, - created_at, - updated_at - ) - VALUES ( - $1, - $2, - $3, - NULLIF($4, ''), - NULLIF($5, '')::inet, - NULLIF($6, ''), - NULLIF($7, ''), - NULLIF($8, ''), - NULLIF($9, ''), - $10::jsonb, - $11, - $12::uuid, - now(), - now(), - now(), - now() + id, agent_id, agent_zone_id, site_name, room_name, + ip_address, mac_address, model, hostname, miner_sn, + raw_payload, discovery_status, matched_miner_id, + first_seen_at, last_seen_at, created_at, updated_at + ) VALUES ( + $1, $2, $3::uuid, $4, NULLIF($5, ''), + NULLIF($6, '')::inet, NULLIF($7, ''), NULLIF($8, ''), + NULLIF($9, ''), NULLIF($10, ''), $11::jsonb, $12, + $13::uuid, now(), now(), now(), now() ) ON CONFLICT (id) DO NOTHING """, str(uuid.uuid4()), - agent_id, + authenticated_agent_id, + str(ownership_context.agent_zone_id) + if ownership_context.agent_zone_id + else None, site_name, room_name, ip_address, @@ -26759,12 +26680,12 @@ async def minerops_agent_post_discoveries(request: Request): status, str(matched["id"]) if matched else None, ) - inserted += 1 except Exception as exc: if len(errors) < 20: - errors.append({"ip_address": ip_address, "mac_address": mac_address, "error": str(exc)}) - + errors.append( + {"ip_address": ip_address, "mac_address": mac_address, "error": str(exc)} + ) finally: await conn.close() @@ -26774,6 +26695,10 @@ async def minerops_agent_post_discoveries(request: Request): "inserted": inserted, "matched_existing": matched_existing, "staged_new": staged_new, + "agent_zone_id": str(ownership_context.agent_zone_id) + if ownership_context.agent_zone_id + else None, + "routing_reason": ownership_context.routing_reason, "errors": errors, "message": "Discovery results received.", } @@ -26841,9 +26766,12 @@ async def minerops_agent_discoveries(current_user: dict = Depends(get_current_us @app.post("/api/agent/discoveries/review") -async def minerops_agent_discovery_review(request: Request, current_user: dict = Depends(get_current_user)): - import asyncpg - +async def minerops_agent_discovery_review( + request: Request, + current_user: dict = Depends(get_current_user), + zone_service: AgentZoneService = Depends(get_agent_zone_service), +): + del current_user payload = await request.json() ip_address = str(payload.get("ip_address") or "").strip() @@ -26864,6 +26792,7 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict = SELECT id, agent_id, + agent_zone_id, site_name, room_name, ip_address::text AS ip_address, @@ -26936,6 +26865,12 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict = """, ip_address) if existing: + association = zone_service.associate_miners_from_agent( + discovery["agent_id"], [UUID(existing["id"])] + ) + ownership_status = association.outcomes.get( + UUID(existing["id"]), association.routing_reason or "preserved" + ) await conn.execute(""" UPDATE minerops_agent_discoveries SET @@ -26950,29 +26885,34 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict = "action": action, "ip_address": ip_address, "miner_id": existing["id"], + "ownership_status": ownership_status, "message": "Discovery already matched an existing inventory miner." } room_id = None - site_id = None - + routing = zone_service.routing_context( + discovery["agent_id"], discovery["agent_zone_id"] + ) + site_id = routing.site_id if routing.routing_reason is None else None + agent_zone_id = ( + routing.agent_zone_id if routing.routing_reason is None else None + ) room_name = discovery["room_name"] - if room_name: - try: - room = await conn.fetchrow(""" - SELECT id::text AS id, site_id::text AS site_id - FROM rooms - WHERE lower(name) = lower($1) - LIMIT 1 - """, str(room_name)) - - if room: - room_id = room["id"] - site_id = room["site_id"] - except Exception: - room_id = None - site_id = None + if room_name and site_id: + room = await conn.fetchrow( + """ + SELECT id::text AS id + FROM rooms + WHERE site_id = $1::uuid AND lower(name) = lower($2) + ORDER BY id + LIMIT 1 + """, + str(site_id), + str(room_name), + ) + if room: + room_id = room["id"] model = discovery["model"] or "WhatsMiner" hostname = discovery["hostname"] @@ -26981,6 +26921,7 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict = new_miner = await conn.fetchrow(""" INSERT INTO miners ( site_id, + agent_zone_id, room_id, current_mac, current_ip, @@ -26998,14 +26939,15 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict = VALUES ( $1::uuid, $2::uuid, - $3, - $4::inet, - $5, + $3::uuid, + $4, + $5::inet, $6, $7, - $7, - 'discovered', $8, + $8, + 'discovered', + $9, now(), now(), now(), @@ -27014,6 +26956,7 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict = RETURNING id::text AS id """, site_id, + agent_zone_id, room_id, mac, ip_address, @@ -27045,10 +26988,22 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict = @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 - 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() @@ -27094,16 +27049,18 @@ async def minerops_agent_miner_enrichment(request: Request): SELECT id::text AS id FROM miners WHERE id = $1::uuid + AND agent_zone_id = $2::uuid LIMIT 1 - """, miner_id) + """, miner_id, str(routing.agent_zone_id)) if miner is None and ip_address: miner = await conn.fetchrow(""" SELECT id::text AS id FROM miners WHERE current_ip = $1::inet + AND agent_zone_id = $2::uuid LIMIT 1 - """, ip_address) + """, ip_address, str(routing.agent_zone_id)) if miner is None: raise HTTPException(status_code=404, detail="Miner not found.") @@ -27191,10 +27148,22 @@ async def minerops_agent_miner_enrichment(request: Request): @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 - 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() @@ -27256,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 FROM miners WHERE id = $1::uuid + AND agent_zone_id = $2::uuid LIMIT 1 - """, miner_id) + """, miner_id, str(routing.agent_zone_id)) if source is None and ip_address: source = await conn.fetchrow(""" SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes FROM miners WHERE current_ip = $1::inet + AND agent_zone_id = $2::uuid LIMIT 1 - """, ip_address) + """, ip_address, str(routing.agent_zone_id)) if source is None: raise HTTPException(status_code=404, detail="Source miner not found.") @@ -27280,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 FROM miners WHERE upper(current_mac) = upper($1) + AND agent_zone_id = $2::uuid LIMIT 1 - """, mac) + """, mac, str(routing.agent_zone_id)) if existing_mac_miner and existing_mac_miner["id"] != source_id: target_id = existing_mac_miner["id"] @@ -27366,7 +27338,8 @@ async def minerops_agent_miner_enrichment_v2(request: Request): matched_miner_id = $2::uuid, updated_at = now() 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: pass @@ -27405,10 +27378,22 @@ async def minerops_agent_miner_enrichment_v2(request: Request): @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 - 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() @@ -27465,8 +27450,9 @@ async def minerops_agent_miner_enrichment_v3(request: Request): SELECT id::text AS id FROM miners WHERE upper(current_mac) = upper($1) + AND agent_zone_id = $2::uuid LIMIT 1 - """, mac) + """, mac, str(routing.agent_zone_id)) if row: target_id = row["id"] @@ -27477,8 +27463,9 @@ async def minerops_agent_miner_enrichment_v3(request: Request): SELECT id::text AS id FROM miners WHERE id = $1::uuid + AND agent_zone_id = $2::uuid LIMIT 1 - """, miner_id) + """, miner_id, str(routing.agent_zone_id)) if row: target_id = row["id"] @@ -27489,8 +27476,9 @@ async def minerops_agent_miner_enrichment_v3(request: Request): SELECT id::text AS id FROM miners WHERE current_ip = $1::inet + AND agent_zone_id = $2::uuid LIMIT 1 - """, ip_address) + """, ip_address, str(routing.agent_zone_id)) if row: target_id = row["id"] @@ -27554,6 +27542,7 @@ async def minerops_agent_miner_enrichment_v3(request: Request): raw_payload = COALESCE(raw_payload, '{}'::jsonb) || $7::jsonb, updated_at = now() WHERE ip_address = $1::inet + AND agent_zone_id = $8::uuid """, ip_address, mac, @@ -27561,7 +27550,8 @@ async def minerops_agent_miner_enrichment_v3(request: Request): hostname, miner_sn, 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(""" @@ -27602,13 +27592,15 @@ async def minerops_agent_miner_enrichment_v3(request: Request): raw_payload = COALESCE(raw_payload, '{}'::jsonb) || $6::jsonb, updated_at = now() WHERE ip_address = $1::inet + AND agent_zone_id = $7::uuid """, ip_address, mac, model, hostname, miner_sn, - json.dumps({"enrichment": payload, "reason": "no_inventory_match"}) + json.dumps({"enrichment": payload, "reason": "no_inventory_match"}), + str(routing.agent_zone_id) ) return { @@ -29601,8 +29593,11 @@ async def minerops_inject_clean_detail_v3(request, call_next): from fastapi import Request as MinerOpsApi3Request, HTTPException as MinerOpsApi3HTTPException @app.post("/api/agent/api3-enrichment") -async def minerops_agent_api3_enrichment(request: MinerOpsApi3Request): - import os +async def minerops_agent_api3_enrichment( + request: MinerOpsApi3Request, + authenticated_agent_id: str = Depends(require_agent_identity), + zone_service: AgentZoneService = Depends(get_agent_zone_service), +): import json import uuid import psycopg @@ -29640,19 +29635,23 @@ async def minerops_agent_api3_enrichment(request: MinerOpsApi3Request): return value return [value] - expected_secret = os.getenv("MINEROPS_AGENT_SHARED_SECRET", "") - supplied_secret = ( - request.headers.get("x-minerops-agent-secret") - or request.headers.get("x-agent-secret") - or "" - ) - - if expected_secret and supplied_secret != expected_secret: - raise MinerOpsApi3HTTPException(status_code=401, detail="Invalid agent secret.") + routing = zone_service.routing_context(authenticated_agent_id) + if ( + routing.routing_reason + or routing.agent_zone_id is None + or routing.site_id is None + ): + raise MinerOpsApi3HTTPException( + status_code=409, + detail=( + "Agent routing unavailable: " + + (routing.routing_reason or "agent_zone_unassigned") + ), + ) body = await request.json() - agent_id = as_text(body.get("agent_id")) or "unknown-agent" - site_name = as_text(body.get("site_name")) or "unknown-site" + agent_id = authenticated_agent_id + site_name = None items = body.get("items") or [] if not isinstance(items, list): @@ -29673,6 +29672,20 @@ async def minerops_agent_api3_enrichment(request: MinerOpsApi3Request): conn = get_db_connection() 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( """ ALTER TABLE miners @@ -29853,10 +29866,11 @@ async def minerops_agent_api3_enrichment(request: MinerOpsApi3Request): SELECT id FROM miners WHERE upper(current_mac) = upper(%s) + AND agent_zone_id = %s ORDER BY updated_at DESC NULLS LAST LIMIT 1; """, - (mac,), + (mac, routing.agent_zone_id), ) row = cur.fetchone() if row: @@ -29868,15 +29882,19 @@ async def minerops_agent_api3_enrichment(request: MinerOpsApi3Request): SELECT id FROM miners WHERE current_ip = %s::inet + AND agent_zone_id = %s ORDER BY updated_at DESC NULLS LAST LIMIT 1; """, - (ip,), + (ip, routing.agent_zone_id), ) row = cur.fetchone() if row: miner_id = row["id"] + if miner_id is None: + continue + if miner_id is not None: matched += 1 diff --git a/backend/app/miner_control/repository.py b/backend/app/miner_control/repository.py index ce9ca1d..a723524 100644 --- a/backend/app/miner_control/repository.py +++ b/backend/app/miner_control/repository.py @@ -40,15 +40,20 @@ class MinerControlRepository: with connection.cursor(row_factory=dict_row) as cur: cur.execute( """ - SELECT miners.id, miners.site_id, host(miners.current_ip) AS current_ip, + SELECT miners.id, miners.site_id, miners.agent_zone_id, + zones.id IS NOT NULL AS agent_zone_exists, + zones.is_enabled AS agent_zone_enabled, + host(miners.current_ip) AS current_ip, miners.status AS runtime_state, miners.configured_performance_mode, miners.api3_supported, miners.api3_api_switch, agents.agent_id, agents.last_checkin_at, agents.version AS agent_version, agents.last_payload FROM miners + LEFT JOIN agent_zones AS zones + ON zones.id = miners.agent_zone_id LEFT JOIN minerops_site_agents AS agents - ON agents.site_id = miners.site_id + ON agents.agent_zone_id = miners.agent_zone_id WHERE miners.id = %s """, (miner_id,), diff --git a/backend/app/miner_control/service.py b/backend/app/miner_control/service.py index 3f8a602..4987c69 100644 --- a/backend/app/miner_control/service.py +++ b/backend/app/miner_control/service.py @@ -29,10 +29,14 @@ class MinerControlService: @staticmethod def _capability(context: dict) -> ControlCapability: - if not context.get("site_id"): - return ControlCapability(state="unavailable", reason="site_unassigned") + if not context.get("agent_zone_id"): + return ControlCapability(state="unavailable", reason="agent_zone_unassigned") + if context.get("agent_zone_exists") is not True: + return ControlCapability(state="unavailable", reason="agent_zone_missing") + if context.get("agent_zone_enabled") is not True: + return ControlCapability(state="unavailable", reason="agent_zone_disabled") if not context.get("agent_id"): - return ControlCapability(state="unavailable", reason="agent_unassigned") + return ControlCapability(state="unavailable", reason="agent_unavailable") if not context.get("current_ip"): return ControlCapability(state="unavailable", reason="current_ip_missing") if context.get("api3_supported") is False: diff --git a/backend/tests/test_agent_auth.py b/backend/tests/test_agent_auth.py new file mode 100644 index 0000000..26f4dc1 --- /dev/null +++ b/backend/tests/test_agent_auth.py @@ -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() diff --git a/backend/tests/test_agent_commands.py b/backend/tests/test_agent_commands.py index f569033..20d686a 100644 --- a/backend/tests/test_agent_commands.py +++ b/backend/tests/test_agent_commands.py @@ -7,7 +7,11 @@ from pathlib import Path from fastapi import FastAPI 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 ( AgentCommandConflict, AgentResultValidationError, @@ -123,6 +127,11 @@ class AgentCommandServiceTests(unittest.TestCase): AgentCommandService(FakeRepository()).submit_result("test-agent", request) +class FakeAgentAuthRepository: + def get_credential(self, agent_id): + return None + + class AgentCommandRouterTests(unittest.TestCase): def setUp(self): self.app = FastAPI() @@ -131,6 +140,9 @@ class AgentCommandRouterTests(unittest.TestCase): self.app.dependency_overrides[get_agent_command_service] = lambda: AgentCommandService( self.repository ) + self.app.dependency_overrides[get_agent_auth_service] = lambda: AgentAuthService( + FakeAgentAuthRepository() + ) self.client = TestClient(self.app) self.old_id = settings.minerops_agent_id self.old_secret = settings.minerops_agent_shared_secret @@ -199,6 +211,8 @@ class AgentSourceContractTests(unittest.TestCase): self.assertNotIn('DefaultValue "super"', service) self.assertNotIn('DefaultValue "super"', powershell) self.assertIn('"X-MinerOps-Agent-ID"', service) + self.assertIn('.agent_zone_id', service) + self.assertIn('"agent_zone_id"', (root / "config.example.json").read_text()) self.assertNotIn("?agent_id=", service) self.assertIn('"get.miner.setting"', control) self.assertIn("$attempt -le 3", control) @@ -233,3 +247,122 @@ class AgentSourceContractTests(unittest.TestCase): if __name__ == "__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) diff --git a/backend/tests/test_agent_enrollment.py b/backend/tests/test_agent_enrollment.py new file mode 100644 index 0000000..d12b518 --- /dev/null +++ b/backend/tests/test_agent_enrollment.py @@ -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() diff --git a/backend/tests/test_agent_enrollment_postgres.py b/backend/tests/test_agent_enrollment_postgres.py new file mode 100644 index 0000000..d67eca6 --- /dev/null +++ b/backend/tests/test_agent_enrollment_postgres.py @@ -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() diff --git a/backend/tests/test_agent_zones.py b/backend/tests/test_agent_zones.py new file mode 100644 index 0000000..ee70dd7 --- /dev/null +++ b/backend/tests/test_agent_zones.py @@ -0,0 +1,283 @@ +"""Focused model, service, router, and architecture tests for Agent Zones v1.""" + +import unittest +from pathlib import Path +from uuid import UUID, uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.agent_commands.dependencies import require_agent_identity +from app.agent_zones.dependencies import get_agent_zone_service +from app.agent_zones.exceptions import AgentZoneValidationError +from app.agent_zones.models import AgentCheckinRequest, AgentZoneCreateRequest +from app.agent_zones.router import router +from app.agent_zones.service import AgentZoneService +from app.identity import get_current_user, required_api_permission + + +SITE_A = UUID("00000000-0000-4000-8000-000000001601") +SITE_B = UUID("00000000-0000-4000-8000-000000001602") +ZONE_A = UUID("00000000-0000-4000-8000-000000001611") +ZONE_B = UUID("00000000-0000-4000-8000-000000001612") +MINER_A = UUID("00000000-0000-4000-8000-000000001621") +APP_ROOT = Path(__file__).parents[1] / "app" + + +def zone(zone_id=ZONE_A, site_id=SITE_A, *, enabled=True, name="Main Room"): + return { + "id": zone_id, + "site_id": site_id, + "name": name, + "description": None, + "is_enabled": enabled, + "assigned_agent_id": None, + "networks": [], + } + + +class FakeRepository: + def __init__(self): + self.zones = {ZONE_A: zone()} + self.agent = None + self.saved = [] + self.assign_result = {MINER_A: "assigned"} + + def get_agent(self, agent_id): + return self.agent if self.agent and self.agent["agent_id"] == agent_id else None + + def get_zone(self, zone_id): + return self.zones.get(zone_id) + + def list_usable_zones(self, site_id): + return [ + value for value in self.zones.values() + if value["site_id"] == site_id and value["is_enabled"] + ] + + def list_zones(self, site_id=None): + return [ + value for value in self.zones.values() + if site_id is None or value["site_id"] == site_id + ] + + def create_zone(self, *, site_id, name, description, networks): + created_id = uuid4() + self.zones[created_id] = { + **zone(created_id, site_id, name=name), + "description": description, + "networks": [ + {"id": uuid4(), "network": network} for network in networks + ], + } + return created_id + + def save_checkin(self, **kwargs): + self.saved.append(kwargs) + self.agent = { + "agent_id": kwargs["agent_id"], + "site_id": kwargs["site_id"], + "agent_zone_id": kwargs["agent_zone_id"], + "site_name": kwargs["site_name"], + } + + def get_agent_assignment(self, agent_id): + if not self.agent or self.agent["agent_id"] != agent_id: + return None + zone_row = self.zones.get(self.agent.get("agent_zone_id")) + return { + **self.agent, + "zone_enabled": zone_row["is_enabled"] if zone_row else None, + } + + def assign_miners_to_zone(self, miner_ids, agent_zone_id): + self.assigned = (list(miner_ids), agent_zone_id) + return {miner_id: self.assign_result.get(miner_id, "miner_not_found") + for miner_id in miner_ids} + + +class AgentZoneModelTests(unittest.TestCase): + def test_names_and_networks_are_normalized(self): + request = AgentZoneCreateRequest( + site_id=SITE_A, + name=" Main Room ", + description=" Local VLAN ", + networks=["10.30.0.0/24", "2001:db8::/64"], + ) + self.assertEqual(request.name, "Main Room") + self.assertEqual(request.description, "Local VLAN") + self.assertEqual(request.networks, ["10.30.0.0/24", "2001:db8::/64"]) + + def test_invalid_host_network_duplicate_and_extra_fields_are_rejected(self): + for networks in (["10.30.0.7/24"], ["10.30.0.0/24", "10.30.0.0/24"]): + with self.subTest(networks=networks), self.assertRaises(Exception): + AgentZoneCreateRequest(site_id=SITE_A, name="Zone", networks=networks) + with self.assertRaises(Exception): + AgentZoneCreateRequest(site_id=SITE_A, name="Zone", unknown=True) + + +class AgentZoneServiceTests(unittest.TestCase): + def setUp(self): + self.repository = FakeRepository() + self.service = AgentZoneService(self.repository) + + def checkin(self, **kwargs): + return self.service.checkin( + "agent-a", + AgentCheckinRequest(agent_id="agent-a", site_name="North", **kwargs), + ) + + def test_explicit_first_assignment_and_same_assignment_are_allowed(self): + first = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A) + second = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A) + self.assertTrue(first.routing_available) + self.assertEqual(second.agent_zone_id, ZONE_A) + + def test_explicit_zone_site_mismatch_and_identity_spoof_are_rejected(self): + with self.assertRaisesRegex(AgentZoneValidationError, "does not belong"): + self.checkin(site_id=SITE_B, agent_zone_id=ZONE_A) + with self.assertRaisesRegex(AgentZoneValidationError, "authenticated"): + self.service.checkin( + "agent-a", AgentCheckinRequest(agent_id="agent-b", site_id=SITE_A) + ) + + def test_established_agent_cannot_change_zone_within_site(self): + self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A) + self.repository.zones[ZONE_B] = zone(ZONE_B, SITE_A, name="Room 2") + with self.assertRaisesRegex(AgentZoneValidationError, "different Agent Zone"): + self.checkin(site_id=SITE_A, agent_zone_id=ZONE_B) + self.assertEqual(self.repository.agent["agent_zone_id"], ZONE_A) + + def test_established_agent_site_conflict_precedes_zone_conflict(self): + self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A) + self.repository.zones[ZONE_B] = zone(ZONE_B, SITE_B, name="Remote") + with self.assertRaisesRegex(AgentZoneValidationError, "cannot change Sites"): + self.checkin(site_id=SITE_B, agent_zone_id=ZONE_B) + self.assertEqual(self.repository.agent["agent_zone_id"], ZONE_A) + + def test_omitted_zone_preserves_established_assignment_and_site(self): + self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A) + result = self.service.checkin( + "agent-a", AgentCheckinRequest(agent_id="agent-a", site_name="North") + ) + self.assertEqual(result.site_id, SITE_A) + self.assertEqual(result.agent_zone_id, ZONE_A) + + def test_transitional_resolution_requires_exactly_one_enabled_zone(self): + single = self.checkin(site_id=SITE_A) + self.assertEqual(single.agent_zone_id, ZONE_A) + self.repository.agent = None + self.repository.zones = {} + zero = self.checkin(site_id=SITE_A) + self.assertEqual(zero.routing_reason, "agent_zone_unavailable") + self.repository.agent = None + self.repository.zones = { + ZONE_A: zone(), + ZONE_B: zone(ZONE_B, SITE_A, name="Room 2"), + } + many = self.checkin(site_id=SITE_A) + self.assertEqual(many.routing_reason, "agent_zone_ambiguous") + self.assertIsNone(many.agent_zone_id) + + def test_disabled_zone_is_not_selected_and_existing_assignment_is_factual(self): + self.repository.zones[ZONE_A]["is_enabled"] = False + unassigned = self.checkin(site_id=SITE_A) + self.assertEqual(unassigned.routing_reason, "agent_zone_unavailable") + self.repository.agent = { + "agent_id": "agent-a", "site_id": SITE_A, + "agent_zone_id": ZONE_A, "site_name": "North", + } + existing = self.service.checkin( + "agent-a", AgentCheckinRequest(agent_id="agent-a") + ) + self.assertEqual(existing.routing_reason, "agent_zone_disabled") + self.assertEqual(existing.agent_zone_id, ZONE_A) + + def test_payload_is_sanitized_before_persistence(self): + request = AgentCheckinRequest.model_validate({ + "agent_id": "agent-a", "site_id": str(SITE_A), + "api3_password": "secret", "nested": {"access_token": "secret"}, + "control_available": True, + }) + self.service.checkin("agent-a", request) + payload = self.repository.saved[-1]["payload"] + self.assertNotIn("password", str(payload).lower()) + self.assertNotIn("token", str(payload).lower()) + self.assertTrue(payload["control_available"]) + + def test_miner_zone_assignment_surfaces_conflicts_without_rehoming(self): + self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A) + self.repository.assign_result[MINER_A] = "agent_zone_conflict" + result = self.service.associate_miners_from_agent("agent-a", [MINER_A]) + self.assertEqual(result.outcomes[MINER_A], "agent_zone_conflict") + self.assertEqual(self.repository.assigned, ([MINER_A], ZONE_A)) + + def test_routing_context_rejects_stale_recorded_zone(self): + self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A) + result = self.service.routing_context("agent-a", ZONE_B) + self.assertEqual(result.routing_reason, "agent_zone_conflict") + self.assertIsNone(result.site_id) + + +class AgentZoneRouterTests(unittest.TestCase): + def setUp(self): + self.repository = FakeRepository() + self.service = AgentZoneService(self.repository) + self.app = FastAPI() + self.app.include_router(router) + self.app.dependency_overrides[get_agent_zone_service] = lambda: self.service + self.app.dependency_overrides[require_agent_identity] = lambda: "agent-a" + self.client = TestClient(self.app) + + def tearDown(self): + self.client.close() + + def authenticate(self, permissions): + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), "username": "operator", "role": "Custom", + "permissions": list(permissions), + } + + def test_list_and_create_use_existing_agent_permissions(self): + self.assertEqual(self.client.get("/api/agent-zones").status_code, 401) + self.authenticate(("agents.view",)) + self.assertEqual(self.client.get("/api/agent-zones").status_code, 200) + self.assertEqual( + self.client.post( + "/api/agent-zones", json={"site_id": str(SITE_A), "name": "Room 2"} + ).status_code, + 403, + ) + self.authenticate(("agents.manage",)) + response = self.client.post( + "/api/agent-zones", + json={"site_id": str(SITE_A), "name": "Room 2", "networks": ["10.40.0.0/24"]}, + ) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()["data"]["name"], "Room 2") + + def test_checkin_uses_authenticated_agent_identity(self): + response = self.client.post( + "/api/agent/checkin", + json={"agent_id": "agent-a", "site_id": str(SITE_A), + "agent_zone_id": str(ZONE_A)}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["agent_id"], "agent-a") + self.assertNotIn("secret", response.text.lower()) + + def test_permission_and_architecture_audits(self): + self.assertEqual(required_api_permission("GET", "/api/agent-zones"), "agents.view") + self.assertEqual(required_api_permission("POST", "/api/agent-zones"), "agents.manage") + router_source = (APP_ROOT / "agent_zones" / "router.py").read_text() + service_source = (APP_ROOT / "agent_zones" / "service.py").read_text() + repository_source = (APP_ROOT / "agent_zones" / "repository.py").read_text() + self.assertNotIn("psycopg", router_source) + self.assertNotIn("SELECT ", router_source) + self.assertNotIn("fastapi", service_source.lower()) + self.assertNotIn("fastapi", repository_source.lower()) + self.assertNotIn('@app.post("/api/agent/checkin")', (APP_ROOT / "main.py").read_text()) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_agent_zones_postgres.py b/backend/tests/test_agent_zones_postgres.py new file mode 100644 index 0000000..0b1f50a --- /dev/null +++ b/backend/tests/test_agent_zones_postgres.py @@ -0,0 +1,244 @@ +"""Real PostgreSQL integrity and routing tests for Agent Zones v1.""" + +import unittest +from uuid import UUID + +import psycopg +from psycopg.errors import ForeignKeyViolation, UniqueViolation + +from app.agent_zones.repository import AgentZoneRepository +from app.config import settings +from app.miner_control.repository import MinerControlRepository +from app.miner_control.service import MinerControlService + + +SITE_A = UUID("00000000-0000-4000-8000-000000001630") +SITE_B = UUID("00000000-0000-4000-8000-000000001631") +ZONE_A = UUID("00000000-0000-4000-8000-000000001632") +ZONE_B = UUID("00000000-0000-4000-8000-000000001633") +ZONE_C = UUID("00000000-0000-4000-8000-000000001636") +MINER_UNASSIGNED = UUID("00000000-0000-4000-8000-000000001634") +MINER_OTHER = UUID("00000000-0000-4000-8000-000000001635") +AGENT_A = "agent-zone-postgres-a" + + +def connect(): + return psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + connect_timeout=5, + ) + + +class AgentZonePostgresTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + "INSERT INTO sites (id, name) VALUES (%s, 'Zone Test A'), " + "(%s, 'Zone Test B') ON CONFLICT (id) DO NOTHING", + (SITE_A, SITE_B), + ) + cur.execute( + "INSERT INTO agent_zones (id, site_id, name) VALUES " + "(%s, %s, 'Main Room'), (%s, %s, 'Remote Room'), " + "(%s, %s, 'Unstaffed Zone') " + "ON CONFLICT (id) DO UPDATE SET is_enabled = true", + (ZONE_A, SITE_A, ZONE_B, SITE_B, ZONE_C, SITE_A), + ) + cur.execute( + "INSERT INTO miners (id, site_id, asset_tag, current_ip, status, " + "api3_supported, first_seen_at, last_seen_at) VALUES " + "(%s, %s, 'ZONE-UNASSIGNED', '192.0.2.31', 'online', true, NULL, NULL), " + "(%s, %s, 'ZONE-OTHER', '192.0.2.32', 'online', true, NULL, NULL) " + "ON CONFLICT (id) DO UPDATE SET agent_zone_id = NULL", + (MINER_UNASSIGNED, SITE_A, MINER_OTHER, SITE_B), + ) + cur.execute( + "UPDATE miners SET agent_zone_id = %s WHERE id = %s", + (ZONE_B, MINER_OTHER), + ) + cur.execute( + "INSERT INTO minerops_site_agents (" + "agent_id, site_id, agent_zone_id, site_name, last_payload) " + "VALUES (%s, %s, %s, 'Zone Test A', '{\"control_available\":true}') " + "ON CONFLICT (agent_id) DO UPDATE SET site_id = EXCLUDED.site_id, " + "agent_zone_id = EXCLUDED.agent_zone_id, last_payload = EXCLUDED.last_payload", + (AGENT_A, SITE_A, ZONE_A), + ) + connection.commit() + finally: + connection.close() + + @classmethod + def tearDownClass(cls): + connection = connect() + try: + with connection.cursor() as cur: + cur.execute("DELETE FROM minerops_site_agents WHERE agent_id LIKE 'agent-zone-postgres-%'") + cur.execute("DELETE FROM miners WHERE id IN (%s, %s)", (MINER_UNASSIGNED, MINER_OTHER)) + cur.execute("DELETE FROM agent_zone_network_ranges WHERE agent_zone_id IN (%s, %s, %s)", (ZONE_A, ZONE_B, ZONE_C)) + cur.execute("DELETE FROM agent_zones WHERE id IN (%s, %s, %s)", (ZONE_A, ZONE_B, ZONE_C)) + cur.execute("DELETE FROM sites WHERE id IN (%s, %s)", (SITE_A, SITE_B)) + connection.commit() + finally: + connection.close() + + def setUp(self): + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + "UPDATE agent_zones SET is_enabled = true " + "WHERE id IN (%s, %s, %s)", + (ZONE_A, ZONE_B, ZONE_C), + ) + cur.execute( + "UPDATE miners SET agent_zone_id = NULL WHERE id = %s", + (MINER_UNASSIGNED,), + ) + cur.execute( + "UPDATE miners SET site_id = %s, agent_zone_id = %s WHERE id = %s", + (SITE_B, ZONE_B, MINER_OTHER), + ) + cur.execute( + "UPDATE minerops_site_agents SET site_id = %s, agent_zone_id = %s " + "WHERE agent_id = %s", + (SITE_A, ZONE_A, AGENT_A), + ) + connection.commit() + finally: + connection.close() + + def test_repository_lists_multiple_canonical_networks(self): + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + "INSERT INTO agent_zone_network_ranges (agent_zone_id, network) " + "VALUES (%s, '10.30.0.0/24'), (%s, '10.31.0.0/24') " + "ON CONFLICT DO NOTHING", + (ZONE_A, ZONE_A), + ) + connection.commit() + finally: + connection.close() + listed = AgentZoneRepository(connect).list_zones(SITE_A) + self.assertEqual([item["network"] for item in listed[0]["networks"]], + ["10.30.0.0/24", "10.31.0.0/24"]) + + def test_active_names_are_case_insensitively_unique_per_site(self): + connection = connect() + try: + with self.assertRaises(UniqueViolation), connection.cursor() as cur: + cur.execute( + "INSERT INTO agent_zones (site_id, name) VALUES (%s, 'main room')", + (SITE_A,), + ) + finally: + connection.rollback() + connection.close() + + def test_one_agent_per_zone_but_multiple_zones_per_site(self): + connection = connect() + try: + with self.assertRaises(UniqueViolation), connection.cursor() as cur: + cur.execute( + "INSERT INTO minerops_site_agents " + "(agent_id, site_id, agent_zone_id, site_name) " + "VALUES ('agent-zone-postgres-duplicate', %s, %s, 'Duplicate')", + (SITE_A, ZONE_A), + ) + finally: + connection.rollback() + connection.close() + + def test_composite_foreign_keys_reject_cross_site_assignment(self): + connection = connect() + try: + with self.assertRaises(ForeignKeyViolation), connection.cursor() as cur: + cur.execute( + "UPDATE miners SET agent_zone_id = %s WHERE id = %s", + (ZONE_A, MINER_OTHER), + ) + finally: + connection.rollback() + connection.close() + + def test_unassigned_miner_inherits_zone_and_existing_other_zone_wins(self): + repository = AgentZoneRepository(connect) + outcomes = repository.assign_miners_to_zone( + [MINER_UNASSIGNED, MINER_OTHER], ZONE_A + ) + self.assertEqual(outcomes[MINER_UNASSIGNED], "assigned") + self.assertEqual(outcomes[MINER_OTHER], "agent_zone_conflict") + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + "SELECT id, agent_zone_id FROM miners WHERE id IN (%s, %s)", + (MINER_UNASSIGNED, MINER_OTHER), + ) + assignments = dict(cur.fetchall()) + finally: + connection.close() + self.assertEqual(assignments[MINER_UNASSIGNED], ZONE_A) + self.assertEqual(assignments[MINER_OTHER], ZONE_B) + + def test_disabled_zone_blocks_control_without_site_fallback(self): + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + "UPDATE miners SET agent_zone_id = %s WHERE id = %s", + (ZONE_A, MINER_UNASSIGNED), + ) + cur.execute("UPDATE agent_zones SET is_enabled = false WHERE id = %s", (ZONE_A,)) + connection.commit() + finally: + connection.close() + try: + projection = MinerControlService(MinerControlRepository(connect)).get_controls( + MINER_UNASSIGNED + ) + self.assertEqual(projection.service_control.reason, "agent_zone_disabled") + finally: + connection = connect() + try: + with connection.cursor() as cur: + cur.execute("UPDATE agent_zones SET is_enabled = true WHERE id = %s", (ZONE_A,)) + connection.commit() + finally: + connection.close() + + + def test_enabled_zone_without_agent_does_not_fall_back_within_site(self): + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + "UPDATE miners SET agent_zone_id = %s WHERE id = %s", + (ZONE_C, MINER_UNASSIGNED), + ) + connection.commit() + finally: + connection.close() + projection = MinerControlService(MinerControlRepository(connect)).get_controls( + MINER_UNASSIGNED + ) + self.assertEqual(projection.service_control.reason, "agent_unavailable") + + def test_null_zone_does_not_fall_back_to_site_agent(self): + projection = MinerControlService(MinerControlRepository(connect)).get_controls( + MINER_UNASSIGNED + ) + self.assertEqual(projection.service_control.reason, "agent_zone_unassigned") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_miner_control.py b/backend/tests/test_miner_control.py index 5f092e9..e24cc9d 100644 --- a/backend/tests/test_miner_control.py +++ b/backend/tests/test_miner_control.py @@ -26,6 +26,9 @@ def context(**overrides): result = { "id": MINER_ID, "site_id": uuid4(), + "agent_zone_id": uuid4(), + "agent_zone_exists": True, + "agent_zone_enabled": True, "current_ip": "192.0.2.10", "runtime_state": "online", "configured_performance_mode": "normal", @@ -78,8 +81,10 @@ class MinerControlServiceTests(unittest.TestCase): def test_capability_missing_facts_is_not_optimistic(self): cases = ( - ({"site_id": None}, "unavailable", "site_unassigned"), - ({"agent_id": None}, "unavailable", "agent_unassigned"), + ({"agent_zone_id": None}, "unavailable", "agent_zone_unassigned"), + ({"agent_zone_exists": False}, "unavailable", "agent_zone_missing"), + ({"agent_zone_enabled": False}, "unavailable", "agent_zone_disabled"), + ({"agent_id": None}, "unavailable", "agent_unavailable"), ({"current_ip": None}, "unavailable", "current_ip_missing"), ({"api3_supported": False}, "unavailable", "api3_unsupported"), ({"api3_supported": None}, "unknown", "api3_support_unknown"), diff --git a/backend/tests/test_miner_control_postgres.py b/backend/tests/test_miner_control_postgres.py index 87651a2..7381ba3 100644 --- a/backend/tests/test_miner_control_postgres.py +++ b/backend/tests/test_miner_control_postgres.py @@ -18,6 +18,7 @@ from app.miner_control.service import MinerControlService SITE_ID = UUID("00000000-0000-4000-8000-000000001510") +ZONE_ID = UUID("00000000-0000-4000-8000-000000001514") MINER_ID = UUID("00000000-0000-4000-8000-000000001511") USER_ID = UUID("00000000-0000-4000-8000-000000001512") OTHER_MINER_ID = UUID("00000000-0000-4000-8000-000000001513") @@ -54,6 +55,12 @@ class MinerControlPostgresTests(unittest.TestCase): "INSERT INTO sites (id, name) VALUES (%s, 'Control Test Site') " "ON CONFLICT (id) DO NOTHING", (SITE_ID,) ) + cur.execute( + "INSERT INTO agent_zones (id, site_id, name) " + "VALUES (%s, %s, 'Control Test Zone') " + "ON CONFLICT (id) DO UPDATE SET is_enabled = true", + (ZONE_ID, SITE_ID), + ) cur.execute( """ INSERT INTO users (id, username, display_name, password_hash, role_id) @@ -64,23 +71,25 @@ class MinerControlPostgresTests(unittest.TestCase): cur.execute( """ INSERT INTO miners ( - id, site_id, asset_tag, current_ip, status, api3_supported, + id, site_id, agent_zone_id, asset_tag, current_ip, status, api3_supported, configured_performance_mode, first_seen_at, last_seen_at - ) VALUES (%s, %s, 'CONTROL-TEST', '192.0.2.10', 'online', true, + ) VALUES (%s, %s, %s, 'CONTROL-TEST', '192.0.2.10', 'online', true, 'normal', NULL, NULL) ON CONFLICT (id) DO UPDATE SET site_id = EXCLUDED.site_id, + agent_zone_id = EXCLUDED.agent_zone_id, current_ip = EXCLUDED.current_ip, status = EXCLUDED.status, api3_supported = true - """, (MINER_ID, SITE_ID) + """, (MINER_ID, SITE_ID, ZONE_ID) ) cur.execute( """ INSERT INTO minerops_site_agents ( - agent_id, site_id, site_name, last_payload - ) VALUES (%s, %s, 'Control Test Site', %s) + agent_id, site_id, agent_zone_id, site_name, last_payload + ) VALUES (%s, %s, %s, 'Control Test Site', %s) ON CONFLICT (agent_id) DO UPDATE SET site_id = EXCLUDED.site_id, + agent_zone_id = EXCLUDED.agent_zone_id, last_payload = EXCLUDED.last_payload - """, (AGENT_ID, SITE_ID, Jsonb({"control_available": True})) + """, (AGENT_ID, SITE_ID, ZONE_ID, Jsonb({"control_available": True})) ) connection.commit() finally: @@ -100,6 +109,7 @@ class MinerControlPostgresTests(unittest.TestCase): cur.execute("DELETE FROM minerops_site_agents WHERE agent_id = %s", (AGENT_ID,)) cur.execute("DELETE FROM miners WHERE id = %s", (MINER_ID,)) cur.execute("DELETE FROM users WHERE id = %s", (USER_ID,)) + cur.execute("DELETE FROM agent_zones WHERE id = %s", (ZONE_ID,)) cur.execute("DELETE FROM sites WHERE id = %s", (SITE_ID,)) connection.commit() finally: diff --git a/backend/tests/test_system_health.py b/backend/tests/test_system_health.py index 93a9669..2dfbc88 100644 --- a/backend/tests/test_system_health.py +++ b/backend/tests/test_system_health.py @@ -19,7 +19,7 @@ from app.system_health.service import SystemHealthService APP_ROOT = Path(__file__).parents[1] / "app" -EXPECTED_HEAD = "0015_miner_control_foundation" +EXPECTED_HEAD = "0017_agent_enrollment" def database_payload(revision=EXPECTED_HEAD): diff --git a/docs/design/AGENT_ZONES.md b/docs/design/AGENT_ZONES.md new file mode 100644 index 0000000..41fe011 --- /dev/null +++ b/docs/design/AGENT_ZONES.md @@ -0,0 +1,132 @@ +# Agent Zones + +## Purpose + +An Agent Zone is MinerOps' durable network-reachability boundary. A Site is an +operational location and may contain networks that cannot be reached by one +Agent computer. Agent Zones let those networks remain within one Site without +making Site identity determine control routing. + +The implemented relationship is: + +```text +Site + -> Agent Zone + -> Agent installation + -> miners reachable through that Zone +``` + +`agent_id` remains the durable Agent-installation identity. Agent Zone identity +is a UUID independent of the Agent computer assigned to it. + +## Site, Zone, And Agent + +A Site groups rooms and operational records. An Agent Zone groups one or more +CIDR network ranges that are reachable from an Agent installation. The ranges +are factual Zone configuration for future enrollment and do not implement an +arbitrary routing table. + +A Site can contain multiple Zones. For example: + +```text +Plexus Mining Site + Main Room 10.30.0.0/24 -> Agent A + Room 2 10.40.0.0/24 -> Agent B + Immersion Room 10.50.0.0/24 -> Agent C +``` + +Zone names are unique case-insensitively among enabled Zones at the same Site. +They are not globally unique. A Zone can contain multiple canonical PostgreSQL +`CIDR` ranges. + +## Routing Authority + +Miner control resolves only from Hub-owned facts: + +```text +miner UUID + -> miners.agent_zone_id + -> Agent assigned to that Zone + -> miner current IP recorded by MinerOps + -> local API3 operation +``` + +A control request from a browser contains none of the routing inputs. It cannot +select an IP address, Agent, Agent Zone, raw vendor command, credentials, or a +dry-run mode. MinerOps never falls back to another Agent merely because it is +assigned to the same Site. + +A miner without a Zone is unavailable with `agent_zone_unassigned`. A missing +or disabled Zone is unavailable with `agent_zone_disabled`. A Zone without an +assigned Agent is unavailable with `agent_unavailable`. + +## Durable Miner Ownership + +`miners.agent_zone_id` is the authoritative reachability assignment. Snapshot +and discovery ingestion can assign a currently unassigned miner when the +authenticated Agent has a valid enabled Zone and Site boundaries agree. + +An observation from a different Zone does not move an assigned miner. MinerOps +records or returns `agent_zone_conflict` or `site_conflict` while preserving the +existing assignment. Snapshot and discovery payloads may still be staged as +factual observations. Explicit administrative reassignment is deferred. + +## Agent Assignment + +Agent Zones v1 permits at most one Agent installation per non-null Zone. A Site +can therefore have multiple Agents when they occupy different Zones. Primary +and standby Agents, automatic failover, and Agent high availability are not +implemented. + +An enabled Zone is usable for transitional check-in and control routing. A +disabled Zone preserves its durable associations but cannot be selected for a +new check-in and cannot route miner control. + +## Check-In Compatibility + +The Windows Agent may send `site_id` and optional `agent_zone_id` during +check-in. Existing configurations that omit `agent_zone_id` remain compatible: + +- An established valid assignment is preserved. +- An unassigned Agent can resolve automatically only when the supplied Site has + exactly one enabled Zone. +- Zero enabled Zones returns `agent_zone_unavailable`. +- Multiple enabled Zones returns `agent_zone_ambiguous`. +- MinerOps never chooses the first Zone. + +An explicit Zone must exist, be enabled, and belong to the supplied Site. An +Agent cannot move its established Zone or Site through check-in. Later +administrative Agent management will own reassignment. + +## Permissions And APIs + +The implemented management foundation reuses existing permissions: + +- `GET /api/agent-zones` requires `agents.view`. +- `POST /api/agent-zones` requires `agents.manage`. +- `POST /api/agent/checkin` requires authenticated Agent identity. + +The existing role matrix grants `agents.view` to Admin, Manager, Technician, +and Read Only. `agents.manage` is granted to Admin and Manager. No Agent Zone UI +is implemented in this milestone. + +## Migration Compatibility + +Migration 0016 creates one deterministic `Default Zone` for each pre-existing +Site. Existing miners and Agent assignments with a durable `site_id` are +backfilled to that Site's default Zone. Existing snapshots and discoveries are +associated when their durable Agent assignment makes that association factual. +Rows without a safe Site association remain unassigned. + +Downgrade to migration 0015 is allowed only when the Zone configuration can be +represented by its one-Agent-per-Site model. Downgrade refuses before +structural changes when a Site has multiple Agents, non-default Zone topology, +custom Zone state, or network ranges. + +## Future Relationship + +One-time Agent enrollment tokens, Settings -> Agents, installer downloads, +Linux Agents, update management, administrative reassignment, and primary or +standby Agents are future work. An enrollment flow can use a Zone's configured +network ranges, but enrollment tokens and automatic range delivery are not +implemented by Agent Zones v1. diff --git a/scripts/seed-development-demo.sh b/scripts/seed-development-demo.sh index 9cad861..d232985 100755 --- a/scripts/seed-development-demo.sh +++ b/scripts/seed-development-demo.sh @@ -78,8 +78,8 @@ migration_head="$( "${compose[@]}" exec -T postgres sh -ec \ 'psql -v ON_ERROR_STOP=1 -At -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT version_num FROM alembic_version"' )" -if [[ "${migration_head}" != "0015_miner_control_foundation" ]]; then - echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0015_miner_control_foundation." >&2 +if [[ "${migration_head}" != "0016_agent_zones" ]]; then + echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0016_agent_zones." >&2 exit 1 fi diff --git a/scripts/test-agent-enrollment-migration.sh b/scripts/test-agent-enrollment-migration.sh new file mode 100755 index 0000000..8b8c521 --- /dev/null +++ b/scripts/test-agent-enrollment-migration.sh @@ -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." diff --git a/scripts/test-agent-enrollment.sh b/scripts/test-agent-enrollment.sh new file mode 100755 index 0000000..cb4aa9c --- /dev/null +++ b/scripts/test-agent-enrollment.sh @@ -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." diff --git a/scripts/test-agent-zones-migration.sh b/scripts/test-agent-zones-migration.sh new file mode 100755 index 0000000..6c497fc --- /dev/null +++ b/scripts/test-agent-zones-migration.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" +project_name="${MINEROPS_AGENT_ZONES_MIGRATION_PROJECT:-minerops-agent-zones-migration-$$}" +compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml) + +cleanup() { + status=$? + if (( status != 0 )); then + "${compose[@]}" logs --no-color --tail 160 postgres || true + fi + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + exit "${status}" +} +trap cleanup EXIT +run_alembic() { "${compose[@]}" run --rm --no-deps backend alembic "$@"; } +psql_db() { + "${compose[@]}" exec -T postgres sh -ec \ + 'exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" "$@"' sh "$@" +} + +[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; } +"${compose[@]}" up --detach postgres +for attempt in $(seq 1 60); do + "${compose[@]}" exec -T postgres sh -ec \ + 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break + (( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; } + sleep 1 +done +"${compose[@]}" build backend + +site_a="00000000-0000-4000-8000-000000001690" +site_b="00000000-0000-4000-8000-000000001691" +miner_a="00000000-0000-4000-8000-000000001692" + +echo "Testing production path 0014 -> 0015 -> 0016 and truthful unassigned data..." +run_alembic upgrade 0014_repair_parts_tracking +psql_db -c "INSERT INTO minerops_site_agents (agent_id, site_name) VALUES ('legacy-zone-unassigned', 'Legacy Display')" +run_alembic upgrade 0015_miner_control_foundation +psql_db </dev/null)" == *"0016_agent_zones"* ]] + +psql_db < '10.30.0.0/24' THEN + RAISE EXCEPTION 'CIDR was not stored canonically'; + END IF; + BEGIN + INSERT INTO agent_zones (site_id, name) VALUES ('${site_a}', 'default zone'); + RAISE EXCEPTION 'Case-insensitive active Zone duplicate was accepted'; + EXCEPTION WHEN unique_violation THEN NULL; + END; + BEGIN + INSERT INTO minerops_site_agents (agent_id, site_id, agent_zone_id, site_name) + VALUES ('duplicate-zone-agent', '${site_a}', zone_a, 'Duplicate'); + RAISE EXCEPTION 'Second Agent in one Zone was accepted'; + EXCEPTION WHEN unique_violation THEN NULL; + END; + BEGIN + UPDATE miners SET site_id = '${site_b}' WHERE id = '${miner_a}'; + RAISE EXCEPTION 'Cross-Site miner Zone combination was accepted'; + EXCEPTION WHEN foreign_key_violation THEN NULL; + END; +END \$\$; +SQL + +echo "Confirming CIDR values with host bits are rejected..." +if psql_db -c "INSERT INTO agent_zone_network_ranges (agent_zone_id, network) SELECT id, '10.30.0.7/24'::cidr FROM agent_zones WHERE site_id = '${site_a}' AND name = 'Default Zone'"; then + echo "Host-address CIDR unexpectedly passed PostgreSQL validation" >&2 + exit 1 +fi + + +echo "Testing downgrade refusal for irreducible network configuration..." +if output="$(run_alembic downgrade 0015_miner_control_foundation 2>&1)"; then + echo "Downgrade unexpectedly accepted Agent Zone network ranges" >&2 + exit 1 +fi +[[ "${output}" == *"cannot be represented by the one-Agent-per-Site model"* ]] +[[ "$(run_alembic current 2>/dev/null)" == *"0016_agent_zones"* ]] +[[ "$(psql_db -Atc "SELECT count(*) FROM agent_zone_network_ranges")" == "1" ]] +psql_db -c "DELETE FROM agent_zone_network_ranges" + +echo "Testing downgrade refusal with multiple Agents at one Site..." +psql_db <&2 + exit 1 +fi +[[ "${output}" == *"multiple Agent installations are assigned"* ]] +[[ "${output}" == *"${site_a}"* ]] +[[ "$(run_alembic current 2>/dev/null)" == *"0016_agent_zones"* ]] +[[ "$(psql_db -Atc "SELECT count(*) FROM minerops_site_agents WHERE site_id='${site_a}'")" == "2" ]] +psql_db -c "DELETE FROM minerops_site_agents WHERE agent_id = 'zone-agent-b'; DELETE FROM agent_zones WHERE site_id = '${site_a}' AND name = 'Room 2'" + +echo "Testing legal downgrade, data preservation, and re-upgrade..." +run_alembic downgrade 0015_miner_control_foundation +[[ "$(run_alembic current 2>/dev/null)" == *"0015_miner_control_foundation"* ]] +[[ "$(psql_db -Atc "SELECT count(*) FROM miners WHERE id='${miner_a}'")" == "1" ]] +[[ "$(psql_db -Atc "SELECT count(*) FROM minerops_site_agents WHERE agent_id='zone-agent-a'")" == "1" ]] +[[ "$(psql_db -Atc "SELECT count(*) FROM information_schema.tables WHERE table_name='agent_zones'")" == "0" ]] +run_alembic upgrade 0016_agent_zones +[[ "$(run_alembic current 2>/dev/null)" == *"0016_agent_zones"* ]] + +echo "Testing empty database through head..." +"${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_agent_zones_empty' +"${compose[@]}" run --rm --no-deps -e POSTGRES_DB=minerops_agent_zones_empty backend alembic upgrade head +empty_revision="$("${compose[@]}" exec -T postgres sh -ec \ + 'psql -U "$POSTGRES_USER" -d minerops_agent_zones_empty -Atc "SELECT version_num FROM alembic_version"')" +[[ "${empty_revision}" == "0016_agent_zones" ]] + +echo "Agent Zones migration lifecycle checks passed." diff --git a/scripts/test-agent-zones.sh b/scripts/test-agent-zones.sh new file mode 100755 index 0000000..9030be7 --- /dev/null +++ b/scripts/test-agent-zones.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" +project_name="${MINEROPS_AGENT_ZONES_TEST_PROJECT:-minerops-agent-zones-test-$$}" +compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml) + +cleanup() { + status=$? + if (( status != 0 )); then + "${compose[@]}" logs --no-color --tail 160 postgres || true + fi + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + exit "${status}" +} +trap cleanup EXIT + +[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; } +for source_file in \ + backend/tests/test_agent_zones.py \ + backend/tests/test_agent_zones_postgres.py \ + backend/tests/test_miner_control.py \ + backend/tests/test_miner_control_postgres.py \ + backend/tests/test_agent_commands.py; do + permissions="$(stat -c '%a' "${source_file}")" + (( (10#${permissions} % 10) & 4 )) || { + echo "${source_file} is not readable by the non-root test container" >&2 + exit 1 + } +done + +"${compose[@]}" up --detach postgres +for attempt in $(seq 1 60); do + "${compose[@]}" exec -T postgres sh -ec \ + 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break + (( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; } + sleep 1 +done +"${compose[@]}" build backend +"${compose[@]}" run --rm --no-deps backend alembic upgrade head +"${compose[@]}" run --rm --no-deps \ + -v "${repo_root}/backend/tests:/app/tests:ro" \ + -v "${repo_root}/agents:/agents:ro" \ + backend python -m unittest -v \ + tests.test_agent_zones \ + tests.test_agent_zones_postgres \ + tests.test_miner_control \ + tests.test_miner_control_postgres \ + tests.test_agent_commands + +scripts/test-windows-agent-source.sh +echo "Agent Zones focused tests passed." diff --git a/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh index e8313a5..f4db3be 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -132,7 +132,7 @@ health_json="$(cat "${health_file}")" echo "Checking migrations and required schema..." current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" -[[ "${current_revision}" == *"0015_miner_control_foundation"* ]] +[[ "${current_revision}" == *"0016_agent_zones"* ]] missing_tables="$( "${compose[@]}" exec -T postgres sh -c \ diff --git a/scripts/test-miner-control.sh b/scripts/test-miner-control.sh index c425874..fe5520c 100755 --- a/scripts/test-miner-control.sh +++ b/scripts/test-miner-control.sh @@ -16,7 +16,7 @@ cleanup() { } trap cleanup EXIT [[ -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}")" (( (10#${permissions} % 10) & 4 )) || { 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}/agents:/agents:ro" \ backend python -m unittest -v \ + tests.test_agent_auth \ tests.test_miner_control \ tests.test_agent_commands \ tests.test_miner_control_postgres diff --git a/scripts/test-windows-agent-source.sh b/scripts/test-windows-agent-source.sh index 6506221..c9a0d85 100755 --- a/scripts/test-windows-agent-source.sh +++ b/scripts/test-windows-agent-source.sh @@ -5,6 +5,10 @@ service="agents/windows/MinerOpsAgentService.ps1" control="agents/windows/MinerOpsControl.ps1" installer="agents/windows/install.ps1" rg -q 'X-MinerOps-Agent-ID' "${service}" +rg -Fq '$config.agent_zone_id' "${service}" +rg -Fq '$body["agent_zone_id"]' "${service}" +rg -q '"agent_zone_id"' agents/windows/config.example.json +rg -q '`agent_zone_id` is the optional' agents/windows/README.md ! rg -q 'commands/pending\?agent_id=' "${service}" ! rg -q 'DefaultValue "super"' "${service}" rg -q 'get\.miner\.setting' "${control}" diff --git a/sql/development-demo.sql b/sql/development-demo.sql index 274de11..7b89d4e 100644 --- a/sql/development-demo.sql +++ b/sql/development-demo.sql @@ -40,6 +40,54 @@ ON CONFLICT (id) DO UPDATE SET description = EXCLUDED.description, updated_at = now(); +INSERT INTO agent_zones (id, site_id, name, description, is_enabled) +VALUES ( + 'd0000000-0000-4000-8000-000000000201', + 'd0000000-0000-4000-8000-000000000001', + 'Demo Operations Network', + 'Synthetic development-only Agent reachability Zone.', + true +) +ON CONFLICT (id) DO UPDATE SET + site_id = EXCLUDED.site_id, + name = EXCLUDED.name, + description = EXCLUDED.description, + is_enabled = EXCLUDED.is_enabled, + updated_at = now(); + +INSERT INTO agent_zone_network_ranges ( + id, agent_zone_id, network +) VALUES ( + 'd0000000-0000-4000-8000-000000000202', + 'd0000000-0000-4000-8000-000000000201', + '192.0.2.0/24'::cidr +) +ON CONFLICT (id) DO UPDATE SET + agent_zone_id = EXCLUDED.agent_zone_id, + network = EXCLUDED.network; + +INSERT INTO minerops_site_agents ( + agent_id, site_id, agent_zone_id, site_name, hostname, version, + last_checkin_at, last_payload +) VALUES ( + 'plexus-windows-agent', + 'd0000000-0000-4000-8000-000000000001', + 'd0000000-0000-4000-8000-000000000201', + 'Demo Mining Site', + 'demo-agent', + 'development-fixture', + now(), + jsonb_build_object('control_available', true, 'synthetic', true) +) +ON CONFLICT (agent_id) DO UPDATE SET + site_id = EXCLUDED.site_id, + agent_zone_id = EXCLUDED.agent_zone_id, + site_name = EXCLUDED.site_name, + hostname = EXCLUDED.hostname, + version = EXCLUDED.version, + last_checkin_at = EXCLUDED.last_checkin_at, + last_payload = EXCLUDED.last_payload; + INSERT INTO rooms (id, site_id, name, description, sort_order) VALUES ( @@ -94,7 +142,7 @@ WITH demo_miners AS ( FROM generate_series(1, 30) AS series(i) ) INSERT INTO miners ( - id, site_id, room_id, asset_tag, current_mac, current_ip, hostname, + id, site_id, agent_zone_id, room_id, asset_tag, current_mac, current_ip, hostname, miner_sn, power_sn, control_board_sn, model, miner_type, api_version, firmware_version, platform, chip, status, lifecycle_status, notes, first_seen_at, last_seen_at, created_at, updated_at @@ -102,6 +150,7 @@ INSERT INTO miners ( SELECT id, 'd0000000-0000-4000-8000-000000000001', + 'd0000000-0000-4000-8000-000000000201', room_id, format('DEMO-MIN-%s', lpad(i::text, 3, '0')), mac, @@ -126,6 +175,7 @@ SELECT FROM demo_miners ON CONFLICT (id) DO UPDATE SET site_id = EXCLUDED.site_id, + agent_zone_id = EXCLUDED.agent_zone_id, room_id = EXCLUDED.room_id, asset_tag = EXCLUDED.asset_tag, current_mac = EXCLUDED.current_mac, diff --git a/sql/verify-development-schema.sql b/sql/verify-development-schema.sql index f9a9546..a05b1b2 100644 --- a/sql/verify-development-schema.sql +++ b/sql/verify-development-schema.sql @@ -26,6 +26,8 @@ WITH expected(table_name) AS ( ('miner_pool_snapshots'), ('miner_api3_enrichment_snapshots'), ('miner_api3_hashboard_snapshots'), + ('agent_zones'), + ('agent_zone_network_ranges'), ('minerops_site_agents'), ('minerops_agent_snapshots'), ('minerops_agent_command_results'),