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

SCIM: implement Groups #1357

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
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
31 changes: 29 additions & 2 deletions app/gen-server/entity/Group.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import {BaseEntity, Column, Entity, JoinTable, ManyToMany, OneToOne, PrimaryGeneratedColumn} from "typeorm";
import {BaseEntity, BeforeInsert, BeforeUpdate, Column, Entity, JoinTable, ManyToMany,
OneToOne, PrimaryGeneratedColumn} from "typeorm";

import {AclRule} from "./AclRule";
import {User} from "./User";

@Entity({name: 'groups'})
export class Group extends BaseEntity {
public static readonly ROLE_TYPE = 'role';
public static readonly RESOURCE_USERS_TYPE = 'resource users';

@PrimaryGeneratedColumn()
public id: number;
Expand All @@ -24,10 +27,34 @@ export class Group extends BaseEntity {
@JoinTable({
name: 'group_groups',
joinColumn: {name: 'group_id'},
inverseJoinColumn: {name: 'subgroup_id'}
inverseJoinColumn: {name: 'subgroup_id'},
})
public memberGroups: Group[];

@OneToOne(type => AclRule, aclRule => aclRule.group)
public aclRule: AclRule;


@Column({type: String, enum: [Group.ROLE_TYPE, Group.RESOURCE_USERS_TYPE], default: Group.ROLE_TYPE,
// Disabling nullable and select is necessary for the code to be run with older versions of the database.
// Especially it is required for testing the migrations.
nullable: true,
// We must set select to false because of older migrations (like 1556726945436-Billing.ts)
// which does not expect a type column at this moment.
select: false})
public type: typeof Group.ROLE_TYPE | typeof Group.RESOURCE_USERS_TYPE;

@BeforeUpdate()
@BeforeInsert()
public checkGroupMembers() {
if (this.type === Group.RESOURCE_USERS_TYPE && (this.memberGroups ?? []).length > 0) {
throw new Error(`Groups of type "${Group.RESOURCE_USERS_TYPE}" cannot contain groups.`);
}
const containItself = (this.memberGroups ?? []).some(group => group.id === this.id);
if (containItself) {
throw new Error('Group cannot contain itself.');
}
}
}


151 changes: 144 additions & 7 deletions app/gen-server/lib/homedb/GroupsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ import * as roles from "app/common/roles";
import { AclRule } from "app/gen-server/entity/AclRule";
import { Document } from "app/gen-server/entity/Document";
import { Group } from "app/gen-server/entity/Group";
import { GroupDescriptor, NonGuestGroup, Resource } from "app/gen-server/lib/homedb/Interfaces";
import { GroupWithMembersDescriptor, NonGuestGroup,
Resource, RoleGroupDescriptor, RunInTransaction } from "app/gen-server/lib/homedb/Interfaces";
import { Organization } from "app/gen-server/entity/Organization";
import { Permissions } from 'app/gen-server/lib/Permissions';
import { User } from "app/gen-server/entity/User";
import { Workspace } from "app/gen-server/entity/Workspace";

import { EntityManager } from "typeorm";
import { UsersManager } from "./UsersManager";
import { ApiError } from "app/common/ApiError";

export type GroupTypes = typeof Group.ROLE_TYPE | typeof Group.RESOURCE_USERS_TYPE;

/**
* Class responsible for Groups and Roles Management.
Expand All @@ -18,18 +23,18 @@ import { EntityManager } from "typeorm";
*/
export class GroupsManager {
// All groups.
public get defaultGroups(): GroupDescriptor[] {
public get defaultGroups(): RoleGroupDescriptor[] {
return this._defaultGroups;
}

// Groups whose permissions are inherited from parent resource to child resources.
public get defaultBasicGroups(): GroupDescriptor[] {
public get defaultBasicGroups(): RoleGroupDescriptor[] {
return this._defaultGroups
.filter(_grpDesc => _grpDesc.nestParent);
}

// Groups that are common to all resources.
public get defaultCommonGroups(): GroupDescriptor[] {
public get defaultCommonGroups(): RoleGroupDescriptor[] {
return this._defaultGroups
.filter(_grpDesc => !_grpDesc.orgOnly);
}
Expand Down Expand Up @@ -91,7 +96,7 @@ export class GroupsManager {
* TODO: app/common/roles already contains an ordering of the default roles. Usage should
* be consolidated.
*/
private readonly _defaultGroups: GroupDescriptor[] = [{
private readonly _defaultGroups: RoleGroupDescriptor[] = [{
name: roles.OWNER,
permissions: Permissions.OWNER,
nestParent: true
Expand All @@ -114,6 +119,8 @@ export class GroupsManager {
orgOnly: true
}];

public constructor (private _usersManager: UsersManager, private _runInTransaction: RunInTransaction) {}

/**
* Helper for adjusting acl inheritance rules. Given an array of top-level groups from the
* resource of interest, and an array of inherited groups belonging to the parent resource,
Expand Down Expand Up @@ -211,8 +218,10 @@ export class GroupsManager {
this.defaultGroups.forEach(groupProps => {
if (!groupProps.orgOnly || !inherit) {
// Skip this group if it's an org only group and the resource inherits from a parent.
const group = new Group();
group.name = groupProps.name;
const group = Group.create({
name: groupProps.name,
type: Group.ROLE_TYPE,
});
if (inherit) {
this.setInheritance(group, inherit);
}
Expand Down Expand Up @@ -272,4 +281,132 @@ export class GroupsManager {
}
return roles.getEffectiveRole(maxInheritedRole);
}

public async createGroup(groupDescriptor: GroupWithMembersDescriptor, optManager?: EntityManager) {
return await this._runInTransaction(optManager, async (manager) => {
const group = Group.create({
type: groupDescriptor.type,
name: groupDescriptor.name,
memberUsers: await this._usersManager.getUsersByIdsStrict(groupDescriptor.memberUsers ?? [], manager),
memberGroups: await this._getGroupsByIdsStrict(groupDescriptor.memberGroups ?? [], manager),
});
return await manager.save(group);
});
}

public async overwriteRoleGroup(
id: number, groupDescriptor: GroupWithMembersDescriptor, optManager?: EntityManager
) {
return await this._runInTransaction(optManager, async (manager) => {
const existingGroup = await this.getGroupWithMembersById(id, {}, manager);
if (!existingGroup || (existingGroup.type !== Group.ROLE_TYPE)) {
throw new ApiError(`Role with id ${id} not found`, 404);
}
return await this._overwriteGroup(id, groupDescriptor, manager);
});
}

public async overwriteResourceUsersGroup(
id: number, groupDescriptor: GroupWithMembersDescriptor, optManager?: EntityManager
) {
return await this._runInTransaction(optManager, async (manager) => {
const existingGroup = await this.getGroupWithMembersById(id, {}, manager);
if (!existingGroup || (existingGroup.type !== Group.RESOURCE_USERS_TYPE)) {
throw new ApiError(`Group with id ${id} not found`, 404);
}
return await this._overwriteGroup(id, groupDescriptor, manager);
});
}

public async deleteGroup(id: number, expectedType?: GroupTypes, optManager?: EntityManager) {
return await this._runInTransaction(optManager, async (manager) => {
const group = await this.getGroupWithMembersById(id, {}, manager);
if (!group || (expectedType && expectedType !== group.type)) {
throw new ApiError(`Group with id ${id} not found`, 404);
}
await manager.createQueryBuilder()
.delete()
.from('group_groups')
.where('subgroup_id = :id', { id })
.execute();
await manager.remove(group);
});
}

public getGroupsWithMembers(mamager?: EntityManager): Promise<Group[]> {
return this._runInTransaction(mamager, async (manager: EntityManager) => {
return this._getGroupsQueryBuilder(manager)
.getMany();
});
}

public getGroupsWithMembersByType(
type: GroupTypes, opts?: {aclRule?: boolean}, mamager?: EntityManager
): Promise<Group[]> {
return this._runInTransaction(mamager, async (manager: EntityManager) => {
return this._getGroupsQueryBuilder(manager, opts)
.where('groups.type = :type', {type})
.getMany();
});
}

public async getGroupWithMembersById(
groupId: number, opts?: {aclRule?: boolean}, optManager?: EntityManager
): Promise<Group|null> {
return await this._runInTransaction(optManager, async (manager) => {
return await this._getGroupsQueryBuilder(manager, opts)
.andWhere('groups.id = :groupId', {groupId})
.getOne();
});
}

private async _overwriteGroup(id: number, groupDescriptor: GroupWithMembersDescriptor, manager: EntityManager) {
const updatedGroup = Group.create({
id,
type: groupDescriptor.type,
name: groupDescriptor.name,
memberUsers: await this._usersManager.getUsersByIdsStrict(groupDescriptor.memberUsers ?? [], manager),
memberGroups: await this._getGroupsByIdsStrict(groupDescriptor.memberGroups ?? [], manager),
});
return await manager.save(updatedGroup);
}

/**
* Returns a Promise for an array of User entites for the given userIds.
*/
private async _getGroupsByIds(groupIds: number[], optManager?: EntityManager): Promise<Group[]> {
if (groupIds.length === 0) {
return [];
}
return await this._runInTransaction(optManager, async (manager) => {
const queryBuilder = this._getGroupsQueryBuilder(manager)
.where('groups.id IN (:...groupIds)', {groupIds});
return await queryBuilder.getMany();
});
}

private async _getGroupsByIdsStrict(groupIds: number[], optManager?: EntityManager): Promise<Group[]> {
const groups = await this._getGroupsByIds(groupIds, optManager);
if (groups.length !== groupIds.length) {
const foundGroupIds = new Set(groups.map(group => group.id));
const missingGroupIds = groupIds.filter(id => !foundGroupIds.has(id));
throw new ApiError('Groups not found: ' + missingGroupIds.join(', '), 404);
}
return groups;
}

private _getGroupsQueryBuilder(manager: EntityManager, opts: {aclRule?: boolean} = {}) {
let queryBuilder = manager.createQueryBuilder()
.select('groups')
.addSelect('groups.type')
.addSelect('memberGroups.type')
.from(Group, 'groups')
.leftJoinAndSelect('groups.memberUsers', 'memberUsers')
.leftJoinAndSelect('groups.memberGroups', 'memberGroups');
if (opts.aclRule) {
queryBuilder = queryBuilder
.leftJoinAndSelect('groups.aclRule', 'aclRule');
}
return queryBuilder;
}
}
47 changes: 41 additions & 6 deletions app/gen-server/lib/homedb/HomeDBManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,13 @@ import {
AvailableUsers,
DocumentAccessChanges,
GetUserOptions,
GroupDescriptor,
GroupWithMembersDescriptor,
NonGuestGroup,
OrgAccessChanges,
PreviousAndCurrent,
QueryResult,
Resource,
RoleGroupDescriptor,
UserProfileChange,
WorkspaceAccessChanges,
} from 'app/gen-server/lib/homedb/Interfaces';
Expand All @@ -73,6 +74,7 @@ import {makeId} from 'app/server/lib/idUtils';
import log from 'app/server/lib/log';
import {Permit} from 'app/server/lib/Permit';
import {getScope} from 'app/server/lib/requestUtils';
import {GroupsManager, GroupTypes} from 'app/gen-server/lib/homedb/GroupsManager';
import {WebHookSecret} from "app/server/lib/Triggers";

import {EventEmitter} from 'events';
Expand All @@ -88,7 +90,6 @@ import {
WhereExpressionBuilder
} from "typeorm";
import {v4 as uuidv4} from "uuid";
import { GroupsManager } from './GroupsManager';

// Support transactions in Sqlite in async code. This is a monkey patch, affecting
// the prototypes of various TypeORM classes.
Expand Down Expand Up @@ -253,7 +254,7 @@ export type BillingOptions = Partial<Pick<BillingAccount,
*/
export class HomeDBManager extends EventEmitter {
private _usersManager = new UsersManager(this, this._runInTransaction.bind(this));
private _groupsManager = new GroupsManager();
private _groupsManager = new GroupsManager(this._usersManager, this._runInTransaction.bind(this));
private _connection: DataSource;
private _exampleWorkspaceId: number;
private _exampleOrgId: number;
Expand All @@ -271,15 +272,15 @@ export class HomeDBManager extends EventEmitter {
return super.emit(event, ...args);
}

public get defaultGroups(): GroupDescriptor[] {
public get defaultGroups(): RoleGroupDescriptor[] {
return this._groupsManager.defaultGroups;
}

public get defaultBasicGroups(): GroupDescriptor[] {
public get defaultBasicGroups(): RoleGroupDescriptor[] {
return this._groupsManager.defaultBasicGroups;
}

public get defaultCommonGroups(): GroupDescriptor[] {
public get defaultCommonGroups(): RoleGroupDescriptor[] {
return this._groupsManager.defaultCommonGroups;
}

Expand Down Expand Up @@ -603,6 +604,7 @@ export class HomeDBManager extends EventEmitter {
includeOrgsAndManagers: boolean,
transaction?: EntityManager): Promise<BillingAccount> {
const org = this.unwrapQueryResult(await this.getOrg(scope, orgKey, transaction));

if (!org.billingAccount.isManager && scope.userId !== this._usersManager.getPreviewerUserId() &&
// The special permit (used for the support user) allows access to the billing account.
scope.specialPermit?.org !== orgKey) {
Expand Down Expand Up @@ -3067,6 +3069,39 @@ export class HomeDBManager extends EventEmitter {
return query.getOne();
}

public async createGroup(groupDescriptor: GroupWithMembersDescriptor, optManager?: EntityManager) {
return this._groupsManager.createGroup(groupDescriptor, optManager);
}

public async overwriteResourceUsersGroup(
id: number, groupDescriptor: GroupWithMembersDescriptor, optManager?: EntityManager
) {
return this._groupsManager.overwriteResourceUsersGroup(id, groupDescriptor, optManager);
}

public async overwriteRoleGroup(
id: number, groupDescriptor: GroupWithMembersDescriptor, optManager?: EntityManager
) {
return this._groupsManager.overwriteRoleGroup(id, groupDescriptor, optManager);
}

public async deleteGroup(id: number, expectedType?: GroupTypes, optManager?: EntityManager) {
return this._groupsManager.deleteGroup(id, expectedType, optManager);
}

public getGroupsWithMembers(manager?: EntityManager): Promise<Group[]> {
return this._groupsManager.getGroupsWithMembers(manager);
}

public getGroupsWithMembersByType(
type: GroupTypes, opts?: {aclRule?: boolean}, manager?: EntityManager): Promise<Group[]> {
return this._groupsManager.getGroupsWithMembersByType(type, opts, manager);
}

public getGroupWithMembersById(id: number, opts?: {aclRule: boolean}, manager?: EntityManager): Promise<Group|null> {
return this._groupsManager.getGroupWithMembersById(id, opts, manager);
}

private _installConfig(
key: ConfigKey,
{ manager }: { manager?: EntityManager }
Expand Down
10 changes: 9 additions & 1 deletion app/gen-server/lib/homedb/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { User } from "app/gen-server/entity/User";
import { Workspace } from "app/gen-server/entity/Workspace";

import { EntityManager } from "typeorm";
import { GroupTypes } from "./GroupsManager";

export interface QueryResult<T> {
status: number;
Expand Down Expand Up @@ -61,13 +62,20 @@ export interface OrgAccessChanges {
accessChanges: Omit<AccessChanges, "publicAccess" | "maxInheritedAccess">;
}

export interface GroupDescriptor {
export interface RoleGroupDescriptor {
readonly name: roles.Role;
readonly permissions: number;
readonly nestParent: boolean;
readonly orgOnly?: boolean;
}

export interface GroupWithMembersDescriptor {
readonly type: GroupTypes;
readonly name: string;
readonly memberUsers?: number[];
readonly memberGroups?: number[];
}

interface AccessChanges {
publicAccess: roles.NonGuestRole | null;
maxInheritedAccess: roles.BasicRole | null;
Expand Down
Loading
Loading