Compare commits

...

3 commits

Author SHA1 Message Date
0b521414b3 feat: add group user by id restriction
All checks were successful
ci / lint_and_test (push) Successful in 14s
Adding by ID can only be done for existing org members
2026-06-10 14:48:22 +01:00
3dbd72a109 feat: 403 exception 2026-06-10 14:47:33 +01:00
ec572aa4c1 feat: sua expiry handling 2026-06-10 14:14:22 +01:00
5 changed files with 43 additions and 15 deletions

View file

@ -27,3 +27,12 @@ class ConflictException(HTTPException):
status_code=status.HTTP_409_CONFLICT,
detail=detail,
)
class ForbiddenException(HTTPException):
def __init__(self, message: Optional[str] = None) -> None:
detail = "Forbidden" if not message else message
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
detail=detail,
)

View file

@ -23,7 +23,7 @@ from src.iam.exceptions import GroupNotFoundException
from src.organisation.exceptions import OrgNotFoundException
from src.schemas import GroupSummary, OrgSummary
from src.service.exceptions import ServiceNotFoundException
from src.exceptions import ConflictException
from src.exceptions import ConflictException, ForbiddenException
from src.database import db_dependency
from src.auth.exceptions import UnauthorizedException
from src.auth.service import claims_dependency
@ -75,7 +75,7 @@ from src.iam.schemas import (
IAMPutGroupInvitationRequest,
IAMPutGroupInvitationAcceptRequest,
)
from src.utils import decode_jwt
from src.utils import verify_email_token
router = APIRouter(
tags=["IAM"],
@ -211,6 +211,11 @@ async def add_group_user(
if user_model in group_model.user_rel:
raise ConflictException("User already in group")
if user_model not in org_model.user_rel:
raise ForbiddenException(
"Adding users directly can only be done with org members. Use email invitation instead."
)
group_model.user_rel.append(user_model)
db.flush()
response = IAMPutGroupUserResponse(
@ -373,11 +378,9 @@ async def accept_invitation(
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.")
email_claims = await verify_email_token(
token=request_model.jwt, user_model=user_model
)
org_model = db.get(Org, email_claims["org_id"])
if org_model is None:

View file

@ -10,7 +10,6 @@ Endpoints:
from fastapi import APIRouter, status, BackgroundTasks
from src.auth.exceptions import UnauthorizedException
from src.organisation.exceptions import OrgNotFoundException
from src.user.schemas import (
UserResponse,
@ -32,7 +31,7 @@ from src.auth.dependencies import (
)
from src.auth.service import claims_dependency
from src.database import db_dependency
from src.utils import decode_jwt
from src.utils import verify_email_token
router = APIRouter(
prefix="/user",
@ -181,11 +180,9 @@ async def accept_invitation(
user_model: user_model_claims_dependency,
request_model: UserPostInvitationAcceptRequest,
):
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.")
email_claims = await verify_email_token(
token=request_model.jwt, user_model=user_model
)
org_model = db.get(Org, email_claims["org_id"])
if org_model is None:

View file

@ -1,3 +1,4 @@
from datetime import datetime, timezone
from joserfc import jwt, jwk, errors
from src.auth.exceptions import UnauthorizedException
@ -21,6 +22,22 @@ async def decode_jwt(encoded):
raise UnauthorizedException("Invalid JWS")
async def verify_email_token(user_model, token):
email_claims = await decode_jwt(token)
claimed_email = email_claims["email"]
expiry = datetime.fromtimestamp(email_claims["exp"], timezone.utc)
if expiry < datetime.now(timezone.utc):
raise UnauthorizedException("Invitation expired.")
if user_model.email != claimed_email:
raise UnauthorizedException("The logged in user and email do not match.")
return email_claims
async def send_email(recipient: str, subject: str, body: str):
print(recipient)
print(subject)

View file

@ -4,7 +4,7 @@ import pytest
from httpx import AsyncClient
from src.user.models import User
from src.organisation.models import Organisation as Org
from src.organisation.models import Organisation as Org, OrgUsers
from src.iam.models import Group
from .conftest import generate_query_and_status
@ -468,6 +468,8 @@ async def test_put_group_user_success(default_client: AsyncClient, db_session):
)
)
db_session.flush()
db_session.add(OrgUsers(user_id=2, org_id=1))
db_session.flush()
resp = await default_client.put(
"/iam/group/user", json={"user_id": 2, "group_id": 1, "organisation_id": 1}