"""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 installation — 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 from datetime import datetime, timezone from typing import Any import httpx from sqlmodel import Session, select import fluksio from fluksio.cloud import config as cloud_config from fluksio.models import User 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 class AlreadyEnrolled(EnrollError): def __init__(self) -> None: super().__init__(409, "This installation is already connected to a portal") def redeem_claim( portal_url: str, claim_code: str, *, timeout: float = 15.0 ) -> dict[str, Any]: """Trade a claim code for this installation's credential and the portal's keys.""" base = portal_url.rstrip("/") 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 # installation, 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 installation: it did not say " "which account owns the installation", ) 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.""" if cloud_config.exists(): raise AlreadyEnrolled() data = redeem_claim(portal_url, claim_code) config = cloud_config.CloudConfig( portal_url=portal_url.rstrip("/"), 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(user.id), enrolled_at=datetime.now(timezone.utc).isoformat(), portal_account=user.email, ) cloud_config.save(config) owner_id = str(data["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 != user.id) ): other.portal_sub = None session.add(other) user.portal_sub = owner_id session.add(user) session.commit() return config