feat: sua added to group invitations
All checks were successful
ci / lint_and_test (push) Successful in 13s

Issue: #23
This commit is contained in:
Chris Milne 2026-06-09 16:52:22 +01:00
parent 7809df4c5a
commit 768a3881ef
4 changed files with 110 additions and 3 deletions

View file

@ -16,9 +16,11 @@ Endpoints:
- [GET](/iam/permissions/search): [root user]: Returns a list of permissions matching a filter(service|resource|action)
"""
from fastapi import APIRouter, status
from fastapi import APIRouter, status, BackgroundTasks
from sqlalchemy.exc import IntegrityError
from src.iam.exceptions import GroupNotFoundException
from src.organisation.exceptions import OrgNotFoundException
from src.service.exceptions import ServiceNotFoundException
from src.exceptions import ConflictException
from src.database import db_dependency
@ -34,11 +36,12 @@ from src.user.models import User
from src.user.dependencies import (
user_model_body_dependency,
user_model_query_dependency,
user_model_claims_dependency,
)
from src.organisation.models import Organisation as Org
from src.service.models import Service
from src.iam.service import service_key_dependency
from src.iam.service import service_key_dependency, send_user_group_invitation
from src.iam.models import (
Permission as Perm,
GroupPermissions as GPerms,
@ -68,7 +71,10 @@ from src.iam.schemas import (
IAMPostPermissionResponse,
IAMGetPermissionsSearchRequest,
IAMGetPermissionsSearchResponse,
IAMPutGroupInvitationRequest,
IAMPutGroupInvitationAcceptRequest,
)
from src.utils import decode_jwt
router = APIRouter(
tags=["IAM"],
@ -315,3 +321,68 @@ async def post_permissions(
permission_models = permission_query.all()
return {"permissions": permission_models}
@router.put(
"/group/user/invitation",
summary="Send an email invitation for non-org member to join a group",
status_code=status.HTTP_200_OK,
)
async def invitation(
background_tasks: BackgroundTasks,
org_model: org_model_root_claim_body_dependency,
group_model: group_model_body_dependency,
request_model: IAMPutGroupInvitationRequest,
):
org_id = org_model.id
org_name = org_model.name
user_email = request_model.user_email
group_id = group_model.id
group_name = group_model.name
background_tasks.add_task(
send_user_group_invitation,
org_id=org_id,
org_name=org_name,
user_email=user_email,
group_id=group_id,
group_name=group_name,
)
return "Invitation sent"
@router.put(
"/group/user//invitation/accept",
summary="Accept email invitation to join an org's group",
status_code=status.HTTP_200_OK,
)
async def accept_invitation(
db: db_dependency,
user_model: user_model_claims_dependency,
request_model: IAMPutGroupInvitationAcceptRequest,
):
email_claims = await decode_jwt(request_model.jwt)
claimed_email = email_claims["email"]
if user_model.email != claimed_email:
raise UnauthorizedException("The logged in user and email do not match.")
org_model = db.get(Org, email_claims["org_id"])
if org_model is None:
raise OrgNotFoundException()
group_model = db.get(Group, email_claims["group_id"])
if group_model is None:
raise GroupNotFoundException()
if group_model not in org_model.group_rel:
raise UnauthorizedException("Group and org do not match.")
if user_model in group_model.user_rel:
raise ConflictException(message="User already in group.")
group_model.user_rel.append(user_model)
db.commit()
return "Invitation accepted"

View file

@ -108,3 +108,11 @@ class IAMGetPermissionsSearchRequest(OrgIDMixin):
class IAMGetPermissionsSearchResponse(CustomBaseModel):
permissions: list[PermissionSchema]
class IAMPutGroupInvitationRequest(OrgIDMixin, GroupIDMixin):
user_email: EmailStr
class IAMPutGroupInvitationAcceptRequest(CustomBaseModel):
jwt: str

View file

@ -6,11 +6,14 @@ Exports:
"""
from typing import Annotated
from datetime import datetime, timedelta, timezone
from src.service.models import Service
from src.database import db_dependency
from src.schemas import ResourceName
from src.auth.exceptions import UnauthorizedException
from src.utils import send_email, generate_jwt
from fastapi import Request, Depends
@ -33,3 +36,28 @@ def valid_service_key(db: db_dependency, request: Request, rn: ResourceName) ->
service_key_dependency = Annotated[bool, Depends(valid_service_key)]
async def send_user_group_invitation(
user_email: str, org_name: str, org_id: int, group_id: int, group_name: str
):
expiry_delta = timedelta(hours=24)
expiry = datetime.now(timezone.utc) + expiry_delta
claims = {
"email": user_email,
"org_id": org_id,
"group_id": group_id,
"group_name": group_name,
"exp": expiry,
"type": "group_invite",
}
token = await generate_jwt(claims)
subject = f"You have been invited to join a group of {org_name}"
body = f"You have been invited to join {group_name}.\nClick the link to accept.\nfrontend.capture/send/to/endpoint/{token}"
await send_email(
recipient=user_email,
subject=subject,
body=body,
)

View file

@ -182,7 +182,7 @@ async def accept_invitation(
request_model: UserPostInvitationAcceptRequest,
):
email_claims = await decode_jwt(request_model.jwt)
claimed_email = email_claims["user_email"]
claimed_email = email_claims["email"]
if user_model.email != claimed_email:
raise UnauthorizedException("The logged in user and email do not match.")