Skip to content

Commit d532cfc

Browse files
fflorenthexaltation
andcommitted
Implement SCIM Groups and Roles endpoint
This change also introduce a "Team" and a "Role" type for Groups. "Role" type is the historical type, it is the role granted to the user for a resource. The "Team" type is a new type, it is meant to gather people and grant them a role together for a resource. The SCIM /Groups endpoints gives access to the "Teams" groups. The SCIM /Roles endpoints gives a (limited) access to the "Roles" groups. For more information, please take a look at this Pr description: gristlabs#1357 (comment) Co-authored-by: Grégoire Cutzach <[email protected]>
1 parent 150d33a commit d532cfc

24 files changed

+2798
-603
lines changed

app/gen-server/entity/Group.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
import {BaseEntity, Column, Entity, JoinTable, ManyToMany, OneToOne, PrimaryGeneratedColumn} from "typeorm";
1+
import {BaseEntity, BeforeInsert, BeforeUpdate, Column, Entity, JoinTable, ManyToMany,
2+
OneToOne, PrimaryGeneratedColumn} from "typeorm";
23

34
import {AclRule} from "./AclRule";
45
import {User} from "./User";
6+
import { ApiError } from "app/common/ApiError";
57

68
@Entity({name: 'groups'})
79
export class Group extends BaseEntity {
10+
public static readonly ROLE_TYPE = 'role';
11+
public static readonly TEAM_TYPE = 'team';
812

913
@PrimaryGeneratedColumn()
1014
public id: number;
@@ -24,10 +28,36 @@ export class Group extends BaseEntity {
2428
@JoinTable({
2529
name: 'group_groups',
2630
joinColumn: {name: 'group_id'},
27-
inverseJoinColumn: {name: 'subgroup_id'}
31+
inverseJoinColumn: {name: 'subgroup_id'},
2832
})
2933
public memberGroups: Group[];
3034

3135
@OneToOne(type => AclRule, aclRule => aclRule.group)
3236
public aclRule: AclRule;
37+
38+
39+
@Column({type: String, enum: [Group.ROLE_TYPE, Group.TEAM_TYPE], default: Group.ROLE_TYPE,
40+
// Disabling nullable and select is necessary for the code to be run with older versions of the database.
41+
// Especially it is required for testing the migrations.
42+
nullable: true,
43+
// We must set select to false because of older migrations (like 1556726945436-Billing.ts)
44+
// which does not expect a type column at this moment.
45+
select: false})
46+
public type: typeof Group.ROLE_TYPE | typeof Group.TEAM_TYPE;
47+
48+
@BeforeUpdate()
49+
@BeforeInsert()
50+
public checkGroupMembers() {
51+
const memberGroups = this.memberGroups ?? [];
52+
53+
if (this.type === Group.TEAM_TYPE && memberGroups.length > 0) {
54+
throw new ApiError(`Groups of type "${Group.TEAM_TYPE}" cannot contain groups.`, 400);
55+
}
56+
const containItself = memberGroups.some(group => group.id === this.id);
57+
if (containItself) {
58+
throw new ApiError('A group cannot contain itself.', 400);
59+
}
60+
}
3361
}
62+
63+

app/gen-server/lib/homedb/GroupsManager.ts

Lines changed: 246 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@ import * as roles from "app/common/roles";
22
import { AclRule } from "app/gen-server/entity/AclRule";
33
import { Document } from "app/gen-server/entity/Document";
44
import { Group } from "app/gen-server/entity/Group";
5-
import { GroupDescriptor, NonGuestGroup, Resource } from "app/gen-server/lib/homedb/Interfaces";
5+
import { GroupWithMembersDescriptor, NonGuestGroup,
6+
Resource, RoleGroupDescriptor, RunInTransaction } from "app/gen-server/lib/homedb/Interfaces";
67
import { Organization } from "app/gen-server/entity/Organization";
78
import { Permissions } from 'app/gen-server/lib/Permissions';
89
import { User } from "app/gen-server/entity/User";
910
import { Workspace } from "app/gen-server/entity/Workspace";
1011

1112
import { EntityManager } from "typeorm";
13+
import { UsersManager } from "./UsersManager";
14+
import { ApiError } from "app/common/ApiError";
15+
16+
export type GroupTypes = typeof Group.ROLE_TYPE | typeof Group.TEAM_TYPE;
1217

1318
/**
1419
* Class responsible for Groups and Roles Management.
@@ -18,18 +23,18 @@ import { EntityManager } from "typeorm";
1823
*/
1924
export class GroupsManager {
2025
// All groups.
21-
public get defaultGroups(): GroupDescriptor[] {
26+
public get defaultGroups(): RoleGroupDescriptor[] {
2227
return this._defaultGroups;
2328
}
2429

2530
// Groups whose permissions are inherited from parent resource to child resources.
26-
public get defaultBasicGroups(): GroupDescriptor[] {
31+
public get defaultBasicGroups(): RoleGroupDescriptor[] {
2732
return this._defaultGroups
2833
.filter(_grpDesc => _grpDesc.nestParent);
2934
}
3035

3136
// Groups that are common to all resources.
32-
public get defaultCommonGroups(): GroupDescriptor[] {
37+
public get defaultCommonGroups(): RoleGroupDescriptor[] {
3338
return this._defaultGroups
3439
.filter(_grpDesc => !_grpDesc.orgOnly);
3540
}
@@ -87,7 +92,7 @@ export class GroupsManager {
8792
* TODO: app/common/roles already contains an ordering of the default roles. Usage should
8893
* be consolidated.
8994
*/
90-
private readonly _defaultGroups: GroupDescriptor[] = [{
95+
private readonly _defaultGroups: RoleGroupDescriptor[] = [{
9196
name: roles.OWNER,
9297
permissions: Permissions.OWNER,
9398
nestParent: true
@@ -110,6 +115,8 @@ export class GroupsManager {
110115
orgOnly: true
111116
}];
112117

118+
public constructor (private _usersManager: UsersManager, private _runInTransaction: RunInTransaction) {}
119+
113120
/**
114121
* Helper for adjusting acl inheritance rules. Given an array of top-level groups from the
115122
* resource of interest, and an array of inherited groups belonging to the parent resource,
@@ -207,8 +214,10 @@ export class GroupsManager {
207214
this.defaultGroups.forEach(groupProps => {
208215
if (!groupProps.orgOnly || !inherit) {
209216
// Skip this group if it's an org only group and the resource inherits from a parent.
210-
const group = new Group();
211-
group.name = groupProps.name;
217+
const group = Group.create({
218+
name: groupProps.name,
219+
type: Group.ROLE_TYPE,
220+
});
212221
if (inherit) {
213222
this.setInheritance(group, inherit);
214223
}
@@ -268,4 +277,234 @@ export class GroupsManager {
268277
}
269278
return roles.getEffectiveRole(maxInheritedRole);
270279
}
280+
281+
/**
282+
* Create a Group.
283+
* @param groupDescriptor - The descriptor for the group to be created.
284+
* @param optManager - Optional EntityManager to use for the transaction.
285+
* @returns The created Group.
286+
*/
287+
public async createGroup(groupDescriptor: GroupWithMembersDescriptor, optManager?: EntityManager) {
288+
return await this._runInTransaction(optManager, async (manager) => {
289+
if (groupDescriptor.type === Group.TEAM_TYPE) {
290+
await this._throwIfTeamNameCollision(groupDescriptor.name, manager);
291+
}
292+
const group = Group.create({
293+
type: groupDescriptor.type,
294+
name: groupDescriptor.name,
295+
memberUsers: await this._usersManager.getUsersByIdsStrict(groupDescriptor.memberUsers ?? [], manager),
296+
memberGroups: await this._getGroupsByIdsStrict(groupDescriptor.memberGroups ?? [], manager),
297+
});
298+
return await manager.save(group);
299+
});
300+
}
301+
302+
/**
303+
* Overwrite a Role Group.
304+
* @param id - The id of the Role Group to be overwritten.
305+
* @param groupDescriptor - The descriptor to overwrite the role with.
306+
* @param optManager - Optional EntityManager to use for the transaction.
307+
*
308+
* @returns The overwritten Role Group
309+
*/
310+
public async overwriteRoleGroup(
311+
id: number, groupDescriptor: GroupWithMembersDescriptor, optManager?: EntityManager
312+
) {
313+
return await this._runInTransaction(optManager, async (manager) => {
314+
const existingGroup = await this.getGroupWithMembersById(id, {}, manager);
315+
if (!existingGroup || (existingGroup.type !== Group.ROLE_TYPE)) {
316+
throw new ApiError(`Role with id ${id} not found`, 404);
317+
}
318+
return await this._overwriteGroup(existingGroup, groupDescriptor, manager);
319+
});
320+
}
321+
322+
/**
323+
* Overwrite a Team Group.
324+
* @param id - The id of the Team Group to be overwritten.
325+
* @param groupDescriptor - The descriptor to overwrite the role with.
326+
* @param optManager - Optional EntityManager to use for the transaction.
327+
*
328+
* @returns The overwritten Team Group
329+
*/
330+
public async overwriteTeamGroup(
331+
id: number, groupDescriptor: GroupWithMembersDescriptor, optManager?: EntityManager
332+
) {
333+
return await this._runInTransaction(optManager, async (manager) => {
334+
const existingGroup = await this.getGroupWithMembersById(id, {}, manager);
335+
if (!existingGroup || (existingGroup.type !== Group.TEAM_TYPE)) {
336+
throw new ApiError(`Group with id ${id} not found`, 404);
337+
}
338+
await this._throwIfTeamNameCollision(groupDescriptor.name, manager, id);
339+
return await this._overwriteGroup(existingGroup, groupDescriptor, manager);
340+
});
341+
}
342+
343+
/**
344+
* Delete a Group.
345+
*
346+
* @param id - The id of the Group to be deleted.
347+
* @param expectedType - The expected type of the Group to be deleted. If the type is specified,
348+
* the Group will only be deleted if it has the expected type.
349+
* If the type is not specified, the Group will be deleted regardless of its type.
350+
* @param optManager - Optional EntityManager to use for the transaction.
351+
*/
352+
public async deleteGroup(id: number, expectedType?: GroupTypes, optManager?: EntityManager) {
353+
return await this._runInTransaction(optManager, async (manager) => {
354+
const group = await this.getGroupWithMembersById(id, {}, manager);
355+
if (!group || (expectedType && expectedType !== group.type)) {
356+
throw new ApiError(`Group with id ${id} not found`, 404);
357+
}
358+
await manager.createQueryBuilder()
359+
.delete()
360+
.from('group_groups')
361+
.where('subgroup_id = :id', { id })
362+
.execute();
363+
await manager.remove(group);
364+
});
365+
}
366+
367+
/**
368+
* Get all the groups with their members.
369+
* @param optManager - Optional EntityManager to use for the transaction.
370+
*/
371+
public getGroupsWithMembers(optManager?: EntityManager): Promise<Group[]> {
372+
return this._runInTransaction(optManager, async (manager: EntityManager) => {
373+
return this._getGroupsQueryBuilder(manager)
374+
.getMany();
375+
});
376+
}
377+
378+
/**
379+
* Get all the groups with their members of the given type.
380+
*
381+
* @param type - The type of the groups to be fetched.
382+
* @param opts - Optional options to be used for the query.
383+
* @param opts.aclRule - Whether to include the aclRule in the query.
384+
* @param optManager - Optional EntityManager to use for the transaction.
385+
*
386+
* @returns A Promise for an array of Group entities.
387+
*/
388+
public getGroupsWithMembersByType(
389+
type: GroupTypes, opts?: {aclRule?: boolean}, optManager?: EntityManager
390+
): Promise<Group[]> {
391+
return this._runInTransaction(optManager, async (manager: EntityManager) => {
392+
return this._getGroupsQueryBuilder(manager, opts)
393+
.where('groups.type = :type', {type})
394+
.getMany();
395+
});
396+
}
397+
398+
/**
399+
* Get a Group with its members by id.
400+
*
401+
* @param id - The id of the Group to be fetched.
402+
* @param opts - Optional options to be used for the query.
403+
* @param opts.aclRule - Whether to include the aclRule in the query.
404+
* @param optManager - Optional EntityManager to use for the transaction.
405+
*
406+
* @returns A Promise for the Group entity.
407+
*/
408+
public async getGroupWithMembersById(
409+
id: number, opts?: {aclRule?: boolean}, optManager?: EntityManager
410+
): Promise<Group|null> {
411+
return await this._runInTransaction(optManager, async (manager) => {
412+
return await this._getGroupsQueryBuilder(manager, opts)
413+
.andWhere('groups.id = :groupId', {groupId: id})
414+
.getOne();
415+
});
416+
}
417+
418+
/**
419+
* Common method to overwrite groups of any type.
420+
* @param existing - The existing group to be overwritten.
421+
* @param groupDescriptor - The descriptor to overwrite the group with.
422+
* @param optManager - The EntityManager to use for the transaction.
423+
* @returns The overwritten Group.
424+
*/
425+
private async _overwriteGroup(
426+
existing: Group, groupDescriptor: GroupWithMembersDescriptor, optManager: EntityManager
427+
) {
428+
if (existing.type !== groupDescriptor.type) {
429+
throw new ApiError("cannot change type of group", 400);
430+
}
431+
const updatedGroup = Group.create({
432+
id: existing.id,
433+
type: groupDescriptor.type,
434+
name: groupDescriptor.name,
435+
memberUsers: await this._usersManager.getUsersByIdsStrict(groupDescriptor.memberUsers ?? [], optManager),
436+
memberGroups: await this._getGroupsByIdsStrict(groupDescriptor.memberGroups ?? [], optManager),
437+
});
438+
return await optManager.save(updatedGroup);
439+
}
440+
441+
/**
442+
* Returns a Promise for an array of Groups for the given groupIds.
443+
*
444+
* @param groupIds - The ids of the Groups to be fetched.
445+
* @param optManager - Optional EntityManager to use for the transaction.
446+
* @returns A Promise for an array of Group entities.
447+
*/
448+
private async _getGroupsByIds(groupIds: number[], optManager?: EntityManager): Promise<Group[]> {
449+
if (groupIds.length === 0) {
450+
return [];
451+
}
452+
return await this._runInTransaction(optManager, async (manager) => {
453+
const queryBuilder = this._getGroupsQueryBuilder(manager)
454+
.where('groups.id IN (:...groupIds)', {groupIds});
455+
return await queryBuilder.getMany();
456+
});
457+
}
458+
459+
/**
460+
* Returns a Promise for an array of Groups for the given groupIds.
461+
* Throws an ApiError if any of the groups are not found.
462+
*
463+
* @param groupIds - The ids of the Groups to be fetched.
464+
* @param optManager - Optional EntityManager to use for the transaction.
465+
*/
466+
private async _getGroupsByIdsStrict(groupIds: number[], optManager?: EntityManager): Promise<Group[]> {
467+
const groups = await this._getGroupsByIds(groupIds, optManager);
468+
if (groups.length !== groupIds.length) {
469+
const foundGroupIds = new Set(groups.map(group => group.id));
470+
const missingGroupIds = groupIds.filter(id => !foundGroupIds.has(id));
471+
throw new ApiError('Groups not found: ' + missingGroupIds.join(', '), 404);
472+
}
473+
return groups;
474+
}
475+
476+
/**
477+
* Returns a QueryBuilder for fetching groups with their members.
478+
* @param optManager - The EntityManager to use for the query.
479+
* @param opts - Optional options to be used for the query.
480+
* @param opts.aclRule - Whether to include the aclRule in the query.
481+
* @returns The QueryBuilder for fetching groups with their members.
482+
*/
483+
private _getGroupsQueryBuilder(optManager: EntityManager, opts: {aclRule?: boolean} = {}) {
484+
let queryBuilder = optManager.createQueryBuilder()
485+
.select('groups')
486+
.addSelect('groups.type')
487+
.addSelect('memberGroups.type')
488+
.from(Group, 'groups')
489+
.leftJoinAndSelect('groups.memberUsers', 'memberUsers')
490+
.leftJoinAndSelect('groups.memberGroups', 'memberGroups');
491+
if (opts.aclRule) {
492+
queryBuilder = queryBuilder
493+
.leftJoinAndSelect('groups.aclRule', 'aclRule');
494+
}
495+
return queryBuilder;
496+
}
497+
498+
private async _throwIfTeamNameCollision(name: string, manager: EntityManager, existingId?: number) {
499+
const query = this._getGroupsQueryBuilder(manager)
500+
.where('groups.name = :name', {name})
501+
.andWhere('groups.type = :type', {type: Group.TEAM_TYPE});
502+
if (existingId !== undefined) {
503+
query.andWhere('groups.id != :id', {id: existingId});
504+
}
505+
const group = await query.getOne();
506+
if (group) {
507+
throw new ApiError(`Group with name "${name}" already exists`, 409);
508+
}
509+
}
271510
}

0 commit comments

Comments
 (0)