List and revoke portal members under Admin → Users

GET /cloud/members asks the portal who it admits to this instance and
marks those with a local user. DELETE /cloud/members/{portal_sub} takes a
membership back. Admin → Users lists the members who have not arrived
yet, with Revoke, which also surfaces a membership left standing when a
local user was deleted while the portal could not be told.
This commit is contained in:
2026-10-15 10:31:25 +02:00
parent 8b47b32d3f
commit 1d66b30d0c
8 changed files with 369 additions and 2 deletions
+95 -1
View File
@@ -13,12 +13,14 @@ chain stops with whoever a superuser here typed a code for.
from __future__ import annotations
import logging
import uuid
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 col, select
from fluksio.api.deps import (
CurrentUser,
@@ -28,7 +30,7 @@ from fluksio.api.deps import (
)
from fluksio.cloud import config as cloud_config
from fluksio.cloud import enroll as enroll_mod
from fluksio.models import Message, UserPublic
from fluksio.models import Message, User, UserPublic
logger = logging.getLogger(__name__)
@@ -159,6 +161,98 @@ def add_remote_user(session: SessionDep, body: RemoteUserBody) -> Any:
raise HTTPException(status_code=exc.status, detail=exc.detail) from exc
class PortalMember(BaseModel):
#: Their portal account, which is what ``portal_sub`` holds here.
user_id: str
email: str
#: Their local user, or None while they have not opened the instance yet.
local_user_id: uuid.UUID | None = None
@router.get(
"/members",
dependencies=[Depends(get_current_active_superuser)],
response_model=list[PortalMember],
)
def read_members(session: SessionDep) -> Any:
"""Everyone the portal admits to this instance, its owner aside.
A share link admits somebody at the portal before this instance has heard
of them, so this is the one place a superuser here can see who may still
arrive. It also shows a membership left standing when a local user was
deleted while the portal could not be told.
"""
config = cloud_config.load()
if config is None:
raise HTTPException(
status_code=409, detail="This instance is not connected to a portal"
)
try:
response = httpx.get(
f"{config.portal_url.rstrip('/')}/api/v1/instance-members/",
headers={"Authorization": f"Bearer {config.token}"},
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 != 200:
raise HTTPException(
status_code=502,
detail=f"The portal did not list members ({response.status_code})",
)
members = response.json()["data"]
subs = [str(member["user_id"]) for member in members]
local = {
user.portal_sub: user.id
for user in session.exec(select(User).where(col(User.portal_sub).in_(subs)))
}
return [
PortalMember(
user_id=str(member["user_id"]),
email=str(member["email"]),
local_user_id=local.get(str(member["user_id"])),
)
for member in members
]
@router.delete(
"/members/{portal_sub}",
dependencies=[Depends(get_current_active_superuser)],
response_model=Message,
)
def revoke_member(portal_sub: uuid.UUID) -> Message:
"""Take a portal membership back.
For somebody who has not arrived, this is the whole of their access.
Somebody who has is deleted under Admin → Users instead, which also does
this.
"""
config = cloud_config.load()
if config is None:
raise HTTPException(
status_code=409, detail="This instance is not connected to a portal"
)
try:
response = httpx.delete(
f"{config.portal_url.rstrip('/')}/api/v1/instance-members/{portal_sub}",
headers={"Authorization": f"Bearer {config.token}"},
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 != 200:
raise HTTPException(
status_code=502,
detail=f"The portal refused the revocation ({response.status_code})",
)
return Message(message="Membership revoked")
def forget_remote_user(portal_sub: str) -> None:
"""Tell the portal a remote user is gone from here.
+55
View File
@@ -717,3 +717,58 @@ def test_a_member_removed_at_the_portal_loses_their_user(db: Session) -> None:
assert db.get(User, boss_id) is not None
db.delete(boss)
db.commit()
def test_admin_lists_and_revokes_portal_members(
client: TestClient,
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
superuser_token_headers: dict[str, str],
normal_user_token_headers: dict[str, str],
db: Session,
) -> None:
"""Who the portal admits, and which of them have arrived here."""
arrived = create_random_user(db)
arrived.portal_sub = str(uuid.uuid4())
db.add(arrived)
db.commit()
waiting = str(uuid.uuid4())
listed = Mock(
status_code=200,
json=Mock(
return_value={
"data": [
{"user_id": arrived.portal_sub, "email": arrived.email},
{"user_id": waiting, "email": "invited@example.com"},
]
}
),
)
with patch("fluksio.api.routes.cloud.httpx.get", return_value=listed) as get:
members = client.get(
f"{settings.API_V1_STR}/cloud/members", headers=superuser_token_headers
)
assert members.status_code == 200, members.text
assert get.call_args.args[0].endswith("/instance-members/")
assert {row["user_id"]: row["local_user_id"] for row in members.json()} == {
arrived.portal_sub: str(arrived.id),
waiting: None,
}
with patch(
"fluksio.api.routes.cloud.httpx.delete", return_value=Mock(status_code=200)
) as delete:
revoked = client.delete(
f"{settings.API_V1_STR}/cloud/members/{waiting}",
headers=superuser_token_headers,
)
assert revoked.status_code == 200, revoked.text
assert delete.call_args.args[0].endswith(f"/instance-members/{waiting}")
assert (
client.get(
f"{settings.API_V1_STR}/cloud/members", headers=normal_user_token_headers
).status_code
== 403
)
db.delete(arrived)
db.commit()