A share link admits somebody at the portal, so this instance first hears of them when they arrive rather than when a superuser types their code in. An unmapped portal identity is now checked once against the portal's own list of who may reach this instance and given an ordinary local account only if the portal vouches for it. Asking rather than believing the token is the point: a token stays signed and valid until it expires, so trusting its claims would let one rebuild the account somebody deleted here and deleting a user would stop being the whole of the revocation. The account-making itself moved out of the route, since both ways in build the same thing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GPMNwB2mGBP5j7dXRopcPH
209 lines
7.4 KiB
Python
209 lines
7.4 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
|
|
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 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, 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,
|
|
}
|
|
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
|
|
|
|
|
|
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) -> 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 fluksio.cloud.connector import start
|
|
|
|
start(app)
|