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
738 changes: 668 additions & 70 deletions OPENAPI_DOC.yml

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions shard.lock
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,11 @@ shards:

office365:
git: https://github.com/placeos/office365.git
version: 1.26.0
version: 1.26.1

openssl_ext:
git: https://github.com/spider-gazelle/openssl_ext.git
version: 2.8.4
version: 2.8.5

pars:
git: https://github.com/spider-gazelle/pars.git
Expand Down
93 changes: 93 additions & 0 deletions spec/controllers/calendars_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,99 @@ describe Calendars do
bad_request = client.get(route, headers: headers).status_code
bad_request.should eq(400)
end

describe "#check_permission" do
it "should return owner role when checking own calendar" do
WebMock.stub(:post, "https://login.microsoftonline.com/bb89674a-238b-4b7d-91ec-6bebad83553a/oauth2/v2.0/token")
.to_return(body: File.read("./spec/fixtures/tokens/o365_token.json"))

route = "#{CALENDARS_BASE}/dev@acaprojects.onmicrosoft.com/permission"
response = client.get(route, headers: headers)
response.status_code.should eq(200)

body = JSON.parse(response.body)
body["has_access"].should eq(true)
body["role"].should eq("owner")
end

it "should return write access when user has write permission" do
WebMock.stub(:post, "https://login.microsoftonline.com/bb89674a-238b-4b7d-91ec-6bebad83553a/oauth2/v2.0/token")
.to_return(body: File.read("./spec/fixtures/tokens/o365_token.json"))
WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/pradeep%40domain.com/calendar/calendarPermissions")
.to_return(body: File.read("./spec/fixtures/calendars/o365/calendar_permissions_write_access.json"))

route = "#{CALENDARS_BASE}/pradeep@domain.com/permission"
response = client.get(route, headers: headers)
response.status_code.should eq(200)

body = JSON.parse(response.body)
body["has_access"].should eq(true)
body["role"].should eq("write")
end

it "should return delegate access when user has delegate permission" do
WebMock.stub(:post, "https://login.microsoftonline.com/bb89674a-238b-4b7d-91ec-6bebad83553a/oauth2/v2.0/token")
.to_return(body: File.read("./spec/fixtures/tokens/o365_token.json"))
WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/pradeep%40domain.com/calendar/calendarPermissions")
.to_return(body: File.read("./spec/fixtures/calendars/o365/calendar_permissions_delegate_access.json"))

route = "#{CALENDARS_BASE}/pradeep@domain.com/permission"
response = client.get(route, headers: headers)
response.status_code.should eq(200)

body = JSON.parse(response.body)
body["has_access"].should eq(true)
body["role"].should eq("delegateWithoutPrivateEventAccess")
end

it "should return no access when user has only read permission" do
WebMock.stub(:post, "https://login.microsoftonline.com/bb89674a-238b-4b7d-91ec-6bebad83553a/oauth2/v2.0/token")
.to_return(body: File.read("./spec/fixtures/tokens/o365_token.json"))
WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/pradeep%40domain.com/calendar/calendarPermissions")
.to_return(body: File.read("./spec/fixtures/calendars/o365/calendar_permissions_read_only.json"))

route = "#{CALENDARS_BASE}/pradeep@domain.com/permission"
response = client.get(route, headers: headers)
response.status_code.should eq(200)

body = JSON.parse(response.body)
body["has_access"].should eq(false)
body["role"].should eq("read")
end

it "should return no access when user is not in permissions list" do
WebMock.stub(:post, "https://login.microsoftonline.com/bb89674a-238b-4b7d-91ec-6bebad83553a/oauth2/v2.0/token")
.to_return(body: File.read("./spec/fixtures/tokens/o365_token.json"))
WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/pradeep%40domain.com/calendar/calendarPermissions")
.to_return(body: File.read("./spec/fixtures/calendars/o365/calendar_permissions_no_access.json"))

route = "#{CALENDARS_BASE}/pradeep@domain.com/permission"
response = client.get(route, headers: headers)
response.status_code.should eq(200)

body = JSON.parse(response.body)
body["has_access"].should eq(false)
body["role"].should eq("none")
end

it "should handle errors gracefully and return error role" do
WebMock.stub(:post, "https://login.microsoftonline.com/bb89674a-238b-4b7d-91ec-6bebad83553a/oauth2/v2.0/token")
.to_return(body: File.read("./spec/fixtures/tokens/o365_token.json"))
# Stub with both encoded and unencoded versions to ensure match
WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/pradeep%40domain.com/calendar/calendarPermissions")
.to_return(status: 500, body: "Internal Server Error")
WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/pradeep@domain.com/calendar/calendarPermissions")
.to_return(status: 500, body: "Internal Server Error")

route = "#{CALENDARS_BASE}/pradeep@domain.com/permission"
response = client.get(route, headers: headers)
response.status_code.should eq(200)

body = JSON.parse(response.body)
body["has_access"].should eq(false)
body["role"].should eq("error")
end
end
end

CALENDARS_BASE = Calendars.base_route
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('pradeep%40domain.com')/calendar/calendarPermissions",
"value": [
{
"id": "RGVsZWdhdGVk",
"role": "delegateWithoutPrivateEventAccess",
"allowedRoles": [
"freeBusyRead",
"limitedRead",
"read",
"write",
"delegateWithoutPrivateEventAccess"
],
"isRemovable": true,
"isInsideOrganization": true,
"emailAddress": {
"name": "Developer",
"address": "dev@acaprojects.onmicrosoft.com"
}
}
]
}
21 changes: 21 additions & 0 deletions spec/fixtures/calendars/o365/calendar_permissions_no_access.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('pradeep%40domain.com')/calendar/calendarPermissions",
"value": [
{
"id": "T3JnYW5pemF0aW9u",
"role": "freeBusyRead",
"allowedRoles": [
"freeBusyRead",
"limitedRead",
"read",
"write"
],
"isRemovable": false,
"isInsideOrganization": true,
"emailAddress": {
"name": "My Organization",
"address": ""
}
}
]
}
20 changes: 20 additions & 0 deletions spec/fixtures/calendars/o365/calendar_permissions_read_only.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('pradeep%40domain.com')/calendar/calendarPermissions",
"value": [
{
"id": "UmVhZE9ubHk",
"role": "read",
"allowedRoles": [
"freeBusyRead",
"limitedRead",
"read"
],
"isRemovable": true,
"isInsideOrganization": true,
"emailAddress": {
"name": "Developer",
"address": "dev@acaprojects.onmicrosoft.com"
}
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('pradeep%40domain.com')/calendar/calendarPermissions",
"value": [
{
"id": "RGVsZWdhdGVk",
"role": "write",
"allowedRoles": [
"freeBusyRead",
"limitedRead",
"read",
"write"
],
"isRemovable": true,
"isInsideOrganization": true,
"emailAddress": {
"name": "Developer",
"address": "dev@acaprojects.onmicrosoft.com"
}
}
]
}
43 changes: 42 additions & 1 deletion src/controllers/calendars.cr
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class Calendars < Application
current_tenant
end

@[AC::Route::Filter(:before_action, except: [:index])]
@[AC::Route::Filter(:before_action, except: [:index, :check_permission])]
private def find_matching_calendars(
@[AC::Param::Info(description: "a comma seperated list of calendar ids, recommend using `system_id` for resource calendars", example: "user@org.com,room2@resource.org.com")]
calendars : String? = nil,
Expand Down Expand Up @@ -46,6 +46,47 @@ class Calendars < Application
client.list_calendars(user.email)
end

record PermissionCheck, has_access : Bool, role : String do
include JSON::Serializable
end

# Check if current user has write access to specified user's calendar
@[AC::Route::GET("/:user_email/permission")]
def check_permission(
@[AC::Param::Info(description: "email or UPN of calendar owner", example: "foo@domain.com")]
user_email : String,
) : PermissionCheck
current_user_email = user.email.downcase
target_email = user_email.downcase

# User always has permission to their own calendar
if current_user_email == target_email
return PermissionCheck.new(has_access: true, role: "owner")
end

# Get Office365 client and call calendarPermissions API
if client.client_id == :office365
o365_client = client.calendar.as(PlaceCalendar::Office365).client
permissions_query = o365_client.list_calendar_permissions(target_email)

# Find current user in permissions list
user_permission = permissions_query.value.find do |perm|
perm.email_address.address.try(&.downcase) == current_user_email
end

if user_permission && user_permission.can_edit?
PermissionCheck.new(has_access: true, role: user_permission.role)
else
PermissionCheck.new(has_access: false, role: user_permission.try(&.role) || "none")
end
else
PermissionCheck.new(has_access: false, role: "unsupported")
end
rescue ex
Log.warn(exception: ex) { "failed to check calendar permission for #{target_email}" }
PermissionCheck.new(has_access: false, role: "error")
end

# checks for availability of matched calendars, returns a list of calendars with availability
@[AC::Route::GET("/availability")]
def availability(
Expand Down
26 changes: 25 additions & 1 deletion src/controllers/events.cr
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,31 @@
if host_email == service_account
return attendees.includes?(user_email)
end
!!get_user_calendars.find { |cal| cal.id.try(&.downcase) == host_email }

# Check if user has write access to the calendar (works for both Office365 and Google)
return true if get_user_calendars.find { |cal| cal.id.try(&.downcase) == host_email }

# Check calendar permissions via calendarPermissions API (Office365 only)
check_calendar_permission(user_email, host_email)
end

protected def check_calendar_permission(user_email : String, host_email : String) : Bool
# Only Office365 supports calendar permissions API
return true unless client.client_id == :office365

o365_client = client.calendar.as(PlaceCalendar::Office365).client
permissions_query = o365_client.list_calendar_permissions(host_email)

# Find current user in permissions list
user_permission = permissions_query.value.find do |perm|
perm.email_address.address.try(&.downcase) == user_email
end

user_permission ? user_permission.can_edit? : false
rescue ex
# If we can't check permissions due to an error (network, API timeout, etc.), log it and allow operation.
Log.warn(exception: ex) { "failed to check calendar permission for #{host_email}, allowing operation" }
true
end

# creates a new calendar event
Expand Down Expand Up @@ -1786,7 +1810,7 @@
end
result
else
clashing_system_ids = clashing_events.compact_map(&.system_id).uniq

Check warning on line 1813 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