import { audit } from '../helpers/audit.ts' import { CustomError } from '../helpers/common.ts' import { ELEVATED_PERMISSIONS, SYSTEM_PERMISSION, isElevated } from '../models/groups.ts' import type { FastifyInstance, FastifyRequest } from 'fastify' import type { GroupPatch, GroupRule, GroupWithUserCount } from '../models/groups.ts' /** * Refuse a change to who is in a group that administers the instance. * * Membership of such a group IS the permission: adding somebody hands them what the group can reach, * and removing somebody takes it away from a real administrator. Deleting the group does both at * once, so it asks the same question. * * Where the line falls depends on what the caller holds, and the two rungs are deliberately * different: * * - **`manage:groups`** is stopped only by `manage:system`, the permission that bypasses every check * on the server. Everything below that is theirs to arrange; managing groups is the job. * - **`write:groups`** is stopped by every one of `ELEVATED_PERMISSIONS`. It is the rung that may * build and populate ordinary groups without being trusted to decide who administers the wiki — * and since it cannot edit a group's permissions at all, its only route to an elevated group would * be through the membership of one that already exists. * * @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 elevatedGroupGuard( req: FastifyRequest, group: GroupWithUserCount, action = 'change who belongs to the group' ): CustomError | null { if (WIKI.models.groups.holdsSystemPermission(req)) { return null } const permissions = req.apiKey?.permissions ?? req.session?.permissions ?? [] if (permissions.includes('manage:groups')) { if (!group.permissions.includes(SYSTEM_PERMISSION)) { return null } return new CustomError( 'groupMembershipSystemProtected', `This group has the ${SYSTEM_PERMISSION} permission. Only a user who holds it can ${action}.`, 403 ) } if (!isElevated(group.permissions)) { return null } const held = group.permissions.filter((p) => (ELEVATED_PERMISSIONS as readonly string[]).includes(p) ) return new CustomError( 'groupMembershipElevatedProtected', `This group administers the wiki (${held.join(', ')}). Only a user who holds manage:groups or manage:system 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( '/', { /* No route-level `permissions`: most of the callers are global-permission holders and the hook could answer for them, but `manage:navigation` is a PAGE RULE now and the hook reads the group-wide list only. Both kinds are checked in the handler instead, so the answer is the same for everyone who needs it. */ 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 (req, reply) => { /* Everything that has to NAME a group without being able to read one. The global half: `manage:sites` because an approval rule names the groups that may suggest an edit and the groups that review one; `read:users` and `manage:users` because the user editor shows which groups an account belongs to; the group permissions themselves. The page-rule half: `manage:navigation`, because a menu item can be limited to groups and the navigation editor has to offer them. Asked as "anywhere" rather than against a path, since this request names no page — see `groups.grantsAnywhere`. Safe 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 `read:groups`, and changing one keeps needing `manage:groups`. */ const actor = WIKI.models.groups.actorForRequest(req) const allowed = [ 'read:groups', 'write:groups', 'manage:groups', 'manage:sites', 'read:users', 'manage:users' ].some((permission) => actor.permissions.includes(permission)) || WIKI.models.groups.grantsAnywhere(actor, 'manage:navigation') if (!allowed) { return reply.forbidden('You are not allowed to list groups.') } /* `isElevated` rather than the permissions themselves: this listing is deliberately thin and is granted to callers who may not read a group in full, but every one of those callers has a control to draw that must not offer a group the server will refuse. */ return (await WIKI.models.groups.getAllGroups()).map((group) => ({ ...group, isElevated: isElevated(group.permissions ?? []) })) } ) /** * 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) => { const invalid = await WIKI.models.groups.validateName(req.body.name) if (invalid) { throw new CustomError('groupCreateInvalidName', invalid) } try { const id = await WIKI.models.groups.createGroup(req.body.name) await audit(req, 'admin', 'createGroup', { groupId: id, name: 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', 'write: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.') } // -> A rename is held to the same rules as a new name, this group excepted: resending the name // it already has is how a client that edits the whole group at once saves anything else if (patch.name !== undefined) { const invalidName = await WIKI.models.groups.validateName(patch.name, group.id) if (invalidName) { throw new CustomError('groupUpdateInvalidName', invalidName) } } // -> 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.' ) } } /* Who may rewrite the global permission list of a group, which is the question the Permissions tab asks and the one thing separating the two group-editing rungs. `write:groups` may not touch it at all: it creates and arranges groups, names them, writes their page rules and moves people in and out of the ordinary ones -- but what a group is ALLOWED to do instance-wide is not its to decide, since granting `manage:users` to a group it belongs to would be a way of granting itself anything. The list is compared rather than merely refused when present, so a client that round-trips the whole group back unchanged -- which is exactly what the editor does on every save -- still saves the fields it may. `manage:groups` may rewrite the list, except for `manage:system` itself: granting that hands over the instance, and revoking it locks the real administrators out. */ if (patch.permissions && !WIKI.models.groups.holdsSystemPermission(req)) { const callerPermissions = req.apiKey?.permissions ?? req.session?.permissions ?? [] const unchanged = group.permissions.length === patch.permissions.length && group.permissions.every((permission) => patch.permissions!.includes(permission)) if (!callerPermissions.includes('manage:groups')) { if (!unchanged) { throw new CustomError( 'groupUpdatePermissionsForbidden', 'Only a user who holds manage:groups or manage:system can change what a group is allowed to do. Every other change to this group is allowed.', 403 ) } } else { 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) /* The permissions and the rules are recorded in full rather than as "changed", because they ARE the answer to who may do what — and reconstructing the set somebody was granted at a point in time is the question an audit log gets asked about a group. */ await audit(req, 'admin', 'updateGroup', { groupId: group.id, name: patch.name ?? group.name, changedFields: Object.keys(patch), ...(patch.permissions !== undefined ? { permissions: patch.permissions } : {}), ...(patch.rules !== undefined ? { rules: patch.rules } : {}) }) 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 = elevatedGroupGuard(req, group, 'delete the group') if (systemGroupRefusal) { throw systemGroupRefusal } try { await WIKI.models.groups.deleteGroup(group.id) await audit(req, 'admin', 'deleteGroup', { groupId: group.id, name: group.name }) 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', 'write: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 = elevatedGroupGuard(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.') } await audit(req, 'admin', 'assignUserToGroup', { groupId: group.id, groupName: group.name, targetUserId: user.id, targetName: user.name, targetEmail: user.email }) 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 = elevatedGroupGuard(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) await audit(req, 'admin', 'unassignUserFromGroup', { groupId: group.id, groupName: group.name, targetUserId: req.params.userId, targetName: user?.name ?? null, targetEmail: user?.email ?? null }) return reply.code(204).send() } ) } export default routes