2025-12-01 09:18:48 +00:00
|
|
|
import * as plugins from '../plugins.js';
|
|
|
|
|
import type { Reception } from './classes.reception.js';
|
|
|
|
|
import { AppConnection } from './classes.appconnection.js';
|
2026-05-07 15:35:37 +00:00
|
|
|
import type { User } from './classes.user.js';
|
2025-12-01 09:18:48 +00:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-04-20 13:21:28 +00:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-01 09:18:48 +00:00
|
|
|
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 || [],
|
2026-05-07 15:35:37 +00:00
|
|
|
roleMappings: [],
|
2025-12-01 09:18:48 +00:00
|
|
|
};
|
|
|
|
|
await connection.save();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 13:21:28 +00:00
|
|
|
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',
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-01 09:18:48 +00:00
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
connection: await connection.createSavableObject(),
|
|
|
|
|
};
|
|
|
|
|
} else {
|
|
|
|
|
// Disconnect
|
|
|
|
|
if (!connection) {
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await connection.disconnect();
|
|
|
|
|
|
2026-04-20 13:21:28 +00:00
|
|
|
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',
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-01 09:18:48 +00:00
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
connection: await connection.createSavableObject(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-05-07 15:35:37 +00:00
|
|
|
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))];
|
2025-12-01 09:18:48 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
}
|