feat: add SCIM provisioning support

pull/8104/head
NGPixel 1 week ago
parent b87c236d4a
commit b7dc7b8357
No known key found for this signature in database

@ -1,60 +1,9 @@
import { audit } from '../helpers/audit.ts' import { audit } from '../helpers/audit.ts'
import { CustomError } from '../helpers/common.ts' import { CustomError } from '../helpers/common.ts'
import { ELEVATED_PERMISSIONS, SYSTEM_PERMISSION, isElevated } from '../models/groups.ts' import { elevatedGroupGuard } from '../helpers/userGuards.ts'
import type { FastifyInstance, FastifyRequest } from 'fastify' import { SYSTEM_PERMISSION, isElevated } from '../models/groups.ts'
import type { GroupPatch, GroupRule, GroupWithUserCount } from '../models/groups.ts' import type { FastifyInstance } from 'fastify'
import type { GroupPatch, GroupRule } 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 { interface GroupUpdateBody {
name?: string name?: string

@ -23,6 +23,7 @@ async function routes(app: FastifyInstance) {
await import('./schemas/metrics.ts').then((m) => m.registerSchemas(app)) await import('./schemas/metrics.ts').then((m) => m.registerSchemas(app))
await import('./schemas/page.ts').then((m) => m.registerSchemas(app)) await import('./schemas/page.ts').then((m) => m.registerSchemas(app))
await import('./schemas/scheduler.ts').then((m) => m.registerSchemas(app)) await import('./schemas/scheduler.ts').then((m) => m.registerSchemas(app))
await import('./schemas/scim.ts').then((m) => m.registerSchemas(app))
await import('./schemas/security.ts').then((m) => m.registerSchemas(app)) await import('./schemas/security.ts').then((m) => m.registerSchemas(app))
await import('./schemas/site.ts').then((m) => m.registerSchemas(app)) await import('./schemas/site.ts').then((m) => m.registerSchemas(app))
await import('./schemas/storage.ts').then((m) => m.registerSchemas(app)) await import('./schemas/storage.ts').then((m) => m.registerSchemas(app))

@ -92,10 +92,15 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'number', type: 'number',
description: 'Number of users assigned to this group.' description: 'Number of users assigned to this group.'
}, },
isProvisioned: {
type: 'boolean',
description:
'Whether a SCIM client owns this group. See the same field on `UserCore`; it gates deletion through the provisioning endpoint in the same way.'
},
isElevated: { isElevated: {
type: 'boolean', type: 'boolean',
description: description:
'Whether this group carries a permission that administers the wiki (`write:users`, `manage:users`, `write:groups`, `manage:groups`, `manage:system`). Membership of such a group is itself a privilege, so only `manage:system` may move a user in or out of one. A flag rather than the permissions themselves, so that a caller who may not read a group can still be told which ones are out of bounds.' 'Whether this group carries a permission that administers the wiki (`write:users`, `manage:users`, `write:groups`, `manage:groups`, `manage:scim`, `manage:system`). Membership of such a group is itself a privilege, so only `manage:system` may move a user in or out of one. A flag rather than the permissions themselves, so that a caller who may not read a group can still be told which ones are out of bounds.'
}, },
createdAt: { createdAt: {
type: 'string', type: 'string',

@ -0,0 +1,94 @@
import { SCIM_DELETE_ACTIONS, SCIM_EMAIL_SOURCES } from '../../models/scim.ts'
import type { FastifyInstance } from 'fastify'
export async function registerSchemas(app: FastifyInstance): Promise<void> {
/**
* SCIM CONFIG - Used both ways: as the response, and as a partial update body
*/
app.addSchema({
$id: 'ScimConfig',
type: 'object',
properties: {
isEnabled: {
type: 'boolean',
description:
'Whether the SCIM 2.0 endpoint is served at `/_scim/v2`. Off, every path under it answers 404 whatever credential is presented.'
},
deleteAction: {
type: 'string',
enum: [...SCIM_DELETE_ACTIONS],
description:
"`deactivate` (the default) answers `DELETE /Users/:id` by clearing the account's sessions and group memberships while keeping the row, so authorship on pages and history survives. `delete` removes the row outright."
},
emailSource: {
type: 'string',
enum: [...SCIM_EMAIL_SOURCES],
description:
"Where a provisioned account's email address is read from. `userName` is what every connector sends and is right wherever the login name is the mailbox; `emails` reads the primary entry of `emails[]` instead."
},
allowGroupCreate: {
type: 'boolean',
description:
'Whether `POST /Groups` may create a wiki group. A group created this way holds the same starting permissions as one created in the admin area and grants nothing beyond them. Off, a directory may only manage the membership of groups that already exist here.'
},
rateLimitEnabled: {
type: 'boolean',
description:
'Whether requests to `/_scim/v2` are rate limited per client address. Counted against the same postgres-backed counter the login limit uses, so instances behind a load balancer share one budget.'
},
rateLimitMax: {
type: 'integer',
minimum: 1,
description:
"Requests one address may make within the window. Set well above what a sync costs: a directory's first run is every user it has, back to back."
},
rateLimitWindow: {
type: 'string',
maxLength: 16,
description: 'Length of the window, as a number and a unit — `30s`, `1m`, `1h`.'
},
rateLimitBan: {
type: 'string',
maxLength: 16,
description:
'How long an address is refused once it goes over, in the same notation. Short by default, so a connector that trips the limit recovers on its next cycle instead of leaving provisioning broken.'
},
ipAllowList: {
type: 'array',
items: { type: 'string', maxLength: 64 },
description:
'Addresses allowed to reach `/_scim/v2`, as single addresses or CIDR subnets (`203.0.113.4`, `203.0.113.0/24`, `2001:db8::/32`). EMPTY means no restriction, leaving the bearer token as the only thing in front of the endpoint. What an address means depends on `security.trustProxy`.'
}
}
})
/**
* SCIM STATUS - What the admin screen shows beside the settings
*/
app.addSchema({
$id: 'ScimStatus',
type: 'object',
properties: {
users: {
type: 'integer',
description: 'How many user accounts a directory currently owns.'
},
groups: {
type: 'integer',
description: 'How many groups a directory currently owns.'
},
lastRequest: {
type: ['object', 'null'],
description:
'The last SCIM request THIS instance answered. Held in memory, so it is empty after a restart and, in a high-availability set, says nothing about what the other instances have served.',
properties: {
at: { type: 'string' },
method: { type: 'string' },
path: { type: 'string' },
status: { type: 'integer' },
message: { type: ['string', 'null'] }
}
}
}
})
}

@ -61,6 +61,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
isVerified: { isVerified: {
type: 'boolean' type: 'boolean'
}, },
isProvisioned: {
type: 'boolean',
description:
'Whether a SCIM client owns this account. Set by the first provisioning write, so an account created here is adopted by a directory that later claims it — and it is what gates deprovisioning: `DELETE /_scim/v2/Users/:id` answers 404 for an account no directory owns.'
},
createdAt: { createdAt: {
type: 'string', type: 'string',
format: 'date-time', format: 'date-time',

@ -143,6 +143,11 @@ async function routes(app: FastifyInstance) {
type: 'boolean', type: 'boolean',
description: 'Whether the Prometheus metrics endpoint is turned on.' description: 'Whether the Prometheus metrics endpoint is turned on.'
}, },
isScimEnabled: {
type: 'boolean',
description:
'Whether the SCIM provisioning endpoint is turned on. Note that it cannot authenticate anybody unless `isApiEnabled` is also true, since a connector arrives holding an API key.'
},
isSchedulerHealthy: { isSchedulerHealthy: {
type: 'boolean', type: 'boolean',
description: description:
@ -208,6 +213,7 @@ async function routes(app: FastifyInstance) {
isApiEnabled: WIKI.config.api.isEnabled === true, isApiEnabled: WIKI.config.api.isEnabled === true,
isMailConfigured: WIKI.config?.mail?.host?.length > 2, isMailConfigured: WIKI.config?.mail?.host?.length > 2,
isMetricsEnabled: WIKI.config.metrics.isEnabled === true, isMetricsEnabled: WIKI.config.metrics.isEnabled === true,
isScimEnabled: WIKI.models.scim.isEnabled(),
isSchedulerHealthy: await WIKI.models.jobs.isHealthy(), isSchedulerHealthy: await WIKI.models.jobs.isHealthy(),
latestVersion: WIKI.config.update.version, latestVersion: WIKI.config.update.version,
latestVersionReleaseDate: WIKI.config.update.versionDate, latestVersionReleaseDate: WIKI.config.update.versionDate,
@ -863,6 +869,115 @@ async function routes(app: FastifyInstance) {
} }
) )
/**
* GET SCIM CONFIGURATION
*/
app.get(
'/scim',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Get the SCIM provisioning configuration',
description:
'Whether the SCIM 2.0 endpoint at `/_scim/v2` is turned on, and how it behaves. Instance-wide: users and groups are not per site, so neither is provisioning.',
tags: ['System'],
response: {
200: { $ref: 'ScimConfig#' }
}
}
},
async () => {
return WIKI.models.scim.getConfig()
}
)
/**
* UPDATE SCIM CONFIGURATION
*/
app.put<{ Body: Record<string, any> }>(
'/scim',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Update the SCIM provisioning configuration',
description:
'Accepts any subset of the fields, and applies at once on every instance — nothing here is read at boot. Turning the endpoint on does not by itself let anything in: a connector also needs an API key belonging to a group that holds `manage:scim`.',
tags: ['System'],
body: { $ref: 'ScimConfig#' },
response: {
200: {
description: 'SCIM configuration updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const patch = WIKI.models.scim.pickFields(req.body)
if (Object.keys(patch).length < 1) {
return reply.badRequest('No valid SCIM setting was provided.')
}
const invalid = WIKI.models.scim.validate(patch)
if (invalid) {
return reply.badRequest(invalid)
}
if (!(await WIKI.models.scim.updateConfig(patch))) {
return reply.internalServerError('Failed to save the SCIM configuration.')
}
// -> Fields rather than values, with `isEnabled` spelled out for the same reason the metrics
// route spells it out: whether a directory may write to the user list is the part that gets
// asked about afterwards.
await audit(req, 'admin', 'updateScimState', {
fields: Object.keys(patch).sort(),
...(patch.isEnabled === undefined ? {} : { isEnabled: patch.isEnabled })
})
return {
ok: true,
message: 'SCIM configuration saved successfully.'
}
}
)
/**
* GET SCIM STATUS
*/
app.get(
'/scim/status',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Get the SCIM provisioning status',
description:
'How many users and groups a directory currently owns, and the last SCIM request this instance answered.',
tags: ['System'],
response: {
200: { $ref: 'ScimStatus#' }
}
}
},
async () => {
return WIKI.models.scim.getStats()
}
)
/** /**
* PREVIEW THE METRICS EXPOSITION * PREVIEW THE METRICS EXPOSITION
*/ */

@ -1,6 +1,7 @@
import { audit } from '../helpers/audit.ts' import { audit } from '../helpers/audit.ts'
import { CustomError, rethrowAsBadRequest } from '../helpers/common.ts' import { CustomError, rethrowAsBadRequest } from '../helpers/common.ts'
import { detectImageMime, imageMimeTypes } from '../helpers/images.ts' import { detectImageMime, imageMimeTypes } from '../helpers/images.ts'
import { elevatedMembershipGuard, systemUserGuard } from '../helpers/userGuards.ts'
import type { FastifyInstance, FastifyRequest } from 'fastify' import type { FastifyInstance, FastifyRequest } from 'fastify'
import type { UserPatch, UserProfilePatch } from '../models/users.ts' import type { UserPatch, UserProfilePatch } from '../models/users.ts'
@ -52,29 +53,6 @@ export function whoAmI(req: FastifyRequest): Record<string, any> {
} }
} }
/**
* Refuse a `manage:users` holder any change to a user who is protected by `manage:system`.
*
* `manage:users` is deliberately short of the root: an administrator who can rename, re-group, reset
* the password of, or delete a `manage:system` account can take the instance over through it. Only
* somebody who already holds `manage:system` may touch one.
*
* @returns The refusal to throw, or null when the caller may proceed
*/
async function systemUserGuard(req: FastifyRequest, userId: string): Promise<CustomError | null> {
if (WIKI.models.groups.holdsSystemPermission(req)) {
return null
}
if (!(await WIKI.models.groups.userHoldsSystemPermission(userId))) {
return null
}
return new CustomError(
'userSystemProtected',
'This user belongs to a group with the manage:system permission. Only a user who holds manage:system can modify them.',
403
)
}
/** /**
* The profile fields an identity provider owns: who the person is, as the wiki displays them. * The profile fields an identity provider owns: who the person is, as the wiki displays them.
* `allowProfileEditing` is what says whether they are the user's to change here. * `allowProfileEditing` is what says whether they are the user's to change here.
@ -1506,16 +1484,9 @@ async function routes(app: FastifyInstance) {
instead of editing the first. Asked of `write:users` and `manage:users` alike: neither is instead of editing the first. Asked of `write:users` and `manage:users` alike: neither is
trusted to decide who administers the instance, which is `manage:system`'s to give. trusted to decide who administers the instance, which is `manage:system`'s to give.
*/ */
const requestedGroups = req.body.groups ?? [] const elevatedRefusal = await elevatedMembershipGuard(req, [], req.body.groups ?? [])
if (requestedGroups.length > 0 && !WIKI.models.groups.holdsSystemPermission(req)) { if (elevatedRefusal) {
const elevated = await WIKI.models.groups.elevatedGroupIds() throw elevatedRefusal
if (requestedGroups.some((id) => elevated.includes(id))) {
throw new CustomError(
'groupMembershipElevatedProtected',
'Only a user who holds manage:system can create a user inside a group that administers the wiki.',
403
)
}
} }
try { try {
@ -1734,21 +1705,13 @@ async function routes(app: FastifyInstance) {
Groups this request leaves alone are not consulted, so a save that only renames the user Groups this request leaves alone are not consulted, so a save that only renames the user
still goes through whatever they belong to. still goes through whatever they belong to.
*/ */
if (!WIKI.models.groups.holdsSystemPermission(req)) { const elevatedRefusal = await elevatedMembershipGuard(
const current = await WIKI.models.users.getUserGroupIds(req.params.userId) req,
const requested = req.body.groups await WIKI.models.users.getUserGroupIds(req.params.userId),
const elevated = await WIKI.models.groups.elevatedGroupIds() req.body.groups
const moved = [
...requested.filter((id) => !current.includes(id)),
...current.filter((id) => !requested.includes(id))
]
if (moved.some((id) => elevated.includes(id))) {
throw new CustomError(
'groupMembershipElevatedProtected',
'Only a user who holds manage:system can add a user to, or remove one from, a group that administers the wiki.',
403
) )
} if (elevatedRefusal) {
throw elevatedRefusal
} }
const rootAdminGroupId = WIKI.config.auth.rootAdminGroupId const rootAdminGroupId = WIKI.config.auth.rootAdminGroupId

@ -100,6 +100,39 @@ defaults:
# about a dozen database queries. # about a dozen database queries.
includeRuntime: true includeRuntime: true
includeWiki: false includeWiki: false
scim:
# SCIM 2.0 provisioning, served at /_scim/v2. Off by default: it is a directory's write access
# to the wiki's user and group lists, and nothing about it is useful until an administrator has
# deliberately turned it on and minted a key for it.
isEnabled: false
# What DELETE /Users/:id does to an account the client owns. `deactivate` clears its sessions
# and its group memberships but keeps the row, so authorship on pages and history survives;
# `delete` removes the row. Deactivation is the default because a wiki's users are authors.
deleteAction: 'deactivate'
# Where a provisioned account's email address is read from. `userName` is the SCIM attribute
# every connector sends and is right wherever the login name is the mailbox; `emails` reads the
# primary (or first work) entry of `emails[]` instead, for a directory whose UPN is not.
emailSource: 'userName'
# Whether POST /Groups may create a wiki group. A created group holds no permissions and no
# page rules, so it grants nothing until an administrator fills it in. Off, a directory may
# only manage the membership of groups that already exist here.
allowGroupCreate: true
# Per-address rate limit on /_scim/v2, counted the same way the login limit is and sharing the
# same postgres-backed counter, so instances behind a load balancer agree about it.
#
# Far more generous than the auth limit, because the traffic is not the same shape: a first
# sync of a large directory is hundreds of requests in a minute, and that is the endpoint
# working. The ban is short for the same reason -- a connector that trips this should recover
# on its next cycle rather than leave provisioning broken for a quarter of an hour.
rateLimitEnabled: true
rateLimitMax: 600
rateLimitWindow: '1m'
rateLimitBan: '1m'
# Addresses allowed to reach /_scim/v2 at all, as single addresses or CIDR subnets. EMPTY
# means no restriction: the bearer token is then the only thing standing in front of the
# endpoint. Note that what an address means depends on `security.trustProxy` -- with it off, a
# wiki behind a reverse proxy sees the proxy for every request.
ipAllowList: []
auth: auth:
autoLogin: false autoLogin: false
enforce2FA: false enforce2FA: false

@ -0,0 +1,808 @@
import { audit } from '../helpers/audit.ts'
import { CustomError, originOf } from '../helpers/common.ts'
import { elevatedGroupGuard, systemUserGuard } from '../helpers/userGuards.ts'
import {
SCHEMA_ERROR,
SCHEMA_GROUP,
SCHEMA_USER,
SCIM_CONTENT_TYPE,
SCIM_MAX_RESULTS,
SCIM_PERMISSION,
ScimError
} from '../models/scim.ts'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
/**
* SCIM 2.0, at `/_scim/v2`.
*
* A controller rather than a route plugin under `api/`, and every one of the four reasons is
* load-bearing:
*
* - **The error body.** RFC 7644 §3.12 gives a refusal its own shape, with a `scimType` a
* connector branches on. The `/_api/` error handler in `index.ts` produces a different one, so
* this plugin sets its own inside its encapsulation context.
* - **The content type** is `application/scim+json`. Parsed here and nowhere else, so every other
* route in the wiki goes on refusing it.
* - **The 404 body** has to be a SCIM error too, which is a not-found handler of its own.
* - **OpenAPI.** `hideUntagged` is on and nothing here declares a tag, so SCIM stays out of the
* API docs — it is described by its own RFC and by the discovery endpoints below.
*
* Authorization is `manage:scim`, held as a bearer API key (the usual case — a connector) or by a
* signed-in session (which is what makes the endpoint drivable by hand while it is being set up).
* It is checked in this plugin's own hook rather than through `config.permissions`, because the
* enabled check has to come first — a wiki that has not turned provisioning on answers 404, not 401
* — and because every refusal on this prefix has to leave as a SCIM error.
*
* What it may then DO is not decided here: `helpers/userGuards.ts` holds the same three guards the
* admin API goes through, so a directory cannot reach through `/_scim` for something an
* administrator could not do through `/_api`. In practice that means a SCIM client can never staff
* the Administrators group, nor touch an account that belongs to it.
*/
/** RFC 7644 §5 — what this service provider supports, which connectors read before they sync. */
const SERVICE_PROVIDER_CONFIG = {
schemas: ['urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'],
documentationUri: 'https://docs.js.wiki/admin/scim',
patch: { supported: true },
bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
filter: { supported: true, maxResults: SCIM_MAX_RESULTS },
changePassword: { supported: false },
sort: { supported: false },
etag: { supported: false },
authenticationSchemes: [
{
type: 'oauthbearertoken',
name: 'OAuth Bearer Token',
description:
'An API key issued under Admin → API, belonging to a group that holds the manage:scim permission.',
specUri: 'https://www.rfc-editor.org/rfc/rfc6750',
primary: true
}
]
}
const RESOURCE_TYPES = [
{
schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'],
id: 'User',
name: 'User',
endpoint: '/Users',
description: 'A wiki user account.',
schema: SCHEMA_USER,
schemaExtensions: []
},
{
schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'],
id: 'Group',
name: 'Group',
endpoint: '/Groups',
description: 'A wiki group. Its permissions and page rules are set in the wiki, never here.',
schema: SCHEMA_GROUP,
schemaExtensions: []
}
]
/** A shorthand for the attribute declarations below, which are otherwise nine identical lines each. */
function attr(name: string, overrides: Record<string, any> = {}): Record<string, any> {
return {
name,
type: 'string',
multiValued: false,
required: false,
caseExact: false,
mutability: 'readWrite',
returned: 'default',
uniqueness: 'none',
...overrides
}
}
/**
* The two schemas, declaring only what this wiki actually stores.
*
* Deliberately short of RFC 7643's full User: a wiki account is a name, an address and whether it is
* active. An attribute declared here that nothing could be written to would be a promise the mapping
* does not keep.
*/
const SCHEMAS = [
{
id: SCHEMA_USER,
name: 'User',
description: 'A wiki user account.',
attributes: [
attr('userName', { required: true, uniqueness: 'server' }),
{
...attr('name'),
type: 'complex',
subAttributes: [attr('formatted'), attr('givenName'), attr('familyName')]
},
attr('displayName'),
attr('title'),
attr('timezone'),
attr('active', { type: 'boolean' }),
{
...attr('emails'),
type: 'complex',
multiValued: true,
subAttributes: [attr('value'), attr('type'), attr('primary', { type: 'boolean' })]
},
{
...attr('groups', { mutability: 'readOnly' }),
type: 'complex',
multiValued: true,
subAttributes: [
attr('value', { mutability: 'readOnly' }),
attr('display', { mutability: 'readOnly' }),
attr('$ref', { type: 'reference', mutability: 'readOnly' })
]
}
],
meta: { resourceType: 'Schema', location: `/Schemas/${SCHEMA_USER}` }
},
{
id: SCHEMA_GROUP,
name: 'Group',
description: 'A wiki group. Its permissions and page rules are set in the wiki, never here.',
attributes: [
attr('displayName', { required: true, uniqueness: 'server' }),
{
...attr('members'),
type: 'complex',
multiValued: true,
subAttributes: [
attr('value'),
attr('display', { mutability: 'immutable' }),
attr('$ref', { type: 'reference' })
]
}
],
meta: { resourceType: 'Schema', location: `/Schemas/${SCHEMA_GROUP}` }
}
]
/** Where `meta.location` and every `$ref` point, as this request reached the wiki. */
function baseUrlFor(req: FastifyRequest): string {
return `${originOf(req)}/_scim/v2`
}
/** Send a resource, always under the SCIM media type. */
function sendScim(reply: FastifyReply, status: number, body: unknown): FastifyReply {
return reply.code(status).type(`${SCIM_CONTENT_TYPE}; charset=utf-8`).send(body)
}
/** What a request holds, whether it arrived as a bearer key or as a browser session. */
function permissionsOf(req: FastifyRequest): string[] | null {
if (req.apiKey) {
return req.apiKey.permissions
}
return req.session?.authenticated ? (req.session.permissions ?? []) : null
}
/** A caller guard's refusal, as the SCIM error it has to leave as. */
function asScimError(refusal: CustomError): ScimError {
return new ScimError(refusal.statusCode, refusal.message)
}
/**
* The body of a write, as an object.
*
* Both content types land here — `application/json` through Fastify's own parser and
* `application/scim+json` through the one registered below — so this only has to catch the request
* that carried nothing at all, which several connectors send while probing an endpoint.
*/
function resourceBody(req: FastifyRequest): Record<string, any> {
const body = req.body
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw new ScimError(400, 'A request body is required.', 'invalidSyntax')
}
return body as Record<string, any>
}
async function routes(app: FastifyInstance) {
/*
RFC 7644 §3.1 gives SCIM its own media type. Registered inside this plugin, so that a body of
`application/scim+json` posted anywhere else in the wiki goes on being refused.
*/
app.addContentTypeParser(
[SCIM_CONTENT_TYPE],
{ parseAs: 'string' },
(_req, body: string | Buffer, done) => {
const text = body.toString().trim()
if (text.length < 1) {
done(null, undefined)
return
}
try {
done(null, JSON.parse(text))
} catch {
done(new ScimError(400, 'The request body is not valid JSON.', 'invalidSyntax'), undefined)
}
}
)
// ----------------------------------------
// Errors
// ----------------------------------------
app.setErrorHandler((error: any, req, reply) => {
const statusCode: number =
error instanceof ScimError ? error.statusCode : (error.statusCode ?? 500)
const isFault = statusCode >= 500
if (isFault) {
WIKI.logger.warn(`SCIM ${req.method} ${req.url} failed: ${error.message}`)
}
const detail = isFault ? 'Internal server error.' : error.message
WIKI.models.scim.recordRequest({
method: req.method,
path: req.url,
status: statusCode,
message: detail
})
return sendScim(reply, statusCode, {
schemas: [SCHEMA_ERROR],
status: String(statusCode),
...(error instanceof ScimError && error.scimType ? { scimType: error.scimType } : {}),
detail
})
})
app.setNotFoundHandler((req, reply) => {
WIKI.models.scim.recordRequest({
method: req.method,
path: req.url,
status: 404,
message: 'No such SCIM endpoint.'
})
return sendScim(reply, 404, {
schemas: [SCHEMA_ERROR],
status: '404',
detail: `No SCIM endpoint answers ${req.method} ${req.url}.`
})
})
// ----------------------------------------
// Access
// ----------------------------------------
app.addHook('onRequest', async (req, reply) => {
if (!WIKI.models.scim.isEnabled()) {
/*
404 rather than 403: with provisioning off there is no endpoint here, and a connector pointed
at a wiki that has not turned it on should be told the URL is wrong rather than that its
token is. The message names the feature, which is in the manual anyway.
*/
return sendScim(reply, 404, {
schemas: [SCHEMA_ERROR],
status: '404',
detail: 'SCIM provisioning is not enabled on this wiki.'
})
}
/*
The address check comes before everything else that costs anything: it is the only gate here
that needs neither the database nor a signature, and an operator who has written a list has
said requests from anywhere else are not to be entertained at all.
403 rather than 404. Hiding the endpoint from an address would be pointless — it is a fixed,
documented path on a wiki that is answering on every other one — and a connector moved to a
new egress range needs to be told which of the two things is wrong.
*/
if (!WIKI.models.scim.isAddressAllowed(req.ip)) {
WIKI.logger.debug(`Refused a SCIM request from ${req.ip}: not in the allowed address list.`)
return sendScim(reply, 403, {
schemas: [SCHEMA_ERROR],
status: '403',
detail: 'This address is not allowed to reach the SCIM endpoint.'
})
}
/*
Then the limit, and before the credential check rather than after it, so that an unauthorized
flood is capped as well as an authorized one — the request being refused is exactly when the
counter matters. Counted per address against the same postgres-backed counter the login limit
uses, so two instances behind a load balancer share one budget.
Successes are counted too, as they are for auth. A sync is what this endpoint is FOR, so the
ceiling is set high enough that an ordinary one never approaches it; see `base.yml`.
*/
const config = WIKI.models.scim.getConfig()
if (config.rateLimitEnabled) {
const verdict = await WIKI.models.rateLimits.consume(
`scim:${req.ip}`,
WIKI.models.scim.rateLimitPolicy()
)
if (!verdict.allowed) {
WIKI.logger.debug(
`Rate limit: refused a SCIM request from ${req.ip}, ${verdict.retryAfter}s left of its ban.`
)
// -> `Retry-After` because this is the same answer as before with a time on it, and a
// connector that reads it will come back rather than give up on the sync
return sendScim(reply.header('Retry-After', String(verdict.retryAfter)), 429, {
schemas: [SCHEMA_ERROR],
status: '429',
detail: `Too many requests. Try again in ${verdict.retryAfter}s.`
})
}
}
const permissions = permissionsOf(req)
if (!permissions) {
return sendScim(reply.header('WWW-Authenticate', 'Bearer realm="scim"'), 401, {
schemas: [SCHEMA_ERROR],
status: '401',
detail: 'This endpoint requires a bearer API key.'
})
}
if (!permissions.includes(SCIM_PERMISSION) && !permissions.includes('manage:system')) {
return sendScim(reply, 403, {
schemas: [SCHEMA_ERROR],
status: '403',
detail: `This endpoint requires the ${SCIM_PERMISSION} permission.`
})
}
})
// -> Failures are recorded by the error handler above, which has the reason; this is the other half
app.addHook('onResponse', async (req, reply) => {
if (reply.statusCode < 400) {
WIKI.models.scim.recordRequest({
method: req.method,
path: req.url,
status: reply.statusCode,
message: null
})
}
})
// ----------------------------------------
// Discovery
// ----------------------------------------
app.get('/v2/ServiceProviderConfig', async (req, reply) =>
sendScim(reply, 200, {
...SERVICE_PROVIDER_CONFIG,
meta: {
resourceType: 'ServiceProviderConfig',
location: `${baseUrlFor(req)}/ServiceProviderConfig`
}
})
)
app.get('/v2/ResourceTypes', async (req, reply) => {
const baseUrl = baseUrlFor(req)
const resources = RESOURCE_TYPES.map((type) => ({
...type,
meta: { resourceType: 'ResourceType', location: `${baseUrl}/ResourceTypes/${type.id}` }
}))
return sendScim(reply, 200, WIKI.models.scim.listResponse(resources, resources.length, 1))
})
app.get<{ Params: { id: string } }>('/v2/ResourceTypes/:id', async (req, reply) => {
const type = RESOURCE_TYPES.find((entry) => entry.id === req.params.id)
if (!type) {
throw new ScimError(404, `No resource type named '${req.params.id}'.`)
}
return sendScim(reply, 200, {
...type,
meta: {
resourceType: 'ResourceType',
location: `${baseUrlFor(req)}/ResourceTypes/${type.id}`
}
})
})
app.get('/v2/Schemas', async (_req, reply) =>
sendScim(reply, 200, WIKI.models.scim.listResponse(SCHEMAS, SCHEMAS.length, 1))
)
app.get<{ Params: { id: string } }>('/v2/Schemas/:id', async (req, reply) => {
const schema = SCHEMAS.find((entry) => entry.id === req.params.id)
if (!schema) {
throw new ScimError(404, `No schema named '${req.params.id}'.`)
}
return sendScim(reply, 200, schema)
})
// ----------------------------------------
// Users
// ----------------------------------------
/** One user as SCIM describes them, memberships included. */
async function userResource(req: FastifyRequest, user: Record<string, any>) {
const memberships = await WIKI.models.scim.membershipsOf([user.id])
return WIKI.models.scim.toScimUser(user, memberships.get(user.id) ?? [], baseUrlFor(req))
}
/** The user this request names, or the 404 that says nothing about why. */
async function requireUser(id: string): Promise<Record<string, any>> {
const user = await WIKI.models.scim.getUser(id)
if (!user) {
throw new ScimError(404, `No user with id '${id}'.`)
}
return user
}
app.get<{ Querystring: Record<string, any> }>('/v2/Users', async (req, reply) => {
const { startIndex, count } = WIKI.models.scim.parsePaging(req.query)
return sendScim(
reply,
200,
await WIKI.models.scim.listUsers({
filter: req.query.filter,
startIndex,
count,
baseUrl: baseUrlFor(req)
})
)
})
app.get<{ Params: { id: string } }>('/v2/Users/:id', async (req, reply) =>
sendScim(reply, 200, await userResource(req, await requireUser(req.params.id)))
)
app.post('/v2/Users', async (req, reply) => {
const id = await WIKI.models.scim.createUser(resourceBody(req))
const user = await requireUser(id)
await audit(req, 'admin', 'createUser', {
source: 'scim',
targetUserId: id,
name: user.name,
email: user.email,
externalId: user.externalId
})
const resource = await userResource(req, user)
return sendScim(reply.header('Location', resource.meta.location), 201, resource)
})
/**
* Replace a user, and adopt it if it was not already provisioned.
*
* Only the attributes the resource carries are applied. A SCIM PUT is nominally a whole-resource
* replace, but this wiki has fields SCIM does not describe and no notion of an unset name — so an
* attribute a connector left out leaves the stored value alone rather than blanking it.
*/
app.put<{ Params: { id: string } }>('/v2/Users/:id', async (req, reply) => {
const user = await requireUser(req.params.id)
const refusal = await systemUserGuard(req, user.id)
if (refusal) {
throw asScimError(refusal)
}
await WIKI.models.scim.applyUser(user, resourceBody(req))
const updated = await requireUser(user.id)
await audit(req, 'admin', 'updateUser', {
source: 'scim',
targetUserId: user.id,
targetName: updated.name,
targetEmail: updated.email,
isActive: updated.isActive
})
return sendScim(reply, 200, await userResource(req, updated))
})
app.patch<{ Params: { id: string } }>('/v2/Users/:id', async (req, reply) => {
const user = await requireUser(req.params.id)
const refusal = await systemUserGuard(req, user.id)
if (refusal) {
throw asScimError(refusal)
}
const fragment = WIKI.models.scim.parseUserPatch(resourceBody(req))
await WIKI.models.scim.applyUser(user, fragment)
const updated = await requireUser(user.id)
await audit(req, 'admin', 'updateUser', {
source: 'scim',
targetUserId: user.id,
targetName: updated.name,
targetEmail: updated.email,
isActive: updated.isActive,
changedFields: Object.keys(fragment)
})
return sendScim(reply, 200, await userResource(req, updated))
})
/**
* Deprovision a user.
*
* Only for an account the directory owns: one created here and never written by a connector
* answers 404, which is SCIM's way of saying "not a resource of mine". That is what keeps a token
* sitting in somebody else's console from emptying the wiki's user list, and it costs nothing —
* a connector adopts an account the first time it writes to one.
*
* What deprovisioning MEANS is the site's `deleteAction` setting. See `models/scim.ts`.
*/
app.delete<{ Params: { id: string } }>('/v2/Users/:id', async (req, reply) => {
const user = await requireUser(req.params.id)
if (!user.isProvisioned) {
throw new ScimError(
404,
`The user '${user.email}' was not created by provisioning, so it cannot be removed by it.`
)
}
const refusal = await systemUserGuard(req, user.id)
if (refusal) {
throw asScimError(refusal)
}
const action = await WIKI.models.scim.deprovisionUser(user.id)
await audit(req, 'admin', action === 'delete' ? 'deleteUser' : 'updateUser', {
source: 'scim',
deprovisioned: action,
targetUserId: user.id,
targetName: user.name,
targetEmail: user.email
})
return reply.code(204).send()
})
// ----------------------------------------
// Groups
// ----------------------------------------
async function groupResource(req: FastifyRequest, group: Record<string, any>) {
const members = await WIKI.models.scim.membersOf([group.id])
return WIKI.models.scim.toScimGroup(group, members.get(group.id) ?? [], baseUrlFor(req))
}
async function requireGroup(id: string): Promise<Record<string, any>> {
const group = await WIKI.models.scim.getGroup(id)
if (!group) {
throw new ScimError(404, `No group with id '${id}'.`)
}
return group
}
/**
* Refuse a membership change the caller may not make.
*
* Two separate questions, and both have to be asked. `elevatedGroupGuard` is about the GROUP: a
* SCIM client holds `manage:scim` and not `manage:groups`, so every group carrying an elevated
* permission is closed to it — which is precisely what stops a directory group called
* "Administrators" from syncing its membership into the wiki's. `systemUserGuard` is about each
* PERSON being moved: an account protected by `manage:system` is not re-grouped by anything short
* of `manage:system`.
*/
async function guardMembership(
req: FastifyRequest,
groupId: string,
touched: string[]
): Promise<void> {
const full = await WIKI.models.groups.getGroupById(groupId)
if (!full) {
throw new ScimError(404, `No group with id '${groupId}'.`)
}
const groupRefusal = elevatedGroupGuard(req, full, 'change who belongs to the group')
if (groupRefusal) {
throw asScimError(groupRefusal)
}
for (const userId of touched) {
const userRefusal = await systemUserGuard(req, userId)
if (userRefusal) {
throw asScimError(userRefusal)
}
}
}
/**
* Bring a group's membership to exactly `target`, one assignment at a time.
*
* Not `users.setUserGroups`, which replaces one user's whole membership and would take them out of
* every other group in the wiki. `assignUserToGroup` and its opposite are per membership, and are
* also where the guest account's fixed membership is enforced.
*/
async function applyMembership(
req: FastifyRequest,
groupId: string,
target: string[]
): Promise<{ added: string[]; removed: string[] }> {
const current = await WIKI.models.scim.memberIdsOf(groupId)
const wanted = [...new Set(target)]
const unknown = await WIKI.models.scim.firstUnknownUser(wanted)
if (unknown) {
throw new ScimError(400, `No user with id '${unknown}'.`, 'invalidValue')
}
const added = wanted.filter((id) => !current.includes(id))
const removed = current.filter((id) => !wanted.includes(id))
if (added.length < 1 && removed.length < 1) {
return { added, removed }
}
await guardMembership(req, groupId, [...added, ...removed])
for (const userId of added) {
await WIKI.models.groups.assignUserToGroup(groupId, userId)
}
for (const userId of removed) {
await WIKI.models.groups.unassignUserFromGroup(groupId, userId)
}
return { added, removed }
}
/** The ids a `members` array names, for a PUT or a create. */
function memberIdsFrom(resource: Record<string, any>): string[] {
if (!Array.isArray(resource.members)) {
return []
}
return resource.members.map((entry: any) => {
const id = typeof entry === 'string' ? entry : entry?.value
if (typeof id !== 'string' || id.length < 1) {
throw new ScimError(
400,
'Each member must carry a `value` naming a user id.',
'invalidValue'
)
}
return id
})
}
app.get<{ Querystring: Record<string, any> }>('/v2/Groups', async (req, reply) => {
const { startIndex, count } = WIKI.models.scim.parsePaging(req.query)
return sendScim(
reply,
200,
await WIKI.models.scim.listGroups({
filter: req.query.filter,
startIndex,
count,
baseUrl: baseUrlFor(req)
})
)
})
app.get<{ Params: { id: string } }>('/v2/Groups/:id', async (req, reply) =>
sendScim(reply, 200, await groupResource(req, await requireGroup(req.params.id)))
)
app.post('/v2/Groups', async (req, reply) => {
const body = resourceBody(req)
const members = memberIdsFrom(body)
const id = await WIKI.models.scim.createGroup(body)
const group = await requireGroup(id)
await audit(req, 'admin', 'createGroup', {
source: 'scim',
groupId: id,
name: group.name,
externalId: group.externalId
})
if (members.length > 0) {
const { added } = await applyMembership(req, id, members)
if (added.length > 0) {
await audit(req, 'admin', 'assignUserToGroup', {
source: 'scim',
groupId: id,
name: group.name,
userIds: added
})
}
}
const resource = await groupResource(req, await requireGroup(id))
return sendScim(reply.header('Location', resource.meta.location), 201, resource)
})
app.put<{ Params: { id: string } }>('/v2/Groups/:id', async (req, reply) => {
const group = await requireGroup(req.params.id)
const body = resourceBody(req)
const refusal = elevatedGroupGuard(
req,
(await WIKI.models.groups.getGroupById(group.id))!,
'modify the group'
)
if (refusal) {
throw asScimError(refusal)
}
await WIKI.models.scim.applyGroup(group, {
displayName: body.displayName,
externalId: body.externalId === undefined ? undefined : body.externalId
})
// -> A PUT states the membership in full, so anybody it does not name is out of the group
const { added, removed } = await applyMembership(req, group.id, memberIdsFrom(body))
await audit(req, 'admin', 'updateGroup', {
source: 'scim',
groupId: group.id,
name: body.displayName ?? group.name,
added,
removed
})
return sendScim(reply, 200, await groupResource(req, await requireGroup(group.id)))
})
app.patch<{ Params: { id: string } }>('/v2/Groups/:id', async (req, reply) => {
const group = await requireGroup(req.params.id)
const ops = WIKI.models.scim.parseGroupPatch(resourceBody(req))
if (ops.displayName !== undefined || ops.externalId !== undefined) {
const refusal = elevatedGroupGuard(
req,
(await WIKI.models.groups.getGroupById(group.id))!,
'modify the group'
)
if (refusal) {
throw asScimError(refusal)
}
await WIKI.models.scim.applyGroup(group, {
displayName: ops.displayName,
externalId: ops.externalId
})
}
let changed: { added: string[]; removed: string[] } = { added: [], removed: [] }
const touchesMembers =
ops.removeAllMembers ||
ops.replaceMembers !== undefined ||
ops.addMembers.length > 0 ||
ops.removeMembers.length > 0
if (touchesMembers) {
const current = await WIKI.models.scim.memberIdsOf(group.id)
const base = ops.removeAllMembers ? [] : (ops.replaceMembers ?? current)
const target = [...base, ...ops.addMembers].filter((id) => !ops.removeMembers.includes(id))
changed = await applyMembership(req, group.id, target)
}
await audit(req, 'admin', 'updateGroup', {
source: 'scim',
groupId: group.id,
name: ops.displayName ?? group.name,
added: changed.added,
removed: changed.removed
})
return sendScim(reply, 200, await groupResource(req, await requireGroup(group.id)))
})
/**
* Delete a group the directory owns.
*
* Gated on `isProvisioned` for the same reason a user is, and additionally closed for a built-in
* group: the guests, users and administrators groups are what anonymous access, the default
* membership and the root administrator resolve against, and nothing outside the wiki gets to
* take one away.
*/
app.delete<{ Params: { id: string } }>('/v2/Groups/:id', async (req, reply) => {
const group = await requireGroup(req.params.id)
if (group.isSystem) {
throw new ScimError(403, `The '${group.name}' group is built in and cannot be deleted.`)
}
if (!group.isProvisioned) {
throw new ScimError(
404,
`The group '${group.name}' was not created by provisioning, so it cannot be removed by it.`
)
}
const refusal = elevatedGroupGuard(
req,
(await WIKI.models.groups.getGroupById(group.id))!,
'delete the group'
)
if (refusal) {
throw asScimError(refusal)
}
await WIKI.models.scim.deleteGroup(group.id)
await audit(req, 'admin', 'deleteGroup', {
source: 'scim',
groupId: group.id,
name: group.name
})
return reply.code(204).send()
})
}
export default routes

@ -0,0 +1,6 @@
ALTER TABLE "groups" ADD COLUMN "externalId" varchar(255);--> statement-breakpoint
ALTER TABLE "groups" ADD COLUMN "isProvisioned" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "externalId" varchar(255);--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "isProvisioned" boolean DEFAULT false NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "groups_externalId_idx" ON "groups" ("externalId");--> statement-breakpoint
CREATE UNIQUE INDEX "users_externalId_idx" ON "users" ("externalId");

File diff suppressed because it is too large Load Diff

@ -314,7 +314,9 @@ export const comments = pgTable(
) )
// GROUPS ------------------------------ // GROUPS ------------------------------
export const groups = pgTable('groups', { export const groups = pgTable(
'groups',
{
id: uuid().primaryKey().defaultRandom(), id: uuid().primaryKey().defaultRandom(),
name: varchar({ length: 255 }).notNull(), name: varchar({ length: 255 }).notNull(),
permissions: jsonb().notNull(), permissions: jsonb().notNull(),
@ -323,9 +325,28 @@ export const groups = pgTable('groups', {
redirectOnFirstLogin: varchar({ length: 255 }).notNull().default(''), redirectOnFirstLogin: varchar({ length: 255 }).notNull().default(''),
redirectOnLogout: varchar({ length: 255 }).notNull().default(''), redirectOnLogout: varchar({ length: 255 }).notNull().default(''),
isSystem: boolean().notNull().default(false), isSystem: boolean().notNull().default(false),
/**
* What the directory provisioning this group calls it, as SCIM's `externalId`.
*
* Null for a group created here, and optional even for one that was not: `externalId` is a
* SHOULD in RFC 7643 and not every client sends it. Which is why it is not the thing that says
* who owns the group — `isProvisioned` is.
*/
externalId: varchar({ length: 255 }),
/**
* Whether a SCIM client owns this group. Set by the first provisioning write and never cleared
* automatically; it is what lets SCIM delete a group it created while leaving one an
* administrator made by hand alone. See `models/scim.ts`.
*/
isProvisioned: boolean().notNull().default(false),
createdAt: timestamp().notNull().defaultNow(), createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow() updatedAt: timestamp().notNull().defaultNow()
}) },
(table) => [
// -> Nulls are distinct to postgres, which is what lets any number of groups have no external id
uniqueIndex('groups_externalId_idx').on(table.externalId)
]
)
// HOOKS ------------------------------- // HOOKS -------------------------------
export const hookStateEnum = pgEnum('hookState', ['pending', 'success', 'error']) export const hookStateEnum = pgEnum('hookState', ['pending', 'success', 'error'])
@ -1096,6 +1117,23 @@ export const users = pgTable(
* while the column keeps the capitalization that was typed. * while the column keeps the capitalization that was typed.
*/ */
handle: varchar({ length: 64 }), handle: varchar({ length: 64 }),
/**
* What the directory provisioning this account calls it, as SCIM's `externalId`.
*
* The identifier that survives a rename or a change of address at the provider, so it is what a
* SCIM client looks an account up by. Null for an account created here, and optional even for a
* provisioned one — see the same column on `groups`.
*/
externalId: varchar({ length: 255 }),
/**
* Whether a SCIM client owns this account. Set by the first provisioning write, which is how an
* account created by hand is adopted by a directory that later claims it.
*
* What it gates is destruction: `DELETE /Users/:id` is only honoured for an account the client
* owns, so a token sitting in somebody else's console cannot empty the wiki's user list. See
* `models/scim.ts`.
*/
isProvisioned: boolean().notNull().default(false),
auth: jsonb().notNull().default({}), auth: jsonb().notNull().default({}),
meta: jsonb().notNull().default({}), meta: jsonb().notNull().default({}),
passkeys: jsonb().notNull().default({}), passkeys: jsonb().notNull().default({}),
@ -1110,6 +1148,8 @@ export const users = pgTable(
}, },
(table) => [ (table) => [
index('users_lastLoginAt_idx').on(table.lastLoginAt), index('users_lastLoginAt_idx').on(table.lastLoginAt),
// -> Nulls are distinct to postgres, as for the handle below
uniqueIndex('users_externalId_idx').on(table.externalId),
// -> Folded, so that two handles differing only in case cannot both exist. Nulls are distinct to // -> Folded, so that two handles differing only in case cannot both exist. Nulls are distinct to
// postgres, which is what lets any number of users have no handle at all. // postgres, which is what lets any number of users have no handle at all.
uniqueIndex('users_handle_idx').on(sql`lower(${table.handle})`) uniqueIndex('users_handle_idx').on(sql`lower(${table.handle})`)

@ -59,3 +59,109 @@ export function classifyClientIp(ip: string | null | undefined): ClientIpClass {
} }
return 'external' return 'external'
} }
/**
* One entry of an operator-written address list: a single address or a CIDR subnet.
*
* Kept as the pieces `net.BlockList` needs rather than as the string, so that the thing which
* validates an entry and the thing which matches against it cannot disagree about what it meant.
*/
export interface IpRange {
address: string
/** Absent for a single address, which is matched exactly. */
prefix?: number
family: 'ipv4' | 'ipv6'
}
/**
* Read one entry of an address list.
*
* Accepts `203.0.113.4`, `203.0.113.0/24`, `2001:db8::1` and `2001:db8::/32`. Everything else is
* rejected, including a prefix that is not a number or is wider than the family allows — an entry
* that cannot be understood must not be quietly dropped from a list whose whole job is to say who
* may through, in either direction: dropped from an allow list it locks somebody out, and the
* operator has no way to see which entry did it.
*
* @returns The parsed range, or null when the entry is not one
*/
export function parseIpRange(entry: string): IpRange | null {
const trimmed = entry.trim()
if (trimmed.length < 1) {
return null
}
const slash = trimmed.lastIndexOf('/')
const address = slash === -1 ? trimmed : trimmed.slice(0, slash)
const family = net.isIPv6(address) ? 'ipv6' : net.isIPv4(address) ? 'ipv4' : null
if (!family) {
return null
}
if (slash === -1) {
return { address, family }
}
const raw = trimmed.slice(slash + 1)
// -> `Number` rather than `parseInt`, which would read `24abc` as 24
const prefix = /^\d+$/.test(raw) ? Number(raw) : Number.NaN
if (!Number.isInteger(prefix) || prefix < 0 || prefix > (family === 'ipv6' ? 128 : 32)) {
return null
}
return { address, prefix, family }
}
/**
* The compiled form of the last list asked about, so that a per-request check is one `check()` call.
*
* Keyed on the entries themselves rather than invalidated by whoever writes the setting: the list
* lives in a config blob that any instance may change, and a cache that has to be told is a cache
* that will one day not be. One slot is enough — there is one such list in the wiki.
*/
let compiledKey: string | null = null
let compiled: net.BlockList | null = null
function compile(entries: readonly string[]): net.BlockList {
const key = entries.join('\n')
if (compiledKey === key && compiled) {
return compiled
}
const list = new net.BlockList()
for (const entry of entries) {
const range = parseIpRange(entry)
if (!range) {
// -> Refused when it was saved; reaching here means it was written straight to the database
WIKI.logger.warn(`Ignoring an unreadable address range in a configured list: ${entry}`)
continue
}
if (range.prefix === undefined) {
list.addAddress(range.address, range.family)
} else {
list.addSubnet(range.address, range.prefix, range.family)
}
}
compiledKey = key
compiled = list
return list
}
/**
* Whether an address falls inside an operator-written list of ranges.
*
* An EMPTY list means no restriction and everything matches — the setting being unset cannot be the
* setting being at its most restrictive, or turning a feature on would lock everybody out of it.
* Anything that is not an IP address at all never matches a non-empty list, which is the strict
* answer for the case that cannot be placed.
*/
export function matchesIpRanges(
ip: string | null | undefined,
entries: readonly string[]
): boolean {
if (entries.length < 1) {
return true
}
if (!ip) {
return false
}
const family = net.isIPv6(ip) ? 'ipv6' : net.isIPv4(ip) ? 'ipv4' : null
if (!family) {
return false
}
return compile(entries).check(ip, family)
}

@ -0,0 +1,142 @@
import { CustomError } from './common.ts'
import { ELEVATED_PERMISSIONS, SYSTEM_PERMISSION, isElevated } from '../models/groups.ts'
import type { FastifyRequest } from 'fastify'
import type { GroupWithUserCount } from '../models/groups.ts'
/**
* The three guards that stand between an administrator and the accounts that administer the wiki.
*
* They live here rather than in the models because every one of them is a question about the
* CALLER — what the session or the API key making this request holds — and a model is reachable
* from the scheduler, where there is no caller to ask about. They live here rather than in
* `api/users.ts` because there is now more than one surface that writes users and groups: the admin
* API, and the SCIM endpoint under `/_scim`, which a directory drives with a bearer token. A guard
* that only one of the two went through would be a guard with a way around it.
*
* All three answer with the refusal to throw rather than throwing it themselves, so that a caller
* can decide whether a refusal is an error or, as SCIM needs, a 404 that discloses nothing.
*/
/** What a request holds, whether it arrived as a session or as an API key. */
function permissionsOf(req: FastifyRequest): string[] {
return req.apiKey?.permissions ?? req.session?.permissions ?? []
}
/**
* Refuse any change to a user who is protected by `manage:system`.
*
* `manage:users` is deliberately short of the root: an administrator who can rename, re-group, reset
* the password of, or delete a `manage:system` account can take the instance over through it. Only
* somebody who already holds `manage:system` may touch one.
*
* @returns The refusal to throw, or null when the caller may proceed
*/
export async function systemUserGuard(
req: FastifyRequest,
userId: string
): Promise<CustomError | null> {
if (WIKI.models.groups.holdsSystemPermission(req)) {
return null
}
if (!(await WIKI.models.groups.userHoldsSystemPermission(userId))) {
return null
}
return new CustomError(
'userSystemProtected',
'This user belongs to a group with the manage:system permission. Only a user who holds manage:system can modify them.',
403
)
}
/**
* 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 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.
* - **Everything else** — `write:groups`, and `manage:scim` on a SCIM request — is stopped by every
* one of `ELEVATED_PERMISSIONS`. Those are the rungs that may build and populate ordinary groups
* without being trusted to decide who administers the wiki, and since neither can edit a group's
* permissions at all, their 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
*/
export 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 = permissionsOf(req)
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
)
}
/**
* Refuse moving a user into or out of a group that administers the wiki.
*
* Both directions, because adding hands them whatever that group can reach and removing takes it
* from a real administrator. Creating an account already inside one is the same act as promoting an
* existing one, so a create asks this with an empty `current` rather than skipping it — otherwise
* the way around every other guard would be to make a second account instead of editing the first.
*
* Groups the request leaves alone are never consulted, so a save that only renames a user still goes
* through whatever they already belong to.
*
* @param current The groups the user is in now — empty when the user is being created
* @param requested The membership being asked for, in full
* @returns The refusal to throw, or null when the caller may proceed
*/
export async function elevatedMembershipGuard(
req: FastifyRequest,
current: readonly string[],
requested: readonly string[]
): Promise<CustomError | null> {
if (WIKI.models.groups.holdsSystemPermission(req)) {
return null
}
const moved = [
...requested.filter((id) => !current.includes(id)),
...current.filter((id) => !requested.includes(id))
]
if (moved.length < 1) {
return null
}
const elevated = await WIKI.models.groups.elevatedGroupIds()
if (!moved.some((id) => elevated.includes(id))) {
return null
}
return new CustomError(
'groupMembershipElevatedProtected',
'Only a user who holds manage:system can add a user to, or remove one from, a group that administers the wiki.',
403
)
}

@ -65,6 +65,7 @@ const SERVER_ROUTE_SEGMENTS = new Set([
'_files', '_files',
'_icons', '_icons',
'_render', '_render',
'_scim',
'_site', '_site',
'_terminal', '_terminal',
'_thumb' '_thumb'
@ -560,10 +561,11 @@ async function initHTTPServer() {
app.addHook('onRequest', async (req, reply) => { app.addHook('onRequest', async (req, reply) => {
/* /*
Bearer tokens authenticate API calls and the metrics endpoint; everything else is Bearer tokens authenticate API calls, the SCIM endpoint and the metrics endpoint; everything
cookie-authenticated. The metrics path is here rather than verifying a key of its own, so that else is cookie-authenticated. Neither of the latter two verifies a key of its own, so that
there is one place a bearer token is checked — it is served by a hook below, at a path that is there is one place a bearer token is checked — metrics is served by a hook below, at a path
a setting, so it cannot declare itself part of the API by its prefix. that is a setting, and SCIM has a prefix of its own because its errors and its media type are
not the API's.
Note that the session is deliberately left untouched: writing to it would have Note that the session is deliberately left untouched: writing to it would have
@fastify/session persist a session row for every scraped request. @fastify/session persist a session row for every scraped request.
@ -572,7 +574,11 @@ async function initHTTPServer() {
if (!header?.startsWith('Bearer ')) { if (!header?.startsWith('Bearer ')) {
return return
} }
if (!req.url.startsWith('/_api/') && !WIKI.models.metrics.matches(req.url.split('?')[0]!)) { if (
!req.url.startsWith('/_api/') &&
!req.url.startsWith('/_scim/') &&
!WIKI.models.metrics.matches(req.url.split('?')[0]!)
) {
return return
} }
const token = header.slice('Bearer '.length).trim() const token = header.slice('Bearer '.length).trim()
@ -731,6 +737,7 @@ async function initHTTPServer() {
app.register(import('./controllers/site.ts'), { prefix: '/_site' }) app.register(import('./controllers/site.ts'), { prefix: '/_site' })
app.register(import('./controllers/icons.ts'), { prefix: '/_icons' }) app.register(import('./controllers/icons.ts'), { prefix: '/_icons' })
app.register(import('./controllers/render.ts'), { prefix: '/_render' }) app.register(import('./controllers/render.ts'), { prefix: '/_render' })
app.register(import('./controllers/scim.ts'), { prefix: '/_scim' })
app.register(import('./controllers/terminal.ts'), { prefix: '/_terminal' }) app.register(import('./controllers/terminal.ts'), { prefix: '/_terminal' })
app.register(import('./controllers/thumb.ts'), { prefix: '/_thumb' }) app.register(import('./controllers/thumb.ts'), { prefix: '/_thumb' })
app.register(import('./controllers/user.ts'), { prefix: '/_user' }) app.register(import('./controllers/user.ts'), { prefix: '/_user' })

@ -226,6 +226,7 @@
"admin.audit.actions.updatePage": "Edited a page", "admin.audit.actions.updatePage": "Edited a page",
"admin.audit.actions.updatePageNavigation": "Changed the navigation of a page", "admin.audit.actions.updatePageNavigation": "Changed the navigation of a page",
"admin.audit.actions.updateProfile": "Updated their profile", "admin.audit.actions.updateProfile": "Updated their profile",
"admin.audit.actions.updateScimState": "Changed the SCIM provisioning configuration",
"admin.audit.actions.updateSearchConfig": "Changed the search configuration", "admin.audit.actions.updateSearchConfig": "Changed the search configuration",
"admin.audit.actions.updateSecurity": "Changed the security configuration", "admin.audit.actions.updateSecurity": "Changed the security configuration",
"admin.audit.actions.updateSite": "Updated a site", "admin.audit.actions.updateSite": "Updated a site",
@ -611,6 +612,7 @@
"admin.groups.permissionsSite": "Site Management", "admin.groups.permissionsSite": "Site Management",
"admin.groups.permissionsUsers": "Users Management", "admin.groups.permissionsUsers": "Users Management",
"admin.groups.permissionsWebhooks": "Webhooks Management", "admin.groups.permissionsWebhooks": "Webhooks Management",
"admin.groups.provisioned": "Managed by the directory",
"admin.groups.redirectOnFirstLogin": "First-time Login Redirect", "admin.groups.redirectOnFirstLogin": "First-time Login Redirect",
"admin.groups.redirectOnFirstLoginHint": "Optionally redirect the user to a specific page when he/she login for the first time. Leave empty to use the site-defined value.", "admin.groups.redirectOnFirstLoginHint": "Optionally redirect the user to a specific page when he/she login for the first time. Leave empty to use the site-defined value.",
"admin.groups.redirectOnLogin": "Redirect on Login", "admin.groups.redirectOnLogin": "Redirect on Login",
@ -903,6 +905,63 @@
"admin.scheduler.updatedAt": "Last Updated", "admin.scheduler.updatedAt": "Last Updated",
"admin.scheduler.useWorker": "Execution Mode", "admin.scheduler.useWorker": "Execution Mode",
"admin.scheduler.waitUntil": "Start", "admin.scheduler.waitUntil": "Start",
"admin.scim.access": "Access",
"admin.scim.accessHint": "Who may reach /_scim/v2, and how hard they may hit it. Both apply before the bearer token is even looked at.",
"admin.scim.allowGroupCreate": "Let the directory create groups",
"admin.scim.allowGroupCreateHint": "A group created this way starts with the same permissions as one created here, and no page rules — so it grants nothing until you fill it in. Off, the directory can only manage the membership of groups that already exist.",
"admin.scim.auth": "Authentication",
"admin.scim.authApiDisabled": "The REST API is switched off, and every API key is refused while it is — so no connector can authenticate yet. Turn it on under System → API.",
"admin.scim.authApiKey": "For an API key, the identity provider sends it in the {headerName} header as a {tokenType} token:",
"admin.scim.authGoToKeys": "Manage API keys",
"admin.scim.authHint": "Every request must hold the {permission} permission, as an API key or as the session of a signed-in browser.",
"admin.scim.configuration": "Configuration",
"admin.scim.deleteAction": "When the directory deletes a user",
"admin.scim.deleteActionDeactivate": "Deactivate the account",
"admin.scim.deleteActionDeactivateHint": "Signs them out everywhere, takes them out of every group and refuses any further login — but keeps the account, so their name stays on the pages they wrote. Re-assigning them in the directory brings them back.",
"admin.scim.deleteActionDelete": "Delete the account",
"admin.scim.deleteActionDeleteWarning": "The account is gone for good, and every page and version they wrote loses its author. There is no undo.",
"admin.scim.deleteActionHint": "Most directories only send a delete once somebody has been gone a while — unassigning them sends a deactivation instead, which always deactivates.",
"admin.scim.disabled": "Provisioning Disabled",
"admin.scim.durationPlaceholder": "e.g. 30s, 5m, 1h",
"admin.scim.emailSource": "Email address comes from",
"admin.scim.emailSourceEmails": "The primary entry of emails[] — for a directory whose login name is not a mailbox",
"admin.scim.emailSourceHint": "This wiki files every account under an email address, and a resource that carries none is refused.",
"admin.scim.emailSourceUserName": "The userName attribute — right for most directories, where it is the mailbox",
"admin.scim.enabled": "Provisioning Enabled",
"admin.scim.ipAllowList": "Allowed IP ranges",
"admin.scim.ipAllowListHint": "One per line, as a single address or a CIDR range — 203.0.113.4, 203.0.113.0/24, 2001:db8::/32. Your identity provider publishes the addresses it connects from.",
"admin.scim.ipAllowListOpen": "Empty — any address may reach the endpoint, and the bearer token is the only thing in front of it.",
"admin.scim.ipAllowListPlaceholder": "203.0.113.0/24\n2001:db8::/32",
"admin.scim.ipAllowListRestricted": "Restricted to {count} range(s). Every other address is refused before its token is read.",
"admin.scim.lastRequest": "Last request to this instance",
"admin.scim.lastRequestNone": "Nothing yet. Run a test from your identity provider and it will show up here.",
"admin.scim.loadFailed": "Failed to load the SCIM provisioning configuration.",
"admin.scim.provisionedGroups": "Groups managed by the directory",
"admin.scim.provisionedUsers": "Users managed by the directory",
"admin.scim.proxyWarning": "This wiki does not trust proxy headers, so every request looks like it comes from the proxy rather than from your identity provider — an allow list will be matched against the wrong address. Enable Trust Proxy, under Security, first.",
"admin.scim.rateLimitBan": "Ban duration",
"admin.scim.rateLimitBanHint": "How long an address is refused once it goes over. Keep it short: a connector that trips the limit should recover on its next cycle rather than leave provisioning broken.",
"admin.scim.rateLimitEnabled": "Rate limit requests",
"admin.scim.rateLimitEnabledHint": "Counted per client address, and shared across instances. Requests are counted whether or not they succeed.",
"admin.scim.rateLimitMax": "Requests allowed",
"admin.scim.rateLimitMaxHint": "Set this well above what one sync costs. A directory's first run is every user it has, back to back — that is the endpoint working, not abusing it.",
"admin.scim.rateLimitMaxSuffix": "per window",
"admin.scim.rateLimitWindow": "Window",
"admin.scim.rateLimitWindowHint": "How long the count runs for before it starts again.",
"admin.scim.reference": "Reference",
"admin.scim.refreshSuccess": "SCIM provisioning configuration has been refreshed.",
"admin.scim.saveFailed": "Failed to save the SCIM provisioning configuration.",
"admin.scim.saveSuccess": "SCIM provisioning configuration saved successfully.",
"admin.scim.status": "Status",
"admin.scim.statusHint": "The last request is held in memory by whichever instance answered it, so it is empty after a restart.",
"admin.scim.subtitle": "Let an identity provider create and deactivate accounts",
"admin.scim.tenantUrl": "Tenant URL",
"admin.scim.tenantUrlCopied": "Tenant URL copied to clipboard.",
"admin.scim.tenantUrlHint": "What your identity provider asks for as the SCIM endpoint, base URL or tenant URL.",
"admin.scim.title": "SCIM Provisioning",
"admin.scim.toggleStateDisabledSuccess": "SCIM provisioning disabled successfully.",
"admin.scim.toggleStateEnabledSuccess": "SCIM provisioning enabled successfully.",
"admin.scim.toggleStateFailed": "Failed to switch the SCIM provisioning state.",
"admin.search.configSaveSuccess": "Search engine configuration saved successfully.", "admin.search.configSaveSuccess": "Search engine configuration saved successfully.",
"admin.search.dictOverrides": "PostgreSQL Dictionary Mapping Overrides", "admin.search.dictOverrides": "PostgreSQL Dictionary Mapping Overrides",
"admin.search.dictOverridesHint": "JSON object of 2 letters locale codes and their PostgreSQL dictionary association. e.g. {0}", "admin.search.dictOverridesHint": "JSON object of 2 letters locale codes and their PostgreSQL dictionary association. e.g. {0}",
@ -1300,6 +1359,7 @@
"admin.users.profile": "User Profile", "admin.users.profile": "User Profile",
"admin.users.pronouns": "Pronouns", "admin.users.pronouns": "Pronouns",
"admin.users.pronounsHint": "The pronouns used to address this user.", "admin.users.pronounsHint": "The pronouns used to address this user.",
"admin.users.provisioned": "Managed by the directory",
"admin.users.pwdAuthActive": "Can Use Password Authentication", "admin.users.pwdAuthActive": "Can Use Password Authentication",
"admin.users.pwdAuthActiveHint": "Whether the user can login using the password authentication.", "admin.users.pwdAuthActiveHint": "Whether the user can login using the password authentication.",
"admin.users.pwdAuthRestrict": "Restrict Password Authentication", "admin.users.pwdAuthRestrict": "Restrict Password Authentication",

@ -123,6 +123,7 @@ export const AUDIT_ACTIONS = {
'installExtension', 'installExtension',
'updateApiState', 'updateApiState',
'updateMetricsState', 'updateMetricsState',
'updateScimState',
'disconnectWebsockets', 'disconnectWebsockets',
'flushCache', 'flushCache',
'regenerateCertificates', 'regenerateCertificates',

@ -21,14 +21,21 @@ export const SYSTEM_PERMISSION = 'manage:system'
* `manage:groups` handing back `manage:users`, with neither step looking like an escalation on its * `manage:groups` handing back `manage:users`, with neither step looking like an escalation on its
* own. * own.
* *
* `manage:system` is the one that also bypasses every route check; the other four get here by being * `manage:system` is the one that also bypasses every route check; the others get here by being able
* able to rewrite who holds what. * to rewrite who holds what.
*
* `manage:scim` is on the list for exactly that reason and not because of what it is called: a SCIM
* client creates accounts and sets their group membership, which is the same power `write:groups`
* has when it staffs an ordinary group — and that one is here too. What keeps it from being a route
* to the elevated permissions themselves is the membership guard in `helpers/userGuards.ts`, which a
* SCIM request passes through like any other caller.
*/ */
export const ELEVATED_PERMISSIONS = [ export const ELEVATED_PERMISSIONS = [
'write:users', 'write:users',
'manage:users', 'manage:users',
'write:groups', 'write:groups',
'manage:groups', 'manage:groups',
'manage:scim',
SYSTEM_PERMISSION SYSTEM_PERMISSION
] as const ] as const
@ -73,6 +80,8 @@ export interface GroupWithUserCount {
redirectOnFirstLogin: string redirectOnFirstLogin: string
redirectOnLogout: string redirectOnLogout: string
isSystem: boolean isSystem: boolean
/** Whether a SCIM client owns this group, which is what the admin list badges. */
isProvisioned: boolean
userCount: number userCount: number
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
@ -86,6 +95,9 @@ export interface GroupPatch {
redirectOnLogout?: string redirectOnLogout?: string
permissions?: string[] permissions?: string[]
rules?: GroupRule[] rules?: GroupRule[]
/** SCIM's bookkeeping; see the same two fields on `UserPatch`. */
isProvisioned?: boolean
externalId?: string | null
} }
/** /**
@ -131,6 +143,7 @@ const groupSelection = {
redirectOnFirstLogin: groupsTable.redirectOnFirstLogin, redirectOnFirstLogin: groupsTable.redirectOnFirstLogin,
redirectOnLogout: groupsTable.redirectOnLogout, redirectOnLogout: groupsTable.redirectOnLogout,
isSystem: groupsTable.isSystem, isSystem: groupsTable.isSystem,
isProvisioned: groupsTable.isProvisioned,
createdAt: groupsTable.createdAt, createdAt: groupsTable.createdAt,
updatedAt: groupsTable.updatedAt, updatedAt: groupsTable.updatedAt,
userCount: count(userGroups.userId) userCount: count(userGroups.userId)

@ -24,6 +24,7 @@ import { pageWatching } from './pageWatching.ts'
import { passkeys } from './passkeys.ts' import { passkeys } from './passkeys.ts'
import { rateLimits } from './rateLimits.ts' import { rateLimits } from './rateLimits.ts'
import { rendering } from './rendering.ts' import { rendering } from './rendering.ts'
import { scim } from './scim.ts'
import { search } from './search.ts' import { search } from './search.ts'
import { security } from './security.ts' import { security } from './security.ts'
import { sessions } from './sessions.ts' import { sessions } from './sessions.ts'
@ -61,6 +62,7 @@ export default {
passkeys, passkeys,
rateLimits, rateLimits,
rendering, rendering,
scim,
search, search,
security, security,
sessions, sessions,

File diff suppressed because it is too large Load Diff

@ -130,6 +130,20 @@ class Settings {
includeWiki: false includeWiki: false
} }
}, },
{
key: 'scim',
value: {
isEnabled: false,
deleteAction: 'deactivate',
emailSource: 'userName',
allowGroupCreate: true,
rateLimitEnabled: true,
rateLimitMax: 600,
rateLimitWindow: '1m',
rateLimitBan: '1m',
ipAllowList: []
}
},
{ {
key: 'search', key: 'search',
value: { value: {

@ -29,6 +29,8 @@ export interface UserCore {
isSystem: boolean isSystem: boolean
isActive: boolean isActive: boolean
isVerified: boolean isVerified: boolean
/** Whether a SCIM client owns this account, which is what the admin list badges. */
isProvisioned: boolean
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
lastLoginAt: Date | null lastLoginAt: Date | null
@ -93,6 +95,13 @@ export interface UserPatch {
handle?: string | null handle?: string | null
isActive?: boolean isActive?: boolean
isVerified?: boolean isVerified?: boolean
/**
* SCIM's bookkeeping, and only SCIM writes either: whether a directory owns this account and what
* that directory calls it. The admin API's update route enumerates the fields it accepts, so
* neither is reachable from a browser — see `models/scim.ts`.
*/
isProvisioned?: boolean
externalId?: string | null
meta?: Record<string, any> meta?: Record<string, any>
prefs?: Record<string, any> prefs?: Record<string, any>
} }
@ -251,6 +260,7 @@ const userSelection = {
isSystem: usersTable.isSystem, isSystem: usersTable.isSystem,
isActive: usersTable.isActive, isActive: usersTable.isActive,
isVerified: usersTable.isVerified, isVerified: usersTable.isVerified,
isProvisioned: usersTable.isProvisioned,
createdAt: usersTable.createdAt, createdAt: usersTable.createdAt,
updatedAt: usersTable.updatedAt, updatedAt: usersTable.updatedAt,
lastLoginAt: usersTable.lastLoginAt lastLoginAt: usersTable.lastLoginAt
@ -450,6 +460,8 @@ class Users {
isSystem: user.isSystem, isSystem: user.isSystem,
isActive: user.isActive, isActive: user.isActive,
isVerified: user.isVerified, isVerified: user.isVerified,
isProvisioned: user.isProvisioned,
externalId: user.externalId,
createdAt: user.createdAt, createdAt: user.createdAt,
updatedAt: user.updatedAt, updatedAt: user.updatedAt,
lastLoginAt: user.lastLoginAt, lastLoginAt: user.lastLoginAt,
@ -461,7 +473,7 @@ class Users {
} }
/** /**
* Create a new user, authenticated against the local strategy. * Create a new user.
* *
* @returns The new user's ID * @returns The new user's ID
*/ */
@ -472,11 +484,21 @@ class Users {
groups = [], groups = [],
mustChangePassword = false, mustChangePassword = false,
isVerified = true, isVerified = true,
isProvisioned = false,
externalId,
strategyId strategyId
}: { }: {
name: string name: string
email: string email: string
password: string /**
* The local-strategy password, for an account that has one.
*
* Omitted for an account that authenticates somewhere else — one created by a provider login or
* by SCIM. Such a user gets no entry in `auth` at all, rather than an entry holding a random
* string nothing can sign in with: an empty blob is what `getProfileAuthMethods` reads as "this
* account has no password", and a hash of a value nobody holds reads as though it had one.
*/
password?: string
groups?: string[] groups?: string[]
mustChangePassword?: boolean mustChangePassword?: boolean
/** /**
@ -485,6 +507,10 @@ class Users {
* registration email or by an administrator marking the account verified. * registration email or by an administrator marking the account verified.
*/ */
isVerified?: boolean isVerified?: boolean
/** Whether a SCIM client owns this account from the moment it exists. See `models/scim.ts`. */
isProvisioned?: boolean
/** What the directory provisioning it calls it, for an account SCIM created. */
externalId?: string | null
/** /**
* Which local strategy the password is filed under. Defaults to the built-in one, which is where * Which local strategy the password is filed under. Defaults to the built-in one, which is where
* every account seeded or created by an administrator keeps it. * every account seeded or created by an administrator keeps it.
@ -501,7 +527,8 @@ class Users {
.values({ .values({
email: email.toLowerCase(), email: email.toLowerCase(),
name, name,
auth: { auth: password
? {
[localStrategyId]: { [localStrategyId]: {
password: await bcrypt.hash(password, 12), password: await bcrypt.hash(password, 12),
mustChangePwd: mustChangePassword, mustChangePwd: mustChangePassword,
@ -510,10 +537,13 @@ class Users {
tfaRequired: false, tfaRequired: false,
tfaSecret: '' tfaSecret: ''
} }
}, }
: {},
isSystem: false, isSystem: false,
isActive: true, isActive: true,
isVerified, isVerified,
isProvisioned,
externalId: externalId ?? null,
meta: { meta: {
location: '', location: '',
jobTitle: '', jobTitle: '',
@ -536,7 +566,7 @@ class Users {
} }
WIKI.models.flags.authDebug( WIKI.models.flags.authDebug(
`Created user ${userId} <${email.toLowerCase()}> in ${groups.length} group(s), mustChangePwd: ${mustChangePassword}, verified: ${isVerified}` `Created user ${userId} <${email.toLowerCase()}> in ${groups.length} group(s), password: ${password ? 'yes' : 'no'}, mustChangePwd: ${mustChangePassword}, verified: ${isVerified}`
) )
await WIKI.models.hooks.emit('user:join', { await WIKI.models.hooks.emit('user:join', {
@ -1519,9 +1549,8 @@ class Users {
const userId = await this.createUser({ const userId = await this.createUser({
name: profile.name || email, name: profile.name || email,
email, email,
// -> Nothing signs in with it: this account authenticates at the provider, and the local // -> No password at all: this account authenticates at the provider, and the local strategy's
// strategy's own entry is what a password would live under // own entry is what one would live under
password: nanoid(32),
groups: strategy.autoEnrollGroups ?? [], groups: strategy.autoEnrollGroups ?? [],
isVerified: true isVerified: true
}) })

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="96px" height="96px" fill-rule="nonzero"><defs><linearGradient x1="22.011" y1="4.011" x2="26.212" y2="8.213" gradientUnits="userSpaceOnUse" id="color-1"><stop offset="0" stop-color="#33d4f0"></stop><stop offset="1" stop-color="#0a85d9"></stop></linearGradient><linearGradient x1="6.011" y1="13.011" x2="10.213" y2="17.212" gradientUnits="userSpaceOnUse" id="color-2"><stop offset="0" stop-color="#33d4f0"></stop><stop offset="1" stop-color="#0a85d9"></stop></linearGradient><linearGradient x1="6.011" y1="31.011" x2="10.213" y2="35.212" gradientUnits="userSpaceOnUse" id="color-3"><stop offset="0" stop-color="#33d4f0"></stop><stop offset="1" stop-color="#0a85d9"></stop></linearGradient><linearGradient x1="38.011" y1="13.011" x2="42.212" y2="17.212" gradientUnits="userSpaceOnUse" id="color-4"><stop offset="0" stop-color="#33d4f0"></stop><stop offset="1" stop-color="#0a85d9"></stop></linearGradient><linearGradient x1="38.011" y1="31.011" x2="42.212" y2="35.212" gradientUnits="userSpaceOnUse" id="color-5"><stop offset="0" stop-color="#33d4f0"></stop><stop offset="1" stop-color="#0a85d9"></stop></linearGradient><linearGradient x1="22.011" y1="40.011" x2="26.212" y2="44.212" gradientUnits="userSpaceOnUse" id="color-6"><stop offset="0" stop-color="#33d4f0"></stop><stop offset="1" stop-color="#0a85d9"></stop></linearGradient><radialGradient cx="16.947" cy="15.416" r="22.172" gradientUnits="userSpaceOnUse" id="color-7"><stop offset="0" stop-color="#fa9f17"></stop><stop offset="0.484" stop-color="#d98200"></stop><stop offset="0.775" stop-color="#c07400"></stop><stop offset="1" stop-color="#b66f05"></stop></radialGradient><radialGradient cx="24" cy="35.059" r="8.877" gradientUnits="userSpaceOnUse" id="color-8"><stop offset="0" stop-color="#000000"></stop><stop offset="1" stop-color="#000000" stop-opacity="0"></stop></radialGradient><radialGradient cx="23.9996" cy="20.9992" r="4.9998" gradientUnits="userSpaceOnUse" id="color-9"><stop offset="0" stop-color="#000000"></stop><stop offset="1" stop-color="#000000" stop-opacity="0"></stop></radialGradient><linearGradient x1="21.286" y1="18.286" x2="26.867" y2="23.867" gradientUnits="userSpaceOnUse" id="color-10"><stop offset="0" stop-color="#ffc875"></stop><stop offset="1" stop-color="#e4951e"></stop></linearGradient><linearGradient x1="20.154" y1="27.965" x2="27.788" y2="35.6" gradientUnits="userSpaceOnUse" id="color-11"><stop offset="0" stop-color="#ffc875"></stop><stop offset="1" stop-color="#e4951e"></stop></linearGradient></defs><g fill="none" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(5.33333,5.33333)"><rect x="-9.21526" y="31.78411" transform="rotate(-30)" width="36" height="2" fill="#64717c"></rect><rect x="-50.78461" y="-9.78461" transform="rotate(-150)" width="36" height="2" fill="#64717c"></rect><rect x="6" y="-25" transform="rotate(90)" width="36" height="2" fill="#64717c"></rect><circle cx="24" cy="6" r="3" fill="url(#color-1)"></circle><circle cx="8" cy="15" r="3" fill="url(#color-2)"></circle><circle cx="8" cy="33" r="3" fill="url(#color-3)"></circle><circle cx="40" cy="15" r="3" fill="url(#color-4)"></circle><circle cx="40" cy="33" r="3" fill="url(#color-5)"></circle><circle cx="24" cy="42" r="3" fill="url(#color-6)"></circle><path d="M35,24c0,6.075 -4.925,11 -11,11c-6.075,0 -11,-4.925 -11,-11c0,-6.075 4.925,-11 11,-11c6.075,0 11,4.925 11,11z" fill="url(#color-7)"></path><path d="M24,35c3.187,0 6.049,-1.364 8.058,-3.53c-1.361,-3.101 -4.454,-5.27 -8.058,-5.27c-3.604,0 -6.697,2.169 -8.058,5.27c2.009,2.166 4.871,3.53 8.058,3.53z" fill="url(#color-8)"></path><circle cx="24" cy="21" r="5" fill="url(#color-9)"></circle><circle cx="24" cy="21" r="4" fill="url(#color-10)"></circle><path d="M24,35c2.758,0 5.273,-1.023 7.204,-2.7c-1.095,-2.919 -3.903,-5 -7.204,-5c-3.301,0 -6.109,2.081 -7.204,5c1.931,1.677 4.446,2.7 7.204,2.7z" fill="url(#color-11)"></path></g></g></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><radialGradient id="U82P9tORUQOQwN6q6fq4Ja" cx="28.686" cy="21.073" r="17.032" gradientUnits="userSpaceOnUse"><stop offset=".683" stop-color="#c24717"/><stop offset=".756" stop-color="#bb4417"/><stop offset=".862" stop-color="#a83b18"/><stop offset=".987" stop-color="#892c1a"/><stop offset="1" stop-color="#852a1a"/></radialGradient><path fill="url(#U82P9tORUQOQwN6q6fq4Ja)" d="M32.002,8.271l0.162-0.496c0.33-1.011-0.184-2.12-1.18-2.493 C21.73,1.815,11.12,5.639,6.348,14.597c-3.137,5.89-3.031,12.643-0.305,18.212l5.354-2.71c-1.876-3.885-1.937-8.582,0.246-12.681 c3.363-6.314,10.947-9.173,18.085-7.811C30.717,9.795,31.69,9.228,32.002,8.271z"/><radialGradient id="U82P9tORUQOQwN6q6fq4Jb" cx="-243.314" cy="-250.927" r="17.032" gradientTransform="rotate(180 -112 -112)" gradientUnits="userSpaceOnUse"><stop offset=".683" stop-color="#c24717"/><stop offset=".756" stop-color="#bb4417"/><stop offset=".862" stop-color="#a83b18"/><stop offset=".987" stop-color="#892c1a"/><stop offset="1" stop-color="#852a1a"/></radialGradient><path fill="url(#U82P9tORUQOQwN6q6fq4Jb)" d="M15.998,39.729l-0.162,0.496c-0.33,1.011,0.184,2.12,1.18,2.493 c9.253,3.467,19.864-0.357,24.635-9.315c3.137-5.89,3.031-12.643,0.305-18.212l-5.354,2.71c1.876,3.885,1.937,8.582-0.246,12.681 c-3.363,6.314-10.947,9.173-18.085,7.811C17.283,38.205,16.31,38.772,15.998,39.729z"/><linearGradient id="U82P9tORUQOQwN6q6fq4Jc" x1="12.838" x2="34.961" y1="7.678" y2="40.027" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fed100"/><stop offset="1" stop-color="#e36001"/></linearGradient><path fill="url(#U82P9tORUQOQwN6q6fq4Jc)" d="M10,24c0,2.004,0.436,4.006,1.291,5.861l2.48-1.26c0.699-0.355,1.478,0.312,1.235,1.057 l-2.439,7.482c-0.214,0.656-0.919,1.014-1.575,0.8L3.51,35.501c-0.745-0.243-0.824-1.265-0.126-1.62l2.563-1.303 c-3.528-7.427-2.235-16.574,3.911-22.72C13.763,5.953,18.881,4,24,4C24,4,10,10,10,24z M44.49,12.499l-7.482-2.439 c-0.656-0.214-1.361,0.145-1.575,0.8l-2.439,7.482c-0.243,0.745,0.536,1.412,1.235,1.057l2.48-1.26C37.564,19.994,38,21.996,38,24 c0,14-14,20-14,20c5.119,0,10.237-1.952,14.142-5.857c6.146-6.146,7.439-15.293,3.911-22.72l2.563-1.303 C45.315,13.765,45.235,12.742,44.49,12.499z"/></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts. removing an icon; `check-icons.mjs` fails the build if this drifts.
275 icons. 276 icons.
*/ */
export const BUNDLED_ICONS = { export const BUNDLED_ICONS = {
"la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32}, "la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32},
@ -57,6 +57,7 @@ export const BUNDLED_ICONS = {
"la:ellipsis-v": {"body":"<path fill=\"currentColor\" d=\"M16 6a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m0 8a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m0 8a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4\"/>","width":32,"height":32}, "la:ellipsis-v": {"body":"<path fill=\"currentColor\" d=\"M16 6a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m0 8a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m0 8a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4\"/>","width":32,"height":32},
"la:envelope": {"body":"<path fill=\"currentColor\" d=\"M3 8v18h26V8zm4.313 2h17.375L16 15.781zM5 10.875l10.438 6.969l.562.343l.563-.343L27 10.875V24H5z\"/>","width":32,"height":32}, "la:envelope": {"body":"<path fill=\"currentColor\" d=\"M3 8v18h26V8zm4.313 2h17.375L16 15.781zM5 10.875l10.438 6.969l.562.343l.563-.343L27 10.875V24H5z\"/>","width":32,"height":32},
"la:eraser": {"body":"<path fill=\"currentColor\" d=\"M18.906 4.094c-.804 0-1.64.273-2.281.843v.032L16.594 5L4.906 16.594c-1.21 1.21-1.203 3.183-.062 4.468l.031.032h.031l6 6c1.211 1.21 3.184 1.203 4.469.062v-.031L27 15.5c1.266-1.266 1.305-3.29.094-4.5l-6-6a3.06 3.06 0 0 0-2.188-.906m-.031 2.031c.32 0 .617.086.813.281l6 6c.386.387.44 1.153-.094 1.688l-5.032 5.031l-7.656-7.656l5.063-5.031l.031-.032c.254-.21.57-.281.875-.281m-7.406 6.781l7.656 7.656l-5.094 5.094c-.011.008-.02.024-.031.032c-.516.43-1.309.378-1.688 0L6.345 19.75c-.016-.02-.016-.043-.032-.063c-.41-.515-.375-1.312 0-1.687z\"/>","width":32,"height":32}, "la:eraser": {"body":"<path fill=\"currentColor\" d=\"M18.906 4.094c-.804 0-1.64.273-2.281.843v.032L16.594 5L4.906 16.594c-1.21 1.21-1.203 3.183-.062 4.468l.031.032h.031l6 6c1.211 1.21 3.184 1.203 4.469.062v-.031L27 15.5c1.266-1.266 1.305-3.29.094-4.5l-6-6a3.06 3.06 0 0 0-2.188-.906m-.031 2.031c.32 0 .617.086.813.281l6 6c.386.387.44 1.153-.094 1.688l-5.032 5.031l-7.656-7.656l5.063-5.031l.031-.032c.254-.21.57-.281.875-.281m-7.406 6.781l7.656 7.656l-5.094 5.094c-.011.008-.02.024-.031.032c-.516.43-1.309.378-1.688 0L6.345 19.75c-.016-.02-.016-.043-.032-.063c-.41-.515-.375-1.312 0-1.687z\"/>","width":32,"height":32},
"la:exclamation-circle": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m0 2c5.535 0 10 4.465 10 10s-4.465 10-10 10S6 21.535 6 16S10.465 6 16 6m-1 4v8h2v-8zm0 10v2h2v-2z\"/>","width":32,"height":32},
"la:exclamation-triangle": {"body":"<path fill=\"currentColor\" d=\"m16 3.219l-.875 1.5l-12 20.781l-.844 1.5H29.72l-.844-1.5l-12-20.781zm0 4L26.25 25H5.75zM15 14v6h2v-6zm0 7v2h2v-2z\"/>","width":32,"height":32}, "la:exclamation-triangle": {"body":"<path fill=\"currentColor\" d=\"m16 3.219l-.875 1.5l-12 20.781l-.844 1.5H29.72l-.844-1.5l-12-20.781zm0 4L26.25 25H5.75zM15 14v6h2v-6zm0 7v2h2v-2z\"/>","width":32,"height":32},
"la:external-link-alt": {"body":"<path fill=\"currentColor\" d=\"M18 5v2h5.563L11.28 19.281l1.438 1.438L25 8.437V14h2V5zM5 9v18h18V14l-2 2v9H7V11h9l2-2z\"/>","width":32,"height":32}, "la:external-link-alt": {"body":"<path fill=\"currentColor\" d=\"M18 5v2h5.563L11.28 19.281l1.438 1.438L25 8.437V14h2V5zM5 9v18h18V14l-2 2v9H7V11h9l2-2z\"/>","width":32,"height":32},
"la:external-link-square-alt": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h18v18H7zm6 3v2h5.563L9.28 21.281l1.438 1.438L20 13.437V19h2v-9z\"/>","width":32,"height":32}, "la:external-link-square-alt": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h18v18H7zm6 3v2h5.563L9.28 21.281l1.438 1.438L20 13.437V19h2v-9z\"/>","width":32,"height":32},

@ -994,6 +994,10 @@ const permissionCards = [
{ {
permission: 'manage:groups', permission: 'manage:groups',
hint: 'Can create / manage groups and assign permissions (but not manage:system) / page rules' hint: 'Can create / manage groups and assign permissions (but not manage:system) / page rules'
},
{
permission: 'manage:scim',
hint: 'Can drive SCIM provisioning at /_scim/v2: create and deactivate accounts, and set the membership of groups that do not administer the wiki. Meant for an API key issued to an identity provider, not for a person.'
} }
] ]
} }

@ -249,6 +249,23 @@
:class="countBadgeClass(adminStore.info.groupsTotal)" /> :class="countBadgeClass(adminStore.info.groupsTotal)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
<!--
`manage:system` rather than `manage:scim`: this screen turns provisioning on and decides
what deprovisioning does, which is a decision ABOUT the directory's write access rather
than an exercise of it. `manage:scim` is what the connector's API key carries.
-->
<w-item
to="/_admin/scim"
active-class="bg-primary text-white"
v-if="userStore.can(`manage:system`)">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-scim.svg" />
</w-item-section>
<w-item-section>{{ t('admin.scim.title') }}</w-item-section>
<w-item-section side>
<status-light :color="scimLight.color" :pulse="scimLight.pulse" />
</w-item-section>
</w-item>
<w-item to="/_admin/users" active-class="bg-primary text-white" v-if="usersAreVisible"> <w-item to="/_admin/users" active-class="bg-primary text-white" v-if="usersAreVisible">
<w-item-section avatar> <w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-account.svg" /> <w-icon name="img:/_assets/icons/fluent-account.svg" />
@ -632,6 +649,26 @@ function countBadgeClass(count) {
*/ */
const storageHealthy = computed(() => adminStore.storageHealth.status === 'healthy') const storageHealthy = computed(() => adminStore.storageHealth.status === 'healthy')
/**
* The SCIM item's light, which has three states rather than the usual on/off.
*
* Provisioning being switched on is not the same as it working: a connector arrives holding an API
* key, and every API key is refused while the REST API master switch is off. So a wiki with SCIM
* enabled and the API disabled is configured for something it cannot actually do, and the light
* says so in orange rather than claiming green — pulsing, because it is a state somebody has to go
* and fix rather than one to be read and left alone.
*
* Off is red like every other endpoint light here: nothing is wrong, it is simply not serving.
*/
const scimLight = computed(() => {
if (!adminStore.info.isScimEnabled) {
return { color: 'negative', pulse: false }
}
return adminStore.info.isApiEnabled
? { color: 'positive', pulse: false }
: { color: 'warning', pulse: true }
})
// WATCHERS // WATCHERS
watch( watch(

@ -70,6 +70,14 @@
<div class="flex items-center"> <div class="flex items-center">
<strong>{{ props.value }}</strong> <strong>{{ props.value }}</strong>
<w-icon class="ml-2" v-if="props.row.isSystem" name="la:lock" color="pink" /> <w-icon class="ml-2" v-if="props.row.isSystem" name="la:lock" color="pink" />
<!-- -> A group a directory owns, which is the one kind SCIM may delete -->
<w-icon
class="ml-2"
v-if="props.row.isProvisioned"
name="la:cloud-download-alt"
color="blue-grey">
<w-tooltip>{{ t('admin.groups.provisioned') }}</w-tooltip>
</w-icon>
</div> </div>
</w-td> </w-td>
</template> </template>

@ -0,0 +1,624 @@
<template>
<w-page class="admin-scim">
<div class="flex flex-wrap p-4 items-center">
<div class="flex-none">
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-scim.svg" />
</div>
<div class="min-w-0 flex-1 pl-4">
<div class="text-h5 admin-page-title animated fadeInLeft">
{{ t('admin.scim.title') }}
</div>
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
{{ t('admin.scim.subtitle') }}
</div>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center">
<template v-if="state.enabled">
<w-signal class="mr-2" color="green" size="md" />
<div class="text-caption text-green">{{ t('admin.scim.enabled') }}</div>
</template>
<template v-else>
<w-signal class="mr-2" color="red" size="md" />
<div class="text-caption text-red">{{ t('admin.scim.disabled') }}</div>
</template>
</div>
</div>
<div class="flex-none">
<w-btn
class="mr-2 ml-4 acrylic-btn"
icon="la:question-circle"
flat
color="grey"
:aria-label="t(`common.actions.viewDocs`)"
:href="siteStore.docsBase + `/admin/scim`"
target="_blank">
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
</w-btn>
<w-btn
class="acrylic-btn mr-2"
icon="la:redo-alt"
flat
color="secondary"
:loading="state.loading > 0"
:aria-label="t(`common.actions.refresh`)"
@click="refresh">
<w-tooltip>{{ t(`common.actions.refresh`) }}</w-tooltip>
</w-btn>
<w-btn
class="mr-2"
unelevated
icon="la:power-off"
:label="!state.enabled ? t(`common.actions.activate`) : t(`common.actions.deactivate`)"
:color="!state.enabled ? `positive` : `negative`"
@click="globalSwitch"
:loading="state.isToggleLoading"
:disabled="state.loading > 0" />
<w-btn
unelevated
icon="mdi:check"
:label="t(`common.actions.apply`)"
color="secondary"
@click="save"
:loading="state.loading > 0" />
</div>
</div>
<w-separator inset />
<div class="grid grid-cols-12 p-4 gap-4">
<div class="col-span-12 lg:col-span-6">
<!-- ----------------------- -->
<!-- Configuration -->
<!-- ----------------------- -->
<w-card class="pb-2">
<w-card-header>{{ t('admin.scim.configuration') }}</w-card-header>
<w-item>
<blueprint-icon icon="trash" top />
<w-item-section>
<w-item-label>{{ t(`admin.scim.deleteAction`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.scim.deleteActionHint`) }}</w-item-label>
<div class="mt-3 flex flex-col gap-3">
<div>
<w-radio
v-model="state.config.deleteAction"
val="deactivate"
:label="t(`admin.scim.deleteActionDeactivate`)" />
<div class="pl-7 text-caption text-grey">
{{ t('admin.scim.deleteActionDeactivateHint') }}
</div>
</div>
<div>
<w-radio
v-model="state.config.deleteAction"
val="delete"
color="negative"
:label="t(`admin.scim.deleteActionDelete`)" />
<!-- -> Under the option rather than in the hint above: losing every page's
authorship is the cost of this choice specifically -->
<div class="pl-7 text-caption text-negative flex items-start">
<w-icon class="mr-1 mt-px" name="la:exclamation-triangle" size="xs" />
<span>{{ t('admin.scim.deleteActionDeleteWarning') }}</span>
</div>
</div>
</div>
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="email" top />
<w-item-section>
<w-item-label>{{ t(`admin.scim.emailSource`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.scim.emailSourceHint`) }}</w-item-label>
<div class="mt-3 flex flex-col gap-3">
<w-radio
v-model="state.config.emailSource"
val="userName"
:label="t(`admin.scim.emailSourceUserName`)" />
<w-radio
v-model="state.config.emailSource"
val="emails"
:label="t(`admin.scim.emailSourceEmails`)" />
</div>
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<!-- -> `tag="label"` makes the whole row operate the toggle, the way the editor config
rows do: the browser forwards the click to the labelable control inside, so the label
and its hint are part of the target rather than text beside one. -->
<w-item tag="label">
<blueprint-icon icon="user-groups" top />
<w-item-section>
<w-item-label>{{ t(`admin.scim.allowGroupCreate`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.scim.allowGroupCreateHint`) }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-toggle
v-model="state.config.allowGroupCreate"
color="primary"
:aria-label="t(`admin.scim.allowGroupCreate`)" />
</w-item-section>
</w-item>
</w-card>
<!-- ----------------------- -->
<!-- Access -->
<!-- ----------------------- -->
<w-card class="pb-2 mt-4">
<w-card-header>
{{ t('admin.scim.access') }}
<template #hint>{{ t('admin.scim.accessHint') }}</template>
</w-card-header>
<w-item tag="label">
<blueprint-icon icon="filtration" top />
<w-item-section>
<w-item-label>{{ t(`admin.scim.rateLimitEnabled`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.scim.rateLimitEnabledHint`) }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-toggle
v-model="state.config.rateLimitEnabled"
color="primary"
:aria-label="t(`admin.scim.rateLimitEnabled`)" />
</w-item-section>
</w-item>
<!-- -> The three numbers only mean anything while the limit is on, so they are not shown
greyed out beside it; the Security screen's own limit does the same. -->
<template v-if="state.config.rateLimitEnabled">
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="pin-pad" />
<w-item-section>
<w-item-label>{{ t(`admin.scim.rateLimitMax`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.scim.rateLimitMaxHint`) }}</w-item-label>
</w-item-section>
<w-item-section style="flex: 0 0 200px">
<w-input
outlined
dense
v-model.number="state.config.rateLimitMax"
:suffix="t(`admin.scim.rateLimitMaxSuffix`)"
:aria-label="t(`admin.scim.rateLimitMax`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="timer" />
<w-item-section>
<w-item-label>{{ t(`admin.scim.rateLimitWindow`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.scim.rateLimitWindowHint`) }}</w-item-label>
</w-item-section>
<w-item-section style="flex: 0 0 200px">
<w-input
outlined
dense
v-model="state.config.rateLimitWindow"
:placeholder="t(`admin.scim.durationPlaceholder`)"
:aria-label="t(`admin.scim.rateLimitWindow`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="denied" />
<w-item-section>
<w-item-label>{{ t(`admin.scim.rateLimitBan`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.scim.rateLimitBanHint`) }}</w-item-label>
</w-item-section>
<w-item-section style="flex: 0 0 200px">
<w-input
outlined
dense
v-model="state.config.rateLimitBan"
:placeholder="t(`admin.scim.durationPlaceholder`)"
:aria-label="t(`admin.scim.rateLimitBan`)" />
</w-item-section>
</w-item>
</template>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="firewall" top />
<w-item-section>
<w-item-label>{{ t(`admin.scim.ipAllowList`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.scim.ipAllowListHint`) }}</w-item-label>
<!-- -> An address only means what it looks like when the proxy headers are trusted,
so a list written against the directory's published egress ranges would refuse
everybody. Same warning the Metrics screen carries over its address classes. -->
<w-item-label
v-if="!state.trustProxy"
class="text-caption text-deep-orange mt-2 flex items-start">
<w-icon class="mr-1 mt-px" name="la:exclamation-triangle" size="xs" />
<span>{{ t('admin.scim.proxyWarning') }}</span>
</w-item-label>
<w-input
class="mt-2"
outlined
dense
type="textarea"
:rows="4"
v-model="state.ipAllowListText"
:placeholder="t(`admin.scim.ipAllowListPlaceholder`)"
:aria-label="t(`admin.scim.ipAllowList`)" />
<!-- -> Says which of the two states the box is currently in, because an empty box
meaning "everybody" is the opposite of what an empty allow list usually means -->
<div class="text-caption text-grey mt-1">
{{
ipAllowListEntries.length > 0
? t('admin.scim.ipAllowListRestricted', { count: ipAllowListEntries.length })
: t('admin.scim.ipAllowListOpen')
}}
</div>
</w-item-section>
</w-item>
</w-card>
</div>
<div class="col-span-12 lg:col-span-6">
<!-- ----------------------- -->
<!-- Reference -->
<!-- ----------------------- -->
<w-card class="pb-2">
<w-card-header>{{ t('admin.scim.reference') }}</w-card-header>
<w-item>
<blueprint-icon icon="link" top />
<w-item-section>
<w-item-label>{{ t(`admin.scim.tenantUrl`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.scim.tenantUrlHint`) }}</w-item-label>
<!--
-> Drawn as a read-only field rather than as a line of text: this is a value to be
taken away and pasted somewhere else, and a box says "select me" where a paragraph
does not. The fill is the one `WInput` gives its filled variant and the border the
pair the rest of the library draws its edges with, so it sits in the card as an
input would without being one.
-> The copy button belongs to the BOX, so it lives in this row rather than in a
`side` section of the item: a side section is centred against the whole row --
label, hint and box together -- which left the button floating opposite the hint
instead of opposite the value it copies. `min-w-0` is what lets the box wrap
inside the flex row rather than push the button off the end.
-->
<div class="mt-2 flex items-center gap-2">
<div
class="text-caption font-robotomono min-w-0 flex-1 break-all rounded border border-black/12 bg-black/4 px-3 py-2 dark:border-white/15 dark:bg-white/6">
{{ tenantUrl }}
</div>
<w-btn
class="acrylic-btn shrink-0"
icon="la:copy"
flat
dense
color="secondary"
:aria-label="t(`common.actions.copy`)"
@click="copyTenantUrl">
<w-tooltip>{{ t(`common.actions.copy`) }}</w-tooltip>
</w-btn>
</div>
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="key" top />
<w-item-section>
<w-item-label>{{ t(`admin.scim.auth`) }}</w-item-label>
<w-item-label caption>
<i18n-t keypath="admin.scim.authHint" scope="global">
<template #permission>
<strong class="font-robotomono">manage:scim</strong>
</template>
</i18n-t>
</w-item-label>
<div class="text-caption mt-2">
<i18n-t keypath="admin.scim.authApiKey" scope="global">
<template #headerName>
<strong class="font-robotomono">Authorization</strong>
</template>
<template #tokenType><strong class="font-robotomono">Bearer</strong></template>
</i18n-t>
</div>
<!-- -> Boxed like the tenant URL above, and for the same reason: it is a literal to
be copied into a connector's configuration rather than prose to be read. -->
<div
class="text-caption font-robotomono mt-2 break-all rounded border border-black/12 bg-black/4 px-3 py-2 dark:border-white/15 dark:bg-white/6">
Authorization: Bearer API-KEY-VALUE
</div>
<div class="mt-3">
<!--
-> Solid: minting the key is the one thing this card asks somebody to go and do, so
it is an action rather than a link out. `WBtn` gives a solid button white text on
its own, so no `text-color` is needed.
-> `dense` sets one padding for both axes (Quasar's 0.285em), which leaves the icon
and the label flush against the ends. The override keeps the dense height and
puts the standard 16px back on the sides, as the dialog action buttons do.
-->
<w-btn
unelevated
dense
padding="xs md"
color="primary"
icon="la:external-link-alt"
:label="t(`admin.scim.authGoToKeys`)"
to="/_admin/api" />
</div>
</w-item-section>
</w-item>
</w-card>
<!-- ----------------------- -->
<!-- Status -->
<!-- ----------------------- -->
<w-card class="mt-4">
<w-card-header>
{{ t('admin.scim.status') }}
<template #hint>{{ t('admin.scim.statusHint') }}</template>
</w-card-header>
<w-card-section class="pt-0">
<div class="flex gap-8">
<div>
<div class="text-h5">{{ state.status.users }}</div>
<div class="text-caption text-grey">{{ t('admin.scim.provisionedUsers') }}</div>
</div>
<div>
<div class="text-h5">{{ state.status.groups }}</div>
<div class="text-caption text-grey">{{ t('admin.scim.provisionedGroups') }}</div>
</div>
</div>
<w-separator class="my-3" />
<div class="text-caption text-grey">{{ t('admin.scim.lastRequest') }}</div>
<div v-if="!state.status.lastRequest" class="text-caption mt-1">
{{ t('admin.scim.lastRequestNone') }}
</div>
<template v-else>
<div class="text-caption font-robotomono mt-1 break-all">
{{ state.status.lastRequest.method }} {{ state.status.lastRequest.path }}
</div>
<div class="text-caption mt-1 flex items-center">
<w-icon
class="mr-1"
size="xs"
:name="lastRequestOk ? 'la:check-circle' : 'la:exclamation-circle'"
:class="lastRequestOk ? 'text-positive' : 'text-negative'" />
<span :class="lastRequestOk ? 'text-positive' : 'text-negative'">
{{ state.status.lastRequest.status }}
</span>
<span class="text-grey ml-2">{{ humanizeDate(state.status.lastRequest.at) }}</span>
</div>
<div v-if="state.status.lastRequest.message" class="text-caption text-grey mt-1">
{{ state.status.lastRequest.message }}
</div>
</template>
<!--
-> Every API key is refused outright while the REST API is switched off, SCIM's
included, and a connector reports that only as a 401 it cannot explain. It belongs
in Status rather than beside the settings: nothing above is wrong, the endpoint
simply cannot answer anybody until that switch is on.
-> The same solid red the Security screen uses for the two things that decide whether
the settings around them do what they look like they do. This is one of those.
-->
<w-card v-if="!state.apiEnabled" class="bg-negative text-white mt-3 rounded" flat>
<w-card-section class="items-center" horizontal>
<w-card-section class="flex-none pr-0">
<w-icon name="la:exclamation-triangle" size="lg" />
</w-card-section>
<w-card-section class="text-caption">
<div>{{ t('admin.scim.authApiDisabled') }}</div>
</w-card-section>
</w-card-section>
</w-card>
</w-card-section>
</w-card>
</div>
</div>
</w-page>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { computed, onMounted, reactive } from 'vue'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading'
import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
import { copyToClipboard } from '@/helpers/clipboard'
// STORES
const adminStore = useAdminStore()
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// META
useMeta(() => ({
title: t('admin.scim.title')
}))
// DATA
const state = reactive({
enabled: false,
loading: 0,
isToggleLoading: false,
// -> Read only, and only to warn: an API key authenticates nothing while the REST API is off
apiEnabled: true,
// -> Read only, and only to warn: with the proxy headers untrusted every request carries the
// proxy's address, so an allow list would be matched against the wrong thing
trustProxy: true,
config: {
deleteAction: 'deactivate',
emailSource: 'userName',
allowGroupCreate: true,
rateLimitEnabled: true,
rateLimitMax: 600,
rateLimitWindow: '1m',
rateLimitBan: '1m'
},
/*
The allow list is edited as text, one entry per line, and stored as an array. Kept outside
`config` for that reason: what the box holds is not what is saved, and a half-typed line must
not disappear the moment it is not yet a valid address.
*/
ipAllowListText: '',
status: {
users: 0,
groups: 0,
lastRequest: null
}
})
// COMPUTED
/*
Built from the browser's own origin rather than from anything stored: the wiki has no single
canonical hostname — a site may be bound to the catch-all — and the URL an administrator needs is
the one their identity provider can actually reach, which is the one they are looking at.
*/
const tenantUrl = computed(() => `${window.location.origin}/_scim/v2`)
const lastRequestOk = computed(() => (state.status.lastRequest?.status ?? 500) < 400)
/** The textarea as the API wants it: trimmed, blanks dropped, one entry per line or comma. */
const ipAllowListEntries = computed(() =>
state.ipAllowListText
.split(/[\n,]/)
.map((entry) => entry.trim())
.filter(Boolean)
)
// METHODS
function humanizeDate(val) {
if (!val) {
return '---'
}
return Temporal.Instant.from(val).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
})
}
async function load() {
state.loading++
loading.show()
try {
const [config, status, api, security] = await Promise.all([
API_CLIENT.get('system/scim').json(),
API_CLIENT.get('system/scim/status').json(),
API_CLIENT.get('system/api').json(),
API_CLIENT.get('system/security').json()
])
state.enabled = config?.isEnabled === true
state.config = { ...state.config, ...config }
state.status = { ...state.status, ...status }
state.apiEnabled = api?.isEnabled === true
state.trustProxy = security?.trustProxy === true
// -> Back into the box one per line; `config.ipAllowList` itself is not bound to anything
state.ipAllowListText = (config?.ipAllowList ?? []).join('\n')
/*
Keeps the status light in the admin sidebar in step without another round trip, as the Metrics
screen does for its own. Both flags, because the SCIM light reads them together: enabled with
the API off is the orange state.
*/
adminStore.info.isScimEnabled = state.enabled
adminStore.info.isApiEnabled = state.apiEnabled
} catch (err) {
notify({
type: 'negative',
message: t('admin.scim.loadFailed'),
caption: apiErrorMessage(err)
})
}
loading.hide()
state.loading--
}
async function refresh() {
await load()
notify({
type: 'positive',
message: t('admin.scim.refreshSuccess')
})
}
async function copyTenantUrl() {
await copyToClipboard(tenantUrl.value)
notify({
type: 'positive',
message: t('admin.scim.tenantUrlCopied')
})
}
async function save() {
state.loading++
try {
const resp = await API_CLIENT.put('system/scim', {
json: {
deleteAction: state.config.deleteAction,
emailSource: state.config.emailSource,
allowGroupCreate: state.config.allowGroupCreate,
rateLimitEnabled: state.config.rateLimitEnabled,
rateLimitMax: state.config.rateLimitMax,
rateLimitWindow: state.config.rateLimitWindow,
rateLimitBan: state.config.rateLimitBan,
ipAllowList: ipAllowListEntries.value
}
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occurred.')
}
notify({
type: 'positive',
message: t('admin.scim.saveSuccess')
})
await load()
} catch (err) {
notify({
type: 'negative',
message: t('admin.scim.saveFailed'),
caption: apiErrorMessage(err)
})
}
state.loading--
}
async function globalSwitch() {
state.isToggleLoading = true
const wanted = !state.enabled
try {
const resp = await API_CLIENT.put('system/scim', {
json: { isEnabled: wanted }
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occurred.')
}
notify({
type: 'positive',
message: wanted
? t('admin.scim.toggleStateEnabledSuccess')
: t('admin.scim.toggleStateDisabledSuccess')
})
await load()
} catch (err) {
notify({
type: 'negative',
message: t('admin.scim.toggleStateFailed'),
caption: apiErrorMessage(err)
})
}
state.isToggleLoading = false
}
// MOUNTED
onMounted(load)
</script>
<style lang="scss"></style>

@ -88,6 +88,14 @@
<strong>{{ props.value }}</strong> <strong>{{ props.value }}</strong>
<w-icon class="ml-2" v-if="props.row.isSystem" name="la:lock" color="pink" /> <w-icon class="ml-2" v-if="props.row.isSystem" name="la:lock" color="pink" />
<w-icon class="ml-2" v-if="!props.row.isActive" name="la:ban" color="pink" /> <w-icon class="ml-2" v-if="!props.row.isActive" name="la:ban" color="pink" />
<!-- -> An account a directory owns, which is the one kind SCIM may deprovision -->
<w-icon
class="ml-2"
v-if="props.row.isProvisioned"
name="la:cloud-download-alt"
color="blue-grey">
<w-tooltip>{{ t('admin.users.provisioned') }}</w-tooltip>
</w-icon>
</div> </div>
</w-td> </w-td>
</template> </template>

@ -107,6 +107,7 @@ const routes = [
{ path: 'auth', component: () => import('@/pages/AdminAuth.vue') }, { path: 'auth', component: () => import('@/pages/AdminAuth.vue') },
{ path: 'groups/:id?/:section?', component: () => import('@/pages/AdminGroups.vue') }, { path: 'groups/:id?/:section?', component: () => import('@/pages/AdminGroups.vue') },
{ path: 'users/:id?/:section?', component: () => import('@/pages/AdminUsers.vue') }, { path: 'users/:id?/:section?', component: () => import('@/pages/AdminUsers.vue') },
{ path: 'scim', component: () => import('@/pages/AdminScim.vue') },
// -> System // -> System
{ path: 'api', component: () => import('@/pages/AdminApi.vue') }, { path: 'api', component: () => import('@/pages/AdminApi.vue') },
{ path: 'audit', component: () => import('@/pages/AdminAudit.vue') }, { path: 'audit', component: () => import('@/pages/AdminAudit.vue') },

@ -22,6 +22,7 @@ export const useAdminStore = defineStore('admin', {
isMCPEnabled: false, isMCPEnabled: false,
isMailConfigured: false, isMailConfigured: false,
isMetricsEnabled: false, isMetricsEnabled: false,
isScimEnabled: false,
isSchedulerHealthy: false isSchedulerHealthy: false
}, },
/** /**
@ -81,6 +82,7 @@ export const useAdminStore = defineStore('admin', {
this.info.latestVersion = resp?.latestVersion ?? 'n/a' this.info.latestVersion = resp?.latestVersion ?? 'n/a'
this.info.isApiEnabled = resp?.isApiEnabled ?? false this.info.isApiEnabled = resp?.isApiEnabled ?? false
this.info.isMetricsEnabled = resp?.isMetricsEnabled ?? false this.info.isMetricsEnabled = resp?.isMetricsEnabled ?? false
this.info.isScimEnabled = resp?.isScimEnabled ?? false
this.info.isMailConfigured = resp?.isMailConfigured ?? false this.info.isMailConfigured = resp?.isMailConfigured ?? false
this.info.isSchedulerHealthy = resp?.isSchedulerHealthy ?? false this.info.isSchedulerHealthy = resp?.isSchedulerHealthy ?? false
}, },

Loading…
Cancel
Save