feat(agent): add durable existing-agent credential transitions

This commit is contained in:
lussierj committed 2026-10-14 13:50:07 +00:00
1 parent abf5654a25
commit 3cfb0d1abe
33 files changed
+3828 -54

No files matched your search

@@ -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."
)
@@ -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")
@@ -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")
+12 -6
View File
@@ -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,),
)
+43 -7
View File
@@ -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"
+55
View File
@@ -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
+464 -2
View File
@@ -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()
+54
View File
@@ -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
+36
View File
@@ -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,
)
+1 -1
View File
@@ -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"]
+4
View File
@@ -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
+44
View File
@@ -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]:
+22
View File
@@ -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:
+3
View File
@@ -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/"):
+20 -27
View File
@@ -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.",
}
+86 -3
View File
@@ -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()
@@ -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()
+149 -1
View File
@@ -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()
+180 -1
View File
@@ -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()
@@ -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()
@@ -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()
)
""",
+36
View File
@@ -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):
@@ -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()
@@ -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()
@@ -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()
+6 -2
View File
@@ -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
+164
View File
@@ -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."
+2 -1
View File
@@ -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."
+3 -1
View File
@@ -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 \
+67
View File
@@ -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."
+67
View File
@@ -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."
+236
View File
@@ -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."
+2 -1
View File
@@ -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."