diff --git a/backend/alembic/versions/0018_legacy_agent_routing.py b/backend/alembic/versions/0018_legacy_agent_routing.py new file mode 100644 index 0000000..13aa15c --- /dev/null +++ b/backend/alembic/versions/0018_legacy_agent_routing.py @@ -0,0 +1,259 @@ +"""route pre-Zone Agent installations without rewriting ownership history + +Revision ID: 0018_legacy_agent_routing +Revises: 0017_agent_enrollment +Create Date: 2026-08-28 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0018_legacy_agent_routing" +down_revision: Union[str, None] = "0017_agent_enrollment" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +DEFAULT_ZONE_NAME = "Default Zone" + +# Every preflight and the eventual Site UPDATE use this exact resolver. An +# exact trim/case-folded name match wins. The sole-Site fallback applies only +# when there are zero exact matches and exactly one durable Site in the whole +# database. Ambiguous exact matches and unresolved multi-Site installations +# deliberately produce NULL resolved_site_id and fail before any UPDATE. +RESOLVED_AGENT_SITES_CTE = """ +site_catalog AS ( + SELECT count(*) AS site_count, + (array_agg(id ORDER BY id))[1] AS sole_site_id + FROM sites +), +unassigned_exact_matches AS ( + SELECT agent.agent_id, + agent.site_name, + count(site.id) AS exact_match_count, + ( + array_agg(site.id ORDER BY site.id) + FILTER (WHERE site.id IS NOT NULL) + )[1] AS exact_site_id + FROM minerops_site_agents AS agent + LEFT JOIN sites AS site + ON lower(btrim(site.name)) = lower(btrim(agent.site_name)) + WHERE agent.site_id IS NULL + GROUP BY agent.agent_id, agent.site_name +), +resolved_unassigned_agents AS ( + SELECT match.agent_id, + match.site_name, + match.exact_match_count, + catalog.site_count, + CASE + WHEN match.exact_match_count = 1 + THEN match.exact_site_id + WHEN match.exact_match_count = 0 + AND catalog.site_count = 1 + THEN catalog.sole_site_id + ELSE NULL + END AS resolved_site_id + FROM unassigned_exact_matches AS match + CROSS JOIN site_catalog AS catalog +) +""" + + +def _format_rows(rows: list[tuple]) -> str: + return ", ".join("/".join(repr(str(value)) for value in row) for row in rows) + + +def upgrade() -> None: + connection = op.get_bind() + + # Keep every preflight result stable until the corresponding routing and + # history updates commit. Production must still take the Hub and all queue + # producers offline before migration; these locks are the database-level + # backstop against an accidental concurrent Site, Zone, Agent, snapshot, or + # discovery write changing an already-checked assumption. + connection.execute( + sa.text( + """ + LOCK TABLE + agent_zones, + minerops_agent_discoveries, + minerops_agent_snapshots, + minerops_site_agents, + sites + IN SHARE ROW EXCLUSIVE MODE + """ + ) + ) + + # Run every fail-closed preflight before the first UPDATE. Site resolution + # is intentionally limited to legacy rows without an explicit Site + # assignment; explicit site_id values are never candidates for replacement. + site_match_failures = connection.execute( + sa.text( + f""" + WITH {RESOLVED_AGENT_SITES_CTE} + SELECT agent_id, site_name, exact_match_count, site_count + FROM resolved_unassigned_agents + WHERE resolved_site_id IS NULL + ORDER BY agent_id + """ + ) + ).all() + if site_match_failures: + raise RuntimeError( + "Cannot route legacy Agent installations: each unassigned Agent " + "must have exactly one Site name match after trim/case folding, " + "or zero exact matches while the database contains exactly one " + "Site. Ambiguous exact matches and unmatched multi-Site " + "installations fail closed. Failing " + "agent/site/exact-match-count/site-count rows: " + f"{_format_rows(list(site_match_failures))}. Assign site_id " + "explicitly, make Site names unambiguous, or identify the correct " + "Site before retrying; no " + "Agent, miner, MAC, queue, snapshot, or discovery data was changed." + ) + + default_zone_failures = connection.execute( + sa.text( + f""" + WITH {RESOLVED_AGENT_SITES_CTE}, + effective_agents AS ( + SELECT agent.agent_id, + coalesce( + agent.site_id, + resolved.resolved_site_id + ) AS site_id + FROM minerops_site_agents AS agent + LEFT JOIN resolved_unassigned_agents AS resolved + ON resolved.agent_id = agent.agent_id + WHERE agent.agent_zone_id IS NULL + ) + SELECT agent.agent_id, agent.site_id::text, count(zone.id) + FROM effective_agents AS agent + LEFT JOIN agent_zones AS zone + ON zone.site_id = agent.site_id + AND zone.is_enabled + AND lower(btrim(zone.name)) = lower(:default_name) + GROUP BY agent.agent_id, agent.site_id + HAVING count(zone.id) <> 1 + ORDER BY agent.agent_id + """ + ), + {"default_name": DEFAULT_ZONE_NAME}, + ).all() + if default_zone_failures: + raise RuntimeError( + "Cannot route legacy Agent installations: each Agent without an " + "explicit Zone must have exactly one enabled Default Zone in its " + "effective Site. Failing agent/site/count rows: " + f"{_format_rows(list(default_zone_failures))}. Repair the Site's " + "Default Zone or assign agent_zone_id explicitly before retrying; " + "no data was changed." + ) + + zone_conflicts = connection.execute( + sa.text( + f""" + WITH {RESOLVED_AGENT_SITES_CTE}, + targets AS ( + SELECT agent.agent_id, zone.id AS agent_zone_id + FROM minerops_site_agents AS agent + LEFT JOIN resolved_unassigned_agents AS resolved + ON resolved.agent_id = agent.agent_id + JOIN agent_zones AS zone + ON zone.site_id = coalesce( + agent.site_id, + resolved.resolved_site_id + ) + AND zone.is_enabled + AND lower(btrim(zone.name)) = lower(:default_name) + WHERE agent.agent_zone_id IS NULL + ) + SELECT target.agent_zone_id::text, + string_agg(target.agent_id, ',' ORDER BY target.agent_id), + coalesce(string_agg(occupant.agent_id, ',' + ORDER BY occupant.agent_id), '') + FROM targets AS target + LEFT JOIN minerops_site_agents AS occupant + ON occupant.agent_zone_id = target.agent_zone_id + GROUP BY target.agent_zone_id + HAVING count(DISTINCT target.agent_id) > 1 + OR count(occupant.agent_id) > 0 + ORDER BY target.agent_zone_id + """ + ), + {"default_name": DEFAULT_ZONE_NAME}, + ).all() + if zone_conflicts: + raise RuntimeError( + "Cannot route legacy Agent installations: one-Agent-per-Zone would " + "be violated. Failing zone/target Agents/current Agent rows: " + f"{_format_rows(list(zone_conflicts))}. Assign distinct enabled " + "Zones explicitly before retrying; no data was changed." + ) + + connection.execute( + sa.text( + f""" + WITH {RESOLVED_AGENT_SITES_CTE} + UPDATE minerops_site_agents AS agent + SET site_id = resolved.resolved_site_id + FROM resolved_unassigned_agents AS resolved + WHERE agent.agent_id = resolved.agent_id + AND agent.site_id IS NULL + AND resolved.resolved_site_id IS NOT NULL + """ + ) + ) + connection.execute( + sa.text( + """ + UPDATE minerops_site_agents AS agent + SET agent_zone_id = zone.id + FROM agent_zones AS zone + WHERE agent.site_id = zone.site_id + AND zone.is_enabled + AND lower(btrim(zone.name)) = lower(:default_name) + AND agent.agent_zone_id IS NULL + """ + ), + {"default_name": DEFAULT_ZONE_NAME}, + ) + connection.execute( + sa.text( + """ + UPDATE minerops_agent_snapshots AS snapshot + SET agent_zone_id = agent.agent_zone_id + FROM minerops_site_agents AS agent + WHERE snapshot.agent_id = agent.agent_id + AND snapshot.agent_zone_id IS NULL + AND agent.agent_zone_id IS NOT NULL + """ + ) + ) + connection.execute( + sa.text( + """ + UPDATE minerops_agent_discoveries AS discovery + SET agent_zone_id = agent.agent_zone_id + FROM minerops_site_agents AS agent + WHERE discovery.agent_id = agent.agent_id + AND discovery.agent_zone_id IS NULL + AND agent.agent_zone_id IS NOT NULL + """ + ) + ) + + +def downgrade() -> None: + # There is no durable, trustworthy way to distinguish values assigned here + # from assignments made by an operator after upgrade. Refuse instead of + # erasing potentially explicit production routing or historical attribution. + raise RuntimeError( + "Cannot downgrade 0018 automatically: legacy Site/Zone routing and " + "snapshot/discovery attribution may now be production data. Restore the " + "pre-upgrade database backup for rollback; no data was changed." + ) diff --git a/backend/alembic/versions/0019_agent_legacy_auth_retire.py b/backend/alembic/versions/0019_agent_legacy_auth_retire.py new file mode 100644 index 0000000..7bbb8ef --- /dev/null +++ b/backend/alembic/versions/0019_agent_legacy_auth_retire.py @@ -0,0 +1,308 @@ +"""make legacy Agent authentication retirement durable and one-way + +Revision ID: 0019_agent_legacy_auth_retire +Revises: 0018_legacy_agent_routing +Create Date: 2026-08-31 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0019_agent_legacy_auth_retire" +down_revision: Union[str, None] = "0018_legacy_agent_routing" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Keep the row-level value as a projection for operators and defense in + # depth. The independent identity table below is the authoritative record: + # it deliberately has no foreign key to the deletable current Agent row. + op.add_column( + "minerops_site_agents", + sa.Column( + "legacy_auth_disabled_at", + sa.DateTime(timezone=True), + nullable=True, + ), + ) + op.create_table( + "agent_legacy_auth_retirements", + sa.Column("agent_id", sa.Text(), primary_key=True), + sa.Column( + "retired_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("clock_timestamp()"), + ), + sa.CheckConstraint( + "btrim(agent_id) <> ''", + name="ck_agent_legacy_auth_retirements_agent_id", + ), + ) + + connection = op.get_bind() + # Stabilize both credential sources until backfill and all enforcement + # triggers are installed. This preserves a single atomic migration view. + connection.execute( + sa.text( + """ + LOCK TABLE minerops_site_agents, agent_enrollments + IN SHARE ROW EXCLUSIVE MODE + """ + ) + ) + + # Backfill every reliable indication that a per-Agent secret was issued. + # A used enrollment remains evidence even if credential columns were later + # cleared. Merely existing as an uncredentialed Agent is not evidence, so + # the production-shaped legacy Plexus identity remains eligible. + op.execute( + """ + WITH credential_evidence AS ( + SELECT + agent_id, + coalesce( + credential_created_at, + credential_revoked_at, + clock_timestamp() + ) AS retired_at + FROM minerops_site_agents + WHERE agent_secret_hash IS NOT NULL + OR credential_created_at IS NOT NULL + OR credential_revoked_at IS NOT NULL + + UNION ALL + + SELECT enrolled_agent_id AS agent_id, used_at AS retired_at + FROM agent_enrollments + WHERE enrolled_agent_id IS NOT NULL + AND used_at IS NOT NULL + ) + INSERT INTO agent_legacy_auth_retirements (agent_id, retired_at) + SELECT agent_id, min(retired_at) + FROM credential_evidence + GROUP BY agent_id + """ + ) + op.execute( + """ + UPDATE minerops_site_agents AS agent + SET legacy_auth_disabled_at = retirement.retired_at + FROM agent_legacy_auth_retirements AS retirement + WHERE retirement.agent_id = agent.agent_id + """ + ) + + op.create_check_constraint( + "ck_minerops_site_agents_credential_retires_legacy_auth", + "minerops_site_agents", + """ + legacy_auth_disabled_at IS NOT NULL + OR ( + agent_secret_hash IS NULL + AND credential_created_at IS NULL + AND credential_revoked_at IS NULL + ) + """, + ) + + # The retirement ledger is append-only for ordinary database operations. + # Migration rollback can still DROP the empty table, and a DBA retains the + # usual PostgreSQL superuser disaster-recovery capabilities. + op.execute( + """ + CREATE FUNCTION minerops_prevent_legacy_auth_retirement_mutation() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + RAISE EXCEPTION + 'legacy Agent authentication retirement history is append-only' + USING ERRCODE = '23514'; + END; + $$ + """ + ) + op.execute( + """ + CREATE TRIGGER trg_agent_legacy_auth_retirements_immutable + BEFORE UPDATE OR DELETE + ON agent_legacy_auth_retirements + FOR EACH ROW + EXECUTE FUNCTION minerops_prevent_legacy_auth_retirement_mutation() + """ + ) + op.execute( + """ + CREATE TRIGGER trg_agent_legacy_auth_retirements_no_truncate + BEFORE TRUNCATE + ON agent_legacy_auth_retirements + FOR EACH STATEMENT + EXECUTE FUNCTION minerops_prevent_legacy_auth_retirement_mutation() + """ + ) + + # Any database path that stores credential evidence also creates the + # identity tombstone in the same transaction. The row marker is always + # synchronized from that tombstone, including on recreation of an Agent + # row whose identity was previously retired. + op.execute( + """ + CREATE FUNCTION minerops_sync_agent_legacy_auth_retirement() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + DECLARE + identity_retired_at timestamptz; + BEGIN + IF NEW.agent_secret_hash IS NOT NULL + OR NEW.credential_created_at IS NOT NULL + OR NEW.credential_revoked_at IS NOT NULL + OR NEW.legacy_auth_disabled_at IS NOT NULL THEN + INSERT INTO agent_legacy_auth_retirements ( + agent_id, + retired_at + ) + VALUES ( + NEW.agent_id, + coalesce( + NEW.credential_created_at, + NEW.legacy_auth_disabled_at, + NEW.credential_revoked_at, + clock_timestamp() + ) + ) + ON CONFLICT (agent_id) DO NOTHING; + END IF; + + SELECT retired_at + INTO identity_retired_at + FROM agent_legacy_auth_retirements + WHERE agent_id = NEW.agent_id; + + IF identity_retired_at IS NOT NULL THEN + IF TG_OP = 'UPDATE' + AND NEW.agent_id = OLD.agent_id + AND NEW.legacy_auth_disabled_at + IS DISTINCT FROM OLD.legacy_auth_disabled_at + AND NEW.legacy_auth_disabled_at + IS DISTINCT FROM identity_retired_at THEN + RAISE EXCEPTION + 'legacy Agent authentication retirement is permanent' + USING ERRCODE = '23514'; + END IF; + NEW.legacy_auth_disabled_at := identity_retired_at; + ELSE + NEW.legacy_auth_disabled_at := NULL; + END IF; + + RETURN NEW; + END; + $$ + """ + ) + op.execute( + """ + CREATE TRIGGER trg_minerops_site_agents_legacy_auth_retirement + BEFORE INSERT OR UPDATE OF + agent_id, + agent_secret_hash, + credential_created_at, + credential_revoked_at, + legacy_auth_disabled_at + ON minerops_site_agents + FOR EACH ROW + EXECUTE FUNCTION minerops_sync_agent_legacy_auth_retirement() + """ + ) + + # Direct, one-way ledger inserts also refresh the optional row projection. + # Skip the nested case where the Agent trigger itself inserted the ledger + # row; that outer trigger already has NEW and will set the projection. + op.execute( + """ + CREATE FUNCTION minerops_project_agent_legacy_auth_retirement() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF pg_trigger_depth() = 1 THEN + UPDATE minerops_site_agents + SET legacy_auth_disabled_at = NEW.retired_at + WHERE agent_id = NEW.agent_id; + END IF; + RETURN NEW; + END; + $$ + """ + ) + op.execute( + """ + CREATE TRIGGER trg_agent_legacy_auth_retirements_project + AFTER INSERT + ON agent_legacy_auth_retirements + FOR EACH ROW + EXECUTE FUNCTION minerops_project_agent_legacy_auth_retirement() + """ + ) + + +def downgrade() -> None: + connection = op.get_bind() + retired_count = connection.execute( + sa.text( + """ + SELECT count(*) + FROM agent_legacy_auth_retirements + """ + ) + ).scalar_one() + + if retired_count: + raise RuntimeError( + "Cannot downgrade 0019: durable identity-level legacy-auth " + "retirement history exists. Restore the pre-upgrade database " + "backup rather than making retired Agent identities eligible for " + "legacy auth again." + ) + + op.execute( + """ + DROP TRIGGER trg_agent_legacy_auth_retirements_project + ON agent_legacy_auth_retirements + """ + ) + op.execute("DROP FUNCTION minerops_project_agent_legacy_auth_retirement()") + op.execute( + """ + DROP TRIGGER trg_minerops_site_agents_legacy_auth_retirement + ON minerops_site_agents + """ + ) + op.execute("DROP FUNCTION minerops_sync_agent_legacy_auth_retirement()") + op.execute( + """ + DROP TRIGGER trg_agent_legacy_auth_retirements_no_truncate + ON agent_legacy_auth_retirements + """ + ) + op.execute( + """ + DROP TRIGGER trg_agent_legacy_auth_retirements_immutable + ON agent_legacy_auth_retirements + """ + ) + op.execute( + "DROP FUNCTION minerops_prevent_legacy_auth_retirement_mutation()" + ) + op.drop_constraint( + "ck_minerops_site_agents_credential_retires_legacy_auth", + "minerops_site_agents", + type_="check", + ) + op.drop_column("minerops_site_agents", "legacy_auth_disabled_at") + op.drop_table("agent_legacy_auth_retirements") diff --git a/backend/alembic/versions/0020_existing_agent_credential_transition.py b/backend/alembic/versions/0020_existing_agent_credential_transition.py new file mode 100644 index 0000000..3f1ad85 --- /dev/null +++ b/backend/alembic/versions/0020_existing_agent_credential_transition.py @@ -0,0 +1,100 @@ +"""bind one-time credential transitions to existing Agent identities + +Revision ID: 0020_existing_agent_transition +Revises: 0019_agent_legacy_auth_retire +Create Date: 2026-09-18 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0020_existing_agent_transition" +down_revision: Union[str, None] = "0019_agent_legacy_auth_retire" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # NULL continues to mean a normal new-Agent enrollment. A non-NULL value + # is server-derived from the current Agent row and binds the one-time token + # to that exact durable identity without accepting browser routing input. + op.add_column( + "agent_enrollments", + sa.Column("target_agent_id", sa.Text(), nullable=True), + ) + op.create_foreign_key( + "fk_agent_enrollments_target_agent", + "agent_enrollments", + "minerops_site_agents", + ["target_agent_id"], + ["agent_id"], + ondelete="RESTRICT", + ) + op.create_check_constraint( + "ck_agent_enrollments_target_agent", + "agent_enrollments", + "target_agent_id IS NULL OR btrim(target_agent_id) <> ''", + ) + op.create_check_constraint( + "ck_agent_enrollments_target_result", + "agent_enrollments", + """ + target_agent_id IS NULL + OR enrolled_agent_id IS NULL + OR enrolled_agent_id = target_agent_id + """, + ) + op.create_index( + "uq_agent_enrollments_open_target_agent", + "agent_enrollments", + ["target_agent_id"], + unique=True, + postgresql_where=sa.text( + "target_agent_id IS NOT NULL " + "AND used_at IS NULL AND revoked_at IS NULL" + ), + ) + + +def downgrade() -> None: + connection = op.get_bind() + transition_count = connection.execute( + sa.text( + """ + SELECT count(*) + FROM agent_enrollments + WHERE target_agent_id IS NOT NULL + """ + ) + ).scalar_one() + + if transition_count: + raise RuntimeError( + "Cannot downgrade 0020: existing-Agent credential transition " + "history exists. Restore the pre-upgrade database backup rather " + "than discarding the identity binding." + ) + + op.drop_index( + "uq_agent_enrollments_open_target_agent", + table_name="agent_enrollments", + ) + op.drop_constraint( + "ck_agent_enrollments_target_result", + "agent_enrollments", + type_="check", + ) + op.drop_constraint( + "ck_agent_enrollments_target_agent", + "agent_enrollments", + type_="check", + ) + op.drop_constraint( + "fk_agent_enrollments_target_agent", + "agent_enrollments", + type_="foreignkey", + ) + op.drop_column("agent_enrollments", "target_agent_id") diff --git a/backend/app/agent_auth/repository.py b/backend/app/agent_auth/repository.py index e9e19df..3eaccab 100644 --- a/backend/app/agent_auth/repository.py +++ b/backend/app/agent_auth/repository.py @@ -32,12 +32,18 @@ class AgentAuthRepository: 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 + SELECT identity.agent_id, + agent.agent_secret_hash, + agent.credential_created_at, + agent.credential_revoked_at, + retirement.retired_at AS legacy_auth_disabled_at + FROM (VALUES (%s::text)) AS identity(agent_id) + LEFT JOIN minerops_site_agents AS agent + ON agent.agent_id = identity.agent_id + LEFT JOIN agent_legacy_auth_retirements AS retirement + ON retirement.agent_id = identity.agent_id + WHERE agent.agent_id IS NOT NULL + OR retirement.agent_id IS NOT NULL """, (agent_id,), ) diff --git a/backend/app/agent_auth/service.py b/backend/app/agent_auth/service.py index 2103fde..959c0d7 100644 --- a/backend/app/agent_auth/service.py +++ b/backend/app/agent_auth/service.py @@ -25,19 +25,50 @@ class AgentAuthService: 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") + # The production Alpha Agent predates the identity header. Missing-ID + # compatibility is therefore bound to exactly the configured legacy + # identity; no request value is allowed to select another Agent. + legacy_id = settings.minerops_agent_id.strip() + legacy_secret = settings.minerops_agent_shared_secret.strip() + missing_identity = not identity + if missing_identity: + if not legacy_id: + raise AgentAuthenticationUnavailable( + "Legacy Agent authentication is not configured" + ) + identity = legacy_id + credential = self.repository.get_credential(identity) + # Revocation is authoritative regardless of which credential path + # would otherwise apply. Never allow malformed or historical + # credential state to fall through to the legacy shared secret. + if credential and credential.get("credential_revoked_at") is not None: + raise AgentCredentialRevoked("Agent credential has been revoked") + + # This value comes from the identity-level retirement ledger, not the + # deletable current Agent row. It is therefore authoritative even when + # the Agent was deleted and has not yet been recreated. + legacy_auth_retired = bool( + credential + and credential.get("legacy_auth_disabled_at") is not None + ) + # Any Agent with a per-Agent credential is permanently on the new # authentication path. The legacy shared secret must never authenticate # that identity, even if the Agent later becomes revoked. if credential and credential.get("agent_secret_hash"): - if credential.get("credential_revoked_at") is not None: - raise AgentCredentialRevoked("Agent credential has been revoked") + # Once the configured legacy identity has ever received a + # per-Agent credential, secret-only fallback is permanently + # disabled. The legacy global secret cannot authenticate it and a + # per-Agent secret cannot be used without an explicit identity. + if missing_identity: + raise AgentAuthenticationFailed( + "Explicit Agent identity is required for enrolled Agents" + ) supplied_hash = hash_agent_secret(secret) if not hmac.compare_digest( @@ -48,10 +79,15 @@ class AgentAuthService: 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() + # The durable identity retirement survives Agent-row deletion, + # credential-field corruption, administrative mistakes, and future + # credential rotation workflows. + if legacy_auth_retired: + raise AgentAuthenticationFailed( + "Legacy Agent authentication has been permanently disabled" + ) + # Temporary compatibility for the single pre-enrollment Agent. if not legacy_id or not legacy_secret: raise AgentAuthenticationUnavailable( "Legacy Agent authentication is not configured" diff --git a/backend/app/agent_enrollment/models.py b/backend/app/agent_enrollment/models.py index a938b8c..20cbc06 100644 --- a/backend/app/agent_enrollment/models.py +++ b/backend/app/agent_enrollment/models.py @@ -53,7 +53,9 @@ class AgentEnrollmentSummary(BaseModel): expires_at: datetime used_at: datetime | None = None revoked_at: datetime | None = None + target_agent_id: str | None = None enrolled_agent_id: str | None = None + purpose: Literal["new_agent", "existing_agent_credential_transition"] status: Literal["waiting", "used", "revoked", "expired"] @@ -96,3 +98,56 @@ class AgentEnrollResponse(BaseModel): class AgentWindowsPackageRequest(AgentEnrollRequest): """Short-lived token used to construct a Windows bootstrap package.""" + + +class AgentCredentialTransitionCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + agent_id: str = Field(min_length=1, max_length=255) + + @field_validator("agent_id") + @classmethod + def normalize_agent_id(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("agent_id cannot be blank") + return normalized + + +class AgentCredentialTransitionCreated(BaseModel): + id: UUID + target_agent_id: str + site_id: UUID + site_name: str + agent_zone_id: UUID + agent_zone_name: str + display_name: str + created_at: datetime + expires_at: datetime + transition_token: str + + +class AgentCredentialTransitionCreateResponse(BaseModel): + data: AgentCredentialTransitionCreated + meta: dict[str, object] = Field(default_factory=dict) + error: None = None + + +class AgentCredentialTransitionRedeemRequest(AgentEnrollRequest): + agent_secret_hash: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class AgentCredentialTransitionCredentials(BaseModel): + agent_id: str + site_id: UUID + site_name: str + agent_zone_id: UUID + agent_zone_name: str + display_name: str + ip_ranges: list[str] = Field(default_factory=list) + + +class AgentCredentialTransitionResponse(BaseModel): + data: AgentCredentialTransitionCredentials + meta: dict[str, object] = Field(default_factory=dict) + error: None = None diff --git a/backend/app/agent_enrollment/repository.py b/backend/app/agent_enrollment/repository.py index 4861ef5..d36e055 100644 --- a/backend/app/agent_enrollment/repository.py +++ b/backend/app/agent_enrollment/repository.py @@ -1,5 +1,6 @@ """PostgreSQL persistence for secure Agent enrollment.""" +import hmac from collections.abc import Callable from uuid import UUID @@ -21,6 +22,7 @@ ConnectionFactory = Callable[[], psycopg.Connection] TOKEN_HASH_CONSTRAINT = "uq_agent_enrollments_token_hash" OPEN_ZONE_CONSTRAINT = "uq_agent_enrollments_open_zone" +OPEN_TARGET_CONSTRAINT = "uq_agent_enrollments_open_target_agent" AGENT_SECRET_HASH_CONSTRAINT = "uq_minerops_site_agents_agent_secret_hash" AGENT_ZONE_CONSTRAINT = "uq_minerops_site_agents_agent_zone_id" AGENT_ID_CONSTRAINT = "minerops_site_agents_pkey" @@ -214,6 +216,184 @@ class AgentEnrollmentRepository: finally: connection.close() + def create_credential_transition( + self, + *, + token_hash: str, + target_agent_id: str, + actor_user_id: UUID, + ) -> dict: + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT + agent.agent_id, + agent.site_id, + agent.agent_zone_id, + agent.display_name, + agent.agent_secret_hash, + agent.credential_created_at, + agent.credential_revoked_at, + agent.legacy_auth_disabled_at, + site.name AS site_name, + zone.name AS agent_zone_name, + zone.is_enabled AS agent_zone_enabled, + retirement.retired_at + FROM minerops_site_agents AS agent + JOIN sites AS site ON site.id = agent.site_id + JOIN agent_zones AS zone + ON zone.id = agent.agent_zone_id + AND zone.site_id = agent.site_id + LEFT JOIN agent_legacy_auth_retirements AS retirement + ON retirement.agent_id = agent.agent_id + WHERE agent.agent_id = %s + FOR UPDATE OF agent, zone + """, + (target_agent_id,), + ) + agent = cur.fetchone() + + if not agent: + raise AgentEnrollmentNotFound( + "Existing Agent with durable Site and Zone routing was not found" + ) + + if agent["agent_zone_enabled"] is not True: + raise AgentEnrollmentConflict("Agent Zone is disabled") + + if any( + agent[field] is not None + for field in ( + "agent_secret_hash", + "credential_created_at", + "credential_revoked_at", + "legacy_auth_disabled_at", + "retired_at", + ) + ): + raise AgentEnrollmentConflict( + "Existing Agent is not eligible for legacy credential transition" + ) + + cur.execute("SELECT clock_timestamp() AS operation_now") + operation_now = cur.fetchone()["operation_now"] + + cur.execute( + """ + UPDATE agent_enrollments + SET revoked_at = %s + WHERE agent_zone_id = %s + AND used_at IS NULL + AND revoked_at IS NULL + RETURNING id + """, + (operation_now, agent["agent_zone_id"]), + ) + replaced_ids = [str(row["id"]) for row in cur.fetchall()] + + display_name = ( + str(agent["display_name"] or agent["agent_id"]).strip()[:120] + ) + cur.execute( + """ + INSERT INTO agent_enrollments ( + token_hash, + site_id, + agent_zone_id, + display_name, + created_by_user_id, + created_at, + expires_at, + target_agent_id + ) + VALUES (%s, %s, %s, %s, %s, %s, %s + interval '30 minutes', %s) + RETURNING + id, + site_id, + agent_zone_id, + display_name, + created_at, + expires_at, + target_agent_id + """, + ( + token_hash, + agent["site_id"], + agent["agent_zone_id"], + display_name, + actor_user_id, + operation_now, + operation_now, + agent["agent_id"], + ), + ) + transition = dict(cur.fetchone()) + transition["site_name"] = agent["site_name"] + transition["agent_zone_name"] = agent["agent_zone_name"] + + cur.execute( + """ + INSERT INTO audit_logs ( + actor_user_id, + action, + entity_type, + entity_id, + new_value_json, + created_at + ) + VALUES ( + %s, + 'agent.credential_transition.created', + 'agent_enrollment', + %s, + %s, + %s + ) + """, + ( + actor_user_id, + transition["id"], + Jsonb( + { + "target_agent_id": agent["agent_id"], + "site_id": str(agent["site_id"]), + "agent_zone_id": str(agent["agent_zone_id"]), + "expires_at": transition["expires_at"].isoformat(), + "replaced_enrollment_ids": replaced_ids, + } + ), + operation_now, + ), + ) + + connection.commit() + return transition + + except UniqueViolation as exc: + connection.rollback() + constraint = exc.diag.constraint_name + + if constraint == TOKEN_HASH_CONSTRAINT: + raise AgentEnrollmentCredentialCollision( + "Generated transition token collided" + ) from exc + + if constraint in {OPEN_ZONE_CONSTRAINT, OPEN_TARGET_CONSTRAINT}: + raise AgentEnrollmentConflict( + "An active credential transition already exists for this Agent" + ) from exc + + raise + + except Exception: + connection.rollback() + raise + + finally: + connection.close() + def list_enrollments( self, *, @@ -238,7 +418,13 @@ class AgentEnrollmentRepository: enrollment.expires_at, enrollment.used_at, enrollment.revoked_at, + enrollment.target_agent_id, enrollment.enrolled_agent_id, + CASE + WHEN enrollment.target_agent_id IS NULL + THEN 'new_agent' + ELSE 'existing_agent_credential_transition' + END AS purpose, CASE WHEN enrollment.used_at IS NOT NULL THEN 'used' @@ -292,7 +478,8 @@ class AgentEnrollmentRepository: display_name, expires_at, used_at, - revoked_at + revoked_at, + target_agent_id FROM agent_enrollments WHERE token_hash = %s FOR UPDATE @@ -309,6 +496,7 @@ class AgentEnrollmentRepository: if ( enrollment["used_at"] is not None or enrollment["revoked_at"] is not None + or enrollment["target_agent_id"] is not None ): raise AgentEnrollmentTokenInvalid( "Invalid or expired enrollment token" @@ -371,6 +559,22 @@ class AgentEnrollmentRepository: "Invalid or expired enrollment token" ) + # Retire the identity before storing its credential. Both + # writes use this transaction, so a failed issuance cannot + # leave a new misleading tombstone. ON CONFLICT preserves an + # earlier retirement if this identity has credential history. + cur.execute( + """ + INSERT INTO agent_legacy_auth_retirements ( + agent_id, + retired_at + ) + VALUES (%s, %s) + ON CONFLICT (agent_id) DO NOTHING + """, + (agent_id, operation_now), + ) + cur.execute( """ INSERT INTO minerops_site_agents ( @@ -380,7 +584,8 @@ class AgentEnrollmentRepository: site_name, display_name, agent_secret_hash, - credential_created_at + credential_created_at, + legacy_auth_disabled_at ) VALUES ( %s, @@ -389,6 +594,7 @@ class AgentEnrollmentRepository: %s, %s, %s, + %s, %s ) """, @@ -400,6 +606,7 @@ class AgentEnrollmentRepository: enrollment["display_name"], agent_secret_hash, operation_now, + operation_now, ), ) @@ -504,3 +711,258 @@ class AgentEnrollmentRepository: finally: connection.close() + + def redeem_credential_transition( + self, + *, + token_hash: str, + agent_secret_hash: str, + ) -> dict: + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT + id, + site_id, + agent_zone_id, + display_name, + expires_at, + used_at, + revoked_at, + target_agent_id, + enrolled_agent_id + FROM agent_enrollments + WHERE token_hash = %s + FOR UPDATE + """, + (token_hash,), + ) + enrollment = cur.fetchone() + + if ( + not enrollment + or enrollment["target_agent_id"] is None + or enrollment["revoked_at"] is not None + ): + raise AgentEnrollmentTokenInvalid( + "Invalid or expired credential transition token" + ) + + cur.execute( + """ + SELECT + agent.agent_id, + agent.site_id, + agent.agent_zone_id, + agent.display_name, + agent.agent_secret_hash, + agent.credential_created_at, + agent.credential_revoked_at, + agent.legacy_auth_disabled_at, + site.name AS site_name, + zone.name AS agent_zone_name, + zone.is_enabled AS agent_zone_enabled, + retirement.retired_at + FROM minerops_site_agents AS agent + JOIN sites AS site ON site.id = agent.site_id + JOIN agent_zones AS zone + ON zone.id = agent.agent_zone_id + AND zone.site_id = agent.site_id + LEFT JOIN agent_legacy_auth_retirements AS retirement + ON retirement.agent_id = agent.agent_id + WHERE agent.agent_id = %s + FOR UPDATE OF agent, zone + """, + (enrollment["target_agent_id"],), + ) + agent = cur.fetchone() + + if not agent: + raise AgentEnrollmentConflict( + "Credential transition target no longer exists with durable routing" + ) + + if ( + agent["site_id"] != enrollment["site_id"] + or agent["agent_zone_id"] != enrollment["agent_zone_id"] + ): + raise AgentEnrollmentConflict( + "Existing Agent routing changed after transition authorization" + ) + + if agent["agent_zone_enabled"] is not True: + raise AgentEnrollmentConflict("Agent Zone is disabled") + + def transition_result() -> dict: + cur.execute( + """ + SELECT network::text AS network + FROM agent_zone_network_ranges + WHERE agent_zone_id = %s + ORDER BY network, id + """, + (enrollment["agent_zone_id"],), + ) + return { + "agent_id": agent["agent_id"], + "site_id": agent["site_id"], + "site_name": agent["site_name"], + "agent_zone_id": agent["agent_zone_id"], + "agent_zone_name": agent["agent_zone_name"], + "display_name": str( + agent["display_name"] or enrollment["display_name"] + ), + "ip_ranges": [row["network"] for row in cur.fetchall()], + } + + # A retry after an ambiguous network response is successful only + # for the exact locally generated credential that won the first + # transaction. It never rotates or reveals a credential. + if enrollment["used_at"] is not None: + stored_hash = str(agent["agent_secret_hash"] or "") + if ( + enrollment["enrolled_agent_id"] + == enrollment["target_agent_id"] + and stored_hash + and hmac.compare_digest(stored_hash, agent_secret_hash) + and agent["credential_created_at"] is not None + and agent["credential_revoked_at"] is None + and agent["legacy_auth_disabled_at"] is not None + and agent["retired_at"] is not None + ): + result = transition_result() + connection.commit() + return result + + raise AgentEnrollmentTokenInvalid( + "Credential transition token has already been consumed" + ) + + cur.execute("SELECT clock_timestamp() AS operation_now") + operation_now = cur.fetchone()["operation_now"] + + if enrollment["expires_at"] <= operation_now: + raise AgentEnrollmentTokenInvalid( + "Invalid or expired credential transition token" + ) + + if any( + agent[field] is not None + for field in ( + "agent_secret_hash", + "credential_created_at", + "credential_revoked_at", + "legacy_auth_disabled_at", + "retired_at", + ) + ): + raise AgentEnrollmentConflict( + "Existing Agent is no longer eligible for credential transition" + ) + + # The durable retirement, Agent credential, enrollment terminal + # state, and audit record commit together. Any later failure + # rolls all of them back. + cur.execute( + """ + INSERT INTO agent_legacy_auth_retirements ( + agent_id, + retired_at + ) + VALUES (%s, %s) + """, + (agent["agent_id"], operation_now), + ) + cur.execute( + """ + UPDATE minerops_site_agents + SET agent_secret_hash = %s, + credential_created_at = %s, + legacy_auth_disabled_at = %s + WHERE agent_id = %s + RETURNING agent_id + """, + ( + agent_secret_hash, + operation_now, + operation_now, + agent["agent_id"], + ), + ) + if not cur.fetchone(): + raise AgentEnrollmentConflict( + "Credential transition target disappeared" + ) + + cur.execute( + """ + UPDATE agent_enrollments + SET used_at = %s, + enrolled_agent_id = target_agent_id + WHERE id = %s + AND used_at IS NULL + AND revoked_at IS NULL + RETURNING used_at, enrolled_agent_id + """, + (operation_now, enrollment["id"]), + ) + completed = cur.fetchone() + if not completed: + raise AgentEnrollmentConflict( + "Credential transition state changed concurrently" + ) + + cur.execute( + """ + INSERT INTO audit_logs ( + actor_user_id, + action, + entity_type, + entity_id, + new_value_json, + created_at + ) + VALUES ( + NULL, + 'agent.credential_transition.redeemed', + 'agent_enrollment', + %s, + %s, + %s + ) + """, + ( + enrollment["id"], + Jsonb( + { + "agent_id": agent["agent_id"], + "site_id": str(agent["site_id"]), + "agent_zone_id": str(agent["agent_zone_id"]), + "used_at": completed["used_at"].isoformat(), + } + ), + operation_now, + ), + ) + + result = transition_result() + + connection.commit() + return result + + except UniqueViolation as exc: + connection.rollback() + if exc.diag.constraint_name == AGENT_SECRET_HASH_CONSTRAINT: + raise AgentEnrollmentCredentialCollision( + "Agent credential is already in use" + ) from exc + raise + + except Exception: + connection.rollback() + raise + + finally: + connection.close() diff --git a/backend/app/agent_enrollment/router.py b/backend/app/agent_enrollment/router.py index 087c58a..b1337d5 100644 --- a/backend/app/agent_enrollment/router.py +++ b/backend/app/agent_enrollment/router.py @@ -8,10 +8,15 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, from app.agent_enrollment.dependencies import get_agent_enrollment_service from app.agent_enrollment.exceptions import ( AgentEnrollmentConflict, + AgentEnrollmentCredentialCollision, AgentEnrollmentNotFound, AgentEnrollmentTokenInvalid, ) from app.agent_enrollment.models import ( + AgentCredentialTransitionCreateRequest, + AgentCredentialTransitionCreateResponse, + AgentCredentialTransitionRedeemRequest, + AgentCredentialTransitionResponse, AgentEnrollmentCreateRequest, AgentEnrollmentCreateResponse, AgentEnrollmentListResponse, @@ -70,6 +75,31 @@ def create_agent_enrollment( raise HTTPException(status_code=409, detail=str(exc)) from exc +@router.post( + "/api/agent-credential-transitions", + response_model=AgentCredentialTransitionCreateResponse, + status_code=status.HTTP_201_CREATED, +) +def create_agent_credential_transition( + request: AgentCredentialTransitionCreateRequest, + response: Response, + current_user: EnrollmentManager, + service: EnrollmentService, +) -> AgentCredentialTransitionCreateResponse: + response.headers["Cache-Control"] = "no-store" + + try: + transition = service.create_credential_transition( + request, + actor_user_id=UUID(str(current_user["id"])), + ) + return AgentCredentialTransitionCreateResponse(data=transition) + except AgentEnrollmentNotFound as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except AgentEnrollmentConflict as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + @router.get( "/api/agent-enrollments", response_model=AgentEnrollmentListResponse, @@ -157,3 +187,27 @@ def enroll_agent( ) from exc except AgentEnrollmentConflict as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.post( + "/api/agent/credential-transition", + response_model=AgentCredentialTransitionResponse, +) +def transition_agent_credential( + request: AgentCredentialTransitionRedeemRequest, + response: Response, + service: EnrollmentService, +) -> AgentCredentialTransitionResponse: + response.headers["Cache-Control"] = "no-store" + + try: + return AgentCredentialTransitionResponse( + data=service.redeem_credential_transition(request) + ) + except AgentEnrollmentTokenInvalid as exc: + raise HTTPException( + status_code=401, + detail="Invalid or expired credential transition token", + ) from exc + except (AgentEnrollmentConflict, AgentEnrollmentCredentialCollision) as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc diff --git a/backend/app/agent_enrollment/service.py b/backend/app/agent_enrollment/service.py index 0bc0192..525182c 100644 --- a/backend/app/agent_enrollment/service.py +++ b/backend/app/agent_enrollment/service.py @@ -9,6 +9,8 @@ from app.agent_enrollment.exceptions import ( AgentEnrollmentCredentialCollision, ) from app.agent_enrollment.models import ( + AgentCredentialTransitionCreateRequest, + AgentCredentialTransitionRedeemRequest, AgentEnrollmentCreateRequest, AgentEnrollRequest, ) @@ -73,6 +75,31 @@ class AgentEnrollmentService: limit=limit, ) + def create_credential_transition( + self, + request: AgentCredentialTransitionCreateRequest, + *, + actor_user_id: UUID, + ) -> dict: + for _ in range(MAX_GENERATION_ATTEMPTS): + token = generate_enrollment_token() + try: + transition = self.repository.create_credential_transition( + token_hash=hash_agent_secret(token), + target_agent_id=request.agent_id, + actor_user_id=actor_user_id, + ) + return { + **transition, + "transition_token": token, + } + except AgentEnrollmentCredentialCollision: + continue + + raise AgentEnrollmentConflict( + "Unable to generate a unique credential transition token" + ) + def redeem(self, request: AgentEnrollRequest) -> dict: token_hash = hash_agent_secret(request.token) @@ -96,3 +123,12 @@ class AgentEnrollmentService: raise AgentEnrollmentConflict( "Unable to generate unique Agent credentials" ) + + def redeem_credential_transition( + self, + request: AgentCredentialTransitionRedeemRequest, + ) -> dict: + return self.repository.redeem_credential_transition( + token_hash=hash_agent_secret(request.token), + agent_secret_hash=request.agent_secret_hash, + ) diff --git a/backend/app/agent_registry/models.py b/backend/app/agent_registry/models.py index ea5eec3..c00574a 100644 --- a/backend/app/agent_registry/models.py +++ b/backend/app/agent_registry/models.py @@ -7,7 +7,7 @@ from uuid import UUID from pydantic import BaseModel, Field -CredentialState = Literal["legacy", "active", "revoked"] +CredentialState = Literal["legacy", "active", "revoked", "retired"] RoutingState = Literal["assigned", "unassigned", "disabled"] diff --git a/backend/app/agent_registry/repository.py b/backend/app/agent_registry/repository.py index 4cd37a4..7dead3b 100644 --- a/backend/app/agent_registry/repository.py +++ b/backend/app/agent_registry/repository.py @@ -103,9 +103,13 @@ class AgentRegistryRepository: THEN 'revoked' WHEN agent.agent_secret_hash IS NOT NULL THEN 'active' + WHEN retirement.retired_at IS NOT NULL + THEN 'retired' ELSE 'legacy' END AS credential_state FROM minerops_site_agents AS agent + LEFT JOIN agent_legacy_auth_retirements AS retirement + ON retirement.agent_id = agent.agent_id LEFT JOIN sites AS site ON site.id = agent.site_id LEFT JOIN agent_zones AS zone diff --git a/backend/app/agent_zones/repository.py b/backend/app/agent_zones/repository.py index afc34d7..8c3680a 100644 --- a/backend/app/agent_zones/repository.py +++ b/backend/app/agent_zones/repository.py @@ -250,6 +250,50 @@ class AgentZoneRepository: finally: connection.close() + def get_agent_configuration(self, agent_id: str) -> dict | None: + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT + agent.agent_id, + agent.site_id, + agent.agent_zone_id, + coalesce(site.name, agent.site_name) AS site_name, + zone.name AS agent_zone_name, + zone.is_enabled AS agent_zone_enabled, + coalesce( + array_agg( + network.network::text + ORDER BY network.network::text, network.id + ) FILTER (WHERE network.id IS NOT NULL), + ARRAY[]::text[] + ) AS networks + FROM minerops_site_agents AS agent + LEFT JOIN sites AS site + ON site.id = agent.site_id + LEFT JOIN agent_zones AS zone + ON zone.id = agent.agent_zone_id + LEFT JOIN agent_zone_network_ranges AS network + ON network.agent_zone_id = zone.id + WHERE agent.agent_id = %s + GROUP BY + agent.agent_id, + agent.site_id, + agent.agent_zone_id, + site.name, + agent.site_name, + zone.name, + zone.is_enabled + """, + (agent_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + finally: + connection.close() + def assign_miners_to_zone( self, miner_ids: Iterable[UUID], agent_zone_id: UUID ) -> dict[UUID, str]: diff --git a/backend/app/agent_zones/service.py b/backend/app/agent_zones/service.py index 01cd57b..5b1685a 100644 --- a/backend/app/agent_zones/service.py +++ b/backend/app/agent_zones/service.py @@ -177,6 +177,28 @@ class AgentZoneService: site_id=assignment.get("site_id"), agent_zone_id=zone_id ) + def configuration(self, agent_id: str) -> dict[str, Any] | None: + row = self.repository.get_agent_configuration(agent_id) + if not row: + return None + return { + "agent_id": row["agent_id"], + "site_id": row["site_id"], + "site_name": row["site_name"], + "agent_zone_id": row["agent_zone_id"], + "agent_zone_name": row["agent_zone_name"], + "ip_ranges": [ + { + "name": row["agent_zone_name"], + "cidr": network, + "enabled": row["agent_zone_enabled"] is True, + "api_port": 4028, + "api3_port": 4433, + } + for network in row.get("networks") or [] + ], + } + def associate_miners_from_agent( self, agent_id: str, miner_ids: list[UUID] ) -> MinerZoneAssociationResult: diff --git a/backend/app/identity.py b/backend/app/identity.py index c470bec..552fde8 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -700,6 +700,7 @@ def required_page_permission(path: str) -> str: def machine_agent_request(path: str) -> bool: exact = { "/api/agent/enroll", + "/api/agent/credential-transition", "/api/agent/checkin", "/api/agent/config", "/api/agent/known-miners", @@ -787,6 +788,8 @@ def required_api_permission(method: str, path: str) -> str | None: return "agents.view" if method == "GET" else "agents.manage" if path == "/api/agent-enrollments/windows-package": return "agents.manage" + if path == "/api/agent-credential-transitions": + return "agents.manage" if 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 ef76a33..a27e930 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -25102,37 +25102,30 @@ async def startup_ensure_minerops_agent_tables(): @app.get("/api/agent/config") -async def minerops_agent_config(request: Request): - minerops_require_agent_secret(request) +async def minerops_agent_config( + request: Request, + authenticated_agent_id: str = Depends(require_agent_identity), + zone_service: AgentZoneService = Depends(get_agent_zone_service), +): + routing = zone_service.routing_context(authenticated_agent_id) + if routing.routing_reason or routing.agent_zone_id is None: + raise HTTPException( + status_code=409, + detail=( + "Agent routing unavailable: " + + (routing.routing_reason or "agent_zone_unassigned") + ), + ) + + configuration = zone_service.configuration(authenticated_agent_id) + if not configuration: + raise HTTPException(status_code=409, detail="Agent configuration unavailable") return { "ok": True, - "site_name": "Plexus", + **configuration, "poll_interval_seconds": 120, - "hub_url": "https://plexus.lussier.cc", - "ip_ranges": [ - { - "name": "Unit 4", - "cidr": "10.30.0.0/22", - "enabled": True, - "api_port": 4028, - "api3_port": 4433, - }, - { - "name": "Unit 6", - "cidr": "10.80.0.0/22", - "enabled": True, - "api_port": 4028, - "api3_port": 4433, - }, - { - "name": "Unit 12", - "cidr": "10.90.0.0/22", - "enabled": False, - "api_port": 4028, - "api3_port": 4433, - }, - ], + "hub_url": str(request.base_url).rstrip("/"), "message": "Agent config from hub.", } diff --git a/backend/tests/test_agent_auth.py b/backend/tests/test_agent_auth.py index 26f4dc1..619c880 100644 --- a/backend/tests/test_agent_auth.py +++ b/backend/tests/test_agent_auth.py @@ -49,6 +49,14 @@ class AgentAuthServiceTests(unittest.TestCase): "legacy-agent", ) + def test_secret_only_legacy_agent_maps_to_configured_identity(self): + self.assertEqual( + self.service().authenticate(None, "legacy-secret"), + "legacy-agent", + ) + with self.assertRaises(AgentAuthenticationFailed): + self.service().authenticate(None, "wrong-secret") + def test_legacy_agent_with_null_credential_still_authenticates(self): rows = { "legacy-agent": { @@ -116,13 +124,83 @@ class AgentAuthServiceTests(unittest.TestCase): with self.subTest(secret=secret), self.assertRaises(AgentCredentialRevoked): self.service(rows).authenticate("agent-new", secret) + def test_revoked_registry_row_never_falls_back_even_without_hash(self): + rows = { + "legacy-agent": { + "agent_id": "legacy-agent", + "agent_secret_hash": None, + "credential_created_at": None, + "credential_revoked_at": NOW, + } + } + for agent_id in ("legacy-agent", None): + with self.subTest(agent_id=agent_id), self.assertRaises( + AgentCredentialRevoked + ): + self.service(rows).authenticate(agent_id, "legacy-secret") + + def test_missing_identity_never_authenticates_enrolled_legacy_agent(self): + rows = { + "legacy-agent": { + "agent_id": "legacy-agent", + "agent_secret_hash": hash_agent_secret("unique-secret"), + "credential_created_at": NOW, + "credential_revoked_at": None, + } + } + for secret in ("unique-secret", "legacy-secret"): + with self.subTest(secret=secret), self.assertRaises(AgentAuthenticationFailed): + self.service(rows).authenticate(None, secret) + + def test_missing_identity_reports_revoked_legacy_credential(self): + rows = { + "legacy-agent": { + "agent_id": "legacy-agent", + "agent_secret_hash": hash_agent_secret("unique-secret"), + "credential_created_at": NOW, + "credential_revoked_at": NOW, + } + } + with self.assertRaises(AgentCredentialRevoked): + self.service(rows).authenticate(None, "legacy-secret") + + def test_durable_retirement_rejects_fallback_after_credentials_are_cleared(self): + rows = { + "legacy-agent": { + "agent_id": "legacy-agent", + "agent_secret_hash": None, + "credential_created_at": None, + "credential_revoked_at": None, + "legacy_auth_disabled_at": NOW, + } + } + for agent_id in ("legacy-agent", None): + with self.subTest(agent_id=agent_id), self.assertRaises( + AgentAuthenticationFailed + ): + self.service(rows).authenticate(agent_id, "legacy-secret") + + def test_identity_retirement_rejects_fallback_without_current_agent_row(self): + rows = { + "legacy-agent": { + "agent_id": "legacy-agent", + "agent_secret_hash": None, + "credential_created_at": None, + "credential_revoked_at": None, + "legacy_auth_disabled_at": NOW, + } + } + for agent_id in ("legacy-agent", None): + with self.subTest(agent_id=agent_id), self.assertRaises( + AgentAuthenticationFailed + ): + self.service(rows).authenticate(agent_id, "legacy-secret") + def test_unknown_identity_does_not_use_shared_secret(self): with self.assertRaises(AgentIdentityUnauthorized): self.service().authenticate("unknown-agent", "legacy-secret") - def test_missing_identity_or_secret_is_rejected(self): - with self.assertRaises(AgentAuthenticationFailed): - self.service().authenticate(None, "legacy-secret") + def test_missing_secret_is_rejected(self): with self.assertRaises(AgentAuthenticationFailed): self.service().authenticate("legacy-agent", None) @@ -131,6 +209,11 @@ class AgentAuthServiceTests(unittest.TestCase): with self.assertRaises(AgentAuthenticationUnavailable): self.service().authenticate("legacy-agent", "anything") + def test_secret_only_path_requires_configured_legacy_identity(self): + settings.minerops_agent_id = "" + with self.assertRaises(AgentAuthenticationUnavailable): + self.service().authenticate(None, "legacy-secret") + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_agent_credential_transition_postgres.py b/backend/tests/test_agent_credential_transition_postgres.py new file mode 100644 index 0000000..525c40c --- /dev/null +++ b/backend/tests/test_agent_credential_transition_postgres.py @@ -0,0 +1,584 @@ +"""PostgreSQL security contract for existing-Agent credential transition.""" + +from concurrent.futures import ThreadPoolExecutor +import os +import unittest +from uuid import UUID, uuid4 + +from pydantic import ValidationError +from psycopg.rows import dict_row + +from app.agent_auth.exceptions import AgentAuthenticationFailed +from app.agent_auth.repository import AgentAuthRepository +from app.agent_auth.service import AgentAuthService, hash_agent_secret +from app.agent_enrollment.exceptions import ( + AgentEnrollmentConflict, + AgentEnrollmentCredentialCollision, + AgentEnrollmentTokenInvalid, +) +from app.agent_enrollment.models import ( + AgentCredentialTransitionCreateRequest, + AgentCredentialTransitionRedeemRequest, + AgentEnrollmentCreateRequest, + AgentEnrollRequest, +) +from app.agent_enrollment.repository import ( + AgentEnrollmentRepository, + default_connection_factory, +) +from app.agent_enrollment.service import AgentEnrollmentService +from app.agent_registry.repository import AgentRegistryRepository +from app.config import settings + + +PRODUCTION_AGENT_ID = "plexus-windows-agent" +PRODUCTION_SITE_ID = UUID("00000000-0000-4000-8000-000000002001") +PRODUCTION_ZONE_ID = UUID("00000000-0000-4000-8000-000000002002") +PRODUCTION_MINER_ID = UUID("00000000-0000-4000-8000-000000002003") + + +def connect(): + return default_connection_factory() + + +class AgentCredentialTransitionPostgresTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + if os.environ.get("MINEROPS_CREDENTIAL_TRANSITION_DISPOSABLE") != "1": + raise unittest.SkipTest( + "credential transition requires its disposable PostgreSQL runner" + ) + if settings.minerops_agent_id != PRODUCTION_AGENT_ID: + raise AssertionError( + "runner must bind the production-shaped legacy Agent identity" + ) + cls.service = AgentEnrollmentService(AgentEnrollmentRepository(connect)) + cls.auth = AgentAuthService(AgentAuthRepository(connect)) + + def create_legacy_agent( + self, + agent_id, + *, + site_id=None, + zone_id=None, + enabled=True, + secret_hash=None, + revoked=False, + ): + site_id = site_id or uuid4() + zone_id = zone_id or uuid4() + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + "INSERT INTO sites (id, name) VALUES (%s, %s)", + (site_id, f"Transition Site {site_id.hex[:8]}"), + ) + cur.execute( + """ + INSERT INTO agent_zones (id, site_id, name, is_enabled) + VALUES (%s, %s, %s, %s) + """, + (zone_id, site_id, "Default Zone", enabled), + ) + cur.execute( + """ + INSERT INTO agent_zone_network_ranges (agent_zone_id, network) + VALUES (%s, '10.220.0.0/24'::cidr) + """, + (zone_id,), + ) + cur.execute( + """ + INSERT INTO minerops_site_agents ( + agent_id, site_id, agent_zone_id, site_name, + display_name, agent_secret_hash, + credential_created_at, credential_revoked_at + ) + VALUES ( + %s, %s, %s, %s, %s, %s, + CASE WHEN %s::boolean THEN now() ELSE NULL END, + CASE WHEN %s::boolean THEN now() ELSE NULL END + ) + """, + ( + agent_id, + site_id, + zone_id, + f"Transition Site {site_id.hex[:8]}", + f"Agent {agent_id}"[:120], + secret_hash, + secret_hash is not None, + revoked, + ), + ) + connection.commit() + finally: + connection.close() + return site_id, zone_id + + def create_transition(self, agent_id): + return self.service.create_credential_transition( + AgentCredentialTransitionCreateRequest(agent_id=agent_id), + actor_user_id=None, + ) + + def redeem_transition(self, token, secret): + return AgentEnrollmentService( + AgentEnrollmentRepository(connect) + ).redeem_credential_transition( + AgentCredentialTransitionRedeemRequest( + token=token, + agent_secret_hash=hash_agent_secret(secret), + ) + ) + + def agent_row(self, agent_id): + connection = connect() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT agent_id, site_id, agent_zone_id, agent_secret_hash, + credential_created_at, credential_revoked_at, + legacy_auth_disabled_at + FROM minerops_site_agents + WHERE agent_id = %s + """, + (agent_id,), + ) + return cur.fetchone() + finally: + connection.close() + + def test_01_production_identity_transitions_without_routing_change(self): + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + """ + INSERT INTO sites (id, name) + VALUES (%s, 'Plexus Mining Site') + """, + (PRODUCTION_SITE_ID,), + ) + cur.execute( + """ + INSERT INTO agent_zones (id, site_id, name, is_enabled) + VALUES (%s, %s, 'Default Zone', true) + """, + (PRODUCTION_ZONE_ID, PRODUCTION_SITE_ID), + ) + cur.execute( + """ + INSERT INTO agent_zone_network_ranges (agent_zone_id, network) + VALUES (%s, '192.0.2.0/24'::cidr) + """, + (PRODUCTION_ZONE_ID,), + ) + cur.execute( + """ + INSERT INTO minerops_site_agents ( + agent_id, site_id, agent_zone_id, site_name, display_name + ) + VALUES (%s, %s, %s, 'Plexus', 'Plexus Windows Agent') + """, + ( + PRODUCTION_AGENT_ID, + PRODUCTION_SITE_ID, + PRODUCTION_ZONE_ID, + ), + ) + cur.execute( + """ + INSERT INTO miners ( + id, site_id, agent_zone_id, asset_tag, + current_ip, current_mac, status + ) + VALUES ( + %s, %s, %s, 'TRANSITION-PRODUCTION-MINER', + '192.0.2.20', '02:00:00:00:20:03', 'online' + ) + """, + ( + PRODUCTION_MINER_ID, + PRODUCTION_SITE_ID, + PRODUCTION_ZONE_ID, + ), + ) + connection.commit() + finally: + connection.close() + + legacy_secret = settings.minerops_agent_shared_secret + self.assertEqual( + self.auth.authenticate(None, legacy_secret), + PRODUCTION_AGENT_ID, + ) + + transition = self.create_transition(PRODUCTION_AGENT_ID) + token = transition["transition_token"] + self.assertEqual(transition["target_agent_id"], PRODUCTION_AGENT_ID) + self.assertEqual(transition["site_id"], PRODUCTION_SITE_ID) + self.assertEqual(transition["agent_zone_id"], PRODUCTION_ZONE_ID) + + connection = connect() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT token_hash, target_agent_id, used_at, + enrolled_agent_id, site_id, agent_zone_id + FROM agent_enrollments WHERE id = %s + """, + (transition["id"],), + ) + authorization = cur.fetchone() + finally: + connection.close() + self.assertEqual(authorization["token_hash"], hash_agent_secret(token)) + self.assertNotEqual(authorization["token_hash"], token) + self.assertEqual(authorization["target_agent_id"], PRODUCTION_AGENT_ID) + self.assertIsNone(authorization["used_at"]) + + new_secret = "production-shaped-per-agent-secret" + result = self.redeem_transition(token, new_secret) + self.assertEqual(result["agent_id"], PRODUCTION_AGENT_ID) + self.assertNotIn("agent_secret", result) + self.assertEqual(result["site_id"], PRODUCTION_SITE_ID) + self.assertEqual(result["agent_zone_id"], PRODUCTION_ZONE_ID) + + # An ambiguous response is safely retryable only with the same locally + # generated credential; no second transition or audit event is created. + retried = self.redeem_transition(token, new_secret) + self.assertEqual(retried, result) + with self.assertRaises(AgentEnrollmentTokenInvalid): + self.redeem_transition(token, "different-secret-cannot-rotate") + + self.assertEqual( + self.auth.authenticate(PRODUCTION_AGENT_ID, new_secret), + PRODUCTION_AGENT_ID, + ) + for supplied_id in (None, PRODUCTION_AGENT_ID): + with self.subTest(supplied_id=supplied_id), self.assertRaises( + AgentAuthenticationFailed + ): + self.auth.authenticate(supplied_id, legacy_secret) + + connection = connect() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT site_id, agent_zone_id, agent_secret_hash, + credential_created_at, legacy_auth_disabled_at + FROM minerops_site_agents WHERE agent_id = %s + """, + (PRODUCTION_AGENT_ID,), + ) + agent = cur.fetchone() + cur.execute( + """ + SELECT site_id, agent_zone_id, current_mac::text, + host(current_ip) AS current_ip + FROM miners WHERE id = %s + """, + (PRODUCTION_MINER_ID,), + ) + miner = cur.fetchone() + cur.execute( + """ + SELECT count(*) AS retirement_count + FROM agent_legacy_auth_retirements + WHERE agent_id = %s + """, + (PRODUCTION_AGENT_ID,), + ) + retirement_count = cur.fetchone()["retirement_count"] + cur.execute( + """ + SELECT count(*) AS redeemed_audits + FROM audit_logs + WHERE action = 'agent.credential_transition.redeemed' + AND entity_id = %s + """, + (transition["id"],), + ) + redeemed_audits = cur.fetchone()["redeemed_audits"] + finally: + connection.close() + + self.assertEqual(agent["site_id"], PRODUCTION_SITE_ID) + self.assertEqual(agent["agent_zone_id"], PRODUCTION_ZONE_ID) + self.assertEqual(agent["agent_secret_hash"], hash_agent_secret(new_secret)) + self.assertIsNotNone(agent["credential_created_at"]) + self.assertIsNotNone(agent["legacy_auth_disabled_at"]) + self.assertEqual(miner["site_id"], PRODUCTION_SITE_ID) + self.assertEqual(miner["agent_zone_id"], PRODUCTION_ZONE_ID) + self.assertEqual(miner["current_mac"], "02:00:00:00:20:03") + self.assertEqual(miner["current_ip"], "192.0.2.20") + self.assertEqual(retirement_count, 1) + self.assertEqual(redeemed_audits, 1) + + # Remove deletable enrollment/current state to prove that the separate + # append-only retirement identity still defeats fallback on recreation. + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + "DELETE FROM agent_enrollments WHERE id = %s", + (transition["id"],), + ) + cur.execute( + "DELETE FROM minerops_site_agents WHERE agent_id = %s", + (PRODUCTION_AGENT_ID,), + ) + cur.execute( + """ + INSERT INTO minerops_site_agents ( + agent_id, site_id, agent_zone_id, site_name + ) VALUES (%s, %s, %s, 'Recreated Plexus') + """, + ( + PRODUCTION_AGENT_ID, + PRODUCTION_SITE_ID, + PRODUCTION_ZONE_ID, + ), + ) + connection.commit() + finally: + connection.close() + + recreated = self.agent_row(PRODUCTION_AGENT_ID) + self.assertIsNone(recreated["agent_secret_hash"]) + self.assertIsNotNone(recreated["legacy_auth_disabled_at"]) + with self.assertRaises(AgentAuthenticationFailed): + self.auth.authenticate(PRODUCTION_AGENT_ID, legacy_secret) + + def test_02_concurrent_redemption_has_one_credential_winner(self): + agent_id = "transition-concurrent-agent" + self.create_legacy_agent(agent_id) + token = self.create_transition(agent_id)["transition_token"] + secrets = ("concurrent-secret-a", "concurrent-secret-b") + + def redeem(secret): + try: + return ("success", secret, self.redeem_transition(token, secret)) + except AgentEnrollmentTokenInvalid: + return ("invalid", secret, None) + + with ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(redeem, secrets)) + + winners = [item for item in results if item[0] == "success"] + losers = [item for item in results if item[0] == "invalid"] + self.assertEqual(len(winners), 1) + self.assertEqual(len(losers), 1) + row = self.agent_row(agent_id) + self.assertEqual(row["agent_secret_hash"], hash_agent_secret(winners[0][1])) + + def test_03_authorization_cannot_substitute_identity_or_steal_zone(self): + agent_a = "transition-bound-agent-a" + agent_b = "transition-bound-agent-b" + site_a, zone_a = self.create_legacy_agent(agent_a) + site_b, zone_b = self.create_legacy_agent(agent_b) + token = self.create_transition(agent_a)["transition_token"] + + with self.assertRaises(ValidationError): + AgentCredentialTransitionRedeemRequest.model_validate( + { + "token": token, + "agent_secret_hash": hash_agent_secret("bound-secret"), + "agent_id": agent_b, + } + ) + + result = self.redeem_transition(token, "bound-secret") + self.assertEqual(result["agent_id"], agent_a) + self.assertEqual((result["site_id"], result["agent_zone_id"]), (site_a, zone_a)) + other = self.agent_row(agent_b) + self.assertEqual((other["site_id"], other["agent_zone_id"]), (site_b, zone_b)) + self.assertIsNone(other["agent_secret_hash"]) + + def test_04_revoked_retired_and_disabled_targets_fail_closed(self): + revoked = "transition-revoked-agent" + self.create_legacy_agent( + revoked, + secret_hash=hash_agent_secret("revoked-secret"), + revoked=True, + ) + with self.assertRaises(AgentEnrollmentConflict): + self.create_transition(revoked) + + retired = "transition-retired-agent" + self.create_legacy_agent(retired) + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + "INSERT INTO agent_legacy_auth_retirements (agent_id) VALUES (%s)", + (retired,), + ) + connection.commit() + finally: + connection.close() + with self.assertRaises(AgentEnrollmentConflict): + self.create_transition(retired) + + disabled = "transition-disabled-agent" + self.create_legacy_agent(disabled, enabled=False) + with self.assertRaises(AgentEnrollmentConflict): + self.create_transition(disabled) + + changed = "transition-zone-disabled-after-authorization" + _, changed_zone = self.create_legacy_agent(changed) + authorization = self.create_transition(changed) + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + "UPDATE agent_zones SET is_enabled = false WHERE id = %s", + (changed_zone,), + ) + connection.commit() + finally: + connection.close() + with self.assertRaises(AgentEnrollmentConflict): + self.redeem_transition( + authorization["transition_token"], "disabled-zone-secret" + ) + self.assertIsNone(self.agent_row(changed)["agent_secret_hash"]) + + def test_05_credential_collision_rolls_back_retirement_and_token_use(self): + collision_hash = hash_agent_secret("already-issued-secret") + self.create_legacy_agent( + "transition-existing-credential-holder", + secret_hash=collision_hash, + ) + target = "transition-rollback-target" + self.create_legacy_agent(target) + transition = self.create_transition(target) + + with self.assertRaises(AgentEnrollmentCredentialCollision): + self.service.redeem_credential_transition( + AgentCredentialTransitionRedeemRequest( + token=transition["transition_token"], + agent_secret_hash=collision_hash, + ) + ) + + row = self.agent_row(target) + self.assertIsNone(row["agent_secret_hash"]) + self.assertIsNone(row["credential_created_at"]) + self.assertIsNone(row["legacy_auth_disabled_at"]) + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + """ + SELECT count(*) FROM agent_legacy_auth_retirements + WHERE agent_id = %s + """, + (target,), + ) + self.assertEqual(cur.fetchone()[0], 0) + cur.execute( + """ + SELECT used_at, enrolled_agent_id + FROM agent_enrollments WHERE id = %s + """, + (transition["id"],), + ) + self.assertEqual(cur.fetchone(), (None, None)) + finally: + connection.close() + + def test_06_normal_enrollment_still_works_and_occupied_zone_is_protected(self): + site_id, occupied_zone = self.create_legacy_agent( + "transition-ordinary-occupied" + ) + with self.assertRaises(AgentEnrollmentConflict): + self.service.create_enrollment( + AgentEnrollmentCreateRequest( + site_id=site_id, + agent_zone_id=occupied_zone, + display_name="Must Not Steal Occupied Zone", + ), + actor_user_id=None, + ) + + free_zone = uuid4() + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + """ + INSERT INTO agent_zones (id, site_id, name, is_enabled) + VALUES (%s, %s, 'Free Enrollment Zone', true) + """, + (free_zone, site_id), + ) + connection.commit() + finally: + connection.close() + + enrollment = self.service.create_enrollment( + AgentEnrollmentCreateRequest( + site_id=site_id, + agent_zone_id=free_zone, + display_name="Ordinary New Agent", + ), + actor_user_id=None, + ) + enrolled = self.service.redeem( + AgentEnrollRequest(token=enrollment["enrollment_token"]) + ) + self.assertRegex(enrolled["agent_id"], r"^agent-[0-9a-f]{32}$") + self.assertEqual(enrolled["agent_zone_id"], free_zone) + + def test_07_registry_history_and_audit_never_expose_plaintext(self): + agent_id = "transition-privacy-agent" + self.create_legacy_agent(agent_id) + transition = self.create_transition(agent_id) + token = transition["transition_token"] + + listed = self.service.list_enrollments() + item = next(row for row in listed if row["id"] == transition["id"]) + self.assertEqual(item["target_agent_id"], agent_id) + self.assertEqual(item["purpose"], "existing_agent_credential_transition") + serialized_item = repr(item) + self.assertNotIn(token, serialized_item) + for forbidden in ( + "token_hash", + "transition_token", + "agent_secret", + "agent_secret_hash", + ): + self.assertNotIn(forbidden, item) + + registry = AgentRegistryRepository().list_registry() + registry_agent = next( + row for row in registry["agents"] if row["agent_id"] == agent_id + ) + for forbidden in ("agent_secret", "agent_secret_hash", "token_hash"): + self.assertNotIn(forbidden, registry_agent) + + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + """ + SELECT new_value_json::text + FROM audit_logs + WHERE entity_id = %s + """, + (transition["id"],), + ) + audit_text = "\n".join(row[0] for row in cur.fetchall()) + finally: + connection.close() + self.assertNotIn(token, audit_text) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_agent_enrollment.py b/backend/tests/test_agent_enrollment.py index d12b518..8b97a28 100644 --- a/backend/tests/test_agent_enrollment.py +++ b/backend/tests/test_agent_enrollment.py @@ -5,13 +5,24 @@ import unittest from datetime import datetime, timedelta, timezone from uuid import UUID +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.agent_enrollment.dependencies import get_agent_enrollment_service from app.agent_auth.service import hash_agent_secret from app.agent_enrollment.models import ( + AgentCredentialTransitionCreateRequest, + AgentCredentialTransitionRedeemRequest, AgentEnrollmentCreateRequest, AgentEnrollRequest, ) from app.agent_enrollment.service import AgentEnrollmentService -from app.identity import machine_agent_request, required_api_permission +from app.agent_enrollment.router import router +from app.identity import ( + get_current_user, + machine_agent_request, + required_api_permission, +) SITE_ID = UUID("00000000-0000-4000-8000-000000001700") @@ -25,6 +36,8 @@ class FakeAgentEnrollmentRepository: def __init__(self): self.created = None self.redeemed = None + self.transition_created = None + self.transition_redeemed = None def create_enrollment(self, **kwargs): self.created = kwargs @@ -42,6 +55,20 @@ class FakeAgentEnrollmentRepository: def list_enrollments(self, **kwargs): return [] + def create_credential_transition(self, **kwargs): + self.transition_created = kwargs + return { + "id": ENROLLMENT_ID, + "target_agent_id": kwargs["target_agent_id"], + "site_id": SITE_ID, + "site_name": "Test Site", + "agent_zone_id": ZONE_ID, + "agent_zone_name": "Test Zone", + "display_name": "Existing Agent", + "created_at": NOW, + "expires_at": NOW + timedelta(minutes=30), + } + def redeem_enrollment(self, **kwargs): self.redeemed = kwargs return { @@ -54,6 +81,18 @@ class FakeAgentEnrollmentRepository: "ip_ranges": ["10.30.0.0/22"], } + def redeem_credential_transition(self, **kwargs): + self.transition_redeemed = kwargs + return { + "agent_id": "existing-agent", + "site_id": SITE_ID, + "site_name": "Test Site", + "agent_zone_id": ZONE_ID, + "agent_zone_name": "Test Zone", + "display_name": "Existing Agent", + "ip_ranges": ["10.30.0.0/22"], + } + class AgentEnrollmentServiceTests(unittest.TestCase): def test_create_returns_plaintext_token_once_but_repository_gets_only_hash(self): @@ -123,6 +162,45 @@ class AgentEnrollmentServiceTests(unittest.TestCase): serialized = repr(repository.redeemed) self.assertNotIn(secret, serialized) + def test_existing_transition_is_server_bound_and_accepts_only_secret_hash(self): + repository = FakeAgentEnrollmentRepository() + service = AgentEnrollmentService(repository) + + created = service.create_credential_transition( + AgentCredentialTransitionCreateRequest( + agent_id=" existing-agent " + ), + actor_user_id=USER_ID, + ) + token = created["transition_token"] + + self.assertEqual( + repository.transition_created["target_agent_id"], + "existing-agent", + ) + self.assertEqual( + repository.transition_created["token_hash"], + hash_agent_secret(token), + ) + self.assertNotIn(token, repr(repository.transition_created)) + + credential_hash = hash_agent_secret("locally-generated-secret") + result = service.redeem_credential_transition( + AgentCredentialTransitionRedeemRequest( + token=token, + agent_secret_hash=credential_hash, + ) + ) + self.assertEqual(result["agent_id"], "existing-agent") + self.assertEqual( + repository.transition_redeemed, + { + "token_hash": hash_agent_secret(token), + "agent_secret_hash": credential_hash, + }, + ) + self.assertNotIn("agent_secret", result) + def test_enrollment_admin_permission_mapping_is_explicit(self): self.assertEqual( required_api_permission("GET", "/api/agent-enrollments"), @@ -132,9 +210,18 @@ class AgentEnrollmentServiceTests(unittest.TestCase): required_api_permission("POST", "/api/agent-enrollments"), "agents.manage", ) + self.assertEqual( + required_api_permission( + "POST", "/api/agent-credential-transitions" + ), + "agents.manage", + ) def test_bootstrap_endpoint_is_machine_exempt_but_admin_endpoint_is_not(self): self.assertTrue(machine_agent_request("/api/agent/enroll")) + self.assertTrue( + machine_agent_request("/api/agent/credential-transition") + ) self.assertFalse( machine_agent_request("/api/agent-enrollments") ) @@ -153,5 +240,66 @@ class AgentEnrollmentServiceTests(unittest.TestCase): ) +class AgentCredentialTransitionRouterTests(unittest.TestCase): + def setUp(self): + self.repository = FakeAgentEnrollmentRepository() + self.service = AgentEnrollmentService(self.repository) + self.app = FastAPI() + self.app.include_router(router) + self.app.dependency_overrides[get_agent_enrollment_service] = ( + lambda: self.service + ) + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(USER_ID), + "role": "Test", + "permissions": ("agents.view", "agents.manage"), + } + self.client = TestClient(self.app) + + def tearDown(self): + self.client.close() + + def test_admin_creation_is_distinct_no_store_and_identity_only(self): + response = self.client.post( + "/api/agent-credential-transitions", + json={"agent_id": "existing-agent"}, + ) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.headers["cache-control"], "no-store") + payload = response.json()["data"] + self.assertEqual(payload["target_agent_id"], "existing-agent") + self.assertIn("transition_token", payload) + self.assertNotIn("site_id", self.repository.transition_created) + self.assertNotIn("agent_zone_id", self.repository.transition_created) + + def test_machine_redemption_returns_no_permanent_plaintext_secret(self): + response = self.client.post( + "/api/agent/credential-transition", + json={ + "token": "T" * 43, + "agent_secret_hash": hash_agent_secret( + "locally-generated-router-secret" + ), + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["cache-control"], "no-store") + payload = response.json()["data"] + self.assertEqual(payload["agent_id"], "existing-agent") + self.assertNotIn("agent_secret", payload) + + def test_view_only_admin_cannot_create_transition(self): + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(USER_ID), + "role": "Test", + "permissions": ("agents.view",), + } + response = self.client.post( + "/api/agent-credential-transitions", + json={"agent_id": "existing-agent"}, + ) + self.assertEqual(response.status_code, 403) + + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_agent_enrollment_postgres.py b/backend/tests/test_agent_enrollment_postgres.py index d67eca6..a7fbd9a 100644 --- a/backend/tests/test_agent_enrollment_postgres.py +++ b/backend/tests/test_agent_enrollment_postgres.py @@ -4,6 +4,7 @@ import unittest from concurrent.futures import ThreadPoolExecutor from uuid import uuid4 +from psycopg.errors import ForeignKeyViolation from psycopg.rows import dict_row from app.agent_auth.exceptions import AgentAuthenticationFailed @@ -11,6 +12,7 @@ from app.agent_auth.repository import AgentAuthRepository from app.agent_auth.service import AgentAuthService, hash_agent_secret from app.agent_enrollment.exceptions import ( AgentEnrollmentConflict, + AgentEnrollmentCredentialCollision, AgentEnrollmentNotFound, AgentEnrollmentTokenInvalid, ) @@ -26,6 +28,7 @@ from app.agent_enrollment.service import AgentEnrollmentService from app.agent_zones.models import AgentCheckinRequest from app.agent_zones.repository import AgentZoneRepository from app.agent_zones.service import AgentZoneService +from app.config import settings class AgentEnrollmentPostgresTests(unittest.TestCase): @@ -322,6 +325,7 @@ class AgentEnrollmentPostgresTests(unittest.TestCase): agent_secret_hash, credential_created_at, credential_revoked_at, + legacy_auth_disabled_at, last_payload::text AS last_payload FROM minerops_site_agents WHERE site_id = %s @@ -345,6 +349,16 @@ class AgentEnrollmentPostgresTests(unittest.TestCase): ) enrollment_row = cur.fetchone() + cur.execute( + """ + SELECT retired_at + FROM agent_legacy_auth_retirements + WHERE agent_id = %s + """, + (agent_id,), + ) + retirement_row = cur.fetchone() + cur.execute( """ SELECT new_value_json::text AS payload @@ -369,6 +383,15 @@ class AgentEnrollmentPostgresTests(unittest.TestCase): ) self.assertIsNotNone(agent["credential_created_at"]) self.assertIsNone(agent["credential_revoked_at"]) + self.assertEqual( + agent["legacy_auth_disabled_at"], + agent["credential_created_at"], + ) + self.assertIsNotNone(retirement_row) + self.assertEqual( + retirement_row["retired_at"], + agent["credential_created_at"], + ) self.assertIsNotNone(enrollment_row["used_at"]) self.assertIsNone(enrollment_row["revoked_at"]) @@ -409,6 +432,17 @@ class AgentEnrollmentPostgresTests(unittest.TestCase): "definitely-not-the-issued-secret", ) + original_legacy_id = settings.minerops_agent_id + original_legacy_secret = settings.minerops_agent_shared_secret + settings.minerops_agent_id = agent_id + settings.minerops_agent_shared_secret = "global-secret-must-not-work" + try: + with self.assertRaises(AgentAuthenticationFailed): + auth_service.authenticate(None, "global-secret-must-not-work") + finally: + settings.minerops_agent_id = original_legacy_id + settings.minerops_agent_shared_secret = original_legacy_secret + # Normal check-in must never mutate the credential. secret_hash_before = agent["agent_secret_hash"] @@ -433,7 +467,8 @@ class AgentEnrollmentPostgresTests(unittest.TestCase): SELECT agent_secret_hash, credential_created_at, - credential_revoked_at + credential_revoked_at, + legacy_auth_disabled_at FROM minerops_site_agents WHERE agent_id = %s """, @@ -453,6 +488,150 @@ class AgentEnrollmentPostgresTests(unittest.TestCase): self.assertIsNone( after_checkin["credential_revoked_at"] ) + self.assertEqual( + after_checkin["legacy_auth_disabled_at"], + agent["legacy_auth_disabled_at"], + ) + + # 0017 enrollment integrity remains RESTRICT: the current Agent row + # cannot be deleted while its enrollment relationship exists. + connection = default_connection_factory() + try: + with self.assertRaises(ForeignKeyViolation): + with connection.cursor() as cur: + cur.execute( + "DELETE FROM minerops_site_agents WHERE agent_id = %s", + (agent_id,), + ) + connection.rollback() + finally: + connection.close() + + # After an operator deliberately removes the enrollment history, the + # independent retirement ledger must outlive deletion and recreation + # of the current Agent row with the identical identity. + connection = default_connection_factory() + try: + with connection.cursor() as cur: + cur.execute( + "DELETE FROM agent_enrollments WHERE id = %s", + (enrollment["id"],), + ) + cur.execute( + "DELETE FROM minerops_site_agents WHERE agent_id = %s", + (agent_id,), + ) + connection.commit() + finally: + connection.close() + + original_legacy_id = settings.minerops_agent_id + original_legacy_secret = settings.minerops_agent_shared_secret + settings.minerops_agent_id = agent_id + settings.minerops_agent_shared_secret = "recreation-global-secret" + try: + with self.assertRaises(AgentAuthenticationFailed): + auth_service.authenticate(None, "recreation-global-secret") + + connection = default_connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + INSERT INTO minerops_site_agents ( + agent_id, + site_id, + agent_zone_id, + site_name + ) + VALUES (%s, %s, %s, %s) + RETURNING legacy_auth_disabled_at + """, + ( + agent_id, + self.site_id, + self.zone_id, + self.site_name, + ), + ) + recreated = cur.fetchone() + connection.commit() + finally: + connection.close() + + self.assertEqual( + recreated["legacy_auth_disabled_at"], + retirement_row["retired_at"], + ) + with self.assertRaises(AgentAuthenticationFailed): + auth_service.authenticate(agent_id, "recreation-global-secret") + finally: + settings.minerops_agent_id = original_legacy_id + settings.minerops_agent_shared_secret = original_legacy_secret + + def test_failed_credential_issuance_rolls_back_new_retirement(self): + enrollment = self.create_enrollment("Collision Agent") + collision_id = f"agent-collision-{uuid4().hex}" + + connection = default_connection_factory() + try: + with connection.cursor() as cur: + cur.execute( + """ + INSERT INTO minerops_site_agents (agent_id, site_name) + VALUES (%s, 'Existing uncredentialed identity') + """, + (collision_id,), + ) + connection.commit() + finally: + connection.close() + + try: + with self.assertRaises(AgentEnrollmentCredentialCollision): + AgentEnrollmentRepository().redeem_enrollment( + token_hash=hash_agent_secret(enrollment["enrollment_token"]), + agent_id=collision_id, + agent_secret_hash=hash_agent_secret("never-issued-secret"), + ) + + connection = default_connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT count(*) AS count + FROM agent_legacy_auth_retirements + WHERE agent_id = %s + """, + (collision_id,), + ) + self.assertEqual(cur.fetchone()["count"], 0) + cur.execute( + """ + SELECT used_at, enrolled_agent_id + FROM agent_enrollments + WHERE id = %s + """, + (enrollment["id"],), + ) + enrollment_row = cur.fetchone() + finally: + connection.close() + + self.assertIsNone(enrollment_row["used_at"]) + self.assertIsNone(enrollment_row["enrolled_agent_id"]) + finally: + connection = default_connection_factory() + try: + with connection.cursor() as cur: + cur.execute( + "DELETE FROM minerops_site_agents WHERE agent_id = %s", + (collision_id,), + ) + connection.commit() + finally: + connection.close() def test_expired_token_is_rejected_without_side_effects(self): enrollment = self.create_enrollment() diff --git a/backend/tests/test_agent_legacy_auth_retirement_postgres.py b/backend/tests/test_agent_legacy_auth_retirement_postgres.py new file mode 100644 index 0000000..9452909 --- /dev/null +++ b/backend/tests/test_agent_legacy_auth_retirement_postgres.py @@ -0,0 +1,245 @@ +"""Disposable PostgreSQL contract for durable legacy-auth retirement.""" + +import os +import unittest + +from psycopg.errors import CheckViolation + +from app.agent_auth.exceptions import ( + AgentAuthenticationFailed, + AgentCredentialRevoked, +) +from app.agent_auth.repository import AgentAuthRepository, default_connection_factory +from app.agent_auth.service import AgentAuthService +from app.config import settings + + +class AgentLegacyAuthRetirementPostgresTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + if os.environ.get("MINEROPS_LEGACY_RETIREMENT_DISPOSABLE") != "1": + raise unittest.SkipTest( + "legacy-auth retirement requires its disposable PostgreSQL runner" + ) + cls.case = os.environ["MINEROPS_LEGACY_RETIREMENT_CASE"] + cls.agent_id = settings.minerops_agent_id + cls.auth = AgentAuthService(AgentAuthRepository()) + + def row(self): + connection = default_connection_factory() + try: + with connection.cursor() as cur: + cur.execute( + """ + SELECT agent_secret_hash, credential_created_at, + credential_revoked_at, legacy_auth_disabled_at + FROM minerops_site_agents + WHERE agent_id = %s + """, + (self.agent_id,), + ) + return cur.fetchone() + finally: + connection.close() + + def retirement_row(self, agent_id=None): + connection = default_connection_factory() + try: + with connection.cursor() as cur: + cur.execute( + """ + SELECT agent_id, retired_at + FROM agent_legacy_auth_retirements + WHERE agent_id = %s + """, + (agent_id or self.agent_id,), + ) + return cur.fetchone() + finally: + connection.close() + + def test_01_migration_backfill_is_selective(self): + row = self.row() + self.assertIsNotNone(row) + if self.case == "legacy": + self.assertEqual(row, (None, None, None, None)) + self.assertIsNone(self.retirement_row()) + else: + self.assertIsNotNone(row[3]) + retirement = self.retirement_row() + self.assertIsNotNone(retirement) + self.assertEqual(retirement[1], row[3]) + + def test_02_real_repository_authentication_obeys_retirement(self): + secret = settings.minerops_agent_shared_secret + if self.case == "legacy": + self.assertEqual(self.auth.authenticate(None, secret), self.agent_id) + self.assertEqual( + self.auth.authenticate(self.agent_id, secret), self.agent_id + ) + return + + for agent_id in (None, self.agent_id): + with self.subTest(agent_id=agent_id), self.assertRaises( + AgentCredentialRevoked + ): + self.auth.authenticate(agent_id, secret) + + def test_03_clearing_credentials_cannot_restore_global_fallback(self): + if self.case == "legacy": + self.skipTest("uncredentialed legacy case must remain eligible") + + connection = default_connection_factory() + try: + with connection.cursor() as cur: + cur.execute( + """ + UPDATE minerops_site_agents + SET agent_secret_hash = NULL, + credential_created_at = NULL, + credential_revoked_at = NULL + WHERE agent_id = %s + """, + (self.agent_id,), + ) + connection.commit() + finally: + connection.close() + + row = self.row() + self.assertEqual(row[:3], (None, None, None)) + self.assertIsNotNone(row[3]) + for agent_id in (None, self.agent_id): + with self.subTest(agent_id=agent_id), self.assertRaises( + AgentAuthenticationFailed + ): + self.auth.authenticate( + agent_id, settings.minerops_agent_shared_secret + ) + + def test_04_deletion_and_recreation_cannot_restore_fallback(self): + if self.case == "legacy": + self.skipTest("uncredentialed legacy case has no retirement history") + + retirement = self.retirement_row() + self.assertIsNotNone(retirement) + + connection = default_connection_factory() + try: + with connection.cursor() as cur: + cur.execute( + "DELETE FROM minerops_site_agents WHERE agent_id = %s", + (self.agent_id,), + ) + connection.commit() + finally: + connection.close() + + self.assertIsNone(self.row()) + self.assertEqual(self.retirement_row(), retirement) + for agent_id in (None, self.agent_id): + with self.subTest(state="deleted", agent_id=agent_id), self.assertRaises( + AgentAuthenticationFailed + ): + self.auth.authenticate( + agent_id, settings.minerops_agent_shared_secret + ) + + connection = default_connection_factory() + try: + with connection.cursor() as cur: + cur.execute( + """ + INSERT INTO minerops_site_agents (agent_id, site_name) + VALUES (%s, 'Recreated') + """, + (self.agent_id,), + ) + connection.commit() + finally: + connection.close() + + recreated = self.row() + self.assertEqual(recreated[:3], (None, None, None)) + self.assertEqual(recreated[3], retirement[1]) + for agent_id in (None, self.agent_id): + with self.subTest(state="recreated", agent_id=agent_id), self.assertRaises( + AgentAuthenticationFailed + ): + self.auth.authenticate( + agent_id, settings.minerops_agent_shared_secret + ) + + def test_05_retirement_ledger_and_projection_are_one_way(self): + if self.case == "legacy": + self.skipTest("uncredentialed legacy case has no retirement history") + + statements = ( + """ + UPDATE agent_legacy_auth_retirements + SET retired_at = retired_at + interval '1 second' + WHERE agent_id = %s + """, + """ + DELETE FROM agent_legacy_auth_retirements + WHERE agent_id = %s + """, + "TRUNCATE agent_legacy_auth_retirements", + """ + UPDATE minerops_site_agents + SET legacy_auth_disabled_at = NULL + WHERE agent_id = %s + """, + ) + for statement in statements: + connection = default_connection_factory() + try: + with self.subTest(statement=" ".join(statement.split())): + with self.assertRaises(CheckViolation): + with connection.cursor() as cur: + if "%s" in statement: + cur.execute(statement, (self.agent_id,)) + else: + cur.execute(statement) + connection.rollback() + finally: + connection.close() + + self.assertIsNotNone(self.retirement_row()) + self.assertIsNotNone(self.row()[3]) + + def test_06_other_legacy_identity_is_not_accidentally_retired(self): + if self.case == "legacy": + self.skipTest("cross-identity check runs in the retired case") + + other_id = self.agent_id + "-untouched" + connection = default_connection_factory() + try: + with connection.cursor() as cur: + cur.execute( + """ + INSERT INTO minerops_site_agents (agent_id, site_name) + VALUES (%s, 'Untouched') + """, + (other_id,), + ) + connection.commit() + finally: + connection.close() + + self.assertIsNone(self.retirement_row(other_id)) + original_legacy_id = settings.minerops_agent_id + settings.minerops_agent_id = other_id + try: + self.assertEqual( + self.auth.authenticate( + other_id, settings.minerops_agent_shared_secret + ), + other_id, + ) + finally: + settings.minerops_agent_id = original_legacy_id + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_agent_registry_postgres.py b/backend/tests/test_agent_registry_postgres.py index 7f7ac06..6a17594 100644 --- a/backend/tests/test_agent_registry_postgres.py +++ b/backend/tests/test_agent_registry_postgres.py @@ -73,7 +73,8 @@ class AgentRegistryPostgresTests(unittest.TestCase): version, ip_address, agent_secret_hash, - credential_created_at + credential_created_at, + legacy_auth_disabled_at ) VALUES ( %s, @@ -85,6 +86,7 @@ class AgentRegistryPostgresTests(unittest.TestCase): %s, %s, %s, + now(), now() ) """, diff --git a/backend/tests/test_agent_zones.py b/backend/tests/test_agent_zones.py index ee70dd7..48d3c63 100644 --- a/backend/tests/test_agent_zones.py +++ b/backend/tests/test_agent_zones.py @@ -90,6 +90,23 @@ class FakeRepository: "zone_enabled": zone_row["is_enabled"] if zone_row else None, } + def get_agent_configuration(self, agent_id): + assignment = self.get_agent_assignment(agent_id) + if not assignment: + return None + zone_row = self.zones.get(assignment.get("agent_zone_id")) + return { + "agent_id": agent_id, + "site_id": assignment.get("site_id"), + "agent_zone_id": assignment.get("agent_zone_id"), + "site_name": assignment.get("site_name"), + "agent_zone_name": zone_row["name"] if zone_row else None, + "agent_zone_enabled": zone_row["is_enabled"] if zone_row else None, + "networks": [ + item["network"] for item in (zone_row or {}).get("networks", []) + ], + } + def assign_miners_to_zone(self, miner_ids, agent_zone_id): self.assigned = (list(miner_ids), agent_zone_id) return {miner_id: self.assign_result.get(miner_id, "miner_not_found") @@ -218,6 +235,25 @@ class AgentZoneServiceTests(unittest.TestCase): self.assertEqual(result.routing_reason, "agent_zone_conflict") self.assertIsNone(result.site_id) + def test_configuration_uses_only_authenticated_agent_zone_networks(self): + self.repository.zones[ZONE_A]["networks"] = [ + {"id": uuid4(), "network": "10.30.0.0/24"} + ] + self.repository.zones[ZONE_B] = zone(ZONE_B, SITE_B, name="Remote") + self.repository.zones[ZONE_B]["networks"] = [ + {"id": uuid4(), "network": "10.80.0.0/24"} + ] + self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A) + + configuration = self.service.configuration("agent-a") + + self.assertEqual(configuration["agent_zone_id"], ZONE_A) + self.assertEqual( + [item["cidr"] for item in configuration["ip_ranges"]], + ["10.30.0.0/24"], + ) + self.assertNotIn("10.80.0.0/24", str(configuration)) + class AgentZoneRouterTests(unittest.TestCase): def setUp(self): diff --git a/backend/tests/test_legacy_agent_http_postgres.py b/backend/tests/test_legacy_agent_http_postgres.py new file mode 100644 index 0000000..6464ea7 --- /dev/null +++ b/backend/tests/test_legacy_agent_http_postgres.py @@ -0,0 +1,275 @@ +"""Full HTTP/FastAPI plus PostgreSQL contract for the unchanged legacy Agent.""" + +import os +import unittest +from uuid import UUID + +from fastapi.testclient import TestClient + +from app.agent_auth.service import hash_agent_secret +from app.config import settings +from app.main import app + + +SITE_A = UUID("00000000-0000-4000-8000-000000001a01") +SITE_B = UUID("00000000-0000-4000-8000-000000001a02") +ZONE_C = UUID("00000000-0000-4000-8000-000000001a03") +MINER_A = UUID("00000000-0000-4000-8000-000000001a11") +MINER_B = UUID("00000000-0000-4000-8000-000000001a12") +LEGACY_AGENT = "legacy-plexus-http" +ENROLLED_AGENT = "enrolled-http-agent" +REVOKED_AGENT = "revoked-http-agent" +ENROLLED_SECRET = "enrolled-http-agent-secret" +REVOKED_SECRET = "revoked-http-agent-secret" +ZONE_A_NETWORK = "10.31.0.0/24" +ZONE_B_NETWORK = "10.81.0.0/24" +ZONE_C_NETWORK = "10.82.0.0/24" + + +def connect(): + from app.agent_auth.repository import default_connection_factory + + return default_connection_factory() + + +class LegacyAgentHttpPostgresTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + if os.environ.get("MINEROPS_LEGACY_HTTP_POSTGRES_DISPOSABLE") != "1": + raise unittest.SkipTest( + "full-stack legacy contract requires its disposable runner" + ) + if settings.minerops_agent_id != LEGACY_AGENT: + raise AssertionError("runner did not bind the configured legacy identity") + + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + """ + SELECT agent_zone_id + FROM minerops_site_agents + WHERE agent_id = %s + """, + (LEGACY_AGENT,), + ) + cls.zone_a = cur.fetchone()[0] + cur.execute( + """ + SELECT id FROM agent_zones + WHERE site_id = %s AND name = 'Default Zone' + """, + (SITE_B,), + ) + cls.zone_b = cur.fetchone()[0] + cur.execute( + """ + INSERT INTO agent_zone_network_ranges (agent_zone_id, network) + VALUES + (%s, %s::cidr), + (%s, %s::cidr) + """, + (cls.zone_a, ZONE_A_NETWORK, cls.zone_b, ZONE_B_NETWORK), + ) + cur.execute( + """ + INSERT INTO agent_zones (id, site_id, name) + VALUES (%s, %s, 'Revoked Contract Zone') + """, + (ZONE_C, SITE_B), + ) + cur.execute( + """ + INSERT INTO agent_zone_network_ranges (agent_zone_id, network) + VALUES (%s, %s::cidr) + """, + (ZONE_C, ZONE_C_NETWORK), + ) + cur.execute( + """ + INSERT INTO minerops_site_agents ( + agent_id, site_id, agent_zone_id, site_name, display_name, + agent_secret_hash, credential_created_at, + credential_revoked_at, legacy_auth_disabled_at + ) VALUES + (%s, %s, %s, 'Contract Site B', 'Enrolled Agent', + %s, now(), NULL, now()), + (%s, %s, %s, 'Contract Site B', 'Revoked Agent', + %s, now() - interval '1 hour', now(), + now() - interval '1 hour') + """, + ( + ENROLLED_AGENT, + SITE_B, + cls.zone_b, + hash_agent_secret(ENROLLED_SECRET), + REVOKED_AGENT, + SITE_B, + ZONE_C, + hash_agent_secret(REVOKED_SECRET), + ), + ) + connection.commit() + finally: + connection.close() + + # Deliberately do not enter TestClient as a context manager. That keeps + # unrelated application startup jobs out of this focused HTTP contract + # while exercising the real ASGI middleware, dependencies, and routes. + cls.client = TestClient(app) + + @classmethod + def tearDownClass(cls): + cls.client.close() + + @staticmethod + def legacy_headers(): + return {"X-MinerOps-Agent-Secret": settings.minerops_agent_shared_secret} + + @staticmethod + def agent_headers(agent_id, secret): + return { + "X-MinerOps-Agent-ID": agent_id, + "X-MinerOps-Agent-Secret": secret, + } + + def assert_no_credentials(self, response): + lowered = response.text.lower() + for forbidden in ( + "agent_secret_hash", "credential_created_at", "token_hash", + settings.minerops_agent_shared_secret.lower(), + ENROLLED_SECRET.lower(), REVOKED_SECRET.lower(), + ): + self.assertNotIn(forbidden, lowered) + + def test_10_secret_only_config_is_legacy_identity_and_zone_bound(self): + response = self.client.get( + "/api/agent/config?agent_id=" + ENROLLED_AGENT + + "&agent_zone_id=" + str(self.zone_b), + headers=self.legacy_headers(), + ) + self.assertEqual(response.status_code, 200, response.text) + payload = response.json() + self.assertEqual(payload["agent_id"], LEGACY_AGENT) + self.assertEqual(payload["agent_zone_id"], str(self.zone_a)) + self.assertEqual( + [item["cidr"] for item in payload["ip_ranges"]], + [ZONE_A_NETWORK], + ) + self.assertNotIn(ZONE_B_NETWORK, response.text) + self.assertNotIn(ZONE_C_NETWORK, response.text) + self.assert_no_credentials(response) + + def test_20_known_miners_returns_only_migrated_legacy_zone(self): + response = self.client.get( + "/api/agent/known-miners", headers=self.legacy_headers() + ) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual( + [item["miner_id"] for item in response.json()["miners"]], + [str(MINER_A)], + ) + self.assertNotIn(str(MINER_B), response.text) + self.assert_no_credentials(response) + + def test_30_exact_old_checkin_shape_succeeds_and_spoofing_fails(self): + old_body = { + "agent_id": LEGACY_AGENT, + "site_name": "Plexus", + "hostname": "legacy-contract-host", + "version": "0.4-command-queue", + "ip_address": "", + "local_time": "2026-08-31T00:00:00Z", + } + response = self.client.post( + "/api/agent/checkin", headers=self.legacy_headers(), json=old_body + ) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["agent_id"], LEGACY_AGENT) + self.assertEqual(response.json()["agent_zone_id"], str(self.zone_a)) + self.assert_no_credentials(response) + + spoofed = self.client.post( + "/api/agent/checkin?agent_id=" + ENROLLED_AGENT, + headers=self.legacy_headers(), + json={**old_body, "agent_id": "body-spoof-agent"}, + ) + self.assertEqual(spoofed.status_code, 422, spoofed.text) + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + "SELECT count(*) FROM minerops_site_agents WHERE agent_id = %s", + ("body-spoof-agent",), + ) + self.assertEqual(cur.fetchone()[0], 0) + finally: + connection.close() + + def test_40_global_secret_rejects_unknown_and_enrolled_explicit_ids(self): + for agent_id, expected_status in ( + ("unknown-agent", 403), + (ENROLLED_AGENT, 401), + ): + with self.subTest(agent_id=agent_id): + response = self.client.get( + "/api/agent/config", + headers=self.agent_headers( + agent_id, settings.minerops_agent_shared_secret + ), + ) + self.assertEqual(response.status_code, expected_status, response.text) + self.assert_no_credentials(response) + + def test_50_enrolled_agent_uses_per_agent_secret_and_own_zone(self): + response = self.client.get( + "/api/agent/config", + headers=self.agent_headers(ENROLLED_AGENT, ENROLLED_SECRET), + ) + self.assertEqual(response.status_code, 200, response.text) + payload = response.json() + self.assertEqual(payload["agent_id"], ENROLLED_AGENT) + self.assertEqual(payload["agent_zone_id"], str(self.zone_b)) + self.assertEqual( + [item["cidr"] for item in payload["ip_ranges"]], + [ZONE_B_NETWORK], + ) + self.assertNotIn(ZONE_A_NETWORK, response.text) + self.assertNotIn(ZONE_C_NETWORK, response.text) + self.assert_no_credentials(response) + + def test_60_revoked_credential_cannot_use_per_agent_or_global_secret(self): + for secret in (REVOKED_SECRET, settings.minerops_agent_shared_secret): + with self.subTest(secret_kind="global" if secret != REVOKED_SECRET else "agent"): + response = self.client.get( + "/api/agent/config", + headers=self.agent_headers(REVOKED_AGENT, secret), + ) + self.assertEqual(response.status_code, 403, response.text) + self.assert_no_credentials(response) + + def test_90_durably_retired_legacy_identity_loses_secret_only_fallback(self): + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + """ + UPDATE minerops_site_agents + SET legacy_auth_disabled_at = now() + WHERE agent_id = %s + """, + (LEGACY_AGENT,), + ) + connection.commit() + finally: + connection.close() + + response = self.client.get( + "/api/agent/config", headers=self.legacy_headers() + ) + self.assertEqual(response.status_code, 401, response.text) + self.assert_no_credentials(response) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_legacy_agent_migrations.py b/backend/tests/test_legacy_agent_migrations.py new file mode 100644 index 0000000..32fa56b --- /dev/null +++ b/backend/tests/test_legacy_agent_migrations.py @@ -0,0 +1,75 @@ +"""Focused source-level contracts for legacy Agent migrations.""" + +import importlib.util +import unittest +from pathlib import Path +from unittest.mock import patch + + +VERSIONS = Path(__file__).parents[1] / "alembic" / "versions" + + +def load_migration(filename): + path = VERSIONS / filename + spec = importlib.util.spec_from_file_location(filename.removesuffix(".py"), path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class EmptyResult: + def all(self): + return [] + + +class RecordingConnection: + def __init__(self): + self.statements = [] + + def execute(self, statement, parameters=None): + del parameters + self.statements.append(str(statement)) + return EmptyResult() + + +class LegacyRoutingMigrationTests(unittest.TestCase): + def test_0018_locks_every_preflight_and_update_table_before_reads(self): + migration = load_migration("0018_legacy_agent_routing.py") + connection = RecordingConnection() + with patch.object(migration.op, "get_bind", return_value=connection): + migration.upgrade() + + first = " ".join(connection.statements[0].split()).lower() + self.assertTrue(first.startswith("lock table")) + self.assertIn("in share row exclusive mode", first) + for table in ( + "agent_zones", "minerops_agent_discoveries", + "minerops_agent_snapshots", "minerops_site_agents", "sites", + ): + with self.subTest(table=table): + self.assertIn(table, first) + + first_update = next( + index for index, statement in enumerate(connection.statements) + if "UPDATE " in statement + ) + self.assertEqual(first_update, 4) + + def test_0019_is_the_direct_forward_only_successor(self): + migration = load_migration("0019_agent_legacy_auth_retire.py") + self.assertEqual(migration.revision, "0019_agent_legacy_auth_retire") + self.assertEqual(migration.down_revision, "0018_legacy_agent_routing") + + def test_0020_adds_only_existing_identity_transition_binding(self): + migration = load_migration( + "0020_existing_agent_credential_transition.py" + ) + self.assertEqual(migration.revision, "0020_existing_agent_transition") + self.assertEqual( + migration.down_revision, + "0019_agent_legacy_auth_retire", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_legacy_agent_production_postgres.py b/backend/tests/test_legacy_agent_production_postgres.py new file mode 100644 index 0000000..fe06b86 --- /dev/null +++ b/backend/tests/test_legacy_agent_production_postgres.py @@ -0,0 +1,223 @@ +"""Fresh-chain production compatibility contract for the legacy Plexus Agent.""" + +import asyncio +import json +import os +import unittest +from uuid import UUID + +import psycopg +from starlette.requests import Request + +from app.agent_auth.exceptions import ( + AgentAuthenticationFailed, + AgentIdentityUnauthorized, +) +from app.agent_auth.repository import AgentAuthRepository +from app.agent_auth.service import AgentAuthService, hash_agent_secret +from app.agent_zones.repository import AgentZoneRepository +from app.agent_zones.service import AgentZoneService +from app.config import settings +from app.main import ( + minerops_agent_known_miners, + minerops_agent_miner_snapshots, +) + + +SITE_A = UUID("00000000-0000-4000-8000-000000001901") +SITE_B = UUID("00000000-0000-4000-8000-000000001902") +MINER_A = UUID("00000000-0000-4000-8000-000000001911") +MINER_B = UUID("00000000-0000-4000-8000-000000001912") +LEGACY_AGENT = "legacy-plexus-production" +NEW_AGENT = "enrolled-agent-production-contract" +NEW_SECRET = "new-agent-production-contract-secret" + + +def connect(): + return psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + connect_timeout=5, + ) + + +def json_request(payload): + body = json.dumps(payload).encode("utf-8") + delivered = False + + async def receive(): + nonlocal delivered + if delivered: + return {"type": "http.request", "body": b"", "more_body": False} + delivered = True + return {"type": "http.request", "body": body, "more_body": False} + + return Request( + { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "path": "/api/agent/miner-snapshots", + "raw_path": b"/api/agent/miner-snapshots", + "query_string": b"", + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 12345), + "server": ("testserver", 80), + }, + receive, + ) + + +class LegacyAgentProductionPostgresTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + if os.environ.get("MINEROPS_LEGACY_PRODUCTION_DISPOSABLE") != "1": + raise unittest.SkipTest( + "Legacy production contract requires its disposable PostgreSQL runner" + ) + if settings.minerops_agent_id != LEGACY_AGENT: + raise AssertionError("Runner did not bind the configured legacy identity") + + cls.auth = AgentAuthService(AgentAuthRepository(connect)) + cls.zones = AgentZoneService(AgentZoneRepository(connect)) + + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + """ + SELECT agent_zone_id + FROM minerops_site_agents + WHERE agent_id = %s + """, + (LEGACY_AGENT,), + ) + cls.zone_a = cur.fetchone()[0] + cur.execute( + """ + SELECT id + FROM agent_zones + WHERE site_id = %s AND name = 'Default Zone' + """, + (SITE_B,), + ) + cls.zone_b = cur.fetchone()[0] + cur.execute( + """ + INSERT INTO minerops_site_agents ( + agent_id, site_id, agent_zone_id, site_name, + agent_secret_hash, credential_created_at, + legacy_auth_disabled_at + ) + VALUES ( + %s, %s, %s, 'Enrolled Contract Agent', %s, now(), now() + ) + """, + ( + NEW_AGENT, + SITE_B, + cls.zone_b, + hash_agent_secret(NEW_SECRET), + ), + ) + connection.commit() + finally: + connection.close() + + def test_secret_only_auth_is_bounded_to_legacy_identity(self): + self.assertEqual( + self.auth.authenticate(None, settings.minerops_agent_shared_secret), + LEGACY_AGENT, + ) + with self.assertRaises(AgentIdentityUnauthorized): + self.auth.authenticate( + "unknown-agent", settings.minerops_agent_shared_secret + ) + with self.assertRaises(AgentAuthenticationFailed): + self.auth.authenticate( + NEW_AGENT, settings.minerops_agent_shared_secret + ) + self.assertEqual( + self.auth.authenticate(NEW_AGENT, NEW_SECRET), + NEW_AGENT, + ) + + def test_migrated_legacy_agent_has_correct_site_and_zone(self): + routing = self.zones.routing_context(LEGACY_AGENT) + self.assertIsNone(routing.routing_reason) + self.assertEqual(routing.site_id, SITE_A) + self.assertEqual(routing.agent_zone_id, self.zone_a) + + def test_known_miners_and_telemetry_remain_zone_scoped(self): + known = asyncio.run( + minerops_agent_known_miners(LEGACY_AGENT, self.zones) + ) + self.assertEqual( + [row["miner_id"] for row in known["miners"]], + [str(MINER_A)], + ) + + result = asyncio.run( + minerops_agent_miner_snapshots( + json_request( + { + "agent_id": LEGACY_AGENT, + "site_name": "Plexus", + "miners": [ + { + "miner_id": str(MINER_A), + "ip_address": "192.0.2.191", + "online": False, + }, + { + "miner_id": str(MINER_B), + "ip_address": "192.0.2.192", + "online": False, + }, + ], + } + ), + LEGACY_AGENT, + self.zones, + ) + ) + self.assertEqual(result["agent_zone_id"], str(self.zone_a)) + self.assertEqual(result["apply_result"]["applied"], 1) + self.assertIn( + {"miner_id": str(MINER_B), "error": "agent_zone_conflict"}, + result["apply_result"]["errors"], + ) + + connection = connect() + try: + with connection.cursor() as cur: + cur.execute( + "SELECT status FROM miners WHERE id = %s", + (MINER_A,), + ) + self.assertEqual(cur.fetchone()[0], "offline") + cur.execute( + "SELECT status FROM miners WHERE id = %s", + (MINER_B,), + ) + self.assertEqual(cur.fetchone()[0], "online") + cur.execute( + """ + SELECT count(*) + FROM minerops_agent_snapshots + WHERE agent_id = %s AND agent_zone_id = %s + """, + (LEGACY_AGENT, self.zone_a), + ) + self.assertGreaterEqual(cur.fetchone()[0], 1) + finally: + connection.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/seed-development-demo.sh b/scripts/seed-development-demo.sh index d232985..721666a 100755 --- a/scripts/seed-development-demo.sh +++ b/scripts/seed-development-demo.sh @@ -78,8 +78,12 @@ 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}" != "0016_agent_zones" ]]; then - echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0016_agent_zones." >&2 +expected_head="$( + "${compose[@]}" run --rm --no-deps backend alembic heads 2>/dev/null | + awk 'NR == 1 { print $1 }' +)" +if [[ -z "${expected_head}" || "${migration_head}" != "${expected_head}" ]]; then + echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected ${expected_head:-unresolved}." >&2 exit 1 fi diff --git a/scripts/test-agent-legacy-auth-retirement-migration.sh b/scripts/test-agent-legacy-auth-retirement-migration.sh new file mode 100755 index 0000000..0a92c8b --- /dev/null +++ b/scripts/test-agent-legacy-auth-retirement-migration.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" +project_name="${MINEROPS_LEGACY_RETIREMENT_PROJECT:-minerops-legacy-retirement-$$}" +compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml) + +cleanup() { + status=$? + if (( status != 0 )); then + "${compose[@]}" logs --no-color --tail 160 postgres || true + fi + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + exit "${status}" +} +trap cleanup EXIT + +run_db_alembic() { + local database="$1" + shift + "${compose[@]}" run --rm --no-deps -e POSTGRES_DB="${database}" backend alembic "$@" +} +psql_named() { + local database="$1" + shift + "${compose[@]}" exec -T postgres sh -ec \ + 'database="$1"; shift; exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$database" "$@"' \ + sh "${database}" "$@" +} +create_database() { + "${compose[@]}" exec -T postgres sh -ec \ + 'exec createdb -U "$POSTGRES_USER" "$1"' sh "$1" +} +run_contract() { + local database="$1" + local case_name="$2" + local agent_id="$3" + "${compose[@]}" run --rm --no-deps \ + --volume "${repo_root}/backend/tests:/app/tests:ro" \ + --env POSTGRES_DB="${database}" \ + --env MINEROPS_LEGACY_RETIREMENT_DISPOSABLE=1 \ + --env MINEROPS_LEGACY_RETIREMENT_CASE="${case_name}" \ + --env MINEROPS_AGENT_ID="${agent_id}" \ + --env MINEROPS_AGENT_SHARED_SECRET=legacy-retirement-contract-secret \ + backend python -m unittest -v \ + tests.test_agent_legacy_auth_retirement_postgres +} + +[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; } +"${compose[@]}" up --detach postgres +for attempt in $(seq 1 60); do + "${compose[@]}" exec -T postgres sh -ec \ + 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break + (( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; } + sleep 1 +done +"${compose[@]}" build backend +expected_head="$(run_db_alembic postgres heads 2>/dev/null | awk 'NR == 1 { print $1 }')" +[[ "${expected_head}" == "0020_existing_agent_transition" ]] + +echo "Testing selective 0019 backfill from the 0018 schema..." +create_database legacy_retirement_uncredentialed +run_db_alembic legacy_retirement_uncredentialed upgrade 0018_legacy_agent_routing +psql_named legacy_retirement_uncredentialed -c \ + "INSERT INTO minerops_site_agents (agent_id, site_name) VALUES ('legacy-uncredentialed', 'Legacy')" +run_db_alembic legacy_retirement_uncredentialed upgrade head +[[ "$(psql_named legacy_retirement_uncredentialed -Atc "SELECT legacy_auth_disabled_at IS NULL FROM minerops_site_agents WHERE agent_id='legacy-uncredentialed'")" == "t" ]] +[[ "$(psql_named legacy_retirement_uncredentialed -Atc "SELECT count(*) FROM agent_legacy_auth_retirements WHERE agent_id='legacy-uncredentialed'")" == "0" ]] + +create_database legacy_retirement_credentialed +run_db_alembic legacy_retirement_credentialed upgrade 0018_legacy_agent_routing +psql_named legacy_retirement_credentialed <<'SQL' +INSERT INTO minerops_site_agents ( + agent_id, site_name, agent_secret_hash, + credential_created_at, credential_revoked_at +) VALUES ( + 'legacy-credentialed', 'Legacy', repeat('a', 64), + now() - interval '1 hour', now() +); +SQL +run_db_alembic legacy_retirement_credentialed upgrade head +[[ "$(psql_named legacy_retirement_credentialed -Atc "SELECT legacy_auth_disabled_at = credential_created_at FROM minerops_site_agents WHERE agent_id='legacy-credentialed'")" == "t" ]] +[[ "$(psql_named legacy_retirement_credentialed -Atc "SELECT retired_at = (SELECT credential_created_at FROM minerops_site_agents WHERE agent_id='legacy-credentialed') FROM agent_legacy_auth_retirements WHERE agent_id='legacy-credentialed'")" == "t" ]] + +echo "Testing backfill from used enrollment history after credential clearing..." +create_database legacy_retirement_enrollment +run_db_alembic legacy_retirement_enrollment upgrade 0018_legacy_agent_routing +psql_named legacy_retirement_enrollment <<'SQL' +INSERT INTO sites (id, name) +VALUES ('00000000-0000-4000-8000-000000001901', 'Enrollment Evidence'); +INSERT INTO agent_zones (id, site_id, name) +VALUES ( + '00000000-0000-4000-8000-000000001902', + '00000000-0000-4000-8000-000000001901', + 'Enrollment Evidence Zone' +); +INSERT INTO minerops_site_agents ( + agent_id, site_name, site_id, agent_zone_id +) VALUES ( + 'enrollment-evidence-cleared', + 'Enrollment Evidence', + '00000000-0000-4000-8000-000000001901', + '00000000-0000-4000-8000-000000001902' +); +INSERT INTO agent_enrollments ( + token_hash, site_id, agent_zone_id, display_name, + created_at, expires_at, used_at, enrolled_agent_id +) VALUES ( + repeat('c', 64), + '00000000-0000-4000-8000-000000001901', + '00000000-0000-4000-8000-000000001902', + 'Enrollment Evidence Agent', + now() - interval '2 hours', + now() + interval '1 hour', + now() - interval '1 hour', + 'enrollment-evidence-cleared' +); +SQL +run_db_alembic legacy_retirement_enrollment upgrade head +[[ "$(psql_named legacy_retirement_enrollment -Atc "SELECT count(*) FROM agent_legacy_auth_retirements WHERE agent_id='enrollment-evidence-cleared'")" == "1" ]] +[[ "$(psql_named legacy_retirement_enrollment -Atc "SELECT legacy_auth_disabled_at IS NOT NULL AND agent_secret_hash IS NULL AND credential_created_at IS NULL AND credential_revoked_at IS NULL FROM minerops_site_agents WHERE agent_id='enrollment-evidence-cleared'")" == "t" ]] + +echo "Testing real PostgreSQL authentication before and after credential clearing..." +run_contract legacy_retirement_uncredentialed legacy legacy-uncredentialed +run_contract legacy_retirement_credentialed retired legacy-credentialed + +echo "Testing database-enforced atomic retirement and ledger immutability..." +psql_named legacy_retirement_credentialed -c \ + "INSERT INTO minerops_site_agents (agent_id, site_name, agent_secret_hash, credential_created_at) VALUES ('database-issued', 'Issued', repeat('b', 64), now())" +[[ "$(psql_named legacy_retirement_credentialed -Atc "SELECT count(*) FROM agent_legacy_auth_retirements WHERE agent_id='database-issued'")" == "1" ]] +[[ "$(psql_named legacy_retirement_credentialed -Atc "SELECT legacy_auth_disabled_at IS NOT NULL FROM minerops_site_agents WHERE agent_id='database-issued'")" == "t" ]] +if psql_named legacy_retirement_credentialed -c \ + "UPDATE minerops_site_agents SET legacy_auth_disabled_at = NULL WHERE agent_id='legacy-credentialed'"; then + echo "Permanent retirement marker was unexpectedly cleared" >&2 + exit 1 +fi +if psql_named legacy_retirement_credentialed -c \ + "UPDATE agent_legacy_auth_retirements SET retired_at = now() WHERE agent_id='legacy-credentialed'"; then + echo "Retirement history was unexpectedly updated" >&2 + exit 1 +fi +if psql_named legacy_retirement_credentialed -c \ + "DELETE FROM agent_legacy_auth_retirements WHERE agent_id='legacy-credentialed'"; then + echo "Retirement history was unexpectedly deleted" >&2 + exit 1 +fi +if psql_named legacy_retirement_credentialed -c \ + "TRUNCATE agent_legacy_auth_retirements"; then + echo "Retirement history was unexpectedly truncated" >&2 + exit 1 +fi + +echo "Testing downgrade decision points..." +run_db_alembic legacy_retirement_uncredentialed downgrade 0018_legacy_agent_routing +[[ "$(psql_named legacy_retirement_uncredentialed -Atc 'SELECT version_num FROM alembic_version')" == "0018_legacy_agent_routing" ]] +if output="$(run_db_alembic legacy_retirement_credentialed downgrade 0018_legacy_agent_routing 2>&1)"; then + echo "0019 downgrade unexpectedly discarded retirement history" >&2 + exit 1 +fi +[[ "${output}" == *"durable identity-level legacy-auth retirement history exists"* ]] +[[ "$(psql_named legacy_retirement_credentialed -Atc 'SELECT version_num FROM alembic_version')" == "${expected_head}" ]] + +echo "Durable legacy Agent authentication retirement checks passed." diff --git a/scripts/test-agent-zones-migration.sh b/scripts/test-agent-zones-migration.sh index 6c497fc..6fd458c 100755 --- a/scripts/test-agent-zones-migration.sh +++ b/scripts/test-agent-zones-migration.sh @@ -179,8 +179,9 @@ run_alembic upgrade 0016_agent_zones echo "Testing empty database through head..." "${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_agent_zones_empty' "${compose[@]}" run --rm --no-deps -e POSTGRES_DB=minerops_agent_zones_empty backend alembic upgrade head +expected_head="$(run_alembic heads 2>/dev/null | awk 'NR == 1 { print $1 }')" empty_revision="$("${compose[@]}" exec -T postgres sh -ec \ 'psql -U "$POSTGRES_USER" -d minerops_agent_zones_empty -Atc "SELECT version_num FROM alembic_version"')" -[[ "${empty_revision}" == "0016_agent_zones" ]] +[[ -n "${expected_head}" && "${empty_revision}" == "${expected_head}" ]] echo "Agent Zones migration lifecycle checks passed." diff --git a/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh index 9bceae8..4da189d 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -132,7 +132,9 @@ health_json="$(cat "${health_file}")" echo "Checking migrations and required schema..." current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" -[[ "${current_revision}" == *"0017_agent_enrollment"* ]] +expected_revision="$("${compose[@]}" exec -T backend alembic heads 2>/dev/null | awk 'NR == 1 { print $1 }')" +[[ -n "${expected_revision}" ]] +[[ "${current_revision}" == *"${expected_revision}"* ]] missing_tables="$( "${compose[@]}" exec -T postgres sh -c \ diff --git a/scripts/test-legacy-agent-http-postgres-contract.sh b/scripts/test-legacy-agent-http-postgres-contract.sh new file mode 100755 index 0000000..b1c9eab --- /dev/null +++ b/scripts/test-legacy-agent-http-postgres-contract.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" +project_name="${MINEROPS_LEGACY_HTTP_PROJECT:-minerops-legacy-http-$$}" +compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml) + +cleanup() { + status=$? + if (( status != 0 )); then + "${compose[@]}" logs --no-color --tail 160 postgres || true + fi + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + exit "${status}" +} +trap cleanup EXIT + +[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; } +"${compose[@]}" up --detach postgres +for attempt in $(seq 1 60); do + "${compose[@]}" exec -T postgres sh -ec \ + 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break + (( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; } + sleep 1 +done +"${compose[@]}" build backend + +echo "Building a production-shaped 0014 database..." +"${compose[@]}" run --rm --no-deps backend \ + alembic upgrade 0014_repair_parts_tracking +"${compose[@]}" exec -T postgres sh -ec \ + 'exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL' +INSERT INTO sites (id, name) VALUES + ('00000000-0000-4000-8000-000000001a01', 'Plexus'), + ('00000000-0000-4000-8000-000000001a02', 'Contract Site B'); +INSERT INTO miners ( + id, site_id, asset_tag, current_ip, current_mac, status, + first_seen_at, last_seen_at +) VALUES + ( + '00000000-0000-4000-8000-000000001a11', + '00000000-0000-4000-8000-000000001a01', + 'LEGACY-HTTP-A', '192.0.2.201', '02:00:00:00:1a:11', + 'online', NULL, NULL + ), + ( + '00000000-0000-4000-8000-000000001a12', + '00000000-0000-4000-8000-000000001a02', + 'LEGACY-HTTP-B', '192.0.2.202', '02:00:00:00:1a:12', + 'online', NULL, NULL + ); +INSERT INTO minerops_site_agents (agent_id, site_name) +VALUES ('legacy-plexus-http', ' plexus '); +SQL +"${compose[@]}" run --rm --no-deps backend alembic upgrade head + +echo "Running real ASGI authentication and PostgreSQL routing checks..." +"${compose[@]}" run --rm --no-deps \ + --volume "${repo_root}/backend/tests:/app/tests:ro" \ + --env MINEROPS_LEGACY_HTTP_POSTGRES_DISPOSABLE=1 \ + --env MINEROPS_AGENT_ID=legacy-plexus-http \ + --env MINEROPS_AGENT_SHARED_SECRET=legacy-http-contract-secret \ + backend python -m unittest -v \ + tests.test_legacy_agent_http_postgres + +echo "Full-stack legacy Agent HTTP/PostgreSQL contract passed." diff --git a/scripts/test-legacy-agent-production-contract.sh b/scripts/test-legacy-agent-production-contract.sh new file mode 100755 index 0000000..203409c --- /dev/null +++ b/scripts/test-legacy-agent-production-contract.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" +project_name="${MINEROPS_LEGACY_PRODUCTION_PROJECT:-minerops-legacy-production-$$}" +compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml) + +cleanup() { + status=$? + if (( status != 0 )); then + "${compose[@]}" logs --no-color --tail 160 postgres || true + fi + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + exit "${status}" +} +trap cleanup EXIT + +[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; } +"${compose[@]}" up --detach postgres +for attempt in $(seq 1 60); do + "${compose[@]}" exec -T postgres sh -ec \ + 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break + (( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; } + sleep 1 +done +"${compose[@]}" build backend + +echo "Building the production-shaped database from the 0014 baseline..." +"${compose[@]}" run --rm --no-deps backend \ + alembic upgrade 0014_repair_parts_tracking +"${compose[@]}" exec -T postgres sh -ec \ + 'exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL' +INSERT INTO sites (id, name) VALUES + ('00000000-0000-4000-8000-000000001901', 'Plexus'), + ('00000000-0000-4000-8000-000000001902', 'Contract Site B'); +INSERT INTO miners ( + id, site_id, asset_tag, current_ip, current_mac, status, + first_seen_at, last_seen_at +) VALUES + ( + '00000000-0000-4000-8000-000000001911', + '00000000-0000-4000-8000-000000001901', + 'LEGACY-CONTRACT-A', '192.0.2.191', '02:00:00:00:19:11', + 'online', NULL, NULL + ), + ( + '00000000-0000-4000-8000-000000001912', + '00000000-0000-4000-8000-000000001902', + 'LEGACY-CONTRACT-B', '192.0.2.192', '02:00:00:00:19:12', + 'online', NULL, NULL + ); +INSERT INTO minerops_site_agents (agent_id, site_name) +VALUES ('legacy-plexus-production', ' plexus '); +SQL +"${compose[@]}" run --rm --no-deps backend alembic upgrade head + +echo "Running legacy authentication, routing, and inert telemetry contract..." +"${compose[@]}" run --rm --no-deps \ + --volume "${repo_root}/backend/tests:/app/tests:ro" \ + --env MINEROPS_LEGACY_PRODUCTION_DISPOSABLE=1 \ + --env MINEROPS_AGENT_ID=legacy-plexus-production \ + --env MINEROPS_AGENT_SHARED_SECRET=legacy-production-contract-secret \ + backend python -m unittest -v \ + tests.test_legacy_agent_production_postgres + +echo "Legacy production compatibility contract passed." diff --git a/scripts/test-legacy-agent-routing-migration.sh b/scripts/test-legacy-agent-routing-migration.sh new file mode 100755 index 0000000..e6c3ab4 --- /dev/null +++ b/scripts/test-legacy-agent-routing-migration.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" +project_name="${MINEROPS_LEGACY_ROUTING_PROJECT:-minerops-legacy-routing-$$}" +compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml) + +cleanup() { + status=$? + if (( status != 0 )); then + "${compose[@]}" logs --no-color --tail 160 postgres || true + fi + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + exit "${status}" +} +trap cleanup EXIT + +run_db_alembic() { + local database="$1" + shift + "${compose[@]}" run --rm --no-deps -e POSTGRES_DB="${database}" backend alembic "$@" +} +psql_named() { + local database="$1" + shift + "${compose[@]}" exec -T postgres sh -ec \ + 'database="$1"; shift; exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$database" "$@"' \ + sh "${database}" "$@" +} +create_database() { + "${compose[@]}" exec -T postgres sh -ec \ + 'exec createdb -U "$POSTGRES_USER" "$1"' sh "$1" +} + +[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; } +"${compose[@]}" up --detach postgres +for attempt in $(seq 1 60); do + "${compose[@]}" exec -T postgres sh -ec \ + 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break + (( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; } + sleep 1 +done +"${compose[@]}" build backend +expected_revision="0018_legacy_agent_routing" + +echo "Testing the real single-Site production shape from 0014 -> 0018..." +create_database legacy_routing_happy +run_db_alembic legacy_routing_happy upgrade 0014_repair_parts_tracking +psql_named legacy_routing_happy <<'SQL' +DELETE FROM sites; +INSERT INTO sites (id, name) +VALUES ( + '191e6d8f-ce40-4488-a8f1-ad6ce1790266', + 'Plexus Mining Site' +); +INSERT INTO miners (id, site_id, asset_tag, first_seen_at, last_seen_at, current_mac) +VALUES ( + '00000000-0000-4000-8000-000000001811', + '191e6d8f-ce40-4488-a8f1-ad6ce1790266', + 'LEGACY-ROUTING-MINER', NULL, NULL, '02:00:00:00:18:11' +); +INSERT INTO minerops_site_agents (agent_id, site_name) +VALUES ('plexus-windows-agent', 'Plexus'); +INSERT INTO minerops_agent_snapshots (agent_id, site_name, miner_count, payload) +VALUES ('plexus-windows-agent', 'Plexus', 1, '{}'); +INSERT INTO minerops_agent_discoveries ( + id, agent_id, site_name, ip_address, raw_payload, discovery_status +) VALUES ( + 'legacy-routing-discovery', 'plexus-windows-agent', 'Plexus', + '192.0.2.181', '{}', 'new' +); +SQL +run_db_alembic legacy_routing_happy upgrade "${expected_revision}" +[[ "$(psql_named legacy_routing_happy -Atc 'SELECT version_num FROM alembic_version')" == "${expected_revision}" ]] +psql_named legacy_routing_happy <<'SQL' +DO $$ +DECLARE + expected_site uuid := '191e6d8f-ce40-4488-a8f1-ad6ce1790266'; + expected_zone uuid; +BEGIN + SELECT id INTO expected_zone FROM agent_zones + WHERE site_id = expected_site AND lower(name) = 'default zone' AND is_enabled; + IF expected_zone IS NULL THEN + RAISE EXCEPTION 'Expected enabled Default Zone is missing'; + END IF; + IF (SELECT site_id FROM minerops_site_agents + WHERE agent_id = 'plexus-windows-agent') + IS DISTINCT FROM expected_site THEN + RAISE EXCEPTION 'Sole-Site legacy Agent resolution failed'; + END IF; + IF (SELECT agent_zone_id FROM minerops_site_agents + WHERE agent_id = 'plexus-windows-agent') + IS DISTINCT FROM expected_zone THEN + RAISE EXCEPTION 'Legacy Agent Zone routing failed'; + END IF; + IF (SELECT agent_zone_id FROM miners + WHERE id = '00000000-0000-4000-8000-000000001811') + IS DISTINCT FROM expected_zone THEN + RAISE EXCEPTION 'Existing miner ownership was not preserved in its Site default Zone'; + END IF; + IF (SELECT current_mac FROM miners + WHERE id = '00000000-0000-4000-8000-000000001811') + IS DISTINCT FROM '02:00:00:00:18:11' THEN + RAISE EXCEPTION 'Miner identity/history projection was rewritten'; + END IF; + IF EXISTS ( + SELECT 1 FROM minerops_agent_snapshots + WHERE agent_id = 'plexus-windows-agent' + AND agent_zone_id IS DISTINCT FROM expected_zone + ) THEN + RAISE EXCEPTION 'Legacy snapshots were not attributed to the Agent Zone'; + END IF; + IF EXISTS ( + SELECT 1 FROM minerops_agent_discoveries + WHERE agent_id = 'plexus-windows-agent' + AND agent_zone_id IS DISTINCT FROM expected_zone + ) THEN + RAISE EXCEPTION 'Legacy discoveries were not attributed to the Agent Zone'; + END IF; +END $$; +SQL + +echo "Testing unmatched multi-Site data fails closed before routing writes..." +create_database legacy_routing_missing +run_db_alembic legacy_routing_missing upgrade 0014_repair_parts_tracking +psql_named legacy_routing_missing <<'SQL' +DELETE FROM sites; +INSERT INTO sites (id, name) VALUES + ('00000000-0000-4000-8000-000000001801', 'Candidate Site A'), + ('00000000-0000-4000-8000-000000001802', 'Candidate Site B'); +SQL +psql_named legacy_routing_missing -c \ + "INSERT INTO minerops_site_agents (agent_id, site_name) VALUES ('missing-agent', 'No Such Site')" +run_db_alembic legacy_routing_missing upgrade 0017_agent_enrollment +if output="$(run_db_alembic legacy_routing_missing upgrade "${expected_revision}" 2>&1)"; then + echo "Missing Site match unexpectedly passed" >&2 + exit 1 +fi +[[ "${output}" == *"unmatched multi-Site installations fail closed"* ]] +[[ "${output}" == *"missing-agent"* && "${output}" == *"/'0'/'2'"* ]] +[[ "$(psql_named legacy_routing_missing -Atc 'SELECT version_num FROM alembic_version')" == "0017_agent_enrollment" ]] +[[ "$(psql_named legacy_routing_missing -Atc "SELECT site_id IS NULL FROM minerops_site_agents WHERE agent_id='missing-agent'")" == "t" ]] +[[ "$(psql_named legacy_routing_missing -Atc "SELECT agent_zone_id IS NULL FROM minerops_site_agents WHERE agent_id='missing-agent'")" == "t" ]] + +echo "Testing ambiguous normalized Site match fails closed..." +create_database legacy_routing_ambiguous +run_db_alembic legacy_routing_ambiguous upgrade 0014_repair_parts_tracking +psql_named legacy_routing_ambiguous <<'SQL' +DELETE FROM sites; +INSERT INTO sites (id, name) VALUES + ('00000000-0000-4000-8000-000000001821', 'Ambiguous Site'), + ('00000000-0000-4000-8000-000000001822', 'ambiguous site'); +INSERT INTO minerops_site_agents (agent_id, site_name) +VALUES ('ambiguous-agent', ' AMBIGUOUS SITE '); +SQL +run_db_alembic legacy_routing_ambiguous upgrade 0017_agent_enrollment +if output="$(run_db_alembic legacy_routing_ambiguous upgrade "${expected_revision}" 2>&1)"; then + echo "Ambiguous Site match unexpectedly passed" >&2 + exit 1 +fi +[[ "${output}" == *"ambiguous-agent"* && "${output}" == *"/'2'"* ]] +[[ "$(psql_named legacy_routing_ambiguous -Atc 'SELECT version_num FROM alembic_version')" == "0017_agent_enrollment" ]] +[[ "$(psql_named legacy_routing_ambiguous -Atc "SELECT site_id IS NULL AND agent_zone_id IS NULL FROM minerops_site_agents WHERE agent_id='ambiguous-agent'")" == "t" ]] + +echo "Testing an exact normalized match wins in a multi-Site database..." +create_database legacy_routing_exact_multi +run_db_alembic legacy_routing_exact_multi upgrade 0014_repair_parts_tracking +psql_named legacy_routing_exact_multi <<'SQL' +DELETE FROM sites; +INSERT INTO sites (id, name) VALUES + ('00000000-0000-4000-8000-000000001825', 'Exact Site'), + ('00000000-0000-4000-8000-000000001826', 'Unrelated Site'); +INSERT INTO minerops_site_agents (agent_id, site_name) +VALUES ('exact-multi-agent', ' eXaCt SiTe '); +SQL +run_db_alembic legacy_routing_exact_multi upgrade 0017_agent_enrollment +run_db_alembic legacy_routing_exact_multi upgrade "${expected_revision}" +[[ "$(psql_named legacy_routing_exact_multi -Atc "SELECT site_id FROM minerops_site_agents WHERE agent_id='exact-multi-agent'")" == "00000000-0000-4000-8000-000000001825" ]] +[[ "$(psql_named legacy_routing_exact_multi -Atc "SELECT agent.agent_zone_id = zone.id FROM minerops_site_agents AS agent JOIN agent_zones AS zone ON zone.site_id=agent.site_id AND zone.name='Default Zone' WHERE agent.agent_id='exact-multi-agent'")" == "t" ]] + +echo "Testing two legacy Agents targeting one Default Zone fails closed..." +create_database legacy_routing_conflict +run_db_alembic legacy_routing_conflict upgrade 0014_repair_parts_tracking +psql_named legacy_routing_conflict <<'SQL' +DELETE FROM sites; +INSERT INTO sites (id, name) +VALUES ('00000000-0000-4000-8000-000000001831', 'Sole Durable Site'); +INSERT INTO minerops_site_agents (agent_id, site_name) VALUES + ('conflict-agent-a', 'Legacy Display A'), + ('conflict-agent-b', 'Legacy Display B'); +SQL +run_db_alembic legacy_routing_conflict upgrade 0017_agent_enrollment +if output="$(run_db_alembic legacy_routing_conflict upgrade "${expected_revision}" 2>&1)"; then + echo "One-Agent-per-Zone conflict unexpectedly passed" >&2 + exit 1 +fi +[[ "${output}" == *"one-Agent-per-Zone would be violated"* ]] +[[ "$(psql_named legacy_routing_conflict -Atc 'SELECT version_num FROM alembic_version')" == "0017_agent_enrollment" ]] +[[ "$(psql_named legacy_routing_conflict -Atc 'SELECT count(*) FROM minerops_site_agents WHERE site_id IS NOT NULL')" == "0" ]] +[[ "$(psql_named legacy_routing_conflict -Atc 'SELECT count(*) FROM minerops_site_agents WHERE agent_zone_id IS NOT NULL')" == "0" ]] + +echo "Testing explicit Site and Zone assignments win..." +create_database legacy_routing_explicit +run_db_alembic legacy_routing_explicit upgrade 0017_agent_enrollment +psql_named legacy_routing_explicit <<'SQL' +INSERT INTO sites (id, name) +VALUES ('00000000-0000-4000-8000-000000001841', 'Explicit Site'); +INSERT INTO agent_zones ( + id, site_id, name +) VALUES ( + '00000000-0000-4000-8000-000000001842', + '00000000-0000-4000-8000-000000001841', + 'Explicit Zone' +); +INSERT INTO minerops_site_agents ( + agent_id, site_id, agent_zone_id, site_name +) VALUES ( + 'explicit-agent', + '00000000-0000-4000-8000-000000001841', + '00000000-0000-4000-8000-000000001842', + 'A Different Display Name' +); +SQL +run_db_alembic legacy_routing_explicit upgrade "${expected_revision}" +[[ "$(psql_named legacy_routing_explicit -Atc "SELECT site_id || '/' || agent_zone_id FROM minerops_site_agents WHERE agent_id='explicit-agent'")" == "00000000-0000-4000-8000-000000001841/00000000-0000-4000-8000-000000001842" ]] + +echo "Testing conservative downgrade refusal..." +if output="$(run_db_alembic legacy_routing_happy downgrade 0017_agent_enrollment 2>&1)"; then + echo "0018 downgrade unexpectedly discarded routing attribution" >&2 + exit 1 +fi +[[ "${output}" == *"Restore the pre-upgrade database backup"* ]] +[[ "$(psql_named legacy_routing_happy -Atc 'SELECT version_num FROM alembic_version')" == "${expected_revision}" ]] + +echo "Legacy Agent routing migration lifecycle checks passed." diff --git a/scripts/test-miner-control-migration.sh b/scripts/test-miner-control-migration.sh index 14f5d42..ccf3880 100755 --- a/scripts/test-miner-control-migration.sh +++ b/scripts/test-miner-control-migration.sh @@ -130,8 +130,9 @@ run_alembic upgrade 0015_miner_control_foundation echo "Testing empty database through head..." "${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_control_empty' "${compose[@]}" run --rm --no-deps -e POSTGRES_DB=minerops_control_empty backend alembic upgrade head +expected_head="$(run_alembic heads 2>/dev/null | awk 'NR == 1 { print $1 }')" empty_revision="$("${compose[@]}" exec -T postgres sh -ec \ 'psql -U "$POSTGRES_USER" -d minerops_control_empty -Atc "SELECT version_num FROM alembic_version"')" -[[ "${empty_revision}" == "0015_miner_control_foundation" ]] +[[ -n "${expected_head}" && "${empty_revision}" == "${expected_head}" ]] echo "Miner Control Foundation migration tests passed."