Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 587 lines 19 kB view raw
1/* eslint-disable max-lines */ 2import os from 'node:os'; 3import path from 'path'; 4import { ApolloServer } from '@apollo/server'; 5import { unwrapResolverError } from '@apollo/server/errors'; 6import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled'; 7import { expressMiddleware } from '@as-integrations/express5'; 8import { makeExecutableSchema } from '@graphql-tools/schema'; 9import { MapperKind, mapSchema } from '@graphql-tools/utils'; 10import { MultiSamlStrategy } from '@node-saml/passport-saml'; 11import { SpanStatusCode } from '@opentelemetry/api'; 12import { 13 SEMATTRS_EXCEPTION_MESSAGE, 14 SEMATTRS_EXCEPTION_STACKTRACE, 15 SEMATTRS_EXCEPTION_TYPE, 16} from '@opentelemetry/semantic-conventions'; 17import { GraphQLError, type GraphQLFormattedError } from 'graphql'; 18import connectPgSimple from 'connect-pg-simple'; 19import cors from 'cors'; 20import express, { type ErrorRequestHandler } from 'express'; 21import session from 'express-session'; 22import depthLimit from 'graphql-depth-limit'; 23import { buildContext, GraphQLLocalStrategy } from 'graphql-passport'; 24import helmet from 'helmet'; 25import passport from 'passport'; 26 27import { 28 makeLoginIncorrectPasswordError, 29 makeLoginSsoRequiredError, 30 makeLoginUserDoesNotExistError, 31} from './graphql/datasources/UserApi.js'; 32import { 33 kyselyUserFindByEmail, 34 kyselyUserFindById, 35} from './graphql/datasources/userKyselyPersistence.js'; 36import resolvers, { type Context } from './graphql/resolvers.js'; 37import { passwordMatchesHash } from './services/userManagementService/index.js'; 38import typeDefs from './graphql/schema.js'; 39import { authSchemaWrapper } from './graphql/utils/authorization.js'; 40import { type Dependencies } from './iocContainer/index.js'; 41import { isEnvTrue, safeGetEnvInt } from './iocContainer/utils.js'; 42import controllers from './routes/index.js'; 43import { createBodySchemaValidator } from './utils/bodySchemaValidation.js'; 44import { jsonStringify } from './utils/encoding.js'; 45import { 46 ErrorType, 47 getErrorsFromAggregateError, 48 makeBadRequestError, 49 makeInternalServerError, 50 makeNotFoundError, 51 sanitizeError, 52 type SerializableError, 53} from './utils/errors.js'; 54import { safePick } from './utils/misc.js'; 55import { 56 isNonEmptyArray, 57 type NonEmptyArray, 58} from './utils/typescript-types.js'; 59 60function getCPUInfo() { 61 const cpus = os.cpus(); 62 63 const total = cpus.reduce( 64 (acc, cpu) => 65 acc + 66 cpu.times.user + 67 cpu.times.nice + 68 cpu.times.sys + 69 cpu.times.irq + 70 cpu.times.idle, 71 0, 72 ); 73 const idle = cpus.reduce((acc, cpu) => acc + cpu.times.idle, 0); 74 75 return { 76 idle, 77 total, 78 }; 79} 80 81async function getCPUUsage() { 82 const stats1 = getCPUInfo(); 83 const startIdle = stats1.idle; 84 const startTotal = stats1.total; 85 await new Promise((resolve) => setTimeout(resolve, 1000)); 86 87 const stats2 = getCPUInfo(); 88 const endIdle = stats2.idle; 89 const endTotal = stats2.total; 90 return 1 - (endIdle - startIdle) / (endTotal - startTotal); 91} 92 93// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing 94const env = process.env.NODE_ENV || 'development'; 95const sessionStore = connectPgSimple(session); 96 97export default async function makeApiServer(deps: Dependencies) { 98 const app = express(); 99 const { KyselyPg } = deps; 100 101 app.use(cors()); 102 103 app.use( 104 helmet( 105 env === 'production' 106 ? {} 107 : { 108 contentSecurityPolicy: { 109 directives: { 110 defaultSrc: ["'self'"], 111 scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], 112 styleSrc: ["'self'", "'unsafe-inline'"], 113 imgSrc: ["'self'", 'data:', 'blob:', 'https:', 'http:'], 114 connectSrc: ["'self'", 'ws:', 'wss:', 'https:', 'http:'], 115 fontSrc: ["'self'", 'data:', 'https:'], 116 frameSrc: ["'self'"], 117 }, 118 }, 119 }, 120 ), 121 ); 122 app.use(express.json({ limit: '50mb' })); 123 124 app.get('/ready', async (_req, res) => { 125 const cpuUsage = await getCPUUsage(); 126 if (cpuUsage > 0.75) { 127 return res.status(500).send('Unhealthy'); 128 } 129 return res.status(200).send('Healthy'); 130 }); 131 132 /** 133 * Passport & User Session Configuration 134 */ 135 const { 136 DATABASE_HOST, 137 DATABASE_PORT = 5432, 138 DATABASE_NAME, 139 DATABASE_USER, 140 DATABASE_PASSWORD, 141 } = process.env; 142 143 const conObject = { 144 host: DATABASE_HOST, 145 port: Number(DATABASE_PORT), 146 user: DATABASE_USER, 147 password: DATABASE_PASSWORD, 148 database: DATABASE_NAME, 149 // NB: `rejectUnauthorized: false` keeps the connection encrypted but skips 150 // certificate validation. 151 ssl: isEnvTrue('DATABASE_SSL') ? { rejectUnauthorized: false } : undefined, 152 }; 153 154 app.use( 155 session({ 156 secret: process.env.SESSION_SECRET!, 157 store: new sessionStore({ conObject }), 158 cookie: { 159 secure: process.env.NODE_ENV === 'production', 160 httpOnly: true, 161 sameSite: 'lax', 162 // 30 Days in milliseconds 163 maxAge: 30 * 24 * 60 * 60 * 1000, 164 }, 165 resave: false, 166 saveUninitialized: false, 167 proxy: true, 168 }), 169 ); 170 app.use(passport.initialize()); 171 app.use(passport.session()); 172 173 passport.use( 174 new MultiSamlStrategy( 175 { 176 passReqToCallback: true, 177 async getSamlOptions(req, done) { 178 // orgId path param should be set in the /saml/* route handlers. 179 const rawOrgId = req.params['orgId']; 180 const orgId = typeof rawOrgId === 'string' ? rawOrgId : undefined; 181 182 if (!orgId) { 183 return done( 184 makeNotFoundError('orgId not found in path.', { 185 shouldErrorSpan: true, 186 }), 187 ); 188 } 189 190 const samlSettings = await deps.OrgSettingsService.getSamlSettings( 191 orgId, 192 ); 193 194 if (!samlSettings) 195 return done( 196 makeInternalServerError('Unexpected error.', { 197 shouldErrorSpan: true, 198 }), 199 ); 200 201 if (!samlSettings.saml_enabled) 202 return done( 203 makeBadRequestError('SAML not enabled for this organization.', { 204 shouldErrorSpan: true, 205 }), 206 ); 207 208 done(null, { 209 entryPoint: samlSettings.sso_url as string, 210 idpCert: samlSettings.cert as string, 211 // I could use UI_URL here but technically the API could be hosted 212 // on a different domain in the future so hopefully this is more 213 // robust, not that it will likely matter. 214 callbackUrl: `${deps.ConfigService.uiUrl}/api/v1/saml/login/${orgId}/callback`, 215 issuer: deps.ConfigService.uiUrl, 216 }); 217 }, 218 }, 219 async (_req, profile, done) => { 220 try { 221 const user = await kyselyUserFindByEmail( 222 KyselyPg, 223 String(profile?.email), 224 ); 225 // we should have already checked for this, but couldn't hurt to check 226 // again 227 if (user == null) { 228 return done( 229 makeLoginUserDoesNotExistError({ shouldErrorSpan: true }), 230 ); 231 } 232 233 return done(null, user as any); 234 } catch (e) { 235 return done( 236 makeInternalServerError('Unknown error during login attempt', { 237 shouldErrorSpan: true, 238 }), 239 ); 240 } 241 }, 242 async (_req, profile, done) => { 243 try { 244 const user = await kyselyUserFindByEmail( 245 KyselyPg, 246 String(profile?.email), 247 ); 248 // we should have already checked for this, but couldn't hurt to check 249 // again 250 if (user == null) { 251 return done( 252 makeLoginUserDoesNotExistError({ shouldErrorSpan: true }), 253 ); 254 } 255 256 return done(null, user as any); 257 } catch (e) { 258 return done( 259 makeInternalServerError('Unknown error during login attempt', { 260 shouldErrorSpan: true, 261 }), 262 ); 263 } 264 }, 265 ), 266 ); 267 268 app.get( 269 '/saml/login/:orgId', 270 passport.authenticate('saml', { failureRedirect: '/', failureFlash: true }), 271 ); 272 273 app.post( 274 `/saml/login/:orgId/callback`, 275 express.urlencoded(), 276 passport.authenticate('saml', { 277 failureRedirect: '/', 278 failureFlash: true, 279 }), 280 (_req, res) => { 281 res.redirect(`${deps.ConfigService.uiUrl}/dashboard`); 282 }, 283 ); 284 285 passport.use( 286 new GraphQLLocalStrategy(async (email, password, done) => { 287 try { 288 const user = await kyselyUserFindByEmail(KyselyPg, String(email)); 289 if (user == null) { 290 return done( 291 makeLoginUserDoesNotExistError({ shouldErrorSpan: true }), 292 ); 293 } 294 const samlSettings = await deps.OrgSettingsService.getSamlSettings( 295 user.orgId, 296 ); 297 298 if ( 299 samlSettings?.saml_enabled && 300 // We allow Coop users to log in with email/password even if SSO is 301 // enabled 302 // so Coop employees can manage user accounts 303 String(email).split('@')[1] !== 'getcoop.com' 304 ) { 305 return done( 306 makeLoginSsoRequiredError({ 307 detail: 308 'SAML is enabled for this organization. Password login is disabled.', 309 shouldErrorSpan: true, 310 }), 311 ); 312 } 313 314 if (!user.loginMethods.includes('password')) { 315 return done( 316 makeLoginIncorrectPasswordError({ 317 detail: 'Password is not set for user.', 318 shouldErrorSpan: true, 319 }), 320 ); 321 } 322 323 // `loginMethods` includes 'password', so the DB CHECK constraint 324 // guarantees `user.password` is non-null here. 325 if ( 326 user.password != null && 327 (await passwordMatchesHash(String(password), user.password)) 328 ) { 329 done(null, user); 330 } else { 331 done(makeLoginIncorrectPasswordError({ shouldErrorSpan: true })); 332 } 333 } catch (e) { 334 deps.Tracer.logActiveSpanFailedIfAny(e); 335 return done( 336 makeInternalServerError('Unknown error during login attempt', { 337 shouldErrorSpan: true, 338 }), 339 ); 340 } 341 }), 342 ); 343 344 passport.serializeUser((user: any, done) => { 345 done(null, user.id); 346 }); 347 348 passport.deserializeUser(async (id, done) => { 349 try { 350 const user = await kyselyUserFindById(KyselyPg, String(id)); 351 if (user == null) { 352 return done( 353 makeNotFoundError(`Session user ${String(id)} not found`, { 354 shouldErrorSpan: true, 355 }), 356 ); 357 } 358 return done(null, user); 359 } catch (e) { 360 return done(e); 361 } 362 }); 363 364 /** 365 * Apollo Server - uses /api/graphql path 366 */ 367 const apolloServer = new ApolloServer<Context>({ 368 schema: mapSchema(makeExecutableSchema({ typeDefs, resolvers }), { 369 [MapperKind.QUERY_ROOT_FIELD]( 370 fieldConfig, 371 _fieldName, 372 _typeName, 373 schema, 374 ) { 375 return authSchemaWrapper(fieldConfig, schema); 376 }, 377 [MapperKind.MUTATION_ROOT_FIELD]( 378 fieldConfig, 379 _fieldName, 380 _typeName, 381 schema, 382 ) { 383 return authSchemaWrapper(fieldConfig, schema); 384 }, 385 }), 386 plugins: [ 387 ...(process.env.NODE_ENV === 'production' 388 ? [ApolloServerPluginLandingPageDisabled()] 389 : []), 390 ], 391 validationRules: [depthLimit(safeGetEnvInt('GRAPHQL_MAX_DEPTH', 10))], 392 introspection: process.env.NODE_ENV !== 'production', 393 formatError(formattedError, error) { 394 // unwrapResolverError removes the GraphQLError wrapper added by graphql-js 395 // when a non-GraphQL error is thrown from a resolver. 396 const rawError = unwrapResolverError(error); 397 398 // If the raw error is a GraphQLError (explicitly thrown by our code or 399 // generated by graphql-js for parse/validation errors), the formattedError 400 // is already correctly shaped -- pass it through. 401 if (rawError instanceof GraphQLError) { 402 return formattedError; 403 } 404 405 // For all other errors (CoopError, unexpected errors, context errors), 406 // sanitize to remove sensitive details and reformat for the client. 407 const sanitizedError = sanitizeError( 408 rawError instanceof Error ? rawError : (error as Error), 409 ); 410 const { title: sanitizedErrorTitle, ...extensions } = sanitizedError; 411 412 const result: GraphQLFormattedError = { 413 // When graphql-js wraps the resolver-thrown error in a GraphQLError, 414 // it automatically tracks some metadata about where the error was thrown 415 // from. That can be useful to clients, in a way that's a bit different 416 // from our CoopError.pointer field; it tells them whether a null 417 // value was return in the response because a given resolver failed, or 418 // because the field's value is actually null. So, we pass this 419 // metadata through as-is. 420 locations: formattedError.locations, 421 path: formattedError.path, 422 // Apollo server also defines some predefined error codes that it could 423 // be helpful for us to mimic on our custom errors (in case Apollo 424 // clients handle them out of the box). The true, Coop-assigned code 425 // for the error, though, will be in the `type` key, just like when 426 // sending errors in REST responses (though, for GQL, this lives under 427 // `extensions`). 428 extensions: { 429 ...extensions, 430 code: extensions.type.includes(ErrorType.Unauthenticated) 431 ? 'UNAUTHENTICATED' 432 : extensions.type.includes(ErrorType.Unauthorized) 433 ? 'FORBIDDEN' 434 : extensions.type.includes(ErrorType.InvalidUserInput) 435 ? 'BAD_USER_INPUT' 436 : 'INTERNAL_SERVER_ERROR', 437 }, 438 message: sanitizedErrorTitle, 439 }; 440 return result; 441 }, 442 }); 443 444 await apolloServer.start(); 445 446 app.use( 447 '/graphql', 448 express.json(), 449 expressMiddleware(apolloServer, { 450 context: async ({ req, res }) => ({ 451 ...buildContext({ req, res }), 452 services: makeGqlServices(deps), 453 dataSources: deps.DataSources, 454 } as unknown as Context), 455 }), 456 ); 457 458 Object.entries(controllers).forEach(([_k, controller]) => { 459 controller.routes.forEach((it) => { 460 const handler = it.handler(deps); 461 const handlers = Array.isArray(handler) ? handler : [handler]; 462 // If the route declares a bodySchema, validate the request body against 463 // it before any handler runs. Routes without a schema (e.g., GETs) skip 464 // validation entirely. 465 const middlewares = it.bodySchema 466 ? [createBodySchemaValidator(it.bodySchema), ...handlers] 467 : handlers; 468 app[it.method]( 469 path.join(controller.pathPrefix, it.path), 470 ...middlewares, 471 ); 472 }); 473 }); 474 475 // catch 404 and forward to error handler 476 app.use(function (_req, _res, next) { 477 next( 478 makeNotFoundError('Requested route not found.', { 479 shouldErrorSpan: true, 480 }), 481 ); 482 }); 483 484 // error handler 485 app.use(async function (err, _req, res, _next) { 486 await deps.Tracer.addActiveSpan( 487 { resource: 'app', operation: 'handleError' }, 488 async (span) => { 489 span.recordException(err); 490 span.setStatus({ code: SpanStatusCode.ERROR, message: err.message }); 491 492 // I don't know if these attributes are necessary, with recordException 493 span.setAttribute(SEMATTRS_EXCEPTION_MESSAGE, err.message); 494 if (err.stack) { 495 span.setAttribute(SEMATTRS_EXCEPTION_STACKTRACE, err.stack); 496 } 497 span.setAttribute(SEMATTRS_EXCEPTION_TYPE, err.name); 498 499 const errors = (() => { 500 if (err instanceof AggregateError) { 501 const extractedErrors = getErrorsFromAggregateError(err); 502 return isNonEmptyArray(extractedErrors) ? extractedErrors : [err]; 503 } else { 504 return [err]; 505 } 506 })() satisfies NonEmptyArray<unknown>; 507 508 // If we had any nested errors (from an AggregateError), 509 // attach those to the span too. 510 if (errors.length > 1 || errors[0] !== err) { 511 span.setAttribute( 512 'errors', 513 jsonStringify( 514 errors.map((it) => safePick(it, ['name', 'message', 'stack'])), 515 ), 516 ); 517 } 518 519 // If we've already sent response headers or the response status code, 520 // we can't actually send a different status code here: it's an error 521 // in HTTP to send the headers portion of a response twice. So, we 522 // need to skip this step. 523 // 524 // This can happen, e.g., if we have a request handler that 525 // immediately responds with a 202/204 but then continues to do some 526 // processing work in the background, and that work errors. 527 if (!res.headersSent) { 528 const safeErrors = errors.map((it) => 529 sanitizeError(it), 530 ) satisfies SerializableError[] as NonEmptyArray<SerializableError>; 531 532 res.status(pickStatus(safeErrors)).json({ errors: safeErrors }); 533 } 534 }, 535 ); 536 } as ErrorRequestHandler); 537 538 return { 539 app, 540 async shutdown() { 541 await Promise.all([ 542 apolloServer.stop(), 543 deps.closeSharedResourcesForShutdown(), 544 ]); 545 }, 546 }; 547} 548 549function pickStatus(safeErrors: NonEmptyArray<SerializableError>) { 550 return safeErrors[0].status; 551} 552 553function makeGqlServices(deps: Dependencies) { 554 return { 555 ...safePick(deps, [ 556 'ApiKeyService', 557 'DataWarehouse', 558 'DerivedFieldsService', 559 'getItemTypeEventuallyConsistent', 560 'getEnabledRulesForItemTypeEventuallyConsistent', 561 'ItemInvestigationService', 562 'ModerationConfigService', 563 'ManualReviewToolService', 564 'HMAHashBankService', 565 'NcmecService', 566 'OrgSettingsService', 567 'PartialItemsService', 568 'ReportingService', 569 'RuleEvaluator', 570 'SignalsService', 571 'SigningKeyPairService', 572 'Tracer', 573 'UserManagementService', 574 'UserStatisticsService', 575 'UserHistoryQueries', 576 'UserStrikeService', 577 'SSOService', 578 ]), 579 // Calling sendEmail straight from a resolver is hella sketch, as the 580 // resolvers shouldn’t have real business logic in them. Future sendEmail 581 // calls should be encapsulated inside some business-logic-containing 582 // service, and it’s that service that should be called from the resolvers. 583 legacy_DO_NOT_USE_DIRECTLY_sendEmail: deps.sendEmail, 584 }; 585} 586 587export type GQLServices = ReturnType<typeof makeGqlServices>;