Skip to content

Commit 8b7d191

Browse files
committed
fix: include members attribute for scim groups unless opting out explicitly
1 parent 798a715 commit 8b7d191

4 files changed

Lines changed: 115 additions & 8 deletions

File tree

integration-tests/testkit/scim.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ export type SCIMListQuery = {
115115
count?: string | number;
116116
startIndex?: string | number;
117117
filter?: string;
118+
excludedAttributes?: 'members';
118119
};
119120

120121
export function createScimTestkit({ baseUrl, headers }: { baseUrl: string; headers: HeadersInit }) {

integration-tests/tests/api/auth/scim.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2922,6 +2922,67 @@ describe.concurrent('/Groups', () => {
29222922
});
29232923
});
29242924
describe.concurrent('GET', () => {
2925+
test.concurrent(
2926+
'lists members by default and excludes them when requested',
2927+
async ({ expect }) => {
2928+
const seed = initSeed();
2929+
const owner = await seed.createOwner();
2930+
const org = await owner.createOrg();
2931+
await org.setFeatureFlag('scim', true);
2932+
const oidc = await org.createOIDCIntegration();
2933+
const domain = await oidc.registerFakeDomain();
2934+
const accessToken = await org.createOrganizationAccessToken({
2935+
permissions: ['scim:provision'],
2936+
resources: { mode: ResourceAssignmentModeType.Granular },
2937+
});
2938+
const scim = createScimTestkit({
2939+
baseUrl,
2940+
headers: {
2941+
'Content-Type': 'application/scim+json',
2942+
Authorization: 'Bearer ' + accessToken.privateAccessKey,
2943+
},
2944+
});
2945+
const user = await scim
2946+
.createUser({
2947+
...newUserValues(),
2948+
emails: [{ primary: true, type: 'work', value: 'listed-member@' + domain }],
2949+
})
2950+
.then(response => response.body);
2951+
const group = await scim
2952+
.createGroup({
2953+
...newGroupValues(),
2954+
displayName: 'Group with listed member',
2955+
members: [{ value: user.id }],
2956+
})
2957+
.then(response => response.body);
2958+
const expectedMembers = [
2959+
{
2960+
value: user.id,
2961+
$ref: baseUrl + '/scim/v2/Users/' + user.id,
2962+
},
2963+
];
2964+
2965+
const listResponse = await scim.listGroups();
2966+
expect(listResponse.body.Resources).toContainEqual(
2967+
expect.objectContaining({ id: group.id, members: expectedMembers }),
2968+
);
2969+
2970+
const filteredResponse = await scim.listGroups({
2971+
filter: `id eq "${group.id}"`,
2972+
});
2973+
expect(filteredResponse.body.Resources[0]?.members).toEqual(expectedMembers);
2974+
2975+
const excludedListResponse = await scim.listGroups({ excludedAttributes: 'members' });
2976+
expect(excludedListResponse.body.Resources[0]).not.toHaveProperty('members');
2977+
2978+
const excludedFilteredResponse = await scim.listGroups({
2979+
filter: `id eq "${group.id}"`,
2980+
excludedAttributes: 'members',
2981+
});
2982+
expect(excludedFilteredResponse.body.Resources[0]).not.toHaveProperty('members');
2983+
},
2984+
);
2985+
29252986
test.concurrent('excludes members when requested', async ({ expect }) => {
29262987
const seed = initSeed();
29272988
const owner = await seed.createOwner();

packages/services/api/src/modules/organization/providers/group-member-store.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,28 @@ export class GroupMemberStore {
102102
return z.array(GroupMemberModel).parse(result);
103103
}
104104

105+
async getGroupMembersForOrganizationIdAndGroupIds(
106+
organizationId: string,
107+
groupIds: Array<string>,
108+
) {
109+
const result = await this.pool.any(psql`
110+
SELECT ${groupMemberFields}
111+
FROM "group_members"
112+
WHERE
113+
"organization_id" = ${organizationId}
114+
AND "group_id" = ANY(${psql.array(groupIds, 'uuid')})
115+
`);
116+
const records = z.array(GroupMemberModel).parse(result);
117+
const groupMembersByGroupId = new Map<string, Array<GroupMember>>();
118+
for (const groupMember of records) {
119+
const members = groupMembersByGroupId.get(groupMember.groupId) ?? [];
120+
members.push(groupMember);
121+
groupMembersByGroupId.set(groupMember.groupId, members);
122+
}
123+
124+
return groupMembersByGroupId
125+
}
126+
105127
async addGroupMembersToGroupByOrganizationIdAndGroupId(
106128
organizationId: string,
107129
groupId: string,

packages/services/server/src/scim.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ const GetGroupQueryModel = z.object({
9191
excludedAttributes: z.literal('members').optional(),
9292
});
9393

94+
const GetGroupsQueryModel = QuerySchemaModel.merge(GetGroupQueryModel);
95+
9496
const SharedUserRouteParams = z.object({
9597
userId: z.string().uuid(),
9698
});
@@ -1194,7 +1196,7 @@ export const createSCIMPlugin =
11941196
return reply.status(result.error.status).send(result.error);
11951197
}
11961198

1197-
const queryParse = QuerySchemaModel.safeParse(req.query);
1199+
const queryParse = GetGroupsQueryModel.safeParse(req.query);
11981200
if (queryParse.error) {
11991201
return reply.status(403).send(
12001202
createSCIMError({
@@ -1205,6 +1207,7 @@ export const createSCIMPlugin =
12051207
}
12061208

12071209
const groupStore = new GroupStore(result.logger, pool);
1210+
const groupMemberStore = new GroupMemberStore(result.logger, pool);
12081211

12091212
const startIndex = queryParse.data.startIndex ?? 1;
12101213
const count = queryParse.data.count ?? 100;
@@ -1252,12 +1255,20 @@ export const createSCIMPlugin =
12521255
}
12531256
}
12541257

1258+
const groupMembers =
1259+
group && queryParse.data.excludedAttributes !== 'members'
1260+
? await groupMemberStore.getGroupMembersForOrganizationIdAndGroupId(
1261+
result.organizationId,
1262+
group.id,
1263+
)
1264+
: undefined;
1265+
12551266
return reply.status(200).send({
12561267
schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
12571268
totalResults: group ? 1 : 0,
12581269
startIndex,
12591270
itemsPerPage: group ? 1 : 0,
1260-
Resources: group ? [createSCIMGroupObjectFromGroup(baseUri, group)] : [],
1271+
Resources: group ? [createSCIMGroupObjectFromGroup(baseUri, group, groupMembers)] : [],
12611272
} satisfies SCIMListResponseObject);
12621273
}
12631274

@@ -1269,8 +1280,24 @@ export const createSCIMPlugin =
12691280
},
12701281
);
12711282

1283+
const groupMembersByGroupId =
1284+
queryParse.data.excludedAttributes !== 'members' && pagedGroups.length > 0
1285+
? await groupMemberStore.getGroupMembersForOrganizationIdAndGroupIds(
1286+
result.organizationId,
1287+
pagedGroups.map(group => group.id),
1288+
)
1289+
: null;
1290+
12721291
for (const group of pagedGroups) {
1273-
groups.push(createSCIMGroupObjectFromGroup(baseUri, group));
1292+
groups.push(
1293+
createSCIMGroupObjectFromGroup(
1294+
baseUri,
1295+
group,
1296+
groupMembersByGroupId === null
1297+
? undefined
1298+
: (groupMembersByGroupId.get(group.id) ?? []),
1299+
),
1300+
);
12741301
}
12751302

12761303
return reply.status(200).send({
@@ -1957,11 +1984,7 @@ function createSCIMGroupObjectFromGroup(
19571984
baseUri: string,
19581985
group: Group,
19591986
/**
1960-
* The members are optional as they do not need to be included within actions such as
1961-
* "list all groups".
1962-
*
1963-
* Only when a specific group object is requested or updated we include the list of members
1964-
* so the SCIM provider can see if a user is or is not a member of an organization.
1987+
* Members are omitted when the client requests `excludedAttributes=members`.
19651988
*/
19661989
members?: Array<GroupMember>,
19671990
): SCIMGroupObject {

0 commit comments

Comments
 (0)