Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m16s
Playwright Tests / test-playwright (2, 2) (push) Failing after 19s
pre-commit / pre-commit (push) Failing after 2m17s
Test Backend / test-backend (3.12) (push) Successful in 4m51s
Test Backend / test-backend (3.14) (push) Successful in 4m32s
Compose Smoke Test / test-compose (push) Failing after 26s
Playwright Tests / merge-reports (push) Failing after 2m17s
228 lines
9.0 KiB
Python
228 lines
9.0 KiB
Python
"""Redeeming a claim code, from the dashboard or from the command line.
|
|
|
|
The work is the same either way — ask the portal, keep what it answers, and
|
|
map the account that asked to the portal identity that owns the instance —
|
|
so it lives here rather than in the route. The command line matters because a
|
|
machine on a cluster has no browser pointed at it: `fluksio enroll` does this
|
|
before the engine has started, holding nothing but the database.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import logging
|
|
import secrets
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
from urllib.parse import urlsplit
|
|
|
|
import httpx
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlmodel import Session, col, select
|
|
|
|
import fluksio
|
|
from fluksio import crud
|
|
from fluksio.cloud import config as cloud_config
|
|
from fluksio.core.security import get_password_hash
|
|
from fluksio.models import User
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class EnrollError(Exception):
|
|
"""A failure with the status the API should answer with."""
|
|
|
|
def __init__(self, status: int, detail: str) -> None:
|
|
super().__init__(detail)
|
|
self.status = status
|
|
self.detail = detail
|
|
|
|
|
|
def is_loopback(url: str) -> bool:
|
|
"""Whether the address is this machine.
|
|
|
|
Not a `*.local` name: mDNS answers it with whoever claims it on the LAN,
|
|
which would then receive the claim code and the credential in clear.
|
|
"""
|
|
host = urlsplit(url).hostname or ""
|
|
if host == "localhost":
|
|
return True
|
|
try:
|
|
return ipaddress.ip_address(host).is_loopback
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def redeem_claim(
|
|
portal_url: str, claim_code: str, *, timeout: float = 15.0
|
|
) -> dict[str, Any]:
|
|
"""Trade a claim code for this instance's credential and the portal's keys."""
|
|
base = portal_url.rstrip("/")
|
|
# The claim code and, from here on, this instance's credential go to
|
|
# this address. Over plain http both are readable by anything on the path,
|
|
# so refuse rather than enrol insecurely — bar a loopback portal, which is
|
|
# how the stack is developed against itself.
|
|
if not base.startswith("https://") and not is_loopback(base):
|
|
raise EnrollError(422, "The portal address must be https:// (or this machine)")
|
|
try:
|
|
response = httpx.post(
|
|
f"{base}/api/v1/enroll/",
|
|
json={"claim_code": claim_code, "app_version": fluksio.__version__},
|
|
timeout=timeout,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
raise EnrollError(502, f"Could not reach the portal: {exc}") from exc
|
|
if response.status_code == 404:
|
|
raise EnrollError(400, "That claim code is unknown or has expired")
|
|
if response.status_code != 200:
|
|
raise EnrollError(502, f"The portal refused the claim ({response.status_code})")
|
|
|
|
data: dict[str, Any] = response.json()
|
|
if not data.get("owner_id"):
|
|
# A portal older than remote users does not say who owns the
|
|
# instance, and without that the enrolling account cannot be mapped
|
|
# to anyone — which would leave the portal connected but refused here.
|
|
raise EnrollError(
|
|
502,
|
|
"That portal is too old for this instance: it did not say "
|
|
"which account owns the instance",
|
|
)
|
|
return data
|
|
|
|
|
|
def enroll(
|
|
session: Session, user: User, portal_url: str, claim_code: str
|
|
) -> cloud_config.CloudConfig:
|
|
"""Redeem the code and write the config the connector dials with.
|
|
|
|
An instance that is already paired is re-paired rather than refused: a new
|
|
claim code is somebody asking for this, and refusing left deleting
|
|
`cloud.json` by hand as the only way through. The code is redeemed before
|
|
anything is written, so a code the portal rejects leaves a working
|
|
connection working.
|
|
"""
|
|
previous = cloud_config.load()
|
|
data = redeem_claim(portal_url, claim_code)
|
|
config = cloud_config.CloudConfig(
|
|
portal_url=portal_url.rstrip("/"),
|
|
ws_url=data["ws_url"],
|
|
instance_id=data["instance_id"],
|
|
token=data["instance_token"],
|
|
issuer=data["issuer"],
|
|
# Pinned here, at the one moment the claim code proves who we are
|
|
# talking to. The tunnel refreshes it on every attach after this.
|
|
jwks=data["jwks"],
|
|
local_user_id=str(user.id),
|
|
enrolled_at=datetime.now(UTC).isoformat(),
|
|
portal_account=user.email,
|
|
)
|
|
cloud_config.save(config)
|
|
|
|
owner_id = str(data["owner_id"])
|
|
if previous is not None and previous.issuer != config.issuer:
|
|
# A mapping is a subject of the portal that issued it, and nothing in
|
|
# the row says which portal that was — so against a different portal
|
|
# the old rows would both block the new owner from being adopted and
|
|
# resolve a stranger's subject onto a local account.
|
|
for stale in session.exec(
|
|
select(User).where(col(User.portal_sub).is_not(None))
|
|
):
|
|
stale.portal_sub = None
|
|
session.add(stale)
|
|
session.flush()
|
|
# 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 != user.id)
|
|
):
|
|
other.portal_sub = None
|
|
session.add(other)
|
|
user.portal_sub = owner_id
|
|
session.add(user)
|
|
session.commit()
|
|
return config
|
|
|
|
|
|
def lookup_member(portal_sub: str, *, timeout: float = 5.0) -> dict[str, Any] | None:
|
|
"""Ask the portal whether it knows this account as a member of ours.
|
|
|
|
``None`` for every kind of no — not enrolled, not a member, or a portal
|
|
that cannot be reached. The caller turns that into "no user", which is a
|
|
refused request rather than an error: an outage must not be a way in, and
|
|
it must not be a 500 either.
|
|
"""
|
|
config = cloud_config.load()
|
|
if config is None:
|
|
return None
|
|
try:
|
|
response = httpx.get(
|
|
f"{config.portal_url.rstrip('/')}/api/v1/instance-members/{portal_sub}",
|
|
headers={"Authorization": f"Bearer {config.token}"},
|
|
timeout=timeout,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
logger.warning("Could not ask the portal about %s: %s", portal_sub, exc)
|
|
return None
|
|
if response.status_code != 200:
|
|
return None
|
|
data: dict[str, Any] = response.json()
|
|
return data
|
|
|
|
|
|
def create_remote_user(session: Session, portal_sub: str, email: str) -> User:
|
|
"""The ordinary local account a portal identity acts as here.
|
|
|
|
Ordinary is the point: it shows up under Admin → Users like everyone
|
|
else, holds no superuser flag, and is removed by deleting it there.
|
|
"""
|
|
if session.exec(select(User).where(User.portal_sub == portal_sub)).first():
|
|
raise EnrollError(409, "That account 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 EnrollError(409, "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.
|
|
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 adopt_member(session: Session, portal_sub: str) -> User | None:
|
|
"""Give a portal identity the portal vouches for a local account.
|
|
|
|
The other way somebody gets in: a superuser here typing their code makes
|
|
the account up front, while an owner's share link admits them at the portal
|
|
and this instance first hears of it when they arrive. Asking the portal
|
|
rather than believing the token is what keeps deleting the local account a
|
|
revocation — the token stays signed and valid until it expires, and on its
|
|
own it must not build the account back.
|
|
|
|
# ponytail: one outbound GET per request from a subject with no local
|
|
# account, which is every request of somebody removed here. Cache it behind
|
|
# a short TTL if that ever shows up in a profile.
|
|
"""
|
|
member = lookup_member(portal_sub)
|
|
if member is None:
|
|
return None
|
|
try:
|
|
return create_remote_user(session, portal_sub, str(member["email"]))
|
|
except (EnrollError, IntegrityError):
|
|
# A first page load fires several requests and the websocket at once,
|
|
# all of them missing the row and all of them creating it. Whoever
|
|
# loses, at the unique index or at the checks before it (the winner
|
|
# may commit while this one asks the portal), reads back what the
|
|
# winner wrote. An email another local account holds finds nothing to
|
|
# read back and stays refused.
|
|
session.rollback()
|
|
return session.exec(select(User).where(User.portal_sub == portal_sub)).first()
|