Compare commits
177
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37c26793f5 | ||
|
|
956f81fab3 | ||
|
|
1dcd54aab7 | ||
|
|
68d3d9d964 | ||
|
|
717a26f223 | ||
|
|
6818806c5e | ||
|
|
dd7f108f08 | ||
|
|
7e4d9e26c6 | ||
|
|
a7352cf587 | ||
|
|
5f8db0557c | ||
|
|
88be45457f | ||
|
|
90a42c37fd | ||
|
|
cf7430d531 | ||
|
|
105a82f97d | ||
|
|
fdd3d11111 | ||
|
|
e7ea1884b7 | ||
|
|
c04402e7ee | ||
|
|
075d209b6f | ||
|
|
26e8582302 | ||
|
|
9f277c5179 | ||
|
|
21411b8f93 | ||
|
|
0c160de404 | ||
|
|
a0eca13c18 | ||
|
|
5cda123a4c | ||
|
|
6169b92e98 | ||
|
|
cc1efe1298 | ||
|
|
7635d338c0 | ||
|
|
03ebabd6b4 | ||
|
|
0ab1bd0b6c | ||
|
|
908e5e05f2 | ||
|
|
3490272815 | ||
|
|
02dab51543 | ||
|
|
358b873aa7 | ||
|
|
44aa26292d | ||
|
|
b8530e2d78 | ||
|
|
7f1b94ad19 | ||
|
|
801ba50221 | ||
|
|
74af23136c | ||
|
|
da9ee5c186 | ||
|
|
037cb8ee2a | ||
|
|
f0e10129a1 | ||
|
|
7c5731e893 | ||
|
|
1012f2f153 | ||
|
|
d6f6be7d50 | ||
|
|
9e10dfd77b | ||
|
|
82782fcf77 | ||
|
|
2477220d2d | ||
|
|
89fe869766 | ||
|
|
1f102bf3b4 | ||
|
|
7a0b8a9eba | ||
|
|
8043505f3a | ||
|
|
919fc3cdd5 | ||
|
|
d7e6334ccb | ||
|
|
06e5c9803a | ||
|
|
528b27f31e | ||
|
|
4282cd221c | ||
|
|
38738c1a3a | ||
|
|
b5dcc7a991 | ||
|
|
5ceda11a0b | ||
|
|
787828d0ca | ||
|
|
89230474a5 | ||
|
|
d7fc11df5e | ||
|
|
1ff5d57ded | ||
|
|
19641ffc05 | ||
|
|
ea80e8c8d4 | ||
|
|
cba0fa8551 | ||
|
|
530cb94f0b | ||
|
|
d4ef6e83cf | ||
|
|
cddd8d7d9d | ||
|
|
1d6959c4ac | ||
|
|
a3fc17bc75 | ||
|
|
ad4b5957fc | ||
|
|
a7cb71726a | ||
|
|
3e7a848769 | ||
|
|
e6e67848b3 | ||
|
|
bf42cd4de9 | ||
|
|
6af7f8b0db | ||
|
|
421d03eb1a | ||
|
|
9d39742802 | ||
|
|
5267f8c3c1 | ||
|
|
c6ec42e735 | ||
|
|
1325e8488f | ||
|
|
d50a7b9d00 | ||
|
|
452729f4ab | ||
|
|
0206b364ee | ||
|
|
86353191d2 | ||
|
|
38f96d97d3 | ||
|
|
e6d44ce620 | ||
|
|
9e1e50e90b | ||
|
|
a73b804d48 | ||
|
|
4407a1952c | ||
|
|
dc3742d322 | ||
|
|
8b4f675cee | ||
|
|
53a931da4e | ||
|
|
228fbaf72e | ||
|
|
0c53717d0f | ||
|
|
e239b67171 | ||
|
|
fd390f9d43 | ||
|
|
eae779b81c | ||
|
|
6d621e0b60 | ||
|
|
ed3de528f3 | ||
|
|
92f1baa320 | ||
|
|
984cd64083 | ||
|
|
db8c7942a9 | ||
|
|
4e5d4f67d5 | ||
|
|
83488f0a35 | ||
|
|
9a52938f64 | ||
|
|
e24e1d8769 | ||
|
|
ef4be48ea6 | ||
|
|
5c9ad93923 | ||
|
|
85fe40688e | ||
|
|
829edec0ea | ||
|
|
81a99ea0b2 | ||
|
|
799e354059 | ||
|
|
584c991172 | ||
|
|
47e976df96 | ||
|
|
b44efddc0f | ||
|
|
bc665cbeab | ||
|
|
02b1393ff4 | ||
|
|
52e27bab53 | ||
|
|
45278a2195 | ||
|
|
2e8b89c4ee | ||
|
|
7d17b443cf | ||
|
|
c6ab6b3d13 | ||
|
|
c741d8133e | ||
|
|
b01cb27c56 | ||
|
|
c48f2043f2 | ||
|
|
630eee4fab | ||
|
|
1ebf553181 | ||
|
|
e8a315ac4e | ||
|
|
c196ff1095 | ||
|
|
bf0fbe4888 | ||
|
|
eb6418c0d5 | ||
|
|
2df2868b20 | ||
|
|
7f48c7fdad | ||
|
|
2101051623 | ||
|
|
a241cb0bfc | ||
|
|
5417012cec | ||
|
|
8e818e0567 | ||
|
|
1fdd04215d | ||
|
|
0bf0749b29 | ||
|
|
4006143e3a | ||
|
|
c6ad767565 | ||
|
|
896f8dbd3d | ||
|
|
f1db27c0ad | ||
|
|
3a1e04bb42 | ||
|
|
ab37847ea1 | ||
|
|
0a2ddf05ca | ||
|
|
04fc0f1d52 | ||
|
|
b76c4c9dcc | ||
|
|
68b71bfb0a | ||
|
|
763c568797 | ||
|
|
4659d08c07 | ||
|
|
b9c350cc45 | ||
|
|
f63d705523 | ||
|
|
2907c5e696 | ||
|
|
dcb47e31ed | ||
|
|
cd5b75fe2c | ||
|
|
97bdf837a9 | ||
|
|
5663b2ae59 | ||
|
|
02b57eb82e | ||
|
|
b23dfa194e | ||
|
|
2484b6fad2 | ||
|
|
c154bb31b7 | ||
|
|
0cf4ebd544 | ||
|
|
805c4d4c09 | ||
|
|
99330b3117 | ||
|
|
cc90a62090 | ||
|
|
79bcd6dc30 | ||
|
|
c6daa61e5e | ||
|
|
b693b6898a | ||
|
|
fb55bc2dcd | ||
|
|
71bd140fe4 | ||
|
|
4c5608147c | ||
|
|
7d16da5c98 | ||
|
|
0ae6e92558 | ||
|
|
052a38e39b |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
Fix Booker atom verify email dialog infinite re-render when entering verification code
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
Refactor SettingsHeader to accept onBackButtonClick callback instead of using useRouter directly.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
Add defaultPhoneCountry prop to BookerPlatformWrapper with ISO 3166-1 alpha-2 type safety
|
||||
@@ -1,6 +1,5 @@
|
||||
/**/package.json @calcom/Foundation
|
||||
/apps/api/**/* @calcom/API
|
||||
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/lib/csp.ts @calcom/Foundation
|
||||
/apps/web/lib/buildNonce.ts @calcom/Foundation
|
||||
|
||||
@@ -26,7 +26,7 @@ runs:
|
||||
with:
|
||||
path: ${{ inputs.path }}
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}
|
||||
- run: USE_POOL=true yarn db-seed
|
||||
- run: yarn db-seed
|
||||
if: steps.cache-db.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
- name: Postgres Dump Backup
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
GET /v2/event-types/{eventTypeId} added to the 'data' response property 'oneOf' list for the response status '200'
|
||||
GET /v2/event-types/{eventTypeId} added '#/components/schemas/EventTypeOutput_2024_06_14, #/components/schemas/TeamEventTypeOutput_2024_06_14' to the 'data' response property 'oneOf' list for the response status '200'
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
- name: Checkout GitHub Pages Branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: calcom/test-results
|
||||
repository: calcom/test-results-2
|
||||
ref: gh-pages
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
- name: Checkout GitHub Pages Branch
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: calcom/test-results
|
||||
repository: calcom/test-results-2
|
||||
ref: gh-pages
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
- name: Set Git User
|
||||
|
||||
@@ -72,18 +72,22 @@ jobs:
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/cache-db
|
||||
|
||||
|
||||
- name: Generate Swagger
|
||||
working-directory: apps/api/v2
|
||||
run: yarn generate-swagger
|
||||
|
||||
- name: Check breaking changes
|
||||
uses: oasdiff/oasdiff-action/breaking@main
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/calcom/cal.com/refs/heads/main/docs/api-reference/v2/openapi.json
|
||||
revision: docs/api-reference/v2/openapi.json
|
||||
fail-on: ERR
|
||||
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$PWD:/work" -w /work \
|
||||
tufin/oasdiff:latest \
|
||||
breaking \
|
||||
https://raw.githubusercontent.com/calcom/cal.com/refs/heads/main/docs/api-reference/v2/openapi.json \
|
||||
docs/api-reference/v2/openapi.json \
|
||||
--fail-on ERR \
|
||||
--err-ignore .github/oasdiff-err-ignore.txt
|
||||
|
||||
- name: Run Tests
|
||||
working-directory: apps/api/v2
|
||||
run: |
|
||||
@@ -91,7 +95,7 @@ jobs:
|
||||
EXIT_CODE=$?
|
||||
echo "yarn workspace @calcom/platform-libraries build && yarn test:e2e command exit code: $EXIT_CODE"
|
||||
exit $EXIT_CODE
|
||||
|
||||
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -5,6 +5,7 @@ permissions:
|
||||
contents: read
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
INTEGRATION_TESTS: "true"
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Checkout GitHub Pages Branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: calcom/test-results
|
||||
repository: calcom/test-results-2
|
||||
ref: gh-pages
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
- name: Set Git User
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
- name: Output Report URL as Workflow Annotation
|
||||
id: url
|
||||
run: |
|
||||
FULL_HTML_REPORT_URL=https://calcom.github.io/test-results/$HTML_REPORT_URL_PATH
|
||||
FULL_HTML_REPORT_URL=https://calcom.github.io/test-results-2/$HTML_REPORT_URL_PATH
|
||||
echo "::notice title=📋 Published Playwright Test Report::$FULL_HTML_REPORT_URL"
|
||||
echo "link=$FULL_HTML_REPORT_URL" >> $GITHUB_OUTPUT
|
||||
- name: Find Comment
|
||||
|
||||
Vendored
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
Vendored
Vendored
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
Vendored
Vendored
@@ -57,7 +57,7 @@ export const eventTypeSelect = {
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: { username: true, id: true },
|
||||
select: { username: true, id: true, timeZone: true },
|
||||
},
|
||||
children: {
|
||||
select: {
|
||||
|
||||
@@ -16,13 +16,15 @@ export const isAdminGuard = async (req: NextApiRequest) => {
|
||||
where: {
|
||||
userId: userId,
|
||||
accepted: true,
|
||||
role: {
|
||||
in: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
},
|
||||
team: {
|
||||
isOrganization: true,
|
||||
organizationSettings: {
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
},
|
||||
OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }],
|
||||
},
|
||||
select: {
|
||||
team: {
|
||||
|
||||
@@ -205,7 +205,8 @@ export class BookingsController_2024_04_15 {
|
||||
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
|
||||
const { orgSlug, locationUrl } = body;
|
||||
try {
|
||||
await this.checkBookingRequiresAuthentication(req, body.eventTypeId);
|
||||
await this.checkBookingRequiresAuthentication(req, body.eventTypeId, body.rescheduleUid);
|
||||
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
|
||||
const booking = await this.regularBookingService.createBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
@@ -459,7 +460,25 @@ export class BookingsController_2024_04_15 {
|
||||
return oAuthClientParams.platformClientId;
|
||||
}
|
||||
|
||||
private async checkBookingRequiresAuthentication(req: Request, eventTypeId: number): Promise<void> {
|
||||
private async isValidRescheduleBooking(rescheduleUid: string, eventTypeId: number): Promise<boolean> {
|
||||
const { bookingInfo } = await getBookingInfo(rescheduleUid);
|
||||
if (!bookingInfo) {
|
||||
return false;
|
||||
}
|
||||
if (bookingInfo.status !== "ACCEPTED" && bookingInfo.status !== "PENDING") {
|
||||
return false;
|
||||
}
|
||||
if (bookingInfo.eventTypeId !== eventTypeId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async checkBookingRequiresAuthentication(
|
||||
req: Request,
|
||||
eventTypeId: number,
|
||||
rescheduleUid?: string
|
||||
): Promise<void> {
|
||||
const eventType = await this.eventTypeRepository.findByIdIncludeHostsAndTeamMembers({
|
||||
id: eventTypeId,
|
||||
});
|
||||
@@ -468,6 +487,17 @@ export class BookingsController_2024_04_15 {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rescheduleUid) {
|
||||
const isValidRescheduleBooking = await this.isValidRescheduleBooking(rescheduleUid, eventTypeId);
|
||||
if (isValidRescheduleBooking) {
|
||||
return;
|
||||
} else {
|
||||
throw new BadRequestException(
|
||||
"Trying to reschedule an event-type which requires authentication but provided invalid rescheduleUid."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userId = await this.getOwnerId(req);
|
||||
|
||||
if (!userId) {
|
||||
@@ -478,8 +508,7 @@ export class BookingsController_2024_04_15 {
|
||||
|
||||
const isEventTypeOwner = eventType.userId === userId;
|
||||
const isHost = eventType.hosts.some((host) => host.userId === userId);
|
||||
const isTeamAdminOrOwner =
|
||||
eventType.team?.members.some((member) => member.userId === userId) ?? false;
|
||||
const isTeamAdminOrOwner = eventType.team?.members.some((member) => member.userId === userId) ?? false;
|
||||
|
||||
let isOrgAdminOrOwner = false;
|
||||
if (eventType.team?.parentId) {
|
||||
|
||||
@@ -194,11 +194,6 @@ export class CreateBookingInput_2024_04_15 {
|
||||
@ApiHideProperty()
|
||||
skipContactOwner?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@ApiHideProperty()
|
||||
_shouldServeCache?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@ApiHideProperty()
|
||||
|
||||
@@ -189,7 +189,7 @@ export class BookingsController_2024_08_13 {
|
||||
2. uid of one of the recurring booking recurrences
|
||||
|
||||
3. uid of recurring booking which will return an array of all recurring booking recurrences (stored as recurringBookingUid on one of the individual recurrences).
|
||||
|
||||
|
||||
If you are fetching a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
||||
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
|
||||
|
||||
@@ -213,7 +213,7 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Get all the recordings for the booking",
|
||||
description: `Fetches all the recordings for the booking \`:bookingUid\`
|
||||
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@@ -229,9 +229,9 @@ export class BookingsController_2024_08_13 {
|
||||
@Get("/:bookingUid/transcripts")
|
||||
@UseGuards(BookingUidGuard)
|
||||
@ApiOperation({
|
||||
summary: "Get all the transcripts download links for the booking",
|
||||
summary: "Get Cal Video real time transcript download links for the booking",
|
||||
description: `Fetches all the transcripts download links for the booking \`:bookingUid\`
|
||||
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@@ -279,7 +279,7 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Reschedule a booking",
|
||||
description: `Reschedule a booking or seated booking
|
||||
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@@ -321,7 +321,7 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Cancel a booking",
|
||||
description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.
|
||||
|
||||
|
||||
\nCancelling normal bookings:
|
||||
If the booking is not seated and not recurring, simply pass :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and optionally cancellationReason in the request body \`{"cancellationReason": "Will travel"}\`.
|
||||
|
||||
@@ -329,11 +329,11 @@ export class BookingsController_2024_08_13 {
|
||||
It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.
|
||||
\n1. As an attendee - provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and seatUid in the request body \`{"seatUid": "123-123-123"}\` . This will remove this particular attendance from the booking.
|
||||
\n2. As the host or org admin of host - host can cancel booking for all attendees aka for every seat, this also applies to org admins. Provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and cancellationReason in the request body \`{"cancellationReason": "Will travel"}\` and \`Authorization: Bearer token\` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.
|
||||
|
||||
|
||||
\nCancelling recurring seated bookings:
|
||||
For recurring seated bookings it is not possible to cancel all of them with 1 call
|
||||
like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.
|
||||
|
||||
|
||||
If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
||||
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
|
||||
|
||||
@@ -374,7 +374,7 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Mark a booking absence",
|
||||
description: `The provided authorization header refers to the owner of the booking.
|
||||
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@@ -399,15 +399,15 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Reassign a booking to auto-selected host",
|
||||
description: `Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.
|
||||
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
async reassignBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@GetUser() user: ApiAuthGuardUser
|
||||
@GetUser() reassignedByUser: ApiAuthGuardUser
|
||||
): Promise<ReassignBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.reassignBooking(bookingUid, user);
|
||||
const booking = await this.bookingsService.reassignBooking(bookingUid, reassignedByUser);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -423,20 +423,20 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Reassign a booking to a specific host",
|
||||
description: `Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.
|
||||
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
async reassignBookingToUser(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Param("userId") userId: number,
|
||||
@GetUser("id") reassignedById: number,
|
||||
@GetUser() reassignedByUser: ApiAuthGuardUser,
|
||||
@Body() body: ReassignToUserBookingInput_2024_08_13
|
||||
): Promise<ReassignBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.reassignBookingToUser(
|
||||
bookingUid,
|
||||
userId,
|
||||
reassignedById,
|
||||
reassignedByUser,
|
||||
body
|
||||
);
|
||||
|
||||
@@ -454,7 +454,7 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Confirm a booking",
|
||||
description: `The provided authorization header refers to the owner of the booking.
|
||||
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@@ -478,8 +478,8 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Decline a booking",
|
||||
description: `The provided authorization header refers to the owner of the booking.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
async declineBooking(
|
||||
@@ -502,8 +502,8 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Get 'Add to Calendar' links for a booking",
|
||||
description: `Retrieve calendar links for a booking that can be used to add the event to various calendar services. Returns links for Google Calendar, Microsoft Office, Microsoft Outlook, and a downloadable ICS file.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
+18
-17
@@ -12,6 +12,7 @@ import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
@@ -20,7 +21,6 @@ import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repo
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import {
|
||||
@@ -72,6 +72,7 @@ type EmailSetup = {
|
||||
team: Team;
|
||||
member1: User;
|
||||
member2: User;
|
||||
member1ApiKey: string;
|
||||
collectiveEventType: { id: number };
|
||||
roundRobinEventType: { id: number };
|
||||
};
|
||||
@@ -89,25 +90,21 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let hostsRepositoryFixture: HostsRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
|
||||
// Setup data for tests
|
||||
let emailsEnabledSetup: EmailSetup;
|
||||
let emailsDisabledSetup: EmailSetup;
|
||||
|
||||
const authEmail = "team-emails-2024-08-13-user-admin@example.com";
|
||||
|
||||
// Utility function to check response data type
|
||||
const responseDataIsBooking = (data: any): data is BookingOutput_2024_08_13 => {
|
||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||
const responseDataIsBooking = (data: unknown): data is BookingOutput_2024_08_13 => {
|
||||
return !Array.isArray(data) && data !== null && typeof data === "object" && data && "id" in data;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
authEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
@@ -122,6 +119,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
// Create a base organization for all tests
|
||||
@@ -131,11 +129,6 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
emailsEnabledSetup = await setupTestEnvironment(true);
|
||||
emailsDisabledSetup = await setupTestEnvironment(false);
|
||||
|
||||
await userRepositoryFixture.create({
|
||||
email: authEmail,
|
||||
organization: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
@@ -173,10 +166,15 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
const collectiveEvent = await createEventType("COLLECTIVE", team.id, [member1.id, member2.id]);
|
||||
const roundRobinEvent = await createEventType("ROUND_ROBIN", team.id, [member1.id, member2.id]);
|
||||
|
||||
// Create API key for member1 to use in authorized tests
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(member1.id, null);
|
||||
const member1ApiKey = `cal_test_${keyString}`;
|
||||
|
||||
return {
|
||||
team,
|
||||
member1,
|
||||
member2,
|
||||
member1ApiKey,
|
||||
collectiveEventType: { id: collectiveEvent.id },
|
||||
roundRobinEventType: { id: roundRobinEvent.id },
|
||||
};
|
||||
@@ -351,6 +349,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
: emailsDisabledSetup.member1.id;
|
||||
const manualReassignResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${rescheduledBookingUid}/reassign/${reassignToId}`)
|
||||
.set("Authorization", `Bearer ${emailsDisabledSetup.member1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(manualReassignResponse.status).toBe(200);
|
||||
@@ -359,6 +358,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
// --- 4. Automatic Reassign ---
|
||||
const autoReassignResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${rescheduledBookingUid}/reassign`)
|
||||
.set("Authorization", `Bearer ${emailsDisabledSetup.member1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(autoReassignResponse.status).toBe(200);
|
||||
@@ -484,6 +484,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
|
||||
const manualReassignResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${rescheduledBookingUid}/reassign/${reassignToId}`)
|
||||
.set("Authorization", `Bearer ${emailsEnabledSetup.member1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(manualReassignResponse.status).toBe(200);
|
||||
@@ -499,6 +500,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
jest.clearAllMocks();
|
||||
const autoReassignResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${rescheduledBookingUid}/reassign`)
|
||||
.set("Authorization", `Bearer ${emailsEnabledSetup.member1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(autoReassignResponse.status).toBe(200);
|
||||
@@ -523,7 +525,6 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
afterAll(async () => {
|
||||
// Clean up database records
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await userRepositoryFixture.deleteByEmail(authEmail);
|
||||
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member1.email);
|
||||
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member2.email);
|
||||
await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.member1.email);
|
||||
|
||||
+67
-11
@@ -1,6 +1,7 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
|
||||
import { BOOKING_REASSIGN_PERMISSION_ERROR } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
@@ -11,6 +12,7 @@ import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
|
||||
@@ -21,15 +23,11 @@ import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repo
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import type { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||
import type { Booking, User, PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
describe("Reassign bookings", () => {
|
||||
let app: INestApplication;
|
||||
@@ -47,6 +45,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let hostsRepositoryFixture: HostsRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
|
||||
const teamUserEmail = `reassign-bookings-2024-08-13-user1-${randomString()}@api.com`;
|
||||
const teamUserEmail2 = `reassign-bookings-2024-08-13-user2-${randomString()}@api.com`;
|
||||
@@ -54,6 +53,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let teamUser1: User;
|
||||
let teamUser2: User;
|
||||
let teamUser3: User;
|
||||
let teamUser1ApiKey: string;
|
||||
let teamUser2ApiKey: string;
|
||||
|
||||
let teamRoundRobinEventTypeId: number;
|
||||
let teamRoundRobinFixedHostEventTypeId: number;
|
||||
@@ -69,12 +70,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let rescheduleReasonBookingInitialHostId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
teamUserEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
@@ -90,6 +88,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
organization = await organizationsRepositoryFixture.create({
|
||||
@@ -126,6 +125,12 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
name: `reassign-bookings-2024-08-13-user3-${randomString()}`,
|
||||
});
|
||||
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(teamUser1.id, null);
|
||||
teamUser1ApiKey = `cal_test_${keyString}`;
|
||||
|
||||
const { keyString: keyString2 } = await apiKeysRepositoryFixture.createApiKey(teamUser2.id, null);
|
||||
teamUser2ApiKey = `cal_test_${keyString2}`;
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: `reassign-bookings-2024-08-13-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
@@ -473,6 +478,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign`)
|
||||
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -512,6 +518,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser1.id}`)
|
||||
.send(body)
|
||||
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -562,6 +569,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingUid}/reassign`)
|
||||
.set("Authorization", `Bearer ${teamUser2ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -611,6 +619,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingUid}/reassign/${teamUser3.id}`)
|
||||
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -669,6 +678,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingUid}/reassign/${reassignToHostId}`)
|
||||
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -704,6 +714,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingUid}/reassign`)
|
||||
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -724,6 +735,51 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 403 when unauthorized user tries to reassign booking", async () => {
|
||||
const unauthorizedUserEmail = `fake-user-${randomString()}@api.com`;
|
||||
const unauthorizedUser = await userRepositoryFixture.create({
|
||||
email: unauthorizedUserEmail,
|
||||
locale: "en",
|
||||
name: `fake-user-${randomString()}`,
|
||||
});
|
||||
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
|
||||
const unauthorizedApiKeyString = `cal_test_${keyString}`;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign`)
|
||||
.set("Authorization", `Bearer ${unauthorizedApiKeyString}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(403);
|
||||
|
||||
expect(response.body.error.message).toBe(BOOKING_REASSIGN_PERMISSION_ERROR);
|
||||
|
||||
await userRepositoryFixture.deleteByEmail(unauthorizedUserEmail);
|
||||
});
|
||||
|
||||
it("should return 403 when unauthorized user tries to reassign booking to specific user", async () => {
|
||||
const unauthorizedUserEmail = `fake-user-${randomString()}@api.com`;
|
||||
const unauthorizedUser = await userRepositoryFixture.create({
|
||||
email: unauthorizedUserEmail,
|
||||
locale: "en",
|
||||
name: `fake-user-${randomString()}`,
|
||||
});
|
||||
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
|
||||
const unauthorizedApiKeyString = `cal_test_${keyString}`;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser2.id}`)
|
||||
.send({ reason: "Testing unauthorized access" })
|
||||
.set("Authorization", `Bearer ${unauthorizedApiKeyString}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(403);
|
||||
|
||||
expect(response.body.error.message).toBe(BOOKING_REASSIGN_PERMISSION_ERROR);
|
||||
|
||||
await userRepositoryFixture.deleteByEmail(unauthorizedUserEmail);
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
@@ -749,4 +805,4 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,6 +72,17 @@ export class BookingsRepository_2024_08_13 {
|
||||
});
|
||||
}
|
||||
|
||||
async getByUidWithEventType(bookingUid: string) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
include: {
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByUidWithUser(bookingUid: string) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
@@ -13,9 +13,8 @@ import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/ge
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||
import { EventTypeAccessService } from "@/modules/event-types/services/event-type-access.service";
|
||||
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { MembershipsService } from "@/modules/memberships/services/memberships.service";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
@@ -24,7 +23,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
@@ -39,6 +38,8 @@ import { Request } from "express";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
export const BOOKING_REASSIGN_PERMISSION_ERROR = "You do not have permission to reassign this booking";
|
||||
|
||||
import {
|
||||
getTranslation,
|
||||
getAllUserBookings,
|
||||
@@ -109,12 +110,11 @@ export class BookingsService_2024_08_13 {
|
||||
private readonly organizationsRepository: OrganizationsRepository,
|
||||
private readonly teamsRepository: TeamsRepository,
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
||||
private readonly membershipsRepository: MembershipsRepository,
|
||||
private readonly membershipsService: MembershipsService,
|
||||
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13,
|
||||
private readonly regularBookingService: RegularBookingService,
|
||||
private readonly recurringBookingService: RecurringBookingService,
|
||||
private readonly instantBookingCreateService: InstantBookingCreateService
|
||||
private readonly instantBookingCreateService: InstantBookingCreateService,
|
||||
private readonly eventTypeAccessService: EventTypeAccessService
|
||||
) {}
|
||||
|
||||
async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) {
|
||||
@@ -128,7 +128,7 @@ export class BookingsService_2024_08_13 {
|
||||
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
|
||||
}
|
||||
const userIsEventTypeAdminOrOwner = authUser
|
||||
? await this.userIsEventTypeAdminOrOwner(authUser, eventType)
|
||||
? await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(authUser, eventType)
|
||||
: false;
|
||||
await this.checkBookingRequiresAuthenticationSetting(eventType, authUser, userIsEventTypeAdminOrOwner);
|
||||
|
||||
@@ -197,44 +197,6 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
}
|
||||
|
||||
async userIsEventTypeAdminOrOwner(authUser: ApiAuthGuardUser, eventType: EventType) {
|
||||
const authUserId = authUser.id;
|
||||
const authUserRole = authUser.role;
|
||||
const eventTypeId = eventType.id;
|
||||
const teamId = eventType.teamId;
|
||||
const eventTypeOwnerId = eventType.userId || null;
|
||||
|
||||
if (authUserRole === "ADMIN") return true;
|
||||
|
||||
if (eventTypeOwnerId === authUserId) return true;
|
||||
|
||||
if (eventTypeId) {
|
||||
const [isUserHost, isUserAssigned] = await Promise.all([
|
||||
this.eventTypesRepository.isUserHostOfEventType(authUserId, eventTypeId),
|
||||
this.eventTypesRepository.isUserAssignedToEventType(authUserId, eventTypeId),
|
||||
]);
|
||||
|
||||
if (isUserHost || isUserAssigned) return true;
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
const membership = await this.membershipsRepository.getUserAdminOrOwnerTeamMembership(
|
||||
authUserId,
|
||||
teamId
|
||||
);
|
||||
if (membership) return true;
|
||||
}
|
||||
|
||||
if (
|
||||
eventTypeOwnerId &&
|
||||
(await this.membershipsService.isUserOrgAdminOrOwnerOfAnotherUser(authUserId, eventTypeOwnerId))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async getBookedEventType(body: CreateBookingInput) {
|
||||
if (body.eventTypeId) {
|
||||
return await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(body.eventTypeId);
|
||||
@@ -616,7 +578,7 @@ export class BookingsService_2024_08_13 {
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(uid);
|
||||
const userIsEventTypeAdminOrOwner =
|
||||
authUser && booking?.eventType
|
||||
? await this.userIsEventTypeAdminOrOwner(authUser, booking.eventType)
|
||||
? await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(authUser, booking.eventType)
|
||||
: false;
|
||||
|
||||
if (booking) {
|
||||
@@ -805,7 +767,7 @@ export class BookingsService_2024_08_13 {
|
||||
|
||||
const userIsEventTypeAdminOrOwner =
|
||||
authUser && databaseBooking.eventType
|
||||
? await this.userIsEventTypeAdminOrOwner(authUser, databaseBooking.eventType)
|
||||
? await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(authUser, databaseBooking.eventType)
|
||||
: false;
|
||||
const isRecurring = !!databaseBooking.recurringEventId;
|
||||
const isSeated = !!databaseBooking.eventType?.seatsPerTimeSlot;
|
||||
@@ -1032,19 +994,34 @@ export class BookingsService_2024_08_13 {
|
||||
});
|
||||
}
|
||||
|
||||
async reassignBooking(bookingUid: string, requestUser: UserWithProfile) {
|
||||
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||
async reassignBooking(bookingUid: string, reassignedByUser: ApiAuthGuardUser) {
|
||||
const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
if (!booking.eventType) {
|
||||
throw new BadRequestException(
|
||||
`Event type with id=${booking.eventTypeId} was not found in the database`
|
||||
);
|
||||
}
|
||||
|
||||
const isAllowed = await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(
|
||||
reassignedByUser,
|
||||
booking.eventType
|
||||
);
|
||||
|
||||
if (!isAllowed) {
|
||||
throw new ForbiddenException(BOOKING_REASSIGN_PERMISSION_ERROR);
|
||||
}
|
||||
|
||||
const platformClientParams = booking.eventTypeId
|
||||
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
|
||||
|
||||
const profile = this.usersService.getUserMainProfile(requestUser);
|
||||
const profile = this.usersService.getUserMainProfile(reassignedByUser);
|
||||
|
||||
try {
|
||||
await roundRobinReassignment({
|
||||
@@ -1052,7 +1029,7 @@ export class BookingsService_2024_08_13 {
|
||||
orgId: profile?.organizationId || null,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
reassignedById: requestUser.id,
|
||||
reassignedById: reassignedByUser.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
@@ -1076,13 +1053,27 @@ export class BookingsService_2024_08_13 {
|
||||
async reassignBookingToUser(
|
||||
bookingUid: string,
|
||||
newUserId: number,
|
||||
reassignedById: number,
|
||||
reassignedByUser: ApiAuthGuardUser,
|
||||
body: ReassignToUserBookingInput_2024_08_13
|
||||
) {
|
||||
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||
const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
if (!booking.eventType) {
|
||||
throw new BadRequestException(
|
||||
`Event type with id=${booking.eventTypeId} was not found in the database`
|
||||
);
|
||||
}
|
||||
|
||||
const isAllowed = await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(
|
||||
reassignedByUser,
|
||||
booking.eventType
|
||||
);
|
||||
|
||||
if (!isAllowed) {
|
||||
throw new ForbiddenException(BOOKING_REASSIGN_PERMISSION_ERROR);
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findByIdWithProfile(newUserId);
|
||||
if (!user) {
|
||||
@@ -1103,7 +1094,7 @@ export class BookingsService_2024_08_13 {
|
||||
newUserId,
|
||||
orgId: profile?.organizationId || null,
|
||||
reassignReason: body.reason,
|
||||
reassignedById,
|
||||
reassignedById: reassignedByUser.id,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
});
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@ export class EventTypesService_2024_04_15 {
|
||||
}
|
||||
|
||||
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_04_15, user: UserWithProfile) {
|
||||
this.checkCanUpdateEventType(user.id, eventTypeId);
|
||||
await this.checkCanUpdateEventType(user.id, eventTypeId);
|
||||
const eventTypeUser = await this.getUserToUpdateEvent(user);
|
||||
const bookingFields = [...(body.bookingFields || [])];
|
||||
|
||||
|
||||
+131
-8
@@ -22,17 +22,31 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
|
||||
import { BookingWindowPeriodInputTypeEnum_2024_06_14, BookerLayoutsInputEnum_2024_06_14, ConfirmationPolicyEnum, NoticeThresholdUnitEnum, FrequencyInput } from "@calcom/platform-enums";
|
||||
import {
|
||||
BookingWindowPeriodInputTypeEnum_2024_06_14,
|
||||
BookerLayoutsInputEnum_2024_06_14,
|
||||
ConfirmationPolicyEnum,
|
||||
NoticeThresholdUnitEnum,
|
||||
FrequencyInput,
|
||||
} from "@calcom/platform-enums";
|
||||
import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import { type ApiSuccessResponse, type CreateEventTypeInput_2024_06_14, type EventTypeOutput_2024_06_14, type GuestsDefaultFieldOutput_2024_06_14, type NameDefaultFieldInput_2024_06_14, type NotesDefaultFieldInput_2024_06_14, type SplitNameDefaultFieldOutput_2024_06_14, type UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||
import {
|
||||
BaseConfirmationPolicy_2024_06_14,
|
||||
TeamEventTypeOutput_2024_06_14,
|
||||
type ApiSuccessResponse,
|
||||
type CreateEventTypeInput_2024_06_14,
|
||||
type EventTypeOutput_2024_06_14,
|
||||
type GuestsDefaultFieldOutput_2024_06_14,
|
||||
type NameDefaultFieldInput_2024_06_14,
|
||||
type NotesDefaultFieldInput_2024_06_14,
|
||||
type SplitNameDefaultFieldOutput_2024_06_14,
|
||||
type UpdateEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import { FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit";
|
||||
import { REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR } from "@calcom/platform-types/utils/RequiresOneOfPropertiesWhenNotDisabled";
|
||||
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
|
||||
|
||||
|
||||
const orderBySlug = (a: { slug: string }, b: { slug: string }) => {
|
||||
if (a.slug < b.slug) return -1;
|
||||
if (a.slug > b.slug) return 1;
|
||||
@@ -83,6 +97,8 @@ describe("Event types Endpoints", () => {
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let apiKeyString: string;
|
||||
let apiKeyOrgUser: string;
|
||||
let systemAdminUser: User;
|
||||
let systemAdminApiKeyString: string;
|
||||
|
||||
const userEmail = `event-types-2024-06-14-user-${randomString()}@api.com`;
|
||||
const falseTestEmail = `event-types-2024-06-14-false-user-${randomString()}@api.com`;
|
||||
@@ -196,6 +212,17 @@ describe("Event types Endpoints", () => {
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = `cal_test_${keyString}`;
|
||||
|
||||
systemAdminUser = await userRepositoryFixture.create({
|
||||
email: `event-types-2024-06-14-system-admin-${randomString()}@api.com`,
|
||||
username: `event-types-2024-06-14-system-admin-${randomString()}`,
|
||||
role: "ADMIN",
|
||||
});
|
||||
const { keyString: adminKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
systemAdminUser.id,
|
||||
null
|
||||
);
|
||||
systemAdminApiKeyString = `cal_test_${adminKeyString}`;
|
||||
|
||||
orgUser = await userRepositoryFixture.create({
|
||||
email: `event-types-2024-06-14-org-user-${randomString()}@example.com`,
|
||||
name: `event-types-2024-06-14-org-user-${randomString()}`,
|
||||
@@ -1354,6 +1381,9 @@ describe("Event types Endpoints", () => {
|
||||
expect(fetchedEventType.locations).toEqual(eventType.locations);
|
||||
expect(fetchedEventType.bookingFields).toEqual(eventType.bookingFields);
|
||||
expect(fetchedEventType.ownerId).toEqual(user.id);
|
||||
expect(Array.isArray(fetchedEventType.users)).toEqual(true);
|
||||
expect(fetchedEventType.users.length).toEqual(1);
|
||||
expect(fetchedEventType.users[0].id).toEqual(fetchedEventType.ownerId);
|
||||
expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
|
||||
expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
|
||||
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
|
||||
@@ -1375,6 +1405,99 @@ describe("Event types Endpoints", () => {
|
||||
expect(fetchedEventType.color).toEqual(eventType.color);
|
||||
});
|
||||
|
||||
it("system admin can access another user's event type by id", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${orgUserEventType1.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${systemAdminApiKeyString}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(orgUserEventType1.id);
|
||||
expect(responseBody.data.ownerId).toEqual(orgUser.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("user can access a team event type as team member (using user's API key)", async () => {
|
||||
const team = await teamRepositoryFixture.create({
|
||||
name: `event-types-2024-06-14-team-${randomString()}`,
|
||||
isOrganization: false,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: team.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const teamEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
title: `event-types-2024-06-14-team-event-${randomString()}`,
|
||||
slug: `event-types-2024-06-14-team-event-${randomString()}`,
|
||||
length: 60,
|
||||
locations: [],
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: { connect: { id: team.id } },
|
||||
});
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${teamEventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(teamEventType.id);
|
||||
expect(responseBody.data.teamId).toEqual(team.id);
|
||||
await teamRepositoryFixture.delete(team.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("user can access a team event type as HOST even if membership role is MEMBER (using user's API key)", async () => {
|
||||
const team = await teamRepositoryFixture.create({
|
||||
name: `event-types-2024-06-14-host-team-${randomString()}`,
|
||||
isOrganization: false,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: team.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const teamEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
title: `event-types-2024-06-14-host-event-${randomString()}`,
|
||||
slug: `event-types-2024-06-14-host-event-${randomString()}`,
|
||||
length: 60,
|
||||
locations: [],
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: { connect: { id: team.id } },
|
||||
hosts: {
|
||||
create: [
|
||||
{
|
||||
user: { connect: { id: user.id } },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${teamEventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(teamEventType.id);
|
||||
expect(responseBody.data.teamId).toEqual(team.id);
|
||||
await teamRepositoryFixture.delete(team.id);
|
||||
});
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username and eventSlug`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${username}&eventSlug=${eventType.slug}`)
|
||||
@@ -1780,7 +1903,7 @@ describe("Event types Endpoints", () => {
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
const policy = updatedEventType.confirmationPolicy as any;
|
||||
const policy = updatedEventType.confirmationPolicy as BaseConfirmationPolicy_2024_06_14;
|
||||
expect(policy?.type).toEqual(ConfirmationPolicyEnum.ALWAYS);
|
||||
expect(policy?.blockUnconfirmedBookingsInBooker).toEqual(false);
|
||||
expect(policy?.noticeThreshold).toBeUndefined();
|
||||
@@ -1808,7 +1931,7 @@ describe("Event types Endpoints", () => {
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
const policy = updatedEventType.confirmationPolicy as any;
|
||||
const policy = updatedEventType.confirmationPolicy as BaseConfirmationPolicy_2024_06_14;
|
||||
expect(policy?.type).toEqual(ConfirmationPolicyEnum.TIME);
|
||||
expect(policy?.noticeThreshold).toEqual({
|
||||
unit: NoticeThresholdUnitEnum.MINUTES,
|
||||
@@ -2793,4 +2916,4 @@ describe("Event types Endpoints", () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+30
-5
@@ -7,6 +7,7 @@ import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_202
|
||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import type { DatabaseEventType } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
|
||||
import {
|
||||
API_KEY_OR_ACCESS_TOKEN_HEADER,
|
||||
@@ -23,6 +24,9 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
|
||||
import type { DatabaseTeamEventType } from "@/modules/organizations/event-types/services/output.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
@@ -73,7 +77,8 @@ export class EventTypesController_2024_06_14 {
|
||||
private readonly eventTypesService: EventTypesService_2024_06_14,
|
||||
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
|
||||
private readonly eventTypeResponseTransformPipe: EventTypeResponseTransformPipe,
|
||||
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14
|
||||
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
|
||||
private readonly outputTeamEventTypesResponsePipe: OutputTeamEventTypesResponsePipe
|
||||
) {}
|
||||
|
||||
@Post("/")
|
||||
@@ -107,24 +112,44 @@ export class EventTypesController_2024_06_14 {
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Get an event type",
|
||||
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
|
||||
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
|
||||
Access control: This endpoint fetches an event type by ID and returns it only if the authenticated user is authorized. Authorization is granted to:
|
||||
- System admins
|
||||
- The event type owner
|
||||
- Hosts of the event type or users assigned to the event type
|
||||
- Team admins/owners of the team that owns the team event type
|
||||
- Organization admins/owners of the event type owner's organization
|
||||
- Organization admins/owners of the team's parent organization
|
||||
|
||||
Note: Update and delete endpoints remain restricted to the event type owner only.`,
|
||||
})
|
||||
async getEventTypeById(
|
||||
@Param("eventTypeId") eventTypeId: string,
|
||||
@GetUser() user: UserWithProfile
|
||||
@GetUser() user: ApiAuthGuardUser
|
||||
): Promise<GetEventTypeOutput_2024_06_14> {
|
||||
const eventType = await this.eventTypesService.getUserEventType(user.id, Number(eventTypeId));
|
||||
const eventType = await this.eventTypesService.getEventTypeByIdIfAuthorized(user, Number(eventTypeId));
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
const responseEventType = this.isTeamEventType(eventType)
|
||||
? await this.outputTeamEventTypesResponsePipe.transform(eventType)
|
||||
: this.eventTypeResponseTransformPipe.transform(eventType);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.eventTypeResponseTransformPipe.transform(eventType),
|
||||
data: responseEventType,
|
||||
};
|
||||
}
|
||||
|
||||
private isTeamEventType(
|
||||
eventType: DatabaseTeamEventType | ({ ownerId: number } & DatabaseEventType)
|
||||
): eventType is DatabaseTeamEventType {
|
||||
return !!eventType.teamId;
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({
|
||||
summary: "Get all event types",
|
||||
|
||||
@@ -10,10 +10,15 @@ import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { EventTypeAccessService } from "@/modules/event-types/services/event-type-access.service";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
|
||||
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/output.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
@@ -26,6 +31,8 @@ import { Module } from "@nestjs/common";
|
||||
EventTypesService_2024_06_14,
|
||||
InputEventTypesService_2024_06_14,
|
||||
OutputEventTypesService_2024_06_14,
|
||||
EventTypeAccessService,
|
||||
TeamsRepository,
|
||||
UsersRepository,
|
||||
UsersService,
|
||||
SchedulesRepository_2024_06_11,
|
||||
@@ -35,6 +42,9 @@ import { Module } from "@nestjs/common";
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
CalendarsRepository,
|
||||
OutputTeamEventTypesResponsePipe,
|
||||
OutputOrganizationsEventTypesService,
|
||||
TeamsEventTypesRepository,
|
||||
],
|
||||
controllers: [EventTypesController_2024_06_14],
|
||||
exports: [
|
||||
@@ -42,6 +52,7 @@ import { Module } from "@nestjs/common";
|
||||
EventTypesRepository_2024_06_14,
|
||||
InputEventTypesService_2024_06_14,
|
||||
OutputEventTypesService_2024_06_14,
|
||||
EventTypeAccessService,
|
||||
],
|
||||
})
|
||||
export class EventTypesModule_2024_06_14 {}
|
||||
|
||||
@@ -93,6 +93,19 @@ export class EventTypesRepository_2024_06_14 {
|
||||
});
|
||||
}
|
||||
|
||||
async getEventTypeByIdWithHosts(eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: { id: eventTypeId },
|
||||
include: {
|
||||
users: true,
|
||||
schedule: true,
|
||||
destinationCalendar: true,
|
||||
calVideoSettings: true,
|
||||
hosts: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getEventTypeByIdIncludeUsersAndTeam(eventTypeId: number) {
|
||||
const eventType = await this.dbRead.prisma.eventType.findUnique({
|
||||
where: { id: eventTypeId },
|
||||
|
||||
+10
-7
@@ -1,19 +1,22 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsIn, ValidateNested } from "class-validator";
|
||||
import { IsIn } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||
import { EventTypeOutput_2024_06_14, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(EventTypeOutput_2024_06_14, TeamEventTypeOutput_2024_06_14)
|
||||
export class GetEventTypeOutput_2024_06_14 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsIn([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: EventTypeOutput_2024_06_14,
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(EventTypeOutput_2024_06_14) },
|
||||
{ $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) },
|
||||
],
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => EventTypeOutput_2024_06_14)
|
||||
data!: EventTypeOutput_2024_06_14 | null;
|
||||
@Type(() => Object)
|
||||
data!: EventTypeOutput_2024_06_14 | TeamEventTypeOutput_2024_06_14 | null;
|
||||
}
|
||||
|
||||
+36
-2
@@ -1,10 +1,14 @@
|
||||
import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_06_14/constants/constants";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { DatabaseEventType } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { InputEventTransformed_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/transformed";
|
||||
import { SystemField, CustomField } from "@/ee/event-types/event-types_2024_06_14/transformers";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { EventTypeAccessService } from "@/modules/event-types/services/event-type-access.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { DatabaseTeamEventType } from "@/modules/organizations/event-types/services/output.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
@@ -30,7 +34,8 @@ export class EventTypesService_2024_06_14 {
|
||||
private readonly usersService: UsersService,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly schedulesRepository: SchedulesRepository_2024_06_11
|
||||
private readonly schedulesRepository: SchedulesRepository_2024_06_11,
|
||||
private readonly eventTypeAccessService: EventTypeAccessService
|
||||
) {}
|
||||
|
||||
async createUserEventType(user: UserWithProfile, body: InputEventTransformed_2024_06_14) {
|
||||
@@ -39,7 +44,7 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
await this.checkCanCreateEventType(user.id, body);
|
||||
const eventTypeUser = await this.getUserToCreateEvent(user);
|
||||
|
||||
|
||||
const { destinationCalendar: _destinationCalendar, ...rest } = body;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
@@ -79,6 +84,31 @@ export class EventTypesService_2024_06_14 {
|
||||
};
|
||||
}
|
||||
|
||||
async getEventTypeByIdIfAuthorized(
|
||||
authUser: ApiAuthGuardUser,
|
||||
eventTypeId: number
|
||||
): Promise<DatabaseTeamEventType | ({ ownerId: number } & DatabaseEventType) | null> {
|
||||
const eventType = await this.eventTypesRepository.getEventTypeByIdWithHosts(eventTypeId);
|
||||
|
||||
if (!eventType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasAccess = await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(
|
||||
authUser,
|
||||
eventType as unknown as EventType
|
||||
);
|
||||
|
||||
if (!hasAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
ownerId: eventType.userId ?? 0,
|
||||
...eventType,
|
||||
};
|
||||
}
|
||||
|
||||
async checkCanCreateEventType(userId: number, body: InputEventTransformed_2024_06_14) {
|
||||
const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug);
|
||||
if (existsWithSlug) {
|
||||
@@ -233,6 +263,10 @@ export class EventTypesService_2024_06_14 {
|
||||
return [dynamicEventType];
|
||||
}
|
||||
|
||||
if (authUser?.id) {
|
||||
return await this.getUserEventTypes(authUser.id);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("Me Endpoints", () => {
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
const userEmail = `me-controller-user-${randomString()}@api.com`;
|
||||
const name = "Me Controller User";
|
||||
let user: User;
|
||||
let org: Team;
|
||||
|
||||
@@ -55,6 +56,7 @@ describe("Me Endpoints", () => {
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
name,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
@@ -92,6 +94,9 @@ describe("Me Endpoints", () => {
|
||||
|
||||
expect(responseBody.data.id).toEqual(user.id);
|
||||
expect(responseBody.data.email).toEqual(user.email);
|
||||
expect(responseBody.data.name).toEqual(user.name);
|
||||
expect(responseBody.data.avatarUrl).toEqual(user.avatarUrl);
|
||||
expect(responseBody.data.bio).toEqual(user.bio);
|
||||
expect(responseBody.data.timeFormat).toEqual(user.timeFormat);
|
||||
expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId);
|
||||
expect(responseBody.data.weekStart).toEqual(user.weekStart);
|
||||
@@ -114,6 +119,8 @@ describe("Me Endpoints", () => {
|
||||
|
||||
expect(responseBody.data.id).toEqual(user.id);
|
||||
expect(responseBody.data.email).toEqual(user.email);
|
||||
expect(responseBody.data.avatarUrl).toEqual(user.avatarUrl);
|
||||
expect(responseBody.data.bio).toEqual(user.bio);
|
||||
expect(responseBody.data.timeFormat).toEqual(user.timeFormat);
|
||||
expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId);
|
||||
expect(responseBody.data.weekStart).toEqual(user.weekStart);
|
||||
@@ -148,13 +155,13 @@ describe("Me Endpoints", () => {
|
||||
});
|
||||
|
||||
it("should not update user associated with access token given invalid time format", async () => {
|
||||
const bodyWithIncorrectTimeFormat: UpdateManagedUserInput = { timeFormat: 100 as any };
|
||||
const bodyWithIncorrectTimeFormat = { timeFormat: 100 };
|
||||
|
||||
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectTimeFormat).expect(400);
|
||||
});
|
||||
|
||||
it("should not update user associated with access token given invalid week start", async () => {
|
||||
const bodyWithIncorrectWeekStart: UpdateManagedUserInput = { weekStart: "waba luba dub dub" as any };
|
||||
const bodyWithIncorrectWeekStart = { weekStart: "waba luba dub dub" };
|
||||
|
||||
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectWeekStart).expect(400);
|
||||
});
|
||||
|
||||
@@ -23,6 +23,18 @@ export class MeOutput {
|
||||
@DocsProperty()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@DocsProperty({ type: String, nullable: true })
|
||||
name!: string | null;
|
||||
|
||||
@IsString()
|
||||
@DocsProperty({ type: String, nullable: true })
|
||||
avatarUrl!: string | null;
|
||||
|
||||
@IsString()
|
||||
@DocsProperty({ type: String, nullable: true })
|
||||
bio!: string | null;
|
||||
|
||||
@IsInt()
|
||||
@DocsProperty()
|
||||
timeFormat!: number;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { OrganizationMembershipService as BaseOrganizationMembershipService } from "@calcom/platform-libraries/organizations";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationMembershipService extends BaseOrganizationMembershipService {
|
||||
constructor(organizationsRepository: OrganizationsRepository) {
|
||||
super({ organizationRepository: organizationsRepository });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RolesModule } from "@/modules/roles/roles.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
@@ -21,6 +22,7 @@ import { PassportModule } from "@nestjs/passport";
|
||||
MembershipsModule,
|
||||
TokensModule,
|
||||
DeploymentsModule,
|
||||
RolesModule,
|
||||
],
|
||||
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||
exports: [NextAuthGuard, ApiAuthGuard],
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Reflector } from "@nestjs/core";
|
||||
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
export const Pbac = Reflector.createDecorator<PermissionString[]>();
|
||||
@@ -54,7 +54,7 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
||||
}
|
||||
}
|
||||
|
||||
const org = await this.organizationsRepository.findById(Number(organizationId));
|
||||
const org = await this.organizationsRepository.findById({ id: Number(organizationId) });
|
||||
|
||||
if (org?.isOrganization && !org?.isPlatform) {
|
||||
const adminAPIAccessIsEnabledInOrg = await this.organizationsRepository.fetchOrgAdminApiStatus(
|
||||
|
||||
@@ -55,7 +55,7 @@ export class IsOrgGuard implements CanActivate {
|
||||
}
|
||||
}
|
||||
|
||||
const org = await this.organizationsRepository.findById(Number(organizationId));
|
||||
const org = await this.organizationsRepository.findById({ id: Number(organizationId) });
|
||||
|
||||
if (org?.isOrganization) {
|
||||
canAccess = true;
|
||||
|
||||
@@ -43,7 +43,7 @@ export class IsWebhookInOrg implements CanActivate {
|
||||
}
|
||||
}
|
||||
|
||||
const org = await this.organizationsRepository.findById(Number(organizationId));
|
||||
const org = await this.organizationsRepository.findById({ id: Number(organizationId) });
|
||||
|
||||
if (org?.isOrganization) {
|
||||
const isWebhookInOrg = await this.organizationsWebhooksRepository.findWebhook(
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Request } from "express";
|
||||
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
import { PermissionCheckService, FeaturesRepository } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
export const REDIS_PBAC_CACHE_KEY = (teamId: number) => `apiv2:team:${teamId}:has:pbac:guard:pbac`;
|
||||
export const REDIS_REQUIRED_PERMISSIONS_CACHE_KEY = (
|
||||
userId: number,
|
||||
teamId: number,
|
||||
requiredPermissions: PermissionString[]
|
||||
) =>
|
||||
`apiv2:user:${userId}:team:${teamId}:requiredPermissions:${requiredPermissions
|
||||
.sort()
|
||||
.join(",")}:guard:pbac`;
|
||||
|
||||
@Injectable()
|
||||
export class PbacGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private prismaReadService: PrismaReadService,
|
||||
private readonly redisService: RedisService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { pbacAuthorizedRequest?: boolean }>();
|
||||
const user = request.user as ApiAuthGuardUser;
|
||||
const teamId = request.params.teamId;
|
||||
const orgId = request.params.orgId;
|
||||
const requiredPermissions = this.reflector.get(Pbac, context.getHandler());
|
||||
|
||||
const effectiveTeamId = teamId || orgId;
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("PbacGuard - the request does not have an authorized user provided");
|
||||
}
|
||||
if (!effectiveTeamId) {
|
||||
throw new BadRequestException(
|
||||
"PbacGuard - can't check pbac because no teamId or orgId provided within the request url"
|
||||
);
|
||||
}
|
||||
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||
request.pbacAuthorizedRequest = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasPbacEnabled = await this.hasPbacEnabled(Number(effectiveTeamId));
|
||||
if (!hasPbacEnabled) {
|
||||
request.pbacAuthorizedRequest = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasRequiredPermissions = await this.checkUserHasRequiredPermissions(
|
||||
user.id,
|
||||
Number(effectiveTeamId),
|
||||
requiredPermissions
|
||||
);
|
||||
|
||||
if (!hasRequiredPermissions) {
|
||||
request.pbacAuthorizedRequest = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
request.pbacAuthorizedRequest = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
async hasPbacEnabled(teamId: number) {
|
||||
const cachedHasPbacEnabled = await this.getCachePbacEnabled(teamId);
|
||||
|
||||
if (cachedHasPbacEnabled) {
|
||||
return cachedHasPbacEnabled;
|
||||
}
|
||||
|
||||
const pbacFeatureFlag = "pbac";
|
||||
const featuresRepository = new FeaturesRepository(this.prismaReadService.prisma);
|
||||
const hasPbacEnabled = await featuresRepository.checkIfTeamHasFeature(teamId, pbacFeatureFlag);
|
||||
|
||||
if (hasPbacEnabled) {
|
||||
await this.setCachePbacEnabled(teamId, hasPbacEnabled);
|
||||
}
|
||||
|
||||
return hasPbacEnabled;
|
||||
}
|
||||
|
||||
async checkUserHasRequiredPermissions(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
requiredPermissions: PermissionString[]
|
||||
) {
|
||||
const cachedAccess = await this.getCacheRequiredPermissions(userId, teamId, requiredPermissions);
|
||||
|
||||
if (cachedAccess) {
|
||||
return cachedAccess;
|
||||
}
|
||||
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
const hasRequiredPermissions = await permissionCheckService.checkPermissions({
|
||||
userId,
|
||||
teamId,
|
||||
permissions: requiredPermissions,
|
||||
fallbackRoles: [],
|
||||
});
|
||||
|
||||
if (hasRequiredPermissions) {
|
||||
await this.setCacheRequiredPermissions(userId, teamId, requiredPermissions, hasRequiredPermissions);
|
||||
}
|
||||
|
||||
return hasRequiredPermissions;
|
||||
}
|
||||
|
||||
private async getCacheRequiredPermissions(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
requiredPermissions: PermissionString[]
|
||||
): Promise<boolean | null> {
|
||||
return this.redisService.get<boolean>(
|
||||
REDIS_REQUIRED_PERMISSIONS_CACHE_KEY(userId, teamId, requiredPermissions)
|
||||
);
|
||||
}
|
||||
|
||||
private async setCacheRequiredPermissions(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
requiredPermissions: PermissionString[],
|
||||
hasRequired: boolean
|
||||
): Promise<void> {
|
||||
await this.redisService.set<boolean>(
|
||||
REDIS_REQUIRED_PERMISSIONS_CACHE_KEY(userId, teamId, requiredPermissions),
|
||||
hasRequired,
|
||||
{ ttl: 300_000 }
|
||||
);
|
||||
}
|
||||
|
||||
private async getCachePbacEnabled(teamId: number) {
|
||||
const cachedResult = await this.redisService.get<boolean>(REDIS_PBAC_CACHE_KEY(teamId));
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
private async setCachePbacEnabled(teamId: number, pbacEnabled: boolean) {
|
||||
await this.redisService.set<boolean>(REDIS_PBAC_CACHE_KEY(teamId), pbacEnabled, {
|
||||
ttl: 300_000,
|
||||
});
|
||||
}
|
||||
|
||||
throwForbiddenError(
|
||||
userId: number,
|
||||
teamId: string,
|
||||
orgId: string,
|
||||
requiredPermissions: PermissionString[]
|
||||
) {
|
||||
let errorMessage = `PbacGuard - user with id=${userId} does not have the minimum required permissions=${requiredPermissions.join(
|
||||
","
|
||||
)} `;
|
||||
if (teamId) {
|
||||
errorMessage += `within team with id=${teamId}`;
|
||||
}
|
||||
if (orgId) {
|
||||
errorMessage += `within organization with id=${orgId}`;
|
||||
}
|
||||
errorMessage += `.`;
|
||||
|
||||
throw new ForbiddenException(errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@ import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger }
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Request } from "express";
|
||||
|
||||
import type { Team } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
private readonly logger = new Logger("RolesGuard Logger");
|
||||
@@ -19,7 +17,13 @@ export class RolesGuard implements CanActivate {
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
|
||||
const request = context.switchToHttp().getRequest<Request & { pbacAuthorizedRequest?: boolean }>();
|
||||
|
||||
if (request.pbacAuthorizedRequest === true) {
|
||||
this.logger.debug("PBAC authorized request, skipping legacy role checking");
|
||||
return true;
|
||||
}
|
||||
|
||||
const teamId = request.params.teamId as string;
|
||||
const orgId = request.params.orgId as string;
|
||||
const user = request.user as ApiAuthGuardUser;
|
||||
|
||||
@@ -305,11 +305,24 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
new Date(invoice.period_end * 1000)
|
||||
);
|
||||
|
||||
const existingSubscription = await this.stripeService
|
||||
.getStripe()
|
||||
.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
const perActiveUserPrice = this.billingConfigService.get(PlatformPlan.PER_ACTIVE_USER)?.base;
|
||||
const subscriptionItem = existingSubscription.items.data.find(
|
||||
(item) => item.price?.id === perActiveUserPrice
|
||||
);
|
||||
|
||||
if (!subscriptionItem) {
|
||||
throw new NotFoundException(
|
||||
"No subscription item found for PER_ACTIVE_USER plan with matching price ID"
|
||||
);
|
||||
}
|
||||
|
||||
await this.stripeService.getStripe().subscriptions.update(subscriptionId, {
|
||||
items: [
|
||||
{
|
||||
quantity: activeManagedUsersCount > 0 ? activeManagedUsersCount : 1,
|
||||
},
|
||||
{ id: subscriptionItem.id, quantity: activeManagedUsersCount > 0 ? activeManagedUsersCount : 1 },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { MembershipsService } from "@/modules/memberships/services/memberships.service";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { EventType } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class EventTypeAccessService {
|
||||
constructor(
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly membershipsRepository: MembershipsRepository,
|
||||
private readonly membershipsService: MembershipsService,
|
||||
private readonly teamsRepository: TeamsRepository
|
||||
) {}
|
||||
|
||||
async userIsEventTypeAdminOrOwner(authUser: ApiAuthGuardUser, eventType: EventType): Promise<boolean> {
|
||||
const authUserId = authUser.id;
|
||||
const eventTypeId = eventType.id;
|
||||
const teamId = eventType.teamId;
|
||||
const eventTypeOwnerId = eventType.userId || null;
|
||||
|
||||
if (authUser.isSystemAdmin) return true;
|
||||
|
||||
if (eventTypeOwnerId === authUserId) return true;
|
||||
|
||||
if (eventTypeId) {
|
||||
const isHostOrAssigned = await this.isUserHostOrAssignedToEventType(authUserId, eventTypeId);
|
||||
if (isHostOrAssigned) return true;
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
const isTeamOrParentOrgAdmin = await this.isUserTeamAdminOrParentOrgAdmin(authUserId, teamId);
|
||||
if (isTeamOrParentOrgAdmin) return true;
|
||||
}
|
||||
|
||||
if (eventTypeOwnerId) {
|
||||
const isOrgAdminOrOwnerOfEventOwner = await this.isUserOrgAdminOrOwnerOfEventOwner(
|
||||
authUserId,
|
||||
eventTypeOwnerId
|
||||
);
|
||||
if (isOrgAdminOrOwnerOfEventOwner) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async isUserHostOrAssignedToEventType(authUserId: number, eventTypeId: number): Promise<boolean> {
|
||||
const [isUserHost, isUserAssigned] = await Promise.all([
|
||||
this.eventTypesRepository.isUserHostOfEventType(authUserId, eventTypeId),
|
||||
this.eventTypesRepository.isUserAssignedToEventType(authUserId, eventTypeId),
|
||||
]);
|
||||
return isUserHost || isUserAssigned;
|
||||
}
|
||||
|
||||
private async isUserTeamAdminOrParentOrgAdmin(authUserId: number, teamId: number): Promise<boolean> {
|
||||
const membership = await this.membershipsRepository.getUserAdminOrOwnerTeamMembership(authUserId, teamId);
|
||||
if (membership) return true;
|
||||
|
||||
const team = await this.teamsRepository.getById(teamId);
|
||||
const parentOrgId = team?.parentId ?? null;
|
||||
if (parentOrgId) {
|
||||
const isOrgAdmin = await this.membershipsRepository.isUserOrganizationAdmin(authUserId, parentOrgId);
|
||||
if (isOrgAdmin) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async isUserOrgAdminOrOwnerOfEventOwner(
|
||||
authUserId: number,
|
||||
eventTypeOwnerId: number
|
||||
): Promise<boolean> {
|
||||
return this.membershipsService.isUserOrgAdminOrOwnerOfAnotherUser(authUserId, eventTypeOwnerId);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
HostPriority,
|
||||
EmailSettings_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import type { EventType } from "@calcom/prisma/client";
|
||||
|
||||
export const HOSTS_REQUIRED_WHEN_SWITCHING_SCHEDULING_TYPE_ERROR =
|
||||
"Hosts required when switching schedulingType. Please provide 'hosts' or set 'assignAllTeamMembers: true' to specify how hosts should be configured for the new scheduling type.";
|
||||
|
||||
export type TransformedCreateTeamEventTypeInput = Awaited<
|
||||
ReturnType<InstanceType<typeof InputOrganizationsEventTypesService>["transformInputCreateTeamEventType"]>
|
||||
@@ -116,10 +120,12 @@ export class InputOrganizationsEventTypesService {
|
||||
teamId: number,
|
||||
inputEventType: CreateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
const { hosts, assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
|
||||
const { assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
|
||||
|
||||
const eventType = this.inputEventTypesService.transformInputCreateEventType(rest);
|
||||
|
||||
const isManagedEventType = rest.schedulingType === "MANAGED";
|
||||
|
||||
const defaultLocations: CreateTeamEventTypeInput_2024_06_14["locations"] = [
|
||||
{
|
||||
type: "integration",
|
||||
@@ -127,12 +133,13 @@ export class InputOrganizationsEventTypesService {
|
||||
},
|
||||
];
|
||||
|
||||
const children = await this.getChildEventTypesForManagedEventTypeCreate(inputEventType, teamId);
|
||||
const children = isManagedEventType
|
||||
? await this.getChildEventTypesForManagedEventTypeCreate(inputEventType, teamId)
|
||||
: undefined;
|
||||
|
||||
let metadata =
|
||||
rest.schedulingType === "MANAGED"
|
||||
? { managedEventConfig: {}, ...eventType.metadata }
|
||||
: eventType.metadata;
|
||||
let metadata = isManagedEventType
|
||||
? { managedEventConfig: {}, ...eventType.metadata }
|
||||
: eventType.metadata;
|
||||
|
||||
if (emailSettings) {
|
||||
metadata = this.addEmailSettingsToMetadata(emailSettings, metadata);
|
||||
@@ -140,12 +147,7 @@ export class InputOrganizationsEventTypesService {
|
||||
|
||||
const teamEventType = {
|
||||
...eventType,
|
||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
|
||||
hosts: !(rest.schedulingType === "MANAGED")
|
||||
? assignAllTeamMembers
|
||||
? await this.getAllTeamMembers(teamId, inputEventType.schedulingType)
|
||||
: this.transformInputHosts(hosts, inputEventType.schedulingType)
|
||||
: undefined,
|
||||
hosts: await this.transformInputCreateTeamEventTypeHosts(teamId, inputEventType),
|
||||
assignAllTeamMembers,
|
||||
locations: this.transformInputTeamLocations(locations || defaultLocations),
|
||||
metadata,
|
||||
@@ -155,6 +157,24 @@ export class InputOrganizationsEventTypesService {
|
||||
return teamEventType;
|
||||
}
|
||||
|
||||
private async transformInputCreateTeamEventTypeHosts(
|
||||
teamId: number,
|
||||
inputEventType: CreateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
const { hosts, assignAllTeamMembers, schedulingType } = inputEventType;
|
||||
|
||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children - each child managed event type is associated with
|
||||
// a specific user and hosts property is only for team event types e.g round robin and collective.
|
||||
if (schedulingType === "MANAGED") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (assignAllTeamMembers) {
|
||||
return await this.getAllTeamMembers(teamId, inputEventType.schedulingType);
|
||||
}
|
||||
return this.transformInputHosts(hosts, inputEventType.schedulingType);
|
||||
}
|
||||
|
||||
private addEmailSettingsToMetadata(
|
||||
emailSettings: EmailSettings_2024_06_14,
|
||||
metadata: NonNullable<EventTypeMetadata>
|
||||
@@ -190,7 +210,7 @@ export class InputOrganizationsEventTypesService {
|
||||
teamId: number,
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
const { hosts, assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
|
||||
const { assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
|
||||
|
||||
const eventType = await this.inputEventTypesService.transformInputUpdateEventType(rest, eventTypeId);
|
||||
const dbEventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId);
|
||||
@@ -213,11 +233,11 @@ export class InputOrganizationsEventTypesService {
|
||||
const teamEventType = {
|
||||
...eventType,
|
||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
|
||||
hosts: !children
|
||||
? assignAllTeamMembers
|
||||
? await this.getAllTeamMembers(teamId, dbEventType.schedulingType)
|
||||
: this.transformInputHosts(hosts, dbEventType.schedulingType)
|
||||
: undefined,
|
||||
hosts: await this.transformInputUpdateTeamEventTypeHosts(
|
||||
teamId,
|
||||
dbEventType.schedulingType,
|
||||
inputEventType
|
||||
),
|
||||
assignAllTeamMembers,
|
||||
children,
|
||||
locations: locations ? this.transformInputTeamLocations(locations) : undefined,
|
||||
@@ -227,6 +247,33 @@ export class InputOrganizationsEventTypesService {
|
||||
return teamEventType;
|
||||
}
|
||||
|
||||
private async transformInputUpdateTeamEventTypeHosts(
|
||||
teamId: number,
|
||||
dbEventTypeSchedulingType: EventType["schedulingType"],
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
const { hosts, assignAllTeamMembers } = inputEventType;
|
||||
|
||||
if (dbEventTypeSchedulingType === "MANAGED") {
|
||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the event type children
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isSchedulingTypeChanging =
|
||||
inputEventType.schedulingType && inputEventType.schedulingType !== dbEventTypeSchedulingType;
|
||||
|
||||
if (isSchedulingTypeChanging && !assignAllTeamMembers && !hosts) {
|
||||
throw new BadRequestException(HOSTS_REQUIRED_WHEN_SWITCHING_SCHEDULING_TYPE_ERROR);
|
||||
}
|
||||
|
||||
const nextSchedulingType = inputEventType.schedulingType || dbEventTypeSchedulingType;
|
||||
if (assignAllTeamMembers) {
|
||||
return await this.getAllTeamMembers(teamId, nextSchedulingType);
|
||||
}
|
||||
|
||||
return this.transformInputHosts(hosts, nextSchedulingType);
|
||||
}
|
||||
|
||||
async getChildEventTypesForManagedEventTypeUpdate(
|
||||
eventTypeId: number,
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
|
||||
@@ -273,7 +320,7 @@ export class InputOrganizationsEventTypesService {
|
||||
}
|
||||
|
||||
async getChildEventTypesForManagedEventTypeCreate(
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
|
||||
inputEventType: Pick<CreateTeamEventTypeInput_2024_06_14, "assignAllTeamMembers" | "hosts">,
|
||||
teamId: number
|
||||
) {
|
||||
const ownersIds = await this.getOwnersIdsForManagedEventTypeCreate(teamId, inputEventType);
|
||||
@@ -289,7 +336,7 @@ export class InputOrganizationsEventTypesService {
|
||||
|
||||
async getOwnersIdsForManagedEventTypeCreate(
|
||||
teamId: number,
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||
inputEventType: Pick<CreateTeamEventTypeInput_2024_06_14, "assignAllTeamMembers" | "hosts">
|
||||
) {
|
||||
if (inputEventType.assignAllTeamMembers) {
|
||||
return await this.getTeamUsersIds(teamId);
|
||||
|
||||
@@ -4,23 +4,17 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { OrganizationRepository } from "@calcom/platform-libraries/organizations";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsRepository {
|
||||
export class OrganizationsRepository extends OrganizationRepository {
|
||||
constructor(
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly stripeService: StripeService
|
||||
) {}
|
||||
|
||||
async findById(organizationId: number) {
|
||||
return this.dbRead.prisma.team.findUnique({
|
||||
where: {
|
||||
id: organizationId,
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
) {
|
||||
super({ prismaClient: dbWrite.prisma });
|
||||
}
|
||||
|
||||
async findByIds(organizationIds: number[]) {
|
||||
|
||||
@@ -6,7 +6,7 @@ export class OrganizationsService {
|
||||
constructor(private readonly organizationsRepository: OrganizationsRepository) {}
|
||||
|
||||
async isPlatform(organizationId: number) {
|
||||
const organization = await this.organizationsRepository.findById(organizationId);
|
||||
const organization = await this.organizationsRepository.findById({ id: organizationId });
|
||||
return organization?.isPlatform;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.ser
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { EmailModule } from "@/modules/email/email.module";
|
||||
import { EmailService } from "@/modules/email/email.service";
|
||||
import { OrganizationMembershipService } from "@/lib/services/organization-membership.service";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository";
|
||||
@@ -39,6 +40,7 @@ import { OrganizationsMembershipRepository } from "@/modules/organizations/membe
|
||||
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||
import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
|
||||
import { OrganizationsRolesModule } from "@/modules/organizations/roles/organizations-roles.module";
|
||||
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
|
||||
import { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service";
|
||||
import { OrganizationsStripeModule } from "@/modules/organizations/stripe/organizations-stripe.module";
|
||||
@@ -50,6 +52,7 @@ import { OrganizationsTeamsInviteController } from "@/modules/organizations/team
|
||||
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
|
||||
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
|
||||
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
|
||||
import { OrganizationsTeamsRolesModule } from "@/modules/organizations/teams/roles/organizations-teams-roles.module";
|
||||
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
|
||||
import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/teams/schedules/organizations-teams-schedules.controller";
|
||||
import { OrganizationTeamWorkflowsController } from "@/modules/organizations/teams/workflows/controllers/org-team-workflows.controller";
|
||||
@@ -96,6 +99,8 @@ import { Module } from "@nestjs/common";
|
||||
TeamsModule,
|
||||
OrganizationsDelegationCredentialModule,
|
||||
OrganizationsOrganizationsModule,
|
||||
OrganizationsRolesModule,
|
||||
OrganizationsTeamsRolesModule,
|
||||
OrganizationsStripeModule,
|
||||
OrganizationsTeamsRoutingFormsModule,
|
||||
MembershipsModule,
|
||||
@@ -104,6 +109,7 @@ import { Module } from "@nestjs/common";
|
||||
],
|
||||
providers: [
|
||||
OrganizationsRepository,
|
||||
OrganizationMembershipService,
|
||||
OrganizationsTeamsRepository,
|
||||
OrganizationsService,
|
||||
OrganizationsTeamsService,
|
||||
@@ -196,4 +202,4 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsEventTypesPrivateLinksController,
|
||||
],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
export class OrganizationsModule { }
|
||||
|
||||
+2
-2
@@ -103,12 +103,12 @@ export class ManagedOrganizationsService {
|
||||
}
|
||||
|
||||
private async isManagerOrganizationPlatform(managerOrganizationId: number) {
|
||||
const organization = await this.organizationsRepository.findById(managerOrganizationId);
|
||||
const organization = await this.organizationsRepository.findById({ id: managerOrganizationId });
|
||||
return !!organization?.isPlatform;
|
||||
}
|
||||
|
||||
async getManagedOrganization(managedOrganizationId: number) {
|
||||
const organization = await this.organizationsRepository.findById(managedOrganizationId);
|
||||
const organization = await this.organizationsRepository.findById({ id: managedOrganizationId });
|
||||
if (!organization) {
|
||||
throw new NotFoundException(`Managed organization with id=${managedOrganizationId} does not exist.`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsArray, IsOptional, IsString, Validate } from "class-validator";
|
||||
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
import { getAllPermissionStringsForScope, Scope } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
import { OrgPermissionStringValidator } from "../permissions/inputs/validators/org-permission-string.validator";
|
||||
|
||||
export const orgPermissionEnum = [...getAllPermissionStringsForScope(Scope.Organization)] as const;
|
||||
|
||||
export class BaseOrgRoleInput {
|
||||
@ApiPropertyOptional({ description: "Color for the role (hex code)" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
color?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Description of the role" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Permissions for this role (format: resource.action). On update, this field replaces the entire permission set for the role (full replace). Use granular permission endpoints for one-by-one changes.",
|
||||
enum: orgPermissionEnum,
|
||||
isArray: true,
|
||||
example: ["eventType.read", "eventType.create", "booking.read"],
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@Validate(OrgPermissionStringValidator, { each: true })
|
||||
@IsOptional()
|
||||
permissions?: PermissionString[];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
import { BaseOrgRoleInput } from "./base-org-role.input";
|
||||
|
||||
export class CreateOrgRoleInput extends BaseOrgRoleInput {
|
||||
@ApiProperty({ description: "Name of the role", minLength: 1 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsString, IsOptional, MinLength } from "class-validator";
|
||||
|
||||
import { BaseOrgRoleInput } from "./base-org-role.input";
|
||||
|
||||
export class UpdateOrgRoleInput extends BaseOrgRoleInput {
|
||||
@ApiPropertyOptional({ description: "Name of the role", minLength: 1 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
}
|
||||
+638
@@ -0,0 +1,638 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { CreateOrgRoleInput } from "@/modules/organizations/roles/inputs/create-org-role.input";
|
||||
import { UpdateOrgRoleInput } from "@/modules/organizations/roles/inputs/update-org-role.input";
|
||||
import { CreateOrgRoleOutput } from "@/modules/organizations/roles/outputs/create-org-role.output";
|
||||
import { DeleteOrgRoleOutput } from "@/modules/organizations/roles/outputs/delete-org-role.output";
|
||||
import { GetAllOrgRolesOutput } from "@/modules/organizations/roles/outputs/get-all-org-roles.output";
|
||||
import { GetOrgRoleOutput } from "@/modules/organizations/roles/outputs/get-org-role.output";
|
||||
import { UpdateOrgRoleOutput } from "@/modules/organizations/roles/outputs/update-org-role.output";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { FeaturesRepositoryFixture } from "test/fixtures/repository/features.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { RoleService } from "@calcom/platform-libraries/pbac";
|
||||
import type { User, Team } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
describe("Organizations Roles Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let featuresRepositoryFixture: FeaturesRepositoryFixture;
|
||||
let roleService: RoleService;
|
||||
|
||||
// Test users
|
||||
let legacyOrgAdminUser: User;
|
||||
let legacyOrgMemberUser: User;
|
||||
let pbacOrgUserWithRolePermission: User;
|
||||
let pbacOrgUserWithoutRolePermission: User;
|
||||
let nonOrgUser: User;
|
||||
|
||||
// API Keys
|
||||
let legacyOrgAdminApiKey: string;
|
||||
let legacyOrgMemberApiKey: string;
|
||||
let pbacOrgUserWithRolePermissionApiKey: string;
|
||||
let pbacOrgUserWithoutRolePermissionApiKey: string;
|
||||
let nonOrgUserApiKey: string;
|
||||
|
||||
// Organization
|
||||
let organization: Team;
|
||||
let pbacEnabledOrganization: Team;
|
||||
|
||||
const legacyOrgAdminEmail = `legacy-org-admin-${randomString()}@api.com`;
|
||||
const legacyOrgMemberEmail = `legacy-org-member-${randomString()}@api.com`;
|
||||
const pbacOrgUserWithRolePermissionEmail = `pbac-org-user-with-role-permission-${randomString()}@api.com`;
|
||||
const pbacOrgUserWithoutRolePermissionEmail = `pbac-org-user-without-role-permission-${randomString()}@api.com`;
|
||||
const nonOrgUserEmail = `non-org-user-${randomString()}@api.com`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
}).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
featuresRepositoryFixture = new FeaturesRepositoryFixture(moduleRef);
|
||||
roleService = new RoleService();
|
||||
|
||||
// Create test users
|
||||
legacyOrgAdminUser = await userRepositoryFixture.create({
|
||||
email: legacyOrgAdminEmail,
|
||||
username: legacyOrgAdminEmail,
|
||||
});
|
||||
|
||||
legacyOrgMemberUser = await userRepositoryFixture.create({
|
||||
email: legacyOrgMemberEmail,
|
||||
username: legacyOrgMemberEmail,
|
||||
});
|
||||
|
||||
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
|
||||
email: pbacOrgUserWithRolePermissionEmail,
|
||||
username: pbacOrgUserWithRolePermissionEmail,
|
||||
});
|
||||
|
||||
pbacOrgUserWithoutRolePermission = await userRepositoryFixture.create({
|
||||
email: pbacOrgUserWithoutRolePermissionEmail,
|
||||
username: pbacOrgUserWithoutRolePermissionEmail,
|
||||
});
|
||||
|
||||
nonOrgUser = await userRepositoryFixture.create({
|
||||
email: nonOrgUserEmail,
|
||||
username: nonOrgUserEmail,
|
||||
});
|
||||
|
||||
// Create organizations
|
||||
organization = await organizationsRepositoryFixture.create({
|
||||
name: `org-roles-test-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
pbacEnabledOrganization = await organizationsRepositoryFixture.create({
|
||||
name: `pbac-org-roles-test-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
|
||||
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledOrganization.id, "pbac");
|
||||
|
||||
// Create memberships
|
||||
await membershipRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: legacyOrgAdminUser.id } },
|
||||
team: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: legacyOrgMemberUser.id } },
|
||||
team: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
const pbacOrgUserWithRolePermissionMembership = await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: pbacOrgUserWithRolePermission.id } },
|
||||
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
const pbacOrgUserWithoutRolePermissionMembership = await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: pbacOrgUserWithoutRolePermission.id } },
|
||||
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
const roleWithPermission = await roleService.createRole({
|
||||
name: "Role Manager",
|
||||
teamId: pbacEnabledOrganization.id,
|
||||
permissions: ["role.create", "role.read", "role.update", "role.delete"],
|
||||
type: "CUSTOM",
|
||||
});
|
||||
|
||||
const roleWithoutPermission = await roleService.createRole({
|
||||
name: "Basic Role",
|
||||
teamId: pbacEnabledOrganization.id,
|
||||
permissions: ["booking.read"],
|
||||
type: "CUSTOM",
|
||||
});
|
||||
|
||||
await roleService.assignRoleToMember(roleWithPermission.id, pbacOrgUserWithRolePermissionMembership.id);
|
||||
await roleService.assignRoleToMember(
|
||||
roleWithoutPermission.id,
|
||||
pbacOrgUserWithoutRolePermissionMembership.id
|
||||
);
|
||||
|
||||
const { keyString: legacyOrgAdminKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
legacyOrgAdminUser.id,
|
||||
null
|
||||
);
|
||||
legacyOrgAdminApiKey = `cal_test_${legacyOrgAdminKeyString}`;
|
||||
|
||||
const { keyString: legacyOrgMemberKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
legacyOrgMemberUser.id,
|
||||
null
|
||||
);
|
||||
legacyOrgMemberApiKey = `cal_test_${legacyOrgMemberKeyString}`;
|
||||
|
||||
const { keyString: pbacOrgUserWithRolePermissionKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
pbacOrgUserWithRolePermission.id,
|
||||
null
|
||||
);
|
||||
pbacOrgUserWithRolePermissionApiKey = `cal_test_${pbacOrgUserWithRolePermissionKeyString}`;
|
||||
|
||||
const { keyString: pbacOrgUserWithoutRolePermissionKeyString } =
|
||||
await apiKeysRepositoryFixture.createApiKey(pbacOrgUserWithoutRolePermission.id, null);
|
||||
pbacOrgUserWithoutRolePermissionApiKey = `cal_test_${pbacOrgUserWithoutRolePermissionKeyString}`;
|
||||
|
||||
const { keyString: nonOrgUserKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
nonOrgUser.id,
|
||||
null
|
||||
);
|
||||
nonOrgUserApiKey = `cal_test_${nonOrgUserKeyString}`;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe("Role Creation Authorization", () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Positive Tests", () => {
|
||||
it("should allow role creation when organization has PBAC enabled and user has a create permission", async () => {
|
||||
const createRoleInput: CreateOrgRoleInput = {
|
||||
name: "Test Role PBAC",
|
||||
permissions: ["booking.read", "eventType.create"],
|
||||
};
|
||||
|
||||
const pbacSpyCanActivate = jest.spyOn(PbacGuard.prototype, "canActivate");
|
||||
const pbacSpyHasPbacEnabled = jest.spyOn(
|
||||
PbacGuard.prototype as unknown as PbacGuard,
|
||||
"hasPbacEnabled"
|
||||
);
|
||||
|
||||
const rolesSpyCanActivate = jest.spyOn(RolesGuard.prototype, "canActivate");
|
||||
const rolesSpyCheckUserRoleAccess = jest.spyOn(
|
||||
RolesGuard.prototype as unknown as RolesGuard,
|
||||
"checkUserRoleAccess"
|
||||
);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateOrgRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(createRoleInput.name);
|
||||
expect(responseBody.data.permissions).toEqual(createRoleInput.permissions);
|
||||
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
|
||||
|
||||
expect(pbacSpyCanActivate).toHaveBeenCalled();
|
||||
expect(pbacSpyHasPbacEnabled).toHaveBeenCalled();
|
||||
expect(rolesSpyCanActivate).toHaveBeenCalled();
|
||||
const pbacCanActivateResult = await pbacSpyCanActivate.mock.results[0].value;
|
||||
const rolesCanActivateResult = await rolesSpyCanActivate.mock.results[0].value;
|
||||
const hasPbac = await pbacSpyHasPbacEnabled.mock.results[0].value;
|
||||
expect(pbacCanActivateResult).toBe(true);
|
||||
expect(rolesCanActivateResult).toBe(true);
|
||||
expect(hasPbac).toBe(true);
|
||||
expect(rolesSpyCheckUserRoleAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow role creation when organization does not have PBAC enabled and user is org admin", async () => {
|
||||
const createRoleInput: CreateOrgRoleInput = {
|
||||
name: "Test Role Legacy Admin",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
const pbacSpyCanActivate = jest.spyOn(PbacGuard.prototype, "canActivate");
|
||||
const pbacSpyHasPbacEnabled = jest.spyOn(
|
||||
PbacGuard.prototype as unknown as PbacGuard,
|
||||
"hasPbacEnabled"
|
||||
);
|
||||
const rolesSpyCanActivate = jest.spyOn(RolesGuard.prototype, "canActivate");
|
||||
const rolesSpyCheckUserRoleAccess = jest.spyOn(
|
||||
RolesGuard.prototype as unknown as RolesGuard,
|
||||
"checkUserRoleAccess"
|
||||
);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${organization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${legacyOrgAdminApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateOrgRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(createRoleInput.name);
|
||||
expect(responseBody.data.organizationId).toEqual(organization.id);
|
||||
|
||||
expect(pbacSpyCanActivate).toHaveBeenCalled();
|
||||
expect(rolesSpyCanActivate).toHaveBeenCalled();
|
||||
expect(pbacSpyHasPbacEnabled).toHaveBeenCalled();
|
||||
const pbacCanActivateResult = await pbacSpyCanActivate.mock.results[0].value;
|
||||
const rolesCanActivateResult = await rolesSpyCanActivate.mock.results[0].value;
|
||||
const hasPbac = await pbacSpyHasPbacEnabled.mock.results[0].value;
|
||||
expect(pbacCanActivateResult).toBe(true);
|
||||
expect(rolesCanActivateResult).toBe(true);
|
||||
expect(hasPbac).toBe(false);
|
||||
expect(rolesSpyCheckUserRoleAccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Negative Tests", () => {
|
||||
it("should not allow role creation when organization has PBAC enabled but user has no role assigned", async () => {
|
||||
const createRoleInput: CreateOrgRoleInput = {
|
||||
name: "Test Role No Role",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
const userWithNoRole = await userRepositoryFixture.create({
|
||||
email: `no-role-user-${randomString()}@api.com`,
|
||||
username: `no-role-user-${randomString()}@api.com`,
|
||||
});
|
||||
|
||||
await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: userWithNoRole.id } },
|
||||
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
const { keyString: noRoleKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
userWithNoRole.id,
|
||||
null
|
||||
);
|
||||
const noRoleApiKey = `cal_test_${noRoleKeyString}`;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${noRoleApiKey}`)
|
||||
.send(createRoleInput);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error.message).toBe(
|
||||
`RolesGuard - user with id=${userWithNoRole.id} does not have the minimum required role=ORG_ADMIN within organization with id=${pbacEnabledOrganization.id}.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should not allow role creation when organization has PBAC enabled but user role lacks required permission", async () => {
|
||||
const createRoleInput: CreateOrgRoleInput = {
|
||||
name: "Test Role No Permission",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithoutRolePermissionApiKey}`)
|
||||
.send(createRoleInput);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error.message).toBe(
|
||||
`RolesGuard - user with id=${pbacOrgUserWithoutRolePermission.id} does not have the minimum required role=ORG_ADMIN within organization with id=${pbacEnabledOrganization.id}.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should not allow role creation when organization does not have PBAC enabled and user has no membership", async () => {
|
||||
const createRoleInput: CreateOrgRoleInput = {
|
||||
name: "Test Role No Membership",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${organization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${nonOrgUserApiKey}`)
|
||||
.send(createRoleInput);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error.message).toBe(
|
||||
`RolesGuard - User is not a member of the organization with id=${organization.id}.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should not allow role creation when organization does not have PBAC enabled and user has member membership (not admin)", async () => {
|
||||
const createRoleInput: CreateOrgRoleInput = {
|
||||
name: "Test Role Member Only",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${organization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${legacyOrgMemberApiKey}`)
|
||||
.send(createRoleInput);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error.message).toBe(
|
||||
`RolesGuard - user with id=${legacyOrgMemberUser.id} does not have the minimum required role=ORG_ADMIN within organization with id=${organization.id}.`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CRUD Role Endpoints", () => {
|
||||
let createdRoleId: string;
|
||||
|
||||
it("should create a role", async () => {
|
||||
const createRoleInput: CreateOrgRoleInput = {
|
||||
name: "CRUD Test Role",
|
||||
permissions: ["booking.read", "eventType.create"],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: CreateOrgRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(createRoleInput.name);
|
||||
expect(responseBody.data.permissions).toEqual(createRoleInput.permissions);
|
||||
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
|
||||
createdRoleId = responseBody.data.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update role permissions and name", async () => {
|
||||
const updateRoleInput: UpdateOrgRoleInput = {
|
||||
name: "CRUD Test Role Updated",
|
||||
permissions: ["booking.read", "eventType.read"],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${createdRoleId}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(updateRoleInput)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: UpdateOrgRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(createdRoleId);
|
||||
expect(responseBody.data.name).toEqual(updateRoleInput.name);
|
||||
expect(responseBody.data.permissions).toEqual(updateRoleInput.permissions);
|
||||
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch the role", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${createdRoleId}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetOrgRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(createdRoleId);
|
||||
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch all roles", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetAllOrgRolesOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(Array.isArray(responseBody.data)).toBe(true);
|
||||
expect(responseBody.data.find((r) => r.id === createdRoleId)).toBeDefined();
|
||||
const created = responseBody.data.find((r) => r.id === createdRoleId);
|
||||
expect(created?.organizationId).toEqual(pbacEnabledOrganization.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete the role", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${createdRoleId}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: DeleteOrgRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(createdRoleId);
|
||||
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Negative error cases", () => {
|
||||
it("should fail to create a role with a duplicate name (400)", async () => {
|
||||
const name = `dup-role-${randomString()}`;
|
||||
|
||||
const firstCreate: CreateOrgRoleInput = { name, permissions: ["booking.read"] };
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(firstCreate)
|
||||
.expect(201);
|
||||
|
||||
const secondCreate: CreateOrgRoleInput = { name, permissions: ["booking.read"] };
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(secondCreate);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(`Role with name "${name}" already exists`);
|
||||
});
|
||||
|
||||
it("should fail to create a role with invalid permissions (400)", async () => {
|
||||
const createRoleInput = {
|
||||
name: `invalid-perms-${randomString()}`,
|
||||
permissions: ["invalid"],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(createRoleInput);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 404 when updating a role not belonging to the organization", async () => {
|
||||
const defaultAdminRoleId = await roleService.getDefaultRoleId(MembershipRole.ADMIN);
|
||||
const updateRoleInput: UpdateOrgRoleInput = {
|
||||
name: `no-update-default-${randomString()}`,
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultAdminRoleId}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(updateRoleInput);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Role with id ${defaultAdminRoleId} within team id ${pbacEnabledOrganization.id} not found`
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 404 when updating a default (system) role not belonging to the organization", async () => {
|
||||
const defaultAdminRoleId = await roleService.getDefaultRoleId(MembershipRole.ADMIN);
|
||||
const updateRoleInput: UpdateOrgRoleInput = {
|
||||
name: `no-update-default-${randomString()}`,
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultAdminRoleId}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(updateRoleInput);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Role with id ${defaultAdminRoleId} within team id ${pbacEnabledOrganization.id} not found`
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail to update with invalid permissions (400)", async () => {
|
||||
// Create a fresh role to update
|
||||
const { body: createRes } = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ name: `update-invalid-${randomString()}` as string, permissions: ["booking.read"] })
|
||||
.expect(201);
|
||||
const roleId: string = createRes.data.id;
|
||||
|
||||
const updateRoleInput = {
|
||||
name: `update-invalid-${randomString()}`,
|
||||
permissions: ["invalid"],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(updateRoleInput);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 404 when updating a role from a different organization", async () => {
|
||||
// Create a role in a different org (legacy/non-PBAC org)
|
||||
const foreignRole = await roleService.createRole({
|
||||
name: `foreign-update-${randomString()}`,
|
||||
teamId: organization.id,
|
||||
permissions: ["booking.read"],
|
||||
type: "CUSTOM",
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${foreignRole.id}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ permissions: ["eventType.read"] });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Role with id ${foreignRole.id} within team id ${pbacEnabledOrganization.id} not found`
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 404 when deleting a role not belonging to the organization", async () => {
|
||||
const defaultMemberRoleId = await roleService.getDefaultRoleId(MembershipRole.MEMBER);
|
||||
const response = await request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultMemberRoleId}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Role with id ${defaultMemberRoleId} within team id ${pbacEnabledOrganization.id} not found`
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 404 when deleting a default (system) role not belonging to the organization", async () => {
|
||||
const defaultMemberRoleId = await roleService.getDefaultRoleId(MembershipRole.MEMBER);
|
||||
const response = await request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultMemberRoleId}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Role with id ${defaultMemberRoleId} within team id ${pbacEnabledOrganization.id} not found`
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 404 when deleting a role from a different organization", async () => {
|
||||
// Create a role in a different org
|
||||
const foreignRole = await roleService.createRole({
|
||||
name: `foreign-delete-${randomString()}`,
|
||||
teamId: organization.id,
|
||||
permissions: ["booking.read"],
|
||||
type: "CUSTOM",
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${foreignRole.id}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Role with id ${foreignRole.id} within team id ${pbacEnabledOrganization.id} not found`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await featuresRepositoryFixture.deleteTeamFeature(pbacEnabledOrganization.id, "pbac");
|
||||
|
||||
await organizationsRepositoryFixture.delete(organization.id);
|
||||
await organizationsRepositoryFixture.delete(pbacEnabledOrganization.id);
|
||||
|
||||
await userRepositoryFixture.deleteByEmail(legacyOrgAdminUser.email);
|
||||
await userRepositoryFixture.deleteByEmail(legacyOrgMemberUser.email);
|
||||
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithRolePermission.email);
|
||||
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithoutRolePermission.email);
|
||||
await userRepositoryFixture.deleteByEmail(nonOrgUser.email);
|
||||
|
||||
try {
|
||||
await featuresRepositoryFixture.deleteBySlug("pbac");
|
||||
} catch (error) {
|
||||
console.error("Cleanup error:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Cleanup error:", error);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import {
|
||||
OPTIONAL_API_KEY_HEADER,
|
||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||
} from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { CreateOrgRoleInput } from "@/modules/organizations/roles/inputs/create-org-role.input";
|
||||
import { UpdateOrgRoleInput } from "@/modules/organizations/roles/inputs/update-org-role.input";
|
||||
import { CreateOrgRoleOutput } from "@/modules/organizations/roles/outputs/create-org-role.output";
|
||||
import { DeleteOrgRoleOutput } from "@/modules/organizations/roles/outputs/delete-org-role.output";
|
||||
import { GetAllOrgRolesOutput } from "@/modules/organizations/roles/outputs/get-all-org-roles.output";
|
||||
import { GetOrgRoleOutput } from "@/modules/organizations/roles/outputs/get-org-role.output";
|
||||
import { UpdateOrgRoleOutput } from "@/modules/organizations/roles/outputs/update-org-role.output";
|
||||
import { OrganizationsRolesOutputService } from "@/modules/organizations/roles/services/organizations-roles-output.service";
|
||||
import { RolesService } from "@/modules/roles/services/roles.service";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
Delete,
|
||||
Patch,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/roles",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, PbacGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Orgs / Roles")
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
export class OrganizationsRolesController {
|
||||
constructor(
|
||||
private readonly rolesService: RolesService,
|
||||
private readonly rolesOutputService: OrganizationsRolesOutputService
|
||||
) {}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.create"])
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: "Create a new organization role" })
|
||||
async createRole(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Body() body: CreateOrgRoleInput
|
||||
): Promise<CreateOrgRoleOutput> {
|
||||
const role = await this.rolesService.createRole(orgId, body);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getOrganizationRoleOutput(role),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.read"])
|
||||
@Get("/:roleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get a specific organization role" })
|
||||
async getRole(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("roleId") roleId: string
|
||||
): Promise<GetOrgRoleOutput> {
|
||||
const role = await this.rolesService.getRole(orgId, roleId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getOrganizationRoleOutput(role),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.read"])
|
||||
@Get("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get all organization roles" })
|
||||
async getAllRoles(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<GetAllOrgRolesOutput> {
|
||||
const { skip, take } = queryParams;
|
||||
const roles = await this.rolesService.getTeamRoles(orgId, skip ?? 0, take ?? 250);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getOrganizationRolesOutput(roles),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Patch("/:roleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Update an organization role" })
|
||||
async updateRole(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Body() body: UpdateOrgRoleInput
|
||||
): Promise<UpdateOrgRoleOutput> {
|
||||
const role = await this.rolesService.updateRole(orgId, roleId, body);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getOrganizationRoleOutput(role),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.delete"])
|
||||
@Delete("/:roleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Delete an organization role" })
|
||||
async deleteRole(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("roleId") roleId: string
|
||||
): Promise<DeleteOrgRoleOutput> {
|
||||
const role = await this.rolesService.deleteRole(orgId, roleId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getOrganizationRoleOutput(role),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsRolesPermissionsController } from "@/modules/organizations/roles/permissions/organizations-roles-permissions.controller";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RolesModule } from "@/modules/roles/roles.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { OrganizationsRolesController } from "./organizations-roles.controller";
|
||||
|
||||
@Module({
|
||||
imports: [StripeModule, PrismaModule, RedisModule, MembershipsModule, RolesModule],
|
||||
providers: [OrganizationsRepository],
|
||||
controllers: [OrganizationsRolesController, OrganizationsRolesPermissionsController],
|
||||
})
|
||||
export class OrganizationsRolesModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class CreateOrgRoleOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: OrgRoleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => OrgRoleOutput)
|
||||
data!: OrgRoleOutput;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class DeleteOrgRoleOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: OrgRoleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => OrgRoleOutput)
|
||||
data!: OrgRoleOutput;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsArray, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetAllOrgRolesOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: [OrgRoleOutput],
|
||||
})
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OrgRoleOutput)
|
||||
data!: OrgRoleOutput[];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetOrgRoleOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: OrgRoleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => OrgRoleOutput)
|
||||
data!: OrgRoleOutput;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsString, IsOptional, IsArray, IsEnum, IsDateString, IsNumber } from "class-validator";
|
||||
|
||||
import { orgPermissionEnum } from "../inputs/base-org-role.input";
|
||||
|
||||
enum RoleTypeEnum {
|
||||
SYSTEM = "SYSTEM",
|
||||
CUSTOM = "CUSTOM",
|
||||
}
|
||||
|
||||
type RoleType = keyof typeof RoleTypeEnum;
|
||||
|
||||
export class OrgRoleOutput {
|
||||
@ApiProperty({ description: "Unique identifier for the role" })
|
||||
@IsString()
|
||||
@Expose()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: "Name of the role" })
|
||||
@IsString()
|
||||
@Expose()
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Color for the role (hex code)" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
color?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: "Description of the role" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
description?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: "Organization ID this role belongs to" })
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
organizationId?: number | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Type of role",
|
||||
enum: RoleTypeEnum,
|
||||
})
|
||||
@IsEnum(RoleTypeEnum)
|
||||
@Expose()
|
||||
type!: RoleType;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Permissions assigned to this role in 'resource.action' format.",
|
||||
enum: orgPermissionEnum,
|
||||
isArray: true,
|
||||
example: ["booking.read", "eventType.create"],
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@Expose()
|
||||
permissions!: string[];
|
||||
|
||||
@ApiProperty({ description: "When the role was created" })
|
||||
@IsDateString()
|
||||
@Expose()
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty({ description: "When the role was last updated" })
|
||||
@IsDateString()
|
||||
@Expose()
|
||||
updatedAt!: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class UpdateOrgRoleOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: OrgRoleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => OrgRoleOutput)
|
||||
data!: OrgRoleOutput;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsArray, IsString, Validate } from "class-validator";
|
||||
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
import { orgPermissionEnum } from "../../inputs/base-org-role.input";
|
||||
import { OrgPermissionStringValidator } from "./validators/org-permission-string.validator";
|
||||
|
||||
export class CreateOrgRolePermissionsInput {
|
||||
@ApiProperty({
|
||||
description: "Permissions to add (format: resource.action)",
|
||||
enum: orgPermissionEnum,
|
||||
isArray: true,
|
||||
example: ["eventType.read", "booking.read"],
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@Validate(OrgPermissionStringValidator, { each: true })
|
||||
permissions!: PermissionString[];
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { ArrayNotEmpty, IsArray, IsOptional, IsString, Validate } from "class-validator";
|
||||
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
import { orgPermissionEnum } from "../../inputs/base-org-role.input";
|
||||
import { OrgPermissionStringValidator } from "./validators/org-permission-string.validator";
|
||||
|
||||
export class DeleteOrgRolePermissionsQuery {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Permissions to remove (format: resource.action). Supports comma-separated values as well as repeated query params.",
|
||||
example: "?permissions=eventType.read,booking.read",
|
||||
enum: orgPermissionEnum,
|
||||
isArray: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((p: string) => p.trim())
|
||||
.filter((p: string) => p.length > 0);
|
||||
}
|
||||
return value;
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty({ message: "permissions cannot be empty." })
|
||||
@IsString({ each: true })
|
||||
@Validate(OrgPermissionStringValidator, { each: true })
|
||||
permissions?: PermissionString[];
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { OrgPermissionStringValidator } from "./org-permission-string.validator";
|
||||
|
||||
describe("PermissionStringValidator", () => {
|
||||
let validator: OrgPermissionStringValidator;
|
||||
|
||||
beforeEach(() => {
|
||||
validator = new OrgPermissionStringValidator();
|
||||
});
|
||||
|
||||
describe("validate", () => {
|
||||
it("should return true for valid permission strings", () => {
|
||||
const validPermissions = [
|
||||
"eventType.read",
|
||||
"eventType.create",
|
||||
"eventType.update",
|
||||
"eventType.delete",
|
||||
"booking.read",
|
||||
"booking.update",
|
||||
"role.create",
|
||||
"role.read",
|
||||
"role.update",
|
||||
"role.delete",
|
||||
"team.read",
|
||||
"team.invite",
|
||||
"organization.read",
|
||||
"organization.listMembers",
|
||||
// no wildcards here
|
||||
];
|
||||
|
||||
validPermissions.forEach((permission) => {
|
||||
expect(validator.validate(permission)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw for invalid permission strings", () => {
|
||||
const invalidPermissions = [
|
||||
"invalid", // no dot
|
||||
"invalid.", // no action
|
||||
".invalid", // no resource
|
||||
"invalid.action", // invalid resource
|
||||
"eventType.invalid", // invalid action
|
||||
"event-type.read", // wrong format (should be eventType)
|
||||
"", // empty string
|
||||
"eventType..read", // double dot
|
||||
"eventType.read.extra", // too many parts
|
||||
];
|
||||
|
||||
invalidPermissions.forEach((permission) => {
|
||||
expect(() => validator.validate(permission)).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw for wildcard permission strings", () => {
|
||||
const wildcardPermissions = ["*", "*.read", "eventType.*"];
|
||||
|
||||
wildcardPermissions.forEach((permission) => {
|
||||
expect(() => validator.validate(permission)).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultMessage", () => {
|
||||
it("should return appropriate error message", () => {
|
||||
const message = validator.defaultMessage();
|
||||
expect(message).toContain("Permission must be a valid permission string");
|
||||
expect(message).toContain("resource.action");
|
||||
});
|
||||
});
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import type { ValidatorConstraintInterface } from "class-validator";
|
||||
import { ValidatorConstraint } from "class-validator";
|
||||
|
||||
import { isValidPermissionStringForScope } from "@calcom/platform-libraries/pbac";
|
||||
import { Scope } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
@ValidatorConstraint({ name: "orgPermissionStringValidator", async: false })
|
||||
export class OrgPermissionStringValidator implements ValidatorConstraintInterface {
|
||||
validate(permission: string) {
|
||||
const isValid = isValidPermissionStringForScope(permission, Scope.Organization);
|
||||
if (!isValid) {
|
||||
throw new BadRequestException(
|
||||
`Permission '${permission}' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')`
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return "Permission must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')";
|
||||
}
|
||||
}
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input";
|
||||
import type { CreateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/create-team-role.output";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { FeaturesRepositoryFixture } from "test/fixtures/repository/features.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { RoleService } from "@calcom/platform-libraries/pbac";
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
import type { Team, User } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
describe("Organizations Roles Permissions Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let featuresRepositoryFixture: FeaturesRepositoryFixture;
|
||||
let roleService: RoleService;
|
||||
|
||||
let pbacOrgUserWithRolePermission: User;
|
||||
let pbacOrgUserWithRolePermissionApiKey: string;
|
||||
|
||||
let pbacEnabledOrganization: Team;
|
||||
|
||||
const pbacUserEmail = `pbac-org-user-with-permissions-${randomString()}@api.com`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
}).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
featuresRepositoryFixture = new FeaturesRepositoryFixture(moduleRef);
|
||||
roleService = new RoleService();
|
||||
|
||||
// Create PBAC org
|
||||
pbacEnabledOrganization = await organizationsRepositoryFixture.create({
|
||||
name: `pbac-org-role-perm-test-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
|
||||
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledOrganization.id, "pbac");
|
||||
|
||||
// Create user + membership in org
|
||||
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
|
||||
email: pbacUserEmail,
|
||||
username: pbacUserEmail,
|
||||
});
|
||||
|
||||
const membership = await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: pbacOrgUserWithRolePermission.id } },
|
||||
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
// Create a role that allows role.read and role.update, assign to user
|
||||
const managerRole = await roleService.createRole({
|
||||
name: `role-manager-${randomString()}`,
|
||||
teamId: pbacEnabledOrganization.id,
|
||||
permissions: ["role.create", "role.read", "role.update"],
|
||||
type: "CUSTOM",
|
||||
});
|
||||
await roleService.assignRoleToMember(managerRole.id, membership.id);
|
||||
|
||||
// API key for user
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(pbacOrgUserWithRolePermission.id, null);
|
||||
pbacOrgUserWithRolePermissionApiKey = `cal_test_${keyString}`;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe("Negative Tests", () => {
|
||||
it("rejects invalid permission on add (400)", async () => {
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `neg-add-invalid-${randomString()}`,
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const roleId = createRes.body.data.id as string;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ permissions: ["invalid"] });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid permission on replace (400)", async () => {
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `neg-put-invalid-${randomString()}`,
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const roleId = createRes.body.data.id as string;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.put(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ permissions: ["invalid"] });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid permission on delete single (400)", async () => {
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `neg-delone-invalid-${randomString()}`,
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const roleId = createRes.body.data.id as string;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions/${"invalid"}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid permission on bulk delete (400)", async () => {
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `neg-delmany-invalid-${randomString()}`,
|
||||
permissions: ["booking.read", "eventType.create"],
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const roleId = createRes.body.data.id as string;
|
||||
|
||||
const invalid = "invalid";
|
||||
const res = await request(app.getHttpServer())
|
||||
.delete(
|
||||
`/v2/organizations/${
|
||||
pbacEnabledOrganization.id
|
||||
}/roles/${roleId}/permissions?permissions=${encodeURIComponent(`booking.read,${invalid}`)}`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error.message).toBe(
|
||||
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 404 when modifying permissions for role not belonging to organization", async () => {
|
||||
// Create foreign organization and role
|
||||
const foreignOrg = await organizationsRepositoryFixture.create({
|
||||
name: `foreign-org-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
const foreignRole = await roleService.createRole({
|
||||
name: `foreign-role-${randomString()}`,
|
||||
teamId: foreignOrg.id,
|
||||
permissions: ["booking.read"],
|
||||
type: "CUSTOM",
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${foreignRole.id}/permissions`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ permissions: ["eventType.read"] });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Role with id ${foreignRole.id} within team id ${pbacEnabledOrganization.id} not found`
|
||||
);
|
||||
|
||||
await organizationsRepositoryFixture.delete(foreignOrg.id);
|
||||
});
|
||||
|
||||
it("returns 404 when modifying permissions of a default (system) role not belonging to organization", async () => {
|
||||
const defaultMemberRoleId = await roleService.getDefaultRoleId(MembershipRole.MEMBER);
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultMemberRoleId}/permissions`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ permissions: ["eventType.read"] });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Role with id ${defaultMemberRoleId} within team id ${pbacEnabledOrganization.id} not found`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("lists permissions for a role (GET /)", async () => {
|
||||
const initialPermissions = ["booking.read"] as const;
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-list-${randomString()}`,
|
||||
permissions: [...initialPermissions],
|
||||
};
|
||||
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
const listRes = await request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200);
|
||||
expect(listRes.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(listRes.body.data).toEqual(initialPermissions);
|
||||
});
|
||||
|
||||
it("adds permissions (POST /)", async () => {
|
||||
const initialPermissions = ["booking.read"] as const;
|
||||
const toAdd = ["eventType.create", "eventType.read"] as const;
|
||||
const expected = [...initialPermissions, ...toAdd] as string[];
|
||||
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-add-${randomString()}`,
|
||||
permissions: [...initialPermissions],
|
||||
};
|
||||
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
const addRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ permissions: toAdd })
|
||||
.expect(200);
|
||||
expect(addRes.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(addRes.body.data).toEqual(expected);
|
||||
});
|
||||
|
||||
it("bulk removes permissions via query (DELETE /)", async () => {
|
||||
const initialPermissions: PermissionString[] = ["booking.read", "eventType.create", "eventType.read"];
|
||||
const toRemove: PermissionString[] = ["eventType.create", "eventType.read"];
|
||||
const expected: PermissionString[] = ["booking.read"];
|
||||
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-delmany-${randomString()}`,
|
||||
permissions: initialPermissions,
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete(
|
||||
`/v2/organizations/${
|
||||
pbacEnabledOrganization.id
|
||||
}/roles/${roleId}/permissions?permissions=${toRemove.join(",")}`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(204);
|
||||
|
||||
const listRes = await request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200);
|
||||
expect(listRes.body.data).toEqual(expected);
|
||||
});
|
||||
|
||||
it("replaces all permissions (PUT /)", async () => {
|
||||
const initialPermissions = ["booking.read"] as const;
|
||||
const replacement = ["booking.read", "eventType.update"] as const;
|
||||
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-put-${randomString()}`,
|
||||
permissions: [...initialPermissions],
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
const putRes = await request(app.getHttpServer())
|
||||
.put(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ permissions: replacement })
|
||||
.expect(200);
|
||||
|
||||
expect(putRes.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(putRes.body.data).toEqual(replacement);
|
||||
});
|
||||
|
||||
it("removes a single permission (DELETE /:permission)", async () => {
|
||||
const initialPermissions = ["booking.read", "eventType.update"] as const;
|
||||
const toRemove = "eventType.update" as const;
|
||||
const expected = ["booking.read"] as const;
|
||||
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-delone-${randomString()}`,
|
||||
permissions: [...initialPermissions],
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions/${toRemove}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(204);
|
||||
|
||||
const listRes = await request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200);
|
||||
expect(listRes.body.data).toEqual(expected);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
// Clean up feature flag from organization
|
||||
await featuresRepositoryFixture.deleteTeamFeature(pbacEnabledOrganization.id, "pbac");
|
||||
|
||||
// Clean up org
|
||||
await organizationsRepositoryFixture.delete(pbacEnabledOrganization.id);
|
||||
|
||||
// Clean up user
|
||||
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithRolePermission.email);
|
||||
|
||||
// Clean up feature definition
|
||||
try {
|
||||
await featuresRepositoryFixture.deleteBySlug("pbac");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import {
|
||||
OPTIONAL_API_KEY_HEADER,
|
||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||
} from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { CreateOrgRolePermissionsInput } from "@/modules/organizations/roles/permissions/inputs/create-org-role-permissions.input";
|
||||
import { DeleteOrgRolePermissionsQuery } from "@/modules/organizations/roles/permissions/inputs/delete-org-role-permissions.query";
|
||||
import { GetOrgRolePermissionsOutput } from "@/modules/organizations/roles/permissions/outputs/get-org-role-permissions.output";
|
||||
import { RolesPermissionsService } from "@/modules/roles/permissions/services/roles-permissions.service";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Put,
|
||||
Query,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/roles/:roleId/permissions",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, PbacGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Orgs / Roles / Permissions")
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
export class OrganizationsRolesPermissionsController {
|
||||
constructor(private readonly rolePermissionsService: RolesPermissionsService) {}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Add permissions to an organization role (single or batch)" })
|
||||
async addPermissions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Body() body: CreateOrgRolePermissionsInput
|
||||
): Promise<GetOrgRolePermissionsOutput> {
|
||||
const permissions = await this.rolePermissionsService.addRolePermissions(orgId, roleId, body.permissions);
|
||||
return { status: SUCCESS_STATUS, data: permissions };
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.read"])
|
||||
@Get("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "List permissions for an organization role" })
|
||||
async listPermissions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("roleId") roleId: string
|
||||
): Promise<GetOrgRolePermissionsOutput> {
|
||||
const permissions = await this.rolePermissionsService.getRolePermissions(orgId, roleId);
|
||||
return { status: SUCCESS_STATUS, data: permissions };
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Put("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Replace all permissions for an organization role" })
|
||||
async setPermissions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Body() body: CreateOrgRolePermissionsInput
|
||||
): Promise<GetOrgRolePermissionsOutput> {
|
||||
const permissions = await this.rolePermissionsService.setRolePermissions(
|
||||
orgId,
|
||||
roleId,
|
||||
body.permissions || []
|
||||
);
|
||||
return { status: SUCCESS_STATUS, data: permissions };
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Delete("/:permission")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: "Remove a permission from an organization role" })
|
||||
async removePermission(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Param("permission") permission: PermissionString
|
||||
): Promise<void> {
|
||||
await this.rolePermissionsService.removeRolePermission(orgId, roleId, permission);
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Delete("/")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: "Remove multiple permissions from an organization role" })
|
||||
async removePermissions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Query() query: DeleteOrgRolePermissionsQuery
|
||||
): Promise<void> {
|
||||
await this.rolePermissionsService.removeRolePermissions(orgId, roleId, query.permissions || []);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsArray, IsString } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetOrgRolePermissionsOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS })
|
||||
status!: typeof SUCCESS_STATUS;
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
data!: string[];
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
|
||||
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { Role } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsRolesOutputService {
|
||||
getOrganizationRoleOutput(role: Role): OrgRoleOutput {
|
||||
return {
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
color: role.color || null,
|
||||
description: role.description || null,
|
||||
organizationId: role.teamId || null,
|
||||
type: role.type,
|
||||
permissions: role.permissions.map((permission) => `${permission.resource}.${permission.action}`),
|
||||
createdAt: role.createdAt.toISOString(),
|
||||
updatedAt: role.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
getOrganizationRolesOutput(roles: Role[]): TeamRoleOutput[] {
|
||||
return roles.map((role) => this.getOrganizationRoleOutput(role));
|
||||
}
|
||||
}
|
||||
+10
@@ -73,6 +73,16 @@ export class GetOrganizationsTeamsBookingsInput_2024_08_13 {
|
||||
})
|
||||
attendeeName?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: false,
|
||||
description: "Filter bookings by the booking Uid.",
|
||||
example: "2NtaeaVcKfpmSZ4CthFdfk",
|
||||
})
|
||||
bookingUid?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
|
||||
+25
-2
@@ -60,6 +60,7 @@ describe("Organizations TeamsBookings Endpoints 2024-08-13", () => {
|
||||
let teamUser2: User;
|
||||
|
||||
let team1EventTypeId: number;
|
||||
let bookingUid: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
@@ -237,6 +238,7 @@ describe("Organizations TeamsBookings Endpoints 2024-08-13", () => {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
bookingUid = data.uid;
|
||||
expect(data.hosts.length).toEqual(1);
|
||||
expect(data.hosts[0].id).toEqual(teamUser.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
@@ -308,6 +310,27 @@ describe("Organizations TeamsBookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should get bookings by teamId and bookingUid", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${organization.id}/teams/${team1.id}/bookings?bookingUid=${bookingUid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(1);
|
||||
expect(data[0].uid).toEqual(bookingUid);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not get bookings by teamId and non existing eventTypeId", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${organization.id}/teams/${team1.id}/bookings?eventTypeId=90909`)
|
||||
@@ -336,8 +359,8 @@ describe("Organizations TeamsBookings Endpoints 2024-08-13", () => {
|
||||
return client;
|
||||
}
|
||||
|
||||
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data !== null && "id" in data;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
+203
@@ -418,6 +418,209 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
// Auto-accept tests
|
||||
describe("auto-accept based on email domain", () => {
|
||||
let orgWithAutoAccept: Team;
|
||||
let subteamWithAutoAccept: Team;
|
||||
let userWithMatchingEmail: User;
|
||||
let userWithUppercaseEmail: User;
|
||||
let userWithMatchingEmailForOverride: User;
|
||||
let userWithNonMatchingEmail: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create org with auto-accept settings
|
||||
orgWithAutoAccept = await organizationsRepositoryFixture.create({
|
||||
name: `auto-accept-org-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
// Update organizationSettings with orgAutoAcceptEmail
|
||||
await organizationsRepositoryFixture.updateSettings(orgWithAutoAccept.id, {
|
||||
orgAutoAcceptEmail: "acme.com",
|
||||
isOrganizationVerified: true,
|
||||
isOrganizationConfigured: true,
|
||||
});
|
||||
|
||||
// Create subteam
|
||||
subteamWithAutoAccept = await teamsRepositoryFixture.create({
|
||||
name: `auto-accept-subteam-${randomString()}`,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: orgWithAutoAccept.id } },
|
||||
});
|
||||
|
||||
// Create event type with assignAllTeamMembers
|
||||
await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: { connect: { id: subteamWithAutoAccept.id } },
|
||||
title: "Auto Accept Event Type",
|
||||
slug: "auto-accept-event-type",
|
||||
length: 30,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
});
|
||||
|
||||
// Create users
|
||||
userWithMatchingEmail = await userRepositoryFixture.create({
|
||||
email: `alice@acme.com`,
|
||||
username: `alice-${randomString()}`,
|
||||
});
|
||||
|
||||
userWithUppercaseEmail = await userRepositoryFixture.create({
|
||||
email: `bob@ACME.COM`,
|
||||
username: `bob-${randomString()}`,
|
||||
});
|
||||
|
||||
userWithMatchingEmailForOverride = await userRepositoryFixture.create({
|
||||
email: `david@acme.com`,
|
||||
username: `david-${randomString()}`,
|
||||
});
|
||||
|
||||
userWithNonMatchingEmail = await userRepositoryFixture.create({
|
||||
email: `charlie@external.com`,
|
||||
username: `charlie-${randomString()}`,
|
||||
});
|
||||
|
||||
// Add users to org
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
user: { connect: { id: userWithMatchingEmail.id } },
|
||||
team: { connect: { id: orgWithAutoAccept.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
user: { connect: { id: userWithUppercaseEmail.id } },
|
||||
team: { connect: { id: orgWithAutoAccept.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
user: { connect: { id: userWithMatchingEmailForOverride.id } },
|
||||
team: { connect: { id: orgWithAutoAccept.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
user: { connect: { id: userWithNonMatchingEmail.id } },
|
||||
team: { connect: { id: orgWithAutoAccept.id } },
|
||||
});
|
||||
|
||||
// Create profiles for users
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${userWithMatchingEmail.id}`,
|
||||
username: userWithMatchingEmail.username || `user-${userWithMatchingEmail.id}`,
|
||||
organization: { connect: { id: orgWithAutoAccept.id } },
|
||||
user: { connect: { id: userWithMatchingEmail.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${userWithUppercaseEmail.id}`,
|
||||
username: userWithUppercaseEmail.username || `user-${userWithUppercaseEmail.id}`,
|
||||
organization: { connect: { id: orgWithAutoAccept.id } },
|
||||
user: { connect: { id: userWithUppercaseEmail.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${userWithMatchingEmailForOverride.id}`,
|
||||
username: userWithMatchingEmailForOverride.username || `user-${userWithMatchingEmailForOverride.id}`,
|
||||
organization: { connect: { id: orgWithAutoAccept.id } },
|
||||
user: { connect: { id: userWithMatchingEmailForOverride.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${userWithNonMatchingEmail.id}`,
|
||||
username: userWithNonMatchingEmail.username || `user-${userWithNonMatchingEmail.id}`,
|
||||
organization: { connect: { id: orgWithAutoAccept.id } },
|
||||
user: { connect: { id: userWithNonMatchingEmail.id } },
|
||||
});
|
||||
|
||||
// Make user an admin of the org for API access
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
accepted: true,
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: orgWithAutoAccept.id } },
|
||||
});
|
||||
});
|
||||
|
||||
it("should auto-accept when email matches orgAutoAcceptEmail", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
|
||||
.send({
|
||||
userId: userWithMatchingEmail.id,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
|
||||
expect(responseBody.data.accepted).toBe(true);
|
||||
|
||||
// Verify EventTypes assignment
|
||||
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(
|
||||
subteamWithAutoAccept.id
|
||||
);
|
||||
const eventTypeWithAssignAll = eventTypes.find((et) => et.assignAllTeamMembers);
|
||||
expect(eventTypeWithAssignAll).toBeTruthy();
|
||||
const userIsHost = eventTypeWithAssignAll?.hosts.some((h) => h.userId === userWithMatchingEmail.id);
|
||||
expect(userIsHost).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle case-insensitive email domain matching", async () => {
|
||||
// User with email="bob@ACME.COM" should match orgAutoAcceptEmail="acme.com"
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
|
||||
.send({
|
||||
userId: userWithUppercaseEmail.id,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
|
||||
expect(responseBody.data.accepted).toBe(true);
|
||||
});
|
||||
|
||||
it("should ALWAYS auto-accept when email matches, even if accepted:false", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
|
||||
.send({
|
||||
userId: userWithMatchingEmailForOverride.id,
|
||||
role: "MEMBER",
|
||||
accepted: false,
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
|
||||
// Should override to true because email matches
|
||||
expect(responseBody.data.accepted).toBe(true);
|
||||
});
|
||||
|
||||
it("should NOT auto-accept when email does not match orgAutoAcceptEmail", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
|
||||
.send({
|
||||
userId: userWithNonMatchingEmail.id,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
|
||||
expect(responseBody.data.accepted).toBe(false);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(userWithMatchingEmail.email);
|
||||
await userRepositoryFixture.deleteByEmail(userWithUppercaseEmail.email);
|
||||
await userRepositoryFixture.deleteByEmail(userWithMatchingEmailForOverride.email);
|
||||
await userRepositoryFixture.deleteByEmail(userWithNonMatchingEmail.email);
|
||||
await organizationsRepositoryFixture.delete(orgWithAutoAccept.id);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
|
||||
|
||||
+22
-3
@@ -12,6 +12,7 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { OrganizationMembershipService } from "@/lib/services/organization-membership.service";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
|
||||
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input";
|
||||
@@ -58,8 +59,9 @@ export class OrganizationsTeamsMembershipsController {
|
||||
|
||||
constructor(
|
||||
private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
|
||||
private readonly organizationsRepository: OrganizationsRepository
|
||||
) {}
|
||||
private readonly organizationsRepository: OrganizationsRepository,
|
||||
private readonly orgMembershipService: OrganizationMembershipService
|
||||
) { }
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get all memberships" })
|
||||
@@ -168,6 +170,9 @@ export class OrganizationsTeamsMembershipsController {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// TODO: Refactor to use inviteMembersWithNoInviterPermissionCheck when it is moved to a Service
|
||||
// See: packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Post("/")
|
||||
@@ -184,7 +189,21 @@ export class OrganizationsTeamsMembershipsController {
|
||||
throw new UnprocessableEntityException("User is not part of the Organization");
|
||||
}
|
||||
|
||||
const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(teamId, data);
|
||||
const shouldAutoAccept = await this.orgMembershipService.shouldAutoAccept({
|
||||
organizationId: orgId,
|
||||
userEmail: user.email,
|
||||
});
|
||||
|
||||
// ALWAYS override when email matches - prevents pending memberships
|
||||
// Remember organizations expect added team member to automatically start receiving bookings for the team event
|
||||
const acceptedStatus = shouldAutoAccept ? true : (data.accepted ?? false);
|
||||
|
||||
const membershipData = { ...data, accepted: acceptedStatus };
|
||||
const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(
|
||||
teamId,
|
||||
membershipData
|
||||
);
|
||||
|
||||
if (membership.accepted) {
|
||||
try {
|
||||
await updateNewTeamMemberEventTypes(user.id, teamId);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsArray, IsOptional, IsString, Validate } from "class-validator";
|
||||
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
import { getAllPermissionStringsForScope, Scope } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
import { TeamPermissionStringValidator } from "../permissions/inputs/validators/team-permission-string.validator";
|
||||
|
||||
export const teamPermissionEnum = [...getAllPermissionStringsForScope(Scope.Team)] as const;
|
||||
|
||||
export class BaseTeamRoleInput {
|
||||
@ApiPropertyOptional({ description: "Color for the role (hex code)" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
color?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Description of the role" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Permissions for this role (format: resource.action). On update, this field replaces the entire permission set for the role (full replace). Use granular permission endpoints for one-by-one changes.",
|
||||
enum: teamPermissionEnum,
|
||||
isArray: true,
|
||||
example: ["eventType.read", "eventType.create", "booking.read"],
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@Validate(TeamPermissionStringValidator, { each: true })
|
||||
@IsOptional()
|
||||
permissions?: PermissionString[];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
import { BaseTeamRoleInput } from "./base-team-role.input";
|
||||
|
||||
export class CreateTeamRoleInput extends BaseTeamRoleInput {
|
||||
@ApiProperty({ description: "Name of the role", minLength: 1 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsString, IsOptional, MinLength } from "class-validator";
|
||||
|
||||
import { BaseTeamRoleInput } from "./base-team-role.input";
|
||||
|
||||
export class UpdateTeamRoleInput extends BaseTeamRoleInput {
|
||||
@ApiPropertyOptional({ description: "Name of the role", minLength: 1 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
}
|
||||
+469
@@ -0,0 +1,469 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input";
|
||||
import { UpdateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/update-team-role.input";
|
||||
import { CreateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/create-team-role.output";
|
||||
import { DeleteTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/delete-team-role.output";
|
||||
import { GetAllTeamRolesOutput } from "@/modules/organizations/teams/roles/outputs/get-all-team-roles.output";
|
||||
import { GetTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/get-team-role.output";
|
||||
import { UpdateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/update-team-role.output";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { FeaturesRepositoryFixture } from "test/fixtures/repository/features.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { RoleService } from "@calcom/platform-libraries/pbac";
|
||||
import type { User, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Roles Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let featuresRepositoryFixture: FeaturesRepositoryFixture;
|
||||
let roleService: RoleService;
|
||||
|
||||
// Test users
|
||||
let legacyOrgAdminUser: User;
|
||||
let legacyOrgMemberUser: User;
|
||||
let pbacOrgUserWithRolePermission: User;
|
||||
let pbacOrgUserWithoutRolePermission: User;
|
||||
let nonOrgUser: User;
|
||||
|
||||
// API Keys
|
||||
let legacyOrgAdminApiKey: string;
|
||||
let legacyOrgMemberApiKey: string;
|
||||
let pbacOrgUserWithRolePermissionApiKey: string;
|
||||
let pbacOrgUserWithoutRolePermissionApiKey: string;
|
||||
let nonOrgUserApiKey: string;
|
||||
|
||||
// Organization and team
|
||||
let organization: Team;
|
||||
let team: Team;
|
||||
let pbacEnabledOrganization: Team;
|
||||
let pbacEnabledTeam: Team;
|
||||
|
||||
const legacyOrgAdminEmail = `legacy-org-admin-${randomString()}@api.com`;
|
||||
const legacyOrgMemberEmail = `legacy-org-member-${randomString()}@api.com`;
|
||||
const pbacOrgUserWithRolePermissionEmail = `pbac-org-user-with-role-permission-${randomString()}@api.com`;
|
||||
const pbacOrgUserWithoutRolePermissionEmail = `pbac-org-user-without-role-permission-${randomString()}@api.com`;
|
||||
const nonOrgUserEmail = `non-org-user-${randomString()}@api.com`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
}).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
featuresRepositoryFixture = new FeaturesRepositoryFixture(moduleRef);
|
||||
roleService = new RoleService();
|
||||
|
||||
// Create test users
|
||||
legacyOrgAdminUser = await userRepositoryFixture.create({
|
||||
email: legacyOrgAdminEmail,
|
||||
username: legacyOrgAdminEmail,
|
||||
});
|
||||
|
||||
legacyOrgMemberUser = await userRepositoryFixture.create({
|
||||
email: legacyOrgMemberEmail,
|
||||
username: legacyOrgMemberEmail,
|
||||
});
|
||||
|
||||
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
|
||||
email: pbacOrgUserWithRolePermissionEmail,
|
||||
username: pbacOrgUserWithRolePermissionEmail,
|
||||
});
|
||||
|
||||
pbacOrgUserWithoutRolePermission = await userRepositoryFixture.create({
|
||||
email: pbacOrgUserWithoutRolePermissionEmail,
|
||||
username: pbacOrgUserWithoutRolePermissionEmail,
|
||||
});
|
||||
|
||||
nonOrgUser = await userRepositoryFixture.create({
|
||||
email: nonOrgUserEmail,
|
||||
username: nonOrgUserEmail,
|
||||
});
|
||||
|
||||
// Create organizations and teams
|
||||
organization = await organizationsRepositoryFixture.create({
|
||||
name: `org-roles-test-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
team = await teamRepositoryFixture.create({
|
||||
name: `team-roles-test-${randomString()}`,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
pbacEnabledOrganization = await organizationsRepositoryFixture.create({
|
||||
name: `pbac-org-roles-test-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
pbacEnabledTeam = await teamRepositoryFixture.create({
|
||||
name: `pbac-team-roles-test-${randomString()}`,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
|
||||
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledTeam.id, "pbac");
|
||||
|
||||
// Create memberships
|
||||
await membershipRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: legacyOrgAdminUser.id } },
|
||||
team: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: legacyOrgMemberUser.id } },
|
||||
team: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
const pbacOrgUserWithRolePermissionMembership = await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: pbacOrgUserWithRolePermission.id } },
|
||||
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
const pbacOrgUserWithoutRolePermissionMembership = await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: pbacOrgUserWithoutRolePermission.id } },
|
||||
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
const roleWithPermission = await roleService.createRole({
|
||||
name: "Role Manager",
|
||||
teamId: pbacEnabledOrganization.id,
|
||||
permissions: ["role.create", "role.read", "role.update", "role.delete"],
|
||||
type: "CUSTOM",
|
||||
});
|
||||
|
||||
const roleWithoutPermission = await roleService.createRole({
|
||||
name: "Basic Role",
|
||||
teamId: pbacEnabledOrganization.id,
|
||||
permissions: ["booking.read"],
|
||||
type: "CUSTOM",
|
||||
});
|
||||
|
||||
await roleService.assignRoleToMember(roleWithPermission.id, pbacOrgUserWithRolePermissionMembership.id);
|
||||
await roleService.assignRoleToMember(
|
||||
roleWithoutPermission.id,
|
||||
pbacOrgUserWithoutRolePermissionMembership.id
|
||||
);
|
||||
|
||||
const { keyString: legacyOrgAdminKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
legacyOrgAdminUser.id,
|
||||
null
|
||||
);
|
||||
legacyOrgAdminApiKey = `cal_test_${legacyOrgAdminKeyString}`;
|
||||
|
||||
const { keyString: legacyOrgMemberKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
legacyOrgMemberUser.id,
|
||||
null
|
||||
);
|
||||
legacyOrgMemberApiKey = `cal_test_${legacyOrgMemberKeyString}`;
|
||||
|
||||
const { keyString: pbacOrgUserWithRolePermissionKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
pbacOrgUserWithRolePermission.id,
|
||||
null
|
||||
);
|
||||
pbacOrgUserWithRolePermissionApiKey = `cal_test_${pbacOrgUserWithRolePermissionKeyString}`;
|
||||
|
||||
const { keyString: pbacOrgUserWithoutRolePermissionKeyString } =
|
||||
await apiKeysRepositoryFixture.createApiKey(pbacOrgUserWithoutRolePermission.id, null);
|
||||
pbacOrgUserWithoutRolePermissionApiKey = `cal_test_${pbacOrgUserWithoutRolePermissionKeyString}`;
|
||||
|
||||
const { keyString: nonOrgUserKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
nonOrgUser.id,
|
||||
null
|
||||
);
|
||||
nonOrgUserApiKey = `cal_test_${nonOrgUserKeyString}`;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe("Role Creation Authorization", () => {
|
||||
describe("Positive Tests", () => {
|
||||
it("should allow role creation when organization has PBAC enabled and user has a create permission", async () => {
|
||||
const createRoleInput: CreateTeamRoleInput = {
|
||||
name: "Test Role PBAC",
|
||||
permissions: ["booking.read", "eventType.create"],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: CreateTeamRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(createRoleInput.name);
|
||||
expect(responseBody.data.permissions).toEqual(createRoleInput.permissions);
|
||||
expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow role creation when organization does not have PBAC enabled and user is org admin", async () => {
|
||||
const createRoleInput: CreateTeamRoleInput = {
|
||||
name: "Test Role Legacy Admin",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${organization.id}/teams/${team.id}/roles`)
|
||||
.set("Authorization", `Bearer ${legacyOrgAdminApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: CreateTeamRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(createRoleInput.name);
|
||||
expect(responseBody.data.teamId).toEqual(team.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Negative Tests", () => {
|
||||
it("should not allow role creation when organization has PBAC enabled but user has no role assigned", async () => {
|
||||
const createRoleInput: CreateTeamRoleInput = {
|
||||
name: "Test Role No Role",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
const userWithNoRole = await userRepositoryFixture.create({
|
||||
email: `no-role-user-${randomString()}@api.com`,
|
||||
username: `no-role-user-${randomString()}@api.com`,
|
||||
});
|
||||
|
||||
await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: userWithNoRole.id } },
|
||||
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
const { keyString: noRoleKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
userWithNoRole.id,
|
||||
null
|
||||
);
|
||||
const noRoleApiKey = `cal_test_${noRoleKeyString}`;
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${noRoleApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not allow role creation when organization has PBAC enabled but user role lacks required permission", async () => {
|
||||
const createRoleInput: CreateTeamRoleInput = {
|
||||
name: "Test Role No Permission",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithoutRolePermissionApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not allow role creation when organization does not have PBAC enabled and user has no membership", async () => {
|
||||
const createRoleInput: CreateTeamRoleInput = {
|
||||
name: "Test Role No Membership",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${organization.id}/teams/${team.id}/roles`)
|
||||
.set("Authorization", `Bearer ${nonOrgUserApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not allow role creation when organization does not have PBAC enabled and user has member membership (not admin)", async () => {
|
||||
const createRoleInput: CreateTeamRoleInput = {
|
||||
name: "Test Role Member Only",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${organization.id}/teams/${team.id}/roles`)
|
||||
.set("Authorization", `Bearer ${legacyOrgMemberApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CRUD Role Endpoints", () => {
|
||||
let createdRoleId: string;
|
||||
|
||||
it("should create a role", async () => {
|
||||
const createRoleInput: CreateTeamRoleInput = {
|
||||
name: "CRUD Test Role",
|
||||
permissions: ["booking.read", "eventType.create"],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: CreateTeamRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(createRoleInput.name);
|
||||
expect(responseBody.data.permissions).toEqual(createRoleInput.permissions);
|
||||
expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id);
|
||||
createdRoleId = responseBody.data.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update role permissions and name", async () => {
|
||||
const updateRoleInput: UpdateTeamRoleInput = {
|
||||
name: "CRUD Test Role Updated",
|
||||
permissions: ["booking.read", "eventType.read"],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${createdRoleId}`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(updateRoleInput)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: UpdateTeamRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(createdRoleId);
|
||||
expect(responseBody.data.name).toEqual(updateRoleInput.name);
|
||||
expect(responseBody.data.permissions).toEqual(updateRoleInput.permissions);
|
||||
expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update only name and keep permissions unchanged", async () => {
|
||||
const updateNameOnly: UpdateTeamRoleInput = {
|
||||
name: "CRUD Test Role Renamed Only",
|
||||
};
|
||||
const expectedPermissions = ["booking.read", "eventType.read"]; // from previous update
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${createdRoleId}`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(updateNameOnly)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: UpdateTeamRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(createdRoleId);
|
||||
expect(responseBody.data.name).toEqual(updateNameOnly.name);
|
||||
expect(responseBody.data.permissions).toEqual(expectedPermissions);
|
||||
expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch the role", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${createdRoleId}`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(createdRoleId);
|
||||
expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch all roles", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetAllTeamRolesOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(Array.isArray(responseBody.data)).toBe(true);
|
||||
expect(responseBody.data.find((r) => r.id === createdRoleId)).toBeDefined();
|
||||
const created = responseBody.data.find((r) => r.id === createdRoleId);
|
||||
expect(created?.teamId).toEqual(pbacEnabledTeam.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete the role", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${createdRoleId}`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: DeleteTeamRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(createdRoleId);
|
||||
expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await featuresRepositoryFixture.deleteTeamFeature(pbacEnabledTeam.id, "pbac");
|
||||
|
||||
await teamRepositoryFixture.delete(team.id);
|
||||
await teamRepositoryFixture.delete(pbacEnabledTeam.id);
|
||||
|
||||
await organizationsRepositoryFixture.delete(organization.id);
|
||||
await organizationsRepositoryFixture.delete(pbacEnabledOrganization.id);
|
||||
|
||||
await userRepositoryFixture.deleteByEmail(legacyOrgAdminUser.email);
|
||||
await userRepositoryFixture.deleteByEmail(legacyOrgMemberUser.email);
|
||||
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithRolePermission.email);
|
||||
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithoutRolePermission.email);
|
||||
await userRepositoryFixture.deleteByEmail(nonOrgUser.email);
|
||||
|
||||
try {
|
||||
await featuresRepositoryFixture.deleteBySlug("pbac");
|
||||
} catch (error) {
|
||||
console.error("Cleanup error:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Cleanup error:", error);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import {
|
||||
OPTIONAL_API_KEY_HEADER,
|
||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||
} from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input";
|
||||
import { UpdateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/update-team-role.input";
|
||||
import { CreateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/create-team-role.output";
|
||||
import { DeleteTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/delete-team-role.output";
|
||||
import { GetAllTeamRolesOutput } from "@/modules/organizations/teams/roles/outputs/get-all-team-roles.output";
|
||||
import { GetTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/get-team-role.output";
|
||||
import { UpdateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/update-team-role.output";
|
||||
import { TeamRolesOutputService } from "@/modules/organizations/teams/roles/services/team-roles-output.service";
|
||||
import { RolesService } from "@/modules/roles/services/roles.service";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
Delete,
|
||||
Patch,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams/:teamId/roles",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(
|
||||
ApiAuthGuard,
|
||||
IsOrgGuard,
|
||||
PbacGuard,
|
||||
RolesGuard,
|
||||
IsTeamInOrg,
|
||||
PlatformPlanGuard,
|
||||
IsAdminAPIEnabledGuard
|
||||
)
|
||||
@DocsTags("Orgs / Teams / Roles")
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
export class OrganizationsTeamsRolesController {
|
||||
constructor(
|
||||
private readonly rolesService: RolesService,
|
||||
private readonly rolesOutputService: TeamRolesOutputService
|
||||
) {}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.create"])
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: "Create a new organization team role" })
|
||||
async createRole(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Body() body: CreateTeamRoleInput
|
||||
): Promise<CreateTeamRoleOutput> {
|
||||
const role = await this.rolesService.createRole(teamId, body);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getTeamRoleOutput(role),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.read"])
|
||||
@Get("/:roleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get a specific organization team role" })
|
||||
async getRole(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("roleId") roleId: string
|
||||
): Promise<GetTeamRoleOutput> {
|
||||
const role = await this.rolesService.getRole(teamId, roleId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getTeamRoleOutput(role),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.read"])
|
||||
@Get("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get all organization team roles" })
|
||||
async getAllRoles(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<GetAllTeamRolesOutput> {
|
||||
const { skip, take } = queryParams;
|
||||
const roles = await this.rolesService.getTeamRoles(teamId, skip ?? 0, take ?? 250);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getTeamRolesOutput(roles),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Patch("/:roleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Update an organization team role" })
|
||||
async updateRole(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Body() body: UpdateTeamRoleInput
|
||||
): Promise<UpdateTeamRoleOutput> {
|
||||
const role = await this.rolesService.updateRole(teamId, roleId, body);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getTeamRoleOutput(role),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.delete"])
|
||||
@Delete("/:roleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Delete an organization team role" })
|
||||
async deleteRole(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("roleId") roleId: string
|
||||
): Promise<DeleteTeamRoleOutput> {
|
||||
const role = await this.rolesService.deleteRole(teamId, roleId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getTeamRoleOutput(role),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { OrganizationsTeamsRolesPermissionsController } from "@/modules/organizations/teams/roles/permissions/organizations-teams-roles-permissions.controller";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RolesModule } from "@/modules/roles/roles.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { OrganizationsTeamsRolesController } from "./organizations-teams-roles.controller";
|
||||
|
||||
@Module({
|
||||
imports: [StripeModule, PrismaModule, RedisModule, MembershipsModule, RolesModule],
|
||||
providers: [OrganizationsRepository, OrganizationsTeamsRepository],
|
||||
controllers: [OrganizationsTeamsRolesController, OrganizationsTeamsRolesPermissionsController],
|
||||
})
|
||||
export class OrganizationsTeamsRolesModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class CreateTeamRoleOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamRoleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => TeamRoleOutput)
|
||||
data!: TeamRoleOutput;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class DeleteTeamRoleOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamRoleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => TeamRoleOutput)
|
||||
data!: TeamRoleOutput;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsArray, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetAllTeamRolesOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: [TeamRoleOutput],
|
||||
})
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TeamRoleOutput)
|
||||
data!: TeamRoleOutput[];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetTeamRoleOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamRoleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => TeamRoleOutput)
|
||||
data!: TeamRoleOutput;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsString, IsOptional, IsArray, IsEnum, IsDateString, IsNumber } from "class-validator";
|
||||
|
||||
import { teamPermissionEnum } from "../inputs/base-team-role.input";
|
||||
|
||||
enum RoleTypeEnum {
|
||||
SYSTEM = "SYSTEM",
|
||||
CUSTOM = "CUSTOM",
|
||||
}
|
||||
|
||||
type RoleType = keyof typeof RoleTypeEnum;
|
||||
|
||||
export class TeamRoleOutput {
|
||||
@ApiProperty({ description: "Unique identifier for the role" })
|
||||
@IsString()
|
||||
@Expose()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: "Name of the role" })
|
||||
@IsString()
|
||||
@Expose()
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Color for the role (hex code)" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
color?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: "Description of the role" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
description?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: "Team ID this role belongs to" })
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
teamId?: number | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Type of role",
|
||||
enum: RoleTypeEnum,
|
||||
})
|
||||
@IsEnum(RoleTypeEnum)
|
||||
@Expose()
|
||||
type!: RoleType;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Permissions assigned to this role in 'resource.action' format.",
|
||||
enum: teamPermissionEnum,
|
||||
isArray: true,
|
||||
example: ["booking.read", "eventType.create"],
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@Expose()
|
||||
permissions!: string[];
|
||||
|
||||
@ApiProperty({ description: "When the role was created" })
|
||||
@IsDateString()
|
||||
@Expose()
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty({ description: "When the role was last updated" })
|
||||
@IsDateString()
|
||||
@Expose()
|
||||
updatedAt!: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class UpdateTeamRoleOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamRoleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => TeamRoleOutput)
|
||||
data!: TeamRoleOutput;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsArray, IsString, Validate } from "class-validator";
|
||||
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
import { teamPermissionEnum } from "../../inputs/base-team-role.input";
|
||||
import { TeamPermissionStringValidator } from "./validators/team-permission-string.validator";
|
||||
|
||||
export class CreateTeamRolePermissionsInput {
|
||||
@ApiProperty({
|
||||
description: "Permissions to add (format: resource.action)",
|
||||
enum: teamPermissionEnum,
|
||||
isArray: true,
|
||||
example: ["eventType.read", "booking.read"],
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@Validate(TeamPermissionStringValidator, { each: true })
|
||||
permissions!: PermissionString[];
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { ArrayNotEmpty, IsArray, IsOptional, IsString, Validate } from "class-validator";
|
||||
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
import { teamPermissionEnum } from "../../inputs/base-team-role.input";
|
||||
import { TeamPermissionStringValidator } from "./validators/team-permission-string.validator";
|
||||
|
||||
export class DeleteTeamRolePermissionsQuery {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Permissions to remove (format: resource.action). Supports comma-separated values as well as repeated query params.",
|
||||
example: "?permissions=eventType.read,booking.read",
|
||||
enum: teamPermissionEnum,
|
||||
isArray: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((p: string) => p.trim())
|
||||
.filter((p: string) => p.length > 0);
|
||||
}
|
||||
return value;
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty({ message: "permissions cannot be empty." })
|
||||
@IsString({ each: true })
|
||||
@Validate(TeamPermissionStringValidator, { each: true })
|
||||
permissions?: PermissionString[];
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { TeamPermissionStringValidator } from "./team-permission-string.validator";
|
||||
|
||||
describe("PermissionStringValidator", () => {
|
||||
let validator: TeamPermissionStringValidator;
|
||||
|
||||
beforeEach(() => {
|
||||
validator = new TeamPermissionStringValidator();
|
||||
});
|
||||
|
||||
describe("validate", () => {
|
||||
it("should return true for valid permission strings", () => {
|
||||
const validPermissions = [
|
||||
"eventType.read",
|
||||
"eventType.create",
|
||||
"eventType.update",
|
||||
"eventType.delete",
|
||||
"booking.read",
|
||||
"booking.update",
|
||||
"role.create",
|
||||
"role.read",
|
||||
"role.update",
|
||||
"role.delete",
|
||||
"team.read",
|
||||
"team.invite",
|
||||
];
|
||||
|
||||
validPermissions.forEach((permission) => {
|
||||
expect(validator.validate(permission)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw for wildcard permission strings", () => {
|
||||
const wildcardPermissions = ["*", "*.read", "eventType.*"];
|
||||
|
||||
wildcardPermissions.forEach((permission) => {
|
||||
expect(() => validator.validate(permission)).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw for invalid permission strings", () => {
|
||||
const invalidPermissions = [
|
||||
"invalid", // no dot
|
||||
"invalid.", // no action
|
||||
".invalid", // no resource
|
||||
"invalid.action", // invalid resource
|
||||
"eventType.invalid", // invalid action
|
||||
"event-type.read", // wrong format (should be eventType)
|
||||
"", // empty string
|
||||
"eventType..read", // double dot
|
||||
"eventType.read.extra", // too many parts
|
||||
];
|
||||
|
||||
invalidPermissions.forEach((permission) => {
|
||||
expect(() => validator.validate(permission)).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw for organization-scoped permission strings", () => {
|
||||
// These are explicitly marked with scope: [Scope.Organization] in permission-registry
|
||||
const orgScoped = [
|
||||
"organization.create",
|
||||
"organization.read",
|
||||
"organization.listMembers",
|
||||
"organization.listMembersPrivate",
|
||||
"organization.invite",
|
||||
"organization.remove",
|
||||
"organization.manageBilling",
|
||||
"organization.changeMemberRole",
|
||||
"organization.impersonate",
|
||||
"organization.update",
|
||||
];
|
||||
|
||||
orgScoped.forEach((permission) => {
|
||||
expect(() => validator.validate(permission)).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultMessage", () => {
|
||||
it("should return appropriate error message", () => {
|
||||
const message = validator.defaultMessage();
|
||||
expect(message).toContain("Permission must be a valid permission string");
|
||||
expect(message).toContain("resource.action");
|
||||
});
|
||||
});
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import type { ValidatorConstraintInterface } from "class-validator";
|
||||
import { ValidatorConstraint } from "class-validator";
|
||||
|
||||
import { isValidPermissionStringForScope } from "@calcom/platform-libraries/pbac";
|
||||
import { Scope } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
@ValidatorConstraint({ name: "teamPermissionStringValidator", async: false })
|
||||
export class TeamPermissionStringValidator implements ValidatorConstraintInterface {
|
||||
validate(permission: string) {
|
||||
const isValid = isValidPermissionStringForScope(permission, Scope.Team);
|
||||
if (!isValid) {
|
||||
throw new BadRequestException(
|
||||
`Permission '${permission}' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')`
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return "Permission must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')";
|
||||
}
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input";
|
||||
import type { CreateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/create-team-role.output";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { FeaturesRepositoryFixture } from "test/fixtures/repository/features.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { RoleService } from "@calcom/platform-libraries/pbac";
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
import type { Team, User } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Teams Roles Permissions Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let featuresRepositoryFixture: FeaturesRepositoryFixture;
|
||||
let roleService: RoleService;
|
||||
|
||||
let pbacOrgUserWithRolePermission: User;
|
||||
let pbacOrgUserWithRolePermissionApiKey: string;
|
||||
|
||||
let pbacEnabledOrganization: Team;
|
||||
let pbacEnabledTeam: Team;
|
||||
|
||||
const pbacUserEmail = `pbac-org-user-with-permissions-${randomString()}@api.com`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
}).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
featuresRepositoryFixture = new FeaturesRepositoryFixture(moduleRef);
|
||||
roleService = new RoleService();
|
||||
|
||||
// Create PBAC org/team
|
||||
pbacEnabledOrganization = await organizationsRepositoryFixture.create({
|
||||
name: `pbac-org-role-perm-test-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
pbacEnabledTeam = await teamRepositoryFixture.create({
|
||||
name: `pbac-team-role-perm-test-${randomString()}`,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
|
||||
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledTeam.id, "pbac");
|
||||
|
||||
// Create user + membership in org
|
||||
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
|
||||
email: pbacUserEmail,
|
||||
username: pbacUserEmail,
|
||||
});
|
||||
|
||||
const membership = await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: pbacOrgUserWithRolePermission.id } },
|
||||
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
// Create a role that allows role.read and role.update, assign to user
|
||||
const managerRole = await roleService.createRole({
|
||||
name: `role-manager-${randomString()}`,
|
||||
teamId: pbacEnabledOrganization.id,
|
||||
permissions: ["role.create", "role.read", "role.update"],
|
||||
type: "CUSTOM",
|
||||
});
|
||||
await roleService.assignRoleToMember(managerRole.id, membership.id);
|
||||
|
||||
// API key for user
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(pbacOrgUserWithRolePermission.id, null);
|
||||
pbacOrgUserWithRolePermissionApiKey = `cal_test_${keyString}`;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("lists permissions for a role (GET /)", async () => {
|
||||
const initialPermissions = ["booking.read"] as const;
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-list-${randomString()}`,
|
||||
permissions: [...initialPermissions],
|
||||
};
|
||||
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
const listRes = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200);
|
||||
expect(listRes.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(listRes.body.data).toEqual(initialPermissions);
|
||||
});
|
||||
|
||||
it("adds permissions (POST /)", async () => {
|
||||
const initialPermissions = ["booking.read"] as const;
|
||||
const toAdd = ["eventType.create", "eventType.read"] as const;
|
||||
const expected = [...initialPermissions, ...toAdd] as string[];
|
||||
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-add-${randomString()}`,
|
||||
permissions: [...initialPermissions],
|
||||
};
|
||||
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
const addRes = await request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ permissions: toAdd })
|
||||
.expect(200);
|
||||
expect(addRes.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(addRes.body.data).toEqual(expected);
|
||||
});
|
||||
|
||||
it("bulk removes permissions via query (DELETE /)", async () => {
|
||||
// Seed role
|
||||
const initialPermissions: PermissionString[] = ["booking.read", "eventType.create", "eventType.read"];
|
||||
const toRemove: PermissionString[] = ["eventType.create", "eventType.read"];
|
||||
const expected: PermissionString[] = ["booking.read"];
|
||||
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-delmany-${randomString()}`,
|
||||
permissions: initialPermissions,
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${
|
||||
pbacEnabledTeam.id
|
||||
}/roles/${roleId}/permissions?permissions=${toRemove.join(",")}`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(204);
|
||||
|
||||
const listRes = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200);
|
||||
expect(listRes.body.data).toEqual(expected);
|
||||
});
|
||||
|
||||
it("replaces all permissions (PUT /)", async () => {
|
||||
const initialPermissions = ["booking.read"] as const;
|
||||
const replacement = ["booking.read", "eventType.update"] as const;
|
||||
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-put-${randomString()}`,
|
||||
permissions: [...initialPermissions],
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
const putRes = await request(app.getHttpServer())
|
||||
.put(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ permissions: replacement })
|
||||
.expect(200);
|
||||
|
||||
expect(putRes.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(putRes.body.data).toEqual(replacement);
|
||||
});
|
||||
|
||||
it("removes a single permission (DELETE /:permission)", async () => {
|
||||
const initialPermissions = ["booking.read", "eventType.update"] as const;
|
||||
const toRemove = "eventType.update" as const;
|
||||
const expected = ["booking.read"] as const;
|
||||
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-delone-${randomString()}`,
|
||||
permissions: [...initialPermissions],
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions/${toRemove}`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(204);
|
||||
|
||||
const listRes = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200);
|
||||
expect(listRes.body.data).toEqual(expected);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
// Clean up feature flag from team
|
||||
await featuresRepositoryFixture.deleteTeamFeature(pbacEnabledTeam.id, "pbac");
|
||||
|
||||
// Clean up teams and orgs
|
||||
await teamRepositoryFixture.delete(pbacEnabledTeam.id);
|
||||
await organizationsRepositoryFixture.delete(pbacEnabledOrganization.id);
|
||||
|
||||
// Clean up user
|
||||
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithRolePermission.email);
|
||||
|
||||
// Clean up feature definition
|
||||
try {
|
||||
await featuresRepositoryFixture.deleteBySlug("pbac");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import {
|
||||
OPTIONAL_API_KEY_HEADER,
|
||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||
} from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { CreateTeamRolePermissionsInput } from "@/modules/organizations/teams/roles/permissions/inputs/create-team-role-permissions.input";
|
||||
import { DeleteTeamRolePermissionsQuery } from "@/modules/organizations/teams/roles/permissions/inputs/delete-team-role-permissions.query";
|
||||
import { GetTeamRolePermissionsOutput } from "@/modules/organizations/teams/roles/permissions/outputs/get-team-role-permissions.output";
|
||||
import { RolesPermissionsService } from "@/modules/roles/permissions/services/roles-permissions.service";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Put,
|
||||
Query,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams/:teamId/roles/:roleId/permissions",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(
|
||||
ApiAuthGuard,
|
||||
IsOrgGuard,
|
||||
PbacGuard,
|
||||
RolesGuard,
|
||||
IsTeamInOrg,
|
||||
PlatformPlanGuard,
|
||||
IsAdminAPIEnabledGuard
|
||||
)
|
||||
@DocsTags("Orgs / Teams / Roles / Permissions")
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
export class OrganizationsTeamsRolesPermissionsController {
|
||||
constructor(private readonly rolePermissionsService: RolesPermissionsService) {}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Add permissions to an organization team role (single or batch)" })
|
||||
async addPermissions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Body() body: CreateTeamRolePermissionsInput
|
||||
): Promise<GetTeamRolePermissionsOutput> {
|
||||
const permissions = await this.rolePermissionsService.addRolePermissions(
|
||||
teamId,
|
||||
roleId,
|
||||
body.permissions
|
||||
);
|
||||
return { status: SUCCESS_STATUS, data: permissions };
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.read"])
|
||||
@Get("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "List permissions for an organization team role" })
|
||||
async listPermissions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("roleId") roleId: string
|
||||
): Promise<GetTeamRolePermissionsOutput> {
|
||||
const permissions = await this.rolePermissionsService.getRolePermissions(teamId, roleId);
|
||||
return { status: SUCCESS_STATUS, data: permissions };
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Put("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Replace all permissions for an organization team role" })
|
||||
async setPermissions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Body() body: CreateTeamRolePermissionsInput
|
||||
): Promise<GetTeamRolePermissionsOutput> {
|
||||
const permissions = await this.rolePermissionsService.setRolePermissions(
|
||||
teamId,
|
||||
roleId,
|
||||
body.permissions || []
|
||||
);
|
||||
return { status: SUCCESS_STATUS, data: permissions };
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Delete("/:permission")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: "Remove a permission from an organization team role" })
|
||||
async removePermission(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Param("permission") permission: PermissionString
|
||||
): Promise<void> {
|
||||
await this.rolePermissionsService.removeRolePermission(teamId, roleId, permission);
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Delete("/")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: "Remove multiple permissions from an organization team role" })
|
||||
async removePermissions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Query() query: DeleteTeamRolePermissionsQuery
|
||||
): Promise<void> {
|
||||
await this.rolePermissionsService.removeRolePermissions(teamId, roleId, query.permissions || []);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsArray, IsString } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetTeamRolePermissionsOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS })
|
||||
status!: typeof SUCCESS_STATUS;
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
data!: string[];
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { Role } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
@Injectable()
|
||||
export class TeamRolesOutputService {
|
||||
getTeamRoleOutput(role: Role): TeamRoleOutput {
|
||||
return {
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
color: role.color || null,
|
||||
description: role.description || null,
|
||||
teamId: role.teamId || null,
|
||||
type: role.type,
|
||||
permissions: role.permissions.map((permission) => `${permission.resource}.${permission.action}`),
|
||||
createdAt: role.createdAt.toISOString(),
|
||||
updatedAt: role.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
getTeamRolesOutput(roles: Role[]): TeamRoleOutput[] {
|
||||
return roles.map((role) => this.getTeamRoleOutput(role));
|
||||
}
|
||||
}
|
||||
+95
@@ -209,4 +209,99 @@ describe("WebhooksController (e2e)", () => {
|
||||
.delete(`/v2/organizations/${org.id}/webhooks/${otherWebhook.id}`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
describe("DELEGATION_CREDENTIAL_ERROR webhook trigger", () => {
|
||||
let delegationWebhook: TeamWebhookOutputResponseDto["data"];
|
||||
|
||||
it("/organizations/:orgId/webhooks (POST) should create webhook with DELEGATION_CREDENTIAL_ERROR trigger", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/webhooks`)
|
||||
.send({
|
||||
subscriberUrl: "https://example.com/delegation-errors",
|
||||
triggers: ["DELEGATION_CREDENTIAL_ERROR"],
|
||||
active: true,
|
||||
} satisfies CreateWebhookInputDto)
|
||||
.expect(201)
|
||||
.then(async (res) => {
|
||||
expect(res.body.status).toBe("success");
|
||||
expect(res.body.data).toMatchObject({
|
||||
id: expect.any(String),
|
||||
subscriberUrl: "https://example.com/delegation-errors",
|
||||
triggers: ["DELEGATION_CREDENTIAL_ERROR"],
|
||||
active: true,
|
||||
teamId: org.id,
|
||||
});
|
||||
delegationWebhook = res.body.data;
|
||||
});
|
||||
});
|
||||
|
||||
it("/organizations/:orgId/webhooks/:webhookId (GET) should retrieve webhook with DELEGATION_CREDENTIAL_ERROR trigger", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/webhooks/${delegationWebhook.id}`)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
expect(res.body.status).toBe("success");
|
||||
expect(res.body.data).toMatchObject({
|
||||
id: delegationWebhook.id,
|
||||
subscriberUrl: "https://example.com/delegation-errors",
|
||||
triggers: ["DELEGATION_CREDENTIAL_ERROR"],
|
||||
active: true,
|
||||
teamId: org.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("/organizations/:orgId/webhooks/:webhookId (PATCH) should update webhook with DELEGATION_CREDENTIAL_ERROR trigger", () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/webhooks/${delegationWebhook.id}`)
|
||||
.send({
|
||||
active: false,
|
||||
subscriberUrl: "https://example.com/delegation-errors-updated",
|
||||
} satisfies UpdateWebhookInputDto)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
expect(res.body.data.active).toBe(false);
|
||||
expect(res.body.data.subscriberUrl).toBe("https://example.com/delegation-errors-updated");
|
||||
expect(res.body.data.triggers).toEqual(["DELEGATION_CREDENTIAL_ERROR"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("/organizations/:orgId/webhooks (POST) should allow combining DELEGATION_CREDENTIAL_ERROR with other triggers", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/webhooks`)
|
||||
.send({
|
||||
subscriberUrl: "https://example.com/combined-webhook",
|
||||
triggers: ["BOOKING_CREATED", "DELEGATION_CREDENTIAL_ERROR"],
|
||||
active: true,
|
||||
} satisfies CreateWebhookInputDto)
|
||||
.expect(201)
|
||||
.then(async (res) => {
|
||||
expect(res.body.status).toBe("success");
|
||||
expect(res.body.data).toMatchObject({
|
||||
id: expect.any(String),
|
||||
subscriberUrl: "https://example.com/combined-webhook",
|
||||
triggers: expect.arrayContaining(["BOOKING_CREATED", "DELEGATION_CREDENTIAL_ERROR"]),
|
||||
active: true,
|
||||
teamId: org.id,
|
||||
});
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/webhooks/${res.body.data.id}`)
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
it("/organizations/:orgId/webhooks/:webhookId (DELETE) should delete webhook with DELEGATION_CREDENTIAL_ERROR trigger", () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/webhooks/${delegationWebhook.id}`)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
expect(res.body.status).toBe("success");
|
||||
expect(res.body.data).toMatchObject({
|
||||
id: delegationWebhook.id,
|
||||
triggers: ["DELEGATION_CREDENTIAL_ERROR"],
|
||||
teamId: org.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user