Adopt a member the portal vouches for
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
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user