mirror of https://github.com/requarks/wiki
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.
568 lines
19 KiB
568 lines
19 KiB
import { v4 as uuid } from 'uuid'
|
|
import { and, count, eq, ilike, or, sql } from 'drizzle-orm'
|
|
import { groups as groupsTable, userGroups, users as usersTable } from '../db/schema.ts'
|
|
import { CustomError } from '../helpers/common.ts'
|
|
import { resolvePageRule, type RulePageRef } from '../helpers/pageRules.ts'
|
|
import type { SystemIds } from './types.ts'
|
|
import type { FastifyRequest } from 'fastify'
|
|
|
|
/** The permission that bypasses every check, and the one the guards below exist to protect. */
|
|
export const SYSTEM_PERMISSION = 'manage:system'
|
|
|
|
/** How a rule's `path` is compared against the page path. */
|
|
export type GroupRuleMatch = 'START' | 'END' | 'REGEX' | 'TAG' | 'TAGALL' | 'EXACT'
|
|
|
|
/** Whether a matching rule grants, denies, or unconditionally grants its roles. */
|
|
export type GroupRuleMode = 'ALLOW' | 'DENY' | 'FORCEALLOW'
|
|
|
|
/** A single page-rule entry within a group. */
|
|
export interface GroupRule {
|
|
id: string
|
|
name: string
|
|
roles: string[]
|
|
match: GroupRuleMatch
|
|
mode: GroupRuleMode
|
|
path: string
|
|
locales: string[]
|
|
sites: string[]
|
|
}
|
|
|
|
/** A group row, joined with the number of users assigned to it. */
|
|
export interface GroupWithUserCount {
|
|
id: string
|
|
name: string
|
|
permissions: string[]
|
|
rules: GroupRule[]
|
|
redirectOnLogin: string
|
|
redirectOnFirstLogin: string
|
|
redirectOnLogout: string
|
|
isSystem: boolean
|
|
userCount: number
|
|
createdAt: Date
|
|
updatedAt: Date
|
|
}
|
|
|
|
/** The subset of group fields that may be modified. `isSystem` is deliberately absent. */
|
|
export interface GroupPatch {
|
|
name?: string
|
|
redirectOnLogin?: string
|
|
redirectOnFirstLogin?: string
|
|
redirectOnLogout?: string
|
|
permissions?: string[]
|
|
rules?: GroupRule[]
|
|
}
|
|
|
|
/**
|
|
* Selection shared by getAllGroups() / getGroupById().
|
|
*
|
|
* `userCount` comes from a left join on `userGroups` aggregated per group, so groups with no members
|
|
* count 0 rather than dropping out of the result.
|
|
*/
|
|
/** A member of a group, mirroring the `UserCore` API schema. */
|
|
export interface GroupUser {
|
|
id: string
|
|
name: string
|
|
email: string
|
|
hasAvatar: boolean
|
|
isSystem: boolean
|
|
isActive: boolean
|
|
isVerified: boolean
|
|
createdAt: Date
|
|
updatedAt: Date
|
|
lastLoginAt: Date | null
|
|
}
|
|
|
|
export interface GroupUserPage {
|
|
total: number
|
|
users: GroupUser[]
|
|
}
|
|
|
|
/**
|
|
* Escape the LIKE wildcards `%` and `_` (and the escape character itself) so that a user-supplied
|
|
* filter is matched literally. Values are still parameterized by the driver — this is about a `%`
|
|
* in the filter silently matching everything, not about injection.
|
|
*/
|
|
function escapeLikePattern(value: string): string {
|
|
return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')
|
|
}
|
|
|
|
const groupSelection = {
|
|
id: groupsTable.id,
|
|
name: groupsTable.name,
|
|
permissions: groupsTable.permissions,
|
|
rules: groupsTable.rules,
|
|
redirectOnLogin: groupsTable.redirectOnLogin,
|
|
redirectOnFirstLogin: groupsTable.redirectOnFirstLogin,
|
|
redirectOnLogout: groupsTable.redirectOnLogout,
|
|
isSystem: groupsTable.isSystem,
|
|
createdAt: groupsTable.createdAt,
|
|
updatedAt: groupsTable.updatedAt,
|
|
userCount: count(userGroups.userId)
|
|
}
|
|
|
|
/**
|
|
* Who is asking, and what they hold outside the page rules.
|
|
*
|
|
* `permissions` is the group-wide list — `manage:system`, `access:admin` and the rest — which is a
|
|
* different thing from the page permissions the rules decide.
|
|
*/
|
|
export interface AccessActor {
|
|
groupIds: string[]
|
|
permissions: string[]
|
|
}
|
|
|
|
/**
|
|
* The page permissions a rule on the GUESTS group may grant.
|
|
*
|
|
* Reading, and saying something in a comment. Everything else — writing or deleting a page, managing
|
|
* assets or comments, reviewing suggestions — is an action attributable to somebody, and the guests
|
|
* group is precisely the absence of a somebody.
|
|
*
|
|
* Mirrored in `GroupEditOverlay.vue`, which offers exactly these when the guests group is open. This
|
|
* copy is the one that decides.
|
|
*/
|
|
export const GUEST_ROLES = [
|
|
'read:pages',
|
|
'read:source',
|
|
'read:history',
|
|
'read:assets',
|
|
'read:comments',
|
|
'write:comments'
|
|
]
|
|
|
|
/**
|
|
* Every group's rules, by group id.
|
|
*
|
|
* Cached because a page permission is checked on every page read, and reading three rows out of the
|
|
* database to answer it would put a query in front of every request. Reloaded whenever a group
|
|
* changes, the same way the site configurations are.
|
|
*/
|
|
let rulesCache: Record<string, GroupRule[]> = {}
|
|
|
|
/**
|
|
* Groups model
|
|
*/
|
|
class Groups {
|
|
/**
|
|
* Reload the page rules of every group into memory.
|
|
*
|
|
* Called at boot and after any change to a group. A group edit therefore takes effect on the next
|
|
* request rather than on the next login, which matters: rules are the whole of page access, and a
|
|
* revoked permission that waits for a logout is not revoked.
|
|
*/
|
|
async reloadCache(): Promise<void> {
|
|
const rows = await WIKI.db
|
|
.select({ id: groupsTable.id, rules: groupsTable.rules })
|
|
.from(groupsTable)
|
|
rulesCache = {}
|
|
for (const row of rows) {
|
|
rulesCache[row.id] = (row.rules ?? []) as GroupRule[]
|
|
}
|
|
WIKI.logger.info(`Loaded page rules for ${rows.length} groups [ OK ]`)
|
|
}
|
|
|
|
/** The pooled rules of a set of groups, which is what a permission is decided against. */
|
|
rulesForGroups(groupIds: string[]): GroupRule[] {
|
|
return groupIds.flatMap((id) => rulesCache[id] ?? [])
|
|
}
|
|
|
|
/**
|
|
* Which groups a request speaks for.
|
|
*
|
|
* An anonymous request is not group-less: it is the guests group, whose rules are how a wiki says
|
|
* what the public may see. Treating it as no groups at all would deny everything, which is a
|
|
* different answer from the one the administrator configured.
|
|
*/
|
|
groupIdsForRequest(req: FastifyRequest): string[] {
|
|
if (req.session?.authenticated && req.session.user?.id) {
|
|
return req.session.groups ?? []
|
|
}
|
|
return [WIKI.data.systemIds.guestsGroupId]
|
|
}
|
|
|
|
/** The actor a request speaks for: its groups, and the group-wide permissions it holds. */
|
|
actorForRequest(req: FastifyRequest): AccessActor {
|
|
return {
|
|
groupIds: this.groupIdsForRequest(req),
|
|
// -> An API key stands in for a session and carries its own permissions, as it does in the
|
|
// route-level check
|
|
permissions: req.apiKey?.permissions ?? req.session?.permissions ?? []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether this caller may do this to this page.
|
|
*
|
|
* The one place page permissions are decided. Everything page-scoped asks this rather than reading
|
|
* the session's permission list, because that list says what a group was granted GLOBALLY and page
|
|
* permissions are not granted that way — see `helpers/pageRules.ts` for how a rule is chosen.
|
|
*
|
|
* @param permission A single page permission, e.g. `read:pages` or `read:history`
|
|
*/
|
|
checkAccess(actor: AccessActor, permission: string, page: RulePageRef): boolean {
|
|
// -> Above the rules entirely: an administrator is not something a rule can lock out, and a
|
|
// wiki whose only administrator had denied themselves would have nobody left to fix it
|
|
if (actor.permissions.includes('manage:system')) {
|
|
return true
|
|
}
|
|
const rule = resolvePageRule(this.rulesForGroups(actor.groupIds), permission, page)
|
|
return rule ? rule.mode !== 'DENY' : false
|
|
}
|
|
async init(ids: SystemIds): Promise<void> {
|
|
WIKI.logger.info('Inserting default groups...')
|
|
|
|
await WIKI.db.insert(groupsTable).values([
|
|
{
|
|
id: ids.groupAdminId,
|
|
name: 'Administrators',
|
|
permissions: ['manage:system'],
|
|
rules: [],
|
|
isSystem: true
|
|
},
|
|
{
|
|
id: ids.groupUserId,
|
|
name: 'Users',
|
|
permissions: ['read:pages', 'read:assets', 'read:comments'],
|
|
rules: [
|
|
{
|
|
id: uuid(),
|
|
name: 'Default Rule',
|
|
roles: ['read:pages', 'read:assets', 'read:comments'],
|
|
match: 'START',
|
|
mode: 'ALLOW',
|
|
path: '',
|
|
locales: [],
|
|
sites: []
|
|
}
|
|
],
|
|
isSystem: true
|
|
},
|
|
{
|
|
id: ids.groupGuestId,
|
|
name: 'Guests',
|
|
permissions: ['read:pages', 'read:assets', 'read:comments'],
|
|
rules: [
|
|
{
|
|
id: uuid(),
|
|
name: 'Default Rule',
|
|
roles: ['read:pages', 'read:assets', 'read:comments'],
|
|
match: 'START',
|
|
mode: 'DENY',
|
|
path: '',
|
|
locales: [],
|
|
sites: []
|
|
}
|
|
],
|
|
isSystem: true
|
|
}
|
|
])
|
|
}
|
|
|
|
/**
|
|
* Create a new (non-system) group, seeded with the same starting permissions and default rule as
|
|
* the `Users` group.
|
|
*
|
|
* @param name Group name
|
|
* @returns The new group's ID
|
|
*/
|
|
async createGroup(name: string): Promise<string> {
|
|
const startingPermissions = ['read:pages', 'read:assets', 'read:comments']
|
|
const result = await WIKI.db
|
|
.insert(groupsTable)
|
|
.values({
|
|
name,
|
|
permissions: startingPermissions,
|
|
rules: [
|
|
{
|
|
id: uuid(),
|
|
name: 'Default Rule',
|
|
roles: startingPermissions,
|
|
match: 'START',
|
|
mode: 'ALLOW',
|
|
path: '',
|
|
locales: [],
|
|
sites: []
|
|
}
|
|
],
|
|
isSystem: false
|
|
})
|
|
.returning({ id: groupsTable.id })
|
|
await this.reloadCache()
|
|
return result[0].id
|
|
}
|
|
|
|
/**
|
|
* Fetch all groups, ordered by name
|
|
*/
|
|
async getAllGroups(): Promise<GroupWithUserCount[]> {
|
|
const results = await WIKI.db
|
|
.select(groupSelection)
|
|
.from(groupsTable)
|
|
.leftJoin(userGroups, eq(userGroups.groupId, groupsTable.id))
|
|
.groupBy(groupsTable.id)
|
|
.orderBy(groupsTable.name)
|
|
return results as GroupWithUserCount[]
|
|
}
|
|
|
|
/**
|
|
* Fetch a single group by ID
|
|
*
|
|
* @param id Group ID
|
|
* @returns The group, or null if no such group exists
|
|
*/
|
|
async getGroupById(id: string): Promise<GroupWithUserCount | null> {
|
|
const results = await WIKI.db
|
|
.select(groupSelection)
|
|
.from(groupsTable)
|
|
.leftJoin(userGroups, eq(userGroups.groupId, groupsTable.id))
|
|
.where(eq(groupsTable.id, id))
|
|
.groupBy(groupsTable.id)
|
|
.limit(1)
|
|
return (results[0] as GroupWithUserCount) ?? null
|
|
}
|
|
|
|
/**
|
|
* Update a group
|
|
*
|
|
* @param id Group ID
|
|
* @param patch Fields to change — must not be empty
|
|
* @returns Whether a group was updated
|
|
*/
|
|
async updateGroup(id: string, patch: GroupPatch): Promise<boolean> {
|
|
const result = await WIKI.db
|
|
.update(groupsTable)
|
|
.set({ ...this.clampGuestPatch(id, patch), updatedAt: sql`now()` })
|
|
.where(eq(groupsTable.id, id))
|
|
await this.reloadCache()
|
|
return (result.rowCount ?? 0) > 0
|
|
}
|
|
|
|
/**
|
|
* Hold the guests group to what the public may be given.
|
|
*
|
|
* The guests group is every anonymous reader at once, so a rule on it is a rule about the open
|
|
* internet: writing a page, deleting one, reading its source history — none of those are things to
|
|
* hand out to nobody in particular, and several of them cannot be undone. So the set is fixed here,
|
|
* beside the rules themselves, rather than only in the admin screen that edits them: what a group
|
|
* may hold is not something a browser should be the only one deciding.
|
|
*
|
|
* Roles outside the set are dropped rather than refused. An administrator saving a group edited
|
|
* before this existed — or through the API — gets the group they asked for minus what may not be
|
|
* granted, instead of a form that cannot be saved and does not say which rule is at fault.
|
|
*/
|
|
private clampGuestPatch(id: string, patch: GroupPatch): GroupPatch {
|
|
if (id !== WIKI.data.systemIds.guestsGroupId || !patch.rules) {
|
|
return patch
|
|
}
|
|
let dropped = 0
|
|
const rules = patch.rules.map((rule) => {
|
|
const roles = (rule.roles ?? []).filter((role) => GUEST_ROLES.includes(role))
|
|
dropped += (rule.roles ?? []).length - roles.length
|
|
return { ...rule, roles }
|
|
})
|
|
if (dropped > 0) {
|
|
WIKI.logger.warn(
|
|
`Dropped ${dropped} permission(s) from the guests group that may not be granted to it.`
|
|
)
|
|
}
|
|
return { ...patch, rules }
|
|
}
|
|
|
|
/**
|
|
* Delete a group. Assignments in `userGroups` are removed by the FK cascade.
|
|
*
|
|
* @param id Group ID
|
|
* @returns Whether a group was deleted
|
|
*/
|
|
async deleteGroup(id: string): Promise<boolean> {
|
|
const result = await WIKI.db.delete(groupsTable).where(eq(groupsTable.id, id))
|
|
await this.reloadCache()
|
|
return (result.rowCount ?? 0) > 0
|
|
}
|
|
|
|
/**
|
|
* Assign a user to a group. Idempotent.
|
|
*
|
|
* @returns False if the user was already a member
|
|
*/
|
|
/**
|
|
* Why this user may not be a member of this group, if they may not.
|
|
*
|
|
* The guests group and the guest account belong to each other and to nothing else:
|
|
*
|
|
* - the group IS anonymous access, so a real user in it would be granted whatever the public is
|
|
* granted regardless of their own groups, and would keep it after every other group was taken
|
|
* away from them;
|
|
* - the account IS the anonymous visitor, so putting it in another group hands that group's
|
|
* permissions to everybody who never logged in.
|
|
*
|
|
* The pair is also why neither half can be taken apart: removing the account from the group would
|
|
* leave anonymous access resolving against nothing, with no way back through the interface.
|
|
*
|
|
* One definition, used by the routes that assign a single membership and by `setUserGroups`, which
|
|
* sets them all at once.
|
|
*
|
|
* @returns The reason, or null when the membership is fine
|
|
*/
|
|
guestMembershipViolation(groupId: string, user: { isSystem?: boolean } | null): string | null {
|
|
const isGuestsGroup = groupId === WIKI.data.systemIds.guestsGroupId
|
|
// -> The guest account is the only system user; see the seeding in `models/users.ts`
|
|
if (user?.isSystem) {
|
|
return isGuestsGroup
|
|
? null
|
|
: 'The guest account cannot be a member of any group other than the guests group.'
|
|
}
|
|
return isGuestsGroup
|
|
? 'The guests group holds the guest account and nothing else — it is what anonymous visitors are.'
|
|
: null
|
|
}
|
|
|
|
async assignUserToGroup(groupId: string, userId: string): Promise<boolean> {
|
|
const user = await WIKI.models.users.getById(userId)
|
|
const violation = this.guestMembershipViolation(groupId, user)
|
|
if (violation) {
|
|
throw new CustomError('groupMembershipForbidden', violation)
|
|
}
|
|
const result = await WIKI.db
|
|
.insert(userGroups)
|
|
.values({ userId, groupId })
|
|
.onConflictDoNothing()
|
|
return (result.rowCount ?? 0) > 0
|
|
}
|
|
|
|
/**
|
|
* Remove a user from a group
|
|
*
|
|
* @returns False if the user was not a member
|
|
*/
|
|
async unassignUserFromGroup(groupId: string, userId: string): Promise<boolean> {
|
|
/*
|
|
The one membership that cannot be taken apart: anonymous access resolves against the guests
|
|
group's rules, and the guest account is what resolves it. Removed, every anonymous visitor would
|
|
hold nothing at all — and nothing in the interface puts a system user back into a group.
|
|
*/
|
|
if (groupId === WIKI.data.systemIds.guestsGroupId) {
|
|
const user = await WIKI.models.users.getById(userId)
|
|
if (user?.isSystem) {
|
|
throw new CustomError(
|
|
'groupMembershipForbidden',
|
|
'The guest account cannot be removed from the guests group.'
|
|
)
|
|
}
|
|
}
|
|
const result = await WIKI.db
|
|
.delete(userGroups)
|
|
.where(and(eq(userGroups.groupId, groupId), eq(userGroups.userId, userId)))
|
|
return (result.rowCount ?? 0) > 0
|
|
}
|
|
|
|
/**
|
|
* Fetch a page of the users assigned to a group, ordered by name.
|
|
*
|
|
* @param groupId Group ID
|
|
* @param filter Optional case-insensitive substring matched against name and email
|
|
* @param page 1-based page number
|
|
* @param limit Page size
|
|
*/
|
|
async getGroupUsers(
|
|
groupId: string,
|
|
{ filter = '', page = 1, limit = 20 }: { filter?: string; page?: number; limit?: number } = {}
|
|
): Promise<GroupUserPage> {
|
|
const conditions = [eq(userGroups.groupId, groupId)]
|
|
if (filter) {
|
|
const pattern = `%${escapeLikePattern(filter)}%`
|
|
conditions.push(or(ilike(usersTable.name, pattern), ilike(usersTable.email, pattern))!)
|
|
}
|
|
const where = and(...conditions)
|
|
|
|
const totals = await WIKI.db
|
|
.select({ total: count() })
|
|
.from(userGroups)
|
|
.innerJoin(usersTable, eq(usersTable.id, userGroups.userId))
|
|
.where(where)
|
|
|
|
const users = await WIKI.db
|
|
.select({
|
|
id: usersTable.id,
|
|
name: usersTable.name,
|
|
email: usersTable.email,
|
|
hasAvatar: usersTable.hasAvatar,
|
|
isSystem: usersTable.isSystem,
|
|
isActive: usersTable.isActive,
|
|
isVerified: usersTable.isVerified,
|
|
createdAt: usersTable.createdAt,
|
|
updatedAt: usersTable.updatedAt,
|
|
lastLoginAt: usersTable.lastLoginAt
|
|
})
|
|
.from(userGroups)
|
|
.innerJoin(usersTable, eq(usersTable.id, userGroups.userId))
|
|
.where(where)
|
|
.orderBy(usersTable.name)
|
|
.limit(limit)
|
|
.offset((page - 1) * limit)
|
|
|
|
return {
|
|
total: totals[0]?.total ?? 0,
|
|
users
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Count the users assigned to a group
|
|
*/
|
|
async countUsersInGroup(groupId: string): Promise<number> {
|
|
return WIKI.db.$count(userGroups, eq(userGroups.groupId, groupId))
|
|
}
|
|
|
|
/**
|
|
* Whether a user is currently assigned to a group
|
|
*/
|
|
async isUserInGroup(groupId: string, userId: string): Promise<boolean> {
|
|
const total = await WIKI.db.$count(
|
|
userGroups,
|
|
and(eq(userGroups.groupId, groupId), eq(userGroups.userId, userId))
|
|
)
|
|
return total > 0
|
|
}
|
|
|
|
/**
|
|
* Whether the caller itself holds `manage:system`.
|
|
*
|
|
* `manage:system` is the permission that bypasses every route check, so a `manage:users` /
|
|
* `manage:groups` holder who could hand it out — or take it away, or edit the account of somebody
|
|
* who has it — would hold it in all but name. The guards built on this answer say so in their own
|
|
* words rather than as a bare 403, because "you may manage users, but not THIS user" is not
|
|
* something the caller can work out from a generic refusal.
|
|
*/
|
|
holdsSystemPermission(req: FastifyRequest): boolean {
|
|
return this.actorForRequest(req).permissions.includes(SYSTEM_PERMISSION)
|
|
}
|
|
|
|
/** The ids of every group carrying `manage:system`. */
|
|
async systemGroupIds(): Promise<string[]> {
|
|
const rows = await WIKI.db
|
|
.select({ id: groupsTable.id, permissions: groupsTable.permissions })
|
|
.from(groupsTable)
|
|
return rows
|
|
.filter((row) => ((row.permissions ?? []) as string[]).includes(SYSTEM_PERMISSION))
|
|
.map((row) => row.id)
|
|
}
|
|
|
|
/**
|
|
* Whether a user is protected by `manage:system` — i.e. belongs to any group carrying it.
|
|
*
|
|
* Membership rather than the session's own list, because the question is asked ABOUT somebody who
|
|
* is not the caller and may not be logged in at all.
|
|
*/
|
|
async userHoldsSystemPermission(userId: string): Promise<boolean> {
|
|
const rows = await WIKI.db
|
|
.select({ permissions: groupsTable.permissions })
|
|
.from(userGroups)
|
|
.innerJoin(groupsTable, eq(groupsTable.id, userGroups.groupId))
|
|
.where(eq(userGroups.userId, userId))
|
|
return rows.some((row) => ((row.permissions ?? []) as string[]).includes(SYSTEM_PERMISSION))
|
|
}
|
|
}
|
|
|
|
export const groups = new Groups()
|