diff --git a/backend/fluksio/api/deps.py b/backend/fluksio/api/deps.py index c346562..da2223c 100644 --- a/backend/fluksio/api/deps.py +++ b/backend/fluksio/api/deps.py @@ -11,6 +11,7 @@ from pydantic import ValidationError from sqlmodel import Session, select from fluksio.cloud import config as cloud_config +from fluksio.cloud import enroll from fluksio.core import security from fluksio.core.config import settings from fluksio.core.db import engine @@ -233,15 +234,25 @@ def _user_for(session: Session, token_data: TokenPayload) -> User | None: """The local account a payload names, by id or by portal identity. A token the portal minted names a person on the portal, not a user here, so - the mapping a superuser made when they admitted them is what turns one into - the other. No mapping, no user — the caller answers that the same way it - answers a token naming a deleted account, which is what makes deleting the - local user the whole of the revocation. + a mapping is what turns one into the other. There are two ways one comes to + exist: a superuser typed their code in, which makes the account up front, + or the owner shared a link from the portal, which admits them there and + leaves this instance to find out when they first arrive. So an unmapped + identity is checked once against the portal's own list of who may reach + this instance, and adopted only if the portal vouches for it. + + Deleting the local user stays the whole of the revocation: the portal is + asked rather than the token believed, so a still-valid token cannot rebuild + the account it named, and a person dropped at the portal is not adopted + again. """ if token_data.portal_sub: - return session.exec( + user = session.exec( select(User).where(User.portal_sub == token_data.portal_sub) ).first() + if user is None: + return enroll.adopt_member(session, token_data.portal_sub) + return user if not token_data.sub: return None try: diff --git a/backend/fluksio/api/routes/cloud.py b/backend/fluksio/api/routes/cloud.py index 1dd7c36..79a1bd2 100644 --- a/backend/fluksio/api/routes/cloud.py +++ b/backend/fluksio/api/routes/cloud.py @@ -13,16 +13,13 @@ chain stops with whoever a superuser here typed a code for. from __future__ import annotations import logging -import secrets 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 select -from fluksio import crud from fluksio.api.deps import ( CurrentUser, SessionDep, @@ -31,8 +28,7 @@ from fluksio.api.deps import ( ) from fluksio.cloud import config as cloud_config from fluksio.cloud import enroll as enroll_mod -from fluksio.core.security import get_password_hash -from fluksio.models import Message, User, UserPublic +from fluksio.models import Message, UserPublic logger = logging.getLogger(__name__) @@ -149,33 +145,17 @@ def add_remote_user(session: SessionDep, body: RemoteUserBody) -> Any: ) 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" + 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"]) ) - 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 + 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: diff --git a/backend/fluksio/cloud/enroll.py b/backend/fluksio/cloud/enroll.py index 9ef9639..7a8e385 100644 --- a/backend/fluksio/cloud/enroll.py +++ b/backend/fluksio/cloud/enroll.py @@ -9,17 +9,24 @@ before the engine has started, holding nothing but the database. from __future__ import annotations +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.""" @@ -127,3 +134,85 @@ def enroll( 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: + return None + except 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 reads back what the winner wrote. + session.rollback() + return session.exec(select(User).where(User.portal_sub == portal_sub)).first() diff --git a/backend/tests/test_cloud.py b/backend/tests/test_cloud.py index a66e5e8..54f95c1 100644 --- a/backend/tests/test_cloud.py +++ b/backend/tests/test_cloud.py @@ -14,6 +14,7 @@ import uuid from dataclasses import replace from unittest.mock import Mock, patch +import httpx import jwt import pytest from cryptography.hazmat.primitives.asymmetric import rsa @@ -519,3 +520,41 @@ async def test_a_config_that_cannot_be_read_is_not_dialled(monkeypatch) -> None: watcher.cancel() assert not started + + +def test_a_member_the_portal_vouches_for_is_adopted( + enrolled: User, # noqa: ARG001 (fixture installs the enrolment) + portal_key: rsa.RSAPrivateKey, + db: Session, +) -> None: + """A share link admits at the portal; the account appears here on arrival.""" + theirs = portal_token(portal_key, subject="portal-user-7") + + vouched = Mock( + status_code=200, + json=Mock( + return_value={"user_id": "portal-user-7", "email": "invited@example.com"} + ), + ) + with patch("fluksio.cloud.enroll.httpx.get", return_value=vouched) as get: + adopted = user_from_token(db, theirs) + assert get.call_args.args[0].endswith("/instance-members/portal-user-7") + assert adopted is not None + assert adopted.email == "invited@example.com" + # Never a superuser, and never able to sign in with a password. + assert adopted.is_superuser is False + + # Once mapped, no further asking: the local row is the answer. + with patch("fluksio.cloud.enroll.httpx.get") as unused: + assert user_from_token(db, theirs) == adopted + unused.assert_not_called() + + # Somebody the portal does not vouch for gets nothing, and neither does a + # portal that cannot be reached. + stranger = portal_token(portal_key, subject="portal-user-8") + with patch("fluksio.cloud.enroll.httpx.get", return_value=Mock(status_code=404)): + assert user_from_token(db, stranger) is None + with patch( + "fluksio.cloud.enroll.httpx.get", side_effect=httpx.ConnectError("no route") + ): + assert user_from_token(db, stranger) is None diff --git a/docs/interface/portal.md b/docs/interface/portal.md index 1b486a4..73453ca 100644 --- a/docs/interface/portal.md +++ b/docs/interface/portal.md @@ -61,18 +61,34 @@ hijacked DNS entry or a mis-issued certificate from impersonating the portal. ## Letting someone else in -Anyone else on the portal reaches your instance only if a superuser there -admits them, and they arrive as a local user of their own rather than as you. +Anyone else on the portal reaches your instance only if you let them, and they +arrive as a local user of their own rather than as you. There are two ways, +and the difference is who starts. + +### Share a link + +On the portal, the share icon on your instance's row gives a QR code and a URL. +Whoever opens it, signed in to Fluksio on that device, joins. It works once and +expires in fifteen minutes; **New link** replaces it, which is also how a link +you would rather not have sent is cancelled. + +Nothing has to be entered on the instance. It learns of the new member when +they first arrive and asks the portal who they are, then gives them their local +account. + +### Take a code + +For a machine whose owner would rather admit people at the machine itself. 1. **They**: **Instances → Join an instance**, and copy the code. It is bound to their portal account and expires in fifteen minutes. 2. **You**, on the instance: **Settings → Remote access → Add remote user**, and enter the code. -3. They now see the instance under **Instances**, marked *Shared by*, - with **Open** and nothing else. Renaming, re-keying and removing stay with - you. -The instance redeems that code against the portal using its own credential. +Either way they now see the instance under **Instances**, marked *Shared by*, +with **Open** and nothing else. Renaming, re-keying and removing stay with you. + +The instance redeems the code against the portal using its own credential. A portal session cannot do this, which is what stops somebody you let in from letting others in. @@ -83,6 +99,8 @@ a superuser and with no password. | From | Action | Effect | |---|---|---| +| The portal | share dialog → **Remove** | that member stops reaching it from the portal; their user on the machine stays until it is deleted there | +| The portal | **Leave**, on a row shared with you | gives up your own access to somebody else's instance | | The portal | **New code** | rotates the credential and drops the current link | | The portal | **Remove** | deletes the registration and cuts the connection | | The instance | **Disconnect** | unilateral and immediate — the portal's tokens stop verifying here whatever the portal still has on file | diff --git a/frontend/src/components/UserSettings/RemoteAccess.tsx b/frontend/src/components/UserSettings/RemoteAccess.tsx index 56926a6..9c1ef26 100644 --- a/frontend/src/components/UserSettings/RemoteAccess.tsx +++ b/frontend/src/components/UserSettings/RemoteAccess.tsx @@ -237,6 +237,8 @@ export function RemoteAccess() { />
They get a code at fluksio.com → Instances → Join an instance. + Or share a link from the portal's own Instances list, which + needs nothing entered here.