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:
@@ -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)],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user