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:
+10
-2
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user