Add org-specific delete invite endpoint (#575)

Adds POST /orgs/{oid}/invites/delete, which expects the invited
email address in the POST body.

This endpoint will also delete duplicate invites with the same
email/oid combination if env var ALLOW_DUPE_INVITES allows dupes.
This commit is contained in:
Tessa Walsh 2023-02-08 16:10:09 -05:00 committed by GitHub
parent 3261e7d666
commit a7a18b9db0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 77 additions and 5 deletions

View File

@ -118,6 +118,15 @@ class InviteOps:
"""remove invite from invite list""" """remove invite from invite list"""
await self.invites.delete_one({"_id": invite_token}) await self.invites.delete_one({"_id": invite_token})
async def remove_invite_by_email(self, email: str, oid: str = None):
"""remove invite from invite list by email"""
query = {"email": email}
if oid:
query["oid"] = oid
# Use delete_many rather than delete_one to clean up any duplicate
# invites as well.
return await self.invites.delete_many(query)
def accept_user_invite(self, user, invite_token: str): def accept_user_invite(self, user, invite_token: str):
"""remove invite from user, if valid token, throw if not""" """remove invite from user, if valid token, throw if not"""
invite = user.invites.pop(invite_token, "") invite = user.invites.pop(invite_token, "")

View File

@ -3,6 +3,7 @@ Organization API handling
""" """
import os import os
import time import time
import urllib.parse
import uuid import uuid
from typing import Dict, Union, Literal, Optional from typing import Dict, Union, Literal, Optional
@ -39,6 +40,11 @@ class RemoveFromOrg(InviteRequest):
"""Remove this user from org""" """Remove this user from org"""
# ============================================================================
class RemovePendingInvite(InviteRequest):
"""Delete pending invite to org by email"""
# ============================================================================ # ============================================================================
class RenameOrg(BaseModel): class RenameOrg(BaseModel):
"""Request to invite another user""" """Request to invite another user"""
@ -468,6 +474,20 @@ def init_orgs_api(app, mdb, user_manager, invites, user_dep: User):
pending_invites = await user_manager.invites.get_pending_invites(org) pending_invites = await user_manager.invites.get_pending_invites(org)
return {"pending_invites": pending_invites} return {"pending_invites": pending_invites}
@router.post("/invites/delete", tags=["invites"])
async def delete_invite(
invite: RemovePendingInvite, org: Organization = Depends(org_owner_dep)
):
# URL decode email just in case
email = urllib.parse.unquote(invite.email)
result = await user_manager.invites.remove_invite_by_email(email, org.id)
if result.deleted_count > 0:
return {
"removed": True,
"count": result.deleted_count,
}
raise HTTPException(status_code=404, detail="invite_not_found")
@router.post("/remove", tags=["invites"]) @router.post("/remove", tags=["invites"])
async def remove_user_from_org( async def remove_user_from_org(
remove: RemoveFromOrg, org: Organization = Depends(org_owner_dep) remove: RemoveFromOrg, org: Organization = Depends(org_owner_dep)

View File

@ -483,11 +483,6 @@ def init_users_api(app, user_manager):
return await user_manager.format_invite(invite) return await user_manager.format_invite(invite)
@users_router.get("/invite-delete/{token}", tags=["invites"])
async def delete_invite(token: str):
await user_manager.invites.remove_invite(token)
return {"removed": True}
@users_router.get("/invites", tags=["invites"]) @users_router.get("/invites", tags=["invites"])
async def get_pending_invites(user: User = Depends(current_active_user)): async def get_pending_invites(user: User = Depends(current_active_user)):
if not user.is_superuser: if not user.is_superuser:

View File

@ -1,4 +1,5 @@
import requests import requests
import uuid
import pytest import pytest
@ -231,3 +232,50 @@ def test_send_and_accept_org_invite(
user for user in users.values() if user["email"] == expected_stored_email user for user in users.values() if user["email"] == expected_stored_email
] ]
assert len(users_with_invited_email) == 1 assert len(users_with_invited_email) == 1
def test_delete_invite_by_email(admin_auth_headers, non_default_org_id):
# Invite user to non-default org
INVITE_EMAIL = "new-non-default-org-invite-by-email@example.com"
r = requests.post(
f"{API_PREFIX}/orgs/{non_default_org_id}/invite",
headers=admin_auth_headers,
json={"email": INVITE_EMAIL, "role": 20},
)
assert r.status_code == 200
data = r.json()
assert data["invited"] == "new_user"
# Delete invite by email
r = requests.post(
f"{API_PREFIX}/orgs/{non_default_org_id}/invites/delete",
headers=admin_auth_headers,
json={"email": INVITE_EMAIL},
)
assert r.status_code == 200
data = r.json()
assert data["removed"]
assert data["count"] == 1
# Verify invite no longer exists
r = requests.get(
f"{API_PREFIX}/orgs/{non_default_org_id}/invites",
headers=admin_auth_headers,
)
assert r.status_code == 200
data = r.json()
invites_matching_email = [
invite for invite in data["pending_invites"] if invite["email"] == INVITE_EMAIL
]
assert len(invites_matching_email) == 0
# Try to delete non-existent email and test we get 404
bad_token = str(uuid.uuid4())
r = requests.post(
f"{API_PREFIX}/orgs/{non_default_org_id}/invites/delete",
headers=admin_auth_headers,
json={"email": "not-a-valid-invite@example.com"},
)
assert r.status_code == 404
data = r.json()
assert data["detail"] == "invite_not_found"