Optional remote access: dial out to a Fluksio portal

An installation can be enrolled with a portal by redeeming a claim code, after
which it holds one authenticated websocket open and answers proxied API calls
over it. Requests are dispatched into this process's own ASGI app, so the HTTP
trigger routes flows install at runtime are visible to it, and the live flow
stream is bridged straight off the event bus.

decode_token grows the third branch its docstring anticipated: tokens signed by
the enrolled portal resolve to the local account that performed the enrolment,
verified against a JWKS pinned at that moment. With no enrolment the branch
raises immediately, so an offline installation is unchanged and untouched.

Disconnecting deletes one file, which is the entire local revocation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtBzdDyLsmDaF1W7DLYtYM
This commit is contained in:
2026-08-19 16:28:38 +02:00
co-authored by Claude Opus 5
parent 8feabd33c5
commit b190897301
9 changed files with 873 additions and 2 deletions
+10 -2
View File
@@ -8,6 +8,7 @@ from jwt.exceptions import InvalidTokenError
from pydantic import ValidationError
from sqlmodel import Session
from app.cloud import config as cloud_config
from app.core import security
from app.core.config import settings
from app.core.db import engine
@@ -39,8 +40,11 @@ def decode_token(token: str) -> dict[str, Any]:
rights — the difference is only in who is holding it, which the MCP
endpoint checks separately.
This is also the seam a hosted deployment widens later: trusting an
additional issuer is a third branch here, not a change anywhere else.
The third branch is the seam a hosted deployment widens: a portal this
installation was enrolled with signs tokens with a key pinned at
enrolment, and they resolve to the local account that performed it. With
no enrolment the branch raises immediately, so an offline installation
pays nothing for the possibility.
"""
try:
session: dict[str, Any] = jwt.decode(
@@ -48,7 +52,11 @@ def decode_token(token: str) -> dict[str, Any]:
)
return session
except InvalidTokenError:
pass
try:
return security.decode_oauth_token(token)
except InvalidTokenError:
return cloud_config.decode_portal_token(token)
def user_from_token(session: Session, token: str) -> User | None:
+4
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter
from app.api.routes import (
alerts,
artifacts,
cloud,
dashboards,
flows,
login,
@@ -33,6 +34,9 @@ api_router.include_router(observability.router)
api_router.include_router(runs.router)
api_router.include_router(artifacts.router)
api_router.include_router(workers.router)
# Remote access through a portal. Always mounted; with no enrolment the
# endpoints only ever report that there is none.
api_router.include_router(cloud.router)
# Always mounted so the generated SDK stays the same shape; the endpoints
# themselves refuse to work unless MCP is switched on.
api_router.include_router(oauth.router)
+145
View File
@@ -0,0 +1,145 @@
"""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 UTC, datetime
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,
"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(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"
)