diff --git a/README.md b/README.md index 5674c54f65..7fad9fbfeb 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,7 @@ Grist can be configured in many ways. Here are the main environment variables it | GRIST_SNAPSHOT_TIME_CAP | optional. Define the caps for tracking buckets. Usage: {"hour": 25, "day": 32, "isoWeek": 12, "month": 96, "year": 1000} | | GRIST_SNAPSHOT_KEEP | optional. Number of recent snapshots to retain unconditionally for a document, regardless of when they were made | | GRIST_PROMCLIENT_PORT | optional. If set, serve the Prometheus metrics on the specified port number. ⚠️ Be sure to use a port which is not publicly exposed ⚠️. | +| GRIST_ENABLE_SCIM | optional. If set, enable the [SCIM API Endpoint](https://support.getgrist.com/install/scim/) (experimental) | #### AI Formula Assistant related variables (all optional): diff --git a/app/gen-server/lib/homedb/HomeDBManager.ts b/app/gen-server/lib/homedb/HomeDBManager.ts index b1d84bce12..721b18633f 100644 --- a/app/gen-server/lib/homedb/HomeDBManager.ts +++ b/app/gen-server/lib/homedb/HomeDBManager.ts @@ -452,6 +452,10 @@ export class HomeDBManager extends EventEmitter { return this._usersManager.getUser(userId, options); } + public async getUsers() { + return this._usersManager.getUsers(); + } + public async getFullUser(userId: number) { return this._usersManager.getFullUser(userId); } @@ -559,6 +563,10 @@ export class HomeDBManager extends EventEmitter { return this._usersManager.deleteUser(scope, userIdToDelete, name); } + public async overwriteUser(userId: number, props: UserProfile) { + return this._usersManager.overwriteUser(userId, props); + } + /** * Returns a QueryResult for the given organization. The orgKey * can be a string (the domain from url) or the id of an org. If it is @@ -2610,6 +2618,10 @@ export class HomeDBManager extends EventEmitter { return this._usersManager.getAnonymousUser(); } + public getSpecialUserIds() { + return this._usersManager.getSpecialUserIds(); + } + public getAnonymousUserId() { return this._usersManager.getAnonymousUserId(); } @@ -3599,7 +3611,7 @@ export class HomeDBManager extends EventEmitter { // Get the user objects which map to non-null values in the userDelta. const userIds = Object.keys(userDelta).filter(userId => userDelta[userId]) .map(userIdStr => parseInt(userIdStr, 10)); - const users = await this._usersManager.getUsers(userIds, manager); + const users = await this._usersManager.getUsersByIds(userIds, manager); // Add unaffected users to the delta so that we have a record of where they are. groups.forEach(grp => { diff --git a/app/gen-server/lib/homedb/UsersManager.ts b/app/gen-server/lib/homedb/UsersManager.ts index 6a1e8bd386..6a0f431c84 100644 --- a/app/gen-server/lib/homedb/UsersManager.ts +++ b/app/gen-server/lib/homedb/UsersManager.ts @@ -109,6 +109,13 @@ export class UsersManager { return this._specialUserIds[key]; } + /** + * Return the special user ids. + */ + public getSpecialUserIds() { + return Object.values(this._specialUserIds); + } + /** * * Get the id of the anonymous user. @@ -395,7 +402,7 @@ export class UsersManager { // Set the user's name if our provider knows it. Otherwise use their username // from email, for lack of something better. If we don't have a profile at this // time, then leave the name blank in the hopes of learning it when the user logs in. - user.name = (profile && (profile.name || email.split('@')[0])) || ''; + user.name = (profile && this._getNameOrDeduceFromEmail(profile.name, email)) || ''; needUpdate = true; } if (!user.picture && profile && profile.picture) { @@ -582,6 +589,32 @@ export class UsersManager { .filter(fullProfile => fullProfile); } + /** + * Update users with passed property. Optional user properties that are missing will be reset to their default value. + */ + public async overwriteUser(userId: number, props: UserProfile): Promise { + return await this._connection.transaction(async manager => { + const user = await this.getUser(userId, {includePrefs: true}); + if (!user) { throw new ApiError("unable to find user to update", 404); } + const login = user.logins[0]; + user.name = this._getNameOrDeduceFromEmail(props.name, props.email); + user.picture = props.picture || ''; + user.options = {...(user.options || {}), locale: props.locale ?? undefined}; + if (props.email) { + login.email = normalizeEmail(props.email); + login.displayEmail = props.email; + } + await manager.save([user, login]); + + return (await this.getUser(userId))!; + }); + } + + public async getUsers() { + return await User.find({relations: ["logins"]}); + } + + /** * ================================== * @@ -688,7 +721,7 @@ export class UsersManager { /** * Returns a Promise for an array of User entites for the given userIds. */ - public async getUsers(userIds: number[], optManager?: EntityManager): Promise { + public async getUsersByIds(userIds: number[], optManager?: EntityManager): Promise { if (userIds.length === 0) { return []; } @@ -772,6 +805,10 @@ export class UsersManager { return id; } + private _getNameOrDeduceFromEmail(name: string, email: string) { + return name || email.split('@')[0]; + } + // This deals with the problem posed by receiving a PermissionDelta specifying a // role for both alice@x and Alice@x. We do not distinguish between such emails. // If there are multiple indistinguishabe emails, we preserve just one of them, diff --git a/app/server/MergedServer.ts b/app/server/MergedServer.ts index 33cf2b0bac..1a16e9321a 100644 --- a/app/server/MergedServer.ts +++ b/app/server/MergedServer.ts @@ -164,6 +164,7 @@ export class MergedServer { this.flexServer.addUpdatesCheck(); // todo: add support for home api to standalone app this.flexServer.addHomeApi(); + this.flexServer.addScimApi(); this.flexServer.addBillingApi(); this.flexServer.addNotifier(); this.flexServer.addAuditLogger(); diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index c8658ab2e4..b0a5f5341d 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -88,6 +88,7 @@ import * as path from 'path'; import * as serveStatic from "serve-static"; import {ConfigBackendAPI} from "app/server/lib/ConfigBackendAPI"; import {IGristCoreConfig} from "app/server/lib/configCore"; +import {buildScimRouter} from 'app/server/lib/scim'; // Health checks are a little noisy in the logs, so we don't show them all. // We show the first N health checks: @@ -896,6 +897,19 @@ export class FlexServer implements GristServer { new ApiServer(this, this.app, this._dbManager, this._widgetRepository = buildWidgetRepository(this)); } + public addScimApi() { + if (this._check('scim', 'api', 'homedb', 'json', 'api-mw')) { return; } + + const scimRouter = isAffirmative(process.env.GRIST_ENABLE_SCIM) ? + buildScimRouter(this._dbManager, this._installAdmin) : + () => { + throw new ApiError('SCIM API is not enabled', 501); + }; + + this.app.use('/api/scim', scimRouter); + } + + public addBillingApi() { if (this._check('billing-api', 'homedb', 'json', 'api-mw')) { return; } this.getBilling().addEndpoints(this.app); diff --git a/app/server/lib/scim/index.ts b/app/server/lib/scim/index.ts new file mode 100644 index 0000000000..cd8ea963f9 --- /dev/null +++ b/app/server/lib/scim/index.ts @@ -0,0 +1,14 @@ +import * as express from 'express'; + +import { buildScimRouterv2 } from 'app/server/lib/scim/v2/ScimV2Api'; +import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager'; +import { InstallAdmin } from 'app/server/lib/InstallAdmin'; + +const buildScimRouter = (dbManager: HomeDBManager, installAdmin: InstallAdmin) => { + const v2 = buildScimRouterv2(dbManager, installAdmin); + const scim = express.Router(); + scim.use('/v2', v2); + return scim; +}; + +export { buildScimRouter }; diff --git a/app/server/lib/scim/v2/ScimTypes.ts b/app/server/lib/scim/v2/ScimTypes.ts new file mode 100644 index 0000000000..d70aaa0d0d --- /dev/null +++ b/app/server/lib/scim/v2/ScimTypes.ts @@ -0,0 +1,6 @@ +export interface RequestContext { + path: string; + isAdmin: boolean; + isScimUser: boolean; +} + diff --git a/app/server/lib/scim/v2/ScimUserController.ts b/app/server/lib/scim/v2/ScimUserController.ts new file mode 100644 index 0000000000..3c14c37f68 --- /dev/null +++ b/app/server/lib/scim/v2/ScimUserController.ts @@ -0,0 +1,175 @@ +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'; + +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 + ) {} + + /** + * Gets a single user with the passed ID. + * + * @param resource The SCIMMY user resource performing the operation + * @param context The request context + */ + public async getSingleUser(resource: any, context: RequestContext) { + return this._runAndHandleErrors(context, async () => { + const id = ScimUserController._getIdFromResource(resource); + const user = await this._dbManager.getUser(id); + if (!user) { + throw new SCIMMY.Types.Error(404, null!, `User with ID ${id} not found`); + } + return toSCIMMYUser(user); + }); + } + + /** + * Gets all users or filters them based on the passed filter. + * + * @param resource The SCIMMY user resource performing the operation + * @param context The request context + */ + 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)); + return filter ? filter.match(scimmyUsers) : scimmyUsers; + }); + } + + /** + * Creates a new user with the passed data. + * + * @param data The data to create the user with + * @param context The request context + */ + public async createUser(data: any, context: RequestContext) { + return this._runAndHandleErrors(context, async () => { + await this._checkEmailCanBeUsed(data.userName); + const userProfile = toUserProfile(data); + const newUser = await this._dbManager.getUserByLoginWithRetry(userProfile.email, { + profile: userProfile + }); + return toSCIMMYUser(newUser); + }); + } + + /** + * Overrides a user with the passed data. + * + * @param resource The SCIMMY user resource performing the operation + * @param data The data to override the user with + * @param context The request context + */ + 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)) { + 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)); + return toSCIMMYUser(updatedUser); + }); + } + + /** + * Deletes a user with the passed ID. + * + * @param resource The SCIMMY user resource performing the operation + * @param context The request context + */ + public async deleteUser(resource: any, context: RequestContext) { + return this._runAndHandleErrors(context, async () => { + const id = ScimUserController._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); + }); + } + + /** + * 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 + */ + private async _runAndHandleErrors(context: RequestContext, cb: () => Promise): Promise { + try { + this._checkAccess(context); + return await cb(); + } catch (err) { + if (err instanceof ApiError) { + log.error('[ScimUserController] 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) { + log.error('[ScimUserController] SCIMMY.Types.Error: ', err.message); + throw err; + } + // By default, return a 500 error + log.error('[ScimUserController] Error: ', err.message); + throw new SCIMMY.Types.Error(500, null!, err.message); + } + } + + /** + * Checks if the passed email can be used for a new user or by the existing user. + * + * @param email The email to check + * @param userIdToUpdate The ID of the user to update. Pass this when updating a user, + * 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); + if (existingUser !== undefined && existingUser.id !== userIdToUpdate) { + throw new SCIMMY.Types.Error(409, 'uniqueness', 'An existing user with the passed email exist.'); + } + } +} + +export const getScimUserConfig = ( + dbManager: HomeDBManager, checkAccess: (context: RequestContext) => void +) => { + const controller = new ScimUserController(dbManager, checkAccess); + + return { + egress: async (resource: any, context: RequestContext) => { + if (resource.id) { + return await controller.getSingleUser(resource, context); + } + return await controller.getUsers(resource, context); + }, + ingress: async (resource: any, data: any, context: RequestContext) => { + if (resource.id) { + return await controller.overwriteUser(resource, data, context); + } + return await controller.createUser(data, context); + }, + degress: async (resource: any, context: RequestContext) => { + return await controller.deleteUser(resource, context); + } + }; +}; diff --git a/app/server/lib/scim/v2/ScimUserUtils.ts b/app/server/lib/scim/v2/ScimUserUtils.ts new file mode 100644 index 0000000000..8c51eada3f --- /dev/null +++ b/app/server/lib/scim/v2/ScimUserUtils.ts @@ -0,0 +1,48 @@ +import { normalizeEmail } from "app/common/emails"; +import { UserProfile } from "app/common/LoginSessionAPI"; +import { User } from "app/gen-server/entity/User.js"; +import SCIMMY from "scimmy"; +import log from 'app/server/lib/log'; + +/** + * Converts a user from your database to a SCIMMY user + */ +export function toSCIMMYUser(user: User) { + if (!user.logins) { + throw new Error("User must have at least one login"); + } + const locale = user.options?.locale ?? "en"; + return new SCIMMY.Schemas.User({ + id: String(user.id), + userName: user.loginEmail, + displayName: user.name, + name: { + formatted: user.name, + }, + locale, + preferredLanguage: locale, // Assume preferredLanguage is the same as locale + photos: user.picture ? [{ + value: user.picture, + type: "photo", + primary: true + }] : undefined, + emails: [{ + value: user.logins[0].displayEmail, + primary: true, + }], + }); +} + +export function toUserProfile(scimUser: any, existingUser?: User): UserProfile { + const emailValue = scimUser.emails?.[0]?.value; + if (emailValue && normalizeEmail(emailValue) !== normalizeEmail(scimUser.userName)) { + log.warn(`userName "${scimUser.userName}" differ from passed primary email "${emailValue}".` + + 'That should be OK, but be aware that the userName will be ignored in favor of the email to identify the user.'); + } + return { + name: scimUser.displayName ?? existingUser?.name, + picture: scimUser.photos?.[0]?.value, + locale: scimUser.locale, + email: emailValue ?? scimUser.userName ?? existingUser?.loginEmail, + }; +} diff --git a/app/server/lib/scim/v2/ScimV2Api.ts b/app/server/lib/scim/v2/ScimV2Api.ts new file mode 100644 index 0000000000..6c19349c51 --- /dev/null +++ b/app/server/lib/scim/v2/ScimV2Api.ts @@ -0,0 +1,52 @@ +import * as express from 'express'; +import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager'; +import SCIMMY from "scimmy"; +import SCIMMYRouters from "scimmy-routers"; +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'; + +const WHITELISTED_PATHS_FOR_NON_ADMINS = [ "/Me", "/Schemas", "/ResourceTypes", "/ServiceProviderConfig" ]; + +const buildScimRouterv2 = (dbManager: HomeDBManager, installAdmin: InstallAdmin) => { + const v2 = express.Router(); + + function checkAccess(context: RequestContext) { + const {isAdmin, isScimUser, path } = context; + if (!isAdmin && !isScimUser && !WHITELISTED_PATHS_FOR_NON_ADMINS.includes(path)) { + throw new SCIMMY.Types.Error(403, null!, 'You are not authorized to access this resource'); + } + } + + SCIMMY.Resources.declare(SCIMMY.Resources.User, getScimUserConfig(dbManager, checkAccess)); + + const scimmyRouter = new SCIMMYRouters({ + type: 'bearer', + handler: async (request: express.Request) => { + const mreq = request as RequestWithLogin; + if (mreq.userId === undefined) { + // Note that any Error thrown here is automatically converted into a 403 response by SCIMMYRouters. + throw new Error('You are not authorized to access this resource!'); + } + + if (mreq.userId === dbManager.getAnonymousUserId()) { + throw new Error('Anonymous users cannot access SCIM resources'); + } + + return String(mreq.userId); // SCIMMYRouters requires the userId to be a string. + }, + context: async (mreq: RequestWithLogin): Promise => { + const isAdmin = await installAdmin.isAdminReq(mreq); + const isScimUser = Boolean( + process.env.GRIST_SCIM_EMAIL && mreq.user?.loginEmail === process.env.GRIST_SCIM_EMAIL + ); + const path = mreq.path; + return { isAdmin, isScimUser, path }; + } + }) as express.Router; // Have to cast it into express.Router. See https://github.com/scimmyjs/scimmy-routers/issues/24 + + return v2.use('/', scimmyRouter); +}; + +export { buildScimRouterv2 }; diff --git a/package.json b/package.json index 8c0556e89f..ab53c61ebf 100644 --- a/package.json +++ b/package.json @@ -189,6 +189,8 @@ "redis": "3.1.1", "redlock": "3.1.2", "saml2-js": "4.0.2", + "scimmy": "1.2.4", + "scimmy-routers": "1.2.2", "short-uuid": "3.1.1", "slugify": "1.6.6", "swagger-ui-dist": "5.11.0", diff --git a/test/gen-server/lib/homedb/UsersManager.ts b/test/gen-server/lib/homedb/UsersManager.ts index 5b304de1ac..e8aedc6ccf 100644 --- a/test/gen-server/lib/homedb/UsersManager.ts +++ b/test/gen-server/lib/homedb/UsersManager.ts @@ -20,8 +20,9 @@ import { assert } from 'chai'; import Sinon, { SinonSandbox, SinonSpy } from 'sinon'; import { EntityManager } from 'typeorm'; import winston from 'winston'; +import omit from 'lodash/omit'; -import {delay} from 'app/common/delay'; +import { delay } from 'app/common/delay'; describe('UsersManager', function () { this.timeout('3m'); @@ -292,6 +293,15 @@ describe('UsersManager', function () { it("getSupportUserId() should retrieve 'support' user id", function () { assert.strictEqual(db.getSupportUserId(), SUPPORT_USER_ID); }); + + it("getSpecialUserIds() should retrieve all the special user ids", function () { + assert.deepEqual(db.getSpecialUserIds(), [ + ANONYMOUS_USER_ID, + PREVIEWER_USER_ID, + EVERYONE_USER_ID, + SUPPORT_USER_ID + ]); + }); }); describe('getUserByKey()', function () { @@ -982,6 +992,68 @@ describe('UsersManager', function () { } }); }); + + describe('overwriteUser()', function () { + it('should reject when user is not found', async function () { + disableLoggingLevel('debug'); + + const promise = db.overwriteUser(NON_EXISTING_USER_ID, { + email: 'whatever@getgrist.com', + name: 'whatever', + }); + + await assert.isRejected(promise, 'unable to find user to update'); + }); + + it('should update user information', async function () { + const localPart = 'overwriteUser-updates-user-info'; + const newLocalPart = 'overwriteUser-updates-user-info-new'; + const user = await createUniqueUser(localPart); + const newInfo: UserProfile = { + name: 'new name', + email: makeEmail(newLocalPart).toUpperCase(), + picture: 'https://mypic.com/me.png', + locale: 'fr-FR', + }; + + await db.overwriteUser(user.id, newInfo); + + const updatedUser = await getOrCreateUser(newLocalPart); + assert.deepInclude(updatedUser, { + id: user.id, + name: newInfo.name, + picture: newInfo.picture, + options: {locale: newInfo.locale}, + }); + assert.deepInclude(updatedUser.logins[0], { + email: newInfo.email.toLowerCase(), + displayEmail: newInfo.email, + }); + }); + }); + + describe('getUsers()', function () { + it('should return all users with their logins', async function () { + const localPart = 'getUsers-user'; + const existingUser = await createUniqueUser(localPart); + const users = await db.getUsers(); + assert.isAbove(users.length, 2); + const mapUsersById = new Map(users.map(user => [user.id, user])); + + // Check that we retrieve the existing user in the result with all their property + // except the personalOrg + const existingUserInResult = mapUsersById.get(existingUser.id); + assertExists(existingUserInResult); + assertExists(existingUserInResult.logins); + assert.lengthOf(existingUserInResult.logins, 1); + assert.deepEqual(existingUserInResult, omit(existingUser, 'personalOrg')); + + // Check that we retrieve special accounts among the result + assert.exists(mapUsersById.get(db.getSupportUserId())); + assert.exists(mapUsersById.get(db.getEveryoneUserId())); + assert.exists(mapUsersById.get(db.getAnonymousUserId())); + }); + }); }); describe('class method without db setup', function () { diff --git a/test/gen-server/seed.ts b/test/gen-server/seed.ts index 121a01ac15..11b6f4cfd1 100644 --- a/test/gen-server/seed.ts +++ b/test/gen-server/seed.ts @@ -631,6 +631,7 @@ export async function createServer(port: number, initDb = createInitialDb): Prom flexServer.addAccessMiddleware(); flexServer.addApiMiddleware(); flexServer.addHomeApi(); + flexServer.addScimApi(); flexServer.addApiErrorHandlers(); await initDb(flexServer.getHomeDBManager().connection); flexServer.summary(); diff --git a/test/server/lib/Authorizer.ts b/test/server/lib/Authorizer.ts index 7a43e33e27..8dc2c1ecf1 100644 --- a/test/server/lib/Authorizer.ts +++ b/test/server/lib/Authorizer.ts @@ -35,6 +35,7 @@ async function activateServer(home: FlexServer, docManager: DocManager) { await home.addLandingPages(); home.addHomeApi(); home.addAuditLogger(); + home.addScimApi(); await home.addTelemetry(); await home.addDoc(); home.addApiErrorHandlers(); diff --git a/test/server/lib/Scim.ts b/test/server/lib/Scim.ts new file mode 100644 index 0000000000..b836856d53 --- /dev/null +++ b/test/server/lib/Scim.ts @@ -0,0 +1,810 @@ +import axios, { AxiosResponse } from 'axios'; +import capitalize from 'lodash/capitalize'; +import { assert } from 'chai'; +import Sinon from 'sinon'; +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'; + +function scimConfigForUser(user: string) { + const config = configForUser(user); + return { + ...config, + headers: { + ...config.headers, + 'Content-Type': 'application/scim+json' + } + }; +} + +const chimpy = scimConfigForUser('Chimpy'); +const kiwi = scimConfigForUser('Kiwi'); +const charon = scimConfigForUser('Charon'); +const anon = scimConfigForUser('Anonymous'); + +const USER_CONFIG_BY_NAME = { + chimpy, + kiwi, + anon, +}; + +type UserConfigByName = typeof USER_CONFIG_BY_NAME; + +describe('Scim', () => { + testUtils.setTmpLogLevel('error'); + + const setupTestServer = (env: NodeJS.ProcessEnv) => { + let homeUrl: string; + let oldEnv: testUtils.EnvironmentSnapshot; + let server: TestServer; + + 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(); + }); + + after(async () => { + oldEnv.restore(); + await server.stop(); + }); + + return { + scimUrl: (path: string) => (homeUrl + '/api/scim/v2' + path), + getDbManager: () => server.dbManager, + }; + }; + + describe('when disabled', function () { + const { scimUrl } = setupTestServer({}); + + it('should return 501 for /api/scim/v2/Users', async function () { + const res = await axios.get(scimUrl('/Users'), chimpy); + assert.equal(res.status, 501); + assert.deepEqual(res.data, { error: 'SCIM API is not enabled' }); + }); + }); + + describe('when enabled using GRIST_ENABLE_SCIM=1', function () { + const { scimUrl, getDbManager } = setupTestServer({ + GRIST_ENABLE_SCIM: '1', + GRIST_DEFAULT_EMAIL: 'chimpy@getgrist.com', + GRIST_SCIM_EMAIL: 'charon@getgrist.com', + }); + const userIdByName: {[name in keyof UserConfigByName]?: number} = {}; + let logWarnStub: Sinon.SinonStub; + let logErrorStub: Sinon.SinonStub; + + before(async function () { + const userNames = Object.keys(USER_CONFIG_BY_NAME) as Array; + for (const user of userNames) { + userIdByName[user] = await getOrCreateUserId(user); + } + }); + + beforeEach(() => { + logWarnStub = Sinon.stub(log, 'warn'); + logErrorStub = Sinon.stub(log, 'error'); + }); + + afterEach(() => { + logWarnStub.restore(); + logErrorStub.restore(); + }); + + function personaToSCIMMYUserWithId(user: keyof UserConfigByName) { + return toSCIMUserWithId(user, userIdByName[user]!); + } + + function toSCIMUserWithId(user: string, id: number) { + return { + ...toSCIMUserWithoutId(user), + id: String(id), + meta: { resourceType: 'User', location: '/api/scim/v2/Users/' + id }, + }; + } + + function toSCIMUserWithoutId(user: string) { + return { + schemas: [ 'urn:ietf:params:scim:schemas:core:2.0:User' ], + userName: user + '@getgrist.com', + name: { formatted: capitalize(user) }, + displayName: capitalize(user), + preferredLanguage: 'en', + locale: 'en', + emails: [ { value: user + '@getgrist.com', primary: true } ] + }; + } + + async function getOrCreateUserId(user: string) { + return (await getDbManager().getUserByLogin(user + '@getgrist.com'))!.id; + } + + async function cleanupUser(userId: number) { + if (await getDbManager().getUser(userId)) { + await getDbManager().deleteUser({ userId: userId }, userId); + } + } + async function checkOperationOnTechUserDisallowed({op, opType}: { + op: (id: number) => Promise, + opType: string + }) { + const db = getDbManager(); + const specialUsers = { + 'anonymous': db.getAnonymousUserId(), + 'support': db.getSupportUserId(), + 'everyone': db.getEveryoneUserId(), + 'preview': db.getPreviewerUserId(), + }; + for (const [label, id] of Object.entries(specialUsers)) { + const res = await op(id); + assert.equal(res.status, 403, `should forbid ${opType} of the special user ${label}`); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '403', + detail: `System user ${opType} not permitted.` + }); + } + } + + function checkCommonErrors( + method: 'get' | 'post' | 'put' | 'patch' | 'delete', + path: string, + validBody: object = {} + ) { + function makeCallWith(user: keyof UserConfigByName) { + if (method === 'get' || method === 'delete') { + return axios[method](scimUrl(path), USER_CONFIG_BY_NAME[user]); + } + return axios[method](scimUrl(path), validBody, USER_CONFIG_BY_NAME[user]); + } + + it('should return 401 for anonymous', async function () { + const res = await makeCallWith('anon'); + assert.equal(res.status, 401); + }); + + it('should return 403 for kiwi', async function () { + const res = await makeCallWith('kiwi'); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '403', + detail: 'You are not authorized to access this resource' + }); + assert.equal(res.status, 403); + }); + + it('should return a 500 in case of unknown Error', async function () { + const sandbox = Sinon.createSandbox(); + try { + const error = new Error('Some unexpected Error'); + + // Stub all the dbManager methods called by the controller + sandbox.stub(getDbManager(), 'getUsers').throws(error); + sandbox.stub(getDbManager(), 'getUser').throws(error); + sandbox.stub(getDbManager(), 'getUserByLoginWithRetry').throws(error); + sandbox.stub(getDbManager(), 'overwriteUser').throws(error); + sandbox.stub(getDbManager(), 'deleteUser').throws(error); + + const res = await makeCallWith('chimpy'); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '500', + detail: error.message + }); + assert.equal(res.status, 500); + } finally { + sandbox.restore(); + } + }); + } + + describe('/Me', function () { + async function checkGetMeAs(user: keyof UserConfigByName, expected: any) { + const res = await axios.get(scimUrl('/Me'), USER_CONFIG_BY_NAME[user]); + assert.equal(res.status, 200); + assert.deepInclude(res.data, expected); + } + + it(`should return the current user for chimpy`, async function () { + return checkGetMeAs('chimpy', personaToSCIMMYUserWithId('chimpy')); + }); + + it(`should return the current user for kiwi`, async function () { + return checkGetMeAs('kiwi', personaToSCIMMYUserWithId('kiwi')); + }); + + it('should return 401 for anonymous', async function () { + const res = await axios.get(scimUrl('/Me'), anon); + assert.equal(res.status, 401); + }); + + it.skip('should allow operation like PATCH for kiwi', async function () { + // SKIPPING this test: only the GET verb is currently implemented by SCIMMY for the /Me endpoint. + // Issue created here: https://github.com/scimmyjs/scimmy/issues/47 + const patchBody = { + schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], + Operations: [{ + op: "replace", + path: 'locale', + value: 'fr', + }], + }; + const res = await axios.patch(scimUrl('/Me'), patchBody, kiwi); + assert.equal(res.status, 200); + assert.deepEqual(res.data, { + ...personaToSCIMMYUserWithId('kiwi'), + locale: 'fr', + preferredLanguage: 'en', + }); + }); + }); + + describe('GET /Users/{id}', function () { + + it('should return the user of id=1 for chimpy', async function () { + const res = await axios.get(scimUrl('/Users/1'), chimpy); + + assert.equal(res.status, 200); + assert.deepInclude(res.data, { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + id: '1', + displayName: 'Chimpy', + userName: 'chimpy@getgrist.com' + }); + }); + + it('should return 404 when the user is not found', async function () { + const res = await axios.get(scimUrl('/Users/1000'), chimpy); + assert.equal(res.status, 404); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '404', + detail: 'User with ID 1000 not found' + }); + }); + + checkCommonErrors('get', '/Users/1'); + }); + + describe('GET /Users', function () { + it('should return all users for chimpy', async function () { + const res = await axios.get(scimUrl('/Users'), chimpy); + assert.equal(res.status, 200); + assert.isAbove(res.data.totalResults, 0, 'should have retrieved some users'); + assert.deepInclude(res.data.Resources, personaToSCIMMYUserWithId('chimpy')); + assert.deepInclude(res.data.Resources, personaToSCIMMYUserWithId('kiwi')); + }); + + it('should handle pagination', async function () { + const endpointPaginated = '/Users?count=1&sortBy=id'; + { + const firstPage = await axios.get(scimUrl(endpointPaginated), chimpy); + assert.equal(firstPage.status, 200); + assert.lengthOf(firstPage.data.Resources, 1); + const firstPageResourceId = parseInt(firstPage.data.Resources[0].id); + assert.equal(firstPageResourceId, 1); + } + + { + const secondPage = await axios.get(scimUrl(endpointPaginated + '&startIndex=2'), chimpy); + assert.equal(secondPage.status, 200); + assert.lengthOf(secondPage.data.Resources, 1); + const secondPageResourceId = parseInt(secondPage.data.Resources[0].id); + assert.equal(secondPageResourceId, 2); + } + }); + + checkCommonErrors('get', '/Users'); + }); + + describe('POST /Users/.search', function () { + const SEARCH_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:SearchRequest'; + + const searchExample = { + schemas: [SEARCH_SCHEMA], + sortBy: 'userName', + sortOrder: 'descending', + }; + + it('should return all users for chimpy order by userName in descending order', async function () { + const res = await axios.post(scimUrl('/Users/.search'), searchExample, chimpy); + assert.equal(res.status, 200); + assert.isAbove(res.data.totalResults, 0, 'should have retrieved some users'); + const users = res.data.Resources.map((r: any) => r.userName); + assert.include(users, 'chimpy@getgrist.com'); + assert.include(users, 'kiwi@getgrist.com'); + const indexOfChimpy = users.indexOf('chimpy@getgrist.com'); + const indexOfKiwi = users.indexOf('kiwi@getgrist.com'); + assert.isBelow(indexOfKiwi, indexOfChimpy, 'kiwi should come before chimpy'); + }); + + it('should also allow access for user Charon (the one refered in GRIST_SCIM_EMAIL)', async function () { + const res = await axios.post(scimUrl('/Users/.search'), searchExample, charon); + assert.equal(res.status, 200); + }); + + it('should filter the users by userName', async function () { + const res = await axios.post(scimUrl('/Users/.search'), { + schemas: [SEARCH_SCHEMA], + attributes: ['userName'], + filter: 'userName sw "chimpy"', + }, chimpy); + assert.equal(res.status, 200); + assert.equal(res.data.totalResults, 1); + assert.deepEqual(res.data.Resources[0], { id: String(userIdByName['chimpy']), userName: 'chimpy@getgrist.com' }, + "should have retrieved only chimpy's username and not other attribute"); + }); + + checkCommonErrors('post', '/Users/.search', searchExample); + }); + + describe('POST /Users', function () { // Create a new users + async function withUserName(userName: string, cb: (userName: string) => Promise) { + try { + await cb(userName); + } finally { + const user = await getDbManager().getExistingUserByLogin(userName + "@getgrist.com"); + if (user) { + await cleanupUser(user.id); + } + } + } + it('should create a new user', async function () { + await withUserName('newuser1', async (userName) => { + const res = await axios.post(scimUrl('/Users'), toSCIMUserWithoutId(userName), chimpy); + assert.equal(res.status, 201); + const newUserId = await getOrCreateUserId(userName); + assert.deepEqual(res.data, toSCIMUserWithId(userName, newUserId)); + }); + }); + + it('should allow creating a new user given only their email passed as username', async function () { + await withUserName('new.user2', async (userName) => { + const res = await axios.post(scimUrl('/Users'), { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: 'new.user2@getgrist.com', + }, chimpy); + assert.equal(res.status, 201); + assert.equal(res.data.userName, userName + '@getgrist.com'); + assert.equal(res.data.displayName, userName); + }); + }); + + it('should also allow user Charon to create a user (the one refered in GRIST_SCIM_EMAIL)', async function () { + await withUserName('new.user.by.charon', async (userName) => { + const res = await axios.post(scimUrl('/Users'), toSCIMUserWithoutId(userName), charon); + assert.equal(res.status, 201); + }); + }); + + it('should warn when passed email differs from username, and ignore the username', async function () { + await withUserName('username', async (userName) => { + const res = await axios.post(scimUrl('/Users'), { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: userName, + emails: [{ value: 'emails.value@getgrist.com' }], + }, chimpy); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:schemas:core:2.0:User' ], + id: '12', + meta: { resourceType: 'User', location: '/api/scim/v2/Users/12' }, + userName: 'emails.value@getgrist.com', + name: { formatted: 'emails.value' }, + displayName: 'emails.value', + preferredLanguage: 'en', + locale: 'en', + emails: [ + { value: 'emails.value@getgrist.com', primary: true } + ] + }); + assert.equal(res.status, 201); + assert.equal(logWarnStub.callCount, 1, "A warning should have been raised"); + assert.match( + logWarnStub.getCalls()[0].args[0], + new RegExp(`userName "${userName}" differ from passed primary email`) + ); + }); + }); + + it('should disallow creating a user with the same email', async function () { + const res = await axios.post(scimUrl('/Users'), toSCIMUserWithoutId('chimpy'), chimpy); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '409', + detail: 'An existing user with the passed email exist.', + scimType: 'uniqueness' + }); + assert.equal(res.status, 409); + }); + + checkCommonErrors('post', '/Users', toSCIMUserWithoutId('some-user')); + }); + + describe('PUT /Users/{id}', function () { + let userToUpdateId: number; + const userToUpdateEmailLocalPart = 'user-to-update'; + + beforeEach(async function () { + userToUpdateId = await getOrCreateUserId(userToUpdateEmailLocalPart); + }); + afterEach(async function () { + await cleanupUser(userToUpdateId); + }); + + it('should update an existing user', async function () { + const userToUpdateProperties = { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: userToUpdateEmailLocalPart + '-now-updated@getgrist.com', + displayName: 'User to Update', + photos: [{ value: 'https://example.com/photo.jpg', type: 'photo', primary: true }], + locale: 'fr', + }; + const res = await axios.put(scimUrl(`/Users/${userToUpdateId}`), userToUpdateProperties, chimpy); + assert.equal(res.status, 200); + const refreshedUser = await axios.get(scimUrl(`/Users/${userToUpdateId}`), chimpy); + assert.deepEqual(refreshedUser.data, { + ...userToUpdateProperties, + id: String(userToUpdateId), + meta: { resourceType: 'User', location: `/api/scim/v2/Users/${userToUpdateId}` }, + emails: [ { value: userToUpdateProperties.userName, primary: true } ], + name: { formatted: userToUpdateProperties.displayName }, + preferredLanguage: 'fr', + }); + }); + + it('should warn when passed email differs from username', async function () { + const res = await axios.put(scimUrl(`/Users/${userToUpdateId}`), { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: 'whatever@getgrist.com', + emails: [{ value: userToUpdateEmailLocalPart + '@getgrist.com', primary: true }], + }, chimpy); + assert.equal(res.status, 200); + assert.equal(logWarnStub.callCount, 1, "A warning should have been raised"); + assert.match(logWarnStub.getCalls()[0].args[0], /differ from passed primary email/); + }); + + it('should disallow updating a user with the same email as another user\'s', async function () { + const res = await axios.put(scimUrl(`/Users/${userToUpdateId}`), toSCIMUserWithoutId('chimpy'), chimpy); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '409', + detail: 'An existing user with the passed email exist.', + scimType: 'uniqueness' + }); + assert.equal(res.status, 409); + }); + + it('should return 404 when the user is not found', async function () { + const res = await axios.put(scimUrl('/Users/1000'), toSCIMUserWithoutId('whoever'), chimpy); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '404', + detail: 'unable to find user to update' + }); + assert.equal(res.status, 404); + }); + + it('should return 403 for system users', async function () { + const data = toSCIMUserWithoutId('whoever'); + await checkOperationOnTechUserDisallowed({ + op: (id) => axios.put(scimUrl(`/Users/${id}`), data, chimpy), + opType: 'modification' + }); + }); + + + it('should deduce the name from the displayEmail when not provided', async function () { + const res = await axios.put(scimUrl(`/Users/${userToUpdateId}`), { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: 'my-email@getgrist.com', + }, chimpy); + assert.equal(res.status, 200); + assert.deepInclude(res.data, { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + id: String(userToUpdateId), + userName: 'my-email@getgrist.com', + displayName: 'my-email', + }); + }); + + it('should return 400 when the user id is malformed', async function () { + const res = await axios.put(scimUrl('/Users/not-an-id'), toSCIMUserWithoutId('whoever'), chimpy); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '400', + detail: 'Invalid passed user ID', + scimType: 'invalidValue' + }); + assert.equal(res.status, 400); + }); + + it('should normalize the passed email for the userName and keep the case for email.value', async function () { + const newEmail = 'my-EMAIL@getgrist.com'; + const res = await axios.put(scimUrl(`/Users/${userToUpdateId}`), { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: newEmail, + }, chimpy); + assert.equal(res.status, 200); + assert.deepInclude(res.data, { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + id: String(userToUpdateId), + userName: newEmail.toLowerCase(), + displayName: 'my-EMAIL', + emails: [{ value: newEmail, primary: true }] + }); + }); + + checkCommonErrors('put', '/Users/1', toSCIMUserWithoutId('chimpy')); + }); + + describe('PATCH /Users/{id}', function () { + let userToPatchId: number; + const userToPatchEmailLocalPart = 'user-to-patch'; + beforeEach(async function () { + userToPatchId = await getOrCreateUserId(userToPatchEmailLocalPart); + }); + afterEach(async function () { + await cleanupUser(userToPatchId); + }); + + const validPatchBody = (newName: string) => ({ + schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], + Operations: [{ + op: "replace", + path: "displayName", + value: newName, + }, { + op: "replace", + path: "locale", + value: 'fr' + }], + }); + + it('should replace values of an existing user', async function () { + const newName = 'User to Patch new Name'; + const res = await axios.patch(scimUrl(`/Users/${userToPatchId}`), validPatchBody(newName), chimpy); + assert.equal(res.status, 200); + const refreshedUser = await axios.get(scimUrl(`/Users/${userToPatchId}`), chimpy); + assert.deepEqual(refreshedUser.data, { + ...toSCIMUserWithId(userToPatchEmailLocalPart, userToPatchId), + displayName: newName, + name: { formatted: newName }, + locale: 'fr', + preferredLanguage: 'fr', + }); + }); + + checkCommonErrors('patch', '/Users/1', validPatchBody('new name2')); + }); + + describe('DELETE /Users/{id}', function () { + let userToDeleteId: number; + const userToDeleteEmailLocalPart = 'user-to-delete'; + + beforeEach(async function () { + userToDeleteId = await getOrCreateUserId(userToDeleteEmailLocalPart); + }); + afterEach(async function () { + await cleanupUser(userToDeleteId); + }); + + it('should delete a user', async function () { + const res = await axios.delete(scimUrl(`/Users/${userToDeleteId}`), chimpy); + assert.equal(res.status, 204); + const refreshedUser = await axios.get(scimUrl(`/Users/${userToDeleteId}`), chimpy); + assert.equal(refreshedUser.status, 404); + }); + + it('should return 404 when the user is not found', async function () { + const res = await axios.delete(scimUrl('/Users/1000'), chimpy); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '404', + detail: 'user not found' + }); + assert.equal(res.status, 404); + }); + + it('should return 403 for system users', async function () { + await checkOperationOnTechUserDisallowed({ + op: (id) => axios.delete(scimUrl(`/Users/${id}`), chimpy), + opType: 'deletion' + }); + }); + + checkCommonErrors('delete', '/Users/1'); + }); + + describe('POST /Bulk', function () { + let usersToCleanupEmails: string[]; + + beforeEach(async function () { + usersToCleanupEmails = []; + }); + + afterEach(async function () { + for (const email of usersToCleanupEmails) { + const user = await getDbManager().getExistingUserByLogin(email); + if (user) { + await cleanupUser(user.id); + } + } + }); + + it('should return statuses for each operation', async function () { + const putOnUnknownResource = { method: 'PUT', path: '/Users/1000', value: toSCIMUserWithoutId('chimpy') }; + const validCreateOperation = { + method: 'POST', path: '/Users/', data: toSCIMUserWithoutId('bulk-user3'), bulkId: '1' + }; + usersToCleanupEmails.push('bulk-user3'); + const createOperationWithUserNameConflict = { + method: 'POST', path: '/Users/', data: toSCIMUserWithoutId('chimpy'), bulkId: '2' + }; + const res = await axios.post(scimUrl('/Bulk'), { + schemas: ['urn:ietf:params:scim:api:messages:2.0:BulkRequest'], + Operations: [ + putOnUnknownResource, + validCreateOperation, + createOperationWithUserNameConflict, + ], + }, chimpy); + assert.equal(res.status, 200); + + const newUserID = await getOrCreateUserId('bulk-user3'); + assert.deepEqual(res.data, { + schemas: [ "urn:ietf:params:scim:api:messages:2.0:BulkResponse" ], + Operations: [ + { + method: "PUT", + location: "/api/scim/v2/Users/1000", + status: "400", + response: { + schemas: [ + "urn:ietf:params:scim:api:messages:2.0:Error" + ], + status: "400", + scimType: "invalidSyntax", + detail: "Expected 'data' to be a single complex value in BulkRequest operation #1" + } + }, { + method: "POST", + bulkId: "1", + location: "/api/scim/v2/Users/" + newUserID, + status: "201" + }, { + method: "POST", + bulkId: "2", + status: "409", + response: { + schemas: [ + "urn:ietf:params:scim:api:messages:2.0:Error" + ], + status: "409", + scimType: "uniqueness", + detail: "An existing user with the passed email exist." + } + } + ] + }); + }); + + it('should return 400 when no operations are provided', async function () { + const res = await axios.post(scimUrl('/Bulk'), { + schemas: ['urn:ietf:params:scim:api:messages:2.0:BulkRequest'], + Operations: [], + }, chimpy); + assert.equal(res.status, 400); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '400', + detail: "BulkRequest request body must contain 'Operations' attribute with at least one operation", + scimType: 'invalidValue' + }); + }); + + it('should disallow accessing resources to kiwi', async function () { + const creationOperation = { + method: 'POST', path: '/Users', data: toSCIMUserWithoutId('bulk-user4'), bulkId: '1' + }; + usersToCleanupEmails.push('bulk-user4'); + const selfPutOperation = { method: 'PUT', path: '/Me', value: toSCIMUserWithoutId('kiwi') }; + const res = await axios.post(scimUrl('/Bulk'), { + schemas: ['urn:ietf:params:scim:api:messages:2.0:BulkRequest'], + Operations: [ + creationOperation, + selfPutOperation, + ], + }, kiwi); + assert.equal(res.status, 200); + assert.deepEqual(res.data, { + schemas: [ "urn:ietf:params:scim:api:messages:2.0:BulkResponse" ], + Operations: [ + { + method: "POST", + bulkId: "1", + status: "403", + response: { + detail: "You are not authorized to access this resource", + schemas: [ "urn:ietf:params:scim:api:messages:2.0:Error" ], + status: "403" + } + }, { + // When writing this test, the SCIMMY implementation does not yet support PUT operations on /Me. + // This reflects the current behavior, but it may change in the future. + // Change this test if the behavior changes. + // It is probably fine to allow altering oneself even for non-admins. + method: "PUT", + location: "/Me", + status: "400", + response: { + schemas: [ + "urn:ietf:params:scim:api:messages:2.0:Error" + ], + status: "400", + detail: "Invalid 'path' value '/Me' in BulkRequest operation #2", + scimType: "invalidValue" + } + } + ] + }); + }); + + it('should disallow accessing resources to anonymous', async function () { + const creationOperation = { + method: 'POST', path: '/Users', data: toSCIMUserWithoutId('bulk-user5'), bulkId: '1' + }; + usersToCleanupEmails.push('bulk-user5'); + const res = await axios.post(scimUrl('/Bulk'), { + schemas: ['urn:ietf:params:scim:api:messages:2.0:BulkRequest'], + Operations: [creationOperation], + }, anon); + assert.equal(res.status, 401); + }); + }); + + it('should allow fetching the Scim schema when autenticated', async function () { + const res = await axios.get(scimUrl('/Schemas'), kiwi); + assert.equal(res.status, 200); + assert.deepInclude(res.data, { + schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'], + }); + assert.property(res.data, 'Resources'); + assert.deepInclude(res.data.Resources[0], { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:Schema'], + id: 'urn:ietf:params:scim:schemas:core:2.0:User', + name: 'User', + description: 'User Account', + }); + }); + + it('should allow fetching the Scim resource types when autenticated', async function () { + const res = await axios.get(scimUrl('/ResourceTypes'), kiwi); + assert.equal(res.status, 200); + assert.deepInclude(res.data, { + schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'], + }); + assert.property(res.data, 'Resources'); + assert.deepInclude(res.data.Resources[0], { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'], + name: 'User', + endpoint: '/Users', + }); + }); + + it('should allow fetching the Scim service provider config when autenticated', async function () { + const res = await axios.get(scimUrl('/ServiceProviderConfig'), kiwi); + assert.equal(res.status, 200); + assert.deepInclude(res.data, { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'], + }); + assert.property(res.data, 'patch'); + assert.property(res.data, 'bulk'); + assert.property(res.data, 'filter'); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index f1f54e2599..993d4303be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7344,6 +7344,16 @@ schema-utils@^3.2.0: ajv "^6.12.5" ajv-keywords "^3.5.2" +scimmy-routers@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/scimmy-routers/-/scimmy-routers-1.2.2.tgz#e1fa506a8cdb0ba04a25e09a365bd726cd781585" + integrity sha512-qDB7DKb2cnujJzEgVdON8EnjZfs6oY+MJQkkCHbihNrQeRjSaEOAC9ohb6dGfMZdahYS0CZIJwGhvZlS6rkKsg== + +scimmy@1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/scimmy/-/scimmy-1.2.4.tgz#3d708d9a5f3c7b3e00d848dcb8f0910d7c409509" + integrity sha512-5i+LwGL7ON61jH+KxL6flpy5h/ABhgx7tc9AdL3KMh9TfHidWl7KHrbD0cJN5bJ5Fb1nOTze8d+PbFl2bZYEJQ== + selenium-webdriver@^4.20.0: version "4.20.0" resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.20.0.tgz#14941ab4a59e8956a5e4b4491a8ba2bd6619d1ac"