Skip to content

Commit

Permalink
Working version for /Groups and /Groups/:id
Browse files Browse the repository at this point in the history
  • Loading branch information
fflorent committed Jan 13, 2025
1 parent ed83965 commit b688628
Show file tree
Hide file tree
Showing 6 changed files with 311 additions and 32 deletions.
53 changes: 53 additions & 0 deletions app/server/lib/scim/v2/BaseController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { HomeDBManager } from "app/gen-server/lib/homedb/HomeDBManager";
import { ApiError } from "app/common/ApiError";
import { LogMethods } from "app/server/lib/LogMethods";
import { RequestContext } from 'app/server/lib/scim/v2/ScimTypes';

import SCIMMY from "scimmy";

export class BaseController {
protected static getIdFromResource(resource: any) {
const id = parseInt(resource.id, 10);
if (Number.isNaN(id)) {
throw new SCIMMY.Types.Error(400, 'invalidValue', 'Invalid passed group ID');
}
return id;
}

protected logger = new LogMethods(this.constructor.name, () => ({}));

constructor(
protected dbManager: HomeDBManager,
protected checkAccess: (context: RequestContext) => void
) {}

/**
* Runs the passed callback and handles any errors that might occur.
* Also checks if the user has access to the operation.
* Any public method of this class should be run through this method.
*
* @param context The request context to check access for the user
* @param cb The callback to run
*/
protected async runAndHandleErrors<T>(context: RequestContext, cb: () => Promise<T>): Promise<T> {
try {
this.checkAccess(context);
return await cb();
} catch (err) {
if (err instanceof ApiError) {
this.logger.error(null, ' ApiError: ', err.status, err.message);
if (err.status === 409) {
throw new SCIMMY.Types.Error(err.status, 'uniqueness', err.message);
}
throw new SCIMMY.Types.Error(err.status, null!, err.message);
}
if (err instanceof SCIMMY.Types.Error) {
this.logger.error(null, ' SCIMMY.Types.Error: ', err.message);
throw err;
}
// By default, return a 500 error
this.logger.error(null, ' Error: ', err.message);
throw new SCIMMY.Types.Error(500, null!, err.message);
}
}
}
56 changes: 56 additions & 0 deletions app/server/lib/scim/v2/ScimGroupController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Group } from 'app/gen-server/entity/Group';
import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager';
import { BaseController } from 'app/server/lib/scim/v2/BaseController';
import { RequestContext } from 'app/server/lib/scim/v2/ScimTypes';
import { toSCIMMYGroup } from 'app/server/lib/scim/v2/ScimUtils';

import SCIMMY from 'scimmy';

class ScimGroupController extends BaseController {
/**
* Gets a single group with the passed ID.
*
* @param resource The SCIMMY group resource performing the operation
* @param context The request context
*/
public async getSingleGroup(resource: any, context: RequestContext) {
return this.runAndHandleErrors(context, async () => {
const id = ScimGroupController.getIdFromResource(resource);
const group = await this.dbManager.getGroupWithMembersById(id);
if (!group || group.type !== Group.RESOURCE_USERS_TYPE) {
throw new SCIMMY.Types.Error(404, null!, `Group with ID ${id} not found`);
}
return toSCIMMYGroup(group);
});
}

/**
* Gets all groups.
* @param resource The SCIMMY group resource performing the operation
* @param context The request context
* @returns All groups
*/
public async getGroups(resource: any, context: RequestContext) {
return this.runAndHandleErrors(context, async () => {
const { filter } = resource;
const scimmyGroup = (await this.dbManager.getGroupsWithMembersByType(Group.RESOURCE_USERS_TYPE))
.map(group => toSCIMMYGroup(group));
return filter ? filter.match(scimmyGroup) : scimmyGroup;
});
}
}

export const getScimGroupConfig = (
dbManager: HomeDBManager, checkAccess: (context: RequestContext) => void
) => {
const controller = new ScimGroupController(dbManager, checkAccess);

return {
egress: async (resource: any, context: RequestContext) => {
if (resource.id) {
return await controller.getSingleGroup(resource, context);
}
return await controller.getGroups(resource, context);
},
};
};
46 changes: 17 additions & 29 deletions app/server/lib/scim/v2/ScimUserController.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,12 @@
import { ApiError } from 'app/common/ApiError';
import { HomeDBManager, Scope } from 'app/gen-server/lib/homedb/HomeDBManager';
import SCIMMY from 'scimmy';
import { toSCIMMYUser, toUserProfile } from 'app/server/lib/scim/v2/ScimUserUtils';
import { RequestContext } from 'app/server/lib/scim/v2/ScimTypes';
import log from 'app/server/lib/log';
import { BaseController } from 'app/server/lib/scim/v2/BaseController';
import { RequestContext } from 'app/server/lib/scim/v2/ScimTypes';
import { toSCIMMYUser, toUserProfile } from 'app/server/lib/scim/v2/ScimUtils';
import SCIMMY from 'scimmy';

class ScimUserController {
private static _getIdFromResource(resource: any) {
const id = parseInt(resource.id, 10);
if (Number.isNaN(id)) {
throw new SCIMMY.Types.Error(400, 'invalidValue', 'Invalid passed user ID');
}
return id;
}

constructor(
private _dbManager: HomeDBManager,
private _checkAccess: (context: RequestContext) => void
) {}

class ScimUserController extends BaseController {
/**
* Gets a single user with the passed ID.
*
Expand All @@ -27,8 +15,8 @@ class ScimUserController {
*/
public async getSingleUser(resource: any, context: RequestContext) {
return this._runAndHandleErrors(context, async () => {
const id = ScimUserController._getIdFromResource(resource);
const user = await this._dbManager.getUser(id);
const id = BaseController.getIdFromResource(resource);
const user = await this.dbManager.getUser(id);
if (!user) {
throw new SCIMMY.Types.Error(404, null!, `User with ID ${id} not found`);
}
Expand All @@ -45,7 +33,7 @@ class ScimUserController {
public async getUsers(resource: any, context: RequestContext) {
return this._runAndHandleErrors(context, async () => {
const { filter } = resource;
const scimmyUsers = (await this._dbManager.getUsers()).map(user => toSCIMMYUser(user));
const scimmyUsers = (await this.dbManager.getUsers()).map(user => toSCIMMYUser(user));
return filter ? filter.match(scimmyUsers) : scimmyUsers;
});
}
Expand All @@ -60,7 +48,7 @@ class ScimUserController {
return this._runAndHandleErrors(context, async () => {
await this._checkEmailCanBeUsed(data.userName);
const userProfile = toUserProfile(data);
const newUser = await this._dbManager.getUserByLoginWithRetry(userProfile.email, {
const newUser = await this.dbManager.getUserByLoginWithRetry(userProfile.email, {
profile: userProfile
});
return toSCIMMYUser(newUser);
Expand All @@ -76,12 +64,12 @@ class ScimUserController {
*/
public async overwriteUser(resource: any, data: any, context: RequestContext) {
return this._runAndHandleErrors(context, async () => {
const id = ScimUserController._getIdFromResource(resource);
if (this._dbManager.getSpecialUserIds().includes(id)) {
const id = BaseController.getIdFromResource(resource);
if (this.dbManager.getSpecialUserIds().includes(id)) {
throw new SCIMMY.Types.Error(403, null!, 'System user modification not permitted.');
}
await this._checkEmailCanBeUsed(data.userName, id);
const updatedUser = await this._dbManager.overwriteUser(id, toUserProfile(data));
const updatedUser = await this.dbManager.overwriteUser(id, toUserProfile(data));
return toSCIMMYUser(updatedUser);
});
}
Expand All @@ -94,14 +82,14 @@ class ScimUserController {
*/
public async deleteUser(resource: any, context: RequestContext) {
return this._runAndHandleErrors(context, async () => {
const id = ScimUserController._getIdFromResource(resource);
if (this._dbManager.getSpecialUserIds().includes(id)) {
const id = BaseController.getIdFromResource(resource);
if (this.dbManager.getSpecialUserIds().includes(id)) {
throw new SCIMMY.Types.Error(403, null!, 'System user deletion not permitted.');
}
const fakeScope: Scope = { userId: id };
// FIXME: deleteUser should probably be rewritten to not require a scope. We should move
// the scope creation to a controller.
await this._dbManager.deleteUser(fakeScope, id);
await this.dbManager.deleteUser(fakeScope, id);
});
}

Expand All @@ -115,7 +103,7 @@ class ScimUserController {
*/
private async _runAndHandleErrors<T>(context: RequestContext, cb: () => Promise<T>): Promise<T> {
try {
this._checkAccess(context);
this.checkAccess(context);
return await cb();
} catch (err) {
if (err instanceof ApiError) {
Expand Down Expand Up @@ -143,7 +131,7 @@ class ScimUserController {
* so it won't raise an error if the passed email is already used by this user.
*/
private async _checkEmailCanBeUsed(email: string, userIdToUpdate?: number) {
const existingUser = await this._dbManager.getExistingUserByLogin(email);
const existingUser = await this.dbManager.getExistingUserByLogin(email);
if (existingUser !== undefined && existingUser.id !== userIdToUpdate) {
throw new SCIMMY.Types.Error(409, 'uniqueness', 'An existing user with the passed email exist.');
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { normalizeEmail } from "app/common/emails";
import { UserProfile } from "app/common/LoginSessionAPI";
import { User } from "app/gen-server/entity/User.js";
import { User } from "app/gen-server/entity/User";
import { Group } from "app/gen-server/entity/Group";
import SCIMMY from "scimmy";
import log from 'app/server/lib/log';

const SCIM_API_BASE_PATH = '/api/scim/v2';

/**
* Converts a user from your database to a SCIMMY user
*/
Expand Down Expand Up @@ -46,3 +49,25 @@ export function toUserProfile(scimUser: any, existingUser?: User): UserProfile {
email: emailValue ?? scimUser.userName ?? existingUser?.loginEmail,
};
}

export function toSCIMMYGroup(group: Group) {
return new SCIMMY.Schemas.Group({
id: String(group.id),
displayName: group.name,
members: [
...group.memberUsers.map((member: any) => ({
value: String(member.id),
display: member.name,
$ref: `${SCIM_API_BASE_PATH}/Users/${member.id}`,
type: 'User',
})),
// As of 2025-01-12, we don't support nested groups, so it should always be empty
...group.memberGroups.map((member: any) => ({
value: String(member.id),
display: member.name,
$ref: `${SCIM_API_BASE_PATH}/Groups/${member.id}`,
type: 'Group',
})),
],
});
}
2 changes: 2 additions & 0 deletions app/server/lib/scim/v2/ScimV2Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { RequestWithLogin } from 'app/server/lib/Authorizer';
import { InstallAdmin } from 'app/server/lib/InstallAdmin';
import { RequestContext } from 'app/server/lib/scim/v2/ScimTypes';
import { getScimUserConfig } from 'app/server/lib/scim/v2/ScimUserController';
import { getScimGroupConfig } from 'app/server/lib/scim/v2/ScimGroupController';

const WHITELISTED_PATHS_FOR_NON_ADMINS = [ "/Me", "/Schemas", "/ResourceTypes", "/ServiceProviderConfig" ];

Expand All @@ -20,6 +21,7 @@ const buildScimRouterv2 = (dbManager: HomeDBManager, installAdmin: InstallAdmin)
}

SCIMMY.Resources.declare(SCIMMY.Resources.User, getScimUserConfig(dbManager, checkAccess));
SCIMMY.Resources.declare(SCIMMY.Resources.Group, getScimGroupConfig(dbManager, checkAccess));

const scimmyRouter = new SCIMMYRouters({
type: 'bearer',
Expand Down
Loading

0 comments on commit b688628

Please sign in to comment.