From d9f0da694becb2b41431d18c348ec9e02f49feb8 Mon Sep 17 00:00:00 2001 From: JC Date: Wed, 26 Aug 2026 21:31:09 +0000 Subject: [PATCH] feat: add secure agent enrollment --- agents/windows/EnrichOneMiner.ps1 | 2 + .../windows/MinerOpsApi3EnrichmentPoller.ps1 | 1 + agents/windows/MinerOpsPoolPoller.ps1 | 1 + .../alembic/versions/0017_agent_enrollment.py | 333 ++++++++++ backend/app/agent_auth/__init__.py | 1 + backend/app/agent_auth/exceptions.py | 21 + backend/app/agent_auth/repository.py | 47 ++ backend/app/agent_auth/service.py | 66 ++ backend/app/agent_commands/dependencies.py | 45 +- backend/app/agent_enrollment/__init__.py | 1 + backend/app/agent_enrollment/dependencies.py | 8 + backend/app/agent_enrollment/exceptions.py | 21 + backend/app/agent_enrollment/models.py | 94 +++ backend/app/agent_enrollment/repository.py | 506 ++++++++++++++ backend/app/agent_enrollment/router.py | 108 +++ backend/app/agent_enrollment/service.py | 98 +++ backend/app/identity.py | 4 + backend/app/main.py | 209 ++++-- backend/tests/test_agent_auth.py | 136 ++++ backend/tests/test_agent_commands.py | 133 +++- backend/tests/test_agent_enrollment.py | 157 +++++ .../tests/test_agent_enrollment_postgres.py | 625 ++++++++++++++++++ backend/tests/test_system_health.py | 2 +- scripts/test-agent-enrollment-migration.sh | 327 +++++++++ scripts/test-agent-enrollment.sh | 69 ++ scripts/test-miner-control.sh | 3 +- 26 files changed, 2942 insertions(+), 76 deletions(-) create mode 100644 backend/alembic/versions/0017_agent_enrollment.py create mode 100644 backend/app/agent_auth/__init__.py create mode 100644 backend/app/agent_auth/exceptions.py create mode 100644 backend/app/agent_auth/repository.py create mode 100644 backend/app/agent_auth/service.py create mode 100644 backend/app/agent_enrollment/__init__.py create mode 100644 backend/app/agent_enrollment/dependencies.py create mode 100644 backend/app/agent_enrollment/exceptions.py create mode 100644 backend/app/agent_enrollment/models.py create mode 100644 backend/app/agent_enrollment/repository.py create mode 100644 backend/app/agent_enrollment/router.py create mode 100644 backend/app/agent_enrollment/service.py create mode 100644 backend/tests/test_agent_auth.py create mode 100644 backend/tests/test_agent_enrollment.py create mode 100644 backend/tests/test_agent_enrollment_postgres.py create mode 100755 scripts/test-agent-enrollment-migration.sh create mode 100755 scripts/test-agent-enrollment.sh 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/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/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/identity.py b/backend/app/identity.py index f3ac3fe..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", @@ -779,6 +781,8 @@ def required_api_permission(method: str, path: str) -> str | None: 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 1d7d2df..509d8f8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -17,6 +17,7 @@ from app.agent_zones.dependencies import get_agent_zone_service from app.agent_zones.models import MinerZoneAssociationResult from app.agent_zones.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 ( @@ -73,6 +74,7 @@ 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) @@ -4167,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() @@ -4236,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): @@ -25402,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() @@ -25420,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() @@ -26964,10 +26988,22 @@ async def minerops_agent_discovery_review( @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() @@ -27013,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.") @@ -27110,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() @@ -27175,16 +27225,18 @@ async def minerops_agent_miner_enrichment_v2(request: Request): SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes 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.") @@ -27199,8 +27251,9 @@ async def minerops_agent_miner_enrichment_v2(request: Request): SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes 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"] @@ -27285,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 @@ -27324,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() @@ -27384,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"] @@ -27396,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"] @@ -27408,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"] @@ -27473,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, @@ -27480,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(""" @@ -27521,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 { @@ -29520,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 @@ -29559,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): @@ -29592,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 @@ -29772,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: @@ -29787,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/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 dc9ee48..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 @@ -235,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_system_health.py b/backend/tests/test_system_health.py index e86cb44..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 = "0016_agent_zones" +EXPECTED_HEAD = "0017_agent_enrollment" def database_payload(revision=EXPECTED_HEAD): 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-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