diff --git a/app/server/lib/scim/v2/BaseController.ts b/app/server/lib/scim/v2/BaseController.ts new file mode 100644 index 0000000000..73dd9f121a --- /dev/null +++ b/app/server/lib/scim/v2/BaseController.ts @@ -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(context: RequestContext, cb: () => Promise): Promise { + 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); + } + } +} diff --git a/app/server/lib/scim/v2/ScimGroupController.ts b/app/server/lib/scim/v2/ScimGroupController.ts new file mode 100644 index 0000000000..1b24051dc7 --- /dev/null +++ b/app/server/lib/scim/v2/ScimGroupController.ts @@ -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); + }, + }; +}; diff --git a/app/server/lib/scim/v2/ScimUserController.ts b/app/server/lib/scim/v2/ScimUserController.ts index 3c14c37f68..881f5aca8f 100644 --- a/app/server/lib/scim/v2/ScimUserController.ts +++ b/app/server/lib/scim/v2/ScimUserController.ts @@ -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. * @@ -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`); } @@ -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; }); } @@ -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); @@ -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); }); } @@ -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); }); } @@ -115,7 +103,7 @@ class ScimUserController { */ private async _runAndHandleErrors(context: RequestContext, cb: () => Promise): Promise { try { - this._checkAccess(context); + this.checkAccess(context); return await cb(); } catch (err) { if (err instanceof ApiError) { @@ -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.'); } diff --git a/app/server/lib/scim/v2/ScimUserUtils.ts b/app/server/lib/scim/v2/ScimUtils.ts similarity index 65% rename from app/server/lib/scim/v2/ScimUserUtils.ts rename to app/server/lib/scim/v2/ScimUtils.ts index 8c51eada3f..940028ddaf 100644 --- a/app/server/lib/scim/v2/ScimUserUtils.ts +++ b/app/server/lib/scim/v2/ScimUtils.ts @@ -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 */ @@ -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', + })), + ], + }); +} diff --git a/app/server/lib/scim/v2/ScimV2Api.ts b/app/server/lib/scim/v2/ScimV2Api.ts index ff28425c7d..1b47210dc2 100644 --- a/app/server/lib/scim/v2/ScimV2Api.ts +++ b/app/server/lib/scim/v2/ScimV2Api.ts @@ -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" ]; @@ -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', diff --git a/test/server/lib/Scim.ts b/test/server/lib/Scim.ts index 7e19b33625..d92b21ec77 100644 --- a/test/server/lib/Scim.ts +++ b/test/server/lib/Scim.ts @@ -7,6 +7,7 @@ import log from 'app/server/lib/log'; import { TestServer } from 'test/gen-server/apiUtils'; import { configForUser } from 'test/gen-server/testUtils'; import * as testUtils from 'test/server/testUtils'; +import { Group } from 'app/gen-server/entity/Group'; function scimConfigForUser(user: string) { const config = configForUser(user); @@ -42,7 +43,6 @@ describe('Scim', () => { before(async function () { oldEnv = new testUtils.EnvironmentSnapshot(); - process.env.TYPEORM_DATABASE = ':memory:'; Object.assign(process.env, env); server = new TestServer(this); homeUrl = await server.start(); @@ -189,6 +189,9 @@ describe('Scim', () => { sandbox.stub(getDbManager(), 'getUserByLoginWithRetry').throws(error); sandbox.stub(getDbManager(), 'overwriteUser').throws(error); sandbox.stub(getDbManager(), 'deleteUser').throws(error); + sandbox.stub(getDbManager(), 'getGroupWithMembersById').throws(error); + sandbox.stub(getDbManager(), 'getGroupsWithMembersByType').throws(error); + sandbox.stub(getDbManager(), 'getGroupsWithMembers').throws(error); const res = await makeCallWith('chimpy'); assert.deepEqual(res.data, { @@ -333,7 +336,7 @@ describe('Scim', () => { await cb(userName); } finally { const user = await getDbManager().getExistingUserByLogin(userName + "@getgrist.com"); - if (user) { + if (user && !process.env.NO_CLEANUP) { await cleanupUser(user.id); } } @@ -605,6 +608,158 @@ describe('Scim', () => { }); }); + describe('Groups', function () { + async function cleanupGroups(groups: Group[]) { + for (const {id} of groups) { + await getDbManager().deleteGroup(id); + } + } + + async function getGroupByNames(groupNames: string[]) { + return await getDbManager().connection.createQueryBuilder() + .select('g') + .from(Group, 'g') + .where('g.name IN (:...groupNames)', { groupNames }) + .getMany(); + } + + async function withGroupNames(groupNames: string[], cb: (groupNames: string[]) => Promise) { + try { + const existingGroups = await getGroupByNames(groupNames); + if (existingGroups.length > 0) { + throw new Error(`Group with name ${existingGroups[0].name} already exists`); + } + return await cb(groupNames); + } finally { + if (!process.env.NO_CLEANUP) { + const groups = await getGroupByNames(groupNames); + await cleanupGroups(groups); + } + } + } + + async function withGroupName(groupName: string, cb: (groupName: string) => Promise) { + return await withGroupNames([groupName], (groupNames) => cb(groupNames[0])); + } + + describe('GET /Groups/{id}', function () { + it(`should return a "${Group.RESOURCE_USERS_TYPE}" group for chimpy`, async function () { + await withGroupName('test-get-group-by-id', async (groupName) => { + const {id: groupId} = await getDbManager().createGroup({ + name: groupName, + type: Group.RESOURCE_USERS_TYPE, + memberUsers: [userIdByName['chimpy']!, userIdByName['kiwi']!] + }); + + const res = await axios.get(scimUrl('/Groups/' + groupId), chimpy); + + assert.equal(res.status, 200); + assert.deepEqual(res.data, { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'], + id: String(groupId), + displayName: groupName, + members: [ + { value: '1', display: 'Chimpy', $ref: '/api/scim/v2/Users/1', type: 'User' }, + { value: '2', display: 'Kiwi', $ref: '/api/scim/v2/Users/2', type: 'User' }, + ], + meta: { resourceType: 'Group', location: `/api/scim/v2/Groups/${groupId}` } + }); + }); + }); + + it('should return 404 when the group is not found', async function () { + const nonExistingId = 10000000; + const res = await axios.get(scimUrl(`/Groups/${nonExistingId}`), chimpy); + assert.equal(res.status, 404); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '404', + detail: `Group with ID ${nonExistingId} not found` + }); + }); + + it(`should return 404 when the group is of type ${Group.ROLE_TYPE}`, async function () { + await withGroupName('test-role-group', async (groupName) => { + const {id: groupId} = await getDbManager().createGroup({ + name: groupName, + type: Group.ROLE_TYPE, + memberUsers: [userIdByName['chimpy']!] + }); + + const res = await axios.get(scimUrl('/Groups/' + groupId), chimpy); + assert.equal(res.status, 404); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '404', + detail: `Group with ID ${groupId} not found` + }); + }); + }); + + it('should return 400 when the group id is malformed', async function () { + const res = await axios.get(scimUrl('/Groups/not-an-id'), chimpy); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '400', + detail: 'Invalid passed group ID', + scimType: 'invalidValue' + }); + assert.equal(res.status, 400); + }); + + checkCommonErrors('get', '/Groups/1'); + }); + + describe('GET /Groups', function () { + it(`should return all ${Group.RESOURCE_USERS_TYPE} groups for chimpy`, async function () { + return withGroupNames( + ['test-group1', 'test-group2', 'test-role-group'], + async ([group1Name, group2Name, roleGroupName]) => { + await getDbManager().createGroup({ + name: roleGroupName, + type: Group.ROLE_TYPE, + memberUsers: [userIdByName['chimpy']!] + }); + const group1 = await getDbManager().createGroup({ + name: group1Name, + type: Group.RESOURCE_USERS_TYPE, + memberUsers: [userIdByName['chimpy']!] + }); + const group2 = await getDbManager().createGroup({ + name: group2Name, + type: Group.RESOURCE_USERS_TYPE, + memberUsers: [userIdByName['kiwi']!] + }); + + const res = await axios.get(scimUrl('/Groups'), chimpy); + assert.equal(res.status, 200); + assert.isAbove(res.data.totalResults, 0, 'should have retrieved some groups'); + assert.isFalse(res.data.Resources.some( + ({displayName}: {displayName: string}) => displayName === roleGroupName + ), 'The API endpoint should not return role Groups'); + assert.deepEqual(res.data.Resources, [ + { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'], + id: String(group1.id), + displayName: group1Name, + members: [{ value: '1', display: 'Chimpy', $ref: '/api/scim/v2/Users/1', type: 'User' }], + meta: { resourceType: 'Group', location: `/api/scim/v2/Groups/${group1.id}` } + }, { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'], + id: String(group2.id), + displayName: group2Name, + members: [{ value: '2', display: 'Kiwi', $ref: '/api/scim/v2/Users/2', type: 'User' }], + meta: { resourceType: 'Group', location: `/api/scim/v2/Groups/${group2.id}` } + } + ]); + } + ); + }); + + checkCommonErrors('get', '/Groups'); + }); + }); + describe('POST /Bulk', function () { let usersToCleanupEmails: string[];