mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 05:54:49 -05:00
feat: add SCIM provisioning support
This commit is contained in:
32 files changed
+10372
-140
No files matched your search
+4
-55
@@ -1,60 +1,9 @@
|
||||
import { audit } from '../helpers/audit.ts'
|
||||
import { CustomError } from '../helpers/common.ts'
|
||||
import { ELEVATED_PERMISSIONS, SYSTEM_PERMISSION, isElevated } from '../models/groups.ts'
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify'
|
||||
import type { GroupPatch, GroupRule, GroupWithUserCount } from '../models/groups.ts'
|
||||
|
||||
/**
|
||||
* Refuse a change to who is in a group that administers the instance.
|
||||
*
|
||||
* Membership of such a group IS the permission: adding somebody hands them what the group can reach,
|
||||
* and removing somebody takes it away from a real administrator. Deleting the group does both at
|
||||
* once, so it asks the same question.
|
||||
*
|
||||
* Where the line falls depends on what the caller holds, and the two rungs are deliberately
|
||||
* different:
|
||||
*
|
||||
* - **`manage:groups`** is stopped only by `manage:system`, the permission that bypasses every check
|
||||
* on the server. Everything below that is theirs to arrange; managing groups is the job.
|
||||
* - **`write:groups`** is stopped by every one of `ELEVATED_PERMISSIONS`. It is the rung that may
|
||||
* build and populate ordinary groups without being trusted to decide who administers the wiki —
|
||||
* and since it cannot edit a group's permissions at all, its only route to an elevated group would
|
||||
* be through the membership of one that already exists.
|
||||
*
|
||||
* @param action What the caller was trying to do, as the message reads it back to them
|
||||
* @returns The refusal to throw, or null when the caller may proceed
|
||||
*/
|
||||
function elevatedGroupGuard(
|
||||
req: FastifyRequest,
|
||||
group: GroupWithUserCount,
|
||||
action = 'change who belongs to the group'
|
||||
): CustomError | null {
|
||||
if (WIKI.models.groups.holdsSystemPermission(req)) {
|
||||
return null
|
||||
}
|
||||
const permissions = req.apiKey?.permissions ?? req.session?.permissions ?? []
|
||||
if (permissions.includes('manage:groups')) {
|
||||
if (!group.permissions.includes(SYSTEM_PERMISSION)) {
|
||||
return null
|
||||
}
|
||||
return new CustomError(
|
||||
'groupMembershipSystemProtected',
|
||||
`This group has the ${SYSTEM_PERMISSION} permission. Only a user who holds it can ${action}.`,
|
||||
403
|
||||
)
|
||||
}
|
||||
if (!isElevated(group.permissions)) {
|
||||
return null
|
||||
}
|
||||
const held = group.permissions.filter((p) =>
|
||||
(ELEVATED_PERMISSIONS as readonly string[]).includes(p)
|
||||
)
|
||||
return new CustomError(
|
||||
'groupMembershipElevatedProtected',
|
||||
`This group administers the wiki (${held.join(', ')}). Only a user who holds manage:groups or manage:system can ${action}.`,
|
||||
403
|
||||
)
|
||||
}
|
||||
import { elevatedGroupGuard } from '../helpers/userGuards.ts'
|
||||
import { SYSTEM_PERMISSION, isElevated } from '../models/groups.ts'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import type { GroupPatch, GroupRule } from '../models/groups.ts'
|
||||
|
||||
interface GroupUpdateBody {
|
||||
name?: string
|
||||
|
||||
@@ -23,6 +23,7 @@ async function routes(app: FastifyInstance) {
|
||||
await import('./schemas/metrics.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/scim.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/storage.ts').then((m) => m.registerSchemas(app))
|
||||
|
||||
@@ -92,10 +92,15 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
type: 'number',
|
||||
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: {
|
||||
type: 'boolean',
|
||||
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: {
|
||||
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: {
|
||||
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: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
|
||||
@@ -143,6 +143,11 @@ async function routes(app: FastifyInstance) {
|
||||
type: 'boolean',
|
||||
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: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
@@ -208,6 +213,7 @@ async function routes(app: FastifyInstance) {
|
||||
isApiEnabled: WIKI.config.api.isEnabled === true,
|
||||
isMailConfigured: WIKI.config?.mail?.host?.length > 2,
|
||||
isMetricsEnabled: WIKI.config.metrics.isEnabled === true,
|
||||
isScimEnabled: WIKI.models.scim.isEnabled(),
|
||||
isSchedulerHealthy: await WIKI.models.jobs.isHealthy(),
|
||||
latestVersion: WIKI.config.update.version,
|
||||
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
|
||||
*/
|
||||
|
||||
+11
-48
@@ -1,6 +1,7 @@
|
||||
import { audit } from '../helpers/audit.ts'
|
||||
import { CustomError, rethrowAsBadRequest } from '../helpers/common.ts'
|
||||
import { detectImageMime, imageMimeTypes } from '../helpers/images.ts'
|
||||
import { elevatedMembershipGuard, systemUserGuard } from '../helpers/userGuards.ts'
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify'
|
||||
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.
|
||||
* `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
|
||||
trusted to decide who administers the instance, which is `manage:system`'s to give.
|
||||
*/
|
||||
const requestedGroups = req.body.groups ?? []
|
||||
if (requestedGroups.length > 0 && !WIKI.models.groups.holdsSystemPermission(req)) {
|
||||
const elevated = await WIKI.models.groups.elevatedGroupIds()
|
||||
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
|
||||
)
|
||||
}
|
||||
const elevatedRefusal = await elevatedMembershipGuard(req, [], req.body.groups ?? [])
|
||||
if (elevatedRefusal) {
|
||||
throw elevatedRefusal
|
||||
}
|
||||
|
||||
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
|
||||
still goes through whatever they belong to.
|
||||
*/
|
||||
if (!WIKI.models.groups.holdsSystemPermission(req)) {
|
||||
const current = await WIKI.models.users.getUserGroupIds(req.params.userId)
|
||||
const requested = req.body.groups
|
||||
const elevated = await WIKI.models.groups.elevatedGroupIds()
|
||||
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
|
||||
)
|
||||
}
|
||||
const elevatedRefusal = await elevatedMembershipGuard(
|
||||
req,
|
||||
await WIKI.models.users.getUserGroupIds(req.params.userId),
|
||||
req.body.groups
|
||||
)
|
||||
if (elevatedRefusal) {
|
||||
throw elevatedRefusal
|
||||
}
|
||||
|
||||
const rootAdminGroupId = WIKI.config.auth.rootAdminGroupId
|
||||
|
||||
@@ -100,6 +100,39 @@ defaults:
|
||||
# about a dozen database queries.
|
||||
includeRuntime: true
|
||||
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:
|
||||
autoLogin: 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
+52
-12
@@ -314,18 +314,39 @@ export const comments = pgTable(
|
||||
)
|
||||
|
||||
// GROUPS ------------------------------
|
||||
export const groups = pgTable('groups', {
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
permissions: jsonb().notNull(),
|
||||
rules: jsonb().notNull(),
|
||||
redirectOnLogin: varchar({ length: 255 }).notNull().default(''),
|
||||
redirectOnFirstLogin: varchar({ length: 255 }).notNull().default(''),
|
||||
redirectOnLogout: varchar({ length: 255 }).notNull().default(''),
|
||||
isSystem: boolean().notNull().default(false),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow()
|
||||
})
|
||||
export const groups = pgTable(
|
||||
'groups',
|
||||
{
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
permissions: jsonb().notNull(),
|
||||
rules: jsonb().notNull(),
|
||||
redirectOnLogin: varchar({ length: 255 }).notNull().default(''),
|
||||
redirectOnFirstLogin: varchar({ length: 255 }).notNull().default(''),
|
||||
redirectOnLogout: varchar({ length: 255 }).notNull().default(''),
|
||||
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(),
|
||||
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 -------------------------------
|
||||
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.
|
||||
*/
|
||||
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({}),
|
||||
meta: jsonb().notNull().default({}),
|
||||
passkeys: jsonb().notNull().default({}),
|
||||
@@ -1110,6 +1148,8 @@ export const users = pgTable(
|
||||
},
|
||||
(table) => [
|
||||
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
|
||||
// postgres, which is what lets any number of users have no handle at all.
|
||||
uniqueIndex('users_handle_idx').on(sql`lower(${table.handle})`)
|
||||
|
||||
@@ -59,3 +59,109 @@ export function classifyClientIp(ip: string | null | undefined): ClientIpClass {
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
+12
-5
@@ -65,6 +65,7 @@ const SERVER_ROUTE_SEGMENTS = new Set([
|
||||
'_files',
|
||||
'_icons',
|
||||
'_render',
|
||||
'_scim',
|
||||
'_site',
|
||||
'_terminal',
|
||||
'_thumb'
|
||||
@@ -560,10 +561,11 @@ async function initHTTPServer() {
|
||||
|
||||
app.addHook('onRequest', async (req, reply) => {
|
||||
/*
|
||||
Bearer tokens authenticate API calls and the metrics endpoint; everything else is
|
||||
cookie-authenticated. The metrics path is here rather than verifying 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
|
||||
a setting, so it cannot declare itself part of the API by its prefix.
|
||||
Bearer tokens authenticate API calls, the SCIM endpoint and the metrics endpoint; everything
|
||||
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 — metrics is served by a hook below, at a path
|
||||
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
|
||||
@fastify/session persist a session row for every scraped request.
|
||||
@@ -572,7 +574,11 @@ async function initHTTPServer() {
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
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
|
||||
}
|
||||
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/icons.ts'), { prefix: '/_icons' })
|
||||
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/thumb.ts'), { prefix: '/_thumb' })
|
||||
app.register(import('./controllers/user.ts'), { prefix: '/_user' })
|
||||
|
||||
@@ -226,6 +226,7 @@
|
||||
"admin.audit.actions.updatePage": "Edited a page",
|
||||
"admin.audit.actions.updatePageNavigation": "Changed the navigation of a page",
|
||||
"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.updateSecurity": "Changed the security configuration",
|
||||
"admin.audit.actions.updateSite": "Updated a site",
|
||||
@@ -611,6 +612,7 @@
|
||||
"admin.groups.permissionsSite": "Site Management",
|
||||
"admin.groups.permissionsUsers": "Users Management",
|
||||
"admin.groups.permissionsWebhooks": "Webhooks Management",
|
||||
"admin.groups.provisioned": "Managed by the directory",
|
||||
"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.redirectOnLogin": "Redirect on Login",
|
||||
@@ -903,6 +905,63 @@
|
||||
"admin.scheduler.updatedAt": "Last Updated",
|
||||
"admin.scheduler.useWorker": "Execution Mode",
|
||||
"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.dictOverrides": "PostgreSQL Dictionary Mapping Overrides",
|
||||
"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.pronouns": "Pronouns",
|
||||
"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.pwdAuthActiveHint": "Whether the user can login using the password authentication.",
|
||||
"admin.users.pwdAuthRestrict": "Restrict Password Authentication",
|
||||
|
||||
@@ -123,6 +123,7 @@ export const AUDIT_ACTIONS = {
|
||||
'installExtension',
|
||||
'updateApiState',
|
||||
'updateMetricsState',
|
||||
'updateScimState',
|
||||
'disconnectWebsockets',
|
||||
'flushCache',
|
||||
'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
|
||||
* own.
|
||||
*
|
||||
* `manage:system` is the one that also bypasses every route check; the other four get here by being
|
||||
* able to rewrite who holds what.
|
||||
* `manage:system` is the one that also bypasses every route check; the others get here by being able
|
||||
* 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 = [
|
||||
'write:users',
|
||||
'manage:users',
|
||||
'write:groups',
|
||||
'manage:groups',
|
||||
'manage:scim',
|
||||
SYSTEM_PERMISSION
|
||||
] as const
|
||||
|
||||
@@ -73,6 +80,8 @@ export interface GroupWithUserCount {
|
||||
redirectOnFirstLogin: string
|
||||
redirectOnLogout: string
|
||||
isSystem: boolean
|
||||
/** Whether a SCIM client owns this group, which is what the admin list badges. */
|
||||
isProvisioned: boolean
|
||||
userCount: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
@@ -86,6 +95,9 @@ export interface GroupPatch {
|
||||
redirectOnLogout?: string
|
||||
permissions?: string[]
|
||||
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,
|
||||
redirectOnLogout: groupsTable.redirectOnLogout,
|
||||
isSystem: groupsTable.isSystem,
|
||||
isProvisioned: groupsTable.isProvisioned,
|
||||
createdAt: groupsTable.createdAt,
|
||||
updatedAt: groupsTable.updatedAt,
|
||||
userCount: count(userGroups.userId)
|
||||
|
||||
@@ -24,6 +24,7 @@ import { pageWatching } from './pageWatching.ts'
|
||||
import { passkeys } from './passkeys.ts'
|
||||
import { rateLimits } from './rateLimits.ts'
|
||||
import { rendering } from './rendering.ts'
|
||||
import { scim } from './scim.ts'
|
||||
import { search } from './search.ts'
|
||||
import { security } from './security.ts'
|
||||
import { sessions } from './sessions.ts'
|
||||
@@ -61,6 +62,7 @@ export default {
|
||||
passkeys,
|
||||
rateLimits,
|
||||
rendering,
|
||||
scim,
|
||||
search,
|
||||
security,
|
||||
sessions,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -130,6 +130,20 @@ class Settings {
|
||||
includeWiki: false
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'scim',
|
||||
value: {
|
||||
isEnabled: false,
|
||||
deleteAction: 'deactivate',
|
||||
emailSource: 'userName',
|
||||
allowGroupCreate: true,
|
||||
rateLimitEnabled: true,
|
||||
rateLimitMax: 600,
|
||||
rateLimitWindow: '1m',
|
||||
rateLimitBan: '1m',
|
||||
ipAllowList: []
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'search',
|
||||
value: {
|
||||
|
||||
+45
-16
@@ -29,6 +29,8 @@ export interface UserCore {
|
||||
isSystem: boolean
|
||||
isActive: boolean
|
||||
isVerified: boolean
|
||||
/** Whether a SCIM client owns this account, which is what the admin list badges. */
|
||||
isProvisioned: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
lastLoginAt: Date | null
|
||||
@@ -93,6 +95,13 @@ export interface UserPatch {
|
||||
handle?: string | null
|
||||
isActive?: 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>
|
||||
prefs?: Record<string, any>
|
||||
}
|
||||
@@ -251,6 +260,7 @@ const userSelection = {
|
||||
isSystem: usersTable.isSystem,
|
||||
isActive: usersTable.isActive,
|
||||
isVerified: usersTable.isVerified,
|
||||
isProvisioned: usersTable.isProvisioned,
|
||||
createdAt: usersTable.createdAt,
|
||||
updatedAt: usersTable.updatedAt,
|
||||
lastLoginAt: usersTable.lastLoginAt
|
||||
@@ -450,6 +460,8 @@ class Users {
|
||||
isSystem: user.isSystem,
|
||||
isActive: user.isActive,
|
||||
isVerified: user.isVerified,
|
||||
isProvisioned: user.isProvisioned,
|
||||
externalId: user.externalId,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
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
|
||||
*/
|
||||
@@ -472,11 +484,21 @@ class Users {
|
||||
groups = [],
|
||||
mustChangePassword = false,
|
||||
isVerified = true,
|
||||
isProvisioned = false,
|
||||
externalId,
|
||||
strategyId
|
||||
}: {
|
||||
name: 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[]
|
||||
mustChangePassword?: boolean
|
||||
/**
|
||||
@@ -485,6 +507,10 @@ class Users {
|
||||
* registration email or by an administrator marking the account verified.
|
||||
*/
|
||||
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
|
||||
* every account seeded or created by an administrator keeps it.
|
||||
@@ -501,19 +527,23 @@ class Users {
|
||||
.values({
|
||||
email: email.toLowerCase(),
|
||||
name,
|
||||
auth: {
|
||||
[localStrategyId]: {
|
||||
password: await bcrypt.hash(password, 12),
|
||||
mustChangePwd: mustChangePassword,
|
||||
restrictLogin: false,
|
||||
tfaIsActive: false,
|
||||
tfaRequired: false,
|
||||
tfaSecret: ''
|
||||
}
|
||||
},
|
||||
auth: password
|
||||
? {
|
||||
[localStrategyId]: {
|
||||
password: await bcrypt.hash(password, 12),
|
||||
mustChangePwd: mustChangePassword,
|
||||
restrictLogin: false,
|
||||
tfaIsActive: false,
|
||||
tfaRequired: false,
|
||||
tfaSecret: ''
|
||||
}
|
||||
}
|
||||
: {},
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
isVerified,
|
||||
isProvisioned,
|
||||
externalId: externalId ?? null,
|
||||
meta: {
|
||||
location: '',
|
||||
jobTitle: '',
|
||||
@@ -536,7 +566,7 @@ class Users {
|
||||
}
|
||||
|
||||
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', {
|
||||
@@ -1519,9 +1549,8 @@ class Users {
|
||||
const userId = await this.createUser({
|
||||
name: profile.name || email,
|
||||
email,
|
||||
// -> Nothing signs in with it: this account authenticates at the provider, and the local
|
||||
// strategy's own entry is what a password would live under
|
||||
password: nanoid(32),
|
||||
// -> No password at all: this account authenticates at the provider, and the local strategy's
|
||||
// own entry is what one would live under
|
||||
groups: strategy.autoEnrollGroups ?? [],
|
||||
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
|
||||
removing an icon; `check-icons.mjs` fails the build if this drifts.
|
||||
|
||||
275 icons.
|
||||
276 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},
|
||||
@@ -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: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: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: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},
|
||||
|
||||
@@ -994,6 +994,10 @@ const permissionCards = [
|
||||
{
|
||||
permission: 'manage:groups',
|
||||
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)" />
|
||||
</w-item-section>
|
||||
</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-section avatar>
|
||||
<w-icon name="img:/_assets/icons/fluent-account.svg" />
|
||||
@@ -632,6 +649,26 @@ function countBadgeClass(count) {
|
||||
*/
|
||||
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
|
||||
|
||||
watch(
|
||||
|
||||
@@ -70,6 +70,14 @@
|
||||
<div class="flex items-center">
|
||||
<strong>{{ props.value }}</strong>
|
||||
<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>
|
||||
</w-td>
|
||||
</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>
|
||||
<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" />
|
||||
<!-- -> 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>
|
||||
</w-td>
|
||||
</template>
|
||||
|
||||
@@ -107,6 +107,7 @@ const routes = [
|
||||
{ path: 'auth', component: () => import('@/pages/AdminAuth.vue') },
|
||||
{ path: 'groups/:id?/:section?', component: () => import('@/pages/AdminGroups.vue') },
|
||||
{ path: 'users/:id?/:section?', component: () => import('@/pages/AdminUsers.vue') },
|
||||
{ path: 'scim', component: () => import('@/pages/AdminScim.vue') },
|
||||
// -> System
|
||||
{ path: 'api', component: () => import('@/pages/AdminApi.vue') },
|
||||
{ path: 'audit', component: () => import('@/pages/AdminAudit.vue') },
|
||||
|
||||
@@ -22,6 +22,7 @@ export const useAdminStore = defineStore('admin', {
|
||||
isMCPEnabled: false,
|
||||
isMailConfigured: false,
|
||||
isMetricsEnabled: false,
|
||||
isScimEnabled: false,
|
||||
isSchedulerHealthy: false
|
||||
},
|
||||
/**
|
||||
@@ -81,6 +82,7 @@ export const useAdminStore = defineStore('admin', {
|
||||
this.info.latestVersion = resp?.latestVersion ?? 'n/a'
|
||||
this.info.isApiEnabled = resp?.isApiEnabled ?? false
|
||||
this.info.isMetricsEnabled = resp?.isMetricsEnabled ?? false
|
||||
this.info.isScimEnabled = resp?.isScimEnabled ?? false
|
||||
this.info.isMailConfigured = resp?.isMailConfigured ?? false
|
||||
this.info.isSchedulerHealthy = resp?.isSchedulerHealthy ?? false
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user