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.
425 lines
13 KiB
425 lines
13 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 { resolvePageRule, type RulePageRef } from '../helpers/pageRules.ts'
|
|
import type { SystemIds } from './types.ts'
|
|
import type { FastifyRequest } from 'fastify'
|
|
|
|
/** 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[]
|
|
}
|
|
|
|
/**
|
|
* 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({ ...patch, updatedAt: sql`now()` })
|
|
.where(eq(groupsTable.id, id))
|
|
await this.reloadCache()
|
|
return (result.rowCount ?? 0) > 0
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
async assignUserToGroup(groupId: string, userId: string): Promise<boolean> {
|
|
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> {
|
|
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
|
|
}
|
|
}
|
|
|
|
export const groups = new Groups()
|