Skip to content
Closed
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
126 changes: 126 additions & 0 deletions spec/controllers/events_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
44 changes: 33 additions & 11 deletions src/controllers/events.cr
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,21 @@
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)
Expand Down Expand Up @@ -134,7 +135,16 @@
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
Expand All @@ -143,7 +153,7 @@
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,
Expand All @@ -156,7 +166,7 @@
{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
Expand All @@ -166,7 +176,7 @@
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
Expand Down Expand Up @@ -310,6 +320,9 @@
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]?
Expand Down Expand Up @@ -1399,7 +1412,7 @@
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
Expand All @@ -1409,7 +1422,7 @@
# 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
Expand All @@ -1427,14 +1440,23 @@
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
Expand Down Expand Up @@ -1810,7 +1832,7 @@
end
result
else
clashing_system_ids = clashing_events.compact_map(&.system_id).uniq

Check warning on line 1835 in src/controllers/events.cr

View workflow job for this annotation

GitHub Actions / Ameba

Performance/ChainedCallWithNoBang

Use bang method variant `uniq!` after chained `compact_map` call
Raw output
> clashing_system_ids = clashing_events.compact_map(&.system_id).uniq
                                                                 ^

if return_available
clashing_system_ids = sys_ids - clashing_system_ids
Expand Down
Loading