feat(ee): SCIM (#1347)

* SCIM - init (EE)

* accept db transaction

* sync

* Content parser support for scim+json

* patch scimmy

* sync

* return early if userIds is empty

* sync

* SCIM db table

* fixes

* scim tokens

* backfill

* feat(audit): add scim token events

* rename scim migration

* fix

* fix translation

* cleanup
This commit is contained in:
Philip Okugbe
2026-05-01 14:53:30 +01:00
committed by GitHub
parent 1d2486455f
commit 641ce142df
43 changed files with 1136 additions and 148 deletions

View File

@@ -0,0 +1,110 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('scim_tokens')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('name', 'varchar', (col) => col.notNull())
.addColumn('token_hash', 'varchar', (col) => col.notNull())
.addColumn('token_last_four', 'varchar(4)', (col) => col.notNull())
.addColumn('last_used_at', 'timestamptz')
.addColumn('is_enabled', 'boolean', (col) => col.notNull().defaultTo(true))
.addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz')
.execute();
await db.schema
.createIndex('idx_scim_tokens_token_hash')
.ifNotExists()
.on('scim_tokens')
.column('token_hash')
.execute();
await db.schema
.createIndex('idx_scim_tokens_workspace_id')
.ifNotExists()
.on('scim_tokens')
.column('workspace_id')
.execute();
await db.schema
.alterTable('users')
.addColumn('scim_external_id', 'text')
.execute();
await db.schema
.createIndex('idx_users_workspace_scim_external_id')
.ifNotExists()
.on('users')
.columns(['workspace_id', 'scim_external_id'])
.where('scim_external_id', 'is not', null)
.unique()
.execute();
await db.schema
.alterTable('groups')
.addColumn('scim_external_id', 'text')
.execute();
await db.schema
.createIndex('idx_groups_workspace_scim_external_id')
.ifNotExists()
.on('groups')
.columns(['workspace_id', 'scim_external_id'])
.where('scim_external_id', 'is not', null)
.unique()
.execute();
await db.schema
.alterTable('groups')
.addColumn('is_external', 'boolean', (col) =>
col.notNull().defaultTo(false),
)
.execute();
// Backfill: mark all non-default groups as external in workspaces with SSO group sync enabled
await sql`
UPDATE groups SET is_external = true
WHERE is_default = false
AND workspace_id IN (
SELECT workspace_id FROM auth_providers WHERE group_sync = true
)
`.execute(db);
await db.schema
.alterTable('workspaces')
.addColumn('is_scim_enabled', 'boolean', (col) =>
col.notNull().defaultTo(false),
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('scim_tokens').execute();
await db.schema.dropIndex('idx_users_workspace_scim_external_id').execute();
await db.schema.alterTable('users').dropColumn('scim_external_id').execute();
await db.schema.dropIndex('idx_groups_workspace_scim_external_id').execute();
await db.schema.alterTable('groups').dropColumn('scim_external_id').execute();
await db.schema.alterTable('groups').dropColumn('is_external').execute();
await db.schema
.alterTable('workspaces')
.dropColumn('is_scim_enabled')
.execute();
}

View File

@@ -9,7 +9,7 @@ import {
} from '@docmost/db/types/entity.types';
import { ExpressionBuilder, sql } from 'kysely';
import { PaginationOptions } from '../../pagination/pagination-options';
import { DB } from '@docmost/db/types/db';
import { DB, Groups } from '@docmost/db/types/db';
import { DefaultGroup } from '../../../core/group/dto/create-group.dto';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
@@ -17,16 +17,34 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
export class GroupRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
private baseFields: Array<keyof Groups> = [
'id',
'name',
'description',
'isDefault',
'isExternal',
'creatorId',
'workspaceId',
'createdAt',
'updatedAt',
'deletedAt',
];
async findById(
groupId: string,
workspaceId: string,
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
opts?: {
includeMemberCount?: boolean;
includeScimExternalId?: boolean;
trx?: KyselyTransaction;
},
): Promise<Group> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('groups')
.selectAll('groups')
.select(this.baseFields)
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
.where('id', '=', groupId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
@@ -35,13 +53,18 @@ export class GroupRepo {
async findByName(
groupName: string,
workspaceId: string,
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
opts?: {
includeMemberCount?: boolean;
includeScimExternalId?: boolean;
trx?: KyselyTransaction;
},
): Promise<Group> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('groups')
.selectAll('groups')
.select(this.baseFields)
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
.where(sql`LOWER(name)`, '=', sql`LOWER(${groupName})`)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
@@ -51,8 +74,11 @@ export class GroupRepo {
updatableGroup: UpdatableGroup,
groupId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await this.db
const db = dbOrTx(this.db, trx);
await db
.updateTable('groups')
.set({ ...updatableGroup, updatedAt: new Date() })
.where('id', '=', groupId)
@@ -68,7 +94,7 @@ export class GroupRepo {
return db
.insertInto('groups')
.values(insertableGroup)
.returningAll()
.returning(this.baseFields)
.executeTakeFirst();
}
@@ -80,7 +106,7 @@ export class GroupRepo {
return (
db
.selectFrom('groups')
.selectAll()
.select(this.baseFields)
// .select((eb) => this.withMemberCount(eb))
.where('isDefault', '=', true)
.where('workspaceId', '=', workspaceId)
@@ -106,7 +132,7 @@ export class GroupRepo {
async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) {
let baseQuery = this.db
.selectFrom('groups')
.selectAll('groups')
.select(this.baseFields)
.select((eb) => this.withMemberCount(eb))
.where('workspaceId', '=', workspaceId);

View File

@@ -44,6 +44,7 @@ export class UserRepo {
opts?: {
includePassword?: boolean;
includeUserMfa?: boolean;
includeScimExternalId?: boolean;
trx?: KyselyTransaction;
},
): Promise<User> {
@@ -53,6 +54,7 @@ export class UserRepo {
.select(this.baseFields)
.$if(opts?.includePassword, (qb) => qb.select('password'))
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
.where('id', '=', userId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
@@ -64,6 +66,7 @@ export class UserRepo {
opts?: {
includePassword?: boolean;
includeUserMfa?: boolean;
includeScimExternalId?: boolean;
trx?: KyselyTransaction;
},
): Promise<User> {
@@ -73,6 +76,7 @@ export class UserRepo {
.select(this.baseFields)
.$if(opts?.includePassword, (qb) => qb.select('password'))
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
.where(sql`LOWER(email)`, '=', sql`LOWER(${email})`)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();

View File

@@ -34,6 +34,7 @@ export class WorkspaceRepo {
'plan',
'enforceMfa',
'trashRetentionDays',
'isScimEnabled',
];
constructor(@InjectKysely() private readonly db: KyselyDB) {}

View File

@@ -213,7 +213,9 @@ export interface Groups {
description: string | null;
id: Generated<string>;
isDefault: boolean;
isExternal: Generated<boolean>;
name: string;
scimExternalId: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
@@ -338,6 +340,7 @@ export interface Users {
name: string | null;
password: string | null;
role: string | null;
scimExternalId: string | null;
settings: Json | null;
timezone: string | null;
updatedAt: Generated<Timestamp>;
@@ -381,6 +384,7 @@ export interface Workspaces {
enforceMfa: Generated<boolean | null>;
enforceSso: Generated<boolean>;
hostname: string | null;
isScimEnabled: Generated<boolean>;
id: Generated<string>;
licenseKey: string | null;
logo: string | null;
@@ -410,6 +414,20 @@ export interface Notifications {
createdAt: Generated<Timestamp>;
}
export interface ScimTokens {
createdAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
id: Generated<string>;
isEnabled: Generated<boolean>;
lastUsedAt: Timestamp | null;
name: string;
tokenHash: string;
tokenLastFour: string;
creatorId: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
export interface Watchers {
id: Generated<string>;
userId: string;
@@ -558,6 +576,7 @@ export interface DB {
pageVerifications: PageVerifications;
pageVerifiers: PageVerifiers;
pages: Pages;
scimTokens: ScimTokens;
shares: Shares;
spaceMembers: SpaceMembers;
spaces: Spaces;

View File

@@ -29,6 +29,7 @@ import {
UserMfa as _UserMFA,
UserSessions,
ApiKeys,
ScimTokens,
Watchers,
Audit as _Audit,
Templates,
@@ -159,6 +160,11 @@ export type ApiKey = Selectable<ApiKeys>;
export type InsertableApiKey = Insertable<ApiKeys>;
export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>;
// Scim Tokens
export type ScimToken = Selectable<ScimTokens>;
export type InsertableScimToken = Insertable<ScimTokens>;
export type UpdatableScimToken = Updateable<Omit<ScimTokens, 'id'>>;
// Page Embedding
export type PageEmbedding = Selectable<PageEmbeddings>;
export type InsertablePageEmbedding = Insertable<PageEmbeddings>;