feat(agent): add durable existing-agent credential transitions
This commit is contained in:
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")
|
||||
@@ -32,12 +32,18 @@ class AgentAuthRepository:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_id,
|
||||
agent_secret_hash,
|
||||
credential_created_at,
|
||||
credential_revoked_at
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
SELECT identity.agent_id,
|
||||
agent.agent_secret_hash,
|
||||
agent.credential_created_at,
|
||||
agent.credential_revoked_at,
|
||||
retirement.retired_at AS legacy_auth_disabled_at
|
||||
FROM (VALUES (%s::text)) AS identity(agent_id)
|
||||
LEFT JOIN minerops_site_agents AS agent
|
||||
ON agent.agent_id = identity.agent_id
|
||||
LEFT JOIN agent_legacy_auth_retirements AS retirement
|
||||
ON retirement.agent_id = identity.agent_id
|
||||
WHERE agent.agent_id IS NOT NULL
|
||||
OR retirement.agent_id IS NOT NULL
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
|
||||
@@ -25,19 +25,50 @@ class AgentAuthService:
|
||||
identity = (agent_id or "").strip()
|
||||
secret = (provided_secret or "").strip()
|
||||
|
||||
if not identity:
|
||||
raise AgentAuthenticationFailed("Missing Agent identity")
|
||||
if not secret:
|
||||
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||
|
||||
# The production Alpha Agent predates the identity header. Missing-ID
|
||||
# compatibility is therefore bound to exactly the configured legacy
|
||||
# identity; no request value is allowed to select another Agent.
|
||||
legacy_id = settings.minerops_agent_id.strip()
|
||||
legacy_secret = settings.minerops_agent_shared_secret.strip()
|
||||
missing_identity = not identity
|
||||
if missing_identity:
|
||||
if not legacy_id:
|
||||
raise AgentAuthenticationUnavailable(
|
||||
"Legacy Agent authentication is not configured"
|
||||
)
|
||||
identity = legacy_id
|
||||
|
||||
credential = self.repository.get_credential(identity)
|
||||
|
||||
# Revocation is authoritative regardless of which credential path
|
||||
# would otherwise apply. Never allow malformed or historical
|
||||
# credential state to fall through to the legacy shared secret.
|
||||
if credential and credential.get("credential_revoked_at") is not None:
|
||||
raise AgentCredentialRevoked("Agent credential has been revoked")
|
||||
|
||||
# This value comes from the identity-level retirement ledger, not the
|
||||
# deletable current Agent row. It is therefore authoritative even when
|
||||
# the Agent was deleted and has not yet been recreated.
|
||||
legacy_auth_retired = bool(
|
||||
credential
|
||||
and credential.get("legacy_auth_disabled_at") is not None
|
||||
)
|
||||
|
||||
# Any Agent with a per-Agent credential is permanently on the new
|
||||
# authentication path. The legacy shared secret must never authenticate
|
||||
# that identity, even if the Agent later becomes revoked.
|
||||
if credential and credential.get("agent_secret_hash"):
|
||||
if credential.get("credential_revoked_at") is not None:
|
||||
raise AgentCredentialRevoked("Agent credential has been revoked")
|
||||
# Once the configured legacy identity has ever received a
|
||||
# per-Agent credential, secret-only fallback is permanently
|
||||
# disabled. The legacy global secret cannot authenticate it and a
|
||||
# per-Agent secret cannot be used without an explicit identity.
|
||||
if missing_identity:
|
||||
raise AgentAuthenticationFailed(
|
||||
"Explicit Agent identity is required for enrolled Agents"
|
||||
)
|
||||
|
||||
supplied_hash = hash_agent_secret(secret)
|
||||
if not hmac.compare_digest(
|
||||
@@ -48,10 +79,15 @@ class AgentAuthService:
|
||||
|
||||
return identity
|
||||
|
||||
# Temporary compatibility for the single pre-enrollment Agent.
|
||||
legacy_id = settings.minerops_agent_id.strip()
|
||||
legacy_secret = settings.minerops_agent_shared_secret.strip()
|
||||
# The durable identity retirement survives Agent-row deletion,
|
||||
# credential-field corruption, administrative mistakes, and future
|
||||
# credential rotation workflows.
|
||||
if legacy_auth_retired:
|
||||
raise AgentAuthenticationFailed(
|
||||
"Legacy Agent authentication has been permanently disabled"
|
||||
)
|
||||
|
||||
# Temporary compatibility for the single pre-enrollment Agent.
|
||||
if not legacy_id or not legacy_secret:
|
||||
raise AgentAuthenticationUnavailable(
|
||||
"Legacy Agent authentication is not configured"
|
||||
|
||||
@@ -53,7 +53,9 @@ class AgentEnrollmentSummary(BaseModel):
|
||||
expires_at: datetime
|
||||
used_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
target_agent_id: str | None = None
|
||||
enrolled_agent_id: str | None = None
|
||||
purpose: Literal["new_agent", "existing_agent_credential_transition"]
|
||||
status: Literal["waiting", "used", "revoked", "expired"]
|
||||
|
||||
|
||||
@@ -96,3 +98,56 @@ class AgentEnrollResponse(BaseModel):
|
||||
|
||||
class AgentWindowsPackageRequest(AgentEnrollRequest):
|
||||
"""Short-lived token used to construct a Windows bootstrap package."""
|
||||
|
||||
|
||||
class AgentCredentialTransitionCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
agent_id: str = Field(min_length=1, max_length=255)
|
||||
|
||||
@field_validator("agent_id")
|
||||
@classmethod
|
||||
def normalize_agent_id(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("agent_id cannot be blank")
|
||||
return normalized
|
||||
|
||||
|
||||
class AgentCredentialTransitionCreated(BaseModel):
|
||||
id: UUID
|
||||
target_agent_id: str
|
||||
site_id: UUID
|
||||
site_name: str
|
||||
agent_zone_id: UUID
|
||||
agent_zone_name: str
|
||||
display_name: str
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
transition_token: str
|
||||
|
||||
|
||||
class AgentCredentialTransitionCreateResponse(BaseModel):
|
||||
data: AgentCredentialTransitionCreated
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
class AgentCredentialTransitionRedeemRequest(AgentEnrollRequest):
|
||||
agent_secret_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class AgentCredentialTransitionCredentials(BaseModel):
|
||||
agent_id: str
|
||||
site_id: UUID
|
||||
site_name: str
|
||||
agent_zone_id: UUID
|
||||
agent_zone_name: str
|
||||
display_name: str
|
||||
ip_ranges: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentCredentialTransitionResponse(BaseModel):
|
||||
data: AgentCredentialTransitionCredentials
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
@@ -1,5 +1,6 @@
|
||||
"""PostgreSQL persistence for secure Agent enrollment."""
|
||||
|
||||
import hmac
|
||||
from collections.abc import Callable
|
||||
from uuid import UUID
|
||||
|
||||
@@ -21,6 +22,7 @@ ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
|
||||
TOKEN_HASH_CONSTRAINT = "uq_agent_enrollments_token_hash"
|
||||
OPEN_ZONE_CONSTRAINT = "uq_agent_enrollments_open_zone"
|
||||
OPEN_TARGET_CONSTRAINT = "uq_agent_enrollments_open_target_agent"
|
||||
AGENT_SECRET_HASH_CONSTRAINT = "uq_minerops_site_agents_agent_secret_hash"
|
||||
AGENT_ZONE_CONSTRAINT = "uq_minerops_site_agents_agent_zone_id"
|
||||
AGENT_ID_CONSTRAINT = "minerops_site_agents_pkey"
|
||||
@@ -214,6 +216,184 @@ class AgentEnrollmentRepository:
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def create_credential_transition(
|
||||
self,
|
||||
*,
|
||||
token_hash: str,
|
||||
target_agent_id: str,
|
||||
actor_user_id: UUID,
|
||||
) -> dict:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
agent.agent_id,
|
||||
agent.site_id,
|
||||
agent.agent_zone_id,
|
||||
agent.display_name,
|
||||
agent.agent_secret_hash,
|
||||
agent.credential_created_at,
|
||||
agent.credential_revoked_at,
|
||||
agent.legacy_auth_disabled_at,
|
||||
site.name AS site_name,
|
||||
zone.name AS agent_zone_name,
|
||||
zone.is_enabled AS agent_zone_enabled,
|
||||
retirement.retired_at
|
||||
FROM minerops_site_agents AS agent
|
||||
JOIN sites AS site ON site.id = agent.site_id
|
||||
JOIN agent_zones AS zone
|
||||
ON zone.id = agent.agent_zone_id
|
||||
AND zone.site_id = agent.site_id
|
||||
LEFT JOIN agent_legacy_auth_retirements AS retirement
|
||||
ON retirement.agent_id = agent.agent_id
|
||||
WHERE agent.agent_id = %s
|
||||
FOR UPDATE OF agent, zone
|
||||
""",
|
||||
(target_agent_id,),
|
||||
)
|
||||
agent = cur.fetchone()
|
||||
|
||||
if not agent:
|
||||
raise AgentEnrollmentNotFound(
|
||||
"Existing Agent with durable Site and Zone routing was not found"
|
||||
)
|
||||
|
||||
if agent["agent_zone_enabled"] is not True:
|
||||
raise AgentEnrollmentConflict("Agent Zone is disabled")
|
||||
|
||||
if any(
|
||||
agent[field] is not None
|
||||
for field in (
|
||||
"agent_secret_hash",
|
||||
"credential_created_at",
|
||||
"credential_revoked_at",
|
||||
"legacy_auth_disabled_at",
|
||||
"retired_at",
|
||||
)
|
||||
):
|
||||
raise AgentEnrollmentConflict(
|
||||
"Existing Agent is not eligible for legacy credential transition"
|
||||
)
|
||||
|
||||
cur.execute("SELECT clock_timestamp() AS operation_now")
|
||||
operation_now = cur.fetchone()["operation_now"]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE agent_enrollments
|
||||
SET revoked_at = %s
|
||||
WHERE agent_zone_id = %s
|
||||
AND used_at IS NULL
|
||||
AND revoked_at IS NULL
|
||||
RETURNING id
|
||||
""",
|
||||
(operation_now, agent["agent_zone_id"]),
|
||||
)
|
||||
replaced_ids = [str(row["id"]) for row in cur.fetchall()]
|
||||
|
||||
display_name = (
|
||||
str(agent["display_name"] or agent["agent_id"]).strip()[:120]
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_enrollments (
|
||||
token_hash,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
created_by_user_id,
|
||||
created_at,
|
||||
expires_at,
|
||||
target_agent_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s + interval '30 minutes', %s)
|
||||
RETURNING
|
||||
id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
created_at,
|
||||
expires_at,
|
||||
target_agent_id
|
||||
""",
|
||||
(
|
||||
token_hash,
|
||||
agent["site_id"],
|
||||
agent["agent_zone_id"],
|
||||
display_name,
|
||||
actor_user_id,
|
||||
operation_now,
|
||||
operation_now,
|
||||
agent["agent_id"],
|
||||
),
|
||||
)
|
||||
transition = dict(cur.fetchone())
|
||||
transition["site_name"] = agent["site_name"]
|
||||
transition["agent_zone_name"] = agent["agent_zone_name"]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO audit_logs (
|
||||
actor_user_id,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
new_value_json,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
%s,
|
||||
'agent.credential_transition.created',
|
||||
'agent_enrollment',
|
||||
%s,
|
||||
%s,
|
||||
%s
|
||||
)
|
||||
""",
|
||||
(
|
||||
actor_user_id,
|
||||
transition["id"],
|
||||
Jsonb(
|
||||
{
|
||||
"target_agent_id": agent["agent_id"],
|
||||
"site_id": str(agent["site_id"]),
|
||||
"agent_zone_id": str(agent["agent_zone_id"]),
|
||||
"expires_at": transition["expires_at"].isoformat(),
|
||||
"replaced_enrollment_ids": replaced_ids,
|
||||
}
|
||||
),
|
||||
operation_now,
|
||||
),
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
return transition
|
||||
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
constraint = exc.diag.constraint_name
|
||||
|
||||
if constraint == TOKEN_HASH_CONSTRAINT:
|
||||
raise AgentEnrollmentCredentialCollision(
|
||||
"Generated transition token collided"
|
||||
) from exc
|
||||
|
||||
if constraint in {OPEN_ZONE_CONSTRAINT, OPEN_TARGET_CONSTRAINT}:
|
||||
raise AgentEnrollmentConflict(
|
||||
"An active credential transition already exists for this Agent"
|
||||
) from exc
|
||||
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def list_enrollments(
|
||||
self,
|
||||
*,
|
||||
@@ -238,7 +418,13 @@ class AgentEnrollmentRepository:
|
||||
enrollment.expires_at,
|
||||
enrollment.used_at,
|
||||
enrollment.revoked_at,
|
||||
enrollment.target_agent_id,
|
||||
enrollment.enrolled_agent_id,
|
||||
CASE
|
||||
WHEN enrollment.target_agent_id IS NULL
|
||||
THEN 'new_agent'
|
||||
ELSE 'existing_agent_credential_transition'
|
||||
END AS purpose,
|
||||
CASE
|
||||
WHEN enrollment.used_at IS NOT NULL
|
||||
THEN 'used'
|
||||
@@ -292,7 +478,8 @@ class AgentEnrollmentRepository:
|
||||
display_name,
|
||||
expires_at,
|
||||
used_at,
|
||||
revoked_at
|
||||
revoked_at,
|
||||
target_agent_id
|
||||
FROM agent_enrollments
|
||||
WHERE token_hash = %s
|
||||
FOR UPDATE
|
||||
@@ -309,6 +496,7 @@ class AgentEnrollmentRepository:
|
||||
if (
|
||||
enrollment["used_at"] is not None
|
||||
or enrollment["revoked_at"] is not None
|
||||
or enrollment["target_agent_id"] is not None
|
||||
):
|
||||
raise AgentEnrollmentTokenInvalid(
|
||||
"Invalid or expired enrollment token"
|
||||
@@ -371,6 +559,22 @@ class AgentEnrollmentRepository:
|
||||
"Invalid or expired enrollment token"
|
||||
)
|
||||
|
||||
# Retire the identity before storing its credential. Both
|
||||
# writes use this transaction, so a failed issuance cannot
|
||||
# leave a new misleading tombstone. ON CONFLICT preserves an
|
||||
# earlier retirement if this identity has credential history.
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_legacy_auth_retirements (
|
||||
agent_id,
|
||||
retired_at
|
||||
)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (agent_id) DO NOTHING
|
||||
""",
|
||||
(agent_id, operation_now),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
@@ -380,7 +584,8 @@ class AgentEnrollmentRepository:
|
||||
site_name,
|
||||
display_name,
|
||||
agent_secret_hash,
|
||||
credential_created_at
|
||||
credential_created_at,
|
||||
legacy_auth_disabled_at
|
||||
)
|
||||
VALUES (
|
||||
%s,
|
||||
@@ -389,6 +594,7 @@ class AgentEnrollmentRepository:
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s
|
||||
)
|
||||
""",
|
||||
@@ -400,6 +606,7 @@ class AgentEnrollmentRepository:
|
||||
enrollment["display_name"],
|
||||
agent_secret_hash,
|
||||
operation_now,
|
||||
operation_now,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -504,3 +711,258 @@ class AgentEnrollmentRepository:
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def redeem_credential_transition(
|
||||
self,
|
||||
*,
|
||||
token_hash: str,
|
||||
agent_secret_hash: str,
|
||||
) -> dict:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
expires_at,
|
||||
used_at,
|
||||
revoked_at,
|
||||
target_agent_id,
|
||||
enrolled_agent_id
|
||||
FROM agent_enrollments
|
||||
WHERE token_hash = %s
|
||||
FOR UPDATE
|
||||
""",
|
||||
(token_hash,),
|
||||
)
|
||||
enrollment = cur.fetchone()
|
||||
|
||||
if (
|
||||
not enrollment
|
||||
or enrollment["target_agent_id"] is None
|
||||
or enrollment["revoked_at"] is not None
|
||||
):
|
||||
raise AgentEnrollmentTokenInvalid(
|
||||
"Invalid or expired credential transition token"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
agent.agent_id,
|
||||
agent.site_id,
|
||||
agent.agent_zone_id,
|
||||
agent.display_name,
|
||||
agent.agent_secret_hash,
|
||||
agent.credential_created_at,
|
||||
agent.credential_revoked_at,
|
||||
agent.legacy_auth_disabled_at,
|
||||
site.name AS site_name,
|
||||
zone.name AS agent_zone_name,
|
||||
zone.is_enabled AS agent_zone_enabled,
|
||||
retirement.retired_at
|
||||
FROM minerops_site_agents AS agent
|
||||
JOIN sites AS site ON site.id = agent.site_id
|
||||
JOIN agent_zones AS zone
|
||||
ON zone.id = agent.agent_zone_id
|
||||
AND zone.site_id = agent.site_id
|
||||
LEFT JOIN agent_legacy_auth_retirements AS retirement
|
||||
ON retirement.agent_id = agent.agent_id
|
||||
WHERE agent.agent_id = %s
|
||||
FOR UPDATE OF agent, zone
|
||||
""",
|
||||
(enrollment["target_agent_id"],),
|
||||
)
|
||||
agent = cur.fetchone()
|
||||
|
||||
if not agent:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Credential transition target no longer exists with durable routing"
|
||||
)
|
||||
|
||||
if (
|
||||
agent["site_id"] != enrollment["site_id"]
|
||||
or agent["agent_zone_id"] != enrollment["agent_zone_id"]
|
||||
):
|
||||
raise AgentEnrollmentConflict(
|
||||
"Existing Agent routing changed after transition authorization"
|
||||
)
|
||||
|
||||
if agent["agent_zone_enabled"] is not True:
|
||||
raise AgentEnrollmentConflict("Agent Zone is disabled")
|
||||
|
||||
def transition_result() -> dict:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT network::text AS network
|
||||
FROM agent_zone_network_ranges
|
||||
WHERE agent_zone_id = %s
|
||||
ORDER BY network, id
|
||||
""",
|
||||
(enrollment["agent_zone_id"],),
|
||||
)
|
||||
return {
|
||||
"agent_id": agent["agent_id"],
|
||||
"site_id": agent["site_id"],
|
||||
"site_name": agent["site_name"],
|
||||
"agent_zone_id": agent["agent_zone_id"],
|
||||
"agent_zone_name": agent["agent_zone_name"],
|
||||
"display_name": str(
|
||||
agent["display_name"] or enrollment["display_name"]
|
||||
),
|
||||
"ip_ranges": [row["network"] for row in cur.fetchall()],
|
||||
}
|
||||
|
||||
# A retry after an ambiguous network response is successful only
|
||||
# for the exact locally generated credential that won the first
|
||||
# transaction. It never rotates or reveals a credential.
|
||||
if enrollment["used_at"] is not None:
|
||||
stored_hash = str(agent["agent_secret_hash"] or "")
|
||||
if (
|
||||
enrollment["enrolled_agent_id"]
|
||||
== enrollment["target_agent_id"]
|
||||
and stored_hash
|
||||
and hmac.compare_digest(stored_hash, agent_secret_hash)
|
||||
and agent["credential_created_at"] is not None
|
||||
and agent["credential_revoked_at"] is None
|
||||
and agent["legacy_auth_disabled_at"] is not None
|
||||
and agent["retired_at"] is not None
|
||||
):
|
||||
result = transition_result()
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
raise AgentEnrollmentTokenInvalid(
|
||||
"Credential transition token has already been consumed"
|
||||
)
|
||||
|
||||
cur.execute("SELECT clock_timestamp() AS operation_now")
|
||||
operation_now = cur.fetchone()["operation_now"]
|
||||
|
||||
if enrollment["expires_at"] <= operation_now:
|
||||
raise AgentEnrollmentTokenInvalid(
|
||||
"Invalid or expired credential transition token"
|
||||
)
|
||||
|
||||
if any(
|
||||
agent[field] is not None
|
||||
for field in (
|
||||
"agent_secret_hash",
|
||||
"credential_created_at",
|
||||
"credential_revoked_at",
|
||||
"legacy_auth_disabled_at",
|
||||
"retired_at",
|
||||
)
|
||||
):
|
||||
raise AgentEnrollmentConflict(
|
||||
"Existing Agent is no longer eligible for credential transition"
|
||||
)
|
||||
|
||||
# The durable retirement, Agent credential, enrollment terminal
|
||||
# state, and audit record commit together. Any later failure
|
||||
# rolls all of them back.
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_legacy_auth_retirements (
|
||||
agent_id,
|
||||
retired_at
|
||||
)
|
||||
VALUES (%s, %s)
|
||||
""",
|
||||
(agent["agent_id"], operation_now),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE minerops_site_agents
|
||||
SET agent_secret_hash = %s,
|
||||
credential_created_at = %s,
|
||||
legacy_auth_disabled_at = %s
|
||||
WHERE agent_id = %s
|
||||
RETURNING agent_id
|
||||
""",
|
||||
(
|
||||
agent_secret_hash,
|
||||
operation_now,
|
||||
operation_now,
|
||||
agent["agent_id"],
|
||||
),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise AgentEnrollmentConflict(
|
||||
"Credential transition target disappeared"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE agent_enrollments
|
||||
SET used_at = %s,
|
||||
enrolled_agent_id = target_agent_id
|
||||
WHERE id = %s
|
||||
AND used_at IS NULL
|
||||
AND revoked_at IS NULL
|
||||
RETURNING used_at, enrolled_agent_id
|
||||
""",
|
||||
(operation_now, enrollment["id"]),
|
||||
)
|
||||
completed = cur.fetchone()
|
||||
if not completed:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Credential transition state changed concurrently"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO audit_logs (
|
||||
actor_user_id,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
new_value_json,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
NULL,
|
||||
'agent.credential_transition.redeemed',
|
||||
'agent_enrollment',
|
||||
%s,
|
||||
%s,
|
||||
%s
|
||||
)
|
||||
""",
|
||||
(
|
||||
enrollment["id"],
|
||||
Jsonb(
|
||||
{
|
||||
"agent_id": agent["agent_id"],
|
||||
"site_id": str(agent["site_id"]),
|
||||
"agent_zone_id": str(agent["agent_zone_id"]),
|
||||
"used_at": completed["used_at"].isoformat(),
|
||||
}
|
||||
),
|
||||
operation_now,
|
||||
),
|
||||
)
|
||||
|
||||
result = transition_result()
|
||||
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
if exc.diag.constraint_name == AGENT_SECRET_HASH_CONSTRAINT:
|
||||
raise AgentEnrollmentCredentialCollision(
|
||||
"Agent credential is already in use"
|
||||
) from exc
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -8,10 +8,15 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response,
|
||||
from app.agent_enrollment.dependencies import get_agent_enrollment_service
|
||||
from app.agent_enrollment.exceptions import (
|
||||
AgentEnrollmentConflict,
|
||||
AgentEnrollmentCredentialCollision,
|
||||
AgentEnrollmentNotFound,
|
||||
AgentEnrollmentTokenInvalid,
|
||||
)
|
||||
from app.agent_enrollment.models import (
|
||||
AgentCredentialTransitionCreateRequest,
|
||||
AgentCredentialTransitionCreateResponse,
|
||||
AgentCredentialTransitionRedeemRequest,
|
||||
AgentCredentialTransitionResponse,
|
||||
AgentEnrollmentCreateRequest,
|
||||
AgentEnrollmentCreateResponse,
|
||||
AgentEnrollmentListResponse,
|
||||
@@ -70,6 +75,31 @@ def create_agent_enrollment(
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/agent-credential-transitions",
|
||||
response_model=AgentCredentialTransitionCreateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_agent_credential_transition(
|
||||
request: AgentCredentialTransitionCreateRequest,
|
||||
response: Response,
|
||||
current_user: EnrollmentManager,
|
||||
service: EnrollmentService,
|
||||
) -> AgentCredentialTransitionCreateResponse:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
try:
|
||||
transition = service.create_credential_transition(
|
||||
request,
|
||||
actor_user_id=UUID(str(current_user["id"])),
|
||||
)
|
||||
return AgentCredentialTransitionCreateResponse(data=transition)
|
||||
except AgentEnrollmentNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except AgentEnrollmentConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/agent-enrollments",
|
||||
response_model=AgentEnrollmentListResponse,
|
||||
@@ -157,3 +187,27 @@ def enroll_agent(
|
||||
) from exc
|
||||
except AgentEnrollmentConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/agent/credential-transition",
|
||||
response_model=AgentCredentialTransitionResponse,
|
||||
)
|
||||
def transition_agent_credential(
|
||||
request: AgentCredentialTransitionRedeemRequest,
|
||||
response: Response,
|
||||
service: EnrollmentService,
|
||||
) -> AgentCredentialTransitionResponse:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
try:
|
||||
return AgentCredentialTransitionResponse(
|
||||
data=service.redeem_credential_transition(request)
|
||||
)
|
||||
except AgentEnrollmentTokenInvalid as exc:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid or expired credential transition token",
|
||||
) from exc
|
||||
except (AgentEnrollmentConflict, AgentEnrollmentCredentialCollision) as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
@@ -9,6 +9,8 @@ from app.agent_enrollment.exceptions import (
|
||||
AgentEnrollmentCredentialCollision,
|
||||
)
|
||||
from app.agent_enrollment.models import (
|
||||
AgentCredentialTransitionCreateRequest,
|
||||
AgentCredentialTransitionRedeemRequest,
|
||||
AgentEnrollmentCreateRequest,
|
||||
AgentEnrollRequest,
|
||||
)
|
||||
@@ -73,6 +75,31 @@ class AgentEnrollmentService:
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def create_credential_transition(
|
||||
self,
|
||||
request: AgentCredentialTransitionCreateRequest,
|
||||
*,
|
||||
actor_user_id: UUID,
|
||||
) -> dict:
|
||||
for _ in range(MAX_GENERATION_ATTEMPTS):
|
||||
token = generate_enrollment_token()
|
||||
try:
|
||||
transition = self.repository.create_credential_transition(
|
||||
token_hash=hash_agent_secret(token),
|
||||
target_agent_id=request.agent_id,
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
return {
|
||||
**transition,
|
||||
"transition_token": token,
|
||||
}
|
||||
except AgentEnrollmentCredentialCollision:
|
||||
continue
|
||||
|
||||
raise AgentEnrollmentConflict(
|
||||
"Unable to generate a unique credential transition token"
|
||||
)
|
||||
|
||||
def redeem(self, request: AgentEnrollRequest) -> dict:
|
||||
token_hash = hash_agent_secret(request.token)
|
||||
|
||||
@@ -96,3 +123,12 @@ class AgentEnrollmentService:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Unable to generate unique Agent credentials"
|
||||
)
|
||||
|
||||
def redeem_credential_transition(
|
||||
self,
|
||||
request: AgentCredentialTransitionRedeemRequest,
|
||||
) -> dict:
|
||||
return self.repository.redeem_credential_transition(
|
||||
token_hash=hash_agent_secret(request.token),
|
||||
agent_secret_hash=request.agent_secret_hash,
|
||||
)
|
||||
@@ -7,7 +7,7 @@ from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
CredentialState = Literal["legacy", "active", "revoked"]
|
||||
CredentialState = Literal["legacy", "active", "revoked", "retired"]
|
||||
RoutingState = Literal["assigned", "unassigned", "disabled"]
|
||||
|
||||
|
||||
|
||||
@@ -103,9 +103,13 @@ class AgentRegistryRepository:
|
||||
THEN 'revoked'
|
||||
WHEN agent.agent_secret_hash IS NOT NULL
|
||||
THEN 'active'
|
||||
WHEN retirement.retired_at IS NOT NULL
|
||||
THEN 'retired'
|
||||
ELSE 'legacy'
|
||||
END AS credential_state
|
||||
FROM minerops_site_agents AS agent
|
||||
LEFT JOIN agent_legacy_auth_retirements AS retirement
|
||||
ON retirement.agent_id = agent.agent_id
|
||||
LEFT JOIN sites AS site
|
||||
ON site.id = agent.site_id
|
||||
LEFT JOIN agent_zones AS zone
|
||||
|
||||
@@ -250,6 +250,50 @@ class AgentZoneRepository:
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def get_agent_configuration(self, agent_id: str) -> dict | None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
agent.agent_id,
|
||||
agent.site_id,
|
||||
agent.agent_zone_id,
|
||||
coalesce(site.name, agent.site_name) AS site_name,
|
||||
zone.name AS agent_zone_name,
|
||||
zone.is_enabled AS agent_zone_enabled,
|
||||
coalesce(
|
||||
array_agg(
|
||||
network.network::text
|
||||
ORDER BY network.network::text, network.id
|
||||
) FILTER (WHERE network.id IS NOT NULL),
|
||||
ARRAY[]::text[]
|
||||
) AS networks
|
||||
FROM minerops_site_agents AS agent
|
||||
LEFT JOIN sites AS site
|
||||
ON site.id = agent.site_id
|
||||
LEFT JOIN agent_zones AS zone
|
||||
ON zone.id = agent.agent_zone_id
|
||||
LEFT JOIN agent_zone_network_ranges AS network
|
||||
ON network.agent_zone_id = zone.id
|
||||
WHERE agent.agent_id = %s
|
||||
GROUP BY
|
||||
agent.agent_id,
|
||||
agent.site_id,
|
||||
agent.agent_zone_id,
|
||||
site.name,
|
||||
agent.site_name,
|
||||
zone.name,
|
||||
zone.is_enabled
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def assign_miners_to_zone(
|
||||
self, miner_ids: Iterable[UUID], agent_zone_id: UUID
|
||||
) -> dict[UUID, str]:
|
||||
|
||||
@@ -177,6 +177,28 @@ class AgentZoneService:
|
||||
site_id=assignment.get("site_id"), agent_zone_id=zone_id
|
||||
)
|
||||
|
||||
def configuration(self, agent_id: str) -> dict[str, Any] | None:
|
||||
row = self.repository.get_agent_configuration(agent_id)
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"agent_id": row["agent_id"],
|
||||
"site_id": row["site_id"],
|
||||
"site_name": row["site_name"],
|
||||
"agent_zone_id": row["agent_zone_id"],
|
||||
"agent_zone_name": row["agent_zone_name"],
|
||||
"ip_ranges": [
|
||||
{
|
||||
"name": row["agent_zone_name"],
|
||||
"cidr": network,
|
||||
"enabled": row["agent_zone_enabled"] is True,
|
||||
"api_port": 4028,
|
||||
"api3_port": 4433,
|
||||
}
|
||||
for network in row.get("networks") or []
|
||||
],
|
||||
}
|
||||
|
||||
def associate_miners_from_agent(
|
||||
self, agent_id: str, miner_ids: list[UUID]
|
||||
) -> MinerZoneAssociationResult:
|
||||
|
||||
@@ -700,6 +700,7 @@ def required_page_permission(path: str) -> str:
|
||||
def machine_agent_request(path: str) -> bool:
|
||||
exact = {
|
||||
"/api/agent/enroll",
|
||||
"/api/agent/credential-transition",
|
||||
"/api/agent/checkin",
|
||||
"/api/agent/config",
|
||||
"/api/agent/known-miners",
|
||||
@@ -787,6 +788,8 @@ def required_api_permission(method: str, path: str) -> str | None:
|
||||
return "agents.view" if method == "GET" else "agents.manage"
|
||||
if path == "/api/agent-enrollments/windows-package":
|
||||
return "agents.manage"
|
||||
if path == "/api/agent-credential-transitions":
|
||||
return "agents.manage"
|
||||
if repair_parts_request(path):
|
||||
return "repairs.read" if method == "GET" else "repairs.parts.manage"
|
||||
if path.startswith("/api/admin/"):
|
||||
|
||||
+20
-27
@@ -25102,37 +25102,30 @@ async def startup_ensure_minerops_agent_tables():
|
||||
|
||||
|
||||
@app.get("/api/agent/config")
|
||||
async def minerops_agent_config(request: Request):
|
||||
minerops_require_agent_secret(request)
|
||||
async def minerops_agent_config(
|
||||
request: Request,
|
||||
authenticated_agent_id: str = Depends(require_agent_identity),
|
||||
zone_service: AgentZoneService = Depends(get_agent_zone_service),
|
||||
):
|
||||
routing = zone_service.routing_context(authenticated_agent_id)
|
||||
if routing.routing_reason or routing.agent_zone_id is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Agent routing unavailable: "
|
||||
+ (routing.routing_reason or "agent_zone_unassigned")
|
||||
),
|
||||
)
|
||||
|
||||
configuration = zone_service.configuration(authenticated_agent_id)
|
||||
if not configuration:
|
||||
raise HTTPException(status_code=409, detail="Agent configuration unavailable")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"site_name": "Plexus",
|
||||
**configuration,
|
||||
"poll_interval_seconds": 120,
|
||||
"hub_url": "https://plexus.lussier.cc",
|
||||
"ip_ranges": [
|
||||
{
|
||||
"name": "Unit 4",
|
||||
"cidr": "10.30.0.0/22",
|
||||
"enabled": True,
|
||||
"api_port": 4028,
|
||||
"api3_port": 4433,
|
||||
},
|
||||
{
|
||||
"name": "Unit 6",
|
||||
"cidr": "10.80.0.0/22",
|
||||
"enabled": True,
|
||||
"api_port": 4028,
|
||||
"api3_port": 4433,
|
||||
},
|
||||
{
|
||||
"name": "Unit 12",
|
||||
"cidr": "10.90.0.0/22",
|
||||
"enabled": False,
|
||||
"api_port": 4028,
|
||||
"api3_port": 4433,
|
||||
},
|
||||
],
|
||||
"hub_url": str(request.base_url).rstrip("/"),
|
||||
"message": "Agent config from hub.",
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,14 @@ class AgentAuthServiceTests(unittest.TestCase):
|
||||
"legacy-agent",
|
||||
)
|
||||
|
||||
def test_secret_only_legacy_agent_maps_to_configured_identity(self):
|
||||
self.assertEqual(
|
||||
self.service().authenticate(None, "legacy-secret"),
|
||||
"legacy-agent",
|
||||
)
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service().authenticate(None, "wrong-secret")
|
||||
|
||||
def test_legacy_agent_with_null_credential_still_authenticates(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
@@ -116,13 +124,83 @@ class AgentAuthServiceTests(unittest.TestCase):
|
||||
with self.subTest(secret=secret), self.assertRaises(AgentCredentialRevoked):
|
||||
self.service(rows).authenticate("agent-new", secret)
|
||||
|
||||
def test_revoked_registry_row_never_falls_back_even_without_hash(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
"agent_id": "legacy-agent",
|
||||
"agent_secret_hash": None,
|
||||
"credential_created_at": None,
|
||||
"credential_revoked_at": NOW,
|
||||
}
|
||||
}
|
||||
for agent_id in ("legacy-agent", None):
|
||||
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||
AgentCredentialRevoked
|
||||
):
|
||||
self.service(rows).authenticate(agent_id, "legacy-secret")
|
||||
|
||||
def test_missing_identity_never_authenticates_enrolled_legacy_agent(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
"agent_id": "legacy-agent",
|
||||
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||
"credential_created_at": NOW,
|
||||
"credential_revoked_at": None,
|
||||
}
|
||||
}
|
||||
for secret in ("unique-secret", "legacy-secret"):
|
||||
with self.subTest(secret=secret), self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service(rows).authenticate(None, secret)
|
||||
|
||||
def test_missing_identity_reports_revoked_legacy_credential(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
"agent_id": "legacy-agent",
|
||||
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||
"credential_created_at": NOW,
|
||||
"credential_revoked_at": NOW,
|
||||
}
|
||||
}
|
||||
with self.assertRaises(AgentCredentialRevoked):
|
||||
self.service(rows).authenticate(None, "legacy-secret")
|
||||
|
||||
def test_durable_retirement_rejects_fallback_after_credentials_are_cleared(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
"agent_id": "legacy-agent",
|
||||
"agent_secret_hash": None,
|
||||
"credential_created_at": None,
|
||||
"credential_revoked_at": None,
|
||||
"legacy_auth_disabled_at": NOW,
|
||||
}
|
||||
}
|
||||
for agent_id in ("legacy-agent", None):
|
||||
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||
AgentAuthenticationFailed
|
||||
):
|
||||
self.service(rows).authenticate(agent_id, "legacy-secret")
|
||||
|
||||
def test_identity_retirement_rejects_fallback_without_current_agent_row(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
"agent_id": "legacy-agent",
|
||||
"agent_secret_hash": None,
|
||||
"credential_created_at": None,
|
||||
"credential_revoked_at": None,
|
||||
"legacy_auth_disabled_at": NOW,
|
||||
}
|
||||
}
|
||||
for agent_id in ("legacy-agent", None):
|
||||
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||
AgentAuthenticationFailed
|
||||
):
|
||||
self.service(rows).authenticate(agent_id, "legacy-secret")
|
||||
|
||||
def test_unknown_identity_does_not_use_shared_secret(self):
|
||||
with self.assertRaises(AgentIdentityUnauthorized):
|
||||
self.service().authenticate("unknown-agent", "legacy-secret")
|
||||
|
||||
def test_missing_identity_or_secret_is_rejected(self):
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service().authenticate(None, "legacy-secret")
|
||||
def test_missing_secret_is_rejected(self):
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service().authenticate("legacy-agent", None)
|
||||
|
||||
@@ -131,6 +209,11 @@ class AgentAuthServiceTests(unittest.TestCase):
|
||||
with self.assertRaises(AgentAuthenticationUnavailable):
|
||||
self.service().authenticate("legacy-agent", "anything")
|
||||
|
||||
def test_secret_only_path_requires_configured_legacy_identity(self):
|
||||
settings.minerops_agent_id = ""
|
||||
with self.assertRaises(AgentAuthenticationUnavailable):
|
||||
self.service().authenticate(None, "legacy-secret")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,584 @@
|
||||
"""PostgreSQL security contract for existing-Agent credential transition."""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import os
|
||||
import unittest
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import ValidationError
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.agent_auth.exceptions import AgentAuthenticationFailed
|
||||
from app.agent_auth.repository import AgentAuthRepository
|
||||
from app.agent_auth.service import AgentAuthService, hash_agent_secret
|
||||
from app.agent_enrollment.exceptions import (
|
||||
AgentEnrollmentConflict,
|
||||
AgentEnrollmentCredentialCollision,
|
||||
AgentEnrollmentTokenInvalid,
|
||||
)
|
||||
from app.agent_enrollment.models import (
|
||||
AgentCredentialTransitionCreateRequest,
|
||||
AgentCredentialTransitionRedeemRequest,
|
||||
AgentEnrollmentCreateRequest,
|
||||
AgentEnrollRequest,
|
||||
)
|
||||
from app.agent_enrollment.repository import (
|
||||
AgentEnrollmentRepository,
|
||||
default_connection_factory,
|
||||
)
|
||||
from app.agent_enrollment.service import AgentEnrollmentService
|
||||
from app.agent_registry.repository import AgentRegistryRepository
|
||||
from app.config import settings
|
||||
|
||||
|
||||
PRODUCTION_AGENT_ID = "plexus-windows-agent"
|
||||
PRODUCTION_SITE_ID = UUID("00000000-0000-4000-8000-000000002001")
|
||||
PRODUCTION_ZONE_ID = UUID("00000000-0000-4000-8000-000000002002")
|
||||
PRODUCTION_MINER_ID = UUID("00000000-0000-4000-8000-000000002003")
|
||||
|
||||
|
||||
def connect():
|
||||
return default_connection_factory()
|
||||
|
||||
|
||||
class AgentCredentialTransitionPostgresTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if os.environ.get("MINEROPS_CREDENTIAL_TRANSITION_DISPOSABLE") != "1":
|
||||
raise unittest.SkipTest(
|
||||
"credential transition requires its disposable PostgreSQL runner"
|
||||
)
|
||||
if settings.minerops_agent_id != PRODUCTION_AGENT_ID:
|
||||
raise AssertionError(
|
||||
"runner must bind the production-shaped legacy Agent identity"
|
||||
)
|
||||
cls.service = AgentEnrollmentService(AgentEnrollmentRepository(connect))
|
||||
cls.auth = AgentAuthService(AgentAuthRepository(connect))
|
||||
|
||||
def create_legacy_agent(
|
||||
self,
|
||||
agent_id,
|
||||
*,
|
||||
site_id=None,
|
||||
zone_id=None,
|
||||
enabled=True,
|
||||
secret_hash=None,
|
||||
revoked=False,
|
||||
):
|
||||
site_id = site_id or uuid4()
|
||||
zone_id = zone_id or uuid4()
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO sites (id, name) VALUES (%s, %s)",
|
||||
(site_id, f"Transition Site {site_id.hex[:8]}"),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zones (id, site_id, name, is_enabled)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(zone_id, site_id, "Default Zone", enabled),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||
VALUES (%s, '10.220.0.0/24'::cidr)
|
||||
""",
|
||||
(zone_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, agent_zone_id, site_name,
|
||||
display_name, agent_secret_hash,
|
||||
credential_created_at, credential_revoked_at
|
||||
)
|
||||
VALUES (
|
||||
%s, %s, %s, %s, %s, %s,
|
||||
CASE WHEN %s::boolean THEN now() ELSE NULL END,
|
||||
CASE WHEN %s::boolean THEN now() ELSE NULL END
|
||||
)
|
||||
""",
|
||||
(
|
||||
agent_id,
|
||||
site_id,
|
||||
zone_id,
|
||||
f"Transition Site {site_id.hex[:8]}",
|
||||
f"Agent {agent_id}"[:120],
|
||||
secret_hash,
|
||||
secret_hash is not None,
|
||||
revoked,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
return site_id, zone_id
|
||||
|
||||
def create_transition(self, agent_id):
|
||||
return self.service.create_credential_transition(
|
||||
AgentCredentialTransitionCreateRequest(agent_id=agent_id),
|
||||
actor_user_id=None,
|
||||
)
|
||||
|
||||
def redeem_transition(self, token, secret):
|
||||
return AgentEnrollmentService(
|
||||
AgentEnrollmentRepository(connect)
|
||||
).redeem_credential_transition(
|
||||
AgentCredentialTransitionRedeemRequest(
|
||||
token=token,
|
||||
agent_secret_hash=hash_agent_secret(secret),
|
||||
)
|
||||
)
|
||||
|
||||
def agent_row(self, agent_id):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_id, site_id, agent_zone_id, agent_secret_hash,
|
||||
credential_created_at, credential_revoked_at,
|
||||
legacy_auth_disabled_at
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_01_production_identity_transitions_without_routing_change(self):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sites (id, name)
|
||||
VALUES (%s, 'Plexus Mining Site')
|
||||
""",
|
||||
(PRODUCTION_SITE_ID,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zones (id, site_id, name, is_enabled)
|
||||
VALUES (%s, %s, 'Default Zone', true)
|
||||
""",
|
||||
(PRODUCTION_ZONE_ID, PRODUCTION_SITE_ID),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||
VALUES (%s, '192.0.2.0/24'::cidr)
|
||||
""",
|
||||
(PRODUCTION_ZONE_ID,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, agent_zone_id, site_name, display_name
|
||||
)
|
||||
VALUES (%s, %s, %s, 'Plexus', 'Plexus Windows Agent')
|
||||
""",
|
||||
(
|
||||
PRODUCTION_AGENT_ID,
|
||||
PRODUCTION_SITE_ID,
|
||||
PRODUCTION_ZONE_ID,
|
||||
),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO miners (
|
||||
id, site_id, agent_zone_id, asset_tag,
|
||||
current_ip, current_mac, status
|
||||
)
|
||||
VALUES (
|
||||
%s, %s, %s, 'TRANSITION-PRODUCTION-MINER',
|
||||
'192.0.2.20', '02:00:00:00:20:03', 'online'
|
||||
)
|
||||
""",
|
||||
(
|
||||
PRODUCTION_MINER_ID,
|
||||
PRODUCTION_SITE_ID,
|
||||
PRODUCTION_ZONE_ID,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
legacy_secret = settings.minerops_agent_shared_secret
|
||||
self.assertEqual(
|
||||
self.auth.authenticate(None, legacy_secret),
|
||||
PRODUCTION_AGENT_ID,
|
||||
)
|
||||
|
||||
transition = self.create_transition(PRODUCTION_AGENT_ID)
|
||||
token = transition["transition_token"]
|
||||
self.assertEqual(transition["target_agent_id"], PRODUCTION_AGENT_ID)
|
||||
self.assertEqual(transition["site_id"], PRODUCTION_SITE_ID)
|
||||
self.assertEqual(transition["agent_zone_id"], PRODUCTION_ZONE_ID)
|
||||
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT token_hash, target_agent_id, used_at,
|
||||
enrolled_agent_id, site_id, agent_zone_id
|
||||
FROM agent_enrollments WHERE id = %s
|
||||
""",
|
||||
(transition["id"],),
|
||||
)
|
||||
authorization = cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertEqual(authorization["token_hash"], hash_agent_secret(token))
|
||||
self.assertNotEqual(authorization["token_hash"], token)
|
||||
self.assertEqual(authorization["target_agent_id"], PRODUCTION_AGENT_ID)
|
||||
self.assertIsNone(authorization["used_at"])
|
||||
|
||||
new_secret = "production-shaped-per-agent-secret"
|
||||
result = self.redeem_transition(token, new_secret)
|
||||
self.assertEqual(result["agent_id"], PRODUCTION_AGENT_ID)
|
||||
self.assertNotIn("agent_secret", result)
|
||||
self.assertEqual(result["site_id"], PRODUCTION_SITE_ID)
|
||||
self.assertEqual(result["agent_zone_id"], PRODUCTION_ZONE_ID)
|
||||
|
||||
# An ambiguous response is safely retryable only with the same locally
|
||||
# generated credential; no second transition or audit event is created.
|
||||
retried = self.redeem_transition(token, new_secret)
|
||||
self.assertEqual(retried, result)
|
||||
with self.assertRaises(AgentEnrollmentTokenInvalid):
|
||||
self.redeem_transition(token, "different-secret-cannot-rotate")
|
||||
|
||||
self.assertEqual(
|
||||
self.auth.authenticate(PRODUCTION_AGENT_ID, new_secret),
|
||||
PRODUCTION_AGENT_ID,
|
||||
)
|
||||
for supplied_id in (None, PRODUCTION_AGENT_ID):
|
||||
with self.subTest(supplied_id=supplied_id), self.assertRaises(
|
||||
AgentAuthenticationFailed
|
||||
):
|
||||
self.auth.authenticate(supplied_id, legacy_secret)
|
||||
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT site_id, agent_zone_id, agent_secret_hash,
|
||||
credential_created_at, legacy_auth_disabled_at
|
||||
FROM minerops_site_agents WHERE agent_id = %s
|
||||
""",
|
||||
(PRODUCTION_AGENT_ID,),
|
||||
)
|
||||
agent = cur.fetchone()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT site_id, agent_zone_id, current_mac::text,
|
||||
host(current_ip) AS current_ip
|
||||
FROM miners WHERE id = %s
|
||||
""",
|
||||
(PRODUCTION_MINER_ID,),
|
||||
)
|
||||
miner = cur.fetchone()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*) AS retirement_count
|
||||
FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(PRODUCTION_AGENT_ID,),
|
||||
)
|
||||
retirement_count = cur.fetchone()["retirement_count"]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*) AS redeemed_audits
|
||||
FROM audit_logs
|
||||
WHERE action = 'agent.credential_transition.redeemed'
|
||||
AND entity_id = %s
|
||||
""",
|
||||
(transition["id"],),
|
||||
)
|
||||
redeemed_audits = cur.fetchone()["redeemed_audits"]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(agent["site_id"], PRODUCTION_SITE_ID)
|
||||
self.assertEqual(agent["agent_zone_id"], PRODUCTION_ZONE_ID)
|
||||
self.assertEqual(agent["agent_secret_hash"], hash_agent_secret(new_secret))
|
||||
self.assertIsNotNone(agent["credential_created_at"])
|
||||
self.assertIsNotNone(agent["legacy_auth_disabled_at"])
|
||||
self.assertEqual(miner["site_id"], PRODUCTION_SITE_ID)
|
||||
self.assertEqual(miner["agent_zone_id"], PRODUCTION_ZONE_ID)
|
||||
self.assertEqual(miner["current_mac"], "02:00:00:00:20:03")
|
||||
self.assertEqual(miner["current_ip"], "192.0.2.20")
|
||||
self.assertEqual(retirement_count, 1)
|
||||
self.assertEqual(redeemed_audits, 1)
|
||||
|
||||
# Remove deletable enrollment/current state to prove that the separate
|
||||
# append-only retirement identity still defeats fallback on recreation.
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM agent_enrollments WHERE id = %s",
|
||||
(transition["id"],),
|
||||
)
|
||||
cur.execute(
|
||||
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||
(PRODUCTION_AGENT_ID,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, agent_zone_id, site_name
|
||||
) VALUES (%s, %s, %s, 'Recreated Plexus')
|
||||
""",
|
||||
(
|
||||
PRODUCTION_AGENT_ID,
|
||||
PRODUCTION_SITE_ID,
|
||||
PRODUCTION_ZONE_ID,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
recreated = self.agent_row(PRODUCTION_AGENT_ID)
|
||||
self.assertIsNone(recreated["agent_secret_hash"])
|
||||
self.assertIsNotNone(recreated["legacy_auth_disabled_at"])
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.auth.authenticate(PRODUCTION_AGENT_ID, legacy_secret)
|
||||
|
||||
def test_02_concurrent_redemption_has_one_credential_winner(self):
|
||||
agent_id = "transition-concurrent-agent"
|
||||
self.create_legacy_agent(agent_id)
|
||||
token = self.create_transition(agent_id)["transition_token"]
|
||||
secrets = ("concurrent-secret-a", "concurrent-secret-b")
|
||||
|
||||
def redeem(secret):
|
||||
try:
|
||||
return ("success", secret, self.redeem_transition(token, secret))
|
||||
except AgentEnrollmentTokenInvalid:
|
||||
return ("invalid", secret, None)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(executor.map(redeem, secrets))
|
||||
|
||||
winners = [item for item in results if item[0] == "success"]
|
||||
losers = [item for item in results if item[0] == "invalid"]
|
||||
self.assertEqual(len(winners), 1)
|
||||
self.assertEqual(len(losers), 1)
|
||||
row = self.agent_row(agent_id)
|
||||
self.assertEqual(row["agent_secret_hash"], hash_agent_secret(winners[0][1]))
|
||||
|
||||
def test_03_authorization_cannot_substitute_identity_or_steal_zone(self):
|
||||
agent_a = "transition-bound-agent-a"
|
||||
agent_b = "transition-bound-agent-b"
|
||||
site_a, zone_a = self.create_legacy_agent(agent_a)
|
||||
site_b, zone_b = self.create_legacy_agent(agent_b)
|
||||
token = self.create_transition(agent_a)["transition_token"]
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
AgentCredentialTransitionRedeemRequest.model_validate(
|
||||
{
|
||||
"token": token,
|
||||
"agent_secret_hash": hash_agent_secret("bound-secret"),
|
||||
"agent_id": agent_b,
|
||||
}
|
||||
)
|
||||
|
||||
result = self.redeem_transition(token, "bound-secret")
|
||||
self.assertEqual(result["agent_id"], agent_a)
|
||||
self.assertEqual((result["site_id"], result["agent_zone_id"]), (site_a, zone_a))
|
||||
other = self.agent_row(agent_b)
|
||||
self.assertEqual((other["site_id"], other["agent_zone_id"]), (site_b, zone_b))
|
||||
self.assertIsNone(other["agent_secret_hash"])
|
||||
|
||||
def test_04_revoked_retired_and_disabled_targets_fail_closed(self):
|
||||
revoked = "transition-revoked-agent"
|
||||
self.create_legacy_agent(
|
||||
revoked,
|
||||
secret_hash=hash_agent_secret("revoked-secret"),
|
||||
revoked=True,
|
||||
)
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.create_transition(revoked)
|
||||
|
||||
retired = "transition-retired-agent"
|
||||
self.create_legacy_agent(retired)
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO agent_legacy_auth_retirements (agent_id) VALUES (%s)",
|
||||
(retired,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.create_transition(retired)
|
||||
|
||||
disabled = "transition-disabled-agent"
|
||||
self.create_legacy_agent(disabled, enabled=False)
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.create_transition(disabled)
|
||||
|
||||
changed = "transition-zone-disabled-after-authorization"
|
||||
_, changed_zone = self.create_legacy_agent(changed)
|
||||
authorization = self.create_transition(changed)
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE agent_zones SET is_enabled = false WHERE id = %s",
|
||||
(changed_zone,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.redeem_transition(
|
||||
authorization["transition_token"], "disabled-zone-secret"
|
||||
)
|
||||
self.assertIsNone(self.agent_row(changed)["agent_secret_hash"])
|
||||
|
||||
def test_05_credential_collision_rolls_back_retirement_and_token_use(self):
|
||||
collision_hash = hash_agent_secret("already-issued-secret")
|
||||
self.create_legacy_agent(
|
||||
"transition-existing-credential-holder",
|
||||
secret_hash=collision_hash,
|
||||
)
|
||||
target = "transition-rollback-target"
|
||||
self.create_legacy_agent(target)
|
||||
transition = self.create_transition(target)
|
||||
|
||||
with self.assertRaises(AgentEnrollmentCredentialCollision):
|
||||
self.service.redeem_credential_transition(
|
||||
AgentCredentialTransitionRedeemRequest(
|
||||
token=transition["transition_token"],
|
||||
agent_secret_hash=collision_hash,
|
||||
)
|
||||
)
|
||||
|
||||
row = self.agent_row(target)
|
||||
self.assertIsNone(row["agent_secret_hash"])
|
||||
self.assertIsNone(row["credential_created_at"])
|
||||
self.assertIsNone(row["legacy_auth_disabled_at"])
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*) FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(target,),
|
||||
)
|
||||
self.assertEqual(cur.fetchone()[0], 0)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT used_at, enrolled_agent_id
|
||||
FROM agent_enrollments WHERE id = %s
|
||||
""",
|
||||
(transition["id"],),
|
||||
)
|
||||
self.assertEqual(cur.fetchone(), (None, None))
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_06_normal_enrollment_still_works_and_occupied_zone_is_protected(self):
|
||||
site_id, occupied_zone = self.create_legacy_agent(
|
||||
"transition-ordinary-occupied"
|
||||
)
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.service.create_enrollment(
|
||||
AgentEnrollmentCreateRequest(
|
||||
site_id=site_id,
|
||||
agent_zone_id=occupied_zone,
|
||||
display_name="Must Not Steal Occupied Zone",
|
||||
),
|
||||
actor_user_id=None,
|
||||
)
|
||||
|
||||
free_zone = uuid4()
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zones (id, site_id, name, is_enabled)
|
||||
VALUES (%s, %s, 'Free Enrollment Zone', true)
|
||||
""",
|
||||
(free_zone, site_id),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
enrollment = self.service.create_enrollment(
|
||||
AgentEnrollmentCreateRequest(
|
||||
site_id=site_id,
|
||||
agent_zone_id=free_zone,
|
||||
display_name="Ordinary New Agent",
|
||||
),
|
||||
actor_user_id=None,
|
||||
)
|
||||
enrolled = self.service.redeem(
|
||||
AgentEnrollRequest(token=enrollment["enrollment_token"])
|
||||
)
|
||||
self.assertRegex(enrolled["agent_id"], r"^agent-[0-9a-f]{32}$")
|
||||
self.assertEqual(enrolled["agent_zone_id"], free_zone)
|
||||
|
||||
def test_07_registry_history_and_audit_never_expose_plaintext(self):
|
||||
agent_id = "transition-privacy-agent"
|
||||
self.create_legacy_agent(agent_id)
|
||||
transition = self.create_transition(agent_id)
|
||||
token = transition["transition_token"]
|
||||
|
||||
listed = self.service.list_enrollments()
|
||||
item = next(row for row in listed if row["id"] == transition["id"])
|
||||
self.assertEqual(item["target_agent_id"], agent_id)
|
||||
self.assertEqual(item["purpose"], "existing_agent_credential_transition")
|
||||
serialized_item = repr(item)
|
||||
self.assertNotIn(token, serialized_item)
|
||||
for forbidden in (
|
||||
"token_hash",
|
||||
"transition_token",
|
||||
"agent_secret",
|
||||
"agent_secret_hash",
|
||||
):
|
||||
self.assertNotIn(forbidden, item)
|
||||
|
||||
registry = AgentRegistryRepository().list_registry()
|
||||
registry_agent = next(
|
||||
row for row in registry["agents"] if row["agent_id"] == agent_id
|
||||
)
|
||||
for forbidden in ("agent_secret", "agent_secret_hash", "token_hash"):
|
||||
self.assertNotIn(forbidden, registry_agent)
|
||||
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT new_value_json::text
|
||||
FROM audit_logs
|
||||
WHERE entity_id = %s
|
||||
""",
|
||||
(transition["id"],),
|
||||
)
|
||||
audit_text = "\n".join(row[0] for row in cur.fetchall())
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertNotIn(token, audit_text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -5,13 +5,24 @@ import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.agent_enrollment.dependencies import get_agent_enrollment_service
|
||||
from app.agent_auth.service import hash_agent_secret
|
||||
from app.agent_enrollment.models import (
|
||||
AgentCredentialTransitionCreateRequest,
|
||||
AgentCredentialTransitionRedeemRequest,
|
||||
AgentEnrollmentCreateRequest,
|
||||
AgentEnrollRequest,
|
||||
)
|
||||
from app.agent_enrollment.service import AgentEnrollmentService
|
||||
from app.identity import machine_agent_request, required_api_permission
|
||||
from app.agent_enrollment.router import router
|
||||
from app.identity import (
|
||||
get_current_user,
|
||||
machine_agent_request,
|
||||
required_api_permission,
|
||||
)
|
||||
|
||||
|
||||
SITE_ID = UUID("00000000-0000-4000-8000-000000001700")
|
||||
@@ -25,6 +36,8 @@ class FakeAgentEnrollmentRepository:
|
||||
def __init__(self):
|
||||
self.created = None
|
||||
self.redeemed = None
|
||||
self.transition_created = None
|
||||
self.transition_redeemed = None
|
||||
|
||||
def create_enrollment(self, **kwargs):
|
||||
self.created = kwargs
|
||||
@@ -42,6 +55,20 @@ class FakeAgentEnrollmentRepository:
|
||||
def list_enrollments(self, **kwargs):
|
||||
return []
|
||||
|
||||
def create_credential_transition(self, **kwargs):
|
||||
self.transition_created = kwargs
|
||||
return {
|
||||
"id": ENROLLMENT_ID,
|
||||
"target_agent_id": kwargs["target_agent_id"],
|
||||
"site_id": SITE_ID,
|
||||
"site_name": "Test Site",
|
||||
"agent_zone_id": ZONE_ID,
|
||||
"agent_zone_name": "Test Zone",
|
||||
"display_name": "Existing Agent",
|
||||
"created_at": NOW,
|
||||
"expires_at": NOW + timedelta(minutes=30),
|
||||
}
|
||||
|
||||
def redeem_enrollment(self, **kwargs):
|
||||
self.redeemed = kwargs
|
||||
return {
|
||||
@@ -54,6 +81,18 @@ class FakeAgentEnrollmentRepository:
|
||||
"ip_ranges": ["10.30.0.0/22"],
|
||||
}
|
||||
|
||||
def redeem_credential_transition(self, **kwargs):
|
||||
self.transition_redeemed = kwargs
|
||||
return {
|
||||
"agent_id": "existing-agent",
|
||||
"site_id": SITE_ID,
|
||||
"site_name": "Test Site",
|
||||
"agent_zone_id": ZONE_ID,
|
||||
"agent_zone_name": "Test Zone",
|
||||
"display_name": "Existing Agent",
|
||||
"ip_ranges": ["10.30.0.0/22"],
|
||||
}
|
||||
|
||||
|
||||
class AgentEnrollmentServiceTests(unittest.TestCase):
|
||||
def test_create_returns_plaintext_token_once_but_repository_gets_only_hash(self):
|
||||
@@ -123,6 +162,45 @@ class AgentEnrollmentServiceTests(unittest.TestCase):
|
||||
serialized = repr(repository.redeemed)
|
||||
self.assertNotIn(secret, serialized)
|
||||
|
||||
def test_existing_transition_is_server_bound_and_accepts_only_secret_hash(self):
|
||||
repository = FakeAgentEnrollmentRepository()
|
||||
service = AgentEnrollmentService(repository)
|
||||
|
||||
created = service.create_credential_transition(
|
||||
AgentCredentialTransitionCreateRequest(
|
||||
agent_id=" existing-agent "
|
||||
),
|
||||
actor_user_id=USER_ID,
|
||||
)
|
||||
token = created["transition_token"]
|
||||
|
||||
self.assertEqual(
|
||||
repository.transition_created["target_agent_id"],
|
||||
"existing-agent",
|
||||
)
|
||||
self.assertEqual(
|
||||
repository.transition_created["token_hash"],
|
||||
hash_agent_secret(token),
|
||||
)
|
||||
self.assertNotIn(token, repr(repository.transition_created))
|
||||
|
||||
credential_hash = hash_agent_secret("locally-generated-secret")
|
||||
result = service.redeem_credential_transition(
|
||||
AgentCredentialTransitionRedeemRequest(
|
||||
token=token,
|
||||
agent_secret_hash=credential_hash,
|
||||
)
|
||||
)
|
||||
self.assertEqual(result["agent_id"], "existing-agent")
|
||||
self.assertEqual(
|
||||
repository.transition_redeemed,
|
||||
{
|
||||
"token_hash": hash_agent_secret(token),
|
||||
"agent_secret_hash": credential_hash,
|
||||
},
|
||||
)
|
||||
self.assertNotIn("agent_secret", result)
|
||||
|
||||
def test_enrollment_admin_permission_mapping_is_explicit(self):
|
||||
self.assertEqual(
|
||||
required_api_permission("GET", "/api/agent-enrollments"),
|
||||
@@ -132,9 +210,18 @@ class AgentEnrollmentServiceTests(unittest.TestCase):
|
||||
required_api_permission("POST", "/api/agent-enrollments"),
|
||||
"agents.manage",
|
||||
)
|
||||
self.assertEqual(
|
||||
required_api_permission(
|
||||
"POST", "/api/agent-credential-transitions"
|
||||
),
|
||||
"agents.manage",
|
||||
)
|
||||
|
||||
def test_bootstrap_endpoint_is_machine_exempt_but_admin_endpoint_is_not(self):
|
||||
self.assertTrue(machine_agent_request("/api/agent/enroll"))
|
||||
self.assertTrue(
|
||||
machine_agent_request("/api/agent/credential-transition")
|
||||
)
|
||||
self.assertFalse(
|
||||
machine_agent_request("/api/agent-enrollments")
|
||||
)
|
||||
@@ -153,5 +240,66 @@ class AgentEnrollmentServiceTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class AgentCredentialTransitionRouterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = FakeAgentEnrollmentRepository()
|
||||
self.service = AgentEnrollmentService(self.repository)
|
||||
self.app = FastAPI()
|
||||
self.app.include_router(router)
|
||||
self.app.dependency_overrides[get_agent_enrollment_service] = (
|
||||
lambda: self.service
|
||||
)
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||
"id": str(USER_ID),
|
||||
"role": "Test",
|
||||
"permissions": ("agents.view", "agents.manage"),
|
||||
}
|
||||
self.client = TestClient(self.app)
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
|
||||
def test_admin_creation_is_distinct_no_store_and_identity_only(self):
|
||||
response = self.client.post(
|
||||
"/api/agent-credential-transitions",
|
||||
json={"agent_id": "existing-agent"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
payload = response.json()["data"]
|
||||
self.assertEqual(payload["target_agent_id"], "existing-agent")
|
||||
self.assertIn("transition_token", payload)
|
||||
self.assertNotIn("site_id", self.repository.transition_created)
|
||||
self.assertNotIn("agent_zone_id", self.repository.transition_created)
|
||||
|
||||
def test_machine_redemption_returns_no_permanent_plaintext_secret(self):
|
||||
response = self.client.post(
|
||||
"/api/agent/credential-transition",
|
||||
json={
|
||||
"token": "T" * 43,
|
||||
"agent_secret_hash": hash_agent_secret(
|
||||
"locally-generated-router-secret"
|
||||
),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
payload = response.json()["data"]
|
||||
self.assertEqual(payload["agent_id"], "existing-agent")
|
||||
self.assertNotIn("agent_secret", payload)
|
||||
|
||||
def test_view_only_admin_cannot_create_transition(self):
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||
"id": str(USER_ID),
|
||||
"role": "Test",
|
||||
"permissions": ("agents.view",),
|
||||
}
|
||||
response = self.client.post(
|
||||
"/api/agent-credential-transitions",
|
||||
json={"agent_id": "existing-agent"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from uuid import uuid4
|
||||
|
||||
from psycopg.errors import ForeignKeyViolation
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.agent_auth.exceptions import AgentAuthenticationFailed
|
||||
@@ -11,6 +12,7 @@ from app.agent_auth.repository import AgentAuthRepository
|
||||
from app.agent_auth.service import AgentAuthService, hash_agent_secret
|
||||
from app.agent_enrollment.exceptions import (
|
||||
AgentEnrollmentConflict,
|
||||
AgentEnrollmentCredentialCollision,
|
||||
AgentEnrollmentNotFound,
|
||||
AgentEnrollmentTokenInvalid,
|
||||
)
|
||||
@@ -26,6 +28,7 @@ from app.agent_enrollment.service import AgentEnrollmentService
|
||||
from app.agent_zones.models import AgentCheckinRequest
|
||||
from app.agent_zones.repository import AgentZoneRepository
|
||||
from app.agent_zones.service import AgentZoneService
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
@@ -322,6 +325,7 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
agent_secret_hash,
|
||||
credential_created_at,
|
||||
credential_revoked_at,
|
||||
legacy_auth_disabled_at,
|
||||
last_payload::text AS last_payload
|
||||
FROM minerops_site_agents
|
||||
WHERE site_id = %s
|
||||
@@ -345,6 +349,16 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
)
|
||||
enrollment_row = cur.fetchone()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT retired_at
|
||||
FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
retirement_row = cur.fetchone()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT new_value_json::text AS payload
|
||||
@@ -369,6 +383,15 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
)
|
||||
self.assertIsNotNone(agent["credential_created_at"])
|
||||
self.assertIsNone(agent["credential_revoked_at"])
|
||||
self.assertEqual(
|
||||
agent["legacy_auth_disabled_at"],
|
||||
agent["credential_created_at"],
|
||||
)
|
||||
self.assertIsNotNone(retirement_row)
|
||||
self.assertEqual(
|
||||
retirement_row["retired_at"],
|
||||
agent["credential_created_at"],
|
||||
)
|
||||
|
||||
self.assertIsNotNone(enrollment_row["used_at"])
|
||||
self.assertIsNone(enrollment_row["revoked_at"])
|
||||
@@ -409,6 +432,17 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
"definitely-not-the-issued-secret",
|
||||
)
|
||||
|
||||
original_legacy_id = settings.minerops_agent_id
|
||||
original_legacy_secret = settings.minerops_agent_shared_secret
|
||||
settings.minerops_agent_id = agent_id
|
||||
settings.minerops_agent_shared_secret = "global-secret-must-not-work"
|
||||
try:
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
auth_service.authenticate(None, "global-secret-must-not-work")
|
||||
finally:
|
||||
settings.minerops_agent_id = original_legacy_id
|
||||
settings.minerops_agent_shared_secret = original_legacy_secret
|
||||
|
||||
# Normal check-in must never mutate the credential.
|
||||
secret_hash_before = agent["agent_secret_hash"]
|
||||
|
||||
@@ -433,7 +467,8 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
SELECT
|
||||
agent_secret_hash,
|
||||
credential_created_at,
|
||||
credential_revoked_at
|
||||
credential_revoked_at,
|
||||
legacy_auth_disabled_at
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
@@ -453,6 +488,150 @@ class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
self.assertIsNone(
|
||||
after_checkin["credential_revoked_at"]
|
||||
)
|
||||
self.assertEqual(
|
||||
after_checkin["legacy_auth_disabled_at"],
|
||||
agent["legacy_auth_disabled_at"],
|
||||
)
|
||||
|
||||
# 0017 enrollment integrity remains RESTRICT: the current Agent row
|
||||
# cannot be deleted while its enrollment relationship exists.
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with self.assertRaises(ForeignKeyViolation):
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||
(agent_id,),
|
||||
)
|
||||
connection.rollback()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
# After an operator deliberately removes the enrollment history, the
|
||||
# independent retirement ledger must outlive deletion and recreation
|
||||
# of the current Agent row with the identical identity.
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM agent_enrollments WHERE id = %s",
|
||||
(enrollment["id"],),
|
||||
)
|
||||
cur.execute(
|
||||
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||
(agent_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
original_legacy_id = settings.minerops_agent_id
|
||||
original_legacy_secret = settings.minerops_agent_shared_secret
|
||||
settings.minerops_agent_id = agent_id
|
||||
settings.minerops_agent_shared_secret = "recreation-global-secret"
|
||||
try:
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
auth_service.authenticate(None, "recreation-global-secret")
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
site_name
|
||||
)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING legacy_auth_disabled_at
|
||||
""",
|
||||
(
|
||||
agent_id,
|
||||
self.site_id,
|
||||
self.zone_id,
|
||||
self.site_name,
|
||||
),
|
||||
)
|
||||
recreated = cur.fetchone()
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(
|
||||
recreated["legacy_auth_disabled_at"],
|
||||
retirement_row["retired_at"],
|
||||
)
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
auth_service.authenticate(agent_id, "recreation-global-secret")
|
||||
finally:
|
||||
settings.minerops_agent_id = original_legacy_id
|
||||
settings.minerops_agent_shared_secret = original_legacy_secret
|
||||
|
||||
def test_failed_credential_issuance_rolls_back_new_retirement(self):
|
||||
enrollment = self.create_enrollment("Collision Agent")
|
||||
collision_id = f"agent-collision-{uuid4().hex}"
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||
VALUES (%s, 'Existing uncredentialed identity')
|
||||
""",
|
||||
(collision_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
try:
|
||||
with self.assertRaises(AgentEnrollmentCredentialCollision):
|
||||
AgentEnrollmentRepository().redeem_enrollment(
|
||||
token_hash=hash_agent_secret(enrollment["enrollment_token"]),
|
||||
agent_id=collision_id,
|
||||
agent_secret_hash=hash_agent_secret("never-issued-secret"),
|
||||
)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*) AS count
|
||||
FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(collision_id,),
|
||||
)
|
||||
self.assertEqual(cur.fetchone()["count"], 0)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT used_at, enrolled_agent_id
|
||||
FROM agent_enrollments
|
||||
WHERE id = %s
|
||||
""",
|
||||
(enrollment["id"],),
|
||||
)
|
||||
enrollment_row = cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertIsNone(enrollment_row["used_at"])
|
||||
self.assertIsNone(enrollment_row["enrolled_agent_id"])
|
||||
finally:
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||
(collision_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_expired_token_is_rejected_without_side_effects(self):
|
||||
enrollment = self.create_enrollment()
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Disposable PostgreSQL contract for durable legacy-auth retirement."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from psycopg.errors import CheckViolation
|
||||
|
||||
from app.agent_auth.exceptions import (
|
||||
AgentAuthenticationFailed,
|
||||
AgentCredentialRevoked,
|
||||
)
|
||||
from app.agent_auth.repository import AgentAuthRepository, default_connection_factory
|
||||
from app.agent_auth.service import AgentAuthService
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class AgentLegacyAuthRetirementPostgresTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if os.environ.get("MINEROPS_LEGACY_RETIREMENT_DISPOSABLE") != "1":
|
||||
raise unittest.SkipTest(
|
||||
"legacy-auth retirement requires its disposable PostgreSQL runner"
|
||||
)
|
||||
cls.case = os.environ["MINEROPS_LEGACY_RETIREMENT_CASE"]
|
||||
cls.agent_id = settings.minerops_agent_id
|
||||
cls.auth = AgentAuthService(AgentAuthRepository())
|
||||
|
||||
def row(self):
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_secret_hash, credential_created_at,
|
||||
credential_revoked_at, legacy_auth_disabled_at
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(self.agent_id,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def retirement_row(self, agent_id=None):
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_id, retired_at
|
||||
FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(agent_id or self.agent_id,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_01_migration_backfill_is_selective(self):
|
||||
row = self.row()
|
||||
self.assertIsNotNone(row)
|
||||
if self.case == "legacy":
|
||||
self.assertEqual(row, (None, None, None, None))
|
||||
self.assertIsNone(self.retirement_row())
|
||||
else:
|
||||
self.assertIsNotNone(row[3])
|
||||
retirement = self.retirement_row()
|
||||
self.assertIsNotNone(retirement)
|
||||
self.assertEqual(retirement[1], row[3])
|
||||
|
||||
def test_02_real_repository_authentication_obeys_retirement(self):
|
||||
secret = settings.minerops_agent_shared_secret
|
||||
if self.case == "legacy":
|
||||
self.assertEqual(self.auth.authenticate(None, secret), self.agent_id)
|
||||
self.assertEqual(
|
||||
self.auth.authenticate(self.agent_id, secret), self.agent_id
|
||||
)
|
||||
return
|
||||
|
||||
for agent_id in (None, self.agent_id):
|
||||
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||
AgentCredentialRevoked
|
||||
):
|
||||
self.auth.authenticate(agent_id, secret)
|
||||
|
||||
def test_03_clearing_credentials_cannot_restore_global_fallback(self):
|
||||
if self.case == "legacy":
|
||||
self.skipTest("uncredentialed legacy case must remain eligible")
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE minerops_site_agents
|
||||
SET agent_secret_hash = NULL,
|
||||
credential_created_at = NULL,
|
||||
credential_revoked_at = NULL
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(self.agent_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
row = self.row()
|
||||
self.assertEqual(row[:3], (None, None, None))
|
||||
self.assertIsNotNone(row[3])
|
||||
for agent_id in (None, self.agent_id):
|
||||
with self.subTest(agent_id=agent_id), self.assertRaises(
|
||||
AgentAuthenticationFailed
|
||||
):
|
||||
self.auth.authenticate(
|
||||
agent_id, settings.minerops_agent_shared_secret
|
||||
)
|
||||
|
||||
def test_04_deletion_and_recreation_cannot_restore_fallback(self):
|
||||
if self.case == "legacy":
|
||||
self.skipTest("uncredentialed legacy case has no retirement history")
|
||||
|
||||
retirement = self.retirement_row()
|
||||
self.assertIsNotNone(retirement)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM minerops_site_agents WHERE agent_id = %s",
|
||||
(self.agent_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertIsNone(self.row())
|
||||
self.assertEqual(self.retirement_row(), retirement)
|
||||
for agent_id in (None, self.agent_id):
|
||||
with self.subTest(state="deleted", agent_id=agent_id), self.assertRaises(
|
||||
AgentAuthenticationFailed
|
||||
):
|
||||
self.auth.authenticate(
|
||||
agent_id, settings.minerops_agent_shared_secret
|
||||
)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||
VALUES (%s, 'Recreated')
|
||||
""",
|
||||
(self.agent_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
recreated = self.row()
|
||||
self.assertEqual(recreated[:3], (None, None, None))
|
||||
self.assertEqual(recreated[3], retirement[1])
|
||||
for agent_id in (None, self.agent_id):
|
||||
with self.subTest(state="recreated", agent_id=agent_id), self.assertRaises(
|
||||
AgentAuthenticationFailed
|
||||
):
|
||||
self.auth.authenticate(
|
||||
agent_id, settings.minerops_agent_shared_secret
|
||||
)
|
||||
|
||||
def test_05_retirement_ledger_and_projection_are_one_way(self):
|
||||
if self.case == "legacy":
|
||||
self.skipTest("uncredentialed legacy case has no retirement history")
|
||||
|
||||
statements = (
|
||||
"""
|
||||
UPDATE agent_legacy_auth_retirements
|
||||
SET retired_at = retired_at + interval '1 second'
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
"""
|
||||
DELETE FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
"TRUNCATE agent_legacy_auth_retirements",
|
||||
"""
|
||||
UPDATE minerops_site_agents
|
||||
SET legacy_auth_disabled_at = NULL
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
)
|
||||
for statement in statements:
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with self.subTest(statement=" ".join(statement.split())):
|
||||
with self.assertRaises(CheckViolation):
|
||||
with connection.cursor() as cur:
|
||||
if "%s" in statement:
|
||||
cur.execute(statement, (self.agent_id,))
|
||||
else:
|
||||
cur.execute(statement)
|
||||
connection.rollback()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertIsNotNone(self.retirement_row())
|
||||
self.assertIsNotNone(self.row()[3])
|
||||
|
||||
def test_06_other_legacy_identity_is_not_accidentally_retired(self):
|
||||
if self.case == "legacy":
|
||||
self.skipTest("cross-identity check runs in the retired case")
|
||||
|
||||
other_id = self.agent_id + "-untouched"
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||
VALUES (%s, 'Untouched')
|
||||
""",
|
||||
(other_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertIsNone(self.retirement_row(other_id))
|
||||
original_legacy_id = settings.minerops_agent_id
|
||||
settings.minerops_agent_id = other_id
|
||||
try:
|
||||
self.assertEqual(
|
||||
self.auth.authenticate(
|
||||
other_id, settings.minerops_agent_shared_secret
|
||||
),
|
||||
other_id,
|
||||
)
|
||||
finally:
|
||||
settings.minerops_agent_id = original_legacy_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -73,7 +73,8 @@ class AgentRegistryPostgresTests(unittest.TestCase):
|
||||
version,
|
||||
ip_address,
|
||||
agent_secret_hash,
|
||||
credential_created_at
|
||||
credential_created_at,
|
||||
legacy_auth_disabled_at
|
||||
)
|
||||
VALUES (
|
||||
%s,
|
||||
@@ -85,6 +86,7 @@ class AgentRegistryPostgresTests(unittest.TestCase):
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
""",
|
||||
|
||||
@@ -90,6 +90,23 @@ class FakeRepository:
|
||||
"zone_enabled": zone_row["is_enabled"] if zone_row else None,
|
||||
}
|
||||
|
||||
def get_agent_configuration(self, agent_id):
|
||||
assignment = self.get_agent_assignment(agent_id)
|
||||
if not assignment:
|
||||
return None
|
||||
zone_row = self.zones.get(assignment.get("agent_zone_id"))
|
||||
return {
|
||||
"agent_id": agent_id,
|
||||
"site_id": assignment.get("site_id"),
|
||||
"agent_zone_id": assignment.get("agent_zone_id"),
|
||||
"site_name": assignment.get("site_name"),
|
||||
"agent_zone_name": zone_row["name"] if zone_row else None,
|
||||
"agent_zone_enabled": zone_row["is_enabled"] if zone_row else None,
|
||||
"networks": [
|
||||
item["network"] for item in (zone_row or {}).get("networks", [])
|
||||
],
|
||||
}
|
||||
|
||||
def assign_miners_to_zone(self, miner_ids, agent_zone_id):
|
||||
self.assigned = (list(miner_ids), agent_zone_id)
|
||||
return {miner_id: self.assign_result.get(miner_id, "miner_not_found")
|
||||
@@ -218,6 +235,25 @@ class AgentZoneServiceTests(unittest.TestCase):
|
||||
self.assertEqual(result.routing_reason, "agent_zone_conflict")
|
||||
self.assertIsNone(result.site_id)
|
||||
|
||||
def test_configuration_uses_only_authenticated_agent_zone_networks(self):
|
||||
self.repository.zones[ZONE_A]["networks"] = [
|
||||
{"id": uuid4(), "network": "10.30.0.0/24"}
|
||||
]
|
||||
self.repository.zones[ZONE_B] = zone(ZONE_B, SITE_B, name="Remote")
|
||||
self.repository.zones[ZONE_B]["networks"] = [
|
||||
{"id": uuid4(), "network": "10.80.0.0/24"}
|
||||
]
|
||||
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||
|
||||
configuration = self.service.configuration("agent-a")
|
||||
|
||||
self.assertEqual(configuration["agent_zone_id"], ZONE_A)
|
||||
self.assertEqual(
|
||||
[item["cidr"] for item in configuration["ip_ranges"]],
|
||||
["10.30.0.0/24"],
|
||||
)
|
||||
self.assertNotIn("10.80.0.0/24", str(configuration))
|
||||
|
||||
|
||||
class AgentZoneRouterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Full HTTP/FastAPI plus PostgreSQL contract for the unchanged legacy Agent."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.agent_auth.service import hash_agent_secret
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
|
||||
|
||||
SITE_A = UUID("00000000-0000-4000-8000-000000001a01")
|
||||
SITE_B = UUID("00000000-0000-4000-8000-000000001a02")
|
||||
ZONE_C = UUID("00000000-0000-4000-8000-000000001a03")
|
||||
MINER_A = UUID("00000000-0000-4000-8000-000000001a11")
|
||||
MINER_B = UUID("00000000-0000-4000-8000-000000001a12")
|
||||
LEGACY_AGENT = "legacy-plexus-http"
|
||||
ENROLLED_AGENT = "enrolled-http-agent"
|
||||
REVOKED_AGENT = "revoked-http-agent"
|
||||
ENROLLED_SECRET = "enrolled-http-agent-secret"
|
||||
REVOKED_SECRET = "revoked-http-agent-secret"
|
||||
ZONE_A_NETWORK = "10.31.0.0/24"
|
||||
ZONE_B_NETWORK = "10.81.0.0/24"
|
||||
ZONE_C_NETWORK = "10.82.0.0/24"
|
||||
|
||||
|
||||
def connect():
|
||||
from app.agent_auth.repository import default_connection_factory
|
||||
|
||||
return default_connection_factory()
|
||||
|
||||
|
||||
class LegacyAgentHttpPostgresTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if os.environ.get("MINEROPS_LEGACY_HTTP_POSTGRES_DISPOSABLE") != "1":
|
||||
raise unittest.SkipTest(
|
||||
"full-stack legacy contract requires its disposable runner"
|
||||
)
|
||||
if settings.minerops_agent_id != LEGACY_AGENT:
|
||||
raise AssertionError("runner did not bind the configured legacy identity")
|
||||
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_zone_id
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(LEGACY_AGENT,),
|
||||
)
|
||||
cls.zone_a = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM agent_zones
|
||||
WHERE site_id = %s AND name = 'Default Zone'
|
||||
""",
|
||||
(SITE_B,),
|
||||
)
|
||||
cls.zone_b = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||
VALUES
|
||||
(%s, %s::cidr),
|
||||
(%s, %s::cidr)
|
||||
""",
|
||||
(cls.zone_a, ZONE_A_NETWORK, cls.zone_b, ZONE_B_NETWORK),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zones (id, site_id, name)
|
||||
VALUES (%s, %s, 'Revoked Contract Zone')
|
||||
""",
|
||||
(ZONE_C, SITE_B),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||
VALUES (%s, %s::cidr)
|
||||
""",
|
||||
(ZONE_C, ZONE_C_NETWORK),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, agent_zone_id, site_name, display_name,
|
||||
agent_secret_hash, credential_created_at,
|
||||
credential_revoked_at, legacy_auth_disabled_at
|
||||
) VALUES
|
||||
(%s, %s, %s, 'Contract Site B', 'Enrolled Agent',
|
||||
%s, now(), NULL, now()),
|
||||
(%s, %s, %s, 'Contract Site B', 'Revoked Agent',
|
||||
%s, now() - interval '1 hour', now(),
|
||||
now() - interval '1 hour')
|
||||
""",
|
||||
(
|
||||
ENROLLED_AGENT,
|
||||
SITE_B,
|
||||
cls.zone_b,
|
||||
hash_agent_secret(ENROLLED_SECRET),
|
||||
REVOKED_AGENT,
|
||||
SITE_B,
|
||||
ZONE_C,
|
||||
hash_agent_secret(REVOKED_SECRET),
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
# Deliberately do not enter TestClient as a context manager. That keeps
|
||||
# unrelated application startup jobs out of this focused HTTP contract
|
||||
# while exercising the real ASGI middleware, dependencies, and routes.
|
||||
cls.client = TestClient(app)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.client.close()
|
||||
|
||||
@staticmethod
|
||||
def legacy_headers():
|
||||
return {"X-MinerOps-Agent-Secret": settings.minerops_agent_shared_secret}
|
||||
|
||||
@staticmethod
|
||||
def agent_headers(agent_id, secret):
|
||||
return {
|
||||
"X-MinerOps-Agent-ID": agent_id,
|
||||
"X-MinerOps-Agent-Secret": secret,
|
||||
}
|
||||
|
||||
def assert_no_credentials(self, response):
|
||||
lowered = response.text.lower()
|
||||
for forbidden in (
|
||||
"agent_secret_hash", "credential_created_at", "token_hash",
|
||||
settings.minerops_agent_shared_secret.lower(),
|
||||
ENROLLED_SECRET.lower(), REVOKED_SECRET.lower(),
|
||||
):
|
||||
self.assertNotIn(forbidden, lowered)
|
||||
|
||||
def test_10_secret_only_config_is_legacy_identity_and_zone_bound(self):
|
||||
response = self.client.get(
|
||||
"/api/agent/config?agent_id=" + ENROLLED_AGENT
|
||||
+ "&agent_zone_id=" + str(self.zone_b),
|
||||
headers=self.legacy_headers(),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["agent_id"], LEGACY_AGENT)
|
||||
self.assertEqual(payload["agent_zone_id"], str(self.zone_a))
|
||||
self.assertEqual(
|
||||
[item["cidr"] for item in payload["ip_ranges"]],
|
||||
[ZONE_A_NETWORK],
|
||||
)
|
||||
self.assertNotIn(ZONE_B_NETWORK, response.text)
|
||||
self.assertNotIn(ZONE_C_NETWORK, response.text)
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
def test_20_known_miners_returns_only_migrated_legacy_zone(self):
|
||||
response = self.client.get(
|
||||
"/api/agent/known-miners", headers=self.legacy_headers()
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(
|
||||
[item["miner_id"] for item in response.json()["miners"]],
|
||||
[str(MINER_A)],
|
||||
)
|
||||
self.assertNotIn(str(MINER_B), response.text)
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
def test_30_exact_old_checkin_shape_succeeds_and_spoofing_fails(self):
|
||||
old_body = {
|
||||
"agent_id": LEGACY_AGENT,
|
||||
"site_name": "Plexus",
|
||||
"hostname": "legacy-contract-host",
|
||||
"version": "0.4-command-queue",
|
||||
"ip_address": "",
|
||||
"local_time": "2026-08-31T00:00:00Z",
|
||||
}
|
||||
response = self.client.post(
|
||||
"/api/agent/checkin", headers=self.legacy_headers(), json=old_body
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(response.json()["agent_id"], LEGACY_AGENT)
|
||||
self.assertEqual(response.json()["agent_zone_id"], str(self.zone_a))
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
spoofed = self.client.post(
|
||||
"/api/agent/checkin?agent_id=" + ENROLLED_AGENT,
|
||||
headers=self.legacy_headers(),
|
||||
json={**old_body, "agent_id": "body-spoof-agent"},
|
||||
)
|
||||
self.assertEqual(spoofed.status_code, 422, spoofed.text)
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT count(*) FROM minerops_site_agents WHERE agent_id = %s",
|
||||
("body-spoof-agent",),
|
||||
)
|
||||
self.assertEqual(cur.fetchone()[0], 0)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_40_global_secret_rejects_unknown_and_enrolled_explicit_ids(self):
|
||||
for agent_id, expected_status in (
|
||||
("unknown-agent", 403),
|
||||
(ENROLLED_AGENT, 401),
|
||||
):
|
||||
with self.subTest(agent_id=agent_id):
|
||||
response = self.client.get(
|
||||
"/api/agent/config",
|
||||
headers=self.agent_headers(
|
||||
agent_id, settings.minerops_agent_shared_secret
|
||||
),
|
||||
)
|
||||
self.assertEqual(response.status_code, expected_status, response.text)
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
def test_50_enrolled_agent_uses_per_agent_secret_and_own_zone(self):
|
||||
response = self.client.get(
|
||||
"/api/agent/config",
|
||||
headers=self.agent_headers(ENROLLED_AGENT, ENROLLED_SECRET),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["agent_id"], ENROLLED_AGENT)
|
||||
self.assertEqual(payload["agent_zone_id"], str(self.zone_b))
|
||||
self.assertEqual(
|
||||
[item["cidr"] for item in payload["ip_ranges"]],
|
||||
[ZONE_B_NETWORK],
|
||||
)
|
||||
self.assertNotIn(ZONE_A_NETWORK, response.text)
|
||||
self.assertNotIn(ZONE_C_NETWORK, response.text)
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
def test_60_revoked_credential_cannot_use_per_agent_or_global_secret(self):
|
||||
for secret in (REVOKED_SECRET, settings.minerops_agent_shared_secret):
|
||||
with self.subTest(secret_kind="global" if secret != REVOKED_SECRET else "agent"):
|
||||
response = self.client.get(
|
||||
"/api/agent/config",
|
||||
headers=self.agent_headers(REVOKED_AGENT, secret),
|
||||
)
|
||||
self.assertEqual(response.status_code, 403, response.text)
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
def test_90_durably_retired_legacy_identity_loses_secret_only_fallback(self):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE minerops_site_agents
|
||||
SET legacy_auth_disabled_at = now()
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(LEGACY_AGENT,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
response = self.client.get(
|
||||
"/api/agent/config", headers=self.legacy_headers()
|
||||
)
|
||||
self.assertEqual(response.status_code, 401, response.text)
|
||||
self.assert_no_credentials(response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Focused source-level contracts for legacy Agent migrations."""
|
||||
|
||||
import importlib.util
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
VERSIONS = Path(__file__).parents[1] / "alembic" / "versions"
|
||||
|
||||
|
||||
def load_migration(filename):
|
||||
path = VERSIONS / filename
|
||||
spec = importlib.util.spec_from_file_location(filename.removesuffix(".py"), path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class EmptyResult:
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
|
||||
class RecordingConnection:
|
||||
def __init__(self):
|
||||
self.statements = []
|
||||
|
||||
def execute(self, statement, parameters=None):
|
||||
del parameters
|
||||
self.statements.append(str(statement))
|
||||
return EmptyResult()
|
||||
|
||||
|
||||
class LegacyRoutingMigrationTests(unittest.TestCase):
|
||||
def test_0018_locks_every_preflight_and_update_table_before_reads(self):
|
||||
migration = load_migration("0018_legacy_agent_routing.py")
|
||||
connection = RecordingConnection()
|
||||
with patch.object(migration.op, "get_bind", return_value=connection):
|
||||
migration.upgrade()
|
||||
|
||||
first = " ".join(connection.statements[0].split()).lower()
|
||||
self.assertTrue(first.startswith("lock table"))
|
||||
self.assertIn("in share row exclusive mode", first)
|
||||
for table in (
|
||||
"agent_zones", "minerops_agent_discoveries",
|
||||
"minerops_agent_snapshots", "minerops_site_agents", "sites",
|
||||
):
|
||||
with self.subTest(table=table):
|
||||
self.assertIn(table, first)
|
||||
|
||||
first_update = next(
|
||||
index for index, statement in enumerate(connection.statements)
|
||||
if "UPDATE " in statement
|
||||
)
|
||||
self.assertEqual(first_update, 4)
|
||||
|
||||
def test_0019_is_the_direct_forward_only_successor(self):
|
||||
migration = load_migration("0019_agent_legacy_auth_retire.py")
|
||||
self.assertEqual(migration.revision, "0019_agent_legacy_auth_retire")
|
||||
self.assertEqual(migration.down_revision, "0018_legacy_agent_routing")
|
||||
|
||||
def test_0020_adds_only_existing_identity_transition_binding(self):
|
||||
migration = load_migration(
|
||||
"0020_existing_agent_credential_transition.py"
|
||||
)
|
||||
self.assertEqual(migration.revision, "0020_existing_agent_transition")
|
||||
self.assertEqual(
|
||||
migration.down_revision,
|
||||
"0019_agent_legacy_auth_retire",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Fresh-chain production compatibility contract for the legacy Plexus Agent."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.agent_auth.exceptions import (
|
||||
AgentAuthenticationFailed,
|
||||
AgentIdentityUnauthorized,
|
||||
)
|
||||
from app.agent_auth.repository import AgentAuthRepository
|
||||
from app.agent_auth.service import AgentAuthService, hash_agent_secret
|
||||
from app.agent_zones.repository import AgentZoneRepository
|
||||
from app.agent_zones.service import AgentZoneService
|
||||
from app.config import settings
|
||||
from app.main import (
|
||||
minerops_agent_known_miners,
|
||||
minerops_agent_miner_snapshots,
|
||||
)
|
||||
|
||||
|
||||
SITE_A = UUID("00000000-0000-4000-8000-000000001901")
|
||||
SITE_B = UUID("00000000-0000-4000-8000-000000001902")
|
||||
MINER_A = UUID("00000000-0000-4000-8000-000000001911")
|
||||
MINER_B = UUID("00000000-0000-4000-8000-000000001912")
|
||||
LEGACY_AGENT = "legacy-plexus-production"
|
||||
NEW_AGENT = "enrolled-agent-production-contract"
|
||||
NEW_SECRET = "new-agent-production-contract-secret"
|
||||
|
||||
|
||||
def connect():
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
connect_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
def json_request(payload):
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
delivered = False
|
||||
|
||||
async def receive():
|
||||
nonlocal delivered
|
||||
if delivered:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
delivered = True
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"http_version": "1.1",
|
||||
"method": "POST",
|
||||
"scheme": "http",
|
||||
"path": "/api/agent/miner-snapshots",
|
||||
"raw_path": b"/api/agent/miner-snapshots",
|
||||
"query_string": b"",
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"client": ("testclient", 12345),
|
||||
"server": ("testserver", 80),
|
||||
},
|
||||
receive,
|
||||
)
|
||||
|
||||
|
||||
class LegacyAgentProductionPostgresTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if os.environ.get("MINEROPS_LEGACY_PRODUCTION_DISPOSABLE") != "1":
|
||||
raise unittest.SkipTest(
|
||||
"Legacy production contract requires its disposable PostgreSQL runner"
|
||||
)
|
||||
if settings.minerops_agent_id != LEGACY_AGENT:
|
||||
raise AssertionError("Runner did not bind the configured legacy identity")
|
||||
|
||||
cls.auth = AgentAuthService(AgentAuthRepository(connect))
|
||||
cls.zones = AgentZoneService(AgentZoneRepository(connect))
|
||||
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_zone_id
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(LEGACY_AGENT,),
|
||||
)
|
||||
cls.zone_a = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM agent_zones
|
||||
WHERE site_id = %s AND name = 'Default Zone'
|
||||
""",
|
||||
(SITE_B,),
|
||||
)
|
||||
cls.zone_b = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, agent_zone_id, site_name,
|
||||
agent_secret_hash, credential_created_at,
|
||||
legacy_auth_disabled_at
|
||||
)
|
||||
VALUES (
|
||||
%s, %s, %s, 'Enrolled Contract Agent', %s, now(), now()
|
||||
)
|
||||
""",
|
||||
(
|
||||
NEW_AGENT,
|
||||
SITE_B,
|
||||
cls.zone_b,
|
||||
hash_agent_secret(NEW_SECRET),
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_secret_only_auth_is_bounded_to_legacy_identity(self):
|
||||
self.assertEqual(
|
||||
self.auth.authenticate(None, settings.minerops_agent_shared_secret),
|
||||
LEGACY_AGENT,
|
||||
)
|
||||
with self.assertRaises(AgentIdentityUnauthorized):
|
||||
self.auth.authenticate(
|
||||
"unknown-agent", settings.minerops_agent_shared_secret
|
||||
)
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.auth.authenticate(
|
||||
NEW_AGENT, settings.minerops_agent_shared_secret
|
||||
)
|
||||
self.assertEqual(
|
||||
self.auth.authenticate(NEW_AGENT, NEW_SECRET),
|
||||
NEW_AGENT,
|
||||
)
|
||||
|
||||
def test_migrated_legacy_agent_has_correct_site_and_zone(self):
|
||||
routing = self.zones.routing_context(LEGACY_AGENT)
|
||||
self.assertIsNone(routing.routing_reason)
|
||||
self.assertEqual(routing.site_id, SITE_A)
|
||||
self.assertEqual(routing.agent_zone_id, self.zone_a)
|
||||
|
||||
def test_known_miners_and_telemetry_remain_zone_scoped(self):
|
||||
known = asyncio.run(
|
||||
minerops_agent_known_miners(LEGACY_AGENT, self.zones)
|
||||
)
|
||||
self.assertEqual(
|
||||
[row["miner_id"] for row in known["miners"]],
|
||||
[str(MINER_A)],
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
minerops_agent_miner_snapshots(
|
||||
json_request(
|
||||
{
|
||||
"agent_id": LEGACY_AGENT,
|
||||
"site_name": "Plexus",
|
||||
"miners": [
|
||||
{
|
||||
"miner_id": str(MINER_A),
|
||||
"ip_address": "192.0.2.191",
|
||||
"online": False,
|
||||
},
|
||||
{
|
||||
"miner_id": str(MINER_B),
|
||||
"ip_address": "192.0.2.192",
|
||||
"online": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
),
|
||||
LEGACY_AGENT,
|
||||
self.zones,
|
||||
)
|
||||
)
|
||||
self.assertEqual(result["agent_zone_id"], str(self.zone_a))
|
||||
self.assertEqual(result["apply_result"]["applied"], 1)
|
||||
self.assertIn(
|
||||
{"miner_id": str(MINER_B), "error": "agent_zone_conflict"},
|
||||
result["apply_result"]["errors"],
|
||||
)
|
||||
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT status FROM miners WHERE id = %s",
|
||||
(MINER_A,),
|
||||
)
|
||||
self.assertEqual(cur.fetchone()[0], "offline")
|
||||
cur.execute(
|
||||
"SELECT status FROM miners WHERE id = %s",
|
||||
(MINER_B,),
|
||||
)
|
||||
self.assertEqual(cur.fetchone()[0], "online")
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM minerops_agent_snapshots
|
||||
WHERE agent_id = %s AND agent_zone_id = %s
|
||||
""",
|
||||
(LEGACY_AGENT, self.zone_a),
|
||||
)
|
||||
self.assertGreaterEqual(cur.fetchone()[0], 1)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -78,8 +78,12 @@ migration_head="$(
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'psql -v ON_ERROR_STOP=1 -At -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT version_num FROM alembic_version"'
|
||||
)"
|
||||
if [[ "${migration_head}" != "0016_agent_zones" ]]; then
|
||||
echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0016_agent_zones." >&2
|
||||
expected_head="$(
|
||||
"${compose[@]}" run --rm --no-deps backend alembic heads 2>/dev/null |
|
||||
awk 'NR == 1 { print $1 }'
|
||||
)"
|
||||
if [[ -z "${expected_head}" || "${migration_head}" != "${expected_head}" ]]; then
|
||||
echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected ${expected_head:-unresolved}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
+164
@@ -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."
|
||||
@@ -179,8 +179,9 @@ run_alembic upgrade 0016_agent_zones
|
||||
echo "Testing empty database through head..."
|
||||
"${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_agent_zones_empty'
|
||||
"${compose[@]}" run --rm --no-deps -e POSTGRES_DB=minerops_agent_zones_empty backend alembic upgrade head
|
||||
expected_head="$(run_alembic heads 2>/dev/null | awk 'NR == 1 { print $1 }')"
|
||||
empty_revision="$("${compose[@]}" exec -T postgres sh -ec \
|
||||
'psql -U "$POSTGRES_USER" -d minerops_agent_zones_empty -Atc "SELECT version_num FROM alembic_version"')"
|
||||
[[ "${empty_revision}" == "0016_agent_zones" ]]
|
||||
[[ -n "${expected_head}" && "${empty_revision}" == "${expected_head}" ]]
|
||||
|
||||
echo "Agent Zones migration lifecycle checks passed."
|
||||
@@ -132,7 +132,9 @@ health_json="$(cat "${health_file}")"
|
||||
|
||||
echo "Checking migrations and required schema..."
|
||||
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
|
||||
[[ "${current_revision}" == *"0017_agent_enrollment"* ]]
|
||||
expected_revision="$("${compose[@]}" exec -T backend alembic heads 2>/dev/null | awk 'NR == 1 { print $1 }')"
|
||||
[[ -n "${expected_revision}" ]]
|
||||
[[ "${current_revision}" == *"${expected_revision}"* ]]
|
||||
|
||||
missing_tables="$(
|
||||
"${compose[@]}" exec -T postgres sh -c \
|
||||
|
||||
+67
@@ -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
@@ -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
@@ -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."
|
||||
@@ -130,8 +130,9 @@ run_alembic upgrade 0015_miner_control_foundation
|
||||
echo "Testing empty database through head..."
|
||||
"${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_control_empty'
|
||||
"${compose[@]}" run --rm --no-deps -e POSTGRES_DB=minerops_control_empty backend alembic upgrade head
|
||||
expected_head="$(run_alembic heads 2>/dev/null | awk 'NR == 1 { print $1 }')"
|
||||
empty_revision="$("${compose[@]}" exec -T postgres sh -ec \
|
||||
'psql -U "$POSTGRES_USER" -d minerops_control_empty -Atc "SELECT version_num FROM alembic_version"')"
|
||||
[[ "${empty_revision}" == "0015_miner_control_foundation" ]]
|
||||
[[ -n "${expected_head}" && "${empty_revision}" == "${expected_head}" ]]
|
||||
|
||||
echo "Miner Control Foundation migration tests passed."
|
||||
Reference in new issue
Block a user