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

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

No files matched your search

+12 -6
View File
@@ -32,12 +32,18 @@ class AgentAuthRepository:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT agent_id,
agent_secret_hash,
credential_created_at,
credential_revoked_at
FROM minerops_site_agents
WHERE agent_id = %s
SELECT identity.agent_id,
agent.agent_secret_hash,
agent.credential_created_at,
agent.credential_revoked_at,
retirement.retired_at AS legacy_auth_disabled_at
FROM (VALUES (%s::text)) AS identity(agent_id)
LEFT JOIN minerops_site_agents AS agent
ON agent.agent_id = identity.agent_id
LEFT JOIN agent_legacy_auth_retirements AS retirement
ON retirement.agent_id = identity.agent_id
WHERE agent.agent_id IS NOT NULL
OR retirement.agent_id IS NOT NULL
""",
(agent_id,),
)
+43 -7
View File
@@ -25,19 +25,50 @@ class AgentAuthService:
identity = (agent_id or "").strip()
secret = (provided_secret or "").strip()
if not identity:
raise AgentAuthenticationFailed("Missing Agent identity")
if not secret:
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
# The production Alpha Agent predates the identity header. Missing-ID
# compatibility is therefore bound to exactly the configured legacy
# identity; no request value is allowed to select another Agent.
legacy_id = settings.minerops_agent_id.strip()
legacy_secret = settings.minerops_agent_shared_secret.strip()
missing_identity = not identity
if missing_identity:
if not legacy_id:
raise AgentAuthenticationUnavailable(
"Legacy Agent authentication is not configured"
)
identity = legacy_id
credential = self.repository.get_credential(identity)
# Revocation is authoritative regardless of which credential path
# would otherwise apply. Never allow malformed or historical
# credential state to fall through to the legacy shared secret.
if credential and credential.get("credential_revoked_at") is not None:
raise AgentCredentialRevoked("Agent credential has been revoked")
# This value comes from the identity-level retirement ledger, not the
# deletable current Agent row. It is therefore authoritative even when
# the Agent was deleted and has not yet been recreated.
legacy_auth_retired = bool(
credential
and credential.get("legacy_auth_disabled_at") is not None
)
# Any Agent with a per-Agent credential is permanently on the new
# authentication path. The legacy shared secret must never authenticate
# that identity, even if the Agent later becomes revoked.
if credential and credential.get("agent_secret_hash"):
if credential.get("credential_revoked_at") is not None:
raise AgentCredentialRevoked("Agent credential has been revoked")
# Once the configured legacy identity has ever received a
# per-Agent credential, secret-only fallback is permanently
# disabled. The legacy global secret cannot authenticate it and a
# per-Agent secret cannot be used without an explicit identity.
if missing_identity:
raise AgentAuthenticationFailed(
"Explicit Agent identity is required for enrolled Agents"
)
supplied_hash = hash_agent_secret(secret)
if not hmac.compare_digest(
@@ -48,10 +79,15 @@ class AgentAuthService:
return identity
# Temporary compatibility for the single pre-enrollment Agent.
legacy_id = settings.minerops_agent_id.strip()
legacy_secret = settings.minerops_agent_shared_secret.strip()
# The durable identity retirement survives Agent-row deletion,
# credential-field corruption, administrative mistakes, and future
# credential rotation workflows.
if legacy_auth_retired:
raise AgentAuthenticationFailed(
"Legacy Agent authentication has been permanently disabled"
)
# Temporary compatibility for the single pre-enrollment Agent.
if not legacy_id or not legacy_secret:
raise AgentAuthenticationUnavailable(
"Legacy Agent authentication is not configured"
+55
View File
@@ -53,7 +53,9 @@ class AgentEnrollmentSummary(BaseModel):
expires_at: datetime
used_at: datetime | None = None
revoked_at: datetime | None = None
target_agent_id: str | None = None
enrolled_agent_id: str | None = None
purpose: Literal["new_agent", "existing_agent_credential_transition"]
status: Literal["waiting", "used", "revoked", "expired"]
@@ -96,3 +98,56 @@ class AgentEnrollResponse(BaseModel):
class AgentWindowsPackageRequest(AgentEnrollRequest):
"""Short-lived token used to construct a Windows bootstrap package."""
class AgentCredentialTransitionCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
agent_id: str = Field(min_length=1, max_length=255)
@field_validator("agent_id")
@classmethod
def normalize_agent_id(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("agent_id cannot be blank")
return normalized
class AgentCredentialTransitionCreated(BaseModel):
id: UUID
target_agent_id: str
site_id: UUID
site_name: str
agent_zone_id: UUID
agent_zone_name: str
display_name: str
created_at: datetime
expires_at: datetime
transition_token: str
class AgentCredentialTransitionCreateResponse(BaseModel):
data: AgentCredentialTransitionCreated
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class AgentCredentialTransitionRedeemRequest(AgentEnrollRequest):
agent_secret_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
class AgentCredentialTransitionCredentials(BaseModel):
agent_id: str
site_id: UUID
site_name: str
agent_zone_id: UUID
agent_zone_name: str
display_name: str
ip_ranges: list[str] = Field(default_factory=list)
class AgentCredentialTransitionResponse(BaseModel):
data: AgentCredentialTransitionCredentials
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
+464 -2
View File
@@ -1,5 +1,6 @@
"""PostgreSQL persistence for secure Agent enrollment."""
import hmac
from collections.abc import Callable
from uuid import UUID
@@ -21,6 +22,7 @@ ConnectionFactory = Callable[[], psycopg.Connection]
TOKEN_HASH_CONSTRAINT = "uq_agent_enrollments_token_hash"
OPEN_ZONE_CONSTRAINT = "uq_agent_enrollments_open_zone"
OPEN_TARGET_CONSTRAINT = "uq_agent_enrollments_open_target_agent"
AGENT_SECRET_HASH_CONSTRAINT = "uq_minerops_site_agents_agent_secret_hash"
AGENT_ZONE_CONSTRAINT = "uq_minerops_site_agents_agent_zone_id"
AGENT_ID_CONSTRAINT = "minerops_site_agents_pkey"
@@ -214,6 +216,184 @@ class AgentEnrollmentRepository:
finally:
connection.close()
def create_credential_transition(
self,
*,
token_hash: str,
target_agent_id: str,
actor_user_id: UUID,
) -> dict:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
agent.agent_id,
agent.site_id,
agent.agent_zone_id,
agent.display_name,
agent.agent_secret_hash,
agent.credential_created_at,
agent.credential_revoked_at,
agent.legacy_auth_disabled_at,
site.name AS site_name,
zone.name AS agent_zone_name,
zone.is_enabled AS agent_zone_enabled,
retirement.retired_at
FROM minerops_site_agents AS agent
JOIN sites AS site ON site.id = agent.site_id
JOIN agent_zones AS zone
ON zone.id = agent.agent_zone_id
AND zone.site_id = agent.site_id
LEFT JOIN agent_legacy_auth_retirements AS retirement
ON retirement.agent_id = agent.agent_id
WHERE agent.agent_id = %s
FOR UPDATE OF agent, zone
""",
(target_agent_id,),
)
agent = cur.fetchone()
if not agent:
raise AgentEnrollmentNotFound(
"Existing Agent with durable Site and Zone routing was not found"
)
if agent["agent_zone_enabled"] is not True:
raise AgentEnrollmentConflict("Agent Zone is disabled")
if any(
agent[field] is not None
for field in (
"agent_secret_hash",
"credential_created_at",
"credential_revoked_at",
"legacy_auth_disabled_at",
"retired_at",
)
):
raise AgentEnrollmentConflict(
"Existing Agent is not eligible for legacy credential transition"
)
cur.execute("SELECT clock_timestamp() AS operation_now")
operation_now = cur.fetchone()["operation_now"]
cur.execute(
"""
UPDATE agent_enrollments
SET revoked_at = %s
WHERE agent_zone_id = %s
AND used_at IS NULL
AND revoked_at IS NULL
RETURNING id
""",
(operation_now, agent["agent_zone_id"]),
)
replaced_ids = [str(row["id"]) for row in cur.fetchall()]
display_name = (
str(agent["display_name"] or agent["agent_id"]).strip()[:120]
)
cur.execute(
"""
INSERT INTO agent_enrollments (
token_hash,
site_id,
agent_zone_id,
display_name,
created_by_user_id,
created_at,
expires_at,
target_agent_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s + interval '30 minutes', %s)
RETURNING
id,
site_id,
agent_zone_id,
display_name,
created_at,
expires_at,
target_agent_id
""",
(
token_hash,
agent["site_id"],
agent["agent_zone_id"],
display_name,
actor_user_id,
operation_now,
operation_now,
agent["agent_id"],
),
)
transition = dict(cur.fetchone())
transition["site_name"] = agent["site_name"]
transition["agent_zone_name"] = agent["agent_zone_name"]
cur.execute(
"""
INSERT INTO audit_logs (
actor_user_id,
action,
entity_type,
entity_id,
new_value_json,
created_at
)
VALUES (
%s,
'agent.credential_transition.created',
'agent_enrollment',
%s,
%s,
%s
)
""",
(
actor_user_id,
transition["id"],
Jsonb(
{
"target_agent_id": agent["agent_id"],
"site_id": str(agent["site_id"]),
"agent_zone_id": str(agent["agent_zone_id"]),
"expires_at": transition["expires_at"].isoformat(),
"replaced_enrollment_ids": replaced_ids,
}
),
operation_now,
),
)
connection.commit()
return transition
except UniqueViolation as exc:
connection.rollback()
constraint = exc.diag.constraint_name
if constraint == TOKEN_HASH_CONSTRAINT:
raise AgentEnrollmentCredentialCollision(
"Generated transition token collided"
) from exc
if constraint in {OPEN_ZONE_CONSTRAINT, OPEN_TARGET_CONSTRAINT}:
raise AgentEnrollmentConflict(
"An active credential transition already exists for this Agent"
) from exc
raise
except Exception:
connection.rollback()
raise
finally:
connection.close()
def list_enrollments(
self,
*,
@@ -238,7 +418,13 @@ class AgentEnrollmentRepository:
enrollment.expires_at,
enrollment.used_at,
enrollment.revoked_at,
enrollment.target_agent_id,
enrollment.enrolled_agent_id,
CASE
WHEN enrollment.target_agent_id IS NULL
THEN 'new_agent'
ELSE 'existing_agent_credential_transition'
END AS purpose,
CASE
WHEN enrollment.used_at IS NOT NULL
THEN 'used'
@@ -292,7 +478,8 @@ class AgentEnrollmentRepository:
display_name,
expires_at,
used_at,
revoked_at
revoked_at,
target_agent_id
FROM agent_enrollments
WHERE token_hash = %s
FOR UPDATE
@@ -309,6 +496,7 @@ class AgentEnrollmentRepository:
if (
enrollment["used_at"] is not None
or enrollment["revoked_at"] is not None
or enrollment["target_agent_id"] is not None
):
raise AgentEnrollmentTokenInvalid(
"Invalid or expired enrollment token"
@@ -371,6 +559,22 @@ class AgentEnrollmentRepository:
"Invalid or expired enrollment token"
)
# Retire the identity before storing its credential. Both
# writes use this transaction, so a failed issuance cannot
# leave a new misleading tombstone. ON CONFLICT preserves an
# earlier retirement if this identity has credential history.
cur.execute(
"""
INSERT INTO agent_legacy_auth_retirements (
agent_id,
retired_at
)
VALUES (%s, %s)
ON CONFLICT (agent_id) DO NOTHING
""",
(agent_id, operation_now),
)
cur.execute(
"""
INSERT INTO minerops_site_agents (
@@ -380,7 +584,8 @@ class AgentEnrollmentRepository:
site_name,
display_name,
agent_secret_hash,
credential_created_at
credential_created_at,
legacy_auth_disabled_at
)
VALUES (
%s,
@@ -389,6 +594,7 @@ class AgentEnrollmentRepository:
%s,
%s,
%s,
%s,
%s
)
""",
@@ -400,6 +606,7 @@ class AgentEnrollmentRepository:
enrollment["display_name"],
agent_secret_hash,
operation_now,
operation_now,
),
)
@@ -504,3 +711,258 @@ class AgentEnrollmentRepository:
finally:
connection.close()
def redeem_credential_transition(
self,
*,
token_hash: str,
agent_secret_hash: str,
) -> dict:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
id,
site_id,
agent_zone_id,
display_name,
expires_at,
used_at,
revoked_at,
target_agent_id,
enrolled_agent_id
FROM agent_enrollments
WHERE token_hash = %s
FOR UPDATE
""",
(token_hash,),
)
enrollment = cur.fetchone()
if (
not enrollment
or enrollment["target_agent_id"] is None
or enrollment["revoked_at"] is not None
):
raise AgentEnrollmentTokenInvalid(
"Invalid or expired credential transition token"
)
cur.execute(
"""
SELECT
agent.agent_id,
agent.site_id,
agent.agent_zone_id,
agent.display_name,
agent.agent_secret_hash,
agent.credential_created_at,
agent.credential_revoked_at,
agent.legacy_auth_disabled_at,
site.name AS site_name,
zone.name AS agent_zone_name,
zone.is_enabled AS agent_zone_enabled,
retirement.retired_at
FROM minerops_site_agents AS agent
JOIN sites AS site ON site.id = agent.site_id
JOIN agent_zones AS zone
ON zone.id = agent.agent_zone_id
AND zone.site_id = agent.site_id
LEFT JOIN agent_legacy_auth_retirements AS retirement
ON retirement.agent_id = agent.agent_id
WHERE agent.agent_id = %s
FOR UPDATE OF agent, zone
""",
(enrollment["target_agent_id"],),
)
agent = cur.fetchone()
if not agent:
raise AgentEnrollmentConflict(
"Credential transition target no longer exists with durable routing"
)
if (
agent["site_id"] != enrollment["site_id"]
or agent["agent_zone_id"] != enrollment["agent_zone_id"]
):
raise AgentEnrollmentConflict(
"Existing Agent routing changed after transition authorization"
)
if agent["agent_zone_enabled"] is not True:
raise AgentEnrollmentConflict("Agent Zone is disabled")
def transition_result() -> dict:
cur.execute(
"""
SELECT network::text AS network
FROM agent_zone_network_ranges
WHERE agent_zone_id = %s
ORDER BY network, id
""",
(enrollment["agent_zone_id"],),
)
return {
"agent_id": agent["agent_id"],
"site_id": agent["site_id"],
"site_name": agent["site_name"],
"agent_zone_id": agent["agent_zone_id"],
"agent_zone_name": agent["agent_zone_name"],
"display_name": str(
agent["display_name"] or enrollment["display_name"]
),
"ip_ranges": [row["network"] for row in cur.fetchall()],
}
# A retry after an ambiguous network response is successful only
# for the exact locally generated credential that won the first
# transaction. It never rotates or reveals a credential.
if enrollment["used_at"] is not None:
stored_hash = str(agent["agent_secret_hash"] or "")
if (
enrollment["enrolled_agent_id"]
== enrollment["target_agent_id"]
and stored_hash
and hmac.compare_digest(stored_hash, agent_secret_hash)
and agent["credential_created_at"] is not None
and agent["credential_revoked_at"] is None
and agent["legacy_auth_disabled_at"] is not None
and agent["retired_at"] is not None
):
result = transition_result()
connection.commit()
return result
raise AgentEnrollmentTokenInvalid(
"Credential transition token has already been consumed"
)
cur.execute("SELECT clock_timestamp() AS operation_now")
operation_now = cur.fetchone()["operation_now"]
if enrollment["expires_at"] <= operation_now:
raise AgentEnrollmentTokenInvalid(
"Invalid or expired credential transition token"
)
if any(
agent[field] is not None
for field in (
"agent_secret_hash",
"credential_created_at",
"credential_revoked_at",
"legacy_auth_disabled_at",
"retired_at",
)
):
raise AgentEnrollmentConflict(
"Existing Agent is no longer eligible for credential transition"
)
# The durable retirement, Agent credential, enrollment terminal
# state, and audit record commit together. Any later failure
# rolls all of them back.
cur.execute(
"""
INSERT INTO agent_legacy_auth_retirements (
agent_id,
retired_at
)
VALUES (%s, %s)
""",
(agent["agent_id"], operation_now),
)
cur.execute(
"""
UPDATE minerops_site_agents
SET agent_secret_hash = %s,
credential_created_at = %s,
legacy_auth_disabled_at = %s
WHERE agent_id = %s
RETURNING agent_id
""",
(
agent_secret_hash,
operation_now,
operation_now,
agent["agent_id"],
),
)
if not cur.fetchone():
raise AgentEnrollmentConflict(
"Credential transition target disappeared"
)
cur.execute(
"""
UPDATE agent_enrollments
SET used_at = %s,
enrolled_agent_id = target_agent_id
WHERE id = %s
AND used_at IS NULL
AND revoked_at IS NULL
RETURNING used_at, enrolled_agent_id
""",
(operation_now, enrollment["id"]),
)
completed = cur.fetchone()
if not completed:
raise AgentEnrollmentConflict(
"Credential transition state changed concurrently"
)
cur.execute(
"""
INSERT INTO audit_logs (
actor_user_id,
action,
entity_type,
entity_id,
new_value_json,
created_at
)
VALUES (
NULL,
'agent.credential_transition.redeemed',
'agent_enrollment',
%s,
%s,
%s
)
""",
(
enrollment["id"],
Jsonb(
{
"agent_id": agent["agent_id"],
"site_id": str(agent["site_id"]),
"agent_zone_id": str(agent["agent_zone_id"]),
"used_at": completed["used_at"].isoformat(),
}
),
operation_now,
),
)
result = transition_result()
connection.commit()
return result
except UniqueViolation as exc:
connection.rollback()
if exc.diag.constraint_name == AGENT_SECRET_HASH_CONSTRAINT:
raise AgentEnrollmentCredentialCollision(
"Agent credential is already in use"
) from exc
raise
except Exception:
connection.rollback()
raise
finally:
connection.close()
+54
View File
@@ -8,10 +8,15 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response,
from app.agent_enrollment.dependencies import get_agent_enrollment_service
from app.agent_enrollment.exceptions import (
AgentEnrollmentConflict,
AgentEnrollmentCredentialCollision,
AgentEnrollmentNotFound,
AgentEnrollmentTokenInvalid,
)
from app.agent_enrollment.models import (
AgentCredentialTransitionCreateRequest,
AgentCredentialTransitionCreateResponse,
AgentCredentialTransitionRedeemRequest,
AgentCredentialTransitionResponse,
AgentEnrollmentCreateRequest,
AgentEnrollmentCreateResponse,
AgentEnrollmentListResponse,
@@ -70,6 +75,31 @@ def create_agent_enrollment(
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post(
"/api/agent-credential-transitions",
response_model=AgentCredentialTransitionCreateResponse,
status_code=status.HTTP_201_CREATED,
)
def create_agent_credential_transition(
request: AgentCredentialTransitionCreateRequest,
response: Response,
current_user: EnrollmentManager,
service: EnrollmentService,
) -> AgentCredentialTransitionCreateResponse:
response.headers["Cache-Control"] = "no-store"
try:
transition = service.create_credential_transition(
request,
actor_user_id=UUID(str(current_user["id"])),
)
return AgentCredentialTransitionCreateResponse(data=transition)
except AgentEnrollmentNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except AgentEnrollmentConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.get(
"/api/agent-enrollments",
response_model=AgentEnrollmentListResponse,
@@ -157,3 +187,27 @@ def enroll_agent(
) from exc
except AgentEnrollmentConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post(
"/api/agent/credential-transition",
response_model=AgentCredentialTransitionResponse,
)
def transition_agent_credential(
request: AgentCredentialTransitionRedeemRequest,
response: Response,
service: EnrollmentService,
) -> AgentCredentialTransitionResponse:
response.headers["Cache-Control"] = "no-store"
try:
return AgentCredentialTransitionResponse(
data=service.redeem_credential_transition(request)
)
except AgentEnrollmentTokenInvalid as exc:
raise HTTPException(
status_code=401,
detail="Invalid or expired credential transition token",
) from exc
except (AgentEnrollmentConflict, AgentEnrollmentCredentialCollision) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
+36
View File
@@ -9,6 +9,8 @@ from app.agent_enrollment.exceptions import (
AgentEnrollmentCredentialCollision,
)
from app.agent_enrollment.models import (
AgentCredentialTransitionCreateRequest,
AgentCredentialTransitionRedeemRequest,
AgentEnrollmentCreateRequest,
AgentEnrollRequest,
)
@@ -73,6 +75,31 @@ class AgentEnrollmentService:
limit=limit,
)
def create_credential_transition(
self,
request: AgentCredentialTransitionCreateRequest,
*,
actor_user_id: UUID,
) -> dict:
for _ in range(MAX_GENERATION_ATTEMPTS):
token = generate_enrollment_token()
try:
transition = self.repository.create_credential_transition(
token_hash=hash_agent_secret(token),
target_agent_id=request.agent_id,
actor_user_id=actor_user_id,
)
return {
**transition,
"transition_token": token,
}
except AgentEnrollmentCredentialCollision:
continue
raise AgentEnrollmentConflict(
"Unable to generate a unique credential transition token"
)
def redeem(self, request: AgentEnrollRequest) -> dict:
token_hash = hash_agent_secret(request.token)
@@ -96,3 +123,12 @@ class AgentEnrollmentService:
raise AgentEnrollmentConflict(
"Unable to generate unique Agent credentials"
)
def redeem_credential_transition(
self,
request: AgentCredentialTransitionRedeemRequest,
) -> dict:
return self.repository.redeem_credential_transition(
token_hash=hash_agent_secret(request.token),
agent_secret_hash=request.agent_secret_hash,
)
+1 -1
View File
@@ -7,7 +7,7 @@ from uuid import UUID
from pydantic import BaseModel, Field
CredentialState = Literal["legacy", "active", "revoked"]
CredentialState = Literal["legacy", "active", "revoked", "retired"]
RoutingState = Literal["assigned", "unassigned", "disabled"]
+4
View File
@@ -103,9 +103,13 @@ class AgentRegistryRepository:
THEN 'revoked'
WHEN agent.agent_secret_hash IS NOT NULL
THEN 'active'
WHEN retirement.retired_at IS NOT NULL
THEN 'retired'
ELSE 'legacy'
END AS credential_state
FROM minerops_site_agents AS agent
LEFT JOIN agent_legacy_auth_retirements AS retirement
ON retirement.agent_id = agent.agent_id
LEFT JOIN sites AS site
ON site.id = agent.site_id
LEFT JOIN agent_zones AS zone
+44
View File
@@ -250,6 +250,50 @@ class AgentZoneRepository:
finally:
connection.close()
def get_agent_configuration(self, agent_id: str) -> dict | None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
agent.agent_id,
agent.site_id,
agent.agent_zone_id,
coalesce(site.name, agent.site_name) AS site_name,
zone.name AS agent_zone_name,
zone.is_enabled AS agent_zone_enabled,
coalesce(
array_agg(
network.network::text
ORDER BY network.network::text, network.id
) FILTER (WHERE network.id IS NOT NULL),
ARRAY[]::text[]
) AS networks
FROM minerops_site_agents AS agent
LEFT JOIN sites AS site
ON site.id = agent.site_id
LEFT JOIN agent_zones AS zone
ON zone.id = agent.agent_zone_id
LEFT JOIN agent_zone_network_ranges AS network
ON network.agent_zone_id = zone.id
WHERE agent.agent_id = %s
GROUP BY
agent.agent_id,
agent.site_id,
agent.agent_zone_id,
site.name,
agent.site_name,
zone.name,
zone.is_enabled
""",
(agent_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
connection.close()
def assign_miners_to_zone(
self, miner_ids: Iterable[UUID], agent_zone_id: UUID
) -> dict[UUID, str]:
+22
View File
@@ -177,6 +177,28 @@ class AgentZoneService:
site_id=assignment.get("site_id"), agent_zone_id=zone_id
)
def configuration(self, agent_id: str) -> dict[str, Any] | None:
row = self.repository.get_agent_configuration(agent_id)
if not row:
return None
return {
"agent_id": row["agent_id"],
"site_id": row["site_id"],
"site_name": row["site_name"],
"agent_zone_id": row["agent_zone_id"],
"agent_zone_name": row["agent_zone_name"],
"ip_ranges": [
{
"name": row["agent_zone_name"],
"cidr": network,
"enabled": row["agent_zone_enabled"] is True,
"api_port": 4028,
"api3_port": 4433,
}
for network in row.get("networks") or []
],
}
def associate_miners_from_agent(
self, agent_id: str, miner_ids: list[UUID]
) -> MinerZoneAssociationResult:
+3
View File
@@ -700,6 +700,7 @@ def required_page_permission(path: str) -> str:
def machine_agent_request(path: str) -> bool:
exact = {
"/api/agent/enroll",
"/api/agent/credential-transition",
"/api/agent/checkin",
"/api/agent/config",
"/api/agent/known-miners",
@@ -787,6 +788,8 @@ def required_api_permission(method: str, path: str) -> str | None:
return "agents.view" if method == "GET" else "agents.manage"
if path == "/api/agent-enrollments/windows-package":
return "agents.manage"
if path == "/api/agent-credential-transitions":
return "agents.manage"
if repair_parts_request(path):
return "repairs.read" if method == "GET" else "repairs.parts.manage"
if path.startswith("/api/admin/"):
+20 -27
View File
@@ -25102,37 +25102,30 @@ async def startup_ensure_minerops_agent_tables():
@app.get("/api/agent/config")
async def minerops_agent_config(request: Request):
minerops_require_agent_secret(request)
async def minerops_agent_config(
request: Request,
authenticated_agent_id: str = Depends(require_agent_identity),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
routing = zone_service.routing_context(authenticated_agent_id)
if routing.routing_reason or routing.agent_zone_id is None:
raise HTTPException(
status_code=409,
detail=(
"Agent routing unavailable: "
+ (routing.routing_reason or "agent_zone_unassigned")
),
)
configuration = zone_service.configuration(authenticated_agent_id)
if not configuration:
raise HTTPException(status_code=409, detail="Agent configuration unavailable")
return {
"ok": True,
"site_name": "Plexus",
**configuration,
"poll_interval_seconds": 120,
"hub_url": "https://plexus.lussier.cc",
"ip_ranges": [
{
"name": "Unit 4",
"cidr": "10.30.0.0/22",
"enabled": True,
"api_port": 4028,
"api3_port": 4433,
},
{
"name": "Unit 6",
"cidr": "10.80.0.0/22",
"enabled": True,
"api_port": 4028,
"api3_port": 4433,
},
{
"name": "Unit 12",
"cidr": "10.90.0.0/22",
"enabled": False,
"api_port": 4028,
"api3_port": 4433,
},
],
"hub_url": str(request.base_url).rstrip("/"),
"message": "Agent config from hub.",
}