Files
app/backend/fluksio/api/routes/cloud.py
T
stroblmeandClaude Opus 5.5 5f05d3f8b4 Link a pending portal member to the local user with their email
A portal identity whose email a local user already has is still refused
on arrival. POST /cloud/members/{portal_sub}/link lets a superuser map it
onto that user, never a superuser's, after the portal confirms the
membership. GET /cloud/members marks such members linkable, and Admin →
Users offers "Link portal account" for them, saying that a linked user is
deleted with a removal at the portal or a Disconnect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
2026-10-15 11:04:53 +02:00

369 lines
13 KiB
Python

"""Connecting this instance 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 instance 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 logging
import uuid
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, Field
from sqlmodel import col, select
from fluksio.api.deps import (
CurrentUser,
SessionDep,
get_current_active_superuser,
get_current_user,
)
from fluksio.cloud import config as cloud_config
from fluksio.cloud import enroll as enroll_mod
from fluksio.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 instance 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,
"instance_id": config.instance_id if config else None,
"last_error": None,
"connected_since": None,
"revoked": False,
}
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
instance, 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.
The same work as `fluksio enroll` on the command line, which is how a
machine with no browser pointed at it does this.
"""
try:
await run_in_threadpool(
enroll_mod.enroll, session, current_user, body.portal_url, body.claim_code
)
except enroll_mod.EnrollError as exc:
raise HTTPException(status_code=exc.status, detail=exc.detail) from exc
_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 instance is not connected to a portal",
)
try:
response = httpx.post(
f"{config.portal_url.rstrip('/')}/api/v1/instance-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 instance'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()
try:
# Shared with the share-link path, which creates the same account from
# the other end — there the portal admitted them and this instance
# finds out when they arrive. Password recovery would issue a usable
# password, which is a gap worth closing the day local sign-in for
# these accounts is wanted.
return enroll_mod.create_remote_user(
session, str(data["user_id"]), str(data["email"])
)
except enroll_mod.EnrollError as exc:
raise HTTPException(status_code=exc.status, detail=exc.detail) from exc
class PortalMember(BaseModel):
#: Their portal account, which is what ``portal_sub`` holds here.
user_id: str
email: str
#: Their local user, or None while they have not opened the instance yet.
local_user_id: uuid.UUID | None = None
#: A local user has their email, which keeps them out until a superuser
#: links the two. Never true of a superuser's account.
linkable: bool = False
@router.get(
"/members",
dependencies=[Depends(get_current_active_superuser)],
response_model=list[PortalMember],
)
def read_members(session: SessionDep) -> Any:
"""Everyone the portal admits to this instance, its owner aside.
A share link admits somebody at the portal before this instance has heard
of them, so this is the one place a superuser here can see who may still
arrive. It also shows a membership left standing when a local user was
deleted while the portal could not be told.
"""
config = cloud_config.load()
if config is None:
raise HTTPException(
status_code=409, detail="This instance is not connected to a portal"
)
try:
response = httpx.get(
f"{config.portal_url.rstrip('/')}/api/v1/instance-members/",
headers={"Authorization": f"Bearer {config.token}"},
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 != 200:
raise HTTPException(
status_code=502,
detail=f"The portal did not list members ({response.status_code})",
)
members = response.json()["data"]
subs = [str(member["user_id"]) for member in members]
local = {
user.portal_sub: user.id
for user in session.exec(select(User).where(col(User.portal_sub).in_(subs)))
}
emails = [str(member["email"]) for member in members]
linkable = {
user.email
for user in session.exec(select(User).where(col(User.email).in_(emails)))
if not user.is_superuser and not user.portal_sub
}
return [
PortalMember(
user_id=str(member["user_id"]),
email=str(member["email"]),
local_user_id=local.get(str(member["user_id"])),
linkable=str(member["user_id"]) not in local
and str(member["email"]) in linkable,
)
for member in members
]
@router.post(
"/members/{portal_sub}/link",
dependencies=[Depends(get_current_active_superuser)],
response_model=UserPublic,
)
def link_member(session: SessionDep, portal_sub: uuid.UUID) -> Any:
"""Let a pending member in as the local user that already has their email.
Arriving on their own they are refused, because handing an existing
account to whoever holds that address at the portal is not a guess to
make. This is a superuser deciding it, and never for a superuser's account.
"""
if cloud_config.load() is None:
raise HTTPException(
status_code=409, detail="This instance is not connected to a portal"
)
sub = str(portal_sub)
member = enroll_mod.lookup_member(sub)
if member is None:
raise HTTPException(
status_code=404, detail="The portal did not confirm this member"
)
if session.exec(select(User).where(User.portal_sub == sub)).first():
raise HTTPException(status_code=409, detail="That account already has access")
user = session.exec(select(User).where(User.email == str(member["email"]))).first()
if user is None:
raise HTTPException(status_code=404, detail="No local user has this email")
if user.is_superuser:
raise HTTPException(
status_code=403, detail="A superuser cannot be linked to a portal account"
)
if user.portal_sub:
raise HTTPException(
status_code=409, detail="This user is linked to another portal account"
)
user.portal_sub = sub
session.add(user)
session.commit()
session.refresh(user)
return user
@router.delete(
"/members/{portal_sub}",
dependencies=[Depends(get_current_active_superuser)],
response_model=Message,
)
def revoke_member(portal_sub: uuid.UUID) -> Message:
"""Take a portal membership back.
For somebody who has not arrived, this is the whole of their access.
Somebody who has is deleted under Admin → Users instead, which also does
this.
"""
config = cloud_config.load()
if config is None:
raise HTTPException(
status_code=409, detail="This instance is not connected to a portal"
)
try:
response = httpx.delete(
f"{config.portal_url.rstrip('/')}/api/v1/instance-members/{portal_sub}",
headers={"Authorization": f"Bearer {config.token}"},
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 != 200:
raise HTTPException(
status_code=502,
detail=f"The portal refused the revocation ({response.status_code})",
)
return Message(message="Membership revoked")
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 instance 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/instance-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, session: SessionDep) -> 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. The users the portal let in go with it. The account it was
enrolled as stays, and so does every superuser.
"""
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
config = cloud_config.load()
cloud_config.delete()
owner = config.local_user_id if config is not None else None
for user in session.exec(
select(User).where(
col(User.portal_sub).is_not(None),
User.is_superuser == False, # noqa: E712
)
):
if str(user.id) != owner:
session.delete(user)
session.commit()
return Message(message="Disconnected from the portal")
def _start_connector(app: Any) -> None:
from fluksio.cloud.connector import start
start(app)