Phase 2: Shared calendars backend core + QA fixes

Router: invite/accept/reject flow, membership CRUD, event locking
(timed + permanent), sync endpoint, local color override.
Services: permission hierarchy, atomic lock acquisition, disconnect cascade.
Events: shared calendar scoping, permission/lock enforcement, updated_by tracking.
Admin: sharing-stats endpoint. nginx: rate limits for invite + sync.

QA fixes: C-01 (read-only invite gate), C-02 (updated_by in this_and_future),
W-01 (pre-commit response build), W-02 (owned calendar short-circuit),
W-03 (sync calendar_ids cap), W-04 (N+1 owner name batch fetch).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-06 04:46:17 +08:00
parent e4b45763b4
commit e6e81c59e7
7 changed files with 1178 additions and 10 deletions

View File

@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config import settings
from app.database import engine
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router
from app.jobs.notifications import run_notification_dispatch
# Import models so Alembic's autogenerate can discover them
@ -20,6 +20,8 @@ from app.models import audit_log as _audit_log_model # noqa: F401
from app.models import notification as _notification_model # noqa: F401
from app.models import connection_request as _connection_request_model # noqa: F401
from app.models import user_connection as _user_connection_model # noqa: F401
from app.models import calendar_member as _calendar_member_model # noqa: F401
from app.models import event_lock as _event_lock_model # noqa: F401
# ---------------------------------------------------------------------------
@ -134,6 +136,7 @@ app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
app.include_router(shared_calendars_router.router, prefix="/api/shared-calendars", tags=["Shared Calendars"])
@app.get("/")

View File

@ -22,6 +22,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.audit_log import AuditLog
from app.models.calendar import Calendar
from app.models.calendar_member import CalendarMember
from app.models.backup_code import BackupCode
from app.models.session import UserSession
from app.models.settings import Settings
@ -618,6 +620,56 @@ async def list_user_sessions(
}
# ---------------------------------------------------------------------------
# GET /users/{user_id}/sharing-stats
# ---------------------------------------------------------------------------
@router.get("/users/{user_id}/sharing-stats")
async def get_user_sharing_stats(
user_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
_actor: User = Depends(get_current_user),
):
"""Return sharing statistics for a user."""
result = await db.execute(sa.select(User).where(User.id == user_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="User not found")
# Calendars owned that are shared
shared_owned = await db.scalar(
sa.select(sa.func.count())
.select_from(Calendar)
.where(Calendar.user_id == user_id, Calendar.is_shared == True)
) or 0
# Calendars the user is a member of (accepted)
member_of = await db.scalar(
sa.select(sa.func.count())
.select_from(CalendarMember)
.where(CalendarMember.user_id == user_id, CalendarMember.status == "accepted")
) or 0
# Pending invites sent by this user
pending_sent = await db.scalar(
sa.select(sa.func.count())
.select_from(CalendarMember)
.where(CalendarMember.invited_by == user_id, CalendarMember.status == "pending")
) or 0
# Pending invites received by this user
pending_received = await db.scalar(
sa.select(sa.func.count())
.select_from(CalendarMember)
.where(CalendarMember.user_id == user_id, CalendarMember.status == "pending")
) or 0
return {
"shared_calendars_owned": shared_owned,
"calendars_member_of": member_of,
"pending_invites_sent": pending_sent,
"pending_invites_received": pending_received,
}
# ---------------------------------------------------------------------------
# GET /config
# ---------------------------------------------------------------------------

View File

@ -49,6 +49,7 @@ from app.services.connection import (
resolve_shared_profile,
send_connection_ntfy,
)
from app.services.calendar_sharing import cascade_on_disconnect
from app.services.notification import create_notification
router = APIRouter()
@ -823,6 +824,9 @@ async def remove_connection(
if reverse_conn:
await db.delete(reverse_conn)
# Cascade: remove calendar memberships and event locks between these users
await cascade_on_disconnect(db, current_user.id, counterpart_id)
await log_audit_event(
db,
action="connection.removed",

View File

@ -18,7 +18,9 @@ from app.schemas.calendar_event import (
)
from app.routers.auth import get_current_user
from app.models.user import User
from app.models.calendar_member import CalendarMember
from app.services.recurrence import generate_occurrences
from app.services.calendar_sharing import check_lock_for_edit, require_permission
router = APIRouter()
@ -142,13 +144,18 @@ async def get_events(
recurrence_rule IS NOT NULL) are excluded their materialised children
are what get displayed on the calendar.
"""
# Scope events through calendar ownership
# Scope events through calendar ownership + shared memberships
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
shared_calendar_ids = select(CalendarMember.calendar_id).where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
all_calendar_ids = user_calendar_ids.union(shared_calendar_ids)
query = (
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.calendar_id.in_(user_calendar_ids))
.where(CalendarEvent.calendar_id.in_(all_calendar_ids))
)
# Exclude parent template rows — they are not directly rendered
@ -219,8 +226,13 @@ async def create_event(
if not data.get("calendar_id"):
data["calendar_id"] = await _get_default_calendar_id(db, current_user.id)
else:
# SEC-04: verify the target calendar belongs to the requesting user
await _verify_calendar_ownership(db, data["calendar_id"], current_user.id)
# SEC-04: verify ownership OR shared calendar permission
cal_ownership_result = await db.execute(
select(Calendar).where(Calendar.id == data["calendar_id"], Calendar.user_id == current_user.id)
)
if not cal_ownership_result.scalar_one_or_none():
# Not owned — check shared calendar permission
await require_permission(db, data["calendar_id"], current_user.id, "create_modify")
# Serialize RecurrenceRule object to JSON string for DB storage
# Exclude None values so defaults in recurrence service work correctly
@ -229,7 +241,7 @@ async def create_event(
if rule_json:
# Parent template: is_recurring=True, no parent_event_id
parent = CalendarEvent(**data, recurrence_rule=rule_json, is_recurring=True)
parent = CalendarEvent(**data, recurrence_rule=rule_json, is_recurring=True, updated_by=current_user.id)
db.add(parent)
await db.flush() # assign parent.id before generating children
@ -258,7 +270,7 @@ async def create_event(
return result.scalar_one()
else:
new_event = CalendarEvent(**data, recurrence_rule=None)
new_event = CalendarEvent(**data, recurrence_rule=None, updated_by=current_user.id)
db.add(new_event)
await db.commit()
@ -277,13 +289,18 @@ async def get_event(
current_user: User = Depends(get_current_user),
):
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
shared_calendar_ids = select(CalendarMember.calendar_id).where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
all_calendar_ids = user_calendar_ids.union(shared_calendar_ids)
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(
CalendarEvent.id == event_id,
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.calendar_id.in_(all_calendar_ids),
)
)
event = result.scalar_one_or_none()
@ -302,13 +319,18 @@ async def update_event(
current_user: User = Depends(get_current_user),
):
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
shared_calendar_ids = select(CalendarMember.calendar_id).where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
all_calendar_ids = user_calendar_ids.union(shared_calendar_ids)
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(
CalendarEvent.id == event_id,
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.calendar_id.in_(all_calendar_ids),
)
)
event = result.scalar_one_or_none()
@ -316,6 +338,10 @@ async def update_event(
if not event:
raise HTTPException(status_code=404, detail="Calendar event not found")
# Shared calendar: require create_modify+ and check lock
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
update_data = event_update.model_dump(exclude_unset=True)
# Extract scope before applying fields to the model
@ -342,6 +368,7 @@ async def update_event(
# Detach from parent so it's an independent event going forward
event.parent_event_id = None
event.is_recurring = False
event.updated_by = current_user.id
await db.commit()
elif scope == "this_and_future":
@ -371,6 +398,7 @@ async def update_event(
event.parent_event_id = None
event.is_recurring = True
event.original_start = None
event.updated_by = current_user.id
# Inherit parent's recurrence_rule if none was provided in update
if not event.recurrence_rule and parent_rule:
@ -386,6 +414,7 @@ async def update_event(
# This IS a parent — update it and regenerate all children
for key, value in update_data.items():
setattr(event, key, value)
event.updated_by = current_user.id
# Delete all existing children and regenerate
if event.recurrence_rule:
@ -405,6 +434,7 @@ async def update_event(
# No scope — plain update (non-recurring events or full-series metadata)
for key, value in update_data.items():
setattr(event, key, value)
event.updated_by = current_user.id
await db.commit()
result = await db.execute(
@ -427,11 +457,16 @@ async def delete_event(
current_user: User = Depends(get_current_user),
):
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
shared_calendar_ids = select(CalendarMember.calendar_id).where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
all_calendar_ids = user_calendar_ids.union(shared_calendar_ids)
result = await db.execute(
select(CalendarEvent).where(
CalendarEvent.id == event_id,
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.calendar_id.in_(all_calendar_ids),
)
)
event = result.scalar_one_or_none()
@ -439,6 +474,10 @@ async def delete_event(
if not event:
raise HTTPException(status_code=404, detail="Calendar event not found")
# Shared calendar: require full_access+ and check lock
await require_permission(db, event.calendar_id, current_user.id, "full_access")
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
if scope == "this":
# Delete just this one occurrence
await db.delete(event)

View File

@ -0,0 +1,849 @@
"""
Shared calendars router invites, membership, locks, sync.
All endpoints live under /api/shared-calendars.
"""
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from sqlalchemy import delete, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.calendar import Calendar
from app.models.calendar_event import CalendarEvent
from app.models.calendar_member import CalendarMember
from app.models.event_lock import EventLock
from app.models.settings import Settings
from app.models.user import User
from app.models.user_connection import UserConnection
from app.routers.auth import get_current_user
from app.schemas.shared_calendar import (
CalendarInviteResponse,
CalendarMemberResponse,
InviteMemberRequest,
LockStatusResponse,
RespondInviteRequest,
SyncResponse,
UpdateLocalColorRequest,
UpdateMemberRequest,
)
from app.services.audit import get_client_ip, log_audit_event
from app.services.calendar_sharing import (
PERMISSION_RANK,
acquire_lock,
get_user_permission,
release_lock,
require_permission,
)
from app.services.notification import create_notification
router = APIRouter()
logger = logging.getLogger(__name__)
PENDING_INVITE_CAP = 10
# -- Helpers ---------------------------------------------------------------
async def _get_settings_for_user(db: AsyncSession, user_id: int) -> Settings | None:
result = await db.execute(select(Settings).where(Settings.user_id == user_id))
return result.scalar_one_or_none()
def _build_member_response(member: CalendarMember) -> dict:
return {
"id": member.id,
"calendar_id": member.calendar_id,
"user_id": member.user_id,
"umbral_name": member.user.umbral_name if member.user else "",
"preferred_name": None,
"permission": member.permission,
"can_add_others": member.can_add_others,
"local_color": member.local_color,
"status": member.status,
"invited_at": member.invited_at,
"accepted_at": member.accepted_at,
}
# -- GET / — List accepted memberships ------------------------------------
@router.get("/")
async def list_shared_calendars(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List calendars the current user has accepted membership in."""
result = await db.execute(
select(CalendarMember)
.where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
.options(selectinload(CalendarMember.calendar))
.order_by(CalendarMember.accepted_at.desc())
)
members = result.scalars().all()
return [
{
"id": m.id,
"calendar_id": m.calendar_id,
"calendar_name": m.calendar.name if m.calendar else "",
"calendar_color": m.calendar.color if m.calendar else "",
"local_color": m.local_color,
"permission": m.permission,
"can_add_others": m.can_add_others,
"is_owner": False,
}
for m in members
]
# -- POST /{cal_id}/invite — Invite via connection_id ---------------------
@router.post("/{cal_id}/invite", status_code=201)
async def invite_member(
body: InviteMemberRequest,
request: Request,
cal_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Invite a connected user to a shared calendar."""
cal_result = await db.execute(
select(Calendar).where(Calendar.id == cal_id)
)
calendar = cal_result.scalar_one_or_none()
if not calendar:
raise HTTPException(status_code=404, detail="Calendar not found")
is_owner = calendar.user_id == current_user.id
inviter_perm = "owner" if is_owner else None
if not is_owner:
member_result = await db.execute(
select(CalendarMember).where(
CalendarMember.calendar_id == cal_id,
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Calendar not found")
if not member.can_add_others:
raise HTTPException(status_code=403, detail="You do not have permission to invite others")
if PERMISSION_RANK.get(member.permission, 0) < PERMISSION_RANK.get("create_modify", 0):
raise HTTPException(status_code=403, detail="Read-only members cannot invite others")
inviter_perm = member.permission
# Permission ceiling
if inviter_perm != "owner":
if PERMISSION_RANK.get(body.permission, 0) > PERMISSION_RANK.get(inviter_perm, 0):
raise HTTPException(
status_code=403,
detail="Cannot grant a permission level higher than your own",
)
# Resolve connection_id -> connected user
conn_result = await db.execute(
select(UserConnection).where(
UserConnection.id == body.connection_id,
UserConnection.user_id == current_user.id,
)
)
connection = conn_result.scalar_one_or_none()
if not connection:
raise HTTPException(status_code=404, detail="Connection not found")
target_user_id = connection.connected_user_id
if target_user_id == calendar.user_id:
raise HTTPException(status_code=400, detail="Cannot invite the calendar owner")
target_result = await db.execute(
select(User).where(User.id == target_user_id)
)
target = target_result.scalar_one_or_none()
if not target or not target.is_active:
raise HTTPException(status_code=404, detail="Target user not found or inactive")
existing = await db.execute(
select(CalendarMember).where(
CalendarMember.calendar_id == cal_id,
CalendarMember.user_id == target_user_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="User already invited or is a member")
pending_count = await db.scalar(
select(func.count())
.select_from(CalendarMember)
.where(
CalendarMember.calendar_id == cal_id,
CalendarMember.status == "pending",
)
) or 0
if pending_count >= PENDING_INVITE_CAP:
raise HTTPException(status_code=429, detail="Too many pending invites for this calendar")
if not calendar.is_shared:
calendar.is_shared = True
new_member = CalendarMember(
calendar_id=cal_id,
user_id=target_user_id,
invited_by=current_user.id,
permission=body.permission,
can_add_others=body.can_add_others,
status="pending",
)
db.add(new_member)
await db.flush()
inviter_settings = await _get_settings_for_user(db, current_user.id)
inviter_display = (inviter_settings.preferred_name if inviter_settings else None) or current_user.umbral_name
cal_name = calendar.name
await create_notification(
db,
user_id=target_user_id,
type="calendar_invite",
title="Calendar Invite",
message=f"{inviter_display} invited you to '{cal_name}'",
data={"calendar_id": cal_id, "calendar_name": cal_name},
source_type="calendar_invite",
source_id=new_member.id,
)
await log_audit_event(
db,
action="calendar.invite_sent",
actor_id=current_user.id,
target_id=target_user_id,
detail={
"calendar_id": cal_id,
"calendar_name": cal_name,
"permission": body.permission,
},
ip=get_client_ip(request),
)
response = {
"message": "Invite sent",
"member_id": new_member.id,
"calendar_id": cal_id,
}
await db.commit()
return response
# -- PUT /invites/{id}/respond — Accept or reject -------------------------
@router.put("/invites/{invite_id}/respond")
async def respond_to_invite(
body: RespondInviteRequest,
request: Request,
invite_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Accept or reject a calendar invite."""
result = await db.execute(
select(CalendarMember)
.where(
CalendarMember.id == invite_id,
CalendarMember.user_id == current_user.id,
CalendarMember.status == "pending",
)
.options(selectinload(CalendarMember.calendar))
)
invite = result.scalar_one_or_none()
if not invite:
raise HTTPException(status_code=404, detail="Invite not found or already resolved")
calendar_name = invite.calendar.name if invite.calendar else "Unknown"
calendar_owner_id = invite.calendar.user_id if invite.calendar else None
inviter_id = invite.invited_by
if body.action == "accept":
invite.status = "accepted"
invite.accepted_at = datetime.now()
notify_user_id = inviter_id or calendar_owner_id
if notify_user_id:
user_settings = await _get_settings_for_user(db, current_user.id)
display = (user_settings.preferred_name if user_settings else None) or current_user.umbral_name
await create_notification(
db,
user_id=notify_user_id,
type="calendar_invite_accepted",
title="Invite Accepted",
message=f"{display} accepted your invite to '{calendar_name}'",
data={"calendar_id": invite.calendar_id},
source_type="calendar_invite",
source_id=invite.id,
)
await log_audit_event(
db,
action="calendar.invite_accepted",
actor_id=current_user.id,
detail={"calendar_id": invite.calendar_id, "calendar_name": calendar_name},
ip=get_client_ip(request),
)
await db.commit()
return {"message": "Invite accepted"}
else:
member_id = invite.id
calendar_id = invite.calendar_id
notify_user_id = inviter_id or calendar_owner_id
if notify_user_id:
user_settings = await _get_settings_for_user(db, current_user.id)
display = (user_settings.preferred_name if user_settings else None) or current_user.umbral_name
await create_notification(
db,
user_id=notify_user_id,
type="calendar_invite_rejected",
title="Invite Rejected",
message=f"{display} declined your invite to '{calendar_name}'",
data={"calendar_id": calendar_id},
source_type="calendar_invite",
source_id=member_id,
)
await log_audit_event(
db,
action="calendar.invite_rejected",
actor_id=current_user.id,
detail={"calendar_id": calendar_id, "calendar_name": calendar_name},
ip=get_client_ip(request),
)
await db.delete(invite)
await db.commit()
return {"message": "Invite rejected"}
# -- GET /invites/incoming — Pending invites -------------------------------
@router.get("/invites/incoming", response_model=list[CalendarInviteResponse])
async def get_incoming_invites(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List pending calendar invites for the current user."""
offset = (page - 1) * per_page
result = await db.execute(
select(CalendarMember)
.where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "pending",
)
.options(
selectinload(CalendarMember.calendar),
selectinload(CalendarMember.inviter),
)
.order_by(CalendarMember.invited_at.desc())
.offset(offset)
.limit(per_page)
)
invites = result.scalars().all()
# Batch-fetch owner names to avoid N+1
owner_ids = list({inv.calendar.user_id for inv in invites if inv.calendar})
if owner_ids:
owner_result = await db.execute(
select(User.id, User.umbral_name).where(User.id.in_(owner_ids))
)
owner_names = {row.id: row.umbral_name for row in owner_result.all()}
else:
owner_names = {}
responses = []
for inv in invites:
owner_name = owner_names.get(inv.calendar.user_id, "") if inv.calendar else ""
responses.append(CalendarInviteResponse(
id=inv.id,
calendar_id=inv.calendar_id,
calendar_name=inv.calendar.name if inv.calendar else "",
calendar_color=inv.calendar.color if inv.calendar else "",
owner_umbral_name=owner_name,
inviter_umbral_name=inv.inviter.umbral_name if inv.inviter else "",
permission=inv.permission,
invited_at=inv.invited_at,
))
return responses
# -- GET /{cal_id}/members — Member list -----------------------------------
@router.get("/{cal_id}/members", response_model=list[CalendarMemberResponse])
async def list_members(
cal_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all members of a shared calendar. Requires membership or ownership."""
perm = await get_user_permission(db, cal_id, current_user.id)
if perm is None:
raise HTTPException(status_code=404, detail="Calendar not found")
result = await db.execute(
select(CalendarMember)
.where(CalendarMember.calendar_id == cal_id)
.options(selectinload(CalendarMember.user))
.order_by(CalendarMember.invited_at.asc())
)
members = result.scalars().all()
user_ids = [m.user_id for m in members]
if user_ids:
settings_result = await db.execute(
select(Settings.user_id, Settings.preferred_name)
.where(Settings.user_id.in_(user_ids))
)
pref_names = {row.user_id: row.preferred_name for row in settings_result.all()}
else:
pref_names = {}
responses = []
for m in members:
resp = _build_member_response(m)
resp["preferred_name"] = pref_names.get(m.user_id)
responses.append(resp)
return responses
# -- PUT /{cal_id}/members/{mid} — Update permission (owner only) ----------
@router.put("/{cal_id}/members/{member_id}")
async def update_member(
body: UpdateMemberRequest,
request: Request,
cal_id: int = Path(ge=1, le=2147483647),
member_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update a member permission or can_add_others. Owner only."""
cal_result = await db.execute(
select(Calendar).where(Calendar.id == cal_id, Calendar.user_id == current_user.id)
)
if not cal_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Only the calendar owner can update members")
member_result = await db.execute(
select(CalendarMember).where(
CalendarMember.id == member_id,
CalendarMember.calendar_id == cal_id,
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
update_data = body.model_dump(exclude_unset=True)
if not update_data:
raise HTTPException(status_code=400, detail="No fields to update")
for key, value in update_data.items():
setattr(member, key, value)
await log_audit_event(
db,
action="calendar.member_updated",
actor_id=current_user.id,
target_id=member.user_id,
detail={"calendar_id": cal_id, "member_id": member_id, "changes": update_data},
ip=get_client_ip(request),
)
await db.commit()
return {"message": "Member updated"}
# -- DELETE /{cal_id}/members/{mid} — Remove member or leave ---------------
@router.delete("/{cal_id}/members/{member_id}", status_code=204)
async def remove_member(
request: Request,
cal_id: int = Path(ge=1, le=2147483647),
member_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Remove a member or leave a shared calendar."""
member_result = await db.execute(
select(CalendarMember).where(
CalendarMember.id == member_id,
CalendarMember.calendar_id == cal_id,
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
cal_result = await db.execute(
select(Calendar).where(Calendar.id == cal_id)
)
calendar = cal_result.scalar_one_or_none()
is_self = member.user_id == current_user.id
is_owner = calendar and calendar.user_id == current_user.id
if not is_self and not is_owner:
raise HTTPException(status_code=403, detail="Only the calendar owner can remove other members")
target_user_id = member.user_id
await db.execute(
delete(EventLock).where(
EventLock.locked_by == target_user_id,
EventLock.event_id.in_(
select(CalendarEvent.id).where(CalendarEvent.calendar_id == cal_id)
),
)
)
await db.delete(member)
remaining = await db.execute(
select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1)
)
if not remaining.scalar_one_or_none() and calendar:
calendar.is_shared = False
action = "calendar.member_left" if is_self else "calendar.member_removed"
await log_audit_event(
db,
action=action,
actor_id=current_user.id,
target_id=target_user_id,
detail={"calendar_id": cal_id, "member_id": member_id},
ip=get_client_ip(request),
)
await db.commit()
return None
# -- PUT /{cal_id}/members/me/color — Update local color -------------------
@router.put("/{cal_id}/members/me/color")
async def update_local_color(
body: UpdateLocalColorRequest,
cal_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update the current user local color for a shared calendar."""
member_result = await db.execute(
select(CalendarMember).where(
CalendarMember.calendar_id == cal_id,
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Membership not found")
member.local_color = body.local_color
await db.commit()
return {"message": "Color updated"}
# -- GET /sync — Sync endpoint ---------------------------------------------
@router.get("/sync", response_model=SyncResponse)
async def sync_shared_calendars(
since: datetime = Query(...),
calendar_ids: Optional[str] = Query(None, description="Comma-separated calendar IDs"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Sync events and member changes since a given timestamp. Cap 500 events."""
MAX_EVENTS = 500
cal_id_list: list[int] = []
if calendar_ids:
for part in calendar_ids.split(","):
part = part.strip()
if part.isdigit():
cal_id_list.append(int(part))
cal_id_list = cal_id_list[:50] # Cap to prevent unbounded IN clause
owned_ids_result = await db.execute(
select(Calendar.id).where(Calendar.user_id == current_user.id)
)
owned_ids = {row[0] for row in owned_ids_result.all()}
member_ids_result = await db.execute(
select(CalendarMember.calendar_id).where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
)
member_ids = {row[0] for row in member_ids_result.all()}
accessible = owned_ids | member_ids
if cal_id_list:
accessible = accessible & set(cal_id_list)
if not accessible:
return SyncResponse(events=[], member_changes=[], server_time=datetime.now())
accessible_list = list(accessible)
events_result = await db.execute(
select(CalendarEvent)
.where(
CalendarEvent.calendar_id.in_(accessible_list),
CalendarEvent.updated_at >= since,
)
.options(selectinload(CalendarEvent.calendar))
.order_by(CalendarEvent.updated_at.desc())
.limit(MAX_EVENTS + 1)
)
events = events_result.scalars().all()
truncated = len(events) > MAX_EVENTS
events = events[:MAX_EVENTS]
event_dicts = []
for e in events:
event_dicts.append({
"id": e.id,
"title": e.title,
"start_datetime": e.start_datetime.isoformat() if e.start_datetime else None,
"end_datetime": e.end_datetime.isoformat() if e.end_datetime else None,
"all_day": e.all_day,
"calendar_id": e.calendar_id,
"calendar_name": e.calendar.name if e.calendar else "",
"updated_at": e.updated_at.isoformat() if e.updated_at else None,
"updated_by": e.updated_by,
})
members_result = await db.execute(
select(CalendarMember)
.where(
CalendarMember.calendar_id.in_(accessible_list),
(CalendarMember.invited_at >= since) | (CalendarMember.accepted_at >= since),
)
.options(selectinload(CalendarMember.user))
)
member_changes = members_result.scalars().all()
member_dicts = []
for m in member_changes:
member_dicts.append({
"id": m.id,
"calendar_id": m.calendar_id,
"user_id": m.user_id,
"umbral_name": m.user.umbral_name if m.user else "",
"permission": m.permission,
"status": m.status,
"invited_at": m.invited_at.isoformat() if m.invited_at else None,
"accepted_at": m.accepted_at.isoformat() if m.accepted_at else None,
})
return SyncResponse(
events=event_dicts,
member_changes=member_dicts,
server_time=datetime.now(),
truncated=truncated,
)
# -- Event Lock Endpoints --------------------------------------------------
@router.post("/events/{event_id}/lock")
async def lock_event(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Acquire a 5-minute editing lock on an event."""
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
lock = await acquire_lock(db, event_id, current_user.id)
# Build response BEFORE commit — ORM objects expire after commit
response = {
"locked": True,
"locked_by_name": current_user.umbral_name,
"expires_at": lock.expires_at,
"is_permanent": lock.is_permanent,
}
await db.commit()
return response
@router.delete("/events/{event_id}/lock", status_code=204)
async def unlock_event(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Release a lock. Only the holder or calendar owner can release."""
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
lock_result = await db.execute(
select(EventLock).where(EventLock.event_id == event_id)
)
lock = lock_result.scalar_one_or_none()
if not lock:
return None
cal_result = await db.execute(
select(Calendar).where(Calendar.id == event.calendar_id)
)
calendar = cal_result.scalar_one_or_none()
is_owner = calendar and calendar.user_id == current_user.id
if lock.locked_by != current_user.id and not is_owner:
raise HTTPException(status_code=403, detail="Only the lock holder or calendar owner can release")
await db.delete(lock)
await db.commit()
return None
@router.get("/events/{event_id}/lock", response_model=LockStatusResponse)
async def get_lock_status(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get the lock status of an event."""
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
perm = await get_user_permission(db, event.calendar_id, current_user.id)
if perm is None:
raise HTTPException(status_code=404, detail="Event not found")
lock_result = await db.execute(
select(EventLock)
.where(EventLock.event_id == event_id)
.options(selectinload(EventLock.holder))
)
lock = lock_result.scalar_one_or_none()
if not lock:
return LockStatusResponse(locked=False)
now = datetime.now()
if not lock.is_permanent and lock.expires_at and lock.expires_at < now:
await db.delete(lock)
await db.commit()
return LockStatusResponse(locked=False)
return LockStatusResponse(
locked=True,
locked_by_name=lock.holder.umbral_name if lock.holder else None,
expires_at=lock.expires_at,
is_permanent=lock.is_permanent,
)
@router.post("/events/{event_id}/owner-lock")
async def set_permanent_lock(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Set a permanent lock on an event. Calendar owner only."""
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
cal_result = await db.execute(
select(Calendar).where(Calendar.id == event.calendar_id, Calendar.user_id == current_user.id)
)
if not cal_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Only the calendar owner can set permanent locks")
now = datetime.now()
await db.execute(
text("""
INSERT INTO event_locks (event_id, locked_by, locked_at, expires_at, is_permanent)
VALUES (:event_id, :user_id, :now, NULL, true)
ON CONFLICT (event_id)
DO UPDATE SET
locked_by = :user_id,
locked_at = :now,
expires_at = NULL,
is_permanent = true
"""),
{"event_id": event_id, "user_id": current_user.id, "now": now},
)
await db.commit()
return {"message": "Permanent lock set", "is_permanent": True}
@router.delete("/events/{event_id}/owner-lock", status_code=204)
async def remove_permanent_lock(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Remove a permanent lock. Calendar owner only."""
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
cal_result = await db.execute(
select(Calendar).where(Calendar.id == event.calendar_id, Calendar.user_id == current_user.id)
)
if not cal_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Only the calendar owner can remove permanent locks")
await db.execute(
delete(EventLock).where(
EventLock.event_id == event_id,
EventLock.is_permanent == True,
)
)
await db.commit()
return None

View File

@ -0,0 +1,205 @@
"""
Calendar sharing service permission checks, lock management, disconnect cascade.
All functions accept an AsyncSession and do NOT commit callers manage transactions.
"""
import logging
from datetime import datetime, timedelta
from fastapi import HTTPException
from sqlalchemy import delete, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.calendar import Calendar
from app.models.calendar_member import CalendarMember
from app.models.event_lock import EventLock
logger = logging.getLogger(__name__)
PERMISSION_RANK = {"read_only": 1, "create_modify": 2, "full_access": 3}
LOCK_DURATION_MINUTES = 5
async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int) -> str | None:
"""
Returns "owner" if the user owns the calendar, the permission string
if they are an accepted member, or None if they have no access.
"""
cal = await db.execute(
select(Calendar).where(Calendar.id == calendar_id)
)
calendar = cal.scalar_one_or_none()
if not calendar:
return None
if calendar.user_id == user_id:
return "owner"
member = await db.execute(
select(CalendarMember).where(
CalendarMember.calendar_id == calendar_id,
CalendarMember.user_id == user_id,
CalendarMember.status == "accepted",
)
)
row = member.scalar_one_or_none()
return row.permission if row else None
async def require_permission(
db: AsyncSession, calendar_id: int, user_id: int, min_level: str
) -> str:
"""
Raises 403 if the user lacks at least min_level permission.
Returns the actual permission string (or "owner").
"""
perm = await get_user_permission(db, calendar_id, user_id)
if perm is None:
raise HTTPException(status_code=404, detail="Calendar not found")
if perm == "owner":
return "owner"
if PERMISSION_RANK.get(perm, 0) < PERMISSION_RANK.get(min_level, 0):
raise HTTPException(status_code=403, detail="Insufficient permission on this calendar")
return perm
async def acquire_lock(db: AsyncSession, event_id: int, user_id: int) -> EventLock:
"""
Atomic INSERT ON CONFLICT acquires a 5-minute lock on the event.
Only succeeds if no unexpired lock exists or the existing lock is held by the same user.
Returns the lock or raises 423 Locked.
"""
now = datetime.now()
expires = now + timedelta(minutes=LOCK_DURATION_MINUTES)
result = await db.execute(
text("""
INSERT INTO event_locks (event_id, locked_by, locked_at, expires_at, is_permanent)
VALUES (:event_id, :user_id, :now, :expires, false)
ON CONFLICT (event_id)
DO UPDATE SET
locked_by = :user_id,
locked_at = :now,
expires_at = :expires,
is_permanent = false
WHERE event_locks.expires_at < :now
OR event_locks.locked_by = :user_id
RETURNING id, event_id, locked_by, locked_at, expires_at, is_permanent
"""),
{"event_id": event_id, "user_id": user_id, "now": now, "expires": expires},
)
row = result.first()
if not row:
raise HTTPException(status_code=423, detail="Event is locked by another user")
lock_result = await db.execute(
select(EventLock).where(EventLock.id == row.id)
)
return lock_result.scalar_one()
async def release_lock(db: AsyncSession, event_id: int, user_id: int) -> None:
"""Delete the lock only if held by this user."""
await db.execute(
delete(EventLock).where(
EventLock.event_id == event_id,
EventLock.locked_by == user_id,
)
)
async def check_lock_for_edit(
db: AsyncSession, event_id: int, user_id: int, calendar_id: int
) -> None:
"""
For shared calendars: verify no active lock by another user blocks this edit.
For personal (non-shared) calendars: no-op.
"""
cal_result = await db.execute(
select(Calendar.is_shared).where(Calendar.id == calendar_id)
)
is_shared = cal_result.scalar_one_or_none()
if not is_shared:
return
lock_result = await db.execute(
select(EventLock).where(EventLock.event_id == event_id)
)
lock = lock_result.scalar_one_or_none()
if not lock:
return
now = datetime.now()
if lock.is_permanent and lock.locked_by != user_id:
raise HTTPException(status_code=423, detail="Event is permanently locked by the calendar owner")
if lock.locked_by != user_id and (lock.expires_at is None or lock.expires_at > now):
raise HTTPException(status_code=423, detail="Event is locked by another user")
async def cascade_on_disconnect(db: AsyncSession, user_a_id: int, user_b_id: int) -> None:
"""
When a connection is severed:
1. Delete CalendarMember rows where one user is a member of the other's calendars
2. Delete EventLock rows held by the disconnected user on affected calendars
3. Reset is_shared=False on calendars with no remaining members
"""
# Find calendars owned by each user
a_cal_ids_result = await db.execute(
select(Calendar.id).where(Calendar.user_id == user_a_id)
)
a_cal_ids = [row[0] for row in a_cal_ids_result.all()]
b_cal_ids_result = await db.execute(
select(Calendar.id).where(Calendar.user_id == user_b_id)
)
b_cal_ids = [row[0] for row in b_cal_ids_result.all()]
# Delete user_b's memberships on user_a's calendars + locks
if a_cal_ids:
await db.execute(
delete(CalendarMember).where(
CalendarMember.calendar_id.in_(a_cal_ids),
CalendarMember.user_id == user_b_id,
)
)
await db.execute(
text("""
DELETE FROM event_locks
WHERE locked_by = :user_id
AND event_id IN (
SELECT id FROM calendar_events WHERE calendar_id = ANY(:cal_ids)
)
"""),
{"user_id": user_b_id, "cal_ids": a_cal_ids},
)
# Delete user_a's memberships on user_b's calendars + locks
if b_cal_ids:
await db.execute(
delete(CalendarMember).where(
CalendarMember.calendar_id.in_(b_cal_ids),
CalendarMember.user_id == user_a_id,
)
)
await db.execute(
text("""
DELETE FROM event_locks
WHERE locked_by = :user_id
AND event_id IN (
SELECT id FROM calendar_events WHERE calendar_id = ANY(:cal_ids)
)
"""),
{"user_id": user_a_id, "cal_ids": b_cal_ids},
)
# Reset is_shared on calendars with no remaining members
all_cal_ids = a_cal_ids + b_cal_ids
for cal_id in all_cal_ids:
remaining = await db.execute(
select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1)
)
if not remaining.scalar_one_or_none():
cal_result = await db.execute(
select(Calendar).where(Calendar.id == cal_id)
)
cal = cal_result.scalar_one_or_none()
if cal:
cal.is_shared = False

View File

@ -4,6 +4,9 @@ limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=register_limit:10m rate=5r/m;
# Admin API generous for legitimate use but still guards against scraping/brute-force
limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=30r/m;
# Calendar sharing endpoints
limit_req_zone $binary_remote_addr zone=cal_invite_limit:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=cal_sync_limit:10m rate=15r/m;
# Connection endpoints prevent search enumeration and request spam
limit_req_zone $binary_remote_addr zone=conn_search_limit:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/m;
@ -99,6 +102,19 @@ server {
include /etc/nginx/proxy-params.conf;
}
# Calendar invite rate-limited to prevent invite spam
location ~ /api/shared-calendars/\d+/invite {
limit_req zone=cal_invite_limit burst=3 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# Calendar sync rate-limited to prevent excessive polling
location /api/shared-calendars/sync {
limit_req zone=cal_sync_limit burst=5 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# Admin API rate-limited separately from general /api traffic
location /api/admin/ {
limit_req zone=admin_limit burst=10 nodelay;