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

1237 lines
46 KiB

import { and, asc, count, eq, inArray, sql } from 'drizzle-orm'
import { durationToSeconds } from '../helpers/common.ts'
import { groups as groupsTable, userGroups, users as usersTable } from '../db/schema.ts'
import { matchesIpRanges, parseIpRange } from '../helpers/network.ts'
import type { RateLimitPolicy } from './rateLimits.ts'
import type { GroupPatch } from './groups.ts'
import type { UserPatch } from './users.ts'
/**
* SCIM 2.0 provisioning — RFC 7643 (schema) and RFC 7644 (protocol).
*
* The other half of single sign-on. SAML and OIDC answer *who is this person signing in*; SCIM
* answers *who exists, who is in what group, and who left on Friday*. Without it the wiki learns
* about a person on their first login and never learns that they are gone — their account stays
* active and their session cookie stays good for thirty days after the directory disabled them.
*
* This model owns the mapping between a SCIM resource and the wiki's rows, the filter grammar, the
* PATCH interpretation and the data operations. It deliberately owns none of the authorization:
* every guard is a question about the CALLER and lives in `helpers/userGuards.ts`, applied by
* `controllers/scim.ts` — the same guards the admin API goes through, so that a directory cannot do
* through `/_scim` what an administrator may not do through `/_api`.
*
* Served at `/_scim/v2` rather than under `/_api`: SCIM has its own error body, its own content
* type and its own discovery documents, none of which belong in this wiki's REST conventions.
*/
/** What a request must hold to drive provisioning. See `ELEVATED_PERMISSIONS` in `models/groups.ts`. */
export const SCIM_PERMISSION = 'manage:scim'
export const SCHEMA_USER = 'urn:ietf:params:scim:schemas:core:2.0:User'
export const SCHEMA_GROUP = 'urn:ietf:params:scim:schemas:core:2.0:Group'
export const SCHEMA_LIST = 'urn:ietf:params:scim:api:messages:2.0:ListResponse'
export const SCHEMA_ERROR = 'urn:ietf:params:scim:api:messages:2.0:Error'
export const SCHEMA_PATCH_OP = 'urn:ietf:params:scim:api:messages:2.0:PatchOp'
/** The media type RFC 7644 §3.1 gives every SCIM request and response. */
export const SCIM_CONTENT_TYPE = 'application/scim+json'
/** How many resources one page may carry, however large a `count` is asked for. */
export const SCIM_MAX_RESULTS = 200
/** The page size used when a client asks for none. */
const SCIM_DEFAULT_COUNT = 100
/**
* A refusal, in the shape RFC 7644 §3.12 gives one.
*
* `scimType` is the machine-readable half and is what a connector branches on — `uniqueness` tells
* it to go and match an existing account instead of creating a second one, where a bare 409 tells it
* nothing. Separate from `CustomError` for exactly that reason: the two error bodies are different
* shapes and `controllers/scim.ts` has its own error handler to emit this one.
*/
export class ScimError extends Error {
statusCode: number
scimType?: string
constructor(statusCode: number, message: string, scimType?: string) {
super(message)
this.name = 'ScimError'
this.statusCode = statusCode
this.scimType = scimType
}
}
export const SCIM_DELETE_ACTIONS = ['deactivate', 'delete'] as const
export type ScimDeleteAction = (typeof SCIM_DELETE_ACTIONS)[number]
export const SCIM_EMAIL_SOURCES = ['userName', 'emails'] as const
export type ScimEmailSource = (typeof SCIM_EMAIL_SOURCES)[number]
/** The settings blob this model owns, as the admin area reads and writes it. */
export interface ScimConfig {
isEnabled: boolean
deleteAction: ScimDeleteAction
emailSource: ScimEmailSource
allowGroupCreate: boolean
rateLimitEnabled: boolean
/** Requests allowed per address within the window. The one that exceeds it earns the ban. */
rateLimitMax: number
/** Window and ban as an operator writes them (`1m`, `30s`, `1h`), like the auth limit's. */
rateLimitWindow: string
rateLimitBan: string
/** Addresses allowed to reach the endpoint, as single addresses or CIDR subnets. */
ipAllowList: string[]
}
/**
* The limit used until an administrator saves their own, and whenever a stored value is unusable.
*
* Deliberately far looser than the login limit next door, because the traffic is not the same
* shape: guessing a password is one request at a time, while a directory's first sync is every user
* it has, back to back. A limit set for the former would refuse the latter for doing its job.
*/
const RATE_LIMIT_DEFAULTS: RateLimitPolicy = {
max: 600,
windowSeconds: 60,
banSeconds: 60
}
/**
* The last request this instance answered, for the admin screen's status card.
*
* In memory and per instance, like the runtime metrics registry: it exists so that somebody setting
* a connector up can see whether anything is arriving at all and what it was told, which is the
* question the server log otherwise answers. Nothing depends on it, and an instance that has just
* started has none.
*/
export interface ScimLastRequest {
at: string
method: string
path: string
status: number
message: string | null
}
let lastRequest: ScimLastRequest | null = null
/** A SCIM resource as a client sends it, or the fragment a PATCH assembles. */
type ScimResource = Record<string, any>
/** Postgres gives back a `Date`; SCIM wants an RFC 3339 string. */
function instantOf(value: Date | null | undefined): string | undefined {
return value ? value.toTemporalInstant().toString({ smallestUnit: 'millisecond' }) : undefined
}
/**
* Whether a value that arrived as JSON means true.
*
* Connectors are inconsistent about `active`: the RFC says boolean, and both `"True"` and `"false"`
* turn up as strings in the wild. A string is read for what it says rather than for being non-empty,
* since `"false"` is truthy to JavaScript and would activate an account the directory just disabled.
*/
function readBoolean(value: any): boolean {
if (typeof value === 'string') {
return value.trim().toLowerCase() === 'true'
}
return Boolean(value)
}
/**
* Whether a string is an address this wiki can file an account under.
*
* Deliberately crude — one `@`, something either side, no spaces. The wiki keys accounts by email
* and does not verify one that arrived from a directory, so this is a check that the value is the
* right KIND of thing, not that it is deliverable.
*/
function looksLikeEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
}
export class Scim {
// ==========================================
// CONFIGURATION
// ==========================================
/** The settings, defaulted field by field so that a blob written before a field existed still reads. */
getConfig(): ScimConfig {
const scim = WIKI.config.scim ?? {}
return {
isEnabled: scim.isEnabled === true,
deleteAction: SCIM_DELETE_ACTIONS.includes(scim.deleteAction)
? scim.deleteAction
: 'deactivate',
emailSource: SCIM_EMAIL_SOURCES.includes(scim.emailSource) ? scim.emailSource : 'userName',
allowGroupCreate: scim.allowGroupCreate !== false,
rateLimitEnabled: scim.rateLimitEnabled !== false,
rateLimitMax:
Number.isFinite(Number(scim.rateLimitMax)) && Number(scim.rateLimitMax) > 0
? Math.floor(Number(scim.rateLimitMax))
: RATE_LIMIT_DEFAULTS.max,
rateLimitWindow: typeof scim.rateLimitWindow === 'string' ? scim.rateLimitWindow : '1m',
rateLimitBan: typeof scim.rateLimitBan === 'string' ? scim.rateLimitBan : '1m',
ipAllowList: Array.isArray(scim.ipAllowList)
? scim.ipAllowList.filter((entry: unknown) => typeof entry === 'string' && entry.length > 0)
: []
}
}
/**
* The limit as `rateLimits.consume` wants it.
*
* Every field falls back on its own, so one unusable value leaves the rest of the limit standing
* rather than turning it off — the same arrangement the auth limit makes, and for the same
* reason: a typo in the ban duration must not silently remove the ceiling.
*/
rateLimitPolicy(): RateLimitPolicy {
const config = this.getConfig()
return {
max: config.rateLimitMax,
windowSeconds: durationToSeconds(config.rateLimitWindow, RATE_LIMIT_DEFAULTS.windowSeconds),
banSeconds: durationToSeconds(config.rateLimitBan, RATE_LIMIT_DEFAULTS.banSeconds)
}
}
/**
* Whether this address may reach the endpoint at all.
*
* An empty allow list means every address, so the restriction is off until somebody writes one —
* see `matchesIpRanges`. What an address MEANS depends on `security.trustProxy`: with it off, a
* wiki behind a proxy sees the proxy for every request, and a list of the directory's published
* egress ranges would then refuse everybody. The admin screen says so beside the field.
*/
isAddressAllowed(ip: string | null | undefined): boolean {
return matchesIpRanges(ip, this.getConfig().ipAllowList)
}
/** Whether provisioning is turned on. Read per request, so a change applies at once. */
isEnabled(): boolean {
return WIKI.config.scim?.isEnabled === true
}
/** Keep only the fields this model owns, dropping anything else a client sends. */
pickFields(body: Record<string, any>): Partial<ScimConfig> {
const patch: Record<string, any> = {}
if (body.isEnabled !== undefined) {
patch.isEnabled = Boolean(body.isEnabled)
}
if (body.allowGroupCreate !== undefined) {
patch.allowGroupCreate = Boolean(body.allowGroupCreate)
}
if (body.deleteAction !== undefined) {
patch.deleteAction = body.deleteAction
}
if (body.emailSource !== undefined) {
patch.emailSource = body.emailSource
}
if (body.rateLimitEnabled !== undefined) {
patch.rateLimitEnabled = Boolean(body.rateLimitEnabled)
}
if (body.rateLimitMax !== undefined) {
patch.rateLimitMax = Number(body.rateLimitMax)
}
if (body.rateLimitWindow !== undefined) {
patch.rateLimitWindow = String(body.rateLimitWindow).trim()
}
if (body.rateLimitBan !== undefined) {
patch.rateLimitBan = String(body.rateLimitBan).trim()
}
if (body.ipAllowList !== undefined) {
/*
Normalised on the way in rather than at match time, so what is stored is exactly what
`validate` checked: trimmed, blanks dropped, de-duplicated. A client may send the list as
the newline-separated text the admin screen edits, since that is what the field there is.
*/
const raw = Array.isArray(body.ipAllowList)
? body.ipAllowList
: String(body.ipAllowList).split(/[\n,]/)
patch.ipAllowList = [...new Set(raw.map((entry: unknown) => String(entry).trim()))].filter(
Boolean
)
}
return patch
}
/** @returns The reason the patch cannot be saved, or null when it is fine */
validate(patch: Partial<ScimConfig>): string | null {
if (
patch.deleteAction !== undefined &&
!SCIM_DELETE_ACTIONS.includes(patch.deleteAction as ScimDeleteAction)
) {
return `The delete action must be one of: ${SCIM_DELETE_ACTIONS.join(', ')}.`
}
if (
patch.emailSource !== undefined &&
!SCIM_EMAIL_SOURCES.includes(patch.emailSource as ScimEmailSource)
) {
return `The email source must be one of: ${SCIM_EMAIL_SOURCES.join(', ')}.`
}
if (
patch.rateLimitMax !== undefined &&
(!Number.isFinite(patch.rateLimitMax) || patch.rateLimitMax < 1)
) {
return 'The rate limit must allow at least one request.'
}
for (const field of ['rateLimitWindow', 'rateLimitBan'] as const) {
const value = patch[field]
/*
Checked here rather than left to `durationToSeconds`'s fallback, which is a backstop for a
value that got into the database some other way. A typo saved from the admin screen has to
come back as an error: silently applying a different limit than the one on screen is the
failure this setting can least afford.
*/
if (value !== undefined && durationToSeconds(value, 0) < 1) {
return `'${value}' is not a duration. Write it as a number and a unit, e.g. 30s, 5m or 1h.`
}
}
if (patch.ipAllowList !== undefined) {
const bad = patch.ipAllowList.find((entry) => !parseIpRange(entry))
if (bad) {
return `'${bad}' is not an IP address or CIDR range, e.g. 203.0.113.4 or 203.0.113.0/24.`
}
}
return null
}
async updateConfig(patch: Partial<ScimConfig>): Promise<boolean> {
const previousConfig = WIKI.config.scim
WIKI.config.scim = { ...previousConfig, ...patch }
if (!(await WIKI.configSvc.saveToDb(['scim']))) {
WIKI.config.scim = previousConfig
return false
}
return true
}
/** How many users and groups a directory currently owns, for the admin screen. */
async getStats(): Promise<{
users: number
groups: number
lastRequest: ScimLastRequest | null
}> {
const [users, groups] = await Promise.all([
WIKI.db.$count(usersTable, eq(usersTable.isProvisioned, true)),
WIKI.db.$count(groupsTable, eq(groupsTable.isProvisioned, true))
])
return { users, groups, lastRequest }
}
recordRequest(entry: Omit<ScimLastRequest, 'at'>): void {
lastRequest = { at: Temporal.Now.instant().toString({ smallestUnit: 'millisecond' }), ...entry }
}
// ==========================================
// MAPPING
// ==========================================
/**
* One user, as SCIM describes them.
*
* `groups` is read-only per RFC 7643 §4.1.2 — membership is written through the Group resource,
* never here — and is included because connectors display it and some reconcile against it.
*/
toScimUser(
user: Record<string, any>,
memberships: Array<{ id: string; name: string }>,
baseUrl: string
): ScimResource {
const meta = (user.meta ?? {}) as Record<string, any>
const prefs = (user.prefs ?? {}) as Record<string, any>
return {
schemas: [SCHEMA_USER],
id: user.id,
...(user.externalId ? { externalId: user.externalId } : {}),
userName: user.email,
name: { formatted: user.name },
displayName: user.name,
active: user.isActive,
emails: [{ value: user.email, type: 'work', primary: true }],
...(meta.jobTitle ? { title: meta.jobTitle } : {}),
...(prefs.timezone ? { timezone: prefs.timezone } : {}),
groups: memberships.map((grp) => ({
value: grp.id,
display: grp.name,
$ref: `${baseUrl}/Groups/${grp.id}`,
type: 'direct'
})),
meta: {
resourceType: 'User',
created: instantOf(user.createdAt),
lastModified: instantOf(user.updatedAt),
location: `${baseUrl}/Users/${user.id}`
}
}
}
toScimGroup(
group: Record<string, any>,
members: Array<{ id: string; name: string }>,
baseUrl: string
): ScimResource {
return {
schemas: [SCHEMA_GROUP],
id: group.id,
...(group.externalId ? { externalId: group.externalId } : {}),
displayName: group.name,
members: members.map((user) => ({
value: user.id,
display: user.name,
$ref: `${baseUrl}/Users/${user.id}`,
type: 'User'
})),
meta: {
resourceType: 'Group',
created: instantOf(group.createdAt),
lastModified: instantOf(group.updatedAt),
location: `${baseUrl}/Groups/${group.id}`
}
}
}
/** The envelope every listing goes out in (RFC 7644 §3.4.2). */
listResponse(resources: ScimResource[], total: number, startIndex: number): ScimResource {
return {
schemas: [SCHEMA_LIST],
totalResults: total,
itemsPerPage: resources.length,
startIndex,
Resources: resources
}
}
// ==========================================
// QUERY PARAMETERS
// ==========================================
/**
* `startIndex` and `count`, clamped.
*
* `startIndex` is 1-based and a value below 1 is read as 1, which RFC 7644 §3.4.2.4 asks for
* explicitly. `count` of 0 is legal and means "tell me the total and send no resources" — which is
* how several connectors size a sync before running it, so it must not be read as "unset".
*/
parsePaging(query: Record<string, any>): { startIndex: number; count: number } {
const rawIndex = Number.parseInt(query.startIndex, 10)
const rawCount = Number.parseInt(query.count, 10)
return {
startIndex: Number.isFinite(rawIndex) && rawIndex > 1 ? rawIndex : 1,
count: Number.isFinite(rawCount)
? Math.min(Math.max(rawCount, 0), SCIM_MAX_RESULTS)
: SCIM_DEFAULT_COUNT
}
}
/**
* The one filter form this endpoint understands: `<attribute> eq "<value>"`.
*
* RFC 7644 §3.4.2.2 defines a whole expression grammar, and connectors use about five corners of
* it. Rather than build an engine for attributes this wiki does not have, the accepted shape is
* one equality against a named attribute, and everything else is refused with `invalidFilter` —
* which is a documented answer a client can act on, unlike a filter silently matching nothing.
*
* Value-path segments are stripped before matching, so `emails[type eq "work"].value` is read as
* `emails.value`. A wiki user has exactly one address, so there is no `type` to distinguish.
*
* @param attrs Which attribute names this resource accepts, mapped to what to match on
* @returns The attribute and the value, or null when no filter was given
*/
parseFilter(
raw: string | undefined,
attrs: Record<string, string>
): { attr: string; value: string } | null {
if (!raw || raw.trim().length < 1) {
return null
}
const flattened = raw.replaceAll(/\[[^\]]*\]/g, '')
const match = /^\s*([\w.$-]+)\s+eq\s+"((?:[^"\\]|\\.)*)"\s*$/i.exec(flattened)
if (!match) {
throw new ScimError(
400,
`Only filters of the form '<attribute> eq "<value>"' are supported. Received: ${raw}`,
'invalidFilter'
)
}
const attr = attrs[match[1]!.toLowerCase()]
if (!attr) {
throw new ScimError(
400,
`Filtering on '${match[1]}' is not supported. Supported attributes: ${Object.keys(attrs).join(', ')}.`,
'invalidFilter'
)
}
return { attr, value: match[2]!.replaceAll('\\"', '"').replaceAll('\\\\', '\\') }
}
// ==========================================
// PATCH
// ==========================================
/**
* Fold a PatchOp body into a partial SCIM resource, so that PATCH and PUT converge on one mapping.
*
* Rather than interpret each operation against the database, every supported operation is
* rewritten as the fragment of a resource it is asking for — `{op: 'replace', path: 'active',
* value: false}` becomes `{active: false}` — and that fragment goes through the same
* `userValuesFrom` a PUT does. One mapping, one set of rules about what an attribute means.
*
* `members` is the exception and cannot be folded this way, because `add` and `remove` are
* relative to what is already there; `parseGroupPatch` handles it separately.
*
* Case is not significant in `op`: the RFC says lowercase and connectors send `Add` and `Replace`.
*/
parsePatchOps(body: Record<string, any>): Array<{ op: string; path?: string; value?: any }> {
const operations = body?.Operations ?? body?.operations
if (!Array.isArray(operations) || operations.length < 1) {
throw new ScimError(
400,
'A PATCH request must carry a non-empty Operations array.',
'invalidValue'
)
}
return operations.map((entry: any) => {
const op = String(entry?.op ?? '').toLowerCase()
if (!['add', 'replace', 'remove'].includes(op)) {
throw new ScimError(400, `Unsupported PATCH operation '${entry?.op}'.`, 'invalidSyntax')
}
return {
op,
path: typeof entry?.path === 'string' ? entry.path : undefined,
value: entry?.value
}
})
}
/**
* The user attributes a PATCH may set, as paths mapped onto where they land in the fragment.
*
* Everything else is refused. The list is what connectors actually send, and a path this wiki has
* nowhere to put is better answered with `invalidPath` than accepted and dropped.
*/
private setUserFragment(fragment: ScimResource, path: string, value: any): void {
const key = path
.replaceAll(/\[[^\]]*\]/g, '')
.trim()
.toLowerCase()
switch (key) {
case 'active':
fragment.active = value
break
case 'username':
fragment.userName = value
break
case 'externalid':
fragment.externalId = value
break
case 'displayname':
fragment.displayName = value
break
case 'title':
fragment.title = value
break
case 'timezone':
fragment.timezone = value
break
case 'name.formatted':
fragment.name = { ...fragment.name, formatted: value }
break
case 'name.givenname':
fragment.name = { ...fragment.name, givenName: value }
break
case 'name.familyname':
fragment.name = { ...fragment.name, familyName: value }
break
case 'emails':
case 'emails.value':
fragment.emails = Array.isArray(value) ? value : [{ value, primary: true }]
break
case 'name':
fragment.name = { ...fragment.name, ...value }
break
default:
throw new ScimError(400, `The path '${path}' cannot be patched on a User.`, 'invalidPath')
}
}
/** Fold a user PatchOp body into the partial resource it is asking for. */
parseUserPatch(body: Record<string, any>): ScimResource {
const fragment: ScimResource = {}
for (const { op, path, value } of this.parsePatchOps(body)) {
if (op === 'remove') {
/*
Removing an attribute the wiki stores as a plain column has no meaning — there is no
"unset" state for a name or an address — with the single exception of `externalId`, which
is nullable and is how a directory releases an account it no longer tracks.
*/
if (path && path.trim().toLowerCase() === 'externalid') {
fragment.externalId = null
continue
}
throw new ScimError(
400,
`A User attribute cannot be removed ('${path ?? 'no path'}').`,
'invalidPath'
)
}
if (!path) {
// -> The no-path form: the value IS the fragment. What Entra sends for `active`.
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new ScimError(
400,
'A PATCH operation without a path must carry an object value.',
'invalidValue'
)
}
for (const [key, entry] of Object.entries(value)) {
this.setUserFragment(fragment, key, entry)
}
continue
}
this.setUserFragment(fragment, path, value)
}
return fragment
}
/**
* Fold a group PatchOp body into a rename, an external id, and what to do about the membership.
*
* `replace` of the whole `members` attribute sets the membership outright; `add` and `remove` are
* relative to it. A `remove` naming no value at all empties the group, which is what
* `{op: 'remove', path: 'members'}` means.
*/
parseGroupPatch(body: Record<string, any>): {
displayName?: string
externalId?: string | null
replaceMembers?: string[]
addMembers: string[]
removeMembers: string[]
removeAllMembers: boolean
} {
const result = {
displayName: undefined as string | undefined,
externalId: undefined as string | null | undefined,
replaceMembers: undefined as string[] | undefined,
addMembers: [] as string[],
removeMembers: [] as string[],
removeAllMembers: false
}
const memberIdsOf = (value: any): string[] => {
const entries = Array.isArray(value)
? value
: value === undefined || value === null
? []
: [value]
return entries.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
})
}
for (const { op, path, value } of this.parsePatchOps(body)) {
const rawPath = (path ?? '').trim()
const key = rawPath.replaceAll(/\[[^\]]*\]/g, '').toLowerCase()
if (!rawPath) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new ScimError(
400,
'A PATCH operation without a path must carry an object value.',
'invalidValue'
)
}
if (value.displayName !== undefined) {
result.displayName = String(value.displayName)
}
if (value.externalId !== undefined) {
result.externalId = value.externalId === null ? null : String(value.externalId)
}
if (value.members !== undefined) {
result.replaceMembers = memberIdsOf(value.members)
}
continue
}
if (key === 'displayname') {
if (op === 'remove') {
throw new ScimError(400, 'A group must have a displayName.', 'invalidValue')
}
result.displayName = String(value)
continue
}
if (key === 'externalid') {
result.externalId = op === 'remove' || value === null ? null : String(value)
continue
}
if (key !== 'members') {
throw new ScimError(400, `The path '${path}' cannot be patched on a Group.`, 'invalidPath')
}
if (op === 'remove') {
/*
`members[value eq "<id>"]` is the form Okta removes one member with, and it carries the id
in the path rather than in a value. The value-path is read here rather than by the flatten
above, which is why the raw path is kept.
*/
const targeted = /\[\s*value\s+eq\s+"((?:[^"\\]|\\.)*)"\s*\]/i.exec(rawPath)
if (targeted) {
result.removeMembers.push(targeted[1]!)
} else if (value === undefined || value === null) {
result.removeAllMembers = true
} else {
result.removeMembers.push(...memberIdsOf(value))
}
continue
}
if (op === 'replace') {
result.replaceMembers = memberIdsOf(value)
continue
}
result.addMembers.push(...memberIdsOf(value))
}
return result
}
// ==========================================
// USERS
// ==========================================
/** Which attributes `GET /Users?filter=` accepts, and what each one matches on. */
private readonly userFilterAttrs: Record<string, string> = {
username: 'email',
'emails.value': 'email',
emails: 'email',
externalid: 'externalId',
id: 'id'
}
/**
* The wiki users a SCIM client may see: everybody except the guest account.
*
* Every user rather than only the provisioned ones, because the first thing a connector does is
* look for an account it has not created yet — `filter=userName eq "..."` — and a listing that hid
* those would have it create a duplicate that the unique email index then refuses. Owning an
* account is what `isProvisioned` records, and it gates destruction rather than visibility.
*/
async listUsers({
filter,
startIndex,
count: pageSize,
baseUrl
}: {
filter?: string
startIndex: number
count: number
baseUrl: string
}): Promise<ScimResource> {
const parsed = this.parseFilter(filter, this.userFilterAttrs)
const conditions = [eq(usersTable.isSystem, false)]
if (parsed?.attr === 'email') {
conditions.push(sql`lower(${usersTable.email}) = lower(${parsed.value})`)
} else if (parsed?.attr === 'externalId') {
conditions.push(eq(usersTable.externalId, parsed.value))
} else if (parsed?.attr === 'id') {
// -> A malformed uuid would make postgres raise rather than match nothing, which is the honest
// answer to "is there a user with this id"
if (!this.isUuid(parsed.value)) {
return this.listResponse([], 0, startIndex)
}
conditions.push(eq(usersTable.id, parsed.value))
}
const where = and(...conditions)
const totals = await WIKI.db.select({ total: count() }).from(usersTable).where(where)
const total = totals[0]?.total ?? 0
if (pageSize < 1) {
return this.listResponse([], total, startIndex)
}
const rows = await WIKI.db
.select()
.from(usersTable)
.where(where)
// -> By id, not by name: a listing paged through while somebody is renamed must not skip a row
.orderBy(asc(usersTable.id))
.limit(pageSize)
.offset(startIndex - 1)
const memberships = await this.membershipsOf(rows.map((row: any) => row.id))
return this.listResponse(
rows.map((row: any) => this.toScimUser(row, memberships.get(row.id) ?? [], baseUrl)),
total,
startIndex
)
}
/** One user by id, or null — including for the guest account, which SCIM never sees. */
async getUser(id: string): Promise<Record<string, any> | null> {
if (!this.isUuid(id)) {
return null
}
const rows = await WIKI.db
.select()
.from(usersTable)
.where(and(eq(usersTable.id, id), eq(usersTable.isSystem, false)))
.limit(1)
return rows[0] ?? null
}
/** The groups each of these users belongs to, in one query. */
async membershipsOf(
userIds: string[]
): Promise<Map<string, Array<{ id: string; name: string }>>> {
const result = new Map<string, Array<{ id: string; name: string }>>()
if (userIds.length < 1) {
return result
}
const rows = await WIKI.db
.select({ userId: userGroups.userId, id: groupsTable.id, name: groupsTable.name })
.from(userGroups)
.innerJoin(groupsTable, eq(groupsTable.id, userGroups.groupId))
.where(inArray(userGroups.userId, userIds))
for (const row of rows) {
const existing = result.get(row.userId) ?? []
existing.push({ id: row.id, name: row.name })
result.set(row.userId, existing)
}
return result
}
/**
* The wiki values a SCIM user resource is asking for.
*
* Applied to a fragment as readily as to a whole resource: a key that is absent is a field the
* request said nothing about, which is what makes PATCH and PUT share this.
*
* The name is composed from whatever the resource gave — `displayName`, then `name.formatted`,
* then the given and family names joined. A PATCH that sends only `name.givenName` therefore sets
* the whole name to the given name, because there is one name column here and no parts to merge
* the fragment into. A composition that comes out empty leaves the stored name alone.
*/
userValuesFrom(resource: ScimResource): { patch: UserPatch; meta: Record<string, any> } {
const patch: UserPatch = {}
const meta: Record<string, any> = {}
const email = this.emailFrom(resource)
if (email !== undefined) {
patch.email = email
}
const name = this.nameFrom(resource)
if (name) {
patch.name = name
}
if (resource.active !== undefined) {
patch.isActive = readBoolean(resource.active)
}
if (resource.externalId !== undefined) {
meta.externalId = resource.externalId === null ? null : String(resource.externalId)
}
if (resource.title !== undefined) {
meta.jobTitle = resource.title === null ? '' : String(resource.title)
}
if (resource.timezone !== undefined) {
meta.timezone = resource.timezone === null ? '' : String(resource.timezone)
}
return { patch, meta }
}
/**
* The address a resource is filing this account under, per the site's `emailSource` setting.
*
* `userName` is what every connector sends and is right wherever the login name is the mailbox.
* `emails` reads the primary entry — or the first work entry, or simply the first — for a
* directory whose user principal name is not an address.
*/
private emailFrom(resource: ScimResource): string | undefined {
const source = this.getConfig().emailSource
let candidate: any
if (source === 'emails') {
const entries = Array.isArray(resource.emails) ? resource.emails : []
const chosen =
entries.find((entry: any) => entry?.primary) ??
entries.find((entry: any) => String(entry?.type ?? '').toLowerCase() === 'work') ??
entries[0]
candidate = chosen?.value
// -> Nothing in `emails[]` and a `userName` that is an address: take it rather than refuse a
// resource that plainly carries one
if (candidate === undefined && looksLikeEmail(String(resource.userName ?? ''))) {
candidate = resource.userName
}
} else {
candidate = resource.userName
}
if (candidate === undefined || candidate === null) {
return undefined
}
const value = String(candidate).trim().toLowerCase()
if (!looksLikeEmail(value)) {
throw new ScimError(
400,
`'${candidate}' is not an email address. This wiki files every account under one, so the ${
source === 'emails' ? 'primary entry of emails[]' : 'userName attribute'
} has to carry it.`,
'invalidValue'
)
}
return value
}
/** The single name column, composed from whatever name attributes the resource carried. */
private nameFrom(resource: ScimResource): string | undefined {
const candidates = [
resource.displayName,
resource.name?.formatted,
[resource.name?.givenName, resource.name?.familyName].filter(Boolean).join(' ')
]
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate.trim()
}
}
return undefined
}
/**
* Create an account from a SCIM resource.
*
* No password: the person authenticates at the provider, and `loginWithProvider` links the account
* by address on their first sign-in. Verified, because the directory vouching for the address is
* the whole point of provisioning — an unverified account is refused at login.
*
* No welcome email either. Onboarding belongs to whoever runs the directory, and a wiki that mailed
* everybody the moment a sync ran would mail an entire company at once.
*/
async createUser(resource: ScimResource): Promise<string> {
const { patch, meta } = this.userValuesFrom(resource)
if (!patch.email) {
throw new ScimError(400, 'userName is required.', 'invalidValue')
}
if (await WIKI.models.users.getByEmail(patch.email)) {
throw new ScimError(409, 'A user with this email address already exists.', 'uniqueness')
}
if (meta.externalId && (await this.getUserByExternalId(meta.externalId))) {
throw new ScimError(409, 'A user with this externalId already exists.', 'uniqueness')
}
const id = await WIKI.models.users.createUser({
name: patch.name || patch.email.split('@')[0]!,
email: patch.email,
isVerified: true,
isProvisioned: true,
externalId: meta.externalId ?? null
})
// -> `createUser` always makes an active account, so a resource arriving disabled is a second
// write rather than a parameter. A directory does provision one: a hire who has not started.
const followUp: UserPatch = {}
if (patch.isActive === false) {
followUp.isActive = false
}
if (meta.jobTitle !== undefined || meta.timezone !== undefined) {
Object.assign(followUp, await this.blobPatchFor(id, meta))
}
if (Object.keys(followUp).length > 0) {
await WIKI.models.users.updateUser(id, followUp)
}
return id
}
/**
* Apply a resource — whole or fragment — to an existing account, and mark it provisioned.
*
* Marking it here is what adopts an account somebody created by hand: a connector finds it with a
* filter, writes its own version of the record onto it, and from that moment the directory owns it.
*/
async applyUser(user: Record<string, any>, resource: ScimResource): Promise<void> {
const { patch, meta } = this.userValuesFrom(resource)
if (patch.email && patch.email !== user.email.toLowerCase()) {
const clash = await WIKI.models.users.getByEmail(patch.email)
if (clash && clash.id !== user.id) {
throw new ScimError(409, 'A user with this email address already exists.', 'uniqueness')
}
}
if (meta.externalId) {
const clash = await this.getUserByExternalId(meta.externalId)
if (clash && clash.id !== user.id) {
throw new ScimError(409, 'A user with this externalId already exists.', 'uniqueness')
}
}
const values: UserPatch = { ...patch, isProvisioned: true }
if (meta.externalId !== undefined) {
values.externalId = meta.externalId
}
Object.assign(values, await this.blobPatchFor(user.id, meta))
await WIKI.models.users.updateUser(user.id, values)
/*
A deactivation has to reach the sessions as well as the row. Login refuses an inactive account,
but a cookie issued before the directory disabled somebody is a session row that nothing
re-checks — thirty days of access after the person left. This is the single most important line
in the file.
*/
if (values.isActive === false) {
await WIKI.models.sessions.clearSessionsFromUser(user.id)
}
}
/** The `meta` / `prefs` blob fields a resource touched, merged onto what is stored. */
private async blobPatchFor(userId: string, meta: Record<string, any>): Promise<UserPatch> {
if (meta.jobTitle === undefined && meta.timezone === undefined) {
return {}
}
const user = await WIKI.models.users.getById(userId)
const patch: UserPatch = {}
if (meta.jobTitle !== undefined) {
patch.meta = { ...((user?.meta ?? {}) as Record<string, any>), jobTitle: meta.jobTitle }
}
if (meta.timezone !== undefined) {
patch.prefs = { ...((user?.prefs ?? {}) as Record<string, any>), timezone: meta.timezone }
}
return patch
}
async getUserByExternalId(externalId: string): Promise<Record<string, any> | null> {
const rows = await WIKI.db
.select()
.from(usersTable)
.where(eq(usersTable.externalId, externalId))
.limit(1)
return rows[0] ?? null
}
/**
* Deprovision an account the directory owns.
*
* `deactivate` — the default — clears the sessions and every group membership but keeps the row,
* because a wiki's users are its authors: deleting one takes the attribution off every page and
* every version they wrote. `delete` removes it outright, for an instance that would rather not
* keep the record.
*
* RFC 7644 wants a subsequent GET to answer 404. Under `deactivate` it answers the resource with
* `active: false` instead, which is a deliberate deviation — and the useful one, since it is what
* lets the same person be re-enabled later rather than collide with their own address.
*/
async deprovisionUser(userId: string): Promise<ScimDeleteAction> {
const action = this.getConfig().deleteAction
if (action === 'delete') {
await WIKI.models.users.deleteUser(userId)
return 'delete'
}
await WIKI.models.users.updateUser(userId, { isActive: false })
await WIKI.db.delete(userGroups).where(eq(userGroups.userId, userId))
await WIKI.models.sessions.clearSessionsFromUser(userId)
return 'deactivate'
}
// ==========================================
// GROUPS
// ==========================================
private readonly groupFilterAttrs: Record<string, string> = {
displayname: 'name',
externalid: 'externalId',
id: 'id'
}
async listGroups({
filter,
startIndex,
count: pageSize,
baseUrl
}: {
filter?: string
startIndex: number
count: number
baseUrl: string
}): Promise<ScimResource> {
const parsed = this.parseFilter(filter, this.groupFilterAttrs)
const conditions = []
if (parsed?.attr === 'name') {
conditions.push(sql`lower(${groupsTable.name}) = lower(${parsed.value})`)
} else if (parsed?.attr === 'externalId') {
conditions.push(eq(groupsTable.externalId, parsed.value))
} else if (parsed?.attr === 'id') {
if (!this.isUuid(parsed.value)) {
return this.listResponse([], 0, startIndex)
}
conditions.push(eq(groupsTable.id, parsed.value))
}
const where = conditions.length > 0 ? and(...conditions) : undefined
const totals = await WIKI.db.select({ total: count() }).from(groupsTable).where(where)
const total = totals[0]?.total ?? 0
if (pageSize < 1) {
return this.listResponse([], total, startIndex)
}
const rows = await WIKI.db
.select()
.from(groupsTable)
.where(where)
.orderBy(asc(groupsTable.id))
.limit(pageSize)
.offset(startIndex - 1)
const members = await this.membersOf(rows.map((row: any) => row.id))
return this.listResponse(
rows.map((row: any) => this.toScimGroup(row, members.get(row.id) ?? [], baseUrl)),
total,
startIndex
)
}
async getGroup(id: string): Promise<Record<string, any> | null> {
if (!this.isUuid(id)) {
return null
}
const rows = await WIKI.db.select().from(groupsTable).where(eq(groupsTable.id, id)).limit(1)
return rows[0] ?? null
}
/**
* The members of each of these groups, in one query.
*
* The guest account is left out: it is the anonymous visitor rather than a person, SCIM never sees
* it as a user, and a group listing that named an id no `GET /Users/:id` would answer is a listing
* a connector cannot reconcile against.
*/
async membersOf(groupIds: string[]): Promise<Map<string, Array<{ id: string; name: string }>>> {
const result = new Map<string, Array<{ id: string; name: string }>>()
if (groupIds.length < 1) {
return result
}
const rows = await WIKI.db
.select({ groupId: userGroups.groupId, id: usersTable.id, name: usersTable.name })
.from(userGroups)
.innerJoin(usersTable, eq(usersTable.id, userGroups.userId))
.where(and(inArray(userGroups.groupId, groupIds), eq(usersTable.isSystem, false)))
for (const row of rows) {
const existing = result.get(row.groupId) ?? []
existing.push({ id: row.id, name: row.name })
result.set(row.groupId, existing)
}
return result
}
async getGroupByExternalId(externalId: string): Promise<Record<string, any> | null> {
const rows = await WIKI.db
.select()
.from(groupsTable)
.where(eq(groupsTable.externalId, externalId))
.limit(1)
return rows[0] ?? null
}
/**
* Create a wiki group from a SCIM resource.
*
* It is born holding the same starting permissions any group created in the admin area holds, and
* nothing more: a group named by a directory is a set of people, not a decision about what they
* may do. Whoever runs the wiki grants it what it should have.
*/
async createGroup(resource: ScimResource): Promise<string> {
if (!this.getConfig().allowGroupCreate) {
throw new ScimError(
403,
'This wiki does not accept groups created by provisioning. Create the group here first, and the directory can then manage its membership.'
)
}
const displayName = String(resource.displayName ?? '').trim()
if (displayName.length < 1) {
throw new ScimError(400, 'displayName is required.', 'invalidValue')
}
const invalid = await WIKI.models.groups.validateName(displayName)
if (invalid) {
throw new ScimError(409, invalid, 'uniqueness')
}
const externalId = resource.externalId === undefined ? null : String(resource.externalId)
if (externalId && (await this.getGroupByExternalId(externalId))) {
throw new ScimError(409, 'A group with this externalId already exists.', 'uniqueness')
}
const id = await WIKI.models.groups.createGroup(displayName)
await WIKI.models.groups.updateGroup(id, { isProvisioned: true, externalId } as GroupPatch)
return id
}
/** Rename a group and record its external id, leaving its permissions and rules untouched. */
async applyGroup(
group: Record<string, any>,
{ displayName, externalId }: { displayName?: string; externalId?: string | null }
): Promise<void> {
const patch: GroupPatch = { isProvisioned: true }
if (displayName !== undefined && displayName.trim() !== group.name) {
const trimmed = displayName.trim()
if (group.isSystem) {
throw new ScimError(403, `The '${group.name}' group is built in and cannot be renamed.`)
}
const invalid = await WIKI.models.groups.validateName(trimmed, group.id)
if (invalid) {
throw new ScimError(409, invalid, 'uniqueness')
}
patch.name = trimmed
}
if (externalId !== undefined) {
if (externalId) {
const clash = await this.getGroupByExternalId(externalId)
if (clash && clash.id !== group.id) {
throw new ScimError(409, 'A group with this externalId already exists.', 'uniqueness')
}
}
patch.externalId = externalId
}
await WIKI.models.groups.updateGroup(group.id, patch)
}
/** The ids currently in a group, which `add` and `remove` are relative to. */
async memberIdsOf(groupId: string): Promise<string[]> {
const rows = await WIKI.db
.select({ userId: userGroups.userId })
.from(userGroups)
.innerJoin(usersTable, eq(usersTable.id, userGroups.userId))
.where(and(eq(userGroups.groupId, groupId), eq(usersTable.isSystem, false)))
return rows.map((row: any) => row.userId)
}
/** Whether these ids all name a user SCIM can see. @returns the first id that does not */
async firstUnknownUser(userIds: string[]): Promise<string | null> {
const unique = [...new Set(userIds)]
if (unique.length < 1) {
return null
}
const malformed = unique.find((id) => !this.isUuid(id))
if (malformed) {
return malformed
}
const rows = await WIKI.db
.select({ id: usersTable.id })
.from(usersTable)
.where(and(inArray(usersTable.id, unique), eq(usersTable.isSystem, false)))
const found = new Set(rows.map((row: any) => row.id))
return unique.find((id) => !found.has(id)) ?? null
}
/** Delete a group the directory owns. Its memberships go with it, by the foreign key's cascade. */
async deleteGroup(groupId: string): Promise<void> {
await WIKI.models.groups.deleteGroup(groupId)
}
private isUuid(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
}
}
export const scim = new Scim()