From bbbb06cdc65202bb440eb93cec988685388ba441 Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Tue, 24 Mar 2026 12:05:35 +0930 Subject: [PATCH 1/2] test(events): [PPT-2247] public event access --- spec/controllers/events_spec.cr | 126 ++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/spec/controllers/events_spec.cr b/spec/controllers/events_spec.cr index c94b6d32..3087293f 100644 --- a/spec/controllers/events_spec.cr +++ b/spec/controllers/events_spec.cr @@ -1443,4 +1443,130 @@ describe Events, tags: ["event"] do clashing.should_not contain("sys-5678") end end + + describe "permission", tags: ["auth"] do + it "#index should not return PRIVATE events for unauthenticated users" do + WebMock.stub(:get, "#{ENV["PLACE_URI"]}/api/engine/v2/systems?limit=1000&offset=0&zone_id=z1") + .to_return(body: File.read("./spec/fixtures/placeos/systems.json")) + WebMock.stub(:post, "https://graph.microsoft.com/v1.0/%24batch") + .to_return(body: File.read("./spec/fixtures/events/o365/batch_index.json")) + + tenant = get_tenant + batch_ical_uid = "040000008200E00074C5B7101A82E008000000008CD0441F4E7FD60100000000000000001000000087A54520ECE5BD4AA552D826F3718E7F" + + # Create a PRIVATE event metadata matching the batch fixture + EventMetadatasHelper.create_event(tenant.id, + ical_uid: batch_ical_uid, + permission: PlaceOS::Model::EventMetadata::Permission::PRIVATE, + ) + + no_auth_headers = Mock::Headers.office365_no_auth + starting = 5.minutes.from_now.to_unix + ending = 90.minutes.from_now.to_unix + + # Unauthenticated user should NOT see PRIVATE events + response = client.get("#{EVENTS_BASE}?zone_ids=z1&period_start=#{starting}&period_end=#{ending}", headers: no_auth_headers) + (response.status_code < 300).should be_true + body = JSON.parse(response.body).as_a + body.size.should eq(0) + end + + it "#index should return PUBLIC events for unauthenticated users" do + WebMock.stub(:get, "#{ENV["PLACE_URI"]}/api/engine/v2/systems?limit=1000&offset=0&zone_id=z1") + .to_return(body: File.read("./spec/fixtures/placeos/systems.json")) + WebMock.stub(:post, "https://graph.microsoft.com/v1.0/%24batch") + .to_return(body: File.read("./spec/fixtures/events/o365/batch_index.json")) + + tenant = get_tenant + batch_ical_uid = "040000008200E00074C5B7101A82E008000000008CD0441F4E7FD60100000000000000001000000087A54520ECE5BD4AA552D826F3718E7F" + + # Create a PUBLIC event metadata matching the batch fixture + EventMetadatasHelper.create_event(tenant.id, + ical_uid: batch_ical_uid, + permission: PlaceOS::Model::EventMetadata::Permission::PUBLIC, + ) + + no_auth_headers = Mock::Headers.office365_no_auth + starting = 5.minutes.from_now.to_unix + ending = 90.minutes.from_now.to_unix + + # Unauthenticated user should see PUBLIC events + response = client.get("#{EVENTS_BASE}?zone_ids=z1&period_start=#{starting}&period_end=#{ending}", headers: no_auth_headers) + (response.status_code < 300).should be_true + body = JSON.parse(response.body).as_a + body.size.should eq(1) + end + + it "#index should require system_ids or zone_ids for unauthenticated users" do + get_tenant + + no_auth_headers = Mock::Headers.office365_no_auth + starting = 5.minutes.from_now.to_unix + ending = 90.minutes.from_now.to_unix + + response = client.get("#{EVENTS_BASE}?period_start=#{starting}&period_end=#{ending}", headers: no_auth_headers) + response.status_code.should eq(422) + end + + it "#show should allow unauthenticated access to PUBLIC events" do + event_id = "AAMkADE3YmQxMGQ2LTRmZDgtNDljYy1hNDg1LWM0NzFmMGI0ZTQ3YgBGAAAAAADFYQb3DJ_xSJHh14kbXHWhBwB08dwEuoS_QYSBDzuv558sAAAAAAENAAB08dwEuoS_QYSBDzuv558sAACGVOwUAAA=" + create_ical_uid = "040000008200E00074C5B7101A82E008000000006DE2E3761F8AD6010000000000000000100000009CCCDBB1F09DE74D8B157797D97F6A10" + + WebMock.stub(:get, /^https:\/\/graph\.microsoft\.com\/v1\.0\/users\/[^\/]*\/calendar\/events\/.*/) + .to_return(body: File.read("./spec/fixtures/events/o365/create.json")) + WebMock.stub(:get, /^https:\/\/graph\.microsoft\.com\/v1\.0\/users\/[^\/]*\/calendar\/calendarView\?.*/) + .to_return(EventsHelper.event_query_response(event_id, create_ical_uid)) + + tenant = get_tenant + + # Create PUBLIC event metadata matching the fixture + EventMetadatasHelper.create_event(tenant.id, + id: event_id, + ical_uid: create_ical_uid, + system_id: "sys-rJQQlR4Cn7", + room_email: "room1@example.com", + permission: PlaceOS::Model::EventMetadata::Permission::PUBLIC, + ) + + no_auth_headers = Mock::Headers.office365_no_auth + response = client.get("#{EVENTS_BASE}/#{event_id}?system_id=sys-rJQQlR4Cn7", headers: no_auth_headers) + response.status_code.should eq(200) + event = JSON.parse(response.body) + event["event_start"].should eq(1598503500) + event["event_end"].should eq(1598507160) + end + + it "#show should deny unauthenticated access to PRIVATE events" do + event_id = "AAMkADE3YmQxMGQ2LTRmZDgtNDljYy1hNDg1LWM0NzFmMGI0ZTQ3YgBGAAAAAADFYQb3DJ_xSJHh14kbXHWhBwB08dwEuoS_QYSBDzuv558sAAAAAAENAAB08dwEuoS_QYSBDzuv558sAACGVOwUAAA=" + create_ical_uid = "040000008200E00074C5B7101A82E008000000006DE2E3761F8AD6010000000000000000100000009CCCDBB1F09DE74D8B157797D97F6A10" + + WebMock.stub(:get, /^https:\/\/graph\.microsoft\.com\/v1\.0\/users\/[^\/]*\/calendar\/events\/.*/) + .to_return(body: File.read("./spec/fixtures/events/o365/create.json")) + WebMock.stub(:get, /^https:\/\/graph\.microsoft\.com\/v1\.0\/users\/[^\/]*\/calendar\/calendarView\?.*/) + .to_return(EventsHelper.event_query_response(event_id, create_ical_uid)) + + tenant = get_tenant + + # Create PRIVATE event metadata matching the fixture + EventMetadatasHelper.create_event(tenant.id, + id: event_id, + ical_uid: create_ical_uid, + system_id: "sys-rJQQlR4Cn7", + room_email: "room1@example.com", + permission: PlaceOS::Model::EventMetadata::Permission::PRIVATE, + ) + + no_auth_headers = Mock::Headers.office365_no_auth + response = client.get("#{EVENTS_BASE}/#{event_id}?system_id=sys-rJQQlR4Cn7", headers: no_auth_headers) + response.status_code.should eq(403) + end + + it "#show should require system_id for unauthenticated access" do + get_tenant + + no_auth_headers = Mock::Headers.office365_no_auth + response = client.get("#{EVENTS_BASE}/some-event-id", headers: no_auth_headers) + response.status_code.should eq(403) + end + end end From 609bbde6793e075c33ffbd538c90fe3ba1ae77f5 Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Tue, 24 Mar 2026 12:05:55 +0930 Subject: [PATCH 2/2] feat(events): [PPT-2247] public event access --- src/controllers/events.cr | 44 +++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/src/controllers/events.cr b/src/controllers/events.cr index 0852a7fe..fb735b6e 100644 --- a/src/controllers/events.cr +++ b/src/controllers/events.cr @@ -2,20 +2,21 @@ class Events < Application base "/api/staff/v1/events" # Skip scope check for relevant routes - skip_action :check_jwt_scope, only: [:show, :get_metadata, :patch_metadata, :guest_checkin, :add_attendee, :delete_attendee] + skip_action :check_jwt_scope, only: [:index, :show, :get_metadata, :patch_metadata, :guest_checkin, :add_attendee, :delete_attendee] # Skip actions that requres login # If a user is logged in then they will be run as part of # #set_tenant_from_domain - skip_action :determine_tenant_from_domain, only: [:add_attendee, :delete_attendee] + skip_action :determine_tenant_from_domain, only: [:index, :show, :add_attendee, :delete_attendee] # Set the tenant based on the domain # This allows unauthenticated requests through # (for public bookings, further checks are done later) - @[AC::Route::Filter(:before_action, only: [:add_attendee, :delete_attendee])] + @[AC::Route::Filter(:before_action, only: [:index, :show, :add_attendee, :delete_attendee])] private def set_tenant_from_domain if auth_token_present? - check_jwt_scope + # guest-scoped tokens have their own access controls, skip the scope check + check_jwt_scope unless user_token.guest_scope? determine_tenant_from_domain else domain = request.hostname.as?(String) @@ -134,7 +135,16 @@ class Events < Application period_start = Time.unix(starting) period_end = Time.unix(ending) - calendars = matching_calendar_ids(calendars, zone_ids, system_ids, allow_default: true) + # For unauthenticated (public) access, require system_ids or zone_ids + # and use the resource calendar identity for calendar API calls. + # Raw calendar IDs are ignored to prevent arbitrary calendar access. + unless auth_token_present? + raise AC::Route::Param::MissingError.new("system_ids or zone_ids required for public event listing", "system_ids", "String") unless system_ids.presence || zone_ids.presence + calendars = nil + end + user_email = user.email if auth_token_present? + + calendars = matching_calendar_ids(calendars, zone_ids, system_ids, allow_default: auth_token_present?) Log.context.set(calendar_size: calendars.size.to_s) return [] of PlaceCalendar::Event unless calendars.size > 0 @@ -143,7 +153,7 @@ class Events < Application requests = [] of HTTP::Request mappings = calendars.map { |calendar_id, system| request = client.list_events_request( - user.email, + user_email || calendar_id, calendar_id, period_start: period_start, period_end: period_end, @@ -156,7 +166,7 @@ class Events < Application {request, calendar_id, system} } - responses = client.batch(user.email, requests) + responses = client.batch(user_email || calendars.keys.first, requests) # Process the response (map requests back to responses) errors = 0 @@ -166,7 +176,7 @@ class Events < Application results = [] of Tuple(String, PlaceOS::Client::API::Models::System?, PlaceCalendar::Event) mappings.each do |(request, calendar_id, system)| begin - results.concat client.list_events(user.email, responses[request]).map { |event| {calendar_id, system, event} } + results.concat client.list_events(user_email || calendar_id, responses[request]).map { |event| {calendar_id, system, event} } rescue error (error.message =~ /MailboxConcurrency/i ? calendar_rate_limit : calendar_other_error) << calendar_id errors += 1 @@ -310,6 +320,9 @@ class Events < Application parent_meta = true if metadata end + # For unauthenticated requests, only return public events + next unless auth_token_present? || metadata.try(&.permission.public?) + if system.nil? if cal_id = event_resources[event.id.as(String)]? system = system_emails[cal_id]? @@ -1399,7 +1412,7 @@ class Events < Application event_id = original_id # Guest access - if user_token.guest_scope? + if auth_token_present? && user_token.guest_scope? guest_event_id, guest_system_id = user.roles system_id ||= guest_system_id raise Error::Forbidden.new("guest #{user_token.id} attempting to view an event they are not associated with") unless system_id == guest_system_id @@ -1409,7 +1422,7 @@ class Events < Application # Need to grab the calendar associated with this system system = placeos_client.systems.fetch(system_id) cal_id = system.email - user_email = user_token.guest_scope? ? cal_id : user.email + user_email = (!auth_token_present? || user_token.guest_scope?) ? cal_id : user.email raise AC::Route::Param::ValueError.new("system '#{system.name}' (#{system_id}) does not have a resource email address specified", "system_id") unless cal_id raise Error::InconsistentState.new("user_email must be present") unless user_email @@ -1427,14 +1440,23 @@ class Events < Application end end - if user_token.guest_scope? + if auth_token_present? && user_token.guest_scope? raise Error::Forbidden.new("guest #{user_token.id} attempting to view an event they are not associated with") unless guest_event_id.in?({original_id, event_id, event.recurring_event_id}) && system_id == guest_system_id end metadata = get_event_metadata(event, system_id, search_recurring: true) + + # For unauthenticated requests, only allow access to public events + unless auth_token_present? + raise Error::Forbidden.new("event is not publicly accessible") unless metadata.try(&.permission.public?) + end + parent_meta = !metadata.try &.for_event_instance?(event, client.client_id) StaffApi::Event.augment(event, cal_id, system, metadata, parent_meta) else + # Personal calendar access requires authentication + raise Error::Forbidden.new("system_id is required for public event access") unless auth_token_present? + # Need to confirm the user can access this calendar user_cal = user_cal.try &.downcase if user_cal == user.email