From b7dc7b83572fe0aa72d37d70b119716259d63269 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Sun, 20 Sep 2026 02:19:41 -0400 Subject: [PATCH] feat: add SCIM provisioning support --- backend/api/groups.ts | 59 +- backend/api/index.ts | 1 + backend/api/schemas/group.ts | 7 +- backend/api/schemas/scim.ts | 94 + backend/api/schemas/user.ts | 5 + backend/api/system.ts | 115 + backend/api/users.ts | 59 +- backend/base.yml | 33 + backend/controllers/scim.ts | 808 ++ .../20260920030631_scarlett/migration.sql | 6 + .../20260920030631_scarlett/snapshot.json | 6916 +++++++++++++++++ backend/db/schema.ts | 64 +- backend/helpers/network.ts | 106 + backend/helpers/userGuards.ts | 142 + backend/index.ts | 17 +- backend/locales/en.json | 60 + backend/models/auditLog.ts | 1 + backend/models/groups.ts | 17 +- backend/models/index.ts | 2 + backend/models/scim.ts | 1236 +++ backend/models/settings.ts | 14 + backend/models/users.ts | 61 +- frontend/public/_assets/icons/fluent-scim.svg | 1 + frontend/public/_assets/icons/fluent-sync.svg | 1 + frontend/src/assets/icons.generated.js | 3 +- frontend/src/components/GroupEditOverlay.vue | 4 + frontend/src/layouts/AdminLayout.vue | 37 + frontend/src/pages/AdminGroups.vue | 8 + frontend/src/pages/AdminScim.vue | 624 ++ frontend/src/pages/AdminUsers.vue | 8 + frontend/src/router/routes.js | 1 + frontend/src/stores/admin.js | 2 + 32 files changed, 10372 insertions(+), 140 deletions(-) create mode 100644 backend/api/schemas/scim.ts create mode 100644 backend/controllers/scim.ts create mode 100644 backend/db/migrations/20260920030631_scarlett/migration.sql create mode 100644 backend/db/migrations/20260920030631_scarlett/snapshot.json create mode 100644 backend/helpers/userGuards.ts create mode 100644 backend/models/scim.ts create mode 100644 frontend/public/_assets/icons/fluent-scim.svg create mode 100644 frontend/public/_assets/icons/fluent-sync.svg create mode 100644 frontend/src/pages/AdminScim.vue diff --git a/backend/api/groups.ts b/backend/api/groups.ts index eadb8b836..79642ceac 100644 --- a/backend/api/groups.ts +++ b/backend/api/groups.ts @@ -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 diff --git a/backend/api/index.ts b/backend/api/index.ts index ba2f855f1..8cf3328c5 100644 --- a/backend/api/index.ts +++ b/backend/api/index.ts @@ -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)) diff --git a/backend/api/schemas/group.ts b/backend/api/schemas/group.ts index bace3ea9c..9c0f78055 100644 --- a/backend/api/schemas/group.ts +++ b/backend/api/schemas/group.ts @@ -92,10 +92,15 @@ export async function registerSchemas(app: FastifyInstance): Promise { 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', diff --git a/backend/api/schemas/scim.ts b/backend/api/schemas/scim.ts new file mode 100644 index 000000000..56155a093 --- /dev/null +++ b/backend/api/schemas/scim.ts @@ -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 { + /** + * 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'] } + } + } + } + }) +} diff --git a/backend/api/schemas/user.ts b/backend/api/schemas/user.ts index 1c19ac965..5b9bcdce9 100644 --- a/backend/api/schemas/user.ts +++ b/backend/api/schemas/user.ts @@ -61,6 +61,11 @@ export async function registerSchemas(app: FastifyInstance): Promise { 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', diff --git a/backend/api/system.ts b/backend/api/system.ts index 0b09222d8..7bad19fc2 100644 --- a/backend/api/system.ts +++ b/backend/api/system.ts @@ -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 }>( + '/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 */ diff --git a/backend/api/users.ts b/backend/api/users.ts index 46e4d39f3..633c00874 100644 --- a/backend/api/users.ts +++ b/backend/api/users.ts @@ -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 { } } -/** - * 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 { - 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 diff --git a/backend/base.yml b/backend/base.yml index 34976e0c7..a3e991ea1 100644 --- a/backend/base.yml +++ b/backend/base.yml @@ -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 diff --git a/backend/controllers/scim.ts b/backend/controllers/scim.ts new file mode 100644 index 000000000..15af3c758 --- /dev/null +++ b/backend/controllers/scim.ts @@ -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 = {}): Record { + 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 { + 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 +} + +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) { + 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> { + 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 }>('/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) { + 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> { + 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 { + 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[] { + 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 }>('/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 diff --git a/backend/db/migrations/20260920030631_scarlett/migration.sql b/backend/db/migrations/20260920030631_scarlett/migration.sql new file mode 100644 index 000000000..0128bcb8e --- /dev/null +++ b/backend/db/migrations/20260920030631_scarlett/migration.sql @@ -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"); \ No newline at end of file diff --git a/backend/db/migrations/20260920030631_scarlett/snapshot.json b/backend/db/migrations/20260920030631_scarlett/snapshot.json new file mode 100644 index 000000000..193236229 --- /dev/null +++ b/backend/db/migrations/20260920030631_scarlett/snapshot.json @@ -0,0 +1,6916 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "24917438-5701-4cfd-8a15-25bd9a55215e", + "prevIds": [ + "ec1640e5-74ad-4168-89af-02f3346b9e9a" + ], + "ddl": [ + { + "values": [ + "document", + "image", + "other" + ], + "name": "assetKind", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "pending", + "success", + "error" + ], + "name": "hookState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "active", + "completed", + "failed", + "interrupted" + ], + "name": "jobHistoryState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "draft", + "published", + "scheduled" + ], + "name": "pagePublishState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "inherit", + "override", + "overrideExact", + "hide", + "hideExact" + ], + "name": "treeNavigationMode", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "folder", + "page", + "asset" + ], + "name": "treeType", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "apiKeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "approvalRules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "assets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "auditLog", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "authentication", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "blocks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "groups", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "hooks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "iconSets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "icons", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobHistory", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobLock", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobSchedule", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "locales", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageEditSubmissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageHistory", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageLinks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageRenderQueue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageWatching", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "rateLimits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "siteAssets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sites", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "storage", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tree", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userAvatars", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userGroups", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userKeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "users", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "varchar(8)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "keyShort", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "groups", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "expiration", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isRevoked", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'START'", + "generated": null, + "identity": null, + "name": "match", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "submitterGroups", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "reviewerGroups", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileName", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileExt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "assetKind", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'other'", + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'application/octet-stream'", + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileSize", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "ts", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "clientIP", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "displayName", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "registration", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "allowedEmailRegex", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "autoEnrollGroups", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "block", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isCustom", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "definition", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "packageData", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "checksum", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorName", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "authorEmail", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "authorIP", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "comments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rules", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "redirectOnLogin", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "redirectOnFirstLogin", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "redirectOnLogout", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "externalId", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isProvisioned", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "events", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "includeMetadata", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "includeContent", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "acceptUntrusted", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authHeader", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "hookState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastErrorMessage", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "info", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refreshedAt", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "16", + "generated": null, + "identity": null, + "name": "width", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "16", + "generated": null, + "identity": null, + "name": "height", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "left", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "top", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "rotate", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hFlip", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "vFlip", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "jobHistoryState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "useWorker", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "wasScheduled", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastErrorMessage", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "executedBy", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastCheckedBy", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastCheckedAt", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cron", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "useWorker", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "retries", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "waitUntil", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isScheduled", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nativeName", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(8)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(3)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "region", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(4)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "script", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isRTL", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isInstalled", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "customCode", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "customName", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "strings", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "completeness", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "items", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "patch", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseHash", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guestName", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guestEmail", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'updated'", + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "versionDate", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetSiteId", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetLocale", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetPath", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetRef", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageLinks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowScripts", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowStyles", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "requestedById", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "alias", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "pagePublishState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "publishState", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishStartDate", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishEndDate", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "relations", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localeGroupId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "render", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "searchContent", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ts", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "toc", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "editor", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentType", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isBrowsable", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isSearchable", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": { + "as": "\"pages\".\"publishState\" != 'draft' AND \"pages\".\"isSearchable\"", + "type": "stored" + }, + "identity": null, + "name": "isSearchableComputed", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "ratingScore", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "ratingCount", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "scripts", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "historyData", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "creatorId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ownerId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "hits", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "windowStartedAt", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bannedUntil", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "settings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hostname", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "contentTypes", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "assetDelivery", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tag", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "usageCount", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "ltree", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folderPath", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileName", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "treeType", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tree", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "treeNavigationMode", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'inherit'", + "generated": null, + "identity": null, + "name": "navigationMode", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "navigationId", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "userAvatars" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "userAvatars" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "userGroups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupId", + "entityType": "columns", + "schema": "public", + "table": "userGroups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "validUntil", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "handle", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "externalId", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isProvisioned", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "auth", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "passkeys", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "prefs", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hasAvatar", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isActive", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isVerified", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastLoginAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "approvalRules_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "approvalRules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "assets_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_ts_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_kind_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_action_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blocks_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "blocks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "comments_page_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "comments_parentId_idx", + "entityType": "indexes", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "comments_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "externalId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "groups_externalId_idx", + "entityType": "indexes", + "schema": "public", + "table": "groups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "locales_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "locales" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "navigation_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "navigation_siteId_locale_key", + "entityType": "indexes", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"authorId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_page_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "versionDate", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "path", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "versionDate", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "targetSiteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetLocale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_target_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "targetSiteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetRef", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_targetRef_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "href", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageLinks_pageId_href_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageRenderQueue_createdAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageWatching_user_site_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageWatching_page_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "creatorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_creatorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ownerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_ownerId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ts", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "pages_ts_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tags", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "pages_tags_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isSearchableComputed", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_isSearchableComputed_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "localeGroupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_localeGroupId_locale_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "path", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_siteId_locale_path_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "rateLimits_updatedAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "rateLimits" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "module", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "storage_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "storage" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "tag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "folderPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_folderpath_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "folderPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gist", + "concurrently": false, + "name": "tree_folderpath_gist_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_fileName_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_hash_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tree", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_locale_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "navigationMode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_navigationMode_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "navigationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_navigationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tags", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "tree_tags_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "groupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_groupId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userKeys_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userKeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastLoginAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_lastLoginAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "externalId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_externalId_idx", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lower(\"handle\")", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_handle_idx", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "approvalRules_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "approvalRules" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "assets_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "assets_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "auditLog_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "blocks_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blocks" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "comments_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "comments", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "comments_parentId_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "comments_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "comments" + }, + { + "nameExplicit": false, + "columns": [ + "prefix" + ], + "schemaTo": "public", + "tableTo": "iconSets", + "columnsTo": [ + "prefix" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "icons_prefix_iconSets_prefix_fkey", + "entityType": "fks", + "schema": "public", + "table": "icons" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "navigation_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageEditSubmissions_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageEditSubmissions_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageEditSubmissions_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "pageHistory_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageHistory_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": false, + "columns": [ + "targetSiteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageLinks_targetSiteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageLinks_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageLinks_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageLinks" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageRenderQueue_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageRenderQueue_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "requestedById" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "pageRenderQueue_requestedById_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageWatching_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageWatching_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageWatching_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "creatorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_creatorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "ownerId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_ownerId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "sessions_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "siteAssets_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "siteAssets" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "storage_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "storage" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "tags_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "tree_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "userGroups_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": false, + "columns": [ + "groupId" + ], + "schemaTo": "public", + "tableTo": "groups", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "userGroups_groupId_groups_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "userKeys_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userKeys" + }, + { + "columns": [ + "prefix", + "name" + ], + "nameExplicit": false, + "name": "icons_pkey", + "entityType": "pks", + "schema": "public", + "table": "icons" + }, + { + "columns": [ + "siteId", + "kind" + ], + "nameExplicit": false, + "name": "siteAssets_pkey", + "entityType": "pks", + "schema": "public", + "table": "siteAssets" + }, + { + "columns": [ + "userId", + "groupId" + ], + "nameExplicit": false, + "name": "userGroups_pkey", + "entityType": "pks", + "schema": "public", + "table": "userGroups" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "apiKeys_pkey", + "schema": "public", + "table": "apiKeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "approvalRules_pkey", + "schema": "public", + "table": "approvalRules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "assets_pkey", + "schema": "public", + "table": "assets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "auditLog_pkey", + "schema": "public", + "table": "auditLog", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "authentication_pkey", + "schema": "public", + "table": "authentication", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blocks_pkey", + "schema": "public", + "table": "blocks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "comments_pkey", + "schema": "public", + "table": "comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "groups_pkey", + "schema": "public", + "table": "groups", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "hooks_pkey", + "schema": "public", + "table": "hooks", + "entityType": "pks" + }, + { + "columns": [ + "prefix" + ], + "nameExplicit": false, + "name": "iconSets_pkey", + "schema": "public", + "table": "iconSets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobHistory_pkey", + "schema": "public", + "table": "jobHistory", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "jobLock_pkey", + "schema": "public", + "table": "jobLock", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobSchedule_pkey", + "schema": "public", + "table": "jobSchedule", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobs_pkey", + "schema": "public", + "table": "jobs", + "entityType": "pks" + }, + { + "columns": [ + "code" + ], + "nameExplicit": false, + "name": "locales_pkey", + "schema": "public", + "table": "locales", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "navigation_pkey", + "schema": "public", + "table": "navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageEditSubmissions_pkey", + "schema": "public", + "table": "pageEditSubmissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageHistory_pkey", + "schema": "public", + "table": "pageHistory", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageLinks_pkey", + "schema": "public", + "table": "pageLinks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageRenderQueue_pkey", + "schema": "public", + "table": "pageRenderQueue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageWatching_pkey", + "schema": "public", + "table": "pageWatching", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pages_pkey", + "schema": "public", + "table": "pages", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "rateLimits_pkey", + "schema": "public", + "table": "rateLimits", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "settings_pkey", + "schema": "public", + "table": "settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sites_pkey", + "schema": "public", + "table": "sites", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "storage_pkey", + "schema": "public", + "table": "storage", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tags_pkey", + "schema": "public", + "table": "tags", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tree_pkey", + "schema": "public", + "table": "tree", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userAvatars_pkey", + "schema": "public", + "table": "userAvatars", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userKeys_pkey", + "schema": "public", + "table": "userKeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "users_pkey", + "schema": "public", + "table": "users", + "entityType": "pks" + }, + { + "nameExplicit": false, + "columns": [ + "customCode" + ], + "nullsNotDistinct": false, + "name": "locales_customCode_key", + "schema": "public", + "table": "locales", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "nullsNotDistinct": false, + "name": "pageRenderQueue_pageId_key", + "schema": "public", + "table": "pageRenderQueue", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "hostname" + ], + "nullsNotDistinct": false, + "name": "sites_hostname_key", + "schema": "public", + "table": "sites", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "users_email_key", + "schema": "public", + "table": "users", + "entityType": "uniques" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/backend/db/schema.ts b/backend/db/schema.ts index e55eff5f2..d47320cfe 100644 --- a/backend/db/schema.ts +++ b/backend/db/schema.ts @@ -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})`) diff --git a/backend/helpers/network.ts b/backend/helpers/network.ts index d4dd63250..a619836c6 100644 --- a/backend/helpers/network.ts +++ b/backend/helpers/network.ts @@ -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) +} diff --git a/backend/helpers/userGuards.ts b/backend/helpers/userGuards.ts new file mode 100644 index 000000000..8ae2ccbb0 --- /dev/null +++ b/backend/helpers/userGuards.ts @@ -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 { + 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 { + 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 + ) +} diff --git a/backend/index.ts b/backend/index.ts index 81a0f403f..702f90994 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -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' }) diff --git a/backend/locales/en.json b/backend/locales/en.json index 3b162a6ad..e62a0c0e5 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -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", diff --git a/backend/models/auditLog.ts b/backend/models/auditLog.ts index 31eb92fd0..3b9030980 100644 --- a/backend/models/auditLog.ts +++ b/backend/models/auditLog.ts @@ -123,6 +123,7 @@ export const AUDIT_ACTIONS = { 'installExtension', 'updateApiState', 'updateMetricsState', + 'updateScimState', 'disconnectWebsockets', 'flushCache', 'regenerateCertificates', diff --git a/backend/models/groups.ts b/backend/models/groups.ts index 32ae53559..0dae71608 100644 --- a/backend/models/groups.ts +++ b/backend/models/groups.ts @@ -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) diff --git a/backend/models/index.ts b/backend/models/index.ts index 83167f006..16789b09e 100644 --- a/backend/models/index.ts +++ b/backend/models/index.ts @@ -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, diff --git a/backend/models/scim.ts b/backend/models/scim.ts new file mode 100644 index 000000000..93fd1536c --- /dev/null +++ b/backend/models/scim.ts @@ -0,0 +1,1236 @@ +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 + +/** 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): Partial { + const patch: Record = {} + 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): 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): Promise { + 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): 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, + memberships: Array<{ id: string; name: string }>, + baseUrl: string + ): ScimResource { + const meta = (user.meta ?? {}) as Record + const prefs = (user.prefs ?? {}) as Record + 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, + 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): { 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: ` eq ""`. + * + * 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 + ): { 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 ' eq ""' 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): 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): 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): { + 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 ""]` 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 = { + 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 { + 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 | 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>> { + const result = new Map>() + 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 } { + const patch: UserPatch = {} + const meta: Record = {} + + 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 { + 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, resource: ScimResource): Promise { + 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): Promise { + 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), jobTitle: meta.jobTitle } + } + if (meta.timezone !== undefined) { + patch.prefs = { ...((user?.prefs ?? {}) as Record), timezone: meta.timezone } + } + return patch + } + + async getUserByExternalId(externalId: string): Promise | 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 { + 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 = { + displayname: 'name', + externalid: 'externalId', + id: 'id' + } + + async listGroups({ + filter, + startIndex, + count: pageSize, + baseUrl + }: { + filter?: string + startIndex: number + count: number + baseUrl: string + }): Promise { + 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 | 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>> { + const result = new Map>() + 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 | 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 { + 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, + { displayName, externalId }: { displayName?: string; externalId?: string | null } + ): Promise { + 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 { + 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 { + 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 { + 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() diff --git a/backend/models/settings.ts b/backend/models/settings.ts index 8983b9b3e..3859bc69d 100644 --- a/backend/models/settings.ts +++ b/backend/models/settings.ts @@ -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: { diff --git a/backend/models/users.ts b/backend/models/users.ts index 229559ceb..cff6911d5 100644 --- a/backend/models/users.ts +++ b/backend/models/users.ts @@ -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 prefs?: Record } @@ -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 }) diff --git a/frontend/public/_assets/icons/fluent-scim.svg b/frontend/public/_assets/icons/fluent-scim.svg new file mode 100644 index 000000000..5c1b0ace0 --- /dev/null +++ b/frontend/public/_assets/icons/fluent-scim.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/_assets/icons/fluent-sync.svg b/frontend/public/_assets/icons/fluent-sync.svg new file mode 100644 index 000000000..d8049303e --- /dev/null +++ b/frontend/public/_assets/icons/fluent-sync.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index 83ea3b075..08375fa9c 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -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":"","width":32,"height":32}, @@ -57,6 +57,7 @@ export const BUNDLED_ICONS = { "la:ellipsis-v": {"body":"","width":32,"height":32}, "la:envelope": {"body":"","width":32,"height":32}, "la:eraser": {"body":"","width":32,"height":32}, + "la:exclamation-circle": {"body":"","width":32,"height":32}, "la:exclamation-triangle": {"body":"","width":32,"height":32}, "la:external-link-alt": {"body":"","width":32,"height":32}, "la:external-link-square-alt": {"body":"","width":32,"height":32}, diff --git a/frontend/src/components/GroupEditOverlay.vue b/frontend/src/components/GroupEditOverlay.vue index e495d7386..a382f4cc2 100644 --- a/frontend/src/components/GroupEditOverlay.vue +++ b/frontend/src/components/GroupEditOverlay.vue @@ -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.' } ] } diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index f0f0575d2..e23d3fb83 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -249,6 +249,23 @@ :class="countBadgeClass(adminStore.info.groupsTotal)" /> + + + + + + {{ t('admin.scim.title') }} + + + + @@ -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( diff --git a/frontend/src/pages/AdminGroups.vue b/frontend/src/pages/AdminGroups.vue index a4306f9ce..220bd5340 100644 --- a/frontend/src/pages/AdminGroups.vue +++ b/frontend/src/pages/AdminGroups.vue @@ -70,6 +70,14 @@
{{ props.value }} + + + {{ t('admin.groups.provisioned') }} +
diff --git a/frontend/src/pages/AdminScim.vue b/frontend/src/pages/AdminScim.vue new file mode 100644 index 000000000..e78b033f6 --- /dev/null +++ b/frontend/src/pages/AdminScim.vue @@ -0,0 +1,624 @@ + + + + + diff --git a/frontend/src/pages/AdminUsers.vue b/frontend/src/pages/AdminUsers.vue index a25e70ce4..bdde3a510 100644 --- a/frontend/src/pages/AdminUsers.vue +++ b/frontend/src/pages/AdminUsers.vue @@ -88,6 +88,14 @@ {{ props.value }} + + + {{ t('admin.users.provisioned') }} + diff --git a/frontend/src/router/routes.js b/frontend/src/router/routes.js index a6a8d3e77..e82adcf52 100644 --- a/frontend/src/router/routes.js +++ b/frontend/src/router/routes.js @@ -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') }, diff --git a/frontend/src/stores/admin.js b/frontend/src/stores/admin.js index 9b2ad754b..a800131c9 100644 --- a/frontend/src/stores/admin.js +++ b/frontend/src/stores/admin.js @@ -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 },