Skip to content
Merged
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
7 changes: 6 additions & 1 deletion buzz/api/events/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import frappe

from buzz.api.events import services
from buzz.api.events.schemas import MyEventsResponse
from buzz.api.events.schemas import CreatedEvent, MyEventsResponse, NewEvent


@frappe.whitelist()
def get_my_events() -> MyEventsResponse:
"""Events hosted by the session user's teams, plus events they hold a ticket to."""
return services.my_events()


@frappe.whitelist(methods=["POST"])
def create_event(event: NewEvent) -> CreatedEvent:
return services.create_event(event)
13 changes: 13 additions & 0 deletions buzz/api/events/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from frappe import _lt

from buzz.api.exceptions import BuzzAPIError, NotPermitted


class CannotCreateEvents(NotPermitted):
title = _lt("Not Permitted")
message = _lt("You cannot create events for this team.")


class ZoomNotAvailable(BuzzAPIError):
title = _lt("Zoom Not Available")
message = _lt("Zoom is not set up on this site, so a Zoom meeting cannot be created.")
23 changes: 22 additions & 1 deletion buzz/api/events/schemas.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from datetime import date, timedelta

from buzz.api.schemas import APIResponse
from buzz.api.schemas import APIRequest, APIResponse


class MyEvent(APIResponse):
Expand All @@ -21,3 +21,24 @@ class MyEvent(APIResponse):
class MyEventsResponse(APIResponse):
upcoming: list[MyEvent]
past: list[MyEvent]


class NewEvent(APIRequest):
team: str
title: str
start_date: date
start_time: timedelta
end_time: timedelta
end_date: date | None = None
about: str | None = None
banner_image: str | None = None
time_zone: str | None = None
venue: str | None = None
# Zoom cannot be booked before the event exists, so the dashboard asks for it here
# and the service books it once the event is saved.
zoom_meeting: bool = False


class CreatedEvent(APIResponse):
name: str
title: str
89 changes: 87 additions & 2 deletions buzz/api/events/services.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import frappe
from frappe import _
from frappe.model.naming import append_number_if_name_exists
from frappe.query_builder import Case
from frappe.utils import getdate

from buzz.api.events.schemas import MyEvent, MyEventsResponse
from buzz.permissions import my_teams
from buzz.api.events.exceptions import CannotCreateEvents, ZoomNotAvailable
from buzz.api.events.schemas import CreatedEvent, MyEvent, MyEventsResponse, NewEvent
from buzz.permissions import has_team_access, my_teams
from buzz.utils import is_app_installed


def my_events() -> MyEventsResponse:
Expand Down Expand Up @@ -63,3 +67,84 @@ def split_by_date(events: list[MyEvent]) -> tuple[list[MyEvent], list[MyEvent]]:
is_over = (event.end_date or event.start_date) < getdate()
(past if is_over else upcoming).append(event)
return upcoming, list(reversed(past))


# Buzz Event demands a category and a host, neither of which the create form asks for.
# These are the defaults it fills in; the organiser changes them on the event afterwards.
DEFAULT_CATEGORY = "Meetups"
# Zoom-backed, so the meeting the organiser asked for is the one the event gets.
ZOOM_CATEGORY = "Zoom Meeting"


def create_event(new: NewEvent) -> CreatedEvent:
"""Create an event for a team from the dashboard's create form."""
if not has_team_access(new.team, "create", frappe.session.user):
CannotCreateEvents.throw()

# Checked before the insert: a half-made event the organiser has to clean up is worse
# than a refusal.
if new.zoom_meeting and not is_app_installed("zoom_integration"):
ZoomNotAvailable.throw()

event = frappe.get_doc(
{
"doctype": "Buzz Event",
"team": new.team,
"title": new.title,
"start_date": new.start_date,
"end_date": new.end_date,
"start_time": new.start_time,
"end_time": new.end_time,
"about": new.about,
"banner_image": new.banner_image,
"time_zone": new.time_zone,
"venue": new.venue,
Comment thread
harshtandiya marked this conversation as resolved.
"medium": "Online" if new.zoom_meeting else "In Person",
"category": ZOOM_CATEGORY if new.zoom_meeting else DEFAULT_CATEGORY,
"host": host_for(new.team),
}
).insert()

if new.zoom_meeting:
book_zoom_meeting(event)

return CreatedEvent(name=str(event.name), title=event.title)


def book_zoom_meeting(event) -> None:
"""Book the Zoom meeting the organiser asked for, and keep the event either way.

`create_meeting_on_zoom` calls Zoom during the request and writes the meeting back
onto the event. Letting it raise would roll the insert back with it, so a Zoom outage
would cost the organiser the whole event rather than just the meeting; they can add
one from the event afterwards.
"""
try:
event.create_meeting_on_zoom()
except Exception:
frappe.log_error(title="Zoom meeting not created", reference_doctype="Buzz Event")
frappe.msgprint(
_("The event was created, but its Zoom meeting could not be. Add one from the event."),
title=_("Zoom Meeting Not Created"),
indicator="orange",
)


def host_for(team: str) -> str:
"""The team's own Event Host, made on first use.

Event Host is required on every event but absent from the create form, and a new team
has none. Host names are docnames and therefore global, so an existing name is given a
suffix rather than joined.
"""
existing = frappe.db.get_value("Event Host", {"team": team}, "name")
if existing:
return existing

team_name = frappe.db.get_value("Buzz Team", team, "team_name") or team
host = frappe.get_doc({"doctype": "Event Host", "name": team_name, "team": team})
host.name = append_number_if_name_exists("Event Host", team_name)
# Event Host is readable by the team but writable by Event Manager only, and creating
# an event is what mints it — the team check above is the authorisation.
host.insert(ignore_permissions=True)
return host.name
108 changes: 108 additions & 0 deletions buzz/api/events/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@
from frappe.tests import IntegrationTestCase
from frappe.utils import add_days, today

from buzz.api.events import create_event as create_event_endpoint
from buzz.api.events import get_my_events
from buzz.api.events.exceptions import CannotCreateEvents, ZoomNotAvailable
from buzz.api.events.schemas import NewEvent
from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user, payload_for
from buzz.test_permissions import add_member, create_ticket
from buzz.utils import is_app_installed


def create_event(title: str, team: str, **overrides) -> str:
Expand Down Expand Up @@ -174,3 +178,107 @@ def test_serializes_every_declared_field(self):
"team_logo",
},
)


class TestCreateEvent(IntegrationTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
frappe.set_user("Administrator")

if not frappe.db.exists("Event Category", "Meetups"):
frappe.get_doc({"doctype": "Event Category", "name": "Meetups"}).insert(ignore_permissions=True)

cls.owner = create_user("create-event-owner@example.com", "Owner")
cls.viewer = create_user("create-event-viewer@example.com", "Viewer")
cls.team = create_owned_team("Create Event Team", cls.owner)
add_member(cls.team, cls.viewer, "Viewer")

def setUp(self):
frappe.set_user(self.owner)
self.addCleanup(frappe.set_user, "Administrator")

def payload(self, **overrides) -> NewEvent:
return NewEvent(
**{
"team": self.team,
"title": "Frappeverse Mumbai",
"start_date": add_days(today(), 30),
"start_time": "09:00:00",
"end_time": "17:00:00",
**overrides,
}
)

def test_creates_an_event_the_team_owns(self):
created = create_event_endpoint(self.payload())

event = frappe.get_doc("Buzz Event", created.name)
self.assertEqual(created.title, "Frappeverse Mumbai")
self.assertEqual(event.team, self.team)
self.assertEqual(event.medium, "In Person")
self.assertEqual(event.category, "Meetups")

def test_mints_one_host_per_team_and_reuses_it(self):
first = frappe.get_doc("Buzz Event", create_event_endpoint(self.payload()).name)
second = frappe.get_doc("Buzz Event", create_event_endpoint(self.payload(title="Second")).name)

self.assertTrue(first.host)
self.assertEqual(first.host, second.host)
self.assertEqual(frappe.db.get_value("Event Host", first.host, "team"), self.team)

def test_carries_the_optional_fields_through(self):
created = create_event_endpoint(
self.payload(
end_date=add_days(today(), 31),
about="<p>Come along</p>",
time_zone="Asia/Kolkata",
)
)

event = frappe.get_doc("Buzz Event", created.name)
self.assertEqual(event.about, "<p>Come along</p>")
self.assertEqual(event.time_zone, "Asia/Kolkata")
# Derived on validate from the zone, so it proves the zone reached the document.
self.assertEqual(event.time_zone_label, "IST")

def test_a_viewer_cannot_create_events(self):
frappe.set_user(self.viewer)

with self.assertRaises(CannotCreateEvents):
create_event_endpoint(self.payload())

def test_a_non_member_cannot_create_events(self):
stranger = create_user("create-event-stranger@example.com", "Stranger")
frappe.set_user(stranger)

with self.assertRaises(CannotCreateEvents):
create_event_endpoint(self.payload())

def test_a_venue_from_another_team_is_refused(self):
"""The reported vector: a manager naming a venue that belongs to someone else.

Event Venue is autonamed by prompt, so another team's venue name is guessable,
and the booking confirmation reads the linked venue's address without a
permission check.
"""
stranger = create_user("create-event-venue-stranger@example.com", "Stranger")
their_team = create_owned_team("Create Event Other Team", stranger)
theirs = frappe.get_doc(
{
"doctype": "Event Venue",
"__newname": "Create Event Other Team Hall",
"address": "1 Test Street",
"team": their_team,
}
).insert(ignore_permissions=True)

with self.assertRaises(frappe.exceptions.ValidationError):
create_event_endpoint(self.payload(venue=str(theirs.name)))

def test_zoom_is_refused_when_the_app_is_missing(self):
if is_app_installed("zoom_integration"):
self.skipTest("zoom_integration is installed on this site")

with self.assertRaises(ZoomNotAvailable):
create_event_endpoint(self.payload(zoom_meeting=True))
16 changes: 16 additions & 0 deletions buzz/events/doctype/buzz_event/buzz_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,24 @@ def validate(self):
self.validate_tax_settings()
self.validate_guest_verification_config()
self.validate_custom_forms()
self.validate_venue_team()
self.set_time_zone_label()

def validate_venue_team(self):
"""A venue may only be linked by the team that owns it.

Nothing downstream re-checks this: the booking confirmation and the calendar
invite both read the linked venue's address without a permission check, so a
cross-team link publishes the other team's address.
"""
if not self.venue:
return

venue_team = frappe.db.get_value("Event Venue", self.venue, "team")
# An unstamped venue predates the team backfill; role permissions still gate it.
if venue_team and venue_team != self.team:
frappe.throw(_("Venue {0} belongs to another team.").format(self.venue))
Comment on lines +115 to +118

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security Unstamped venues bypass ownership

When a manager supplies the name of a legacy venue whose team is null or empty, this truthiness check accepts the venue and links it to the manager's event, causing its address to be exposed through booking details, booking confirmation, and calendar invitations.

How this was verified: The create API copies the supplied venue after authorizing only the event team, while downstream booking and calendar paths dereference the accepted venue without another ownership check.

Suggested change
venue_team = frappe.db.get_value("Event Venue", self.venue, "team")
# An unstamped venue predates the team backfill; role permissions still gate it.
if venue_team and venue_team != self.team:
frappe.throw(_("Venue {0} belongs to another team.").format(self.venue))
venue_team = frappe.db.get_value("Event Venue", self.venue, "team")
if venue_team != self.team:
frappe.throw(_("Venue {0} belongs to another team.").format(self.venue))

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: buzz/events/doctype/buzz_event/buzz_event.py
Line: 115-118

Comment:
**Unstamped venues bypass ownership**

When a manager supplies the name of a legacy venue whose `team` is null or empty, this truthiness check accepts the venue and links it to the manager's event, causing its address to be exposed through booking details, booking confirmation, and calendar invitations.

**How this was verified:** The create API copies the supplied venue after authorizing only the event team, while downstream booking and calendar paths dereference the accepted venue without another ownership check.

```suggestion
		venue_team = frappe.db.get_value("Event Venue", self.venue, "team")
		if venue_team != self.team:
			frappe.throw(_("Venue {0} belongs to another team.").format(self.venue))
```

**Knowledge Base Used:**
- [Event configuration and scheduling](https://app.greptile.com/bwh-tech/-/custom-context/knowledge-base/bwhtech/buzz/-/docs/event-configuration.md)
- [Frappe application integration](https://app.greptile.com/bwh-tech/-/custom-context/knowledge-base/bwhtech/buzz/-/docs/frappe-application-integration.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Valid catch — an unstamped venue is linkable by any team, which is the same leak narrowed to legacy rows. The justification in my comment was a bad analogy: has_team_access abstains on an unstamped row because role permissions still gate it, and nothing gates a venue link.

Not taking the suggestion in this PR, though. Dropping the truthiness check turns a data problem into a save failure — any site still holding such a row would find those events unsaveable, including edits that never touch the venue. The backfill should land first, then the guard tightens.

Tracked in #375, which also notes that buzz.patches.assign_default_team already covers Event Venue and is logged as applied, so the remaining work is mostly confirming it left nothing behind before the guard changes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

That’s a reasonable trade-off for this PR. The current check still protects already-stamped venues, while tightening it before the backfill is verified would make legacy records unsaveable—even on unrelated edits. #375 is the right follow-up: confirm assign_default_team left no unstamped venues, then change the guard to reject any venue whose team differs, including empty values.


def set_time_zone_label(self):
# validate runs before the mandatory check, so dates may still be empty here
if not (self.time_zone and self.start_date and self.start_time):
Expand Down
68 changes: 68 additions & 0 deletions buzz/events/doctype/buzz_event/test_buzz_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from buzz.api.booking.services import are_registrations_closed
from buzz.events.doctype.buzz_event.buzz_event import RESERVED_EVENT_ROUTES, create_from_template
from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user
from buzz.events.doctype.buzz_team_settings.test_buzz_team_settings import (
create_webinar_template,
set_team_settings,
Expand Down Expand Up @@ -158,6 +159,73 @@ def test_unreserved_route_is_accepted(self):
event.insert()
self.assertEqual(event.route, "my-conference-2026")

# ==================== Venue Team Tests ====================

def _make_team(self, team_name: str) -> str:
"""Per test, not per class: tearDown rolls back everything setUpClass inserts."""
owner = create_user("buzz-event-venue-owner@example.com", "Owner")
return create_owned_team(team_name, owner)

def _make_venue(self, name: str, team: str | None) -> str:
"""Event Venue is autonamed by prompt, so the docname is the venue's own name."""
venue = frappe.get_doc(
{
"doctype": "Event Venue",
"__newname": name,
"address": "1 Test Street",
"team": team,
}
).insert(ignore_permissions=True)
return str(venue.name)

def _make_event_with_venue(self, venue: str, team: str | None):
return frappe.get_doc(
{
"doctype": "Buzz Event",
"title": f"Venue Test Event {venue}",
"category": "Test Category",
"host": "Test Host",
"start_date": frappe.utils.today(),
"start_time": "09:00:00",
"end_time": "18:00:00",
"team": team,
"venue": venue,
}
)

def test_a_venue_from_another_team_is_rejected(self):
"""A venue carries its team's address, so linking one across teams leaks it.

Event Venue is autonamed by prompt, so the docname is the venue's own name and
therefore guessable; nothing else stops a manager naming another team's venue.
"""
team = self._make_team("Venue Test Team")
theirs = self._make_venue("Venue Test Other Team Hall", self._make_team("Venue Test Other Team"))
event = self._make_event_with_venue(theirs, team)

with self.assertRaises(frappe.exceptions.ValidationError):
event.validate_venue_team()

def test_the_teams_own_venue_is_accepted(self):
team = self._make_team("Venue Test Team")
ours = self._make_venue("Venue Test Own Hall", team)
event = self._make_event_with_venue(ours, team)

event.validate_venue_team()

def test_a_venue_without_a_team_is_accepted(self):
"""An unstamped venue predates the team backfill; role permissions still gate it.

Same convention as `has_team_access`, which abstains on an unstamped row rather
than refusing one.
"""
team = self._make_team("Venue Test Team")
unstamped = self._make_venue("Venue Test Unstamped Hall", team)
frappe.db.set_value("Event Venue", unstamped, "team", None)
event = self._make_event_with_venue(unstamped, team)

event.validate_venue_team()

# ==================== Create from Template Tests ====================

def test_create_from_template_copies_direct_fields(self):
Expand Down
Loading
Loading