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

345 lines
12 KiB
TypeScript
Raw Normal View History

import * as plugins from '../plugins.js';
import type { Reception } from './classes.reception.js';
import { AppConnection } from './classes.appconnection.js';
import type { User } from './classes.user.js';
export class AppConnectionManager {
public receptionRef: Reception;
public get db() {
return this.receptionRef.db.smartdataDb;
}
public typedrouter = new plugins.typedrequest.TypedRouter();
public CAppConnection = plugins.smartdata.setDefaultManagerForDoc(this, AppConnection);
private async emitOrganizationAlert(optionsArg: {
organizationId: string;
eventType: string;
severity: plugins.idpInterfaces.data.TAlertSeverity;
title: string;
body: string;
actorUserId: string;
relatedEntityId?: string;
relatedEntityType?: string;
}) {
await this.receptionRef.alertManager.createAlertsForEvent({
category: 'admin',
organizationId: optionsArg.organizationId,
eventType: optionsArg.eventType,
severity: optionsArg.severity,
title: optionsArg.title,
body: optionsArg.body,
actorUserId: optionsArg.actorUserId,
relatedEntityId: optionsArg.relatedEntityId,
relatedEntityType: optionsArg.relatedEntityType,
});
}
constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg;
this.receptionRef.typedrouter.addTypedRouter(this.typedrouter);
// Handler: Get app connections for an organization
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetAppConnections>(
'getAppConnections',
async (requestArg) => {
// Verify JWT and get user
const jwtData = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
const user = await this.receptionRef.userManager.CUser.getInstance({
id: jwtData.data.userId,
});
// Check user has access to the organization
const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({
id: requestArg.organizationId,
});
if (!organization) {
throw new plugins.typedrequest.TypedResponseError('Organization not found');
}
const role = await this.receptionRef.roleManager.CRole.getInstance({
data: {
organizationId: organization.id,
userId: user.id,
},
});
if (!role) {
throw new plugins.typedrequest.TypedResponseError(
'User not authorized for this organization'
);
}
// Get all connections for this organization
const connections = await this.CAppConnection.getInstances({
'data.organizationId': requestArg.organizationId,
});
const connectionObjects = await Promise.all(
connections.map(async (conn) => await conn.createSavableObject())
);
return {
connections: connectionObjects,
};
}
)
);
// Handler: Toggle app connection (connect/disconnect)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ToggleAppConnection>(
'toggleAppConnection',
async (requestArg) => {
// Verify JWT and get user
const jwtData = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
const user = await this.receptionRef.userManager.CUser.getInstance({
id: jwtData.data.userId,
});
// Check user has admin access to the organization
const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({
id: requestArg.organizationId,
});
if (!organization) {
throw new plugins.typedrequest.TypedResponseError('Organization not found');
}
const isAdmin = await organization.checkIfUserIsAdmin(user);
if (!isAdmin) {
throw new plugins.typedrequest.TypedResponseError(
'Only organization admins can manage app connections'
);
}
// Get the app
const app = await this.receptionRef.appManager.getAppById(requestArg.appId);
if (!app) {
throw new plugins.typedrequest.TypedResponseError('App not found');
}
// Find existing connection
let connection = await this.CAppConnection.getInstance({
'data.organizationId': requestArg.organizationId,
'data.appId': requestArg.appId,
});
if (requestArg.action === 'connect') {
if (connection && connection.isActive()) {
// Already connected
return {
success: true,
connection: await connection.createSavableObject(),
};
}
if (connection) {
// Reconnect existing connection
await connection.reconnect(user.id);
} else {
// Create new connection
connection = new AppConnection();
connection.id = plugins.smartunique.shortId();
connection.data = {
organizationId: requestArg.organizationId,
appId: requestArg.appId,
appType: app.type,
status: 'active',
connectedAt: Date.now(),
connectedByUserId: user.id,
grantedScopes: app.data.oauthCredentials?.allowedScopes || [],
roleMappings: [],
};
await connection.save();
}
await this.emitOrganizationAlert({
organizationId: requestArg.organizationId,
eventType: 'org_app_connected',
severity: 'medium',
title: 'Organization app connected',
body: `${user.data.email} connected ${app.data.name} to this organization.`,
actorUserId: user.id,
relatedEntityId: app.id,
relatedEntityType: 'global-app',
});
return {
success: true,
connection: await connection.createSavableObject(),
};
} else {
// Disconnect
if (!connection) {
return {
success: true,
};
}
await connection.disconnect();
await this.emitOrganizationAlert({
organizationId: requestArg.organizationId,
eventType: 'org_app_disconnected',
severity: 'medium',
title: 'Organization app disconnected',
body: `${user.data.email} disconnected ${app.data.name} from this organization.`,
actorUserId: user.id,
relatedEntityId: app.id,
relatedEntityType: 'global-app',
});
return {
success: true,
connection: await connection.createSavableObject(),
};
}
}
)
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpdateAppRoleMappings>(
'updateAppRoleMappings',
async (requestArg) => {
const jwtData = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
const user = await this.receptionRef.userManager.CUser.getInstance({
id: jwtData.data.userId,
});
const connection = await this.updateAppRoleMappings({
user,
organizationId: requestArg.organizationId,
appId: requestArg.appId,
roleMappings: requestArg.roleMappings,
});
return {
success: true,
connection: await connection.createSavableObject(),
};
}
)
);
}
public async updateAppRoleMappings(optionsArg: {
user: User;
organizationId: string;
appId: string;
roleMappings: plugins.idpInterfaces.data.IAppRoleMapping[];
}) {
const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({
id: optionsArg.organizationId,
});
if (!organization) {
throw new plugins.typedrequest.TypedResponseError('Organization not found');
}
if (!await organization.checkIfUserIsAdmin(optionsArg.user)) {
throw new plugins.typedrequest.TypedResponseError('Only organization admins can manage app role mappings');
}
const app = await this.receptionRef.appManager.getAppById(optionsArg.appId);
if (!app) {
throw new plugins.typedrequest.TypedResponseError('App not found');
}
const connection = await this.CAppConnection.getInstance({
'data.organizationId': optionsArg.organizationId,
'data.appId': optionsArg.appId,
});
if (!connection || !connection.isActive()) {
throw new plugins.typedrequest.TypedResponseError('App must be connected before role mappings can be configured');
}
const availableRoleKeys = await this.receptionRef.organizationmanager.getAvailableRoleKeys(optionsArg.organizationId);
const cleanMappings = (optionsArg.roleMappings || []).map((mappingArg) => ({
orgRoleKey: this.receptionRef.organizationmanager.validateRoleKey(mappingArg.orgRoleKey),
appRoles: this.cleanStringList(mappingArg.appRoles),
permissions: this.cleanStringList(mappingArg.permissions),
scopes: this.cleanStringList(mappingArg.scopes),
})).filter((mappingArg) => mappingArg.appRoles.length || mappingArg.permissions.length || mappingArg.scopes.length);
const invalidRoleKeys = cleanMappings
.map((mappingArg) => mappingArg.orgRoleKey)
.filter((roleKeyArg) => !availableRoleKeys.includes(roleKeyArg));
if (invalidRoleKeys.length) {
throw new plugins.typedrequest.TypedResponseError(`Unknown organization roles: ${[...new Set(invalidRoleKeys)].join(', ')}.`);
}
const requestedScopes = cleanMappings.flatMap((mappingArg) => mappingArg.scopes);
const allowedScopes = app.data.oauthCredentials?.allowedScopes || [];
const grantedScopes = connection.data.grantedScopes || [];
const unsupportedScopes = requestedScopes.filter((scopeArg) => !allowedScopes.includes(scopeArg));
if (unsupportedScopes.length) {
throw new plugins.typedrequest.TypedResponseError(`Unsupported app scopes: ${[...new Set(unsupportedScopes)].join(', ')}.`);
}
const ungrantedScopes = requestedScopes.filter((scopeArg) => !grantedScopes.includes(scopeArg));
if (ungrantedScopes.length) {
throw new plugins.typedrequest.TypedResponseError(`Scopes not granted to this connection: ${[...new Set(ungrantedScopes)].join(', ')}.`);
}
connection.data.roleMappings = cleanMappings;
await connection.save();
await this.receptionRef.activityLogManager.logActivity(
optionsArg.user.id,
'org_app_role_mappings_updated',
`${optionsArg.user.data.email} updated ${cleanMappings.length} role mappings for ${app.data.name}.`,
{
targetId: connection.id,
targetType: 'app-connection',
}
);
await this.emitOrganizationAlert({
organizationId: optionsArg.organizationId,
eventType: 'org_app_role_mappings_updated',
severity: 'medium',
title: 'Organization app role mappings updated',
body: `${optionsArg.user.data.email} updated role mappings for ${app.data.name}.`,
actorUserId: optionsArg.user.id,
relatedEntityId: app.id,
relatedEntityType: 'global-app',
});
return connection;
}
private cleanStringList(valuesArg: string[]) {
return [...new Set((valuesArg || [])
.map((valueArg) => (valueArg || '').trim())
.filter(Boolean))];
}
/**
* Get all connections for an organization
*/
public async getConnectionsForOrganization(organizationId: string): Promise<AppConnection[]> {
return await this.CAppConnection.getInstances({
'data.organizationId': organizationId,
});
}
/**
* Get connection for a specific app and organization
*/
public async getConnection(
organizationId: string,
appId: string
): Promise<AppConnection | null> {
return await this.CAppConnection.getInstance({
'data.organizationId': organizationId,
'data.appId': appId,
});
}
/**
* Check if an app is connected to an organization
*/
public async isAppConnected(organizationId: string, appId: string): Promise<boolean> {
const connection = await this.getConnection(organizationId, appId);
return connection?.isActive() || false;
}
}