import { GroupType } from '@growi/core'; import { ErrorV3 } from '@growi/core/dist/models'; import { Router, Request } from 'express'; import { body, param, query, validationResult, } from 'express-validator'; import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group'; import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation'; import { SupportedAction } from '~/interfaces/activity'; import Crowi from '~/server/crowi'; import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity'; import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator'; import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer'; import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response'; import { configManager } from '~/server/service/config-manager'; import UserGroupService from '~/server/service/user-group'; import loggerFactory from '~/utils/logger'; const logger = loggerFactory('growi:routes:apiv3:external-user-group'); const router = Router(); interface AuthorizedRequest extends Request { user?: any } module.exports = (crowi: Crowi): Router => { const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi); const adminRequired = require('~/server/middlewares/admin-required')(crowi); const addActivity = generateAddActivityMiddleware(crowi); const activityEvent = crowi.event('activity'); const isExecutingSync = () => { return crowi.ldapUserGroupSyncService?.syncStatus?.isExecutingSync || crowi.keycloakUserGroupSyncService?.syncStatus?.isExecutingSync || false; }; const validators = { ldapSyncSettings: [ body('ldapGroupSearchBase').optional({ nullable: true }).isString(), body('ldapGroupMembershipAttribute').exists({ checkFalsy: true }).isString(), body('ldapGroupMembershipAttributeType').exists({ checkFalsy: true }).isString(), body('ldapGroupChildGroupAttribute').exists({ checkFalsy: true }).isString(), body('autoGenerateUserOnLdapGroupSync').exists().isBoolean(), body('preserveDeletedLdapGroups').exists().isBoolean(), body('ldapGroupNameAttribute').optional({ nullable: true }).isString(), body('ldapGroupDescriptionAttribute').optional({ nullable: true }).isString(), ], keycloakSyncSettings: [ body('keycloakHost').exists({ checkFalsy: true }).isString(), body('keycloakGroupRealm').exists({ checkFalsy: true }).isString(), body('keycloakGroupSyncClientRealm').exists({ checkFalsy: true }).isString(), body('keycloakGroupSyncClientID').exists({ checkFalsy: true }).isString(), body('keycloakGroupSyncClientSecret').exists({ checkFalsy: true }).isString(), body('autoGenerateUserOnKeycloakGroupSync').exists().isBoolean(), body('preserveDeletedKeycloakGroups').exists().isBoolean(), body('keycloakGroupDescriptionAttribute').optional({ nullable: true }).isString(), ], listChildren: [ query('parentIds').optional().isArray(), query('includeGrandChildren').optional().isBoolean(), ], ancestorGroup: [ query('groupId').isString(), ], update: [ body('description').optional().isString(), ], delete: [ param('id').trim().exists({ checkFalsy: true }), query('actionName').trim().exists({ checkFalsy: true }), query('transferToUserGroupId').trim(), ], detail: [ param('id').isString(), ], }; router.get('/', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => { const { query } = req; try { const page = query.page != null ? parseInt(query.page as string) : undefined; const limit = query.limit != null ? parseInt(query.limit as string) : undefined; const offset = query.offset != null ? parseInt(query.offset as string) : undefined; const pagination = query.pagination != null ? query.pagination !== 'false' : undefined; const result = await ExternalUserGroup.findWithPagination({ page, limit, offset, pagination, }); const { docs: userGroups, totalDocs: totalUserGroups, limit: pagingLimit } = result; return res.apiv3({ userGroups, totalUserGroups, pagingLimit }); } catch (err) { const msg = 'Error occurred in fetching external user group list'; logger.error('Error', err); return res.apiv3Err(new ErrorV3(msg)); } }); router.get('/ancestors', loginRequiredStrictly, adminRequired, validators.ancestorGroup, apiV3FormValidator, async(req, res: ApiV3Response) => { const { groupId } = req.query; try { const userGroup = await ExternalUserGroup.findOne({ _id: { $eq: groupId } }); const ancestorUserGroups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(userGroup); return res.apiv3({ ancestorUserGroups }); } catch (err) { const msg = 'Error occurred while searching user groups'; logger.error(msg, err); return res.apiv3Err(new ErrorV3(msg)); } }); router.get('/children', loginRequiredStrictly, adminRequired, validators.listChildren, async(req, res) => { try { const { parentIds, includeGrandChildren = false } = req.query; const externalUserGroupsResult = await ExternalUserGroup.findChildrenByParentIds(parentIds, includeGrandChildren); return res.apiv3({ childUserGroups: externalUserGroupsResult.childUserGroups, grandChildUserGroups: externalUserGroupsResult.grandChildUserGroups, }); } catch (err) { const msg = 'Error occurred in fetching child user group list'; logger.error(msg, err); return res.apiv3Err(new ErrorV3(msg)); } }); router.get('/:id', loginRequiredStrictly, adminRequired, validators.detail, async(req, res: ApiV3Response) => { const { id } = req.params; try { const userGroup = await ExternalUserGroup.findById(id); return res.apiv3({ userGroup }); } catch (err) { const msg = 'Error occurred while getting external user group'; logger.error(msg, err); return res.apiv3Err(new ErrorV3(msg)); } }); router.delete('/:id', loginRequiredStrictly, adminRequired, validators.delete, apiV3FormValidator, addActivity, async(req: AuthorizedRequest, res: ApiV3Response) => { const { id: deleteGroupId } = req.params; const { actionName, transferToUserGroupId } = req.query; const transferGroupInfo = transferToUserGroupId != null ? { item: transferToUserGroupId as string, type: GroupType.externalUserGroup, } : undefined; try { const userGroups = await (crowi.userGroupService as UserGroupService) .removeCompletelyByRootGroupId(deleteGroupId, actionName, req.user, transferGroupInfo, ExternalUserGroup, ExternalUserGroupRelation); const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_DELETE }; activityEvent.emit('update', res.locals.activity._id, parameters); return res.apiv3({ userGroups }); } catch (err) { const msg = 'Error occurred while deleting user groups'; logger.error(msg, err); return res.apiv3Err(new ErrorV3(msg)); } }); router.put('/:id', loginRequiredStrictly, adminRequired, validators.update, apiV3FormValidator, addActivity, async(req, res: ApiV3Response) => { const { id } = req.params; const { description, } = req.body; try { const userGroup = await ExternalUserGroup.findOneAndUpdate({ _id: id }, { $set: { description } }); const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_UPDATE }; activityEvent.emit('update', res.locals.activity._id, parameters); return res.apiv3({ userGroup }); } catch (err) { const msg = 'Error occurred in updating an external user group'; logger.error(msg, err); return res.apiv3Err(new ErrorV3(msg)); } }); router.get('/:id/external-user-group-relations', loginRequiredStrictly, adminRequired, async(req, res: ApiV3Response) => { const { id } = req.params; try { const externalUserGroup = await ExternalUserGroup.findById(id); const userGroupRelations = await ExternalUserGroupRelation.find({ relatedGroup: externalUserGroup }) .populate('relatedUser'); const serialized = userGroupRelations.map(relation => serializeUserGroupRelationSecurely(relation)); return res.apiv3({ userGroupRelations: serialized }); } catch (err) { const msg = `Error occurred in fetching user group relations for external user group: ${id}`; logger.error(msg, err); return res.apiv3Err(new ErrorV3(msg)); } }); router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => { const settings = { ldapGroupSearchBase: configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase'), ldapGroupMembershipAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute'), ldapGroupMembershipAttributeType: configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttributeType'), ldapGroupChildGroupAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupChildGroupAttribute'), autoGenerateUserOnLdapGroupSync: configManager?.getConfig('crowi', 'external-user-group:ldap:autoGenerateUserOnGroupSync'), preserveDeletedLdapGroups: configManager?.getConfig('crowi', 'external-user-group:ldap:preserveDeletedGroups'), ldapGroupNameAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupNameAttribute'), ldapGroupDescriptionAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupDescriptionAttribute'), }; return res.apiv3(settings); }); router.get('/keycloak/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => { const settings = { keycloakHost: configManager?.getConfig('crowi', 'external-user-group:keycloak:host'), keycloakGroupRealm: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm'), keycloakGroupSyncClientRealm: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientRealm'), keycloakGroupSyncClientID: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientID'), keycloakGroupSyncClientSecret: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientSecret'), autoGenerateUserOnKeycloakGroupSync: configManager?.getConfig('crowi', 'external-user-group:keycloak:autoGenerateUserOnGroupSync'), preserveDeletedKeycloakGroups: configManager?.getConfig('crowi', 'external-user-group:keycloak:preserveDeletedGroups'), keycloakGroupDescriptionAttribute: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupDescriptionAttribute'), }; return res.apiv3(settings); }); router.put('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, async(req: AuthorizedRequest, res: ApiV3Response) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.apiv3Err( new ErrorV3('Invalid sync settings', 'external_user_group.invalid_sync_settings'), 400, ); } const params = { 'external-user-group:ldap:groupSearchBase': req.body.ldapGroupSearchBase, 'external-user-group:ldap:groupMembershipAttribute': req.body.ldapGroupMembershipAttribute, 'external-user-group:ldap:groupMembershipAttributeType': req.body.ldapGroupMembershipAttributeType, 'external-user-group:ldap:groupChildGroupAttribute': req.body.ldapGroupChildGroupAttribute, 'external-user-group:ldap:autoGenerateUserOnGroupSync': req.body.autoGenerateUserOnLdapGroupSync, 'external-user-group:ldap:preserveDeletedGroups': req.body.preserveDeletedLdapGroups, 'external-user-group:ldap:groupNameAttribute': req.body.ldapGroupNameAttribute, 'external-user-group:ldap:groupDescriptionAttribute': req.body.ldapGroupDescriptionAttribute, }; if (params['external-user-group:ldap:groupNameAttribute'] == null || params['external-user-group:ldap:groupNameAttribute'] === '') { // default is cn params['external-user-group:ldap:groupNameAttribute'] = 'cn'; } try { await configManager.updateConfigsInTheSameNamespace('crowi', params, true); return res.apiv3({}, 204); } catch (err) { logger.error(err); return res.apiv3Err( new ErrorV3('Sync settings update failed', 'external_user_group.update_sync_settings_failed'), 500, ); } }); router.put('/keycloak/sync-settings', loginRequiredStrictly, adminRequired, validators.keycloakSyncSettings, async(req: AuthorizedRequest, res: ApiV3Response) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.apiv3Err( new ErrorV3('Invalid sync settings', 'external_user_group.invalid_sync_settings'), 400, ); } const params = { 'external-user-group:keycloak:host': req.body.keycloakHost, 'external-user-group:keycloak:groupRealm': req.body.keycloakGroupRealm, 'external-user-group:keycloak:groupSyncClientRealm': req.body.keycloakGroupSyncClientRealm, 'external-user-group:keycloak:groupSyncClientID': req.body.keycloakGroupSyncClientID, 'external-user-group:keycloak:groupSyncClientSecret': req.body.keycloakGroupSyncClientSecret, 'external-user-group:keycloak:autoGenerateUserOnGroupSync': req.body.autoGenerateUserOnKeycloakGroupSync, 'external-user-group:keycloak:preserveDeletedGroups': req.body.preserveDeletedKeycloakGroups, 'external-user-group:keycloak:groupDescriptionAttribute': req.body.keycloakGroupDescriptionAttribute, }; try { await configManager.updateConfigsInTheSameNamespace('crowi', params, true); return res.apiv3({}, 204); } catch (err) { logger.error(err); return res.apiv3Err( new ErrorV3('Sync settings update failed', 'external_user_group.update_sync_settings_failed'), 500, ); } }); router.put('/ldap/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => { if (isExecutingSync()) { return res.apiv3Err( new ErrorV3('There is an ongoing sync process', 'external_user_group.sync_being_executed'), 409, ); } const isLdapEnabled = await configManager.getConfig('crowi', 'security:passport-ldap:isEnabled'); if (!isLdapEnabled) { return res.apiv3Err( new ErrorV3('Authentication using ldap is not set', 'external_user_group.ldap.auth_not_set'), 422, ); } try { await crowi.ldapUserGroupSyncService?.init(req.user.name, req.body.password); } catch (e) { return res.apiv3Err( new ErrorV3('LDAP group sync failed', 'external_user_group.sync_failed'), 500, ); } // Do not await for sync to finish. Result (completed, failed) will be notified to the client by socket-io. crowi.ldapUserGroupSyncService?.syncExternalUserGroups(); return res.apiv3({}, 202); }); router.put('/keycloak/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => { if (isExecutingSync()) { return res.apiv3Err( new ErrorV3('There is an ongoing sync process', 'external_user_group.sync_being_executed'), 409, ); } const getAuthProviderType = () => { const kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host'); const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm'); // starts with kcHost, contains kcGroupRealm in path // see: https://regex101.com/r/3ihDmf/1 const regex = new RegExp(`^${kcHost}/.*/${kcGroupRealm}(/|$).*`); const isOidcEnabled = configManager.getConfig('crowi', 'security:passport-oidc:isEnabled'); const oidcIssuerHost = configManager.getConfig('crowi', 'security:passport-oidc:issuerHost'); if (isOidcEnabled && regex.test(oidcIssuerHost)) return 'oidc'; const isSamlEnabled = configManager.getConfig('crowi', 'security:passport-saml:isEnabled'); const samlEntryPoint = configManager.getConfig('crowi', 'security:passport-saml:entryPoint'); if (isSamlEnabled && regex.test(samlEntryPoint)) return 'saml'; return null; }; const authProviderType = getAuthProviderType(); if (authProviderType == null) { return res.apiv3Err( new ErrorV3('Authentication using keycloak is not set', 'external_user_group.keycloak.auth_not_set'), 422, ); } crowi.keycloakUserGroupSyncService?.init(authProviderType); // Do not await for sync to finish. Result (completed, failed) will be notified to the client by socket-io. crowi.keycloakUserGroupSyncService?.syncExternalUserGroups(); return res.apiv3({}, 202); }); router.get('/ldap/sync-status', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => { const syncStatus = crowi.ldapUserGroupSyncService?.syncStatus; return res.apiv3({ ...syncStatus }); }); router.get('/keycloak/sync-status', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => { const syncStatus = crowi.keycloakUserGroupSyncService?.syncStatus; return res.apiv3({ ...syncStatus }); }); return router; };