diff --git a/app/gen-server/lib/homedb/HomeDBManager.ts b/app/gen-server/lib/homedb/HomeDBManager.ts index 58e6a561..08f77e2d 100644 --- a/app/gen-server/lib/homedb/HomeDBManager.ts +++ b/app/gen-server/lib/homedb/HomeDBManager.ts @@ -441,6 +441,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); } @@ -3271,7 +3275,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 e2be5b1d..81bde779 100644 --- a/app/gen-server/lib/homedb/UsersManager.ts +++ b/app/gen-server/lib/homedb/UsersManager.ts @@ -657,10 +657,14 @@ export class UsersManager { return [this.getSupportUserId(), this.getAnonymousUserId(), this.getEveryoneUserId()]; } + public async getUsers() { + return await User.find({relations: ["logins"]}); + } + /** * 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 []; } diff --git a/app/server/MergedServer.ts b/app/server/MergedServer.ts index 2ac994d1..cfa53b48 100644 --- a/app/server/MergedServer.ts +++ b/app/server/MergedServer.ts @@ -161,6 +161,7 @@ export class MergedServer { await this.flexServer.addLandingPages(); // 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 45297904..5f641549 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -87,6 +87,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 './scim'; // Health checks are a little noisy in the logs, so we don't show them all. // We show the first N health checks: @@ -887,6 +888,12 @@ 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; } + this.app.use('/api/scim', buildScimRouter(this._dbManager, this._installAdmin)); + } + + public addBillingApi() { if (this._check('billing-api', 'homedb', 'json', 'api-mw')) { return; } this._getBilling(); diff --git a/app/server/lib/scim/index.ts b/app/server/lib/scim/index.ts index 54031c35..2ee49303 100644 --- a/app/server/lib/scim/index.ts +++ b/app/server/lib/scim/index.ts @@ -1,21 +1,11 @@ import * as express from 'express'; -import { buildUsersRoute, checkPermissionToUsersEndpoint } from './v2/users'; + +import { buildScimRouterv2 } from './v2/ScimV2Api'; import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager'; -import SCIMMY from "scimmy"; -import SCIMMYRouters from "scimmy-routers"; +import { InstallAdmin } from '../InstallAdmin'; -type SCIMMYResource = typeof SCIMMY.Types.Resource; - -const buildScimRouter = (dbManager: HomeDBManager) => { - const v2 = express.Router(); - v2.use('/Users', checkPermissionToUsersEndpoint, buildUsersRoute(dbManager)); - - SCIMMY.Resources.User.ingress(handler) - SCIMMY.Resources.declare(SCIMMY.Resources.User) - .ingress((resource: SCIMMYResource, data) => { - - - }); +const buildScimRouter = (dbManager: HomeDBManager, installAdmin: InstallAdmin) => { + const v2 = buildScimRouterv2(dbManager, installAdmin); const scim = express.Router(); scim.use('/v2', v2); return scim; diff --git a/app/server/lib/scim/v2/ScimUserUtils.ts b/app/server/lib/scim/v2/ScimUserUtils.ts new file mode 100644 index 00000000..b9db4bf0 --- /dev/null +++ b/app/server/lib/scim/v2/ScimUserUtils.ts @@ -0,0 +1,31 @@ +import { User } from "app/gen-server/entity/User.js"; +import { SCIMMY } from "scimmy-routers"; + +/** + * 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 preferredLanguage = user.options?.locale ?? "en"; + return new SCIMMY.Schemas.User({ + id: String(user.id), + userName: user.loginEmail, + displayName: user.name, + name: { + formatted: user.name, + }, + preferredLanguage, + locale: preferredLanguage, // Assume locale is the same as preferredLanguage + photos: user.picture ? [{ + value: user.picture, + type: "photo", + primary: true + }] : undefined, + emails: [{ + value: user.loginEmail, + primary: true, + }], + }); +} diff --git a/app/server/lib/scim/v2/ScimV2Api.ts b/app/server/lib/scim/v2/ScimV2Api.ts new file mode 100644 index 00000000..05026251 --- /dev/null +++ b/app/server/lib/scim/v2/ScimV2Api.ts @@ -0,0 +1,76 @@ +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 '../../Authorizer'; +import { InstallAdmin } from '../../InstallAdmin'; +import { toSCIMMYUser } from './ScimUserUtils'; + +const WHITELISTED_PATHS_FOR_NON_ADMINS = [ "/Me", "/Schemas", "/ResourceTypes", "/ServiceProviderConfig" ]; + +async function isAuthorizedAction(mreq: RequestWithLogin, installAdmin: InstallAdmin): Promise { + const isAdmin = await installAdmin.isAdminReq(mreq) + const isScimUser = Boolean(process.env.GRIST_SCIM_EMAIL && mreq.user?.loginEmail === process.env.GRIST_SCIM_EMAIL); + return isAdmin || isScimUser || WHITELISTED_PATHS_FOR_NON_ADMINS.includes(mreq.path); +} + +const buildScimRouterv2 = (dbManager: HomeDBManager, installAdmin: InstallAdmin) => { + const v2 = express.Router(); + + SCIMMY.Resources.declare(SCIMMY.Resources.User, { + egress: async (resource: any) => { + const { id, filter } = resource; + if (id) { + const user = await dbManager.getUser(id); + if (!user) { + throw new SCIMMY.Types.Error(404, null!, `User with ID ${id} not found`); + } + return toSCIMMYUser(user); + } + const scimmyUsers = (await dbManager.getUsers()).map(user => toSCIMMYUser(user)); + return filter ? filter.match(scimmyUsers) : scimmyUsers; + }, + ingress: async (resource: any) => { + try { + const { id } = resource; + if (id) { + return null; + } + return []; + } catch (ex) { + // FIXME: remove this + if (Math.random() > 1) { + return null; + } + throw ex; + } + }, + degress: () => { + return null; + } + }); + + 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 user cannot access SCIM resources'); + } + + if (!await isAuthorizedAction(mreq, installAdmin)) { + throw new SCIMMY.Types.Error(403, null!, 'Resource disallowed for non-admin users'); + } + return String(mreq.userId); // HACK: SCIMMYRouters requires the userId to be a string. + } + }) 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/app/server/lib/scim/v2/users.ts b/app/server/lib/scim/v2/users.ts deleted file mode 100644 index 1b4175bd..00000000 --- a/app/server/lib/scim/v2/users.ts +++ /dev/null @@ -1,37 +0,0 @@ -import express, { NextFunction, Request, Response } from 'express'; -import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager'; -import { expressWrap } from '../../expressWrap'; -import { integerParam } from '../../requestUtils'; -import { ApiError } from 'app/common/ApiError'; -import { RequestWithLogin } from '../../Authorizer'; - -function checkPermissionToUsersEndpoint(req: Request, res: Response, next: NextFunction) { - const mreq = req as RequestWithLogin; - const adminEmail = process.env.GRIST_DEFAULT_EMAIL; - if (!adminEmail || mreq.user?.loginEmail !== adminEmail) { - throw new ApiError('Permission denied', 403); - } - return next(); -} - -const buildUsersRoute = (dbManager: HomeDBManager) => { - const userRoute = express.Router(); - - async function findUserOrFail(userId: number) { - const user = await dbManager.getUser(userId); - if (!user) { - throw new ApiError('User not found', 404); - } - return user; - } - - - userRoute.get('/:id', expressWrap(async (req, res) => { - const userId = integerParam(req.params.id, 'id'); - const user = await findUserOrFail(userId); - res.status(200).json(user); - })); - return userRoute; -}; - -export { buildUsersRoute, checkPermissionToUsersEndpoint }; diff --git a/package.json b/package.json index 9a52c5b7..78aabe41 100644 --- a/package.json +++ b/package.json @@ -189,7 +189,8 @@ "redis": "3.1.1", "redlock": "3.1.2", "saml2-js": "4.0.2", - "scimmy-routers": "^1.2.0", + "scimmy": "1.2.3", + "scimmy-routers": "1.2.1", "short-uuid": "3.1.1", "slugify": "1.6.6", "swagger-ui-dist": "5.11.0", diff --git a/test/gen-server/seed.ts b/test/gen-server/seed.ts index c3e7073f..a4acbdf0 100644 --- a/test/gen-server/seed.ts +++ b/test/gen-server/seed.ts @@ -612,6 +612,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 449bf138..9f35c939 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/yarn.lock b/yarn.lock index 35d640ae..4a8d7a96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3801,7 +3801,7 @@ express-rate-limit@7.2.0: resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-7.2.0.tgz#06ce387dd5388f429cab8263c514fc07bf90a445" integrity sha512-T7nul1t4TNyfZMJ7pKRKkdeVJWa2CqB8NA1P8BwYaoDI5QSBZARv5oMS43J7b7I5P+4asjVXjb7ONuwDKucahg== -express@4.19.2, express@^4.18.2: +express@4.19.2, express@^4.19.2: version "4.19.2" resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== @@ -7346,15 +7346,15 @@ schema-utils@^3.2.0: ajv "^6.12.5" ajv-keywords "^3.5.2" -scimmy-routers@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/scimmy-routers/-/scimmy-routers-1.2.0.tgz#42090c616f127aefce194ebf4a3c8f4a4f62e30b" - integrity sha512-+dT8yRglz2gMu0X1LlUYTi/PDgW6Zzu1YRWiv362I/wA64kud24XjYIoufNiW5OhskvBPQGT/P1aYOffcmxxsQ== +scimmy-routers@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/scimmy-routers/-/scimmy-routers-1.2.1.tgz#a535ced83f051b2a0374e8df02da6e17424e8be0" + integrity sha512-PDX4/mwOScSd+TjUPX0k3gH6v50WeYVgK1bisZ8f3p3eyJ0Qy4qYebDo6gzHqYCBjXNQniQxGSQvtlvG22NLdA== dependencies: - express "^4.18.2" - scimmy "^1.2.0" + express "^4.19.2" + scimmy "^1.2.3" -scimmy@^1.2.0: +scimmy@1.2.3, scimmy@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/scimmy/-/scimmy-1.2.3.tgz#56ca9dbf11749b272e18090c923f81dbad4bc911" integrity sha512-16oXCvnieVeKxTDQqi275bLuyOCwXci8Jywm2/M+4dWNNYoduUz0Crj1nFY0ETYMsuYvCdareWov6/Mebu92xA==