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 2 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
14 changes: 14 additions & 0 deletions app/gen-server/entity/Group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ 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 @@ -30,4 +32,16 @@ export class Group extends BaseEntity {

@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 = Group.ROLE_TYPE;
}


91 changes: 86 additions & 5 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 @@ -272,4 +279,78 @@ 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.getUsersByIds(groupDescriptor.memberUsers ?? [], manager),
memberGroups: await this._getGroupsByIds(groupDescriptor.memberGroups ?? [], manager),
});
return await manager.save(group);
});
}

public async updateGroup(
id: number, groupDescriptor: Partial<GroupWithMembersDescriptor>, optManager?: EntityManager
) {
return await this._runInTransaction(optManager, async (manager) => {
const updatedProperties = Group.create({
type: groupDescriptor.type,
name: groupDescriptor.name,
memberUsers: groupDescriptor.memberUsers ?
await this._usersManager.getUsersByIds(groupDescriptor.memberUsers, manager) : [],
memberGroups: groupDescriptor.memberGroups ?
await this._getGroupsByIds(groupDescriptor.memberGroups, manager) : [],
});
const existingGroup = await this.getGroupWithMembersById(id, manager);
if (!existingGroup) {
throw new ApiError(`Group with id ${id} not found`, 404);
}
const group = Group.merge(existingGroup, updatedProperties);
return await manager.save(group);
});
}

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

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

/**
* 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 = manager.createQueryBuilder()
.select('groups')
.from(Group, 'groups')
.where('groups.id IN (:...groupIds)', {groupIds});
return await queryBuilder.getMany();
});
}

private _getGroupByTypeQueryBuilder(manager: EntityManager) {
return manager.createQueryBuilder()
.select('groups')
.addSelect('groups.type')
.from(Group, 'groups')
.leftJoinAndSelect('groups.memberUsers', 'memberUsers')
.leftJoinAndSelect('groups.memberGroups', 'memberGroups');
}
}
26 changes: 20 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 Down Expand Up @@ -88,7 +89,7 @@ import {
WhereExpressionBuilder
} from "typeorm";
import {v4 as uuidv4} from "uuid";
import { GroupsManager } from './GroupsManager';
import { GroupsManager, GroupTypes } 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,18 @@ export class HomeDBManager extends EventEmitter {
return query.getOne();
}

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

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

public getGroupWithMembersById(id: number, manager?: EntityManager): Promise<Group|null> {
return this._groupsManager.getGroupWithMembersById(id, 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
13 changes: 7 additions & 6 deletions app/gen-server/lib/homedb/UsersManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -725,12 +725,13 @@ export class UsersManager {
if (userIds.length === 0) {
return [];
}
const manager = optManager || new EntityManager(this._connection);
const queryBuilder = manager.createQueryBuilder()
.select('users')
.from(User, 'users')
.where('users.id IN (:...userIds)', {userIds});
return await queryBuilder.getMany();
return await this._runInTransaction(optManager, async (manager) => {
const queryBuilder = manager.createQueryBuilder()
.select('users')
.from(User, 'users')
.where('users.id IN (:...userIds)', {userIds});
return await queryBuilder.getMany();
});
}

/**
Expand Down
33 changes: 33 additions & 0 deletions app/gen-server/migration/1734097274107-GroupTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
import { Group } from "app/gen-server/entity/Group";

export class GroupTypes1734097274107 implements MigrationInterface {

public async up(queryRunner: QueryRunner): Promise<any> {
const newColumn = new TableColumn({
name: 'type',
type: 'varchar',
enum: [Group.ROLE_TYPE, Group.RESOURCE_USERS_TYPE],
comment: `If the type is ${Group.ROLE_TYPE}, the group is meant to assign a role to ` +
'users for a resource (document, workspace or org).' +
'\n\n' +
`If the type is "${Group.RESOURCE_USERS_TYPE}", the group is meant to gather users together ` +
'so they can be granted the same role to some resources (hence this name).',
isNullable: true, // Make it not nullable after setting the roles for existing groups
});

await queryRunner.addColumn('groups', newColumn);

await queryRunner.manager
.query('UPDATE groups SET type = $1', [Group.ROLE_TYPE]);

newColumn.isNullable = false;

await queryRunner.changeColumn('groups', newColumn.name, newColumn);
}

public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.dropColumn('groups', 'type');
}
}

36 changes: 23 additions & 13 deletions app/server/lib/dbUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,24 +83,34 @@ export async function getOrCreateConnection(): Promise<Connection> {
}

export async function runMigrations(connection: Connection) {
// on SQLite, migrations fail if we don't temporarily disable foreign key
// constraint checking. This is because for sqlite typeorm copies each
// table and rebuilds it from scratch for each schema change.
// Also, we need to disable foreign key constraint checking outside of any
// transaction, or it has no effect.
const sqlite = connection.driver.options.type === 'sqlite';
if (sqlite) { await connection.query("PRAGMA foreign_keys = OFF;"); }
await connection.runMigrations({ transaction: "all" });
if (sqlite) { await connection.query("PRAGMA foreign_keys = ON;"); }
return await withSqliteForeignKeyConstraintDisabled(connection, async () => {
await connection.runMigrations({ transaction: "all" });
});
}

export async function undoLastMigration(connection: Connection) {
return await withSqliteForeignKeyConstraintDisabled(connection, async () => {
await connection.transaction(async tr => {
await tr.connection.undoLastMigration();
});
});
}

// on SQLite, migrations fail if we don't temporarily disable foreign key
// constraint checking. This is because for sqlite typeorm copies each
// table and rebuilds it from scratch for each schema change.
// Also, we need to disable foreign key constraint checking outside of any
// transaction, or it has no effect.
export async function withSqliteForeignKeyConstraintDisabled<T>(
connection: Connection, cb: () => Promise<T>
): Promise<T> {
const sqlite = connection.driver.options.type === 'sqlite';
if (sqlite) { await connection.query("PRAGMA foreign_keys = OFF;"); }
await connection.transaction(async tr => {
await tr.connection.undoLastMigration();
});
if (sqlite) { await connection.query("PRAGMA foreign_keys = ON;"); }
try {
return await cb();
} finally {
if (sqlite) { await connection.query("PRAGMA foreign_keys = ON;"); }
}
}

// Replace the old janky ormconfig.js file, which was always a source of
Expand Down
Loading
Loading