Files
app/backend/app/api/routes/cloud.py
T
stroblmeandClaude Opus 5 81dad0a449
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
A portal session names a person, not whoever enrolled
Remote access used to collapse every portal session onto the account that
performed the enrolment. That was the only thing it could do while nothing
here knew who was at the other end, and it is why letting a second person
in meant handing them the first one's account.

`user.portal_sub` is where a portal identity meets a local one: set for the
enrolling superuser at enrolment, and for each person a superuser admits
afterwards through Settings -> Remote access -> Add remote user. The code
they type comes from the newcomer's own portal account, and it is redeemed
against the hub with this installation's tunnel credential rather than with
a portal session, so being let in is not itself the power to let others in.
The account created is never a superuser, which closes the same door from
this side.

A proxy token now resolves through that mapping and nowhere else. An
identity nobody mapped resolves to no user rather than falling back on the
enroller, so deleting the local row under Admin -> Users is the whole of
the revocation: it bites on a credential already in flight, and it does not
wait on the portal being reachable to be told. Telling the portal is best
effort for exactly that reason.

The cost is stated where it lands, in DEPLOY.md: an installation enrolled
before this has no mapping, so its owner reconnects once with a fresh code.
Panels and the health summary still act as the enrolling account - neither
of them is a person, and neither gained a way to name one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 11:34:01 +02:00

292 lines
10 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.
Admitting further portal accounts is the same decision made again, so it is
guarded the same way. A person let in this way gets an ordinary local account
of their own — never a superuser, so they cannot admit anyone else, and the
chain stops with whoever a superuser here typed a code for.
"""
from __future__ import annotations
import asyncio
import logging
import secrets
from datetime import datetime, timezone
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlmodel import select
from app import crud
from app.api.deps import (
CurrentUser,
SessionDep,
get_current_active_superuser,
get_current_user,
)
from app.cloud import config as cloud_config
from app.core.security import get_password_hash
from app.models import Message, User, UserPublic
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, session: SessionDep, current_user: CurrentUser, body: EnrollBody
) -> Message:
"""Redeem a claim code and start dialling the portal.
The account performing this is mapped to the portal account that owns the
installation, so the owner's portal sessions arrive here as them. Widening
that to anyone else is a local decision made one person at a time, below —
never something the portal can do from its 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()
owner_id = data.get("owner_id")
if not owner_id:
# A portal older than remote users does not say who owns the
# installation, and without that the enrolling account cannot be mapped
# to anyone — which would leave the portal connected but refused here.
raise HTTPException(
status_code=502,
detail="That portal is too old for this installation: it did not "
"say which account owns the installation",
)
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)
owner_id = str(owner_id)
# Re-enrolling from a different local account moves the mapping rather than
# leaving two accounts claiming the same portal identity, which the unique
# index would refuse and the lookup could not choose between anyway.
for other in session.exec(
select(User).where(User.portal_sub == owner_id, User.id != current_user.id)
):
other.portal_sub = None
session.add(other)
current_user.portal_sub = owner_id
session.add(current_user)
session.commit()
_start_connector(request.app)
return Message(message="Connected to the portal")
class RemoteUserBody(BaseModel):
#: The code the person wanting in minted on their portal account.
code: str = Field(min_length=1, max_length=32)
@router.post(
"/users",
dependencies=[Depends(get_current_active_superuser)],
response_model=UserPublic,
)
def add_remote_user(session: SessionDep, body: RemoteUserBody) -> Any:
"""Admit a portal account, and give it a local user of its own.
The point of the local user is that it is ordinary: a remote person shows
up in Admin → Users like everyone else, holds no superuser flag, and is
removed by deleting them there. They have no password because they never
sign in here — the portal vouches for them, and this row is what says which
of our accounts that vouching amounts to.
"""
config = cloud_config.load()
if config is None:
raise HTTPException(
status_code=409,
detail="This installation is not connected to a portal",
)
try:
response = httpx.post(
f"{config.portal_url.rstrip('/')}/api/v1/installation-members/",
headers={"Authorization": f"Bearer {config.token}"},
json={"code": body.code.strip()},
timeout=15.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 code is unknown or has expired"
)
if response.status_code == 409:
raise HTTPException(
status_code=409, detail="That code belongs to this installation's owner"
)
if response.status_code != 200:
raise HTTPException(
status_code=502,
detail=f"The portal refused the code ({response.status_code})",
)
data = response.json()
portal_sub = str(data["user_id"])
email = str(data["email"])
existing = session.exec(select(User).where(User.portal_sub == portal_sub)).first()
if existing is not None:
raise HTTPException(
status_code=409, detail=f"{existing.email} already has access"
)
if crud.get_user_by_email(session=session, email=email) is not None:
# Never quietly hand an existing local account — possibly a superuser's
# — to whoever holds that address on the portal.
raise HTTPException(
status_code=409, detail="A local user with this email already exists"
)
user = User(
email=email,
# Unusable by construction: this account is reached through the portal
# or not at all. Password recovery would issue one, which is a gap
# worth closing the day local sign-in for these accounts is wanted.
hashed_password=get_password_hash(secrets.token_urlsafe(32)),
is_superuser=False,
is_active=True,
portal_sub=portal_sub,
)
session.add(user)
session.commit()
session.refresh(user)
return user
def forget_remote_user(portal_sub: str) -> None:
"""Tell the portal a remote user is gone from here.
Best effort on purpose: the local account is what grants access, so it is
already over by the time this runs. A portal that cannot be reached keeps a
row that opens nothing — the installation refuses the session either way.
"""
config = cloud_config.load()
if config is None:
return
try:
response = httpx.delete(
f"{config.portal_url.rstrip('/')}/api/v1/installation-members/{portal_sub}",
headers={"Authorization": f"Bearer {config.token}"},
timeout=15.0,
)
except httpx.HTTPError as exc:
logger.warning("Could not drop %s at the portal: %s", portal_sub, exc)
return
if response.status_code != 200:
logger.warning(
"The portal refused to drop %s (%s)", portal_sub, response.status_code
)
@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"
)