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
@@ -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.",
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user