You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/backend/api/groups.ts

758 lines
24 KiB

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