feat: Add SCIM logging functionality for projects and status pages

- Implemented ProjectSCIMLog and StatusPageSCIMLog models to store SCIM operation logs.
- Created services for managing ProjectSCIMLog and StatusPageSCIMLog entries with automatic deletion of old logs.
- Developed SCIMLogger utility for creating logs with sanitized sensitive data.
- Added SCIMLogStatus enum to represent the status of SCIM operations.
- Introduced ProjectSCIMLogsTable and StatusPageSCIMLogsTable components for displaying logs in the dashboard.
- Enhanced logging with detailed request/response information and error handling.
This commit is contained in:
Nawaz Dhandala
2026-01-16 16:42:10 +00:00
parent de05f727d7
commit b4106eb580
15 changed files with 2143 additions and 15 deletions

View File

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

View File

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

View File

@@ -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<void> => {
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<void> => {
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,
};

View File

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

View File

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

View File

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

View File

@@ -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<BaseService> = [
OnCallDutyPolicyTimeLogService,
@@ -355,6 +357,9 @@ const services: Array<BaseService> = [
WorkspaceSettingService,
WorkspaceNotificationRuleService,
WorkspaceNotificationLogService,
ProjectSCIMLogService,
StatusPageSCIMLogService,
];
export const AnalyticsServices: Array<

View File

@@ -0,0 +1,11 @@
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/ProjectSCIMLog";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
this.hardDeleteItemsOlderThanInDays("createdAt", 3);
}
}
export default new Service();

View File

@@ -0,0 +1,11 @@
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/StatusPageSCIMLog";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
this.hardDeleteItemsOlderThanInDays("createdAt", 3);
}
}
export default new Service();

View File

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

View File

@@ -0,0 +1,7 @@
enum SCIMLogStatus {
Success = "Success",
Error = "Error",
Warning = "Warning",
}
export default SCIMLogStatus;

View File

@@ -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<BaseModel>;
}
const ProjectSCIMLogsTable: FunctionComponent<ProjectSCIMLogsTableProps> = (
props: ProjectSCIMLogsTableProps,
): ReactElement => {
const [showModal, setShowModal] = useState<boolean>(false);
const [modalText, setModalText] = useState<string>("");
const [modalTitle, setModalTitle] = useState<string>("");
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<ProjectSCIMLog> = [
{
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 (
<Pill
isMinimal={false}
color={getStatusColor(item["status"] as SCIMLogStatus)}
text={item["status"] as string}
/>
);
}
return <></>;
},
},
];
const defaultFilters: Array<Filter<ProjectSCIMLog>> = [
{ 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 (
<>
<ModelTable<ProjectSCIMLog>
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 && (
<ConfirmModal
title={modalTitle}
description={
<pre
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxHeight: "400px",
overflow: "auto",
}}
>
{modalText}
</pre>
}
onSubmit={() => {
setShowModal(false);
}}
submitButtonText="Close"
submitButtonType={ButtonStyleType.NORMAL}
/>
)}
</>
);
};
export default ProjectSCIMLogsTable;

View File

@@ -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<BaseModel>;
}
const StatusPageSCIMLogsTable: FunctionComponent<
StatusPageSCIMLogsTableProps
> = (props: StatusPageSCIMLogsTableProps): ReactElement => {
const [showModal, setShowModal] = useState<boolean>(false);
const [modalText, setModalText] = useState<string>("");
const [modalTitle, setModalTitle] = useState<string>("");
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<StatusPageSCIMLog> = [
{
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 (
<Pill
isMinimal={false}
color={getStatusColor(item["status"] as SCIMLogStatus)}
text={item["status"] as string}
/>
);
}
return <></>;
},
},
];
const defaultFilters: Array<Filter<StatusPageSCIMLog>> = [
{ 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 (
<>
<ModelTable<StatusPageSCIMLog>
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 && (
<ConfirmModal
title={modalTitle}
description={
<pre
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxHeight: "400px",
overflow: "auto",
}}
>
{modalText}
</pre>
}
onSubmit={() => {
setShowModal(false);
}}
submitButtonText="Close"
submitButtonType={ButtonStyleType.NORMAL}
/>
)}
</>
);
};
export default StatusPageSCIMLogsTable;

View File

@@ -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<PageComponentProps> = (
_props: PageComponentProps,
@@ -65,8 +67,12 @@ const SCIMPage: FunctionComponent<PageComponentProps> = (
return (
<Fragment>
<>
<ModelTable<ProjectSCIM>
<Tabs
tabs={[
{
name: "Configuration",
children: (
<ModelTable<ProjectSCIM>
key={refresher.toString()}
modelType={ProjectSCIM}
userPreferencesKey={"project-scim-table"}
@@ -262,9 +268,18 @@ const SCIMPage: FunctionComponent<PageComponentProps> = (
},
},
]}
/>
/>
),
},
{
name: "Logs",
children: <ProjectSCIMLogsTable />,
},
]}
onTabChange={() => {}}
/>
{showSCIMUrlId && currentSCIMConfig && (
{showSCIMUrlId && currentSCIMConfig && (
<ConfirmModal
title={`SCIM Configuration URLs`}
description={
@@ -403,7 +418,6 @@ const SCIMPage: FunctionComponent<PageComponentProps> = (
submitButtonType={ButtonStyleType.NORMAL}
/>
)}
</>
</Fragment>
);
};

View File

@@ -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<PageComponentProps> = (
_props: PageComponentProps,
@@ -64,8 +66,12 @@ const SCIMPage: FunctionComponent<PageComponentProps> = (
return (
<Fragment>
<>
<ModelTable<StatusPageSCIM>
<Tabs
tabs={[
{
name: "Configuration",
children: (
<ModelTable<StatusPageSCIM>
key={refresher.toString()}
modelType={StatusPageSCIM}
userPreferencesKey={"status-page-scim-table"}
@@ -216,9 +222,22 @@ const SCIMPage: FunctionComponent<PageComponentProps> = (
},
},
]}
/>
/>
),
},
{
name: "Logs",
children: (
<StatusPageSCIMLogsTable
query={{ statusPageId: modelId }}
/>
),
},
]}
onTabChange={() => {}}
/>
{showSCIMUrlId && currentSCIMConfig ? (
{showSCIMUrlId && currentSCIMConfig ? (
<ConfirmModal
title={`SCIM URLs - ${currentSCIMConfig.name}`}
description={
@@ -346,7 +365,6 @@ const SCIMPage: FunctionComponent<PageComponentProps> = (
) : (
<></>
)}
</>
</Fragment>
);
};