From 768a3881ef0c33bd538c8e76e96f1db618d99220 Mon Sep 17 00:00:00 2001 From: luxferre Date: Tue, 9 Jun 2026 16:52:22 +0100 Subject: [PATCH] feat: sua added to group invitations Issue: #23 --- src/iam/router.py | 75 ++++++++++++++++++++++++++++++++++++++++++++-- src/iam/schemas.py | 8 +++++ src/iam/service.py | 28 +++++++++++++++++ src/user/router.py | 2 +- 4 files changed, 110 insertions(+), 3 deletions(-) diff --git a/src/iam/router.py b/src/iam/router.py index 32ad7ee..fa33d6c 100644 --- a/src/iam/router.py +++ b/src/iam/router.py @@ -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" diff --git a/src/iam/schemas.py b/src/iam/schemas.py index e46587b..6a7e9d8 100644 --- a/src/iam/schemas.py +++ b/src/iam/schemas.py @@ -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 diff --git a/src/iam/service.py b/src/iam/service.py index 1e0dfe8..53142ce 100644 --- a/src/iam/service.py +++ b/src/iam/service.py @@ -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, + ) diff --git a/src/user/router.py b/src/user/router.py index 0a37d6f..2989785 100644 --- a/src/user/router.py +++ b/src/user/router.py @@ -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.")