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
369 lines
13 KiB
Python
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)
|