From f4d575313be005288671fc3490bd5f93255f749b Mon Sep 17 00:00:00 2001 From: Ali Naqvi Date: Wed, 18 Mar 2026 16:41:28 +0800 Subject: [PATCH] feat: [PPT-2420] added check calendar permission endpoint --- OPENAPI_DOC.yml | 738 ++++++++++++++++-- shard.lock | 4 +- spec/controllers/calendars_spec.cr | 93 +++ .../calendar_permissions_delegate_access.json | 22 + .../o365/calendar_permissions_no_access.json | 21 + .../o365/calendar_permissions_read_only.json | 20 + .../calendar_permissions_write_access.json | 21 + src/controllers/calendars.cr | 43 +- src/controllers/events.cr | 26 +- 9 files changed, 914 insertions(+), 74 deletions(-) create mode 100644 spec/fixtures/calendars/o365/calendar_permissions_delegate_access.json create mode 100644 spec/fixtures/calendars/o365/calendar_permissions_no_access.json create mode 100644 spec/fixtures/calendars/o365/calendar_permissions_read_only.json create mode 100644 spec/fixtures/calendars/o365/calendar_permissions_write_access.json diff --git a/OPENAPI_DOC.yml b/OPENAPI_DOC.yml index 7372d00c..ed2c5e2e 100644 --- a/OPENAPI_DOC.yml +++ b/OPENAPI_DOC.yml @@ -3866,6 +3866,89 @@ paths: application/json: schema: $ref: '#/components/schemas/Application__CommonError' + /api/staff/v1/calendars/{user_email}/permission: + get: + summary: Check if current user has write access to specified user's calendar + tags: + - Calendars + operationId: Calendars_check_permission + parameters: + - name: user_email + in: path + description: email or UPN of calendar owner + example: foo@domain.com + required: true + schema: + type: string + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Calendars__PermissionCheck' + 429: + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 400: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 401: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 403: + description: Forbidden + 404: + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 511: + description: Network Authentication Required + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 406: + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/Application__ContentError' + 415: + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/Application__ContentError' + 422: + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/Application__ValidationError' + 500: + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 405: + description: Method Not Allowed + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' /api/staff/v1/calendars/availability: get: summary: checks for availability of matched calendars, returns a list of calendars @@ -7199,11 +7282,361 @@ paths: $ref: '#/components/schemas/Application__CommonError' /api/staff/v1/guests/{id}: get: - summary: returns the details of a particular guest and if they are expected - to attend in person today + summary: returns the details of a particular guest and if they are expected + to attend in person today + tags: + - Guests + operationId: Guests_show + parameters: + - name: id + in: path + description: looks up a guest using either their id or email + example: external@org.com + required: true + schema: + anyOf: + - type: integer + format: Int64 + - type: string + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Model__Guest' + 429: + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 400: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 401: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 403: + description: Forbidden + 404: + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 511: + description: Network Authentication Required + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 406: + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/Application__ContentError' + 415: + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/Application__ContentError' + 422: + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/Application__ValidationError' + 500: + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 405: + description: Method Not Allowed + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + put: + summary: patches a guest record with the changes provided + tags: + - Guests + operationId: Guests_update + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Model__Guest' + required: true + parameters: + - name: id + in: path + description: looks up a guest using either their id or email + example: external@org.com + required: true + schema: + anyOf: + - type: integer + format: Int64 + - type: string + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Model__Guest' + 429: + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 400: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 401: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 403: + description: Forbidden + 404: + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 511: + description: Network Authentication Required + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 406: + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/Application__ContentError' + 415: + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/Application__ContentError' + 422: + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/Application__ValidationError' + 500: + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 405: + description: Method Not Allowed + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + delete: + summary: removes the guest record from the database + tags: + - Guests + operationId: Guests_destroy + parameters: + - name: id + in: path + description: looks up a guest using either their id or email + example: external@org.com + required: true + schema: + anyOf: + - type: integer + format: Int64 + - type: string + responses: + 202: + description: Accepted + 429: + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 400: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 401: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 403: + description: Forbidden + 404: + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 511: + description: Network Authentication Required + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 406: + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/Application__ContentError' + 415: + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/Application__ContentError' + 422: + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/Application__ValidationError' + 500: + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 405: + description: Method Not Allowed + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + patch: + summary: patches a guest record with the changes provided + tags: + - Guests + operationId: Guests_update{2} + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Model__Guest' + required: true + parameters: + - name: id + in: path + description: looks up a guest using either their id or email + example: external@org.com + required: true + schema: + anyOf: + - type: integer + format: Int64 + - type: string + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Model__Guest' + 429: + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 400: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 401: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 403: + description: Forbidden + 404: + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 511: + description: Network Authentication Required + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 406: + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/Application__ContentError' + 415: + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/Application__ContentError' + 422: + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/Application__ValidationError' + 500: + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 405: + description: Method Not Allowed + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + /api/staff/v1/guests/{id}/meetings: + get: + summary: returns the meetings that the provided guest is attending today (approximation + based on internal records) tags: - Guests - operationId: Guests_show + operationId: Guests_meetings parameters: - name: id in: path @@ -7215,13 +7648,28 @@ paths: - type: integer format: Int64 - type: string + - name: include_past + in: query + description: shoule we include past events they have visited + example: "true" + schema: + type: boolean + - name: limit + in: query + description: how many results to return + example: "10" + schema: + type: integer + format: Int32 responses: 200: description: OK content: application/json: schema: - $ref: '#/components/schemas/PlaceOS__Model__Guest' + type: array + items: + $ref: '#/components/schemas/PlaceCalendar__Event' 429: description: Too Many Requests content: @@ -7284,17 +7732,13 @@ paths: application/json: schema: $ref: '#/components/schemas/Application__CommonError' - put: - summary: patches a guest record with the changes provided + /api/staff/v1/guests/{id}/bookings: + get: + summary: returns the list of bookings a guest is expected to or has attended + in person tags: - Guests - operationId: Guests_update - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PlaceOS__Model__Guest' - required: true + operationId: Guests_bookings parameters: - name: id in: path @@ -7306,13 +7750,28 @@ paths: - type: integer format: Int64 - type: string + - name: include_past + in: query + description: shoule we include past bookings + example: "true" + schema: + type: boolean + - name: limit + in: query + description: how many results to return + example: "10" + schema: + type: integer + format: Int32 responses: 200: description: OK content: application/json: schema: - $ref: '#/components/schemas/PlaceOS__Model__Guest' + type: array + items: + $ref: '#/components/schemas/PlaceOS__Model__Booking' 429: description: Too Many Requests content: @@ -7375,11 +7834,11 @@ paths: application/json: schema: $ref: '#/components/schemas/Application__CommonError' - delete: - summary: removes the guest record from the database + /api/staff/v1/guests/{id}/catering/menu: + get: tags: - Guests - operationId: Guests_destroy + operationId: Guests_catering_menu parameters: - name: id in: path @@ -7391,9 +7850,23 @@ paths: - type: integer format: Int64 - type: string + - name: booking_id + in: query + description: the booking id to obtain catering for + example: "32" + schema: + type: integer + format: Int64 + nullable: true responses: - 202: - description: Accepted + 200: + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Guests__Children' 429: description: Too Many Requests content: @@ -7456,17 +7929,11 @@ paths: application/json: schema: $ref: '#/components/schemas/Application__CommonError' - patch: - summary: patches a guest record with the changes provided + /api/staff/v1/guests/{id}/catering: + get: tags: - Guests - operationId: Guests_update{2} - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PlaceOS__Model__Guest' - required: true + operationId: Guests_catering parameters: - name: id in: path @@ -7478,13 +7945,21 @@ paths: - type: integer format: Int64 - type: string + - name: booking_id + in: query + description: the booking id to obtain catering for + example: "32" + schema: + type: integer + format: Int64 + nullable: true responses: 200: description: OK content: application/json: schema: - $ref: '#/components/schemas/PlaceOS__Model__Guest' + $ref: '#/components/schemas/Hash_String__JSON__Any_' 429: description: Too Many Requests content: @@ -7547,13 +8022,11 @@ paths: application/json: schema: $ref: '#/components/schemas/Application__CommonError' - /api/staff/v1/guests/{id}/meetings: - get: - summary: returns the meetings that the provided guest is attending today (approximation - based on internal records) + delete: + summary: remove catering selection from the specified users visit tags: - Guests - operationId: Guests_meetings + operationId: Guests_catering_destroy parameters: - name: id in: path @@ -7565,28 +8038,17 @@ paths: - type: integer format: Int64 - type: string - - name: include_past + - name: booking_id in: query - description: shoule we include past events they have visited - example: "true" - schema: - type: boolean - - name: limit - in: query - description: how many results to return - example: "10" + description: the booking id to remove the catering from + example: "32" schema: type: integer - format: Int32 + format: Int64 + nullable: true responses: - 200: - description: OK - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/PlaceCalendar__Event' + 202: + description: Accepted 429: description: Too Many Requests content: @@ -7649,13 +8111,16 @@ paths: application/json: schema: $ref: '#/components/schemas/Application__CommonError' - /api/staff/v1/guests/{id}/bookings: - get: - summary: returns the list of bookings a guest is expected to or has attended - in person + patch: tags: - Guests - operationId: Guests_bookings + operationId: Guests_catering_update + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Hash_String__JSON__Any_' + required: true parameters: - name: id in: path @@ -7667,28 +8132,21 @@ paths: - type: integer format: Int64 - type: string - - name: include_past - in: query - description: shoule we include past bookings - example: "true" - schema: - type: boolean - - name: limit + - name: booking_id in: query - description: how many results to return - example: "10" + description: the booking id to obtain catering for + example: "32" schema: type: integer - format: Int32 + format: Int64 + nullable: true responses: 200: description: OK content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/PlaceOS__Model__Booking' + $ref: '#/components/schemas/Hash_String__JSON__Any_' 429: description: Too Many Requests content: @@ -12095,6 +12553,16 @@ components: required: - summary - primary + Calendars__PermissionCheck: + type: object + properties: + has_access: + type: boolean + role: + type: string + required: + - has_access + - role Calendars__Availability: type: object properties: @@ -13435,6 +13903,136 @@ components: - id - email - username + Guests__Children: + type: object + properties: + zone: + type: object + properties: + created_at: + type: integer + format: Int64 + nullable: true + updated_at: + type: integer + format: Int64 + nullable: true + name: + type: string + nullable: true + description: + type: string + nullable: true + tags: + type: array + items: + type: string + nullable: true + location: + type: string + nullable: true + display_name: + type: string + nullable: true + code: + type: string + nullable: true + type: + type: string + nullable: true + count: + type: integer + format: Int32 + nullable: true + capacity: + type: integer + format: Int32 + nullable: true + map_id: + type: string + nullable: true + timezone: + type: object + nullable: true + triggers: + type: array + items: + type: string + nullable: true + images: + type: array + items: + type: string + nullable: true + playlists: + type: array + items: + type: string + nullable: true + place_id: + type: string + nullable: true + parent_id: + type: string + nullable: true + remove_triggers: + type: array + items: + type: string + nullable: true + add_triggers: + type: array + items: + type: string + nullable: true + id: + type: string + nullable: true + update_systems: + type: boolean + required: + - update_systems + metadata: + type: object + additionalProperties: + type: object + properties: + name: + type: string + description: + type: string + details: + type: object + parent_id: + type: string + nullable: true + schema_id: + type: string + nullable: true + editors: + type: array + items: + type: string + modified_by_id: + type: string + nullable: true + updated_at: + type: integer + format: Int64 + created_at: + type: integer + format: Int64 + required: + - name + - description + - details + - editors + - updated_at + - created_at + required: + - zone + - metadata + description: 'NOTE:: same as rest-api metadata Children' HealthCheck__BuildInfo: type: object properties: diff --git a/shard.lock b/shard.lock index 727fd66c..dc9b4997 100644 --- a/shard.lock +++ b/shard.lock @@ -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 diff --git a/spec/controllers/calendars_spec.cr b/spec/controllers/calendars_spec.cr index 58586f7b..c99bf7ec 100644 --- a/spec/controllers/calendars_spec.cr +++ b/spec/controllers/calendars_spec.cr @@ -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 diff --git a/spec/fixtures/calendars/o365/calendar_permissions_delegate_access.json b/spec/fixtures/calendars/o365/calendar_permissions_delegate_access.json new file mode 100644 index 00000000..96ec0e5d --- /dev/null +++ b/spec/fixtures/calendars/o365/calendar_permissions_delegate_access.json @@ -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" + } + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/calendars/o365/calendar_permissions_no_access.json b/spec/fixtures/calendars/o365/calendar_permissions_no_access.json new file mode 100644 index 00000000..8311c598 --- /dev/null +++ b/spec/fixtures/calendars/o365/calendar_permissions_no_access.json @@ -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": "" + } + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/calendars/o365/calendar_permissions_read_only.json b/spec/fixtures/calendars/o365/calendar_permissions_read_only.json new file mode 100644 index 00000000..077e3911 --- /dev/null +++ b/spec/fixtures/calendars/o365/calendar_permissions_read_only.json @@ -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" + } + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/calendars/o365/calendar_permissions_write_access.json b/spec/fixtures/calendars/o365/calendar_permissions_write_access.json new file mode 100644 index 00000000..b055374f --- /dev/null +++ b/spec/fixtures/calendars/o365/calendar_permissions_write_access.json @@ -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" + } + } + ] +} \ No newline at end of file diff --git a/src/controllers/calendars.cr b/src/controllers/calendars.cr index 805607fe..5b3c2d99 100644 --- a/src/controllers/calendars.cr +++ b/src/controllers/calendars.cr @@ -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, @@ -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( diff --git a/src/controllers/events.cr b/src/controllers/events.cr index 780dd289..0852a7fe 100644 --- a/src/controllers/events.cr +++ b/src/controllers/events.cr @@ -378,7 +378,31 @@ class Events < Application 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