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
@@ -608,25 +608,21 @@
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
"Image removed successfully": "Image removed successfully",
"API key": "API key",
"API key created successfully": "API key created successfully",
"API keys": "API keys",
"API management": "API management",
"Are you sure you want to revoke this API key": "Are you sure you want to revoke this API key",
"Create API Key": "Create API Key",
"Custom expiration date": "Custom expiration date",
"Enter a descriptive token name": "Enter a descriptive token name",
"Expiration": "Expiration",
"Expired": "Expired",
"Expires": "Expires",
"I've saved my API key": "I've saved my API key",
"Last use": "Last Used",
"No API keys found": "No API keys found",
"No expiration": "No expiration",
"Revoke API key": "Revoke API key",
"Revoked successfully": "Revoked successfully",
"Select expiration date": "Select expiration date",
"This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.",
"Update API key": "Update API key",
"Update": "Update",
"Update {{credential}}": "Update {{credential}}",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace",
"Restrict API key creation to admins": "Restrict API key creation to admins",
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "Only admins and owners can create new API keys. Existing member keys will continue to work.",
@@ -880,5 +876,29 @@
"Try a different search term.": "Try a different search term.",
"Try again": "Try again",
"Untitled chat": "Untitled chat",
"What can I help you with?": "What can I help you with?"
"What can I help you with?": "What can I help you with?",
"Are you sure you want to revoke this {{credential}}": "Are you sure you want to revoke this {{credential}}",
"Automatically provision users and groups from your identity provider via SCIM.": "Automatically provision users and groups from your identity provider via SCIM.",
"Configure your identity provider with this URL to provision users and groups.": "Configure your identity provider with this URL to provision users and groups.",
"Create {{credential}}": "Create {{credential}}",
"{{credential}} created": "{{credential}} created",
"{{credential}} created successfully": "{{credential}} created successfully",
"Created by": "Created by",
"Custom": "Custom",
"Enable SCIM": "Enable SCIM",
"Enter a descriptive name": "Enter a descriptive name",
"I've saved my {{credential}}": "I've saved my {{credential}}",
"Important": "Important",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "Make sure to copy your {{credential}} now. You won't be able to see it again!",
"Never": "Never",
"Revoke {{credential}}": "Revoke {{credential}}",
"SCIM endpoint URL": "SCIM endpoint URL",
"SCIM provisioning": "SCIM provisioning",
"SCIM takes precedence over SSO group sync while enabled.": "SCIM takes precedence over SSO group sync while enabled.",
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.",
"SCIM token": "SCIM token",
"SCIM tokens": "SCIM tokens",
"This action cannot be undone. Your identity provider will stop syncing immediately.": "This action cannot be undone. Your identity provider will stop syncing immediately.",
"Toggle SCIM provisioning": "Toggle SCIM provisioning",
"Token": "Token"
}
@@ -116,7 +116,9 @@ export default function GlobalAppShell({
</AppShell.Navbar>
<AppShell.Main>
{isSettingsRoute ? (
<Container size={900}>{children}</Container>
<Container size={900} pb={80}>
{children}
</Container>
) : (
children
)}
@@ -13,6 +13,7 @@ import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key";
import { getAuditLogs } from "@/ee/audit/services/audit-service";
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
export const prefetchWorkspaceMembers = () => {
const params: QueryParams = { limit: 100, query: "" };
@@ -98,3 +99,10 @@ export const prefetchVerifiedPages = () => {
queryFn: () => getVerificationList(params),
});
};
export const prefetchScimTokens = () => {
queryClient.prefetchQuery({
queryKey: ["scim-token-list", { cursor: undefined }],
queryFn: () => getScimTokens({}),
});
};
@@ -31,6 +31,7 @@ import {
prefetchBilling,
prefetchGroups,
prefetchLicense,
prefetchScimTokens,
prefetchShares,
prefetchSpaces,
prefetchSsoProviders,
@@ -204,7 +205,10 @@ export default function SettingsSidebar() {
}
break;
case "Security & SSO":
prefetchHandler = prefetchSsoProviders;
prefetchHandler = () => {
prefetchSsoProviders();
prefetchScimTokens();
};
break;
case "Public sharing":
prefetchHandler = prefetchShares;
@@ -31,7 +31,7 @@ export function ApiKeyCreatedModal({
<Modal
opened={opened}
onClose={onClose}
title={t("API key created")}
title={t("{{credential}} created", { credential: t("API key") })}
size="lg"
>
<Stack gap="md">
@@ -41,7 +41,8 @@ export function ApiKeyCreatedModal({
color="red"
>
{t(
"Make sure to copy your API key now. You won't be able to see it again!",
"Make sure to copy your {{credential}} now. You won't be able to see it again!",
{ credential: t("API key") },
)}
</Alert>
@@ -64,7 +65,7 @@ export function ApiKeyCreatedModal({
</div>
<Button fullWidth onClick={onClose} mt="md">
{t("I've saved my API key")}
{t("I've saved my {{credential}}", { credential: t("API key") })}
</Button>
</Stack>
</Modal>
@@ -105,7 +105,7 @@ export function CreateApiKeyModal({
<Modal
opened={opened}
onClose={handleClose}
title={t("Create API Key")}
title={t("Create {{credential}}", { credential: t("API key") })}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
@@ -30,12 +30,14 @@ export function RevokeApiKeyModal({
<Modal
opened={opened}
onClose={onClose}
title={t("Revoke API key")}
title={t("Revoke {{credential}}", { credential: t("API key") })}
size="md"
>
<Stack gap="md">
<Text>
{t("Are you sure you want to revoke this API key")}{" "}
{t("Are you sure you want to revoke this {{credential}}", {
credential: t("API key"),
})}{" "}
<strong>{apiKey?.name}</strong>?
</Text>
<Text size="sm" c="dimmed">
@@ -53,7 +53,7 @@ export function UpdateApiKeyModal({
<Modal
opened={opened}
onClose={onClose}
title={t("Update API key")}
title={t("Update {{credential}}", { credential: t("API key") })}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
@@ -63,7 +63,11 @@ export function useCreateApiKeyMutation() {
return useMutation<IApiKey, Error, ICreateApiKeyRequest>({
mutationFn: (data) => createApiKey(data),
onSuccess: () => {
notifications.show({ message: t("API key created successfully") });
notifications.show({
message: t("{{credential}} created successfully", {
credential: t("API key"),
}),
});
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
@@ -33,6 +33,10 @@ export const auditEventLabels: Record<string, string> = {
"api_key.updated": "Updated API key",
"api_key.deleted": "Deleted API key",
"scim_token.created": "Created SCIM token",
"scim_token.updated": "Updated SCIM token",
"scim_token.deleted": "Deleted SCIM token",
"space.created": "Created space",
"space.updated": "Updated space",
"space.deleted": "Deleted space",
@@ -174,6 +178,14 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "api_key.deleted", label: "Deleted API key" },
],
},
{
group: "SCIM token",
items: [
{ value: "scim_token.created", label: "Created SCIM token" },
{ value: "scim_token.updated", label: "Updated SCIM token" },
{ value: "scim_token.deleted", label: "Deleted SCIM token" },
],
},
{
group: "License",
items: [
@@ -0,0 +1,78 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useTranslation } from "react-i18next";
import { useCreateScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface CreateScimTokenModalProps {
opened: boolean;
onClose: () => void;
onSuccess: (response: IScimToken) => void;
}
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof formSchema>;
export function CreateScimTokenModal({
opened,
onClose,
onSuccess,
}: CreateScimTokenModalProps) {
const { t } = useTranslation();
const createMutation = useCreateScimTokenMutation();
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: { name: "" },
});
const handleSubmit = async (data: FormValues) => {
try {
const created = await createMutation.mutateAsync({ name: data.name });
onSuccess(created);
form.reset();
onClose();
} catch (err) {
//
}
};
const handleClose = () => {
form.reset();
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("Create {{credential}}", { credential: t("SCIM token") })}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive name")}
data-autofocus
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={createMutation.isPending}>
{t("Create")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -0,0 +1,55 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function EnableScim() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.isScimEnabled ?? false);
const hasAccess = useHasFeature(Feature.SCIM);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ isScimEnabled: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Enable SCIM")}</Text>
<Text size="sm" c="dimmed">
{t(
"Automatically provision users and groups from your identity provider via SCIM.",
)}
</Text>
</div>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle SCIM provisioning")}
/>
</Tooltip>
</Group>
);
}
@@ -0,0 +1,61 @@
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRevokeScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface RevokeScimTokenModalProps {
opened: boolean;
onClose: () => void;
scimToken: IScimToken | null;
}
export function RevokeScimTokenModal({
opened,
onClose,
scimToken,
}: RevokeScimTokenModalProps) {
const { t } = useTranslation();
const revokeMutation = useRevokeScimTokenMutation();
const handleRevoke = async () => {
if (!scimToken) return;
await revokeMutation.mutateAsync({ tokenId: scimToken.id });
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Revoke {{credential}}", { credential: t("SCIM token") })}
size="md"
>
<Stack gap="md">
<Text>
{t("Are you sure you want to revoke this {{credential}}", {
credential: t("SCIM token"),
})}{" "}
<strong>{scimToken?.name}</strong>?
</Text>
<Text size="sm" c="dimmed">
{t(
"This action cannot be undone. Your identity provider will stop syncing immediately.",
)}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={handleRevoke}
loading={revokeMutation.isPending}
>
{t("Revoke")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,69 @@
import {
Modal,
Text,
Stack,
Alert,
Group,
Button,
TextInput,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface ScimTokenCreatedModalProps {
opened: boolean;
onClose: () => void;
scimToken: IScimToken | null;
}
export function ScimTokenCreatedModal({
opened,
onClose,
scimToken,
}: ScimTokenCreatedModalProps) {
const { t } = useTranslation();
if (!scimToken) return null;
return (
<Modal
opened={opened}
onClose={onClose}
title={t("{{credential}} created", { credential: t("SCIM token") })}
size="lg"
>
<Stack gap="md">
<Alert
icon={<IconAlertTriangle size={16} />}
title={t("Important")}
color="red"
>
{t(
"Make sure to copy your {{credential}} now. You won't be able to see it again!",
{ credential: t("SCIM token") },
)}
</Alert>
<div>
<Text size="sm" fw={500} mb="xs">
{t("SCIM token")}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{ flex: 1 }}
value={scimToken.token}
readOnly
/>
<CopyTextButton text={scimToken.token} />
</Group>
</div>
<Button fullWidth onClick={onClose} mt="md">
{t("I've saved my {{credential}}", { credential: t("SCIM token") })}
</Button>
</Stack>
</Modal>
);
}
@@ -0,0 +1,130 @@
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { format } from "date-fns";
import { useTranslation } from "react-i18next";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import NoTableResults from "@/components/common/no-table-results";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface ScimTokenTableProps {
tokens: IScimToken[];
isLoading?: boolean;
onUpdate?: (token: IScimToken) => void;
onRevoke?: (token: IScimToken) => void;
}
export function ScimTokenTable({
tokens,
isLoading,
onUpdate,
onRevoke,
}: ScimTokenTableProps) {
const { t } = useTranslation();
const formatDate = (date: Date | string | null) => {
if (!date) return t("Never");
return format(new Date(date), "MMM dd, yyyy");
};
return (
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
<Table.Th>{t("Token")}</Table.Th>
<Table.Th>{t("Created by")}</Table.Th>
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{tokens && tokens.length > 0 ? (
tokens.map((token) => (
<Table.Tr key={token.id}>
<Table.Td>
<Text fz="sm" fw={500}>
{token.name}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" ff="monospace" c="dimmed">
{token.tokenLastFour}
</Text>
</Table.Td>
{token.creator ? (
<Table.Td>
<Group gap="4" wrap="nowrap">
<CustomAvatar
avatarUrl={token.creator?.avatarUrl}
name={token.creator.name}
size="sm"
/>
<Text fz="sm" lineClamp={1}>
{token.creator.name}
</Text>
</Group>
</Table.Td>
) : (
<Table.Td>
<Text fz="sm" c="dimmed">
</Text>
</Table.Td>
)}
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(token.lastUsedAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(token.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{onUpdate && (
<Menu.Item
leftSection={<IconEdit size={16} />}
onClick={() => onUpdate(token)}
>
{t("Rename")}
</Menu.Item>
)}
{onRevoke && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={() => onRevoke(token)}
>
{t("Revoke")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={6} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -0,0 +1,30 @@
import { Group, Stack, Text, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
export function ScimUrlPanel() {
const { t } = useTranslation();
const scimUrl = `${window.location.origin}/api/scim/v2`;
return (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("SCIM endpoint URL")}
</Text>
<Text size="xs" c="dimmed">
{t(
"Configure your identity provider with this URL to provision users and groups.",
)}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{ flex: 1 }}
value={scimUrl}
readOnly
/>
<CopyTextButton text={scimUrl} />
</Group>
</Stack>
);
}
@@ -0,0 +1,77 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useTranslation } from "react-i18next";
import { useEffect } from "react";
import { useUpdateScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof formSchema>;
interface UpdateScimTokenModalProps {
opened: boolean;
onClose: () => void;
scimToken: IScimToken | null;
}
export function UpdateScimTokenModal({
opened,
onClose,
scimToken,
}: UpdateScimTokenModalProps) {
const { t } = useTranslation();
const updateMutation = useUpdateScimTokenMutation();
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: { name: "" },
});
useEffect(() => {
if (opened && scimToken) {
form.setValues({ name: scimToken.name });
}
}, [opened, scimToken]);
const handleSubmit = async (data: FormValues) => {
if (!scimToken) return;
await updateMutation.mutateAsync({
tokenId: scimToken.id,
name: data.name,
});
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Update {{credential}}", { credential: t("SCIM token") })}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive name")}
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={updateMutation.isPending}>
{t("Update")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./types/scim-token.types";
export * from "./services/scim-token-service";
@@ -0,0 +1,96 @@
import { IPagination, QueryParams } from "@/lib/types.ts";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
createScimToken,
getScimTokens,
revokeScimToken,
updateScimToken,
} from "@/ee/scim/services/scim-token-service";
import {
IScimToken,
ICreateScimTokenRequest,
IRevokeScimTokenRequest,
IUpdateScimTokenRequest,
} from "@/ee/scim/types/scim-token.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useGetScimTokensQuery(
params?: QueryParams,
): UseQueryResult<IPagination<IScimToken>, Error> {
return useQuery({
queryKey: ["scim-token-list", params],
queryFn: () => getScimTokens(params),
placeholderData: keepPreviousData,
});
}
export function useCreateScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IScimToken, Error, ICreateScimTokenRequest>({
mutationFn: (data) => createScimToken(data),
onSuccess: () => {
notifications.show({
message: t("{{credential}} created successfully", {
credential: t("SCIM token"),
}),
});
queryClient.invalidateQueries({
predicate: (item) =>
["scim-token-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useUpdateScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IUpdateScimTokenRequest>({
mutationFn: (data) => updateScimToken(data),
onSuccess: () => {
notifications.show({ message: t("Updated successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["scim-token-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useRevokeScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IRevokeScimTokenRequest>({
mutationFn: (data) => revokeScimToken(data),
onSuccess: () => {
notifications.show({ message: t("Revoked successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["scim-token-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
@@ -0,0 +1,34 @@
import api from "@/lib/api-client";
import {
IScimToken,
ICreateScimTokenRequest,
IRevokeScimTokenRequest,
IUpdateScimTokenRequest,
} from "@/ee/scim/types/scim-token.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getScimTokens(
params?: QueryParams,
): Promise<IPagination<IScimToken>> {
const req = await api.post("/scim-tokens", { ...params });
return req.data;
}
export async function createScimToken(
data: ICreateScimTokenRequest,
): Promise<IScimToken> {
const req = await api.post<IScimToken>("/scim-tokens/create", data);
return req.data;
}
export async function updateScimToken(
data: IUpdateScimTokenRequest,
): Promise<void> {
await api.post("/scim-tokens/update", data);
}
export async function revokeScimToken(
data: IRevokeScimTokenRequest,
): Promise<void> {
await api.post("/scim-tokens/revoke", data);
}
@@ -0,0 +1,27 @@
import { IUser } from "@/features/user/types/user.types.ts";
export interface IScimToken {
id: string;
name: string;
token?: string;
tokenLastFour: string;
isEnabled: boolean;
creatorId: string;
workspaceId: string;
lastUsedAt: string | null;
createdAt: string;
creator?: Partial<IUser>;
}
export interface ICreateScimTokenRequest {
name: string;
}
export interface IUpdateScimTokenRequest {
tokenId: string;
name: string;
}
export interface IRevokeScimTokenRequest {
tokenId: string;
}
@@ -69,8 +69,8 @@ export default function SsoProviderList() {
return (
<>
<Card shadow="sm" radius="sm">
<Table.ScrollContainer minWidth={600}>
<Table verticalSpacing="sm">
<Table.ScrollContainer minWidth={600} maxHeight={400}>
<Table verticalSpacing="sm" stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
+137 -6
View File
@@ -1,8 +1,18 @@
import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Divider, Title } from "@mantine/core";
import React from "react";
import {
Alert,
Button,
Card,
Divider,
Group,
Space,
Title,
Tooltip,
} from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import React, { useState } from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import SsoProviderList from "@/ee/security/components/sso-provider-list.tsx";
import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx";
@@ -12,16 +22,41 @@ import { useTranslation } from "react-i18next";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import TrashRetention from "@/ee/security/components/trash-retention.tsx";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useGetScimTokensQuery } from "@/ee/scim/queries/scim-token-query";
import { ScimUrlPanel } from "@/ee/scim/components/scim-url-panel";
import { ScimTokenTable } from "@/ee/scim/components/scim-token-table";
import { CreateScimTokenModal } from "@/ee/scim/components/create-scim-token-modal";
import { ScimTokenCreatedModal } from "@/ee/scim/components/scim-token-created-modal";
import { RevokeScimTokenModal } from "@/ee/scim/components/revoke-scim-token-modal";
import { UpdateScimTokenModal } from "@/ee/scim/components/update-scim-token-modal";
import EnableScim from "@/ee/scim/components/enable-scim";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import Paginate from "@/components/common/paginate";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
const SCIM_TOKEN_LIMIT = 5;
export default function Security() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM);
const hasRetention = useHasFeature(Feature.RETENTION);
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
const hasScim = useHasFeature(Feature.SCIM);
const [workspace] = useAtom(workspaceAtom);
const isScimEnabled = workspace?.isScimEnabled ?? false;
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data: scimData, isLoading: scimLoading } = useGetScimTokensQuery(
hasScim && isScimEnabled ? { cursor } : undefined,
);
const [createOpen, setCreateOpen] = useState(false);
const [createdToken, setCreatedToken] = useState<IScimToken | null>(null);
const [updateTarget, setUpdateTarget] = useState<IScimToken | null>(null);
const [revokeTarget, setRevokeTarget] = useState<IScimToken | null>(null);
if (!isAdmin) {
return null;
@@ -45,7 +80,7 @@ export default function Security() {
<Divider my="lg" />
<Title order={4} my="lg">
Single sign-on (SSO)
{t("Single sign-on (SSO)")}
</Title>
<EnforceSso />
@@ -66,6 +101,102 @@ export default function Security() {
)}
<SsoProviderList />
{hasScim && (
<>
<Divider my="xl" />
<Title order={4} my="lg">
{t("SCIM provisioning")}
</Title>
<Alert
icon={<IconInfoCircle size={16} />}
color="blue"
variant="light"
mb="md"
>
{t("SCIM takes precedence over SSO group sync while enabled.")}
</Alert>
<EnableScim />
<Divider my="lg" />
<ScimUrlPanel />
{isScimEnabled && (
<>
<Divider my="lg" />
<Group justify="space-between" mb="md">
<Title order={5}>{t("SCIM tokens")}</Title>
<Tooltip
label={t(
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.",
{ max: SCIM_TOKEN_LIMIT },
)}
disabled={(scimData?.items.length ?? 0) < SCIM_TOKEN_LIMIT}
refProp="rootRef"
>
<Button
onClick={() => setCreateOpen(true)}
disabled={(scimData?.items.length ?? 0) >= SCIM_TOKEN_LIMIT}
>
{t("Create {{credential}}", {
credential: t("SCIM token"),
})}
</Button>
</Tooltip>
</Group>
<Card shadow="sm" radius="sm">
<ScimTokenTable
tokens={scimData?.items}
isLoading={scimLoading}
onUpdate={setUpdateTarget}
onRevoke={setRevokeTarget}
/>
</Card>
<Space h="md" />
{scimData?.items.length > 0 && (
<Paginate
hasPrevPage={scimData?.meta?.hasPrevPage}
hasNextPage={scimData?.meta?.hasNextPage}
onNext={() => goNext(scimData?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
<CreateScimTokenModal
opened={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={setCreatedToken}
/>
<ScimTokenCreatedModal
opened={!!createdToken}
onClose={() => setCreatedToken(null)}
scimToken={createdToken}
/>
<UpdateScimTokenModal
opened={!!updateTarget}
onClose={() => setUpdateTarget(null)}
scimToken={updateTarget}
/>
<RevokeScimTokenModal
opened={!!revokeTarget}
onClose={() => setRevokeTarget(null)}
scimToken={revokeTarget}
/>
</>
)}
</>
)}
</>
);
}
@@ -28,6 +28,7 @@ export interface IWorkspace {
trashRetentionDays?: number;
restrictApiToAdmins?: boolean;
allowMemberTemplates?: boolean;
isScimEnabled?: boolean;
}
export interface IWorkspaceSettings {
+1
View File
@@ -111,6 +111,7 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sanitize-filename": "1.6.3",
"scimmy": "1.3.5",
"socket.io": "^4.8.3",
"stripe": "^17.7.0",
"tlds": "^1.261.0",
@@ -23,6 +23,11 @@ export const AuditEvent = {
API_KEY_UPDATED: 'api_key.updated',
API_KEY_DELETED: 'api_key.deleted',
// SCIM Tokens
SCIM_TOKEN_CREATED: 'scim_token.created',
SCIM_TOKEN_UPDATED: 'scim_token.updated',
SCIM_TOKEN_DELETED: 'scim_token.deleted',
// Space
SPACE_CREATED: 'space.created',
SPACE_UPDATED: 'space.updated',
@@ -119,6 +124,7 @@ export const AuditResource = {
COMMENT: 'comment',
SHARE: 'share',
API_KEY: 'api_key',
SCIM_TOKEN: 'scim_token',
SSO_PROVIDER: 'sso_provider',
WORKSPACE_INVITATION: 'workspace_invitation',
ATTACHMENT: 'attachment',
+1 -1
View File
@@ -110,7 +110,7 @@ export function extractBearerTokenFromHeader(
request: FastifyRequest,
): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
return type?.toLowerCase() === 'bearer' ? token : undefined;
}
/**
@@ -7,7 +7,7 @@ import {
} from '@nestjs/common';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { GroupService } from './group.service';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { InjectKysely } from 'nestjs-kysely';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@@ -20,6 +20,7 @@ import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
import { dbOrTx } from '@docmost/db/utils';
@Injectable()
export class GroupUserService {
@@ -54,17 +55,23 @@ export class GroupUserService {
userIds: string[],
groupId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await this.groupService.findAndValidateGroup(groupId, workspaceId);
const db = dbOrTx(this.db, trx);
await this.groupService.findAndValidateGroup(groupId, workspaceId, trx);
if (userIds.length === 0) return;
// make sure we have valid workspace users
const validUsers = await this.db
const validUsers = await db
.selectFrom('users')
.select(['id', 'name'])
.where('users.id', 'in', userIds)
.where('users.workspaceId', '=', workspaceId)
.execute();
if (validUsers.length === 0) return;
// prepare users to add to group
const groupUsersToInsert = [];
for (const user of validUsers) {
@@ -75,7 +82,7 @@ export class GroupUserService {
}
// batch insert new group users
await this.db
await db
.insertInto('groupUsers')
.values(groupUsersToInsert)
.onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing())
@@ -216,8 +216,11 @@ export class GroupService {
async findAndValidateGroup(
groupId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<Group> {
const group = await this.groupRepo.findById(groupId, workspaceId);
const group = await this.groupRepo.findById(groupId, workspaceId, {
trx,
});
if (!group) {
throw new NotFoundException('Group not found');
}
@@ -41,6 +41,10 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsBoolean()
mcpEnabled: boolean;
@IsOptional()
@IsBoolean()
isScimEnabled: boolean;
@IsOptional()
@IsBoolean()
aiChat: boolean;
@@ -331,7 +331,8 @@ export class WorkspaceService {
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined'
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined'
) {
const ws = await this.db
.selectFrom('workspaces')
@@ -351,6 +352,14 @@ export class WorkspaceService {
}
}
if (typeof updateWorkspaceDto.isScimEnabled !== 'undefined') {
if (!this.licenseCheckService.hasFeature(ws.licenseKey, Feature.SCIM, ws.plan)) {
throw new ForbiddenException(
'This feature requires a valid license',
);
}
}
if (
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
@@ -535,6 +544,7 @@ export class WorkspaceService {
'enforceSso',
'enforceMfa',
'emailDomains',
'isScimEnabled',
],
updateWorkspaceDto,
workspaceBefore,
@@ -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();
}
@@ -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);
@@ -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();
@@ -34,6 +34,7 @@ export class WorkspaceRepo {
'plan',
'enforceMfa',
'trashRetentionDays',
'isScimEnabled',
];
constructor(@InjectKysely() private readonly db: KyselyDB) {}
+19
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;
@@ -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>;
+16
View File
@@ -50,6 +50,22 @@ async function bootstrap() {
await app.register(fastifyMultipart);
await app.register(fastifyCookie);
app
.getHttpAdapter()
.getInstance()
.addContentTypeParser(
'application/scim+json',
{ parseAs: 'string' },
(_, body, done) => {
try {
const json = JSON.parse(body.toString());
done(null, json);
} catch (err: any) {
done(err);
}
},
);
app
.getHttpAdapter()
.getInstance()