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: with connection.cursor(row_factory=dict_row) as cur:
cur.execute( cur.execute(
""" """
SELECT agent_id, SELECT identity.agent_id,
agent_secret_hash, agent.agent_secret_hash,
credential_created_at, agent.credential_created_at,
credential_revoked_at agent.credential_revoked_at,
FROM minerops_site_agents retirement.retired_at AS legacy_auth_disabled_at
WHERE agent_id = %s 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,), (agent_id,),
) )
+43 -7
View File
@@ -25,19 +25,50 @@ class AgentAuthService:
identity = (agent_id or "").strip() identity = (agent_id or "").strip()
secret = (provided_secret or "").strip() secret = (provided_secret or "").strip()
if not identity:
raise AgentAuthenticationFailed("Missing Agent identity")
if not secret: if not secret:
raise AgentAuthenticationFailed("Invalid or missing Agent 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) 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 # Any Agent with a per-Agent credential is permanently on the new
# authentication path. The legacy shared secret must never authenticate # authentication path. The legacy shared secret must never authenticate
# that identity, even if the Agent later becomes revoked. # that identity, even if the Agent later becomes revoked.
if credential and credential.get("agent_secret_hash"): if credential and credential.get("agent_secret_hash"):
if credential.get("credential_revoked_at") is not None: # Once the configured legacy identity has ever received a
raise AgentCredentialRevoked("Agent credential has been revoked") # 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) supplied_hash = hash_agent_secret(secret)
if not hmac.compare_digest( if not hmac.compare_digest(
@@ -48,10 +79,15 @@ class AgentAuthService:
return identity return identity
# Temporary compatibility for the single pre-enrollment Agent. # The durable identity retirement survives Agent-row deletion,
legacy_id = settings.minerops_agent_id.strip() # credential-field corruption, administrative mistakes, and future
legacy_secret = settings.minerops_agent_shared_secret.strip() # 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: if not legacy_id or not legacy_secret:
raise AgentAuthenticationUnavailable( raise AgentAuthenticationUnavailable(
"Legacy Agent authentication is not configured" "Legacy Agent authentication is not configured"
+55
View File
@@ -53,7 +53,9 @@ class AgentEnrollmentSummary(BaseModel):
expires_at: datetime expires_at: datetime
used_at: datetime | None = None used_at: datetime | None = None
revoked_at: datetime | None = None revoked_at: datetime | None = None
target_agent_id: str | None = None
enrolled_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"] status: Literal["waiting", "used", "revoked", "expired"]
@@ -96,3 +98,56 @@ class AgentEnrollResponse(BaseModel):
class AgentWindowsPackageRequest(AgentEnrollRequest): class AgentWindowsPackageRequest(AgentEnrollRequest):
"""Short-lived token used to construct a Windows bootstrap package.""" """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.""" """PostgreSQL persistence for secure Agent enrollment."""
import hmac
from collections.abc import Callable from collections.abc import Callable
from uuid import UUID from uuid import UUID
@@ -21,6 +22,7 @@ ConnectionFactory = Callable[[], psycopg.Connection]
TOKEN_HASH_CONSTRAINT = "uq_agent_enrollments_token_hash" TOKEN_HASH_CONSTRAINT = "uq_agent_enrollments_token_hash"
OPEN_ZONE_CONSTRAINT = "uq_agent_enrollments_open_zone" 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_SECRET_HASH_CONSTRAINT = "uq_minerops_site_agents_agent_secret_hash"
AGENT_ZONE_CONSTRAINT = "uq_minerops_site_agents_agent_zone_id" AGENT_ZONE_CONSTRAINT = "uq_minerops_site_agents_agent_zone_id"
AGENT_ID_CONSTRAINT = "minerops_site_agents_pkey" AGENT_ID_CONSTRAINT = "minerops_site_agents_pkey"
@@ -214,6 +216,184 @@ class AgentEnrollmentRepository:
finally: finally:
connection.close() 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( def list_enrollments(
self, self,
*, *,
@@ -238,7 +418,13 @@ class AgentEnrollmentRepository:
enrollment.expires_at, enrollment.expires_at,
enrollment.used_at, enrollment.used_at,
enrollment.revoked_at, enrollment.revoked_at,
enrollment.target_agent_id,
enrollment.enrolled_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 CASE
WHEN enrollment.used_at IS NOT NULL WHEN enrollment.used_at IS NOT NULL
THEN 'used' THEN 'used'
@@ -292,7 +478,8 @@ class AgentEnrollmentRepository:
display_name, display_name,
expires_at, expires_at,
used_at, used_at,
revoked_at revoked_at,
target_agent_id
FROM agent_enrollments FROM agent_enrollments
WHERE token_hash = %s WHERE token_hash = %s
FOR UPDATE FOR UPDATE
@@ -309,6 +496,7 @@ class AgentEnrollmentRepository:
if ( if (
enrollment["used_at"] is not None enrollment["used_at"] is not None
or enrollment["revoked_at"] is not None or enrollment["revoked_at"] is not None
or enrollment["target_agent_id"] is not None
): ):
raise AgentEnrollmentTokenInvalid( raise AgentEnrollmentTokenInvalid(
"Invalid or expired enrollment token" "Invalid or expired enrollment token"
@@ -371,6 +559,22 @@ class AgentEnrollmentRepository:
"Invalid or expired enrollment token" "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( cur.execute(
""" """
INSERT INTO minerops_site_agents ( INSERT INTO minerops_site_agents (
@@ -380,7 +584,8 @@ class AgentEnrollmentRepository:
site_name, site_name,
display_name, display_name,
agent_secret_hash, agent_secret_hash,
credential_created_at credential_created_at,
legacy_auth_disabled_at
) )
VALUES ( VALUES (
%s, %s,
@@ -389,6 +594,7 @@ class AgentEnrollmentRepository:
%s, %s,
%s, %s,
%s, %s,
%s,
%s %s
) )
""", """,
@@ -400,6 +606,7 @@ class AgentEnrollmentRepository:
enrollment["display_name"], enrollment["display_name"],
agent_secret_hash, agent_secret_hash,
operation_now, operation_now,
operation_now,
), ),
) )
@@ -504,3 +711,258 @@ class AgentEnrollmentRepository:
finally: finally:
connection.close() 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.dependencies import get_agent_enrollment_service
from app.agent_enrollment.exceptions import ( from app.agent_enrollment.exceptions import (
AgentEnrollmentConflict, AgentEnrollmentConflict,
AgentEnrollmentCredentialCollision,
AgentEnrollmentNotFound, AgentEnrollmentNotFound,
AgentEnrollmentTokenInvalid, AgentEnrollmentTokenInvalid,
) )
from app.agent_enrollment.models import ( from app.agent_enrollment.models import (
AgentCredentialTransitionCreateRequest,
AgentCredentialTransitionCreateResponse,
AgentCredentialTransitionRedeemRequest,
AgentCredentialTransitionResponse,
AgentEnrollmentCreateRequest, AgentEnrollmentCreateRequest,
AgentEnrollmentCreateResponse, AgentEnrollmentCreateResponse,
AgentEnrollmentListResponse, AgentEnrollmentListResponse,
@@ -70,6 +75,31 @@ def create_agent_enrollment(
raise HTTPException(status_code=409, detail=str(exc)) from exc 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( @router.get(
"/api/agent-enrollments", "/api/agent-enrollments",
response_model=AgentEnrollmentListResponse, response_model=AgentEnrollmentListResponse,
@@ -157,3 +187,27 @@ def enroll_agent(
) from exc ) from exc
except AgentEnrollmentConflict as exc: except AgentEnrollmentConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from 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, AgentEnrollmentCredentialCollision,
) )
from app.agent_enrollment.models import ( from app.agent_enrollment.models import (
AgentCredentialTransitionCreateRequest,
AgentCredentialTransitionRedeemRequest,
AgentEnrollmentCreateRequest, AgentEnrollmentCreateRequest,
AgentEnrollRequest, AgentEnrollRequest,
) )
@@ -73,6 +75,31 @@ class AgentEnrollmentService:
limit=limit, 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: def redeem(self, request: AgentEnrollRequest) -> dict:
token_hash = hash_agent_secret(request.token) token_hash = hash_agent_secret(request.token)
@@ -96,3 +123,12 @@ class AgentEnrollmentService:
raise AgentEnrollmentConflict( raise AgentEnrollmentConflict(
"Unable to generate unique Agent credentials" "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 from pydantic import BaseModel, Field
CredentialState = Literal["legacy", "active", "revoked"] CredentialState = Literal["legacy", "active", "revoked", "retired"]
RoutingState = Literal["assigned", "unassigned", "disabled"] RoutingState = Literal["assigned", "unassigned", "disabled"]
+4
View File
@@ -103,9 +103,13 @@ class AgentRegistryRepository:
THEN 'revoked' THEN 'revoked'
WHEN agent.agent_secret_hash IS NOT NULL WHEN agent.agent_secret_hash IS NOT NULL
THEN 'active' THEN 'active'
WHEN retirement.retired_at IS NOT NULL
THEN 'retired'
ELSE 'legacy' ELSE 'legacy'
END AS credential_state END AS credential_state
FROM minerops_site_agents AS agent 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 LEFT JOIN sites AS site
ON site.id = agent.site_id ON site.id = agent.site_id
LEFT JOIN agent_zones AS zone LEFT JOIN agent_zones AS zone
+44
View File
@@ -250,6 +250,50 @@ class AgentZoneRepository:
finally: finally:
connection.close() 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( def assign_miners_to_zone(
self, miner_ids: Iterable[UUID], agent_zone_id: UUID self, miner_ids: Iterable[UUID], agent_zone_id: UUID
) -> dict[UUID, str]: ) -> dict[UUID, str]:
+22
View File
@@ -177,6 +177,28 @@ class AgentZoneService:
site_id=assignment.get("site_id"), agent_zone_id=zone_id 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( def associate_miners_from_agent(
self, agent_id: str, miner_ids: list[UUID] self, agent_id: str, miner_ids: list[UUID]
) -> MinerZoneAssociationResult: ) -> MinerZoneAssociationResult:
+3
View File
@@ -700,6 +700,7 @@ def required_page_permission(path: str) -> str:
def machine_agent_request(path: str) -> bool: def machine_agent_request(path: str) -> bool:
exact = { exact = {
"/api/agent/enroll", "/api/agent/enroll",
"/api/agent/credential-transition",
"/api/agent/checkin", "/api/agent/checkin",
"/api/agent/config", "/api/agent/config",
"/api/agent/known-miners", "/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" return "agents.view" if method == "GET" else "agents.manage"
if path == "/api/agent-enrollments/windows-package": if path == "/api/agent-enrollments/windows-package":
return "agents.manage" return "agents.manage"
if path == "/api/agent-credential-transitions":
return "agents.manage"
if repair_parts_request(path): if repair_parts_request(path):
return "repairs.read" if method == "GET" else "repairs.parts.manage" return "repairs.read" if method == "GET" else "repairs.parts.manage"
if path.startswith("/api/admin/"): if path.startswith("/api/admin/"):
+20 -27
View File
@@ -25102,37 +25102,30 @@ async def startup_ensure_minerops_agent_tables():
@app.get("/api/agent/config") @app.get("/api/agent/config")
async def minerops_agent_config(request: Request): async def minerops_agent_config(
minerops_require_agent_secret(request) 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 { return {
"ok": True, "ok": True,
"site_name": "Plexus", **configuration,
"poll_interval_seconds": 120, "poll_interval_seconds": 120,
"hub_url": "https://plexus.lussier.cc", "hub_url": str(request.base_url).rstrip("/"),
"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,
},
],
"message": "Agent config from hub.", "message": "Agent config from hub.",
} }
+86 -3
View File
@@ -49,6 +49,14 @@ class AgentAuthServiceTests(unittest.TestCase):
"legacy-agent", "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): def test_legacy_agent_with_null_credential_still_authenticates(self):
rows = { rows = {
"legacy-agent": { "legacy-agent": {
@@ -116,13 +124,83 @@ class AgentAuthServiceTests(unittest.TestCase):
with self.subTest(secret=secret), self.assertRaises(AgentCredentialRevoked): with self.subTest(secret=secret), self.assertRaises(AgentCredentialRevoked):
self.service(rows).authenticate("agent-new", secret) 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): def test_unknown_identity_does_not_use_shared_secret(self):
with self.assertRaises(AgentIdentityUnauthorized): with self.assertRaises(AgentIdentityUnauthorized):
self.service().authenticate("unknown-agent", "legacy-secret") self.service().authenticate("unknown-agent", "legacy-secret")
def test_missing_identity_or_secret_is_rejected(self): def test_missing_secret_is_rejected(self):
with self.assertRaises(AgentAuthenticationFailed):
self.service().authenticate(None, "legacy-secret")
with self.assertRaises(AgentAuthenticationFailed): with self.assertRaises(AgentAuthenticationFailed):
self.service().authenticate("legacy-agent", None) self.service().authenticate("legacy-agent", None)
@@ -131,6 +209,11 @@ class AgentAuthServiceTests(unittest.TestCase):
with self.assertRaises(AgentAuthenticationUnavailable): with self.assertRaises(AgentAuthenticationUnavailable):
self.service().authenticate("legacy-agent", "anything") 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__": if __name__ == "__main__":
unittest.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 datetime import datetime, timedelta, timezone
from uuid import UUID 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_auth.service import hash_agent_secret
from app.agent_enrollment.models import ( from app.agent_enrollment.models import (
AgentCredentialTransitionCreateRequest,
AgentCredentialTransitionRedeemRequest,
AgentEnrollmentCreateRequest, AgentEnrollmentCreateRequest,
AgentEnrollRequest, AgentEnrollRequest,
) )
from app.agent_enrollment.service import AgentEnrollmentService 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") SITE_ID = UUID("00000000-0000-4000-8000-000000001700")
@@ -25,6 +36,8 @@ class FakeAgentEnrollmentRepository:
def __init__(self): def __init__(self):
self.created = None self.created = None
self.redeemed = None self.redeemed = None
self.transition_created = None
self.transition_redeemed = None
def create_enrollment(self, **kwargs): def create_enrollment(self, **kwargs):
self.created = kwargs self.created = kwargs
@@ -42,6 +55,20 @@ class FakeAgentEnrollmentRepository:
def list_enrollments(self, **kwargs): def list_enrollments(self, **kwargs):
return [] 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): def redeem_enrollment(self, **kwargs):
self.redeemed = kwargs self.redeemed = kwargs
return { return {
@@ -54,6 +81,18 @@ class FakeAgentEnrollmentRepository:
"ip_ranges": ["10.30.0.0/22"], "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): class AgentEnrollmentServiceTests(unittest.TestCase):
def test_create_returns_plaintext_token_once_but_repository_gets_only_hash(self): 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) serialized = repr(repository.redeemed)
self.assertNotIn(secret, serialized) 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): def test_enrollment_admin_permission_mapping_is_explicit(self):
self.assertEqual( self.assertEqual(
required_api_permission("GET", "/api/agent-enrollments"), required_api_permission("GET", "/api/agent-enrollments"),
@@ -132,9 +210,18 @@ class AgentEnrollmentServiceTests(unittest.TestCase):
required_api_permission("POST", "/api/agent-enrollments"), required_api_permission("POST", "/api/agent-enrollments"),
"agents.manage", "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): 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/enroll"))
self.assertTrue(
machine_agent_request("/api/agent/credential-transition")
)
self.assertFalse( self.assertFalse(
machine_agent_request("/api/agent-enrollments") 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+180 -1
View File
@@ -4,6 +4,7 @@ import unittest
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from uuid import uuid4 from uuid import uuid4
from psycopg.errors import ForeignKeyViolation
from psycopg.rows import dict_row from psycopg.rows import dict_row
from app.agent_auth.exceptions import AgentAuthenticationFailed 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_auth.service import AgentAuthService, hash_agent_secret
from app.agent_enrollment.exceptions import ( from app.agent_enrollment.exceptions import (
AgentEnrollmentConflict, AgentEnrollmentConflict,
AgentEnrollmentCredentialCollision,
AgentEnrollmentNotFound, AgentEnrollmentNotFound,
AgentEnrollmentTokenInvalid, AgentEnrollmentTokenInvalid,
) )
@@ -26,6 +28,7 @@ from app.agent_enrollment.service import AgentEnrollmentService
from app.agent_zones.models import AgentCheckinRequest from app.agent_zones.models import AgentCheckinRequest
from app.agent_zones.repository import AgentZoneRepository from app.agent_zones.repository import AgentZoneRepository
from app.agent_zones.service import AgentZoneService from app.agent_zones.service import AgentZoneService
from app.config import settings
class AgentEnrollmentPostgresTests(unittest.TestCase): class AgentEnrollmentPostgresTests(unittest.TestCase):
@@ -322,6 +325,7 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
agent_secret_hash, agent_secret_hash,
credential_created_at, credential_created_at,
credential_revoked_at, credential_revoked_at,
legacy_auth_disabled_at,
last_payload::text AS last_payload last_payload::text AS last_payload
FROM minerops_site_agents FROM minerops_site_agents
WHERE site_id = %s WHERE site_id = %s
@@ -345,6 +349,16 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
) )
enrollment_row = cur.fetchone() 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( cur.execute(
""" """
SELECT new_value_json::text AS payload SELECT new_value_json::text AS payload
@@ -369,6 +383,15 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
) )
self.assertIsNotNone(agent["credential_created_at"]) self.assertIsNotNone(agent["credential_created_at"])
self.assertIsNone(agent["credential_revoked_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.assertIsNotNone(enrollment_row["used_at"])
self.assertIsNone(enrollment_row["revoked_at"]) self.assertIsNone(enrollment_row["revoked_at"])
@@ -409,6 +432,17 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
"definitely-not-the-issued-secret", "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. # Normal check-in must never mutate the credential.
secret_hash_before = agent["agent_secret_hash"] secret_hash_before = agent["agent_secret_hash"]
@@ -433,7 +467,8 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
SELECT SELECT
agent_secret_hash, agent_secret_hash,
credential_created_at, credential_created_at,
credential_revoked_at credential_revoked_at,
legacy_auth_disabled_at
FROM minerops_site_agents FROM minerops_site_agents
WHERE agent_id = %s WHERE agent_id = %s
""", """,
@@ -453,6 +488,150 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
self.assertIsNone( self.assertIsNone(
after_checkin["credential_revoked_at"] 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): def test_expired_token_is_rejected_without_side_effects(self):
enrollment = self.create_enrollment() 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, version,
ip_address, ip_address,
agent_secret_hash, agent_secret_hash,
credential_created_at credential_created_at,
legacy_auth_disabled_at
) )
VALUES ( VALUES (
%s, %s,
@@ -85,6 +86,7 @@ class AgentRegistryPostgresTests(unittest.TestCase):
%s, %s,
%s, %s,
%s, %s,
now(),
now() now()
) )
""", """,
+36
View File
@@ -90,6 +90,23 @@ class FakeRepository:
"zone_enabled": zone_row["is_enabled"] if zone_row else None, "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): def assign_miners_to_zone(self, miner_ids, agent_zone_id):
self.assigned = (list(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") 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.assertEqual(result.routing_reason, "agent_zone_conflict")
self.assertIsNone(result.site_id) 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): class AgentZoneRouterTests(unittest.TestCase):
def setUp(self): 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 \ "${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"' '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 expected_head="$(
echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0016_agent_zones." >&2 "${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 exit 1
fi 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..." echo "Testing empty database through head..."
"${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_agent_zones_empty' "${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 "${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 \ empty_revision="$("${compose[@]}" exec -T postgres sh -ec \
'psql -U "$POSTGRES_USER" -d minerops_agent_zones_empty -Atc "SELECT version_num FROM alembic_version"')" '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." 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..." echo "Checking migrations and required schema..."
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" 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="$( missing_tables="$(
"${compose[@]}" exec -T postgres sh -c \ "${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..." echo "Testing empty database through head..."
"${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_control_empty' "${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 "${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 \ empty_revision="$("${compose[@]}" exec -T postgres sh -ec \
'psql -U "$POSTGRES_USER" -d minerops_control_empty -Atc "SELECT version_num FROM alembic_version"')" '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." echo "Miner Control Foundation migration tests passed."