diff --git a/backend/app/api/organizations.py b/backend/app/api/organizations.py index c776c6559..c04d202bc 100644 --- a/backend/app/api/organizations.py +++ b/backend/app/api/organizations.py @@ -54,6 +54,7 @@ ) from app.schemas.pagination import DefaultLimitOffsetPage from app.services.organizations import ( + ROLE_RANK, OrganizationContext, accept_invite, apply_invite_board_access, @@ -481,7 +482,16 @@ async def update_org_member( ) updates = payload.model_dump(exclude_unset=True) if "role" in updates and updates["role"] is not None: - updates["role"] = normalize_role(updates["role"]) + new_role = normalize_role(updates["role"]) + # Prevent granting roles at or above the caller's level. + caller_rank = ROLE_RANK.get(ctx.member.role, 0) + new_rank = ROLE_RANK.get(new_role, 0) + if new_rank >= caller_rank: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot grant a role equal to or above your own", + ) + updates["role"] = new_role updates["updated_at"] = utcnow() member = await crud.patch(session, member, updates) user = await User.objects.by_id(member.user_id).first(session) @@ -628,12 +638,22 @@ async def create_org_invite( if existing_member is not None: raise HTTPException(status_code=status.HTTP_409_CONFLICT) + # Prevent granting roles at or above the caller's level. + requested_role = normalize_role(payload.role) + caller_rank = ROLE_RANK.get(ctx.member.role, 0) + requested_rank = ROLE_RANK.get(requested_role, 0) + if requested_rank >= caller_rank: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot grant a role equal to or above your own", + ) + token = secrets.token_urlsafe(24) invite = OrganizationInvite( organization_id=ctx.organization.id, invited_email=email, token=token, - role=normalize_role(payload.role), + role=requested_role, all_boards_read=payload.all_boards_read, all_boards_write=payload.all_boards_write, created_by_user_id=ctx.member.user_id, @@ -702,11 +722,15 @@ async def accept_org_invite( ).first(session) if invite is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - if ( - invite.invited_email - and auth.user.email - and normalize_invited_email(invite.invited_email) - != normalize_invited_email(auth.user.email) + # Require both emails to be present — reject if either is missing to + # prevent bypassing the email-binding check via None/empty values. + if not invite.invited_email or not auth.user.email: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invite requires a matching email address", + ) + if normalize_invited_email(invite.invited_email) != normalize_invited_email( + auth.user.email ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) diff --git a/backend/app/schemas/organizations.py b/backend/app/schemas/organizations.py index 0a17abdee..9e296f5a9 100644 --- a/backend/app/schemas/organizations.py +++ b/backend/app/schemas/organizations.py @@ -3,8 +3,10 @@ from __future__ import annotations from datetime import datetime +from typing import Any from uuid import UUID +from pydantic import model_validator from sqlmodel import Field, SQLModel RUNTIME_ANNOTATION_TYPES = (datetime, UUID) @@ -116,13 +118,26 @@ class OrganizationInviteRead(SQLModel): role: str all_boards_read: bool all_boards_write: bool - token: str + has_token: bool = False created_by_user_id: UUID | None = None accepted_by_user_id: UUID | None = None accepted_at: datetime | None = None created_at: datetime updated_at: datetime + @model_validator(mode="wrap") + @classmethod + def _compute_has_token(cls, values: Any, handler: Any) -> OrganizationInviteRead: + """Compute has_token from the ORM token field without exposing the raw value.""" + model = handler(values) + token = ( + getattr(values, "token", None) + if not isinstance(values, dict) + else values.get("token") + ) + model.has_token = bool(token) + return model + class OrganizationInviteAccept(SQLModel): """Payload for accepting an organization invite token."""