diff --git a/App/FeatureSet/Identity/API/SCIM.ts b/App/FeatureSet/Identity/API/SCIM.ts index 7c644722b6..156955f49a 100644 --- a/App/FeatureSet/Identity/API/SCIM.ts +++ b/App/FeatureSet/Identity/API/SCIM.ts @@ -2,6 +2,8 @@ import SCIMMiddleware from "Common/Server/Middleware/SCIMAuthorization"; import UserService from "Common/Server/Services/UserService"; import TeamMemberService from "Common/Server/Services/TeamMemberService"; import TeamService from "Common/Server/Services/TeamService"; +import { createProjectSCIMLog } from "../Utils/SCIMLogger"; +import SCIMLogStatus from "Common/Types/SCIM/SCIMLogStatus"; import Express, { ExpressRequest, ExpressResponse, @@ -1063,12 +1065,44 @@ router.post( `Project SCIM Bulk - completed processing ${results.length} operations with ${errorCount} errors`, ); + const bulkResponse: JSONObject = generateBulkResponse(results); + + // Log the bulk operation + void createProjectSCIMLog({ + projectId: projectId, + projectScimId: new ObjectID(projectScimId), + operationType: "BulkOperation", + status: errorCount > 0 ? SCIMLogStatus.Warning : SCIMLogStatus.Success, + statusMessage: `Processed ${results.length} operations with ${errorCount} errors`, + httpMethod: "POST", + requestPath: req.path, + httpStatusCode: 200, + requestBody: req.body, + responseBody: bulkResponse, + }); + return Response.sendJsonObjectResponse( req, res, - generateBulkResponse(results), + bulkResponse, ); } catch (err) { + // Log the error + const oneuptimeRequestErr: OneUptimeRequest = req as OneUptimeRequest; + const bearerDataErr: JSONObject = + oneuptimeRequestErr.bearerTokenData as JSONObject; + void createProjectSCIMLog({ + projectId: bearerDataErr["projectId"] as ObjectID, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "BulkOperation", + status: SCIMLogStatus.Error, + statusMessage: (err as Error).message, + httpMethod: "POST", + requestPath: req.path, + httpStatusCode: 500, + requestBody: req.body, + }); + logger.error(err); return next(err); } @@ -1249,12 +1283,41 @@ router.get( logger.debug(`SCIM Users response prepared with ${users.length} users`); + const responseBody: JSONObject = generateUsersListResponse(paginatedUsers, startIndex, users.length); + + // Log the operation + void createProjectSCIMLog({ + projectId: projectId, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "ListUsers", + status: SCIMLogStatus.Success, + httpMethod: "GET", + requestPath: req.path, + httpStatusCode: 200, + responseBody: responseBody, + }); + return Response.sendJsonObjectResponse( req, res, - generateUsersListResponse(paginatedUsers, startIndex, users.length), + responseBody, ); } catch (err) { + // Log the error + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + void createProjectSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "ListUsers", + status: SCIMLogStatus.Error, + statusMessage: (err as Error).message, + httpMethod: "GET", + requestPath: req.path, + httpStatusCode: 500, + }); + logger.error(err); return next(err); } @@ -1325,8 +1388,36 @@ router.get( "project", ); + // Log the operation + void createProjectSCIMLog({ + projectId: projectId, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "GetUser", + status: SCIMLogStatus.Success, + httpMethod: "GET", + requestPath: req.path, + httpStatusCode: 200, + affectedUserEmail: projectUser.user.email?.toString(), + responseBody: user, + }); + return Response.sendJsonObjectResponse(req, res, user); } catch (err) { + // Log the error + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + void createProjectSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "GetUser", + status: SCIMLogStatus.Error, + statusMessage: (err as Error).message, + httpMethod: "GET", + requestPath: req.path, + httpStatusCode: 404, + }); + logger.error(err); return next(err); } @@ -1475,6 +1566,21 @@ const handleUserUpdate: ( req.params["projectScimId"]!, "project", ); + + // Log the operation + void createProjectSCIMLog({ + projectId: projectId, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "UpdateUser", + status: SCIMLogStatus.Success, + httpMethod: req.method, + requestPath: req.path, + httpStatusCode: 200, + affectedUserEmail: email, + requestBody: scimUser, + responseBody: user, + }); + return Response.sendJsonObjectResponse(req, res, user); } } @@ -1489,8 +1595,38 @@ const handleUserUpdate: ( "project", ); + // Log the operation + void createProjectSCIMLog({ + projectId: projectId, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "UpdateUser", + status: SCIMLogStatus.Success, + httpMethod: req.method, + requestPath: req.path, + httpStatusCode: 200, + affectedUserEmail: projectUser.user.email?.toString(), + requestBody: scimUser, + responseBody: user, + }); + return Response.sendJsonObjectResponse(req, res, user); } catch (err) { + // Log the error + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + void createProjectSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "UpdateUser", + status: SCIMLogStatus.Error, + statusMessage: (err as Error).message, + httpMethod: req.method, + requestPath: req.path, + httpStatusCode: 400, + requestBody: req.body, + }); + logger.error(err); return next(err); } @@ -1589,14 +1725,43 @@ router.get( `SCIM Groups response prepared with ${groups.length} groups`, ); - return Response.sendJsonObjectResponse(req, res, { + const responseBody: JSONObject = { schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], totalResults: groups.length, startIndex: startIndex, itemsPerPage: paginatedGroups.length, Resources: paginatedGroups, + }; + + // Log the operation + void createProjectSCIMLog({ + projectId: projectId, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "ListGroups", + status: SCIMLogStatus.Success, + httpMethod: "GET", + requestPath: req.path, + httpStatusCode: 200, + responseBody: responseBody, }); + + return Response.sendJsonObjectResponse(req, res, responseBody); } catch (err) { + // Log the error + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + void createProjectSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "ListGroups", + status: SCIMLogStatus.Error, + statusMessage: (err as Error).message, + httpMethod: "GET", + requestPath: req.path, + httpStatusCode: 500, + }); + logger.error(err); return next(err); } @@ -1663,8 +1828,36 @@ router.get( true, // Include members for individual group request ); + // Log the operation + void createProjectSCIMLog({ + projectId: projectId, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "GetGroup", + status: SCIMLogStatus.Success, + httpMethod: "GET", + requestPath: req.path, + httpStatusCode: 200, + affectedGroupName: team.name?.toString(), + responseBody: group, + }); + return Response.sendJsonObjectResponse(req, res, group); } catch (err) { + // Log the error + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + void createProjectSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "GetGroup", + status: SCIMLogStatus.Error, + statusMessage: (err as Error).message, + httpMethod: "GET", + requestPath: req.path, + httpStatusCode: 404, + }); + logger.error(err); return next(err); } @@ -1826,6 +2019,20 @@ router.post( `SCIM Create group - returning group with id: ${teamForResponse.id}`, ); + // Log the operation + void createProjectSCIMLog({ + projectId: projectId, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "CreateGroup", + status: SCIMLogStatus.Success, + httpMethod: "POST", + requestPath: req.path, + httpStatusCode: createdNewTeam ? 201 : 200, + affectedGroupName: displayName, + requestBody: scimGroup, + responseBody: groupResponse, + }); + if (createdNewTeam) { res.status(201); } else { @@ -1834,6 +2041,22 @@ router.post( return Response.sendJsonObjectResponse(req, res, groupResponse); } catch (err) { + // Log the error + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + void createProjectSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "CreateGroup", + status: SCIMLogStatus.Error, + statusMessage: (err as Error).message, + httpMethod: "POST", + requestPath: req.path, + httpStatusCode: 400, + requestBody: req.body, + }); + logger.error(err); return next(err); } @@ -1992,11 +2215,42 @@ router.put( req.params["projectScimId"]!, true, ); + + // Log the operation + void createProjectSCIMLog({ + projectId: projectId, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "UpdateGroup", + status: SCIMLogStatus.Success, + httpMethod: "PUT", + requestPath: req.path, + httpStatusCode: 200, + affectedGroupName: displayName || updatedTeam.name?.toString(), + requestBody: scimGroup, + responseBody: updatedGroup, + }); + return Response.sendJsonObjectResponse(req, res, updatedGroup); } throw new NotFoundException("Failed to retrieve updated group"); } catch (err) { + // Log the error + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + void createProjectSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "UpdateGroup", + status: SCIMLogStatus.Error, + statusMessage: (err as Error).message, + httpMethod: "PUT", + requestPath: req.path, + httpStatusCode: 400, + requestBody: req.body, + }); + logger.error(err); return next(err); } @@ -2083,11 +2337,38 @@ router.delete( logger.debug(`SCIM Delete group - team successfully deleted`); + // Log the operation + void createProjectSCIMLog({ + projectId: projectId, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "DeleteGroup", + status: SCIMLogStatus.Success, + httpMethod: "DELETE", + requestPath: req.path, + httpStatusCode: 204, + affectedGroupName: team.name?.toString(), + }); + res.status(204); return Response.sendJsonObjectResponse(req, res, { message: "Group deleted", }); } catch (err) { + // Log the error + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + void createProjectSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "DeleteGroup", + status: SCIMLogStatus.Error, + statusMessage: (err as Error).message, + httpMethod: "DELETE", + requestPath: req.path, + httpStatusCode: 400, + }); + logger.error(err); return next(err); } @@ -2327,11 +2608,42 @@ router.patch( req.params["projectScimId"]!, true, ); + + // Log the operation + void createProjectSCIMLog({ + projectId: projectId, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "UpdateGroup", + status: SCIMLogStatus.Success, + httpMethod: "PATCH", + requestPath: req.path, + httpStatusCode: 200, + affectedGroupName: updatedTeam.name?.toString(), + requestBody: scimPatch, + responseBody: updatedGroup, + }); + return Response.sendJsonObjectResponse(req, res, updatedGroup); } throw new NotFoundException("Failed to retrieve updated group"); } catch (err) { + // Log the error + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + void createProjectSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "UpdateGroup", + status: SCIMLogStatus.Error, + statusMessage: (err as Error).message, + httpMethod: "PATCH", + requestPath: req.path, + httpStatusCode: 400, + requestBody: req.body, + }); + logger.error(err); return next(err); } @@ -2429,9 +2741,39 @@ router.post( `SCIM Create user - returning created user with id: ${user.id}`, ); + // Log the operation + void createProjectSCIMLog({ + projectId: projectId, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "CreateUser", + status: SCIMLogStatus.Success, + httpMethod: "POST", + requestPath: req.path, + httpStatusCode: 201, + affectedUserEmail: email, + requestBody: scimUser, + responseBody: createdUser, + }); + res.status(201); return Response.sendJsonObjectResponse(req, res, createdUser); } catch (err) { + // Log the error + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + void createProjectSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "CreateUser", + status: SCIMLogStatus.Error, + statusMessage: (err as Error).message, + httpMethod: "POST", + requestPath: req.path, + httpStatusCode: 400, + requestBody: req.body, + }); + logger.error(err); return next(err); } @@ -2492,11 +2834,37 @@ router.delete( `SCIM Delete user - user successfully deprovisioned from project`, ); + // Log the operation + void createProjectSCIMLog({ + projectId: projectId, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "DeleteUser", + status: SCIMLogStatus.Success, + httpMethod: "DELETE", + requestPath: req.path, + httpStatusCode: 204, + }); + res.status(204); return Response.sendJsonObjectResponse(req, res, { message: "User deprovisioned", }); } catch (err) { + // Log the error + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + void createProjectSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + projectScimId: new ObjectID(req.params["projectScimId"]!), + operationType: "DeleteUser", + status: SCIMLogStatus.Error, + statusMessage: (err as Error).message, + httpMethod: "DELETE", + requestPath: req.path, + httpStatusCode: 400, + }); + logger.error(err); return next(err); } diff --git a/App/FeatureSet/Identity/API/StatusPageSCIM.ts b/App/FeatureSet/Identity/API/StatusPageSCIM.ts index fc3e36640e..8c59fcadc8 100644 --- a/App/FeatureSet/Identity/API/StatusPageSCIM.ts +++ b/App/FeatureSet/Identity/API/StatusPageSCIM.ts @@ -1,5 +1,7 @@ import SCIMMiddleware from "Common/Server/Middleware/SCIMAuthorization"; import StatusPagePrivateUserService from "Common/Server/Services/StatusPagePrivateUserService"; +import { createStatusPageSCIMLog } from "../Utils/SCIMLogger"; +import SCIMLogStatus from "Common/Types/SCIM/SCIMLogStatus"; import Express, { ExpressRequest, ExpressResponse, @@ -443,12 +445,46 @@ router.post( `Status Page SCIM Bulk - completed processing ${results.length} operations with ${errorCount} errors`, ); + const bulkResponse: JSONObject = generateBulkResponse(results); + + // Log the bulk operation + void createStatusPageSCIMLog({ + projectId: projectId, + statusPageId: statusPageId, + statusPageScimId: new ObjectID(statusPageScimId), + operationType: "BulkOperation", + status: errorCount > 0 ? SCIMLogStatus.Warning : SCIMLogStatus.Success, + statusMessage: `Processed ${results.length} operations with ${errorCount} errors`, + httpMethod: "POST", + requestPath: req.path, + httpStatusCode: 200, + requestBody: req.body, + responseBody: bulkResponse, + }); + return Response.sendJsonObjectResponse( req, res, - generateBulkResponse(results), + bulkResponse, ); } catch (err) { + // Log the error + const oneuptimeRequestErr: OneUptimeRequest = req as OneUptimeRequest; + const bearerDataErr: JSONObject = + oneuptimeRequestErr.bearerTokenData as JSONObject; + void createStatusPageSCIMLog({ + projectId: bearerDataErr["projectId"] as ObjectID, + statusPageId: bearerDataErr["statusPageId"] as ObjectID, + statusPageScimId: new ObjectID(req.params["statusPageScimId"]!), + operationType: "BulkOperation", + status: SCIMLogStatus.Error, + statusMessage: (err as Error).message, + httpMethod: "POST", + requestPath: req.path, + httpStatusCode: 500, + requestBody: req.body, + }); + logger.error(err); return next(err); } @@ -565,14 +601,45 @@ router.get( `Status Page SCIM Users response prepared with ${users.length} users`, ); - return Response.sendJsonObjectResponse(req, res, { + const responseBody: JSONObject = { schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], totalResults: users.length, startIndex: startIndex, itemsPerPage: paginatedUsers.length, Resources: paginatedUsers, + }; + + // Log the operation + void createStatusPageSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + statusPageId: statusPageId, + statusPageScimId: new ObjectID(req.params["statusPageScimId"]!), + operationType: "ListUsers", + status: SCIMLogStatus.Success, + httpMethod: "GET", + requestPath: req.path, + httpStatusCode: 200, + responseBody: responseBody, }); + + return Response.sendJsonObjectResponse(req, res, responseBody); } catch (err) { + // Log the error + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + void createStatusPageSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + statusPageId: bearerData["statusPageId"] as ObjectID, + statusPageScimId: new ObjectID(req.params["statusPageScimId"]!), + operationType: "ListUsers", + status: SCIMLogStatus.Error, + statusMessage: (err as Error).message, + httpMethod: "GET", + requestPath: req.path, + httpStatusCode: 500, + }); + logger.error(err); return next(err); } @@ -642,8 +709,38 @@ router.get( `Status Page SCIM Get user - returning user with id: ${statusPageUser.id}`, ); + // Log the operation + void createStatusPageSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + statusPageId: statusPageId, + statusPageScimId: new ObjectID(req.params["statusPageScimId"]!), + operationType: "GetUser", + status: SCIMLogStatus.Success, + httpMethod: "GET", + requestPath: req.path, + httpStatusCode: 200, + affectedUserEmail: statusPageUser.email?.toString(), + responseBody: user, + }); + return Response.sendJsonObjectResponse(req, res, user); } catch (err) { + // Log the error + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + void createStatusPageSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + statusPageId: bearerData["statusPageId"] as ObjectID, + statusPageScimId: new ObjectID(req.params["statusPageScimId"]!), + operationType: "GetUser", + status: SCIMLogStatus.Error, + statusMessage: (err as Error).message, + httpMethod: "GET", + requestPath: req.path, + httpStatusCode: 404, + }); + logger.error(err); return next(err); } @@ -747,9 +844,41 @@ router.post( `Status Page SCIM Create user - returning created user with id: ${user.id}`, ); + // Log the operation + void createStatusPageSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + statusPageId: statusPageId, + statusPageScimId: new ObjectID(req.params["statusPageScimId"]!), + operationType: "CreateUser", + status: SCIMLogStatus.Success, + httpMethod: "POST", + requestPath: req.path, + httpStatusCode: 201, + affectedUserEmail: email, + requestBody: scimUser, + responseBody: createdUser, + }); + res.status(201); return Response.sendJsonObjectResponse(req, res, createdUser); } catch (err) { + // Log the error + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + void createStatusPageSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + statusPageId: bearerData["statusPageId"] as ObjectID, + statusPageScimId: new ObjectID(req.params["statusPageScimId"]!), + operationType: "CreateUser", + status: SCIMLogStatus.Error, + statusMessage: (err as Error).message, + httpMethod: "POST", + requestPath: req.path, + httpStatusCode: 400, + requestBody: req.body, + }); + logger.error(err); return next(err); } @@ -890,6 +1019,22 @@ const handleStatusPageUserUpdate: ( req.params["statusPageScimId"]!, "status-page", ); + + // Log the operation + void createStatusPageSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + statusPageId: statusPageId, + statusPageScimId: new ObjectID(req.params["statusPageScimId"]!), + operationType: "UpdateUser", + status: SCIMLogStatus.Success, + httpMethod: req.method, + requestPath: req.path, + httpStatusCode: 200, + affectedUserEmail: email, + requestBody: scimUser, + responseBody: user, + }); + return Response.sendJsonObjectResponse(req, res, user); } } @@ -906,8 +1051,40 @@ const handleStatusPageUserUpdate: ( "status-page", ); + // Log the operation + void createStatusPageSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + statusPageId: statusPageId, + statusPageScimId: new ObjectID(req.params["statusPageScimId"]!), + operationType: "UpdateUser", + status: SCIMLogStatus.Success, + httpMethod: req.method, + requestPath: req.path, + httpStatusCode: 200, + affectedUserEmail: statusPageUser.email?.toString(), + requestBody: scimUser, + responseBody: user, + }); + return Response.sendJsonObjectResponse(req, res, user); } catch (err) { + // Log the error + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + void createStatusPageSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + statusPageId: bearerData["statusPageId"] as ObjectID, + statusPageScimId: new ObjectID(req.params["statusPageScimId"]!), + operationType: "UpdateUser", + status: SCIMLogStatus.Error, + statusMessage: (err as Error).message, + httpMethod: req.method, + requestPath: req.path, + httpStatusCode: 400, + requestBody: req.body, + }); + logger.error(err); return next(err); } @@ -994,10 +1171,38 @@ router.delete( `Status Page SCIM Delete user - user deleted successfully for userId: ${userId}`, ); + // Log the operation + void createStatusPageSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + statusPageId: statusPageId, + statusPageScimId: new ObjectID(req.params["statusPageScimId"]!), + operationType: "DeleteUser", + status: SCIMLogStatus.Success, + httpMethod: "DELETE", + requestPath: req.path, + httpStatusCode: 204, + }); + // Return 204 No Content for successful deletion res.status(204); return Response.sendEmptySuccessResponse(req, res); } catch (err) { + // Log the error + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + void createStatusPageSCIMLog({ + projectId: bearerData["projectId"] as ObjectID, + statusPageId: bearerData["statusPageId"] as ObjectID, + statusPageScimId: new ObjectID(req.params["statusPageScimId"]!), + operationType: "DeleteUser", + status: SCIMLogStatus.Error, + statusMessage: (err as Error).message, + httpMethod: "DELETE", + requestPath: req.path, + httpStatusCode: 400, + }); + logger.error(err); return next(err); } diff --git a/App/FeatureSet/Identity/Utils/SCIMLogger.ts b/App/FeatureSet/Identity/Utils/SCIMLogger.ts new file mode 100644 index 0000000000..7fb30954fd --- /dev/null +++ b/App/FeatureSet/Identity/Utils/SCIMLogger.ts @@ -0,0 +1,161 @@ +import ProjectSCIMLog from "Common/Models/DatabaseModels/ProjectSCIMLog"; +import StatusPageSCIMLog from "Common/Models/DatabaseModels/StatusPageSCIMLog"; +import ProjectSCIMLogService from "Common/Server/Services/ProjectSCIMLogService"; +import StatusPageSCIMLogService from "Common/Server/Services/StatusPageSCIMLogService"; +import logger from "Common/Server/Utils/Logger"; +import ObjectID from "Common/Types/ObjectID"; +import SCIMLogStatus from "Common/Types/SCIM/SCIMLogStatus"; +import { JSONObject, JSONValue, JSONArray } from "Common/Types/JSON"; + +export interface ProjectSCIMLogData { + projectId: ObjectID; + projectScimId: ObjectID; + operationType: string; + status: SCIMLogStatus; + statusMessage?: string; + httpMethod?: string; + requestPath?: string; + httpStatusCode?: number; + affectedUserEmail?: string; + affectedGroupName?: string; + requestBody?: JSONObject; + responseBody?: JSONObject; +} + +export interface StatusPageSCIMLogData { + projectId: ObjectID; + statusPageId: ObjectID; + statusPageScimId: ObjectID; + operationType: string; + status: SCIMLogStatus; + statusMessage?: string; + httpMethod?: string; + requestPath?: string; + httpStatusCode?: number; + affectedUserEmail?: string; + requestBody?: JSONObject; + responseBody?: JSONObject; +} + +const sanitizeSensitiveData = (data: JSONObject | undefined): JSONObject | undefined => { + if (!data) { + return undefined; + } + + const sanitized: JSONObject = { ...data }; + const sensitiveKeys: string[] = [ + "password", + "bearerToken", + "bearer_token", + "authorization", + "Authorization", + "token", + "secret", + "apiKey", + "api_key", + ]; + + const sanitizeRecursive = (obj: JSONObject): JSONObject => { + const result: JSONObject = {}; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const value: JSONValue = obj[key]; + if (sensitiveKeys.some((k: string) => key.toLowerCase().includes(k.toLowerCase()))) { + result[key] = "[REDACTED]"; + } else if (typeof value === "object" && value !== null && !Array.isArray(value)) { + result[key] = sanitizeRecursive(value as JSONObject); + } else if (Array.isArray(value)) { + result[key] = (value as JSONArray).map((item: JSONValue) => { + if (typeof item === "object" && item !== null && !Array.isArray(item)) { + return sanitizeRecursive(item as JSONObject); + } + return item; + }) as JSONArray; + } else { + result[key] = value; + } + } + } + return result; + }; + + return sanitizeRecursive(sanitized); +}; + +const buildLogBody = (data: { + requestBody?: JSONObject; + responseBody?: JSONObject; + timestamp: Date; +}): string => { + const logBody: JSONObject = { + timestamp: data.timestamp.toISOString(), + request: sanitizeSensitiveData(data.requestBody), + response: sanitizeSensitiveData(data.responseBody), + }; + return JSON.stringify(logBody); +}; + +export const createProjectSCIMLog = async (data: ProjectSCIMLogData): Promise => { + try { + const log: ProjectSCIMLog = new ProjectSCIMLog(); + log.projectId = data.projectId; + log.projectScimId = data.projectScimId; + log.operationType = data.operationType; + log.status = data.status; + log.statusMessage = data.statusMessage; + log.httpMethod = data.httpMethod; + log.requestPath = data.requestPath; + log.httpStatusCode = data.httpStatusCode; + log.affectedUserEmail = data.affectedUserEmail; + log.affectedGroupName = data.affectedGroupName; + log.logBody = buildLogBody({ + requestBody: data.requestBody, + responseBody: data.responseBody, + timestamp: new Date(), + }); + + await ProjectSCIMLogService.create({ + data: log, + props: { isRoot: true }, + }); + } catch (err) { + // Log errors silently to not affect SCIM operations + logger.error("Failed to create Project SCIM log entry:"); + logger.error(err); + } +}; + +export const createStatusPageSCIMLog = async (data: StatusPageSCIMLogData): Promise => { + try { + const log: StatusPageSCIMLog = new StatusPageSCIMLog(); + log.projectId = data.projectId; + log.statusPageId = data.statusPageId; + log.statusPageScimId = data.statusPageScimId; + log.operationType = data.operationType; + log.status = data.status; + log.statusMessage = data.statusMessage; + log.httpMethod = data.httpMethod; + log.requestPath = data.requestPath; + log.httpStatusCode = data.httpStatusCode; + log.affectedUserEmail = data.affectedUserEmail; + log.logBody = buildLogBody({ + requestBody: data.requestBody, + responseBody: data.responseBody, + timestamp: new Date(), + }); + + await StatusPageSCIMLogService.create({ + data: log, + props: { isRoot: true }, + }); + } catch (err) { + // Log errors silently to not affect SCIM operations + logger.error("Failed to create Status Page SCIM log entry:"); + logger.error(err); + } +}; + +export default { + createProjectSCIMLog, + createStatusPageSCIMLog, +}; diff --git a/Common/Models/DatabaseModels/Index.ts b/Common/Models/DatabaseModels/Index.ts index a01a3d95ba..401638c9bf 100644 --- a/Common/Models/DatabaseModels/Index.ts +++ b/Common/Models/DatabaseModels/Index.ts @@ -197,6 +197,8 @@ import OnCallDutyPolicyUserOverride from "./OnCallDutyPolicyUserOverride"; import MonitorFeed from "./MonitorFeed"; import MetricType from "./MetricType"; import ProjectSCIM from "./ProjectSCIM"; +import ProjectSCIMLog from "./ProjectSCIMLog"; +import StatusPageSCIMLog from "./StatusPageSCIMLog"; const AllModelTypes: Array<{ new (): BaseModel; @@ -417,6 +419,8 @@ const AllModelTypes: Array<{ OnCallDutyPolicyTimeLog, ProjectSCIM, + ProjectSCIMLog, + StatusPageSCIMLog, ]; const modelTypeMap: { [key: string]: { new (): BaseModel } } = {}; diff --git a/Common/Models/DatabaseModels/ProjectSCIMLog.ts b/Common/Models/DatabaseModels/ProjectSCIMLog.ts new file mode 100644 index 0000000000..d1d38602fd --- /dev/null +++ b/Common/Models/DatabaseModels/ProjectSCIMLog.ts @@ -0,0 +1,422 @@ +import Project from "./Project"; +import ProjectSCIM from "./ProjectSCIM"; +import User from "./User"; +import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel"; +import Route from "../../Types/API/Route"; +import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl"; +import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl"; +import ColumnLength from "../../Types/Database/ColumnLength"; +import ColumnType from "../../Types/Database/ColumnType"; +import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint"; +import EnableDocumentation from "../../Types/Database/EnableDocumentation"; +import TableColumn from "../../Types/Database/TableColumn"; +import TableColumnType from "../../Types/Database/TableColumnType"; +import TableMetadata from "../../Types/Database/TableMetadata"; +import TenantColumn from "../../Types/Database/TenantColumn"; +import IconProp from "../../Types/Icon/IconProp"; +import ObjectID from "../../Types/ObjectID"; +import Permission from "../../Types/Permission"; +import SCIMLogStatus from "../../Types/SCIM/SCIMLogStatus"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; + +@EnableDocumentation() +@TenantColumn("projectId") +@TableAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadProjectSCIMLog, + ], + delete: [], + update: [], +}) +@CrudApiEndpoint(new Route("/project-scim-log")) +@Entity({ + name: "ProjectSCIMLog", +}) +@TableMetadata({ + tableName: "ProjectSCIMLog", + singularName: "SCIM Log", + pluralName: "SCIM Logs", + icon: IconProp.Terminal, + tableDescription: + "Logs of all SCIM provisioning operations for this project.", +}) +export default class ProjectSCIMLog extends BaseModel { + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadProjectSCIMLog, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "projectId", + type: TableColumnType.Entity, + modelType: Project, + title: "Project", + description: "Relation to Project Resource in which this object belongs", + }) + @ManyToOne( + () => { + return Project; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "projectId" }) + public project?: Project = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadProjectSCIMLog, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: true, + canReadOnRelationQuery: true, + title: "Project ID", + description: "ID of your OneUptime Project in which this object belongs", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: false, + transformer: ObjectID.getDatabaseTransformer(), + }) + public projectId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadProjectSCIMLog, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "projectScimId", + type: TableColumnType.Entity, + modelType: ProjectSCIM, + title: "Project SCIM", + description: "Relation to ProjectSCIM Resource in which this log belongs", + }) + @ManyToOne( + () => { + return ProjectSCIM; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "projectScimId" }) + public projectScim?: ProjectSCIM = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadProjectSCIMLog, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: true, + canReadOnRelationQuery: true, + title: "Project SCIM ID", + description: "ID of your Project SCIM configuration", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: false, + transformer: ObjectID.getDatabaseTransformer(), + }) + public projectScimId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadProjectSCIMLog, + ], + update: [], + }) + @TableColumn({ + required: true, + type: TableColumnType.ShortText, + title: "Operation Type", + description: + "Type of SCIM operation (e.g., CreateUser, UpdateUser, DeleteUser, ListUsers, GetUser, CreateGroup, UpdateGroup, DeleteGroup, ListGroups, GetGroup, BulkOperation)", + canReadOnRelationQuery: true, + }) + @Column({ + nullable: false, + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + }) + public operationType?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadProjectSCIMLog, + ], + update: [], + }) + @TableColumn({ + required: true, + type: TableColumnType.ShortText, + title: "Status", + description: "Status of the SCIM operation", + canReadOnRelationQuery: true, + }) + @Column({ + nullable: false, + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + }) + public status?: SCIMLogStatus = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadProjectSCIMLog, + ], + update: [], + }) + @TableColumn({ + required: false, + type: TableColumnType.LongText, + title: "Status Message", + description: "Short error or status description", + canReadOnRelationQuery: false, + }) + @Column({ + nullable: true, + type: ColumnType.LongText, + length: ColumnLength.LongText, + }) + public statusMessage?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadProjectSCIMLog, + ], + update: [], + }) + @TableColumn({ + required: false, + type: TableColumnType.VeryLongText, + title: "Log Body", + description: "Detailed JSON with request/response data", + canReadOnRelationQuery: false, + }) + @Column({ + nullable: true, + type: ColumnType.VeryLongText, + }) + public logBody?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadProjectSCIMLog, + ], + update: [], + }) + @TableColumn({ + required: false, + type: TableColumnType.ShortText, + title: "HTTP Method", + description: "HTTP method used (GET, POST, PUT, PATCH, DELETE)", + canReadOnRelationQuery: true, + }) + @Column({ + nullable: true, + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + }) + public httpMethod?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadProjectSCIMLog, + ], + update: [], + }) + @TableColumn({ + required: false, + type: TableColumnType.LongText, + title: "Request Path", + description: "The SCIM endpoint path", + canReadOnRelationQuery: false, + }) + @Column({ + nullable: true, + type: ColumnType.LongText, + length: ColumnLength.LongText, + }) + public requestPath?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadProjectSCIMLog, + ], + update: [], + }) + @TableColumn({ + required: false, + type: TableColumnType.Number, + title: "HTTP Status Code", + description: "Response HTTP status code", + canReadOnRelationQuery: true, + }) + @Column({ + nullable: true, + type: ColumnType.Number, + }) + public httpStatusCode?: number = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadProjectSCIMLog, + ], + update: [], + }) + @Index() + @TableColumn({ + required: false, + type: TableColumnType.Email, + title: "Affected User Email", + description: "Email of the user affected by this operation", + canReadOnRelationQuery: true, + }) + @Column({ + nullable: true, + type: ColumnType.Email, + length: ColumnLength.Email, + }) + public affectedUserEmail?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadProjectSCIMLog, + ], + update: [], + }) + @Index() + @TableColumn({ + required: false, + type: TableColumnType.ShortText, + title: "Affected Group Name", + description: "Name of the group/team affected by this operation", + canReadOnRelationQuery: true, + }) + @Column({ + nullable: true, + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + }) + public affectedGroupName?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "deletedByUserId", + type: TableColumnType.Entity, + title: "Deleted by User", + modelType: User, + description: + "Relation to User who deleted this object (if this object was deleted by a User)", + }) + @ManyToOne( + () => { + return User; + }, + { + cascade: false, + eager: false, + nullable: true, + onDelete: "SET NULL", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "deletedByUserId" }) + public deletedByUser?: User = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ + type: TableColumnType.ObjectID, + title: "Deleted by User ID", + description: + "User ID who deleted this object (if this object was deleted by a User)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public deletedByUserId?: ObjectID = undefined; +} diff --git a/Common/Models/DatabaseModels/StatusPageSCIMLog.ts b/Common/Models/DatabaseModels/StatusPageSCIMLog.ts new file mode 100644 index 0000000000..5b576a4c8e --- /dev/null +++ b/Common/Models/DatabaseModels/StatusPageSCIMLog.ts @@ -0,0 +1,455 @@ +import Project from "./Project"; +import StatusPage from "./StatusPage"; +import StatusPageSCIM from "./StatusPageSCIM"; +import User from "./User"; +import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel"; +import Route from "../../Types/API/Route"; +import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl"; +import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl"; +import ColumnLength from "../../Types/Database/ColumnLength"; +import ColumnType from "../../Types/Database/ColumnType"; +import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint"; +import EnableDocumentation from "../../Types/Database/EnableDocumentation"; +import TableColumn from "../../Types/Database/TableColumn"; +import TableColumnType from "../../Types/Database/TableColumnType"; +import TableMetadata from "../../Types/Database/TableMetadata"; +import TenantColumn from "../../Types/Database/TenantColumn"; +import IconProp from "../../Types/Icon/IconProp"; +import ObjectID from "../../Types/ObjectID"; +import Permission from "../../Types/Permission"; +import SCIMLogStatus from "../../Types/SCIM/SCIMLogStatus"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; + +@EnableDocumentation() +@TenantColumn("projectId") +@TableAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadStatusPageSCIMLog, + ], + delete: [], + update: [], +}) +@CrudApiEndpoint(new Route("/status-page-scim-log")) +@Entity({ + name: "StatusPageSCIMLog", +}) +@TableMetadata({ + tableName: "StatusPageSCIMLog", + singularName: "Status Page SCIM Log", + pluralName: "Status Page SCIM Logs", + icon: IconProp.Terminal, + tableDescription: + "Logs of all SCIM provisioning operations for status pages.", +}) +export default class StatusPageSCIMLog extends BaseModel { + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadStatusPageSCIMLog, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "projectId", + type: TableColumnType.Entity, + modelType: Project, + title: "Project", + description: "Relation to Project Resource in which this object belongs", + }) + @ManyToOne( + () => { + return Project; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "projectId" }) + public project?: Project = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadStatusPageSCIMLog, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: true, + canReadOnRelationQuery: true, + title: "Project ID", + description: "ID of your OneUptime Project in which this object belongs", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: false, + transformer: ObjectID.getDatabaseTransformer(), + }) + public projectId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadStatusPageSCIMLog, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "statusPageId", + type: TableColumnType.Entity, + modelType: StatusPage, + title: "Status Page", + description: "Relation to Status Page Resource", + }) + @ManyToOne( + () => { + return StatusPage; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "statusPageId" }) + public statusPage?: StatusPage = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadStatusPageSCIMLog, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: true, + canReadOnRelationQuery: true, + title: "Status Page ID", + description: "ID of the Status Page", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: false, + transformer: ObjectID.getDatabaseTransformer(), + }) + public statusPageId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadStatusPageSCIMLog, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "statusPageScimId", + type: TableColumnType.Entity, + modelType: StatusPageSCIM, + title: "Status Page SCIM", + description: + "Relation to StatusPageSCIM Resource in which this log belongs", + }) + @ManyToOne( + () => { + return StatusPageSCIM; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "statusPageScimId" }) + public statusPageScim?: StatusPageSCIM = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadStatusPageSCIMLog, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: true, + canReadOnRelationQuery: true, + title: "Status Page SCIM ID", + description: "ID of your Status Page SCIM configuration", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: false, + transformer: ObjectID.getDatabaseTransformer(), + }) + public statusPageScimId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadStatusPageSCIMLog, + ], + update: [], + }) + @TableColumn({ + required: true, + type: TableColumnType.ShortText, + title: "Operation Type", + description: + "Type of SCIM operation (e.g., CreateUser, UpdateUser, DeleteUser, ListUsers, GetUser, BulkOperation)", + canReadOnRelationQuery: true, + }) + @Column({ + nullable: false, + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + }) + public operationType?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadStatusPageSCIMLog, + ], + update: [], + }) + @TableColumn({ + required: true, + type: TableColumnType.ShortText, + title: "Status", + description: "Status of the SCIM operation", + canReadOnRelationQuery: true, + }) + @Column({ + nullable: false, + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + }) + public status?: SCIMLogStatus = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadStatusPageSCIMLog, + ], + update: [], + }) + @TableColumn({ + required: false, + type: TableColumnType.LongText, + title: "Status Message", + description: "Short error or status description", + canReadOnRelationQuery: false, + }) + @Column({ + nullable: true, + type: ColumnType.LongText, + length: ColumnLength.LongText, + }) + public statusMessage?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadStatusPageSCIMLog, + ], + update: [], + }) + @TableColumn({ + required: false, + type: TableColumnType.VeryLongText, + title: "Log Body", + description: "Detailed JSON with request/response data", + canReadOnRelationQuery: false, + }) + @Column({ + nullable: true, + type: ColumnType.VeryLongText, + }) + public logBody?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadStatusPageSCIMLog, + ], + update: [], + }) + @TableColumn({ + required: false, + type: TableColumnType.ShortText, + title: "HTTP Method", + description: "HTTP method used (GET, POST, PUT, PATCH, DELETE)", + canReadOnRelationQuery: true, + }) + @Column({ + nullable: true, + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + }) + public httpMethod?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadStatusPageSCIMLog, + ], + update: [], + }) + @TableColumn({ + required: false, + type: TableColumnType.LongText, + title: "Request Path", + description: "The SCIM endpoint path", + canReadOnRelationQuery: false, + }) + @Column({ + nullable: true, + type: ColumnType.LongText, + length: ColumnLength.LongText, + }) + public requestPath?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadStatusPageSCIMLog, + ], + update: [], + }) + @TableColumn({ + required: false, + type: TableColumnType.Number, + title: "HTTP Status Code", + description: "Response HTTP status code", + canReadOnRelationQuery: true, + }) + @Column({ + nullable: true, + type: ColumnType.Number, + }) + public httpStatusCode?: number = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadStatusPageSCIMLog, + ], + update: [], + }) + @Index() + @TableColumn({ + required: false, + type: TableColumnType.Email, + title: "Affected User Email", + description: "Email of the user affected by this operation", + canReadOnRelationQuery: true, + }) + @Column({ + nullable: true, + type: ColumnType.Email, + length: ColumnLength.Email, + }) + public affectedUserEmail?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "deletedByUserId", + type: TableColumnType.Entity, + title: "Deleted by User", + modelType: User, + description: + "Relation to User who deleted this object (if this object was deleted by a User)", + }) + @ManyToOne( + () => { + return User; + }, + { + cascade: false, + eager: false, + nullable: true, + onDelete: "SET NULL", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "deletedByUserId" }) + public deletedByUser?: User = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ + type: TableColumnType.ObjectID, + title: "Deleted by User ID", + description: + "User ID who deleted this object (if this object was deleted by a User)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public deletedByUserId?: ObjectID = undefined; +} diff --git a/Common/Server/Services/Index.ts b/Common/Server/Services/Index.ts index 1b10d4f05a..0ab02ed7ce 100644 --- a/Common/Server/Services/Index.ts +++ b/Common/Server/Services/Index.ts @@ -173,6 +173,8 @@ import OnCallDutyPolicyUserOverrideService from "./OnCallDutyPolicyUserOverrideS import MonitorLogService from "./MonitorLogService"; import OnCallDutyPolicyTimeLogService from "./OnCallDutyPolicyTimeLogService"; +import ProjectSCIMLogService from "./ProjectSCIMLogService"; +import StatusPageSCIMLogService from "./StatusPageSCIMLogService"; const services: Array = [ OnCallDutyPolicyTimeLogService, @@ -355,6 +357,9 @@ const services: Array = [ WorkspaceSettingService, WorkspaceNotificationRuleService, WorkspaceNotificationLogService, + + ProjectSCIMLogService, + StatusPageSCIMLogService, ]; export const AnalyticsServices: Array< diff --git a/Common/Server/Services/ProjectSCIMLogService.ts b/Common/Server/Services/ProjectSCIMLogService.ts new file mode 100644 index 0000000000..f0a1c2a922 --- /dev/null +++ b/Common/Server/Services/ProjectSCIMLogService.ts @@ -0,0 +1,11 @@ +import DatabaseService from "./DatabaseService"; +import Model from "../../Models/DatabaseModels/ProjectSCIMLog"; + +export class Service extends DatabaseService { + public constructor() { + super(Model); + this.hardDeleteItemsOlderThanInDays("createdAt", 3); + } +} + +export default new Service(); diff --git a/Common/Server/Services/StatusPageSCIMLogService.ts b/Common/Server/Services/StatusPageSCIMLogService.ts new file mode 100644 index 0000000000..206b94e0f4 --- /dev/null +++ b/Common/Server/Services/StatusPageSCIMLogService.ts @@ -0,0 +1,11 @@ +import DatabaseService from "./DatabaseService"; +import Model from "../../Models/DatabaseModels/StatusPageSCIMLog"; + +export class Service extends DatabaseService { + public constructor() { + super(Model); + this.hardDeleteItemsOlderThanInDays("createdAt", 3); + } +} + +export default new Service(); diff --git a/Common/Types/Permission.ts b/Common/Types/Permission.ts index 5b8c1cb49b..7549843ade 100644 --- a/Common/Types/Permission.ts +++ b/Common/Types/Permission.ts @@ -164,6 +164,9 @@ enum Permission { ReadWorkspaceNotificationLog = "ReadWorkspaceNotificationLog", ReadLlmLog = "ReadLlmLog", + ReadProjectSCIMLog = "ReadProjectSCIMLog", + ReadStatusPageSCIMLog = "ReadStatusPageSCIMLog", + CreateIncidentOwnerTeam = "CreateIncidentOwnerTeam", DeleteIncidentOwnerTeam = "DeleteIncidentOwnerTeam", EditIncidentOwnerTeam = "EditIncidentOwnerTeam", @@ -1373,6 +1376,23 @@ export class PermissionHelper { isAccessControlPermission: false, }, + { + permission: Permission.ReadProjectSCIMLog, + title: "Read Project SCIM Log", + description: + "This permission can read SCIM provisioning logs of the project.", + isAssignableToTenant: true, + isAccessControlPermission: false, + }, + { + permission: Permission.ReadStatusPageSCIMLog, + title: "Read Status Page SCIM Log", + description: + "This permission can read SCIM provisioning logs of status pages in the project.", + isAssignableToTenant: true, + isAccessControlPermission: false, + }, + { permission: Permission.CreateProjectMonitorStatus, title: "Create Monitor Status", diff --git a/Common/Types/SCIM/SCIMLogStatus.ts b/Common/Types/SCIM/SCIMLogStatus.ts new file mode 100644 index 0000000000..3c804e41d6 --- /dev/null +++ b/Common/Types/SCIM/SCIMLogStatus.ts @@ -0,0 +1,7 @@ +enum SCIMLogStatus { + Success = "Success", + Error = "Error", + Warning = "Warning", +} + +export default SCIMLogStatus; diff --git a/Dashboard/src/Components/SCIMLogs/ProjectSCIMLogsTable.tsx b/Dashboard/src/Components/SCIMLogs/ProjectSCIMLogsTable.tsx new file mode 100644 index 0000000000..b550f1285e --- /dev/null +++ b/Dashboard/src/Components/SCIMLogs/ProjectSCIMLogsTable.tsx @@ -0,0 +1,217 @@ +import React, { FunctionComponent, ReactElement, useState } from "react"; +import ModelTable from "Common/UI/Components/ModelTable/ModelTable"; +import ProjectSCIMLog from "Common/Models/DatabaseModels/ProjectSCIMLog"; +import FieldType from "Common/UI/Components/Types/FieldType"; +import Columns from "Common/UI/Components/ModelTable/Columns"; +import Pill from "Common/UI/Components/Pill/Pill"; +import { Green, Red, Yellow } from "Common/Types/BrandColors"; +import Color from "Common/Types/Color"; +import SCIMLogStatus from "Common/Types/SCIM/SCIMLogStatus"; +import ProjectUtil from "Common/UI/Utils/Project"; +import Filter from "Common/UI/Components/ModelFilter/Filter"; +import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal"; +import IconProp from "Common/Types/Icon/IconProp"; +import { ButtonStyleType } from "Common/UI/Components/Button/Button"; +import Query from "Common/Types/BaseDatabase/Query"; +import BaseModel from "Common/Types/Workflow/Components/BaseModel"; + +export interface ProjectSCIMLogsTableProps { + query?: Query; +} + +const ProjectSCIMLogsTable: FunctionComponent = ( + props: ProjectSCIMLogsTableProps, +): ReactElement => { + const [showModal, setShowModal] = useState(false); + const [modalText, setModalText] = useState(""); + const [modalTitle, setModalTitle] = useState(""); + + const getStatusColor = (status: SCIMLogStatus): Color => { + switch (status) { + case SCIMLogStatus.Success: + return Green; + case SCIMLogStatus.Warning: + return Yellow; + case SCIMLogStatus.Error: + return Red; + default: + return Green; + } + }; + + const defaultColumns: Columns = [ + { + field: { operationType: true }, + title: "Operation", + type: FieldType.Text, + noValueMessage: "-", + }, + { + field: { affectedUserEmail: true }, + title: "User Email", + type: FieldType.Email, + hideOnMobile: true, + noValueMessage: "-", + }, + { + field: { affectedGroupName: true }, + title: "Group Name", + type: FieldType.Text, + hideOnMobile: true, + noValueMessage: "-", + }, + { + field: { httpMethod: true }, + title: "Method", + type: FieldType.Text, + hideOnMobile: true, + noValueMessage: "-", + }, + { + field: { httpStatusCode: true }, + title: "Status Code", + type: FieldType.Number, + hideOnMobile: true, + noValueMessage: "-", + }, + { + field: { createdAt: true }, + title: "Time", + type: FieldType.DateTime, + }, + { + field: { status: true }, + title: "Status", + type: FieldType.Text, + getElement: (item: ProjectSCIMLog): ReactElement => { + if (item["status"]) { + return ( + + ); + } + return <>; + }, + }, + ]; + + const defaultFilters: Array> = [ + { field: { createdAt: true }, title: "Time", type: FieldType.Date }, + { field: { status: true }, title: "Status", type: FieldType.Dropdown }, + { + field: { operationType: true }, + title: "Operation Type", + type: FieldType.Dropdown, + }, + ]; + + return ( + <> + + modelType={ProjectSCIMLog} + id="project-scim-logs-table" + name="SCIM Logs" + isDeleteable={false} + isEditable={false} + isCreateable={false} + showViewIdButton={true} + isViewable={false} + userPreferencesKey="project-scim-logs-table" + query={{ + projectId: ProjectUtil.getCurrentProjectId()!, + ...(props.query || {}), + }} + selectMoreFields={{ + logBody: true, + statusMessage: true, + requestPath: true, + }} + cardProps={{ + title: "SCIM Logs", + description: "Logs of all SCIM provisioning operations.", + }} + noItemsMessage="No SCIM logs yet." + showRefreshButton={true} + columns={defaultColumns} + filters={defaultFilters} + actionButtons={[ + { + title: "View Details", + buttonStyleType: ButtonStyleType.NORMAL, + icon: IconProp.Info, + onClick: async ( + item: ProjectSCIMLog, + onCompleteAction: VoidFunction, + ) => { + let logDetails: string = ""; + if (item["logBody"]) { + try { + const parsed: object = JSON.parse(item["logBody"] as string); + logDetails = JSON.stringify(parsed, null, 2); + } catch { + logDetails = item["logBody"] as string; + } + } + if (item["statusMessage"]) { + logDetails = + `Status Message: ${item["statusMessage"]}\n\n` + logDetails; + } + if (item["requestPath"]) { + logDetails = + `Request Path: ${item["requestPath"]}\n\n` + logDetails; + } + setModalText(logDetails || "No details available"); + setModalTitle("SCIM Operation Details"); + setShowModal(true); + onCompleteAction(); + }, + }, + { + title: "View Status Message", + buttonStyleType: ButtonStyleType.NORMAL, + icon: IconProp.Error, + onClick: async ( + item: ProjectSCIMLog, + onCompleteAction: VoidFunction, + ) => { + setModalText( + (item["statusMessage"] as string) || "No status message", + ); + setModalTitle("Status Message"); + setShowModal(true); + onCompleteAction(); + }, + }, + ]} + /> + + {showModal && ( + + {modalText} + + } + onSubmit={() => { + setShowModal(false); + }} + submitButtonText="Close" + submitButtonType={ButtonStyleType.NORMAL} + /> + )} + + ); +}; + +export default ProjectSCIMLogsTable; diff --git a/Dashboard/src/Components/SCIMLogs/StatusPageSCIMLogsTable.tsx b/Dashboard/src/Components/SCIMLogs/StatusPageSCIMLogsTable.tsx new file mode 100644 index 0000000000..127fa57529 --- /dev/null +++ b/Dashboard/src/Components/SCIMLogs/StatusPageSCIMLogsTable.tsx @@ -0,0 +1,210 @@ +import React, { FunctionComponent, ReactElement, useState } from "react"; +import ModelTable from "Common/UI/Components/ModelTable/ModelTable"; +import StatusPageSCIMLog from "Common/Models/DatabaseModels/StatusPageSCIMLog"; +import FieldType from "Common/UI/Components/Types/FieldType"; +import Columns from "Common/UI/Components/ModelTable/Columns"; +import Pill from "Common/UI/Components/Pill/Pill"; +import { Green, Red, Yellow } from "Common/Types/BrandColors"; +import Color from "Common/Types/Color"; +import SCIMLogStatus from "Common/Types/SCIM/SCIMLogStatus"; +import ProjectUtil from "Common/UI/Utils/Project"; +import Filter from "Common/UI/Components/ModelFilter/Filter"; +import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal"; +import IconProp from "Common/Types/Icon/IconProp"; +import { ButtonStyleType } from "Common/UI/Components/Button/Button"; +import Query from "Common/Types/BaseDatabase/Query"; +import BaseModel from "Common/Types/Workflow/Components/BaseModel"; + +export interface StatusPageSCIMLogsTableProps { + query?: Query; +} + +const StatusPageSCIMLogsTable: FunctionComponent< + StatusPageSCIMLogsTableProps +> = (props: StatusPageSCIMLogsTableProps): ReactElement => { + const [showModal, setShowModal] = useState(false); + const [modalText, setModalText] = useState(""); + const [modalTitle, setModalTitle] = useState(""); + + const getStatusColor = (status: SCIMLogStatus): Color => { + switch (status) { + case SCIMLogStatus.Success: + return Green; + case SCIMLogStatus.Warning: + return Yellow; + case SCIMLogStatus.Error: + return Red; + default: + return Green; + } + }; + + const defaultColumns: Columns = [ + { + field: { operationType: true }, + title: "Operation", + type: FieldType.Text, + noValueMessage: "-", + }, + { + field: { affectedUserEmail: true }, + title: "User Email", + type: FieldType.Email, + hideOnMobile: true, + noValueMessage: "-", + }, + { + field: { httpMethod: true }, + title: "Method", + type: FieldType.Text, + hideOnMobile: true, + noValueMessage: "-", + }, + { + field: { httpStatusCode: true }, + title: "Status Code", + type: FieldType.Number, + hideOnMobile: true, + noValueMessage: "-", + }, + { + field: { createdAt: true }, + title: "Time", + type: FieldType.DateTime, + }, + { + field: { status: true }, + title: "Status", + type: FieldType.Text, + getElement: (item: StatusPageSCIMLog): ReactElement => { + if (item["status"]) { + return ( + + ); + } + return <>; + }, + }, + ]; + + const defaultFilters: Array> = [ + { field: { createdAt: true }, title: "Time", type: FieldType.Date }, + { field: { status: true }, title: "Status", type: FieldType.Dropdown }, + { + field: { operationType: true }, + title: "Operation Type", + type: FieldType.Dropdown, + }, + ]; + + return ( + <> + + modelType={StatusPageSCIMLog} + id="status-page-scim-logs-table" + name="SCIM Logs" + isDeleteable={false} + isEditable={false} + isCreateable={false} + showViewIdButton={true} + isViewable={false} + userPreferencesKey="status-page-scim-logs-table" + query={{ + projectId: ProjectUtil.getCurrentProjectId()!, + ...(props.query || {}), + }} + selectMoreFields={{ + logBody: true, + statusMessage: true, + requestPath: true, + }} + cardProps={{ + title: "SCIM Logs", + description: "Logs of all SCIM provisioning operations for this status page.", + }} + noItemsMessage="No SCIM logs yet." + showRefreshButton={true} + columns={defaultColumns} + filters={defaultFilters} + actionButtons={[ + { + title: "View Details", + buttonStyleType: ButtonStyleType.NORMAL, + icon: IconProp.Info, + onClick: async ( + item: StatusPageSCIMLog, + onCompleteAction: VoidFunction, + ) => { + let logDetails: string = ""; + if (item["logBody"]) { + try { + const parsed: object = JSON.parse(item["logBody"] as string); + logDetails = JSON.stringify(parsed, null, 2); + } catch { + logDetails = item["logBody"] as string; + } + } + if (item["statusMessage"]) { + logDetails = + `Status Message: ${item["statusMessage"]}\n\n` + logDetails; + } + if (item["requestPath"]) { + logDetails = + `Request Path: ${item["requestPath"]}\n\n` + logDetails; + } + setModalText(logDetails || "No details available"); + setModalTitle("SCIM Operation Details"); + setShowModal(true); + onCompleteAction(); + }, + }, + { + title: "View Status Message", + buttonStyleType: ButtonStyleType.NORMAL, + icon: IconProp.Error, + onClick: async ( + item: StatusPageSCIMLog, + onCompleteAction: VoidFunction, + ) => { + setModalText( + (item["statusMessage"] as string) || "No status message", + ); + setModalTitle("Status Message"); + setShowModal(true); + onCompleteAction(); + }, + }, + ]} + /> + + {showModal && ( + + {modalText} + + } + onSubmit={() => { + setShowModal(false); + }} + submitButtonText="Close" + submitButtonType={ButtonStyleType.NORMAL} + /> + )} + + ); +}; + +export default StatusPageSCIMLogsTable; diff --git a/Dashboard/src/Pages/Settings/SCIM.tsx b/Dashboard/src/Pages/Settings/SCIM.tsx index e67709728e..73890921f8 100644 --- a/Dashboard/src/Pages/Settings/SCIM.tsx +++ b/Dashboard/src/Pages/Settings/SCIM.tsx @@ -22,6 +22,8 @@ import React, { } from "react"; import IconProp from "Common/Types/Icon/IconProp"; import Route from "Common/Types/API/Route"; +import Tabs from "Common/UI/Components/Tabs/Tabs"; +import ProjectSCIMLogsTable from "../../Components/SCIMLogs/ProjectSCIMLogsTable"; const SCIMPage: FunctionComponent = ( _props: PageComponentProps, @@ -65,8 +67,12 @@ const SCIMPage: FunctionComponent = ( return ( - <> - + key={refresher.toString()} modelType={ProjectSCIM} userPreferencesKey={"project-scim-table"} @@ -262,9 +268,18 @@ const SCIMPage: FunctionComponent = ( }, }, ]} - /> + /> + ), + }, + { + name: "Logs", + children: , + }, + ]} + onTabChange={() => {}} + /> - {showSCIMUrlId && currentSCIMConfig && ( + {showSCIMUrlId && currentSCIMConfig && ( = ( submitButtonType={ButtonStyleType.NORMAL} /> )} - ); }; diff --git a/Dashboard/src/Pages/StatusPages/View/SCIM.tsx b/Dashboard/src/Pages/StatusPages/View/SCIM.tsx index c3b13c496a..827b69d8b2 100644 --- a/Dashboard/src/Pages/StatusPages/View/SCIM.tsx +++ b/Dashboard/src/Pages/StatusPages/View/SCIM.tsx @@ -20,6 +20,8 @@ import React, { } from "react"; import IconProp from "Common/Types/Icon/IconProp"; import Route from "Common/Types/API/Route"; +import Tabs from "Common/UI/Components/Tabs/Tabs"; +import StatusPageSCIMLogsTable from "../../../Components/SCIMLogs/StatusPageSCIMLogsTable"; const SCIMPage: FunctionComponent = ( _props: PageComponentProps, @@ -64,8 +66,12 @@ const SCIMPage: FunctionComponent = ( return ( - <> - + key={refresher.toString()} modelType={StatusPageSCIM} userPreferencesKey={"status-page-scim-table"} @@ -216,9 +222,22 @@ const SCIMPage: FunctionComponent = ( }, }, ]} - /> + /> + ), + }, + { + name: "Logs", + children: ( + + ), + }, + ]} + onTabChange={() => {}} + /> - {showSCIMUrlId && currentSCIMConfig ? ( + {showSCIMUrlId && currentSCIMConfig ? ( = ( ) : ( <> )} - ); };