feat(app): wire dashboard administration flows
This commit is contained in:
@@ -313,6 +313,11 @@ export class AlertManager {
|
||||
org_app_disconnected: { minimumSeverity: 'medium' },
|
||||
org_invitation_created: { minimumSeverity: 'low' },
|
||||
org_invitation_resent: { minimumSeverity: 'low' },
|
||||
org_updated: { minimumSeverity: 'high' },
|
||||
org_deleted: { minimumSeverity: 'critical' },
|
||||
org_role_definition_updated: { minimumSeverity: 'medium' },
|
||||
org_role_definition_deleted: { minimumSeverity: 'high' },
|
||||
org_app_role_mappings_updated: { minimumSeverity: 'medium' },
|
||||
org_member_removed: { minimumSeverity: 'high' },
|
||||
org_member_roles_updated: { minimumSeverity: 'high' },
|
||||
org_ownership_transferred: { minimumSeverity: 'critical' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -150,6 +151,7 @@ export class AppConnectionManager {
|
||||
connectedAt: Date.now(),
|
||||
connectedByUserId: user.id,
|
||||
grantedScopes: app.data.oauthCredentials?.allowedScopes || [],
|
||||
roleMappings: [],
|
||||
};
|
||||
await connection.save();
|
||||
}
|
||||
@@ -198,6 +200,116 @@ export class AppConnectionManager {
|
||||
}
|
||||
)
|
||||
);
|
||||
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))];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -123,7 +123,9 @@ export class ReceptionHousekeeping {
|
||||
}),
|
||||
'12 * * * * *'
|
||||
);
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this.taskmanager.start();
|
||||
logger.log('info', 'housekeeping started');
|
||||
}
|
||||
|
||||
@@ -588,7 +588,7 @@ export class OidcManager {
|
||||
|
||||
// Add claims based on scopes
|
||||
if (scopes.includes('profile') || scopes.includes('email') || scopes.includes('organizations') || scopes.includes('roles')) {
|
||||
const userInfo = await this.getUserClaims(userId, scopes);
|
||||
const userInfo = await this.getUserClaims(userId, scopes, clientId);
|
||||
Object.assign(claims, userInfo);
|
||||
}
|
||||
|
||||
@@ -638,7 +638,7 @@ export class OidcManager {
|
||||
}
|
||||
|
||||
// Get user claims based on token scopes
|
||||
const userInfo = await this.getUserClaims(tokenData.data.userId, tokenData.data.scopes);
|
||||
const userInfo = await this.getUserClaims(tokenData.data.userId, tokenData.data.scopes, tokenData.data.clientId);
|
||||
|
||||
return new Response(JSON.stringify(userInfo), {
|
||||
status: 200,
|
||||
@@ -651,7 +651,8 @@ export class OidcManager {
|
||||
*/
|
||||
private async getUserClaims(
|
||||
userId: string,
|
||||
scopes: plugins.idpInterfaces.data.TOidcScope[]
|
||||
scopes: plugins.idpInterfaces.data.TOidcScope[],
|
||||
clientId?: string
|
||||
): Promise<plugins.idpInterfaces.data.IUserInfoResponse> {
|
||||
const user = await this.receptionRef.userManager.CUser.getInstance({ id: userId });
|
||||
if (!user) {
|
||||
@@ -697,11 +698,52 @@ export class OidcManager {
|
||||
roles.push('admin');
|
||||
}
|
||||
claims.roles = roles;
|
||||
|
||||
if (clientId) {
|
||||
Object.assign(claims, await this.getMappedAppClaims(user, clientId));
|
||||
}
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
private async getMappedAppClaims(userArg: any, clientIdArg: string) {
|
||||
const app = await this.findAppByClientId(clientIdArg);
|
||||
if (!app) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const connections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({
|
||||
'data.appId': app.id,
|
||||
'data.status': 'active',
|
||||
});
|
||||
const memberRoles = await this.receptionRef.roleManager.getAllRolesForUser(userArg);
|
||||
const appRoles = new Set<string>();
|
||||
const appPermissions = new Set<string>();
|
||||
const appScopes = new Set<string>();
|
||||
|
||||
for (const connection of connections) {
|
||||
const memberRole = memberRoles.find((roleArg) => roleArg.data.organizationId === connection.data.organizationId);
|
||||
if (!memberRole) {
|
||||
continue;
|
||||
}
|
||||
for (const mapping of connection.data.roleMappings || []) {
|
||||
if (!memberRole.data.roles.includes(mapping.orgRoleKey)) {
|
||||
continue;
|
||||
}
|
||||
for (const appRole of mapping.appRoles || []) appRoles.add(appRole);
|
||||
for (const permission of mapping.permissions || []) appPermissions.add(permission);
|
||||
for (const scope of mapping.scopes || []) appScopes.add(scope);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
app_roles: [...appRoles],
|
||||
app_permissions: [...appPermissions],
|
||||
app_scopes: [...appScopes],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the revocation endpoint
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,7 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<
|
||||
slug: slugNameArg,
|
||||
billingPlanId: null,
|
||||
roleIds: [],
|
||||
roleDefinitions: [],
|
||||
}
|
||||
await newOrg.save();
|
||||
return newOrg;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Organization } from './classes.organization.js';
|
||||
import { User } from './classes.user.js';
|
||||
|
||||
export class OrganizationManager {
|
||||
public static readonly platformRoleKeys = ['owner', 'admin', 'editor', 'viewer', 'guest', 'outlaw'];
|
||||
|
||||
public receptionRef: Reception;
|
||||
public get db() {
|
||||
return this.receptionRef.db.smartdataDb;
|
||||
@@ -93,6 +95,476 @@ export class OrganizationManager {
|
||||
}
|
||||
)
|
||||
);
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpdateOrganization>(
|
||||
'updateOrganization',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
const organization = await this.updateOrganizationWithAudit({
|
||||
user,
|
||||
organizationId: requestArg.organizationId,
|
||||
name: requestArg.name,
|
||||
slug: requestArg.slug,
|
||||
confirmationText: requestArg.confirmationText,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
organization: await organization.createSavableObject(),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_DeleteOrganization>(
|
||||
'deleteOrganization',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.deleteOrganizationWithAudit({
|
||||
user,
|
||||
organizationId: requestArg.organizationId,
|
||||
confirmationText: requestArg.confirmationText,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedOrganizationId: requestArg.organizationId,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetOrgRoleDefinitions>(
|
||||
'getOrgRoleDefinitions',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
const organization = await this.getOrganizationOrThrow(requestArg.organizationId);
|
||||
await this.getRoleOrThrow(user, organization);
|
||||
return {
|
||||
roleDefinitions: this.getCustomRoleDefinitions(organization),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpsertOrgRoleDefinition>(
|
||||
'upsertOrgRoleDefinition',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
const roleDefinitions = await this.upsertOrgRoleDefinition({
|
||||
user,
|
||||
organizationId: requestArg.organizationId,
|
||||
roleDefinition: requestArg.roleDefinition,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
roleDefinitions,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_DeleteOrgRoleDefinition>(
|
||||
'deleteOrgRoleDefinition',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
const roleDefinitions = await this.deleteOrgRoleDefinition({
|
||||
user,
|
||||
organizationId: requestArg.organizationId,
|
||||
roleKey: requestArg.roleKey,
|
||||
confirmationText: requestArg.confirmationText,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
roleDefinitions,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private getCustomRoleDefinitions(organizationArg: Organization) {
|
||||
return organizationArg.data.roleDefinitions || [];
|
||||
}
|
||||
|
||||
private normalizeRoleKey(roleKeyArg: string) {
|
||||
return (roleKeyArg || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
public validateRoleKey(roleKeyArg: string) {
|
||||
const roleKey = this.normalizeRoleKey(roleKeyArg);
|
||||
if (!roleKey || roleKey.length < 2 || roleKey.length > 64) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Role key must be between 2 and 64 characters.');
|
||||
}
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(roleKey)) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Role key may only contain lowercase letters, numbers, and single dashes.');
|
||||
}
|
||||
return roleKey;
|
||||
}
|
||||
|
||||
public async getAvailableRoleKeys(organizationIdArg: string) {
|
||||
const organization = await this.getOrganizationOrThrow(organizationIdArg);
|
||||
return [
|
||||
...OrganizationManager.platformRoleKeys,
|
||||
...this.getCustomRoleDefinitions(organization).map((roleDefinitionArg) => roleDefinitionArg.key),
|
||||
];
|
||||
}
|
||||
|
||||
public async assertRoleKeysAreValid(organizationIdArg: string, roleKeysArg: string[]) {
|
||||
const normalizedRoleKeys = [...new Set((roleKeysArg || []).map((roleKeyArg) => this.validateRoleKey(roleKeyArg)))];
|
||||
if (!normalizedRoleKeys.length) {
|
||||
throw new plugins.typedrequest.TypedResponseError('At least one role is required.');
|
||||
}
|
||||
const availableRoleKeys = await this.getAvailableRoleKeys(organizationIdArg);
|
||||
const invalidRoleKeys = normalizedRoleKeys.filter((roleKeyArg) => !availableRoleKeys.includes(roleKeyArg));
|
||||
if (invalidRoleKeys.length) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`Unknown organization roles: ${invalidRoleKeys.join(', ')}.`);
|
||||
}
|
||||
return normalizedRoleKeys;
|
||||
}
|
||||
|
||||
private normalizeSlug(slugArg: string) {
|
||||
return (slugArg || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
private validateSlug(slugArg: string) {
|
||||
const slug = this.normalizeSlug(slugArg);
|
||||
if (!slug || slug.length < 3 || slug.length > 64) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization slug must be between 3 and 64 characters.');
|
||||
}
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization slug may only contain lowercase letters, numbers, and single dashes.');
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
private assertConfirmation(confirmationTextArg: string, expectedTextArg: string) {
|
||||
if ((confirmationTextArg || '').trim() !== expectedTextArg) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`Confirmation text must be exactly "${expectedTextArg}".`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrganizationOrThrow(organizationIdArg: string) {
|
||||
const organization = await this.COrganization.getInstance({
|
||||
id: organizationIdArg,
|
||||
});
|
||||
if (!organization) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found.');
|
||||
}
|
||||
return organization;
|
||||
}
|
||||
|
||||
private async getRoleOrThrow(userArg: User, organizationArg: Organization) {
|
||||
const role = await this.receptionRef.roleManager.getRoleForUserAndOrg(userArg, organizationArg);
|
||||
if (!role) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not authorized for this organization.');
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
private async verifyAdmin(userArg: User, organizationArg: Organization) {
|
||||
const role = await this.getRoleOrThrow(userArg, organizationArg);
|
||||
if (!role.data.roles.some((roleArg) => ['owner', 'admin'].includes(roleArg))) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization admin privileges required.');
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
private async verifyOwner(userArg: User, organizationArg: Organization) {
|
||||
const role = await this.getRoleOrThrow(userArg, organizationArg);
|
||||
if (!role.data.roles.includes('owner')) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization owner privileges required.');
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
public async upsertOrgRoleDefinition(optionsArg: {
|
||||
user: User;
|
||||
organizationId: string;
|
||||
roleDefinition: {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
}) {
|
||||
const organization = await this.getOrganizationOrThrow(optionsArg.organizationId);
|
||||
await this.verifyAdmin(optionsArg.user, organization);
|
||||
const roleKey = this.validateRoleKey(optionsArg.roleDefinition.key);
|
||||
if (OrganizationManager.platformRoleKeys.includes(roleKey)) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Platform roles cannot be redefined by an organization.');
|
||||
}
|
||||
|
||||
const roleName = (optionsArg.roleDefinition.name || '').trim();
|
||||
if (!roleName) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Role name is required.');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const roleDefinitions = this.getCustomRoleDefinitions(organization);
|
||||
const existingRoleDefinition = roleDefinitions.find((roleDefinitionArg) => roleDefinitionArg.key === roleKey);
|
||||
if (existingRoleDefinition) {
|
||||
existingRoleDefinition.name = roleName;
|
||||
existingRoleDefinition.description = optionsArg.roleDefinition.description?.trim() || '';
|
||||
existingRoleDefinition.updatedAt = now;
|
||||
} else {
|
||||
roleDefinitions.push({
|
||||
key: roleKey,
|
||||
name: roleName,
|
||||
description: optionsArg.roleDefinition.description?.trim() || '',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
organization.data.roleDefinitions = roleDefinitions.sort((leftArg, rightArg) => leftArg.name.localeCompare(rightArg.name));
|
||||
await organization.save();
|
||||
|
||||
await this.receptionRef.activityLogManager.logActivity(
|
||||
optionsArg.user.id,
|
||||
'role_changed',
|
||||
`${optionsArg.user.data.email} ${existingRoleDefinition ? 'updated' : 'created'} organization role ${roleKey}.`,
|
||||
{
|
||||
targetId: organization.id,
|
||||
targetType: 'organization-role',
|
||||
}
|
||||
);
|
||||
|
||||
await this.emitOrganizationAlert({
|
||||
organizationId: organization.id,
|
||||
eventType: 'org_role_definition_updated',
|
||||
severity: 'medium',
|
||||
title: 'Organization role definition updated',
|
||||
body: `${optionsArg.user.data.email} ${existingRoleDefinition ? 'updated' : 'created'} organization role ${roleKey}.`,
|
||||
actorUserId: optionsArg.user.id,
|
||||
relatedEntityId: roleKey,
|
||||
relatedEntityType: 'organization-role',
|
||||
});
|
||||
|
||||
return organization.data.roleDefinitions;
|
||||
}
|
||||
|
||||
public async deleteOrgRoleDefinition(optionsArg: {
|
||||
user: User;
|
||||
organizationId: string;
|
||||
roleKey: string;
|
||||
confirmationText: string;
|
||||
}) {
|
||||
const organization = await this.getOrganizationOrThrow(optionsArg.organizationId);
|
||||
await this.verifyAdmin(optionsArg.user, organization);
|
||||
const roleKey = this.validateRoleKey(optionsArg.roleKey);
|
||||
if (OrganizationManager.platformRoleKeys.includes(roleKey)) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Platform roles cannot be deleted.');
|
||||
}
|
||||
this.assertConfirmation(optionsArg.confirmationText, `delete role ${roleKey}`);
|
||||
|
||||
const roleDefinitions = this.getCustomRoleDefinitions(organization);
|
||||
if (!roleDefinitions.some((roleDefinitionArg) => roleDefinitionArg.key === roleKey)) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization role definition not found.');
|
||||
}
|
||||
|
||||
organization.data.roleDefinitions = roleDefinitions.filter((roleDefinitionArg) => roleDefinitionArg.key !== roleKey);
|
||||
await organization.save();
|
||||
|
||||
const roles = await this.receptionRef.roleManager.getAllRolesForOrg(organization.id);
|
||||
for (const role of roles) {
|
||||
if (role.data.roles.includes(roleKey)) {
|
||||
role.data.roles = role.data.roles.filter((roleKeyArg) => roleKeyArg !== roleKey);
|
||||
if (!role.data.roles.length) {
|
||||
role.data.roles = ['viewer'];
|
||||
}
|
||||
await role.save();
|
||||
}
|
||||
}
|
||||
|
||||
const appConnections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({
|
||||
'data.organizationId': organization.id,
|
||||
});
|
||||
for (const connection of appConnections) {
|
||||
if (connection.data.roleMappings?.some((mappingArg) => mappingArg.orgRoleKey === roleKey)) {
|
||||
connection.data.roleMappings = connection.data.roleMappings.filter((mappingArg) => mappingArg.orgRoleKey !== roleKey);
|
||||
await connection.save();
|
||||
}
|
||||
}
|
||||
|
||||
await this.receptionRef.activityLogManager.logActivity(
|
||||
optionsArg.user.id,
|
||||
'role_changed',
|
||||
`${optionsArg.user.data.email} deleted organization role ${roleKey}.`,
|
||||
{
|
||||
targetId: organization.id,
|
||||
targetType: 'organization-role',
|
||||
}
|
||||
);
|
||||
|
||||
await this.emitOrganizationAlert({
|
||||
organizationId: organization.id,
|
||||
eventType: 'org_role_definition_deleted',
|
||||
severity: 'high',
|
||||
title: 'Organization role definition deleted',
|
||||
body: `${optionsArg.user.data.email} deleted organization role ${roleKey}. Member assignments and app mappings were cleaned up.`,
|
||||
actorUserId: optionsArg.user.id,
|
||||
relatedEntityId: roleKey,
|
||||
relatedEntityType: 'organization-role',
|
||||
});
|
||||
|
||||
return organization.data.roleDefinitions;
|
||||
}
|
||||
|
||||
public async updateOrganizationWithAudit(optionsArg: {
|
||||
user: User;
|
||||
organizationId: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
confirmationText: string;
|
||||
}) {
|
||||
const organization = await this.getOrganizationOrThrow(optionsArg.organizationId);
|
||||
await this.verifyAdmin(optionsArg.user, organization);
|
||||
this.assertConfirmation(optionsArg.confirmationText, organization.data.slug);
|
||||
|
||||
const previousName = organization.data.name;
|
||||
const previousSlug = organization.data.slug;
|
||||
const nextName = typeof optionsArg.name === 'string' ? optionsArg.name.trim() : previousName;
|
||||
const nextSlug = typeof optionsArg.slug === 'string' ? this.validateSlug(optionsArg.slug) : previousSlug;
|
||||
|
||||
if (!nextName) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization name is required.');
|
||||
}
|
||||
|
||||
if (nextSlug !== previousSlug) {
|
||||
const existingOrganization = await this.COrganization.getInstance({
|
||||
data: {
|
||||
slug: nextSlug,
|
||||
},
|
||||
});
|
||||
if (existingOrganization && existingOrganization.id !== organization.id) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization slug is already in use.');
|
||||
}
|
||||
}
|
||||
|
||||
organization.data.name = nextName;
|
||||
organization.data.slug = nextSlug;
|
||||
await organization.save();
|
||||
|
||||
const changes = [
|
||||
previousName !== nextName ? `name "${previousName}" -> "${nextName}"` : '',
|
||||
previousSlug !== nextSlug ? `slug "${previousSlug}" -> "${nextSlug}"` : '',
|
||||
].filter(Boolean).join(', ') || 'no field changes';
|
||||
|
||||
await this.receptionRef.activityLogManager.logActivity(
|
||||
optionsArg.user.id,
|
||||
'org_updated',
|
||||
`Organization ${previousName} updated: ${changes}.`,
|
||||
{
|
||||
targetId: organization.id,
|
||||
targetType: 'organization',
|
||||
}
|
||||
);
|
||||
|
||||
await this.emitOrganizationAlert({
|
||||
organizationId: organization.id,
|
||||
eventType: 'org_updated',
|
||||
severity: 'high',
|
||||
title: 'Organization settings updated',
|
||||
body: `${optionsArg.user.data.email} updated ${previousName}: ${changes}.`,
|
||||
actorUserId: optionsArg.user.id,
|
||||
relatedEntityId: organization.id,
|
||||
relatedEntityType: 'organization',
|
||||
});
|
||||
|
||||
return organization;
|
||||
}
|
||||
|
||||
public async deleteOrganizationWithAudit(optionsArg: {
|
||||
user: User;
|
||||
organizationId: string;
|
||||
confirmationText: string;
|
||||
}) {
|
||||
const organization = await this.getOrganizationOrThrow(optionsArg.organizationId);
|
||||
await this.verifyOwner(optionsArg.user, organization);
|
||||
this.assertConfirmation(optionsArg.confirmationText, `delete ${organization.data.slug}`);
|
||||
|
||||
const organizationName = organization.data.name;
|
||||
const organizationSlug = organization.data.slug;
|
||||
const roles = await this.receptionRef.roleManager.getAllRolesForOrg(organization.id);
|
||||
const appConnections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({
|
||||
'data.organizationId': organization.id,
|
||||
});
|
||||
const invitations = await this.receptionRef.userInvitationManager.CUserInvitation.getInstances({});
|
||||
const billingPlans = await this.receptionRef.billingPlanManager.CBillingPlan.getInstances({
|
||||
'data.organizationId': organization.id,
|
||||
});
|
||||
|
||||
await this.receptionRef.activityLogManager.logActivity(
|
||||
optionsArg.user.id,
|
||||
'org_deleted',
|
||||
`Organization ${organizationName} (${organizationSlug}) deleted.`,
|
||||
{
|
||||
targetId: organization.id,
|
||||
targetType: 'organization',
|
||||
}
|
||||
);
|
||||
|
||||
await this.emitOrganizationAlert({
|
||||
organizationId: organization.id,
|
||||
eventType: 'org_deleted',
|
||||
severity: 'critical',
|
||||
title: 'Organization deleted',
|
||||
body: `${optionsArg.user.data.email} deleted ${organizationName}. ${roles.length} memberships and ${appConnections.length} app connections were removed.`,
|
||||
actorUserId: optionsArg.user.id,
|
||||
relatedEntityId: organization.id,
|
||||
relatedEntityType: 'organization',
|
||||
});
|
||||
|
||||
for (const connection of appConnections) {
|
||||
await connection.delete();
|
||||
}
|
||||
|
||||
for (const invitation of invitations) {
|
||||
if (invitation.data.organizationRefs.some((refArg) => refArg.organizationId === organization.id)) {
|
||||
await invitation.removeOrganization(organization.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const billingPlan of billingPlans) {
|
||||
await billingPlan.delete();
|
||||
}
|
||||
|
||||
for (const role of roles) {
|
||||
const memberUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
id: role.data.userId,
|
||||
});
|
||||
if (memberUser?.data.connectedOrgs) {
|
||||
memberUser.data.connectedOrgs = memberUser.data.connectedOrgs.filter(
|
||||
(organizationIdArg) => organizationIdArg !== organization.id
|
||||
);
|
||||
await memberUser.save();
|
||||
}
|
||||
await role.delete();
|
||||
}
|
||||
|
||||
await organization.delete();
|
||||
}
|
||||
|
||||
public async getAllOrganizationsForUser(
|
||||
|
||||
@@ -72,13 +72,15 @@ export class Reception {
|
||||
* starts the reception instance
|
||||
*/
|
||||
public async start() {
|
||||
await this.szPlatformClient.init(await this.serviceQenv.getEnvVarOnDemand('SERVEZONE_PLATFROM_AUTHORIZATION'));
|
||||
const serveZoneAuthorization = await this.serviceQenv.getEnvVarOnDemand('SERVEZONE_PLATFORM_AUTHORIZATION');
|
||||
await this.szPlatformClient.init(serveZoneAuthorization || 'test');
|
||||
logger.log('info', 'starting reception');
|
||||
logger.log('info', 'adding typedrouter to website server');
|
||||
this.options.websiteServer.typedrouter.addTypedRouter(this.typedrouter);
|
||||
logger.log('info', 'starting database');
|
||||
await this.db.start();
|
||||
await this.jwtManager.start();
|
||||
await this.housekeeping.start();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -59,6 +59,10 @@ export class UserInvitationManager {
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
const roles = await this.receptionRef.organizationmanager.assertRoleKeysAreValid(
|
||||
requestArg.organizationId,
|
||||
requestArg.roles
|
||||
);
|
||||
|
||||
const email = requestArg.email.toLowerCase().trim();
|
||||
|
||||
@@ -86,7 +90,7 @@ export class UserInvitationManager {
|
||||
action: 'create',
|
||||
userId: existingUser.id,
|
||||
organizationId: requestArg.organizationId,
|
||||
roles: requestArg.roles,
|
||||
roles,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
@@ -103,14 +107,14 @@ export class UserInvitationManager {
|
||||
let isNew = false;
|
||||
if (invitation) {
|
||||
// Add org to existing invitation
|
||||
await invitation.addOrganization(requestArg.organizationId, user.id, requestArg.roles);
|
||||
await invitation.addOrganization(requestArg.organizationId, user.id, roles);
|
||||
} else {
|
||||
// Create new invitation
|
||||
invitation = await UserInvitation.createNewInvitation(
|
||||
email,
|
||||
requestArg.organizationId,
|
||||
user.id,
|
||||
requestArg.roles
|
||||
roles
|
||||
);
|
||||
isNew = true;
|
||||
}
|
||||
@@ -323,6 +327,10 @@ export class UserInvitationManager {
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
const roles = await this.receptionRef.organizationmanager.assertRoleKeysAreValid(
|
||||
requestArg.organizationId,
|
||||
requestArg.roles
|
||||
);
|
||||
|
||||
const role = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
@@ -336,7 +344,7 @@ export class UserInvitationManager {
|
||||
}
|
||||
|
||||
// If removing owner role, check we're not removing the last owner
|
||||
if (role.data.roles.includes('owner') && !requestArg.roles.includes('owner')) {
|
||||
if (role.data.roles.includes('owner') && !roles.includes('owner')) {
|
||||
const allRoles = await this.receptionRef.roleManager.CRole.getInstances({
|
||||
data: { organizationId: requestArg.organizationId },
|
||||
});
|
||||
@@ -349,7 +357,7 @@ export class UserInvitationManager {
|
||||
}
|
||||
}
|
||||
|
||||
role.data.roles = requestArg.roles;
|
||||
role.data.roles = roles;
|
||||
await role.save();
|
||||
|
||||
const updatedUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
@@ -360,7 +368,7 @@ export class UserInvitationManager {
|
||||
eventType: 'org_member_roles_updated',
|
||||
severity: 'high',
|
||||
title: 'Organization member roles updated',
|
||||
body: `${user.data.email} changed roles for ${updatedUser?.data?.email || requestArg.userId} to ${requestArg.roles.join(', ')}.`,
|
||||
body: `${user.data.email} changed roles for ${updatedUser?.data?.email || requestArg.userId} to ${roles.join(', ')}.`,
|
||||
actorUserId: user.id,
|
||||
relatedEntityId: requestArg.userId,
|
||||
relatedEntityType: 'user',
|
||||
@@ -391,6 +399,18 @@ export class UserInvitationManager {
|
||||
);
|
||||
}
|
||||
|
||||
const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
||||
id: requestArg.organizationId,
|
||||
});
|
||||
if (!organization) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found.');
|
||||
}
|
||||
if ((requestArg.confirmationText || '').trim() !== `transfer ${organization.data.slug}`) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
`Confirmation text must be exactly "transfer ${organization.data.slug}".`
|
||||
);
|
||||
}
|
||||
|
||||
// Get new owner's role
|
||||
const newOwnerRole = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
@@ -418,6 +438,15 @@ export class UserInvitationManager {
|
||||
const newOwner = await this.receptionRef.userManager.CUser.getInstance({
|
||||
id: requestArg.newOwnerId,
|
||||
});
|
||||
await this.receptionRef.activityLogManager.logActivity(
|
||||
user.id,
|
||||
'org_ownership_transferred',
|
||||
`${user.data.email} transferred ownership of ${organization.data.name} to ${newOwner?.data?.email || requestArg.newOwnerId}.`,
|
||||
{
|
||||
targetId: requestArg.organizationId,
|
||||
targetType: 'organization',
|
||||
}
|
||||
);
|
||||
await this.emitOrganizationAlert({
|
||||
organizationId: requestArg.organizationId,
|
||||
eventType: 'org_ownership_transferred',
|
||||
|
||||
Reference in New Issue
Block a user