mirror of https://github.com/boxyhq/jackson.git
Merge branch 'main' into add-audit-logs
# Conflicts: # package-lock.json # pages/api/v1/connections/index.ts # pages/api/v1/saml/config.ts
This commit is contained in:
commit
ecca8f7df4
|
@ -1,4 +1,4 @@
|
|||
ARG NODEJS_IMAGE=node:18.15.0-alpine3.17
|
||||
ARG NODEJS_IMAGE=node:18.16.1-alpine3.18
|
||||
FROM --platform=$BUILDPLATFORM $NODEJS_IMAGE AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# SAML Jackson: Enterprise SSO made simple
|
||||
|
||||
<p>
|
||||
<a href="https://bestpractices.coreinfrastructure.org/projects/7493"><img src="https://bestpractices.coreinfrastructure.org/projects/7493/badge"></a>
|
||||
<a href="https://www.npmjs.com/package/@boxyhq/saml-jackson"><img src="https://img.shields.io/npm/dt/@boxyhq/saml-jackson" alt="npm" ></a>
|
||||
<a href="https://hub.docker.com/r/boxyhq/jackson"><img src="https://img.shields.io/docker/pulls/boxyhq/jackson" alt="Docker pull"></a>
|
||||
<a href="https://github.com/boxyhq/jackson/stargazers"><img src="https://img.shields.io/github/stars/boxyhq/jackson" alt="Github stargazers"></a>
|
||||
|
@ -28,7 +29,13 @@ Jackson implements the SAML login flow as an OAuth 2.0 or OpenID Connect flow, a
|
|||
|
||||
Try our hosted demo showcasing the SAML SP login flow [here](https://saml-demo.boxyhq.com), no SAML configuration required thanks to our [Mock SAML](https://mocksaml.com) service.
|
||||
|
||||
You can also try our hosted demo showcasing the SAML IdP login flow [here](https://mocksaml.com/saml/login).
|
||||
## Videos
|
||||
- SSO/OIDC Tutorial [SAML Jackson Enterprise SSO](https://www.youtube.com/watch?v=nvsD4-GQw4A)
|
||||
- SAML single sign-on login [demo](https://www.youtube.com/watch?v=VBUznQwoEWU)
|
||||
|
||||
## Demo
|
||||
- SAML IdP login flow showcasing self hosted [Mock SAML](https://mocksaml.com/saml/login)
|
||||
- SAML [demo flow](https://saml-demo.boxyhq.com/)
|
||||
|
||||
## Documentation
|
||||
|
||||
|
|
|
@ -103,14 +103,16 @@ const EditConnection = ({ connection, setupLinkToken, isSettingsView = false }:
|
|||
const [delModalVisible, setDelModalVisible] = useState(false);
|
||||
const toggleDelConfirm = () => setDelModalVisible(!delModalVisible);
|
||||
const deleteConnection = async () => {
|
||||
const queryParams = new URLSearchParams({
|
||||
clientID: connection.clientID,
|
||||
clientSecret: connection.clientSecret,
|
||||
});
|
||||
const res = await fetch(
|
||||
setupLinkToken ? `/api/setup/${setupLinkToken}/sso-connection` : '/api/admin/connections',
|
||||
setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/sso-connection?${queryParams}`
|
||||
: `/api/admin/connections?${queryParams}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ clientID: connection?.clientID, clientSecret: connection?.clientSecret }),
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useTranslation } from 'next-i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { ApiResponse } from 'types';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import type { Directory } from '@boxyhq/saml-jackson';
|
||||
|
@ -13,28 +13,32 @@ interface CreateDirectoryProps {
|
|||
defaultWebhookEndpoint: string | undefined;
|
||||
}
|
||||
|
||||
type UnSavedDirectory = Omit<Directory, 'id' | 'log_webhook_events' | 'scim' | 'deactivated' | 'webhook'> & {
|
||||
webhook_url: string;
|
||||
webhook_secret: string;
|
||||
};
|
||||
|
||||
const defaultDirectory: UnSavedDirectory = {
|
||||
name: '',
|
||||
tenant: '',
|
||||
product: '',
|
||||
webhook_url: '',
|
||||
webhook_secret: '',
|
||||
type: 'azure-scim-v2',
|
||||
google_domain: '',
|
||||
};
|
||||
|
||||
const CreateDirectory = ({ setupLinkToken, defaultWebhookEndpoint }: CreateDirectoryProps) => {
|
||||
const { t } = useTranslation('common');
|
||||
const router = useRouter();
|
||||
const { providers } = useDirectoryProviders(setupLinkToken);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [directory, setDirectory] = useState({
|
||||
name: '',
|
||||
tenant: '',
|
||||
product: '',
|
||||
webhook_url: defaultWebhookEndpoint,
|
||||
webhook_secret: '',
|
||||
type: '',
|
||||
const [showDomain, setShowDomain] = useState(false);
|
||||
const [directory, setDirectory] = useState<UnSavedDirectory>({
|
||||
...defaultDirectory,
|
||||
webhook_url: defaultWebhookEndpoint || '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (providers && Object.keys(providers).length > 0) {
|
||||
setDirectory((directory) => {
|
||||
return { ...directory, type: Object.keys(providers)[0] };
|
||||
});
|
||||
}
|
||||
}, [providers]);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
|
@ -78,6 +82,11 @@ const CreateDirectory = ({ setupLinkToken, defaultWebhookEndpoint }: CreateDirec
|
|||
...directory,
|
||||
[target.id]: target.value,
|
||||
});
|
||||
|
||||
// Ask for domain if google is selected
|
||||
if (target.id === 'type') {
|
||||
target.value === 'google' ? setShowDomain(true) : setShowDomain(false);
|
||||
}
|
||||
};
|
||||
|
||||
const backUrl = setupLinkToken ? `/setup/${setupLinkToken}/directory-sync` : '/admin/directory-sync';
|
||||
|
@ -116,6 +125,22 @@ const CreateDirectory = ({ setupLinkToken, defaultWebhookEndpoint }: CreateDirec
|
|||
})}
|
||||
</select>
|
||||
</div>
|
||||
{showDomain && (
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('directory_domain')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='google_domain'
|
||||
className='input-bordered input w-full'
|
||||
onChange={onChange}
|
||||
value={directory.google_domain}
|
||||
pattern='^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.[a-zA-Z]{2,}$'
|
||||
title='Please enter a valid domain (e.g: boxyhq.com)'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!setupLinkToken && (
|
||||
<>
|
||||
<div className='form-control w-full'>
|
||||
|
|
|
@ -6,6 +6,7 @@ import React from 'react';
|
|||
import useDirectory from '@lib/ui/hooks/useDirectory';
|
||||
import Loading from '@components/Loading';
|
||||
import { errorToast } from '@components/Toaster';
|
||||
import { dsyncGoogleAuthURL } from '@lib/env';
|
||||
|
||||
const DirectoryInfo = ({ directoryId, setupLinkToken }: { directoryId: string; setupLinkToken?: string }) => {
|
||||
const { t } = useTranslation('common');
|
||||
|
@ -71,14 +72,24 @@ const DirectoryInfo = ({ directoryId, setupLinkToken }: { directoryId: string; s
|
|||
)}
|
||||
</dl>
|
||||
</div>
|
||||
<div className='mt-4 space-y-4 rounded border p-6'>
|
||||
<div className='form-control'>
|
||||
<InputWithCopyButton text={directory.scim.endpoint as string} label={t('scim_endpoint')} />
|
||||
{directory.scim.endpoint && directory.scim.secret && (
|
||||
<div className='mt-4 space-y-4 rounded border p-6'>
|
||||
<div className='form-control'>
|
||||
<InputWithCopyButton text={directory.scim.endpoint as string} label={t('scim_endpoint')} />
|
||||
</div>
|
||||
<div className='form-control'>
|
||||
<InputWithCopyButton text={directory.scim.secret} label={t('scim_token')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='form-control'>
|
||||
<InputWithCopyButton text={directory.scim.secret} label={t('scim_token')} />
|
||||
)}
|
||||
{directory.type === 'google' && (
|
||||
<div className='form-control mt-10'>
|
||||
<InputWithCopyButton
|
||||
text={`${dsyncGoogleAuthURL}?directoryId=${directory.id}`}
|
||||
label={t('dsync_google_auth_url')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -11,7 +11,7 @@ import useDirectory from '@lib/ui/hooks/useDirectory';
|
|||
import { ToggleConnectionStatus } from './ToggleConnectionStatus';
|
||||
import { DeleteDirectory } from './DeleteDirectory';
|
||||
|
||||
type FormState = Pick<Directory, 'name' | 'log_webhook_events' | 'webhook'>;
|
||||
type FormState = Pick<Directory, 'name' | 'log_webhook_events' | 'webhook' | 'google_domain'>;
|
||||
|
||||
const defaultFormState: FormState = {
|
||||
name: '',
|
||||
|
@ -20,6 +20,7 @@ const defaultFormState: FormState = {
|
|||
endpoint: '',
|
||||
secret: '',
|
||||
},
|
||||
google_domain: '',
|
||||
};
|
||||
|
||||
const EditDirectory = ({ directoryId, setupLinkToken }: { directoryId: string; setupLinkToken?: string }) => {
|
||||
|
@ -39,6 +40,7 @@ const EditDirectory = ({ directoryId, setupLinkToken }: { directoryId: string; s
|
|||
endpoint: directory.webhook?.endpoint,
|
||||
secret: directory.webhook?.secret,
|
||||
},
|
||||
google_domain: directory.google_domain,
|
||||
});
|
||||
}
|
||||
}, [directory]);
|
||||
|
@ -131,6 +133,20 @@ const EditDirectory = ({ directoryId, setupLinkToken }: { directoryId: string; s
|
|||
value={directoryUpdated.name}
|
||||
/>
|
||||
</div>
|
||||
{directory.type === 'google' && (
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('directory_domain')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='google_domain'
|
||||
className='input-bordered input w-full'
|
||||
onChange={onChange}
|
||||
value={directoryUpdated.google_domain}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('webhook_url')}</span>
|
||||
|
|
|
@ -77,7 +77,7 @@ export const deleteConnection = async (
|
|||
{ tenant, product }: { tenant: string; product: string }
|
||||
) => {
|
||||
const response = await request.delete('/api/v1/connections', {
|
||||
data: {
|
||||
params: {
|
||||
tenant,
|
||||
product,
|
||||
},
|
||||
|
|
|
@ -182,7 +182,7 @@ test.describe('SCIM /api/scim/v2.0/:directoryId/Groups', () => {
|
|||
startIndex: 1,
|
||||
totalResults: 2,
|
||||
itemsPerPage: 2,
|
||||
Resources: [
|
||||
Resources: expect.arrayContaining([
|
||||
{
|
||||
...groups[1],
|
||||
id: expect.any(String),
|
||||
|
@ -191,7 +191,7 @@ test.describe('SCIM /api/scim/v2.0/:directoryId/Groups', () => {
|
|||
...groups[0],
|
||||
id: expect.any(String),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
11
lib/env.ts
11
lib/env.ts
|
@ -80,6 +80,15 @@ const jacksonOptions: JacksonOption = {
|
|||
endpoint: process.env.WEBHOOK_URL || '',
|
||||
secret: process.env.WEBHOOK_SECRET || '',
|
||||
},
|
||||
dsync: {
|
||||
providers: {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID || '',
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
||||
callbackUrl: process.env.GOOGLE_REDIRECT_URI || '',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const adminPortalSSODefaults = {
|
||||
|
@ -94,3 +103,5 @@ export { retraced as retracedOptions };
|
|||
export { terminus as terminusOptions };
|
||||
export { apiKeys };
|
||||
export { jacksonOptions };
|
||||
|
||||
export const dsyncGoogleAuthURL = externalUrl + '/api/scim/oauth/authorize';
|
||||
|
|
|
@ -206,5 +206,7 @@
|
|||
"yes_proceed": "Yes, proceed",
|
||||
"delete_this_directory": "Delete this directory connection?",
|
||||
"delete_this_directory_desc": "This action cannot be undone. This will permanently delete the directory connection, users, and groups.",
|
||||
"directory_connection_deleted_successfully": "Directory connection deleted successfully"
|
||||
}
|
||||
"directory_connection_deleted_successfully": "Directory connection deleted successfully",
|
||||
"directory_domain": "Directory Domain",
|
||||
"dsync_google_auth_url": "The URL that you will need to authorize the application to access your Google Directory."
|
||||
}
|
||||
|
|
|
@ -14,6 +14,8 @@ const unAuthenticatedApiRoutes = [
|
|||
'/api/logout/**',
|
||||
'/api/oauth/**',
|
||||
'/api/scim/v2.0/**',
|
||||
'/api/scim/oauth/**',
|
||||
'/api/scim/cron',
|
||||
'/api/well-known/**',
|
||||
'/api/setup/**',
|
||||
'/api/branding',
|
||||
|
|
|
@ -13,7 +13,7 @@ module.exports = {
|
|||
config.plugins.push(
|
||||
new webpack.IgnorePlugin({
|
||||
resourceRegExp:
|
||||
/(^@google-cloud\/spanner|^@mongodb-js\/zstd|^aws-crt|^aws4$|^pg-native$|^mongodb-client-encryption$|^@sap\/hana-client$|^snappy$|^react-native-sqlite-storage$|^bson-ext$|^cardinal$|^kerberos$|^hdb-pool$|^sql.js$|^sqlite3$|^better-sqlite3$|^ioredis$|^typeorm-aurora-data-api-driver$|^pg-query-stream$|^oracledb$|^mysql$|^snappy\/package\.json$)/,
|
||||
/(^@google-cloud\/spanner|^@mongodb-js\/zstd|^aws-crt|^aws4$|^pg-native$|^mongodb-client-encryption$|^@sap\/hana-client$|^snappy$|^react-native-sqlite-storage$|^bson-ext$|^cardinal$|^kerberos$|^hdb-pool$|^sql.js$|^sqlite3$|^better-sqlite3$|^ioredis$|^typeorm-aurora-data-api-driver$|^pg-query-stream$|^oracledb$|^mysql$|^snappy\/package\.json$|^cloudflare:sockets$)/,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
14
npm/map.js
14
npm/map.js
|
@ -29,6 +29,20 @@ const map = {
|
|||
'test/federated-saml/app.test.ts': ['src/ee/federated-saml/app.ts'],
|
||||
'test/federated-saml/sso.test.ts': ['src/ee/federated-saml/sso.ts'],
|
||||
'test/event/index.test.ts': ['src/event/*'],
|
||||
'test/dsync/google_oauth.test.ts': [
|
||||
'src/directory-sync/non-scim/google/oauth.ts',
|
||||
'src/directory-sync/non-scim/google/index.ts',
|
||||
'src/directory-sync/non-scim/utils.ts',
|
||||
],
|
||||
'test/dsync/google_api.test.ts': [
|
||||
'src/directory-sync/non-scim/google/api.ts',
|
||||
'src/directory-sync/non-scim/google/index.ts',
|
||||
'src/directory-sync/non-scim/syncUsers.ts',
|
||||
'src/directory-sync/non-scim/syncGroups.ts',
|
||||
'src/directory-sync/non-scim/syncGroupsMembers.ts',
|
||||
'src/directory-sync/non-scim/utils.ts',
|
||||
'src/directory-sync/non-scim/index.ts',
|
||||
],
|
||||
};
|
||||
|
||||
module.exports = (testFile) => {
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -40,23 +40,25 @@
|
|||
"statements": 70
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-dynamodb": "3.341.0",
|
||||
"@aws-sdk/credential-providers": "3.341.0",
|
||||
"@aws-sdk/util-dynamodb": "3.341.0",
|
||||
"@aws-sdk/client-dynamodb": "3.360.0",
|
||||
"@aws-sdk/credential-providers": "3.360.0",
|
||||
"@aws-sdk/util-dynamodb": "3.360.0",
|
||||
"@boxyhq/error-code-mnemonic": "0.1.1",
|
||||
"@boxyhq/metrics": "0.2.2",
|
||||
"@boxyhq/metrics": "0.2.4",
|
||||
"@boxyhq/saml20": "1.2.1",
|
||||
"axios": "1.4.0",
|
||||
"encoding": "0.1.13",
|
||||
"googleapis": "118.0.0",
|
||||
"jose": "4.14.4",
|
||||
"lodash": "4.17.21",
|
||||
"mixpanel": "0.17.0",
|
||||
"mongodb": "5.5.0",
|
||||
"mongodb": "5.6.0",
|
||||
"mssql": "9.1.1",
|
||||
"mysql2": "3.3.3",
|
||||
"mysql2": "3.4.0",
|
||||
"node-forge": "1.3.1",
|
||||
"openid-client": "5.4.2",
|
||||
"pg": "8.10.0",
|
||||
"redis": "4.6.6",
|
||||
"redis": "4.6.7",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"ripemd160": "2.0.2",
|
||||
"typeorm": "0.3.16",
|
||||
|
@ -65,15 +67,17 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "8.0.2",
|
||||
"@types/node": "20.2.5",
|
||||
"@types/lodash": "4.14.195",
|
||||
"@types/node": "20.3.1",
|
||||
"@types/sinon": "10.0.15",
|
||||
"@types/tap": "15.0.8",
|
||||
"cross-env": "7.0.3",
|
||||
"sinon": "15.1.0",
|
||||
"tap": "16.3.4",
|
||||
"nock": "13.3.1",
|
||||
"sinon": "15.2.0",
|
||||
"tap": "16.3.7",
|
||||
"ts-node": "10.9.1",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.0.4"
|
||||
"typescript": "5.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16",
|
||||
|
|
|
@ -668,27 +668,27 @@ export class ConnectionAPIController implements IConnectionAPIController {
|
|||
* parameters:
|
||||
* clientIDDel:
|
||||
* name: clientID
|
||||
* in: formData
|
||||
* in: query
|
||||
* type: string
|
||||
* description: Client ID
|
||||
* clientSecretDel:
|
||||
* name: clientSecret
|
||||
* in: formData
|
||||
* in: query
|
||||
* type: string
|
||||
* description: Client Secret
|
||||
* tenantDel:
|
||||
* name: tenant
|
||||
* in: formData
|
||||
* in: query
|
||||
* type: string
|
||||
* description: Tenant
|
||||
* productDel:
|
||||
* name: product
|
||||
* in: formData
|
||||
* in: query
|
||||
* type: string
|
||||
* description: Product
|
||||
* strategyDel:
|
||||
* name: strategy
|
||||
* in: formData
|
||||
* in: query
|
||||
* type: string
|
||||
* description: Strategy which can help to filter connections with tenant/product query
|
||||
* /api/v1/connections:
|
||||
|
@ -702,9 +702,6 @@ export class ConnectionAPIController implements IConnectionAPIController {
|
|||
* summary: Delete SSO Connections
|
||||
* operationId: delete-sso-connection
|
||||
* tags: [Connections]
|
||||
* consumes:
|
||||
* - application/x-www-form-urlencoded
|
||||
* - application/json
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Success
|
||||
|
@ -803,4 +800,98 @@ export class ConnectionAPIController implements IConnectionAPIController {
|
|||
public async deleteConfig(body: DelConnectionsQuery): Promise<void> {
|
||||
await this.deleteConnections({ ...body, strategy: 'saml' });
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* parameters:
|
||||
* productParamGet:
|
||||
* in: query
|
||||
* name: product
|
||||
* type: string
|
||||
* description: Product
|
||||
* required: true
|
||||
* definitions:
|
||||
* Connection:
|
||||
* type: object
|
||||
* properties:
|
||||
* clientID:
|
||||
* type: string
|
||||
* description: Connection clientID
|
||||
* clientSecret:
|
||||
* type: string
|
||||
* description: Connection clientSecret
|
||||
* name:
|
||||
* type: string
|
||||
* description: Connection name
|
||||
* description:
|
||||
* type: string
|
||||
* description: Connection description
|
||||
* redirectUrl:
|
||||
* type: string
|
||||
* description: A list of allowed redirect URLs
|
||||
* defaultRedirectUrl:
|
||||
* type: string
|
||||
* description: The redirect URL to use in the IdP login flow
|
||||
* tenant:
|
||||
* type: string
|
||||
* description: Connection tenant
|
||||
* product:
|
||||
* type: string
|
||||
* description: Connection product
|
||||
* idpMetadata:
|
||||
* type: object
|
||||
* description: SAML IdP metadata
|
||||
* oidcProvider:
|
||||
* type: object
|
||||
* description: OIDC IdP metadata
|
||||
* responses:
|
||||
* '200Get':
|
||||
* description: Success
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/definitions/Connection'
|
||||
* '400Get':
|
||||
* description: Please provide a `product`.
|
||||
* '401Get':
|
||||
* description: Unauthorized
|
||||
* /api/v1/connections/product:
|
||||
* get:
|
||||
* summary: Get SSO Connections by product
|
||||
* parameters:
|
||||
* - $ref: '#/parameters/productParamGet'
|
||||
* operationId: get-connections-by-product
|
||||
* tags: [Connections]
|
||||
* responses:
|
||||
* '200':
|
||||
* $ref: '#/responses/200Get'
|
||||
* '400':
|
||||
* $ref: '#/responses/400Get'
|
||||
* '401':
|
||||
* $ref: '#/responses/401Get'
|
||||
*/
|
||||
public async getConnectionsByProduct(body: {
|
||||
product: string;
|
||||
pageOffset?: number;
|
||||
pageLimit?: number;
|
||||
pageToken?: string;
|
||||
}): Promise<{ data: (SAMLSSORecord | OIDCSSORecord)[]; pageToken?: string }> {
|
||||
const { product, pageOffset, pageLimit, pageToken } = body;
|
||||
|
||||
if (!product) {
|
||||
throw new JacksonError('Please provide a `product`.', 400);
|
||||
}
|
||||
|
||||
const connections = await this.connectionStore.getByIndex(
|
||||
{
|
||||
name: IndexNames.Product,
|
||||
value: product,
|
||||
},
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
pageToken
|
||||
);
|
||||
|
||||
return { data: transformConnections(connections.data), pageToken };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -86,11 +86,20 @@ const oidc = {
|
|||
|
||||
record.clientSecret = connectionClientSecret;
|
||||
|
||||
await connectionStore.put(record.clientID, record, {
|
||||
// secondary index on tenant + product
|
||||
name: IndexNames.TenantProduct,
|
||||
value: dbutils.keyFromParts(tenant, product),
|
||||
});
|
||||
await connectionStore.put(
|
||||
record.clientID,
|
||||
record,
|
||||
{
|
||||
// secondary index on tenant + product
|
||||
name: IndexNames.TenantProduct,
|
||||
value: dbutils.keyFromParts(tenant, product),
|
||||
},
|
||||
{
|
||||
// secondary index on product
|
||||
name: IndexNames.Product,
|
||||
value: product,
|
||||
}
|
||||
);
|
||||
|
||||
return record as OIDCSSORecord;
|
||||
},
|
||||
|
@ -187,11 +196,20 @@ const oidc = {
|
|||
record['deactivated'] = body.deactivated;
|
||||
}
|
||||
|
||||
await connectionStore.put(clientInfo?.clientID, record, {
|
||||
// secondary index on tenant + product
|
||||
name: IndexNames.TenantProduct,
|
||||
value: dbutils.keyFromParts(_savedConnection.tenant, _savedConnection.product),
|
||||
});
|
||||
await connectionStore.put(
|
||||
clientInfo?.clientID,
|
||||
record,
|
||||
{
|
||||
// secondary index on tenant + product
|
||||
name: IndexNames.TenantProduct,
|
||||
value: dbutils.keyFromParts(_savedConnection.tenant, _savedConnection.product),
|
||||
},
|
||||
{
|
||||
// secondary index on product
|
||||
name: IndexNames.Product,
|
||||
value: _savedConnection.product,
|
||||
}
|
||||
);
|
||||
|
||||
return record;
|
||||
},
|
||||
|
|
|
@ -163,6 +163,11 @@ const saml = {
|
|||
// secondary index on tenant + product
|
||||
name: IndexNames.TenantProduct,
|
||||
value: dbutils.keyFromParts(tenant, product),
|
||||
},
|
||||
{
|
||||
// secondary index on product
|
||||
name: IndexNames.Product,
|
||||
value: product,
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -281,6 +286,11 @@ const saml = {
|
|||
// secondary index on tenant + product
|
||||
name: IndexNames.TenantProduct,
|
||||
value: dbutils.keyFromParts(_savedConnection.tenant, _savedConnection.product),
|
||||
},
|
||||
{
|
||||
// secondary index on product
|
||||
name: IndexNames.Product,
|
||||
value: _savedConnection.product,
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -110,7 +110,7 @@ export class SAMLHandler {
|
|||
...originalParams,
|
||||
});
|
||||
|
||||
return { redirectUrl: `${url.toString()}?${params.toString()}` };
|
||||
return { redirectUrl: `${url}?${params}` };
|
||||
}
|
||||
|
||||
// IdP initiated flow
|
||||
|
@ -119,7 +119,7 @@ export class SAMLHandler {
|
|||
entityId,
|
||||
});
|
||||
|
||||
const postForm = saml.createPostForm(`${this.opts.idpDiscoveryPath}?${params.toString()}`, [
|
||||
const postForm = saml.createPostForm(`${this.opts.idpDiscoveryPath}?${params}`, [
|
||||
{
|
||||
name: 'SAMLResponse',
|
||||
value: originalParams.SAMLResponse,
|
||||
|
|
|
@ -26,6 +26,7 @@ export enum IndexNames {
|
|||
Service = 'service',
|
||||
OIDCProviderClientID = 'OIDCProviderClientID',
|
||||
SSOClientID = 'SSOClientID',
|
||||
Product = 'product',
|
||||
}
|
||||
|
||||
// The namespace prefix for the database store
|
||||
|
@ -36,6 +37,7 @@ export const storeNamespacePrefix = {
|
|||
users: 'dsync:users',
|
||||
groups: 'dsync:groups',
|
||||
members: 'dsync:members',
|
||||
providers: 'dsync:providers',
|
||||
},
|
||||
saml: {
|
||||
config: 'saml:config',
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import type { DatabaseStore, JacksonOption, IEventController } 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 type { DatabaseStore, JacksonOption, IEventController, EventCallback } from '../typings';
|
||||
import { DirectoryConfig } from './scim/DirectoryConfig';
|
||||
import { DirectoryUsers } from './scim/DirectoryUsers';
|
||||
import { DirectoryGroups } from './scim/DirectoryGroups';
|
||||
import { Users } from './scim/Users';
|
||||
import { Groups } from './scim/Groups';
|
||||
import { getDirectorySyncProviders } from './scim/utils';
|
||||
import { RequestHandler } from './request';
|
||||
import { handleEventCallback } from './events';
|
||||
import { WebhookEventsLogger } from './WebhookEventsLogger';
|
||||
import { handleEventCallback } from './scim/events';
|
||||
import { WebhookEventsLogger } from './scim/WebhookEventsLogger';
|
||||
import { newGoogleProvider } from './non-scim/google';
|
||||
import { startSync } from './non-scim';
|
||||
|
||||
const directorySync = async (params: {
|
||||
db: DatabaseStore;
|
||||
|
@ -23,18 +25,31 @@ const directorySync = async (params: {
|
|||
|
||||
const directoryUsers = new DirectoryUsers({ directories, users });
|
||||
const directoryGroups = new DirectoryGroups({ directories, users, groups });
|
||||
const requestHandler = new RequestHandler(directoryUsers, directoryGroups);
|
||||
|
||||
// Fetch the supported providers
|
||||
const getProviders = () => {
|
||||
return getDirectorySyncProviders();
|
||||
};
|
||||
|
||||
const googleProvider = newGoogleProvider({ directories, opts });
|
||||
|
||||
return {
|
||||
users,
|
||||
groups,
|
||||
directories,
|
||||
webhookLogs: logger,
|
||||
requests: new RequestHandler(directoryUsers, directoryGroups),
|
||||
requests: requestHandler,
|
||||
providers: getProviders,
|
||||
events: {
|
||||
callback: await handleEventCallback(directories, logger),
|
||||
},
|
||||
providers: () => {
|
||||
return getDirectorySyncProviders();
|
||||
google: googleProvider.oauth,
|
||||
sync: async (callback: EventCallback) => {
|
||||
return await startSync(
|
||||
{ userController: users, groupController: groups, opts, directories, requestHandler },
|
||||
callback
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -0,0 +1,179 @@
|
|||
import { google } from 'googleapis';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
|
||||
import type {
|
||||
Directory,
|
||||
IDirectoryConfig,
|
||||
Group,
|
||||
GroupMember,
|
||||
IDirectoryProvider,
|
||||
JacksonOption,
|
||||
PaginationParams,
|
||||
} from '../../../typings';
|
||||
|
||||
interface GoogleProviderParams {
|
||||
opts: JacksonOption;
|
||||
directories: IDirectoryConfig;
|
||||
}
|
||||
|
||||
export class GoogleProvider implements IDirectoryProvider {
|
||||
opts: JacksonOption;
|
||||
directories: IDirectoryConfig;
|
||||
groupFieldsToExcludeWhenCompare = ['etag'];
|
||||
userFieldsToExcludeWhenCompare = ['etag', 'lastLoginTime', 'thumbnailPhotoEtag'];
|
||||
|
||||
constructor({ directories, opts }: GoogleProviderParams) {
|
||||
this.opts = opts;
|
||||
this.directories = directories;
|
||||
}
|
||||
|
||||
createOAuth2Client(directory: Directory) {
|
||||
const googleProvider = this.opts.dsync?.providers.google;
|
||||
|
||||
const authClient = new OAuth2Client(
|
||||
googleProvider?.clientId,
|
||||
googleProvider?.clientSecret,
|
||||
googleProvider?.callbackUrl
|
||||
);
|
||||
|
||||
authClient.setCredentials({
|
||||
access_token: directory.google_access_token,
|
||||
refresh_token: directory.google_refresh_token,
|
||||
});
|
||||
|
||||
return authClient;
|
||||
}
|
||||
|
||||
async getDirectories() {
|
||||
const { data: directories } = await this.directories.filterBy({
|
||||
provider: 'google',
|
||||
});
|
||||
|
||||
if (!directories || directories.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return directories.filter((directory) => {
|
||||
return (
|
||||
directory.google_access_token && directory.google_refresh_token && directory.google_domain !== ''
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async getUsers(directory: Directory, options: PaginationParams | null) {
|
||||
const query = {
|
||||
maxResults: 200,
|
||||
domain: directory.google_domain,
|
||||
};
|
||||
|
||||
if (options?.pageToken) {
|
||||
query['pageToken'] = options.pageToken;
|
||||
}
|
||||
|
||||
const googleAdmin = google.admin({ version: 'directory_v1', auth: this.createOAuth2Client(directory) });
|
||||
|
||||
const response = await googleAdmin.users.list(query);
|
||||
|
||||
if (!response.data.users) {
|
||||
return {
|
||||
data: [],
|
||||
metadata: null,
|
||||
};
|
||||
}
|
||||
|
||||
const users = response.data.users.map((user) => {
|
||||
return {
|
||||
id: user.id as string,
|
||||
email: user.primaryEmail as string,
|
||||
first_name: user.name?.givenName as string,
|
||||
last_name: user.name?.familyName as string,
|
||||
active: !user.suspended,
|
||||
raw: user,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
data: users,
|
||||
metadata: {
|
||||
nextPageToken: response.data.nextPageToken,
|
||||
hasNextPage: !!response.data.nextPageToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getGroups(directory: Directory, options: PaginationParams | null) {
|
||||
const googleAdmin = google.admin({ version: 'directory_v1', auth: this.createOAuth2Client(directory) });
|
||||
|
||||
const query = {
|
||||
maxResults: 200,
|
||||
domain: directory.google_domain,
|
||||
};
|
||||
|
||||
if (options?.pageToken) {
|
||||
query['pageToken'] = options.pageToken;
|
||||
}
|
||||
|
||||
const response = await googleAdmin.groups.list(query);
|
||||
|
||||
if (!response.data.groups) {
|
||||
return {
|
||||
data: [],
|
||||
metadata: null,
|
||||
};
|
||||
}
|
||||
|
||||
const groups = response.data.groups.map((group) => {
|
||||
return {
|
||||
id: group.id as string,
|
||||
name: group.name as string,
|
||||
raw: group,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
data: groups,
|
||||
metadata: {
|
||||
pageToken: response.data.nextPageToken as string,
|
||||
hasNextPage: !!response.data.nextPageToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getGroupMembers(directory: Directory, group: Group) {
|
||||
const googleAdmin = google.admin({ version: 'directory_v1', auth: this.createOAuth2Client(directory) });
|
||||
|
||||
const allMembers: GroupMember[] = [];
|
||||
const query = {
|
||||
maxResults: 200,
|
||||
groupKey: group.id,
|
||||
domain: directory.google_domain,
|
||||
};
|
||||
|
||||
let nextPageToken: string | undefined | null = null;
|
||||
|
||||
do {
|
||||
if (nextPageToken) {
|
||||
query['pageToken'] = nextPageToken;
|
||||
}
|
||||
|
||||
const response = await googleAdmin.members.list(query);
|
||||
|
||||
if (!response.data.members) {
|
||||
break;
|
||||
}
|
||||
|
||||
const members = response.data.members.map((user) => {
|
||||
return {
|
||||
id: user.id as string,
|
||||
raw: user,
|
||||
};
|
||||
});
|
||||
|
||||
allMembers.push(...members);
|
||||
|
||||
nextPageToken = response.data.nextPageToken;
|
||||
} while (nextPageToken);
|
||||
|
||||
return allMembers;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import { GoogleAuth } from './oauth';
|
||||
import { GoogleProvider } from './api';
|
||||
import type { IDirectoryConfig, JacksonOption } from '../../../typings';
|
||||
|
||||
interface NewGoogleProviderParams {
|
||||
directories: IDirectoryConfig;
|
||||
opts: JacksonOption;
|
||||
}
|
||||
|
||||
export const newGoogleProvider = (params: NewGoogleProviderParams) => {
|
||||
const { directories, opts } = params;
|
||||
|
||||
return {
|
||||
directory: new GoogleProvider({ opts, directories }),
|
||||
oauth: new GoogleAuth({ opts, directories }),
|
||||
};
|
||||
};
|
|
@ -0,0 +1,131 @@
|
|||
import { OAuth2Client, Credentials } from 'google-auth-library';
|
||||
|
||||
import { JacksonError, apiError } from '../../../controller/error';
|
||||
import type { Directory, IDirectoryConfig, JacksonOption, Response } from '../../../typings';
|
||||
|
||||
const scope = [
|
||||
'https://www.googleapis.com/auth/admin.directory.user.readonly',
|
||||
'https://www.googleapis.com/auth/admin.directory.group.readonly',
|
||||
'https://www.googleapis.com/auth/admin.directory.group.member.readonly',
|
||||
];
|
||||
|
||||
interface GoogleAuthParams {
|
||||
opts: JacksonOption;
|
||||
directories: IDirectoryConfig;
|
||||
}
|
||||
|
||||
export class GoogleAuth {
|
||||
private opts: JacksonOption;
|
||||
private directories: IDirectoryConfig;
|
||||
|
||||
constructor({ directories, opts }: GoogleAuthParams) {
|
||||
this.opts = opts;
|
||||
this.directories = directories;
|
||||
}
|
||||
|
||||
createOAuth2Client(directory: Directory) {
|
||||
const googleProvider = this.opts.dsync?.providers.google;
|
||||
|
||||
const authClient = new OAuth2Client(
|
||||
googleProvider?.clientId,
|
||||
googleProvider?.clientSecret,
|
||||
googleProvider?.callbackUrl
|
||||
);
|
||||
|
||||
authClient.setCredentials({
|
||||
access_token: directory.google_access_token,
|
||||
refresh_token: directory.google_refresh_token,
|
||||
});
|
||||
|
||||
return authClient;
|
||||
}
|
||||
|
||||
// Generate the Google authorization URL
|
||||
async generateAuthorizationUrl(params: {
|
||||
directoryId: string;
|
||||
}): Promise<Response<{ authorizationUrl: string }>> {
|
||||
const { directoryId } = params;
|
||||
|
||||
try {
|
||||
const { data: directory, error } = await this.directories.get(directoryId);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (directory?.type !== 'google') {
|
||||
throw new JacksonError('Directory is not a Google Directory', 400);
|
||||
}
|
||||
|
||||
const oauth2Client = this.createOAuth2Client(directory);
|
||||
|
||||
const authorizationUrl = oauth2Client.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
scope,
|
||||
state: JSON.stringify({ directoryId }),
|
||||
});
|
||||
|
||||
const data = {
|
||||
authorizationUrl,
|
||||
};
|
||||
|
||||
return { data, error: null };
|
||||
} catch (error: any) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the Google API access token from the authorization code
|
||||
async getAccessToken(params: { directoryId: string; code: string }): Promise<Response<Credentials>> {
|
||||
const { directoryId, code } = params;
|
||||
|
||||
try {
|
||||
const { data: directory, error } = await this.directories.get(directoryId);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const oauth2Client = this.createOAuth2Client(directory);
|
||||
|
||||
const { tokens } = await oauth2Client.getToken(code);
|
||||
|
||||
return { data: tokens, error: null };
|
||||
} catch (error: any) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Set the Google API access token and refresh token for the directory
|
||||
async setToken(params: {
|
||||
directoryId: string;
|
||||
accessToken: Credentials['access_token'];
|
||||
refreshToken: Credentials['refresh_token'];
|
||||
}): Promise<Response<Directory>> {
|
||||
const { directoryId, accessToken, refreshToken } = params;
|
||||
|
||||
try {
|
||||
if (!accessToken) {
|
||||
throw new JacksonError(`Access token is required`, 400);
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new JacksonError(`Refresh token is required`, 400);
|
||||
}
|
||||
|
||||
const { data } = await this.directories.update(directoryId, {
|
||||
google_access_token: accessToken,
|
||||
google_refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
throw new JacksonError('Failed to update directory', 400);
|
||||
}
|
||||
|
||||
return { data, error: null };
|
||||
} catch (error: any) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import { newGoogleProvider } from './google';
|
||||
import type {
|
||||
IDirectoryConfig,
|
||||
IUsers,
|
||||
IGroups,
|
||||
IRequestHandler,
|
||||
JacksonOption,
|
||||
EventCallback,
|
||||
} from '../../typings';
|
||||
import { SyncUsers } from './syncUsers';
|
||||
import { SyncGroups } from './syncGroups';
|
||||
import { SyncGroupMembers } from './syncGroupMembers';
|
||||
|
||||
interface SyncParams {
|
||||
userController: IUsers;
|
||||
groupController: IGroups;
|
||||
opts: JacksonOption;
|
||||
directories: IDirectoryConfig;
|
||||
requestHandler: IRequestHandler;
|
||||
}
|
||||
|
||||
// Method to start the directory sync process
|
||||
// This method will be called by the directory sync cron job
|
||||
export const startSync = async (params: SyncParams, callback: EventCallback) => {
|
||||
const { userController, groupController, opts, directories, requestHandler } = params;
|
||||
|
||||
const { directory: provider } = newGoogleProvider({ directories, opts });
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
console.info('Starting the sync process');
|
||||
|
||||
const allDirectories = await provider.getDirectories();
|
||||
|
||||
if (allDirectories.length === 0) {
|
||||
console.info('No directories found. Skipping the sync process');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const directory of allDirectories) {
|
||||
const params = {
|
||||
directory,
|
||||
userController,
|
||||
groupController,
|
||||
provider,
|
||||
requestHandler,
|
||||
callback,
|
||||
};
|
||||
|
||||
await new SyncUsers(params).sync();
|
||||
await new SyncGroups(params).sync();
|
||||
await new SyncGroupMembers(params).sync();
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
|
||||
console.info(`Sync process completed in ${(endTime - startTime) / 1000} seconds`);
|
||||
};
|
|
@ -0,0 +1,138 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import type {
|
||||
Directory,
|
||||
IGroups,
|
||||
Group,
|
||||
IRequestHandler,
|
||||
DirectorySyncRequest,
|
||||
EventCallback,
|
||||
IDirectoryProvider,
|
||||
PaginationParams,
|
||||
GroupMembership,
|
||||
} from '../../typings';
|
||||
import {
|
||||
compareAndFindDeletedMembers,
|
||||
compareAndFindNewMembers,
|
||||
toGroupMembershipSCIMPayload,
|
||||
} from './utils';
|
||||
|
||||
interface SyncGroupMembersParams {
|
||||
groupController: IGroups;
|
||||
provider: IDirectoryProvider;
|
||||
requestHandler: IRequestHandler;
|
||||
callback: EventCallback;
|
||||
directory: Directory;
|
||||
}
|
||||
|
||||
type HandleRequestParams = Pick<DirectorySyncRequest, 'method' | 'body' | 'resourceId'>;
|
||||
|
||||
export class SyncGroupMembers {
|
||||
private groupController: IGroups;
|
||||
private provider: IDirectoryProvider;
|
||||
private requestHandler: IRequestHandler;
|
||||
private callback: EventCallback;
|
||||
private directory: Directory;
|
||||
|
||||
constructor({ directory, groupController, requestHandler, provider, callback }: SyncGroupMembersParams) {
|
||||
this.groupController = groupController;
|
||||
this.provider = provider;
|
||||
this.requestHandler = requestHandler;
|
||||
this.callback = callback;
|
||||
this.directory = directory;
|
||||
}
|
||||
|
||||
async sync() {
|
||||
let nextPageOption: PaginationParams | null = null;
|
||||
|
||||
do {
|
||||
const { data: groups, metadata } = await this.provider.getGroups(this.directory, nextPageOption);
|
||||
|
||||
if (!groups || groups.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
const membersFromDB = await this.getAllExistingMembers(group);
|
||||
const membersFromProvider = await this.provider.getGroupMembers(this.directory, group);
|
||||
|
||||
const idsFromDB = _.map(membersFromDB, 'user_id');
|
||||
const idsFromProvider = _.map(membersFromProvider, 'id');
|
||||
|
||||
const deletedMembers = compareAndFindDeletedMembers(idsFromDB, idsFromProvider);
|
||||
const newMembers = compareAndFindNewMembers(idsFromDB, idsFromProvider);
|
||||
|
||||
if (deletedMembers && deletedMembers.length > 0) {
|
||||
await this.deleteMembers(group, deletedMembers);
|
||||
}
|
||||
|
||||
if (newMembers && newMembers.length > 0) {
|
||||
await this.addMembers(group, newMembers);
|
||||
}
|
||||
}
|
||||
|
||||
nextPageOption = metadata;
|
||||
} while (nextPageOption && nextPageOption.hasNextPage);
|
||||
}
|
||||
|
||||
// Get all existing members for a group from the Jackson store
|
||||
async getAllExistingMembers(group: Group) {
|
||||
const existingMembers: GroupMembership['user_id'][] = [];
|
||||
const pageLimit = 500;
|
||||
let pageOffset = 0;
|
||||
|
||||
while (true as boolean) {
|
||||
const { data: members } = await this.groupController
|
||||
.setTenantAndProduct(this.directory.tenant, this.directory.product)
|
||||
.getGroupMembers({
|
||||
groupId: group.id,
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
});
|
||||
|
||||
if (!members || members.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
existingMembers.push(...members);
|
||||
|
||||
if (members.length < pageLimit) {
|
||||
break;
|
||||
}
|
||||
|
||||
pageOffset += pageLimit;
|
||||
}
|
||||
|
||||
return existingMembers;
|
||||
}
|
||||
|
||||
async addMembers(group: Group, memberIds: string[]) {
|
||||
await this.handleRequest({
|
||||
method: 'PATCH',
|
||||
body: toGroupMembershipSCIMPayload(memberIds, 'add'),
|
||||
resourceId: group.id,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMembers(group: Group, memberIds: string[]) {
|
||||
await this.handleRequest({
|
||||
method: 'PATCH',
|
||||
body: toGroupMembershipSCIMPayload(memberIds, 'remove'),
|
||||
resourceId: group.id,
|
||||
});
|
||||
}
|
||||
|
||||
async handleRequest(payload: HandleRequestParams) {
|
||||
const request: DirectorySyncRequest = {
|
||||
query: {},
|
||||
body: payload.body,
|
||||
resourceType: 'groups',
|
||||
method: payload.method,
|
||||
directoryId: this.directory.id,
|
||||
apiSecret: this.directory.scim.secret,
|
||||
resourceId: payload.resourceId,
|
||||
};
|
||||
|
||||
await this.requestHandler.handle(request, this.callback);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,147 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import type {
|
||||
Directory,
|
||||
IGroups,
|
||||
Group,
|
||||
IRequestHandler,
|
||||
DirectorySyncRequest,
|
||||
EventCallback,
|
||||
IDirectoryProvider,
|
||||
PaginationParams,
|
||||
} from '../../typings';
|
||||
import { compareAndFindDeletedGroups, isGroupUpdated, toGroupSCIMPayload } from './utils';
|
||||
|
||||
interface SyncGroupsParams {
|
||||
groupController: IGroups;
|
||||
provider: IDirectoryProvider;
|
||||
requestHandler: IRequestHandler;
|
||||
callback: EventCallback;
|
||||
directory: Directory;
|
||||
}
|
||||
|
||||
type HandleRequestParams = Pick<DirectorySyncRequest, 'method' | 'body' | 'resourceId'>;
|
||||
|
||||
export class SyncGroups {
|
||||
private groupController: IGroups;
|
||||
private provider: IDirectoryProvider;
|
||||
private requestHandler: IRequestHandler;
|
||||
private callback: EventCallback;
|
||||
private directory: Directory;
|
||||
|
||||
constructor({ directory, groupController, callback, requestHandler, provider }: SyncGroupsParams) {
|
||||
this.groupController = groupController;
|
||||
this.provider = provider;
|
||||
this.requestHandler = requestHandler;
|
||||
this.callback = callback;
|
||||
this.directory = directory;
|
||||
}
|
||||
|
||||
async sync() {
|
||||
const groupsFromProvider: Group[] = [];
|
||||
let nextPageOption: PaginationParams | null = null;
|
||||
|
||||
do {
|
||||
const { data: groups, metadata } = await this.provider.getGroups(this.directory, nextPageOption);
|
||||
|
||||
if (!groups || groups.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Create or update groups
|
||||
for (const group of groups) {
|
||||
const { data: existingGroup } = await this.groupController
|
||||
.setTenantAndProduct(this.directory.tenant, this.directory.product)
|
||||
.get(group.id);
|
||||
|
||||
if (!existingGroup) {
|
||||
await this.createGroup(group);
|
||||
} else if (isGroupUpdated(existingGroup, group, this.provider.groupFieldsToExcludeWhenCompare)) {
|
||||
await this.updateGroup(group);
|
||||
}
|
||||
}
|
||||
|
||||
// Store groups to compare and delete later
|
||||
groupsFromProvider.push(...groups);
|
||||
|
||||
nextPageOption = metadata;
|
||||
} while (nextPageOption && nextPageOption.hasNextPage);
|
||||
|
||||
// Delete users that are not in the directory anymore
|
||||
const existingGroups = await this.getAllExistingGroups();
|
||||
const groupsToDelete = compareAndFindDeletedGroups(existingGroups, groupsFromProvider);
|
||||
|
||||
await this.deleteGroups(groupsToDelete);
|
||||
}
|
||||
|
||||
// Get all the existing groups from the Jackson store
|
||||
async getAllExistingGroups() {
|
||||
const existingGroups: Group[] = [];
|
||||
const pageLimit = 500;
|
||||
let pageOffset = 0;
|
||||
|
||||
while (true as boolean) {
|
||||
const { data: groups } = await this.groupController
|
||||
.setTenantAndProduct(this.directory.tenant, this.directory.product)
|
||||
.getAll({
|
||||
directoryId: this.directory.id,
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
});
|
||||
|
||||
if (!groups || groups.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
existingGroups.push(...groups);
|
||||
|
||||
if (groups.length < pageLimit) {
|
||||
break;
|
||||
}
|
||||
|
||||
pageOffset += pageLimit;
|
||||
}
|
||||
|
||||
return existingGroups;
|
||||
}
|
||||
|
||||
async createGroup(group: Group) {
|
||||
await this.handleRequest({
|
||||
method: 'POST',
|
||||
body: toGroupSCIMPayload(group),
|
||||
resourceId: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async updateGroup(group: Group) {
|
||||
await this.handleRequest({
|
||||
method: 'PUT',
|
||||
body: toGroupSCIMPayload(group),
|
||||
resourceId: group.id,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteGroups(groups: Group[]) {
|
||||
for (const group of groups) {
|
||||
await this.handleRequest({
|
||||
method: 'DELETE',
|
||||
body: toGroupSCIMPayload(group),
|
||||
resourceId: group.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleRequest(payload: HandleRequestParams) {
|
||||
const request: DirectorySyncRequest = {
|
||||
query: {},
|
||||
body: payload.body,
|
||||
resourceType: 'groups',
|
||||
method: payload.method,
|
||||
directoryId: this.directory.id,
|
||||
apiSecret: this.directory.scim.secret,
|
||||
resourceId: payload.resourceId,
|
||||
};
|
||||
|
||||
await this.requestHandler.handle(request, this.callback);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,147 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import type {
|
||||
Directory,
|
||||
User,
|
||||
IUsers,
|
||||
IRequestHandler,
|
||||
DirectorySyncRequest,
|
||||
EventCallback,
|
||||
IDirectoryProvider,
|
||||
PaginationParams,
|
||||
} from '../../typings';
|
||||
import { compareAndFindDeletedUsers, isUserUpdated, toUserSCIMPayload } from './utils';
|
||||
|
||||
interface SyncUserParams {
|
||||
directory: Directory;
|
||||
userController: IUsers;
|
||||
callback: EventCallback;
|
||||
provider: IDirectoryProvider;
|
||||
requestHandler: IRequestHandler;
|
||||
}
|
||||
|
||||
type HandleRequestParams = Pick<DirectorySyncRequest, 'method' | 'body' | 'resourceId'>;
|
||||
|
||||
export class SyncUsers {
|
||||
private directory: Directory;
|
||||
private userController: IUsers;
|
||||
private callback: EventCallback;
|
||||
private provider: IDirectoryProvider;
|
||||
private requestHandler: IRequestHandler;
|
||||
|
||||
constructor({ directory, userController, callback, provider, requestHandler }: SyncUserParams) {
|
||||
this.callback = callback;
|
||||
this.provider = provider;
|
||||
this.directory = directory;
|
||||
this.requestHandler = requestHandler;
|
||||
this.userController = userController;
|
||||
}
|
||||
|
||||
async sync() {
|
||||
const usersFromProvider: User[] = [];
|
||||
let nextPageOption: PaginationParams | null = null;
|
||||
|
||||
do {
|
||||
const { data: users, metadata } = await this.provider.getUsers(this.directory, nextPageOption);
|
||||
|
||||
if (users.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Create or update users
|
||||
for (const user of users) {
|
||||
const { data: existingUser } = await this.userController
|
||||
.setTenantAndProduct(this.directory.tenant, this.directory.product)
|
||||
.get(user.id);
|
||||
|
||||
if (!existingUser) {
|
||||
await this.createUser(user);
|
||||
} else if (isUserUpdated(existingUser, user, this.provider.userFieldsToExcludeWhenCompare)) {
|
||||
await this.updateUser(user);
|
||||
}
|
||||
}
|
||||
|
||||
// Store users to compare and delete later
|
||||
usersFromProvider.push(...users);
|
||||
|
||||
nextPageOption = metadata;
|
||||
} while (nextPageOption && nextPageOption.hasNextPage);
|
||||
|
||||
// Delete users that are not in the directory anymore
|
||||
const existingUsers = await this.getAllExistingUsers();
|
||||
const usersToDelete = compareAndFindDeletedUsers(existingUsers, usersFromProvider);
|
||||
|
||||
await this.deleteUsers(usersToDelete);
|
||||
}
|
||||
|
||||
// Get all the existing users from the Jackson store
|
||||
async getAllExistingUsers() {
|
||||
const existingUsers: User[] = [];
|
||||
const pageLimit = 500;
|
||||
let pageOffset = 0;
|
||||
|
||||
while (true as boolean) {
|
||||
const { data: users } = await this.userController
|
||||
.setTenantAndProduct(this.directory.tenant, this.directory.product)
|
||||
.getAll({
|
||||
directoryId: this.directory.id,
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
});
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
existingUsers.push(...users);
|
||||
|
||||
if (users.length < pageLimit) {
|
||||
break;
|
||||
}
|
||||
|
||||
pageOffset += pageLimit;
|
||||
}
|
||||
|
||||
return existingUsers;
|
||||
}
|
||||
|
||||
async createUser(user: User) {
|
||||
await this.handleRequest({
|
||||
method: 'POST',
|
||||
body: toUserSCIMPayload(user),
|
||||
resourceId: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async updateUser(user: User) {
|
||||
await this.handleRequest({
|
||||
method: 'PUT',
|
||||
body: toUserSCIMPayload(user),
|
||||
resourceId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUsers(users: User[]) {
|
||||
for (const user of users) {
|
||||
await this.handleRequest({
|
||||
method: 'DELETE',
|
||||
body: toUserSCIMPayload(user),
|
||||
resourceId: user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleRequest(payload: HandleRequestParams) {
|
||||
const request: DirectorySyncRequest = {
|
||||
query: {},
|
||||
body: payload.body,
|
||||
resourceType: 'users',
|
||||
method: payload.method,
|
||||
directoryId: this.directory.id,
|
||||
apiSecret: this.directory.scim.secret,
|
||||
resourceId: payload.resourceId,
|
||||
};
|
||||
|
||||
await this.requestHandler.handle(request, this.callback);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,138 @@
|
|||
import _ from 'lodash';
|
||||
import crypto from 'crypto';
|
||||
|
||||
import type { User, Group } from '../../typings';
|
||||
|
||||
export const toUserSCIMPayload = (user: User) => {
|
||||
return {
|
||||
userName: user.email,
|
||||
name: {
|
||||
givenName: user.first_name,
|
||||
familyName: user.last_name,
|
||||
},
|
||||
emails: [
|
||||
{
|
||||
primary: true,
|
||||
value: user.email,
|
||||
type: 'work',
|
||||
},
|
||||
],
|
||||
userId: user.id,
|
||||
active: user.active,
|
||||
rawAttributes: user.raw,
|
||||
};
|
||||
};
|
||||
|
||||
export const toGroupSCIMPayload = (group: Group) => {
|
||||
return {
|
||||
displayName: group.name,
|
||||
groupId: group.id,
|
||||
rawAttributes: group.raw,
|
||||
};
|
||||
};
|
||||
|
||||
export const toGroupMembershipSCIMPayload = (memberIds: string[], operation: 'add' | 'remove') => {
|
||||
const memberValues = memberIds.map((memberId) => {
|
||||
return {
|
||||
value: memberId,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
Operations: [
|
||||
{
|
||||
op: operation,
|
||||
path: 'members',
|
||||
value: memberValues,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const isUserUpdated = (
|
||||
existingUser: User,
|
||||
userFromProvider: User,
|
||||
ignoreFields: string[] | undefined
|
||||
) => {
|
||||
const copyOfExistingUser = _.cloneDeep(existingUser);
|
||||
const copyOfUserFromProvider = _.cloneDeep(userFromProvider);
|
||||
|
||||
if (ignoreFields && ignoreFields.length > 0) {
|
||||
ignoreFields.forEach((field) => {
|
||||
_.unset(copyOfExistingUser.raw, field);
|
||||
_.unset(copyOfUserFromProvider.raw, field);
|
||||
});
|
||||
}
|
||||
|
||||
return getObjectHash(copyOfExistingUser.raw) !== getObjectHash(copyOfUserFromProvider.raw);
|
||||
};
|
||||
|
||||
export const isGroupUpdated = (
|
||||
existingGroup: Group,
|
||||
groupFromProvider: Group,
|
||||
ignoreFields: string[] | undefined
|
||||
) => {
|
||||
const copyOfExistingGroup = _.cloneDeep(existingGroup);
|
||||
const copyOfGroupFromProvider = _.cloneDeep(groupFromProvider);
|
||||
|
||||
if (ignoreFields && ignoreFields.length > 0) {
|
||||
ignoreFields.forEach((field) => {
|
||||
_.unset(copyOfExistingGroup.raw, field);
|
||||
_.unset(copyOfGroupFromProvider.raw, field);
|
||||
});
|
||||
}
|
||||
|
||||
return getObjectHash(copyOfExistingGroup.raw) !== getObjectHash(copyOfGroupFromProvider.raw);
|
||||
};
|
||||
|
||||
export const compareAndFindDeletedGroups = (existingGroups: Group[] | null, groups: Group[]) => {
|
||||
if (!existingGroups || existingGroups.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groupsToDelete = existingGroups.filter((existingGroup) => {
|
||||
return !groups.some((group) => group.id === existingGroup.id);
|
||||
});
|
||||
|
||||
return groupsToDelete;
|
||||
};
|
||||
|
||||
export const compareAndFindDeletedUsers = (existingUsers: User[] | null, users: User[]) => {
|
||||
if (!existingUsers || existingUsers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const usersToDelete = existingUsers.filter((existingUser) => {
|
||||
return !users.some((user) => user.id === existingUser.id);
|
||||
});
|
||||
|
||||
return usersToDelete;
|
||||
};
|
||||
|
||||
export const compareAndFindDeletedMembers = (idsFromDB: string[], idsFromProvider: string[]) => {
|
||||
return idsFromDB.filter((userId) => !idsFromProvider.includes(userId));
|
||||
};
|
||||
|
||||
export const compareAndFindNewMembers = (idsFromDB: string[], idsFromProvider: string[]) => {
|
||||
return idsFromProvider.filter((userId) => !idsFromDB.includes(userId));
|
||||
};
|
||||
|
||||
const normalizeObject = (obj: any) => {
|
||||
if (_.isArray(obj)) {
|
||||
return obj.map(normalizeObject);
|
||||
} else if (_.isObject(obj)) {
|
||||
const sortedKeys = _.sortBy(Object.keys(obj));
|
||||
return _.fromPairs(sortedKeys.map((key) => [key, normalizeObject(obj[key])]));
|
||||
} else {
|
||||
return obj;
|
||||
}
|
||||
};
|
||||
|
||||
const getObjectHash = (obj: any) => {
|
||||
const normalizedObj = normalizeObject(obj);
|
||||
const hash = crypto.createHash('sha1');
|
||||
|
||||
hash.update(JSON.stringify(normalizedObj));
|
||||
|
||||
return hash.digest('hex');
|
||||
};
|
|
@ -1,6 +1,5 @@
|
|||
import type { Storable, DatabaseStore } from '../typings';
|
||||
import { storeNamespacePrefix } from '../controller/utils';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { Storable, DatabaseStore } from '../../typings';
|
||||
import { storeNamespacePrefix } from '../../controller/utils';
|
||||
|
||||
export class Base {
|
||||
protected db: DatabaseStore;
|
||||
|
@ -21,29 +20,11 @@ export class Base {
|
|||
return this.db.store(`${storeNamespacePrefix.dsync[type]}:${this.tenant}:${this.product}`);
|
||||
}
|
||||
|
||||
setTenant(tenant: string): this {
|
||||
// Set the tenant and product
|
||||
setTenantAndProduct(tenant: string, product: 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 randomUUID();
|
||||
}
|
||||
}
|
|
@ -1,32 +1,43 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type {
|
||||
Storable,
|
||||
Directory,
|
||||
JacksonOption,
|
||||
DatabaseStore,
|
||||
DirectoryType,
|
||||
ApiError,
|
||||
PaginationParams,
|
||||
IUsers,
|
||||
IGroups,
|
||||
IWebhookEventsLogger,
|
||||
IEventController,
|
||||
} from '../typings';
|
||||
import * as dbutils from '../db/utils';
|
||||
import { createRandomSecret, isConnectionActive, validateTenantAndProduct } from '../controller/utils';
|
||||
import { apiError, JacksonError } from '../controller/error';
|
||||
import { storeNamespacePrefix } from '../controller/utils';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { IndexNames } from '../controller/utils';
|
||||
import { getDirectorySyncProviders } from './utils';
|
||||
Response,
|
||||
Index,
|
||||
} from '../../typings';
|
||||
import * as dbutils from '../../db/utils';
|
||||
import {
|
||||
createRandomSecret,
|
||||
isConnectionActive,
|
||||
validateTenantAndProduct,
|
||||
storeNamespacePrefix,
|
||||
IndexNames,
|
||||
} from '../../controller/utils';
|
||||
import { apiError, JacksonError } from '../../controller/error';
|
||||
import { getDirectorySyncProviders, isSCIMEnabledProvider } from './utils';
|
||||
|
||||
type ConstructorParams = {
|
||||
interface DirectoryConfigParams {
|
||||
db: DatabaseStore;
|
||||
opts: JacksonOption;
|
||||
users: IUsers;
|
||||
groups: IGroups;
|
||||
logger: IWebhookEventsLogger;
|
||||
eventController: IEventController;
|
||||
};
|
||||
}
|
||||
|
||||
interface FilterByParams extends PaginationParams {
|
||||
product?: string;
|
||||
provider?: DirectoryType;
|
||||
}
|
||||
|
||||
export class DirectoryConfig {
|
||||
private _store: Storable | null = null;
|
||||
|
@ -37,7 +48,7 @@ export class DirectoryConfig {
|
|||
private logger: IWebhookEventsLogger;
|
||||
private eventController: IEventController;
|
||||
|
||||
constructor({ db, opts, users, groups, logger, eventController }: ConstructorParams) {
|
||||
constructor({ db, opts, users, groups, logger, eventController }: DirectoryConfigParams) {
|
||||
this.opts = opts;
|
||||
this.db = db;
|
||||
this.users = users;
|
||||
|
@ -59,9 +70,22 @@ export class DirectoryConfig {
|
|||
webhook_url?: string;
|
||||
webhook_secret?: string;
|
||||
type?: DirectoryType;
|
||||
}): Promise<{ data: Directory | null; error: ApiError | null }> {
|
||||
google_domain?: string;
|
||||
google_access_token?: string;
|
||||
google_refresh_token?: string;
|
||||
}): Promise<Response<Directory>> {
|
||||
try {
|
||||
const { name, tenant, product, webhook_url, webhook_secret, type = 'generic-scim-v2' } = params;
|
||||
const {
|
||||
name,
|
||||
tenant,
|
||||
product,
|
||||
webhook_url,
|
||||
webhook_secret,
|
||||
type = 'generic-scim-v2',
|
||||
google_domain,
|
||||
google_access_token,
|
||||
google_refresh_token,
|
||||
} = params;
|
||||
|
||||
if (!tenant || !product) {
|
||||
throw new JacksonError('Missing required parameters.', 400);
|
||||
|
@ -77,28 +101,59 @@ export class DirectoryConfig {
|
|||
const directoryName = name || `scim-${tenant}-${product}`;
|
||||
const id = randomUUID();
|
||||
const hasWebhook = webhook_url && webhook_secret;
|
||||
const isSCIMProvider = isSCIMEnabledProvider(type);
|
||||
|
||||
const directory: Directory = {
|
||||
let directory: Directory = {
|
||||
id,
|
||||
name: directoryName,
|
||||
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 : '',
|
||||
},
|
||||
scim: isSCIMProvider
|
||||
? {
|
||||
path: `${this.opts.scimPath}/${id}`,
|
||||
secret: await createRandomSecret(16),
|
||||
}
|
||||
: {
|
||||
path: '',
|
||||
secret: '',
|
||||
},
|
||||
};
|
||||
|
||||
await this.store().put(id, directory, {
|
||||
name: IndexNames.TenantProduct,
|
||||
value: dbutils.keyFromParts(tenant, product),
|
||||
});
|
||||
if (type === 'google') {
|
||||
directory = {
|
||||
...directory,
|
||||
google_domain: google_domain || '',
|
||||
google_access_token: google_access_token || '',
|
||||
google_refresh_token: google_refresh_token || '',
|
||||
};
|
||||
}
|
||||
|
||||
const indexes: Index[] = [
|
||||
{
|
||||
name: IndexNames.TenantProduct,
|
||||
value: dbutils.keyFromParts(tenant, product),
|
||||
},
|
||||
{
|
||||
name: IndexNames.Product,
|
||||
value: product,
|
||||
},
|
||||
];
|
||||
|
||||
// Add secondary index for Non-SCIM providers
|
||||
if (!isSCIMProvider) {
|
||||
indexes.push({
|
||||
name: storeNamespacePrefix.dsync.providers,
|
||||
value: type,
|
||||
});
|
||||
}
|
||||
|
||||
await this.store().put(id, directory, ...indexes);
|
||||
|
||||
const connection = this.transform(directory);
|
||||
|
||||
|
@ -111,7 +166,7 @@ export class DirectoryConfig {
|
|||
}
|
||||
|
||||
// Get the configuration by id
|
||||
public async get(id: string): Promise<{ data: Directory | null; error: ApiError | null }> {
|
||||
public async get(id: string): Promise<Response<Directory>> {
|
||||
try {
|
||||
if (!id) {
|
||||
throw new JacksonError('Missing required parameters.', 400);
|
||||
|
@ -132,60 +187,57 @@ export class DirectoryConfig {
|
|||
// 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 }> {
|
||||
param: Omit<Partial<Directory>, 'id' | 'tenant' | 'prodct' | 'scim' | 'type'>
|
||||
): Promise<Response<Directory>> {
|
||||
try {
|
||||
if (!id) {
|
||||
throw new JacksonError('Missing required parameters.', 400);
|
||||
}
|
||||
|
||||
const { name, log_webhook_events, webhook, type } = param;
|
||||
const {
|
||||
name,
|
||||
log_webhook_events,
|
||||
webhook,
|
||||
deactivated,
|
||||
google_domain,
|
||||
google_access_token,
|
||||
google_refresh_token,
|
||||
} = param;
|
||||
|
||||
let directory: Directory = await this.store().get(id);
|
||||
const directory: Directory = await this.store().get(id);
|
||||
|
||||
if (name) {
|
||||
directory.name = name;
|
||||
}
|
||||
let updatedDirectory: Directory = {
|
||||
...directory,
|
||||
name: name || directory.name,
|
||||
webhook: webhook || directory.webhook,
|
||||
deactivated: deactivated !== undefined ? deactivated : directory.deactivated,
|
||||
log_webhook_events:
|
||||
log_webhook_events !== undefined ? log_webhook_events : directory.log_webhook_events,
|
||||
google_domain: google_domain || directory.google_domain,
|
||||
google_access_token: google_access_token || directory.google_access_token,
|
||||
google_refresh_token: google_refresh_token || directory.google_refresh_token,
|
||||
};
|
||||
|
||||
if (log_webhook_events !== undefined) {
|
||||
directory.log_webhook_events = log_webhook_events;
|
||||
}
|
||||
await this.store().put(id, updatedDirectory);
|
||||
|
||||
if (webhook) {
|
||||
directory.webhook = webhook;
|
||||
}
|
||||
|
||||
if (type) {
|
||||
directory.type = type;
|
||||
}
|
||||
updatedDirectory = this.transform(updatedDirectory);
|
||||
|
||||
if ('deactivated' in param) {
|
||||
directory['deactivated'] = param.deactivated;
|
||||
}
|
||||
|
||||
await this.store().put(id, { ...directory });
|
||||
|
||||
directory = this.transform(directory);
|
||||
|
||||
if ('deactivated' in param) {
|
||||
if (isConnectionActive(directory)) {
|
||||
await this.eventController.notify('dsync.activated', directory);
|
||||
if (isConnectionActive(updatedDirectory)) {
|
||||
await this.eventController.notify('dsync.activated', updatedDirectory);
|
||||
} else {
|
||||
await this.eventController.notify('dsync.deactivated', directory);
|
||||
await this.eventController.notify('dsync.deactivated', updatedDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
return { data: directory, error: null };
|
||||
return { data: updatedDirectory, 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 }> {
|
||||
public async getByTenantAndProduct(tenant: string, product: string): Promise<Response<Directory[]>> {
|
||||
try {
|
||||
if (!tenant || !product) {
|
||||
throw new JacksonError('Missing required parameters.', 400);
|
||||
|
@ -204,12 +256,12 @@ export class DirectoryConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// Get all configurations
|
||||
public async getAll({ pageOffset, pageLimit, pageToken }: PaginationParams = {}): Promise<{
|
||||
data: Directory[] | null;
|
||||
pageToken?: string;
|
||||
error: ApiError | null;
|
||||
}> {
|
||||
// Get directory connections with pagination
|
||||
public async getAll(
|
||||
params: PaginationParams = {}
|
||||
): Promise<Response<Directory[]> & { pageToken?: string }> {
|
||||
const { pageOffset, pageLimit, pageToken } = params;
|
||||
|
||||
try {
|
||||
const { data: directories, pageToken: nextPageToken } = await this.store().getAll(
|
||||
pageOffset,
|
||||
|
@ -232,7 +284,7 @@ export class DirectoryConfig {
|
|||
}
|
||||
|
||||
// Delete a configuration by id
|
||||
public async delete(id: string): Promise<{ data: null; error: ApiError | null }> {
|
||||
public async delete(id: string): Promise<Response<null>> {
|
||||
try {
|
||||
if (!id) {
|
||||
throw new JacksonError('Missing required parameter.', 400);
|
||||
|
@ -267,12 +319,15 @@ export class DirectoryConfig {
|
|||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
if (directory.scim.path) {
|
||||
// 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}`;
|
||||
// Construct the SCIM endpoint
|
||||
directory.scim.endpoint = `${this.opts.externalUrl}${directory.scim.path}`;
|
||||
}
|
||||
|
||||
if (!('deactivated' in directory)) {
|
||||
directory.deactivated = false;
|
||||
|
@ -280,4 +335,47 @@ export class DirectoryConfig {
|
|||
|
||||
return directory;
|
||||
}
|
||||
|
||||
// Filter connections by product or provider
|
||||
public async filterBy(
|
||||
params: FilterByParams = {}
|
||||
): Promise<Response<Directory[]> & { pageToken?: string }> {
|
||||
const { product, provider, pageOffset, pageLimit, pageToken } = params;
|
||||
let index: Index | null = null;
|
||||
|
||||
if (product) {
|
||||
// Filter by product
|
||||
index = {
|
||||
name: IndexNames.Product,
|
||||
value: product,
|
||||
};
|
||||
} else if (provider) {
|
||||
// Filter by provider
|
||||
index = {
|
||||
name: storeNamespacePrefix.dsync.providers,
|
||||
value: provider,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (!index) {
|
||||
throw new JacksonError('Please provider a product or provider.', 400);
|
||||
}
|
||||
|
||||
const { data: directories, pageToken: nextPageToken } = await this.store().getByIndex(
|
||||
index,
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
pageToken
|
||||
);
|
||||
|
||||
return {
|
||||
data: directories.map((directory) => this.transform(directory)),
|
||||
pageToken: nextPageToken,
|
||||
error: null,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,37 +10,36 @@ import type {
|
|||
IUsers,
|
||||
IGroups,
|
||||
GroupPatchOperation,
|
||||
} from '../typings';
|
||||
} from '../../typings';
|
||||
import { parseGroupOperation } from './utils';
|
||||
import { sendEvent } from './events';
|
||||
|
||||
interface DirectoryGroupsParams {
|
||||
directories: IDirectoryConfig;
|
||||
users: IUsers;
|
||||
groups: IGroups;
|
||||
}
|
||||
|
||||
export class DirectoryGroups {
|
||||
private directories: IDirectoryConfig;
|
||||
private users: IUsers;
|
||||
private groups: IGroups;
|
||||
private callback: EventCallback | undefined;
|
||||
|
||||
constructor({
|
||||
directories,
|
||||
users,
|
||||
groups,
|
||||
}: {
|
||||
directories: IDirectoryConfig;
|
||||
users: IUsers;
|
||||
groups: IGroups;
|
||||
}) {
|
||||
constructor({ directories, users, groups }: DirectoryGroupsParams) {
|
||||
this.directories = directories;
|
||||
this.users = users;
|
||||
this.groups = groups;
|
||||
}
|
||||
|
||||
public async create(directory: Directory, body: any): Promise<DirectorySyncResponse> {
|
||||
const { displayName } = body as { displayName: string };
|
||||
const { displayName, groupId } = body as { displayName: string; groupId?: string };
|
||||
|
||||
const { data: group } = await this.groups.create({
|
||||
directoryId: directory.id,
|
||||
name: displayName,
|
||||
raw: { ...body, members: [] },
|
||||
id: groupId,
|
||||
raw: 'rawAttributes' in body ? body.rawAttributes : { ...body, members: [] },
|
||||
});
|
||||
|
||||
await sendEvent('group.created', { directory, group }, this.callback);
|
||||
|
@ -81,7 +80,7 @@ export class DirectoryGroups {
|
|||
groups = data;
|
||||
} else {
|
||||
// Fetch all the existing group
|
||||
const { data } = await this.groups.getAll({ pageOffset: undefined, pageLimit: undefined });
|
||||
const { data } = await this.groups.getAll({ directoryId, pageOffset: undefined, pageLimit: undefined });
|
||||
|
||||
groups = data;
|
||||
}
|
||||
|
@ -136,12 +135,7 @@ export class DirectoryGroups {
|
|||
}
|
||||
|
||||
public async update(directory: Directory, group: Group, body: any): Promise<DirectorySyncResponse> {
|
||||
const { displayName } = body;
|
||||
|
||||
// Update group name
|
||||
const updatedGroup = await this.updateDisplayName(directory, group, {
|
||||
displayName,
|
||||
});
|
||||
const updatedGroup = await this.updateDisplayName(directory, group, body);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
|
@ -167,14 +161,9 @@ export class DirectoryGroups {
|
|||
|
||||
// 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,
|
||||
},
|
||||
name: body.displayName,
|
||||
raw: 'rawAttributes' in body ? body.rawAttributes : { ...group.raw, ...body },
|
||||
});
|
||||
|
||||
if (error || !updatedGroup) {
|
|
@ -8,30 +8,36 @@ import type {
|
|||
IDirectoryConfig,
|
||||
IUsers,
|
||||
UserPatchOperation,
|
||||
} from '../typings';
|
||||
} from '../../typings';
|
||||
import { parseUserPatchRequest, extractStandardUserAttributes, updateRawUserAttributes } from './utils';
|
||||
import { sendEvent } from './events';
|
||||
|
||||
interface DirectoryUsersParams {
|
||||
directories: IDirectoryConfig;
|
||||
users: IUsers;
|
||||
}
|
||||
|
||||
export class DirectoryUsers {
|
||||
private directories: IDirectoryConfig;
|
||||
private users: IUsers;
|
||||
private callback: EventCallback | undefined;
|
||||
|
||||
constructor({ directories, users }: { directories: IDirectoryConfig; users: IUsers }) {
|
||||
constructor({ directories, users }: DirectoryUsersParams) {
|
||||
this.directories = directories;
|
||||
this.users = users;
|
||||
}
|
||||
|
||||
public async create(directory: Directory, body: any): Promise<DirectorySyncResponse> {
|
||||
const { first_name, last_name, email, active } = extractStandardUserAttributes(body);
|
||||
const { id, first_name, last_name, email, active } = extractStandardUserAttributes(body);
|
||||
|
||||
const { data: user } = await this.users.create({
|
||||
directoryId: directory.id,
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
active,
|
||||
raw: body,
|
||||
raw: 'rawAttributes' in body ? body.rawAttributes : body,
|
||||
});
|
||||
|
||||
await sendEvent('user.created', { directory, user }, this.callback);
|
||||
|
@ -57,7 +63,7 @@ export class DirectoryUsers {
|
|||
last_name: name.familyName,
|
||||
email: emails[0].value,
|
||||
active,
|
||||
raw: body,
|
||||
raw: 'rawAttributes' in body ? body.rawAttributes : body,
|
||||
});
|
||||
|
||||
await sendEvent('user.updated', { directory, user: updatedUser }, this.callback);
|
|
@ -1,6 +1,8 @@
|
|||
import type { Group, DatabaseStore, ApiError, PaginationParams, GroupMembership } from '../typings';
|
||||
import * as dbutils from '../db/utils';
|
||||
import { apiError, JacksonError } from '../controller/error';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type { Group, DatabaseStore, PaginationParams, Response, GroupMembership } from '../../typings';
|
||||
import * as dbutils from '../../db/utils';
|
||||
import { apiError, JacksonError } from '../../controller/error';
|
||||
import { Base } from './Base';
|
||||
|
||||
const indexNames = {
|
||||
|
@ -8,32 +10,33 @@ const indexNames = {
|
|||
directoryId: 'directoryId',
|
||||
};
|
||||
|
||||
interface CreateGroupParams {
|
||||
directoryId: string;
|
||||
name: string;
|
||||
raw: any;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export class Groups extends Base {
|
||||
constructor({ db }: { db: DatabaseStore }) {
|
||||
super({ db });
|
||||
}
|
||||
|
||||
// Create a new group
|
||||
public async create({
|
||||
directoryId,
|
||||
name,
|
||||
raw,
|
||||
}: {
|
||||
directoryId: string;
|
||||
name: string;
|
||||
raw: any;
|
||||
}): Promise<{ data: Group | null; error: ApiError | null }> {
|
||||
public async create(params: CreateGroupParams): Promise<Response<Group>> {
|
||||
const { directoryId, name, raw, id: groupId } = params;
|
||||
|
||||
const id = groupId || randomUUID();
|
||||
|
||||
raw['id'] = id;
|
||||
|
||||
const group: Group = {
|
||||
id,
|
||||
name,
|
||||
raw,
|
||||
};
|
||||
|
||||
try {
|
||||
const id = this.createId();
|
||||
|
||||
raw['id'] = id;
|
||||
|
||||
const group: Group = {
|
||||
id,
|
||||
name,
|
||||
raw,
|
||||
};
|
||||
|
||||
await this.store('groups').put(
|
||||
id,
|
||||
group,
|
||||
|
@ -54,7 +57,7 @@ export class Groups extends Base {
|
|||
}
|
||||
|
||||
// Get a group by id
|
||||
public async get(id: string): Promise<{ data: Group | null; error: ApiError | null }> {
|
||||
public async get(id: string): Promise<Response<Group>> {
|
||||
try {
|
||||
const group = await this.store('groups').get(id);
|
||||
|
||||
|
@ -75,16 +78,16 @@ export class Groups extends Base {
|
|||
name: string;
|
||||
raw: any;
|
||||
}
|
||||
): Promise<{ data: Group | null; error: ApiError | null }> {
|
||||
): Promise<Response<Group>> {
|
||||
const { name, raw } = param;
|
||||
|
||||
const group: Group = {
|
||||
id,
|
||||
name,
|
||||
raw,
|
||||
};
|
||||
|
||||
try {
|
||||
const { name, raw } = param;
|
||||
|
||||
const group: Group = {
|
||||
id,
|
||||
name,
|
||||
raw,
|
||||
};
|
||||
|
||||
await this.store('groups').put(id, group);
|
||||
|
||||
return { data: group, error: null };
|
||||
|
@ -94,7 +97,7 @@ export class Groups extends Base {
|
|||
}
|
||||
|
||||
// Delete a group by id
|
||||
public async delete(id: string): Promise<{ data: null; error: ApiError | null }> {
|
||||
public async delete(id: string): Promise<Response<null>> {
|
||||
try {
|
||||
const { data, error } = await this.get(id);
|
||||
|
||||
|
@ -111,20 +114,6 @@ export class Groups extends Base {
|
|||
}
|
||||
}
|
||||
|
||||
// Get all users in a group
|
||||
public async getAllUsers(groupId: string): Promise<{ user_id: string }[]> {
|
||||
const { data: users } = 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));
|
||||
|
@ -158,10 +147,7 @@ export class Groups extends Base {
|
|||
}
|
||||
|
||||
// Search groups by displayName
|
||||
public async search(
|
||||
displayName: string,
|
||||
directoryId: string
|
||||
): Promise<{ data: Group[] | null; error: ApiError | null }> {
|
||||
public async search(displayName: string, directoryId: string): Promise<Response<Group[]>> {
|
||||
try {
|
||||
const { data: groups } = await this.store('groups').getByIndex({
|
||||
name: indexNames.directoryIdDisplayname,
|
||||
|
@ -179,10 +165,7 @@ export class Groups extends Base {
|
|||
params: PaginationParams & {
|
||||
directoryId?: string;
|
||||
}
|
||||
): Promise<{
|
||||
data: Group[] | null;
|
||||
error: ApiError | null;
|
||||
}> {
|
||||
): Promise<Response<Group[]>> {
|
||||
const { pageOffset, pageLimit, directoryId } = params;
|
||||
|
||||
try {
|
||||
|
@ -206,6 +189,32 @@ export class Groups extends Base {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get members of a group paginated
|
||||
* @param groupId
|
||||
* @returns
|
||||
*/
|
||||
public async getGroupMembers(
|
||||
parmas: { groupId: string } & PaginationParams
|
||||
): Promise<Response<GroupMembership['user_id'][]>> {
|
||||
const { groupId, pageOffset, pageLimit } = parmas;
|
||||
|
||||
try {
|
||||
const { data: members } = await this.store('members').getByIndex(
|
||||
{
|
||||
name: 'groupId',
|
||||
value: groupId,
|
||||
},
|
||||
pageOffset,
|
||||
pageLimit
|
||||
);
|
||||
|
||||
return { data: members, error: null };
|
||||
} catch (err: any) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all groups from a directory
|
||||
async deleteAll(directoryId: string) {
|
||||
const index = {
|
|
@ -1,21 +1,24 @@
|
|||
import type { User, DatabaseStore, ApiError, PaginationParams } from '../typings';
|
||||
import { apiError, JacksonError } from '../controller/error';
|
||||
import { Base } from './Base';
|
||||
import { keyFromParts } from '../db/utils';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
type CreateUserPayload = {
|
||||
import type { User, DatabaseStore, PaginationParams, Response } from '../../typings';
|
||||
import { apiError, JacksonError } from '../../controller/error';
|
||||
import { Base } from './Base';
|
||||
import { keyFromParts } from '../../db/utils';
|
||||
|
||||
const indexNames = {
|
||||
directoryIdUsername: 'directoryIdUsername',
|
||||
directoryId: 'directoryId',
|
||||
};
|
||||
|
||||
interface CreateUserParams {
|
||||
directoryId: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
active: boolean;
|
||||
raw: any;
|
||||
};
|
||||
|
||||
const indexNames = {
|
||||
directoryIdUsername: 'directoryIdUsername',
|
||||
directoryId: 'directoryId',
|
||||
};
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export class Users extends Base {
|
||||
constructor({ db }: { db: DatabaseStore }) {
|
||||
|
@ -23,28 +26,23 @@ export class Users extends Base {
|
|||
}
|
||||
|
||||
// Create a new user
|
||||
public async create({
|
||||
directoryId,
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
active,
|
||||
raw,
|
||||
}: CreateUserPayload): Promise<{ data: User | null; error: ApiError | null }> {
|
||||
public async create(params: CreateUserParams): Promise<Response<User>> {
|
||||
const { directoryId, first_name, last_name, email, active, raw, id: userId } = params;
|
||||
|
||||
const id = userId || randomUUID();
|
||||
|
||||
raw['id'] = id;
|
||||
|
||||
const user = {
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
active,
|
||||
raw,
|
||||
};
|
||||
|
||||
try {
|
||||
const id = this.createId();
|
||||
|
||||
raw['id'] = id;
|
||||
|
||||
const user = {
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
active,
|
||||
raw,
|
||||
};
|
||||
|
||||
await this.store('users').put(
|
||||
id,
|
||||
user,
|
||||
|
@ -65,7 +63,7 @@ export class Users extends Base {
|
|||
}
|
||||
|
||||
// Get a user by id
|
||||
public async get(id: string): Promise<{ data: User | null; error: ApiError | null }> {
|
||||
public async get(id: string): Promise<Response<User>> {
|
||||
try {
|
||||
const user = await this.store('users').get(id);
|
||||
|
||||
|
@ -89,21 +87,21 @@ export class Users extends Base {
|
|||
active: boolean;
|
||||
raw: object;
|
||||
}
|
||||
): Promise<{ data: User | null; error: ApiError | null }> {
|
||||
): Promise<Response<User>> {
|
||||
const { first_name, last_name, email, active, raw } = param;
|
||||
|
||||
raw['id'] = id;
|
||||
|
||||
const user = {
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
active,
|
||||
raw,
|
||||
};
|
||||
|
||||
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 };
|
||||
|
@ -113,7 +111,7 @@ export class Users extends Base {
|
|||
}
|
||||
|
||||
// Delete a user by id
|
||||
public async delete(id: string): Promise<{ data: null; error: ApiError | null }> {
|
||||
public async delete(id: string): Promise<Response<null>> {
|
||||
try {
|
||||
const { data, error } = await this.get(id);
|
||||
|
||||
|
@ -130,17 +128,12 @@ export class Users extends Base {
|
|||
}
|
||||
|
||||
// Search users by userName
|
||||
public async search(
|
||||
userName: string,
|
||||
directoryId: string
|
||||
): Promise<{ data: User[] | null; error: ApiError | null }> {
|
||||
public async search(userName: string, directoryId: string): Promise<Response<User[]>> {
|
||||
try {
|
||||
const users = (
|
||||
await this.store('users').getByIndex({
|
||||
name: indexNames.directoryIdUsername,
|
||||
value: keyFromParts(directoryId, userName),
|
||||
})
|
||||
).data;
|
||||
const { data: users } = await this.store('users').getByIndex({
|
||||
name: indexNames.directoryIdUsername,
|
||||
value: keyFromParts(directoryId, userName),
|
||||
});
|
||||
|
||||
return { data: users, error: null };
|
||||
} catch (err: any) {
|
||||
|
@ -155,10 +148,7 @@ export class Users extends Base {
|
|||
directoryId,
|
||||
}: PaginationParams & {
|
||||
directoryId?: string;
|
||||
} = {}): Promise<{
|
||||
data: User[] | null;
|
||||
error: ApiError | null;
|
||||
}> {
|
||||
} = {}): Promise<Response<User[]>> {
|
||||
try {
|
||||
let users: User[] = [];
|
||||
|
||||
|
@ -185,7 +175,7 @@ export class Users extends Base {
|
|||
}
|
||||
|
||||
// Delete all users from a directory
|
||||
async deleteAll(directoryId: string): Promise<void> {
|
||||
async deleteAll(directoryId: string) {
|
||||
const index = {
|
||||
name: indexNames.directoryId,
|
||||
value: directoryId,
|
|
@ -1,10 +1,12 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type {
|
||||
Directory,
|
||||
DatabaseStore,
|
||||
WebhookEventLog,
|
||||
DirectorySyncEvent,
|
||||
PaginationParams,
|
||||
} from '../typings';
|
||||
} from '../../typings';
|
||||
import { Base } from './Base';
|
||||
|
||||
type GetAllParams = PaginationParams & {
|
||||
|
@ -17,7 +19,7 @@ export class WebhookEventsLogger extends Base {
|
|||
}
|
||||
|
||||
public async log(directory: Directory, event: DirectorySyncEvent, status: number) {
|
||||
const id = this.createId();
|
||||
const id = randomUUID();
|
||||
|
||||
const log: WebhookEventLog = {
|
||||
...event,
|
||||
|
@ -40,6 +42,7 @@ export class WebhookEventsLogger extends Base {
|
|||
return await this.store('logs').get(id);
|
||||
}
|
||||
|
||||
// Get the event logs for a directory paginated
|
||||
public async getAll(params: GetAllParams = {}) {
|
||||
const { pageOffset, pageLimit, directoryId } = params;
|
||||
|
|
@ -7,23 +7,27 @@ import type {
|
|||
DirectorySyncEvent,
|
||||
IWebhookEventsLogger,
|
||||
IDirectoryConfig,
|
||||
} from '../typings';
|
||||
import { sendPayloadToWebhook } from '../event/webhook';
|
||||
} from '../../typings';
|
||||
import { sendPayloadToWebhook } from '../../event/webhook';
|
||||
import { transformEventPayload } from './transform';
|
||||
import { isConnectionActive } from '../controller/utils';
|
||||
import { isConnectionActive } from '../../controller/utils';
|
||||
|
||||
type Payload = { directory: Directory; group?: Group | null; user?: User | null };
|
||||
|
||||
export const sendEvent = async (
|
||||
event: DirectorySyncEventType,
|
||||
payload: { directory: Directory; group?: Group | null; user?: User | null },
|
||||
payload: Payload,
|
||||
callback?: EventCallback
|
||||
) => {
|
||||
if (!isConnectionActive(payload.directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventTransformed = transformEventPayload(event, payload);
|
||||
if (!callback) {
|
||||
return;
|
||||
}
|
||||
|
||||
return callback ? await callback(eventTransformed) : Promise.resolve();
|
||||
await callback(transformEventPayload(event, payload));
|
||||
};
|
||||
|
||||
export const handleEventCallback = async (
|
|
@ -1,4 +1,4 @@
|
|||
import { Directory, DirectorySyncEvent, DirectorySyncEventType, Group, User } from '../typings';
|
||||
import { Directory, DirectorySyncEvent, DirectorySyncEventType, Group, User } from '../../typings';
|
||||
|
||||
export const transformUser = (user: User): User => {
|
||||
return {
|
|
@ -1,8 +1,9 @@
|
|||
import type { User } from '../typings';
|
||||
import { DirectorySyncProviders, UserPatchOperation, GroupPatchOperation } from '../typings';
|
||||
import lodash from 'lodash';
|
||||
|
||||
const parseGroupOperation = (operation: GroupPatchOperation) => {
|
||||
import { DirectorySyncProviders } from '../../typings';
|
||||
import type { DirectoryType, User, UserPatchOperation, GroupPatchOperation } from '../../typings';
|
||||
|
||||
export const parseGroupOperation = (operation: GroupPatchOperation) => {
|
||||
const { op, path, value } = operation;
|
||||
|
||||
if (path === 'members') {
|
||||
|
@ -45,7 +46,7 @@ const parseGroupOperation = (operation: GroupPatchOperation) => {
|
|||
|
||||
// List of directory sync providers
|
||||
// TODO: Fix the return type
|
||||
const getDirectorySyncProviders = (): { [K: string]: string } => {
|
||||
export const getDirectorySyncProviders = (): { [K: string]: string } => {
|
||||
return Object.entries(DirectorySyncProviders).reduce((acc, [key, value]) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
|
@ -53,7 +54,7 @@ const getDirectorySyncProviders = (): { [K: string]: string } => {
|
|||
};
|
||||
|
||||
// Parse the PATCH request body and return the user attributes (both standard and custom)
|
||||
const parseUserPatchRequest = (operation: UserPatchOperation) => {
|
||||
export const parseUserPatchRequest = (operation: UserPatchOperation) => {
|
||||
const { value, path } = operation;
|
||||
|
||||
const attributes: Partial<User> = {};
|
||||
|
@ -95,12 +96,13 @@ const parseUserPatchRequest = (operation: UserPatchOperation) => {
|
|||
};
|
||||
|
||||
// Extract standard attributes from the user body
|
||||
const extractStandardUserAttributes = (body: any) => {
|
||||
const { name, emails, userName, active } = body as {
|
||||
export const extractStandardUserAttributes = (body: any) => {
|
||||
const { name, emails, userName, active, userId } = body as {
|
||||
name?: { givenName: string; familyName: string };
|
||||
emails?: { value: string }[];
|
||||
userName: string;
|
||||
active: boolean;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
|
@ -108,11 +110,12 @@ const extractStandardUserAttributes = (body: any) => {
|
|||
last_name: name && 'familyName' in name ? name.familyName : '',
|
||||
email: emails && emails.length > 0 ? emails[0].value : userName,
|
||||
active: active || true,
|
||||
id: userId, // For non-SCIM providers, the id will exist in the body
|
||||
};
|
||||
};
|
||||
|
||||
// Update raw user attributes
|
||||
const updateRawUserAttributes = (raw, attributes) => {
|
||||
export const updateRawUserAttributes = (raw, attributes) => {
|
||||
const keys = Object.keys(attributes);
|
||||
|
||||
if (keys.length === 0) {
|
||||
|
@ -126,10 +129,6 @@ const updateRawUserAttributes = (raw, attributes) => {
|
|||
return raw;
|
||||
};
|
||||
|
||||
export {
|
||||
parseGroupOperation,
|
||||
getDirectorySyncProviders,
|
||||
parseUserPatchRequest,
|
||||
extractStandardUserAttributes,
|
||||
updateRawUserAttributes,
|
||||
export const isSCIMEnabledProvider = (type: DirectoryType) => {
|
||||
return type !== 'google';
|
||||
};
|
|
@ -1,10 +1,12 @@
|
|||
import directorySync from '.';
|
||||
import { DirectoryConfig } from './DirectoryConfig';
|
||||
import { DirectoryGroups } from './DirectoryGroups';
|
||||
import { DirectoryUsers } from './DirectoryUsers';
|
||||
import { Users } from './Users';
|
||||
import { Groups } from './Groups';
|
||||
import { WebhookEventsLogger } from './WebhookEventsLogger';
|
||||
import { DirectoryConfig } from './scim/DirectoryConfig';
|
||||
import { DirectoryGroups } from './scim/DirectoryGroups';
|
||||
import { DirectoryUsers } from './scim/DirectoryUsers';
|
||||
import { Users } from './scim/Users';
|
||||
import { Groups } from './scim/Groups';
|
||||
import { WebhookEventsLogger } from './scim/WebhookEventsLogger';
|
||||
import { ApiError } from '../typings';
|
||||
import { RequestHandler } from './request';
|
||||
|
||||
export type IDirectorySyncController = Awaited<ReturnType<typeof directorySync>>;
|
||||
export type IDirectoryConfig = InstanceType<typeof DirectoryConfig>;
|
||||
|
@ -13,6 +15,7 @@ export type IDirectoryUsers = InstanceType<typeof DirectoryUsers>;
|
|||
export type IUsers = InstanceType<typeof Users>;
|
||||
export type IGroups = InstanceType<typeof Groups>;
|
||||
export type IWebhookEventsLogger = InstanceType<typeof WebhookEventsLogger>;
|
||||
export type IRequestHandler = InstanceType<typeof RequestHandler>;
|
||||
|
||||
export type DirectorySyncEventType =
|
||||
| 'user.created'
|
||||
|
@ -29,7 +32,8 @@ export enum DirectorySyncProviders {
|
|||
'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',
|
||||
'generic-scim-v2' = 'Generic SCIM v2.0',
|
||||
'google' = 'Google',
|
||||
}
|
||||
|
||||
export type DirectoryType = keyof typeof DirectorySyncProviders;
|
||||
|
@ -51,6 +55,9 @@ export type Directory = {
|
|||
secret: string;
|
||||
};
|
||||
deactivated?: boolean;
|
||||
google_domain?: string;
|
||||
google_access_token?: string;
|
||||
google_refresh_token?: string;
|
||||
};
|
||||
|
||||
export type DirectorySyncGroupMember = { value: string; email?: string };
|
||||
|
@ -60,10 +67,6 @@ export type DirectorySyncResponse = {
|
|||
data?: any;
|
||||
};
|
||||
|
||||
export interface DirectorySyncRequestHandler {
|
||||
handle(request: DirectorySyncRequest, callback?: EventCallback): Promise<DirectorySyncResponse>;
|
||||
}
|
||||
|
||||
export interface Events {
|
||||
handle(event: DirectorySyncEvent): Promise<void>;
|
||||
}
|
||||
|
@ -92,10 +95,6 @@ export interface DirectorySyncEvent {
|
|||
product: string;
|
||||
}
|
||||
|
||||
export interface EventCallback {
|
||||
(event: DirectorySyncEvent): Promise<void>;
|
||||
}
|
||||
|
||||
export interface WebhookEventLog extends DirectorySyncEvent {
|
||||
id: string;
|
||||
webhook_endpoint: string;
|
||||
|
@ -119,12 +118,18 @@ export type Group = {
|
|||
raw?: any;
|
||||
};
|
||||
|
||||
export type GroupMember = {
|
||||
id: string;
|
||||
raw?: any;
|
||||
};
|
||||
|
||||
export type UserWithGroup = User & { group: Group };
|
||||
|
||||
export type PaginationParams = {
|
||||
pageOffset?: number;
|
||||
pageLimit?: number;
|
||||
pageToken?: string;
|
||||
hasNextPage?: boolean;
|
||||
};
|
||||
|
||||
export type UserPatchOperation = {
|
||||
|
@ -164,3 +169,51 @@ export type GroupMembership = {
|
|||
group_id: string;
|
||||
user_id: string;
|
||||
};
|
||||
|
||||
export type Response<T> = { data: T; error: null } | { data: null; error: ApiError };
|
||||
|
||||
export type EventCallback = (event: DirectorySyncEvent) => Promise<void>;
|
||||
|
||||
export interface IDirectoryProvider {
|
||||
/**
|
||||
* Fields to exclude from the user payload while comparing the user to find if it is updated
|
||||
*/
|
||||
userFieldsToExcludeWhenCompare?: string[];
|
||||
|
||||
/**
|
||||
* Fields to exclude from the group payload while comparing the group to find if it is updated
|
||||
*/
|
||||
groupFieldsToExcludeWhenCompare?: string[];
|
||||
|
||||
/**
|
||||
* Get all directories for the provider
|
||||
*/
|
||||
getDirectories(): Promise<Directory[]>;
|
||||
|
||||
/**
|
||||
* Get all users for a directory
|
||||
* @param directory
|
||||
* @param options
|
||||
*/
|
||||
getUsers(
|
||||
directory: Directory,
|
||||
options: PaginationParams | null
|
||||
): Promise<{ data: User[]; metadata: PaginationParams | null }>;
|
||||
|
||||
/**
|
||||
* Get all groups for a directory
|
||||
* @param directory
|
||||
* @param options
|
||||
*/
|
||||
getGroups(
|
||||
directory: Directory,
|
||||
options: PaginationParams | null
|
||||
): Promise<{ data: Group[]; metadata: PaginationParams | null }>;
|
||||
|
||||
/**
|
||||
* Get all members of a group
|
||||
* @param directory
|
||||
* @param group
|
||||
*/
|
||||
getGroupMembers(directory: Directory, group: Group): Promise<GroupMember[]>;
|
||||
}
|
||||
|
|
|
@ -160,6 +160,12 @@ export interface IConnectionAPIController {
|
|||
* @deprecated Use `deleteConnections` instead.
|
||||
*/
|
||||
deleteConfig(body: DelConfigQuery): Promise<void>;
|
||||
getConnectionsByProduct(body: {
|
||||
product: string;
|
||||
pageOffset?: number;
|
||||
pageLimit?: number;
|
||||
pageToken?: string;
|
||||
}): Promise<{ data: (SAMLSSORecord | OIDCSSORecord)[]; pageToken?: string }>;
|
||||
}
|
||||
|
||||
export interface IOAuthController {
|
||||
|
@ -410,6 +416,15 @@ export interface JacksonOption {
|
|||
adminToken?: string;
|
||||
};
|
||||
webhook?: Webhook;
|
||||
dsync?: {
|
||||
providers: {
|
||||
google: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
callbackUrl: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface SLORequestParams {
|
||||
|
|
|
@ -187,7 +187,7 @@ tap.test('directories.', async (t) => {
|
|||
});
|
||||
|
||||
t.test('get a directory by tenant and product', async (t) => {
|
||||
const { data: directoriesFetched } = await directorySync.directories.getByTenantAndProduct(
|
||||
const { data: directoriesFetched, error } = await directorySync.directories.getByTenantAndProduct(
|
||||
tenant,
|
||||
product
|
||||
);
|
||||
|
@ -231,7 +231,6 @@ tap.test('directories.', async (t) => {
|
|||
secret: 'secret',
|
||||
},
|
||||
log_webhook_events: true,
|
||||
type: 'azure-scim-v2' as DirectoryType,
|
||||
};
|
||||
|
||||
const { data: updatedDirectory } = await directorySync.directories.update(directory.id, {
|
||||
|
@ -242,7 +241,6 @@ tap.test('directories.', async (t) => {
|
|||
t.same(directory.id, updatedDirectory?.id);
|
||||
t.same(updatedDirectory?.name, toUpdate.name);
|
||||
t.same(updatedDirectory?.log_webhook_events, toUpdate.log_webhook_events);
|
||||
t.same(updatedDirectory?.type, toUpdate.type);
|
||||
t.match(updatedDirectory?.webhook.endpoint, toUpdate.webhook?.endpoint);
|
||||
t.match(updatedDirectory?.webhook.secret, toUpdate.webhook?.secret);
|
||||
|
||||
|
@ -253,7 +251,6 @@ tap.test('directories.', async (t) => {
|
|||
t.same(directoryFetched?.id, updatedDirectory?.id);
|
||||
t.same(directoryFetched?.name, toUpdate.name);
|
||||
t.same(directoryFetched?.log_webhook_events, toUpdate.log_webhook_events);
|
||||
t.same(directoryFetched?.type, toUpdate.type);
|
||||
t.match(directoryFetched?.webhook.endpoint, toUpdate.webhook?.endpoint);
|
||||
t.match(directoryFetched?.webhook.secret, toUpdate.webhook?.secret);
|
||||
});
|
||||
|
@ -333,4 +330,103 @@ tap.test('directories.', async (t) => {
|
|||
t.match(directoryFetched2?.deactivated, false);
|
||||
t.match(isConnectionActive(directoryFetched2!), true);
|
||||
});
|
||||
|
||||
t.test('Fetch the non-scim directories by directory provider', async (t) => {
|
||||
// Create a google directory
|
||||
const { data: firstDirectory } = await directorySync.directories.create({
|
||||
...directoryPayload,
|
||||
name: 'Google Directory 1',
|
||||
type: 'google' as DirectoryType,
|
||||
});
|
||||
|
||||
// Create an Okta directory
|
||||
await directorySync.directories.create({
|
||||
...directoryPayload,
|
||||
name: 'Okta Directory 1',
|
||||
type: 'okta-scim-v2' as DirectoryType,
|
||||
});
|
||||
|
||||
// Create another Google directory
|
||||
const { data: secondDirectory } = await directorySync.directories.create({
|
||||
...directoryPayload,
|
||||
name: 'Google Directory 2',
|
||||
type: 'google' as DirectoryType,
|
||||
});
|
||||
|
||||
// Fetch all the directories of type google
|
||||
const { data: googleDirectoryFetched } = await directorySync.directories.filterBy({
|
||||
provider: 'google',
|
||||
});
|
||||
|
||||
t.ok(googleDirectoryFetched);
|
||||
t.match(googleDirectoryFetched?.length, 2);
|
||||
t.match(googleDirectoryFetched?.[1].id, firstDirectory?.id);
|
||||
t.match(googleDirectoryFetched?.[0].id, secondDirectory?.id);
|
||||
});
|
||||
|
||||
t.test('The SCIM endpoint should be empty for non-scim provider', async (t) => {
|
||||
const { data: directoryCreated } = await directorySync.directories.create({
|
||||
...directoryPayload,
|
||||
tenant: 'acme',
|
||||
type: 'google' as DirectoryType,
|
||||
});
|
||||
|
||||
t.ok(directoryCreated);
|
||||
t.match(directoryCreated?.scim.path, '');
|
||||
t.match(directoryCreated?.scim.secret, '');
|
||||
});
|
||||
|
||||
t.test('Should be able to update the Google credentials', async (t) => {
|
||||
const { data: directory } = await directorySync.directories.create({
|
||||
...directoryPayload,
|
||||
tenant: 'acme',
|
||||
type: 'google' as DirectoryType,
|
||||
});
|
||||
|
||||
if (!directory) {
|
||||
t.fail("Couldn't create a directory");
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: directoryUpdated } = await directorySync.directories.update(directory.id, {
|
||||
google_access_token: 'access_token',
|
||||
google_refresh_token: 'refresh_token',
|
||||
google_domain: 'acme.com',
|
||||
});
|
||||
|
||||
t.ok(directoryUpdated);
|
||||
t.match(directoryUpdated?.google_access_token, 'access_token');
|
||||
t.match(directoryUpdated?.google_refresh_token, 'refresh_token');
|
||||
t.match(directoryUpdated?.google_domain, 'acme.com');
|
||||
|
||||
// Check that the directory was updated
|
||||
const { data: directoryFetched } = await directorySync.directories.get(directory.id);
|
||||
|
||||
t.ok(directoryFetched);
|
||||
t.match(directoryFetched?.google_access_token, 'access_token');
|
||||
t.match(directoryFetched?.google_refresh_token, 'refresh_token');
|
||||
t.match(directoryFetched?.google_domain, 'acme.com');
|
||||
});
|
||||
|
||||
t.test('Fetch all connections for a product', async (t) => {
|
||||
await directorySync.directories.create({
|
||||
...directoryPayload,
|
||||
tenant: 'first-tenant',
|
||||
product: 'a-new-product',
|
||||
});
|
||||
|
||||
const { data: directories, error } = await directorySync.directories.filterBy({
|
||||
product: 'a-new-product',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
t.fail(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
t.ok(directories);
|
||||
t.match(directories.length, 1);
|
||||
t.match(directories[0].tenant, 'first-tenant');
|
||||
t.match(directories[0].product, 'a-new-product');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,399 @@
|
|||
import tap from 'tap';
|
||||
import nock from 'nock';
|
||||
import type { DirectorySyncEvent } from '@boxyhq/saml-jackson';
|
||||
|
||||
import { jacksonOptions } from '../utils';
|
||||
import { IDirectorySyncController, DirectoryType, Directory } from '../../src/typings';
|
||||
|
||||
let directorySyncController: IDirectorySyncController;
|
||||
|
||||
const directoryPayload = {
|
||||
tenant: 'boxyhq',
|
||||
product: 'saml-jackson-google',
|
||||
name: 'Google Directory',
|
||||
type: 'google' as DirectoryType,
|
||||
google_domain: 'boxyhq.com',
|
||||
google_access_token: 'access_token',
|
||||
google_refresh_token: 'refresh_token',
|
||||
};
|
||||
|
||||
const fakeGoogleDirectory = {
|
||||
users: [
|
||||
{
|
||||
id: 'elizasmith',
|
||||
primaryEmail: 'eliza@example.com',
|
||||
name: {
|
||||
givenName: 'Eliza',
|
||||
familyName: 'Smith',
|
||||
},
|
||||
suspended: false,
|
||||
password: 'password',
|
||||
hashFunction: 'SHA-1',
|
||||
changePasswordAtNextLogin: false,
|
||||
ipWhitelisted: false,
|
||||
etag: 'abcd1234',
|
||||
},
|
||||
{
|
||||
id: 'johndoe',
|
||||
primaryEmail: 'john@example.com',
|
||||
name: {
|
||||
givenName: 'John',
|
||||
familyName: 'Doe',
|
||||
},
|
||||
suspended: false,
|
||||
password: 'password',
|
||||
hashFunction: 'SHA-1',
|
||||
changePasswordAtNextLogin: false,
|
||||
ipWhitelisted: false,
|
||||
etag: 'efgh5678',
|
||||
},
|
||||
],
|
||||
|
||||
groups: [
|
||||
{
|
||||
id: 'engineering',
|
||||
email: 'engineering@example.com',
|
||||
name: 'Engineering Team',
|
||||
description: 'A group for the engineering department',
|
||||
adminCreated: true,
|
||||
directMembersCount: '10',
|
||||
kind: 'admin#directory#group',
|
||||
etag: 'abcd1234',
|
||||
aliases: ['eng-team@example.com', 'engineering@example.com'],
|
||||
nonEditableAliases: ['group123@example.com'],
|
||||
},
|
||||
{
|
||||
id: 'sales',
|
||||
email: 'sales@example.com',
|
||||
name: 'Sales Team',
|
||||
description: 'A group for the sales department',
|
||||
adminCreated: true,
|
||||
directMembersCount: '5',
|
||||
kind: 'admin#directory#group',
|
||||
etag: 'efgh5678',
|
||||
aliases: ['sales@example.com'],
|
||||
nonEditableAliases: ['sales-group456@example.com'],
|
||||
},
|
||||
],
|
||||
|
||||
members: {
|
||||
engineering: [
|
||||
{
|
||||
kind: 'directory#member',
|
||||
id: 'elizasmith',
|
||||
email: 'eliza@example.com',
|
||||
role: 'MANAGER',
|
||||
type: 'USER',
|
||||
},
|
||||
{
|
||||
kind: 'directory#member',
|
||||
id: 'johndoe',
|
||||
email: 'johndoe@example.com',
|
||||
role: 'MANAGER',
|
||||
type: 'USER',
|
||||
},
|
||||
],
|
||||
sales: [
|
||||
{
|
||||
kind: 'directory#member',
|
||||
id: 'elizasmith',
|
||||
email: 'eliza@example.com',
|
||||
role: 'MANAGER',
|
||||
type: 'USER',
|
||||
},
|
||||
],
|
||||
marketing: [
|
||||
{
|
||||
kind: 'directory#member',
|
||||
id: 'jackson',
|
||||
email: 'jackson@example.com',
|
||||
role: 'MANAGER',
|
||||
type: 'USER',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Mock /admin/directory/v1/users
|
||||
const mockUsersAPI = (users: any[]) => {
|
||||
nock('https://admin.googleapis.com')
|
||||
.get('/admin/directory/v1/users')
|
||||
.query({
|
||||
maxResults: 200,
|
||||
domain: 'boxyhq.com',
|
||||
})
|
||||
.reply(200, { users });
|
||||
};
|
||||
|
||||
// Mock /admin/directory/v1/groups
|
||||
const mockGroupsAPI = (groups: any[]) => {
|
||||
nock('https://admin.googleapis.com')
|
||||
.get('/admin/directory/v1/groups')
|
||||
.query({
|
||||
maxResults: 200,
|
||||
domain: 'boxyhq.com',
|
||||
})
|
||||
.times(2)
|
||||
.reply(200, { groups });
|
||||
};
|
||||
|
||||
// Mock /admin/directory/v1/groups/{groupKey}/members
|
||||
const mockGroupMembersAPI = (groupKey: string, members: any[]) => {
|
||||
nock('https://admin.googleapis.com')
|
||||
.get(`/admin/directory/v1/groups/${groupKey}/members`)
|
||||
.query({
|
||||
maxResults: 200,
|
||||
domain: 'boxyhq.com',
|
||||
})
|
||||
.reply(200, { members });
|
||||
};
|
||||
|
||||
tap.before(async () => {
|
||||
directorySyncController = (await (await import('../../src/index')).default(jacksonOptions))
|
||||
.directorySyncController;
|
||||
|
||||
await directorySyncController.directories.create(directoryPayload);
|
||||
});
|
||||
|
||||
tap.teardown(async () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
tap.test('Sync 1', async (t) => {
|
||||
const events: DirectorySyncEvent[] = [];
|
||||
|
||||
// Mock necessary API calls
|
||||
mockUsersAPI(fakeGoogleDirectory.users);
|
||||
mockGroupsAPI(fakeGoogleDirectory.groups);
|
||||
mockGroupMembersAPI('engineering', fakeGoogleDirectory.members.engineering);
|
||||
mockGroupMembersAPI('sales', fakeGoogleDirectory.members.sales);
|
||||
|
||||
await directorySyncController.sync(async (event: DirectorySyncEvent) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
nock.cleanAll();
|
||||
|
||||
t.strictSame(events.length, 7);
|
||||
|
||||
t.strictSame(events[0].event, 'user.created');
|
||||
t.strictSame(events[0].data.id, fakeGoogleDirectory.users[0].id);
|
||||
t.strictSame(events[0].data.raw, fakeGoogleDirectory.users[0]);
|
||||
|
||||
t.strictSame(events[1].event, 'user.created');
|
||||
t.strictSame(events[1].data.id, fakeGoogleDirectory.users[1].id);
|
||||
t.strictSame(events[1].data.raw, fakeGoogleDirectory.users[1]);
|
||||
|
||||
t.strictSame(events[2].event, 'group.created');
|
||||
t.strictSame(events[2].data.id, fakeGoogleDirectory.groups[0].id);
|
||||
t.strictSame(events[2].data.raw, fakeGoogleDirectory.groups[0]);
|
||||
|
||||
t.strictSame(events[3].event, 'group.created');
|
||||
t.strictSame(events[3].data.id, fakeGoogleDirectory.groups[1].id);
|
||||
t.strictSame(events[3].data.raw, fakeGoogleDirectory.groups[1]);
|
||||
|
||||
t.strictSame(events[4].event, 'group.user_added');
|
||||
t.strictSame(events[4].data.id, fakeGoogleDirectory.users[0].id);
|
||||
t.strictSame(events[4].data.raw, fakeGoogleDirectory.users[0]);
|
||||
|
||||
// Check that the user was added to the group
|
||||
if ('group' in events[4].data) {
|
||||
t.strictSame(events[4].data.group.id, fakeGoogleDirectory.groups[0].id);
|
||||
}
|
||||
|
||||
t.strictSame(events[5].event, 'group.user_added');
|
||||
t.strictSame(events[5].data.id, fakeGoogleDirectory.users[1].id);
|
||||
t.strictSame(events[5].data.raw, fakeGoogleDirectory.users[1]);
|
||||
|
||||
// Check that the user was added to the group
|
||||
if ('group' in events[5].data) {
|
||||
t.strictSame(events[5].data.group.id, fakeGoogleDirectory.groups[0].id);
|
||||
}
|
||||
|
||||
t.strictSame(events[6].event, 'group.user_added');
|
||||
t.strictSame(events[6].data.id, fakeGoogleDirectory.users[0].id);
|
||||
t.strictSame(events[6].data.raw, fakeGoogleDirectory.users[0]);
|
||||
|
||||
// Check that the user was added to the group
|
||||
if ('group' in events[6].data) {
|
||||
t.strictSame(events[6].data.group.id, fakeGoogleDirectory.groups[1].id);
|
||||
}
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.test('Sync 2', async (t) => {
|
||||
const events: DirectorySyncEvent[] = [];
|
||||
|
||||
// Update user
|
||||
fakeGoogleDirectory.users[0].name.givenName = 'Eliza Updated';
|
||||
|
||||
// Update group
|
||||
fakeGoogleDirectory.groups[0].name = 'Engineering Updated';
|
||||
|
||||
mockUsersAPI(fakeGoogleDirectory.users);
|
||||
mockGroupsAPI(fakeGoogleDirectory.groups);
|
||||
mockGroupMembersAPI('engineering', fakeGoogleDirectory.members.engineering);
|
||||
mockGroupMembersAPI('sales', fakeGoogleDirectory.members.sales);
|
||||
|
||||
await directorySyncController.sync(async (event: DirectorySyncEvent) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
nock.cleanAll();
|
||||
|
||||
t.strictSame(events.length, 2);
|
||||
|
||||
t.strictSame(events[0].event, 'user.updated');
|
||||
t.strictSame(events[0].data.id, fakeGoogleDirectory.users[0].id);
|
||||
t.strictSame(events[0].data.raw, fakeGoogleDirectory.users[0]);
|
||||
|
||||
t.strictSame(events[1].event, 'group.updated');
|
||||
t.strictSame(events[1].data.id, fakeGoogleDirectory.groups[0].id);
|
||||
t.strictSame(events[1].data.raw, fakeGoogleDirectory.groups[0]);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.test('Sync 3', async (t) => {
|
||||
const events: DirectorySyncEvent[] = [];
|
||||
|
||||
// Delete the last user
|
||||
const deleteUser = fakeGoogleDirectory.users.pop();
|
||||
|
||||
// Delete the last group
|
||||
const deleteGroup = fakeGoogleDirectory.groups.pop();
|
||||
|
||||
// Clear the members
|
||||
fakeGoogleDirectory.members.sales = [];
|
||||
|
||||
mockUsersAPI(fakeGoogleDirectory.users);
|
||||
mockGroupsAPI(fakeGoogleDirectory.groups);
|
||||
mockGroupMembersAPI('engineering', fakeGoogleDirectory.members.engineering);
|
||||
|
||||
await directorySyncController.sync(async (event: DirectorySyncEvent) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
nock.cleanAll();
|
||||
|
||||
t.strictSame(events.length, 2);
|
||||
|
||||
t.strictSame(events[0].event, 'user.deleted');
|
||||
t.strictSame(events[0].data.id, deleteUser?.id);
|
||||
t.strictSame(events[0].data.raw, deleteUser);
|
||||
|
||||
t.strictSame(events[1].event, 'group.deleted');
|
||||
t.strictSame(events[1].data.id, deleteGroup?.id);
|
||||
t.strictSame(events[1].data.raw, deleteGroup);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.test('Sync 4', async (t) => {
|
||||
const events: DirectorySyncEvent[] = [];
|
||||
|
||||
// Add new user
|
||||
const newUser = {
|
||||
...fakeGoogleDirectory.users[0],
|
||||
id: 'jackson',
|
||||
primaryEmail: 'jackson@example.com',
|
||||
name: {
|
||||
givenName: 'Jackson',
|
||||
familyName: 'Smith',
|
||||
},
|
||||
};
|
||||
|
||||
// Add new group
|
||||
const newGroup = {
|
||||
...fakeGoogleDirectory.groups[0],
|
||||
id: 'marketing',
|
||||
email: 'marketing@example.com',
|
||||
name: 'Marketing Team',
|
||||
description: 'A group for the marketing department',
|
||||
};
|
||||
|
||||
fakeGoogleDirectory.users.push(newUser);
|
||||
fakeGoogleDirectory.groups.push(newGroup);
|
||||
|
||||
mockUsersAPI(fakeGoogleDirectory.users);
|
||||
mockGroupsAPI(fakeGoogleDirectory.groups);
|
||||
mockGroupMembersAPI('engineering', fakeGoogleDirectory.members.engineering);
|
||||
mockGroupMembersAPI('marketing', fakeGoogleDirectory.members.marketing);
|
||||
|
||||
await directorySyncController.sync(async (event: DirectorySyncEvent) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
nock.cleanAll();
|
||||
|
||||
t.strictSame(events.length, 3);
|
||||
|
||||
t.strictSame(events[0].event, 'user.created');
|
||||
t.strictSame(events[0].data.id, newUser.id);
|
||||
t.strictSame(events[0].data.raw, newUser);
|
||||
|
||||
t.strictSame(events[1].event, 'group.created');
|
||||
t.strictSame(events[1].data.id, newGroup.id);
|
||||
t.strictSame(events[1].data.raw, newGroup);
|
||||
|
||||
t.strictSame(events[2].event, 'group.user_added');
|
||||
t.strictSame(events[2].data.id, newUser.id);
|
||||
t.strictSame(events[2].data.raw, newUser);
|
||||
|
||||
// Check that the user was added to the group
|
||||
if ('group' in events[2].data) {
|
||||
t.strictSame(events[2].data.group.id, newGroup.id);
|
||||
}
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.test('Sync 5', async (t) => {
|
||||
const events: DirectorySyncEvent[] = [];
|
||||
|
||||
// Remove elizasmith from the engineering group
|
||||
fakeGoogleDirectory.members.engineering.shift();
|
||||
|
||||
// Add elizasmith to the marketing group
|
||||
fakeGoogleDirectory.members.marketing.push({
|
||||
kind: 'directory#member',
|
||||
id: 'elizasmith',
|
||||
email: 'eliza@example.com',
|
||||
role: 'MANAGER',
|
||||
type: 'USER',
|
||||
});
|
||||
|
||||
mockUsersAPI(fakeGoogleDirectory.users);
|
||||
mockGroupsAPI(fakeGoogleDirectory.groups);
|
||||
mockGroupMembersAPI('engineering', fakeGoogleDirectory.members.engineering);
|
||||
mockGroupMembersAPI('marketing', fakeGoogleDirectory.members.marketing);
|
||||
|
||||
await directorySyncController.sync(async (event: DirectorySyncEvent) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
nock.cleanAll();
|
||||
|
||||
t.strictSame(events.length, 2);
|
||||
|
||||
t.strictSame(events[0].event, 'group.user_removed');
|
||||
t.strictSame(events[0].data.id, fakeGoogleDirectory.users[0].id);
|
||||
t.strictSame(events[0].data.raw, fakeGoogleDirectory.users[0]);
|
||||
|
||||
// Check that the user was removed from the group
|
||||
if ('group' in events[0].data) {
|
||||
t.strictSame(events[0].data.group.id, fakeGoogleDirectory.groups[0].id);
|
||||
}
|
||||
|
||||
t.strictSame(events[1].event, 'group.user_added');
|
||||
t.strictSame(events[1].data.id, fakeGoogleDirectory.users[0].id);
|
||||
t.strictSame(events[1].data.raw, fakeGoogleDirectory.users[0]);
|
||||
|
||||
// Check that the user was added to the group
|
||||
if ('group' in events[1].data) {
|
||||
t.strictSame(events[1].data.group.id, fakeGoogleDirectory.groups[1].id);
|
||||
}
|
||||
|
||||
t.end();
|
||||
});
|
|
@ -0,0 +1,91 @@
|
|||
import tap from 'tap';
|
||||
|
||||
import { jacksonOptions } from '../utils';
|
||||
import { IDirectorySyncController, DirectoryType, Directory } from '../../src/typings';
|
||||
|
||||
let directory: Directory;
|
||||
let directorySyncController: IDirectorySyncController;
|
||||
|
||||
const directoryPayload = {
|
||||
tenant: 'boxyhq',
|
||||
product: 'saml-jackson-google',
|
||||
name: 'Google Directory',
|
||||
type: 'google' as DirectoryType,
|
||||
google_domain: 'boxyhq.com',
|
||||
google_access_token: 'access_token',
|
||||
google_refresh_token: 'refresh_token',
|
||||
};
|
||||
|
||||
tap.before(async () => {
|
||||
directorySyncController = (await (await import('../../src/index')).default(jacksonOptions))
|
||||
.directorySyncController;
|
||||
|
||||
const { data, error } = await directorySyncController.directories.create(directoryPayload);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
directory = data;
|
||||
});
|
||||
|
||||
tap.teardown(async () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
tap.test('generate the Google API authorization URL', async (t) => {
|
||||
const result = await directorySyncController.google.generateAuthorizationUrl({
|
||||
directoryId: directory.id,
|
||||
});
|
||||
|
||||
t.ok(result);
|
||||
t.strictSame(result.error, null);
|
||||
|
||||
const parsedUrl = new URL(result.data?.authorizationUrl || '');
|
||||
|
||||
t.strictSame(parsedUrl.origin, 'https://accounts.google.com');
|
||||
t.strictSame(parsedUrl.pathname, '/o/oauth2/v2/auth');
|
||||
t.strictSame(
|
||||
{
|
||||
access_type: parsedUrl.searchParams.get('access_type'),
|
||||
prompt: parsedUrl.searchParams.get('prompt'),
|
||||
response_type: parsedUrl.searchParams.get('response_type'),
|
||||
client_id: parsedUrl.searchParams.get('client_id'),
|
||||
redirect_uri: parsedUrl.searchParams.get('redirect_uri'),
|
||||
scope: parsedUrl.searchParams.get('scope'),
|
||||
state: parsedUrl.searchParams.get('state'),
|
||||
},
|
||||
{
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
response_type: 'code',
|
||||
client_id: 'GOOGLE_CLIENT_ID',
|
||||
redirect_uri: `GOOGLE_REDIRECT_URI`,
|
||||
state: JSON.stringify({ directoryId: directory.id }),
|
||||
scope:
|
||||
'https://www.googleapis.com/auth/admin.directory.user.readonly https://www.googleapis.com/auth/admin.directory.group.readonly https://www.googleapis.com/auth/admin.directory.group.member.readonly',
|
||||
}
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.test('set access token and refresh token', async (t) => {
|
||||
const { data: updatedDirectory } = await directorySyncController.google.setToken({
|
||||
directoryId: directory.id,
|
||||
accessToken: 'ACCESS_TOKEN',
|
||||
refreshToken: 'REFRESH_TOKEN',
|
||||
});
|
||||
|
||||
t.ok(updatedDirectory);
|
||||
t.strictSame(updatedDirectory?.google_access_token, 'ACCESS_TOKEN');
|
||||
t.strictSame(updatedDirectory?.google_refresh_token, 'REFRESH_TOKEN');
|
||||
|
||||
const { data: directoryFetched } = await directorySyncController.directories.get(directory.id);
|
||||
|
||||
t.ok(directoryFetched);
|
||||
t.strictSame(directoryFetched?.google_access_token, 'ACCESS_TOKEN');
|
||||
t.strictSame(directoryFetched?.google_refresh_token, 'REFRESH_TOKEN');
|
||||
|
||||
t.end();
|
||||
});
|
|
@ -101,7 +101,7 @@ tap.test('Webhook Events / ', async (t) => {
|
|||
// Create a user
|
||||
await directorySync.requests.handle(usersRequest.create(directory, users[0]), eventCallback);
|
||||
|
||||
const events = await directorySync.webhookLogs.getAll({});
|
||||
const events = await directorySync.webhookLogs.getAll();
|
||||
|
||||
t.equal(events.length, 0);
|
||||
|
||||
|
@ -115,7 +115,7 @@ tap.test('Webhook Events / ', async (t) => {
|
|||
// Create a user
|
||||
await directorySync.requests.handle(usersRequest.create(directory, users[0]), eventCallback);
|
||||
|
||||
const logs = await directorySync.webhookLogs.getAll({});
|
||||
const logs = await directorySync.webhookLogs.getAll();
|
||||
|
||||
const log = await directorySync.webhookLogs.get(logs[0].id);
|
||||
|
||||
|
@ -148,7 +148,7 @@ tap.test('Webhook Events / ', async (t) => {
|
|||
mock.verify();
|
||||
mock.restore();
|
||||
|
||||
const logs = await directorySync.webhookLogs.getAll({});
|
||||
const logs = await directorySync.webhookLogs.getAll();
|
||||
|
||||
t.ok(logs);
|
||||
t.equal(logs.length, 3);
|
||||
|
@ -194,7 +194,7 @@ tap.test('Webhook Events / ', async (t) => {
|
|||
mock.verify();
|
||||
mock.restore();
|
||||
|
||||
const logs = await directorySync.webhookLogs.getAll({});
|
||||
const logs = await directorySync.webhookLogs.getAll();
|
||||
|
||||
t.ok(logs);
|
||||
t.equal(logs.length, 3);
|
||||
|
@ -249,7 +249,7 @@ tap.test('Webhook Events / ', async (t) => {
|
|||
mock.verify();
|
||||
mock.restore();
|
||||
|
||||
const logs = await directorySync.webhookLogs.getAll({});
|
||||
const logs = await directorySync.webhookLogs.getAll();
|
||||
|
||||
t.ok(logs);
|
||||
t.equal(logs.length, 4);
|
||||
|
|
|
@ -6,6 +6,7 @@ import * as dbutils from '../../src/db/utils';
|
|||
import controllers from '../../src/index';
|
||||
import loadConnection from '../../src/loadConnection';
|
||||
import {
|
||||
IAdminController,
|
||||
IConnectionAPIController,
|
||||
SAMLSSOConnection,
|
||||
SAMLSSOConnectionWithEncodedMetadata,
|
||||
|
@ -657,4 +658,19 @@ tap.test('controller/api', async (t) => {
|
|||
clientSecret,
|
||||
});
|
||||
});
|
||||
|
||||
t.test('Should be able to fetch connections by product', async (t) => {
|
||||
const connectionCreated = await connectionAPIController.createSAMLConnection(
|
||||
saml_connection as SAMLSSOConnectionWithEncodedMetadata
|
||||
);
|
||||
|
||||
const connections = await connectionAPIController.getConnectionsByProduct({
|
||||
product: connectionCreated.product,
|
||||
});
|
||||
|
||||
t.ok(connections.data.length === 1);
|
||||
t.ok(connections.data[0].product, connectionCreated.product);
|
||||
|
||||
t.end();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -36,6 +36,15 @@ const jacksonOptions = <JacksonOption>{
|
|||
},
|
||||
boxyhqLicenseKey: 'dummy-license',
|
||||
noAnalytics: true,
|
||||
dsync: {
|
||||
providers: {
|
||||
google: {
|
||||
clientId: 'GOOGLE_CLIENT_ID',
|
||||
clientSecret: 'GOOGLE_CLIENT_SECRET',
|
||||
callbackUrl: 'GOOGLE_REDIRECT_URI',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export { addSSOConnections, jacksonOptions };
|
||||
|
|
File diff suppressed because it is too large
Load Diff
34
package.json
34
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "jackson",
|
||||
"version": "1.9.11",
|
||||
"version": "1.10.1",
|
||||
"private": true,
|
||||
"description": "SAML 2.0 service",
|
||||
"keywords": [
|
||||
|
@ -43,24 +43,24 @@
|
|||
"prebuild": "mkdirp public/terminus && (cp node_modules/blockly/media/sprites.png public/terminus || copy .\\node_modules\\blockly\\media\\sprites.png .\\public\\terminus)"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boxyhq/metrics": "0.2.2",
|
||||
"@boxyhq/react-ui": "2.0.0",
|
||||
"@boxyhq/metrics": "0.2.4",
|
||||
"@boxyhq/react-ui": "3.0.0",
|
||||
"@boxyhq/saml-jackson": "file:npm",
|
||||
"@heroicons/react": "2.0.18",
|
||||
"@retracedhq/logs-viewer": "2.4.6",
|
||||
"@retracedhq/retraced": "0.6.3",
|
||||
"@retracedhq/logs-viewer": "2.5.0",
|
||||
"@retracedhq/retraced": "0.7.0",
|
||||
"@tailwindcss/typography": "0.5.9",
|
||||
"axios": "1.4.0",
|
||||
"blockly": "9.3.3",
|
||||
"classnames": "2.3.2",
|
||||
"cors": "2.8.5",
|
||||
"daisyui": "2.52.0",
|
||||
"i18next": "22.5.0",
|
||||
"i18next": "22.5.1",
|
||||
"micromatch": "4.0.5",
|
||||
"next": "13.2.4",
|
||||
"next": "13.4.7",
|
||||
"next-auth": "4.22.1",
|
||||
"next-i18next": "13.2.2",
|
||||
"nodemailer": "6.9.1",
|
||||
"next-i18next": "13.3.0",
|
||||
"nodemailer": "6.9.3",
|
||||
"raw-body": "2.5.2",
|
||||
"react": "18.2.0",
|
||||
"react-daisyui": "3.1.2",
|
||||
|
@ -68,25 +68,25 @@
|
|||
"react-i18next": "12.3.1",
|
||||
"react-syntax-highlighter": "15.5.0",
|
||||
"sharp": "0.32.1",
|
||||
"swr": "2.1.5"
|
||||
"swr": "2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apidevtools/swagger-cli": "4.0.4",
|
||||
"@playwright/test": "1.34.3",
|
||||
"@playwright/test": "1.35.1",
|
||||
"@types/cors": "2.8.13",
|
||||
"@types/micromatch": "4.0.2",
|
||||
"@types/node": "20.1.4",
|
||||
"@types/react": "18.2.8",
|
||||
"@types/node": "20.3.1",
|
||||
"@types/react": "18.2.12",
|
||||
"@typescript-eslint/eslint-plugin": "5.59.7",
|
||||
"@typescript-eslint/parser": "5.59.8",
|
||||
"@typescript-eslint/parser": "5.60.1",
|
||||
"autoprefixer": "10.4.14",
|
||||
"cross-env": "7.0.3",
|
||||
"env-cmd": "10.1.0",
|
||||
"eslint": "8.41.0",
|
||||
"eslint-config-next": "13.4.4",
|
||||
"eslint": "8.43.0",
|
||||
"eslint-config-next": "13.4.7",
|
||||
"eslint-config-prettier": "8.8.0",
|
||||
"mkdirp": "3.0.1",
|
||||
"postcss": "8.4.23",
|
||||
"postcss": "8.4.24",
|
||||
"prettier": "2.8.8",
|
||||
"prettier-plugin-tailwindcss": "0.3.0",
|
||||
"swagger-jsdoc": "6.2.8",
|
||||
|
|
|
@ -41,7 +41,7 @@ const Login = ({
|
|||
return;
|
||||
}
|
||||
|
||||
const onSSOSubmit = async (ssoIdentifier: string) => {
|
||||
const onSSOSubmit = async ({ ssoIdentifier }) => {
|
||||
await signIn('boxyhq-saml', undefined, { client_id: ssoIdentifier });
|
||||
};
|
||||
|
||||
|
|
|
@ -124,7 +124,7 @@ const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { connectionAPIController } = await jackson();
|
||||
|
||||
const { clientID, clientSecret } = req.body as {
|
||||
const { clientID, clientSecret } = req.query as {
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
|
|
|
@ -35,7 +35,7 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
}
|
||||
|
||||
const event = await directorySyncController.webhookLogs
|
||||
.with(directory.tenant, directory.product)
|
||||
.setTenantAndProduct(directory.tenant, directory.product)
|
||||
.get(eventId);
|
||||
|
||||
return res.status(200).json({ data: event });
|
||||
|
|
|
@ -40,11 +40,13 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
const pageOffset = parseInt(offset);
|
||||
const pageLimit = parseInt(limit);
|
||||
|
||||
const events = await directorySyncController.webhookLogs.with(directory.tenant, directory.product).getAll({
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
directoryId,
|
||||
});
|
||||
const events = await directorySyncController.webhookLogs
|
||||
.setTenantAndProduct(directory.tenant, directory.product)
|
||||
.getAll({
|
||||
pageOffset,
|
||||
pageLimit,
|
||||
directoryId,
|
||||
});
|
||||
|
||||
return res.status(200).json({ data: events });
|
||||
};
|
||||
|
@ -65,7 +67,9 @@ const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
return res.status(404).json({ error: { message: 'Directory not found.' } });
|
||||
}
|
||||
|
||||
await directorySyncController.webhookLogs.with(directory.tenant, directory.product).deleteAll(directoryId);
|
||||
await directorySyncController.webhookLogs
|
||||
.setTenantAndProduct(directory.tenant, directory.product)
|
||||
.deleteAll(directoryId);
|
||||
|
||||
return res.status(200).json({ data: null });
|
||||
};
|
||||
|
|
|
@ -27,7 +27,7 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
}
|
||||
|
||||
const { data: group, error } = await directorySyncController.groups
|
||||
.with(directory.tenant, directory.product)
|
||||
.setTenantAndProduct(directory.tenant, directory.product)
|
||||
.get(groupId);
|
||||
|
||||
if (error) {
|
||||
|
|
|
@ -30,7 +30,7 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
const pageLimit = parseInt(limit);
|
||||
|
||||
const { data: groups, error } = await directorySyncController.groups
|
||||
.with(directory.tenant, directory.product)
|
||||
.setTenantAndProduct(directory.tenant, directory.product)
|
||||
.getAll({ pageOffset, pageLimit, directoryId });
|
||||
|
||||
if (error) {
|
||||
|
|
|
@ -27,7 +27,7 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
}
|
||||
|
||||
const { data: user, error } = await directorySyncController.users
|
||||
.with(directory.tenant, directory.product)
|
||||
.setTenantAndProduct(directory.tenant, directory.product)
|
||||
.get(userId);
|
||||
|
||||
if (error) {
|
||||
|
|
|
@ -30,7 +30,7 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
const pageLimit = parseInt(limit);
|
||||
|
||||
const { data: users, error } = await directorySyncController.users
|
||||
.with(directory.tenant, directory.product)
|
||||
.setTenantAndProduct(directory.tenant, directory.product)
|
||||
.getAll({ pageOffset, pageLimit, directoryId });
|
||||
|
||||
if (error) {
|
||||
|
|
|
@ -20,7 +20,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { name, tenant, product, type, webhook_url, webhook_secret } = req.body;
|
||||
const { name, tenant, product, type, webhook_url, webhook_secret, google_domain } = req.body;
|
||||
|
||||
const { data, error } = await directorySyncController.directories.create({
|
||||
name,
|
||||
|
@ -29,6 +29,7 @@ const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
type: type as DirectoryType,
|
||||
webhook_url,
|
||||
webhook_secret,
|
||||
google_domain,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import jackson from '@lib/jackson';
|
||||
import { validateApiKey } from '@lib/auth';
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { apiKey } = req.query as { apiKey: string };
|
||||
|
||||
try {
|
||||
if (validateApiKey(apiKey) === false) {
|
||||
throw new Error('Please provide a valid Jackson API key');
|
||||
}
|
||||
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
await directorySyncController.sync(directorySyncController.events.callback);
|
||||
|
||||
res.status(200).json({ message: 'Sync completed' });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ message: e.message || 'Sync failed' });
|
||||
}
|
||||
};
|
||||
|
||||
export default handler;
|
|
@ -0,0 +1,36 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import jackson from '@lib/jackson';
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { method } = req;
|
||||
|
||||
if (method !== 'GET') {
|
||||
return res
|
||||
.setHeader('Allow', 'GET')
|
||||
.status(405)
|
||||
.json({ error: { message: `Method ${method} Not Allowed` } });
|
||||
}
|
||||
|
||||
const { directoryId } = req.query as { directoryId: string };
|
||||
|
||||
try {
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { data, error } = await directorySyncController.google.generateAuthorizationUrl({
|
||||
directoryId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res.redirect(data.authorizationUrl).end();
|
||||
} catch (error: any) {
|
||||
const { message, statusCode = 500 } = error;
|
||||
|
||||
return res.status(statusCode).json({ error: { message } });
|
||||
}
|
||||
};
|
||||
|
||||
export default handler;
|
|
@ -0,0 +1,53 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import jackson from '@lib/jackson';
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { method } = req;
|
||||
|
||||
if (method !== 'GET') {
|
||||
return res
|
||||
.setHeader('Allow', 'GET')
|
||||
.status(405)
|
||||
.json({ error: { message: `Method ${method} Not Allowed` } });
|
||||
}
|
||||
|
||||
const { code, state } = req.query as { code: string; state: string };
|
||||
|
||||
try {
|
||||
const { directoryId } = JSON.parse(state);
|
||||
|
||||
if (!directoryId) {
|
||||
throw new Error('Directory ID not found in state.');
|
||||
}
|
||||
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
// Fetch the access token and refresh token from the authorization code
|
||||
const tokenResponse = await directorySyncController.google.getAccessToken({
|
||||
directoryId,
|
||||
code,
|
||||
});
|
||||
|
||||
if (tokenResponse.error) {
|
||||
throw tokenResponse.error;
|
||||
}
|
||||
|
||||
// Set the access token and refresh token for the directory
|
||||
const response = await directorySyncController.google.setToken({
|
||||
directoryId,
|
||||
accessToken: tokenResponse.data.access_token,
|
||||
refreshToken: tokenResponse.data.refresh_token,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return res.send('Authorized done successfully. You may close this window.');
|
||||
} catch (error: any) {
|
||||
return res.status(500).send({ error });
|
||||
}
|
||||
};
|
||||
|
||||
export default handler;
|
|
@ -68,7 +68,7 @@ const handleDELETE = async (req: NextApiRequest, res: NextApiResponse, setupLink
|
|||
const { connectionAPIController } = await jackson();
|
||||
|
||||
const body = {
|
||||
...req.body,
|
||||
...req.query,
|
||||
tenant: setupLink.tenant,
|
||||
product: setupLink.product,
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import jackson from '@lib/jackson';
|
||||
import { oidcMetadataParse, strategyChecker } from '@lib/utils';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { GetConnectionsQuery } from '@boxyhq/saml-jackson';
|
||||
import type { DelConnectionsQuery, GetConnectionsQuery } from '@boxyhq/saml-jackson';
|
||||
import { sendAudit } from '@ee/audit-log/lib/retraced';
|
||||
import { extractAuthToken, redactApiKey } from '@lib/auth';
|
||||
|
||||
|
@ -94,7 +94,7 @@ const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { connectionAPIController } = await jackson();
|
||||
|
||||
await connectionAPIController.deleteConnections(req.body);
|
||||
await connectionAPIController.deleteConnections(req.query as DelConnectionsQuery);
|
||||
|
||||
sendAudit({
|
||||
action: 'sso.connection.delete',
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
import jackson from '@lib/jackson';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req;
|
||||
|
||||
try {
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return await handleGET(req, res);
|
||||
default:
|
||||
res.setHeader('Allow', 'GET');
|
||||
res.status(405).json({ error: { message: `Method ${method} Not Allowed` } });
|
||||
}
|
||||
} catch (error: any) {
|
||||
const { message, statusCode = 500 } = error;
|
||||
|
||||
return res.status(statusCode).json({ error: { message } });
|
||||
}
|
||||
}
|
||||
|
||||
// Get the connections filtered by the product
|
||||
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { connectionAPIController } = await jackson();
|
||||
|
||||
const { product, pageOffset, pageLimit, pageToken } = req.query as {
|
||||
product: string;
|
||||
pageOffset: string;
|
||||
pageLimit: string;
|
||||
pageToken?: string;
|
||||
};
|
||||
|
||||
const connections = await connectionAPIController.getConnectionsByProduct({
|
||||
product,
|
||||
pageOffset: parseInt(pageOffset),
|
||||
pageLimit: parseInt(pageLimit),
|
||||
pageToken,
|
||||
});
|
||||
|
||||
return res.status(200).json(connections.data);
|
||||
};
|
|
@ -0,0 +1,45 @@
|
|||
import jackson from '@lib/jackson';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req;
|
||||
|
||||
try {
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return await handleGET(req, res);
|
||||
default:
|
||||
res.setHeader('Allow', 'GET');
|
||||
res.status(405).json({ error: { message: `Method ${method} Not Allowed` } });
|
||||
}
|
||||
} catch (error: any) {
|
||||
const { message, statusCode = 500 } = error;
|
||||
|
||||
return res.status(statusCode).json({ error: { message } });
|
||||
}
|
||||
}
|
||||
|
||||
// Get the connections filtered by the product
|
||||
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { directorySyncController } = await jackson();
|
||||
|
||||
const { product, pageOffset, pageLimit, pageToken } = req.query as {
|
||||
product: string;
|
||||
pageOffset: string;
|
||||
pageLimit: string;
|
||||
pageToken?: string;
|
||||
};
|
||||
|
||||
if (!product) {
|
||||
throw new Error('Please provide a product');
|
||||
}
|
||||
|
||||
const connections = await directorySyncController.directories.filterBy({
|
||||
product,
|
||||
pageOffset: parseInt(pageOffset),
|
||||
pageLimit: parseInt(pageLimit),
|
||||
pageToken,
|
||||
});
|
||||
|
||||
return res.status(200).json(connections);
|
||||
};
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import jackson from '@lib/jackson';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { GetConfigQuery } from '@boxyhq/saml-jackson';
|
||||
import type { DelConnectionsQuery, GetConfigQuery } from '@boxyhq/saml-jackson';
|
||||
import { sendAudit } from '@ee/audit-log/lib/retraced';
|
||||
import { extractAuthToken, redactApiKey } from '@lib/auth';
|
||||
|
||||
|
@ -38,7 +38,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
|
||||
return res.status(204).end(connection);
|
||||
} else if (req.method === 'DELETE') {
|
||||
const connection = await connectionAPIController.deleteConfig(req.body);
|
||||
const connection = await connectionAPIController.deleteConfig(req.query as DelConnectionsQuery);
|
||||
|
||||
sendAudit({
|
||||
action: 'sso.connection.delete',
|
||||
|
|
|
@ -85,7 +85,7 @@ const IdpSelector = ({ connections }: { connections: (OIDCSSORecord | SAMLSSORec
|
|||
onClick={() => {
|
||||
connectionSelected(connection.clientID);
|
||||
}}>
|
||||
<div className='flex items-center gap-2 py-3 px-3'>
|
||||
<div className='flex items-center gap-2 px-3 py-3'>
|
||||
<div className='placeholder avatar'>
|
||||
<div className='w-8 rounded-full bg-primary text-white'>
|
||||
<span className='text-lg font-bold'>{name.charAt(0).toUpperCase()}</span>
|
||||
|
@ -134,7 +134,7 @@ const AppSelector = ({
|
|||
{connections.map((connection) => {
|
||||
return (
|
||||
<li key={connection.clientID} className='rounded bg-gray-100'>
|
||||
<div className='flex items-center gap-2 py-3 px-3'>
|
||||
<div className='flex items-center gap-2 px-3 py-3'>
|
||||
<input
|
||||
type='radio'
|
||||
name='idp_hint'
|
||||
|
@ -175,14 +175,12 @@ export const getServerSideProps = async ({ query, locale, req }) => {
|
|||
if (idp_hint) {
|
||||
const params = new URLSearchParams(paramsToRelay).toString();
|
||||
|
||||
const destinations = {
|
||||
saml: `/api/federated-saml/sso?${params}`,
|
||||
oauth: `/api/oauth/authorize?${params}`,
|
||||
};
|
||||
const destination =
|
||||
authFlow === 'saml' ? `/api/federated-saml/sso?${params}` : `/api/oauth/authorize?${params}`;
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
destination: destinations[authFlow],
|
||||
destination,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"info": {
|
||||
"title": "SAML Jackson API",
|
||||
"version": "1.9.7",
|
||||
"version": "1.10.1",
|
||||
"description": "This is the API documentation for SAML Jackson service.",
|
||||
"termsOfService": "https://boxyhq.com/terms.html",
|
||||
"contact": {
|
||||
|
@ -444,10 +444,6 @@
|
|||
"tags": [
|
||||
"Connections"
|
||||
],
|
||||
"consumes": [
|
||||
"application/x-www-form-urlencoded",
|
||||
"application/json"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
|
@ -461,6 +457,31 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/connections/product": {
|
||||
"get": {
|
||||
"summary": "Get SSO Connections by product",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/parameters/productParamGet"
|
||||
}
|
||||
],
|
||||
"operationId": "get-connections-by-product",
|
||||
"tags": [
|
||||
"Connections"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/200Get"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/responses/400Get"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/responses/401Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/oauth/token": {
|
||||
"post": {
|
||||
"summary": "Code exchange",
|
||||
|
@ -685,7 +706,7 @@
|
|||
}
|
||||
},
|
||||
"400Get": {
|
||||
"description": "Please provide `clientID` or `tenant` and `product`."
|
||||
"description": "Please provide a `product`."
|
||||
},
|
||||
"401Get": {
|
||||
"description": "Unauthorized"
|
||||
|
@ -878,7 +899,8 @@
|
|||
"in": "query",
|
||||
"name": "product",
|
||||
"type": "string",
|
||||
"description": "Product"
|
||||
"description": "Product",
|
||||
"required": true
|
||||
},
|
||||
"clientIDParamGet": {
|
||||
"in": "query",
|
||||
|
@ -894,31 +916,31 @@
|
|||
},
|
||||
"clientIDDel": {
|
||||
"name": "clientID",
|
||||
"in": "formData",
|
||||
"in": "query",
|
||||
"type": "string",
|
||||
"description": "Client ID"
|
||||
},
|
||||
"clientSecretDel": {
|
||||
"name": "clientSecret",
|
||||
"in": "formData",
|
||||
"in": "query",
|
||||
"type": "string",
|
||||
"description": "Client Secret"
|
||||
},
|
||||
"tenantDel": {
|
||||
"name": "tenant",
|
||||
"in": "formData",
|
||||
"in": "query",
|
||||
"type": "string",
|
||||
"description": "Tenant"
|
||||
},
|
||||
"productDel": {
|
||||
"name": "product",
|
||||
"in": "formData",
|
||||
"in": "query",
|
||||
"type": "string",
|
||||
"description": "Product"
|
||||
},
|
||||
"strategyDel": {
|
||||
"name": "strategy",
|
||||
"in": "formData",
|
||||
"in": "query",
|
||||
"type": "string",
|
||||
"description": "Strategy which can help to filter connections with tenant/product query"
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
ARG NODEJS_IMAGE=node:18.15.0-alpine3.17
|
||||
ARG NODEJS_IMAGE=node:18.16.1-alpine3.18
|
||||
FROM --platform=$BUILDPLATFORM $NODEJS_IMAGE AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
|
|
Loading…
Reference in New Issue