mirror of https://github.com/boxyhq/jackson.git
Directory Sync (#202)
* SCIM Config API - / POST * SCIM wip * Add SCIM Webhook * Send webhoo event, and add signature * SCIM Group wip * wip * SCIM wip * User store wip * wip * wip * SCIM - Groups management * Add the params validation * Cleanup * Create user API, return the created user * Replace the nanoid with crypto .randomBytes * Improve the transform methods * Fix the events APIs * Fix * Wip - Testing with OneLogin SCIM * wip * Make changes to SCIM APIs * wip * Add the method createRandomSecret * wip * wip * wip * wip * wip * wip * wip * refactor wip * refactor wip * wip * Users finished * Group finished * Group fix * Fix the types * Fix the types * wip webhook events * Fix the config API * wip * wip * wip * wip * Improve the methods * wip * wip * wip webhook * Refactor the code * Add some comments * Fix the API * wip SCIM * Fix the pk * Return the all the groups * Fix * Improve the code * Final changes * wip APIs * Rename variables * Rename the classes * Fix the APIs * wip * Admin UI - wip * Add SCIM config screen * Admin UI wip * Admin UI wip * Admin UI wip * Fix the Admin UI * Add tabs * Add tabs * Add user screens * Add EmptyState * Add users, groups info screen * Add JSON syntax highlighter * Fix the config details screen * Add authentication to the APIs * wip * Add types * Add webhook event logs * Add type to directory * Display the event log details * Fix the missing arg * Ability to configure the logging enable/disable * Display alert if webhook logging is disabled * Fix the SCIM * Applied prettier * Search users by userName * Fix the section width * Add pagination for /users /groups in admin UI * Add pagination for directory listing * Fix the issues with list() * Add APIs * Add Next.js middleware for authentication * Fix the TS issue * Add pagination for SCIM /users * Add pagination for SCIM /users * Moved the tests into sub folders * Add unit tests for directories, users * wip * wip - unit tests * wip - unit tests * Some improvments * wip * Finished the SCIM unit tests * Some fixes * Fixes * Rename methods * Fix the TS * Many fixes * Fixes * Fixes * SCIM Fixes * SCIM updates * Fix the unit tests * Fix the unit tests * Fix the unit tests * Improve the unit tests * A fix * File renamed as per JS standard * Fix * Updates * Fix the SCIM APIs * Fix the tests * Added the Base class * Some fixes * Some fixes * Some fixes * Fix the events * Renamed to directorySyncController for consistency * Moved the createId to Base class * Moved the createId to Base class * Remove the Next.js middleware and add authentication to each routes * Change the text * Merged * Revert the changes * Improved the response of the SDK and APIs * Fix the return value * Azure related changes * Add the middleware back * Infer the types from getServerSideProps * givenName and familyName can be empty depends on the mapping * Fix the issue with update * API changes * Fixes * Fix the types * Revert the change * Improving the Webhooks and Callback * Added the event callback and changed the implementation for Webhook * Fix the SCIM API * Fix the events.ts file * wip * Cleanup and improve the request handler * Revert the package.json changes * Make the directory name optional. * Add a generic scim provider to the type * wip * Remove supabase UI * Update package-lock.json * Update the UI with DaisyUI * UI fixes * Final changes to the UI * Standardize the Input theme Co-authored-by: Kiran <kiran@Kirans-MacBook-Pro.local> Co-authored-by: Deepak Prabhakara <deepak@boxyhq.com>
This commit is contained in:
parent
29f532bd3a
commit
461a820b6d
|
@ -0,0 +1,14 @@
|
|||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
vairant?: 'info' | 'success' | 'warning' | 'error';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Badge = (props: Props) => {
|
||||
const { vairant = 'info', children } = props;
|
||||
|
||||
return <div className={`badge gap-2 badge-${vairant}`}>{children}</div>;
|
||||
};
|
||||
|
||||
export default Badge;
|
|
@ -1,9 +1,10 @@
|
|||
import Link from 'next/link';
|
||||
import { InformationCircleIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
const EmptyState = ({ title, href }: { title: string; href?: string }) => {
|
||||
const EmptyState = ({ title, href, className }: { title: string; href?: string; className?: string }) => {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center space-y-3 rounded border py-32'>
|
||||
<div
|
||||
className={`my-3 flex flex-col items-center justify-center space-y-3 rounded border py-32 ${className}`}>
|
||||
<InformationCircleIcon className='h-10 w-10' />
|
||||
<h4 className='text-center'>{title}</h4>
|
||||
{href && (
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
import Link from 'next/link';
|
||||
|
||||
const Paginate = ({
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
itemsCount,
|
||||
path,
|
||||
}: {
|
||||
pageOffset: number;
|
||||
pageLimit: number;
|
||||
itemsCount: number;
|
||||
path: string;
|
||||
}) => {
|
||||
if ((itemsCount === 0 && pageOffset === 0) || (itemsCount < pageLimit && pageOffset === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextPageUrl = itemsCount === pageLimit ? `${path}offset=${pageOffset + pageLimit}` : '#';
|
||||
const previousPageUrl = pageOffset > 0 ? `${path}offset=${pageOffset - pageLimit}` : '#';
|
||||
|
||||
return (
|
||||
<div className='flex justify-center py-3 px-3'>
|
||||
<Link href={previousPageUrl}>
|
||||
<a className='mr-3 inline-flex items-center rounded-lg border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white'>
|
||||
<svg
|
||||
className='mr-2 h-5 w-5'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
Previous
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<Link href={nextPageUrl}>
|
||||
<a className='inline-flex items-center rounded-lg border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white'>
|
||||
Next
|
||||
<svg
|
||||
className='ml-2 h-5 w-5'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Paginate;
|
|
@ -1,22 +1,31 @@
|
|||
import { ShieldCheckIcon } from '@heroicons/react/20/solid';
|
||||
import { ShieldCheckIcon, UsersIcon } from '@heroicons/react/20/solid';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import classNames from 'classnames';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import Logo from '../public/logo.png';
|
||||
|
||||
const menus = [
|
||||
{
|
||||
href: '/admin/saml/config',
|
||||
text: 'SAML Connections',
|
||||
icon: ShieldCheckIcon,
|
||||
current: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const Sidebar = (props: { isOpen: boolean; setIsOpen: any }) => {
|
||||
const { isOpen, setIsOpen } = props;
|
||||
|
||||
const { asPath } = useRouter();
|
||||
|
||||
const menus = [
|
||||
{
|
||||
href: '/admin/saml/config',
|
||||
text: 'SAML Connections',
|
||||
icon: ShieldCheckIcon,
|
||||
active: asPath.includes('/admin/saml'),
|
||||
},
|
||||
{
|
||||
href: '/admin/directory-sync',
|
||||
text: 'Directory Sync',
|
||||
icon: UsersIcon,
|
||||
active: asPath.includes('/admin/directory-sync'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
|
@ -94,7 +103,10 @@ export const Sidebar = (props: { isOpen: boolean; setIsOpen: any }) => {
|
|||
<a
|
||||
key={menu.text}
|
||||
href={menu.href}
|
||||
className='group flex items-center rounded-md bg-gray-100 px-2 py-2 text-sm font-medium text-gray-900'>
|
||||
className={classNames(
|
||||
'group flex items-center rounded-md px-2 py-2 text-sm text-gray-900',
|
||||
menu.active ? 'bg-gray-100 font-bold' : 'font-medium'
|
||||
)}>
|
||||
<menu.icon className='mr-4 h-6 w-6 flex-shrink-0' aria-hidden='true' />
|
||||
<div>{menu.text}</div>
|
||||
</a>
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
import Link from 'next/link';
|
||||
import type { Directory } from '@lib/jackson';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const DirectoryTab = (props: { directory: Directory; activeTab: string }) => {
|
||||
const { directory, activeTab } = props;
|
||||
|
||||
const menus = [
|
||||
{
|
||||
name: 'Directory',
|
||||
href: `/admin/directory-sync/${directory.id}`,
|
||||
active: activeTab === 'directory',
|
||||
},
|
||||
{
|
||||
name: 'Users',
|
||||
href: `/admin/directory-sync/${directory.id}/users`,
|
||||
active: activeTab === 'users',
|
||||
},
|
||||
{
|
||||
name: 'Groups',
|
||||
href: `/admin/directory-sync/${directory.id}/groups`,
|
||||
active: activeTab === 'groups',
|
||||
},
|
||||
{
|
||||
name: 'Webhook Events',
|
||||
href: `/admin/directory-sync/${directory.id}/events`,
|
||||
active: activeTab === 'events',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className='-mb-px flex space-x-5 border-b' aria-label='Tabs'>
|
||||
{menus.map((menu) => {
|
||||
return (
|
||||
<Link href={menu.href} key={menu.href}>
|
||||
<a
|
||||
className={classNames(
|
||||
'inline-flex items-center border-b-2 py-4 text-sm font-medium',
|
||||
menu.active
|
||||
? 'border-gray-700 text-gray-700'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
)}>
|
||||
{menu.name}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default DirectoryTab;
|
|
@ -195,7 +195,7 @@ const AddEdit = ({ samlConfig }: AddEditProps) => {
|
|||
{isEditView ? 'Edit Connection' : 'Create Connection'}
|
||||
</h2>
|
||||
<form onSubmit={saveSAMLConfiguration}>
|
||||
<div className='min-w-[28rem] rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 md:w-3/4 md:max-w-lg'>
|
||||
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 md:w-3/4 md:max-w-lg'>
|
||||
{fieldCatalog
|
||||
.filter(({ attributes: { showOnlyInEditView } }) => (isEditView ? true : !showOnlyInEditView))
|
||||
.map(
|
||||
|
@ -242,7 +242,7 @@ const AddEdit = ({ samlConfig }: AddEditProps) => {
|
|||
readOnly={readOnly}
|
||||
maxLength={maxLength}
|
||||
onChange={handleChange}
|
||||
className={`block w-full rounded-lg border border-gray-300 bg-gray-50 p-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500 ${
|
||||
className={`textarea textarea-bordered h-24 w-full ${
|
||||
isArray ? 'whitespace-pre' : ''
|
||||
}`}
|
||||
rows={rows}
|
||||
|
@ -257,7 +257,7 @@ const AddEdit = ({ samlConfig }: AddEditProps) => {
|
|||
readOnly={readOnly}
|
||||
maxLength={maxLength}
|
||||
onChange={handleChange}
|
||||
className='block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500'
|
||||
className='input input-bordered w-full'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import env from '@lib/env';
|
||||
|
||||
export const validateApiKey = (token: string | null) => {
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return env.apiKeys.includes(token);
|
||||
};
|
||||
|
||||
export const extractAuthToken = (req): string | null => {
|
||||
let authHeader = '';
|
||||
|
||||
if (typeof req.headers.get === 'function') {
|
||||
authHeader = req.headers.get('authorization') || '';
|
||||
} else {
|
||||
authHeader = req.headers.authorization || '';
|
||||
}
|
||||
|
||||
const parts = authHeader.split(' ');
|
||||
|
||||
return parts.length > 1 ? parts[1] : null;
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
type GetSSRResult<TProps> = { props: TProps } | { redirect: any } | { notFound: boolean };
|
||||
|
||||
type GetSSRFn<TProps> = (args: any) => Promise<GetSSRResult<TProps>>;
|
||||
|
||||
export type inferSSRProps<TFn extends GetSSRFn<any>> = TFn extends GetSSRFn<infer TProps>
|
||||
? NonNullable<TProps>
|
||||
: never;
|
|
@ -1,13 +1,23 @@
|
|||
import jackson, {
|
||||
import type {
|
||||
IAdminController,
|
||||
IAPIController,
|
||||
IdPConfig,
|
||||
ILogoutController,
|
||||
IOAuthController,
|
||||
IHealthCheckController,
|
||||
DirectorySync,
|
||||
DirectoryType,
|
||||
Directory,
|
||||
User,
|
||||
Group,
|
||||
DirectorySyncEvent,
|
||||
HTTPMethod,
|
||||
DirectorySyncRequest,
|
||||
IOidcDiscoveryController,
|
||||
ISPSAMLConfig,
|
||||
} from '@boxyhq/saml-jackson';
|
||||
|
||||
import jackson from '@boxyhq/saml-jackson';
|
||||
import env from '@lib/env';
|
||||
import '@lib/metrics';
|
||||
|
||||
|
@ -16,6 +26,7 @@ let oauthController: IOAuthController;
|
|||
let adminController: IAdminController;
|
||||
let logoutController: ILogoutController;
|
||||
let healthCheckController: IHealthCheckController;
|
||||
let directorySyncController: DirectorySync;
|
||||
let oidcDiscoveryController: IOidcDiscoveryController;
|
||||
let spConfig: ISPSAMLConfig;
|
||||
|
||||
|
@ -28,6 +39,7 @@ export default async function init() {
|
|||
!g.adminController ||
|
||||
!g.healthCheckController ||
|
||||
!g.logoutController ||
|
||||
!g.directorySync ||
|
||||
!g.oidcDiscoveryController ||
|
||||
!g.spConfig
|
||||
) {
|
||||
|
@ -37,6 +49,7 @@ export default async function init() {
|
|||
adminController = ret.adminController;
|
||||
logoutController = ret.logoutController;
|
||||
healthCheckController = ret.healthCheckController;
|
||||
directorySyncController = ret.directorySync;
|
||||
oidcDiscoveryController = ret.oidcDiscoveryController;
|
||||
spConfig = ret.spConfig;
|
||||
|
||||
|
@ -45,6 +58,7 @@ export default async function init() {
|
|||
g.adminController = adminController;
|
||||
g.logoutController = logoutController;
|
||||
g.healthCheckController = healthCheckController;
|
||||
g.directorySync = directorySyncController;
|
||||
g.oidcDiscoveryController = oidcDiscoveryController;
|
||||
g.spConfig = spConfig;
|
||||
g.isJacksonReady = true;
|
||||
|
@ -54,6 +68,7 @@ export default async function init() {
|
|||
adminController = g.adminController;
|
||||
logoutController = g.logoutController;
|
||||
healthCheckController = g.healthCheckController;
|
||||
directorySyncController = g.directorySync;
|
||||
oidcDiscoveryController = g.oidcDiscoveryController;
|
||||
spConfig = g.spConfig;
|
||||
}
|
||||
|
@ -65,8 +80,18 @@ export default async function init() {
|
|||
adminController,
|
||||
logoutController,
|
||||
healthCheckController,
|
||||
directorySyncController,
|
||||
oidcDiscoveryController,
|
||||
};
|
||||
}
|
||||
|
||||
export type { IdPConfig };
|
||||
export type {
|
||||
IdPConfig,
|
||||
DirectoryType,
|
||||
Directory,
|
||||
User,
|
||||
Group,
|
||||
DirectorySyncEvent,
|
||||
HTTPMethod,
|
||||
DirectorySyncRequest,
|
||||
};
|
||||
|
|
31
lib/utils.ts
31
lib/utils.ts
|
@ -1,21 +1,6 @@
|
|||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import env from '@lib/env';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import micromatch from 'micromatch';
|
||||
|
||||
export const validateApiKey = (token) => {
|
||||
return env.apiKeys.includes(token);
|
||||
};
|
||||
|
||||
export const extractAuthToken = (req: NextApiRequest) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const parts = (authHeader || '').split(' ');
|
||||
if (parts.length > 1) {
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const validateEmailWithACL = (email) => {
|
||||
const NEXTAUTH_ACL = process.env.NEXTAUTH_ACL || undefined;
|
||||
const acl = NEXTAUTH_ACL?.split(',');
|
||||
|
@ -39,3 +24,17 @@ export const setErrorCookie = (res: NextApiResponse, value: unknown, options: {
|
|||
}
|
||||
res.setHeader('Set-Cookie', cookieContents);
|
||||
};
|
||||
|
||||
const IsJsonString = (body: any): boolean => {
|
||||
try {
|
||||
const json = JSON.parse(body);
|
||||
|
||||
return typeof json === 'object';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const bodyParser = (req: NextApiRequest): any => {
|
||||
return IsJsonString(req.body) ? JSON.parse(req.body) : req.body;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
// eslint-disable-next-line
|
||||
import type { NextRequest } from 'next/server';
|
||||
// eslint-disable-next-line
|
||||
import { NextResponse } from 'next/server';
|
||||
import { validateApiKey, extractAuthToken } from '@lib/auth';
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const pathname = req.nextUrl.pathname;
|
||||
|
||||
if (pathname.startsWith('/api/v1')) {
|
||||
if (!validateApiKey(extractAuthToken(req))) {
|
||||
return NextResponse.rewrite(new URL('/api/v1/unauthenticated', req.nextUrl));
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
21
npm/map.js
21
npm/map.js
|
@ -1,8 +1,21 @@
|
|||
const map = {
|
||||
'test/api.test.ts': ['src/controller/api.ts'],
|
||||
'test/oauth.test.ts': ['src/controller/oauth.ts', 'src/controller/oauth/*', 'src/controller/utils.ts'],
|
||||
'test/logout.test.ts': ['src/controller/logout.ts', 'src/controller/utils.ts'],
|
||||
'test/db.test.ts': ['src/db/*'],
|
||||
'test/saml/api.test.ts': ['src/controller/api.ts'],
|
||||
'test/saml/oauth.test.ts': ['src/controller/oauth.ts', 'src/controller/oauth/*', 'src/controller/utils.ts'],
|
||||
'test/saml/logout.test.ts': ['src/controller/logout.ts', 'src/controller/utils.ts'],
|
||||
'test/db/db.test.ts': ['src/db/*'],
|
||||
|
||||
'test/dsync/directories.test.ts': ['src/directory-sync/DirectoryConfig.ts'],
|
||||
'test/dsync/users.test.ts': [
|
||||
'src/directory-sync/DirectoryUsers.ts',
|
||||
'src/directory-sync/Users.ts',
|
||||
'src/directory-sync/request.ts',
|
||||
],
|
||||
'test/dsync/groups.test.ts': [
|
||||
'src/directory-sync/DirectoryGroups.ts',
|
||||
'src/directory-sync/Groups.ts',
|
||||
'src/directory-sync/request.ts',
|
||||
],
|
||||
'test/dsync/events.test.ts': ['src/directory-sync/events.ts'],
|
||||
};
|
||||
|
||||
module.exports = (testFile) => {
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -42,6 +42,7 @@
|
|||
"@opentelemetry/api": "1.0.4",
|
||||
"@opentelemetry/api-metrics": "0.27.0",
|
||||
"@peculiar/webcrypto": "1.4.0",
|
||||
"axios": "^0.27.2",
|
||||
"@peculiar/x509": "1.8.3",
|
||||
"jose": "4.9.2",
|
||||
"marked": "4.1.0",
|
||||
|
@ -56,6 +57,7 @@
|
|||
"xmlbuilder": "15.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "7.2.0",
|
||||
"@types/node": "18.7.16",
|
||||
"@types/sinon": "10.0.13",
|
||||
"@types/tap": "15.0.7",
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { ApiError } from '../typings';
|
||||
|
||||
export class JacksonError extends Error {
|
||||
public name: string;
|
||||
public statusCode: number;
|
||||
|
@ -11,3 +13,9 @@ export class JacksonError extends Error {
|
|||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiError = (err: any) => {
|
||||
const { message, statusCode = 500 } = err;
|
||||
|
||||
return { data: null, error: { message, code: statusCode } as ApiError };
|
||||
};
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import type { OAuthErrorHandlerParams } from '../typings';
|
||||
import { JacksonError } from './error';
|
||||
import * as redirect from './oauth/redirect';
|
||||
import crypto from 'crypto';
|
||||
import * as jose from 'jose';
|
||||
|
||||
export enum IndexNames {
|
||||
|
@ -8,6 +9,20 @@ export enum IndexNames {
|
|||
TenantProduct = 'tenantProduct',
|
||||
}
|
||||
|
||||
// The namespace prefix for the database store
|
||||
export const storeNamespacePrefix = {
|
||||
dsync: {
|
||||
config: 'dsync:config',
|
||||
logs: 'dsync:logs',
|
||||
users: 'dsync:users',
|
||||
groups: 'dsync:groups',
|
||||
members: 'dsync:members',
|
||||
},
|
||||
saml: {
|
||||
config: 'saml:config',
|
||||
},
|
||||
};
|
||||
|
||||
export const relayStatePrefix = 'boxyhq_jackson_';
|
||||
|
||||
export const validateAbsoluteUrl = (url, message) => {
|
||||
|
@ -35,6 +50,15 @@ export function getErrorMessage(error: unknown) {
|
|||
return String(error);
|
||||
}
|
||||
|
||||
export const createRandomSecret = async (length: number) => {
|
||||
return crypto
|
||||
.randomBytes(length)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
};
|
||||
|
||||
export async function loadJWSPrivateKey(key: string, alg: string): Promise<jose.KeyLike> {
|
||||
const pkcs8 = Buffer.from(key, 'base64').toString('ascii');
|
||||
const privateKey = await jose.importPKCS8(pkcs8, alg);
|
||||
|
|
|
@ -53,26 +53,37 @@ class Mem implements DatabaseDriver {
|
|||
|
||||
async getAll(namespace: string, pageOffset: number, pageLimit: number): Promise<unknown[]> {
|
||||
const offsetAndLimitValueCheck = !dbutils.isNumeric(pageOffset) && !dbutils.isNumeric(pageLimit);
|
||||
let take = Number(offsetAndLimitValueCheck ? this.options.pageLimit : pageLimit);
|
||||
const skip = Number(offsetAndLimitValueCheck ? 0 : pageOffset);
|
||||
let count = 0;
|
||||
take += skip;
|
||||
const returnValue: string[] = [];
|
||||
const skip = Number(offsetAndLimitValueCheck ? 0 : pageOffset);
|
||||
|
||||
let take = Number(offsetAndLimitValueCheck ? this.options.pageLimit : pageLimit);
|
||||
let count = 0;
|
||||
|
||||
take += skip;
|
||||
|
||||
if (namespace) {
|
||||
const val: string[] = Array.from(
|
||||
this.indexes[dbutils.keyFromParts(dbutils.createdAtPrefix, namespace)]
|
||||
);
|
||||
const index = dbutils.keyFromParts(dbutils.createdAtPrefix, namespace);
|
||||
|
||||
if (this.indexes[index] === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const val: string[] = Array.from(this.indexes[index]);
|
||||
const iterator: IterableIterator<string> = val.reverse().values();
|
||||
|
||||
for (const value of iterator) {
|
||||
if (count >= take) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (count >= skip) {
|
||||
returnValue.push(this.store[dbutils.keyFromParts(namespace, value)]);
|
||||
}
|
||||
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return returnValue || [];
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
import type { Storable, DatabaseStore } from '../typings';
|
||||
import { storeNamespacePrefix } from '../controller/utils';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export class Base {
|
||||
protected db: DatabaseStore;
|
||||
protected tenant: null | string = null;
|
||||
protected product: null | string = null;
|
||||
|
||||
constructor({ db }: { db: DatabaseStore }) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
// Return the database store
|
||||
store(type: 'groups' | 'members' | 'users' | 'logs'): Storable {
|
||||
if (!this.tenant || !this.product) {
|
||||
throw new Error('Set tenant and product before using store.');
|
||||
}
|
||||
|
||||
return this.db.store(`${storeNamespacePrefix.dsync[type]}:${this.tenant}:${this.product}`);
|
||||
}
|
||||
|
||||
setTenant(tenant: string): this {
|
||||
this.tenant = tenant;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
setProduct(product: string): this {
|
||||
this.product = product;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
// Set the tenant and product
|
||||
setTenantAndProduct(tenant: string, product: string): this {
|
||||
return this.setTenant(tenant).setProduct(product);
|
||||
}
|
||||
|
||||
// Set the tenant and product
|
||||
with(tenant: string, product: string): this {
|
||||
return this.setTenant(tenant).setProduct(product);
|
||||
}
|
||||
|
||||
createId(): string {
|
||||
return uuidv4();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,197 @@
|
|||
import type { Storable, Directory, JacksonOption, DatabaseStore, DirectoryType, ApiError } from '../typings';
|
||||
import * as dbutils from '../db/utils';
|
||||
import { createRandomSecret } from '../controller/utils';
|
||||
import { apiError, JacksonError } from '../controller/error';
|
||||
import { storeNamespacePrefix } from '../controller/utils';
|
||||
|
||||
export class DirectoryConfig {
|
||||
private _store: Storable | null = null;
|
||||
private opts: JacksonOption;
|
||||
private db: DatabaseStore;
|
||||
|
||||
constructor({ db, opts }: { db: DatabaseStore; opts: JacksonOption }) {
|
||||
this.opts = opts;
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
// Return the database store
|
||||
private store(): Storable {
|
||||
return this._store || (this._store = this.db.store(storeNamespacePrefix.dsync.config));
|
||||
}
|
||||
|
||||
// Create the configuration
|
||||
public async create({
|
||||
name,
|
||||
tenant,
|
||||
product,
|
||||
webhook_url,
|
||||
webhook_secret,
|
||||
type = 'generic-scim-v2',
|
||||
}: {
|
||||
name?: string;
|
||||
tenant: string;
|
||||
product: string;
|
||||
webhook_url?: string;
|
||||
webhook_secret?: string;
|
||||
type?: DirectoryType;
|
||||
}): Promise<{ data: Directory | null; error: ApiError | null }> {
|
||||
try {
|
||||
if (!tenant || !product) {
|
||||
throw new JacksonError('Missing required parameters.', 400);
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
name = `scim-${tenant}-${product}`;
|
||||
}
|
||||
|
||||
const id = dbutils.keyDigest(dbutils.keyFromParts(tenant, product));
|
||||
|
||||
const hasWebhook = webhook_url && webhook_secret;
|
||||
|
||||
const directory: Directory = {
|
||||
id,
|
||||
name,
|
||||
tenant,
|
||||
product,
|
||||
type,
|
||||
log_webhook_events: false,
|
||||
scim: {
|
||||
path: `${this.opts.scimPath}/${id}`,
|
||||
secret: await createRandomSecret(16),
|
||||
},
|
||||
webhook: {
|
||||
endpoint: hasWebhook ? webhook_url : '',
|
||||
secret: hasWebhook ? webhook_secret : '',
|
||||
},
|
||||
};
|
||||
|
||||
await this.store().put(id, directory);
|
||||
|
||||
return { data: this.transform(directory), error: null };
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the configuration by id
|
||||
public async get(id: string): Promise<{ data: Directory | null; error: ApiError | null }> {
|
||||
try {
|
||||
if (!id) {
|
||||
throw new JacksonError('Missing required parameters.', 400);
|
||||
}
|
||||
|
||||
const directory: Directory = await this.store().get(id);
|
||||
|
||||
if (!directory) {
|
||||
throw new JacksonError('Directory configuration not found.', 404);
|
||||
}
|
||||
|
||||
return { data: this.transform(directory), error: null };
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the configuration. Partial updates are supported
|
||||
public async update(
|
||||
id: string,
|
||||
param: Omit<Partial<Directory>, 'id' | 'tenant' | 'prodct' | 'scim'>
|
||||
): Promise<{ data: Directory | null; error: ApiError | null }> {
|
||||
try {
|
||||
if (!id) {
|
||||
throw new JacksonError('Missing required parameters.', 400);
|
||||
}
|
||||
|
||||
const { name, log_webhook_events, webhook, type } = param;
|
||||
|
||||
const directory = await this.store().get(id);
|
||||
|
||||
if (name) {
|
||||
directory.name = name;
|
||||
}
|
||||
|
||||
if (log_webhook_events !== undefined) {
|
||||
directory.log_webhook_events = log_webhook_events;
|
||||
}
|
||||
|
||||
if (webhook) {
|
||||
directory.webhook = webhook;
|
||||
}
|
||||
|
||||
if (type) {
|
||||
directory.type = type;
|
||||
}
|
||||
|
||||
await this.store().put(id, { ...directory });
|
||||
|
||||
return { data: this.transform(directory), error: null };
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the configuration by tenant and product
|
||||
public async getByTenantAndProduct(
|
||||
tenant: string,
|
||||
product: string
|
||||
): Promise<{ data: Directory | null; error: ApiError | null }> {
|
||||
try {
|
||||
if (!tenant || !product) {
|
||||
throw new JacksonError('Missing required parameters.', 400);
|
||||
}
|
||||
|
||||
return await this.get(dbutils.keyDigest(dbutils.keyFromParts(tenant, product)));
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all configurations
|
||||
public async list({
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
}: {
|
||||
pageOffset: number;
|
||||
pageLimit: number;
|
||||
}): Promise<{ data: Directory[] | null; error: ApiError | null }> {
|
||||
try {
|
||||
const directories = (await this.store().getAll(pageOffset, pageLimit)) as Directory[];
|
||||
|
||||
const transformedDirectories = directories
|
||||
? directories.map((directory) => this.transform(directory))
|
||||
: [];
|
||||
|
||||
return {
|
||||
data: transformedDirectories,
|
||||
error: null,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a configuration by id
|
||||
// Note: This feature is not yet implemented
|
||||
public async delete(id: string): Promise<void> {
|
||||
if (!id) {
|
||||
throw new JacksonError('Missing required parameter.', 400);
|
||||
}
|
||||
|
||||
// TODO: Delete the users and groups associated with the configuration
|
||||
|
||||
await this.store().delete(id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
private transform(directory: Directory): Directory {
|
||||
// Add the flag to ensure SCIM compliance when using Azure AD
|
||||
if (directory.type === 'azure-scim-v2') {
|
||||
directory.scim.path = `${directory.scim.path}/?aadOptscim062020`;
|
||||
}
|
||||
|
||||
directory.scim.endpoint = `${this.opts.externalUrl}${directory.scim.path}`;
|
||||
|
||||
return directory;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,321 @@
|
|||
import type {
|
||||
Group,
|
||||
DirectoryConfig,
|
||||
DirectorySyncResponse,
|
||||
Directory,
|
||||
DirectorySyncGroupMember,
|
||||
DirectorySyncRequest,
|
||||
Users,
|
||||
Groups,
|
||||
ApiError,
|
||||
IDirectoryGroups,
|
||||
EventCallback,
|
||||
HTTPMethod,
|
||||
} from '../typings';
|
||||
import { parseGroupOperations, toGroupMembers } from './utils';
|
||||
import { sendEvent } from './events';
|
||||
|
||||
export class DirectoryGroups implements IDirectoryGroups {
|
||||
private directories: DirectoryConfig;
|
||||
private users: Users;
|
||||
private groups: Groups;
|
||||
private callback: EventCallback | undefined;
|
||||
|
||||
constructor({
|
||||
directories,
|
||||
users,
|
||||
groups,
|
||||
}: {
|
||||
directories: DirectoryConfig;
|
||||
users: Users;
|
||||
groups: Groups;
|
||||
}) {
|
||||
this.directories = directories;
|
||||
this.users = users;
|
||||
this.groups = groups;
|
||||
}
|
||||
|
||||
public async create(directory: Directory, body: any): Promise<DirectorySyncResponse> {
|
||||
const { displayName, members } = body;
|
||||
|
||||
const { data: group } = await this.groups.create({
|
||||
name: displayName,
|
||||
raw: body,
|
||||
});
|
||||
|
||||
await sendEvent('group.created', { directory, group }, this.callback);
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
data: {
|
||||
schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'],
|
||||
id: group?.id,
|
||||
displayName: group?.name,
|
||||
members: members ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async get(group: Group): Promise<DirectorySyncResponse> {
|
||||
return {
|
||||
status: 200,
|
||||
data: {
|
||||
schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'],
|
||||
id: group.id,
|
||||
displayName: group.name,
|
||||
members: toGroupMembers(await this.groups.getAllUsers(group.id)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async delete(directory: Directory, group: Group): Promise<DirectorySyncResponse> {
|
||||
await this.groups.removeAllUsers(group.id);
|
||||
await this.groups.delete(group.id);
|
||||
|
||||
await sendEvent('group.deleted', { directory, group }, this.callback);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
data: {},
|
||||
};
|
||||
}
|
||||
|
||||
public async getAll(queryParams: { filter?: string }): Promise<DirectorySyncResponse> {
|
||||
const { filter } = queryParams;
|
||||
|
||||
let groups: Group[] | null = [];
|
||||
|
||||
if (filter) {
|
||||
// Filter by group displayName
|
||||
// filter: displayName eq "Developer"
|
||||
const { data } = await this.groups.search(filter.split('eq ')[1].replace(/['"]+/g, ''));
|
||||
|
||||
groups = data;
|
||||
} else {
|
||||
// Fetch all the existing group
|
||||
const { data } = await this.groups.list({ pageOffset: undefined, pageLimit: undefined });
|
||||
|
||||
groups = data;
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
data: {
|
||||
schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
|
||||
totalResults: groups ? groups.length : 0,
|
||||
itemsPerPage: groups ? groups.length : 0,
|
||||
startIndex: 1,
|
||||
Resources: groups ? groups.map((group) => group.raw) : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Update group displayName
|
||||
public async updateDisplayName(directory: Directory, group: Group, body: any): Promise<Group> {
|
||||
const { displayName } = body;
|
||||
|
||||
const { data: updatedGroup, error } = await this.groups.update(group.id, {
|
||||
name: displayName,
|
||||
raw: {
|
||||
...group.raw,
|
||||
...body,
|
||||
},
|
||||
});
|
||||
|
||||
if (error || !updatedGroup) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sendEvent('group.updated', { directory, group: updatedGroup }, this.callback);
|
||||
|
||||
return updatedGroup;
|
||||
}
|
||||
|
||||
public async patch(directory: Directory, group: Group, body: any): Promise<DirectorySyncResponse> {
|
||||
const { Operations } = body;
|
||||
|
||||
const operation = parseGroupOperations(Operations);
|
||||
|
||||
// Add group members
|
||||
if (operation.action === 'addGroupMember') {
|
||||
await this.addGroupMembers(directory, group, operation.members);
|
||||
}
|
||||
|
||||
// Remove group members
|
||||
if (operation.action === 'removeGroupMember') {
|
||||
await this.removeGroupMembers(directory, group, operation.members);
|
||||
}
|
||||
|
||||
// Update group name
|
||||
if (operation.action === 'updateGroupName') {
|
||||
await this.updateDisplayName(directory, group, {
|
||||
displayName: operation.displayName,
|
||||
});
|
||||
}
|
||||
|
||||
const { data: updatedGroup } = await this.groups.get(group.id);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
data: {
|
||||
schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'],
|
||||
id: updatedGroup?.id,
|
||||
displayName: updatedGroup?.name,
|
||||
members: toGroupMembers(await this.groups.getAllUsers(group.id)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async update(directory: Directory, group: Group, body: any): Promise<DirectorySyncResponse> {
|
||||
const { displayName, members } = body;
|
||||
|
||||
// Update group name
|
||||
const updatedGroup = await this.updateDisplayName(directory, group, {
|
||||
displayName,
|
||||
});
|
||||
|
||||
// Update group members
|
||||
if (members) {
|
||||
await this.addOrRemoveGroupMembers(directory, group, members);
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
data: {
|
||||
schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'],
|
||||
id: group.id,
|
||||
displayName: updatedGroup.name,
|
||||
members: toGroupMembers(await this.groups.getAllUsers(group.id)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async addGroupMembers(
|
||||
directory: Directory,
|
||||
group: Group,
|
||||
members: DirectorySyncGroupMember[] | undefined,
|
||||
sendWebhookEvent = true
|
||||
) {
|
||||
if (members === undefined || (members && members.length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
if (await this.groups.isUserInGroup(group.id, member.value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.groups.addUserToGroup(group.id, member.value);
|
||||
|
||||
const { data: user } = await this.users.get(member.value);
|
||||
|
||||
if (sendWebhookEvent && user) {
|
||||
await sendEvent('group.user_added', { directory, group, user }, this.callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async removeGroupMembers(
|
||||
directory: Directory,
|
||||
group: Group,
|
||||
members: DirectorySyncGroupMember[],
|
||||
sendWebhookEvent = true
|
||||
) {
|
||||
if (members.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
await this.groups.removeUserFromGroup(group.id, member.value);
|
||||
|
||||
const { data: user } = await this.users.get(member.value);
|
||||
|
||||
// User may not exist in the directory, so we need to check if the user exists
|
||||
if (sendWebhookEvent && user) {
|
||||
await sendEvent('group.user_removed', { directory, group, user }, this.callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add or remove users from a group
|
||||
public async addOrRemoveGroupMembers(
|
||||
directory: Directory,
|
||||
group: Group,
|
||||
members: DirectorySyncGroupMember[]
|
||||
) {
|
||||
const users = toGroupMembers(await this.groups.getAllUsers(group.id));
|
||||
|
||||
const usersToAdd = members.filter((member) => !users.some((user) => user.value === member.value));
|
||||
|
||||
const usersToRemove = users
|
||||
.filter((user) => !members.some((member) => member.value === user.value))
|
||||
.map((user) => ({ value: user.value }));
|
||||
|
||||
await this.addGroupMembers(directory, group, usersToAdd, false);
|
||||
await this.removeGroupMembers(directory, group, usersToRemove, false);
|
||||
}
|
||||
|
||||
private respondWithError(error: ApiError | null) {
|
||||
return {
|
||||
status: error ? error.code : 500,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle the request from the Identity Provider and route it to the appropriate method
|
||||
public async handleRequest(
|
||||
request: DirectorySyncRequest,
|
||||
callback?: EventCallback
|
||||
): Promise<DirectorySyncResponse> {
|
||||
const { body, query, resourceId: groupId, directoryId, apiSecret } = request;
|
||||
|
||||
const method = request.method.toUpperCase() as HTTPMethod;
|
||||
|
||||
// Get the directory
|
||||
const { data: directory, error } = await this.directories.get(directoryId);
|
||||
|
||||
if (error || !directory) {
|
||||
return this.respondWithError(error);
|
||||
}
|
||||
|
||||
// Validate the request
|
||||
if (directory.scim.secret != apiSecret) {
|
||||
return this.respondWithError({ code: 401, message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
this.callback = callback;
|
||||
|
||||
this.users.setTenantAndProduct(directory.tenant, directory.product);
|
||||
this.groups.setTenantAndProduct(directory.tenant, directory.product);
|
||||
|
||||
// Get the group
|
||||
const { data: group } = groupId ? await this.groups.get(groupId) : { data: null };
|
||||
|
||||
if (group) {
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return await this.get(group);
|
||||
case 'PUT':
|
||||
return await this.update(directory, group, body);
|
||||
case 'PATCH':
|
||||
return await this.patch(directory, group, body);
|
||||
case 'DELETE':
|
||||
return await this.delete(directory, group);
|
||||
}
|
||||
}
|
||||
|
||||
switch (method) {
|
||||
case 'POST':
|
||||
return await this.create(directory, body);
|
||||
case 'GET':
|
||||
return await this.getAll({
|
||||
filter: query.filter,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
status: 404,
|
||||
data: {},
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,210 @@
|
|||
import type {
|
||||
DirectoryConfig,
|
||||
Directory,
|
||||
DirectorySyncResponse,
|
||||
DirectorySyncRequest,
|
||||
User,
|
||||
Users,
|
||||
ApiError,
|
||||
IDirectoryUsers,
|
||||
EventCallback,
|
||||
HTTPMethod,
|
||||
} from '../typings';
|
||||
import { parseUserOperations } from './utils';
|
||||
import { sendEvent } from './events';
|
||||
|
||||
export class DirectoryUsers implements IDirectoryUsers {
|
||||
private directories: DirectoryConfig;
|
||||
private users: Users;
|
||||
private callback: EventCallback | undefined;
|
||||
|
||||
constructor({ directories, users }: { directories: DirectoryConfig; users: Users }) {
|
||||
this.directories = directories;
|
||||
this.users = users;
|
||||
}
|
||||
|
||||
public async create(directory: Directory, body: any): Promise<DirectorySyncResponse> {
|
||||
const { name, emails } = body;
|
||||
|
||||
const { data: user } = await this.users.create({
|
||||
first_name: name && 'givenName' in name ? name.givenName : '',
|
||||
last_name: name && 'familyName' in name ? name.familyName : '',
|
||||
email: emails[0].value,
|
||||
active: true,
|
||||
raw: body,
|
||||
});
|
||||
|
||||
await sendEvent('user.created', { directory, user }, this.callback);
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
data: user?.raw,
|
||||
};
|
||||
}
|
||||
|
||||
public async get(user: User): Promise<DirectorySyncResponse> {
|
||||
return {
|
||||
status: 200,
|
||||
data: user.raw,
|
||||
};
|
||||
}
|
||||
|
||||
public async update(directory: Directory, user: User, body: any): Promise<DirectorySyncResponse> {
|
||||
const { name, emails, active } = body;
|
||||
|
||||
const { data: updatedUser } = await this.users.update(user.id, {
|
||||
first_name: name.givenName,
|
||||
last_name: name.familyName,
|
||||
email: emails[0].value,
|
||||
active,
|
||||
raw: body,
|
||||
});
|
||||
|
||||
await sendEvent('user.updated', { directory, user: updatedUser }, this.callback);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
data: updatedUser?.raw,
|
||||
};
|
||||
}
|
||||
|
||||
public async patch(directory: Directory, user: User, body: any): Promise<DirectorySyncResponse> {
|
||||
const { Operations } = body;
|
||||
|
||||
const operation = parseUserOperations(Operations);
|
||||
|
||||
if (operation.action === 'updateUser') {
|
||||
const { data: updatedUser } = await this.users.update(user.id, {
|
||||
...user,
|
||||
...operation.attributes,
|
||||
raw: { ...user.raw, ...operation.raw },
|
||||
});
|
||||
|
||||
await sendEvent('user.updated', { directory, user: updatedUser }, this.callback);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
data: updatedUser?.raw,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
public async delete(directory: Directory, user: User): Promise<DirectorySyncResponse> {
|
||||
await this.users.delete(user.id);
|
||||
|
||||
await sendEvent('user.deleted', { directory, user }, this.callback);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
data: user.raw,
|
||||
};
|
||||
}
|
||||
|
||||
public async getAll(queryParams: {
|
||||
count: number;
|
||||
startIndex: number;
|
||||
filter?: string;
|
||||
}): Promise<DirectorySyncResponse> {
|
||||
const { startIndex, filter, count } = queryParams;
|
||||
|
||||
let users: User[] | null = [];
|
||||
let totalResults = 0;
|
||||
|
||||
if (filter) {
|
||||
// Search users by userName
|
||||
// filter: userName eq "john@example.com"
|
||||
const { data } = await this.users.search(filter.split('eq ')[1].replace(/['"]+/g, ''));
|
||||
|
||||
users = data;
|
||||
totalResults = users ? users.length : 0;
|
||||
} else {
|
||||
// Fetch all the existing Users (Paginated)
|
||||
// At this moment, we don't have method to count the database records.
|
||||
const { data: allUsers } = await this.users.list({});
|
||||
const { data } = await this.users.list({ pageOffset: startIndex - 1, pageLimit: count });
|
||||
|
||||
users = data;
|
||||
totalResults = allUsers ? allUsers.length : 0;
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
data: {
|
||||
schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
|
||||
startIndex: startIndex ? startIndex : 1,
|
||||
totalResults: totalResults ? totalResults : 0,
|
||||
itemsPerPage: count ? count : 0,
|
||||
Resources: users ? users.map((user) => user.raw) : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private respondWithError(error: ApiError | null) {
|
||||
return {
|
||||
status: error ? error.code : 500,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle the request from the Identity Provider and route it to the appropriate method
|
||||
public async handleRequest(
|
||||
request: DirectorySyncRequest,
|
||||
callback?: EventCallback
|
||||
): Promise<DirectorySyncResponse> {
|
||||
const { body, query, resourceId: userId, directoryId, apiSecret } = request;
|
||||
|
||||
const method = request.method.toUpperCase() as HTTPMethod;
|
||||
|
||||
// Get the directory
|
||||
const { data: directory, error } = await this.directories.get(directoryId);
|
||||
|
||||
if (error || !directory) {
|
||||
return this.respondWithError(error);
|
||||
}
|
||||
|
||||
// Validate the request
|
||||
if (directory.scim.secret != apiSecret) {
|
||||
return this.respondWithError({ code: 401, message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
this.callback = callback;
|
||||
this.users.setTenantAndProduct(directory.tenant, directory.product);
|
||||
|
||||
// Get the user
|
||||
const { data: user } = userId ? await this.users.get(userId) : { data: null };
|
||||
|
||||
if (user) {
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return await this.get(user);
|
||||
case 'PATCH':
|
||||
return await this.patch(directory, user, body);
|
||||
case 'PUT':
|
||||
return await this.update(directory, user, body);
|
||||
case 'DELETE':
|
||||
return await this.delete(directory, user);
|
||||
}
|
||||
}
|
||||
|
||||
switch (method) {
|
||||
case 'POST':
|
||||
return await this.create(directory, body);
|
||||
case 'GET':
|
||||
return await this.getAll({
|
||||
count: query.count as number,
|
||||
startIndex: query.startIndex as number,
|
||||
filter: query.filter,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
status: 404,
|
||||
data: {},
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,185 @@
|
|||
import type { Group, DatabaseStore, ApiError } from '../typings';
|
||||
import * as dbutils from '../db/utils';
|
||||
import { apiError, JacksonError } from '../controller/error';
|
||||
import { Base } from './Base';
|
||||
|
||||
export class Groups extends Base {
|
||||
constructor({ db }: { db: DatabaseStore }) {
|
||||
super({ db });
|
||||
}
|
||||
|
||||
// Create a new group
|
||||
public async create(param: {
|
||||
name: string;
|
||||
raw: any;
|
||||
}): Promise<{ data: Group | null; error: ApiError | null }> {
|
||||
try {
|
||||
const { name, raw } = param;
|
||||
|
||||
const id = this.createId();
|
||||
|
||||
raw['id'] = id;
|
||||
|
||||
const group: Group = {
|
||||
id,
|
||||
name,
|
||||
raw,
|
||||
};
|
||||
|
||||
await this.store('groups').put(id, group, {
|
||||
name: 'displayName',
|
||||
value: name,
|
||||
});
|
||||
|
||||
return { data: group, error: null };
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Get a group by id
|
||||
public async get(id: string): Promise<{ data: Group | null; error: ApiError | null }> {
|
||||
try {
|
||||
const group = await this.store('groups').get(id);
|
||||
|
||||
if (!group) {
|
||||
throw new JacksonError(`Group with id ${id} not found.`, 404);
|
||||
}
|
||||
|
||||
return { data: group, error: null };
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the group data
|
||||
public async update(
|
||||
id: string,
|
||||
param: {
|
||||
name: string;
|
||||
raw: any;
|
||||
}
|
||||
): Promise<{ data: Group | null; error: ApiError | null }> {
|
||||
try {
|
||||
const { name, raw } = param;
|
||||
|
||||
const group: Group = {
|
||||
id,
|
||||
name,
|
||||
raw,
|
||||
};
|
||||
|
||||
await this.store('groups').put(id, group);
|
||||
|
||||
return { data: group, error: null };
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a group by id
|
||||
public async delete(id: string): Promise<{ data: null; error: ApiError | null }> {
|
||||
try {
|
||||
const { data, error } = await this.get(id);
|
||||
|
||||
if (error || !data) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.store('groups').delete(id);
|
||||
|
||||
return { data: null, error: null };
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all users in a group
|
||||
public async getAllUsers(groupId: string): Promise<{ user_id: string }[]> {
|
||||
const users: { user_id: string }[] = await this.store('members').getByIndex({
|
||||
name: 'groupId',
|
||||
value: groupId,
|
||||
});
|
||||
|
||||
if (users.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
// Add a user to a group
|
||||
public async addUserToGroup(groupId: string, userId: string) {
|
||||
const id = dbutils.keyDigest(dbutils.keyFromParts(groupId, userId));
|
||||
|
||||
await this.store('members').put(
|
||||
id,
|
||||
{
|
||||
group_id: groupId,
|
||||
user_id: userId,
|
||||
},
|
||||
{
|
||||
name: 'groupId',
|
||||
value: groupId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Remove a user from a group
|
||||
public async removeUserFromGroup(groupId: string, userId: string) {
|
||||
const id = dbutils.keyDigest(dbutils.keyFromParts(groupId, userId));
|
||||
|
||||
await this.store('members').delete(id);
|
||||
}
|
||||
|
||||
// Remove all users from a group
|
||||
public async removeAllUsers(groupId: string) {
|
||||
const users = await this.getAllUsers(groupId);
|
||||
|
||||
if (users.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const user of users) {
|
||||
await this.removeUserFromGroup(groupId, user.user_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a user is a member of a group
|
||||
public async isUserInGroup(groupId: string, userId: string): Promise<boolean> {
|
||||
const id = dbutils.keyDigest(dbutils.keyFromParts(groupId, userId));
|
||||
|
||||
return !!(await this.store('members').get(id));
|
||||
}
|
||||
|
||||
// Search groups by displayName
|
||||
public async search(displayName: string): Promise<{ data: Group[] | null; error: ApiError | null }> {
|
||||
try {
|
||||
const groups = (await this.store('groups').getByIndex({
|
||||
name: 'displayName',
|
||||
value: displayName,
|
||||
})) as Group[];
|
||||
|
||||
return { data: groups, error: null };
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all groups in a directory
|
||||
public async list({
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
}: {
|
||||
pageOffset?: number;
|
||||
pageLimit?: number;
|
||||
}): Promise<{ data: Group[] | null; error: ApiError | null }> {
|
||||
try {
|
||||
const groups = (await this.store('groups').getAll(pageOffset, pageLimit)) as Group[];
|
||||
|
||||
return { data: groups, error: null };
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,152 @@
|
|||
import type { User, DatabaseStore, ApiError } from '../typings';
|
||||
import { apiError, JacksonError } from '../controller/error';
|
||||
import { Base } from './Base';
|
||||
|
||||
export class Users extends Base {
|
||||
constructor({ db }: { db: DatabaseStore }) {
|
||||
super({ db });
|
||||
}
|
||||
|
||||
// Create a new user
|
||||
public async create(param: {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
active: boolean;
|
||||
raw: any;
|
||||
}): Promise<{ data: User | null; error: ApiError | null }> {
|
||||
try {
|
||||
const { first_name, last_name, email, active, raw } = param;
|
||||
|
||||
const id = this.createId();
|
||||
|
||||
raw['id'] = id;
|
||||
|
||||
const user = {
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
active,
|
||||
raw,
|
||||
};
|
||||
|
||||
await this.store('users').put(id, user, {
|
||||
name: 'userName',
|
||||
value: email,
|
||||
});
|
||||
|
||||
return { data: user, error: null };
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Get a user by id
|
||||
public async get(id: string): Promise<{ data: User | null; error: ApiError | null }> {
|
||||
try {
|
||||
const user = await this.store('users').get(id);
|
||||
|
||||
if (user === null) {
|
||||
throw new JacksonError('User not found', 404);
|
||||
}
|
||||
|
||||
return { data: user, error: null };
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the user data
|
||||
public async update(
|
||||
id: string,
|
||||
param: {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
active: boolean;
|
||||
raw: object;
|
||||
}
|
||||
): Promise<{ data: User | null; error: ApiError | null }> {
|
||||
try {
|
||||
const { first_name, last_name, email, active, raw } = param;
|
||||
|
||||
raw['id'] = id;
|
||||
|
||||
const user = {
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
active,
|
||||
raw,
|
||||
};
|
||||
|
||||
await this.store('users').put(id, user);
|
||||
|
||||
return { data: user, error: null };
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a user by id
|
||||
public async delete(id: string): Promise<{ data: null; error: ApiError | null }> {
|
||||
try {
|
||||
const { data, error } = await this.get(id);
|
||||
|
||||
if (error || !data) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.store('users').delete(id);
|
||||
|
||||
return { data: null, error: null };
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all users in a directory
|
||||
public async list({
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
}: {
|
||||
pageOffset?: number;
|
||||
pageLimit?: number;
|
||||
}): Promise<{ data: User[] | null; error: ApiError | null }> {
|
||||
try {
|
||||
const users = (await this.store('users').getAll(pageOffset, pageLimit)) as User[];
|
||||
|
||||
return { data: users, error: null };
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Search users by userName
|
||||
public async search(userName: string): Promise<{ data: User[] | null; error: ApiError | null }> {
|
||||
try {
|
||||
const users = (await this.store('users').getByIndex({ name: 'userName', value: userName })) as User[];
|
||||
|
||||
return { data: users, error: null };
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all the users
|
||||
public async clear() {
|
||||
const { data: users, error } = await this.list({});
|
||||
|
||||
if (!users || error) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
users.map(async (user) => {
|
||||
return this.delete(user.id);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
import type {
|
||||
Directory,
|
||||
DatabaseStore,
|
||||
WebhookEventLog,
|
||||
DirectorySyncEvent,
|
||||
IWebhookEventsLogger,
|
||||
} from '../typings';
|
||||
import { Base } from './Base';
|
||||
|
||||
export class WebhookEventsLogger extends Base implements IWebhookEventsLogger {
|
||||
constructor({ db }: { db: DatabaseStore }) {
|
||||
super({ db });
|
||||
}
|
||||
|
||||
public async log(directory: Directory, event: DirectorySyncEvent): Promise<WebhookEventLog> {
|
||||
const id = this.createId();
|
||||
|
||||
const log: WebhookEventLog = {
|
||||
...event,
|
||||
id,
|
||||
webhook_endpoint: directory.webhook.endpoint,
|
||||
created_at: new Date(),
|
||||
};
|
||||
|
||||
await this.store('logs').put(id, log);
|
||||
|
||||
return log;
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<WebhookEventLog> {
|
||||
return await this.store('logs').get(id);
|
||||
}
|
||||
|
||||
public async getAll(): Promise<WebhookEventLog[]> {
|
||||
return (await this.store('logs').getAll()) as WebhookEventLog[];
|
||||
}
|
||||
|
||||
public async delete(id: string) {
|
||||
await this.store('logs').delete(id);
|
||||
}
|
||||
|
||||
public async clear() {
|
||||
const events = await this.getAll();
|
||||
|
||||
await Promise.all(
|
||||
events.map(async (event) => {
|
||||
await this.delete(event.id);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async updateStatus(log: WebhookEventLog, statusCode: number): Promise<WebhookEventLog> {
|
||||
const updatedLog = {
|
||||
...log,
|
||||
status_code: statusCode,
|
||||
delivered: statusCode === 200,
|
||||
};
|
||||
|
||||
await this.store('logs').put(log.id, updatedLog);
|
||||
|
||||
return updatedLog;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
import type {
|
||||
DirectorySyncEventType,
|
||||
Directory,
|
||||
User,
|
||||
Group,
|
||||
EventCallback,
|
||||
DirectorySyncEvent,
|
||||
DirectoryConfig,
|
||||
IWebhookEventsLogger,
|
||||
} from '../typings';
|
||||
import { createHeader, transformEventPayload } from './utils';
|
||||
import axios from 'axios';
|
||||
|
||||
export const sendEvent = async (
|
||||
event: DirectorySyncEventType,
|
||||
payload: { directory: Directory; group?: Group | null; user?: User | null },
|
||||
callback?: EventCallback
|
||||
) => {
|
||||
const eventTransformed = transformEventPayload(event, payload);
|
||||
|
||||
return callback ? await callback(eventTransformed) : Promise.resolve();
|
||||
};
|
||||
|
||||
export const handleEventCallback = async (
|
||||
directories: DirectoryConfig,
|
||||
webhookEventsLogger: IWebhookEventsLogger
|
||||
) => {
|
||||
return async (event: DirectorySyncEvent) => {
|
||||
const { tenant, product, directory_id: directoryId } = event;
|
||||
|
||||
const { data: directory } = await directories.get(directoryId);
|
||||
|
||||
if (!directory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { webhook } = directory;
|
||||
|
||||
// If there is no webhook, then we don't need to send an event
|
||||
if (webhook.endpoint === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
webhookEventsLogger.setTenantAndProduct(tenant, product);
|
||||
|
||||
const headers = await createHeader(webhook.secret, event);
|
||||
|
||||
// Log the events only if `log_webhook_events` is enabled
|
||||
const log = directory.log_webhook_events ? await webhookEventsLogger.log(directory, event) : undefined;
|
||||
|
||||
let status = 200;
|
||||
|
||||
try {
|
||||
await axios.post(webhook.endpoint, event, {
|
||||
headers,
|
||||
});
|
||||
} catch (err: any) {
|
||||
status = err.response ? err.response.status : 500;
|
||||
}
|
||||
|
||||
if (log) {
|
||||
await webhookEventsLogger.updateStatus(log, status);
|
||||
}
|
||||
};
|
||||
};
|
|
@ -0,0 +1,44 @@
|
|||
import type { DatabaseStore, DirectorySync, JacksonOption } from '../typings';
|
||||
import { DirectoryConfig } from './DirectoryConfig';
|
||||
import { DirectoryUsers } from './DirectoryUsers';
|
||||
import { DirectoryGroups } from './DirectoryGroups';
|
||||
import { Users } from './Users';
|
||||
import { Groups } from './Groups';
|
||||
import { getDirectorySyncProviders } from './utils';
|
||||
import { DirectorySyncRequestHandler } from './request';
|
||||
import { handleEventCallback } from './events';
|
||||
import { WebhookEventsLogger } from './WebhookEventsLogger';
|
||||
|
||||
const directorySync = async ({
|
||||
db,
|
||||
opts,
|
||||
}: {
|
||||
db: DatabaseStore;
|
||||
opts: JacksonOption;
|
||||
}): Promise<DirectorySync> => {
|
||||
const directories = new DirectoryConfig({ db, opts });
|
||||
|
||||
const users = new Users({ db });
|
||||
const groups = new Groups({ db });
|
||||
|
||||
const directoryUsers = new DirectoryUsers({ directories, users });
|
||||
const directoryGroups = new DirectoryGroups({ directories, users, groups });
|
||||
|
||||
const webhookEventsLogger = new WebhookEventsLogger({ db });
|
||||
|
||||
return {
|
||||
users,
|
||||
groups,
|
||||
directories,
|
||||
webhookLogs: webhookEventsLogger,
|
||||
requests: new DirectorySyncRequestHandler(directoryUsers, directoryGroups),
|
||||
events: {
|
||||
callback: await handleEventCallback(directories, webhookEventsLogger),
|
||||
},
|
||||
providers: () => {
|
||||
return getDirectorySyncProviders();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default directorySync;
|
|
@ -0,0 +1,21 @@
|
|||
import type {
|
||||
DirectorySyncResponse,
|
||||
IDirectoryGroups,
|
||||
IDirectoryUsers,
|
||||
EventCallback,
|
||||
DirectorySyncRequest,
|
||||
} from '../typings';
|
||||
|
||||
export class DirectorySyncRequestHandler {
|
||||
constructor(private directoryUsers: IDirectoryUsers, private directoryGroups: IDirectoryGroups) {}
|
||||
|
||||
async handle(request: DirectorySyncRequest, callback?: EventCallback): Promise<DirectorySyncResponse> {
|
||||
if (request.resourceType === 'users') {
|
||||
return await this.directoryUsers.handleRequest(request, callback);
|
||||
} else if (request.resourceType === 'groups') {
|
||||
return await this.directoryGroups.handleRequest(request, callback);
|
||||
}
|
||||
|
||||
return { status: 404, data: {} };
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import { Group, User } from '../typings';
|
||||
|
||||
const transformUser = (user: User): User => {
|
||||
return {
|
||||
id: user.id,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
email: user.email,
|
||||
active: user.active,
|
||||
raw: user.raw,
|
||||
};
|
||||
};
|
||||
|
||||
const transformGroup = (group: Group): Group => {
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
raw: group.raw,
|
||||
};
|
||||
};
|
||||
|
||||
const transformUserGroup = (user: User, group: Group): User & { group: Group } => {
|
||||
return {
|
||||
...transformUser(user),
|
||||
group: transformGroup(group),
|
||||
};
|
||||
};
|
||||
|
||||
export { transformUser, transformGroup, transformUserGroup };
|
|
@ -0,0 +1,188 @@
|
|||
import type {
|
||||
Directory,
|
||||
DirectorySyncEvent,
|
||||
DirectorySyncEventType,
|
||||
DirectorySyncGroupMember,
|
||||
Group,
|
||||
User,
|
||||
} from '../typings';
|
||||
import { DirectorySyncProviders } from '../typings';
|
||||
import { transformUser, transformGroup, transformUserGroup } from './transform';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const parseGroupOperations = (
|
||||
operations: {
|
||||
op: 'add' | 'remove' | 'replace';
|
||||
path: string;
|
||||
value: any;
|
||||
}[]
|
||||
):
|
||||
| {
|
||||
action: 'addGroupMember' | 'removeGroupMember';
|
||||
members: DirectorySyncGroupMember[];
|
||||
}
|
||||
| {
|
||||
action: 'updateGroupName';
|
||||
displayName: string;
|
||||
}
|
||||
| {
|
||||
action: 'unknown';
|
||||
} => {
|
||||
const { op, path, value } = operations[0];
|
||||
|
||||
// Add group members
|
||||
if (op === 'add' && path === 'members') {
|
||||
return {
|
||||
action: 'addGroupMember',
|
||||
members: value,
|
||||
};
|
||||
}
|
||||
|
||||
// Remove group members
|
||||
if (op === 'remove' && path === 'members') {
|
||||
return {
|
||||
action: 'removeGroupMember',
|
||||
members: value,
|
||||
};
|
||||
}
|
||||
|
||||
// Remove group members
|
||||
if (op === 'remove' && path.startsWith('members[value eq')) {
|
||||
return {
|
||||
action: 'removeGroupMember',
|
||||
members: [{ value: path.split('"')[1] }],
|
||||
};
|
||||
}
|
||||
|
||||
// Update group name
|
||||
if (op === 'replace') {
|
||||
return {
|
||||
action: 'updateGroupName',
|
||||
displayName: value.displayName,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'unknown',
|
||||
};
|
||||
};
|
||||
|
||||
const toGroupMembers = (users: { user_id: string }[]): DirectorySyncGroupMember[] => {
|
||||
return users.map((user) => ({
|
||||
value: user.user_id,
|
||||
}));
|
||||
};
|
||||
|
||||
export const parseUserOperations = (operations: {
|
||||
op: 'replace';
|
||||
value: any;
|
||||
}): {
|
||||
action: 'updateUser' | 'unknown';
|
||||
raw: any;
|
||||
attributes: Partial<User>;
|
||||
} => {
|
||||
const { op, value } = operations[0];
|
||||
|
||||
const attributes: Partial<User> = {};
|
||||
|
||||
// Update the user
|
||||
if (op === 'replace') {
|
||||
if ('active' in value) {
|
||||
attributes['active'] = value.active;
|
||||
}
|
||||
|
||||
if ('name.givenName' in value) {
|
||||
attributes['first_name'] = value['name.givenName'];
|
||||
}
|
||||
|
||||
if ('name.familyName' in value) {
|
||||
attributes['last_name'] = value['name.familyName'];
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'updateUser',
|
||||
raw: value,
|
||||
attributes,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'unknown',
|
||||
raw: value,
|
||||
attributes,
|
||||
};
|
||||
};
|
||||
|
||||
// List of directory sync providers
|
||||
// TODO: Fix the return type
|
||||
const getDirectorySyncProviders = (): { [K: string]: string } => {
|
||||
return Object.entries(DirectorySyncProviders).reduce((acc, [key, value]) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const transformEventPayload = (
|
||||
event: DirectorySyncEventType,
|
||||
payload: { directory: Directory; group?: Group | null; user?: User | null }
|
||||
): DirectorySyncEvent => {
|
||||
const { directory, group, user } = payload;
|
||||
const { tenant, product, id: directory_id } = directory;
|
||||
|
||||
const eventPayload = {
|
||||
event,
|
||||
tenant,
|
||||
product,
|
||||
directory_id,
|
||||
} as DirectorySyncEvent;
|
||||
|
||||
// User events
|
||||
if (['user.created', 'user.updated', 'user.deleted'].includes(event) && user) {
|
||||
eventPayload['data'] = transformUser(user);
|
||||
}
|
||||
|
||||
// Group events
|
||||
if (['group.created', 'group.updated', 'group.deleted'].includes(event) && group) {
|
||||
eventPayload['data'] = transformGroup(group);
|
||||
}
|
||||
|
||||
// Group membership events
|
||||
if (['group.user_added', 'group.user_removed'].includes(event) && user && group) {
|
||||
eventPayload['data'] = transformUserGroup(user, group);
|
||||
}
|
||||
|
||||
return eventPayload;
|
||||
};
|
||||
|
||||
// Create request headers
|
||||
const createHeader = async (secret: string, event: DirectorySyncEvent) => {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'BoxyHQ-Signature': await createSignatureString(secret, event),
|
||||
};
|
||||
};
|
||||
|
||||
// Create a signature string
|
||||
const createSignatureString = async (secret: string, event: DirectorySyncEvent) => {
|
||||
if (!secret) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const timestamp = new Date().getTime();
|
||||
|
||||
const signature = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(`${timestamp}.${JSON.stringify(event)}`)
|
||||
.digest('hex');
|
||||
|
||||
return `t=${timestamp},s=${signature}`;
|
||||
};
|
||||
|
||||
export {
|
||||
parseGroupOperations,
|
||||
toGroupMembers,
|
||||
getDirectorySyncProviders,
|
||||
transformEventPayload,
|
||||
createHeader,
|
||||
createSignatureString,
|
||||
};
|
|
@ -1,16 +1,18 @@
|
|||
import type { DirectorySync, JacksonOption } from './typings';
|
||||
|
||||
import DB from './db/db';
|
||||
import defaultDb from './db/defaultDb';
|
||||
import readConfig from './read-config';
|
||||
|
||||
import { AdminController } from './controller/admin';
|
||||
import { APIController } from './controller/api';
|
||||
import { OAuthController } from './controller/oauth';
|
||||
import { HealthCheckController } from './controller/health-check';
|
||||
import { LogoutController } from './controller/logout';
|
||||
import initDirectorySync from './directory-sync';
|
||||
import { OidcDiscoveryController } from './controller/oidc-discovery';
|
||||
import { SPSAMLConfig } from './controller/sp-config';
|
||||
|
||||
import DB from './db/db';
|
||||
import defaultDb from './db/defaultDb';
|
||||
import readConfig from './read-config';
|
||||
import { JacksonOption } from './typings';
|
||||
|
||||
const defaultOpts = (opts: JacksonOption): JacksonOption => {
|
||||
const newOpts = {
|
||||
...opts,
|
||||
|
@ -24,6 +26,8 @@ const defaultOpts = (opts: JacksonOption): JacksonOption => {
|
|||
throw new Error('samlPath is required');
|
||||
}
|
||||
|
||||
newOpts.scimPath = newOpts.scimPath || '/api/scim/v2.0';
|
||||
|
||||
newOpts.samlAudience = newOpts.samlAudience || 'https://saml.boxyhq.com';
|
||||
newOpts.preLoadedConfig = newOpts.preLoadedConfig || ''; // path to folder containing static SAML config that will be preloaded. This is useful for self-hosted deployments that only have to support a single tenant (or small number of known tenants).
|
||||
newOpts.idpEnabled = newOpts.idpEnabled === true;
|
||||
|
@ -46,6 +50,7 @@ export const controllers = async (
|
|||
adminController: AdminController;
|
||||
logoutController: LogoutController;
|
||||
healthCheckController: HealthCheckController;
|
||||
directorySync: DirectorySync;
|
||||
oidcDiscoveryController: OidcDiscoveryController;
|
||||
spConfig: SPSAMLConfig;
|
||||
}> => {
|
||||
|
@ -63,6 +68,7 @@ export const controllers = async (
|
|||
const adminController = new AdminController({ configStore });
|
||||
const healthCheckController = new HealthCheckController({ healthCheckStore });
|
||||
await healthCheckController.init();
|
||||
|
||||
const oauthController = new OAuthController({
|
||||
configStore,
|
||||
sessionStore,
|
||||
|
@ -77,6 +83,8 @@ export const controllers = async (
|
|||
opts,
|
||||
});
|
||||
|
||||
const directorySync = await initDirectorySync({ db, opts });
|
||||
|
||||
const oidcDiscoveryController = new OidcDiscoveryController({ opts });
|
||||
|
||||
const spConfig = new SPSAMLConfig(opts);
|
||||
|
@ -103,6 +111,7 @@ export const controllers = async (
|
|||
adminController,
|
||||
logoutController,
|
||||
healthCheckController,
|
||||
directorySync,
|
||||
oidcDiscoveryController,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -33,6 +33,7 @@ export interface IOAuthController {
|
|||
export interface IAdminController {
|
||||
getAllConfig(pageOffset?: number, pageLimit?: number);
|
||||
}
|
||||
|
||||
export interface IHealthCheckController {
|
||||
status(): Promise<{
|
||||
status: number;
|
||||
|
@ -40,6 +41,11 @@ export interface IHealthCheckController {
|
|||
init(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ILogoutController {
|
||||
createRequest(body: SLORequestParams): Promise<{ logoutUrl: string | null; logoutForm: string | null }>;
|
||||
handleResponse(body: SAMLResponsePayload): Promise<any>;
|
||||
}
|
||||
|
||||
export interface IOidcDiscoveryController {
|
||||
openidConfig(): {
|
||||
issuer: string;
|
||||
|
@ -128,6 +134,10 @@ export interface Storable {
|
|||
getByIndex(idx: Index): Promise<any>;
|
||||
}
|
||||
|
||||
export interface DatabaseStore {
|
||||
store(namespace: string): Storable;
|
||||
}
|
||||
|
||||
export interface Encrypted {
|
||||
iv?: string;
|
||||
tag?: string;
|
||||
|
@ -160,6 +170,7 @@ export interface JacksonOption {
|
|||
db: DatabaseOption;
|
||||
clientSecretVerifier?: string;
|
||||
idpDiscoveryPath?: string;
|
||||
scimPath?: string;
|
||||
openid: {
|
||||
jwsAlg?: string;
|
||||
jwtSigningKeys?: {
|
||||
|
@ -200,13 +211,8 @@ export interface SAMLConfig {
|
|||
defaultRedirectUrl: string;
|
||||
}
|
||||
|
||||
export interface ILogoutController {
|
||||
createRequest(body: SLORequestParams): Promise<{ logoutUrl: string | null; logoutForm: string | null }>;
|
||||
handleResponse(body: SAMLResponsePayload): Promise<any>;
|
||||
}
|
||||
|
||||
// See Error Response section in https://www.oauth.com/oauth2-servers/authorization/the-authorization-response/
|
||||
export interface OAuthErrorHandlerParams {
|
||||
// See Error Response section in https://www.oauth.com/oauth2-servers/authorization/the-authorization-response/
|
||||
error:
|
||||
| 'invalid_request'
|
||||
| 'access_denied'
|
||||
|
@ -232,3 +238,272 @@ export interface ISPSAMLConfig {
|
|||
toMarkdown(): string;
|
||||
toHTML(): string;
|
||||
}
|
||||
|
||||
export type DirectorySyncEventType =
|
||||
| 'user.created'
|
||||
| 'user.updated'
|
||||
| 'user.deleted'
|
||||
| 'group.created'
|
||||
| 'group.updated'
|
||||
| 'group.deleted'
|
||||
| 'group.user_added'
|
||||
| 'group.user_removed';
|
||||
|
||||
export interface Base {
|
||||
store(type: 'groups' | 'members' | 'users'): Storable;
|
||||
setTenant(tenant: string): this;
|
||||
setProduct(product: string): this;
|
||||
setTenantAndProduct(tenant: string, product: string): this;
|
||||
with(tenant: string, product: string): this;
|
||||
createId(): string;
|
||||
}
|
||||
|
||||
export interface Users extends Base {
|
||||
list({
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
}: {
|
||||
pageOffset?: number;
|
||||
pageLimit?: number;
|
||||
}): Promise<{ data: User[] | null; error: ApiError | null }>;
|
||||
get(id: string): Promise<{ data: User | null; error: ApiError | null }>;
|
||||
search(userName: string): Promise<{ data: User[] | null; error: ApiError | null }>;
|
||||
delete(id: string): Promise<{ data: null; error: ApiError | null }>;
|
||||
clear(): Promise<void>;
|
||||
create(param: {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
active: boolean;
|
||||
raw: any;
|
||||
}): Promise<{ data: User | null; error: ApiError | null }>;
|
||||
update(
|
||||
id: string,
|
||||
param: {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
active: boolean;
|
||||
raw: object;
|
||||
}
|
||||
): Promise<{ data: User | null; error: ApiError | null }>;
|
||||
}
|
||||
|
||||
export interface Groups extends Base {
|
||||
create(param: { name: string; raw: any }): Promise<{ data: Group | null; error: ApiError | null }>;
|
||||
removeAllUsers(groupId: string): Promise<void>;
|
||||
list({
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
}: {
|
||||
pageOffset?: number;
|
||||
pageLimit?: number;
|
||||
}): Promise<{ data: Group[] | null; error: ApiError | null }>;
|
||||
get(id: string): Promise<{ data: Group | null; error: ApiError | null }>;
|
||||
getAllUsers(groupId: string): Promise<{ user_id: string }[]>;
|
||||
delete(id: string): Promise<{ data: null; error: ApiError | null }>;
|
||||
addUserToGroup(groupId: string, userId: string): Promise<void>;
|
||||
isUserInGroup(groupId: string, userId: string): Promise<boolean>;
|
||||
removeUserFromGroup(groupId: string, userId: string): Promise<void>;
|
||||
search(displayName: string): Promise<{ data: Group[] | null; error: ApiError | null }>;
|
||||
update(
|
||||
id: string,
|
||||
param: {
|
||||
name: string;
|
||||
raw: any;
|
||||
}
|
||||
): Promise<{ data: Group | null; error: ApiError | null }>;
|
||||
}
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
active: boolean;
|
||||
raw?: any;
|
||||
};
|
||||
|
||||
export type Group = {
|
||||
id: string;
|
||||
name: string;
|
||||
raw?: any;
|
||||
};
|
||||
|
||||
export enum DirectorySyncProviders {
|
||||
'azure-scim-v2' = 'Azure SCIM v2.0',
|
||||
'onelogin-scim-v2' = 'OneLogin SCIM v2.0',
|
||||
'okta-scim-v2' = 'Okta SCIM v2.0',
|
||||
'jumpcloud-scim-v2' = 'JumpCloud v2.0',
|
||||
'generic-scim-v2' = 'SCIM Generic v2.0',
|
||||
}
|
||||
|
||||
export type DirectoryType = keyof typeof DirectorySyncProviders;
|
||||
|
||||
export type HTTPMethod = 'POST' | 'PUT' | 'DELETE' | 'GET' | 'PATCH';
|
||||
|
||||
export type Directory = {
|
||||
id: string;
|
||||
name: string;
|
||||
tenant: string;
|
||||
product: string;
|
||||
type: DirectoryType;
|
||||
log_webhook_events: boolean;
|
||||
scim: {
|
||||
path: string;
|
||||
endpoint?: string;
|
||||
secret: string;
|
||||
};
|
||||
webhook: {
|
||||
endpoint: string;
|
||||
secret: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type DirectorySyncGroupMember = { value: string; email?: string };
|
||||
|
||||
export interface DirectoryConfig {
|
||||
create({
|
||||
name,
|
||||
tenant,
|
||||
product,
|
||||
webhook_url,
|
||||
webhook_secret,
|
||||
type,
|
||||
}: {
|
||||
name?: string;
|
||||
tenant: string;
|
||||
product: string;
|
||||
webhook_url?: string;
|
||||
webhook_secret?: string;
|
||||
type?: DirectoryType;
|
||||
}): Promise<{ data: Directory | null; error: ApiError | null }>;
|
||||
update(
|
||||
id: string,
|
||||
param: Omit<Partial<Directory>, 'id' | 'tenant' | 'prodct' | 'scim'>
|
||||
): Promise<{ data: Directory | null; error: ApiError | null }>;
|
||||
get(id: string): Promise<{ data: Directory | null; error: ApiError | null }>;
|
||||
getByTenantAndProduct(
|
||||
tenant: string,
|
||||
product: string
|
||||
): Promise<{ data: Directory | null; error: ApiError | null }>;
|
||||
list({
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
}: {
|
||||
pageOffset?: number;
|
||||
pageLimit?: number;
|
||||
}): Promise<{ data: Directory[] | null; error: ApiError | null }>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface IDirectoryUsers {
|
||||
create(directory: Directory, body: any): Promise<DirectorySyncResponse>;
|
||||
get(user: User): Promise<DirectorySyncResponse>;
|
||||
update(directory: Directory, user: User, body: any): Promise<DirectorySyncResponse>;
|
||||
patch(directory: Directory, user: User, body: any): Promise<DirectorySyncResponse>;
|
||||
delete(directory: Directory, user: User, active: boolean): Promise<DirectorySyncResponse>;
|
||||
getAll(queryParams: { count: number; startIndex: number; filter?: string }): Promise<DirectorySyncResponse>;
|
||||
handleRequest(request: DirectorySyncRequest, eventCallback?: EventCallback): Promise<DirectorySyncResponse>;
|
||||
}
|
||||
|
||||
export interface IDirectoryGroups {
|
||||
create(directory: Directory, body: any): Promise<DirectorySyncResponse>;
|
||||
get(group: Group): Promise<DirectorySyncResponse>;
|
||||
updateDisplayName(directory: Directory, group: Group, body: any): Promise<Group>;
|
||||
delete(directory: Directory, group: Group): Promise<DirectorySyncResponse>;
|
||||
getAll(queryParams: { filter?: string }): Promise<DirectorySyncResponse>;
|
||||
addGroupMembers(
|
||||
directory: Directory,
|
||||
group: Group,
|
||||
members: DirectorySyncGroupMember[] | undefined,
|
||||
sendWebhookEvent: boolean
|
||||
): Promise<void>;
|
||||
removeGroupMembers(
|
||||
directory: Directory,
|
||||
group: Group,
|
||||
members: DirectorySyncGroupMember[],
|
||||
sendWebhookEvent: boolean
|
||||
): Promise<void>;
|
||||
addOrRemoveGroupMembers(
|
||||
directory: Directory,
|
||||
group: Group,
|
||||
members: DirectorySyncGroupMember[]
|
||||
): Promise<void>;
|
||||
update(directory: Directory, group: Group, body: any): Promise<DirectorySyncResponse>;
|
||||
patch(directory: Directory, group: Group, body: any): Promise<DirectorySyncResponse>;
|
||||
handleRequest(request: DirectorySyncRequest, eventCallback?: EventCallback): Promise<DirectorySyncResponse>;
|
||||
}
|
||||
|
||||
export interface IWebhookEventsLogger extends Base {
|
||||
log(directory: Directory, event: DirectorySyncEvent): Promise<WebhookEventLog>;
|
||||
getAll(): Promise<WebhookEventLog[]>;
|
||||
get(id: string): Promise<WebhookEventLog>;
|
||||
clear(): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
updateStatus(log: WebhookEventLog, statusCode: number): Promise<WebhookEventLog>;
|
||||
}
|
||||
|
||||
export type DirectorySyncResponse = {
|
||||
status: number;
|
||||
data?: any;
|
||||
};
|
||||
|
||||
export interface DirectorySyncRequestHandler {
|
||||
handle(request: DirectorySyncRequest, callback?: EventCallback): Promise<DirectorySyncResponse>;
|
||||
}
|
||||
|
||||
export interface Events {
|
||||
handle(event: DirectorySyncEvent): Promise<void>;
|
||||
}
|
||||
|
||||
export interface DirectorySyncRequest {
|
||||
method: HTTPMethod;
|
||||
body: any | undefined;
|
||||
directoryId: Directory['id'];
|
||||
resourceType: 'users' | 'groups';
|
||||
resourceId: string | undefined;
|
||||
apiSecret: string | null;
|
||||
query: {
|
||||
count?: number;
|
||||
startIndex?: number;
|
||||
filter?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type DirectorySync = {
|
||||
requests: DirectorySyncRequestHandler;
|
||||
directories: DirectoryConfig;
|
||||
groups: Groups;
|
||||
users: Users;
|
||||
events: { callback: EventCallback };
|
||||
webhookLogs: IWebhookEventsLogger;
|
||||
providers: () => {
|
||||
[K in string]: string;
|
||||
};
|
||||
};
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
export interface DirectorySyncEvent {
|
||||
directory_id: Directory['id'];
|
||||
event: DirectorySyncEventType;
|
||||
data: User | Group | (User & { group: Group });
|
||||
tenant: string;
|
||||
product: string;
|
||||
}
|
||||
|
||||
export interface EventCallback {
|
||||
(event: DirectorySyncEvent): Promise<void>;
|
||||
}
|
||||
|
||||
export interface WebhookEventLog extends DirectorySyncEvent {
|
||||
id: string;
|
||||
webhook_endpoint: string;
|
||||
created_at: Date;
|
||||
status_code?: number;
|
||||
delivered?: boolean;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { DatabaseEngine, DatabaseOption, EncryptionKey, Storable } from '../src/typings';
|
||||
import { DatabaseEngine, DatabaseOption, EncryptionKey, Storable } from '../../src/typings';
|
||||
import tap from 'tap';
|
||||
import DB from '../src/db/db';
|
||||
import DB from '../../src/db/db';
|
||||
|
||||
const encryptionKey: EncryptionKey = 'I+mnyTixBoNGu0OtpG0KXJSunoPTiWMb';
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Directory, DirectoryType } from '../../../src/typings';
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
export const getFakeDirectory = () => {
|
||||
return {
|
||||
name: faker.company.companyName(),
|
||||
tenant: faker.internet.domainName(),
|
||||
product: faker.commerce.productName(),
|
||||
type: 'okta-scim-v2' as DirectoryType,
|
||||
log_webhook_events: false,
|
||||
} as Directory;
|
||||
};
|
|
@ -0,0 +1,168 @@
|
|||
import type { DirectorySyncRequest, Directory } from '../../../src/typings';
|
||||
|
||||
const requests = {
|
||||
// Create a group
|
||||
// POST /api/scim/v2.0/{directoryId: directory.id}/Groups
|
||||
create: (directory: Directory, group: any): DirectorySyncRequest => {
|
||||
return {
|
||||
method: 'POST',
|
||||
body: {
|
||||
schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'],
|
||||
displayName: group.displayName,
|
||||
members: [],
|
||||
},
|
||||
directoryId: directory.id,
|
||||
resourceType: 'groups',
|
||||
resourceId: undefined,
|
||||
apiSecret: directory.scim.secret,
|
||||
query: {},
|
||||
};
|
||||
},
|
||||
|
||||
// Get a group by id
|
||||
// GET /api/scim/v2.0/{directoryId: directory.id}/Groups/{groupId}
|
||||
getById: (directory: Directory, groupId: string): DirectorySyncRequest => {
|
||||
return {
|
||||
method: 'GET',
|
||||
body: undefined,
|
||||
directoryId: directory.id,
|
||||
resourceType: 'groups',
|
||||
resourceId: groupId,
|
||||
apiSecret: directory.scim.secret,
|
||||
query: {},
|
||||
};
|
||||
},
|
||||
|
||||
// Filter by displayName
|
||||
// GET /api/scim/v2.0/{directoryId: directory.id}/Groups?filter=displayName eq "{displayName}"
|
||||
filterByDisplayName: (directory: Directory, displayName: string): DirectorySyncRequest => {
|
||||
return {
|
||||
method: 'GET',
|
||||
directoryId: directory.id,
|
||||
body: undefined,
|
||||
resourceType: 'groups',
|
||||
resourceId: undefined,
|
||||
apiSecret: directory.scim.secret,
|
||||
query: {
|
||||
filter: `displayName eq "${displayName}"`,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// Update a group by id
|
||||
// PUT /api/scim/v2.0/{directoryId: directory.id}/Groups/{groupId}
|
||||
updateById: (directory: Directory, groupId: string, group: any): DirectorySyncRequest => {
|
||||
return {
|
||||
method: 'PUT',
|
||||
body: group,
|
||||
directoryId: directory.id,
|
||||
resourceType: 'groups',
|
||||
resourceId: groupId,
|
||||
apiSecret: directory.scim.secret,
|
||||
query: {},
|
||||
};
|
||||
},
|
||||
|
||||
// Delete a group by id
|
||||
// DELETE /api/scim/v2.0/{directoryId: directory.id}/Groups/{groupId}
|
||||
deleteById: (directory: Directory, groupId: string): DirectorySyncRequest => {
|
||||
return {
|
||||
method: 'DELETE',
|
||||
body: undefined,
|
||||
directoryId: directory.id,
|
||||
resourceType: 'groups',
|
||||
resourceId: groupId,
|
||||
apiSecret: directory.scim.secret,
|
||||
query: {},
|
||||
};
|
||||
},
|
||||
|
||||
// Get all groups
|
||||
// GET /api/scim/v2.0/{directoryId: directory.id}/Groups
|
||||
getAll: (directory: Directory): DirectorySyncRequest => {
|
||||
return {
|
||||
method: 'GET',
|
||||
body: undefined,
|
||||
directoryId: directory.id,
|
||||
resourceType: 'groups',
|
||||
resourceId: undefined,
|
||||
apiSecret: directory.scim.secret,
|
||||
query: {
|
||||
count: 1,
|
||||
startIndex: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addMembers: (directory: Directory, groupId: string, members: any): DirectorySyncRequest => {
|
||||
return {
|
||||
method: 'PATCH',
|
||||
directoryId: directory.id,
|
||||
body: {
|
||||
schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
|
||||
Operations: [
|
||||
{
|
||||
op: 'add',
|
||||
path: 'members',
|
||||
value: members,
|
||||
},
|
||||
],
|
||||
},
|
||||
resourceType: 'groups',
|
||||
resourceId: groupId,
|
||||
apiSecret: directory.scim.secret,
|
||||
query: {},
|
||||
};
|
||||
},
|
||||
|
||||
removeMembers: (
|
||||
directory: Directory,
|
||||
groupId: string,
|
||||
members: any,
|
||||
path: string
|
||||
): DirectorySyncRequest => {
|
||||
return {
|
||||
method: 'PATCH',
|
||||
directoryId: directory.id,
|
||||
body: {
|
||||
schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
|
||||
Operations: [
|
||||
{
|
||||
op: 'remove',
|
||||
value: members,
|
||||
path,
|
||||
},
|
||||
],
|
||||
},
|
||||
resourceType: 'groups',
|
||||
resourceId: groupId,
|
||||
apiSecret: directory.scim.secret,
|
||||
query: {},
|
||||
};
|
||||
},
|
||||
|
||||
updateName: (directory: Directory, groupId: string, group: any): DirectorySyncRequest => {
|
||||
return {
|
||||
method: 'PATCH',
|
||||
directoryId: directory.id,
|
||||
body: {
|
||||
schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
|
||||
Operations: [
|
||||
{
|
||||
op: 'replace',
|
||||
value: {
|
||||
id: groupId,
|
||||
displayName: group.displayName,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
resourceType: 'groups',
|
||||
resourceId: groupId,
|
||||
apiSecret: directory.scim.secret,
|
||||
query: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default requests;
|
|
@ -0,0 +1,9 @@
|
|||
const groups = [
|
||||
{
|
||||
schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'],
|
||||
displayName: 'Developers',
|
||||
members: [],
|
||||
},
|
||||
];
|
||||
|
||||
export default groups;
|
|
@ -0,0 +1,111 @@
|
|||
import type { Directory, DirectorySyncRequest } from '../../../src/typings';
|
||||
|
||||
const requests = {
|
||||
create: (directory: Directory, user: any): DirectorySyncRequest => {
|
||||
return {
|
||||
method: 'POST',
|
||||
body: user,
|
||||
directoryId: directory.id,
|
||||
resourceType: 'users',
|
||||
resourceId: undefined,
|
||||
apiSecret: directory.scim.secret,
|
||||
query: {},
|
||||
};
|
||||
},
|
||||
|
||||
// GET /Users?filter=userName eq "userName"
|
||||
filterByUsername: (directory: Directory, userName: string): DirectorySyncRequest => {
|
||||
return {
|
||||
method: 'GET',
|
||||
body: undefined,
|
||||
directoryId: directory.id,
|
||||
resourceType: 'users',
|
||||
resourceId: undefined,
|
||||
apiSecret: directory.scim.secret,
|
||||
query: {
|
||||
filter: `userName eq "${userName}"`,
|
||||
count: 1,
|
||||
startIndex: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// GET /Users/{userId}
|
||||
getById: (directory: Directory, userId: string): DirectorySyncRequest => {
|
||||
return {
|
||||
method: 'GET',
|
||||
body: undefined,
|
||||
directoryId: directory.id,
|
||||
resourceType: 'users',
|
||||
resourceId: userId,
|
||||
apiSecret: directory.scim.secret,
|
||||
query: {},
|
||||
};
|
||||
},
|
||||
|
||||
// PUT /Users/{userId}
|
||||
updateById: (directory: Directory, userId: string, user: any): DirectorySyncRequest => {
|
||||
return {
|
||||
method: 'PUT',
|
||||
body: user,
|
||||
directoryId: directory.id,
|
||||
resourceType: 'users',
|
||||
resourceId: userId,
|
||||
apiSecret: directory.scim.secret,
|
||||
query: {},
|
||||
};
|
||||
},
|
||||
|
||||
// PATCH /Users/{userId}
|
||||
updateOperationById: (directory: Directory, userId: string): DirectorySyncRequest => {
|
||||
return {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
Operations: [
|
||||
{
|
||||
op: 'replace',
|
||||
value: {
|
||||
active: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
directoryId: directory.id,
|
||||
resourceType: 'users',
|
||||
resourceId: userId,
|
||||
apiSecret: directory.scim.secret,
|
||||
query: {},
|
||||
};
|
||||
},
|
||||
|
||||
// GET /Users/
|
||||
getAll: (directory: Directory): DirectorySyncRequest => {
|
||||
return {
|
||||
method: 'GET',
|
||||
body: undefined,
|
||||
directoryId: directory.id,
|
||||
resourceType: 'users',
|
||||
resourceId: undefined,
|
||||
apiSecret: directory.scim.secret,
|
||||
query: {
|
||||
count: 1,
|
||||
startIndex: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// DELETE /Users/{userId}
|
||||
deleteById: (directory: Directory, userId: string): DirectorySyncRequest => {
|
||||
return {
|
||||
method: 'DELETE',
|
||||
body: undefined,
|
||||
directoryId: directory.id,
|
||||
resourceType: 'users',
|
||||
resourceId: userId,
|
||||
apiSecret: directory.scim.secret,
|
||||
query: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default requests;
|
|
@ -0,0 +1,44 @@
|
|||
const users = [
|
||||
{
|
||||
schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'],
|
||||
userName: 'jackson@boxyhq.com',
|
||||
name: {
|
||||
givenName: 'Jackson',
|
||||
familyName: 'M',
|
||||
},
|
||||
emails: [
|
||||
{
|
||||
primary: true,
|
||||
value: 'jackson@boxyhq.com',
|
||||
type: 'work',
|
||||
},
|
||||
],
|
||||
displayName: 'Jackson M',
|
||||
locale: 'en-US',
|
||||
externalId: '00u5b1hpjh9tGaknX5d7',
|
||||
groups: [],
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'],
|
||||
userName: 'kiran@boxyhq.com',
|
||||
name: {
|
||||
givenName: 'Kiran',
|
||||
familyName: 'K',
|
||||
},
|
||||
emails: [
|
||||
{
|
||||
primary: true,
|
||||
value: 'kiran@boxyhq.com',
|
||||
type: 'work',
|
||||
},
|
||||
],
|
||||
displayName: 'Kiran K',
|
||||
locale: 'en-US',
|
||||
externalId: '00u1b1hpjh91GaknX5d7',
|
||||
groups: [],
|
||||
active: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default users;
|
|
@ -0,0 +1,189 @@
|
|||
import { DirectorySync, Directory, DirectoryType } from '../../src/typings';
|
||||
import tap from 'tap';
|
||||
import { getFakeDirectory } from './data/directories';
|
||||
import { getDatabaseOption } from '../utils';
|
||||
|
||||
let directorySync: DirectorySync;
|
||||
|
||||
tap.before(async () => {
|
||||
const jackson = await (await import('../../src/index')).default(getDatabaseOption());
|
||||
|
||||
directorySync = jackson.directorySync;
|
||||
});
|
||||
|
||||
tap.teardown(async () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
tap.test('Directories / ', async (t) => {
|
||||
let directory: Directory;
|
||||
let fakeDirectory: Directory;
|
||||
|
||||
t.beforeEach(async () => {
|
||||
// Create a directory before each test
|
||||
// t.afterEach() is not working for some reason, so we need to manually delete the directory after each test
|
||||
|
||||
fakeDirectory = getFakeDirectory();
|
||||
|
||||
const { data, error } = await directorySync.directories.create(fakeDirectory);
|
||||
|
||||
if (error || !data) {
|
||||
t.fail("Couldn't create a directory");
|
||||
return;
|
||||
}
|
||||
|
||||
directory = data;
|
||||
});
|
||||
|
||||
t.test('should be able to create a directory', async (t) => {
|
||||
t.ok(directory);
|
||||
t.hasStrict(directory, fakeDirectory);
|
||||
t.type(directory.scim, 'object');
|
||||
t.type(directory.webhook, 'object');
|
||||
|
||||
await directorySync.directories.delete(directory.id);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('should not be able to create a directory without required params', async (t) => {
|
||||
const { data, error } = await directorySync.directories.create({
|
||||
name: '',
|
||||
tenant: '',
|
||||
product: '',
|
||||
type: 'azure-scim-v2',
|
||||
});
|
||||
|
||||
t.ok(error);
|
||||
t.notOk(data);
|
||||
t.equal(error?.code, 400);
|
||||
t.equal(error?.message, 'Missing required parameters.');
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('should be able to get a directory', async (t) => {
|
||||
const { data: directoryFetched } = await directorySync.directories.get(directory.id);
|
||||
|
||||
t.ok(directoryFetched);
|
||||
t.hasStrict(directoryFetched, fakeDirectory);
|
||||
t.match(directoryFetched?.id, directory.id);
|
||||
|
||||
await directorySync.directories.delete(directory.id);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("should not be able to get a directory that doesn't exist", async (t) => {
|
||||
const { data, error } = await directorySync.directories.get('fake-id');
|
||||
|
||||
t.ok(error);
|
||||
t.notOk(data);
|
||||
t.equal(error?.code, 404);
|
||||
t.equal(error?.message, 'Directory configuration not found.');
|
||||
});
|
||||
|
||||
t.test('should not be able to get a directory without an id', async (t) => {
|
||||
const { data, error } = await directorySync.directories.get('');
|
||||
|
||||
t.ok(error);
|
||||
t.notOk(data);
|
||||
t.equal(error?.code, 400);
|
||||
t.equal(error?.message, 'Missing required parameters.');
|
||||
});
|
||||
|
||||
t.test('should not be able to update a directory without an id', async (t) => {
|
||||
const { data, error } = await directorySync.directories.update('', {
|
||||
log_webhook_events: false,
|
||||
});
|
||||
|
||||
t.ok(error);
|
||||
t.notOk(data);
|
||||
t.equal(error?.code, 400);
|
||||
t.equal(error?.message, 'Missing required parameters.');
|
||||
});
|
||||
|
||||
t.test('should be able to update a directory', async (t) => {
|
||||
const toUpdate = {
|
||||
name: 'BoxyHQ 1',
|
||||
webhook: {
|
||||
endpoint: 'https://my-cool-app.com/webhook',
|
||||
secret: 'secret',
|
||||
},
|
||||
log_webhook_events: true,
|
||||
type: 'jumpcloud-scim-v2' as DirectoryType,
|
||||
};
|
||||
|
||||
const { data: updatedDirectory } = await directorySync.directories.update(directory.id, toUpdate);
|
||||
|
||||
t.ok(updatedDirectory);
|
||||
t.match(directory.id, updatedDirectory?.id);
|
||||
t.match(updatedDirectory?.name, toUpdate.name);
|
||||
t.match(updatedDirectory?.webhook.endpoint, toUpdate.webhook.endpoint);
|
||||
t.match(updatedDirectory?.webhook.secret, toUpdate.webhook.secret);
|
||||
t.match(updatedDirectory?.log_webhook_events, toUpdate.log_webhook_events);
|
||||
|
||||
// Partial update
|
||||
const { data: anotherDirectory } = await directorySync.directories.update(directory.id, {
|
||||
name: 'BoxyHQ 2',
|
||||
log_webhook_events: false,
|
||||
});
|
||||
|
||||
t.ok(anotherDirectory);
|
||||
t.match(anotherDirectory?.name, 'BoxyHQ 2');
|
||||
t.match(anotherDirectory?.log_webhook_events, false);
|
||||
|
||||
await directorySync.directories.delete(directory.id);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('should be able to get a directory by tenant and product', async (t) => {
|
||||
const { data: directoryFetched } = await directorySync.directories.getByTenantAndProduct(
|
||||
directory.tenant,
|
||||
directory.product
|
||||
);
|
||||
|
||||
t.ok(directoryFetched);
|
||||
t.hasStrict(directoryFetched, fakeDirectory);
|
||||
t.match(directoryFetched, directory);
|
||||
|
||||
await directorySync.directories.delete(directory.id);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('should be able to delete a directory', async (t) => {
|
||||
await directorySync.directories.delete(directory.id);
|
||||
|
||||
const { data } = await directorySync.directories.get(directory.id);
|
||||
|
||||
t.notOk(data);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('should be able to get all directories', async (t) => {
|
||||
const directoriesList = await directorySync.directories.list({});
|
||||
|
||||
t.ok(directoriesList);
|
||||
|
||||
await directorySync.directories.delete(directory.id);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test(
|
||||
'should not be able to get a directory by tenant and product without tenant and product',
|
||||
async (t) => {
|
||||
const { data, error } = await directorySync.directories.getByTenantAndProduct('', '');
|
||||
|
||||
t.ok(error);
|
||||
t.notOk(data);
|
||||
t.equal(error?.code, 400);
|
||||
t.equal(error?.message, 'Missing required parameters.');
|
||||
}
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
|
@ -0,0 +1,313 @@
|
|||
import { DirectorySync, Directory } from '../../src/typings';
|
||||
import tap from 'tap';
|
||||
import groups from './data/groups';
|
||||
import users from './data/users';
|
||||
import { default as usersRequest } from './data/user-requests';
|
||||
import { default as groupsRequest } from './data/group-requests';
|
||||
import { getFakeDirectory } from './data/directories';
|
||||
import { getDatabaseOption } from '../utils';
|
||||
|
||||
let directorySync: DirectorySync;
|
||||
let directory: Directory;
|
||||
const fakeDirectory = getFakeDirectory();
|
||||
|
||||
tap.before(async () => {
|
||||
const jackson = await (await import('../../src/index')).default(getDatabaseOption());
|
||||
|
||||
directorySync = jackson.directorySync;
|
||||
|
||||
const { data, error } = await directorySync.directories.create(fakeDirectory);
|
||||
|
||||
if (error || !data) {
|
||||
tap.fail("Couldn't create a directory");
|
||||
return;
|
||||
}
|
||||
|
||||
directory = data;
|
||||
});
|
||||
|
||||
tap.teardown(async () => {
|
||||
// Delete the directory after test
|
||||
await directorySync.directories.delete(directory.id);
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
tap.test('Directory groups / ', async (t) => {
|
||||
let createdGroup: any;
|
||||
|
||||
tap.beforeEach(async () => {
|
||||
// Create a group before each test
|
||||
const { data } = await directorySync.requests.handle(groupsRequest.create(directory, groups[0]));
|
||||
|
||||
createdGroup = data;
|
||||
});
|
||||
|
||||
tap.afterEach(async () => {
|
||||
// Delete the group after each test
|
||||
await directorySync.groups.delete(createdGroup.id);
|
||||
});
|
||||
|
||||
t.test('Should be able to create a new group', async (t) => {
|
||||
t.ok(createdGroup);
|
||||
t.hasStrict(createdGroup, groups[0]);
|
||||
t.ok('id' in createdGroup);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should be able to get the group by id', async (t) => {
|
||||
const { status, data } = await directorySync.requests.handle(
|
||||
groupsRequest.getById(directory, createdGroup.id)
|
||||
);
|
||||
|
||||
t.ok(data);
|
||||
t.equal(status, 200);
|
||||
t.hasStrict(data, createdGroup);
|
||||
t.hasStrict(data, groups[0]);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should be able to get the group by displayName', async (t) => {
|
||||
const { status, data } = await directorySync.requests.handle(
|
||||
groupsRequest.filterByDisplayName(directory, createdGroup.displayName)
|
||||
);
|
||||
|
||||
t.ok(data);
|
||||
t.equal(status, 200);
|
||||
t.hasStrict(data.Resources[0], createdGroup);
|
||||
t.hasStrict(data.Resources[0], groups[0]);
|
||||
t.equal(data.Resources.length, 1);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should be able to get all groups', async (t) => {
|
||||
const { status, data } = await directorySync.requests.handle(groupsRequest.getAll(directory));
|
||||
|
||||
t.ok(data);
|
||||
t.equal(status, 200);
|
||||
t.hasStrict(data.Resources[0], createdGroup);
|
||||
t.hasStrict(data.Resources[0], groups[0]);
|
||||
t.equal(data.totalResults, 1);
|
||||
t.equal(data.Resources[0].members.length, 0);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should be able to update the group name - POST request', async (t) => {
|
||||
const { status, data } = await directorySync.requests.handle(
|
||||
groupsRequest.updateById(directory, createdGroup.id, {
|
||||
displayName: 'Developers Updated',
|
||||
})
|
||||
);
|
||||
|
||||
t.ok(data);
|
||||
t.equal(status, 200);
|
||||
t.equal(data.displayName, 'Developers Updated');
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should be able to update the group name - PATCH request', async (t) => {
|
||||
const { status, data } = await directorySync.requests.handle(
|
||||
groupsRequest.updateName(directory, createdGroup.id, {
|
||||
...createdGroup,
|
||||
displayName: 'Developers Updated',
|
||||
})
|
||||
);
|
||||
|
||||
t.ok(data);
|
||||
t.equal(status, 200);
|
||||
t.equal(data.displayName, 'Developers Updated');
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should be able to add or remove the group members - PUT request', async (t) => {
|
||||
const { data: user1 } = await directorySync.requests.handle(usersRequest.create(directory, users[0]));
|
||||
|
||||
const { data: user2 } = await directorySync.requests.handle(usersRequest.create(directory, users[1]));
|
||||
|
||||
const { status, data } = await directorySync.requests.handle(
|
||||
groupsRequest.updateById(directory, createdGroup.id, {
|
||||
...createdGroup,
|
||||
members: [
|
||||
{
|
||||
value: user1.id,
|
||||
},
|
||||
{
|
||||
value: user2.id,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
let members = toMemberArray(data.members);
|
||||
|
||||
t.ok(data);
|
||||
t.equal(status, 200);
|
||||
t.equal(data.members.length, 2);
|
||||
t.ok(members.includes(user1.id));
|
||||
t.ok(members.includes(user2.id));
|
||||
|
||||
// Removing the user1 from the group (Body has the user2 id only)
|
||||
const { data: data1 } = await directorySync.requests.handle(
|
||||
groupsRequest.updateById(directory, createdGroup.id, {
|
||||
...createdGroup,
|
||||
members: [
|
||||
{
|
||||
value: user2.id,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
members = toMemberArray(data1.members);
|
||||
|
||||
t.ok(data1);
|
||||
t.equal(data1.members.length, 1);
|
||||
t.ok(members.includes(user2.id));
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should be able add a member to an existing group - PATCH request', async (t) => {
|
||||
const { data: user1 } = await directorySync.requests.handle(usersRequest.create(directory, users[0]));
|
||||
|
||||
const response1 = await directorySync.requests.handle(
|
||||
groupsRequest.addMembers(directory, createdGroup.id, [
|
||||
{
|
||||
value: user1.id,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
let members = toMemberArray(response1.data.members);
|
||||
|
||||
t.ok(response1.data);
|
||||
t.equal(response1.status, 200);
|
||||
t.equal(response1.data.members.length, 1);
|
||||
t.ok(members.includes(user1.id));
|
||||
|
||||
// Add another member
|
||||
const { data: user2 } = await directorySync.requests.handle(usersRequest.create(directory, users[1]));
|
||||
|
||||
// Fetch the group again
|
||||
const group = await directorySync.requests.handle(groupsRequest.getById(directory, createdGroup.id));
|
||||
|
||||
// Add the second member
|
||||
group.data.members.push({ value: user2.id });
|
||||
|
||||
const response2 = await directorySync.requests.handle(
|
||||
groupsRequest.addMembers(directory, createdGroup.id, group.data.members)
|
||||
);
|
||||
|
||||
members = toMemberArray(response2.data.members);
|
||||
|
||||
t.ok(response2.data);
|
||||
t.equal(response2.status, 200);
|
||||
t.equal(response2.data.members.length, 2);
|
||||
t.ok(members.includes(user1.id));
|
||||
t.ok(members.includes(user2.id));
|
||||
|
||||
// Clean up
|
||||
await directorySync.users.delete(user1.id);
|
||||
await directorySync.users.delete(user2.id);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should be able remove a member from an existing group - PATCH request', async (t) => {
|
||||
const { data: user1 } = await directorySync.requests.handle(usersRequest.create(directory, users[0]));
|
||||
|
||||
const { data: user2 } = await directorySync.requests.handle(usersRequest.create(directory, users[1]));
|
||||
|
||||
// Add 2 members
|
||||
const response1 = await directorySync.requests.handle(
|
||||
groupsRequest.addMembers(directory, createdGroup.id, [
|
||||
{
|
||||
value: user1.id,
|
||||
},
|
||||
{
|
||||
value: user2.id,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
t.ok(response1.data);
|
||||
t.equal(response1.status, 200);
|
||||
|
||||
// Remove the first member
|
||||
const response2 = await directorySync.requests.handle(
|
||||
groupsRequest.removeMembers(
|
||||
directory,
|
||||
createdGroup.id,
|
||||
[
|
||||
{
|
||||
value: user1.id,
|
||||
},
|
||||
],
|
||||
'members'
|
||||
)
|
||||
);
|
||||
|
||||
const members = toMemberArray(response2.data.members);
|
||||
|
||||
t.ok(response2.data);
|
||||
t.equal(response2.status, 200);
|
||||
t.equal(response2.data.members.length, 1);
|
||||
t.ok(members.includes(user2.id));
|
||||
|
||||
// Remove the second member
|
||||
const response3 = await directorySync.requests.handle(
|
||||
groupsRequest.removeMembers(
|
||||
directory,
|
||||
createdGroup.id,
|
||||
[
|
||||
{
|
||||
value: user2.id,
|
||||
},
|
||||
],
|
||||
`members[value eq "${user2.id}"]`
|
||||
)
|
||||
);
|
||||
|
||||
t.ok(response3.data);
|
||||
t.equal(response3.status, 200);
|
||||
t.equal(response3.data.members.length, 0);
|
||||
|
||||
// Clean up
|
||||
await directorySync.users.delete(user1.id);
|
||||
await directorySync.users.delete(user2.id);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should be able to delete a group', async (t) => {
|
||||
const { status } = await directorySync.requests.handle(
|
||||
groupsRequest.deleteById(directory, createdGroup.id)
|
||||
);
|
||||
|
||||
t.equal(status, 200);
|
||||
|
||||
// Try to get the group
|
||||
try {
|
||||
await directorySync.requests.handle(groupsRequest.getById(directory, createdGroup.id));
|
||||
} catch (e: any) {
|
||||
t.equal(e.statusCode, 404);
|
||||
t.equal(e.message, `Group with id ${createdGroup.id} not found.`);
|
||||
}
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
const toMemberArray = (members) => {
|
||||
return members.map((member) => {
|
||||
return member.value;
|
||||
});
|
||||
};
|
|
@ -0,0 +1,165 @@
|
|||
import { DirectorySync, Directory } from '../../src/typings';
|
||||
import tap from 'tap';
|
||||
import users from './data/users';
|
||||
import requests from './data/user-requests';
|
||||
import { getFakeDirectory } from './data/directories';
|
||||
import { getDatabaseOption } from '../utils';
|
||||
|
||||
let directorySync: DirectorySync;
|
||||
let directory: Directory;
|
||||
const fakeDirectory = getFakeDirectory();
|
||||
|
||||
tap.before(async () => {
|
||||
const jackson = await (await import('../../src/index')).default(getDatabaseOption());
|
||||
|
||||
directorySync = jackson.directorySync;
|
||||
|
||||
const { data, error } = await directorySync.directories.create(fakeDirectory);
|
||||
|
||||
if (error || !data) {
|
||||
tap.fail("Couldn't create a directory");
|
||||
return;
|
||||
}
|
||||
|
||||
directory = data;
|
||||
});
|
||||
|
||||
tap.teardown(async () => {
|
||||
// Delete the directory after test
|
||||
await directorySync.directories.delete(directory.id);
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
tap.test('Directory users / ', async (t) => {
|
||||
let createdUser: any;
|
||||
|
||||
tap.beforeEach(async () => {
|
||||
// Create a user before each test
|
||||
const { data } = await directorySync.requests.handle(requests.create(directory, users[0]));
|
||||
|
||||
createdUser = data;
|
||||
});
|
||||
|
||||
tap.afterEach(async () => {
|
||||
// Delete the user after each test
|
||||
await directorySync.users.delete(createdUser.id);
|
||||
});
|
||||
|
||||
t.test('Should be able to get the user by userName', async (t) => {
|
||||
const { status, data } = await directorySync.requests.handle(
|
||||
requests.filterByUsername(directory, createdUser.userName)
|
||||
);
|
||||
|
||||
t.ok(data);
|
||||
t.equal(status, 200);
|
||||
t.hasStrict(data.Resources[0], createdUser);
|
||||
t.hasStrict(data.Resources[0], users[0]);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should be able to get the user by id', async (t) => {
|
||||
const { status, data } = await directorySync.requests.handle(requests.getById(directory, createdUser.id));
|
||||
|
||||
t.ok(data);
|
||||
t.equal(status, 200);
|
||||
t.hasStrict(data, users[0]);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should be able to update the user using PUT request', async (t) => {
|
||||
const toUpdate = {
|
||||
...users[0],
|
||||
name: {
|
||||
givenName: 'Jackson Updated',
|
||||
familyName: 'M',
|
||||
},
|
||||
city: 'New York',
|
||||
};
|
||||
|
||||
const { status, data: updatedUser } = await directorySync.requests.handle(
|
||||
requests.updateById(directory, createdUser.id, toUpdate)
|
||||
);
|
||||
|
||||
t.ok(updatedUser);
|
||||
t.equal(status, 200);
|
||||
t.hasStrict(updatedUser, toUpdate);
|
||||
t.match(updatedUser.city, toUpdate.city);
|
||||
|
||||
// Make sure the user was updated
|
||||
const { data: user } = await directorySync.requests.handle(requests.getById(directory, createdUser.id));
|
||||
|
||||
t.ok(user);
|
||||
t.hasStrict(user, toUpdate);
|
||||
t.match(user.city, toUpdate.city);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should be able to delete the user using PATCH request', async (t) => {
|
||||
const toUpdate = {
|
||||
...users[0],
|
||||
active: false,
|
||||
};
|
||||
|
||||
const { status, data } = await directorySync.requests.handle(
|
||||
requests.updateOperationById(directory, createdUser.id)
|
||||
);
|
||||
|
||||
t.ok(data);
|
||||
t.equal(status, 200);
|
||||
t.hasStrict(data, toUpdate);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should be able to fetch all users', async (t) => {
|
||||
const { status, data } = await directorySync.requests.handle(requests.getAll(directory));
|
||||
|
||||
t.ok(data);
|
||||
t.equal(status, 200);
|
||||
t.ok(data.Resources);
|
||||
t.equal(data.Resources.length, 1);
|
||||
t.hasStrict(data.Resources[0], users[0]);
|
||||
t.equal(data.totalResults, 1);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should be able to delete the user', async (t) => {
|
||||
const { status, data } = await directorySync.requests.handle(
|
||||
requests.deleteById(directory, createdUser.id)
|
||||
);
|
||||
|
||||
t.equal(status, 200);
|
||||
t.ok(data);
|
||||
t.strictSame(data, createdUser);
|
||||
|
||||
// Make sure the user was deleted
|
||||
const { data: user } = await directorySync.requests.handle(
|
||||
requests.filterByUsername(directory, createdUser.userName)
|
||||
);
|
||||
|
||||
t.hasStrict(user.Resources, []);
|
||||
t.hasStrict(user.totalResults, 0);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should be able to delete all users using clear() method', async (t) => {
|
||||
directorySync.users.setTenantAndProduct(directory.tenant, directory.product);
|
||||
|
||||
await directorySync.users.clear();
|
||||
|
||||
// Make sure all the user was deleted
|
||||
const { data: users } = await directorySync.users.list({});
|
||||
|
||||
t.equal(users?.length, 0);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
|
@ -0,0 +1,313 @@
|
|||
import { DirectorySync, Directory, DirectorySyncEvent, EventCallback } from '../../src/typings';
|
||||
import tap from 'tap';
|
||||
import groups from './data/groups';
|
||||
import users from './data/users';
|
||||
import { default as usersRequest } from './data/user-requests';
|
||||
import { default as groupRequest } from './data/group-requests';
|
||||
import { getFakeDirectory } from './data/directories';
|
||||
import { getDatabaseOption } from '../utils';
|
||||
import sinon from 'sinon';
|
||||
import axios from 'axios';
|
||||
import { createSignatureString } from '../../src/directory-sync/utils';
|
||||
|
||||
let directorySync: DirectorySync;
|
||||
let directory: Directory;
|
||||
let eventCallback: EventCallback;
|
||||
|
||||
const fakeDirectory = getFakeDirectory();
|
||||
|
||||
const webhook: Directory['webhook'] = {
|
||||
endpoint: 'http://localhost',
|
||||
secret: 'secret',
|
||||
};
|
||||
|
||||
tap.before(async () => {
|
||||
const jackson = await (await import('../../src/index')).default(getDatabaseOption());
|
||||
|
||||
directorySync = jackson.directorySync;
|
||||
|
||||
// Create a directory before starting the test
|
||||
const { data, error } = await directorySync.directories.create({
|
||||
...fakeDirectory,
|
||||
webhook_url: webhook.endpoint,
|
||||
webhook_secret: webhook.secret,
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
tap.fail("Couldn't create a directory");
|
||||
return;
|
||||
}
|
||||
|
||||
directory = data;
|
||||
|
||||
// Turn on webhook event logging for the directory
|
||||
await directorySync.directories.update(directory.id, {
|
||||
log_webhook_events: true,
|
||||
});
|
||||
|
||||
directorySync.webhookLogs.setTenantAndProduct(directory.tenant, directory.product);
|
||||
directorySync.users.setTenantAndProduct(directory.tenant, directory.product);
|
||||
|
||||
eventCallback = directorySync.events.callback;
|
||||
});
|
||||
|
||||
tap.teardown(async () => {
|
||||
// Delete the directory after the test
|
||||
await directorySync.directories.delete(directory.id);
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
tap.test('Webhook Events / ', async (t) => {
|
||||
tap.afterEach(async () => {
|
||||
await directorySync.webhookLogs.clear();
|
||||
});
|
||||
|
||||
t.test("Should be able to get the directory's webhook", async (t) => {
|
||||
t.match(directory.webhook.endpoint, webhook.endpoint);
|
||||
t.match(directory.webhook.secret, webhook.secret);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should not log events if the directory has no webhook', async (t) => {
|
||||
await directorySync.directories.update(directory.id, {
|
||||
webhook: {
|
||||
endpoint: '',
|
||||
secret: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Create a user
|
||||
await directorySync.requests.handle(usersRequest.create(directory, users[0]), eventCallback);
|
||||
|
||||
const events = await directorySync.webhookLogs.getAll();
|
||||
|
||||
t.equal(events.length, 0);
|
||||
|
||||
// Restore the directory's webhook
|
||||
await directorySync.directories.update(directory.id, {
|
||||
webhook: {
|
||||
endpoint: webhook.endpoint,
|
||||
secret: webhook.secret,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
t.test('Should not log webhook events if the logging is turned off', async (t) => {
|
||||
// Turn off webhook event logging for the directory
|
||||
await directorySync.directories.update(directory.id, {
|
||||
log_webhook_events: false,
|
||||
});
|
||||
|
||||
// Create a user
|
||||
await directorySync.requests.handle(usersRequest.create(directory, users[0]), eventCallback);
|
||||
|
||||
const events = await directorySync.webhookLogs.getAll();
|
||||
|
||||
t.equal(events.length, 0);
|
||||
|
||||
// Turn on webhook event logging for the directory
|
||||
await directorySync.directories.update(directory.id, {
|
||||
log_webhook_events: true,
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should be able to get an event by id', async (t) => {
|
||||
// Create a user
|
||||
await directorySync.requests.handle(usersRequest.create(directory, users[0]), eventCallback);
|
||||
|
||||
const logs = await directorySync.webhookLogs.getAll();
|
||||
|
||||
const log = await directorySync.webhookLogs.get(logs[0].id);
|
||||
|
||||
t.equal(log.id, logs[0].id);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should send user related events', async (t) => {
|
||||
const mock = sinon.mock(axios);
|
||||
|
||||
mock.expects('post').thrice().withArgs(webhook.endpoint).throws();
|
||||
|
||||
// Create the user
|
||||
const { data: createdUser } = await directorySync.requests.handle(
|
||||
usersRequest.create(directory, users[0]),
|
||||
eventCallback
|
||||
);
|
||||
|
||||
// Update the user
|
||||
const { data: updatedUser } = await directorySync.requests.handle(
|
||||
usersRequest.updateById(directory, createdUser.id, users[0]),
|
||||
eventCallback
|
||||
);
|
||||
|
||||
// Delete the user
|
||||
const { data: deletedUser } = await directorySync.requests.handle(
|
||||
usersRequest.deleteById(directory, createdUser.id),
|
||||
eventCallback
|
||||
);
|
||||
|
||||
mock.verify();
|
||||
mock.restore();
|
||||
|
||||
const logs = await directorySync.webhookLogs.getAll();
|
||||
|
||||
t.ok(logs);
|
||||
t.equal(logs.length, 3);
|
||||
|
||||
t.match(logs[0].event, 'user.deleted');
|
||||
t.match(logs[0].directory_id, directory.id);
|
||||
t.hasStrict(logs[0].data.raw, deletedUser);
|
||||
|
||||
t.match(logs[1].event, 'user.updated');
|
||||
t.match(logs[1].directory_id, directory.id);
|
||||
t.hasStrict(logs[1].data.raw, updatedUser);
|
||||
|
||||
t.match(logs[2].event, 'user.created');
|
||||
t.match(logs[2].directory_id, directory.id);
|
||||
t.hasStrict(logs[2].data.raw, createdUser);
|
||||
|
||||
await directorySync.users.clear();
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should send group related events', async (t) => {
|
||||
const mock = sinon.mock(axios);
|
||||
|
||||
mock.expects('post').thrice().withArgs(webhook.endpoint).throws();
|
||||
|
||||
// Create the group
|
||||
const { data: createdGroup } = await directorySync.requests.handle(
|
||||
groupRequest.create(directory, groups[0]),
|
||||
eventCallback
|
||||
);
|
||||
|
||||
// Update the group
|
||||
const { data: updatedGroup } = await directorySync.requests.handle(
|
||||
groupRequest.updateById(directory, createdGroup.id, groups[0]),
|
||||
eventCallback
|
||||
);
|
||||
|
||||
// Delete the group
|
||||
const { data: deletedGroup } = await directorySync.requests.handle(
|
||||
groupRequest.deleteById(directory, createdGroup.id),
|
||||
eventCallback
|
||||
);
|
||||
|
||||
mock.verify();
|
||||
mock.restore();
|
||||
|
||||
const logs = await directorySync.webhookLogs.getAll();
|
||||
|
||||
t.ok(logs);
|
||||
t.equal(logs.length, 3);
|
||||
|
||||
t.match(logs[0].event, 'group.deleted');
|
||||
t.match(logs[0].directory_id, directory.id);
|
||||
t.hasStrict(logs[0].data.raw, deletedGroup);
|
||||
|
||||
t.match(logs[1].event, 'group.updated');
|
||||
t.match(logs[1].directory_id, directory.id);
|
||||
t.hasStrict(logs[1].data.raw, updatedGroup);
|
||||
|
||||
t.match(logs[2].event, 'group.created');
|
||||
t.match(logs[2].directory_id, directory.id);
|
||||
t.hasStrict(logs[2].data.raw, createdGroup);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should send group membership related events', async (t) => {
|
||||
const mock = sinon.mock(axios);
|
||||
|
||||
mock.expects('post').exactly(4).withArgs(webhook.endpoint).throws();
|
||||
|
||||
// Create the user
|
||||
const { data: createdUser } = await directorySync.requests.handle(
|
||||
usersRequest.create(directory, users[0]),
|
||||
eventCallback
|
||||
);
|
||||
|
||||
// Create the group
|
||||
const { data: createdGroup } = await directorySync.requests.handle(
|
||||
groupRequest.create(directory, groups[0]),
|
||||
eventCallback
|
||||
);
|
||||
|
||||
// Add the user to the group
|
||||
await directorySync.requests.handle(
|
||||
groupRequest.addMembers(directory, createdGroup.id, [{ value: createdUser.id }]),
|
||||
eventCallback
|
||||
);
|
||||
|
||||
// Remove the user from the group
|
||||
await directorySync.requests.handle(
|
||||
groupRequest.removeMembers(
|
||||
directory,
|
||||
createdGroup.id,
|
||||
[{ value: createdUser.id }],
|
||||
`members[value eq "${createdUser.id}"]`
|
||||
),
|
||||
eventCallback
|
||||
);
|
||||
|
||||
mock.verify();
|
||||
mock.restore();
|
||||
|
||||
const logs = await directorySync.webhookLogs.getAll();
|
||||
|
||||
t.ok(logs);
|
||||
t.equal(logs.length, 4);
|
||||
|
||||
t.match(logs[0].event, 'group.user_removed');
|
||||
t.match(logs[0].directory_id, directory.id);
|
||||
t.hasStrict(logs[0].data.raw, createdUser);
|
||||
|
||||
t.match(logs[1].event, 'group.user_added');
|
||||
t.match(logs[1].directory_id, directory.id);
|
||||
t.hasStrict(logs[1].data.raw, createdUser);
|
||||
|
||||
await directorySync.users.delete(createdUser.id);
|
||||
await directorySync.groups.delete(createdGroup.id);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('createSignatureString()', async (t) => {
|
||||
const event: DirectorySyncEvent = {
|
||||
event: 'user.created',
|
||||
directory_id: directory.id,
|
||||
tenant: directory.tenant,
|
||||
product: directory.product,
|
||||
data: {
|
||||
raw: [],
|
||||
id: 'user-id',
|
||||
first_name: 'Kiran',
|
||||
last_name: 'Krishnan',
|
||||
email: 'kiran@boxyhq.com',
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
|
||||
const signatureString = await createSignatureString(directory.webhook.secret, event);
|
||||
const parts = signatureString.split(',');
|
||||
|
||||
t.ok(signatureString);
|
||||
t.ok(parts[0].match(/^t=[0-9a-f]/));
|
||||
t.ok(parts[1].match(/^s=[0-9a-f]/));
|
||||
|
||||
// Empty secret should create an empty signature
|
||||
const emptySignatureString = await createSignatureString('', event);
|
||||
|
||||
t.match(emptySignatureString, '');
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
|
@ -1,11 +1,12 @@
|
|||
import * as path from 'path';
|
||||
import sinon from 'sinon';
|
||||
import tap from 'tap';
|
||||
import * as dbutils from '../src/db/utils';
|
||||
import controllers from '../src/index';
|
||||
import readConfig from '../src/read-config';
|
||||
import { IdPConfig, JacksonOption } from '../src/typings';
|
||||
import * as dbutils from '../../src/db/utils';
|
||||
import controllers from '../../src/index';
|
||||
import readConfig from '../../src/read-config';
|
||||
import { IdPConfig, JacksonOption } from '../../src/typings';
|
||||
import { saml_config } from './fixture';
|
||||
import { getDatabaseOption } from '../utils';
|
||||
|
||||
let apiController;
|
||||
|
||||
|
@ -25,7 +26,7 @@ const OPTIONS = <JacksonOption>{
|
|||
};
|
||||
|
||||
tap.before(async () => {
|
||||
const controller = await controllers(OPTIONS);
|
||||
const controller = await controllers(getDatabaseOption());
|
||||
|
||||
apiController = controller.apiController;
|
||||
});
|
|
@ -1,4 +1,4 @@
|
|||
import { OAuthReqBody, OAuthTokenReq } from '../src';
|
||||
import { OAuthReqBody, OAuthTokenReq } from '../../src';
|
||||
import boxyhq from './data/metadata/boxyhq';
|
||||
import boxyhqNobinding from './data/metadata/boxyhq-nobinding';
|
||||
|
|
@ -3,10 +3,11 @@ import { promises as fs } from 'fs';
|
|||
import path from 'path';
|
||||
import sinon from 'sinon';
|
||||
import tap from 'tap';
|
||||
import readConfig from '../src/read-config';
|
||||
import { IAPIController, ILogoutController, JacksonOption } from '../src/typings';
|
||||
import { relayStatePrefix } from '../src/controller/utils';
|
||||
import readConfig from '../../src/read-config';
|
||||
import { IAPIController, ILogoutController, JacksonOption } from '../../src/typings';
|
||||
import { relayStatePrefix } from '../../src/controller/utils';
|
||||
import { saml_config } from './fixture';
|
||||
import { getDatabaseOption } from '../utils';
|
||||
|
||||
let apiController: IAPIController;
|
||||
let logoutController: ILogoutController;
|
||||
|
@ -36,7 +37,7 @@ const addMetadata = async (metadataPath) => {
|
|||
};
|
||||
|
||||
tap.before(async () => {
|
||||
const controller = await (await import('../src/index')).default(options);
|
||||
const controller = await (await import('../../src/index')).default(getDatabaseOption());
|
||||
|
||||
apiController = controller.apiController;
|
||||
logoutController = controller.logoutController;
|
|
@ -1,6 +1,6 @@
|
|||
import crypto from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
import * as utils from '../src/controller/utils';
|
||||
import * as utils from '../../src/controller/utils';
|
||||
import path from 'path';
|
||||
import {
|
||||
IOAuthController,
|
||||
|
@ -9,11 +9,11 @@ import {
|
|||
OAuthReqBody,
|
||||
OAuthTokenReq,
|
||||
SAMLResponsePayload,
|
||||
} from '../src/typings';
|
||||
} from '../../src/typings';
|
||||
import sinon from 'sinon';
|
||||
import tap from 'tap';
|
||||
import { JacksonError } from '../src/controller/error';
|
||||
import readConfig from '../src/read-config';
|
||||
import { JacksonError } from '../../src/controller/error';
|
||||
import readConfig from '../../src/read-config';
|
||||
import saml from '@boxyhq/saml20';
|
||||
import * as jose from 'jose';
|
||||
import {
|
||||
|
@ -38,6 +38,7 @@ import {
|
|||
token_req_idp_initiated_saml_login,
|
||||
token_req_unencoded_client_id_gen,
|
||||
} from './fixture';
|
||||
import { getDatabaseOption } from '../utils';
|
||||
|
||||
let apiController: IAPIController;
|
||||
let oauthController: IOAuthController;
|
||||
|
@ -78,9 +79,9 @@ const addMetadata = async (metadataPath) => {
|
|||
tap.before(async () => {
|
||||
keyPair = await jose.generateKeyPair('RS256', { modulusLength: 3072 });
|
||||
|
||||
const controller = await (await import('../src/index')).default(options);
|
||||
const controller = await (await import('../../src/index')).default(options);
|
||||
const idpFlowEnabledController = await (
|
||||
await import('../src/index')
|
||||
await import('../../src/index')
|
||||
).default({ ...options, idpEnabled: true });
|
||||
|
||||
apiController = controller.apiController;
|
|
@ -0,0 +1,12 @@
|
|||
import { JacksonOption } from '../src/typings';
|
||||
|
||||
export const getDatabaseOption = () => {
|
||||
return {
|
||||
externalUrl: 'https://my-cool-app.com',
|
||||
samlAudience: 'https://saml.boxyhq.com',
|
||||
samlPath: '/sso/oauth/saml',
|
||||
db: {
|
||||
engine: 'mem',
|
||||
},
|
||||
} as JacksonOption;
|
||||
};
|
File diff suppressed because it is too large
Load Diff
|
@ -60,6 +60,8 @@
|
|||
"next-mdx-remote": "4.1.0",
|
||||
"nodemailer": "6.7.8",
|
||||
"raw-body": "2.5.1",
|
||||
"react-hot-toast": "2.2.0",
|
||||
"react-syntax-highlighter": "15.5.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"sharp": "0.31.0",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import type { AppProps } from 'next/app';
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
import { AccountLayout } from '@components/layouts';
|
||||
|
||||
|
@ -17,6 +18,7 @@ function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) {
|
|||
<SessionProvider session={session}>
|
||||
<AccountLayout>
|
||||
<Component {...pageProps} />
|
||||
<Toaster />
|
||||
</AccountLayout>
|
||||
</SessionProvider>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,157 @@
|
|||
import type { NextPage, GetServerSidePropsContext } from 'next';
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import toast from 'react-hot-toast';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeftIcon } from '@heroicons/react/outline';
|
||||
|
||||
import jackson from '@lib/jackson';
|
||||
import { inferSSRProps } from '@lib/inferSSRProps';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const Edit: NextPage<inferSSRProps<typeof getServerSideProps>> = ({
|
||||
directory: { id, name, log_webhook_events, webhook },
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const [directory, setDirectory] = React.useState({
|
||||
name,
|
||||
log_webhook_events,
|
||||
webhook_url: webhook.endpoint,
|
||||
webhook_secret: webhook.secret,
|
||||
});
|
||||
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const rawResponse = await fetch(`/api/admin/directory-sync/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(directory),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
const { data, error } = await rawResponse.json();
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
toast.success('Directory updated successfully');
|
||||
router.replace('/admin/directory-sync');
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const value = target.type === 'checkbox' ? target.checked : target.value;
|
||||
|
||||
setDirectory({
|
||||
...directory,
|
||||
[target.id]: value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link href='/admin/directory-sync'>
|
||||
<a className='btn btn-outline items-center space-x-2'>
|
||||
<ArrowLeftIcon aria-hidden className='h-4 w-4' />
|
||||
<span>Back</span>
|
||||
</a>
|
||||
</Link>
|
||||
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>Update Directory</h2>
|
||||
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 md:w-3/4 md:max-w-lg'>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className='flex flex-col space-y-3'>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>Directory name</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='name'
|
||||
className='input input-bordered w-full'
|
||||
required
|
||||
onChange={onChange}
|
||||
value={directory.name}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>Webhook URL</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='webhook_url'
|
||||
className='input input-bordered w-full'
|
||||
onChange={onChange}
|
||||
value={directory.webhook_url}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>Webhook secret</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='webhook_secret'
|
||||
className='input input-bordered w-full'
|
||||
onChange={onChange}
|
||||
value={directory.webhook_secret}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full py-2'>
|
||||
<div className='flex items-center'>
|
||||
<input
|
||||
id='log_webhook_events'
|
||||
type='checkbox'
|
||||
checked={directory.log_webhook_events}
|
||||
onChange={onChange}
|
||||
className='h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-blue-600'
|
||||
/>
|
||||
<label className='ml-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
|
||||
Enable Webhook events logging
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button className={classNames('btn btn-primary', loading ? 'loading' : '')}>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { directoryId } = context.query;
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { data: directory } = await directorySyncController.directories.get(directoryId as string);
|
||||
|
||||
if (!directory) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
directory,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default Edit;
|
|
@ -0,0 +1,70 @@
|
|||
import type { NextPage, GetServerSidePropsContext } from 'next';
|
||||
import React from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter/dist/cjs';
|
||||
import { coy } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||
|
||||
import jackson from '@lib/jackson';
|
||||
import DirectoryTab from '@components/dsync/DirectoryTab';
|
||||
import { inferSSRProps } from '@lib/inferSSRProps';
|
||||
|
||||
const EventInfo: NextPage<inferSSRProps<typeof getServerSideProps>> = ({ directory, event }) => {
|
||||
return (
|
||||
<>
|
||||
<h2 className='font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
|
||||
<div className='w-full md:w-3/4'>
|
||||
<DirectoryTab directory={directory} activeTab='events' />
|
||||
<div className='my-3 rounded border text-sm'>
|
||||
<SyntaxHighlighter language='json' style={coy}>
|
||||
{JSON.stringify(event, null, 3)}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='font-bold text-primary dark:text-white md:text-2xl'>{directory.name}</h2>
|
||||
</div>
|
||||
<DirectoryTab directory={directory} activeTab='events' />
|
||||
<div className='w-3/4 rounded border text-sm'>
|
||||
<SyntaxHighlighter language='json' style={coy}>
|
||||
{JSON.stringify(event, null, 3)}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { directoryId, eventId } = context.query;
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { data: directory } = await directorySyncController.directories.get(directoryId as string);
|
||||
|
||||
if (!directory) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const event = await directorySyncController.webhookLogs
|
||||
.with(directory.tenant, directory.product)
|
||||
.get(eventId as string);
|
||||
|
||||
if (!event) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
directory,
|
||||
event,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default EventInfo;
|
|
@ -0,0 +1,119 @@
|
|||
import type { NextPage, GetServerSidePropsContext } from 'next';
|
||||
import React from 'react';
|
||||
import { EyeIcon } from '@heroicons/react/outline';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import jackson from '@lib/jackson';
|
||||
import EmptyState from '@components/EmptyState';
|
||||
import DirectoryTab from '@components/dsync/DirectoryTab';
|
||||
import { inferSSRProps } from '@lib/inferSSRProps';
|
||||
import Badge from '@components/Badge';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const Events: NextPage<inferSSRProps<typeof getServerSideProps>> = ({ directory, events }) => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const clearEvents = async () => {
|
||||
setLoading(true);
|
||||
|
||||
await fetch(`/api/admin/directory-sync/${directory.id}/events`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
router.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className='font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
|
||||
<div className='w-full md:w-3/4'>
|
||||
<DirectoryTab directory={directory} activeTab='events' />
|
||||
{events.length === 0 ? (
|
||||
<EmptyState title='No webhook events found' />
|
||||
) : (
|
||||
<>
|
||||
<div className='my-3 flex justify-end'>
|
||||
<button
|
||||
onClick={clearEvents}
|
||||
className={classNames('btn btn-error btn-sm', loading ? 'loading' : '')}>
|
||||
Clear Events
|
||||
</button>
|
||||
</div>
|
||||
<div className='rounded border'>
|
||||
<table className='w-full text-left text-sm text-gray-500 dark:text-gray-400'>
|
||||
<thead className='bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400'>
|
||||
<tr>
|
||||
<th scope='col' className='px-6 py-3'>
|
||||
Event Type
|
||||
</th>
|
||||
<th scope='col' className='px-6 py-3'>
|
||||
Sent At
|
||||
</th>
|
||||
<th scope='col' className='px-6 py-3'>
|
||||
Status Code
|
||||
</th>
|
||||
<th scope='col' className='px-6 py-3'></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((event) => {
|
||||
return (
|
||||
<tr
|
||||
key={event.id}
|
||||
className='border-b bg-white last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-600'>
|
||||
<td className='px-6 py-3 font-semibold'>{event.event}</td>
|
||||
<td className='px-6 py-3'>{event.created_at.toString()}</td>
|
||||
<td className='px-6 py-3'>
|
||||
{event.status_code === 200 ? (
|
||||
<Badge vairant='success'>200</Badge>
|
||||
) : (
|
||||
<Badge vairant='error'>{`${event.status_code}`}</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className='px-6 py-3'>
|
||||
<Link href={`/admin/directory-sync/${directory.id}/events/${event.id}`}>
|
||||
<a>
|
||||
<EyeIcon className='h-5 w-5' />
|
||||
</a>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { directoryId } = context.query;
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { data: directory } = await directorySyncController.directories.get(directoryId as string);
|
||||
|
||||
if (!directory) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const events = await directorySyncController.webhookLogs.with(directory.tenant, directory.product).getAll();
|
||||
|
||||
return {
|
||||
props: {
|
||||
directory,
|
||||
events,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default Events;
|
|
@ -0,0 +1,56 @@
|
|||
import type { NextPage, GetServerSidePropsContext } from 'next';
|
||||
import React from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter/dist/cjs';
|
||||
import { coy } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||
|
||||
import jackson from '@lib/jackson';
|
||||
import DirectoryTab from '@components/dsync/DirectoryTab';
|
||||
import { inferSSRProps } from '@lib/inferSSRProps';
|
||||
|
||||
const GroupInfo: NextPage<inferSSRProps<typeof getServerSideProps>> = ({ directory, group }) => {
|
||||
return (
|
||||
<>
|
||||
<h2 className='font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
|
||||
<div className='w-full md:w-3/4'>
|
||||
<DirectoryTab directory={directory} activeTab='groups' />
|
||||
<div className='my-3 rounded border text-sm'>
|
||||
<SyntaxHighlighter language='json' style={coy}>
|
||||
{JSON.stringify(group, null, 3)}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { directoryId, groupId } = context.query;
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { data: directory } = await directorySyncController.directories.get(directoryId as string);
|
||||
|
||||
if (!directory) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { data: group } = await directorySyncController.groups
|
||||
.with(directory.tenant, directory.product)
|
||||
.get(groupId as string);
|
||||
|
||||
if (!group) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
directory,
|
||||
group,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default GroupInfo;
|
|
@ -0,0 +1,100 @@
|
|||
import type { NextPage, GetServerSidePropsContext } from 'next';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { EyeIcon } from '@heroicons/react/outline';
|
||||
|
||||
import jackson from '@lib/jackson';
|
||||
import EmptyState from '@components/EmptyState';
|
||||
import Paginate from '@components/Paginate';
|
||||
import DirectoryTab from '@components/dsync/DirectoryTab';
|
||||
import { inferSSRProps } from '@lib/inferSSRProps';
|
||||
|
||||
const GroupsList: NextPage<inferSSRProps<typeof getServerSideProps>> = ({
|
||||
directory,
|
||||
groups,
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<h2 className='font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
|
||||
<div className='w-full md:w-3/4'>
|
||||
<DirectoryTab directory={directory} activeTab='groups' />
|
||||
{groups?.length === 0 && pageOffset === 0 ? (
|
||||
<EmptyState title='No groups found' />
|
||||
) : (
|
||||
<div className='my-3 rounded border'>
|
||||
<table className='w-full table-fixed text-left text-sm text-gray-500 dark:text-gray-400'>
|
||||
<thead className='bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400'>
|
||||
<tr>
|
||||
<th scope='col' className='w-5/6 px-6 py-3'>
|
||||
Name
|
||||
</th>
|
||||
<th scope='col' className='w-1/6 px-6 py-3'>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups &&
|
||||
groups.map((group) => {
|
||||
return (
|
||||
<tr
|
||||
key={group.id}
|
||||
className='border-b bg-white last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-600'>
|
||||
<td className='px-6 py-3'>{group.name}</td>
|
||||
<td className='px-6 py-3'>
|
||||
<Link href={`/admin/directory-sync/${directory.id}/groups/${group.id}`}>
|
||||
<a>
|
||||
<EyeIcon className='h-5 w-5' />
|
||||
</a>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<Paginate
|
||||
pageOffset={pageOffset}
|
||||
pageLimit={pageLimit}
|
||||
itemsCount={groups ? groups.length : 0}
|
||||
path={`/admin/directory-sync/${directory.id}/groups?`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { directoryId, offset = 0 } = context.query;
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const pageOffset = parseInt(offset as string);
|
||||
const pageLimit = 25;
|
||||
|
||||
const { data: directory } = await directorySyncController.directories.get(directoryId as string);
|
||||
|
||||
if (!directory) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { data: groups } = await directorySyncController.groups
|
||||
.setTenantAndProduct(directory.tenant, directory.product)
|
||||
.list({ pageOffset, pageLimit });
|
||||
|
||||
return {
|
||||
props: {
|
||||
directory,
|
||||
groups,
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default GroupsList;
|
|
@ -0,0 +1,77 @@
|
|||
import type { NextPage, GetServerSidePropsContext } from 'next';
|
||||
import React from 'react';
|
||||
import jackson from '@lib/jackson';
|
||||
import DirectoryTab from '@components/dsync/DirectoryTab';
|
||||
import { inferSSRProps } from '@lib/inferSSRProps';
|
||||
|
||||
const Info: NextPage<inferSSRProps<typeof getServerSideProps>> = ({ directory }) => {
|
||||
return (
|
||||
<>
|
||||
<h2 className='font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
|
||||
<div className='w-full md:w-3/4'>
|
||||
<DirectoryTab directory={directory} activeTab='directory' />
|
||||
<div className='my-3 rounded border'>
|
||||
<dl>
|
||||
<div className='border-b px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
|
||||
<dt className='text-sm font-medium text-gray-500'>Directory ID</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.id}</dd>
|
||||
</div>
|
||||
<div className='border-b px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
|
||||
<dt className='text-sm font-medium text-gray-500'>Tenant</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.tenant}</dd>
|
||||
</div>
|
||||
<div className='border-b px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
|
||||
<dt className='text-sm font-medium text-gray-500'>Product</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.product}</dd>
|
||||
</div>
|
||||
<div className='border-b bg-gray-100 px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
|
||||
<dt className='pt-2 text-sm font-medium text-gray-500'>SCIM Endpoint</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.scim.endpoint}</dd>
|
||||
</div>
|
||||
<div className='border-b bg-gray-100 px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
|
||||
<dt className='pt-2 text-sm font-medium text-gray-500'>SCIM Token</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.scim.secret}</dd>
|
||||
</div>
|
||||
{directory.webhook.endpoint && directory.webhook.secret && (
|
||||
<>
|
||||
<div className='border-b px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
|
||||
<dt className='text-sm font-medium text-gray-500'>Webhook Endpoint</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
|
||||
{directory.webhook.endpoint}
|
||||
</dd>
|
||||
</div>
|
||||
<div className='px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
|
||||
<dt className='pt-2 text-sm font-medium text-gray-500'>Webhook Secret</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
|
||||
{directory.webhook.secret}
|
||||
</dd>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { directoryId } = context.query;
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { data: directory } = await directorySyncController.directories.get(directoryId as string);
|
||||
|
||||
if (!directory) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
directory,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default Info;
|
|
@ -0,0 +1,56 @@
|
|||
import type { NextPage, GetServerSidePropsContext } from 'next';
|
||||
import React from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter/dist/cjs';
|
||||
import { coy } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||
|
||||
import jackson from '@lib/jackson';
|
||||
import DirectoryTab from '@components/dsync/DirectoryTab';
|
||||
import { inferSSRProps } from '@lib/inferSSRProps';
|
||||
|
||||
const UserInfo: NextPage<inferSSRProps<typeof getServerSideProps>> = ({ directory, user }) => {
|
||||
return (
|
||||
<>
|
||||
<h2 className='font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
|
||||
<div className='w-full md:w-3/4'>
|
||||
<DirectoryTab directory={directory} activeTab='users' />
|
||||
<div className='my-3 rounded border text-sm'>
|
||||
<SyntaxHighlighter language='json' style={coy}>
|
||||
{JSON.stringify(user, null, 3)}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { directoryId, userId } = context.query;
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { data: directory } = await directorySyncController.directories.get(directoryId as string);
|
||||
|
||||
if (!directory) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { data: user } = await directorySyncController.users
|
||||
.with(directory.tenant, directory.product)
|
||||
.get(userId as string);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
directory,
|
||||
user,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default UserInfo;
|
|
@ -0,0 +1,119 @@
|
|||
import type { NextPage, GetServerSidePropsContext } from 'next';
|
||||
import React from 'react';
|
||||
import { EyeIcon } from '@heroicons/react/outline';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { inferSSRProps } from '@lib/inferSSRProps';
|
||||
import jackson from '@lib/jackson';
|
||||
import EmptyState from '@components/EmptyState';
|
||||
import Paginate from '@components/Paginate';
|
||||
import DirectoryTab from '@components/dsync/DirectoryTab';
|
||||
import Badge from '@components/Badge';
|
||||
|
||||
const UsersList: NextPage<inferSSRProps<typeof getServerSideProps>> = ({
|
||||
directory,
|
||||
users,
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<h2 className='font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
|
||||
<div className='w-full md:w-3/4'>
|
||||
<DirectoryTab directory={directory} activeTab='users' />
|
||||
{users?.length === 0 && pageOffset === 0 ? (
|
||||
<EmptyState title='No users found' />
|
||||
) : (
|
||||
<div className='my-3 rounded border'>
|
||||
<table className='w-full text-left text-sm text-gray-500 dark:text-gray-400'>
|
||||
<thead className='bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400'>
|
||||
<tr>
|
||||
<th scope='col' className='px-6 py-3'>
|
||||
First Name
|
||||
</th>
|
||||
<th scope='col' className='px-6 py-3'>
|
||||
Last Name
|
||||
</th>
|
||||
<th scope='col' className='px-6 py-3'>
|
||||
Email
|
||||
</th>
|
||||
<th scope='col' className='px-6 py-3'>
|
||||
Status
|
||||
</th>
|
||||
<th scope='col' className='px-6 py-3'>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users &&
|
||||
users.map((user) => {
|
||||
return (
|
||||
<tr
|
||||
key={user.id}
|
||||
className='border-b bg-white last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-600'>
|
||||
<td className='px-6 py-3'>{user.first_name}</td>
|
||||
<td className='px-6 py-3'>{user.last_name}</td>
|
||||
<td className='px-6 py-3'>{user.email}</td>
|
||||
<td className='px-6 py-3'>
|
||||
{user.active ? (
|
||||
<Badge vairant='success'>Active</Badge>
|
||||
) : (
|
||||
<Badge vairant='warning'>Suspended</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className='px-6 py-3'>
|
||||
<Link href={`/admin/directory-sync/${directory.id}/users/${user.id}`}>
|
||||
<a>
|
||||
<EyeIcon className='h-5 w-5' />
|
||||
</a>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<Paginate
|
||||
pageOffset={pageOffset}
|
||||
pageLimit={pageLimit}
|
||||
itemsCount={users ? users.length : 0}
|
||||
path={`/admin/directory-sync/${directory.id}/users?`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { directoryId, offset = 0 } = context.query;
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const pageOffset = parseInt(offset as string);
|
||||
const pageLimit = 25;
|
||||
|
||||
const { data: directory } = await directorySyncController.directories.get(directoryId as string);
|
||||
|
||||
if (!directory) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { data: users } = await directorySyncController.users
|
||||
.setTenantAndProduct(directory.tenant, directory.product)
|
||||
.list({ pageOffset, pageLimit });
|
||||
|
||||
return {
|
||||
props: {
|
||||
directory,
|
||||
users,
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default UsersList;
|
|
@ -0,0 +1,109 @@
|
|||
import type { InferGetServerSidePropsType, GetServerSidePropsContext } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { PencilAltIcon, DatabaseIcon } from '@heroicons/react/outline';
|
||||
|
||||
import jackson from '@lib/jackson';
|
||||
import EmptyState from '@components/EmptyState';
|
||||
import Paginate from '@components/Paginate';
|
||||
|
||||
const Index = ({
|
||||
directories,
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
providers,
|
||||
}: InferGetServerSidePropsType<typeof getServerSideProps>) => {
|
||||
return (
|
||||
<>
|
||||
<div className='mb-5 flex items-center justify-between'>
|
||||
<h2 className='font-bold text-gray-700 dark:text-white md:text-xl'>Directory Sync</h2>
|
||||
<Link href={'/admin/directory-sync/new'}>
|
||||
<a className='btn btn-primary'>+ New Directory</a>
|
||||
</Link>
|
||||
</div>
|
||||
{directories?.length === 0 && pageOffset === 0 ? (
|
||||
<EmptyState title='No directories found' href='/admin/directory-sync/new' />
|
||||
) : (
|
||||
<div className='rounder border'>
|
||||
<table className='w-full text-left text-sm text-gray-500 dark:text-gray-400'>
|
||||
<thead className='bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400'>
|
||||
<tr>
|
||||
<th scope='col' className='px-6 py-3'>
|
||||
Name
|
||||
</th>
|
||||
<th scope='col' className='px-6 py-3'>
|
||||
Tenant
|
||||
</th>
|
||||
<th scope='col' className='px-6 py-3'>
|
||||
Product
|
||||
</th>
|
||||
<th scope='col' className='px-6 py-3'>
|
||||
Type
|
||||
</th>
|
||||
<th scope='col' className='px-6 py-3'>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{directories &&
|
||||
directories.map((directory) => {
|
||||
return (
|
||||
<tr
|
||||
key={directory.id}
|
||||
className='border-b bg-white last:border-b-0 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<td className='whitespace-nowrap px-6 py-3 text-sm text-gray-500 dark:text-gray-400'>
|
||||
{directory.name}
|
||||
</td>
|
||||
<td className='px-6'>{directory.tenant}</td>
|
||||
<td className='px-6'>{directory.product}</td>
|
||||
<td className='px-6'>{providers[directory.type]}</td>
|
||||
<td className='px-6'>
|
||||
<div className='flex flex-row'>
|
||||
<Link href={`/admin/directory-sync/${directory.id}`}>
|
||||
<a className='link-primary'>
|
||||
<DatabaseIcon className='h-5 w-5 text-secondary' />
|
||||
</a>
|
||||
</Link>
|
||||
<Link href={`/admin/directory-sync/${directory.id}/edit`}>
|
||||
<a className='link-primary'>
|
||||
<PencilAltIcon className='h-5 w-5 text-secondary' />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<Paginate
|
||||
pageOffset={pageOffset}
|
||||
pageLimit={pageLimit}
|
||||
itemsCount={directories ? directories.length : 0}
|
||||
path={`/admin/directory-sync?`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { offset = 0 } = context.query;
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const pageOffset = parseInt(offset as string);
|
||||
const pageLimit = 25;
|
||||
const { data: directories } = await directorySyncController.directories.list({ pageOffset, pageLimit });
|
||||
|
||||
return {
|
||||
props: {
|
||||
providers: directorySyncController.providers(),
|
||||
directories,
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default Index;
|
|
@ -0,0 +1,166 @@
|
|||
import type { NextPage, GetServerSideProps } from 'next';
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import toast from 'react-hot-toast';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeftIcon } from '@heroicons/react/outline';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import jackson from '@lib/jackson';
|
||||
|
||||
const New: NextPage<{ providers: any }> = ({ providers }) => {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [directory, setDirectory] = React.useState({
|
||||
name: '',
|
||||
tenant: '',
|
||||
product: '',
|
||||
webhook_url: '',
|
||||
webhook_secret: '',
|
||||
type: '',
|
||||
});
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const rawResponse = await fetch('/api/admin/directory-sync', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(directory),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
const { data, error } = await rawResponse.json();
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
toast.success('Directory created successfully');
|
||||
router.replace(`/admin/directory-sync/${data.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
|
||||
setDirectory({
|
||||
...directory,
|
||||
[target.id]: target.value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link href='/admin/directory-sync'>
|
||||
<a className='btn btn-outline items-center space-x-2'>
|
||||
<ArrowLeftIcon aria-hidden className='h-4 w-4' />
|
||||
<span>Back</span>
|
||||
</a>
|
||||
</Link>
|
||||
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>New Directory</h2>
|
||||
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 md:w-3/4 md:max-w-lg'>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className='flex flex-col space-y-3'>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>Directory name</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='name'
|
||||
className='input input-bordered w-full'
|
||||
required
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>Directory provider</span>
|
||||
</label>
|
||||
<select className='select select-bordered w-full' id='type' onChange={onChange} required>
|
||||
{Object.keys(providers).map((key) => {
|
||||
return (
|
||||
<option key={key} value={key}>
|
||||
{providers[key]}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>Tenant</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='tenant'
|
||||
className='input input-bordered w-full'
|
||||
required
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>Product</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='product'
|
||||
className='input input-bordered w-full'
|
||||
required
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>Webhook URL</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='webhook_url'
|
||||
className='input input-bordered w-full'
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>Webhook secret</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='webhook_secret'
|
||||
className='input input-bordered w-full'
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button className={classNames('btn btn-primary', loading ? 'loading' : '')}>
|
||||
Create Directory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async () => {
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
return {
|
||||
props: {
|
||||
providers: directorySyncController.providers(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default New;
|
|
@ -60,7 +60,7 @@ const SAMLConfigurations: NextPage = () => {
|
|||
{samlConfigs.map((samlConfig) => (
|
||||
<tr
|
||||
key={samlConfig.clientID}
|
||||
className='border-b bg-white dark:border-gray-700 dark:bg-gray-800'>
|
||||
className='border-b bg-white last:border-b-0 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<td className='whitespace-nowrap px-6 py-3 text-sm font-medium text-gray-900 dark:text-white'>
|
||||
{samlConfig.tenant}
|
||||
</td>
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import jackson from '@lib/jackson';
|
||||
import { checkSession } from '@lib/middleware';
|
||||
|
||||
export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { method } = req;
|
||||
|
||||
switch (method) {
|
||||
case 'DELETE':
|
||||
return handleDELETE(req, res);
|
||||
default:
|
||||
res.setHeader('Allow', ['GET']);
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
|
||||
}
|
||||
};
|
||||
|
||||
// Delete all events
|
||||
const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { directoryId } = req.query;
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { data: directory, error } = await directorySyncController.directories.get(directoryId as string);
|
||||
|
||||
if (!directory) {
|
||||
return res.status(404).json({ data: null, error });
|
||||
}
|
||||
|
||||
await directorySyncController.webhookLogs.setTenantAndProduct(directory.tenant, directory.product).clear();
|
||||
|
||||
return res.status(201).json({ data: null, error: null });
|
||||
};
|
||||
|
||||
export default checkSession(handler);
|
|
@ -0,0 +1,36 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import jackson from '@lib/jackson';
|
||||
import { checkSession } from '@lib/middleware';
|
||||
|
||||
export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { method } = req;
|
||||
|
||||
switch (method) {
|
||||
case 'PUT':
|
||||
return handlePUT(req, res);
|
||||
default:
|
||||
res.setHeader('Allow', ['GET']);
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
|
||||
}
|
||||
};
|
||||
|
||||
// Update a directory configuration
|
||||
const handlePUT = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { directoryId } = req.query;
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { name, webhook_url, webhook_secret, log_webhook_events } = req.body;
|
||||
|
||||
const { data, error } = await directorySyncController.directories.update(directoryId as string, {
|
||||
name,
|
||||
log_webhook_events,
|
||||
webhook: {
|
||||
endpoint: webhook_url,
|
||||
secret: webhook_secret,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(error ? error.code : 201).json({ data, error });
|
||||
};
|
||||
|
||||
export default checkSession(handler);
|
|
@ -0,0 +1,36 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { DirectoryType } from '@lib/jackson';
|
||||
import jackson from '@lib/jackson';
|
||||
import { checkSession } from '@lib/middleware';
|
||||
|
||||
export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { method } = req;
|
||||
|
||||
switch (method) {
|
||||
case 'POST':
|
||||
return handlePOST(req, res);
|
||||
default:
|
||||
res.setHeader('Allow', ['GET']);
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new configuration
|
||||
const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { name, tenant, product, type, webhook_url, webhook_secret } = req.body;
|
||||
|
||||
const { data, error } = await directorySyncController.directories.create({
|
||||
name,
|
||||
tenant,
|
||||
product,
|
||||
type: type as DirectoryType,
|
||||
webhook_url,
|
||||
webhook_secret,
|
||||
});
|
||||
|
||||
return res.status(error ? error.code : 201).json({ data, error });
|
||||
};
|
||||
|
||||
export default checkSession(handler);
|
|
@ -1,7 +1,6 @@
|
|||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import jackson from '@lib/jackson';
|
||||
import { extractAuthToken } from '@lib/utils';
|
||||
import { extractAuthToken } from '@lib/auth';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { HTTPMethod, DirectorySyncRequest } from '@lib/jackson';
|
||||
import jackson from '@lib/jackson';
|
||||
import { extractAuthToken } from '@lib/auth';
|
||||
import { bodyParser } from '@lib/utils';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { method, query } = req;
|
||||
|
||||
const params = query.directory as string[];
|
||||
|
||||
const [directoryId, path, resourceId] = params;
|
||||
|
||||
// Handle the SCIM API requests
|
||||
const request: DirectorySyncRequest = {
|
||||
method: method as HTTPMethod,
|
||||
body: bodyParser(req),
|
||||
directoryId,
|
||||
resourceId,
|
||||
resourceType: path === 'Users' ? 'users' : 'groups',
|
||||
apiSecret: extractAuthToken(req),
|
||||
query: {
|
||||
count: req.query.count ? parseInt(req.query.count as string) : undefined,
|
||||
startIndex: req.query.startIndex ? parseInt(req.query.startIndex as string) : undefined,
|
||||
filter: req.query.filter as string,
|
||||
},
|
||||
};
|
||||
|
||||
const { status, data } = await directorySyncController.requests.handle(
|
||||
request,
|
||||
directorySyncController.events.callback
|
||||
);
|
||||
|
||||
return res.status(status).json(data);
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import jackson from '@lib/jackson';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req;
|
||||
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return handleGET(req, res);
|
||||
default:
|
||||
res.setHeader('Allow', ['GET']);
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
|
||||
}
|
||||
}
|
||||
|
||||
// Get directory by id
|
||||
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { directoryId } = req.query;
|
||||
|
||||
const { data, error } = await directorySyncController.directories.get(directoryId as string);
|
||||
|
||||
return res.status(error ? error.code : 200).json({ data, error });
|
||||
};
|
|
@ -0,0 +1,32 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import jackson from '@lib/jackson';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req;
|
||||
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return handleGET(req, res);
|
||||
default:
|
||||
res.setHeader('Allow', ['GET']);
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
|
||||
}
|
||||
}
|
||||
|
||||
// Get a group by id
|
||||
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { tenant, product, groupId } = req.query;
|
||||
|
||||
directorySyncController.groups.setTenantAndProduct(<string>tenant, <string>product);
|
||||
|
||||
const { data: group, error } = await directorySyncController.groups.get(<string>groupId);
|
||||
|
||||
// Get the members of the group if it exists
|
||||
if (group) {
|
||||
group['members'] = await directorySyncController.groups.getAllUsers(<string>groupId);
|
||||
}
|
||||
|
||||
return res.status(error ? error.code : 200).json({ data: group, error });
|
||||
};
|
|
@ -0,0 +1,27 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import jackson from '@lib/jackson';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req;
|
||||
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return handleGET(req, res);
|
||||
default:
|
||||
res.setHeader('Allow', ['GET']);
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
|
||||
}
|
||||
}
|
||||
|
||||
// Get the groups
|
||||
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { tenant, product } = req.query;
|
||||
|
||||
const { data, error } = await directorySyncController.groups
|
||||
.setTenantAndProduct(<string>tenant, <string>product)
|
||||
.list({});
|
||||
|
||||
return res.status(error ? error.code : 200).json({ data, error });
|
||||
};
|
|
@ -0,0 +1,56 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import jackson from '@lib/jackson';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req;
|
||||
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return handleGET(req, res);
|
||||
case 'POST':
|
||||
return handlePOST(req, res);
|
||||
default:
|
||||
res.setHeader('Allow', ['GET']);
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
|
||||
}
|
||||
}
|
||||
|
||||
// Get the configurations
|
||||
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { tenant, product } = req.query;
|
||||
|
||||
// If tenant and product are specified, get the configuration by tenant and product
|
||||
if (tenant && product) {
|
||||
const { data, error } = await directorySyncController.directories.getByTenantAndProduct(
|
||||
tenant as string,
|
||||
product as string
|
||||
);
|
||||
|
||||
return res.status(error ? error.code : 200).json({ data, error });
|
||||
}
|
||||
|
||||
// otherwise, get all configurations
|
||||
const { data, error } = await directorySyncController.directories.list({});
|
||||
|
||||
return res.status(error ? error.code : 200).json({ data, error });
|
||||
};
|
||||
|
||||
// Create a new configuration
|
||||
const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { name, tenant, type, product, webhook_url, webhook_secret } = req.body;
|
||||
|
||||
const { data, error } = await directorySyncController.directories.create({
|
||||
name,
|
||||
tenant,
|
||||
product,
|
||||
type,
|
||||
webhook_url,
|
||||
webhook_secret,
|
||||
});
|
||||
|
||||
return res.status(error ? error.code : 201).json({ data, error });
|
||||
};
|
|
@ -0,0 +1,27 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import jackson from '@lib/jackson';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req;
|
||||
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return handleGET(req, res);
|
||||
default:
|
||||
res.setHeader('Allow', ['GET']);
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
|
||||
}
|
||||
}
|
||||
|
||||
// Get a user by id
|
||||
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { tenant, product, userId } = req.query;
|
||||
|
||||
const { data, error } = await directorySyncController.users
|
||||
.setTenantAndProduct(<string>tenant, <string>product)
|
||||
.get(<string>userId);
|
||||
|
||||
return res.status(error ? error.code : 200).json({ data, error });
|
||||
};
|
|
@ -0,0 +1,27 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import jackson from '@lib/jackson';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req;
|
||||
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return handleGET(req, res);
|
||||
default:
|
||||
res.setHeader('Allow', ['GET']);
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
|
||||
}
|
||||
}
|
||||
|
||||
// Get the users
|
||||
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { tenant, product } = req.query;
|
||||
|
||||
const { data, error } = await directorySyncController.users
|
||||
.setTenantAndProduct(<string>tenant, <string>product)
|
||||
.list({});
|
||||
|
||||
return res.status(error ? error.code : 200).json({ data, error });
|
||||
};
|
|
@ -1,15 +1,8 @@
|
|||
import jackson from '@lib/jackson';
|
||||
import { extractAuthToken, validateApiKey } from '@lib/utils';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const apiKey = extractAuthToken(req);
|
||||
if (!validateApiKey(apiKey)) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { apiController } = await jackson();
|
||||
if (req.method === 'POST') {
|
||||
res.json(await apiController.config(req.body));
|
||||
|
|
|
@ -1,15 +1,8 @@
|
|||
import jackson from '@lib/jackson';
|
||||
import { extractAuthToken, validateApiKey } from '@lib/utils';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const apiKey = extractAuthToken(req);
|
||||
if (!validateApiKey(apiKey)) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { apiController } = await jackson();
|
||||
if (req.method === 'GET') {
|
||||
const rsp = await apiController.getConfig(req.query as any);
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return res.status(401).json({ data: null, error: { message: 'Unauthorized' } });
|
||||
}
|
Loading…
Reference in New Issue