mirror of https://github.com/boxyhq/jackson.git
694 lines
18 KiB
TypeScript
694 lines
18 KiB
TypeScript
import { randomUUID } from 'crypto';
|
|
|
|
import type {
|
|
Storable,
|
|
Directory,
|
|
JacksonOption,
|
|
DatabaseStore,
|
|
DirectoryType,
|
|
PaginationParams,
|
|
IUsers,
|
|
IGroups,
|
|
IWebhookEventsLogger,
|
|
IEventController,
|
|
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';
|
|
import * as metrics from '../../opentelemetry/metrics';
|
|
|
|
interface DirectoryConfigParams {
|
|
db: DatabaseStore;
|
|
opts: JacksonOption;
|
|
users: IUsers;
|
|
groups: IGroups;
|
|
logger: IWebhookEventsLogger;
|
|
eventController: IEventController;
|
|
}
|
|
|
|
interface FilterByParams extends PaginationParams {
|
|
product?: string;
|
|
provider?: DirectoryType;
|
|
}
|
|
|
|
/**
|
|
* @swagger
|
|
* definitions:
|
|
* Directory:
|
|
* type: object
|
|
* properties:
|
|
* id:
|
|
* type: string
|
|
* description: Directory ID
|
|
* name:
|
|
* type: string
|
|
* description: name
|
|
* tenant:
|
|
* type: string
|
|
* description: Tenant
|
|
* product:
|
|
* type: string
|
|
* description: Product
|
|
* type:
|
|
* type: string
|
|
* description: Directory provider
|
|
* deactivated:
|
|
* type: boolean
|
|
* description: Status
|
|
* log_webhook_events:
|
|
* type: boolean
|
|
* description: If true, webhook requests will be logged
|
|
* scim:
|
|
* type: object
|
|
* properties:
|
|
* path:
|
|
* type: string
|
|
* description: SCIM path
|
|
* endpoint:
|
|
* type: string
|
|
* description: SCIM url
|
|
* secret:
|
|
* type: string
|
|
* description: SCIM secret
|
|
* webhook:
|
|
* type: object
|
|
* properties:
|
|
* endpoint:
|
|
* type: string
|
|
* description: Webhook url
|
|
* secret:
|
|
* type: string
|
|
* description: Webhook secret
|
|
*/
|
|
export class DirectoryConfig {
|
|
private _store: Storable | null = null;
|
|
private opts: JacksonOption;
|
|
private db: DatabaseStore;
|
|
private users: IUsers;
|
|
private groups: IGroups;
|
|
private logger: IWebhookEventsLogger;
|
|
private eventController: IEventController;
|
|
|
|
constructor({ db, opts, users, groups, logger, eventController }: DirectoryConfigParams) {
|
|
this.opts = opts;
|
|
this.db = db;
|
|
this.users = users;
|
|
this.groups = groups;
|
|
this.logger = logger;
|
|
this.eventController = eventController;
|
|
}
|
|
|
|
// Return the database store
|
|
private store(): Storable {
|
|
return this._store || (this._store = this.db.store(storeNamespacePrefix.dsync.config));
|
|
}
|
|
|
|
/**
|
|
* @swagger
|
|
* parameters:
|
|
* tenant:
|
|
* name: tenant
|
|
* description: Tenant
|
|
* in: query
|
|
* required: true
|
|
* type: string
|
|
* product:
|
|
* name: product
|
|
* description: Product
|
|
* in: query
|
|
* required: true
|
|
* type: string
|
|
* directoryId:
|
|
* name: directory id
|
|
* description: Directory ID
|
|
* in: query
|
|
* required: false
|
|
* type: string
|
|
* pageOffset:
|
|
* name: pageOffset
|
|
* description: Starting point from which the set of records are retrieved
|
|
* in: query
|
|
* required: false
|
|
* type: string
|
|
* pageLimit:
|
|
* name: pageLimit
|
|
* description: Number of records to be fetched for the page
|
|
* in: query
|
|
* required: false
|
|
* type: string
|
|
* pageToken:
|
|
* name: pageToken
|
|
* description: Token used for DynamoDB pagination
|
|
* in: query
|
|
* required: false
|
|
* type: string
|
|
*/
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/v1/dsync:
|
|
* post:
|
|
* summary: Create a directory connection
|
|
* parameters:
|
|
* - name: tenant
|
|
* description: Tenant
|
|
* in: formData
|
|
* required: true
|
|
* type: string
|
|
* - name: product
|
|
* description: Product
|
|
* in: formData
|
|
* required: true
|
|
* type: string
|
|
* - name: name
|
|
* description: Name
|
|
* in: formData
|
|
* required: false
|
|
* type: string
|
|
* - name: webhook_url
|
|
* description: Webhook URL
|
|
* in: formData
|
|
* required: false
|
|
* type: string
|
|
* - name: webhook_secret
|
|
* description: Webhook secret
|
|
* in: formData
|
|
* required: false
|
|
* type: string
|
|
* - name: type
|
|
* description: Directory provider. (Supported values are azure-scim-v2, onelogin-scim-v2, okta-scim-v2, jumpcloud-scim-v2, generic-scim-v2, google)
|
|
* in: formData
|
|
* required: false
|
|
* type: string
|
|
* tags: [Directory Sync]
|
|
* produces:
|
|
* - application/json
|
|
* consumes:
|
|
* - application/x-www-form-urlencoded
|
|
* - application/json
|
|
* responses:
|
|
* 200:
|
|
* description: Success
|
|
* schema:
|
|
* $ref: '#/definitions/Directory'
|
|
*/
|
|
public async create(params: {
|
|
name?: string;
|
|
tenant: string;
|
|
product: string;
|
|
webhook_url?: string;
|
|
webhook_secret?: string;
|
|
type?: DirectoryType;
|
|
google_domain?: string;
|
|
google_access_token?: string;
|
|
google_refresh_token?: string;
|
|
}): Promise<Response<Directory>> {
|
|
metrics.increment('createDsyncConnection');
|
|
|
|
try {
|
|
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);
|
|
}
|
|
|
|
// Validate the directory type
|
|
if (!Object.keys(getDirectorySyncProviders()).includes(type)) {
|
|
throw new JacksonError('Invalid directory type.', 400);
|
|
}
|
|
|
|
validateTenantAndProduct(tenant, product);
|
|
|
|
const directoryName = name || `scim-${tenant}-${product}`;
|
|
const id = randomUUID();
|
|
const hasWebhook = webhook_url && webhook_secret;
|
|
const isSCIMProvider = isSCIMEnabledProvider(type);
|
|
|
|
let directory: Directory = {
|
|
id,
|
|
name: directoryName,
|
|
tenant,
|
|
product,
|
|
type,
|
|
log_webhook_events: false,
|
|
webhook: {
|
|
endpoint: hasWebhook ? webhook_url : '',
|
|
secret: hasWebhook ? webhook_secret : '',
|
|
},
|
|
scim: isSCIMProvider
|
|
? {
|
|
path: `${this.opts.scimPath}/${id}`,
|
|
secret: await createRandomSecret(16),
|
|
}
|
|
: {
|
|
path: '',
|
|
secret: '',
|
|
},
|
|
};
|
|
|
|
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);
|
|
|
|
await this.eventController.notify('dsync.created', connection);
|
|
|
|
return { data: connection, error: null };
|
|
} catch (err: any) {
|
|
return apiError(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/v1/dsync/{directoryId}:
|
|
* get:
|
|
* summary: Get a directory connection by id
|
|
* parameters:
|
|
* - name: directoryId
|
|
* description: Directory ID
|
|
* in: path
|
|
* required: true
|
|
* type: string
|
|
* tags:
|
|
* - Directory Sync
|
|
* produces:
|
|
* - application/json
|
|
* responses:
|
|
* '200':
|
|
* description: Success
|
|
* schema:
|
|
* $ref: '#/definitions/Directory'
|
|
*/
|
|
public async get(id: string): Promise<Response<Directory>> {
|
|
metrics.increment('getDsyncConnections');
|
|
|
|
try {
|
|
if (!id) {
|
|
throw new JacksonError('Missing required parameters.', 400);
|
|
}
|
|
|
|
const directory: Directory = await this.store().get(id);
|
|
|
|
if (!directory) {
|
|
throw new JacksonError('Directory configuration not found.', 404);
|
|
}
|
|
|
|
return { data: this.transform(directory), error: null };
|
|
} catch (err: any) {
|
|
return apiError(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/v1/dsync/{directoryId}:
|
|
* patch:
|
|
* summary: Update a directory connection
|
|
* parameters:
|
|
* - name: directoryId
|
|
* description: Directory ID
|
|
* in: path
|
|
* required: true
|
|
* type: string
|
|
* - name: name
|
|
* description: Name
|
|
* in: formData
|
|
* required: false
|
|
* type: string
|
|
* - name: webhook_url
|
|
* description: Webhook URL
|
|
* in: formData
|
|
* required: false
|
|
* type: string
|
|
* - name: webhook_secret
|
|
* description: Webhook secret
|
|
* in: formData
|
|
* required: false
|
|
* type: string
|
|
* - name: log_webhook_events
|
|
* description: If true, webhook requests will be logged
|
|
* in: formData
|
|
* required: false
|
|
* type: string
|
|
* - name: deactivated
|
|
* description: If true, the directory connection will be deactivated
|
|
* in: formData
|
|
* required: false
|
|
* type: string
|
|
* - name: google_domain
|
|
* description: Google domain
|
|
* in: formData
|
|
* required: false
|
|
* type: string
|
|
* - name: google_access_token
|
|
* description: Google access token
|
|
* in: formData
|
|
* required: false
|
|
* type: string
|
|
* - name: google_refresh_token
|
|
* description: Google refresh token
|
|
* in: formData
|
|
* required: false
|
|
* type: string
|
|
* tags: [Directory Sync]
|
|
* produces:
|
|
* - application/json
|
|
* consumes:
|
|
* - application/x-www-form-urlencoded
|
|
* - application/json
|
|
* responses:
|
|
* 200:
|
|
* description: Success
|
|
* schema:
|
|
* $ref: '#/definitions/Directory'
|
|
*/
|
|
public async update(
|
|
id: string,
|
|
param: Omit<Partial<Directory>, 'id' | 'tenant' | 'prodct' | 'scim' | 'type'> & {
|
|
webhook_url?: string;
|
|
webhook_secret?: string;
|
|
}
|
|
): Promise<Response<Directory>> {
|
|
try {
|
|
if (!id) {
|
|
throw new JacksonError('Missing required parameters.', 400);
|
|
}
|
|
|
|
const directory: Directory = await this.store().get(id);
|
|
const toUpdate = {
|
|
...directory,
|
|
};
|
|
|
|
const propertiesToUpdate = [
|
|
'name',
|
|
'log_webhook_events',
|
|
'webhook',
|
|
'deactivated',
|
|
'google_domain',
|
|
'google_access_token',
|
|
'google_refresh_token',
|
|
];
|
|
|
|
for (const property of propertiesToUpdate) {
|
|
if (property in param) {
|
|
toUpdate[property] = param[property];
|
|
}
|
|
}
|
|
|
|
if ('webhook_url' in param) {
|
|
toUpdate['webhook']['endpoint'] = param.webhook_url || '';
|
|
}
|
|
|
|
if ('webhook_secret' in param) {
|
|
toUpdate['webhook']['secret'] = param.webhook_secret || '';
|
|
}
|
|
|
|
await this.store().put(id, toUpdate);
|
|
|
|
const updatedDirectory = this.transform(toUpdate);
|
|
|
|
if ('deactivated' in param) {
|
|
if (isConnectionActive(updatedDirectory)) {
|
|
await this.eventController.notify('dsync.activated', updatedDirectory);
|
|
} else {
|
|
await this.eventController.notify('dsync.deactivated', updatedDirectory);
|
|
}
|
|
}
|
|
|
|
return { data: updatedDirectory, error: null };
|
|
} catch (err: any) {
|
|
return apiError(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/v1/dsync:
|
|
* get:
|
|
* summary: Get a directory connection by tenant and product
|
|
* parameters:
|
|
* - $ref: '#/parameters/tenant'
|
|
* - $ref: '#/parameters/product'
|
|
* tags: [Directory Sync]
|
|
* produces:
|
|
* - application/json
|
|
* consumes:
|
|
* - application/x-www-form-urlencoded
|
|
* - application/json
|
|
* responses:
|
|
* 200:
|
|
* description: Success
|
|
* schema:
|
|
* type: array
|
|
* items:
|
|
* $ref: '#/definitions/Directory'
|
|
*/
|
|
public async getByTenantAndProduct(tenant: string, product: string): Promise<Response<Directory[]>> {
|
|
metrics.increment('getDsyncConnections');
|
|
|
|
try {
|
|
if (!tenant || !product) {
|
|
throw new JacksonError('Missing required parameters.', 400);
|
|
}
|
|
|
|
const { data: directories } = await this.store().getByIndex({
|
|
name: IndexNames.TenantProduct,
|
|
value: dbutils.keyFromParts(tenant, product),
|
|
});
|
|
|
|
const transformedDirectories = directories.map((directory) => this.transform(directory));
|
|
|
|
return { data: transformedDirectories, error: null };
|
|
} catch (err: any) {
|
|
return apiError(err);
|
|
}
|
|
}
|
|
|
|
// Get directory connections with pagination
|
|
public async getAll(
|
|
params: PaginationParams = {}
|
|
): Promise<Response<Directory[]> & { pageToken?: string }> {
|
|
metrics.increment('getDsyncConnections');
|
|
|
|
const { pageOffset, pageLimit, pageToken } = params;
|
|
|
|
try {
|
|
const { data: directories, pageToken: nextPageToken } = await this.store().getAll(
|
|
pageOffset,
|
|
pageLimit,
|
|
pageToken
|
|
);
|
|
|
|
const transformedDirectories = directories
|
|
? directories.map((directory) => this.transform(directory))
|
|
: [];
|
|
|
|
return {
|
|
data: transformedDirectories,
|
|
pageToken: nextPageToken,
|
|
error: null,
|
|
};
|
|
} catch (err: any) {
|
|
return apiError(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/v1/dsync/{directoryId}:
|
|
* delete:
|
|
* summary: Delete a directory connection by id
|
|
* parameters:
|
|
* - name: directoryId
|
|
* description: Directory ID
|
|
* in: path
|
|
* required: true
|
|
* type: string
|
|
* tags:
|
|
* - Directory Sync
|
|
* produces:
|
|
* - application/json
|
|
* responses:
|
|
* '200':
|
|
* description: Success
|
|
*/
|
|
public async delete(id: string): Promise<Response<null>> {
|
|
metrics.increment('deleteDsyncConnections');
|
|
|
|
try {
|
|
if (!id) {
|
|
throw new JacksonError('Missing required parameter.', 400);
|
|
}
|
|
|
|
const { data: directory } = await this.get(id);
|
|
|
|
if (!directory) {
|
|
throw new JacksonError('Directory configuration not found.', 404);
|
|
}
|
|
|
|
const { tenant, product } = directory;
|
|
|
|
// Delete the configuration
|
|
await this.store().delete(id);
|
|
|
|
// Delete the groups
|
|
await this.groups.setTenantAndProduct(tenant, product).deleteAll(id);
|
|
|
|
// Delete the users
|
|
await this.users.setTenantAndProduct(tenant, product).deleteAll(id);
|
|
|
|
// Delete the webhook events
|
|
await this.logger.setTenantAndProduct(tenant, product).deleteAll(id);
|
|
|
|
await this.eventController.notify('dsync.deleted', directory);
|
|
|
|
return { data: null, error: null };
|
|
} catch (err: any) {
|
|
return apiError(err);
|
|
}
|
|
}
|
|
|
|
private transform(directory: Directory): Directory {
|
|
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`;
|
|
}
|
|
|
|
// Construct the SCIM endpoint
|
|
directory.scim.endpoint = `${this.opts.externalUrl}${directory.scim.path}`;
|
|
}
|
|
|
|
if (!('deactivated' in directory)) {
|
|
directory.deactivated = false;
|
|
}
|
|
|
|
return directory;
|
|
}
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/v1/dsync/product:
|
|
* get:
|
|
* summary: Get directory connections by product
|
|
* parameters:
|
|
* - $ref: '#/parameters/product'
|
|
* - $ref: '#/parameters/pageOffset'
|
|
* - $ref: '#/parameters/pageLimit'
|
|
* - $ref: '#/parameters/pageToken'
|
|
* tags:
|
|
* - Directory Sync
|
|
* produces:
|
|
* - application/json
|
|
* responses:
|
|
* '200':
|
|
* description: Success
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* data:
|
|
* type: array
|
|
* items:
|
|
* $ref: '#/definitions/Directory'
|
|
* pageToken:
|
|
* type: string
|
|
* description: token for pagination
|
|
*/
|
|
public async filterBy(
|
|
params: FilterByParams = {}
|
|
): Promise<Response<Directory[]> & { pageToken?: string }> {
|
|
metrics.increment('getDsyncConnections');
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
public async getCount(idx?: Index) {
|
|
return await this.store().getCount(idx);
|
|
}
|
|
}
|