Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions backend/app/api/organizations.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
)
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.organizations import (
ROLE_RANK,
OrganizationContext,
accept_invite,
apply_invite_board_access,
Expand Down Expand Up @@ -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
Comment on lines +485 to +494
updates["updated_at"] = utcnow()
member = await crud.patch(session, member, updates)
user = await User.objects.by_id(member.user_id).first(session)
Expand Down Expand Up @@ -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(
Comment on lines +642 to +646
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot grant a role equal to or above your own",
Comment on lines +641 to +648
)

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,
Expand Down Expand Up @@ -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)
Comment on lines +725 to 735

Expand Down
17 changes: 16 additions & 1 deletion backend/app/schemas/organizations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Comment on lines +121 to 123
accepted_at: datetime | None = None
created_at: datetime
updated_at: datetime

@model_validator(mode="wrap")
@classmethod
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove unrelated changes

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."""
Expand Down
Loading