SCIM: Implement egress + tests

This commit is contained in:
fflorent 2024-09-02 17:50:42 +02:00
parent 92b4a65da0
commit 98f50d3c81
12 changed files with 142 additions and 63 deletions

View File

@ -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 => {

View File

@ -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<User[]> {
public async getUsersByIds(userIds: number[], optManager?: EntityManager): Promise<User[]> {
if (userIds.length === 0) {
return [];
}

View File

@ -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();

View File

@ -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();

View File

@ -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;

View File

@ -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,
}],
});
}

View File

@ -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<boolean> {
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 };

View File

@ -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 };

View File

@ -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",

View File

@ -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();

View File

@ -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();

View File

@ -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==