Files
app/ts/reception/classes.alertmanager.ts
T

504 lines
17 KiB
TypeScript
Raw Normal View History

import * as plugins from '../plugins.js';
import { Alert } from './classes.alert.js';
import { AlertRule } from './classes.alertrule.js';
import type { Reception } from './classes.reception.js';
const severityOrder: Record<plugins.idpInterfaces.data.TAlertSeverity, number> = {
low: 1,
medium: 2,
high: 3,
critical: 4,
};
export class AlertManager {
public receptionRef: Reception;
public get db() {
return this.receptionRef.db.smartdataDb;
}
public typedRouter = new plugins.typedrequest.TypedRouter();
public CAlert = plugins.smartdata.setDefaultManagerForDoc(this, Alert);
public CAlertRule = plugins.smartdata.setDefaultManagerForDoc(this, AlertRule);
constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg;
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ListPassportAlerts>(
'listPassportAlerts',
async (requestArg) => {
const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest(
requestArg,
{
action: 'listPassportAlerts',
}
);
const alerts = await this.listAlertsForUser(
passportDevice.data.userId,
!!requestArg.includeDismissed
);
return {
alerts: alerts.map((alertArg) => ({ id: alertArg.id, data: alertArg.data })),
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportAlertByHint>(
'getPassportAlertByHint',
async (requestArg) => {
const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest(
requestArg,
{
action: 'getPassportAlertByHint',
signedFields: [`hint_id=${requestArg.hintId}`],
}
);
const alert = await this.getAlertByHint(passportDevice.data.userId, requestArg.hintId);
return {
alert: alert ? { id: alert.id, data: alert.data } : undefined,
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MarkPassportAlertSeen>(
'markPassportAlertSeen',
async (requestArg) => {
const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest(
requestArg,
{
action: 'markPassportAlertSeen',
signedFields: [`hint_id=${requestArg.hintId}`],
}
);
await this.markAlertSeen(passportDevice.data.userId, requestArg.hintId);
return {
success: true,
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_DismissPassportAlert>(
'dismissPassportAlert',
async (requestArg) => {
const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest(
requestArg,
{
action: 'dismissPassportAlert',
signedFields: [`hint_id=${requestArg.hintId}`],
}
);
await this.dismissAlert(passportDevice.data.userId, requestArg.hintId);
return {
success: true,
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpsertAlertRule>(
'upsertAlertRule',
async (requestArg) => {
const actorUserId = await this.verifyAlertRuleAccess(
requestArg.jwt,
requestArg.scope,
requestArg.organizationId
);
const rule = requestArg.ruleId
? await this.CAlertRule.getInstance({ id: requestArg.ruleId })
: new AlertRule();
if (!rule) {
throw new plugins.typedrequest.TypedResponseError('Alert rule not found');
}
rule.id = rule.id || plugins.smartunique.shortId();
rule.data = {
scope: requestArg.scope,
organizationId: requestArg.organizationId,
eventType: requestArg.eventType,
minimumSeverity: requestArg.minimumSeverity,
recipientMode: requestArg.recipientMode,
recipientUserIds: requestArg.recipientUserIds || [],
push: requestArg.push,
enabled: requestArg.enabled,
createdByUserId: rule.data?.createdByUserId || actorUserId,
createdAt: rule.data?.createdAt || Date.now(),
updatedAt: Date.now(),
};
await rule.save();
return {
rule: {
id: rule.id,
data: rule.data,
},
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetAlertRules>(
'getAlertRules',
async (requestArg) => {
await this.verifyAlertRuleAccess(requestArg.jwt, requestArg.scope || 'global', requestArg.organizationId);
const rules = await this.CAlertRule.getInstances({});
return {
rules: rules
.filter((ruleArg) => {
if (requestArg.scope && ruleArg.data.scope !== requestArg.scope) {
return false;
}
if (requestArg.organizationId && ruleArg.data.organizationId !== requestArg.organizationId) {
return false;
}
return true;
})
.map((ruleArg) => ({ id: ruleArg.id, data: ruleArg.data })),
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_DeleteAlertRule>(
'deleteAlertRule',
async (requestArg) => {
const rule = await this.CAlertRule.getInstance({ id: requestArg.ruleId });
if (!rule) {
throw new plugins.typedrequest.TypedResponseError('Alert rule not found');
}
await this.verifyAlertRuleAccess(requestArg.jwt, rule.data.scope, rule.data.organizationId);
await rule.delete();
return {
success: true,
};
}
)
);
}
private async verifyAlertRuleAccess(
jwtArg: string,
scopeArg: plugins.idpInterfaces.data.TAlertRuleScope,
organizationIdArg?: string
) {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwtArg);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
if (scopeArg === 'global') {
const user = await this.receptionRef.userManager.CUser.getInstance({ id: jwt.data.userId });
if (!user?.data?.isGlobalAdmin) {
throw new plugins.typedrequest.TypedResponseError('Global admin privileges required');
}
return jwt.data.userId;
}
if (!organizationIdArg) {
throw new plugins.typedrequest.TypedResponseError('organizationId is required');
}
const role = await this.receptionRef.roleManager.CRole.getInstance({
data: {
userId: jwt.data.userId,
organizationId: organizationIdArg,
},
});
if (!role || !role.data.roles.some((roleArg) => ['owner', 'admin'].includes(roleArg))) {
throw new plugins.typedrequest.TypedResponseError('Organization admin privileges required');
}
return jwt.data.userId;
}
private async resolveGlobalAdminRecipients() {
const users = await this.receptionRef.userManager.CUser.getInstances({});
return users.filter((userArg) => !!userArg.data.isGlobalAdmin);
}
private async resolveOrganizationAdminRecipients(organizationIdArg: string) {
const roles = await this.receptionRef.roleManager.getAllRolesForOrg(organizationIdArg);
const adminUserIds = [...new Set(
roles
.filter((roleArg) => roleArg.data.roles.some((roleNameArg) => ['owner', 'admin'].includes(roleNameArg)))
.map((roleArg) => roleArg.data.userId)
)];
const users = await Promise.all(
adminUserIds.map((userIdArg) => this.receptionRef.userManager.CUser.getInstance({ id: userIdArg }))
);
return users.filter(Boolean);
}
private async resolveRuleRecipients(ruleArg: AlertRule) {
switch (ruleArg.data.recipientMode) {
case 'global_admins':
return this.resolveGlobalAdminRecipients();
case 'org_admins':
if (!ruleArg.data.organizationId) {
return [];
}
return this.resolveOrganizationAdminRecipients(ruleArg.data.organizationId);
case 'specific_users':
if (!ruleArg.data.recipientUserIds?.length) {
return [];
}
const users = await Promise.all(
ruleArg.data.recipientUserIds.map((userIdArg) =>
this.receptionRef.userManager.CUser.getInstance({ id: userIdArg })
)
);
return users.filter(Boolean);
}
}
private async getMatchingRules(optionsArg: {
eventType: string;
severity: plugins.idpInterfaces.data.TAlertSeverity;
organizationId?: string;
}) {
const rules = await this.CAlertRule.getInstances({});
const matchingRules = rules.filter((ruleArg) => {
if (!ruleArg.data.enabled) {
return false;
}
if (ruleArg.data.eventType !== optionsArg.eventType) {
return false;
}
if (ruleArg.data.scope === 'organization' && ruleArg.data.organizationId !== optionsArg.organizationId) {
return false;
}
return severityOrder[optionsArg.severity] >= severityOrder[ruleArg.data.minimumSeverity];
});
if (matchingRules.length > 0) {
return matchingRules;
}
if (optionsArg.eventType === 'global_admin_access') {
return [this.createBuiltInRule('builtin-global-admin-access', {
scope: 'global',
eventType: 'global_admin_access',
minimumSeverity: 'high',
recipientMode: 'global_admins',
})];
}
if (optionsArg.eventType === 'global_app_credentials_regenerated') {
return [this.createBuiltInRule('builtin-global-app-credentials-regenerated', {
scope: 'global',
eventType: 'global_app_credentials_regenerated',
minimumSeverity: 'critical',
recipientMode: 'global_admins',
})];
}
if (optionsArg.organizationId) {
const organizationFallbackMap: Record<
string,
{
minimumSeverity: plugins.idpInterfaces.data.TAlertSeverity;
}
> = {
org_app_connected: { minimumSeverity: 'medium' },
org_app_disconnected: { minimumSeverity: 'medium' },
org_invitation_created: { minimumSeverity: 'low' },
org_invitation_resent: { minimumSeverity: 'low' },
org_member_removed: { minimumSeverity: 'high' },
org_member_roles_updated: { minimumSeverity: 'high' },
org_ownership_transferred: { minimumSeverity: 'critical' },
};
const fallbackConfig = organizationFallbackMap[optionsArg.eventType];
if (fallbackConfig) {
return [this.createBuiltInRule(`builtin-${optionsArg.eventType}`, {
scope: 'organization',
organizationId: optionsArg.organizationId,
eventType: optionsArg.eventType,
minimumSeverity: fallbackConfig.minimumSeverity,
recipientMode: 'org_admins',
})];
}
}
return [];
}
private createBuiltInRule(
ruleIdArg: string,
optionsArg: {
scope: plugins.idpInterfaces.data.TAlertRuleScope;
organizationId?: string;
eventType: string;
minimumSeverity: plugins.idpInterfaces.data.TAlertSeverity;
recipientMode: plugins.idpInterfaces.data.TAlertRuleRecipientMode;
}
) {
const fallbackRule = new AlertRule();
fallbackRule.id = ruleIdArg;
fallbackRule.data = {
scope: optionsArg.scope,
organizationId: optionsArg.organizationId,
eventType: optionsArg.eventType,
minimumSeverity: optionsArg.minimumSeverity,
recipientMode: optionsArg.recipientMode,
recipientUserIds: [],
push: true,
enabled: true,
createdByUserId: 'system',
createdAt: 0,
updatedAt: 0,
};
return fallbackRule;
}
public async createAlertsForEvent(optionsArg: {
category: plugins.idpInterfaces.data.TAlertCategory;
eventType: string;
severity: plugins.idpInterfaces.data.TAlertSeverity;
title: string;
body: string;
actorUserId?: string;
organizationId?: string;
relatedEntityId?: string;
relatedEntityType?: string;
}) {
const matchingRules = await this.getMatchingRules(optionsArg);
if (matchingRules.length === 0) {
return [];
}
const recipientIds = new Set<string>();
for (const rule of matchingRules) {
const recipients = await this.resolveRuleRecipients(rule);
for (const recipient of recipients) {
recipientIds.add(recipient.id);
}
}
const createdAlerts: Alert[] = [];
for (const recipientUserId of recipientIds) {
const alert = new Alert();
alert.id = plugins.smartunique.shortId();
alert.data = {
recipientUserId,
organizationId: optionsArg.organizationId,
category: optionsArg.category,
eventType: optionsArg.eventType,
severity: optionsArg.severity,
title: optionsArg.title,
body: optionsArg.body,
actorUserId: optionsArg.actorUserId,
relatedEntityId: optionsArg.relatedEntityId,
relatedEntityType: optionsArg.relatedEntityType,
notification: {
hintId: plugins.crypto.randomUUID(),
status: 'pending',
attemptCount: 0,
createdAt: Date.now(),
deliveredAt: null,
seenAt: null,
lastError: null,
},
createdAt: Date.now(),
seenAt: null,
dismissedAt: null,
};
await alert.save();
createdAlerts.push(alert);
const devices = await this.receptionRef.passportManager.getPassportDevicesForUser(recipientUserId);
let delivered = false;
for (const device of devices) {
const result = await this.receptionRef.passportPushManager.deliverAlertHint(device, alert);
delivered = delivered || result;
}
if (!delivered && devices.length === 0) {
alert.data.notification = {
...alert.data.notification,
status: 'failed',
attemptCount: alert.data.notification.attemptCount + 1,
lastError: 'Recipient has no active passport device',
};
await alert.save();
}
}
return createdAlerts;
}
public async listAlertsForUser(userIdArg: string, includeDismissedArg = false) {
const alerts = await this.CAlert.getInstances({
'data.recipientUserId': userIdArg,
});
return alerts
.filter((alertArg) => includeDismissedArg || !alertArg.data.dismissedAt)
.sort((leftArg, rightArg) => rightArg.data.createdAt - leftArg.data.createdAt);
}
public async getAlertByHint(userIdArg: string, hintIdArg: string) {
return this.CAlert.getInstance({
'data.recipientUserId': userIdArg,
'data.notification.hintId': hintIdArg,
});
}
public async markAlertSeen(userIdArg: string, hintIdArg: string) {
const alert = await this.getAlertByHint(userIdArg, hintIdArg);
if (!alert) {
throw new plugins.typedrequest.TypedResponseError('Alert not found');
}
alert.data.seenAt = Date.now();
alert.data.notification = {
...alert.data.notification,
status: 'seen',
seenAt: Date.now(),
};
await alert.save();
return alert;
}
public async dismissAlert(userIdArg: string, hintIdArg: string) {
const alert = await this.getAlertByHint(userIdArg, hintIdArg);
if (!alert) {
throw new plugins.typedrequest.TypedResponseError('Alert not found');
}
alert.data.dismissedAt = Date.now();
if (!alert.data.seenAt) {
alert.data.seenAt = Date.now();
}
alert.data.notification = {
...alert.data.notification,
status: 'seen',
seenAt: alert.data.notification.seenAt || Date.now(),
};
await alert.save();
return alert;
}
public async reDeliverPendingAlerts() {
const alerts = await this.CAlert.getInstances({});
for (const alert of alerts) {
if (alert.data.notification.status === 'sent' || alert.data.notification.status === 'seen') {
continue;
}
const devices = await this.receptionRef.passportManager.getPassportDevicesForUser(
alert.data.recipientUserId
);
for (const device of devices) {
await this.receptionRef.passportPushManager.deliverAlertHint(device, alert);
}
}
}
}