Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Enhance SCIM payload handling and attribute creation #18427

Merged
merged 2 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import { describe, expect, it } from "vitest";

import getAttributesFromScimPayload from "../getAttributesFromScimPayload";

const directoryId = "xxx-xxx-xxx-xxx";
describe("getAttributesFromScimPayload", () => {
it("should return empty object for unsupported events", () => {
const event = {
event: "user.deleted",
data: { raw: { schemas: [] } },
} as DirectorySyncEvent;

const result = getAttributesFromScimPayload(event);
const result = getAttributesFromScimPayload({ event, directoryId });
expect(result).toEqual({});
});

Expand All @@ -28,7 +29,7 @@ describe("getAttributesFromScimPayload", () => {
},
} as DirectorySyncEvent;

const result = getAttributesFromScimPayload(event);
const result = getAttributesFromScimPayload({ event, directoryId });
expect(result).toEqual({
department: "Engineering",
title: "Software Engineer",
Expand All @@ -48,7 +49,7 @@ describe("getAttributesFromScimPayload", () => {
},
} as DirectorySyncEvent;

const result = getAttributesFromScimPayload(event);
const result = getAttributesFromScimPayload({ event, directoryId });
expect(result).toEqual({
skills: ["JavaScript", "TypeScript", "React"],
});
Expand All @@ -68,7 +69,7 @@ describe("getAttributesFromScimPayload", () => {
},
} as DirectorySyncEvent;

const result = getAttributesFromScimPayload(event);
const result = getAttributesFromScimPayload({ event, directoryId });
expect(result).toEqual({
department: "Engineering",
});
Expand All @@ -87,7 +88,7 @@ describe("getAttributesFromScimPayload", () => {
},
} as DirectorySyncEvent;

const result = getAttributesFromScimPayload(event);
const result = getAttributesFromScimPayload({ event, directoryId });
expect(result).toEqual({});
});

Expand All @@ -108,7 +109,7 @@ describe("getAttributesFromScimPayload", () => {
},
} as DirectorySyncEvent;

const result = getAttributesFromScimPayload(event);
const result = getAttributesFromScimPayload({ event, directoryId });
expect(result).toEqual({
department: "Engineering",
location: "Remote",
Expand All @@ -128,13 +129,13 @@ describe("getAttributesFromScimPayload", () => {
},
} as DirectorySyncEvent;

const result = getAttributesFromScimPayload(event);
const result = getAttributesFromScimPayload({ event, directoryId });
expect(result).toEqual({
department: "Engineering",
});
});

it("should extract from core namespace as well.", () => {
it("should extract from core namespace as well ignoring the core attributes as defined in the SCIM spec.", () => {
const event = {
event: "user.created",
data: {
Expand All @@ -152,13 +153,14 @@ describe("getAttributesFromScimPayload", () => {
},
} as DirectorySyncEvent;

const result = getAttributesFromScimPayload(event);
const result = getAttributesFromScimPayload({ event, directoryId });
expect(result).toEqual({
userName: "[email protected]",
displayName: "Kush",
territory: "XANAM",
externalId: "00ulb1kpy4EMATtuS5d7",
groups: [],
// Core Attributes won't be there - It avoids unnecessary warnings about attributes not defined in cal.com
// userName: "[email protected]",
// displayName: "Kush",
// externalId: "00ulb1kpy4EMATtuS5d7",
// groups: [],
});
});
});
60 changes: 52 additions & 8 deletions packages/features/ee/dsync/lib/getAttributesFromScimPayload.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,39 @@
import type { DirectorySyncEvent } from "@boxyhq/saml-jackson";

import { DIRECTORY_IDS_TO_LOG } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";

const log = logger.getSubLogger({ prefix: ["getAttributesFromScimPayload"] });

type ScimUserAttributeName = string;
type ScimUserAttributeValue = string | string[];
const coreSchemaUrn = "urn:ietf:params:scim:schemas:core:2.0:User";
//It avoids unnecessary warnings about attributes not defined in cal.com
const coreUserAttributesToIgnore = [
"userName",
"name",
"displayName",
"emails",
"active",
"externalId",
"id",
"groups",
"meta",
"locale",
"password",
"phoneNumbers",
"photos",
"profileUrl",
"timezone",
"title",
"addresses",
"entitlements",
"ims",
"roles",
"x509Certificates",
];

/**
* event.data.raw has this format
* {
Expand Down Expand Up @@ -45,9 +72,13 @@ type ScimUserAttributeValue = string | string[];
* "segment": "SMB"
* }
*/
function getAttributesFromScimPayload(
event: DirectorySyncEvent
): Record<ScimUserAttributeName, ScimUserAttributeValue> {
function getAttributesFromScimPayload({
event,
directoryId,
}: {
event: DirectorySyncEvent;
directoryId: string;
}): Record<ScimUserAttributeName, ScimUserAttributeValue> {
const scimUserAttributes: Record<ScimUserAttributeName, ScimUserAttributeValue> = {};

if (event.event !== "user.created" && event.event !== "user.updated") {
Expand All @@ -57,10 +88,11 @@ function getAttributesFromScimPayload(

const raw = event.data.raw;
raw.schemas.forEach((schema: unknown) => {
if (schema === "urn:ietf:params:scim:schemas:core:2.0:User") {
if (schema === coreSchemaUrn) {
// Core schema has payload in the root
const { schemas: _schemas, ...namespaceData } = raw;
collectAttributes(namespaceData);

collectAttributes({ data: namespaceData, ignoreList: coreUserAttributesToIgnore });
return;
}
const namespaceName = schema;
Expand All @@ -77,15 +109,27 @@ function getAttributesFromScimPayload(
return;
}

collectAttributes(namespaceData);
collectAttributes({ data: namespaceData });
});

log.debug("Collected attributes", `Attributes: ${safeStringify(scimUserAttributes)}`);
const shouldLog = DIRECTORY_IDS_TO_LOG.includes(directoryId);
if (shouldLog) {
console.log("Collected Attributes:", `${safeStringify(scimUserAttributes)}`);
}

return scimUserAttributes;

function collectAttributes(data: Record<string, unknown>) {
function collectAttributes({
data,
ignoreList = [],
}: {
data: Record<string, unknown>;
ignoreList?: string[];
}) {
Object.entries(data).forEach(([customAttributeName, value]) => {
if (ignoreList.includes(customAttributeName)) {
return;
}
if (!value) {
log.warn(
"getAttributesFromScimPayload",
Expand Down
68 changes: 32 additions & 36 deletions packages/features/ee/dsync/lib/handleUserEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ async function syncCustomAttributesToUser({
return;
}

const customAttributes = getAttributesFromScimPayload(event);
const customAttributes = getAttributesFromScimPayload({ event, directoryId });
await assignValueToUserInOrgBulk({
orgId: org.id,
userId: user.id,
Expand All @@ -69,11 +69,6 @@ const handleUserEvents = async (event: DirectorySyncEvent, organizationId: numbe
select: dSyncUserSelect,
});

// User is already a part of that org
if (user && UserRepository.isAMemberOfOrganization({ user, organizationId }) && eventData.active) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was causing the update to not sync attribues as the user is already a part of org.

return;
}

const translation = await getTranslation(user?.locale || "en", "common");

const org = await getTeamOrThrow(organizationId);
Expand All @@ -84,36 +79,37 @@ const handleUserEvents = async (event: DirectorySyncEvent, organizationId: numbe

if (user) {
if (eventData.active) {
// If data.active is true then provision the user into the org
const addedUser = await inviteExistingUserToOrg({
user: user as UserWithMembership,
org,
translation,
});

await sendExistingUserTeamInviteEmails({
currentUserName: user.username,
currentUserTeamName: org.name,
existingUsersWithMemberships: [
{
...addedUser,
profile: null,
},
],
language: translation,
isOrg: true,
teamId: org.id,
isAutoJoin: true,
currentUserParentTeamName: org?.parent?.name,
orgSlug: org.slug,
});

await syncCustomAttributesToUser({
event,
userEmail,
org,
directoryId,
});
if (UserRepository.isAMemberOfOrganization({ user, organizationId })) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still allow attributes sync for existing user.

await syncCustomAttributesToUser({
event,
userEmail,
org,
directoryId,
});
} else {
// If data.active is true then provision the user into the org
const addedUser = await inviteExistingUserToOrg({
user: user as UserWithMembership,
org,
translation,
});
await sendExistingUserTeamInviteEmails({
currentUserName: user.username,
currentUserTeamName: org.name,
existingUsersWithMemberships: [
{
...addedUser,
profile: null,
},
],
language: translation,
isOrg: true,
teamId: org.id,
isAutoJoin: true,
currentUserParentTeamName: org?.parent?.name,
orgSlug: org.slug,
});
}
} else {
// If data.active is false then remove the user from the org
await removeUserFromOrg({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,10 @@ function CreateAttributesPage() {
<AttributeForm
header={<CreateAttributeHeader isPending={mutation.isPending} />}
onSubmit={(values) => {
// Create set of attributes to get unique values
const uniqueAttributes = new Set(values.options.map((option) => option.value));
const { attrName, ...rest } = values;
mutation.mutate({
name: values.attrName,
type: values.type,
isLocked: values.isLocked,
options: Array.from(uniqueAttributes).map((value) => ({ value })),
...rest,
name: attrName,
});
}}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { TRPCError } from "@trpc/server";

import type { TrpcSessionUser } from "../../../trpc";
import type { ZCreateAttributeSchema } from "./create.schema";
import { getOptionsWithValidContains } from "./utils";

type GetOptions = {
ctx: {
Expand All @@ -28,8 +29,8 @@ const createAttributesHandler = async ({ input, ctx }: GetOptions) => {
}

const slug = slugify(input.name);
const options = input.options.map((v) => v.value);
const optionsWithoutDuplicates = Array.from(new Set(options));
const uniqueOptions = getOptionsWithValidContains(input.options);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using common utility


const typeHasOptions = typesWithOptions.includes(input.type);

let attributes: Attribute;
Expand All @@ -55,10 +56,11 @@ const createAttributesHandler = async ({ input, ctx }: GetOptions) => {
// TEXT/NUMBER don't have options
if (typeHasOptions) {
await prisma.attributeOption.createMany({
data: optionsWithoutDuplicates.map((value) => ({
data: uniqueOptions.map(({ value, isGroup }) => ({
attributeId: attributes.id,
value,
slug: slugify(value),
isGroup,
})),
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export const createAttributeSchema = z.object({
name: z.string(),
type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]),
isLocked: z.boolean().optional(),
options: z.array(z.object({ value: z.string() })),
options: z.array(z.object({ value: z.string(), isGroup: z.boolean().optional() })),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was causing a groupOption to become regular option when created during the attribute creation itself.

});

export type ZCreateAttributeSchema = z.infer<typeof createAttributeSchema>;
24 changes: 1 addition & 23 deletions packages/trpc/server/routers/viewer/attributes/edit.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { TRPCError } from "@trpc/server";

import type { TrpcSessionUser } from "../../../trpc";
import type { ZEditAttributeSchema } from "./edit.schema";
import { getOptionsWithValidContains } from "./utils";

type GetOptions = {
ctx: {
Expand Down Expand Up @@ -115,29 +116,6 @@ const editAttributesHandler = async ({ input, ctx }: GetOptions) => {
return attributes;
};

/**
* Ensures that contains has no non-existent sub-options
*/
function getOptionsWithValidContains(options: ZEditAttributeSchema["options"]) {
return options.map(({ contains, ...option }) => {
if (!contains)
return {
...option,
contains: [],
};
const possibleSubOptions = options
.filter((option) => !option.isGroup)
.filter((option): option is typeof option & { id: string } => option.id !== undefined);

const possibleSubOptionsIds = possibleSubOptions.map((option) => option.id);

return {
...option,
contains: contains.filter((subOptionId) => possibleSubOptionsIds.includes(subOptionId)),
};
});
}

async function validateOptionsBelongToAttribute(
options: ZEditAttributeSchema["options"],
attributeId: string
Expand Down
Loading
Loading