6d935e7180
- Add Redis-backed opportunity cache with background refresh (30s interval) - Fix concurrency bug: use lazy thunks instead of eager promises for batching - Add withCwRetry utility with exponential backoff for transient CW errors - Add adaptive TTL algorithms (primary, sub-resource, products) based on opportunity activity - Add include query param on GET /sales/opportunities/:id (notes,contacts,products) - Add opt-in CW API logger (LOG_CW_API env var) with timestamped files in cw-api-logs/ - Add debug-scripts/analyze-cw-calls.py for API call analysis - Add computeSubResourceCacheTTL and computeProductsCacheTTL algorithms with tests - Increase CW API timeout from 15s to 30s - Unblock cache refresh from startup chain (remove await) - Prioritize recently updated opportunities in refresh cycle - Add CACHING.md documentation - Update API_ROUTES.md with caching details and include param - Update copilot instructions to require CACHING.md sync - Add dev:log script for CW API call logging during development
352 lines
11 KiB
TypeScript
352 lines
11 KiB
TypeScript
import UserController from "./UserController";
|
|
import { Collection } from "@discordjs/collection";
|
|
import jwt, { JsonWebTokenError } from "jsonwebtoken";
|
|
import { permissionsPrivateKey, prisma } from "../constants";
|
|
import PermissionsVerificationError from "../Errors/PermissionsVerificationError";
|
|
import { mergeArrays } from "../modules/tools/mergeArrays";
|
|
import { signPermissions } from "../modules/permission-utils/signPermissions";
|
|
import { DecodedPermissionsBlock } from "../types/PermissionTypes";
|
|
import { permissionValidator } from "../modules/permission-utils/permissionValidator";
|
|
import { z } from "zod";
|
|
import { roles } from "../managers/roles";
|
|
import GenericError from "../Errors/GenericError";
|
|
import RoleError from "../Errors/RoleError";
|
|
import { Role, User } from "../../generated/prisma/client";
|
|
import { events } from "../modules/globalEvents";
|
|
|
|
/**
|
|
* Roles
|
|
*
|
|
* Roles are for adding onto a users permissions. They are not for defining a default users permissions.
|
|
*
|
|
* Roles have two forms of identifiers, titles and monikers. The title is something that can be capitalized and publicly displayed,
|
|
* so if you make modifications, and you need to be able to see how they were able to do that without having explicit permission to
|
|
* make the modifications, the title is what would be shown to you. The moniker is the human readable identifier. So in an api
|
|
* response where you need to get the roles of a user, instead of being given a bunch of id's, you will be given a bunch of monikers
|
|
* which are easy to read and easy to identify.
|
|
*/
|
|
export class RoleController {
|
|
public readonly id: string;
|
|
public title: string;
|
|
public moniker: string; // e.g. admin, super_admin, moderator
|
|
|
|
private _permissionsToken: string;
|
|
private _users: (User & { roles: Role[] })[];
|
|
|
|
/** Cached result of JWT verification — avoids repeated RSA verify calls. */
|
|
private _cachedVerifiedPermissions: { permissions: string[] } | null = null;
|
|
|
|
public readonly createdAt: Date;
|
|
public updatedAt: Date;
|
|
|
|
public deleted: boolean = false;
|
|
|
|
constructor(roledata: Role & { users: (User & { roles: Role[] })[] }) {
|
|
this.id = roledata.id;
|
|
this.title = roledata.title;
|
|
this.moniker = roledata.moniker;
|
|
|
|
this._permissionsToken = roledata.permissions;
|
|
this._users = roledata.users;
|
|
|
|
this.createdAt = roledata.createdAt;
|
|
this.updatedAt = roledata.updatedAt;
|
|
}
|
|
|
|
private _signPermissions = signPermissions;
|
|
|
|
/**
|
|
* Verify Permissions
|
|
*
|
|
* This method is vital to maintining the security of this system. This method is here to ensure
|
|
* that the permissions object is authentic and signed by our system.
|
|
*
|
|
* @param permissionsToken - Signed permissions JWT
|
|
* @returns - Verified object with permissions in it.
|
|
*/
|
|
private _verifyPermissions(permissionsToken: string) {
|
|
// Return cached result if the token hasn't changed
|
|
if (
|
|
this._cachedVerifiedPermissions &&
|
|
permissionsToken === this._permissionsToken
|
|
) {
|
|
return this._cachedVerifiedPermissions;
|
|
}
|
|
|
|
let perms: DecodedPermissionsBlock;
|
|
try {
|
|
perms = jwt.verify(permissionsToken, permissionsPrivateKey, {
|
|
algorithms: ["RS256"],
|
|
issuer: "roles",
|
|
subject: this.id,
|
|
}) as DecodedPermissionsBlock;
|
|
} catch (err) {
|
|
events.emit("role:permissions:verification_error", {
|
|
currentSigned: this._permissionsToken,
|
|
attemptedVerification: permissionsToken,
|
|
err: err as Error,
|
|
role: this,
|
|
});
|
|
throw new PermissionsVerificationError(
|
|
`Unable to verify permissions for role '${this.title}, it is recommended that you override and rewrite these permissions immediately.`,
|
|
(err as Error).message,
|
|
);
|
|
}
|
|
|
|
const result = perms as { permissions: string[] };
|
|
// Cache only if verifying the current token
|
|
if (permissionsToken === this._permissionsToken) {
|
|
this._cachedVerifiedPermissions = result;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get Users
|
|
*
|
|
* This will get all the users that have this role and return it as a collection dictionary where the key is
|
|
* the `id` of the user and the value is the users `UserController`.
|
|
*
|
|
* @returns {Collection<string, UserController>} - A collection of all the users that are assigned to this role
|
|
*/
|
|
public getUsers() {
|
|
const collection = new Collection<string, UserController>();
|
|
this._users.map((v) => collection.set(v.id, new UserController(v)));
|
|
return collection;
|
|
}
|
|
|
|
/**
|
|
* Check Permission
|
|
*
|
|
* Check to see if a role has a specified set of permissions.
|
|
*
|
|
* @param permission - The permission to check for
|
|
* @returns {boolean} Does this role have the specified permission
|
|
*/
|
|
public checkPermission(permission: string): boolean {
|
|
const permissions = this._verifyPermissions(this._permissionsToken);
|
|
return permissionValidator(permission, permissions.permissions);
|
|
}
|
|
|
|
/**
|
|
* Set permissions
|
|
*
|
|
* This will remove all existing permissions and replate them with the permissions defined in the params
|
|
* of this method.
|
|
*
|
|
* @param permissions - Array of all Permissions
|
|
* @returns {void}
|
|
*/
|
|
public async setPermissions(...permissions: string[]) {
|
|
/*
|
|
Make sure the current permissions are verified before updating them again. Basically speaking if the permissions
|
|
are tampered with in any way, this bricks the further modification of the permissions
|
|
*/
|
|
const previous = this._verifyPermissions(this._permissionsToken);
|
|
|
|
const newPermissionsToken = this._signPermissions({
|
|
issuer: "roles",
|
|
subject: this.id,
|
|
permissions,
|
|
});
|
|
const newRaw = await prisma.role.update({
|
|
where: { id: this.id },
|
|
data: { permissions: newPermissionsToken },
|
|
});
|
|
|
|
events.emit("role:permissions:set", {
|
|
current: permissions,
|
|
currentSigned: newPermissionsToken,
|
|
previous: previous.permissions,
|
|
previousSigned: this._permissionsToken,
|
|
role: this,
|
|
});
|
|
|
|
this._permissionsToken = newPermissionsToken;
|
|
this.updatedAt = newRaw.updatedAt;
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* Add Permissions
|
|
*
|
|
* This will take the permissions provided in the params of the method and combine them into the pre-existing
|
|
* permissions of this role.
|
|
*
|
|
* @param permissions - Array of added Permissions
|
|
* @returns {void}
|
|
*/
|
|
public async addPermissions(...permissions: string[]) {
|
|
/*
|
|
Make sure the current permissions are verified before updating them again. Basically speaking if the permissions
|
|
are tampered with in any way, this bricks the further modification of the permissions
|
|
*/
|
|
const previous = this._verifyPermissions(this._permissionsToken);
|
|
|
|
const newPermissionsToken = this._signPermissions({
|
|
issuer: "roles",
|
|
subject: this.id,
|
|
permissions: mergeArrays(previous.permissions, permissions),
|
|
});
|
|
const newRaw = await prisma.role.update({
|
|
where: { id: this.id },
|
|
data: { permissions: newPermissionsToken },
|
|
});
|
|
|
|
events.emit("role:permissions:added", {
|
|
previous: previous.permissions,
|
|
previousSigned: this._permissionsToken,
|
|
added: permissions,
|
|
currentSigned: newPermissionsToken,
|
|
role: this,
|
|
});
|
|
|
|
this._permissionsToken = newPermissionsToken;
|
|
this.updatedAt = newRaw.updatedAt;
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* Remove Permissions
|
|
*
|
|
* This will take the permissions provided in the params of the method and remove them from the exisitng
|
|
* permissions object for this role.
|
|
*
|
|
* @param permissions - Array of removed Permissions
|
|
* @returns {void}
|
|
*/
|
|
public async removePermissions(...permissions: string[]) {
|
|
/*
|
|
Make sure the current permissions are verified before updating them again. Basically speaking if the permissions
|
|
are tampered with in any way, this bricks the further modification of the permissions
|
|
*/
|
|
const previous = this._verifyPermissions(this._permissionsToken);
|
|
|
|
const newPermissionsToken = this._signPermissions({
|
|
issuer: "roles",
|
|
subject: this.id,
|
|
permissions: previous.permissions.filter((v) => !permissions.includes(v)),
|
|
});
|
|
const newRaw = await prisma.role.update({
|
|
where: { id: this.id },
|
|
data: { permissions: newPermissionsToken },
|
|
});
|
|
|
|
events.emit("role:permissions:removed", {
|
|
previous: previous.permissions,
|
|
previousSigned: this._permissionsToken,
|
|
removed: permissions,
|
|
currentSigned: newPermissionsToken,
|
|
role: this,
|
|
});
|
|
|
|
this._permissionsToken = newPermissionsToken;
|
|
this.updatedAt = newRaw.updatedAt;
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* Get Current Permissions
|
|
*
|
|
* Get all of the permissions for this role.
|
|
* @returns {string[]} - Existing permissions in an array
|
|
*/
|
|
public getPermissions() {
|
|
const permissions = this._verifyPermissions(this._permissionsToken!);
|
|
return permissions;
|
|
}
|
|
|
|
public async update(
|
|
data: Partial<{
|
|
title: string;
|
|
moniker: string;
|
|
permissions: string[];
|
|
}>,
|
|
) {
|
|
const schema = z
|
|
.object({
|
|
title: z.string().min(1, "Title cannot be empty."),
|
|
moniker: z.string().min(1, "Moniker cannot be empty."),
|
|
permissions: z.array(
|
|
z.string().min(1, "Permission node cannot be empty"),
|
|
),
|
|
})
|
|
.partial()
|
|
.strict();
|
|
|
|
data = schema.parse(data);
|
|
|
|
if (data.moniker) {
|
|
const checkMoniker = await prisma.role.findFirst({
|
|
where: { moniker: data.moniker },
|
|
});
|
|
|
|
if (checkMoniker && checkMoniker.moniker !== this.moniker)
|
|
throw new RoleError(
|
|
"Moniker is already taken.",
|
|
"Another role with this moniker already exists in the databse.",
|
|
);
|
|
}
|
|
|
|
const updatedRole = await prisma.role.update({
|
|
where: { id: this.id },
|
|
data: {
|
|
...data,
|
|
permissions: undefined,
|
|
},
|
|
});
|
|
|
|
if (data.permissions) await this.setPermissions(...data.permissions);
|
|
|
|
events.emit("role:updated", { role: this, updateData: data });
|
|
this.title = data.title ?? this.title;
|
|
this.moniker = data.moniker ?? this.moniker;
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Delete Role
|
|
*
|
|
* @returns {Promise<RoleController>} The Remains of a deleted role.
|
|
*/
|
|
public async delete() {
|
|
const deletedData = await prisma.role.delete({ where: { id: this.id } });
|
|
|
|
this.deleted = true;
|
|
events.emit("role:deleted", this);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* To JSON
|
|
*
|
|
* Create a JSON object that can be used in things like API responses.
|
|
*
|
|
* @param opts - Optional values to include in the response.
|
|
* @returns - JSON-Friendly object.
|
|
*/
|
|
public toJson(opts?: { viewUsers?: boolean; viewPermissions?: boolean }) {
|
|
let object = {
|
|
id: this.id,
|
|
title: this.title,
|
|
moniker: this.moniker,
|
|
permissions: opts?.viewPermissions
|
|
? this._verifyPermissions(this._permissionsToken).permissions
|
|
: undefined,
|
|
users: opts?.viewUsers
|
|
? this._users.map((v) => ({
|
|
id: v.id,
|
|
name: v.name,
|
|
login: v.login,
|
|
roles: v.roles.map((r: any) => r.id),
|
|
}))
|
|
: undefined,
|
|
createdAt: this.createdAt,
|
|
updatedAt: this.updatedAt,
|
|
};
|
|
|
|
return object;
|
|
}
|
|
}
|