feat(backend): add SCIM for user provisioning (#12713)

* Add SCIM
Closes https://github.com/inventree/InvenTree/issues/6339

* also test scim

* extend testing with conformance suite

* fix test suite results

* coverage completion

* fil test gaps

* add missing schema values

* fix more type issues

* fix error view

* remove route from OpenAPI

* add changelog entry

* add codeowners
This commit is contained in:
Matthias Mair
2026-08-31 09:24:14 +10:00
committed by GitHub
parent c821fa0f43
commit 4066fa6e0a
34 changed files with 2288 additions and 9 deletions
+3
View File
@@ -258,6 +258,9 @@ export enum ApiEndpoints {
notes_image_upload = 'notes-image-upload/',
email_list = 'admin/email/',
email_test = 'admin/email/test/',
scim_config = 'admin/scim/',
scim_generate = 'admin/scim/generate/',
scim_disable = 'admin/scim/disable/',
config_list = 'admin/config/',
parameter_list = 'parameter/',
parameter_template_list = 'parameter/template/',
@@ -21,6 +21,7 @@ import {
IconQrcode,
IconReport,
IconScale,
IconShieldLock,
IconSitemap,
IconTags,
IconUsersGroup
@@ -72,6 +73,10 @@ const MachineManagementPanel = Loadable(
lazy(() => import('./MachineManagementPanel'))
);
const ScimManagementPanel = Loadable(
lazy(() => import('./ScimManagementPanel'))
);
const ErrorReportTable = Loadable(
lazy(() => import('../../../../tables/settings/ErrorTable'))
);
@@ -262,6 +267,13 @@ export default function AdminCenter() {
icon: <IconDevicesPc />,
content: <MachineManagementPanel />,
hidden: !user.hasViewRole(UserRoles.admin)
},
{
name: 'identity',
label: t`Identity`,
icon: <IconShieldLock />,
content: <ScimManagementPanel />,
hidden: !user.hasViewRole(UserRoles.admin)
}
];
}, [user]);
@@ -273,6 +285,7 @@ export default function AdminCenter() {
label: t`Operations`,
panelIDs: [
'user',
'identity',
'barcode-history',
'background',
'errors',
@@ -0,0 +1,194 @@
import { CopyButton } from '@lib/components/CopyButton';
import { StylishText } from '@lib/components/StylishText';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {
Alert,
Badge,
Button,
Code,
Divider,
Group,
Loader,
Modal,
Paper,
Stack,
Table,
Text
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { IconShieldLock, IconShieldOff } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { api, queryClient } from '../../../../App';
import { showApiErrorMessage } from '../../../../functions/notifications';
export default function ScimManagementPanel() {
const [secret, setSecret] = useState<string>('');
const [
secretModalOpened,
{ open: openSecretModal, close: closeSecretModal }
] = useDisclosure(false);
const { data, isFetching } = useQuery({
queryKey: ['scim-config'],
queryFn: () =>
api.get(apiUrl(ApiEndpoints.scim_config)).then((res) => res.data),
refetchOnMount: true
});
const generateSecret = (action: 'generate' | 'rotate') => {
api
.post(apiUrl(ApiEndpoints.scim_generate))
.then((res) => {
setSecret(res.data.secret);
openSecretModal();
queryClient.invalidateQueries({ queryKey: ['scim-config'] });
showNotification({
title:
action === 'generate' ? t`SCIM enabled` : t`SCIM secret rotated`,
message: t`The new bearer secret is only shown once`,
color: 'green'
});
})
.catch((error) => {
showApiErrorMessage({ error, title: t`Error generating SCIM secret` });
});
};
const disableScim = () => {
api
.post(apiUrl(ApiEndpoints.scim_disable))
.then(() => {
queryClient.invalidateQueries({ queryKey: ['scim-config'] });
showNotification({
title: t`SCIM disabled`,
message: t`The SCIM provisioning endpoint has been disabled and its secret revoked`,
color: 'blue'
});
})
.catch((error) => {
showApiErrorMessage({ error, title: t`Error disabling SCIM` });
});
};
if (isFetching && !data) {
return <Loader />;
}
return (
<Stack gap='md'>
<Modal
opened={secretModalOpened}
onClose={closeSecretModal}
title={<StylishText size='xl'>{t`SCIM Bearer Secret`}</StylishText>}
centered
data-testid='scim-secret-modal'
>
<Alert color='yellow' mb='sm'>
<Trans>
This secret is only shown once - copy it now and store it in your
Identity Provider's SCIM configuration. It cannot be retrieved
again, only rotated.
</Trans>
</Alert>
<Paper p='sm' withBorder>
<Group justify='space-between' wrap='nowrap'>
<Code style={{ wordBreak: 'break-all', whiteSpace: 'normal' }}>
{secret}
</Code>
<CopyButton value={secret} />
</Group>
</Paper>
</Modal>
<Alert icon={<IconShieldLock />} color='blue'>
<Trans>
SCIM allows an external Identity Provider (e.g. Okta, Microsoft Entra
ID, OneLogin) to automatically provision and deprovision Users and
Groups. Single Sign-On (interactive login) is configured separately,
under Single Sign On.
</Trans>
</Alert>
<Table>
<Table.Tbody>
<Table.Tr>
<Table.Td>
<Trans>Status</Trans>
</Table.Td>
<Table.Td>
{data?.enabled ? (
<Badge color='green'>
<Trans>Enabled</Trans>
</Badge>
) : (
<Badge color='gray'>
<Trans>Disabled</Trans>
</Badge>
)}
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Base URL</Trans>
</Table.Td>
<Table.Td>
<Group gap='xs' wrap='nowrap'>
<Code>{data?.base_url}</Code>
<CopyButton value={data?.base_url} />
</Group>
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Secret Generated</Trans>
</Table.Td>
<Table.Td>{data?.secret_generated ?? '-'}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Last Used</Trans>
</Table.Td>
<Table.Td>{data?.last_used ?? '-'}</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
<Divider />
<Group>
<Button
leftSection={<IconShieldLock size={16} />}
onClick={() => generateSecret(data?.enabled ? 'rotate' : 'generate')}
>
{data?.enabled ? (
<Trans>Rotate Secret</Trans>
) : (
<Trans>Enable SCIM</Trans>
)}
</Button>
{data?.enabled && (
<Button
color='red'
variant='outline'
leftSection={<IconShieldOff size={16} />}
onClick={disableScim}
>
<Trans>Disable SCIM</Trans>
</Button>
)}
</Group>
<Text size='sm' c='dimmed'>
<Trans>
Rotating the secret immediately invalidates the previous one - update
your Identity Provider's configuration straight away.
</Trans>
</Text>
</Stack>
);
}