From 484e315948e826847c201be9d7c883aeb2bf2b1f Mon Sep 17 00:00:00 2001 From: Nithin Shekar Kuruba <81444731+NithinKuruba@users.noreply.github.com> Date: Wed, 19 Jun 2024 11:36:36 -0700 Subject: [PATCH 1/2] fix: remove disable integration step before deletion (#1222) * fix: remove disable integration step before deletion * fix: handle deletion using try and catch * fix: updated unit tests --- .../11.remove-inactive-users.test.ts | 2 +- lambda/app/src/controllers/user.ts | 34 +++++++++++-------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/lambda/__tests__/11.remove-inactive-users.test.ts b/lambda/__tests__/11.remove-inactive-users.test.ts index 8cf511262..8cd7153b4 100644 --- a/lambda/__tests__/11.remove-inactive-users.test.ts +++ b/lambda/__tests__/11.remove-inactive-users.test.ts @@ -124,7 +124,7 @@ describe('users and teams', () => { expect(deleteResponse.status).toEqual(200); const user = await models.user.findOne({ where: { idir_userid: TEAM_ADMIN_IDIR_USERID_01 }, raw: true }); expect(user).toBeNull(); - expect(emailList.length).toEqual(2); + expect(emailList.length).toEqual(1); expect(emailList[0].subject).toEqual(template.subject); expect(emailList[0].body).toEqual(template.body); expect(emailList[0].to.length).toEqual(1); diff --git a/lambda/app/src/controllers/user.ts b/lambda/app/src/controllers/user.ts index 8d64ae9ad..5e7fa0c93 100644 --- a/lambda/app/src/controllers/user.ts +++ b/lambda/app/src/controllers/user.ts @@ -7,12 +7,11 @@ import { getDisplayName } from '../utils/helpers'; import { findAllowedIntegrationInfo } from '@lambda-app/queries/request'; import { listRoleUsers, listUserRoles, manageUserRole, manageUserRoles } from '@lambda-app/keycloak/users'; import { canCreateOrDeleteRoles } from '@app/helpers/permissions'; -import { disableIntegration } from '@lambda-app/keycloak/client'; -import { EMAILS } from '@lambda-shared/enums'; +import { EMAILS, EVENTS } from '@lambda-shared/enums'; import { sendTemplate } from '@lambda-shared/templates'; import { getAllEmailsOfTeam } from '@lambda-app/queries/team'; import { UserSurveyInformation } from '@lambda-shared/interfaces'; -import { processIntegrationRequest } from './requests'; +import { createEvent, processIntegrationRequest } from './requests'; export const findOrCreateUser = async (session: Session) => { let { idir_userid, email } = session; @@ -279,19 +278,26 @@ export const deleteStaleUsers = async (user: any) => { if (nonTeamRequests.length > 0) { for (let rqst of nonTeamRequests) { - // assign sso team user - rqst.userId = ssoUser.id; - - if (!rqst.archived) { - rqst.archived = true; - if (rqst.status !== 'draft') { - rqst.status = 'submitted'; - - await disableIntegration(rqst.get({ plain: true, clone: true })); - await processIntegrationRequest(rqst); + try { + // assign sso team user + rqst.userId = ssoUser.id; + + if (!rqst.archived) { + rqst.archived = true; + if (rqst.status !== 'draft') { + rqst.status = 'submitted'; + } } + await rqst.save(); + await processIntegrationRequest(rqst); + } catch (err) { + console.log(err); + createEvent({ + eventCode: EVENTS.REQUEST_DELETE_FAILURE, + requestId: rqst?.id, + userId: ssoUser.id, + }); } - await rqst.save(); } } From bb56797c5800ea0ecb7fb663f4d9473399058d7a Mon Sep 17 00:00:00 2001 From: Jonathan Langlois <37274633+jlangy@users.noreply.github.com> Date: Thu, 20 Jun 2024 08:43:11 -0700 Subject: [PATCH 2/2] feat: bcsc backend (#1217) * chore: update compose file update docker compose to include dc * chore: migration add migration for bcsc clients table * feat: bcsc backend add client creation * chore: bcsc add bc services card * chore: bcsc tests add unit tests for bcsc * feat: deployment update deployment vars * fix: deployment remove unused deployment vars * chore: cleanup cleanup imports * chore: sonar add sonarcloud recs * chore: scopes move scopes to constant value * chore: bcsc client uri add client uri fields * chore: sonar fix sonar warnings * fix: test fix failing test * chore: queue delay add delay to request queue * fix: unused attribute remove unused config param * chore: configs remove logging config change * chore: tests update unit test * fix: tests add required user in test * chore: scopes reduce default scope list * chore: test uncomment test case --- .github/workflows/terraform.yml | 9 + docker-compose.yml | 8 +- .../14.verifiable-credential.test.ts | 25 +-- .../__tests__/17.run-queued-requests.test.ts | 26 ++- lambda/__tests__/20.bcsc.test.ts | 206 ++++++++++++++++++ lambda/__tests__/helpers/fixtures.ts | 1 + .../__tests__/helpers/modules/integrations.ts | 47 +++- lambda/app/src/bcsc/client.ts | 110 ++++++++++ lambda/app/src/controllers/requests.ts | 206 +++++++++++++++++- lambda/app/src/keycloak/clientScopes.ts | 89 ++++++++ lambda/app/src/keycloak/idp.ts | 127 +++++++++++ lambda/app/src/keycloak/integration.ts | 38 +++- lambda/app/src/utils/constants.ts | 3 + lambda/app/src/utils/helpers.ts | 54 ++++- .../2024.06.06T11.01.11.add-bcsc-table.ts | 99 +++++++++ lambda/db/src/umzug.ts | 1 + lambda/request-queue/src/main.ts | 5 + lambda/shared/sequelize/models/BcscClient.ts | 65 ++++++ lambda/shared/sequelize/models/Request.ts | 22 ++ lambda/shared/sequelize/models/models.ts | 3 +- localserver/.env.example | 8 + terraform/lambda-app.tf | 8 + terraform/lambda-request-queue.tf | 8 + terraform/variables.tf | 35 +++ 24 files changed, 1152 insertions(+), 51 deletions(-) create mode 100644 lambda/__tests__/20.bcsc.test.ts create mode 100644 lambda/app/src/bcsc/client.ts create mode 100644 lambda/app/src/keycloak/clientScopes.ts create mode 100644 lambda/app/src/keycloak/idp.ts create mode 100644 lambda/db/src/migrations/2024.06.06T11.01.11.add-bcsc-table.ts create mode 100644 lambda/shared/sequelize/models/BcscClient.ts diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index c22282d38..807daf8f2 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -106,6 +106,15 @@ jobs: AWS_ECR_URI=${{secrets.DEV_AWS_ECR_URI}} INCLUDE_DIGITAL_CREDENTIAL=true INSTALL_SSO_CSS_GRAFANA=false + + INCLUDE_BC_SERVICES_CARD=true + BCSC_INITIAL_ACCESS_TOKEN_DEV=${{secrets.BCSC_INITIAL_ACCESS_TOKEN_DEV}} + BCSC_INITIAL_ACCESS_TOKEN_TEST=${{secrets.BCSC_INITIAL_ACCESS_TOKEN_TEST}} + BCSC_INITIAL_ACCESS_TOKEN_PROD=${{secrets.BCSC_INITIAL_ACCESS_TOKEN_DPROD} + BCSC_REGISTRATION_BASE_URL_DEV=${{secrets.BCSC_REGISTRATION_BASE_URL_DEV}} + BCSC_REGISTRATION_BASE_URL_TEST=${{secrets.BCSC_REGISTRATION_BASE_URL_TEST}} + BCSC_REGISTRATION_BASE_URL_PROD=${{secrets.BCSC_REGISTRATION_BASE_URL_DPROD} + EOF - name: Set env to production diff --git a/docker-compose.yml b/docker-compose.yml index 9dbfab17f..e021c9d9d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,7 @@ services: MAINTENANCE_MODE_ACTIVE: 'false' SSO_REQUESTS_BACKEND_HOSTNAME: sso-requests APP_URL: http://localhost:3000 + INCLUDE_DIGITAL_CREDENTIAL: 'true' depends_on: - sso-requests networks: @@ -85,13 +86,14 @@ services: GRAFANA_API_URL: https://sso-grafana-sandbox.apps.gold.devops.gov.bc.ca/api GOLD_IP_ADDRESS: '142.34.229.4' CYPRESS_RUNNER: 'true' + INCLUDE_DIGITAL_CREDENTIAL: 'true' networks: css-net: aliases: [sso-requests-api] dev-keycloak: container_name: dev-keycloak - image: ghcr.io/bcgov/sso:7.6.39-build.1 + image: ghcr.io/bcgov/sso:7.6.39-build.2 depends_on: - sso-db ports: @@ -113,7 +115,7 @@ services: test-keycloak: container_name: test-keycloak - image: ghcr.io/bcgov/sso:7.6.39-build.1 + image: ghcr.io/bcgov/sso:7.6.39-build.2 depends_on: - sso-db ports: @@ -135,7 +137,7 @@ services: prod-keycloak: container_name: prod-keycloak - image: ghcr.io/bcgov/sso:7.6.39-build.1 + image: ghcr.io/bcgov/sso:7.6.39-build.2 depends_on: - sso-db ports: diff --git a/lambda/__tests__/14.verifiable-credential.test.ts b/lambda/__tests__/14.verifiable-credential.test.ts index 280156f9f..957163597 100644 --- a/lambda/__tests__/14.verifiable-credential.test.ts +++ b/lambda/__tests__/14.verifiable-credential.test.ts @@ -1,14 +1,11 @@ import { buildGitHubRequestData } from '@lambda-app/controllers/requests'; import { Status } from 'app/interfaces/types'; -import app from './helpers/server'; -import supertest from 'supertest'; -import { APP_BASE_PATH } from './helpers/constants'; import { cleanUpDatabaseTables, createMockAuth, createMockSendEmail } from './helpers/utils'; import { TEAM_ADMIN_IDIR_EMAIL_01, TEAM_ADMIN_IDIR_USERID_01 } from './helpers/fixtures'; import { models } from '@lambda-shared/sequelize/models/models'; import { IntegrationData } from '@lambda-shared/interfaces'; import { DIT_EMAIL_ADDRESS } from '@lambda-shared/local'; -import { updateIntegration } from './helpers/modules/integrations'; +import { submitNewIntegration, updateIntegration } from './helpers/modules/integrations'; import { EMAILS } from '@lambda-shared/enums'; jest.mock('../app/src/authenticate'); @@ -111,26 +108,6 @@ const mockIntegration: IntegrationData = { primaryEndUsers: [], }; -const submitNewIntegration = async (integration: IntegrationData) => { - const { projectName, projectLead, serviceType, usesTeam } = integration; - const { - body: { id }, - } = await supertest(app) - .post(`${APP_BASE_PATH}/requests`) - .send({ - projectName, - projectLead, - serviceType, - usesTeam, - }) - .set('Accept', 'application/json'); - - return supertest(app) - .put(`${APP_BASE_PATH}/requests?submit=true`) - .send({ ...integration, id }) - .set('Accept', 'application/json'); -}; - const OLD_ENV = process.env; beforeEach(() => { jest.resetModules(); diff --git a/lambda/__tests__/17.run-queued-requests.test.ts b/lambda/__tests__/17.run-queued-requests.test.ts index 01b55b87f..3bfdb7699 100644 --- a/lambda/__tests__/17.run-queued-requests.test.ts +++ b/lambda/__tests__/17.run-queued-requests.test.ts @@ -44,7 +44,7 @@ describe('Request Queue', () => { kcClientSpy.mockImplementation(() => Promise.resolve(true)); const emailResults = createMockSendEmail(); - await createRequestQueueItem(1, formDataProd, ACTION_TYPES.CREATE as QUEUE_ACTION); + await createRequestQueueItem(1, formDataProd, ACTION_TYPES.CREATE as QUEUE_ACTION, 61); const request = await generateRequest(formDataProd); expect(request.status).toBe('draft'); @@ -72,12 +72,26 @@ describe('Request Queue', () => { kcClientSpy.mockRestore(); }); + it('Ignores queue items if created too recently', async () => { + const kcClientSpy = jest.spyOn(IntegrationModule, 'keycloakClient'); + kcClientSpy.mockImplementation(() => Promise.resolve(true)); + // Simulate one second old + await createRequestQueueItem(1, formDataProd, ACTION_TYPES.CREATE as QUEUE_ACTION, 1); + + await handler(); + expect(kcClientSpy).not.toHaveBeenCalled(); + + // Check the queue item is still there + const queueItems = await getQueueItems(); + expect(queueItems.length).toBe(1); + }); + it('Sends an update email if request was previously applied', async () => { const kcClientSpy = jest.spyOn(IntegrationModule, 'keycloakClient'); kcClientSpy.mockImplementation(() => Promise.resolve(true)); const emailResults = createMockSendEmail(); - await createRequestQueueItem(1, formDataProd, ACTION_TYPES.UPDATE as QUEUE_ACTION); + await createRequestQueueItem(1, formDataProd, ACTION_TYPES.UPDATE as QUEUE_ACTION, 61); const request = await generateRequest(formDataProd); // Insert a previous applied event await createEvent({ eventCode: EVENTS.REQUEST_APPLY_SUCCESS, requestId: request.id }); @@ -94,7 +108,7 @@ describe('Request Queue', () => { it('Keeps item in the queue if any environments fail and updates request status to failed', async () => { const kcClientSpy = jest.spyOn(IntegrationModule, 'keycloakClient'); - await createRequestQueueItem(1, formDataProd, ACTION_TYPES.CREATE as QUEUE_ACTION); + await createRequestQueueItem(1, formDataProd, ACTION_TYPES.CREATE as QUEUE_ACTION, 61); const request = await generateRequest(formDataProd); // Have one environment fail @@ -162,7 +176,7 @@ describe('Delete and Update', () => { find: jest.fn(() => Promise.resolve([archivedData])), }, }); - await createRequestQueueItem(1, archivedData, ACTION_TYPES.DELETE as QUEUE_ACTION); + await createRequestQueueItem(1, archivedData, ACTION_TYPES.DELETE as QUEUE_ACTION, 61); await generateRequest(archivedData); const emailResults = createMockSendEmail(); @@ -177,7 +191,7 @@ describe('Delete and Update', () => { find: jest.fn(() => Promise.resolve([formDataProd])), }, }); - await createRequestQueueItem(1, formDataProd, ACTION_TYPES.CREATE as QUEUE_ACTION); + await createRequestQueueItem(1, formDataProd, ACTION_TYPES.CREATE as QUEUE_ACTION, 61); await generateRequest(formDataProd); await handler(); @@ -197,6 +211,7 @@ describe('Delete and Update', () => { request.id, { ...formDataProd, existingClientId: existingClientId }, ACTION_TYPES.CREATE as QUEUE_ACTION, + 61, ); await handler(); @@ -215,6 +230,7 @@ describe('Delete and Update', () => { request.id, { ...formDataProd, existingClientId: '' }, ACTION_TYPES.CREATE as QUEUE_ACTION, + 61, ); await handler(); diff --git a/lambda/__tests__/20.bcsc.test.ts b/lambda/__tests__/20.bcsc.test.ts new file mode 100644 index 000000000..a5acc762d --- /dev/null +++ b/lambda/__tests__/20.bcsc.test.ts @@ -0,0 +1,206 @@ +import { cleanUpDatabaseTables, createMockAuth } from './helpers/utils'; +import * as IdpModule from '@lambda-app/keycloak/idp'; +import * as ClientScopeModule from '@lambda-app/keycloak/clientScopes'; +import { buildGitHubRequestData, createBCSCIntegration } from '@lambda-app/controllers/requests'; +import { TEAM_ADMIN_IDIR_EMAIL_01, TEAM_ADMIN_IDIR_USERID_01, formDataProd } from './helpers/fixtures'; +import { bcscIdpMappers } from '@lambda-app/utils/constants'; +import { submitNewIntegration } from './helpers/modules/integrations'; +import { IntegrationData } from '@lambda-shared/interfaces'; + +jest.mock('@lambda-app/controllers/bc-services-card', () => { + return { + getPrivacyZones: jest.fn(() => Promise.resolve([{ privacy_zone_uri: 'zone', privacy_zone_name: 'zone' }])), + getAttributes: jest.fn(() => Promise.resolve([{ name: 'attr' }])), + }; +}); + +jest.mock('../app/src/authenticate'); + +jest.mock('@lambda-app/keycloak/adminClient', () => { + return { + getAdminClient: jest.fn(() => Promise.resolve({})), + }; +}); + +jest.mock('@lambda-shared/utils/ches'); +jest.mock('@lambda-app/bcsc/client', () => { + const original = jest.requireActual('@lambda-app/bcsc/client'); + return { + ...original, + createBCSCClient: jest.fn(() => + Promise.resolve({ + data: { + client_secret: 'secret', + client_id: 'client_id', + registration_access_token: 'token', + }, + }), + ), + updateBCSCClient: jest.fn(() => + Promise.resolve({ + data: { + client_secret: 'secret', + client_id: 'client_id', + registration_access_token: 'token', + }, + }), + ), + }; +}); + +const OLD_ENV = process.env; +beforeEach(() => { + jest.resetModules(); + process.env = { ...OLD_ENV }; +}); + +afterAll(() => { + process.env = OLD_ENV; +}); + +describe('BCSC', () => { + const spies = { + getIdp: null, + getIdpMappers: null, + createIdpMapper: null, + getClientScope: null, + getClientScopeMapper: null, + createClientScopeMapper: null, + createClientScope: null, + createIdp: null, + }; + + beforeEach(() => { + spies.getIdp = jest.spyOn(IdpModule, 'getIdp'); + spies.getIdp.mockImplementation(() => Promise.resolve(null)); + + spies.getIdpMappers = jest.spyOn(IdpModule, 'getIdpMappers'); + spies.getIdpMappers.mockImplementation(() => Promise.resolve([])); + spies.createIdpMapper = jest.spyOn(IdpModule, 'createIdpMapper'); + spies.createIdpMapper.mockImplementation(() => Promise.resolve(null)); + spies.getClientScope = jest.spyOn(ClientScopeModule, 'getClientScope'); + spies.getClientScope.mockImplementation(() => Promise.resolve({ id: '1' })); + spies.getClientScopeMapper = jest.spyOn(ClientScopeModule, 'getClientScopeMapper'); + spies.getClientScopeMapper.mockImplementation(() => Promise.resolve(null)); + spies.createClientScopeMapper = jest.spyOn(ClientScopeModule, 'createClientScopeMapper'); + spies.createClientScopeMapper.mockImplementation(() => Promise.resolve(null)); + spies.createClientScope = jest.spyOn(ClientScopeModule, 'createClientScope'); + spies.createClientScope.mockImplementation(() => Promise.resolve({ id: 1, name: 'name' })); + spies.createIdp = jest.spyOn(IdpModule, 'createIdp'); + spies.createIdp.mockImplementation(() => Promise.resolve(null)); + }); + + afterAll(async () => { + await cleanUpDatabaseTables(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('Only creates the idp if not found', async () => { + spies.getIdp.mockImplementation(() => Promise.resolve(null)); + await createBCSCIntegration('dev', bcscProdIntegration, 1); + expect(spies.createIdp).toHaveBeenCalled(); + + jest.clearAllMocks(); + + spies.getIdp.mockImplementation(() => Promise.resolve({})); + await createBCSCIntegration('dev', bcscProdIntegration, 1); + expect(spies.createIdp).not.toHaveBeenCalled(); + }); + + it('Only creates the idp mappers if not found', async () => { + // Return all requiredMappers + spies.getIdpMappers.mockImplementation(() => Promise.resolve(bcscIdpMappers)); + await createBCSCIntegration('dev', bcscProdIntegration, 1); + expect(spies.createIdpMapper).not.toHaveBeenCalled(); + + jest.clearAllMocks(); + + spies.getIdpMappers.mockImplementation(() => Promise.resolve([])); + await createBCSCIntegration('dev', bcscProdIntegration, 1); + expect(spies.createIdpMapper).toHaveBeenCalledTimes(bcscIdpMappers.length); + }); + + it('Only creates the client scope if not found', async () => { + // Return all requiredMappers + spies.getClientScope.mockImplementation(() => Promise.resolve(null)); + await createBCSCIntegration('dev', bcscProdIntegration, 1); + expect(spies.createClientScope).toHaveBeenCalled(); + + jest.clearAllMocks(); + + spies.getClientScope.mockImplementation(() => Promise.resolve({ id: '1', name: 'name' })); + await createBCSCIntegration('dev', bcscProdIntegration, 1); + expect(spies.createClientScope).not.toHaveBeenCalled(); + }); + + it('Only creates the client scope mappers if not found', async () => { + // Return all requiredMappers + spies.getClientScopeMapper.mockImplementation(() => Promise.resolve(null)); + await createBCSCIntegration('dev', bcscProdIntegration, 1); + expect(spies.createClientScopeMapper).toHaveBeenCalledTimes(1); + + jest.clearAllMocks(); + + spies.getClientScopeMapper.mockImplementation(() => Promise.resolve(bcscProdIntegration.bcscAttributes)); + await createBCSCIntegration('dev', bcscProdIntegration, 1); + expect(spies.createClientScopeMapper).not.toHaveBeenCalled(); + }); +}); + +const bcscProdIntegration: IntegrationData = { + ...formDataProd, + devIdps: ['bcservicescard', 'idir'], + bcscPrivacyZone: 'zone', + bcscAttributes: ['attr'], + primaryEndUsers: [], + devHomePageUri: 'https://example.com', + testHomePageUri: 'https://example.com', + prodHomePageUri: 'https://example.com', +}; + +describe('Feature flag', () => { + beforeAll(async () => { + createMockAuth(TEAM_ADMIN_IDIR_USERID_01, TEAM_ADMIN_IDIR_EMAIL_01); + }); + + it('Does not allow bc services card as an IDP if feature flag is not included in env vars', async () => { + process.env.INCLUDE_BC_SERVICES_CARD = undefined; + const result = await submitNewIntegration(bcscProdIntegration); + expect(result.status).toBe(422); + }); + + it('Does not allow bc services card as an IDP if feature flag is set but not true', async () => { + process.env.INCLUDE_BC_SERVICES_CARD = 'false'; + const result = await submitNewIntegration(bcscProdIntegration); + expect(result.status).toBe(422); + }); + + it('Allows bc services card as an IDP if feature flag is set to true', async () => { + process.env.INCLUDE_BC_SERVICES_CARD = 'true'; + const result = await submitNewIntegration(bcscProdIntegration); + expect(result.status).toBe(200); + }); +}); + +describe('Build Github Dispatch', () => { + it('Removes bc services card from production IDP list if not approved yet, but keeps it in dev and test', () => { + const processedIntegration = buildGitHubRequestData(bcscProdIntegration); + expect(processedIntegration.prodIdps.includes('bcservicescard')).toBe(false); + + // Leaves other idp alone + expect(processedIntegration.prodIdps.includes('idir')).toBe(true); + + // Keeps VC in dev and test + expect(processedIntegration.testIdps.includes('bcservicescard')).toBe(true); + expect(processedIntegration.devIdps.includes('bcservicescard')).toBe(true); + }); + + it('Keeps bc services card in production IDP list if approved', () => { + const approvedIntegration = { ...bcscProdIntegration, bcServicesCardApproved: true }; + const processedIntegration = buildGitHubRequestData(approvedIntegration); + expect(processedIntegration.prodIdps.includes('bcservicescard')).toBe(true); + }); +}); diff --git a/lambda/__tests__/helpers/fixtures.ts b/lambda/__tests__/helpers/fixtures.ts index 65f1c6e21..d3754999e 100644 --- a/lambda/__tests__/helpers/fixtures.ts +++ b/lambda/__tests__/helpers/fixtures.ts @@ -42,6 +42,7 @@ export const formDataDev: IntegrationData = { idirUserDisplayName: 'test user', usesTeam: false, requester: 'SSO Admin', + userId: 1, user: { id: 0, idirUserid: 'QWERASDF', diff --git a/lambda/__tests__/helpers/modules/integrations.ts b/lambda/__tests__/helpers/modules/integrations.ts index d43e243b2..a99d10538 100644 --- a/lambda/__tests__/helpers/modules/integrations.ts +++ b/lambda/__tests__/helpers/modules/integrations.ts @@ -68,14 +68,55 @@ interface RequestData extends IntegrationData { existingClientId?: string; } -export const createRequestQueueItem = async (requestId: number, requestData: RequestData, action: QUEUE_ACTION) => { - return models.requestQueue.create({ type: 'request', action, requestId, request: requestData }); +export const createRequestQueueItem = async ( + requestId: number, + requestData: RequestData, + action: QUEUE_ACTION, + ageSeconds?: number, +) => { + const queueItem: any = { type: 'request', action, requestId, request: requestData }; + if (ageSeconds) { + const currentTime = new Date(); + const secondsAgoTime = currentTime.getTime() - ageSeconds * 1000; + queueItem.createdAt = new Date(secondsAgoTime); + } + return models.requestQueue.create(queueItem); }; export const getQueueItems = async () => models.requestQueue.findAll(); export const getRequest = async (id: number) => models.request.findOne({ where: { id } }); -export const generateRequest = async (data: IntegrationData) => models.request.create(data); +export const generateRequest = async (data: IntegrationData) => { + if (data.userId) { + await models.user.findOrCreate({ + where: { id: data.userId }, + defaults: { + idirEmail: 'mail', + }, + }); + } + return models.request.create(data); +}; export const getEventsByRequestId = async (id: number) => models.event.findAll({ where: { requestId: id } }); + +export const submitNewIntegration = async (integration: IntegrationData) => { + const { projectName, projectLead, serviceType, usesTeam } = integration; + const { + body: { id }, + } = await supertest(app) + .post(`${APP_BASE_PATH}/requests`) + .send({ + projectName, + projectLead, + serviceType, + usesTeam, + }) + .set('Accept', 'application/json'); + + return supertest(app) + .put(`${APP_BASE_PATH}/requests?submit=true`) + .send({ ...integration, id }) + .set('Accept', 'application/json'); +}; diff --git a/lambda/app/src/bcsc/client.ts b/lambda/app/src/bcsc/client.ts new file mode 100644 index 000000000..d70b2c658 --- /dev/null +++ b/lambda/app/src/bcsc/client.ts @@ -0,0 +1,110 @@ +import { models } from '../../../shared/sequelize/models/models'; +import axios from 'axios'; +import { IntegrationData } from '@lambda-shared/interfaces'; +import { getBCSCEnvVars, getRequiredBCSCScopes } from '@lambda-app/utils/helpers'; +import { getAllEmailsOfTeam } from '@lambda-app/queries/team'; + +export interface BCSCClientParameters { + id?: number; + clientId?: string; + clientName?: string; + clientUri?: string; + clientSecret?: string; + /** Provide scope as a space-separated string, e.g "openid address profile" */ + contacts?: string[]; + tokenEndpointAuthMethod?: string; + idTokenSignedResponseAlg?: string; + userinfoSignedResponseAlg?: string; + created?: boolean; + registrationAccessToken?: string; + environment?: string; +} + +const getBCSCContacts = async (integration: IntegrationData) => { + let contacts = []; + if (integration.usesTeam) { + const teamEmails = await getAllEmailsOfTeam(Number(integration.teamId)); + contacts = teamEmails.map((member) => member?.idir_email); + } else { + const contact = await models.user.findOne({ + where: { + id: integration.userId, + }, + }); + contacts.push(contact?.idirEmail); + } + return contacts; +}; + +export const createBCSCClient = async (data: BCSCClientParameters, integration: IntegrationData, userId: number) => { + const contacts = await getBCSCContacts(integration); + const { bcscBaseUrl, kcBaseUrl, accessToken } = getBCSCEnvVars(data.environment); + const jwksUri = `${kcBaseUrl}/realms/standard/protocol/openid-connect/certs`; + const requiredScopes = await getRequiredBCSCScopes(integration.bcscAttributes); + + const result = await axios.post( + `${bcscBaseUrl}/oauth2/register`, + { + client_name: `${data.clientName}-${data.environment}`, + client_uri: integration[`${data.environment}HomePageUri`], + redirect_uris: [`${kcBaseUrl}/auth/realms/standard/broker/${integration.clientId}/endpoint`], + scope: requiredScopes, + contacts: contacts, + token_endpoint_auth_method: 'client_secret_post', + id_token_signed_response_alg: 'RS256', + userinfo_signed_response_alg: 'RS256', + claims: integration.bcscAttributes, + privacy_zone_uri: integration.bcscPrivacyZone, + jwks_uri: jwksUri, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }, + ); + return result; +}; + +export const updateBCSCClient = async (bcscClient: BCSCClientParameters, integration: IntegrationData) => { + const { kcBaseUrl, bcscBaseUrl } = getBCSCEnvVars(bcscClient.environment); + const contacts = await getBCSCContacts(integration); + const jwksUri = `${kcBaseUrl}/realms/standard/protocol/openid-connect/certs`; + const requiredScopes = await getRequiredBCSCScopes(integration.bcscAttributes); + + const result = await axios.put( + `${bcscBaseUrl}/oauth2/register/${bcscClient.clientId}`, + { + client_name: `${bcscClient.clientName}-${bcscClient.environment}`, + client_uri: integration[`${bcscClient.environment}HomePageUri`], + redirect_uris: [`${kcBaseUrl}/auth/realms/standard/broker/${integration.clientId}/endpoint`], + scope: requiredScopes, + contacts, + token_endpoint_auth_method: 'client_secret_post', + id_token_signed_response_alg: 'RS256', + userinfo_signed_response_alg: 'RS256', + claims: integration.bcscAttributes, + privacy_zone_uri: integration.bcscPrivacyZone, + jwks_uri: jwksUri, + client_id: bcscClient.clientId, + registration_access_token: bcscClient.registrationAccessToken, + }, + { + headers: { + Authorization: `Bearer ${bcscClient.registrationAccessToken}`, + }, + }, + ); + return result; +}; + +export const deleteBCSCClient = async (data: { clientId: string; registrationToken: string; environment: string }) => { + const { bcscBaseUrl } = getBCSCEnvVars(data.environment); + const result = await axios.delete(`${bcscBaseUrl}/oauth2/register/${data.clientId}`, { + headers: { + Authorization: `Bearer ${data.registrationToken}`, + }, + }); + return result; +}; diff --git a/lambda/app/src/controllers/requests.ts b/lambda/app/src/controllers/requests.ts index a788cf418..5a737e4ca 100644 --- a/lambda/app/src/controllers/requests.ts +++ b/lambda/app/src/controllers/requests.ts @@ -10,6 +10,8 @@ import { isAdmin, getDisplayName, getWhereClauseForAllRequests, + getBCSCEnvVars, + getRequiredBCSCScopes, } from '../utils/helpers'; import { sequelize, models } from '@lambda-shared/sequelize/models/models'; import { Session, IntegrationData, User } from '@lambda-shared/interfaces'; @@ -34,6 +36,8 @@ import { checkDigitalCredential, checkNotBceidGroup, checkNotGithubGroup, + usesBcServicesCard, + checkBcServicesCard, } from '@app/helpers/integration'; import { NewRole, bulkCreateRole, setCompositeClientRoles } from '@lambda-app/keycloak/users'; import { getRolesWithEnvironments } from '@lambda-app/queries/roles'; @@ -47,6 +51,16 @@ import { } from '@app/schemas'; import pick from 'lodash.pick'; import { validateIdirEmail } from '@lambda-app/bceid-webservice-proxy/idir'; +import { BCSCClientParameters, createBCSCClient, updateBCSCClient } from '@lambda-app/bcsc/client'; +import { createIdp, createIdpMapper, deleteIdp, getIdp, getIdpMappers } from '@lambda-app/keycloak/idp'; +import { + createClientScope, + createClientScopeMapper, + deleteClientScope, + getClientScope, + getClientScopeMapper, +} from '@lambda-app/keycloak/clientScopes'; +import { bcscIdpMappers } from '@lambda-app/utils/constants'; const APP_ENV = process.env.APP_ENV || 'development'; const NEW_REQUEST_DAY_LIMIT = APP_ENV === 'production' ? 10 : 1000; @@ -87,6 +101,12 @@ const allowedFieldsForGithub = [ 'teamId', 'apiServiceAccount', 'requester', + 'bcscAttributes', + 'devHomePageUri', + 'testHomePageUri', + 'prodHomePageUri', + 'bcscPrivacyZone', + 'usesTeam', ...envFieldsAll, ]; @@ -200,6 +220,181 @@ export const createRequest = async (session: Session, data: IntegrationData) => return { ...result.dataValues, numOfRequestsForToday }; }; +export const createBCSCIntegration = async (env: string, integration: IntegrationData, userId: number) => { + const { bcscBaseUrl } = getBCSCEnvVars(env); + + const bcscClient = await models.bcscClient.findOne({ + where: { + requestId: integration.id, + environment: env, + }, + }); + + let bcscClientSecret = bcscClient?.clientSecret; + let bcscClientId = bcscClient?.clientId; + if (!bcscClient) { + const bcscClientName = `${integration.projectName}-${integration.id}`; + const clientResponse: any = await createBCSCClient( + { + clientName: bcscClientName, + environment: env, + }, + integration, + userId, + ); + await models.bcscClient.create({ + clientName: bcscClientName, + requestId: integration.id, + environment: env, + clientSecret: clientResponse.data.client_secret, + registrationAccessToken: clientResponse.data.registration_access_token, + created: true, + clientId: clientResponse.data.client_id, + }); + bcscClientSecret = clientResponse.data.client_secret; + bcscClientId = clientResponse.data.client_id; + } else if (bcscClient.archived) { + await models.bcscClient.update( + { + archived: false, + }, + { + where: { + requestId: integration.id, + environment: env, + }, + }, + ); + } else { + await updateBCSCClient(bcscClient, integration); + } + const requiredScopes = await getRequiredBCSCScopes(integration.bcscAttributes); + const idpCreated = await getIdp(env, integration.clientId); + if (!idpCreated) { + await createIdp( + { + alias: integration.clientId, + displayName: `BC Services Card - ${integration.clientId}`, + enabled: true, + storeToken: true, + providerId: 'oidc', + realm: 'standard', + config: { + clientId: bcscClientId, + clientSecret: bcscClientSecret, + authorizationUrl: `${bcscBaseUrl}/login/oidc/authorize`, + tokenUrl: `${bcscBaseUrl}/oauth2/token`, + userInfoUrl: `${bcscBaseUrl}/oauth2/userinfo`, + jwksUrl: `${bcscBaseUrl}/oauth2/jwk`, + syncMode: 'IMPORT', + disableUserInfo: true, + clientAuthMethod: 'client_secret_post', + validateSignature: true, + useJwksUrl: true, + defaultScope: requiredScopes, + }, + }, + env, + ); + } + + const idpMappers = await getIdpMappers({ + environment: env, + idpAlias: integration.clientId, + }); + + const createIdpMapperPromises = bcscIdpMappers.map((mapper) => { + const alreadyExists = idpMappers.some((existingMapper) => existingMapper.name === mapper.name); + if (!alreadyExists) { + return createIdpMapper({ + environment: env, + name: mapper.name, + idpAlias: integration.clientId, + idpMapper: mapper.type, + idpMapperConfig: { + claim: mapper.name, + attribute: mapper.name, + syncMode: 'FORCE', + template: mapper.template, + }, + }); + } + }); + + await Promise.all(createIdpMapperPromises); + + const clientScopeData = { + environment: env, + realmName: 'standard', + scopeName: integration.clientId, + }; + + let clientScope = await getClientScope(clientScopeData); + if (!clientScope) { + clientScope = await createClientScope(clientScopeData); + } + + await getClientScopeMapper({ + environment: env, + scopeId: clientScope.id, + mapperName: 'attributes', + }).then((mapperExists) => { + if (!mapperExists) { + createClientScopeMapper({ + environment: env, + realmName: 'standard', + scopeName: clientScope.name, + protocol: 'openid-connect', + protocolMapper: 'oidc-idp-userinfo-mapper', + protocolMapperName: 'attributes', + protocolMapperConfig: { + signatureExpected: true, + userAttributes: integration.bcscAttributes?.join(','), + 'claim.name': 'attributes', + 'jsonType.label': 'String', + 'id.token.claim': true, + 'access.token.claim': true, + 'userinfo.token.claim': true, + }, + }); + } + }); +}; + +export const deleteBCSCIntegration = async (request: BCSCClientParameters, keycoakClientId: string) => { + // Keeping the bcsc client for restoration if needed. Just setting it as archived. + await models.bcscClient.update( + { + archived: true, + }, + { + where: { + id: request.id, + }, + }, + ); + const idpExists = await getIdp(request.environment, keycoakClientId); + if (idpExists) { + await deleteIdp({ + environment: request.environment, + realmName: 'standard', + idpAlias: keycoakClientId, + }); + } + const clientScope = await getClientScope({ + environment: request.environment, + realmName: 'standard', + scopeName: keycoakClientId, + }); + if (clientScope) { + await deleteClientScope({ + realmName: 'standard', + environment: request.environment, + scopeName: keycoakClientId, + }); + } +}; + export const updateRequest = async ( session: Session, data: IntegrationData, @@ -241,13 +436,16 @@ export const updateRequest = async ( const isApprovingDigitalCredential = !originalData.digitalCredentialApproved && current.digitalCredentialApproved; if (isApprovingDigitalCredential && !userIsAdmin) throw Error('unauthorized request'); + const isApprovingBCSC = !originalData.bcServicesCardApproved && current.bcServicesCardApproved; + if (isApprovingBCSC && !userIsAdmin) throw Error('unauthorized request'); + const allowedTeams = await getAllowedTeams(user, { raw: true }); current.updatedAt = sequelize.literal('CURRENT_TIMESTAMP'); let finalData = getCurrentValue(); if (submit) { - const validationErrors = validateRequest(mergedData, originalData, isMerged, allowedTeams); + const validationErrors = await validateRequest(mergedData, originalData, allowedTeams, isMerged); if (!isEmpty(validationErrors)) { if (isString(validationErrors)) throw Error(validationErrors); else throw Error(JSON.stringify({ validationError: true, errors: validationErrors, prepared: mergedData })); @@ -378,7 +576,6 @@ export const updateRequest = async ( } await createEvent(eventData); - await processIntegrationRequest(updated, false, existingClientId, addingProd); updated = await getAllowedRequest(session, data.id); @@ -698,6 +895,7 @@ export const buildGitHubRequestData = (baseData: IntegrationData) => { const hasBceid = usesBceid(baseData); const hasGithub = usesGithub(baseData); const hasDigitalCredential = usesDigitalCredential(baseData); + const hasBCSC = usesBcServicesCard(baseData); // let's use dev's idps until having a env-specific idp selections if (baseData.environments.includes('test')) baseData.testIdps = baseData.devIdps; @@ -713,6 +911,10 @@ export const buildGitHubRequestData = (baseData: IntegrationData) => { baseData.prodIdps = baseData.prodIdps.filter((idp) => !checkDigitalCredential(idp)); } + if (!baseData.bcServicesCardApproved && hasBCSC) { + baseData.prodIdps = baseData.prodIdps.filter((idp) => !checkBcServicesCard(idp)); + } + // prevent the TF from creating GitHub integration in prod environment if not approved if (!baseData.githubApproved && hasGithub) { baseData.prodIdps = baseData.prodIdps.filter(checkNotGithubGroup); diff --git a/lambda/app/src/keycloak/clientScopes.ts b/lambda/app/src/keycloak/clientScopes.ts new file mode 100644 index 000000000..c0bb7eb0d --- /dev/null +++ b/lambda/app/src/keycloak/clientScopes.ts @@ -0,0 +1,89 @@ +import { getAdminClient } from './adminClient'; + +interface ProtocolMapperConfig { + 'user.attribute'?: string; + 'claim.name'?: string; + /** JSON type used to populate the claim in the payload */ + 'jsonType.label'?: 'String' | 'long' | 'int' | 'boolean' | 'JSON'; + /** Add attribute to the id token */ + 'id.token.claim'?: boolean; + /** Add attribute to the access token */ + 'access.token.claim'?: boolean; + /** Add attribute to the userinfo */ + 'userinfo.token.claim'?: boolean; + [key: string]: any; +} + +export const getClientScope = async (data: { environment: string; scopeName: string; realmName: string }) => { + const { environment, realmName, scopeName } = data; + const { kcAdminClient } = await getAdminClient({ serviceType: 'gold', environment }); + return kcAdminClient.clientScopes.findOneByName({ + realm: realmName, + name: scopeName, + }); +}; + +export const createClientScope = async (data: { environment: string; realmName: string; scopeName: string }) => { + const { environment, realmName, scopeName } = data; + const { kcAdminClient } = await getAdminClient({ serviceType: 'gold', environment }); + + // Create does not return the id unfortunately, need to fetch again after. + await kcAdminClient.clientScopes.create({ + realm: realmName, + name: scopeName, + protocol: 'openid-connect', + }); + + return kcAdminClient.clientScopes.findOneByName({ + realm: realmName, + name: scopeName, + }); +}; + +export const deleteClientScope = async (data: { realmName: string; environment: string; scopeName: string }) => { + const { environment, realmName, scopeName } = data; + const { kcAdminClient } = await getAdminClient({ serviceType: 'gold', environment }); + await kcAdminClient.clientScopes.delByName({ realm: realmName, name: scopeName }); +}; + +export const getClientScopeMapper = async (data: { environment: string; scopeId: string; mapperName: string }) => { + const { environment, scopeId, mapperName } = data; + const { kcAdminClient } = await getAdminClient({ serviceType: 'gold', environment }); + return kcAdminClient.clientScopes.findProtocolMapperByName({ + realm: 'standard', + id: scopeId, + name: mapperName, + }); +}; + +export const createClientScopeMapper = async (data: { + environment: string; + realmName: string; + scopeName: string; + protocolMapper: string; + protocolMapperName: string; + protocol: string; + protocolMapperConfig: ProtocolMapperConfig; +}) => { + const { environment, realmName, scopeName, protocol, protocolMapper, protocolMapperConfig, protocolMapperName } = + data; + const { kcAdminClient } = await getAdminClient({ serviceType: 'gold', environment }); + + const clientScopes = await kcAdminClient.clientScopes.find({ realm: realmName }); + if (!clientScopes) return false; + + const clientScope = clientScopes.find((scope) => scope.name === scopeName); + if (!clientScope) return false; + + await kcAdminClient.clientScopes.addProtocolMapper( + { realm: realmName, id: clientScope.id }, + { + name: protocolMapperName, + protocol, + protocolMapper, + config: protocolMapperConfig, + }, + ); + + return true; +}; diff --git a/lambda/app/src/keycloak/idp.ts b/lambda/app/src/keycloak/idp.ts new file mode 100644 index 000000000..f2ad19c4d --- /dev/null +++ b/lambda/app/src/keycloak/idp.ts @@ -0,0 +1,127 @@ +import { getAdminClient } from './adminClient'; + +interface IdpConfig { + clientId?: string; + clientSecret?: string; + authorizationUrl?: string; + tokenUrl?: string; + logoutUrl?: string; + userInfoUrl?: string; + defaultScope?: string; + useJwksUrl?: boolean; + jwksUrl?: string; + issuer?: string; + clientAuthMethod?: string; + syncMode?: string; + disableUserInfo?: boolean; + validateSignature?: boolean; +} + +interface IdpMapperConfig { + claim: string; + 'user.attribute'?: string; + attribute?: string; + syncMode: 'INHERIT' | 'FORCE' | 'IMPORT'; + template?: string; +} + +export const getIdp = async (environment: string, alias: string) => { + const { kcAdminClient } = await getAdminClient({ serviceType: 'gold', environment }); + return kcAdminClient.identityProviders.findOne({ + realm: 'standard', + alias, + }); +}; + +export const createIdp = async ( + IdpConfig: { + alias: string; + displayName: string; + enabled: boolean; + config: IdpConfig; + storeToken?: boolean; + providerId: string; + realm: string; + }, + environment: string, +) => { + const { alias, displayName, enabled, config, storeToken, providerId, realm } = IdpConfig; + const { kcAdminClient } = await getAdminClient({ serviceType: 'gold', environment }); + return kcAdminClient.identityProviders.create({ + alias, + displayName, + realm, + enabled, + config, + providerId, + storeToken, + }); +}; + +export const deleteIdp = async (data: { environment: string; realmName: string; idpAlias: string }) => { + const { environment, realmName, idpAlias } = data; + const { kcAdminClient } = await getAdminClient({ serviceType: 'gold', environment }); + await kcAdminClient.identityProviders.del({ + realm: realmName, + alias: idpAlias, + }); + return true; +}; + +export const getIdpMappers = async (data: { environment: string; idpAlias: string }) => { + const { environment, idpAlias } = data; + const { kcAdminClient } = await getAdminClient({ serviceType: 'gold', environment }); + return kcAdminClient.identityProviders.findMappers({ + alias: idpAlias, + realm: 'standard', + }); +}; + +export const createIdpMapper = async (data: { + environment: string; + name: string; + idpAlias: string; + idpMapper: string; + idpMapperConfig: IdpMapperConfig; +}) => { + const { environment, idpAlias, idpMapperConfig, name, idpMapper } = data; + + const { kcAdminClient } = await getAdminClient({ serviceType: 'gold', environment }); + + await kcAdminClient.identityProviders.createMapper({ + alias: idpAlias, + realm: 'standard', + identityProviderMapper: { + identityProviderAlias: idpAlias, + name, + identityProviderMapper: idpMapper, + config: idpMapperConfig, + }, + }); + + return true; +}; + +export const deleteIdpMapper = async (data: { + environment: string; + realmName: string; + idpAlias: string; + mapperName: string; +}) => { + const { environment, realmName, idpAlias, mapperName } = data; + const { kcAdminClient } = await getAdminClient({ serviceType: 'gold', environment }); + const mappers = await kcAdminClient.identityProviders.findMappers({ + realm: realmName, + alias: idpAlias, + }); + + const mapper = mappers.find((m) => m.name === mapperName); + if (!mapper) return false; + + await kcAdminClient.identityProviders.delMapper({ + realm: realmName, + alias: idpAlias, + id: mapper.id, + }); + return true; +}; diff --git a/lambda/app/src/keycloak/integration.ts b/lambda/app/src/keycloak/integration.ts index 0c1de5434..b8022f2d1 100644 --- a/lambda/app/src/keycloak/integration.ts +++ b/lambda/app/src/keycloak/integration.ts @@ -3,11 +3,11 @@ import { getAdminClient } from './adminClient'; import { IntegrationData } from '@lambda-shared/interfaces'; import AuthenticationFlowRepresentation from 'keycloak-admin/lib/defs/authenticationFlowRepresentation'; import { models } from '@lambda-shared/sequelize/models/models'; -import { createEvent } from '@lambda-app/controllers/requests'; +import { createBCSCIntegration, createEvent, deleteBCSCIntegration } from '@lambda-app/controllers/requests'; import { ACTION_TYPES, EMAILS, EVENTS, REQUEST_TYPES } from '@lambda-shared/enums'; import { getTeamById } from '@lambda-app/queries/team'; import { sendTemplate } from '@lambda-shared/templates'; -import { usesBceid, usesGithub, usesDigitalCredential } from '@app/helpers/integration'; +import { usesBceid, usesGithub, usesDigitalCredential, usesBcServicesCard } from '@app/helpers/integration'; import axios from 'axios'; const realm = 'standard'; @@ -98,6 +98,22 @@ export const samlClientProfile = ( return samlClient; }; +const getDefaultClientScopes = (integration: IntegrationData, environment: string) => { + const defaultScopes = integration.protocol === 'oidc' ? ['common', 'profile', 'email'] : ['common-saml']; + + // BCSC client scope is named after the client id on bcsc side + if (usesBcServicesCard(integration)) { + defaultScopes.push(integration.clientId); + } + const otherIdpScopes = integration[`${environment}Idps`]?.filter((idp) => idp !== 'bcservicescard') || []; + if (integration.protocol === 'oidc') { + defaultScopes.concat(otherIdpScopes); + } else { + defaultScopes.concat(otherIdpScopes).map((idp: string) => `${idp}-saml`); + } + return defaultScopes; +}; + export const keycloakClient = async ( environment: string, integration: IntegrationData, @@ -126,6 +142,15 @@ export const keycloakClient = async ( if (integration.archived) { if (clients.length > 0) { + if (usesBcServicesCard(integration)) { + const bcscClientDetails = await models.bcscClient.findOne({ + where: { + requestId: integration.id, + environment, + }, + }); + await deleteBCSCIntegration(bcscClientDetails, integration.clientId); + } // delete the client await kcAdminClient.clients.del({ id: clients[0].id, realm }); } @@ -140,6 +165,9 @@ export const keycloakClient = async ( return true; } + if (usesBcServicesCard(integration)) { + await createBCSCIntegration(environment, integration, integration.userId); + } const authenticationFlows = await axios.get(`${kcAdminClient.baseUrl}/admin/realms/standard/authentication/flows`, { headers: { @@ -152,11 +180,8 @@ export const keycloakClient = async ( integration.protocol === 'oidc' ? openIdClientProfile(integration, environment, authenticationFlows.data) : samlClientProfile(integration, environment, authenticationFlows.data); - const defaultScopes = - integration.protocol === 'oidc' - ? ['common', 'profile', 'email'].concat(integration[`${environment}Idps`] || []) - : ['common-saml'].concat(integration[`${environment}Idps`].map((idp: string) => `${idp}-saml`) || []); + const defaultScopes = getDefaultClientScopes(integration, environment); if (clients.length === 0) { // if client does not exist then just create client client = await kcAdminClient.clients.create({ realm, ...clientData }); @@ -206,7 +231,6 @@ export const keycloakClient = async ( }); } } - for (const scope of defaultScopes.filter((n: string) => !existingDefaultScopes.includes(n))) { await kcAdminClient.clients.addDefaultClientScope({ id: client.id, diff --git a/lambda/app/src/utils/constants.ts b/lambda/app/src/utils/constants.ts index f867dc0a1..85aa9f60e 100644 --- a/lambda/app/src/utils/constants.ts +++ b/lambda/app/src/utils/constants.ts @@ -1,2 +1,5 @@ export const MS_GRAPH_URL = 'https://graph.microsoft.com'; export const CYPRESS_MOCKED_IDIR_LOOKUP = [{ mail: 'pathfinder.ssotraining2@gov.bc.ca', id: 1 }]; +export const bcscIdpMappers = [ + { name: 'username', type: 'oidc-username-idp-mapper', template: '${CLAIM.sub}@${ALIAS}' }, +]; diff --git a/lambda/app/src/utils/helpers.ts b/lambda/app/src/utils/helpers.ts index 98e8c8992..f207135ad 100644 --- a/lambda/app/src/utils/helpers.ts +++ b/lambda/app/src/utils/helpers.ts @@ -13,10 +13,14 @@ import { EMAILS } from '@lambda-shared/enums'; import { sequelize, models } from '@lambda-shared/sequelize/models/models'; import { getTeamById, isTeamAdmin } from '../queries/team'; import { generateInvitationToken } from '@lambda-app/helpers/token'; +import { getAttributes, getPrivacyZones } from '@lambda-app/controllers/bc-services-card'; +import { usesBcServicesCard } from '@app/helpers/integration'; export const errorMessage = 'No changes submitted. Please change your details to update your integration.'; export const IDIM_EMAIL_ADDRESS = 'bcgov.sso@gov.bc.ca'; +let cachedClaims = []; + export const omitNonFormFields = (data: Integration) => omit(data, [ 'updatedAt', @@ -92,13 +96,15 @@ export const getDifferences = (newData: any, originalData: Integration) => { return diff(omitNonFormFields(originalData), omitNonFormFields(newData)); }; -export const validateRequest = (formData: any, original: Integration, isUpdate = false, teams: any[]) => { - // if (isUpdate) { - // const differences = getDifferences(formData, original); - // if (!differences) return errorMessage; - // } +export const validateRequest = async (formData: any, original: Integration, teams: any[], isUpdate = false) => { + const validationArgs: any = { formData, teams }; - const schemas = getSchemas({ formData, teams }); + if (usesBcServicesCard(formData)) { + const [validPrivacyZones, validAttributes] = await Promise.all([getPrivacyZones(), getAttributes()]); + validationArgs.bcscPrivacyZones = validPrivacyZones; + validationArgs.bcscAttributes = validAttributes; + } + const schemas = getSchemas(validationArgs); return validateForm(formData, schemas); }; @@ -193,3 +199,39 @@ export async function inviteTeamMembers(userId: number, users: (User & { role: s }), ); } + +export const getBCSCEnvVars = (env: string) => { + let bcscBaseUrl: string; + let accessToken: string; + let kcBaseUrl: string; + + if (env === 'dev') { + bcscBaseUrl = process.env.BCSC_REGISTRATION_BASE_URL_DEV; + accessToken = process.env.BCSC_INITIAL_ACCESS_TOKEN_DEV; + kcBaseUrl = process.env.KEYCLOAK_V2_DEV_URL; + } + if (env === 'test') { + bcscBaseUrl = process.env.BCSC_REGISTRATION_BASE_URL_TEST; + accessToken = process.env.BCSC_INITIAL_ACCESS_TOKEN_TEST; + kcBaseUrl = process.env.KEYCLOAK_V2_TEST_URL; + } + if (env === 'prod') { + bcscBaseUrl = process.env.BCSC_REGISTRATION_BASE_URL_PROD; + accessToken = process.env.BCSC_INITIAL_ACCESS_TOKEN_PROD; + kcBaseUrl = process.env.KEYCLOAK_V2_PROD_URL; + } + return { + bcscBaseUrl, + accessToken, + kcBaseUrl, + }; +}; + +export const getRequiredBCSCScopes = async (claims) => { + if (cachedClaims.length === 0) { + cachedClaims = await getAttributes(); + } + const allClaims = cachedClaims; + const requiredScopes = allClaims.filter((claim) => claims.includes(claim.name)).map((claim) => claim.scope); + return ['openid', ...new Set(requiredScopes)].join(' '); +}; diff --git a/lambda/db/src/migrations/2024.06.06T11.01.11.add-bcsc-table.ts b/lambda/db/src/migrations/2024.06.06T11.01.11.add-bcsc-table.ts new file mode 100644 index 000000000..7be2c399b --- /dev/null +++ b/lambda/db/src/migrations/2024.06.06T11.01.11.add-bcsc-table.ts @@ -0,0 +1,99 @@ +import { DataTypes } from 'sequelize'; + +export const name = '2024.06.06T11.01.11.add-bcsc-table'; + +// see https://sequelize.org/master/manual/naming-strategies.html +export const up = async ({ context: sequelize }) => { + await sequelize.getQueryInterface().createTable('bcsc_clients', { + id: { + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true, + defaultValue: sequelize.UUIDV4, + autoIncrement: true, + }, + client_id: { + type: DataTypes.TEXT, + allowNull: false, + }, + client_secret: { + type: DataTypes.TEXT, + allowNull: false, + }, + registration_access_token: { + type: DataTypes.TEXT, + allowNull: false, + }, + environment: { + type: DataTypes.TEXT, + allowNull: false, + }, + client_name: { + type: DataTypes.TEXT, + allowNull: false, + }, + archived: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + request_id: { + type: DataTypes.INTEGER, + allowNull: false, + }, + createdAt: { + type: DataTypes.DATE, + field: 'created_at', + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updatedAt: { + type: DataTypes.DATE, + field: 'updated_at', + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }); + + await sequelize.getQueryInterface().addConstraint('bcsc_clients', { + fields: ['request_id', 'environment'], + type: 'unique', + }); + + await sequelize.getQueryInterface().addColumn('requests', 'bcsc_privacy_zone', { + type: DataTypes.TEXT, + allowNull: true, + }); + + await sequelize.getQueryInterface().addColumn('requests', 'dev_home_page_uri', { + type: DataTypes.TEXT, + allowNull: true, + }); + + await sequelize.getQueryInterface().addColumn('requests', 'test_home_page_uri', { + type: DataTypes.TEXT, + allowNull: true, + }); + + await sequelize.getQueryInterface().addColumn('requests', 'prod_home_page_uri', { + type: DataTypes.TEXT, + allowNull: true, + }); + + await sequelize.getQueryInterface().addColumn('requests', 'bcsc_attributes', { + type: DataTypes.ARRAY(DataTypes.TEXT), + allowNull: false, + defaultValue: [], + }); +}; + +export const down = async ({ context: sequelize }) => { + await sequelize.getQueryInterface().dropTable('bcsc_clients'); + await sequelize.getQueryInterface().removeColumn('requests', 'bcsc_privacy_zone'); + await sequelize.getQueryInterface().removeColumn('requests', 'bcsc_attributes'); + await sequelize.getQueryInterface().removeColumn('requests', 'dev_home_page_uri'); + await sequelize.getQueryInterface().removeColumn('requests', 'test_home_page_uri'); + await sequelize.getQueryInterface().removeColumn('requests', 'prod_home_page_uri'); +}; + +export default { name, up, down }; diff --git a/lambda/db/src/umzug.ts b/lambda/db/src/umzug.ts index da19cd420..7bee3746c 100644 --- a/lambda/db/src/umzug.ts +++ b/lambda/db/src/umzug.ts @@ -54,6 +54,7 @@ export const createMigrator = async (logger?: any) => { await import('./migrations/2023.12.28T00.00.00.create-request-queues-table'), await import('./migrations/2024.01.10T00.00.00.update-request-roles-table'), await import('./migrations/2024.04.23T00.00.00.add-flag-offline-access-enabled'), + await import('./migrations/2024.06.06T11.01.11.add-bcsc-table'), ], context: sequelize, storage: new SequelizeStorage({ diff --git a/lambda/request-queue/src/main.ts b/lambda/request-queue/src/main.ts index f1e78f49c..be7d29d54 100644 --- a/lambda/request-queue/src/main.ts +++ b/lambda/request-queue/src/main.ts @@ -6,6 +6,8 @@ import { keycloakClient } from '@lambda-app/keycloak/integration'; import { updatePlannedIntegration, createEvent } from '@lambda-app/controllers/requests'; import { ACTION_TYPES, EVENTS } from '@lambda-shared/enums'; +const REQUEST_QUEUE_INTERVAL_SECONDS = 60; + export const handler = async () => { try { const allPromises: Promise[] = []; @@ -15,6 +17,9 @@ export const handler = async () => { } requestQueue.forEach((queuedRequest) => { + const requestQueueSecondsAgo = (new Date().getTime() - new Date(queuedRequest.createdAt).getTime()) / 1000; + // Only act on queued items more than a minute old to prevent potential duplication. + if (requestQueueSecondsAgo < REQUEST_QUEUE_INTERVAL_SECONDS) return; console.info(`processing queued request ${queuedRequest.request.id}`); const { existingClientId, ...request } = queuedRequest.request; diff --git a/lambda/shared/sequelize/models/BcscClient.ts b/lambda/shared/sequelize/models/BcscClient.ts new file mode 100644 index 000000000..f10088d3d --- /dev/null +++ b/lambda/shared/sequelize/models/BcscClient.ts @@ -0,0 +1,65 @@ +const init = (sequelize, DataTypes) => { + const BcscClient = sequelize.define( + 'bcscClient', + { + id: { + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true, + defaultValue: sequelize.UUIDV4, + autoIncrement: true, + }, + clientId: { + type: DataTypes.TEXT, + allowNull: false, + }, + clientSecret: { + type: DataTypes.TEXT, + allowNull: false, + }, + registrationAccessToken: { + type: DataTypes.TEXT, + allowNull: false, + }, + clientName: { + type: DataTypes.TEXT, + allowNull: false, + }, + environment: { + type: DataTypes.TEXT, + allowNull: false, + }, + requestId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + archived: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + createdAt: { + type: DataTypes.DATE, + field: 'created_at', + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updatedAt: { + type: DataTypes.DATE, + field: 'updated_at', + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }, + { + underscored: true, + associate: function (models) { + BcscClient.belongsTo(models.request); + }, + }, + ); + + return BcscClient; +}; + +export default init; diff --git a/lambda/shared/sequelize/models/Request.ts b/lambda/shared/sequelize/models/Request.ts index cee6c4cee..86b48fdec 100644 --- a/lambda/shared/sequelize/models/Request.ts +++ b/lambda/shared/sequelize/models/Request.ts @@ -368,12 +368,34 @@ const init = (sequelize, DataTypes) => { allowNull: false, defaultValue: false, }, + bcscPrivacyZone: { + type: DataTypes.TEXT, + allowNull: true, + }, + bcscAttributes: { + type: DataTypes.ARRAY(DataTypes.TEXT), + allowNull: false, + defaultValue: [], + }, + devHomePageUri: { + type: DataTypes.TEXT, + allowNull: true, + }, + testHomePageUri: { + type: DataTypes.TEXT, + allowNull: true, + }, + prodHomePageUri: { + type: DataTypes.TEXT, + allowNull: true, + }, }, { underscored: true, associate: function (models) { Request.belongsTo(models.team); Request.belongsTo(models.user); + Request.hasMany(models.bcscClient); }, }, ); diff --git a/lambda/shared/sequelize/models/models.ts b/lambda/shared/sequelize/models/models.ts index d1562fc0e..709b51b3b 100644 --- a/lambda/shared/sequelize/models/models.ts +++ b/lambda/shared/sequelize/models/models.ts @@ -8,6 +8,7 @@ import Survey from './Survey'; import UserTeam from './UserTeam'; import RequestQueue from './RequestQueue'; import RequestRole from './RequestRole'; +import BcscClient from './BcscClient'; const env = process.env.NODE_ENV || 'development'; const config = configs[env]; @@ -26,7 +27,7 @@ if (config.databaseUrl) { console.log('sequelize initialized', !!sequelize); -[Event, Request, Team, User, UserTeam, Survey, RequestQueue, RequestRole].forEach((init) => { +[Event, Request, Team, User, UserTeam, Survey, RequestQueue, RequestRole, BcscClient].forEach((init) => { const model = init(sequelize, DataTypes); models[model.name] = model; modelNames.push(model.name); diff --git a/localserver/.env.example b/localserver/.env.example index e21c4652e..04a59658d 100644 --- a/localserver/.env.example +++ b/localserver/.env.example @@ -32,3 +32,11 @@ GOLD_IP_ADDRESS="142.34.229.4" MS_GRAPH_API_AUTHORITY= MS_GRAPH_API_CLIENT_ID= MS_GRAPH_API_CLIENT_SECRET= + +BCSC_REGISTRATION_BASE_URL_DEV= +BCSC_REGISTRATION_BASE_URL_TEST= +BCSC_REGISTRATION_BASE_URL_PROD= + +BCSC_INITIAL_ACCESS_TOKEN_DEV= +BCSC_INITIAL_ACCESS_TOKEN_TEST= +BCSC_INITIAL_ACCESS_TOKEN_PROD= diff --git a/terraform/lambda-app.tf b/terraform/lambda-app.tf index 1f96dd41e..70bbf9839 100644 --- a/terraform/lambda-app.tf +++ b/terraform/lambda-app.tf @@ -53,6 +53,14 @@ resource "aws_lambda_function" "app" { MS_GRAPH_API_AUTHORITY = var.ms_graph_api_authority MS_GRAPH_API_CLIENT_ID = var.ms_graph_api_client_id MS_GRAPH_API_CLIENT_SECRET = var.ms_graph_api_client_secret + + INCLUDE_BC_SERVICES_CARD = var.include_bc_services_card + BCSC_INITIAL_ACCESS_TOKEN_DEV = var.bcsc_initial_access_token_dev + BCSC_INITIAL_ACCESS_TOKEN_TEST = var.bcsc_initial_access_token_test + BCSC_INITIAL_ACCESS_TOKEN_PROD = var.bcsc_initial_access_token_prod + BCSC_REGISTRATION_BASE_URL_DEV = var.bcsc_registration_base_url_dev + BCSC_REGISTRATION_BASE_URL_TEST = var.bcsc_registration_base_url_test + BCSC_REGISTRATION_BASE_URL_PROD = var.bcsc_registration_base_url_prod } } diff --git a/terraform/lambda-request-queue.tf b/terraform/lambda-request-queue.tf index d38ddcd32..e6a9751a2 100644 --- a/terraform/lambda-request-queue.tf +++ b/terraform/lambda-request-queue.tf @@ -38,6 +38,14 @@ resource "aws_lambda_function" "request_queue" { CHES_PASSWORD = var.ches_password CHES_USERNAME = var.ches_username GOLD_IP_ADDRESS = var.gold_ip_address + + INCLUDE_BC_SERVICES_CARD = var.include_bc_services_card + BCSC_INITIAL_ACCESS_TOKEN_DEV = var.bcsc_initial_access_token_dev + BCSC_INITIAL_ACCESS_TOKEN_TEST = var.bcsc_initial_access_token_test + BCSC_INITIAL_ACCESS_TOKEN_PROD = var.bcsc_initial_access_token_prod + BCSC_REGISTRATION_BASE_URL_DEV = var.bcsc_registration_base_url_dev + BCSC_REGISTRATION_BASE_URL_TEST = var.bcsc_registration_base_url_test + BCSC_REGISTRATION_BASE_URL_PROD = var.bcsc_registration_base_url_prod } } diff --git a/terraform/variables.tf b/terraform/variables.tf index ae2f2cb9f..7c2837bac 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -181,3 +181,38 @@ variable "rc_webhook" { default = "" sensitive = true } + +variable "include_bc_services_card" { + type = string + default = false +} + +variable "bcsc_initial_access_token_dev" { + type = string + sensitive = true +} + +variable "bcsc_initial_access_token_test" { + type = string + sensitive = true +} + +variable "bcsc_initial_access_token_prod" { + type = string + sensitive = true +} + +variable "bcsc_registration_base_url_dev" { + type = string + sensitive = true +} + +variable "bcsc_registration_base_url_test" { + type = string + sensitive = true +} + +variable "bcsc_registration_base_url_prod" { + type = string + sensitive = true +}