Files
app/backend/app/api/routes/cloud.py
T
stroblmeandClaude Opus 5 1d6918d8df Pair a wall panel through the portal
A screen somewhere this installation is not reachable from asks the portal for
a code instead, and the portal mints its credential — because a token signed
here is one such a device could never present.

Where it was minted changes nothing about what it may do. The panel gate moved
off the branch that decodes a local panel token and onto whatever claims name
a panel, so the portal's and this installation's are bounded by the same check
against the same panel's dashboards. A token of that scope naming no panel is
refused rather than left holding the account it borrows.

The connector marks what arrives on its socket, since that is the only thing
that makes it true, and the approval screen now names what is holding a code —
approving adopts whatever answers, so it is worth a look first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017F9RnYCJgASuBTcAjxmnsp
2026-08-20 23:42:58 +02:00

147 lines
5.0 KiB
Python

"""Connecting this installation to a Fluksio portal, and cutting it loose.
Entirely optional, and superuser-only to change: enrolling grants a remote
party the rights of the account that performed it, which is not a decision an
ordinary user of this installation gets to make on everyone else's behalf.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from app.api.deps import CurrentUser, get_current_active_superuser, get_current_user
from app.cloud import config as cloud_config
from app.models import Message
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/cloud", tags=["cloud"])
class EnrollBody(BaseModel):
#: Where the portal's API lives, e.g. https://hub.fluksio.com
portal_url: str = Field(min_length=1, max_length=255)
claim_code: str = Field(min_length=1, max_length=32)
@router.get("/status", dependencies=[Depends(get_current_user)])
def read_status(request: Request) -> dict[str, Any]:
"""Whether this installation is enrolled, and whether the link is up.
Readable by any signed-in user: everyone here has a right to know whether
the machine they are using can be reached from outside.
"""
connector = getattr(request.app.state, "cloud_connector", None)
if connector is None:
config = cloud_config.load()
return {
"enrolled": config is not None,
"connected": False,
"portal_url": config.portal_url if config else None,
"issuer": config.issuer if config else None,
"portal_account": config.portal_account if config else None,
"installation_id": config.installation_id if config else None,
"last_error": None,
"connected_since": None,
}
status: dict[str, Any] = connector.status()
return status
@router.post(
"/enroll",
dependencies=[Depends(get_current_active_superuser)],
response_model=Message,
)
async def enroll(
request: Request, current_user: CurrentUser, body: EnrollBody
) -> Message:
"""Redeem a claim code and start dialling the portal.
The account performing this is recorded as the one every portal session
will act as. There is no way to widen that later from the portal side.
"""
if cloud_config.exists():
raise HTTPException(
status_code=409,
detail="This installation is already connected to a portal",
)
base = body.portal_url.rstrip("/")
try:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
f"{base}/api/v1/enroll/",
json={"claim_code": body.claim_code, "app_version": "0.1.0"},
)
except httpx.HTTPError as exc:
raise HTTPException(
status_code=502, detail=f"Could not reach the portal: {exc}"
) from exc
if response.status_code == 404:
raise HTTPException(
status_code=400, detail="That claim code is unknown or has expired"
)
if response.status_code != 200:
raise HTTPException(
status_code=502,
detail=f"The portal refused the claim ({response.status_code})",
)
data = response.json()
config = cloud_config.CloudConfig(
portal_url=base,
ws_url=data["ws_url"],
installation_id=data["installation_id"],
token=data["installation_token"],
issuer=data["issuer"],
# Pinned here, at the one moment the claim code proves who we are
# talking to. Nothing refreshes this.
jwks=data["jwks"],
local_user_id=str(current_user.id),
enrolled_at=datetime.now(timezone.utc).isoformat(),
portal_account=current_user.email,
)
cloud_config.save(config)
_start_connector(request.app)
return Message(message="Connected to the portal")
@router.delete(
"", dependencies=[Depends(get_current_active_superuser)], response_model=Message
)
def disconnect(request: Request) -> Message:
"""Sever the connection from this side.
Unilateral and immediate: the config is the only reason portal tokens
verify here, so deleting it ends remote access whatever the portal still
has on file.
"""
task = getattr(request.app.state, "cloud_task", None)
if task is not None:
task.cancel()
request.app.state.cloud_task = None
request.app.state.cloud_connector = None
cloud_config.delete()
return Message(message="Disconnected from the portal")
def _start_connector(app: Any) -> None:
from app.cloud.connector import CloudConnector
existing = getattr(app.state, "cloud_task", None)
if existing is not None:
existing.cancel()
connector = CloudConnector(app)
app.state.cloud_connector = connector
app.state.cloud_task = asyncio.create_task(
connector.serve_forever(), name="cloud-connector"
)