Link a pending portal member to the local user with their email

A portal identity whose email a local user already has is still refused
on arrival. POST /cloud/members/{portal_sub}/link lets a superuser map it
onto that user, never a superuser's, after the portal confirms the
membership. GET /cloud/members marks such members linkable, and Admin →
Users offers "Link portal account" for them, saying that a linked user is
deleted with a removal at the portal or a Disconnect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-15 11:04:53 +02:00
co-authored by Claude Opus 5.5
parent 579c0c22b2
commit 5f05d3f8b4
7 changed files with 233 additions and 10 deletions
+53
View File
@@ -167,6 +167,9 @@ class PortalMember(BaseModel):
email: str
#: Their local user, or None while they have not opened the instance yet.
local_user_id: uuid.UUID | None = None
#: A local user has their email, which keeps them out until a superuser
#: links the two. Never true of a superuser's account.
linkable: bool = False
@router.get(
@@ -208,16 +211,66 @@ def read_members(session: SessionDep) -> Any:
user.portal_sub: user.id
for user in session.exec(select(User).where(col(User.portal_sub).in_(subs)))
}
emails = [str(member["email"]) for member in members]
linkable = {
user.email
for user in session.exec(select(User).where(col(User.email).in_(emails)))
if not user.is_superuser and not user.portal_sub
}
return [
PortalMember(
user_id=str(member["user_id"]),
email=str(member["email"]),
local_user_id=local.get(str(member["user_id"])),
linkable=str(member["user_id"]) not in local
and str(member["email"]) in linkable,
)
for member in members
]
@router.post(
"/members/{portal_sub}/link",
dependencies=[Depends(get_current_active_superuser)],
response_model=UserPublic,
)
def link_member(session: SessionDep, portal_sub: uuid.UUID) -> Any:
"""Let a pending member in as the local user that already has their email.
Arriving on their own they are refused, because handing an existing
account to whoever holds that address at the portal is not a guess to
make. This is a superuser deciding it, and never for a superuser's account.
"""
if cloud_config.load() is None:
raise HTTPException(
status_code=409, detail="This instance is not connected to a portal"
)
sub = str(portal_sub)
member = enroll_mod.lookup_member(sub)
if member is None:
raise HTTPException(
status_code=404, detail="The portal did not confirm this member"
)
if session.exec(select(User).where(User.portal_sub == sub)).first():
raise HTTPException(status_code=409, detail="That account already has access")
user = session.exec(select(User).where(User.email == str(member["email"]))).first()
if user is None:
raise HTTPException(status_code=404, detail="No local user has this email")
if user.is_superuser:
raise HTTPException(
status_code=403, detail="A superuser cannot be linked to a portal account"
)
if user.portal_sub:
raise HTTPException(
status_code=409, detail="This user is linked to another portal account"
)
user.portal_sub = sub
session.add(user)
session.commit()
session.refresh(user)
return user
@router.delete(
"/members/{portal_sub}",
dependencies=[Depends(get_current_active_superuser)],
+73
View File
@@ -809,6 +809,79 @@ def test_admin_lists_and_revokes_portal_members(
db.commit()
def test_a_superuser_links_a_pending_member_to_their_local_user(
client: TestClient,
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
portal_key: rsa.RSAPrivateKey,
superuser_token_headers: dict[str, str],
normal_user_token_headers: dict[str, str],
db: Session,
) -> None:
"""Arriving alone they are refused; linked, they arrive as that user."""
local = create_random_user(db)
boss = create_random_user(db)
boss.is_superuser = True
db.add(boss)
db.commit()
sub, boss_sub = str(uuid.uuid4()), str(uuid.uuid4())
def member(user_id: str, email: str) -> Mock:
return Mock(
status_code=200,
json=Mock(return_value={"user_id": user_id, "email": email}),
)
def link(user_id: str, reply: Mock, headers: dict[str, str]) -> httpx.Response:
with patch("fluksio.cloud.enroll.httpx.get", return_value=reply):
return client.post(
f"{settings.API_V1_STR}/cloud/members/{user_id}/link", headers=headers
)
# Never adopted on their own: the local row might be anyone's.
with patch("fluksio.cloud.enroll.httpx.get", return_value=member(sub, local.email)):
assert user_from_token(db, portal_token(portal_key, subject=sub)) is None
listed = Mock(
status_code=200,
json=Mock(
return_value={
"data": [
{"user_id": sub, "email": local.email},
{"user_id": boss_sub, "email": boss.email},
]
}
),
)
with patch("fluksio.api.routes.cloud.httpx.get", return_value=listed):
members = client.get(
f"{settings.API_V1_STR}/cloud/members", headers=superuser_token_headers
).json()
assert {row["user_id"]: row["linkable"] for row in members} == {
sub: True,
boss_sub: False,
}
su = superuser_token_headers
assert link(boss_sub, member(boss_sub, boss.email), su).status_code == 403
assert link(sub, Mock(status_code=404), su).status_code == 404
assert link(sub, member(sub, "nobody-here@example.com"), su).status_code == 404
assert (
link(sub, member(sub, local.email), normal_user_token_headers).status_code
== 403
)
linked = link(sub, member(sub, local.email), su)
assert linked.status_code == 200, linked.text
assert linked.json()["portal_sub"] == sub
assert link(sub, member(sub, local.email), su).status_code == 409
db.expire_all()
resolved = user_from_token(db, portal_token(portal_key, subject=sub))
assert resolved is not None and resolved.id == local.id
db.delete(db.get(User, local.id))
db.delete(db.get(User, boss.id))
db.commit()
def test_the_portal_keys_follow_the_tunnel(
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
portal_key: rsa.RSAPrivateKey,
+6
View File
@@ -56,6 +56,12 @@ Somebody admitted by a link gets their local account the first time they open
the instance. Until then they are listed under **Admin → Users → Pending portal
members**, where you can revoke the invitation.
If a local user already has their email, they are refused rather than handed
that account. A superuser can press **Link portal account** beside them to let
them in as that user, who keeps their local password. Superuser accounts
cannot be linked. A linked user counts as a portal user from then on, so
removing them at the portal, or disconnecting, deletes them.
## Hooks and agents through the portal
The portal also forwards a flow's hooks and the MCP endpoint, so they work
+5
View File
@@ -3059,6 +3059,11 @@ export const PortalMemberSchema = {
}
],
title: 'Local User Id'
},
linkable: {
type: 'boolean',
title: 'Linkable',
default: false
}
},
type: 'object',
File diff suppressed because one or more lines are too long
+7
View File
@@ -1049,6 +1049,7 @@ export type PortalMember = {
user_id: string;
email: string;
local_user_id?: (string | null);
linkable?: boolean;
};
export type PrivateUserCreate = {
@@ -1644,6 +1645,12 @@ export type CloudAddRemoteUserResponse = (UserPublic);
export type CloudReadMembersResponse = (Array<PortalMember>);
export type CloudLinkMemberData = {
portalSub: string;
};
export type CloudLinkMemberResponse = (UserPublic);
export type CloudRevokeMemberData = {
portalSub: string;
};
@@ -20,7 +20,9 @@ const membersKey = ["cloud", "members"]
/** Portal members who have no user here yet, and the way to take one back.
* A share link admits somebody at the portal before this instance hears of
* them, so without this list nobody here could see who may still arrive.
* Those who have arrived are users above, deleted there. */
* Those who have arrived are users above, deleted there. A member whose email
* a local user already has is refused on arrival until linked to that user
* here. */
export function PortalMembers() {
const { data: status } = useQuery({
queryKey: ["cloud", "status"],
@@ -41,6 +43,7 @@ export function PortalMembers() {
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const [pending, setPending] = useState<PortalMember | null>(null)
const [linking, setLinking] = useState<PortalMember | null>(null)
const revoke = useMutation({
mutationFn: (portalSub: string) => CloudService.revokeMember({ portalSub }),
@@ -54,6 +57,19 @@ export function PortalMembers() {
},
})
const link = useMutation({
mutationFn: (portalSub: string) => CloudService.linkMember({ portalSub }),
onSuccess: (user) => {
showSuccessToast(`Linked ${user.email}`)
setLinking(null)
queryClient.invalidateQueries({ queryKey: ["users"] })
},
onError: handleError.bind(showErrorToast),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: membersKey })
},
})
if (!enrolled) return null
const waiting = (data ?? []).filter((member) => !member.local_user_id)
@@ -81,14 +97,25 @@ export function PortalMembers() {
data-testid="portal-member"
>
<span className="text-sm">{member.email}</span>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setPending(member)}
>
Revoke
</Button>
<div className="flex gap-1">
{member.linkable && (
<Button
variant="ghost"
size="sm"
onClick={() => setLinking(member)}
>
Link portal account
</Button>
)}
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setPending(member)}
>
Revoke
</Button>
</div>
</div>
))}
</div>
@@ -120,6 +147,33 @@ export function PortalMembers() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={linking !== null}
onOpenChange={(open) => !open && setLinking(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Link {linking?.email}?</DialogTitle>
<DialogDescription>
Through the portal they open this instance as the local user with
this email, who keeps their password. Removing them at the portal,
or disconnecting it, then deletes that user.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setLinking(null)}>
Cancel
</Button>
<Button
disabled={link.isPending}
onClick={() => linking && link.mutate(linking.user_id)}
>
Link
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
)
}