Rename the import package app to fluksio
A wheel whose top-level module is `app` collides with anything else in a user's venv, so the package that is about to be published takes the name it is published under. Only the Python package moves; the repo, the Docker WORKDIR and the compose project keep theirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
"""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 fluksio import crud
|
||||
from fluksio.api.deps import (
|
||||
CurrentUser,
|
||||
SessionDep,
|
||||
get_current_active_superuser,
|
||||
get_current_user,
|
||||
)
|
||||
from fluksio.cloud import config as cloud_config
|
||||
from fluksio.core.security import get_password_hash
|
||||
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 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 fluksio.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