import { CustomError } from '../helpers/common.ts' import { SYSTEM_PERMISSION } from '../models/groups.ts' import type { FastifyInstance, FastifyRequest } from 'fastify' import type { GroupPatch, GroupRule, GroupWithUserCount } from '../models/groups.ts' /** * Refuse a `manage:groups` holder any change to who is in a group that carries `manage:system`. * * Membership of such a group IS the permission: adding somebody hands them the root of the instance, * and removing somebody takes it away from a real administrator. Deleting the group does both at * once, so it asks the same question. * * @param action What the caller was trying to do, as the message reads it back to them * @returns The refusal to throw, or null when the caller may proceed */ function systemGroupGuard( req: FastifyRequest, group: GroupWithUserCount, action = 'change who belongs to the group' ): CustomError | null { if (!group.permissions.includes(SYSTEM_PERMISSION)) { return null } if (WIKI.models.groups.holdsSystemPermission(req)) { return null } return new CustomError( 'groupMembershipSystemProtected', `This group has the ${SYSTEM_PERMISSION} permission. Only a user who holds it can ${action}.`, 403 ) } interface GroupUpdateBody { name?: string redirectOnLogin?: string redirectOnFirstLogin?: string redirectOnLogout?: string permissions?: string[] rules?: GroupRule[] } /** * Groups API Routes */ async function routes(app: FastifyInstance) { /** * LIST ALL GROUPS */ app.get( '/', { config: { // -> `manage:navigation` is here because a menu item can be limited to groups, so the // navigation editor has to be able to name them. It is safe to grant on this route and this // route only: the listing is `GroupCore`, which carries no permissions, no rules and no // members — reading one group in full, or its members, keeps needing `manage:groups`. permissions: ['read:groups', 'manage:groups', 'manage:navigation'] }, schema: { summary: 'List all groups', description: 'Every group by id and name, with its member count. Nothing about what a group may do or who is in it — that is `GET /groups/{groupId}`.', tags: ['Groups'], response: { 200: { description: 'List of all groups', type: 'array', items: { $ref: 'GroupCore#' } } } } }, async () => { return WIKI.models.groups.getAllGroups() } ) /** * CREATE GROUP */ app.post<{ Body: { name: string } }>( '/', { config: { permissions: ['write:groups', 'manage:groups'] }, schema: { summary: 'Create a new group', description: 'Creates a non-system group, seeded with the same starting permissions and default rule as the built-in `Users` group.', tags: ['Groups'], body: { type: 'object', required: ['name'], properties: { name: { type: 'string', minLength: 1, maxLength: 255 } }, examples: [{ name: 'Editors' }] }, response: { 200: { description: 'Group created successfully', type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' }, id: { type: 'string', format: 'uuid' } } } } } }, async (req, reply) => { if (!/^[^<>"]+$/.test(req.body.name)) { throw new CustomError('groupCreateInvalidName', 'Invalid Group Name') } try { const id = await WIKI.models.groups.createGroup(req.body.name) return { ok: true, message: 'Group created successfully.', id } } catch (err: any) { WIKI.logger.warn(err) return reply.internalServerError() } } ) /** * GET SINGLE GROUP */ app.get<{ Params: { groupId: string } }>( '/:groupId', { config: { permissions: ['read:groups', 'manage:groups'] }, schema: { summary: 'Get a single group', description: 'Returns the group with its full permissions and page rules.', tags: ['Groups'], params: { type: 'object', properties: { groupId: { type: 'string', format: 'uuid' } }, required: ['groupId'] }, response: { 200: { description: 'Group info', type: 'object', $ref: 'Group#' } } } }, async (req, reply) => { const group = await WIKI.models.groups.getGroupById(req.params.groupId) if (!group) { return reply.notFound('Group does not exist.') } return group } ) /** * UPDATE GROUP */ app.put<{ Params: { groupId: string }; Body: GroupUpdateBody }>( '/:groupId', { config: { permissions: ['write:groups', 'manage:groups'] }, schema: { summary: 'Update a group', description: 'Updates any subset of the group fields. Omitted fields are left unchanged. The permissions of the root administrators group cannot be modified.', tags: ['Groups'], params: { type: 'object', properties: { groupId: { type: 'string', format: 'uuid' } }, required: ['groupId'] }, body: { type: 'object', properties: { name: { type: 'string', minLength: 1, maxLength: 255 }, redirectOnLogin: { type: 'string', maxLength: 255 }, redirectOnFirstLogin: { type: 'string', maxLength: 255 }, redirectOnLogout: { type: 'string', maxLength: 255 }, permissions: { type: 'array', items: { type: 'string' } }, rules: { type: 'array', items: { $ref: 'GroupRule#' } } }, examples: [ { name: 'Editors', permissions: ['read:pages', 'write:pages'] } ] }, response: { 200: { description: 'Group updated successfully', type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' } } } } } }, async (req, reply) => { const group = await WIKI.models.groups.getGroupById(req.params.groupId) if (!group) { return reply.notFound('Group does not exist.') } // -> Collect only the fields actually provided const patch: GroupPatch = {} if (req.body.name !== undefined) { patch.name = req.body.name } if (req.body.redirectOnLogin !== undefined) { patch.redirectOnLogin = req.body.redirectOnLogin } if (req.body.redirectOnFirstLogin !== undefined) { patch.redirectOnFirstLogin = req.body.redirectOnFirstLogin } if (req.body.redirectOnLogout !== undefined) { patch.redirectOnLogout = req.body.redirectOnLogout } if (req.body.permissions !== undefined) { patch.permissions = req.body.permissions } if (req.body.rules !== undefined) { patch.rules = req.body.rules } if (Object.keys(patch).length < 1) { throw new CustomError('groupUpdateEmpty', 'No group fields provided to update.') } // -> The root administrators group must keep its permissions, or the instance becomes // unmanageable with no way to grant `manage:system` back. Resending the current set is // allowed, so that a client editing other fields can still submit the whole group. if (patch.permissions && group.id === WIKI.config.auth.rootAdminGroupId) { const isUnchanged = patch.permissions.length === group.permissions.length && patch.permissions.every((p) => group.permissions.includes(p)) if (!isUnchanged) { throw new CustomError( 'groupUpdateRootAdminPermissions', 'Cannot modify the permissions of the root administrators group.' ) } } /* A `manage:groups` holder may edit a group that carries `manage:system` -- name, rules, redirects, every other permission -- but may not turn that one permission on or off. Granting it is handing over the instance; revoking it is locking the real administrators out. */ if (patch.permissions && !WIKI.models.groups.holdsSystemPermission(req)) { const held = group.permissions.includes(SYSTEM_PERMISSION) if (held !== patch.permissions.includes(SYSTEM_PERMISSION)) { throw new CustomError( 'groupUpdateSystemPermission', `Only a user who holds the ${SYSTEM_PERMISSION} permission can grant or revoke it. Every other change to this group is allowed.`, 403 ) } } // -> Rule IDs must be unique within the group, as they address the rule client-side if (patch.rules) { const ruleIds = patch.rules.map((r) => r.id) if (new Set(ruleIds).size !== ruleIds.length) { throw new CustomError('groupUpdateDuplicateRuleId', 'Group rule IDs must be unique.') } } try { await WIKI.models.groups.updateGroup(group.id, patch) return { ok: true, message: 'Group updated successfully.' } } catch (err: any) { WIKI.logger.warn(err) return reply.internalServerError() } } ) /** * DELETE GROUP */ app.delete<{ Params: { groupId: string } }>( '/:groupId', { config: { permissions: ['manage:groups'] }, schema: { summary: 'Delete a group', description: 'Deletes the group and removes all of its user assignments. System groups cannot be deleted.', tags: ['Groups'], params: { type: 'object', properties: { groupId: { type: 'string', format: 'uuid' } }, required: ['groupId'] }, response: { 204: { description: 'Group deleted successfully' } } } }, async (req, reply) => { const group = await WIKI.models.groups.getGroupById(req.params.groupId) if (!group) { return reply.notFound('Group does not exist.') } if (group.isSystem) { return reply.conflict('Cannot delete a system group.') } // -> Deleting the group removes every member from it, so it is the membership guard's question const systemGroupRefusal = systemGroupGuard(req, group, 'delete the group') if (systemGroupRefusal) { throw systemGroupRefusal } try { await WIKI.models.groups.deleteGroup(group.id) return reply.code(204).send() } catch (err: any) { WIKI.logger.warn(err) return reply.internalServerError() } } ) /** * LIST GROUP USERS */ app.get<{ Params: { groupId: string } Querystring: { filter?: string; page?: number; limit?: number } }>( '/:groupId/users', { config: { permissions: ['read:groups', 'manage:groups'] }, schema: { summary: 'List the users assigned to a group', description: 'Returns a page of group members, ordered by name.', tags: ['Groups'], params: { type: 'object', properties: { groupId: { type: 'string', format: 'uuid' } }, required: ['groupId'] }, querystring: { type: 'object', properties: { filter: { type: 'string', description: 'Case-insensitive substring matched against the name and email.', maxLength: 255 }, page: { type: 'integer', minimum: 1, default: 1 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 } } }, response: { 200: { description: 'List of group members', type: 'object', properties: { page: { type: 'integer' }, limit: { type: 'integer' }, total: { type: 'integer' }, users: { type: 'array', items: { $ref: 'UserCore#' } } } } } } }, async (req, reply) => { const group = await WIKI.models.groups.getGroupById(req.params.groupId) if (!group) { return reply.notFound('Group does not exist.') } const page = req.query.page ?? 1 const limit = req.query.limit ?? 20 const { total, users } = await WIKI.models.groups.getGroupUsers(group.id, { filter: req.query.filter, page, limit }) return { page, limit, total, users } } ) /** * ASSIGN USER TO GROUP */ app.post<{ Params: { groupId: string; userId: string } }>( '/:groupId/users/:userId', { config: { permissions: ['write:groups', 'manage:groups'] }, schema: { summary: 'Assign a user to a group', description: 'System users (the guest account) cannot be assigned: their group membership is fixed at install time.', tags: ['Groups'], params: { type: 'object', properties: { groupId: { type: 'string', format: 'uuid' }, userId: { type: 'string', format: 'uuid' } }, required: ['groupId', 'userId'] }, response: { 200: { description: 'User assigned successfully', type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' } } } } } }, async (req, reply) => { const group = await WIKI.models.groups.getGroupById(req.params.groupId) if (!group) { return reply.notFound('Group does not exist.') } const user = await WIKI.models.users.getById(req.params.userId) if (!user) { return reply.notFound('User does not exist.') } const systemGroupRefusal = systemGroupGuard(req, group) if (systemGroupRefusal) { throw systemGroupRefusal } /* The guests group and the guest account belong to each other and to nothing else — the group is what anonymous visitors hold, and the account is who they are. `guestMembershipViolation` is the one definition of that, shared with `setUserGroups`, which is what the user editor and provider enrolment go through. */ const violation = WIKI.models.groups.guestMembershipViolation(group.id, user) if (violation) { return reply.conflict(violation) } const assigned = await WIKI.models.groups.assignUserToGroup(group.id, req.params.userId) if (!assigned) { return reply.conflict('User is already assigned to this group.') } return { ok: true, message: 'User assigned to group successfully.' } } ) /** * UNASSIGN USER FROM GROUP */ app.delete<{ Params: { groupId: string; userId: string } }>( '/:groupId/users/:userId', { config: { permissions: ['write:groups', 'manage:groups'] }, schema: { summary: 'Unassign a user from a group', description: 'Removes the user from the group. The last remaining user cannot be removed from the root administrators group, and system users (the guest account) cannot be unassigned at all.', tags: ['Groups'], params: { type: 'object', properties: { groupId: { type: 'string', format: 'uuid' }, userId: { type: 'string', format: 'uuid' } }, required: ['groupId', 'userId'] }, response: { 204: { description: 'User unassigned successfully' } } } }, async (req, reply) => { const group = await WIKI.models.groups.getGroupById(req.params.groupId) if (!group) { return reply.notFound('Group does not exist.') } if (!(await WIKI.models.groups.isUserInGroup(group.id, req.params.userId))) { return reply.notFound('User is not assigned to this group.') } const systemGroupRefusal = systemGroupGuard(req, group) if (systemGroupRefusal) { throw systemGroupRefusal } // -> Removing the guest account from the guests group would strip anonymous visitors of the // permissions that group carries, with no way to put it back. `unassignUserFromGroup` // refuses that pair as well; this answers it as a conflict rather than as a failure. const user = await WIKI.models.users.getById(req.params.userId) if (user?.isSystem) { return reply.conflict('Cannot unassign a system user from a group.') } // -> Emptying the root administrators group would lock everyone out of system management if (group.id === WIKI.config.auth.rootAdminGroupId) { if ((await WIKI.models.groups.countUsersInGroup(group.id)) <= 1) { return reply.conflict('Cannot remove the last user from the root administrators group.') } } await WIKI.models.groups.unassignUserFromGroup(group.id, req.params.userId) return reply.code(204).send() } ) } export default routes