feat(reception): add passport device authentication flows and alert delivery management
This commit is contained in:
@@ -0,0 +1,425 @@
|
||||
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);
|
||||
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_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') {
|
||||
const fallbackRule = new AlertRule();
|
||||
fallbackRule.id = 'builtin-global-admin-access';
|
||||
fallbackRule.data = {
|
||||
scope: 'global',
|
||||
organizationId: undefined,
|
||||
eventType: 'global_admin_access',
|
||||
minimumSeverity: 'high',
|
||||
recipientMode: 'global_admins',
|
||||
recipientUserIds: [],
|
||||
push: true,
|
||||
enabled: true,
|
||||
createdByUserId: 'system',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
return [fallbackRule];
|
||||
}
|
||||
|
||||
if (optionsArg.eventType === 'global_app_credentials_regenerated') {
|
||||
const fallbackRule = new AlertRule();
|
||||
fallbackRule.id = 'builtin-global-app-credentials-regenerated';
|
||||
fallbackRule.data = {
|
||||
scope: 'global',
|
||||
organizationId: undefined,
|
||||
eventType: 'global_app_credentials_regenerated',
|
||||
minimumSeverity: 'critical',
|
||||
recipientMode: 'global_admins',
|
||||
recipientUserIds: [],
|
||||
push: true,
|
||||
enabled: true,
|
||||
createdByUserId: 'system',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
return [fallbackRule];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
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) {
|
||||
const alerts = await this.CAlert.getInstances({
|
||||
'data.recipientUserId': userIdArg,
|
||||
});
|
||||
return alerts.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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user