diff --git a/backend/functions/lib/adapters/gateways/okta/okta-user-group-gateway.test.ts b/backend/functions/lib/adapters/gateways/okta/okta-user-group-gateway.test.ts new file mode 100644 index 0000000000..8d71ab4922 --- /dev/null +++ b/backend/functions/lib/adapters/gateways/okta/okta-user-group-gateway.test.ts @@ -0,0 +1,158 @@ +import { Collection, Group, User } from '@okta/okta-sdk-nodejs'; +import { CamsUserGroup, CamsUserReference } from '../../../../../../common/src/cams/users'; +import { OktaUserGroupGateway } from './okta-user-group-gateway'; +import { createMockApplicationContext } from '../../../testing/testing-utilities'; +import { UnknownError } from '../../../common-errors/unknown-error'; + +const listGroups = jest.fn(); +const listGroupUsers = jest.fn(); +jest.mock('@okta/okta-sdk-nodejs', () => { + return { + Client: function () { + return { + groupApi: { + listGroups, + listGroupUsers, + }, + }; + }, + }; +}); + +describe('OktaGroupGateway', () => { + describe('getUserGroups', () => { + const group1: Group = { + id: 'foo1', + profile: { + name: 'cams group name #1', + }, + }; + + const group2: Group = { + id: 'foo2', + profile: { + name: 'cams group name #2', + }, + }; + + const group3: Group = { + id: 'foo3', + profile: { + name: 'cams group name #3', + }, + }; + + test('should return a list of CamsUserGroups', async () => { + listGroups.mockResolvedValue(buildMockCollection([group1, group2, group3])); + + const actual = await OktaUserGroupGateway.getUserGroups(await createMockApplicationContext()); + + const expected: CamsUserGroup[] = [ + { + id: group1.id, + name: group1.profile.name, + }, + { + id: group2.id, + name: group2.profile.name, + }, + { + id: group3.id, + name: group3.profile.name, + }, + ]; + expect(actual).toEqual(expected); + }); + + test('should throw an error it an error is returned by the api', async () => { + listGroups.mockRejectedValue(new UnknownError('TEST-MODULE')); + + await expect( + OktaUserGroupGateway.getUserGroups(await createMockApplicationContext()), + ).rejects.toThrow(); + }); + }); + + describe('getUserGroupMembership', () => { + const camsUserGroup: CamsUserGroup = { + id: 'foo', + name: 'cams group name', + }; + + const user: User = { + id: 'user@nodomain.com', + profile: { + displayName: 'Abe Lincoln', + }, + }; + + test('should return a list of CamsUsers', async () => { + listGroupUsers.mockResolvedValue(buildMockCollection([user])); + + const actual = await OktaUserGroupGateway.getUserGroupUsers( + await createMockApplicationContext(), + camsUserGroup, + ); + + const expected: CamsUserReference[] = [ + { + id: user.id, + name: user.profile.displayName, + }, + ]; + expect(actual).toEqual(expected); + }); + + test('should throw an error it an error is returned by the api', async () => { + listGroupUsers.mockRejectedValue(new UnknownError('TEST-MODULE')); + + await expect( + OktaUserGroupGateway.getUserGroupUsers(await createMockApplicationContext(), camsUserGroup), + ).rejects.toThrow(); + }); + }); +}); + +function buildMockCollection(dataToReturn: T[]): Collection { + const currentItems = dataToReturn as unknown as Record[]; + + let index = 0; + const collection: Collection = { + nextUri: '', + httpApi: undefined, + factory: undefined, + currentItems, + request: undefined, + next: function (): Promise<{ done: boolean; value: T | null }> { + const nextResponse = { done: index === currentItems.length, value: null }; + if (!nextResponse.done) { + nextResponse.value = currentItems[index]; + index++; + } + return Promise.resolve(nextResponse); + }, + getNextPage: function (): Promise[]> { + throw new Error('Function not implemented.'); + }, + each: function ( + _iterator: (item: T) => Promise | boolean | unknown, + ): Promise { + throw new Error('Function not implemented.'); + }, + subscribe: function (_config: { + interval?: number; + next: (item: T) => unknown | Promise; + error: (e: Error) => unknown | Promise; + complete: () => void; + }): { unsubscribe(): void } { + throw new Error('Function not implemented.'); + }, + [Symbol.asyncIterator]: function (): { + next: () => Promise<{ done: boolean; value: T | null }>; + } { + return { next: this.next }; + }, + }; + + return collection; +} diff --git a/backend/functions/lib/adapters/gateways/okta/okta-user-group-gateway.ts b/backend/functions/lib/adapters/gateways/okta/okta-user-group-gateway.ts new file mode 100644 index 0000000000..1c87929044 --- /dev/null +++ b/backend/functions/lib/adapters/gateways/okta/okta-user-group-gateway.ts @@ -0,0 +1,135 @@ +import { + Client, + GroupApiListGroupsRequest, + GroupApiListGroupUsersRequest, +} from '@okta/okta-sdk-nodejs'; +import { CamsUserGroup, CamsUserReference } from '../../../../../../common/src/cams/users'; +import { UserGroupGateway } from '../../types/authorization'; +import { ApplicationContext } from '../../types/basic'; +import { V2Configuration } from '@okta/okta-sdk-nodejs/src/types/configuration'; +import { UnknownError } from '../../../common-errors/unknown-error'; + +const MODULE_NAME = 'OKTA_USER_GROUP_GATEWAY'; +const MAX_PAGE_SIZE = 200; + +let singleton: Client = undefined; + +/** + * initialize + * + * Creates an Okta Client instance and retains it is module scope as a singleton. + * Subsequent calls to initialize return the previously created instance. + * + * See: https://github.com/okta/okta-sdk-nodejs?tab=readme-ov-file#oauth-20-authentication + * See: https://github.com/okta/okta-sdk-nodejs?tab=readme-ov-file#known-issues + * + * @param _context ApplicationContext + * @returns + */ +async function initialize(_context: ApplicationContext): Promise { + // EXAMPLE CODE + // const client = new okta.Client({ + // orgUrl: 'https://dev-1234.oktapreview.com/', + // authorizationMode: 'PrivateKey', + // clientId: '{oauth application ID}', + // scopes: ['okta.users.manage'], + // privateKey: '{JWK}', // <-- see notes below + // keyId: 'kidValue' + // }); + try { + const config: V2Configuration = { + // TODO: Map from context configuration + orgUrl: 'https://oktasubdomain/', + clientId: '{oauth application ID}', + authorizationMode: 'PrivateKey', + scopes: ['okta.groups.read'], + privateKey: '{ private key JSON }', + keyId: '', + }; + if (!singleton) { + singleton = new Client(config); + } + return singleton; + } catch (originalError) { + throw new UnknownError(MODULE_NAME, { originalError }); + } +} + +/** + * getUserGroups + * + * Retrieves a list of Okta groups and transforms them into a list of CamsUserGroup. + * + * See: https://github.com/okta/okta-sdk-nodejs?tab=readme-ov-file#groups + * See: https://developer.okta.com/docs/api/ + * See: https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Group/#tag/Group/operation/listGroups + * + * @param context ApplicationContext + * @returns CamsUserGroup[] + */ +async function getUserGroups(context: ApplicationContext): Promise { + const camsUserGroups: CamsUserGroup[] = []; + try { + const client = await initialize(context); + const query: GroupApiListGroupsRequest = { + limit: MAX_PAGE_SIZE, + }; + const oktaGroups = await client.groupApi.listGroups(query); + + for await (const oktaGroup of oktaGroups) { + camsUserGroups.push({ + id: oktaGroup.id, + name: oktaGroup.profile.name, + }); + } + } catch (originalError) { + throw new UnknownError(MODULE_NAME, { originalError }); + } + return camsUserGroups; +} + +/** + * getUserGroupUsers + * + * Retrieves a list of Okta users for a given Okta group and transforms them + * into a list of CamsUserReference. + * + * See: https://github.com/okta/okta-sdk-nodejs?tab=readme-ov-file#groups + * See: https://developer.okta.com/docs/api/ + * See: https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Group/#tag/Group/operation/listGroupUsers + * + * @param context ApplicationContext + * @param group CamsUserGroup + * @returns CamsUserReference[] + */ +async function getUserGroupUsers( + context: ApplicationContext, + group: CamsUserGroup, +): Promise { + const camsUserReferences: CamsUserReference[] = []; + try { + const client = await initialize(context); + const query: GroupApiListGroupUsersRequest = { + groupId: group.id, + limit: MAX_PAGE_SIZE, + }; + const oktaUsers = await client.groupApi.listGroupUsers(query); + + for await (const oktaUser of oktaUsers) { + camsUserReferences.push({ + id: oktaUser.id, + name: oktaUser.profile.displayName, + }); + } + } catch (originalError) { + throw new UnknownError(MODULE_NAME, { originalError }); + } + return camsUserReferences; +} + +export const OktaUserGroupGateway: UserGroupGateway = { + getUserGroups, + getUserGroupUsers, +}; + +export default OktaUserGroupGateway; diff --git a/backend/functions/lib/factory.ts b/backend/functions/lib/factory.ts index bb2cc55644..7b4695f54f 100644 --- a/backend/functions/lib/factory.ts +++ b/backend/functions/lib/factory.ts @@ -30,7 +30,7 @@ import { CasesCosmosDbRepository } from './adapters/gateways/cases.cosmosdb.repo import ConsolidationOrdersCosmosDbRepository from './adapters/gateways/consolidations.cosmosdb.repository'; import { MockHumbleClient } from './testing/mock.cosmos-client-humble'; import { CosmosDbRepository } from './adapters/gateways/cosmos/cosmos.repository'; -import { OpenIdConnectGateway } from './adapters/types/authorization'; +import { OpenIdConnectGateway, UserGroupGateway } from './adapters/types/authorization'; import OktaGateway from './adapters/gateways/okta/okta-gateway'; import { UserSessionCacheRepository } from './adapters/gateways/user-session-cache.repository'; import { UserSessionCacheCosmosDbRepository } from './adapters/gateways/user-session-cache.cosmosdb.repository'; @@ -43,6 +43,7 @@ import LocalStorageGateway from './adapters/gateways/storage/local-storage-gatew import MockAttorneysGateway from './testing/mock-gateways/mock-attorneys.gateway'; import { MockOrdersGateway } from './testing/mock-gateways/mock.orders.gateway'; import { MockOfficesGateway } from './testing/mock-gateways/mock.offices.gateway'; +import OktaUserGroupGateway from './adapters/gateways/okta/okta-user-group-gateway'; export const getAttorneyGateway = (): AttorneyGatewayInterface => { return MockAttorneysGateway; @@ -167,3 +168,7 @@ export const getUserSessionCacheRepository = ( export const getStorageGateway = (_context: ApplicationContext): StorageGateway => { return LocalStorageGateway; }; + +export const getUserGroupGateway = (_context: ApplicationContext): UserGroupGateway => { + return OktaUserGroupGateway; +};