From 5b581f6c9f203e81576bce9f99f8d081c56185d4 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 10:23:02 -0500 Subject: [PATCH 01/59] frontend component and script --- frontend/deploy-smoke.js | 31 +++++++++++++++ frontend/package.json | 6 ++- frontend/src/app/HealthChecks.tsx | 3 ++ frontend/src/app/ProdSmokeTest.test.tsx | 30 +++++++++++++++ frontend/src/app/ProdSmokeTest.tsx | 29 ++++++++++++++ frontend/yarn.lock | 50 ++++++++++++++++++++++++- 6 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 frontend/deploy-smoke.js create mode 100644 frontend/src/app/ProdSmokeTest.test.tsx create mode 100644 frontend/src/app/ProdSmokeTest.tsx diff --git a/frontend/deploy-smoke.js b/frontend/deploy-smoke.js new file mode 100644 index 0000000000..b2740c3231 --- /dev/null +++ b/frontend/deploy-smoke.js @@ -0,0 +1,31 @@ +// Script that does a simple Selenium scrape of +// - A frontend page with a simple status message that hits a health check backend +// endpoint which does a simple ping to a non-sensitive DB table to verify +// all the connections are good. +// https://github.com/CDCgov/prime-simplereport/pull/7057 + +require("dotenv").config(); +let { Builder } = require("selenium-webdriver"); +const Chrome = require("selenium-webdriver/chrome"); + +console.log(`Running smoke test for ${process.env.REACT_APP_BASE_URL}`); +const options = new Chrome.Options(); +const driver = new Builder() + .forBrowser("chrome") + .setChromeOptions(options.addArguments("--headless=new")) + .build(); +driver + .navigate() + .to(`${process.env.REACT_APP_BASE_URL}app/health/prod-smoke-test`) + .then(() => { + let value = driver.findElement({ id: "root" }).getText(); + return value; + }) + .then((value) => { + driver.quit(); + return value; + }) + .then((value) => { + if (value.includes("success")) process.exit(0); + process.exit(1); + }); diff --git a/frontend/package.json b/frontend/package.json index f6ce9c5ac4..dbc0501846 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -75,7 +75,9 @@ "storybook": "yarn create-storybook-public && SASS_PATH=$(cd ./node_modules && pwd):$(cd ./node_modules/@uswds && pwd):$(cd ./node_modules/@uswds/uswds/packages && pwd):$(cd ./src/scss && pwd) storybook dev -p 6006 -s ../storybook_public", "build-storybook": "yarn create-storybook-public && REACT_APP_BACKEND_URL=http://localhost:8080 SASS_PATH=$(cd ./node_modules && pwd):$(cd ./node_modules/@uswds && pwd):$(cd ./node_modules/@uswds/uswds/packages && pwd):$(cd ./src/scss && pwd) storybook build -s storybook_public", "maintenance:start": "[ -z \"$MAINTENANCE_MESSAGE\" ] && echo \"MAINTENANCE_MESSAGE must be set!\" || (echo $MAINTENANCE_MESSAGE > maintenance.json && yarn maintenance:deploy && rm maintenance.json)", - "maintenance:deploy": "[ -z \"$MAINTENANCE_ENV\" ] && echo \"MAINTENANCE_ENV must be set!\" || az storage blob upload -f maintenance.json -n maintenance.json -c '$web' --account-name simplereport${MAINTENANCE_ENV}app --overwrite" + "maintenance:deploy": "[ -z \"$MAINTENANCE_ENV\" ] && echo \"MAINTENANCE_ENV must be set!\" || az storage blob upload -f maintenance.json -n maintenance.json -c '$web' --account-name simplereport${MAINTENANCE_ENV}app --overwrite", + "smoke:env:deploy": "node deploy-smoke.js", + "smoke:env:deploy:ci": "node -r dotenv/config deploy-smoke.js dotenv_config_path=.env.production.local dotenv_config_debug=true" }, "prettier": { "singleQuote": false @@ -206,6 +208,7 @@ "chromatic": "^6.10.2", "dayjs": "^1.10.7", "depcheck": "^1.4.3", + "dotenv": "^16.3.1", "eslint-config-prettier": "^8.8.0", "eslint-plugin-graphql": "^4.0.0", "eslint-plugin-import": "^2.29.0", @@ -225,6 +228,7 @@ "prettier": "^2.8.4", "redux-mock-store": "^1.5.4", "sass": "^1.63.6", + "selenium-webdriver": "^4.16.0", "storybook": "^7.5.2", "storybook-addon-apollo-client": "^5.0.0", "stylelint": "^13.13.1", diff --git a/frontend/src/app/HealthChecks.tsx b/frontend/src/app/HealthChecks.tsx index 608113e171..3e8a7fdaff 100644 --- a/frontend/src/app/HealthChecks.tsx +++ b/frontend/src/app/HealthChecks.tsx @@ -1,5 +1,7 @@ import { Route, Routes } from "react-router-dom"; +import ProdSmokeTest from "./ProdSmokeTest"; + const HealthChecks = () => ( pong} /> @@ -7,6 +9,7 @@ const HealthChecks = () => ( path="commit" element={
{process.env.REACT_APP_CURRENT_COMMIT}
} /> + } />
); diff --git a/frontend/src/app/ProdSmokeTest.test.tsx b/frontend/src/app/ProdSmokeTest.test.tsx new file mode 100644 index 0000000000..c591ad23fd --- /dev/null +++ b/frontend/src/app/ProdSmokeTest.test.tsx @@ -0,0 +1,30 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { FetchMock } from "jest-fetch-mock"; + +import ProdSmokeTest from "./ProdSmokeTest"; + +describe("ProdSmokeTest", () => { + beforeEach(() => { + (fetch as FetchMock).resetMocks(); + }); + + it("renders success when returned from the API endpoint", async () => { + (fetch as FetchMock).mockResponseOnce(JSON.stringify({ status: "UP" })); + + render(); + await waitFor(() => + expect(screen.queryByText("Status loading...")).not.toBeInTheDocument() + ); + expect(screen.getByText("Status returned success :)")); + }); + + it("renders failure when returned from the API endpoint", async () => { + (fetch as FetchMock).mockResponseOnce(JSON.stringify({ status: "DOWN" })); + + render(); + await waitFor(() => + expect(screen.queryByText("Status loading...")).not.toBeInTheDocument() + ); + expect(screen.getByText("Status returned failure :(")); + }); +}); diff --git a/frontend/src/app/ProdSmokeTest.tsx b/frontend/src/app/ProdSmokeTest.tsx new file mode 100644 index 0000000000..ddba865338 --- /dev/null +++ b/frontend/src/app/ProdSmokeTest.tsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; + +import FetchClient from "./utils/api"; + +const api = new FetchClient(undefined, { mode: "cors" }); +const ProdSmokeTest = (): JSX.Element => { + const [success, setSuccess] = useState(); + useEffect(() => { + api + .getRequest("/actuator/health/prod-smoke-test") + .then((response) => { + console.log(response); + const status = JSON.parse(response); + if (status.status === "UP") return setSuccess(true); + // log something using app insights + setSuccess(false); + }) + .catch((e) => { + console.error(e); + setSuccess(false); + }); + }, []); + + if (success === undefined) return <>Status loading...; + if (success) return <> Status returned success :) ; + return <> Status returned failure :( ; +}; + +export default ProdSmokeTest; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 2a21c2cbcd..474fd4bcd9 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -9120,7 +9120,7 @@ dotenv@^10.0.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81" integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== -dotenv@^16.0.0: +dotenv@^16.0.0, dotenv@^16.3.1: version "16.3.1" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== @@ -11151,6 +11151,11 @@ ignore@^5.2.4: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78" integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg== +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== + immer@^9.0.7: version "9.0.16" resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.16.tgz#8e7caab80118c2b54b37ad43e05758cdefad0198" @@ -12932,6 +12937,16 @@ jsonpointer@^5.0.0: array-includes "^3.1.5" object.assign "^4.1.3" +jszip@^3.10.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" + integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + setimmediate "^1.0.5" + jwt-decode@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" @@ -13015,6 +13030,13 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + lilconfig@^2.0.3, lilconfig@^2.0.5, lilconfig@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4" @@ -14668,6 +14690,11 @@ pako@~0.2.0: resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA== +pako@~1.0.2: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + param-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5" @@ -16872,6 +16899,15 @@ select-hose@^2.0.0: resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" integrity sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg== +selenium-webdriver@^4.16.0: + version "4.16.0" + resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.16.0.tgz#2f1a2426d876aa389d1c937b00f034c2c7808360" + integrity sha512-IbqpRpfGE7JDGgXHJeWuCqT/tUqnLvZ14csSwt+S8o4nJo3RtQoE9VR4jB47tP/A8ArkYsh/THuMY6kyRP6kuA== + dependencies: + jszip "^3.10.1" + tmp "^0.2.1" + ws ">=8.14.2" + selfsigned@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-2.1.1.tgz#18a7613d714c0cd3385c48af0075abf3f266af61" @@ -18025,6 +18061,13 @@ tmp@^0.0.33: dependencies: os-tmpdir "~1.0.2" +tmp@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" + integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ== + dependencies: + rimraf "^3.0.0" + tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -19434,6 +19477,11 @@ ws@8.14.1: resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.1.tgz#4b9586b4f70f9e6534c7bb1d3dc0baa8b8cf01e0" integrity sha512-4OOseMUq8AzRBI/7SLMUwO+FEDnguetSk7KMb1sHwvF2w2Wv5Hoj0nlifx8vtGsftE/jWHojPy8sMMzYLJ2G/A== +ws@>=8.14.2: + version "8.15.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.15.0.tgz#db080a279260c5f532fc668d461b8346efdfcf86" + integrity sha512-H/Z3H55mrcrgjFwI+5jKavgXvwQLtfPCUEp6pi35VhoB0pfcHnSoyuTzkBEZpzq49g1193CUEwIvmsjcotenYw== + "ws@^5.2.0 || ^6.0.0 || ^7.0.0", ws@^7.4.6: version "7.5.9" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" From 61bec6cc42131f3a7fd846671e4aed5421505ae5 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 10:23:23 -0500 Subject: [PATCH 02/59] backend config --- .../BackendAndDatabaseHealthIndicator.java | 31 ++++++++++++++ .../config/SecurityConfiguration.java | 3 ++ backend/src/main/resources/application.yaml | 1 + ...BackendAndDatabaseHealthIndicatorTest.java | 41 +++++++++++++++++++ .../test_util/SliceTestConfiguration.java | 4 +- 5 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java create mode 100644 backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java new file mode 100644 index 0000000000..0bb039fef2 --- /dev/null +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -0,0 +1,31 @@ +package gov.cdc.usds.simplereport.api.heathcheck; + +import gov.cdc.usds.simplereport.db.repository.FeatureFlagRepository; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.exception.JDBCConnectionException; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +@Component("prod-smoke-test") +@Slf4j +@RequiredArgsConstructor +public class BackendAndDatabaseHealthIndicator implements HealthIndicator { + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final FeatureFlagRepository _repo; + + @Override + public Health health() { + try { + _repo.findAll(); + return Health.up().build(); + } catch (JDBCConnectionException e) { + return Health.down().build(); + } + } +} diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/config/SecurityConfiguration.java b/backend/src/main/java/gov/cdc/usds/simplereport/config/SecurityConfiguration.java index 15fdeee263..07a737850a 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/config/SecurityConfiguration.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/config/SecurityConfiguration.java @@ -1,6 +1,7 @@ package gov.cdc.usds.simplereport.config; import com.okta.spring.boot.oauth.Okta; +import gov.cdc.usds.simplereport.api.heathcheck.BackendAndDatabaseHealthIndicator; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; import gov.cdc.usds.simplereport.service.model.IdentitySupplier; import lombok.extern.slf4j.Slf4j; @@ -57,6 +58,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers(EndpointRequest.to(InfoEndpoint.class)) .permitAll() + .requestMatchers(EndpointRequest.to(BackendAndDatabaseHealthIndicator.class)) + .permitAll() // Patient experience authorization is handled in PatientExperienceController // If this configuration changes, please update the documentation on both sides .requestMatchers(HttpMethod.POST, WebConfiguration.PATIENT_EXPERIENCE) diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 1df6cb8e0a..8f792d8cc0 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -78,6 +78,7 @@ management: endpoint.health.probes.enabled: true endpoint.info.enabled: true endpoints.web.exposure.include: health, info + endpoint.health.show-components: always okta: oauth2: issuer: https://hhs-prime.okta.com/oauth2/default diff --git a/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java b/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java new file mode 100644 index 0000000000..61ae972a84 --- /dev/null +++ b/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java @@ -0,0 +1,41 @@ +package gov.cdc.usds.simplereport.api.healthcheck; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import gov.cdc.usds.simplereport.api.heathcheck.BackendAndDatabaseHealthIndicator; +import gov.cdc.usds.simplereport.db.repository.BaseRepositoryTest; +import gov.cdc.usds.simplereport.db.repository.FeatureFlagRepository; +import java.sql.SQLException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.hibernate.exception.JDBCConnectionException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.mock.mockito.SpyBean; + +@RequiredArgsConstructor +@EnableConfigurationProperties +class BackendAndDatabaseHealthIndicatorTest extends BaseRepositoryTest { + + @SpyBean private FeatureFlagRepository mockRepo; + + @Autowired private BackendAndDatabaseHealthIndicator indicator; + + @Test + void health_succeedsWhenRepoDoesntThrow() { + when(mockRepo.findAll()).thenReturn(List.of()); + assertThat(indicator.health()).isEqualTo(Health.up().build()); + } + + @Test + void health_failsWhenRepoDoesntThrow() { + JDBCConnectionException dbConnectionException = + new JDBCConnectionException( + "connection issue", new SQLException("some reason", "some state")); + when(mockRepo.findAll()).thenThrow(dbConnectionException); + assertThat(indicator.health()).isEqualTo(Health.down().build()); + } +} diff --git a/backend/src/test/java/gov/cdc/usds/simplereport/test_util/SliceTestConfiguration.java b/backend/src/test/java/gov/cdc/usds/simplereport/test_util/SliceTestConfiguration.java index 30e8144768..3ac3f7dd7d 100644 --- a/backend/src/test/java/gov/cdc/usds/simplereport/test_util/SliceTestConfiguration.java +++ b/backend/src/test/java/gov/cdc/usds/simplereport/test_util/SliceTestConfiguration.java @@ -5,6 +5,7 @@ import gov.cdc.usds.simplereport.api.CurrentOrganizationRolesContextHolder; import gov.cdc.usds.simplereport.api.CurrentTenantDataAccessContextHolder; import gov.cdc.usds.simplereport.api.WebhookContextHolder; +import gov.cdc.usds.simplereport.api.heathcheck.BackendAndDatabaseHealthIndicator; import gov.cdc.usds.simplereport.api.pxp.CurrentPatientContextHolder; import gov.cdc.usds.simplereport.config.AuditingConfig; import gov.cdc.usds.simplereport.config.AuthorizationProperties; @@ -100,7 +101,8 @@ CurrentTenantDataAccessContextHolder.class, WebhookContextHolder.class, TenantDataAccessService.class, - PatientSelfRegistrationLinkService.class + PatientSelfRegistrationLinkService.class, + BackendAndDatabaseHealthIndicator.class }) @EnableConfigurationProperties({InitialSetupProperties.class, AuthorizationProperties.class}) public class SliceTestConfiguration { From e754b8daf3e9cb55819ed171981e9329eace3a1b Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 10:23:28 -0500 Subject: [PATCH 03/59] actions --- .../actions/post-deploy-smoke-test/action.yml | 34 +++++++++++++ .github/workflows/smokeTestDeployDev.yml | 50 +++++++++++++++++++ .github/workflows/smokeTestDeployProd.yml | 34 +++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 .github/actions/post-deploy-smoke-test/action.yml create mode 100644 .github/workflows/smokeTestDeployDev.yml create mode 100644 .github/workflows/smokeTestDeployProd.yml diff --git a/.github/actions/post-deploy-smoke-test/action.yml b/.github/actions/post-deploy-smoke-test/action.yml new file mode 100644 index 0000000000..0b573ecaa2 --- /dev/null +++ b/.github/actions/post-deploy-smoke-test/action.yml @@ -0,0 +1,34 @@ +name: Smoke test post deploy +description: Ping a backend health endpoint that reaches into the db and return a status message to a frontend status page. Visit that page and ensure things are healthy +inputs: + deploy-env: + description: The environment being deployed (e.g. "prod" or "test") + required: true +runs: + using: composite + steps: + - name: create env file + shell: bash + working-directory: frontend + run: | + touch .env + echo REACT_APP_BASE_URL=https://${{ inputs.deploy-env }}.simplereport.gov/ >> .env.production.local + - name: Run smoke test script + shell: bash + working-directory: frontend + run: yarn smoke:env:deploy:ci + + # slack_alert: + # runs-on: ubuntu-latest + # if: failure() + # needs: [ smoke-test-front-and-back-end ] + # steps: + # - uses: actions/checkout@v4 + # - name: Send alert to Slack + # uses: ./.github/actions/slack-message + # with: + # username: ${{ github.actor }} + # description: | + # :siren-gif: Post-deploy smoke test couldn't verify that the frontend is talking to the backend. ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} :siren-gif: + # webhook_url: ${{ secrets.SR_ALERTS_SLACK_WEBHOOK_URL }} + # user_map: $${{ secrets.SR_ALERTS_GITHUB_SLACK_MAP }} \ No newline at end of file diff --git a/.github/workflows/smokeTestDeployDev.yml b/.github/workflows/smokeTestDeployDev.yml new file mode 100644 index 0000000000..4c6d64e72d --- /dev/null +++ b/.github/workflows/smokeTestDeployDev.yml @@ -0,0 +1,50 @@ +name: Smoke test deploy +run-name: Smoke test the deploy for a dev env by @${{ github.actor }} + +on: + # DELETE ME WHEN MERGING + push: + # UNCOMMENT ME WHEN MERGING +# workflow_dispatch: +# inputs: +# deploy_env: +# description: 'The environment to smoke test' +# required: true +# type: choice +# options: +# - "" +# - dev +# - dev2 +# - dev3 +# - dev4 +# - dev5 +# - dev6 +# - dev7 +# - pentest + +env: + NODE_VERSION: 18 + +jobs: + smoke-test-front-and-back-end: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + - name: Cache yarn + uses: actions/cache@v3.3.2 + with: + path: ~/.cache/yarn + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + - name: Set up dependencies + working-directory: frontend + run: yarn install --prefer-offline + - name: Smoke test the env + uses: ./.github/actions/post-deploy-smoke-test + with: + # REPLACE ME WITH deploy-env: ${{inputs.deploy_env}} + deploy-env: dev7 + diff --git a/.github/workflows/smokeTestDeployProd.yml b/.github/workflows/smokeTestDeployProd.yml new file mode 100644 index 0000000000..da442331a3 --- /dev/null +++ b/.github/workflows/smokeTestDeployProd.yml @@ -0,0 +1,34 @@ +name: Smoke test deploy Prod +run-name: Smoke test the deploy for prod by @${{ github.actor }} + +on: + workflow_run: + workflows: [ " Deploy Prod" ] + types: + - completed + +env: + NODE_VERSION: 18 + DEPLOY_ENV: prod + +jobs: + smoke-test-front-and-back-end: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + - name: Cache yarn + uses: actions/cache@v3.3.2 + with: + path: ~/.cache/yarn + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + - name: Set up dependencies + working-directory: frontend + run: yarn install --prefer-offline + - name: Smoke test the env + uses: ./.github/actions/post-deploy-smoke-test + with: + deploy-env: ${{ env.DEPLOY_ENV }} From 1b95fdda0944d5b0b98923330a7bede82db8d45e Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 10:36:20 -0500 Subject: [PATCH 04/59] rename --- .../{smokeTestDeployDev.yml => smokeTestDeployManual.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{smokeTestDeployDev.yml => smokeTestDeployManual.yml} (100%) diff --git a/.github/workflows/smokeTestDeployDev.yml b/.github/workflows/smokeTestDeployManual.yml similarity index 100% rename from .github/workflows/smokeTestDeployDev.yml rename to .github/workflows/smokeTestDeployManual.yml From ea353ae2b9218a2855dc45f1f662326c2ca9d042 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 10:36:53 -0500 Subject: [PATCH 05/59] final --- .github/workflows/smokeTestDeployManual.yml | 39 ++++++++++----------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/.github/workflows/smokeTestDeployManual.yml b/.github/workflows/smokeTestDeployManual.yml index 4c6d64e72d..a803c0a0f8 100644 --- a/.github/workflows/smokeTestDeployManual.yml +++ b/.github/workflows/smokeTestDeployManual.yml @@ -2,25 +2,22 @@ name: Smoke test deploy run-name: Smoke test the deploy for a dev env by @${{ github.actor }} on: - # DELETE ME WHEN MERGING - push: - # UNCOMMENT ME WHEN MERGING -# workflow_dispatch: -# inputs: -# deploy_env: -# description: 'The environment to smoke test' -# required: true -# type: choice -# options: -# - "" -# - dev -# - dev2 -# - dev3 -# - dev4 -# - dev5 -# - dev6 -# - dev7 -# - pentest + workflow_dispatch: + inputs: + deploy_env: + description: 'The environment to smoke test' + required: true + type: choice + options: + - "" + - dev + - dev2 + - dev3 + - dev4 + - dev5 + - dev6 + - dev7 + - pentest env: NODE_VERSION: 18 @@ -45,6 +42,6 @@ jobs: - name: Smoke test the env uses: ./.github/actions/post-deploy-smoke-test with: - # REPLACE ME WITH deploy-env: ${{inputs.deploy_env}} - deploy-env: dev7 + deploy-env: ${{inputs.deploy_env}} + From 0573b6c4741f91cc29941e9d013944079218f40e Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 10:38:18 -0500 Subject: [PATCH 06/59] comment workflows --- .github/workflows/checkForChanges.yml | 66 +-- .github/workflows/checkGraphql.yml | 118 ++-- .github/workflows/chromatic.yml | 128 ++--- .github/workflows/codeql.yml | 110 ++-- .github/workflows/coverity.yml | 148 ++--- .github/workflows/e2eLocal.yml | 296 +++++----- .github/workflows/lighthouse.yml | 170 +++--- .github/workflows/liquibaseRollback.yml | 94 ++-- .github/workflows/liquibaseValidate.yml | 82 +-- .github/workflows/smokeTestDeployManual.yml | 39 +- .github/workflows/smokeTestDeployProd.yml | 2 +- .github/workflows/terraformChecks.yml | 202 +++---- .github/workflows/testingWorkflow.yml | 568 ++++++++++---------- .github/workflows/tfsec.yml | 56 +- 14 files changed, 1041 insertions(+), 1038 deletions(-) diff --git a/.github/workflows/checkForChanges.yml b/.github/workflows/checkForChanges.yml index badc700b5c..1ca0621035 100644 --- a/.github/workflows/checkForChanges.yml +++ b/.github/workflows/checkForChanges.yml @@ -1,33 +1,33 @@ -name: Check for Changes -# Reusable workflow, compatible with push and pull_request events -on: - workflow_call: - inputs: - # can be a file or a folder - what_to_check: - required: true - type: string - outputs: - has_changes: - description: true or false string - value: ${{ jobs.check_for_changes.outputs.has_changes }} -jobs: - check_for_changes: - runs-on: ubuntu-latest - outputs: - has_changes: ${{ steps.check_for_changes.outputs.has_changes }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - shell: bash - id: check_for_changes - run: | - echo "::group::Set has_changes output" - diff=$(git diff-tree --no-commit-id --name-only -r "origin/main" HEAD -- "${{ inputs.what_to_check }}") - echo "Diff: $diff" - diff_length=$(echo ${#diff}) - has_diff=$([ $diff_length -gt 0 ] && echo "true" || echo "false") - echo "The Changes: ${has_diff}" - echo "has_changes=${has_diff}" >> "$GITHUB_OUTPUT" - echo "::endgroup::" +#name: Check for Changes +## Reusable workflow, compatible with push and pull_request events +#on: +# workflow_call: +# inputs: +# # can be a file or a folder +# what_to_check: +# required: true +# type: string +# outputs: +# has_changes: +# description: true or false string +# value: ${{ jobs.check_for_changes.outputs.has_changes }} +#jobs: +# check_for_changes: +# runs-on: ubuntu-latest +# outputs: +# has_changes: ${{ steps.check_for_changes.outputs.has_changes }} +# steps: +# - uses: actions/checkout@v4 +# with: +# fetch-depth: 0 +# - shell: bash +# id: check_for_changes +# run: | +# echo "::group::Set has_changes output" +# diff=$(git diff-tree --no-commit-id --name-only -r "origin/main" HEAD -- "${{ inputs.what_to_check }}") +# echo "Diff: $diff" +# diff_length=$(echo ${#diff}) +# has_diff=$([ $diff_length -gt 0 ] && echo "true" || echo "false") +# echo "The Changes: ${has_diff}" +# echo "has_changes=${has_diff}" >> "$GITHUB_OUTPUT" +# echo "::endgroup::" diff --git a/.github/workflows/checkGraphql.yml b/.github/workflows/checkGraphql.yml index 85c159e4a1..5d3237c9a2 100644 --- a/.github/workflows/checkGraphql.yml +++ b/.github/workflows/checkGraphql.yml @@ -1,59 +1,59 @@ -name: Check Graphql - -on: - workflow_dispatch: - pull_request: - branches: - - "**" - merge_group: - types: - - checks_requested - push: - branches: - - main - -env: - NODE_VERSION: 18 - -defaults: - run: - working-directory: frontend - -jobs: - check-graphql-types: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{env.NODE_VERSION}} - uses: actions/setup-node@v4.0.0 - with: - node-version: ${{env.NODE_VERSION}} - - name: Cache yarn - uses: actions/cache@v3.3.2 - with: - path: ~/.cache/yarn - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - name: Node setup - run: yarn install --prefer-offline - - name: Generate grahpql types - run: yarn codegen - - name: Lint generated files - run: yarn lint:write - - name: Check for changes - run: | - if [[ -z "$(git status --porcelain)" ]]; then - exit 0 - else - echo "Current generated code does not match code in latest commit. try running cd frontend/ && yarn codegen" - git diff >> diff.txt - exit 1 - fi - - name: Archive git diff - uses: actions/upload-artifact@v3 - if: failure() - with: - name: files changed - path: frontend/diff.txt - retention-days: 7 +#name: Check Graphql +# +#on: +# workflow_dispatch: +# pull_request: +# branches: +# - "**" +# merge_group: +# types: +# - checks_requested +# push: +# branches: +# - main +# +#env: +# NODE_VERSION: 18 +# +#defaults: +# run: +# working-directory: frontend +# +#jobs: +# check-graphql-types: +# runs-on: ubuntu-latest +# steps: +# - uses: actions/checkout@v4 +# - name: Use Node.js ${{env.NODE_VERSION}} +# uses: actions/setup-node@v4.0.0 +# with: +# node-version: ${{env.NODE_VERSION}} +# - name: Cache yarn +# uses: actions/cache@v3.3.2 +# with: +# path: ~/.cache/yarn +# key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} +# restore-keys: | +# ${{ runner.os }}-yarn- +# - name: Node setup +# run: yarn install --prefer-offline +# - name: Generate grahpql types +# run: yarn codegen +# - name: Lint generated files +# run: yarn lint:write +# - name: Check for changes +# run: | +# if [[ -z "$(git status --porcelain)" ]]; then +# exit 0 +# else +# echo "Current generated code does not match code in latest commit. try running cd frontend/ && yarn codegen" +# git diff >> diff.txt +# exit 1 +# fi +# - name: Archive git diff +# uses: actions/upload-artifact@v3 +# if: failure() +# with: +# name: files changed +# path: frontend/diff.txt +# retention-days: 7 diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index c76021d463..6786b235be 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -1,64 +1,64 @@ -name: Chromatic - -on: - workflow_dispatch: - pull_request: - branches: - - "**" - paths: - - "frontend/**" - push: - branches: - - main - paths: - - "frontend/**" - -env: - NODE_VERSION: 18 - -jobs: - chromatic-deployment: - runs-on: ubuntu-latest - steps: - - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Chromatic wants the history - - - uses: actions/setup-node@v4.0.0 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Install dependencies - run: yarn - working-directory: frontend - - - name: Publish to Chromatic - if: github.ref != 'refs/heads/main' - uses: chromaui/action@v10 - with: - workingDir: frontend - token: ${{ secrets.GITHUB_TOKEN }} - projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - ignoreLastBuildOnBranch: "**" # Better comparisons after rebasing - exitZeroOnChanges: true - exitOnceUploaded: true - - - name: Publish to Chromatic (auto-accept changes on merge main) - if: github.ref == 'refs/heads/main' - uses: chromaui/action@v10 - with: - workingDir: frontend - token: ${{ secrets.GITHUB_TOKEN }} - projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - ignoreLastBuildOnBranch: "**" - exitZeroOnChanges: true - exitOnceUploaded: true - autoAcceptChanges: true # handle squash-on-merge - - - name: Artifact Chromatic logs - if: failure() - uses: actions/upload-artifact@v3 - with: - name: logs - path: frontend/*.log +#name: Chromatic +# +#on: +# workflow_dispatch: +# pull_request: +# branches: +# - "**" +# paths: +# - "frontend/**" +# push: +# branches: +# - main +# paths: +# - "frontend/**" +# +#env: +# NODE_VERSION: 18 +# +#jobs: +# chromatic-deployment: +# runs-on: ubuntu-latest +# steps: +# +# - uses: actions/checkout@v4 +# with: +# fetch-depth: 0 # Chromatic wants the history +# +# - uses: actions/setup-node@v4.0.0 +# with: +# node-version: ${{ env.NODE_VERSION }} +# +# - name: Install dependencies +# run: yarn +# working-directory: frontend +# +# - name: Publish to Chromatic +# if: github.ref != 'refs/heads/main' +# uses: chromaui/action@v10 +# with: +# workingDir: frontend +# token: ${{ secrets.GITHUB_TOKEN }} +# projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} +# ignoreLastBuildOnBranch: "**" # Better comparisons after rebasing +# exitZeroOnChanges: true +# exitOnceUploaded: true +# +# - name: Publish to Chromatic (auto-accept changes on merge main) +# if: github.ref == 'refs/heads/main' +# uses: chromaui/action@v10 +# with: +# workingDir: frontend +# token: ${{ secrets.GITHUB_TOKEN }} +# projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} +# ignoreLastBuildOnBranch: "**" +# exitZeroOnChanges: true +# exitOnceUploaded: true +# autoAcceptChanges: true # handle squash-on-merge +# +# - name: Artifact Chromatic logs +# if: failure() +# uses: actions/upload-artifact@v3 +# with: +# name: logs +# path: frontend/*.log diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4f7fb9d53a..4230dc7f90 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,55 +1,55 @@ -name: "CodeQL" - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - schedule: - - cron: "45 4 * * 3" - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - JAVA_VERSION: 17 - JAVA_DISTRIBUTION: 'zulu' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ javascript, java ] - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - queries: +security-and-quality - - - name: Set up JDK - uses: actions/setup-java@v4 - with: - java-version: ${{env.JAVA_VERSION}} - distribution: ${{env.JAVA_DISTRIBUTION}} - - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{ matrix.language }}" \ No newline at end of file +#name: "CodeQL" +# +#on: +# push: +# branches: [ "main" ] +# pull_request: +# branches: [ "main" ] +# schedule: +# - cron: "45 4 * * 3" +# +#concurrency: +# group: ${{ github.workflow }}-${{ github.ref }} +# cancel-in-progress: true +# +#env: +# JAVA_VERSION: 17 +# JAVA_DISTRIBUTION: 'zulu' +# +#jobs: +# analyze: +# name: Analyze +# runs-on: ubuntu-latest +# permissions: +# actions: read +# contents: read +# security-events: write +# +# strategy: +# fail-fast: false +# matrix: +# language: [ javascript, java ] +# +# steps: +# - name: Checkout +# uses: actions/checkout@v4 +# +# - name: Initialize CodeQL +# uses: github/codeql-action/init@v2 +# with: +# languages: ${{ matrix.language }} +# queries: +security-and-quality +# +# - name: Set up JDK +# uses: actions/setup-java@v4 +# with: +# java-version: ${{env.JAVA_VERSION}} +# distribution: ${{env.JAVA_DISTRIBUTION}} +# +# - name: Autobuild +# uses: github/codeql-action/autobuild@v2 +# +# - name: Perform CodeQL Analysis +# uses: github/codeql-action/analyze@v2 +# with: +# category: "/language:${{ matrix.language }}" \ No newline at end of file diff --git a/.github/workflows/coverity.yml b/.github/workflows/coverity.yml index e9370d1604..42ba3b3ab1 100644 --- a/.github/workflows/coverity.yml +++ b/.github/workflows/coverity.yml @@ -1,74 +1,74 @@ -name: coverity-scan -on: - workflow_dispatch: # because sometimes you just want to force a branch to have tests run - push: - branches: - - main - paths: - - .github/workflows/coverity_scan.yml - pull_request: - branches: - - "**" - paths: - - .github/workflows/coverity_scan.yml - schedule: - - cron: '0 18 * * *' # Daily at 18:00 UTC - -env: - JAVA_VERSION: 17 - JAVA_DISTRIBUTION: 'zulu' - NODE_VERSION: 18 - -defaults: - run: - working-directory: ./backend - -jobs: - scan: - runs-on: ubuntu-latest - if: ${{ github.actor != 'dependabot[bot]' }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4.0.0 - with: - node-version: ${{env.NODE_VERSION}} - - name: Cache npm local files - uses: actions/cache@v3.3.2 - with: - path: | - ./frontend/node_modules - key: npm-${{env.NODE_VERSION}}-${{ hashFiles('frontend/yarn.lock', 'frontend/package.json') }} - - name: Install dependencies - run: yarn install - working-directory: ./frontend - - name: Set up JDK - uses: actions/setup-java@v4 - with: - java-version: ${{env.JAVA_VERSION}} - distribution: ${{env.JAVA_DISTRIBUTION}} - - name: Cache Java Dependencies - uses: actions/cache@v3.3.2 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-build-${{ hashFiles('*.gradle', 'gradle/dependency-locks/*') }} - - name: Download Coverity Build Tool - run: | - wget -q https://scan.coverity.com/download/java/linux64 --post-data "token=${{ secrets.COVERITY_TOKEN }}&project=CDCgov/prime-simplereport" -O cov-analysis-linux64.tar.gz - mkdir cov-analysis-linux64 - tar xzf cov-analysis-linux64.tar.gz --strip 1 -C cov-analysis-linux64 - - name: Build with cov-build - run: | - export PATH=`pwd`/cov-analysis-linux64/bin:$PATH - cov-build --dir cov-int --fs-capture-search ../frontend ./gradlew clean assemble - - name: Submit the result to Coverity Scan - run: | - tar czvf sr.tgz cov-int - curl \ - --form token=${{ secrets.COVERITY_TOKEN }} \ - --form email=nicholas.a.robison@omb.eop.gov \ - --form file=@sr.tgz \ - --form version=${{ env.GITHUB_REF }} \ - --form description="`git rev-parse --short HEAD`" \ - https://scan.coverity.com/builds?project=CDCgov%2Fprime-simplereport +#name: coverity-scan +#on: +# workflow_dispatch: # because sometimes you just want to force a branch to have tests run +# push: +# branches: +# - main +# paths: +# - .github/workflows/coverity_scan.yml +# pull_request: +# branches: +# - "**" +# paths: +# - .github/workflows/coverity_scan.yml +# schedule: +# - cron: '0 18 * * *' # Daily at 18:00 UTC +# +#env: +# JAVA_VERSION: 17 +# JAVA_DISTRIBUTION: 'zulu' +# NODE_VERSION: 18 +# +#defaults: +# run: +# working-directory: ./backend +# +#jobs: +# scan: +# runs-on: ubuntu-latest +# if: ${{ github.actor != 'dependabot[bot]' }} +# steps: +# - uses: actions/checkout@v4 +# - uses: actions/setup-node@v4.0.0 +# with: +# node-version: ${{env.NODE_VERSION}} +# - name: Cache npm local files +# uses: actions/cache@v3.3.2 +# with: +# path: | +# ./frontend/node_modules +# key: npm-${{env.NODE_VERSION}}-${{ hashFiles('frontend/yarn.lock', 'frontend/package.json') }} +# - name: Install dependencies +# run: yarn install +# working-directory: ./frontend +# - name: Set up JDK +# uses: actions/setup-java@v4 +# with: +# java-version: ${{env.JAVA_VERSION}} +# distribution: ${{env.JAVA_DISTRIBUTION}} +# - name: Cache Java Dependencies +# uses: actions/cache@v3.3.2 +# with: +# path: | +# ~/.gradle/caches +# ~/.gradle/wrapper +# key: gradle-build-${{ hashFiles('*.gradle', 'gradle/dependency-locks/*') }} +# - name: Download Coverity Build Tool +# run: | +# wget -q https://scan.coverity.com/download/java/linux64 --post-data "token=${{ secrets.COVERITY_TOKEN }}&project=CDCgov/prime-simplereport" -O cov-analysis-linux64.tar.gz +# mkdir cov-analysis-linux64 +# tar xzf cov-analysis-linux64.tar.gz --strip 1 -C cov-analysis-linux64 +# - name: Build with cov-build +# run: | +# export PATH=`pwd`/cov-analysis-linux64/bin:$PATH +# cov-build --dir cov-int --fs-capture-search ../frontend ./gradlew clean assemble +# - name: Submit the result to Coverity Scan +# run: | +# tar czvf sr.tgz cov-int +# curl \ +# --form token=${{ secrets.COVERITY_TOKEN }} \ +# --form email=nicholas.a.robison@omb.eop.gov \ +# --form file=@sr.tgz \ +# --form version=${{ env.GITHUB_REF }} \ +# --form description="`git rev-parse --short HEAD`" \ +# https://scan.coverity.com/builds?project=CDCgov%2Fprime-simplereport diff --git a/.github/workflows/e2eLocal.yml b/.github/workflows/e2eLocal.yml index a3dbf65bff..8acee0cc6d 100644 --- a/.github/workflows/e2eLocal.yml +++ b/.github/workflows/e2eLocal.yml @@ -1,148 +1,148 @@ -name: Run end-to-end tests - -on: - workflow_call: - secrets: - OKTA_API_KEY: - required: true - SMARTY_AUTH_ID: - required: true - SMARTY_AUTH_TOKEN: - required: true - CYPRESS_OKTA_USERNAME: - required: true - CYPRESS_OKTA_PASSWORD: - required: true - CYPRESS_OKTA_SECRET: - required: true - inputs: - DOCKER_BACKEND_IMAGE_VERSION: - required: false - type: string - DOCKER_CYPRESS_IMAGE_VERSION: - required: false - type: string - DOCKER_DATABASE_IMAGE_VERSION: - required: false - type: string - DOCKER_FRONTEND_IMAGE_VERSION: - required: false - type: string - DOCKER_NGINX_IMAGE_VERSION: - required: false - type: string - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-e2e-local - cancel-in-progress: true - -jobs: - - cypress-local-env: - runs-on: ubuntu-latest - steps: - - - name: Checkout - uses: actions/checkout@v4 - - - name: Set Swap Space - uses: ./.github/actions/set-swap-space - with: - swap-size-gb: 10 - - - name: Update files permissions - # Even though we don't use it, we need the .env file created here due to an issue similar to this one: https://github.com/mutagen-io/mutagen/issues/265 - run: | - echo "::group::Update permissions" - echo FAKE_ENV="true" >> .env - sudo chmod -R 777 backend - sudo chmod -R 777 frontend - echo "::endgroup::" - - - name: Log in to the Container registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Run Cypress - timeout-minutes: 30 - env: - # CI settings - CI: 1 - # docker settings - DOCKER_CLIENT_TIMEOUT: 180 - DOCKER_BACKEND_IMAGE_VERSION: ${{ inputs.DOCKER_BACKEND_IMAGE_VERSION }} - DOCKER_CYPRESS_IMAGE_VERSION: ${{ inputs.DOCKER_CYPRESS_IMAGE_VERSION }} - DOCKER_DATABASE_IMAGE_VERSION: ${{ inputs.DOCKER_DATABASE_IMAGE_VERSION }} - DOCKER_FRONTEND_IMAGE_VERSION: ${{ inputs.DOCKER_FRONTEND_IMAGE_VERSION }} - DOCKER_NGINX_IMAGE_VERSION: ${{ inputs.DOCKER_NGINX_IMAGE_VERSION }} - COMPOSE_HTTP_TIMEOUT: 180 - # backend settings - SPRING_PROFILES_ACTIVE: e2e,db-dockerized - OKTA_TESTING_DISABLEHTTPSCHECK: "true" - OKTA_API_KEY: ${{ secrets.OKTA_API_KEY }} - OKTA_OAUTH2_CLIENT_ID: 0oa1k0163nAwfVxNW1d7 - SMARTY_AUTH_ID: ${{ secrets.SMARTY_AUTH_ID }} - SMARTY_AUTH_TOKEN: ${{ secrets.SMARTY_AUTH_TOKEN }} - SPRING_LIQUIBASE_ENABLED: "true" - GIT_DISCOVERY_ACROSS_FILESYSTEM: 1 - WIREMOCK_URL: "http://wiremock:8088" - # cypress settings - CYPRESS_OKTA_REDIRECT_URI: "https%3A%2F%2Flocalhost.simplereport.gov%2Fapp" - CYPRESS_OKTA_USERNAME: ${{ secrets.CYPRESS_OKTA_USERNAME }} - CYPRESS_OKTA_PASSWORD: ${{ secrets.CYPRESS_OKTA_PASSWORD }} - CYPRESS_OKTA_SECRET: ${{ secrets.CYPRESS_OKTA_SECRET }} - CYPRESS_BACKEND_URL: "https://localhost.simplereport.gov/api" - SPEC_PATH: "cypress/e2e/**" - TEST_ENV: "https://localhost.simplereport.gov" - # frontend settings - REACT_APP_OKTA_URL: "http://wiremock:8088" - REACT_APP_OKTA_CLIENT_ID: 0oa1k0163nAwfVxNW1d7 - REACT_APP_BASE_URL: https://localhost.simplereport.gov - REACT_APP_BACKEND_URL: https://localhost.simplereport.gov/api - PUBLIC_URL: /app/ - REACT_APP_OKTA_ENABLED: "true" - REACT_APP_DISABLE_MAINTENANCE_BANNER: "true" - shell: bash - run: | - echo "::group::Run Cypress locally" - echo "Backend branch tag (or latest): ${{ inputs.DOCKER_BACKEND_IMAGE_VERSION }}" - echo "Cypress branch tag (or latest): ${{ inputs.DOCKER_CYPRESS_IMAGE_VERSION }}" - echo "Database branch tag (or latest): ${{ inputs.DOCKER_DATABASE_IMAGE_VERSION }}" - echo "Frontend branch tag (or latest): ${{ inputs.DOCKER_FRONTEND_IMAGE_VERSION }}" - echo "Nginx branch tag (or latest): ${{ inputs.DOCKER_NGINX_IMAGE_VERSION }}" - docker compose -f docker-compose.yml -f docker-compose.cypress.yml up --abort-on-container-exit --attach cypress --exit-code-from cypress --quiet-pull - echo "::endgroup::" - - - name: Get docker logs - if: always() - shell: bash - run: | - echo "Saving $container logs" - docker compose -f docker-compose.yml -f docker-compose.cypress.yml logs --timestamps >& cypress-run.log - - - name: Stop containers - if: always() - shell: bash - run: | - echo "::group::Stop Docker containers" - docker compose -f docker-compose.yml -f docker-compose.cypress.yml down - echo "::endgroup::" - - - name: Archive cypress failures - if: always() - uses: actions/upload-artifact@v3 - with: - name: cypress-results - path: | - cypress/videos/* - cypress/screenshots/* - - - name: Archive docker logs - if: always() - uses: actions/upload-artifact@v3 - with: - name: logs - path: cypress-run.log +#name: Run end-to-end tests +# +#on: +# workflow_call: +# secrets: +# OKTA_API_KEY: +# required: true +# SMARTY_AUTH_ID: +# required: true +# SMARTY_AUTH_TOKEN: +# required: true +# CYPRESS_OKTA_USERNAME: +# required: true +# CYPRESS_OKTA_PASSWORD: +# required: true +# CYPRESS_OKTA_SECRET: +# required: true +# inputs: +# DOCKER_BACKEND_IMAGE_VERSION: +# required: false +# type: string +# DOCKER_CYPRESS_IMAGE_VERSION: +# required: false +# type: string +# DOCKER_DATABASE_IMAGE_VERSION: +# required: false +# type: string +# DOCKER_FRONTEND_IMAGE_VERSION: +# required: false +# type: string +# DOCKER_NGINX_IMAGE_VERSION: +# required: false +# type: string +# +#concurrency: +# group: ${{ github.workflow }}-${{ github.ref }}-e2e-local +# cancel-in-progress: true +# +#jobs: +# +# cypress-local-env: +# runs-on: ubuntu-latest +# steps: +# +# - name: Checkout +# uses: actions/checkout@v4 +# +# - name: Set Swap Space +# uses: ./.github/actions/set-swap-space +# with: +# swap-size-gb: 10 +# +# - name: Update files permissions +# # Even though we don't use it, we need the .env file created here due to an issue similar to this one: https://github.com/mutagen-io/mutagen/issues/265 +# run: | +# echo "::group::Update permissions" +# echo FAKE_ENV="true" >> .env +# sudo chmod -R 777 backend +# sudo chmod -R 777 frontend +# echo "::endgroup::" +# +# - name: Log in to the Container registry +# uses: docker/login-action@v3 +# with: +# registry: ghcr.io +# username: ${{ github.actor }} +# password: ${{ secrets.GITHUB_TOKEN }} +# +# - name: Run Cypress +# timeout-minutes: 30 +# env: +# # CI settings +# CI: 1 +# # docker settings +# DOCKER_CLIENT_TIMEOUT: 180 +# DOCKER_BACKEND_IMAGE_VERSION: ${{ inputs.DOCKER_BACKEND_IMAGE_VERSION }} +# DOCKER_CYPRESS_IMAGE_VERSION: ${{ inputs.DOCKER_CYPRESS_IMAGE_VERSION }} +# DOCKER_DATABASE_IMAGE_VERSION: ${{ inputs.DOCKER_DATABASE_IMAGE_VERSION }} +# DOCKER_FRONTEND_IMAGE_VERSION: ${{ inputs.DOCKER_FRONTEND_IMAGE_VERSION }} +# DOCKER_NGINX_IMAGE_VERSION: ${{ inputs.DOCKER_NGINX_IMAGE_VERSION }} +# COMPOSE_HTTP_TIMEOUT: 180 +# # backend settings +# SPRING_PROFILES_ACTIVE: e2e,db-dockerized +# OKTA_TESTING_DISABLEHTTPSCHECK: "true" +# OKTA_API_KEY: ${{ secrets.OKTA_API_KEY }} +# OKTA_OAUTH2_CLIENT_ID: 0oa1k0163nAwfVxNW1d7 +# SMARTY_AUTH_ID: ${{ secrets.SMARTY_AUTH_ID }} +# SMARTY_AUTH_TOKEN: ${{ secrets.SMARTY_AUTH_TOKEN }} +# SPRING_LIQUIBASE_ENABLED: "true" +# GIT_DISCOVERY_ACROSS_FILESYSTEM: 1 +# WIREMOCK_URL: "http://wiremock:8088" +# # cypress settings +# CYPRESS_OKTA_REDIRECT_URI: "https%3A%2F%2Flocalhost.simplereport.gov%2Fapp" +# CYPRESS_OKTA_USERNAME: ${{ secrets.CYPRESS_OKTA_USERNAME }} +# CYPRESS_OKTA_PASSWORD: ${{ secrets.CYPRESS_OKTA_PASSWORD }} +# CYPRESS_OKTA_SECRET: ${{ secrets.CYPRESS_OKTA_SECRET }} +# CYPRESS_BACKEND_URL: "https://localhost.simplereport.gov/api" +# SPEC_PATH: "cypress/e2e/**" +# TEST_ENV: "https://localhost.simplereport.gov" +# # frontend settings +# REACT_APP_OKTA_URL: "http://wiremock:8088" +# REACT_APP_OKTA_CLIENT_ID: 0oa1k0163nAwfVxNW1d7 +# REACT_APP_BASE_URL: https://localhost.simplereport.gov +# REACT_APP_BACKEND_URL: https://localhost.simplereport.gov/api +# PUBLIC_URL: /app/ +# REACT_APP_OKTA_ENABLED: "true" +# REACT_APP_DISABLE_MAINTENANCE_BANNER: "true" +# shell: bash +# run: | +# echo "::group::Run Cypress locally" +# echo "Backend branch tag (or latest): ${{ inputs.DOCKER_BACKEND_IMAGE_VERSION }}" +# echo "Cypress branch tag (or latest): ${{ inputs.DOCKER_CYPRESS_IMAGE_VERSION }}" +# echo "Database branch tag (or latest): ${{ inputs.DOCKER_DATABASE_IMAGE_VERSION }}" +# echo "Frontend branch tag (or latest): ${{ inputs.DOCKER_FRONTEND_IMAGE_VERSION }}" +# echo "Nginx branch tag (or latest): ${{ inputs.DOCKER_NGINX_IMAGE_VERSION }}" +# docker compose -f docker-compose.yml -f docker-compose.cypress.yml up --abort-on-container-exit --attach cypress --exit-code-from cypress --quiet-pull +# echo "::endgroup::" +# +# - name: Get docker logs +# if: always() +# shell: bash +# run: | +# echo "Saving $container logs" +# docker compose -f docker-compose.yml -f docker-compose.cypress.yml logs --timestamps >& cypress-run.log +# +# - name: Stop containers +# if: always() +# shell: bash +# run: | +# echo "::group::Stop Docker containers" +# docker compose -f docker-compose.yml -f docker-compose.cypress.yml down +# echo "::endgroup::" +# +# - name: Archive cypress failures +# if: always() +# uses: actions/upload-artifact@v3 +# with: +# name: cypress-results +# path: | +# cypress/videos/* +# cypress/screenshots/* +# +# - name: Archive docker logs +# if: always() +# uses: actions/upload-artifact@v3 +# with: +# name: logs +# path: cypress-run.log diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 35c4eb5547..0b7116ad43 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -1,85 +1,85 @@ -name: Run Lighthouse Audit -on: - workflow_call: - inputs: - DOCKER_BACKEND_IMAGE_VERSION: - required: false - type: string - DOCKER_DATABASE_IMAGE_VERSION: - required: false - type: string - DOCKER_NGINX_IMAGE_VERSION: - required: false - type: string - DOCKER_FRONTEND_LIGHTHOUSE_IMAGE_VERSION: - required: false - type: string - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - lhci: - name: Lighthouse - runs-on: ubuntu-latest - steps: - - - name: Checkout - uses: actions/checkout@v4 - - - name: Set Swap Space - uses: ./.github/actions/set-swap-space - with: - swap-size-gb: 10 - - - name: Add hosts to /etc/hosts - run: | - sudo echo "127.0.0.1 localhost.simplereport.gov" | sudo tee -a /etc/hosts - - - name: Update files permissions - # Even though we don't use it, we need the .env file created here due to an issue similar to this one: https://github.com/mutagen-io/mutagen/issues/265 - run: | - echo "::group::Update permissions" - echo FAKE_ENV="true" >> .env - sudo chmod -R 777 backend - sudo chmod -R 777 frontend - echo "::endgroup::" - - - name: Log in to the Container registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Setup and Run Lighthouse - env: - # CI settings - CI: 1 - # docker settings - DOCKER_CLIENT_TIMEOUT: 180 - COMPOSE_HTTP_TIMEOUT: 180 - DOCKER_BACKEND_IMAGE_VERSION: ${{ inputs.DOCKER_BACKEND_IMAGE_VERSION }} - DOCKER_DATABASE_IMAGE_VERSION: ${{ inputs.DOCKER_DATABASE_IMAGE_VERSION }} - DOCKER_NGINX_IMAGE_VERSION: ${{ inputs.DOCKER_NGINX_IMAGE_VERSION }} - DOCKER_FRONTEND_LIGHTHOUSE_IMAGE_VERSION: ${{ inputs.DOCKER_FRONTEND_LIGHTHOUSE_IMAGE_VERSION }} - shell: bash - run: | - echo "::group::Running containers" - echo "Backend branch tag (or latest): ${{ inputs.DOCKER_BACKEND_IMAGE_VERSION }}" - echo "Database branch tag (or latest): ${{ inputs.DOCKER_DATABASE_IMAGE_VERSION }}" - echo "Nginx branch tag (or latest): ${{ inputs.DOCKER_NGINX_IMAGE_VERSION }}" - echo "Frontend branch tag (or latest): ${{ inputs.DOCKER_FRONTEND_LIGHTHOUSE_IMAGE_VERSION }}" - echo "::endgroup::" - - echo "::group::Run Lighthouse locally" - bash lighthouse.sh - echo "::endgroup::" - - - name: Archive Lighthouse results - uses: actions/upload-artifact@v3 - if: always() - with: - name: lighthouse-results - path: lighthouse/* \ No newline at end of file +#name: Run Lighthouse Audit +#on: +# workflow_call: +# inputs: +# DOCKER_BACKEND_IMAGE_VERSION: +# required: false +# type: string +# DOCKER_DATABASE_IMAGE_VERSION: +# required: false +# type: string +# DOCKER_NGINX_IMAGE_VERSION: +# required: false +# type: string +# DOCKER_FRONTEND_LIGHTHOUSE_IMAGE_VERSION: +# required: false +# type: string +# +#concurrency: +# group: ${{ github.workflow }}-${{ github.ref }} +# cancel-in-progress: true +# +#jobs: +# lhci: +# name: Lighthouse +# runs-on: ubuntu-latest +# steps: +# +# - name: Checkout +# uses: actions/checkout@v4 +# +# - name: Set Swap Space +# uses: ./.github/actions/set-swap-space +# with: +# swap-size-gb: 10 +# +# - name: Add hosts to /etc/hosts +# run: | +# sudo echo "127.0.0.1 localhost.simplereport.gov" | sudo tee -a /etc/hosts +# +# - name: Update files permissions +# # Even though we don't use it, we need the .env file created here due to an issue similar to this one: https://github.com/mutagen-io/mutagen/issues/265 +# run: | +# echo "::group::Update permissions" +# echo FAKE_ENV="true" >> .env +# sudo chmod -R 777 backend +# sudo chmod -R 777 frontend +# echo "::endgroup::" +# +# - name: Log in to the Container registry +# uses: docker/login-action@v3 +# with: +# registry: ghcr.io +# username: ${{ github.actor }} +# password: ${{ secrets.GITHUB_TOKEN }} +# +# - name: Setup and Run Lighthouse +# env: +# # CI settings +# CI: 1 +# # docker settings +# DOCKER_CLIENT_TIMEOUT: 180 +# COMPOSE_HTTP_TIMEOUT: 180 +# DOCKER_BACKEND_IMAGE_VERSION: ${{ inputs.DOCKER_BACKEND_IMAGE_VERSION }} +# DOCKER_DATABASE_IMAGE_VERSION: ${{ inputs.DOCKER_DATABASE_IMAGE_VERSION }} +# DOCKER_NGINX_IMAGE_VERSION: ${{ inputs.DOCKER_NGINX_IMAGE_VERSION }} +# DOCKER_FRONTEND_LIGHTHOUSE_IMAGE_VERSION: ${{ inputs.DOCKER_FRONTEND_LIGHTHOUSE_IMAGE_VERSION }} +# shell: bash +# run: | +# echo "::group::Running containers" +# echo "Backend branch tag (or latest): ${{ inputs.DOCKER_BACKEND_IMAGE_VERSION }}" +# echo "Database branch tag (or latest): ${{ inputs.DOCKER_DATABASE_IMAGE_VERSION }}" +# echo "Nginx branch tag (or latest): ${{ inputs.DOCKER_NGINX_IMAGE_VERSION }}" +# echo "Frontend branch tag (or latest): ${{ inputs.DOCKER_FRONTEND_LIGHTHOUSE_IMAGE_VERSION }}" +# echo "::endgroup::" +# +# echo "::group::Run Lighthouse locally" +# bash lighthouse.sh +# echo "::endgroup::" +# +# - name: Archive Lighthouse results +# uses: actions/upload-artifact@v3 +# if: always() +# with: +# name: lighthouse-results +# path: lighthouse/* \ No newline at end of file diff --git a/.github/workflows/liquibaseRollback.yml b/.github/workflows/liquibaseRollback.yml index a4ca30f422..97a975e6e7 100644 --- a/.github/workflows/liquibaseRollback.yml +++ b/.github/workflows/liquibaseRollback.yml @@ -1,47 +1,47 @@ -name: Liquibase Rollback - -concurrency: - group: db-actions - cancel-in-progress: false - -on: - workflow_dispatch: - inputs: - deploy_env: - description: The environment of the database. - required: true - type: choice - options: [demo, dev, dev2, dev3, dev4, dev5, dev6, dev7, pentest, test, training, stg, prod] - default: dev - action: - description: The liquibase action to run. - required: true - type: choice - options: [rollback] - default: rollback - liquibase_rollback_tag: - description: The Liquibase tag to roll back to - required: true - type: string - default: "" - -jobs: - - db_rollback_action: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: DB Liquibase Rollback - uses: ./.github/actions/db-actions - with: - deploy_env: ${{ github.event.inputs.deploy_env }} - action: ${{ github.event.inputs.action }} - liquibase_rollback_tag: ${{ github.event.inputs.liquibase_rollback_tag }} - azure_credentials: ${{ secrets.AZURE_CREDENTIALS }} - acr_repo_url: ${{ secrets.ACR_REPO_URL }} - acr_admin_username: ${{ secrets.ACR_ADMIN_USERNAME }} - acr_admin_pasword: ${{ secrets.ACR_ADMIN_PASWORD }} - terraform_arm_client_id: ${{ secrets.TERRAFORM_ARM_CLIENT_ID }} - terraform_arm_client_secret: ${{ secrets.TERRAFORM_ARM_CLIENT_SECRET }} - terraform_arm_subscription_id: ${{ secrets.TERRAFORM_ARM_SUBSCRIPTION_ID }} - terraform_arm_tenant_id: ${{ secrets.TERRAFORM_ARM_TENANT_ID }} +#name: Liquibase Rollback +# +#concurrency: +# group: db-actions +# cancel-in-progress: false +# +#on: +# workflow_dispatch: +# inputs: +# deploy_env: +# description: The environment of the database. +# required: true +# type: choice +# options: [demo, dev, dev2, dev3, dev4, dev5, dev6, dev7, pentest, test, training, stg, prod] +# default: dev +# action: +# description: The liquibase action to run. +# required: true +# type: choice +# options: [rollback] +# default: rollback +# liquibase_rollback_tag: +# description: The Liquibase tag to roll back to +# required: true +# type: string +# default: "" +# +#jobs: +# +# db_rollback_action: +# runs-on: ubuntu-latest +# steps: +# - uses: actions/checkout@v4 +# - name: DB Liquibase Rollback +# uses: ./.github/actions/db-actions +# with: +# deploy_env: ${{ github.event.inputs.deploy_env }} +# action: ${{ github.event.inputs.action }} +# liquibase_rollback_tag: ${{ github.event.inputs.liquibase_rollback_tag }} +# azure_credentials: ${{ secrets.AZURE_CREDENTIALS }} +# acr_repo_url: ${{ secrets.ACR_REPO_URL }} +# acr_admin_username: ${{ secrets.ACR_ADMIN_USERNAME }} +# acr_admin_pasword: ${{ secrets.ACR_ADMIN_PASWORD }} +# terraform_arm_client_id: ${{ secrets.TERRAFORM_ARM_CLIENT_ID }} +# terraform_arm_client_secret: ${{ secrets.TERRAFORM_ARM_CLIENT_SECRET }} +# terraform_arm_subscription_id: ${{ secrets.TERRAFORM_ARM_SUBSCRIPTION_ID }} +# terraform_arm_tenant_id: ${{ secrets.TERRAFORM_ARM_TENANT_ID }} diff --git a/.github/workflows/liquibaseValidate.yml b/.github/workflows/liquibaseValidate.yml index 11ff68d8b0..7ee285bf87 100644 --- a/.github/workflows/liquibaseValidate.yml +++ b/.github/workflows/liquibaseValidate.yml @@ -1,41 +1,41 @@ -name: Liquibase Validate - -concurrency: - group: db-actions - cancel-in-progress: false - -on: - workflow_dispatch: - inputs: - deploy_env: - description: The environment of the database. - required: true - type: choice - options: [demo, dev, dev2, dev3, dev4, dev5, dev6, dev7, pentest, test, training, stg, prod] - default: dev - action: - description: The liquibase action to run. - required: true - type: choice - options: [validate] - default: validate - -jobs: - - db_validate_action: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: DB Liquibase Rollback - uses: ./.github/actions/db-actions - with: - deploy_env: ${{ github.event.inputs.deploy_env }} - action: ${{ github.event.inputs.action }} - azure_credentials: ${{ secrets.AZURE_CREDENTIALS }} - acr_repo_url: ${{ secrets.ACR_REPO_URL }} - acr_admin_username: ${{ secrets.ACR_ADMIN_USERNAME }} - acr_admin_pasword: ${{ secrets.ACR_ADMIN_PASWORD }} - terraform_arm_client_id: ${{ secrets.TERRAFORM_ARM_CLIENT_ID }} - terraform_arm_client_secret: ${{ secrets.TERRAFORM_ARM_CLIENT_SECRET }} - terraform_arm_subscription_id: ${{ secrets.TERRAFORM_ARM_SUBSCRIPTION_ID }} - terraform_arm_tenant_id: ${{ secrets.TERRAFORM_ARM_TENANT_ID }} +#name: Liquibase Validate +# +#concurrency: +# group: db-actions +# cancel-in-progress: false +# +#on: +# workflow_dispatch: +# inputs: +# deploy_env: +# description: The environment of the database. +# required: true +# type: choice +# options: [demo, dev, dev2, dev3, dev4, dev5, dev6, dev7, pentest, test, training, stg, prod] +# default: dev +# action: +# description: The liquibase action to run. +# required: true +# type: choice +# options: [validate] +# default: validate +# +#jobs: +# +# db_validate_action: +# runs-on: ubuntu-latest +# steps: +# - uses: actions/checkout@v4 +# - name: DB Liquibase Rollback +# uses: ./.github/actions/db-actions +# with: +# deploy_env: ${{ github.event.inputs.deploy_env }} +# action: ${{ github.event.inputs.action }} +# azure_credentials: ${{ secrets.AZURE_CREDENTIALS }} +# acr_repo_url: ${{ secrets.ACR_REPO_URL }} +# acr_admin_username: ${{ secrets.ACR_ADMIN_USERNAME }} +# acr_admin_pasword: ${{ secrets.ACR_ADMIN_PASWORD }} +# terraform_arm_client_id: ${{ secrets.TERRAFORM_ARM_CLIENT_ID }} +# terraform_arm_client_secret: ${{ secrets.TERRAFORM_ARM_CLIENT_SECRET }} +# terraform_arm_subscription_id: ${{ secrets.TERRAFORM_ARM_SUBSCRIPTION_ID }} +# terraform_arm_tenant_id: ${{ secrets.TERRAFORM_ARM_TENANT_ID }} diff --git a/.github/workflows/smokeTestDeployManual.yml b/.github/workflows/smokeTestDeployManual.yml index a803c0a0f8..4c6d64e72d 100644 --- a/.github/workflows/smokeTestDeployManual.yml +++ b/.github/workflows/smokeTestDeployManual.yml @@ -2,22 +2,25 @@ name: Smoke test deploy run-name: Smoke test the deploy for a dev env by @${{ github.actor }} on: - workflow_dispatch: - inputs: - deploy_env: - description: 'The environment to smoke test' - required: true - type: choice - options: - - "" - - dev - - dev2 - - dev3 - - dev4 - - dev5 - - dev6 - - dev7 - - pentest + # DELETE ME WHEN MERGING + push: + # UNCOMMENT ME WHEN MERGING +# workflow_dispatch: +# inputs: +# deploy_env: +# description: 'The environment to smoke test' +# required: true +# type: choice +# options: +# - "" +# - dev +# - dev2 +# - dev3 +# - dev4 +# - dev5 +# - dev6 +# - dev7 +# - pentest env: NODE_VERSION: 18 @@ -42,6 +45,6 @@ jobs: - name: Smoke test the env uses: ./.github/actions/post-deploy-smoke-test with: - deploy-env: ${{inputs.deploy_env}} - + # REPLACE ME WITH deploy-env: ${{inputs.deploy_env}} + deploy-env: dev7 diff --git a/.github/workflows/smokeTestDeployProd.yml b/.github/workflows/smokeTestDeployProd.yml index da442331a3..60516bc31e 100644 --- a/.github/workflows/smokeTestDeployProd.yml +++ b/.github/workflows/smokeTestDeployProd.yml @@ -3,7 +3,7 @@ run-name: Smoke test the deploy for prod by @${{ github.actor }} on: workflow_run: - workflows: [ " Deploy Prod" ] + workflows: [ "Deploy Prod" ] types: - completed diff --git a/.github/workflows/terraformChecks.yml b/.github/workflows/terraformChecks.yml index 09d341fbe4..87f54826b7 100644 --- a/.github/workflows/terraformChecks.yml +++ b/.github/workflows/terraformChecks.yml @@ -1,101 +1,101 @@ -name: Terraform Checks - -on: - workflow_dispatch: # because sometimes you just want to force a branch to have tests run - pull_request: - branches: - - "**" - merge_group: - types: - - checks_requested - -defaults: - run: - working-directory: ./ops - -jobs: - check-terraform-formatting: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: hashicorp/setup-terraform@v3.0.0 - with: - terraform_version: 1.3.3 - - name: Terraform fmt - run: terraform fmt -check -recursive - - check-terraform-validity: - runs-on: ubuntu-latest - env: - TERRAFORM_DIRS: | - dev dev/persistent dev2 dev2/persistent dev3 dev3/persistent dev4 dev4/persistent - dev5 dev5/persistent dev6 dev6/persistent dev7 dev7/persistent - test test/persistent demo demo/persistent training training/persistent - stg stg/persistent pentest pentest/persistent prod prod/persistent - global - steps: - - uses: actions/checkout@v4 - - uses: hashicorp/setup-terraform@v3.0.0 - with: - terraform_version: 1.3.3 - - name: Terraform Init - run: | - for d in $TERRAFORM_DIRS - do - echo "Initializing $d"; - (cd $d && terraform init -backend=false) - done - - name: Terraform Validate - run: | - for d in $TERRAFORM_DIRS - do - echo "Validating $d"; - (cd $d && terraform validate) - done - - terraform-plan: - runs-on: ubuntu-latest - needs: [check-terraform-validity] - env: # all Azure interaction is through terraform - ARM_CLIENT_ID: ${{ secrets.TERRAFORM_ARM_CLIENT_ID }} - ARM_CLIENT_SECRET: ${{ secrets.TERRAFORM_ARM_CLIENT_SECRET }} - ARM_SUBSCRIPTION_ID: ${{ secrets.TERRAFORM_ARM_SUBSCRIPTION_ID }} - ARM_TENANT_ID: ${{ secrets.TERRAFORM_ARM_TENANT_ID }} - OKTA_API_TOKEN: ${{ secrets.OKTA_API_TOKEN }} - steps: - - uses: actions/checkout@v4 - - name: Dependabot bypass - if: ${{ github.actor == 'dependabot[bot]' }} - run: | - true - - uses: azure/login@v1 - if: ${{ github.actor != 'dependabot[bot]' }} - with: - creds: ${{ secrets.AZURE_CREDENTIALS }} - - uses: hashicorp/setup-terraform@v3.0.0 - if: ${{ github.actor != 'dependabot[bot]' }} - with: - terraform_version: 1.3.3 - - name: Terraform Init Prod - if: ${{ github.actor != 'dependabot[bot]' }} - run: make init-prod - - name: Build ReportStream function app Prod - if: ${{ github.actor != 'dependabot[bot]' }} - uses: ./.github/actions/build-reportstream-functions - with: - deploy-env: ${{env.DEPLOY_ENV}} - - name: Terraform Plan Prod - if: ${{ github.actor != 'dependabot[bot]' }} - run: make plan-prod - - - name: Terraform Init Stg - if: ${{ github.actor != 'dependabot[bot]' }} - run: make init-stg - - name: Build ReportStream function app Stg - if: ${{ github.actor != 'dependabot[bot]' }} - uses: ./.github/actions/build-reportstream-functions - with: - deploy-env: stg - - name: Terraform plan Stg - if: ${{ github.actor != 'dependabot[bot]' }} - run: make plan-stg +#name: Terraform Checks +# +#on: +# workflow_dispatch: # because sometimes you just want to force a branch to have tests run +# pull_request: +# branches: +# - "**" +# merge_group: +# types: +# - checks_requested +# +#defaults: +# run: +# working-directory: ./ops +# +#jobs: +# check-terraform-formatting: +# runs-on: ubuntu-latest +# steps: +# - uses: actions/checkout@v4 +# - uses: hashicorp/setup-terraform@v3.0.0 +# with: +# terraform_version: 1.3.3 +# - name: Terraform fmt +# run: terraform fmt -check -recursive +# +# check-terraform-validity: +# runs-on: ubuntu-latest +# env: +# TERRAFORM_DIRS: | +# dev dev/persistent dev2 dev2/persistent dev3 dev3/persistent dev4 dev4/persistent +# dev5 dev5/persistent dev6 dev6/persistent dev7 dev7/persistent +# test test/persistent demo demo/persistent training training/persistent +# stg stg/persistent pentest pentest/persistent prod prod/persistent +# global +# steps: +# - uses: actions/checkout@v4 +# - uses: hashicorp/setup-terraform@v3.0.0 +# with: +# terraform_version: 1.3.3 +# - name: Terraform Init +# run: | +# for d in $TERRAFORM_DIRS +# do +# echo "Initializing $d"; +# (cd $d && terraform init -backend=false) +# done +# - name: Terraform Validate +# run: | +# for d in $TERRAFORM_DIRS +# do +# echo "Validating $d"; +# (cd $d && terraform validate) +# done +# +# terraform-plan: +# runs-on: ubuntu-latest +# needs: [check-terraform-validity] +# env: # all Azure interaction is through terraform +# ARM_CLIENT_ID: ${{ secrets.TERRAFORM_ARM_CLIENT_ID }} +# ARM_CLIENT_SECRET: ${{ secrets.TERRAFORM_ARM_CLIENT_SECRET }} +# ARM_SUBSCRIPTION_ID: ${{ secrets.TERRAFORM_ARM_SUBSCRIPTION_ID }} +# ARM_TENANT_ID: ${{ secrets.TERRAFORM_ARM_TENANT_ID }} +# OKTA_API_TOKEN: ${{ secrets.OKTA_API_TOKEN }} +# steps: +# - uses: actions/checkout@v4 +# - name: Dependabot bypass +# if: ${{ github.actor == 'dependabot[bot]' }} +# run: | +# true +# - uses: azure/login@v1 +# if: ${{ github.actor != 'dependabot[bot]' }} +# with: +# creds: ${{ secrets.AZURE_CREDENTIALS }} +# - uses: hashicorp/setup-terraform@v3.0.0 +# if: ${{ github.actor != 'dependabot[bot]' }} +# with: +# terraform_version: 1.3.3 +# - name: Terraform Init Prod +# if: ${{ github.actor != 'dependabot[bot]' }} +# run: make init-prod +# - name: Build ReportStream function app Prod +# if: ${{ github.actor != 'dependabot[bot]' }} +# uses: ./.github/actions/build-reportstream-functions +# with: +# deploy-env: ${{env.DEPLOY_ENV}} +# - name: Terraform Plan Prod +# if: ${{ github.actor != 'dependabot[bot]' }} +# run: make plan-prod +# +# - name: Terraform Init Stg +# if: ${{ github.actor != 'dependabot[bot]' }} +# run: make init-stg +# - name: Build ReportStream function app Stg +# if: ${{ github.actor != 'dependabot[bot]' }} +# uses: ./.github/actions/build-reportstream-functions +# with: +# deploy-env: stg +# - name: Terraform plan Stg +# if: ${{ github.actor != 'dependabot[bot]' }} +# run: make plan-stg diff --git a/.github/workflows/testingWorkflow.yml b/.github/workflows/testingWorkflow.yml index 4ac88d0558..ad0bc136cb 100644 --- a/.github/workflows/testingWorkflow.yml +++ b/.github/workflows/testingWorkflow.yml @@ -1,284 +1,284 @@ -name: Testing Workflow - -on: - workflow_dispatch: - inputs: - platforms: - description: "Build additional architectures (linux/arm64). linux/amd64 is built by default." - required: false - force_build: - description: "Force build the docker images" - required: true - default: true - pull_request: - branches: - - "**" - merge_group: - types: - - checks_requested - push: - branches: - - main - -permissions: - contents: read - packages: write - -jobs: -# Check for changes in the backend, cypress, database, frontend, and nginx directories - workflow_changes: - with: - what_to_check: ./.github - uses: ./.github/workflows/checkForChanges.yml - - backend_changes: - with: - what_to_check: ./backend - uses: ./.github/workflows/checkForChanges.yml - - cypress_changes: - with: - what_to_check: ./cypress - uses: ./.github/workflows/checkForChanges.yml - - database_changes: - with: - what_to_check: ./backend/db-setup - uses: ./.github/workflows/checkForChanges.yml - - frontend_changes: - with: - what_to_check: ./frontend - uses: ./.github/workflows/checkForChanges.yml - - nginx_changes: - with: - what_to_check: ./nginx - uses: ./.github/workflows/checkForChanges.yml - -# Build Docker Images for the backend, cypress, database, frontend, and nginx - build_backend_image: - if: needs.workflow_changes.outputs.has_changes == 'true' || needs.backend_changes.outputs.has_changes == 'true' || inputs.force_build == 'true' || github.ref == 'refs/heads/main' - needs: - - backend_changes - - workflow_changes - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - platform: ${{ inputs.platform }} - outputs: - version: ${{ steps.set_backend_version.outputs.version }} - steps: - - uses: actions/checkout@v4 - - name: Build Backend Image - id: set_backend_version - uses: ./.github/actions/docker-buildx - with: - file: ./backend/Dockerfile - gh_username: ${{ github.actor }} - gh_token: ${{ secrets.GITHUB_TOKEN }} - image_name: backend - platform: ${{ matrix.platform }} - - build_cypress_image: - if: needs.workflow_changes.outputs.has_changes == 'true' || needs.cypress_changes.outputs.has_changes == 'true' || inputs.force_build == 'true' || github.ref == 'refs/heads/main' - needs: - - cypress_changes - - workflow_changes - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - platform: ${{ inputs.platform }} - outputs: - version: ${{ steps.set_cypress_version.outputs.version }} - steps: - - uses: actions/checkout@v4 - - name: Build Cypress Image - id: set_cypress_version - uses: ./.github/actions/docker-buildx - with: - file: ./cypress/Dockerfile - gh_username: ${{ github.actor }} - gh_token: ${{ secrets.GITHUB_TOKEN }} - image_name: cypress - platform: ${{ matrix.platform }} - - build_database_image: - if: needs.workflow_changes.outputs.has_changes == 'true' || needs.database_changes.outputs.has_changes == 'true' || inputs.force_build == 'true' || github.ref == 'refs/heads/main' - needs: - - database_changes - - workflow_changes - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - platform: ${{ inputs.platform }} - outputs: - version: ${{ steps.set_database_version.outputs.version }} - steps: - - uses: actions/checkout@v4 - - name: Build Database Image - id: set_database_version - uses: ./.github/actions/docker-buildx - with: - context: ./backend/db-setup - gh_username: ${{ github.actor }} - gh_token: ${{ secrets.GITHUB_TOKEN }} - image_name: database - platform: ${{ matrix.platform }} - - build_frontend_image: - if: needs.workflow_changes.outputs.has_changes == 'true' || needs.frontend_changes.outputs.has_changes == 'true' || inputs.force_build == 'true' || github.ref == 'refs/heads/main' - needs: - - frontend_changes - - workflow_changes - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - platform: ${{ inputs.platform }} - outputs: - version: ${{ steps.set_frontend_version.outputs.version }} - steps: - - uses: actions/checkout@v4 - - name: Build Frontend Image - id: set_frontend_version - uses: ./.github/actions/docker-buildx - with: - file: ./frontend/Dockerfile - gh_username: ${{ github.actor }} - gh_token: ${{ secrets.GITHUB_TOKEN }} - image_name: frontend - platform: ${{ matrix.platform }} - build_args: | - "REACT_APP_OKTA_URL=http://wiremock:8088" - "REACT_APP_OKTA_CLIENT_ID=0oa1k0163nAwfVxNW1d7" - "REACT_APP_BASE_URL=https://localhost.simplereport.gov" - "REACT_APP_BACKEND_URL=https://localhost.simplereport.gov/api" - "PUBLIC_URL=/app/" - "REACT_APP_OKTA_ENABLED=true" - "REACT_APP_DISABLE_MAINTENANCE_BANNER=true" - - build_frontend_lighthouse_image: - if: needs.workflow_changes.outputs.has_changes == 'true' || needs.frontend_changes.outputs.has_changes == 'true' || inputs.force_build == 'true' || github.ref == 'refs/heads/main' - needs: - - frontend_changes - - workflow_changes - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - platform: ${{ inputs.platform }} - outputs: - version: ${{ steps.set_frontend_lighthouse_version.outputs.version }} - steps: - - uses: actions/checkout@v4 - - name: Build Frontend Lighthouse Image - id: set_frontend_lighthouse_version - uses: ./.github/actions/docker-buildx - with: - file: ./frontend/Dockerfile - gh_username: ${{ github.actor }} - gh_token: ${{ secrets.GITHUB_TOKEN }} - image_name: frontend-lighthouse - platform: ${{ matrix.platform }} - build_args: | - "REACT_APP_BASE_URL=https://localhost.simplereport.gov" - "REACT_APP_BACKEND_URL=https://localhost.simplereport.gov/api" - "PUBLIC_URL=/app/" - "REACT_APP_OKTA_ENABLED=false" - "REACT_APP_DISABLE_MAINTENANCE_BANNER=true" - - build_nginx_image: - if: needs.workflow_changes.outputs.has_changes == 'true' || needs.nginx_changes.outputs.has_changes == 'true' || inputs.force_build == 'true' || github.ref == 'refs/heads/main' - needs: - - nginx_changes - - workflow_changes - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - platform: ${{ inputs.platform }} - outputs: - version: ${{ steps.set_nginx_version.outputs.version }} - steps: - - uses: actions/checkout@v4 - - name: Build Nginx Image - id: set_nginx_version - uses: ./.github/actions/docker-buildx - with: - context: ./nginx - gh_username: ${{ github.actor }} - gh_token: ${{ secrets.GITHUB_TOKEN }} - image_name: nginx - platform: ${{ matrix.platform }} - -# Automated tests - e2e_local: - if: | - always() && !failure() && !cancelled() - needs: - - build_backend_image - - build_cypress_image - - build_database_image - - build_frontend_image - - build_nginx_image - uses: ./.github/workflows/e2eLocal.yml - secrets: - CYPRESS_OKTA_USERNAME: ${{ secrets.CYPRESS_OKTA_USERNAME }} - CYPRESS_OKTA_PASSWORD: ${{ secrets.CYPRESS_OKTA_PASSWORD }} - CYPRESS_OKTA_SECRET: ${{ secrets.CYPRESS_OKTA_SECRET }} - OKTA_API_KEY: ${{ secrets.OKTA_API_KEY }} - SMARTY_AUTH_ID: ${{ secrets.SMARTY_AUTH_ID }} - SMARTY_AUTH_TOKEN: ${{ secrets.SMARTY_AUTH_TOKEN }} - with: - DOCKER_BACKEND_IMAGE_VERSION: ${{ needs.build_backend_image.outputs.version }} - DOCKER_CYPRESS_IMAGE_VERSION: ${{ needs.build_cypress_image.outputs.version }} - DOCKER_DATABASE_IMAGE_VERSION: ${{ needs.build_database_image.outputs.version }} - DOCKER_FRONTEND_IMAGE_VERSION: ${{ needs.build_frontend_image.outputs.version }} - DOCKER_NGINX_IMAGE_VERSION: ${{ needs.build_nginx_image.outputs.version }} - - lighthouse: - if: | - always() && !failure() && !cancelled() - needs: - - build_backend_image - - build_database_image - - build_frontend_lighthouse_image - - build_nginx_image - uses: ./.github/workflows/lighthouse.yml - with: - DOCKER_BACKEND_IMAGE_VERSION: ${{ needs.build_backend_image.outputs.version }} - DOCKER_DATABASE_IMAGE_VERSION: ${{ needs.build_database_image.outputs.version }} - DOCKER_FRONTEND_LIGHTHOUSE_IMAGE_VERSION: ${{ needs.build_frontend_lighthouse_image.outputs.version }} - DOCKER_NGINX_IMAGE_VERSION: ${{ needs.build_nginx_image.outputs.version }} - - tests: - if: | - always() && !failure() && !cancelled() - needs: - - build_database_image - uses: ./.github/workflows/test.yml - secrets: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_TEST_ACCOUNT_SID }} - TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_TEST_AUTH_TOKEN }} - with: - DOCKER_DATABASE_IMAGE_VERSION: ${{ needs.build_database_image.outputs.version }} - - liquibase_action_checks: - if: | - always() && !failure() && !cancelled() - needs: - - build_database_image - uses: ./.github/workflows/testDBActions.yml - with: - DOCKER_DATABASE_IMAGE_VERSION: ${{ needs.build_database_image.outputs.version }} \ No newline at end of file +#name: Testing Workflow +# +#on: +# workflow_dispatch: +# inputs: +# platforms: +# description: "Build additional architectures (linux/arm64). linux/amd64 is built by default." +# required: false +# force_build: +# description: "Force build the docker images" +# required: true +# default: true +# pull_request: +# branches: +# - "**" +# merge_group: +# types: +# - checks_requested +# push: +# branches: +# - main +# +#permissions: +# contents: read +# packages: write +# +#jobs: +## Check for changes in the backend, cypress, database, frontend, and nginx directories +# workflow_changes: +# with: +# what_to_check: ./.github +# uses: ./.github/workflows/checkForChanges.yml +# +# backend_changes: +# with: +# what_to_check: ./backend +# uses: ./.github/workflows/checkForChanges.yml +# +# cypress_changes: +# with: +# what_to_check: ./cypress +# uses: ./.github/workflows/checkForChanges.yml +# +# database_changes: +# with: +# what_to_check: ./backend/db-setup +# uses: ./.github/workflows/checkForChanges.yml +# +# frontend_changes: +# with: +# what_to_check: ./frontend +# uses: ./.github/workflows/checkForChanges.yml +# +# nginx_changes: +# with: +# what_to_check: ./nginx +# uses: ./.github/workflows/checkForChanges.yml +# +## Build Docker Images for the backend, cypress, database, frontend, and nginx +# build_backend_image: +# if: needs.workflow_changes.outputs.has_changes == 'true' || needs.backend_changes.outputs.has_changes == 'true' || inputs.force_build == 'true' || github.ref == 'refs/heads/main' +# needs: +# - backend_changes +# - workflow_changes +# runs-on: ubuntu-latest +# strategy: +# fail-fast: false +# matrix: +# include: +# - platform: ${{ inputs.platform }} +# outputs: +# version: ${{ steps.set_backend_version.outputs.version }} +# steps: +# - uses: actions/checkout@v4 +# - name: Build Backend Image +# id: set_backend_version +# uses: ./.github/actions/docker-buildx +# with: +# file: ./backend/Dockerfile +# gh_username: ${{ github.actor }} +# gh_token: ${{ secrets.GITHUB_TOKEN }} +# image_name: backend +# platform: ${{ matrix.platform }} +# +# build_cypress_image: +# if: needs.workflow_changes.outputs.has_changes == 'true' || needs.cypress_changes.outputs.has_changes == 'true' || inputs.force_build == 'true' || github.ref == 'refs/heads/main' +# needs: +# - cypress_changes +# - workflow_changes +# runs-on: ubuntu-latest +# strategy: +# fail-fast: false +# matrix: +# include: +# - platform: ${{ inputs.platform }} +# outputs: +# version: ${{ steps.set_cypress_version.outputs.version }} +# steps: +# - uses: actions/checkout@v4 +# - name: Build Cypress Image +# id: set_cypress_version +# uses: ./.github/actions/docker-buildx +# with: +# file: ./cypress/Dockerfile +# gh_username: ${{ github.actor }} +# gh_token: ${{ secrets.GITHUB_TOKEN }} +# image_name: cypress +# platform: ${{ matrix.platform }} +# +# build_database_image: +# if: needs.workflow_changes.outputs.has_changes == 'true' || needs.database_changes.outputs.has_changes == 'true' || inputs.force_build == 'true' || github.ref == 'refs/heads/main' +# needs: +# - database_changes +# - workflow_changes +# runs-on: ubuntu-latest +# strategy: +# fail-fast: false +# matrix: +# include: +# - platform: ${{ inputs.platform }} +# outputs: +# version: ${{ steps.set_database_version.outputs.version }} +# steps: +# - uses: actions/checkout@v4 +# - name: Build Database Image +# id: set_database_version +# uses: ./.github/actions/docker-buildx +# with: +# context: ./backend/db-setup +# gh_username: ${{ github.actor }} +# gh_token: ${{ secrets.GITHUB_TOKEN }} +# image_name: database +# platform: ${{ matrix.platform }} +# +# build_frontend_image: +# if: needs.workflow_changes.outputs.has_changes == 'true' || needs.frontend_changes.outputs.has_changes == 'true' || inputs.force_build == 'true' || github.ref == 'refs/heads/main' +# needs: +# - frontend_changes +# - workflow_changes +# runs-on: ubuntu-latest +# strategy: +# fail-fast: false +# matrix: +# include: +# - platform: ${{ inputs.platform }} +# outputs: +# version: ${{ steps.set_frontend_version.outputs.version }} +# steps: +# - uses: actions/checkout@v4 +# - name: Build Frontend Image +# id: set_frontend_version +# uses: ./.github/actions/docker-buildx +# with: +# file: ./frontend/Dockerfile +# gh_username: ${{ github.actor }} +# gh_token: ${{ secrets.GITHUB_TOKEN }} +# image_name: frontend +# platform: ${{ matrix.platform }} +# build_args: | +# "REACT_APP_OKTA_URL=http://wiremock:8088" +# "REACT_APP_OKTA_CLIENT_ID=0oa1k0163nAwfVxNW1d7" +# "REACT_APP_BASE_URL=https://localhost.simplereport.gov" +# "REACT_APP_BACKEND_URL=https://localhost.simplereport.gov/api" +# "PUBLIC_URL=/app/" +# "REACT_APP_OKTA_ENABLED=true" +# "REACT_APP_DISABLE_MAINTENANCE_BANNER=true" +# +# build_frontend_lighthouse_image: +# if: needs.workflow_changes.outputs.has_changes == 'true' || needs.frontend_changes.outputs.has_changes == 'true' || inputs.force_build == 'true' || github.ref == 'refs/heads/main' +# needs: +# - frontend_changes +# - workflow_changes +# runs-on: ubuntu-latest +# strategy: +# fail-fast: false +# matrix: +# include: +# - platform: ${{ inputs.platform }} +# outputs: +# version: ${{ steps.set_frontend_lighthouse_version.outputs.version }} +# steps: +# - uses: actions/checkout@v4 +# - name: Build Frontend Lighthouse Image +# id: set_frontend_lighthouse_version +# uses: ./.github/actions/docker-buildx +# with: +# file: ./frontend/Dockerfile +# gh_username: ${{ github.actor }} +# gh_token: ${{ secrets.GITHUB_TOKEN }} +# image_name: frontend-lighthouse +# platform: ${{ matrix.platform }} +# build_args: | +# "REACT_APP_BASE_URL=https://localhost.simplereport.gov" +# "REACT_APP_BACKEND_URL=https://localhost.simplereport.gov/api" +# "PUBLIC_URL=/app/" +# "REACT_APP_OKTA_ENABLED=false" +# "REACT_APP_DISABLE_MAINTENANCE_BANNER=true" +# +# build_nginx_image: +# if: needs.workflow_changes.outputs.has_changes == 'true' || needs.nginx_changes.outputs.has_changes == 'true' || inputs.force_build == 'true' || github.ref == 'refs/heads/main' +# needs: +# - nginx_changes +# - workflow_changes +# runs-on: ubuntu-latest +# strategy: +# fail-fast: false +# matrix: +# include: +# - platform: ${{ inputs.platform }} +# outputs: +# version: ${{ steps.set_nginx_version.outputs.version }} +# steps: +# - uses: actions/checkout@v4 +# - name: Build Nginx Image +# id: set_nginx_version +# uses: ./.github/actions/docker-buildx +# with: +# context: ./nginx +# gh_username: ${{ github.actor }} +# gh_token: ${{ secrets.GITHUB_TOKEN }} +# image_name: nginx +# platform: ${{ matrix.platform }} +# +## Automated tests +# e2e_local: +# if: | +# always() && !failure() && !cancelled() +# needs: +# - build_backend_image +# - build_cypress_image +# - build_database_image +# - build_frontend_image +# - build_nginx_image +# uses: ./.github/workflows/e2eLocal.yml +# secrets: +# CYPRESS_OKTA_USERNAME: ${{ secrets.CYPRESS_OKTA_USERNAME }} +# CYPRESS_OKTA_PASSWORD: ${{ secrets.CYPRESS_OKTA_PASSWORD }} +# CYPRESS_OKTA_SECRET: ${{ secrets.CYPRESS_OKTA_SECRET }} +# OKTA_API_KEY: ${{ secrets.OKTA_API_KEY }} +# SMARTY_AUTH_ID: ${{ secrets.SMARTY_AUTH_ID }} +# SMARTY_AUTH_TOKEN: ${{ secrets.SMARTY_AUTH_TOKEN }} +# with: +# DOCKER_BACKEND_IMAGE_VERSION: ${{ needs.build_backend_image.outputs.version }} +# DOCKER_CYPRESS_IMAGE_VERSION: ${{ needs.build_cypress_image.outputs.version }} +# DOCKER_DATABASE_IMAGE_VERSION: ${{ needs.build_database_image.outputs.version }} +# DOCKER_FRONTEND_IMAGE_VERSION: ${{ needs.build_frontend_image.outputs.version }} +# DOCKER_NGINX_IMAGE_VERSION: ${{ needs.build_nginx_image.outputs.version }} +# +# lighthouse: +# if: | +# always() && !failure() && !cancelled() +# needs: +# - build_backend_image +# - build_database_image +# - build_frontend_lighthouse_image +# - build_nginx_image +# uses: ./.github/workflows/lighthouse.yml +# with: +# DOCKER_BACKEND_IMAGE_VERSION: ${{ needs.build_backend_image.outputs.version }} +# DOCKER_DATABASE_IMAGE_VERSION: ${{ needs.build_database_image.outputs.version }} +# DOCKER_FRONTEND_LIGHTHOUSE_IMAGE_VERSION: ${{ needs.build_frontend_lighthouse_image.outputs.version }} +# DOCKER_NGINX_IMAGE_VERSION: ${{ needs.build_nginx_image.outputs.version }} +# +# tests: +# if: | +# always() && !failure() && !cancelled() +# needs: +# - build_database_image +# uses: ./.github/workflows/test.yml +# secrets: +# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} +# TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_TEST_ACCOUNT_SID }} +# TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_TEST_AUTH_TOKEN }} +# with: +# DOCKER_DATABASE_IMAGE_VERSION: ${{ needs.build_database_image.outputs.version }} +# +# liquibase_action_checks: +# if: | +# always() && !failure() && !cancelled() +# needs: +# - build_database_image +# uses: ./.github/workflows/testDBActions.yml +# with: +# DOCKER_DATABASE_IMAGE_VERSION: ${{ needs.build_database_image.outputs.version }} \ No newline at end of file diff --git a/.github/workflows/tfsec.yml b/.github/workflows/tfsec.yml index 274855ff59..ec27bfe031 100644 --- a/.github/workflows/tfsec.yml +++ b/.github/workflows/tfsec.yml @@ -1,28 +1,28 @@ -# Use this action if we want these alerts to show up in the Github Security Alerts: https://github.com/marketplace/actions/run-tfsec-with-sarif-upload -# Use this action if we want these alerts to be added as comments on a PR: https://github.com/marketplace/actions/run-tfsec-with-sarif-upload -# This action repo: https://github.com/aquasecurity/tfsec-action -name: tfsec - -on: - push: - branches: - - main - pull_request: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - tfsec: - name: tfsec - runs-on: ubuntu-latest - - steps: - - name: Clone repo - uses: actions/checkout@master - - name: tfsec - uses: aquasecurity/tfsec-action@v1.0.3 - with: - # added these exceptions for now, the ticket to fix them is here: https://github.com/CDCgov/prime-simplereport/issues/5879 - additional_args: -e azure-keyvault-ensure-key-expiry,azure-keyvault-no-purge +## Use this action if we want these alerts to show up in the Github Security Alerts: https://github.com/marketplace/actions/run-tfsec-with-sarif-upload +## Use this action if we want these alerts to be added as comments on a PR: https://github.com/marketplace/actions/run-tfsec-with-sarif-upload +## This action repo: https://github.com/aquasecurity/tfsec-action +#name: tfsec +# +#on: +# push: +# branches: +# - main +# pull_request: +# +#concurrency: +# group: ${{ github.workflow }}-${{ github.ref }} +# cancel-in-progress: true +# +#jobs: +# tfsec: +# name: tfsec +# runs-on: ubuntu-latest +# +# steps: +# - name: Clone repo +# uses: actions/checkout@master +# - name: tfsec +# uses: aquasecurity/tfsec-action@v1.0.3 +# with: +# # added these exceptions for now, the ticket to fix them is here: https://github.com/CDCgov/prime-simplereport/issues/5879 +# additional_args: -e azure-keyvault-ensure-key-expiry,azure-keyvault-no-purge From a08714764b564a1d1b8d611009e5216467ff821b Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 10:23:02 -0500 Subject: [PATCH 07/59] frontend component and script --- frontend/deploy-smoke.js | 31 +++++++++++++++ frontend/package.json | 6 ++- frontend/src/app/HealthChecks.tsx | 3 ++ frontend/src/app/ProdSmokeTest.test.tsx | 30 +++++++++++++++ frontend/src/app/ProdSmokeTest.tsx | 29 ++++++++++++++ frontend/yarn.lock | 50 ++++++++++++++++++++++++- 6 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 frontend/deploy-smoke.js create mode 100644 frontend/src/app/ProdSmokeTest.test.tsx create mode 100644 frontend/src/app/ProdSmokeTest.tsx diff --git a/frontend/deploy-smoke.js b/frontend/deploy-smoke.js new file mode 100644 index 0000000000..b2740c3231 --- /dev/null +++ b/frontend/deploy-smoke.js @@ -0,0 +1,31 @@ +// Script that does a simple Selenium scrape of +// - A frontend page with a simple status message that hits a health check backend +// endpoint which does a simple ping to a non-sensitive DB table to verify +// all the connections are good. +// https://github.com/CDCgov/prime-simplereport/pull/7057 + +require("dotenv").config(); +let { Builder } = require("selenium-webdriver"); +const Chrome = require("selenium-webdriver/chrome"); + +console.log(`Running smoke test for ${process.env.REACT_APP_BASE_URL}`); +const options = new Chrome.Options(); +const driver = new Builder() + .forBrowser("chrome") + .setChromeOptions(options.addArguments("--headless=new")) + .build(); +driver + .navigate() + .to(`${process.env.REACT_APP_BASE_URL}app/health/prod-smoke-test`) + .then(() => { + let value = driver.findElement({ id: "root" }).getText(); + return value; + }) + .then((value) => { + driver.quit(); + return value; + }) + .then((value) => { + if (value.includes("success")) process.exit(0); + process.exit(1); + }); diff --git a/frontend/package.json b/frontend/package.json index 5534b1fb4b..75af947025 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -75,7 +75,9 @@ "storybook": "yarn create-storybook-public && SASS_PATH=$(cd ./node_modules && pwd):$(cd ./node_modules/@uswds && pwd):$(cd ./node_modules/@uswds/uswds/packages && pwd):$(cd ./src/scss && pwd) storybook dev -p 6006 -s ../storybook_public", "build-storybook": "yarn create-storybook-public && REACT_APP_BACKEND_URL=http://localhost:8080 SASS_PATH=$(cd ./node_modules && pwd):$(cd ./node_modules/@uswds && pwd):$(cd ./node_modules/@uswds/uswds/packages && pwd):$(cd ./src/scss && pwd) storybook build -s storybook_public", "maintenance:start": "[ -z \"$MAINTENANCE_MESSAGE\" ] && echo \"MAINTENANCE_MESSAGE must be set!\" || (echo $MAINTENANCE_MESSAGE > maintenance.json && yarn maintenance:deploy && rm maintenance.json)", - "maintenance:deploy": "[ -z \"$MAINTENANCE_ENV\" ] && echo \"MAINTENANCE_ENV must be set!\" || az storage blob upload -f maintenance.json -n maintenance.json -c '$web' --account-name simplereport${MAINTENANCE_ENV}app --overwrite" + "maintenance:deploy": "[ -z \"$MAINTENANCE_ENV\" ] && echo \"MAINTENANCE_ENV must be set!\" || az storage blob upload -f maintenance.json -n maintenance.json -c '$web' --account-name simplereport${MAINTENANCE_ENV}app --overwrite", + "smoke:env:deploy": "node deploy-smoke.js", + "smoke:env:deploy:ci": "node -r dotenv/config deploy-smoke.js dotenv_config_path=.env.production.local dotenv_config_debug=true" }, "prettier": { "singleQuote": false @@ -205,6 +207,7 @@ "chromatic": "^6.10.2", "dayjs": "^1.10.7", "depcheck": "^1.4.3", + "dotenv": "^16.3.1", "eslint-config-prettier": "^8.8.0", "eslint-plugin-graphql": "^4.0.0", "eslint-plugin-import": "^2.29.0", @@ -224,6 +227,7 @@ "prettier": "^2.8.4", "redux-mock-store": "^1.5.4", "sass": "^1.63.6", + "selenium-webdriver": "^4.16.0", "storybook": "^7.5.2", "storybook-addon-apollo-client": "^5.0.0", "stylelint": "^13.13.1", diff --git a/frontend/src/app/HealthChecks.tsx b/frontend/src/app/HealthChecks.tsx index 608113e171..3e8a7fdaff 100644 --- a/frontend/src/app/HealthChecks.tsx +++ b/frontend/src/app/HealthChecks.tsx @@ -1,5 +1,7 @@ import { Route, Routes } from "react-router-dom"; +import ProdSmokeTest from "./ProdSmokeTest"; + const HealthChecks = () => ( pong} /> @@ -7,6 +9,7 @@ const HealthChecks = () => ( path="commit" element={
{process.env.REACT_APP_CURRENT_COMMIT}
} /> + } />
); diff --git a/frontend/src/app/ProdSmokeTest.test.tsx b/frontend/src/app/ProdSmokeTest.test.tsx new file mode 100644 index 0000000000..c591ad23fd --- /dev/null +++ b/frontend/src/app/ProdSmokeTest.test.tsx @@ -0,0 +1,30 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { FetchMock } from "jest-fetch-mock"; + +import ProdSmokeTest from "./ProdSmokeTest"; + +describe("ProdSmokeTest", () => { + beforeEach(() => { + (fetch as FetchMock).resetMocks(); + }); + + it("renders success when returned from the API endpoint", async () => { + (fetch as FetchMock).mockResponseOnce(JSON.stringify({ status: "UP" })); + + render(); + await waitFor(() => + expect(screen.queryByText("Status loading...")).not.toBeInTheDocument() + ); + expect(screen.getByText("Status returned success :)")); + }); + + it("renders failure when returned from the API endpoint", async () => { + (fetch as FetchMock).mockResponseOnce(JSON.stringify({ status: "DOWN" })); + + render(); + await waitFor(() => + expect(screen.queryByText("Status loading...")).not.toBeInTheDocument() + ); + expect(screen.getByText("Status returned failure :(")); + }); +}); diff --git a/frontend/src/app/ProdSmokeTest.tsx b/frontend/src/app/ProdSmokeTest.tsx new file mode 100644 index 0000000000..ddba865338 --- /dev/null +++ b/frontend/src/app/ProdSmokeTest.tsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; + +import FetchClient from "./utils/api"; + +const api = new FetchClient(undefined, { mode: "cors" }); +const ProdSmokeTest = (): JSX.Element => { + const [success, setSuccess] = useState(); + useEffect(() => { + api + .getRequest("/actuator/health/prod-smoke-test") + .then((response) => { + console.log(response); + const status = JSON.parse(response); + if (status.status === "UP") return setSuccess(true); + // log something using app insights + setSuccess(false); + }) + .catch((e) => { + console.error(e); + setSuccess(false); + }); + }, []); + + if (success === undefined) return <>Status loading...; + if (success) return <> Status returned success :) ; + return <> Status returned failure :( ; +}; + +export default ProdSmokeTest; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 2667cfd408..4f9bc8205f 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -9072,7 +9072,7 @@ dotenv@^10.0.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81" integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== -dotenv@^16.0.0: +dotenv@^16.0.0, dotenv@^16.3.1: version "16.3.1" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== @@ -11098,6 +11098,11 @@ ignore@^5.2.4: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78" integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg== +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== + immer@^9.0.7: version "9.0.16" resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.16.tgz#8e7caab80118c2b54b37ad43e05758cdefad0198" @@ -12874,6 +12879,16 @@ jsonpointer@^5.0.0: array-includes "^3.1.5" object.assign "^4.1.3" +jszip@^3.10.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" + integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + setimmediate "^1.0.5" + jwt-decode@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" @@ -12952,6 +12967,13 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + lilconfig@^2.0.3, lilconfig@^2.0.5, lilconfig@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4" @@ -14207,6 +14229,11 @@ pako@~0.2.0: resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA== +pako@~1.0.2: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + param-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5" @@ -16394,6 +16421,15 @@ select-hose@^2.0.0: resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" integrity sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg== +selenium-webdriver@^4.16.0: + version "4.16.0" + resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.16.0.tgz#2f1a2426d876aa389d1c937b00f034c2c7808360" + integrity sha512-IbqpRpfGE7JDGgXHJeWuCqT/tUqnLvZ14csSwt+S8o4nJo3RtQoE9VR4jB47tP/A8ArkYsh/THuMY6kyRP6kuA== + dependencies: + jszip "^3.10.1" + tmp "^0.2.1" + ws ">=8.14.2" + selfsigned@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-2.1.1.tgz#18a7613d714c0cd3385c48af0075abf3f266af61" @@ -17547,6 +17583,13 @@ tmp@^0.0.33: dependencies: os-tmpdir "~1.0.2" +tmp@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" + integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ== + dependencies: + rimraf "^3.0.0" + tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -18879,6 +18922,11 @@ ws@8.14.1: resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.1.tgz#4b9586b4f70f9e6534c7bb1d3dc0baa8b8cf01e0" integrity sha512-4OOseMUq8AzRBI/7SLMUwO+FEDnguetSk7KMb1sHwvF2w2Wv5Hoj0nlifx8vtGsftE/jWHojPy8sMMzYLJ2G/A== +ws@>=8.14.2: + version "8.15.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.15.0.tgz#db080a279260c5f532fc668d461b8346efdfcf86" + integrity sha512-H/Z3H55mrcrgjFwI+5jKavgXvwQLtfPCUEp6pi35VhoB0pfcHnSoyuTzkBEZpzq49g1193CUEwIvmsjcotenYw== + "ws@^5.2.0 || ^6.0.0 || ^7.0.0", ws@^7.4.6: version "7.5.9" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" From f4e58fb4c4c988256c802c8981420c68b3a43e06 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 10:23:23 -0500 Subject: [PATCH 08/59] backend config --- .../BackendAndDatabaseHealthIndicator.java | 31 ++++++++++++++ .../config/SecurityConfiguration.java | 3 ++ backend/src/main/resources/application.yaml | 1 + ...BackendAndDatabaseHealthIndicatorTest.java | 41 +++++++++++++++++++ .../test_util/SliceTestConfiguration.java | 4 +- 5 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java create mode 100644 backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java new file mode 100644 index 0000000000..0bb039fef2 --- /dev/null +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -0,0 +1,31 @@ +package gov.cdc.usds.simplereport.api.heathcheck; + +import gov.cdc.usds.simplereport.db.repository.FeatureFlagRepository; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.exception.JDBCConnectionException; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +@Component("prod-smoke-test") +@Slf4j +@RequiredArgsConstructor +public class BackendAndDatabaseHealthIndicator implements HealthIndicator { + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final FeatureFlagRepository _repo; + + @Override + public Health health() { + try { + _repo.findAll(); + return Health.up().build(); + } catch (JDBCConnectionException e) { + return Health.down().build(); + } + } +} diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/config/SecurityConfiguration.java b/backend/src/main/java/gov/cdc/usds/simplereport/config/SecurityConfiguration.java index 15fdeee263..07a737850a 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/config/SecurityConfiguration.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/config/SecurityConfiguration.java @@ -1,6 +1,7 @@ package gov.cdc.usds.simplereport.config; import com.okta.spring.boot.oauth.Okta; +import gov.cdc.usds.simplereport.api.heathcheck.BackendAndDatabaseHealthIndicator; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; import gov.cdc.usds.simplereport.service.model.IdentitySupplier; import lombok.extern.slf4j.Slf4j; @@ -57,6 +58,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers(EndpointRequest.to(InfoEndpoint.class)) .permitAll() + .requestMatchers(EndpointRequest.to(BackendAndDatabaseHealthIndicator.class)) + .permitAll() // Patient experience authorization is handled in PatientExperienceController // If this configuration changes, please update the documentation on both sides .requestMatchers(HttpMethod.POST, WebConfiguration.PATIENT_EXPERIENCE) diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 1df6cb8e0a..8f792d8cc0 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -78,6 +78,7 @@ management: endpoint.health.probes.enabled: true endpoint.info.enabled: true endpoints.web.exposure.include: health, info + endpoint.health.show-components: always okta: oauth2: issuer: https://hhs-prime.okta.com/oauth2/default diff --git a/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java b/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java new file mode 100644 index 0000000000..61ae972a84 --- /dev/null +++ b/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java @@ -0,0 +1,41 @@ +package gov.cdc.usds.simplereport.api.healthcheck; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import gov.cdc.usds.simplereport.api.heathcheck.BackendAndDatabaseHealthIndicator; +import gov.cdc.usds.simplereport.db.repository.BaseRepositoryTest; +import gov.cdc.usds.simplereport.db.repository.FeatureFlagRepository; +import java.sql.SQLException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.hibernate.exception.JDBCConnectionException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.mock.mockito.SpyBean; + +@RequiredArgsConstructor +@EnableConfigurationProperties +class BackendAndDatabaseHealthIndicatorTest extends BaseRepositoryTest { + + @SpyBean private FeatureFlagRepository mockRepo; + + @Autowired private BackendAndDatabaseHealthIndicator indicator; + + @Test + void health_succeedsWhenRepoDoesntThrow() { + when(mockRepo.findAll()).thenReturn(List.of()); + assertThat(indicator.health()).isEqualTo(Health.up().build()); + } + + @Test + void health_failsWhenRepoDoesntThrow() { + JDBCConnectionException dbConnectionException = + new JDBCConnectionException( + "connection issue", new SQLException("some reason", "some state")); + when(mockRepo.findAll()).thenThrow(dbConnectionException); + assertThat(indicator.health()).isEqualTo(Health.down().build()); + } +} diff --git a/backend/src/test/java/gov/cdc/usds/simplereport/test_util/SliceTestConfiguration.java b/backend/src/test/java/gov/cdc/usds/simplereport/test_util/SliceTestConfiguration.java index 30e8144768..3ac3f7dd7d 100644 --- a/backend/src/test/java/gov/cdc/usds/simplereport/test_util/SliceTestConfiguration.java +++ b/backend/src/test/java/gov/cdc/usds/simplereport/test_util/SliceTestConfiguration.java @@ -5,6 +5,7 @@ import gov.cdc.usds.simplereport.api.CurrentOrganizationRolesContextHolder; import gov.cdc.usds.simplereport.api.CurrentTenantDataAccessContextHolder; import gov.cdc.usds.simplereport.api.WebhookContextHolder; +import gov.cdc.usds.simplereport.api.heathcheck.BackendAndDatabaseHealthIndicator; import gov.cdc.usds.simplereport.api.pxp.CurrentPatientContextHolder; import gov.cdc.usds.simplereport.config.AuditingConfig; import gov.cdc.usds.simplereport.config.AuthorizationProperties; @@ -100,7 +101,8 @@ CurrentTenantDataAccessContextHolder.class, WebhookContextHolder.class, TenantDataAccessService.class, - PatientSelfRegistrationLinkService.class + PatientSelfRegistrationLinkService.class, + BackendAndDatabaseHealthIndicator.class }) @EnableConfigurationProperties({InitialSetupProperties.class, AuthorizationProperties.class}) public class SliceTestConfiguration { From 988c7e8615fbfd5d945e9a2772f2b8e6dbc22e33 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 10:23:28 -0500 Subject: [PATCH 09/59] actions --- .../actions/post-deploy-smoke-test/action.yml | 34 +++++++++++++ .github/workflows/smokeTestDeployDev.yml | 50 +++++++++++++++++++ .github/workflows/smokeTestDeployProd.yml | 34 +++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 .github/actions/post-deploy-smoke-test/action.yml create mode 100644 .github/workflows/smokeTestDeployDev.yml create mode 100644 .github/workflows/smokeTestDeployProd.yml diff --git a/.github/actions/post-deploy-smoke-test/action.yml b/.github/actions/post-deploy-smoke-test/action.yml new file mode 100644 index 0000000000..0b573ecaa2 --- /dev/null +++ b/.github/actions/post-deploy-smoke-test/action.yml @@ -0,0 +1,34 @@ +name: Smoke test post deploy +description: Ping a backend health endpoint that reaches into the db and return a status message to a frontend status page. Visit that page and ensure things are healthy +inputs: + deploy-env: + description: The environment being deployed (e.g. "prod" or "test") + required: true +runs: + using: composite + steps: + - name: create env file + shell: bash + working-directory: frontend + run: | + touch .env + echo REACT_APP_BASE_URL=https://${{ inputs.deploy-env }}.simplereport.gov/ >> .env.production.local + - name: Run smoke test script + shell: bash + working-directory: frontend + run: yarn smoke:env:deploy:ci + + # slack_alert: + # runs-on: ubuntu-latest + # if: failure() + # needs: [ smoke-test-front-and-back-end ] + # steps: + # - uses: actions/checkout@v4 + # - name: Send alert to Slack + # uses: ./.github/actions/slack-message + # with: + # username: ${{ github.actor }} + # description: | + # :siren-gif: Post-deploy smoke test couldn't verify that the frontend is talking to the backend. ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} :siren-gif: + # webhook_url: ${{ secrets.SR_ALERTS_SLACK_WEBHOOK_URL }} + # user_map: $${{ secrets.SR_ALERTS_GITHUB_SLACK_MAP }} \ No newline at end of file diff --git a/.github/workflows/smokeTestDeployDev.yml b/.github/workflows/smokeTestDeployDev.yml new file mode 100644 index 0000000000..4c6d64e72d --- /dev/null +++ b/.github/workflows/smokeTestDeployDev.yml @@ -0,0 +1,50 @@ +name: Smoke test deploy +run-name: Smoke test the deploy for a dev env by @${{ github.actor }} + +on: + # DELETE ME WHEN MERGING + push: + # UNCOMMENT ME WHEN MERGING +# workflow_dispatch: +# inputs: +# deploy_env: +# description: 'The environment to smoke test' +# required: true +# type: choice +# options: +# - "" +# - dev +# - dev2 +# - dev3 +# - dev4 +# - dev5 +# - dev6 +# - dev7 +# - pentest + +env: + NODE_VERSION: 18 + +jobs: + smoke-test-front-and-back-end: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + - name: Cache yarn + uses: actions/cache@v3.3.2 + with: + path: ~/.cache/yarn + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + - name: Set up dependencies + working-directory: frontend + run: yarn install --prefer-offline + - name: Smoke test the env + uses: ./.github/actions/post-deploy-smoke-test + with: + # REPLACE ME WITH deploy-env: ${{inputs.deploy_env}} + deploy-env: dev7 + diff --git a/.github/workflows/smokeTestDeployProd.yml b/.github/workflows/smokeTestDeployProd.yml new file mode 100644 index 0000000000..da442331a3 --- /dev/null +++ b/.github/workflows/smokeTestDeployProd.yml @@ -0,0 +1,34 @@ +name: Smoke test deploy Prod +run-name: Smoke test the deploy for prod by @${{ github.actor }} + +on: + workflow_run: + workflows: [ " Deploy Prod" ] + types: + - completed + +env: + NODE_VERSION: 18 + DEPLOY_ENV: prod + +jobs: + smoke-test-front-and-back-end: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + - name: Cache yarn + uses: actions/cache@v3.3.2 + with: + path: ~/.cache/yarn + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + - name: Set up dependencies + working-directory: frontend + run: yarn install --prefer-offline + - name: Smoke test the env + uses: ./.github/actions/post-deploy-smoke-test + with: + deploy-env: ${{ env.DEPLOY_ENV }} From 66594908cdd7656f642d79bc7231ae26662fe334 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 10:36:20 -0500 Subject: [PATCH 10/59] rename --- .../{smokeTestDeployDev.yml => smokeTestDeployManual.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{smokeTestDeployDev.yml => smokeTestDeployManual.yml} (100%) diff --git a/.github/workflows/smokeTestDeployDev.yml b/.github/workflows/smokeTestDeployManual.yml similarity index 100% rename from .github/workflows/smokeTestDeployDev.yml rename to .github/workflows/smokeTestDeployManual.yml From d3095615d3bcf42ca9782019a6ceccbca2d4e463 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 17:14:12 -0500 Subject: [PATCH 11/59] some other stuff --- .github/workflows/smokeTestDeployManual.yml | 39 ++++++++++----------- .github/workflows/smokeTestDeployProd.yml | 2 +- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/.github/workflows/smokeTestDeployManual.yml b/.github/workflows/smokeTestDeployManual.yml index 4c6d64e72d..a803c0a0f8 100644 --- a/.github/workflows/smokeTestDeployManual.yml +++ b/.github/workflows/smokeTestDeployManual.yml @@ -2,25 +2,22 @@ name: Smoke test deploy run-name: Smoke test the deploy for a dev env by @${{ github.actor }} on: - # DELETE ME WHEN MERGING - push: - # UNCOMMENT ME WHEN MERGING -# workflow_dispatch: -# inputs: -# deploy_env: -# description: 'The environment to smoke test' -# required: true -# type: choice -# options: -# - "" -# - dev -# - dev2 -# - dev3 -# - dev4 -# - dev5 -# - dev6 -# - dev7 -# - pentest + workflow_dispatch: + inputs: + deploy_env: + description: 'The environment to smoke test' + required: true + type: choice + options: + - "" + - dev + - dev2 + - dev3 + - dev4 + - dev5 + - dev6 + - dev7 + - pentest env: NODE_VERSION: 18 @@ -45,6 +42,6 @@ jobs: - name: Smoke test the env uses: ./.github/actions/post-deploy-smoke-test with: - # REPLACE ME WITH deploy-env: ${{inputs.deploy_env}} - deploy-env: dev7 + deploy-env: ${{inputs.deploy_env}} + diff --git a/.github/workflows/smokeTestDeployProd.yml b/.github/workflows/smokeTestDeployProd.yml index da442331a3..60516bc31e 100644 --- a/.github/workflows/smokeTestDeployProd.yml +++ b/.github/workflows/smokeTestDeployProd.yml @@ -3,7 +3,7 @@ run-name: Smoke test the deploy for prod by @${{ github.actor }} on: workflow_run: - workflows: [ " Deploy Prod" ] + workflows: [ "Deploy Prod" ] types: - completed From 8b18c85cafe4cd1beee446cf229add6103a54555 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 17:15:25 -0500 Subject: [PATCH 12/59] add slack alert back in --- .github/workflows/smokeTestDeployManual.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/smokeTestDeployManual.yml b/.github/workflows/smokeTestDeployManual.yml index a803c0a0f8..cdc10164d2 100644 --- a/.github/workflows/smokeTestDeployManual.yml +++ b/.github/workflows/smokeTestDeployManual.yml @@ -43,5 +43,17 @@ jobs: uses: ./.github/actions/post-deploy-smoke-test with: deploy-env: ${{inputs.deploy_env}} - - + slack_alert: + runs-on: ubuntu-latest + if: failure() + needs: [ smoke-test-front-and-back-end ] + steps: + - uses: actions/checkout@v4 + - name: Send alert to Slack + uses: ./.github/actions/slack-message + with: + username: ${{ github.actor }} + description: | + :siren-gif: Post-deploy smoke test couldn't verify that the frontend is talking to the backend. ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} :siren-gif: + webhook_url: ${{ secrets.SR_ALERTS_SLACK_WEBHOOK_URL }} + user_map: $${{ secrets.SR_ALERTS_GITHUB_SLACK_MAP }} \ No newline at end of file From 8a14470a9e4ef4e823e50feba187fd3a6af811e8 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 17:18:27 -0500 Subject: [PATCH 13/59] remove slack comment --- .../actions/post-deploy-smoke-test/action.yml | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/.github/actions/post-deploy-smoke-test/action.yml b/.github/actions/post-deploy-smoke-test/action.yml index 0b573ecaa2..545b16d49d 100644 --- a/.github/actions/post-deploy-smoke-test/action.yml +++ b/.github/actions/post-deploy-smoke-test/action.yml @@ -16,19 +16,4 @@ runs: - name: Run smoke test script shell: bash working-directory: frontend - run: yarn smoke:env:deploy:ci - - # slack_alert: - # runs-on: ubuntu-latest - # if: failure() - # needs: [ smoke-test-front-and-back-end ] - # steps: - # - uses: actions/checkout@v4 - # - name: Send alert to Slack - # uses: ./.github/actions/slack-message - # with: - # username: ${{ github.actor }} - # description: | - # :siren-gif: Post-deploy smoke test couldn't verify that the frontend is talking to the backend. ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} :siren-gif: - # webhook_url: ${{ secrets.SR_ALERTS_SLACK_WEBHOOK_URL }} - # user_map: $${{ secrets.SR_ALERTS_GITHUB_SLACK_MAP }} \ No newline at end of file + run: yarn smoke:env:deploy:ci \ No newline at end of file From 1dbb98ce27409a471befd6ecac834d9fd34b11e9 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 17:20:47 -0500 Subject: [PATCH 14/59] move slack alert over --- .github/workflows/smokeTestDeployManual.yml | 59 --------------------- .github/workflows/smokeTestDeployProd.yml | 14 +++++ 2 files changed, 14 insertions(+), 59 deletions(-) delete mode 100644 .github/workflows/smokeTestDeployManual.yml diff --git a/.github/workflows/smokeTestDeployManual.yml b/.github/workflows/smokeTestDeployManual.yml deleted file mode 100644 index cdc10164d2..0000000000 --- a/.github/workflows/smokeTestDeployManual.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Smoke test deploy -run-name: Smoke test the deploy for a dev env by @${{ github.actor }} - -on: - workflow_dispatch: - inputs: - deploy_env: - description: 'The environment to smoke test' - required: true - type: choice - options: - - "" - - dev - - dev2 - - dev3 - - dev4 - - dev5 - - dev6 - - dev7 - - pentest - -env: - NODE_VERSION: 18 - -jobs: - smoke-test-front-and-back-end: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Use Node.js - uses: actions/setup-node@v3 - with: - node-version: '18' - - name: Cache yarn - uses: actions/cache@v3.3.2 - with: - path: ~/.cache/yarn - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - - name: Set up dependencies - working-directory: frontend - run: yarn install --prefer-offline - - name: Smoke test the env - uses: ./.github/actions/post-deploy-smoke-test - with: - deploy-env: ${{inputs.deploy_env}} - slack_alert: - runs-on: ubuntu-latest - if: failure() - needs: [ smoke-test-front-and-back-end ] - steps: - - uses: actions/checkout@v4 - - name: Send alert to Slack - uses: ./.github/actions/slack-message - with: - username: ${{ github.actor }} - description: | - :siren-gif: Post-deploy smoke test couldn't verify that the frontend is talking to the backend. ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} :siren-gif: - webhook_url: ${{ secrets.SR_ALERTS_SLACK_WEBHOOK_URL }} - user_map: $${{ secrets.SR_ALERTS_GITHUB_SLACK_MAP }} \ No newline at end of file diff --git a/.github/workflows/smokeTestDeployProd.yml b/.github/workflows/smokeTestDeployProd.yml index 60516bc31e..bd9bafc382 100644 --- a/.github/workflows/smokeTestDeployProd.yml +++ b/.github/workflows/smokeTestDeployProd.yml @@ -32,3 +32,17 @@ jobs: uses: ./.github/actions/post-deploy-smoke-test with: deploy-env: ${{ env.DEPLOY_ENV }} + slack_alert: + runs-on: ubuntu-latest + if: failure() + needs: [ smoke-test-front-and-back-end ] + steps: + - uses: actions/checkout@v4 + - name: Send alert to Slack + uses: ./.github/actions/slack-message + with: + username: ${{ github.actor }} + description: | + :siren-gif: Post-deploy smoke test couldn't verify that the frontend is talking to the backend. ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} :siren-gif: + webhook_url: ${{ secrets.SR_ALERTS_SLACK_WEBHOOK_URL }} + user_map: $${{ secrets.SR_ALERTS_GITHUB_SLACK_MAP }} \ No newline at end of file From a33000cb88ded076446e5668044110392ab10298 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Thu, 14 Dec 2023 13:38:18 -0500 Subject: [PATCH 15/59] dan feedback --- .github/actions/post-deploy-smoke-test/action.yml | 2 +- .github/workflows/smokeTestDeployProd.yml | 3 ++- .../heathcheck/BackendAndDatabaseHealthIndicator.java | 9 ++------- frontend/deploy-smoke.js | 2 +- .../{ProdSmokeTest.test.tsx => DeploySmokeTest.test.tsx} | 8 ++++---- .../src/app/{ProdSmokeTest.tsx => DeploySmokeTest.tsx} | 4 ++-- frontend/src/app/HealthChecks.tsx | 4 ++-- 7 files changed, 14 insertions(+), 18 deletions(-) rename frontend/src/app/{ProdSmokeTest.test.tsx => DeploySmokeTest.test.tsx} (84%) rename frontend/src/app/{ProdSmokeTest.tsx => DeploySmokeTest.tsx} (91%) diff --git a/.github/actions/post-deploy-smoke-test/action.yml b/.github/actions/post-deploy-smoke-test/action.yml index 545b16d49d..df2f1d1f69 100644 --- a/.github/actions/post-deploy-smoke-test/action.yml +++ b/.github/actions/post-deploy-smoke-test/action.yml @@ -1,5 +1,5 @@ name: Smoke test post deploy -description: Ping a backend health endpoint that reaches into the db and return a status message to a frontend status page. Visit that page and ensure things are healthy +description: Invoke a script that visits a deploy smoke check page that displays whether the backend / db are healthy. inputs: deploy-env: description: The environment being deployed (e.g. "prod" or "test") diff --git a/.github/workflows/smokeTestDeployProd.yml b/.github/workflows/smokeTestDeployProd.yml index bd9bafc382..31055e18c6 100644 --- a/.github/workflows/smokeTestDeployProd.yml +++ b/.github/workflows/smokeTestDeployProd.yml @@ -9,7 +9,8 @@ on: env: NODE_VERSION: 18 - DEPLOY_ENV: prod + # prod env variable + DEPLOY_ENV: www jobs: smoke-test-front-and-back-end: diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java index 0bb039fef2..c5cfcdae43 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -1,10 +1,7 @@ package gov.cdc.usds.simplereport.api.heathcheck; import gov.cdc.usds.simplereport.db.repository.FeatureFlagRepository; -import lombok.AccessLevel; -import lombok.Getter; import lombok.RequiredArgsConstructor; -import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.JDBCConnectionException; import org.springframework.boot.actuate.health.Health; @@ -15,14 +12,12 @@ @Slf4j @RequiredArgsConstructor public class BackendAndDatabaseHealthIndicator implements HealthIndicator { - @Getter(AccessLevel.NONE) - @Setter(AccessLevel.NONE) - private final FeatureFlagRepository _repo; + private final FeatureFlagRepository _ffRepo; @Override public Health health() { try { - _repo.findAll(); + _ffRepo.findAll(); return Health.up().build(); } catch (JDBCConnectionException e) { return Health.down().build(); diff --git a/frontend/deploy-smoke.js b/frontend/deploy-smoke.js index b2740c3231..8ac643ed1a 100644 --- a/frontend/deploy-smoke.js +++ b/frontend/deploy-smoke.js @@ -16,7 +16,7 @@ const driver = new Builder() .build(); driver .navigate() - .to(`${process.env.REACT_APP_BASE_URL}app/health/prod-smoke-test`) + .to(`${process.env.REACT_APP_BASE_URL}app/health/deploy-smoke-test`) .then(() => { let value = driver.findElement({ id: "root" }).getText(); return value; diff --git a/frontend/src/app/ProdSmokeTest.test.tsx b/frontend/src/app/DeploySmokeTest.test.tsx similarity index 84% rename from frontend/src/app/ProdSmokeTest.test.tsx rename to frontend/src/app/DeploySmokeTest.test.tsx index c591ad23fd..0d3a8cbde0 100644 --- a/frontend/src/app/ProdSmokeTest.test.tsx +++ b/frontend/src/app/DeploySmokeTest.test.tsx @@ -1,9 +1,9 @@ import { render, screen, waitFor } from "@testing-library/react"; import { FetchMock } from "jest-fetch-mock"; -import ProdSmokeTest from "./ProdSmokeTest"; +import DeploySmokeTest from "./DeploySmokeTest"; -describe("ProdSmokeTest", () => { +describe("DeploySmokeTest", () => { beforeEach(() => { (fetch as FetchMock).resetMocks(); }); @@ -11,7 +11,7 @@ describe("ProdSmokeTest", () => { it("renders success when returned from the API endpoint", async () => { (fetch as FetchMock).mockResponseOnce(JSON.stringify({ status: "UP" })); - render(); + render(); await waitFor(() => expect(screen.queryByText("Status loading...")).not.toBeInTheDocument() ); @@ -21,7 +21,7 @@ describe("ProdSmokeTest", () => { it("renders failure when returned from the API endpoint", async () => { (fetch as FetchMock).mockResponseOnce(JSON.stringify({ status: "DOWN" })); - render(); + render(); await waitFor(() => expect(screen.queryByText("Status loading...")).not.toBeInTheDocument() ); diff --git a/frontend/src/app/ProdSmokeTest.tsx b/frontend/src/app/DeploySmokeTest.tsx similarity index 91% rename from frontend/src/app/ProdSmokeTest.tsx rename to frontend/src/app/DeploySmokeTest.tsx index ddba865338..d83a766b2c 100644 --- a/frontend/src/app/ProdSmokeTest.tsx +++ b/frontend/src/app/DeploySmokeTest.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import FetchClient from "./utils/api"; const api = new FetchClient(undefined, { mode: "cors" }); -const ProdSmokeTest = (): JSX.Element => { +const DeploySmokeTest = (): JSX.Element => { const [success, setSuccess] = useState(); useEffect(() => { api @@ -26,4 +26,4 @@ const ProdSmokeTest = (): JSX.Element => { return <> Status returned failure :( ; }; -export default ProdSmokeTest; +export default DeploySmokeTest; diff --git a/frontend/src/app/HealthChecks.tsx b/frontend/src/app/HealthChecks.tsx index 3e8a7fdaff..1f72c3a5b1 100644 --- a/frontend/src/app/HealthChecks.tsx +++ b/frontend/src/app/HealthChecks.tsx @@ -1,6 +1,6 @@ import { Route, Routes } from "react-router-dom"; -import ProdSmokeTest from "./ProdSmokeTest"; +import DeploySmokeTest from "./DeploySmokeTest"; const HealthChecks = () => ( @@ -9,7 +9,7 @@ const HealthChecks = () => ( path="commit" element={
{process.env.REACT_APP_CURRENT_COMMIT}
} /> - } /> + } />
); From 67e9a1c89be5993bd63f7fbbcefdcb9ddf5652b5 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 10:34:21 -0500 Subject: [PATCH 16/59] add okta call and update script config --- .../actions/post-deploy-smoke-test/action.yml | 2 +- .../BackendAndDatabaseHealthIndicator.java | 32 +- .../idp/repository/DemoOktaRepository.java | 715 ++++---- .../idp/repository/LiveOktaRepository.java | 1462 +++++++++-------- .../idp/repository/OktaRepository.java | 74 +- backend/src/main/resources/application.yaml | 1 + frontend/deploy-smoke.js | 8 +- frontend/package.json | 4 +- frontend/src/app/DeploySmokeTest.tsx | 3 +- 9 files changed, 1170 insertions(+), 1131 deletions(-) diff --git a/.github/actions/post-deploy-smoke-test/action.yml b/.github/actions/post-deploy-smoke-test/action.yml index df2f1d1f69..d221374907 100644 --- a/.github/actions/post-deploy-smoke-test/action.yml +++ b/.github/actions/post-deploy-smoke-test/action.yml @@ -16,4 +16,4 @@ runs: - name: Run smoke test script shell: bash working-directory: frontend - run: yarn smoke:env:deploy:ci \ No newline at end of file + run: yarn smoke:deploy:ci \ No newline at end of file diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java index c5cfcdae43..22badc2d6d 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -1,26 +1,38 @@ package gov.cdc.usds.simplereport.api.heathcheck; +import com.okta.sdk.resource.api.GroupApi; +import com.okta.sdk.resource.client.ApiException; import gov.cdc.usds.simplereport.db.repository.FeatureFlagRepository; +import gov.cdc.usds.simplereport.idp.repository.LiveOktaRepository; +import gov.cdc.usds.simplereport.idp.repository.OktaRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.JDBCConnectionException; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.stereotype.Component; -@Component("prod-smoke-test") +@Component("backend-and-db-smoke-test") @Slf4j @RequiredArgsConstructor public class BackendAndDatabaseHealthIndicator implements HealthIndicator { - private final FeatureFlagRepository _ffRepo; + private final FeatureFlagRepository _ffRepo; + private final OktaRepository _oktaRepo; - @Override - public Health health() { - try { - _ffRepo.findAll(); - return Health.up().build(); - } catch (JDBCConnectionException e) { - return Health.down().build(); + @Override + public Health health() { + try { + _ffRepo.findAll(); + _oktaRepo.getConnectTimeoutForHealthCheck(); + + return Health.up().build(); + } catch (JDBCConnectionException e) { + return Health.down().build(); + // Okta API call errored + } catch (ApiException e) { + log.info(e.getMessage()); + return Health.down().build(); + } } - } } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java index d9ef3b5fd8..7baf653582 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java @@ -13,6 +13,7 @@ import gov.cdc.usds.simplereport.db.model.Facility; import gov.cdc.usds.simplereport.db.model.Organization; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; + import java.util.Collection; import java.util.EnumSet; import java.util.HashMap; @@ -24,6 +25,7 @@ import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; + import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.support.ScopeNotActiveException; @@ -32,403 +34,410 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; -/** Handles all user/organization management in Okta */ +/** + * Handles all user/organization management in Okta + */ @Profile(BeanProfiles.NO_OKTA_MGMT) @Service @Slf4j public class DemoOktaRepository implements OktaRepository { - @Value("${simple-report.authorization.environment-name:DEV}") - private String environment; - - private final OrganizationExtractor organizationExtractor; - private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; - - Map usernameOrgRolesMap; - Map> orgUsernamesMap; - Map> orgFacilitiesMap; - Set inactiveUsernames; - Set allUsernames; - private final Set adminGroupMemberSet; - - public DemoOktaRepository( - OrganizationExtractor extractor, - CurrentTenantDataAccessContextHolder contextHolder, - DemoUserConfiguration demoUserConfiguration) { - this.usernameOrgRolesMap = new HashMap<>(); - this.orgUsernamesMap = new HashMap<>(); - this.orgFacilitiesMap = new HashMap<>(); - this.inactiveUsernames = new HashSet<>(); - this.allUsernames = new HashSet<>(); - - this.organizationExtractor = extractor; - this.tenantDataContextHolder = contextHolder; - this.adminGroupMemberSet = - demoUserConfiguration.getSiteAdminEmails().stream().collect(Collectors.toUnmodifiableSet()); - - log.info("Done initializing Demo Okta repository."); - } - - public Optional createUser( - IdentityAttributes userIdentity, - Organization org, - Set facilities, - Set roles, - boolean active) { - if (allUsernames.contains(userIdentity.getUsername())) { - throw new ConflictingUserException(); + @Value("${simple-report.authorization.environment-name:DEV}") + private String environment; + + private final OrganizationExtractor organizationExtractor; + private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; + + Map usernameOrgRolesMap; + Map> orgUsernamesMap; + Map> orgFacilitiesMap; + Set inactiveUsernames; + Set allUsernames; + private final Set adminGroupMemberSet; + + public DemoOktaRepository( + OrganizationExtractor extractor, + CurrentTenantDataAccessContextHolder contextHolder, + DemoUserConfiguration demoUserConfiguration) { + this.usernameOrgRolesMap = new HashMap<>(); + this.orgUsernamesMap = new HashMap<>(); + this.orgFacilitiesMap = new HashMap<>(); + this.inactiveUsernames = new HashSet<>(); + this.allUsernames = new HashSet<>(); + + this.organizationExtractor = extractor; + this.tenantDataContextHolder = contextHolder; + this.adminGroupMemberSet = + demoUserConfiguration.getSiteAdminEmails().stream().collect(Collectors.toUnmodifiableSet()); + + log.info("Done initializing Demo Okta repository."); } - String organizationExternalId = org.getExternalId(); - Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); - rolesToCreate.addAll(roles); - Set facilityUUIDs = - PermissionHolder.grantsAllFacilityAccess(rolesToCreate) - // create an empty set of facilities if user can access all facilities anyway - ? Set.of() - : facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()); - if (!orgFacilitiesMap.containsKey(organizationExternalId)) { - throw new IllegalGraphqlArgumentException( - "Cannot add Okta user to nonexistent organization=" + organizationExternalId); - } else if (!orgFacilitiesMap.get(organizationExternalId).containsAll(facilityUUIDs)) { - throw new IllegalGraphqlArgumentException( - "Cannot add Okta user to one or more nonexistent facilities in facilities_set=" - + facilities.stream().map(f -> f.getFacilityName()).collect(Collectors.toSet()) - + " in organization=" - + organizationExternalId); + public Optional createUser( + IdentityAttributes userIdentity, + Organization org, + Set facilities, + Set roles, + boolean active) { + if (allUsernames.contains(userIdentity.getUsername())) { + throw new ConflictingUserException(); + } + + String organizationExternalId = org.getExternalId(); + Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); + rolesToCreate.addAll(roles); + Set facilityUUIDs = + PermissionHolder.grantsAllFacilityAccess(rolesToCreate) + // create an empty set of facilities if user can access all facilities anyway + ? Set.of() + : facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()); + if (!orgFacilitiesMap.containsKey(organizationExternalId)) { + throw new IllegalGraphqlArgumentException( + "Cannot add Okta user to nonexistent organization=" + organizationExternalId); + } else if (!orgFacilitiesMap.get(organizationExternalId).containsAll(facilityUUIDs)) { + throw new IllegalGraphqlArgumentException( + "Cannot add Okta user to one or more nonexistent facilities in facilities_set=" + + facilities.stream().map(f -> f.getFacilityName()).collect(Collectors.toSet()) + + " in organization=" + + organizationExternalId); + } + + OrganizationRoleClaims orgRoles = + new OrganizationRoleClaims(organizationExternalId, facilityUUIDs, rolesToCreate); + usernameOrgRolesMap.put(userIdentity.getUsername(), orgRoles); + allUsernames.add(userIdentity.getUsername()); + + orgUsernamesMap.get(organizationExternalId).add(userIdentity.getUsername()); + + if (!active) { + inactiveUsernames.add(userIdentity.getUsername()); + } + + return Optional.of(orgRoles); } - OrganizationRoleClaims orgRoles = - new OrganizationRoleClaims(organizationExternalId, facilityUUIDs, rolesToCreate); - usernameOrgRolesMap.put(userIdentity.getUsername(), orgRoles); - allUsernames.add(userIdentity.getUsername()); + // this method currently doesn't do much in a demo envt + public Optional updateUser(IdentityAttributes userIdentity) { + if (!usernameOrgRolesMap.containsKey(userIdentity.getUsername())) { + throw new IllegalGraphqlArgumentException( + "Cannot change name of Okta user with unrecognized username"); + } + OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(userIdentity.getUsername()); + return Optional.of(orgRoles); + } - orgUsernamesMap.get(organizationExternalId).add(userIdentity.getUsername()); + public Optional updateUserEmail( + IdentityAttributes userIdentity, String newEmail) { + String currentEmail = userIdentity.getUsername(); + if (!usernameOrgRolesMap.containsKey(currentEmail)) { + throw new IllegalGraphqlArgumentException( + "Cannot change email of Okta user with unrecognized username"); + } + + if (usernameOrgRolesMap.containsKey(newEmail)) { + throw new ConflictingUserException(); + } + + String org = usernameOrgRolesMap.get(userIdentity.getUsername()).getOrganizationExternalId(); + orgUsernamesMap.get(org).remove(currentEmail); + orgUsernamesMap.get(org).add(newEmail); + usernameOrgRolesMap.put(newEmail, usernameOrgRolesMap.remove(userIdentity.getUsername())); + OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(newEmail); + return Optional.of(orgRoles); + } - if (!active) { - inactiveUsernames.add(userIdentity.getUsername()); + public void reprovisionUser(IdentityAttributes userIdentity) { + final String username = userIdentity.getUsername(); + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reprovision Okta user with unrecognized username"); + } + if (!inactiveUsernames.contains(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reprovision user in unsupported state: (not deleted)"); + } + + // Only re-enable the user. If name attributes and credentials were supported here, then + // the name should be updated and credentials reset. + inactiveUsernames.remove(userIdentity.getUsername()); } - return Optional.of(orgRoles); - } + public Optional updateUserPrivileges( + String username, Organization org, Set facilities, Set roles) { + String orgId = org.getExternalId(); + if (!orgUsernamesMap.containsKey(orgId)) { + throw new IllegalGraphqlArgumentException( + "Cannot update Okta user privileges for nonexistent organization."); + } + if (!orgUsernamesMap.get(orgId).contains(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot update Okta user privileges for organization they are not in."); + } + Set newRoles = EnumSet.of(OrganizationRole.getDefault()); + newRoles.addAll(roles); + Set facilityUUIDs = + facilities.stream() + // create an empty set of facilities if user can access all facilities anyway + .filter(f -> !PermissionHolder.grantsAllFacilityAccess(newRoles)) + .map(f -> f.getInternalId()) + .collect(Collectors.toSet()); + OrganizationRoleClaims newRoleClaims = + new OrganizationRoleClaims(orgId, facilityUUIDs, newRoles); + usernameOrgRolesMap.put(username, newRoleClaims); + + return Optional.of(newRoleClaims); + } - // this method currently doesn't do much in a demo envt - public Optional updateUser(IdentityAttributes userIdentity) { - if (!usernameOrgRolesMap.containsKey(userIdentity.getUsername())) { - throw new IllegalGraphqlArgumentException( - "Cannot change name of Okta user with unrecognized username"); + @Override + public List updateUserPrivilegesAndGroupAccess( + String username, + Organization org, + Set facilities, + OrganizationRole roles, + boolean allFacilitiesAccess) { + + String oldOrgId = usernameOrgRolesMap.get(username).getOrganizationExternalId(); + orgUsernamesMap.get(oldOrgId).remove(username); + orgUsernamesMap.get(org.getExternalId()).add(username); + OrganizationRoleClaims newRoleClaims = + new OrganizationRoleClaims( + org.getExternalId(), + facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()), + Set.of(roles, OrganizationRole.getDefault())); + + usernameOrgRolesMap.replace(username, newRoleClaims); + + // Live Okta repository returns list of Group names, but our demo repo didn't implement + // group mappings and it didn't feel worth it to add that implementation since the return is + // used mostly for testing. Return the list of facility ID's in the new org instead + return orgFacilitiesMap.get(org.getExternalId()).stream().map(UUID::toString).toList(); } - OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(userIdentity.getUsername()); - return Optional.of(orgRoles); - } - - public Optional updateUserEmail( - IdentityAttributes userIdentity, String newEmail) { - String currentEmail = userIdentity.getUsername(); - if (!usernameOrgRolesMap.containsKey(currentEmail)) { - throw new IllegalGraphqlArgumentException( - "Cannot change email of Okta user with unrecognized username"); + + public void resetUserPassword(String username) { + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reset password for Okta user with unrecognized username"); + } } - if (usernameOrgRolesMap.containsKey(newEmail)) { - throw new ConflictingUserException(); + public void resetUserMfa(String username) { + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reset MFA for Okta user with unrecognized username"); + } } - String org = usernameOrgRolesMap.get(userIdentity.getUsername()).getOrganizationExternalId(); - orgUsernamesMap.get(org).remove(currentEmail); - orgUsernamesMap.get(org).add(newEmail); - usernameOrgRolesMap.put(newEmail, usernameOrgRolesMap.remove(userIdentity.getUsername())); - OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(newEmail); - return Optional.of(orgRoles); - } - - public void reprovisionUser(IdentityAttributes userIdentity) { - final String username = userIdentity.getUsername(); - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reprovision Okta user with unrecognized username"); + public void setUserIsActive(String username, boolean active) { + if (active) { + inactiveUsernames.remove(username); + } else { + inactiveUsernames.add(username); + } } - if (!inactiveUsernames.contains(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reprovision user in unsupported state: (not deleted)"); + + public UserStatus getUserStatus(String username) { + if (inactiveUsernames.contains(username)) { + return UserStatus.SUSPENDED; + } else { + return UserStatus.ACTIVE; + } } - // Only re-enable the user. If name attributes and credentials were supported here, then - // the name should be updated and credentials reset. - inactiveUsernames.remove(userIdentity.getUsername()); - } - - public Optional updateUserPrivileges( - String username, Organization org, Set facilities, Set roles) { - String orgId = org.getExternalId(); - if (!orgUsernamesMap.containsKey(orgId)) { - throw new IllegalGraphqlArgumentException( - "Cannot update Okta user privileges for nonexistent organization."); + public void reactivateUser(String username) { + if (inactiveUsernames.contains(username)) { + inactiveUsernames.remove(username); + } } - if (!orgUsernamesMap.get(orgId).contains(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot update Okta user privileges for organization they are not in."); + + public void resendActivationEmail(String username) { + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reset password for Okta user with unrecognized username"); + } } - Set newRoles = EnumSet.of(OrganizationRole.getDefault()); - newRoles.addAll(roles); - Set facilityUUIDs = - facilities.stream() - // create an empty set of facilities if user can access all facilities anyway - .filter(f -> !PermissionHolder.grantsAllFacilityAccess(newRoles)) - .map(f -> f.getInternalId()) - .collect(Collectors.toSet()); - OrganizationRoleClaims newRoleClaims = - new OrganizationRoleClaims(orgId, facilityUUIDs, newRoles); - usernameOrgRolesMap.put(username, newRoleClaims); - - return Optional.of(newRoleClaims); - } - - @Override - public List updateUserPrivilegesAndGroupAccess( - String username, - Organization org, - Set facilities, - OrganizationRole roles, - boolean allFacilitiesAccess) { - - String oldOrgId = usernameOrgRolesMap.get(username).getOrganizationExternalId(); - orgUsernamesMap.get(oldOrgId).remove(username); - orgUsernamesMap.get(org.getExternalId()).add(username); - OrganizationRoleClaims newRoleClaims = - new OrganizationRoleClaims( - org.getExternalId(), - facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()), - Set.of(roles, OrganizationRole.getDefault())); - - usernameOrgRolesMap.replace(username, newRoleClaims); - - // Live Okta repository returns list of Group names, but our demo repo didn't implement - // group mappings and it didn't feel worth it to add that implementation since the return is - // used mostly for testing. Return the list of facility ID's in the new org instead - return orgFacilitiesMap.get(org.getExternalId()).stream().map(UUID::toString).toList(); - } - - public void resetUserPassword(String username) { - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reset password for Okta user with unrecognized username"); + + // returns ALL users including inactive ones + public Set getAllUsersForOrganization(Organization org) { + if (!orgUsernamesMap.containsKey(org.getExternalId())) { + throw new IllegalGraphqlArgumentException( + "Cannot get Okta users from nonexistent organization."); + } + return orgUsernamesMap.get(org.getExternalId()).stream() + .collect(Collectors.toUnmodifiableSet()); } - } - public void resetUserMfa(String username) { - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reset MFA for Okta user with unrecognized username"); + public Map getAllUsersWithStatusForOrganization(Organization org) { + if (!orgUsernamesMap.containsKey(org.getExternalId())) { + throw new IllegalGraphqlArgumentException( + "Cannot get Okta users from nonexistent organization."); + } + return orgUsernamesMap.get(org.getExternalId()).stream() + .collect(Collectors.toMap(u -> u, u -> getUserStatus(u))); } - } - public void setUserIsActive(String username, boolean active) { - if (active) { - inactiveUsernames.remove(username); - } else { - inactiveUsernames.add(username); + // this method doesn't mean much in a demo env + public void createOrganization(Organization org) { + String externalId = org.getExternalId(); + orgUsernamesMap.putIfAbsent(externalId, new HashSet<>()); + orgFacilitiesMap.putIfAbsent(externalId, new HashSet<>()); } - } - public UserStatus getUserStatus(String username) { - if (inactiveUsernames.contains(username)) { - return UserStatus.SUSPENDED; - } else { - return UserStatus.ACTIVE; + // this method means nothing in a demo env + public void activateOrganization(Organization org) { + inactiveUsernames.removeAll(orgUsernamesMap.get(org.getExternalId())); } - } - public void reactivateUser(String username) { - if (inactiveUsernames.contains(username)) { - inactiveUsernames.remove(username); + // this method means nothing in a demo env + public String activateOrganizationWithSingleUser(Organization org) { + activateOrganization(org); + return "activationToken"; } - } - public void resendActivationEmail(String username) { - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reset password for Okta user with unrecognized username"); + public List fetchAdminUserEmail(Organization org) { + Set> admins = + usernameOrgRolesMap.entrySet().stream() + .filter(e -> e.getValue().getGrantedRoles().contains(OrganizationRole.ADMIN)) + .collect(Collectors.toSet()); + return admins.stream().map(Entry::getKey).collect(Collectors.toList()); } - } - // returns ALL users including inactive ones - public Set getAllUsersForOrganization(Organization org) { - if (!orgUsernamesMap.containsKey(org.getExternalId())) { - throw new IllegalGraphqlArgumentException( - "Cannot get Okta users from nonexistent organization."); + public void createFacility(Facility facility) { + String orgExternalId = facility.getOrganization().getExternalId(); + if (!orgFacilitiesMap.containsKey(orgExternalId)) { + throw new IllegalGraphqlArgumentException( + "Cannot create Okta facility in nonexistent organization."); + } + orgFacilitiesMap.get(orgExternalId).add(facility.getInternalId()); } - return orgUsernamesMap.get(org.getExternalId()).stream() - .collect(Collectors.toUnmodifiableSet()); - } - - public Map getAllUsersWithStatusForOrganization(Organization org) { - if (!orgUsernamesMap.containsKey(org.getExternalId())) { - throw new IllegalGraphqlArgumentException( - "Cannot get Okta users from nonexistent organization."); + + public void deleteOrganization(Organization org) { + String externalId = org.getExternalId(); + orgUsernamesMap.remove(externalId); + orgFacilitiesMap.remove(externalId); + // remove all users from this map whose org roles are in the deleted org + usernameOrgRolesMap = + usernameOrgRolesMap.entrySet().stream() + .filter(e -> !(e.getValue().getOrganizationExternalId().equals(externalId))) + .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())); } - return orgUsernamesMap.get(org.getExternalId()).stream() - .collect(Collectors.toMap(u -> u, u -> getUserStatus(u))); - } - - // this method doesn't mean much in a demo env - public void createOrganization(Organization org) { - String externalId = org.getExternalId(); - orgUsernamesMap.putIfAbsent(externalId, new HashSet<>()); - orgFacilitiesMap.putIfAbsent(externalId, new HashSet<>()); - } - - // this method means nothing in a demo env - public void activateOrganization(Organization org) { - inactiveUsernames.removeAll(orgUsernamesMap.get(org.getExternalId())); - } - - // this method means nothing in a demo env - public String activateOrganizationWithSingleUser(Organization org) { - activateOrganization(org); - return "activationToken"; - } - - public List fetchAdminUserEmail(Organization org) { - Set> admins = - usernameOrgRolesMap.entrySet().stream() - .filter(e -> e.getValue().getGrantedRoles().contains(OrganizationRole.ADMIN)) - .collect(Collectors.toSet()); - return admins.stream().map(Entry::getKey).collect(Collectors.toList()); - } - - public void createFacility(Facility facility) { - String orgExternalId = facility.getOrganization().getExternalId(); - if (!orgFacilitiesMap.containsKey(orgExternalId)) { - throw new IllegalGraphqlArgumentException( - "Cannot create Okta facility in nonexistent organization."); + + public void deleteFacility(Facility facility) { + String orgExternalId = facility.getOrganization().getExternalId(); + if (!orgFacilitiesMap.containsKey(orgExternalId)) { + throw new IllegalGraphqlArgumentException( + "Cannot delete Okta facility from nonexistent organization."); + } + orgFacilitiesMap.get(orgExternalId).remove(facility.getInternalId()); + // remove this facility from every user's OrganizationRoleClaims, as necessary + usernameOrgRolesMap = + usernameOrgRolesMap.entrySet().stream() + .collect( + Collectors.toMap( + e -> e.getKey(), + e -> { + OrganizationRoleClaims oldRoleClaims = e.getValue(); + Set newFacilities = + oldRoleClaims.getFacilities().stream() + .filter(f -> !f.equals(facility.getInternalId())) + .collect(Collectors.toSet()); + return new OrganizationRoleClaims( + orgExternalId, newFacilities, oldRoleClaims.getGrantedRoles()); + })); } - orgFacilitiesMap.get(orgExternalId).add(facility.getInternalId()); - } - - public void deleteOrganization(Organization org) { - String externalId = org.getExternalId(); - orgUsernamesMap.remove(externalId); - orgFacilitiesMap.remove(externalId); - // remove all users from this map whose org roles are in the deleted org - usernameOrgRolesMap = - usernameOrgRolesMap.entrySet().stream() - .filter(e -> !(e.getValue().getOrganizationExternalId().equals(externalId))) - .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())); - } - - public void deleteFacility(Facility facility) { - String orgExternalId = facility.getOrganization().getExternalId(); - if (!orgFacilitiesMap.containsKey(orgExternalId)) { - throw new IllegalGraphqlArgumentException( - "Cannot delete Okta facility from nonexistent organization."); + + private Optional getOrganizationRoleClaimsFromTenantDataAccess( + Collection groupNames) { + List claims = organizationExtractor.convertClaims(groupNames); + + if (claims.size() != 1) { + log.warn("User is in {} Okta organizations, not 1", claims.size()); + return Optional.empty(); + } + return Optional.of(claims.get(0)); } - orgFacilitiesMap.get(orgExternalId).remove(facility.getInternalId()); - // remove this facility from every user's OrganizationRoleClaims, as necessary - usernameOrgRolesMap = - usernameOrgRolesMap.entrySet().stream() - .collect( - Collectors.toMap( - e -> e.getKey(), - e -> { - OrganizationRoleClaims oldRoleClaims = e.getValue(); - Set newFacilities = - oldRoleClaims.getFacilities().stream() - .filter(f -> !f.equals(facility.getInternalId())) - .collect(Collectors.toSet()); - return new OrganizationRoleClaims( - orgExternalId, newFacilities, oldRoleClaims.getGrantedRoles()); - })); - } - - private Optional getOrganizationRoleClaimsFromTenantDataAccess( - Collection groupNames) { - List claims = organizationExtractor.convertClaims(groupNames); - - if (claims.size() != 1) { - log.warn("User is in {} Okta organizations, not 1", claims.size()); - return Optional.empty(); + + public Optional getOrganizationRoleClaimsForUser(String username) { + // when accessing tenant data, bypass okta and get org from the altered authorities + try { + if (tenantDataContextHolder.hasBeenPopulated() + && username.equals(tenantDataContextHolder.getUsername())) { + return getOrganizationRoleClaimsFromTenantDataAccess( + tenantDataContextHolder.getAuthorities()); + } + return Optional.ofNullable(usernameOrgRolesMap.get(username)); + } catch (ScopeNotActiveException e) { + // Tests are set up with a full SecurityContextHolder and should not rely on + // usernameOrgRolesMap as the source of truth. + if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { + return Optional.of(usernameOrgRolesMap.get(username)); + } + Set authorities = + SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + return getOrganizationRoleClaimsFromTenantDataAccess(authorities); + } } - return Optional.of(claims.get(0)); - } - - public Optional getOrganizationRoleClaimsForUser(String username) { - // when accessing tenant data, bypass okta and get org from the altered authorities - try { - if (tenantDataContextHolder.hasBeenPopulated() - && username.equals(tenantDataContextHolder.getUsername())) { - return getOrganizationRoleClaimsFromTenantDataAccess( - tenantDataContextHolder.getAuthorities()); - } - return Optional.ofNullable(usernameOrgRolesMap.get(username)); - } catch (ScopeNotActiveException e) { - // Tests are set up with a full SecurityContextHolder and should not rely on - // usernameOrgRolesMap as the source of truth. - if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { - return Optional.of(usernameOrgRolesMap.get(username)); - } - Set authorities = - SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.toSet()); - return getOrganizationRoleClaimsFromTenantDataAccess(authorities); + + public PartialOktaUser findUser(String username) { + UserStatus status = + inactiveUsernames.contains(username) ? UserStatus.SUSPENDED : UserStatus.ACTIVE; + boolean isAdmin = adminGroupMemberSet.contains(username); + + Optional orgClaims; + + try { + orgClaims = Optional.ofNullable(usernameOrgRolesMap.get(username)); + } catch (ScopeNotActiveException e) { + // Tests are set up with a full SecurityContextHolder and should not rely on + // usernameOrgRolesMap as the source of truth. + if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { + orgClaims = Optional.of(usernameOrgRolesMap.get(username)); + } else { + Set authorities = + SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + orgClaims = getOrganizationRoleClaimsFromTenantDataAccess(authorities); + } + } + + return PartialOktaUser.builder() + .isSiteAdmin(isAdmin) + .status(status) + .username(username) + .organizationRoleClaims(orgClaims) + .build(); } - } - - public PartialOktaUser findUser(String username) { - UserStatus status = - inactiveUsernames.contains(username) ? UserStatus.SUSPENDED : UserStatus.ACTIVE; - boolean isAdmin = adminGroupMemberSet.contains(username); - - Optional orgClaims; - - try { - orgClaims = Optional.ofNullable(usernameOrgRolesMap.get(username)); - } catch (ScopeNotActiveException e) { - // Tests are set up with a full SecurityContextHolder and should not rely on - // usernameOrgRolesMap as the source of truth. - if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { - orgClaims = Optional.of(usernameOrgRolesMap.get(username)); - } else { - Set authorities = - SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.toSet()); - orgClaims = getOrganizationRoleClaimsFromTenantDataAccess(authorities); - } + + public void reset() { + usernameOrgRolesMap.clear(); + orgUsernamesMap.clear(); + orgFacilitiesMap.clear(); + inactiveUsernames.clear(); + allUsernames.clear(); } - return PartialOktaUser.builder() - .isSiteAdmin(isAdmin) - .status(status) - .username(username) - .organizationRoleClaims(orgClaims) - .build(); - } - - public void reset() { - usernameOrgRolesMap.clear(); - orgUsernamesMap.clear(); - orgFacilitiesMap.clear(); - inactiveUsernames.clear(); - allUsernames.clear(); - } - - public Integer getUsersInSingleFacility(Facility facility) { - Integer accessCount = 0; - - for (OrganizationRoleClaims existingClaims : usernameOrgRolesMap.values()) { - boolean hasAllFacilityAccess = - existingClaims.getGrantedRoles().stream() - .anyMatch(role -> OrganizationRole.ALL_FACILITIES.getName().equals(role.name())); - boolean hasSpecificFacilityAccess = - existingClaims.getFacilities().stream() - .anyMatch(facilityAccessId -> facility.getInternalId().equals(facilityAccessId)); - if (!hasAllFacilityAccess && hasSpecificFacilityAccess) { - accessCount++; - } + public Integer getUsersInSingleFacility(Facility facility) { + Integer accessCount = 0; + + for (OrganizationRoleClaims existingClaims : usernameOrgRolesMap.values()) { + boolean hasAllFacilityAccess = + existingClaims.getGrantedRoles().stream() + .anyMatch(role -> OrganizationRole.ALL_FACILITIES.getName().equals(role.name())); + boolean hasSpecificFacilityAccess = + existingClaims.getFacilities().stream() + .anyMatch(facilityAccessId -> facility.getInternalId().equals(facilityAccessId)); + if (!hasAllFacilityAccess && hasSpecificFacilityAccess) { + accessCount++; + } + } + + return accessCount; } - return accessCount; - } + public int getConnectTimeoutForHealthCheck() { + int FAKE_CONNECTION_TIMEOUT = 0; + return FAKE_CONNECTION_TIMEOUT; + } } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java index 7c61c63d3f..f1a5157353 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java @@ -5,6 +5,7 @@ import com.okta.sdk.resource.api.ApplicationGroupsApi; import com.okta.sdk.resource.api.GroupApi; import com.okta.sdk.resource.api.UserApi; +import com.okta.sdk.resource.client.ApiClient; import com.okta.sdk.resource.client.ApiException; import com.okta.sdk.resource.common.PagedList; import com.okta.sdk.resource.group.GroupBuilder; @@ -32,6 +33,7 @@ import gov.cdc.usds.simplereport.db.model.Facility; import gov.cdc.usds.simplereport.db.model.Organization; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; + import java.util.ArrayList; import java.util.Collection; import java.util.EnumSet; @@ -45,6 +47,7 @@ import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; + import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; @@ -61,733 +64,740 @@ @Slf4j public class LiveOktaRepository implements OktaRepository { - private static final String OKTA_GROUP_NOT_FOUND = "Okta group not found for this organization"; - - private final String rolePrefix; - private final Application app; - private final OrganizationExtractor extractor; - private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; - private final GroupApi groupApi; - private final UserApi userApi; - private final ApplicationGroupsApi applicationGroupsApi; - private final String adminGroupName; - - private static final String OKTA_ORG_PROFILE_MATCHER = "profile.name sw \""; - private static final int OKTA_PAGE_SIZE = 500; - - public LiveOktaRepository( - AuthorizationProperties authorizationProperties, - @Value("${okta.oauth2.client-id}") String oktaOAuth2ClientId, - OrganizationExtractor organizationExtractor, - CurrentTenantDataAccessContextHolder tenantDataContextHolder, - GroupApi groupApi, - ApplicationApi applicationApi, - UserApi userApi, - ApplicationGroupsApi applicationGroupsApi) { - this.rolePrefix = authorizationProperties.getRolePrefix(); - this.adminGroupName = authorizationProperties.getAdminGroupName(); - - this.groupApi = groupApi; - this.userApi = userApi; - this.applicationGroupsApi = applicationGroupsApi; - - try { - this.app = applicationApi.getApplication(oktaOAuth2ClientId, null); - } catch (ApiException e) { - throw new MisconfiguredApplicationException( - "Cannot find Okta application with id=" + oktaOAuth2ClientId, e); - } - - this.extractor = organizationExtractor; - this.tenantDataContextHolder = tenantDataContextHolder; - } - - @Override - public Optional createUser( - IdentityAttributes userIdentity, - Organization org, - Set facilities, - Set roles, - boolean active) { - // By default, when creating a user, we give them privileges of a standard user - String organizationExternalId = org.getExternalId(); - Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); - rolesToCreate.addAll(roles); - - // Add user to new groups - Set groupNamesToAdd = new HashSet<>(); - groupNamesToAdd.addAll( - rolesToCreate.stream() - .map(r -> generateRoleGroupName(organizationExternalId, r)) - .collect(Collectors.toSet())); - groupNamesToAdd.addAll( - facilities.stream() - // use an empty set of facilities if user can access all facilities anyway - .filter(f -> !PermissionHolder.grantsAllFacilityAccess(rolesToCreate)) - .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) - .collect(Collectors.toSet())); - - // Search and q results need to be combined because search results have a delay of the newest - // added groups. - // https://github.com/okta/okta-sdk-java/issues/750 - var searchResults = - groupApi - .listGroups( - null, - null, - null, - null, - null, - OKTA_ORG_PROFILE_MATCHER + generateGroupOrgPrefix(organizationExternalId) + "\"", - null, - null) - .stream(); - var qResults = - groupApi - .listGroups( - generateGroupOrgPrefix(organizationExternalId), - null, - null, - null, - null, - null, - null, - null) - .stream(); - var orgGroups = Stream.concat(searchResults, qResults).distinct().toList(); - throwErrorIfEmpty( - orgGroups.stream(), - String.format( - "Cannot add Okta user to nonexistent organization=%s", organizationExternalId)); - Set orgGroupNames = - orgGroups.stream().map(g -> g.getProfile().getName()).collect(Collectors.toSet()); - groupNamesToAdd.stream() - .filter(n -> !orgGroupNames.contains(n)) - .forEach( - n -> { - throw new IllegalGraphqlArgumentException( - String.format("Cannot add Okta user to nonexistent group=%s", n)); - }); - Set groupIdsToAdd = - orgGroups.stream() - .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) - .map(Group::getId) - .collect(Collectors.toSet()); - validateRequiredFields(userIdentity); - try { - var user = - UserBuilder.instance() - .setFirstName(userIdentity.getFirstName()) - .setMiddleName(userIdentity.getMiddleName()) - .setLastName(userIdentity.getLastName()) - .setHonorificSuffix(userIdentity.getSuffix()) - .setEmail(userIdentity.getUsername()) - .setLogin(userIdentity.getUsername()) - .setActive(active) - .buildAndCreate(userApi); - groupIdsToAdd.forEach(groupId -> groupApi.assignUserToGroup(groupId, user.getId())); - } catch (ApiException e) { - if (e.getMessage() - .contains("An object with this field already exists in the current organization")) { - throw new ConflictingUserException(); - } else { - throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); - } - } - - List claims = extractor.convertClaims(groupNamesToAdd); - if (claims.size() != 1) { - log.warn("User is in {} Okta organizations, not 1", claims.size()); - return Optional.empty(); - } - return Optional.of(claims.get(0)); - } - - private static void validateRequiredFields(IdentityAttributes userIdentity) { - if (StringUtils.isBlank(userIdentity.getLastName())) { - throw new IllegalGraphqlArgumentException("Cannot create Okta user without last name"); - } - if (StringUtils.isBlank(userIdentity.getUsername())) { - throw new IllegalGraphqlArgumentException("Cannot create Okta user without username"); - } - } - - @Override - public Set getAllUsersForOrganization(Organization org) { - return getAllUsersForOrg(org).stream() - .map(u -> u.getProfile().getLogin()) - .collect(Collectors.toUnmodifiableSet()); - } - - @Override - public Map getAllUsersWithStatusForOrganization(Organization org) { - return getAllUsersForOrg(org).stream() - .collect(Collectors.toMap(u -> u.getProfile().getLogin(), User::getStatus)); - } - - private List getAllUsersForOrg(Organization org) { - PagedList pagedUserList = new PagedList<>(); - List allUsers = new ArrayList<>(); - Group orgDefaultOktaGroup = getDefaultOktaGroup(org); - do { - pagedUserList = - (PagedList) - groupApi.listGroupUsers( - orgDefaultOktaGroup.getId(), pagedUserList.getAfter(), OKTA_PAGE_SIZE); - allUsers.addAll(pagedUserList); - } while (pagedUserList.hasMoreItems()); - return allUsers; - } - - private Group getDefaultOktaGroup(Organization org) { - final String orgDefaultGroupName = - generateRoleGroupName(org.getExternalId(), OrganizationRole.getDefault()); - final var oktaGroupList = - groupApi.listGroups(orgDefaultGroupName, null, null, null, null, null, null, null); - - return oktaGroupList.stream() - .filter(g -> orgDefaultGroupName.equals(g.getProfile().getName())) - .findFirst() - .orElseThrow(() -> new IllegalGraphqlArgumentException(OKTA_GROUP_NOT_FOUND)); - } - - @Override - public Optional updateUser(IdentityAttributes userIdentity) { - var user = - getUserOrThrowError( - userIdentity.getUsername(), "Cannot update Okta user with unrecognized username"); - updateUser(user, userIdentity); - - return getOrganizationRoleClaimsForUser(user); - } - - private void updateUser(User user, IdentityAttributes userIdentity) { - user.getProfile().setFirstName(userIdentity.getFirstName()); - user.getProfile().setMiddleName(userIdentity.getMiddleName()); - user.getProfile().setLastName(userIdentity.getLastName()); - // Is it our fault we don't accommodate honorific suffix? Or Okta's fault they - // don't have regular suffix? You decide. - user.getProfile().setHonorificSuffix(userIdentity.getSuffix()); - var updateRequest = new UpdateUserRequest(); - updateRequest.setProfile(user.getProfile()); - userApi.updateUser(user.getId(), updateRequest, false); - } - - @Override - public Optional updateUserEmail( - IdentityAttributes userIdentity, String email) { - var user = - getUserOrThrowError( - userIdentity.getUsername(), - "Cannot update email of Okta user with unrecognized username"); - UserProfile profile = user.getProfile(); - profile.setLogin(email); - profile.setEmail(email); - user.setProfile(profile); - var updateRequest = new UpdateUserRequest(); - updateRequest.setProfile(profile); - try { - userApi.updateUser(user.getId(), updateRequest, false); - } catch (ApiException e) { - if (e.getMessage() - .contains("An object with this field already exists in the current organization")) { - throw new ConflictingUserException(); - } else { - throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); - } - } - - return getOrganizationRoleClaimsForUser(user); - } - - @Override - public void reprovisionUser(IdentityAttributes userIdentity) { - var user = - getUserOrThrowError( - userIdentity.getUsername(), "Cannot reprovision Okta user with unrecognized username"); - UserStatus userStatus = user.getStatus(); - - // any org user "deleted" through our api will be in SUSPENDED state - if (userStatus != UserStatus.SUSPENDED) { - throw new ConflictingUserException(); - } - - updateUser(user, userIdentity); - userApi.resetFactors(user.getId()); - - // transitioning from SUSPENDED -> DEPROVISIONED -> ACTIVE will reset the user's password and - // password reset question. This cannot be done with `.reactivateUser()` because it requires the - // user to be in PROVISIONED state - userApi.deactivateUser(user.getId(), false); - userApi.activateUser(user.getId(), true); - } - - @Override - public List updateUserPrivilegesAndGroupAccess( - String username, - Organization org, - Set facilities, - OrganizationRole role, - boolean assignedToAllFacilities) { - - // unassign user from current groups - - User oktaUserToMove = getUserOrThrowError(username, "Couldn't find user"); - List groupsToUnassign = userApi.listUserGroups(oktaUserToMove.getId()); - - groupsToUnassign.stream() - // only match on the org-related group ids and not the Okta-wide orgs like "Everyone" - .filter(g -> g.getProfile().getName().contains("TENANT")) - .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), oktaUserToMove.getId())); - - // add them to the new groups - String organizationExternalId = org.getExternalId(); - EnumSet rolesToCreate = - assignedToAllFacilities - ? EnumSet.of(OrganizationRole.getDefault(), role, OrganizationRole.ALL_FACILITIES) - : EnumSet.of(OrganizationRole.getDefault(), role); - - Set groupNamesToAdd = new HashSet<>(); - groupNamesToAdd.addAll( - rolesToCreate.stream() - .map(r -> generateRoleGroupName(organizationExternalId, r)) - .collect(Collectors.toSet())); - - groupNamesToAdd.addAll( - facilities.stream() - .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) - .collect(Collectors.toSet())); - - String groupOrgPrefix = generateGroupOrgPrefix(org.getExternalId()); - Map orgsToAddUserToMap = - groupApi - .listGroups( - null, - null, - null, - null, - null, - OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", - null, - null) - .stream() - .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) - .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); - - orgsToAddUserToMap.forEach( - (name, group) -> groupApi.assignUserToGroup(group.getId(), oktaUserToMove.getId())); - return orgsToAddUserToMap.keySet().stream().toList(); - } - - @Override - public Optional updateUserPrivileges( - String username, Organization org, Set facilities, Set roles) { - User user = - getUserOrThrowError(username, "Cannot update role of Okta user with unrecognized username"); - - String orgId = org.getExternalId(); - - final String groupOrgPrefix = generateGroupOrgPrefix(orgId); - final String groupOrgDefaultName = generateRoleGroupName(orgId, OrganizationRole.getDefault()); - - // Map user's current Okta group memberships (Okta group name -> Okta Group). - // The Okta group name is our friendly role and facility group names - Map currentOrgGroupMapForUser = - userApi.listUserGroups(user.getId()).stream() - .filter( - g -> - GroupType.OKTA_GROUP == g.getType() - && g.getProfile().getName().startsWith(groupOrgPrefix)) - .collect(Collectors.toMap(g -> g.getProfile().getName(), g -> g)); - - if (!currentOrgGroupMapForUser.containsKey(groupOrgDefaultName)) { - // The user is not a member of the default group for this organization. If they happen - // to be in any of this organization's groups, remove the user from those groups. - currentOrgGroupMapForUser - .values() - .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), user.getId())); - throw new IllegalGraphqlArgumentException( - "Cannot update privileges of Okta user in organization they do not belong to."); - } - - Set expectedOrgGroupNamesForUser = new HashSet<>(); - expectedOrgGroupNamesForUser.add(groupOrgDefaultName); - expectedOrgGroupNamesForUser.addAll( - roles.stream().map(r -> generateRoleGroupName(orgId, r)).collect(Collectors.toSet())); - if (!PermissionHolder.grantsAllFacilityAccess(roles)) { - expectedOrgGroupNamesForUser.addAll( - facilities.stream() - .map(f -> generateFacilityGroupName(orgId, f.getInternalId())) - .collect(Collectors.toSet())); - } - - // to remove... - Set groupNamesToRemove = new HashSet<>(currentOrgGroupMapForUser.keySet()); - groupNamesToRemove.removeIf(expectedOrgGroupNamesForUser::contains); - - // to add... - Set groupNamesToAdd = new HashSet<>(expectedOrgGroupNamesForUser); - groupNamesToAdd.removeIf(currentOrgGroupMapForUser::containsKey); - - if (!groupNamesToRemove.isEmpty() || !groupNamesToAdd.isEmpty()) { - Map fullOrgGroupMap = - groupApi - .listGroups( - null, - null, - null, - null, - null, - OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", - null, - null) - .stream() - .filter(g -> GroupType.OKTA_GROUP == g.getType()) - .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); - if (fullOrgGroupMap.size() == 0) { - throw new IllegalGraphqlArgumentException( - String.format("Cannot add Okta user to nonexistent organization=%s", orgId)); - } - - for (String groupName : groupNamesToRemove) { - Group group = fullOrgGroupMap.get(groupName); - log.info("Removing {} from Okta group: {}", username, group.getProfile().getName()); - groupApi.unassignUserFromGroup(group.getId(), user.getId()); - } - - for (String groupName : groupNamesToAdd) { - if (!fullOrgGroupMap.containsKey(groupName)) { - throw new IllegalGraphqlArgumentException( - String.format("Cannot add Okta user to nonexistent group=%s", groupName)); + private static final String OKTA_GROUP_NOT_FOUND = "Okta group not found for this organization"; + + private final String rolePrefix; + private final Application app; + private final OrganizationExtractor extractor; + private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; + + private final ApplicationApi applicationApi; + private final GroupApi groupApi; + private final UserApi userApi; + private final ApplicationGroupsApi applicationGroupsApi; + private final String adminGroupName; + + private static final String OKTA_ORG_PROFILE_MATCHER = "profile.name sw \""; + private static final int OKTA_PAGE_SIZE = 500; + + public LiveOktaRepository( + AuthorizationProperties authorizationProperties, + @Value("${okta.oauth2.client-id}") String oktaOAuth2ClientId, + OrganizationExtractor organizationExtractor, + CurrentTenantDataAccessContextHolder tenantDataContextHolder, + GroupApi groupApi, + ApplicationApi applicationApi, + UserApi userApi, + ApplicationGroupsApi applicationGroupsApi) { + this.rolePrefix = authorizationProperties.getRolePrefix(); + this.adminGroupName = authorizationProperties.getAdminGroupName(); + + this.applicationApi = applicationApi; + this.groupApi = groupApi; + this.userApi = userApi; + this.applicationGroupsApi = applicationGroupsApi; + + try { + this.app = applicationApi.getApplication(oktaOAuth2ClientId, null); + } catch (ApiException e) { + throw new MisconfiguredApplicationException( + "Cannot find Okta application with id=" + oktaOAuth2ClientId, e); + } + + this.extractor = organizationExtractor; + this.tenantDataContextHolder = tenantDataContextHolder; + } + + @Override + public Optional createUser( + IdentityAttributes userIdentity, + Organization org, + Set facilities, + Set roles, + boolean active) { + // By default, when creating a user, we give them privileges of a standard user + String organizationExternalId = org.getExternalId(); + Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); + rolesToCreate.addAll(roles); + + // Add user to new groups + Set groupNamesToAdd = new HashSet<>(); + groupNamesToAdd.addAll( + rolesToCreate.stream() + .map(r -> generateRoleGroupName(organizationExternalId, r)) + .collect(Collectors.toSet())); + groupNamesToAdd.addAll( + facilities.stream() + // use an empty set of facilities if user can access all facilities anyway + .filter(f -> !PermissionHolder.grantsAllFacilityAccess(rolesToCreate)) + .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) + .collect(Collectors.toSet())); + + // Search and q results need to be combined because search results have a delay of the newest + // added groups. + // https://github.com/okta/okta-sdk-java/issues/750 + var searchResults = + groupApi + .listGroups( + null, + null, + null, + null, + null, + OKTA_ORG_PROFILE_MATCHER + generateGroupOrgPrefix(organizationExternalId) + "\"", + null, + null) + .stream(); + var qResults = + groupApi + .listGroups( + generateGroupOrgPrefix(organizationExternalId), + null, + null, + null, + null, + null, + null, + null) + .stream(); + var orgGroups = Stream.concat(searchResults, qResults).distinct().toList(); + throwErrorIfEmpty( + orgGroups.stream(), + String.format( + "Cannot add Okta user to nonexistent organization=%s", organizationExternalId)); + Set orgGroupNames = + orgGroups.stream().map(g -> g.getProfile().getName()).collect(Collectors.toSet()); + groupNamesToAdd.stream() + .filter(n -> !orgGroupNames.contains(n)) + .forEach( + n -> { + throw new IllegalGraphqlArgumentException( + String.format("Cannot add Okta user to nonexistent group=%s", n)); + }); + Set groupIdsToAdd = + orgGroups.stream() + .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) + .map(Group::getId) + .collect(Collectors.toSet()); + validateRequiredFields(userIdentity); + try { + var user = + UserBuilder.instance() + .setFirstName(userIdentity.getFirstName()) + .setMiddleName(userIdentity.getMiddleName()) + .setLastName(userIdentity.getLastName()) + .setHonorificSuffix(userIdentity.getSuffix()) + .setEmail(userIdentity.getUsername()) + .setLogin(userIdentity.getUsername()) + .setActive(active) + .buildAndCreate(userApi); + groupIdsToAdd.forEach(groupId -> groupApi.assignUserToGroup(groupId, user.getId())); + } catch (ApiException e) { + if (e.getMessage() + .contains("An object with this field already exists in the current organization")) { + throw new ConflictingUserException(); + } else { + throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); + } + } + + List claims = extractor.convertClaims(groupNamesToAdd); + if (claims.size() != 1) { + log.warn("User is in {} Okta organizations, not 1", claims.size()); + return Optional.empty(); + } + return Optional.of(claims.get(0)); + } + + private static void validateRequiredFields(IdentityAttributes userIdentity) { + if (StringUtils.isBlank(userIdentity.getLastName())) { + throw new IllegalGraphqlArgumentException("Cannot create Okta user without last name"); + } + if (StringUtils.isBlank(userIdentity.getUsername())) { + throw new IllegalGraphqlArgumentException("Cannot create Okta user without username"); + } + } + + @Override + public Set getAllUsersForOrganization(Organization org) { + return getAllUsersForOrg(org).stream() + .map(u -> u.getProfile().getLogin()) + .collect(Collectors.toUnmodifiableSet()); + } + + @Override + public Map getAllUsersWithStatusForOrganization(Organization org) { + return getAllUsersForOrg(org).stream() + .collect(Collectors.toMap(u -> u.getProfile().getLogin(), User::getStatus)); + } + + private List getAllUsersForOrg(Organization org) { + PagedList pagedUserList = new PagedList<>(); + List allUsers = new ArrayList<>(); + Group orgDefaultOktaGroup = getDefaultOktaGroup(org); + do { + pagedUserList = + (PagedList) + groupApi.listGroupUsers( + orgDefaultOktaGroup.getId(), pagedUserList.getAfter(), OKTA_PAGE_SIZE); + allUsers.addAll(pagedUserList); + } while (pagedUserList.hasMoreItems()); + return allUsers; + } + + private Group getDefaultOktaGroup(Organization org) { + final String orgDefaultGroupName = + generateRoleGroupName(org.getExternalId(), OrganizationRole.getDefault()); + final var oktaGroupList = + groupApi.listGroups(orgDefaultGroupName, null, null, null, null, null, null, null); + + return oktaGroupList.stream() + .filter(g -> orgDefaultGroupName.equals(g.getProfile().getName())) + .findFirst() + .orElseThrow(() -> new IllegalGraphqlArgumentException(OKTA_GROUP_NOT_FOUND)); + } + + @Override + public Optional updateUser(IdentityAttributes userIdentity) { + var user = + getUserOrThrowError( + userIdentity.getUsername(), "Cannot update Okta user with unrecognized username"); + updateUser(user, userIdentity); + + return getOrganizationRoleClaimsForUser(user); + } + + private void updateUser(User user, IdentityAttributes userIdentity) { + user.getProfile().setFirstName(userIdentity.getFirstName()); + user.getProfile().setMiddleName(userIdentity.getMiddleName()); + user.getProfile().setLastName(userIdentity.getLastName()); + // Is it our fault we don't accommodate honorific suffix? Or Okta's fault they + // don't have regular suffix? You decide. + user.getProfile().setHonorificSuffix(userIdentity.getSuffix()); + var updateRequest = new UpdateUserRequest(); + updateRequest.setProfile(user.getProfile()); + userApi.updateUser(user.getId(), updateRequest, false); + } + + @Override + public Optional updateUserEmail( + IdentityAttributes userIdentity, String email) { + var user = + getUserOrThrowError( + userIdentity.getUsername(), + "Cannot update email of Okta user with unrecognized username"); + UserProfile profile = user.getProfile(); + profile.setLogin(email); + profile.setEmail(email); + user.setProfile(profile); + var updateRequest = new UpdateUserRequest(); + updateRequest.setProfile(profile); + try { + userApi.updateUser(user.getId(), updateRequest, false); + } catch (ApiException e) { + if (e.getMessage() + .contains("An object with this field already exists in the current organization")) { + throw new ConflictingUserException(); + } else { + throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); + } + } + + return getOrganizationRoleClaimsForUser(user); + } + + @Override + public void reprovisionUser(IdentityAttributes userIdentity) { + var user = + getUserOrThrowError( + userIdentity.getUsername(), "Cannot reprovision Okta user with unrecognized username"); + UserStatus userStatus = user.getStatus(); + + // any org user "deleted" through our api will be in SUSPENDED state + if (userStatus != UserStatus.SUSPENDED) { + throw new ConflictingUserException(); } - Group group = fullOrgGroupMap.get(groupName); - log.info("Adding {} to Okta group: {}", username, group.getProfile().getName()); - groupApi.assignUserToGroup(group.getId(), user.getId()); - } - } - - return getOrganizationRoleClaimsForUser(user); - } - - @Override - public void resetUserPassword(String username) { - var user = - getUserOrThrowError( - username, "Cannot reset password for Okta user with unrecognized username"); - userApi.generateResetPasswordToken(user.getId(), true, false); - } - - @Override - public void resetUserMfa(String username) { - var user = - getUserOrThrowError(username, "Cannot reset MFA for Okta user with unrecognized username"); - userApi.resetFactors(user.getId()); - } - - @Override - public void setUserIsActive(String username, boolean active) { - var user = - getUserOrThrowError( - username, "Cannot update active status of Okta user with unrecognized username"); - - if (active && user.getStatus() == UserStatus.SUSPENDED) { - userApi.unsuspendUser(user.getId()); - } else if (!active && user.getStatus() != UserStatus.SUSPENDED) { - userApi.suspendUser(user.getId()); - } - } - - @Override - public UserStatus getUserStatus(String username) { - return getUserOrThrowError( - username, "Cannot retrieve Okta user's status with unrecognized username") - .getStatus(); - } - - @Override - public void reactivateUser(String username) { - var user = - getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); - userApi.unsuspendUser(user.getId()); - } - - @Override - public void resendActivationEmail(String username) { - var user = - getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); - if (user.getStatus() == UserStatus.PROVISIONED) { - userApi.reactivateUser(user.getId(), true); - } else if (user.getStatus() == UserStatus.STAGED) { - userApi.activateUser(user.getId(), true); - } else { - throw new IllegalGraphqlArgumentException( - "Cannot reactivate user with status: " + user.getStatus()); - } - } - - /** - * Iterates over all OrganizationRole's, creating new corresponding Okta groups for this - * organization where they do not already exist. For those OrganizationRole's that are in - * MIGRATION_DEST_ROLES and whose Okta groups are newly created, migrate all users from this org - * to those new Okta groups, where the migrated users are sourced from all pre-existing Okta - * groups for this organization. Separately, iterates over all facilities in this org, creating - * new corresponding Okta groups where they do not already exist. Does not perform any migration - * to these facility groups. - */ - @Override - public void createOrganization(Organization org) { - String name = org.getOrganizationName(); - String externalId = org.getExternalId(); - - for (OrganizationRole role : OrganizationRole.values()) { - String roleGroupName = generateRoleGroupName(externalId, role); - String roleGroupDescription = generateRoleGroupDescription(name, role); - Group g = - GroupBuilder.instance() - .setName(roleGroupName) - .setDescription(roleGroupDescription) - .buildAndCreate(groupApi); - applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); - - log.info("Created Okta group={}", roleGroupName); - } - } - - private List getOrgAdminUsers(Organization org) { - String externalId = org.getExternalId(); - String roleGroupName = generateRoleGroupName(externalId, OrganizationRole.ADMIN); - var groups = groupApi.listGroups(roleGroupName, null, null, null, null, null, null, null); - throwErrorIfEmpty(groups.stream(), "Cannot activate nonexistent Okta organization"); - Group group = groups.get(0); - return groupApi.listGroupUsers(group.getId(), null, null); - } - - private String activateUser(User user) { - if (user.getStatus() == UserStatus.PROVISIONED) { - // reactivates user and sends them an Okta email to reactivate their account - return userApi.reactivateUser(user.getId(), true).getActivationToken(); - } else if (user.getStatus() == UserStatus.STAGED) { - return userApi.activateUser(user.getId(), true).getActivationToken(); - } else { - throw new IllegalGraphqlArgumentException( - "Cannot activate Okta organization whose users have status=" + user.getStatus().name()); - } - } - - @Override - public void activateOrganization(Organization org) { - var users = getOrgAdminUsers(org); - for (User u : users) { - activateUser(u); - } - } - - @Override - public String activateOrganizationWithSingleUser(Organization org) { - User user = getOrgAdminUsers(org).get(0); - return activateUser(user); - } - - @Override - public List fetchAdminUserEmail(Organization org) { - var admins = getOrgAdminUsers(org); - return admins.stream().map(u -> u.getProfile().getLogin()).toList(); - } - - @Override - public void createFacility(Facility facility) { - // Only create the facility group if the facility's organization has already been created - String orgExternalId = facility.getOrganization().getExternalId(); - var orgGroups = - groupApi.listGroups( - generateGroupOrgPrefix(orgExternalId), null, null, null, null, null, null, null); - throwErrorIfEmpty( - orgGroups.stream(), - String.format( - "Cannot create Okta group for facility=%s: facility's org=%s, has not yet been created in Okta", - facility.getFacilityName(), facility.getOrganization().getExternalId())); - - String orgName = facility.getOrganization().getOrganizationName(); - String facilityGroupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); - Group g = - GroupBuilder.instance() - .setName(facilityGroupName) - .setDescription(generateFacilityGroupDescription(orgName, facility.getFacilityName())) - .buildAndCreate(groupApi); - applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); - - log.info("Created Okta group={}", facilityGroupName); - } - - public void deleteFacility(Facility facility) { - String orgExternalId = facility.getOrganization().getExternalId(); - String groupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); - var groups = groupApi.listGroups(groupName, null, null, null, null, null, null, null); - for (Group group : groups) { - groupApi.deleteGroup(group.getId()); - } - } - - @Override - public void deleteOrganization(Organization org) { - String externalId = org.getExternalId(); - var orgGroups = - groupApi.listGroups( - generateGroupOrgPrefix(externalId), null, null, null, null, null, null, null); - for (Group group : orgGroups) { - groupApi.deleteGroup(group.getId()); - } - } - - // returns the external ID of the organization the specified user belongs to - @Override - public Optional getOrganizationRoleClaimsForUser(String username) { - // When a site admin is using tenant data access, bypass okta and get org from the altered - // authorities. If the site admin is getting the claims for another site admin who also has - // active tenant data access, then reflect what is in Okta, not the temporary claims. - if (tenantDataContextHolder.hasBeenPopulated() - && username.equals(tenantDataContextHolder.getUsername())) { - return getOrganizationRoleClaimsFromAuthorities(tenantDataContextHolder.getAuthorities()); - } - - return getOrganizationRoleClaimsForUser( - getUserOrThrowError(username, "Cannot get org external ID for nonexistent user")); - } - - public Integer getUsersInSingleFacility(Facility facility) { - String facilityAccessGroupName = - generateFacilityGroupName( - facility.getOrganization().getExternalId(), facility.getInternalId()); - - List facilityAccessGroup = - groupApi.listGroups(facilityAccessGroupName, null, null, 1, "stats", null, null, null); - - if (facilityAccessGroup.isEmpty()) { - return 0; - } - - try { - LinkedHashMap stats = - (LinkedHashMap) facilityAccessGroup.get(0).getEmbedded().get("stats"); - return ((Integer) stats.get("usersCount")); - } catch (NullPointerException e) { - throw new BadRequestException("Unable to retrieve okta group stats", e); - } - } - - public PartialOktaUser findUser(String username) { - User user = - getUserOrThrowError( - username, "Cannot retrieve Okta user's status with unrecognized username"); - - List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); - - Optional orgClaims = convertToOrganizationRoleClaims(userGroups); - - return PartialOktaUser.builder() - .username(username) - .isSiteAdmin(isSiteAdmin(userGroups)) - .status(user.getStatus()) - .organizationRoleClaims(orgClaims) - .build(); - } - - private Optional getOrganizationRoleClaimsFromAuthorities( - Collection authorities) { - List claims = extractor.convertClaims(authorities); - - if (claims.size() != 1) { - log.warn("User's Tenant Data Access has claims in {} organizations, not 1", claims.size()); - return Optional.empty(); - } - return Optional.of(claims.get(0)); - } - - private Optional getOrganizationRoleClaimsForUser(User user) { - List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); - return convertToOrganizationRoleClaims(userGroups); - } - - private Optional convertToOrganizationRoleClaims(List userGroups) { - List groupNames = - userGroups.stream() - .filter(g -> g.getType() == GroupType.OKTA_GROUP) - .map(g -> g.getProfile().getName()) - .toList(); - List claims = extractor.convertClaims(groupNames); - - if (claims.size() != 1) { - log.warn("User is in {} Okta organizations, not 1", claims.size()); - return Optional.empty(); - } - return Optional.of(claims.get(0)); - } - - private boolean isSiteAdmin(List oktaGroups) { - return oktaGroups.stream() - .filter(g -> g.getType() == GroupType.OKTA_GROUP) - .anyMatch(g -> adminGroupName.equals(g.getProfile().getName())); - } - - private String generateGroupOrgPrefix(String orgExternalId) { - return String.format("%s%s", rolePrefix, orgExternalId); - } - - private String generateRoleGroupName(String orgExternalId, OrganizationRole role) { - return String.format("%s%s%s", rolePrefix, orgExternalId, generateRoleSuffix(role)); - } - - private String generateFacilityGroupName(String orgExternalId, UUID facilityId) { - return String.format( - "%s%s%s", rolePrefix, orgExternalId, generateFacilitySuffix(facilityId.toString())); - } - - private String generateRoleGroupDescription(String orgName, OrganizationRole role) { - return String.format("%s - %ss", orgName, role.getDescription()); - } - - private String generateFacilityGroupDescription(String orgName, String facilityName) { - return String.format("%s - Facility Access - %s", orgName, facilityName); - } - - private String generateRoleSuffix(OrganizationRole role) { - return ":" + role.name(); - } - - private String generateFacilitySuffix(String facilityId) { - return ":" + OrganizationExtractor.FACILITY_ACCESS_MARKER + ":" + facilityId; - } - - private User getUserOrThrowError(String email, String errorMessage) { - try { - return userApi.getUser(email); - } catch (ApiException e) { - throw new IllegalGraphqlArgumentException(errorMessage); - } - } - - private void throwErrorIfEmpty(Stream stream, String errorMessage) { - if (stream.findAny().isEmpty()) { - throw new IllegalGraphqlArgumentException(errorMessage); - } - } - - private String prettifyOktaError(ApiException e) { - var errorMessage = "Code: " + e.getCode() + "; Message: " + e.getMessage(); - if (e.getResponseBody() != null) { - Error error = ApiExceptionHelper.getError(e); - if (error != null) { - errorMessage = - "Okta Error: " + error.getErrorCode() + ", Error summary: " + error.getErrorSummary(); - if (error.getErrorCauses() != null) { - errorMessage += - ", Error Cause(s): " - + error.getErrorCauses().stream() - .map(ErrorErrorCausesInner::getErrorSummary) - .collect(Collectors.joining(", ")); + + updateUser(user, userIdentity); + userApi.resetFactors(user.getId()); + + // transitioning from SUSPENDED -> DEPROVISIONED -> ACTIVE will reset the user's password and + // password reset question. This cannot be done with `.reactivateUser()` because it requires the + // user to be in PROVISIONED state + userApi.deactivateUser(user.getId(), false); + userApi.activateUser(user.getId(), true); + } + + @Override + public List updateUserPrivilegesAndGroupAccess( + String username, + Organization org, + Set facilities, + OrganizationRole role, + boolean assignedToAllFacilities) { + + // unassign user from current groups + + User oktaUserToMove = getUserOrThrowError(username, "Couldn't find user"); + List groupsToUnassign = userApi.listUserGroups(oktaUserToMove.getId()); + + groupsToUnassign.stream() + // only match on the org-related group ids and not the Okta-wide orgs like "Everyone" + .filter(g -> g.getProfile().getName().contains("TENANT")) + .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), oktaUserToMove.getId())); + + // add them to the new groups + String organizationExternalId = org.getExternalId(); + EnumSet rolesToCreate = + assignedToAllFacilities + ? EnumSet.of(OrganizationRole.getDefault(), role, OrganizationRole.ALL_FACILITIES) + : EnumSet.of(OrganizationRole.getDefault(), role); + + Set groupNamesToAdd = new HashSet<>(); + groupNamesToAdd.addAll( + rolesToCreate.stream() + .map(r -> generateRoleGroupName(organizationExternalId, r)) + .collect(Collectors.toSet())); + + groupNamesToAdd.addAll( + facilities.stream() + .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) + .collect(Collectors.toSet())); + + String groupOrgPrefix = generateGroupOrgPrefix(org.getExternalId()); + Map orgsToAddUserToMap = + groupApi + .listGroups( + null, + null, + null, + null, + null, + OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", + null, + null) + .stream() + .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) + .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); + + orgsToAddUserToMap.forEach( + (name, group) -> groupApi.assignUserToGroup(group.getId(), oktaUserToMove.getId())); + return orgsToAddUserToMap.keySet().stream().toList(); + } + + @Override + public Optional updateUserPrivileges( + String username, Organization org, Set facilities, Set roles) { + User user = + getUserOrThrowError(username, "Cannot update role of Okta user with unrecognized username"); + + String orgId = org.getExternalId(); + + final String groupOrgPrefix = generateGroupOrgPrefix(orgId); + final String groupOrgDefaultName = generateRoleGroupName(orgId, OrganizationRole.getDefault()); + + // Map user's current Okta group memberships (Okta group name -> Okta Group). + // The Okta group name is our friendly role and facility group names + Map currentOrgGroupMapForUser = + userApi.listUserGroups(user.getId()).stream() + .filter( + g -> + GroupType.OKTA_GROUP == g.getType() + && g.getProfile().getName().startsWith(groupOrgPrefix)) + .collect(Collectors.toMap(g -> g.getProfile().getName(), g -> g)); + + if (!currentOrgGroupMapForUser.containsKey(groupOrgDefaultName)) { + // The user is not a member of the default group for this organization. If they happen + // to be in any of this organization's groups, remove the user from those groups. + currentOrgGroupMapForUser + .values() + .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), user.getId())); + throw new IllegalGraphqlArgumentException( + "Cannot update privileges of Okta user in organization they do not belong to."); + } + + Set expectedOrgGroupNamesForUser = new HashSet<>(); + expectedOrgGroupNamesForUser.add(groupOrgDefaultName); + expectedOrgGroupNamesForUser.addAll( + roles.stream().map(r -> generateRoleGroupName(orgId, r)).collect(Collectors.toSet())); + if (!PermissionHolder.grantsAllFacilityAccess(roles)) { + expectedOrgGroupNamesForUser.addAll( + facilities.stream() + .map(f -> generateFacilityGroupName(orgId, f.getInternalId())) + .collect(Collectors.toSet())); + } + + // to remove... + Set groupNamesToRemove = new HashSet<>(currentOrgGroupMapForUser.keySet()); + groupNamesToRemove.removeIf(expectedOrgGroupNamesForUser::contains); + + // to add... + Set groupNamesToAdd = new HashSet<>(expectedOrgGroupNamesForUser); + groupNamesToAdd.removeIf(currentOrgGroupMapForUser::containsKey); + + if (!groupNamesToRemove.isEmpty() || !groupNamesToAdd.isEmpty()) { + Map fullOrgGroupMap = + groupApi + .listGroups( + null, + null, + null, + null, + null, + OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", + null, + null) + .stream() + .filter(g -> GroupType.OKTA_GROUP == g.getType()) + .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); + if (fullOrgGroupMap.size() == 0) { + throw new IllegalGraphqlArgumentException( + String.format("Cannot add Okta user to nonexistent organization=%s", orgId)); + } + + for (String groupName : groupNamesToRemove) { + Group group = fullOrgGroupMap.get(groupName); + log.info("Removing {} from Okta group: {}", username, group.getProfile().getName()); + groupApi.unassignUserFromGroup(group.getId(), user.getId()); + } + + for (String groupName : groupNamesToAdd) { + if (!fullOrgGroupMap.containsKey(groupName)) { + throw new IllegalGraphqlArgumentException( + String.format("Cannot add Okta user to nonexistent group=%s", groupName)); + } + Group group = fullOrgGroupMap.get(groupName); + log.info("Adding {} to Okta group: {}", username, group.getProfile().getName()); + groupApi.assignUserToGroup(group.getId(), user.getId()); + } + } + + return getOrganizationRoleClaimsForUser(user); + } + + @Override + public void resetUserPassword(String username) { + var user = + getUserOrThrowError( + username, "Cannot reset password for Okta user with unrecognized username"); + userApi.generateResetPasswordToken(user.getId(), true, false); + } + + @Override + public void resetUserMfa(String username) { + var user = + getUserOrThrowError(username, "Cannot reset MFA for Okta user with unrecognized username"); + userApi.resetFactors(user.getId()); + } + + @Override + public void setUserIsActive(String username, boolean active) { + var user = + getUserOrThrowError( + username, "Cannot update active status of Okta user with unrecognized username"); + + if (active && user.getStatus() == UserStatus.SUSPENDED) { + userApi.unsuspendUser(user.getId()); + } else if (!active && user.getStatus() != UserStatus.SUSPENDED) { + userApi.suspendUser(user.getId()); + } + } + + @Override + public UserStatus getUserStatus(String username) { + return getUserOrThrowError( + username, "Cannot retrieve Okta user's status with unrecognized username") + .getStatus(); + } + + @Override + public void reactivateUser(String username) { + var user = + getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); + userApi.unsuspendUser(user.getId()); + } + + @Override + public void resendActivationEmail(String username) { + var user = + getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); + if (user.getStatus() == UserStatus.PROVISIONED) { + userApi.reactivateUser(user.getId(), true); + } else if (user.getStatus() == UserStatus.STAGED) { + userApi.activateUser(user.getId(), true); + } else { + throw new IllegalGraphqlArgumentException( + "Cannot reactivate user with status: " + user.getStatus()); + } + } + + /** + * Iterates over all OrganizationRole's, creating new corresponding Okta groups for this + * organization where they do not already exist. For those OrganizationRole's that are in + * MIGRATION_DEST_ROLES and whose Okta groups are newly created, migrate all users from this org + * to those new Okta groups, where the migrated users are sourced from all pre-existing Okta + * groups for this organization. Separately, iterates over all facilities in this org, creating + * new corresponding Okta groups where they do not already exist. Does not perform any migration + * to these facility groups. + */ + @Override + public void createOrganization(Organization org) { + String name = org.getOrganizationName(); + String externalId = org.getExternalId(); + + for (OrganizationRole role : OrganizationRole.values()) { + String roleGroupName = generateRoleGroupName(externalId, role); + String roleGroupDescription = generateRoleGroupDescription(name, role); + Group g = + GroupBuilder.instance() + .setName(roleGroupName) + .setDescription(roleGroupDescription) + .buildAndCreate(groupApi); + applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); + + log.info("Created Okta group={}", roleGroupName); + } + } + + private List getOrgAdminUsers(Organization org) { + String externalId = org.getExternalId(); + String roleGroupName = generateRoleGroupName(externalId, OrganizationRole.ADMIN); + var groups = groupApi.listGroups(roleGroupName, null, null, null, null, null, null, null); + throwErrorIfEmpty(groups.stream(), "Cannot activate nonexistent Okta organization"); + Group group = groups.get(0); + return groupApi.listGroupUsers(group.getId(), null, null); + } + + private String activateUser(User user) { + if (user.getStatus() == UserStatus.PROVISIONED) { + // reactivates user and sends them an Okta email to reactivate their account + return userApi.reactivateUser(user.getId(), true).getActivationToken(); + } else if (user.getStatus() == UserStatus.STAGED) { + return userApi.activateUser(user.getId(), true).getActivationToken(); + } else { + throw new IllegalGraphqlArgumentException( + "Cannot activate Okta organization whose users have status=" + user.getStatus().name()); + } + } + + @Override + public void activateOrganization(Organization org) { + var users = getOrgAdminUsers(org); + for (User u : users) { + activateUser(u); + } + } + + @Override + public String activateOrganizationWithSingleUser(Organization org) { + User user = getOrgAdminUsers(org).get(0); + return activateUser(user); + } + + @Override + public List fetchAdminUserEmail(Organization org) { + var admins = getOrgAdminUsers(org); + return admins.stream().map(u -> u.getProfile().getLogin()).toList(); + } + + @Override + public void createFacility(Facility facility) { + // Only create the facility group if the facility's organization has already been created + String orgExternalId = facility.getOrganization().getExternalId(); + var orgGroups = + groupApi.listGroups( + generateGroupOrgPrefix(orgExternalId), null, null, null, null, null, null, null); + throwErrorIfEmpty( + orgGroups.stream(), + String.format( + "Cannot create Okta group for facility=%s: facility's org=%s, has not yet been created in Okta", + facility.getFacilityName(), facility.getOrganization().getExternalId())); + + String orgName = facility.getOrganization().getOrganizationName(); + String facilityGroupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); + Group g = + GroupBuilder.instance() + .setName(facilityGroupName) + .setDescription(generateFacilityGroupDescription(orgName, facility.getFacilityName())) + .buildAndCreate(groupApi); + applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); + + log.info("Created Okta group={}", facilityGroupName); + } + + public void deleteFacility(Facility facility) { + String orgExternalId = facility.getOrganization().getExternalId(); + String groupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); + var groups = groupApi.listGroups(groupName, null, null, null, null, null, null, null); + for (Group group : groups) { + groupApi.deleteGroup(group.getId()); + } + } + + @Override + public void deleteOrganization(Organization org) { + String externalId = org.getExternalId(); + var orgGroups = + groupApi.listGroups( + generateGroupOrgPrefix(externalId), null, null, null, null, null, null, null); + for (Group group : orgGroups) { + groupApi.deleteGroup(group.getId()); + } + } + + // returns the external ID of the organization the specified user belongs to + @Override + public Optional getOrganizationRoleClaimsForUser(String username) { + // When a site admin is using tenant data access, bypass okta and get org from the altered + // authorities. If the site admin is getting the claims for another site admin who also has + // active tenant data access, then reflect what is in Okta, not the temporary claims. + if (tenantDataContextHolder.hasBeenPopulated() + && username.equals(tenantDataContextHolder.getUsername())) { + return getOrganizationRoleClaimsFromAuthorities(tenantDataContextHolder.getAuthorities()); + } + + return getOrganizationRoleClaimsForUser( + getUserOrThrowError(username, "Cannot get org external ID for nonexistent user")); + } + + public Integer getUsersInSingleFacility(Facility facility) { + String facilityAccessGroupName = + generateFacilityGroupName( + facility.getOrganization().getExternalId(), facility.getInternalId()); + + List facilityAccessGroup = + groupApi.listGroups(facilityAccessGroupName, null, null, 1, "stats", null, null, null); + + if (facilityAccessGroup.isEmpty()) { + return 0; + } + + try { + LinkedHashMap stats = + (LinkedHashMap) facilityAccessGroup.get(0).getEmbedded().get("stats"); + return ((Integer) stats.get("usersCount")); + } catch (NullPointerException e) { + throw new BadRequestException("Unable to retrieve okta group stats", e); + } + } + + public PartialOktaUser findUser(String username) { + User user = + getUserOrThrowError( + username, "Cannot retrieve Okta user's status with unrecognized username"); + + List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); + + Optional orgClaims = convertToOrganizationRoleClaims(userGroups); + + return PartialOktaUser.builder() + .username(username) + .isSiteAdmin(isSiteAdmin(userGroups)) + .status(user.getStatus()) + .organizationRoleClaims(orgClaims) + .build(); + } + + public int getConnectTimeoutForHealthCheck() { + return applicationApi.getApiClient().getConnectTimeout(); + } + + private Optional getOrganizationRoleClaimsFromAuthorities( + Collection authorities) { + List claims = extractor.convertClaims(authorities); + + if (claims.size() != 1) { + log.warn("User's Tenant Data Access has claims in {} organizations, not 1", claims.size()); + return Optional.empty(); + } + return Optional.of(claims.get(0)); + } + + private Optional getOrganizationRoleClaimsForUser(User user) { + List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); + return convertToOrganizationRoleClaims(userGroups); + } + + private Optional convertToOrganizationRoleClaims(List userGroups) { + List groupNames = + userGroups.stream() + .filter(g -> g.getType() == GroupType.OKTA_GROUP) + .map(g -> g.getProfile().getName()) + .toList(); + List claims = extractor.convertClaims(groupNames); + + if (claims.size() != 1) { + log.warn("User is in {} Okta organizations, not 1", claims.size()); + return Optional.empty(); + } + return Optional.of(claims.get(0)); + } + + private boolean isSiteAdmin(List oktaGroups) { + return oktaGroups.stream() + .filter(g -> g.getType() == GroupType.OKTA_GROUP) + .anyMatch(g -> adminGroupName.equals(g.getProfile().getName())); + } + + private String generateGroupOrgPrefix(String orgExternalId) { + return String.format("%s%s", rolePrefix, orgExternalId); + } + + private String generateRoleGroupName(String orgExternalId, OrganizationRole role) { + return String.format("%s%s%s", rolePrefix, orgExternalId, generateRoleSuffix(role)); + } + + private String generateFacilityGroupName(String orgExternalId, UUID facilityId) { + return String.format( + "%s%s%s", rolePrefix, orgExternalId, generateFacilitySuffix(facilityId.toString())); + } + + private String generateRoleGroupDescription(String orgName, OrganizationRole role) { + return String.format("%s - %ss", orgName, role.getDescription()); + } + + private String generateFacilityGroupDescription(String orgName, String facilityName) { + return String.format("%s - Facility Access - %s", orgName, facilityName); + } + + private String generateRoleSuffix(OrganizationRole role) { + return ":" + role.name(); + } + + private String generateFacilitySuffix(String facilityId) { + return ":" + OrganizationExtractor.FACILITY_ACCESS_MARKER + ":" + facilityId; + } + + private User getUserOrThrowError(String email, String errorMessage) { + try { + return userApi.getUser(email); + } catch (ApiException e) { + throw new IllegalGraphqlArgumentException(errorMessage); + } + } + + private void throwErrorIfEmpty(Stream stream, String errorMessage) { + if (stream.findAny().isEmpty()) { + throw new IllegalGraphqlArgumentException(errorMessage); + } + } + + private String prettifyOktaError(ApiException e) { + var errorMessage = "Code: " + e.getCode() + "; Message: " + e.getMessage(); + if (e.getResponseBody() != null) { + Error error = ApiExceptionHelper.getError(e); + if (error != null) { + errorMessage = + "Okta Error: " + error.getErrorCode() + ", Error summary: " + error.getErrorSummary(); + if (error.getErrorCauses() != null) { + errorMessage += + ", Error Cause(s): " + + error.getErrorCauses().stream() + .map(ErrorErrorCausesInner::getErrorSummary) + .collect(Collectors.joining(", ")); + } + } } - } + return errorMessage; } - return errorMessage; - } } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java index 92cf4c1dfa..c03f2f674b 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java @@ -6,6 +6,7 @@ import gov.cdc.usds.simplereport.db.model.Facility; import gov.cdc.usds.simplereport.db.model.Organization; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; + import java.util.List; import java.util.Map; import java.util.Optional; @@ -18,62 +19,65 @@ */ public interface OktaRepository { - Optional createUser( - IdentityAttributes userIdentity, - Organization org, - Set facilities, - Set roles, - boolean active); + Optional createUser( + IdentityAttributes userIdentity, + Organization org, + Set facilities, + Set roles, + boolean active); + + Optional updateUser(IdentityAttributes userIdentity); + + Optional updateUserEmail(IdentityAttributes userIdentity, String email); - Optional updateUser(IdentityAttributes userIdentity); + void reprovisionUser(IdentityAttributes userIdentity); - Optional updateUserEmail(IdentityAttributes userIdentity, String email); + Optional updateUserPrivileges( + String username, Organization org, Set facilities, Set roles); - void reprovisionUser(IdentityAttributes userIdentity); + List updateUserPrivilegesAndGroupAccess( + String username, + Organization org, + Set facilities, + OrganizationRole roles, + boolean assignedToAllFacilities); - Optional updateUserPrivileges( - String username, Organization org, Set facilities, Set roles); + void resetUserPassword(String username); - List updateUserPrivilegesAndGroupAccess( - String username, - Organization org, - Set facilities, - OrganizationRole roles, - boolean assignedToAllFacilities); + void resetUserMfa(String username); - void resetUserPassword(String username); + void setUserIsActive(String username, boolean active); - void resetUserMfa(String username); + void reactivateUser(String username); - void setUserIsActive(String username, boolean active); + void resendActivationEmail(String username); - void reactivateUser(String username); + UserStatus getUserStatus(String username); - void resendActivationEmail(String username); + Set getAllUsersForOrganization(Organization org); - UserStatus getUserStatus(String username); + Map getAllUsersWithStatusForOrganization(Organization org); - Set getAllUsersForOrganization(Organization org); + void createOrganization(Organization org); - Map getAllUsersWithStatusForOrganization(Organization org); + void activateOrganization(Organization org); - void createOrganization(Organization org); + String activateOrganizationWithSingleUser(Organization org); - void activateOrganization(Organization org); + List fetchAdminUserEmail(Organization org); - String activateOrganizationWithSingleUser(Organization org); + void createFacility(Facility facility); - List fetchAdminUserEmail(Organization org); + void deleteOrganization(Organization org); - void createFacility(Facility facility); + void deleteFacility(Facility facility); - void deleteOrganization(Organization org); + Optional getOrganizationRoleClaimsForUser(String username); - void deleteFacility(Facility facility); + Integer getUsersInSingleFacility(Facility facility); - Optional getOrganizationRoleClaimsForUser(String username); + PartialOktaUser findUser(String username); - Integer getUsersInSingleFacility(Facility facility); + int getConnectTimeoutForHealthCheck(); - PartialOktaUser findUser(String username); } diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 8f792d8cc0..726ac9eaac 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -88,6 +88,7 @@ okta: client: org-url: https://hhs-prime.okta.com token: ${OKTA_API_KEY:MISSING} + group: smarty-streets: id: ${SMARTY_AUTH_ID} token: ${SMARTY_AUTH_TOKEN} diff --git a/frontend/deploy-smoke.js b/frontend/deploy-smoke.js index 8ac643ed1a..7c3a1be92e 100644 --- a/frontend/deploy-smoke.js +++ b/frontend/deploy-smoke.js @@ -8,7 +8,11 @@ require("dotenv").config(); let { Builder } = require("selenium-webdriver"); const Chrome = require("selenium-webdriver/chrome"); -console.log(`Running smoke test for ${process.env.REACT_APP_BASE_URL}`); +const appUrl = process.env.REACT_APP_BASE_URL.includes("localhost") + ? process.env.REACT_APP_BASE_URL + : `${process.env.REACT_APP_BASE_URL}/app`; + +console.log(`Running smoke test for ${appUrl}`); const options = new Chrome.Options(); const driver = new Builder() .forBrowser("chrome") @@ -16,7 +20,7 @@ const driver = new Builder() .build(); driver .navigate() - .to(`${process.env.REACT_APP_BASE_URL}app/health/deploy-smoke-test`) + .to(`${appUrl}/health/deploy-smoke-test`) .then(() => { let value = driver.findElement({ id: "root" }).getText(); return value; diff --git a/frontend/package.json b/frontend/package.json index 75af947025..dc09e3edf0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -76,8 +76,8 @@ "build-storybook": "yarn create-storybook-public && REACT_APP_BACKEND_URL=http://localhost:8080 SASS_PATH=$(cd ./node_modules && pwd):$(cd ./node_modules/@uswds && pwd):$(cd ./node_modules/@uswds/uswds/packages && pwd):$(cd ./src/scss && pwd) storybook build -s storybook_public", "maintenance:start": "[ -z \"$MAINTENANCE_MESSAGE\" ] && echo \"MAINTENANCE_MESSAGE must be set!\" || (echo $MAINTENANCE_MESSAGE > maintenance.json && yarn maintenance:deploy && rm maintenance.json)", "maintenance:deploy": "[ -z \"$MAINTENANCE_ENV\" ] && echo \"MAINTENANCE_ENV must be set!\" || az storage blob upload -f maintenance.json -n maintenance.json -c '$web' --account-name simplereport${MAINTENANCE_ENV}app --overwrite", - "smoke:env:deploy": "node deploy-smoke.js", - "smoke:env:deploy:ci": "node -r dotenv/config deploy-smoke.js dotenv_config_path=.env.production.local dotenv_config_debug=true" + "smoke:deploy:local": "node -r dotenv/config deploy-smoke.js dotenv_config_path=.env.local", + "smoke:deploy:ci": "node -r dotenv/config deploy-smoke.js dotenv_config_path=.env.production.local" }, "prettier": { "singleQuote": false diff --git a/frontend/src/app/DeploySmokeTest.tsx b/frontend/src/app/DeploySmokeTest.tsx index d83a766b2c..e87fbbf1d3 100644 --- a/frontend/src/app/DeploySmokeTest.tsx +++ b/frontend/src/app/DeploySmokeTest.tsx @@ -7,9 +7,8 @@ const DeploySmokeTest = (): JSX.Element => { const [success, setSuccess] = useState(); useEffect(() => { api - .getRequest("/actuator/health/prod-smoke-test") + .getRequest("/actuator/health/backend-and-db-smoke-test") .then((response) => { - console.log(response); const status = JSON.parse(response); if (status.status === "UP") return setSuccess(true); // log something using app insights From f6938edf9d472d0e94350492d648db4a80636752 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 10:39:50 -0500 Subject: [PATCH 17/59] change to push to prove script works --- .github/workflows/smokeTestDeployProd.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/smokeTestDeployProd.yml b/.github/workflows/smokeTestDeployProd.yml index d620771c8f..795d92c35b 100644 --- a/.github/workflows/smokeTestDeployProd.yml +++ b/.github/workflows/smokeTestDeployProd.yml @@ -2,15 +2,15 @@ name: Smoke test deploy Prod run-name: Smoke test the deploy for prod by @${{ github.actor }} on: - workflow_run: - workflows: ["Deploy Prod"] - types: - - completed + push: + # workflow_run: + # workflows: ["Deploy Prod"] + # types: + # - completed env: NODE_VERSION: 18 - # prod env prefix for {env}.simplereport.gov - DEPLOY_ENV: www + DEPLOY_ENV: dev7 jobs: smoke-test-front-and-back-end: From 91dca0a8694955ace0b0eded38c40c69ae9f4c75 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 10:40:46 -0500 Subject: [PATCH 18/59] lint --- .../BackendAndDatabaseHealthIndicator.java | 33 +- .../idp/repository/DemoOktaRepository.java | 720 ++++---- .../idp/repository/LiveOktaRepository.java | 1469 ++++++++--------- .../idp/repository/OktaRepository.java | 74 +- 4 files changed, 1142 insertions(+), 1154 deletions(-) diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java index 22badc2d6d..71fbda9587 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -1,14 +1,11 @@ package gov.cdc.usds.simplereport.api.heathcheck; -import com.okta.sdk.resource.api.GroupApi; import com.okta.sdk.resource.client.ApiException; import gov.cdc.usds.simplereport.db.repository.FeatureFlagRepository; -import gov.cdc.usds.simplereport.idp.repository.LiveOktaRepository; import gov.cdc.usds.simplereport.idp.repository.OktaRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.JDBCConnectionException; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.stereotype.Component; @@ -17,22 +14,22 @@ @Slf4j @RequiredArgsConstructor public class BackendAndDatabaseHealthIndicator implements HealthIndicator { - private final FeatureFlagRepository _ffRepo; - private final OktaRepository _oktaRepo; + private final FeatureFlagRepository _ffRepo; + private final OktaRepository _oktaRepo; - @Override - public Health health() { - try { - _ffRepo.findAll(); - _oktaRepo.getConnectTimeoutForHealthCheck(); + @Override + public Health health() { + try { + _ffRepo.findAll(); + _oktaRepo.getConnectTimeoutForHealthCheck(); - return Health.up().build(); - } catch (JDBCConnectionException e) { - return Health.down().build(); - // Okta API call errored - } catch (ApiException e) { - log.info(e.getMessage()); - return Health.down().build(); - } + return Health.up().build(); + } catch (JDBCConnectionException e) { + return Health.down().build(); + // Okta API call errored + } catch (ApiException e) { + log.info(e.getMessage()); + return Health.down().build(); } + } } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java index 7baf653582..0bf9ec5c08 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java @@ -13,7 +13,6 @@ import gov.cdc.usds.simplereport.db.model.Facility; import gov.cdc.usds.simplereport.db.model.Organization; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; - import java.util.Collection; import java.util.EnumSet; import java.util.HashMap; @@ -25,7 +24,6 @@ import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; - import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.support.ScopeNotActiveException; @@ -34,410 +32,408 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; -/** - * Handles all user/organization management in Okta - */ +/** Handles all user/organization management in Okta */ @Profile(BeanProfiles.NO_OKTA_MGMT) @Service @Slf4j public class DemoOktaRepository implements OktaRepository { - @Value("${simple-report.authorization.environment-name:DEV}") - private String environment; - - private final OrganizationExtractor organizationExtractor; - private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; - - Map usernameOrgRolesMap; - Map> orgUsernamesMap; - Map> orgFacilitiesMap; - Set inactiveUsernames; - Set allUsernames; - private final Set adminGroupMemberSet; - - public DemoOktaRepository( - OrganizationExtractor extractor, - CurrentTenantDataAccessContextHolder contextHolder, - DemoUserConfiguration demoUserConfiguration) { - this.usernameOrgRolesMap = new HashMap<>(); - this.orgUsernamesMap = new HashMap<>(); - this.orgFacilitiesMap = new HashMap<>(); - this.inactiveUsernames = new HashSet<>(); - this.allUsernames = new HashSet<>(); - - this.organizationExtractor = extractor; - this.tenantDataContextHolder = contextHolder; - this.adminGroupMemberSet = - demoUserConfiguration.getSiteAdminEmails().stream().collect(Collectors.toUnmodifiableSet()); - - log.info("Done initializing Demo Okta repository."); + @Value("${simple-report.authorization.environment-name:DEV}") + private String environment; + + private final OrganizationExtractor organizationExtractor; + private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; + + Map usernameOrgRolesMap; + Map> orgUsernamesMap; + Map> orgFacilitiesMap; + Set inactiveUsernames; + Set allUsernames; + private final Set adminGroupMemberSet; + + public DemoOktaRepository( + OrganizationExtractor extractor, + CurrentTenantDataAccessContextHolder contextHolder, + DemoUserConfiguration demoUserConfiguration) { + this.usernameOrgRolesMap = new HashMap<>(); + this.orgUsernamesMap = new HashMap<>(); + this.orgFacilitiesMap = new HashMap<>(); + this.inactiveUsernames = new HashSet<>(); + this.allUsernames = new HashSet<>(); + + this.organizationExtractor = extractor; + this.tenantDataContextHolder = contextHolder; + this.adminGroupMemberSet = + demoUserConfiguration.getSiteAdminEmails().stream().collect(Collectors.toUnmodifiableSet()); + + log.info("Done initializing Demo Okta repository."); + } + + public Optional createUser( + IdentityAttributes userIdentity, + Organization org, + Set facilities, + Set roles, + boolean active) { + if (allUsernames.contains(userIdentity.getUsername())) { + throw new ConflictingUserException(); } - public Optional createUser( - IdentityAttributes userIdentity, - Organization org, - Set facilities, - Set roles, - boolean active) { - if (allUsernames.contains(userIdentity.getUsername())) { - throw new ConflictingUserException(); - } - - String organizationExternalId = org.getExternalId(); - Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); - rolesToCreate.addAll(roles); - Set facilityUUIDs = - PermissionHolder.grantsAllFacilityAccess(rolesToCreate) - // create an empty set of facilities if user can access all facilities anyway - ? Set.of() - : facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()); - if (!orgFacilitiesMap.containsKey(organizationExternalId)) { - throw new IllegalGraphqlArgumentException( - "Cannot add Okta user to nonexistent organization=" + organizationExternalId); - } else if (!orgFacilitiesMap.get(organizationExternalId).containsAll(facilityUUIDs)) { - throw new IllegalGraphqlArgumentException( - "Cannot add Okta user to one or more nonexistent facilities in facilities_set=" - + facilities.stream().map(f -> f.getFacilityName()).collect(Collectors.toSet()) - + " in organization=" - + organizationExternalId); - } - - OrganizationRoleClaims orgRoles = - new OrganizationRoleClaims(organizationExternalId, facilityUUIDs, rolesToCreate); - usernameOrgRolesMap.put(userIdentity.getUsername(), orgRoles); - allUsernames.add(userIdentity.getUsername()); - - orgUsernamesMap.get(organizationExternalId).add(userIdentity.getUsername()); - - if (!active) { - inactiveUsernames.add(userIdentity.getUsername()); - } - - return Optional.of(orgRoles); + String organizationExternalId = org.getExternalId(); + Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); + rolesToCreate.addAll(roles); + Set facilityUUIDs = + PermissionHolder.grantsAllFacilityAccess(rolesToCreate) + // create an empty set of facilities if user can access all facilities anyway + ? Set.of() + : facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()); + if (!orgFacilitiesMap.containsKey(organizationExternalId)) { + throw new IllegalGraphqlArgumentException( + "Cannot add Okta user to nonexistent organization=" + organizationExternalId); + } else if (!orgFacilitiesMap.get(organizationExternalId).containsAll(facilityUUIDs)) { + throw new IllegalGraphqlArgumentException( + "Cannot add Okta user to one or more nonexistent facilities in facilities_set=" + + facilities.stream().map(f -> f.getFacilityName()).collect(Collectors.toSet()) + + " in organization=" + + organizationExternalId); } - // this method currently doesn't do much in a demo envt - public Optional updateUser(IdentityAttributes userIdentity) { - if (!usernameOrgRolesMap.containsKey(userIdentity.getUsername())) { - throw new IllegalGraphqlArgumentException( - "Cannot change name of Okta user with unrecognized username"); - } - OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(userIdentity.getUsername()); - return Optional.of(orgRoles); - } + OrganizationRoleClaims orgRoles = + new OrganizationRoleClaims(organizationExternalId, facilityUUIDs, rolesToCreate); + usernameOrgRolesMap.put(userIdentity.getUsername(), orgRoles); + allUsernames.add(userIdentity.getUsername()); - public Optional updateUserEmail( - IdentityAttributes userIdentity, String newEmail) { - String currentEmail = userIdentity.getUsername(); - if (!usernameOrgRolesMap.containsKey(currentEmail)) { - throw new IllegalGraphqlArgumentException( - "Cannot change email of Okta user with unrecognized username"); - } - - if (usernameOrgRolesMap.containsKey(newEmail)) { - throw new ConflictingUserException(); - } - - String org = usernameOrgRolesMap.get(userIdentity.getUsername()).getOrganizationExternalId(); - orgUsernamesMap.get(org).remove(currentEmail); - orgUsernamesMap.get(org).add(newEmail); - usernameOrgRolesMap.put(newEmail, usernameOrgRolesMap.remove(userIdentity.getUsername())); - OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(newEmail); - return Optional.of(orgRoles); - } + orgUsernamesMap.get(organizationExternalId).add(userIdentity.getUsername()); - public void reprovisionUser(IdentityAttributes userIdentity) { - final String username = userIdentity.getUsername(); - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reprovision Okta user with unrecognized username"); - } - if (!inactiveUsernames.contains(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reprovision user in unsupported state: (not deleted)"); - } - - // Only re-enable the user. If name attributes and credentials were supported here, then - // the name should be updated and credentials reset. - inactiveUsernames.remove(userIdentity.getUsername()); + if (!active) { + inactiveUsernames.add(userIdentity.getUsername()); } - public Optional updateUserPrivileges( - String username, Organization org, Set facilities, Set roles) { - String orgId = org.getExternalId(); - if (!orgUsernamesMap.containsKey(orgId)) { - throw new IllegalGraphqlArgumentException( - "Cannot update Okta user privileges for nonexistent organization."); - } - if (!orgUsernamesMap.get(orgId).contains(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot update Okta user privileges for organization they are not in."); - } - Set newRoles = EnumSet.of(OrganizationRole.getDefault()); - newRoles.addAll(roles); - Set facilityUUIDs = - facilities.stream() - // create an empty set of facilities if user can access all facilities anyway - .filter(f -> !PermissionHolder.grantsAllFacilityAccess(newRoles)) - .map(f -> f.getInternalId()) - .collect(Collectors.toSet()); - OrganizationRoleClaims newRoleClaims = - new OrganizationRoleClaims(orgId, facilityUUIDs, newRoles); - usernameOrgRolesMap.put(username, newRoleClaims); - - return Optional.of(newRoleClaims); - } + return Optional.of(orgRoles); + } - @Override - public List updateUserPrivilegesAndGroupAccess( - String username, - Organization org, - Set facilities, - OrganizationRole roles, - boolean allFacilitiesAccess) { - - String oldOrgId = usernameOrgRolesMap.get(username).getOrganizationExternalId(); - orgUsernamesMap.get(oldOrgId).remove(username); - orgUsernamesMap.get(org.getExternalId()).add(username); - OrganizationRoleClaims newRoleClaims = - new OrganizationRoleClaims( - org.getExternalId(), - facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()), - Set.of(roles, OrganizationRole.getDefault())); - - usernameOrgRolesMap.replace(username, newRoleClaims); - - // Live Okta repository returns list of Group names, but our demo repo didn't implement - // group mappings and it didn't feel worth it to add that implementation since the return is - // used mostly for testing. Return the list of facility ID's in the new org instead - return orgFacilitiesMap.get(org.getExternalId()).stream().map(UUID::toString).toList(); + // this method currently doesn't do much in a demo envt + public Optional updateUser(IdentityAttributes userIdentity) { + if (!usernameOrgRolesMap.containsKey(userIdentity.getUsername())) { + throw new IllegalGraphqlArgumentException( + "Cannot change name of Okta user with unrecognized username"); } - - public void resetUserPassword(String username) { - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reset password for Okta user with unrecognized username"); - } + OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(userIdentity.getUsername()); + return Optional.of(orgRoles); + } + + public Optional updateUserEmail( + IdentityAttributes userIdentity, String newEmail) { + String currentEmail = userIdentity.getUsername(); + if (!usernameOrgRolesMap.containsKey(currentEmail)) { + throw new IllegalGraphqlArgumentException( + "Cannot change email of Okta user with unrecognized username"); } - public void resetUserMfa(String username) { - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reset MFA for Okta user with unrecognized username"); - } + if (usernameOrgRolesMap.containsKey(newEmail)) { + throw new ConflictingUserException(); } - public void setUserIsActive(String username, boolean active) { - if (active) { - inactiveUsernames.remove(username); - } else { - inactiveUsernames.add(username); - } + String org = usernameOrgRolesMap.get(userIdentity.getUsername()).getOrganizationExternalId(); + orgUsernamesMap.get(org).remove(currentEmail); + orgUsernamesMap.get(org).add(newEmail); + usernameOrgRolesMap.put(newEmail, usernameOrgRolesMap.remove(userIdentity.getUsername())); + OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(newEmail); + return Optional.of(orgRoles); + } + + public void reprovisionUser(IdentityAttributes userIdentity) { + final String username = userIdentity.getUsername(); + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reprovision Okta user with unrecognized username"); } - - public UserStatus getUserStatus(String username) { - if (inactiveUsernames.contains(username)) { - return UserStatus.SUSPENDED; - } else { - return UserStatus.ACTIVE; - } + if (!inactiveUsernames.contains(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reprovision user in unsupported state: (not deleted)"); } - public void reactivateUser(String username) { - if (inactiveUsernames.contains(username)) { - inactiveUsernames.remove(username); - } + // Only re-enable the user. If name attributes and credentials were supported here, then + // the name should be updated and credentials reset. + inactiveUsernames.remove(userIdentity.getUsername()); + } + + public Optional updateUserPrivileges( + String username, Organization org, Set facilities, Set roles) { + String orgId = org.getExternalId(); + if (!orgUsernamesMap.containsKey(orgId)) { + throw new IllegalGraphqlArgumentException( + "Cannot update Okta user privileges for nonexistent organization."); } - - public void resendActivationEmail(String username) { - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reset password for Okta user with unrecognized username"); - } + if (!orgUsernamesMap.get(orgId).contains(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot update Okta user privileges for organization they are not in."); } - - // returns ALL users including inactive ones - public Set getAllUsersForOrganization(Organization org) { - if (!orgUsernamesMap.containsKey(org.getExternalId())) { - throw new IllegalGraphqlArgumentException( - "Cannot get Okta users from nonexistent organization."); - } - return orgUsernamesMap.get(org.getExternalId()).stream() - .collect(Collectors.toUnmodifiableSet()); + Set newRoles = EnumSet.of(OrganizationRole.getDefault()); + newRoles.addAll(roles); + Set facilityUUIDs = + facilities.stream() + // create an empty set of facilities if user can access all facilities anyway + .filter(f -> !PermissionHolder.grantsAllFacilityAccess(newRoles)) + .map(f -> f.getInternalId()) + .collect(Collectors.toSet()); + OrganizationRoleClaims newRoleClaims = + new OrganizationRoleClaims(orgId, facilityUUIDs, newRoles); + usernameOrgRolesMap.put(username, newRoleClaims); + + return Optional.of(newRoleClaims); + } + + @Override + public List updateUserPrivilegesAndGroupAccess( + String username, + Organization org, + Set facilities, + OrganizationRole roles, + boolean allFacilitiesAccess) { + + String oldOrgId = usernameOrgRolesMap.get(username).getOrganizationExternalId(); + orgUsernamesMap.get(oldOrgId).remove(username); + orgUsernamesMap.get(org.getExternalId()).add(username); + OrganizationRoleClaims newRoleClaims = + new OrganizationRoleClaims( + org.getExternalId(), + facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()), + Set.of(roles, OrganizationRole.getDefault())); + + usernameOrgRolesMap.replace(username, newRoleClaims); + + // Live Okta repository returns list of Group names, but our demo repo didn't implement + // group mappings and it didn't feel worth it to add that implementation since the return is + // used mostly for testing. Return the list of facility ID's in the new org instead + return orgFacilitiesMap.get(org.getExternalId()).stream().map(UUID::toString).toList(); + } + + public void resetUserPassword(String username) { + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reset password for Okta user with unrecognized username"); } + } - public Map getAllUsersWithStatusForOrganization(Organization org) { - if (!orgUsernamesMap.containsKey(org.getExternalId())) { - throw new IllegalGraphqlArgumentException( - "Cannot get Okta users from nonexistent organization."); - } - return orgUsernamesMap.get(org.getExternalId()).stream() - .collect(Collectors.toMap(u -> u, u -> getUserStatus(u))); + public void resetUserMfa(String username) { + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reset MFA for Okta user with unrecognized username"); } + } - // this method doesn't mean much in a demo env - public void createOrganization(Organization org) { - String externalId = org.getExternalId(); - orgUsernamesMap.putIfAbsent(externalId, new HashSet<>()); - orgFacilitiesMap.putIfAbsent(externalId, new HashSet<>()); + public void setUserIsActive(String username, boolean active) { + if (active) { + inactiveUsernames.remove(username); + } else { + inactiveUsernames.add(username); } + } - // this method means nothing in a demo env - public void activateOrganization(Organization org) { - inactiveUsernames.removeAll(orgUsernamesMap.get(org.getExternalId())); + public UserStatus getUserStatus(String username) { + if (inactiveUsernames.contains(username)) { + return UserStatus.SUSPENDED; + } else { + return UserStatus.ACTIVE; } + } - // this method means nothing in a demo env - public String activateOrganizationWithSingleUser(Organization org) { - activateOrganization(org); - return "activationToken"; + public void reactivateUser(String username) { + if (inactiveUsernames.contains(username)) { + inactiveUsernames.remove(username); } + } - public List fetchAdminUserEmail(Organization org) { - Set> admins = - usernameOrgRolesMap.entrySet().stream() - .filter(e -> e.getValue().getGrantedRoles().contains(OrganizationRole.ADMIN)) - .collect(Collectors.toSet()); - return admins.stream().map(Entry::getKey).collect(Collectors.toList()); + public void resendActivationEmail(String username) { + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reset password for Okta user with unrecognized username"); } + } - public void createFacility(Facility facility) { - String orgExternalId = facility.getOrganization().getExternalId(); - if (!orgFacilitiesMap.containsKey(orgExternalId)) { - throw new IllegalGraphqlArgumentException( - "Cannot create Okta facility in nonexistent organization."); - } - orgFacilitiesMap.get(orgExternalId).add(facility.getInternalId()); + // returns ALL users including inactive ones + public Set getAllUsersForOrganization(Organization org) { + if (!orgUsernamesMap.containsKey(org.getExternalId())) { + throw new IllegalGraphqlArgumentException( + "Cannot get Okta users from nonexistent organization."); } - - public void deleteOrganization(Organization org) { - String externalId = org.getExternalId(); - orgUsernamesMap.remove(externalId); - orgFacilitiesMap.remove(externalId); - // remove all users from this map whose org roles are in the deleted org - usernameOrgRolesMap = - usernameOrgRolesMap.entrySet().stream() - .filter(e -> !(e.getValue().getOrganizationExternalId().equals(externalId))) - .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())); + return orgUsernamesMap.get(org.getExternalId()).stream() + .collect(Collectors.toUnmodifiableSet()); + } + + public Map getAllUsersWithStatusForOrganization(Organization org) { + if (!orgUsernamesMap.containsKey(org.getExternalId())) { + throw new IllegalGraphqlArgumentException( + "Cannot get Okta users from nonexistent organization."); } - - public void deleteFacility(Facility facility) { - String orgExternalId = facility.getOrganization().getExternalId(); - if (!orgFacilitiesMap.containsKey(orgExternalId)) { - throw new IllegalGraphqlArgumentException( - "Cannot delete Okta facility from nonexistent organization."); - } - orgFacilitiesMap.get(orgExternalId).remove(facility.getInternalId()); - // remove this facility from every user's OrganizationRoleClaims, as necessary - usernameOrgRolesMap = - usernameOrgRolesMap.entrySet().stream() - .collect( - Collectors.toMap( - e -> e.getKey(), - e -> { - OrganizationRoleClaims oldRoleClaims = e.getValue(); - Set newFacilities = - oldRoleClaims.getFacilities().stream() - .filter(f -> !f.equals(facility.getInternalId())) - .collect(Collectors.toSet()); - return new OrganizationRoleClaims( - orgExternalId, newFacilities, oldRoleClaims.getGrantedRoles()); - })); + return orgUsernamesMap.get(org.getExternalId()).stream() + .collect(Collectors.toMap(u -> u, u -> getUserStatus(u))); + } + + // this method doesn't mean much in a demo env + public void createOrganization(Organization org) { + String externalId = org.getExternalId(); + orgUsernamesMap.putIfAbsent(externalId, new HashSet<>()); + orgFacilitiesMap.putIfAbsent(externalId, new HashSet<>()); + } + + // this method means nothing in a demo env + public void activateOrganization(Organization org) { + inactiveUsernames.removeAll(orgUsernamesMap.get(org.getExternalId())); + } + + // this method means nothing in a demo env + public String activateOrganizationWithSingleUser(Organization org) { + activateOrganization(org); + return "activationToken"; + } + + public List fetchAdminUserEmail(Organization org) { + Set> admins = + usernameOrgRolesMap.entrySet().stream() + .filter(e -> e.getValue().getGrantedRoles().contains(OrganizationRole.ADMIN)) + .collect(Collectors.toSet()); + return admins.stream().map(Entry::getKey).collect(Collectors.toList()); + } + + public void createFacility(Facility facility) { + String orgExternalId = facility.getOrganization().getExternalId(); + if (!orgFacilitiesMap.containsKey(orgExternalId)) { + throw new IllegalGraphqlArgumentException( + "Cannot create Okta facility in nonexistent organization."); } - - private Optional getOrganizationRoleClaimsFromTenantDataAccess( - Collection groupNames) { - List claims = organizationExtractor.convertClaims(groupNames); - - if (claims.size() != 1) { - log.warn("User is in {} Okta organizations, not 1", claims.size()); - return Optional.empty(); - } - return Optional.of(claims.get(0)); + orgFacilitiesMap.get(orgExternalId).add(facility.getInternalId()); + } + + public void deleteOrganization(Organization org) { + String externalId = org.getExternalId(); + orgUsernamesMap.remove(externalId); + orgFacilitiesMap.remove(externalId); + // remove all users from this map whose org roles are in the deleted org + usernameOrgRolesMap = + usernameOrgRolesMap.entrySet().stream() + .filter(e -> !(e.getValue().getOrganizationExternalId().equals(externalId))) + .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())); + } + + public void deleteFacility(Facility facility) { + String orgExternalId = facility.getOrganization().getExternalId(); + if (!orgFacilitiesMap.containsKey(orgExternalId)) { + throw new IllegalGraphqlArgumentException( + "Cannot delete Okta facility from nonexistent organization."); } - - public Optional getOrganizationRoleClaimsForUser(String username) { - // when accessing tenant data, bypass okta and get org from the altered authorities - try { - if (tenantDataContextHolder.hasBeenPopulated() - && username.equals(tenantDataContextHolder.getUsername())) { - return getOrganizationRoleClaimsFromTenantDataAccess( - tenantDataContextHolder.getAuthorities()); - } - return Optional.ofNullable(usernameOrgRolesMap.get(username)); - } catch (ScopeNotActiveException e) { - // Tests are set up with a full SecurityContextHolder and should not rely on - // usernameOrgRolesMap as the source of truth. - if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { - return Optional.of(usernameOrgRolesMap.get(username)); - } - Set authorities = - SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.toSet()); - return getOrganizationRoleClaimsFromTenantDataAccess(authorities); - } + orgFacilitiesMap.get(orgExternalId).remove(facility.getInternalId()); + // remove this facility from every user's OrganizationRoleClaims, as necessary + usernameOrgRolesMap = + usernameOrgRolesMap.entrySet().stream() + .collect( + Collectors.toMap( + e -> e.getKey(), + e -> { + OrganizationRoleClaims oldRoleClaims = e.getValue(); + Set newFacilities = + oldRoleClaims.getFacilities().stream() + .filter(f -> !f.equals(facility.getInternalId())) + .collect(Collectors.toSet()); + return new OrganizationRoleClaims( + orgExternalId, newFacilities, oldRoleClaims.getGrantedRoles()); + })); + } + + private Optional getOrganizationRoleClaimsFromTenantDataAccess( + Collection groupNames) { + List claims = organizationExtractor.convertClaims(groupNames); + + if (claims.size() != 1) { + log.warn("User is in {} Okta organizations, not 1", claims.size()); + return Optional.empty(); } - - public PartialOktaUser findUser(String username) { - UserStatus status = - inactiveUsernames.contains(username) ? UserStatus.SUSPENDED : UserStatus.ACTIVE; - boolean isAdmin = adminGroupMemberSet.contains(username); - - Optional orgClaims; - - try { - orgClaims = Optional.ofNullable(usernameOrgRolesMap.get(username)); - } catch (ScopeNotActiveException e) { - // Tests are set up with a full SecurityContextHolder and should not rely on - // usernameOrgRolesMap as the source of truth. - if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { - orgClaims = Optional.of(usernameOrgRolesMap.get(username)); - } else { - Set authorities = - SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.toSet()); - orgClaims = getOrganizationRoleClaimsFromTenantDataAccess(authorities); - } - } - - return PartialOktaUser.builder() - .isSiteAdmin(isAdmin) - .status(status) - .username(username) - .organizationRoleClaims(orgClaims) - .build(); + return Optional.of(claims.get(0)); + } + + public Optional getOrganizationRoleClaimsForUser(String username) { + // when accessing tenant data, bypass okta and get org from the altered authorities + try { + if (tenantDataContextHolder.hasBeenPopulated() + && username.equals(tenantDataContextHolder.getUsername())) { + return getOrganizationRoleClaimsFromTenantDataAccess( + tenantDataContextHolder.getAuthorities()); + } + return Optional.ofNullable(usernameOrgRolesMap.get(username)); + } catch (ScopeNotActiveException e) { + // Tests are set up with a full SecurityContextHolder and should not rely on + // usernameOrgRolesMap as the source of truth. + if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { + return Optional.of(usernameOrgRolesMap.get(username)); + } + Set authorities = + SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + return getOrganizationRoleClaimsFromTenantDataAccess(authorities); } - - public void reset() { - usernameOrgRolesMap.clear(); - orgUsernamesMap.clear(); - orgFacilitiesMap.clear(); - inactiveUsernames.clear(); - allUsernames.clear(); + } + + public PartialOktaUser findUser(String username) { + UserStatus status = + inactiveUsernames.contains(username) ? UserStatus.SUSPENDED : UserStatus.ACTIVE; + boolean isAdmin = adminGroupMemberSet.contains(username); + + Optional orgClaims; + + try { + orgClaims = Optional.ofNullable(usernameOrgRolesMap.get(username)); + } catch (ScopeNotActiveException e) { + // Tests are set up with a full SecurityContextHolder and should not rely on + // usernameOrgRolesMap as the source of truth. + if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { + orgClaims = Optional.of(usernameOrgRolesMap.get(username)); + } else { + Set authorities = + SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + orgClaims = getOrganizationRoleClaimsFromTenantDataAccess(authorities); + } } - public Integer getUsersInSingleFacility(Facility facility) { - Integer accessCount = 0; - - for (OrganizationRoleClaims existingClaims : usernameOrgRolesMap.values()) { - boolean hasAllFacilityAccess = - existingClaims.getGrantedRoles().stream() - .anyMatch(role -> OrganizationRole.ALL_FACILITIES.getName().equals(role.name())); - boolean hasSpecificFacilityAccess = - existingClaims.getFacilities().stream() - .anyMatch(facilityAccessId -> facility.getInternalId().equals(facilityAccessId)); - if (!hasAllFacilityAccess && hasSpecificFacilityAccess) { - accessCount++; - } - } - - return accessCount; + return PartialOktaUser.builder() + .isSiteAdmin(isAdmin) + .status(status) + .username(username) + .organizationRoleClaims(orgClaims) + .build(); + } + + public void reset() { + usernameOrgRolesMap.clear(); + orgUsernamesMap.clear(); + orgFacilitiesMap.clear(); + inactiveUsernames.clear(); + allUsernames.clear(); + } + + public Integer getUsersInSingleFacility(Facility facility) { + Integer accessCount = 0; + + for (OrganizationRoleClaims existingClaims : usernameOrgRolesMap.values()) { + boolean hasAllFacilityAccess = + existingClaims.getGrantedRoles().stream() + .anyMatch(role -> OrganizationRole.ALL_FACILITIES.getName().equals(role.name())); + boolean hasSpecificFacilityAccess = + existingClaims.getFacilities().stream() + .anyMatch(facilityAccessId -> facility.getInternalId().equals(facilityAccessId)); + if (!hasAllFacilityAccess && hasSpecificFacilityAccess) { + accessCount++; + } } - public int getConnectTimeoutForHealthCheck() { - int FAKE_CONNECTION_TIMEOUT = 0; - return FAKE_CONNECTION_TIMEOUT; - } + return accessCount; + } + + public int getConnectTimeoutForHealthCheck() { + int FAKE_CONNECTION_TIMEOUT = 0; + return FAKE_CONNECTION_TIMEOUT; + } } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java index f1a5157353..7704a85786 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java @@ -5,7 +5,6 @@ import com.okta.sdk.resource.api.ApplicationGroupsApi; import com.okta.sdk.resource.api.GroupApi; import com.okta.sdk.resource.api.UserApi; -import com.okta.sdk.resource.client.ApiClient; import com.okta.sdk.resource.client.ApiException; import com.okta.sdk.resource.common.PagedList; import com.okta.sdk.resource.group.GroupBuilder; @@ -33,7 +32,6 @@ import gov.cdc.usds.simplereport.db.model.Facility; import gov.cdc.usds.simplereport.db.model.Organization; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; - import java.util.ArrayList; import java.util.Collection; import java.util.EnumSet; @@ -47,7 +45,6 @@ import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; - import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; @@ -64,740 +61,740 @@ @Slf4j public class LiveOktaRepository implements OktaRepository { - private static final String OKTA_GROUP_NOT_FOUND = "Okta group not found for this organization"; - - private final String rolePrefix; - private final Application app; - private final OrganizationExtractor extractor; - private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; - - private final ApplicationApi applicationApi; - private final GroupApi groupApi; - private final UserApi userApi; - private final ApplicationGroupsApi applicationGroupsApi; - private final String adminGroupName; - - private static final String OKTA_ORG_PROFILE_MATCHER = "profile.name sw \""; - private static final int OKTA_PAGE_SIZE = 500; - - public LiveOktaRepository( - AuthorizationProperties authorizationProperties, - @Value("${okta.oauth2.client-id}") String oktaOAuth2ClientId, - OrganizationExtractor organizationExtractor, - CurrentTenantDataAccessContextHolder tenantDataContextHolder, - GroupApi groupApi, - ApplicationApi applicationApi, - UserApi userApi, - ApplicationGroupsApi applicationGroupsApi) { - this.rolePrefix = authorizationProperties.getRolePrefix(); - this.adminGroupName = authorizationProperties.getAdminGroupName(); - - this.applicationApi = applicationApi; - this.groupApi = groupApi; - this.userApi = userApi; - this.applicationGroupsApi = applicationGroupsApi; - - try { - this.app = applicationApi.getApplication(oktaOAuth2ClientId, null); - } catch (ApiException e) { - throw new MisconfiguredApplicationException( - "Cannot find Okta application with id=" + oktaOAuth2ClientId, e); - } - - this.extractor = organizationExtractor; - this.tenantDataContextHolder = tenantDataContextHolder; - } - - @Override - public Optional createUser( - IdentityAttributes userIdentity, - Organization org, - Set facilities, - Set roles, - boolean active) { - // By default, when creating a user, we give them privileges of a standard user - String organizationExternalId = org.getExternalId(); - Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); - rolesToCreate.addAll(roles); - - // Add user to new groups - Set groupNamesToAdd = new HashSet<>(); - groupNamesToAdd.addAll( - rolesToCreate.stream() - .map(r -> generateRoleGroupName(organizationExternalId, r)) - .collect(Collectors.toSet())); - groupNamesToAdd.addAll( - facilities.stream() - // use an empty set of facilities if user can access all facilities anyway - .filter(f -> !PermissionHolder.grantsAllFacilityAccess(rolesToCreate)) - .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) - .collect(Collectors.toSet())); - - // Search and q results need to be combined because search results have a delay of the newest - // added groups. - // https://github.com/okta/okta-sdk-java/issues/750 - var searchResults = - groupApi - .listGroups( - null, - null, - null, - null, - null, - OKTA_ORG_PROFILE_MATCHER + generateGroupOrgPrefix(organizationExternalId) + "\"", - null, - null) - .stream(); - var qResults = - groupApi - .listGroups( - generateGroupOrgPrefix(organizationExternalId), - null, - null, - null, - null, - null, - null, - null) - .stream(); - var orgGroups = Stream.concat(searchResults, qResults).distinct().toList(); - throwErrorIfEmpty( - orgGroups.stream(), - String.format( - "Cannot add Okta user to nonexistent organization=%s", organizationExternalId)); - Set orgGroupNames = - orgGroups.stream().map(g -> g.getProfile().getName()).collect(Collectors.toSet()); - groupNamesToAdd.stream() - .filter(n -> !orgGroupNames.contains(n)) - .forEach( - n -> { - throw new IllegalGraphqlArgumentException( - String.format("Cannot add Okta user to nonexistent group=%s", n)); - }); - Set groupIdsToAdd = - orgGroups.stream() - .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) - .map(Group::getId) - .collect(Collectors.toSet()); - validateRequiredFields(userIdentity); - try { - var user = - UserBuilder.instance() - .setFirstName(userIdentity.getFirstName()) - .setMiddleName(userIdentity.getMiddleName()) - .setLastName(userIdentity.getLastName()) - .setHonorificSuffix(userIdentity.getSuffix()) - .setEmail(userIdentity.getUsername()) - .setLogin(userIdentity.getUsername()) - .setActive(active) - .buildAndCreate(userApi); - groupIdsToAdd.forEach(groupId -> groupApi.assignUserToGroup(groupId, user.getId())); - } catch (ApiException e) { - if (e.getMessage() - .contains("An object with this field already exists in the current organization")) { - throw new ConflictingUserException(); - } else { - throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); - } - } - - List claims = extractor.convertClaims(groupNamesToAdd); - if (claims.size() != 1) { - log.warn("User is in {} Okta organizations, not 1", claims.size()); - return Optional.empty(); - } - return Optional.of(claims.get(0)); - } - - private static void validateRequiredFields(IdentityAttributes userIdentity) { - if (StringUtils.isBlank(userIdentity.getLastName())) { - throw new IllegalGraphqlArgumentException("Cannot create Okta user without last name"); - } - if (StringUtils.isBlank(userIdentity.getUsername())) { - throw new IllegalGraphqlArgumentException("Cannot create Okta user without username"); - } - } - - @Override - public Set getAllUsersForOrganization(Organization org) { - return getAllUsersForOrg(org).stream() - .map(u -> u.getProfile().getLogin()) - .collect(Collectors.toUnmodifiableSet()); - } - - @Override - public Map getAllUsersWithStatusForOrganization(Organization org) { - return getAllUsersForOrg(org).stream() - .collect(Collectors.toMap(u -> u.getProfile().getLogin(), User::getStatus)); - } - - private List getAllUsersForOrg(Organization org) { - PagedList pagedUserList = new PagedList<>(); - List allUsers = new ArrayList<>(); - Group orgDefaultOktaGroup = getDefaultOktaGroup(org); - do { - pagedUserList = - (PagedList) - groupApi.listGroupUsers( - orgDefaultOktaGroup.getId(), pagedUserList.getAfter(), OKTA_PAGE_SIZE); - allUsers.addAll(pagedUserList); - } while (pagedUserList.hasMoreItems()); - return allUsers; - } - - private Group getDefaultOktaGroup(Organization org) { - final String orgDefaultGroupName = - generateRoleGroupName(org.getExternalId(), OrganizationRole.getDefault()); - final var oktaGroupList = - groupApi.listGroups(orgDefaultGroupName, null, null, null, null, null, null, null); - - return oktaGroupList.stream() - .filter(g -> orgDefaultGroupName.equals(g.getProfile().getName())) - .findFirst() - .orElseThrow(() -> new IllegalGraphqlArgumentException(OKTA_GROUP_NOT_FOUND)); - } - - @Override - public Optional updateUser(IdentityAttributes userIdentity) { - var user = - getUserOrThrowError( - userIdentity.getUsername(), "Cannot update Okta user with unrecognized username"); - updateUser(user, userIdentity); - - return getOrganizationRoleClaimsForUser(user); - } - - private void updateUser(User user, IdentityAttributes userIdentity) { - user.getProfile().setFirstName(userIdentity.getFirstName()); - user.getProfile().setMiddleName(userIdentity.getMiddleName()); - user.getProfile().setLastName(userIdentity.getLastName()); - // Is it our fault we don't accommodate honorific suffix? Or Okta's fault they - // don't have regular suffix? You decide. - user.getProfile().setHonorificSuffix(userIdentity.getSuffix()); - var updateRequest = new UpdateUserRequest(); - updateRequest.setProfile(user.getProfile()); - userApi.updateUser(user.getId(), updateRequest, false); - } - - @Override - public Optional updateUserEmail( - IdentityAttributes userIdentity, String email) { - var user = - getUserOrThrowError( - userIdentity.getUsername(), - "Cannot update email of Okta user with unrecognized username"); - UserProfile profile = user.getProfile(); - profile.setLogin(email); - profile.setEmail(email); - user.setProfile(profile); - var updateRequest = new UpdateUserRequest(); - updateRequest.setProfile(profile); - try { - userApi.updateUser(user.getId(), updateRequest, false); - } catch (ApiException e) { - if (e.getMessage() - .contains("An object with this field already exists in the current organization")) { - throw new ConflictingUserException(); - } else { - throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); - } - } - - return getOrganizationRoleClaimsForUser(user); - } - - @Override - public void reprovisionUser(IdentityAttributes userIdentity) { - var user = - getUserOrThrowError( - userIdentity.getUsername(), "Cannot reprovision Okta user with unrecognized username"); - UserStatus userStatus = user.getStatus(); - - // any org user "deleted" through our api will be in SUSPENDED state - if (userStatus != UserStatus.SUSPENDED) { - throw new ConflictingUserException(); + private static final String OKTA_GROUP_NOT_FOUND = "Okta group not found for this organization"; + + private final String rolePrefix; + private final Application app; + private final OrganizationExtractor extractor; + private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; + + private final ApplicationApi applicationApi; + private final GroupApi groupApi; + private final UserApi userApi; + private final ApplicationGroupsApi applicationGroupsApi; + private final String adminGroupName; + + private static final String OKTA_ORG_PROFILE_MATCHER = "profile.name sw \""; + private static final int OKTA_PAGE_SIZE = 500; + + public LiveOktaRepository( + AuthorizationProperties authorizationProperties, + @Value("${okta.oauth2.client-id}") String oktaOAuth2ClientId, + OrganizationExtractor organizationExtractor, + CurrentTenantDataAccessContextHolder tenantDataContextHolder, + GroupApi groupApi, + ApplicationApi applicationApi, + UserApi userApi, + ApplicationGroupsApi applicationGroupsApi) { + this.rolePrefix = authorizationProperties.getRolePrefix(); + this.adminGroupName = authorizationProperties.getAdminGroupName(); + + this.applicationApi = applicationApi; + this.groupApi = groupApi; + this.userApi = userApi; + this.applicationGroupsApi = applicationGroupsApi; + + try { + this.app = applicationApi.getApplication(oktaOAuth2ClientId, null); + } catch (ApiException e) { + throw new MisconfiguredApplicationException( + "Cannot find Okta application with id=" + oktaOAuth2ClientId, e); + } + + this.extractor = organizationExtractor; + this.tenantDataContextHolder = tenantDataContextHolder; + } + + @Override + public Optional createUser( + IdentityAttributes userIdentity, + Organization org, + Set facilities, + Set roles, + boolean active) { + // By default, when creating a user, we give them privileges of a standard user + String organizationExternalId = org.getExternalId(); + Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); + rolesToCreate.addAll(roles); + + // Add user to new groups + Set groupNamesToAdd = new HashSet<>(); + groupNamesToAdd.addAll( + rolesToCreate.stream() + .map(r -> generateRoleGroupName(organizationExternalId, r)) + .collect(Collectors.toSet())); + groupNamesToAdd.addAll( + facilities.stream() + // use an empty set of facilities if user can access all facilities anyway + .filter(f -> !PermissionHolder.grantsAllFacilityAccess(rolesToCreate)) + .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) + .collect(Collectors.toSet())); + + // Search and q results need to be combined because search results have a delay of the newest + // added groups. + // https://github.com/okta/okta-sdk-java/issues/750 + var searchResults = + groupApi + .listGroups( + null, + null, + null, + null, + null, + OKTA_ORG_PROFILE_MATCHER + generateGroupOrgPrefix(organizationExternalId) + "\"", + null, + null) + .stream(); + var qResults = + groupApi + .listGroups( + generateGroupOrgPrefix(organizationExternalId), + null, + null, + null, + null, + null, + null, + null) + .stream(); + var orgGroups = Stream.concat(searchResults, qResults).distinct().toList(); + throwErrorIfEmpty( + orgGroups.stream(), + String.format( + "Cannot add Okta user to nonexistent organization=%s", organizationExternalId)); + Set orgGroupNames = + orgGroups.stream().map(g -> g.getProfile().getName()).collect(Collectors.toSet()); + groupNamesToAdd.stream() + .filter(n -> !orgGroupNames.contains(n)) + .forEach( + n -> { + throw new IllegalGraphqlArgumentException( + String.format("Cannot add Okta user to nonexistent group=%s", n)); + }); + Set groupIdsToAdd = + orgGroups.stream() + .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) + .map(Group::getId) + .collect(Collectors.toSet()); + validateRequiredFields(userIdentity); + try { + var user = + UserBuilder.instance() + .setFirstName(userIdentity.getFirstName()) + .setMiddleName(userIdentity.getMiddleName()) + .setLastName(userIdentity.getLastName()) + .setHonorificSuffix(userIdentity.getSuffix()) + .setEmail(userIdentity.getUsername()) + .setLogin(userIdentity.getUsername()) + .setActive(active) + .buildAndCreate(userApi); + groupIdsToAdd.forEach(groupId -> groupApi.assignUserToGroup(groupId, user.getId())); + } catch (ApiException e) { + if (e.getMessage() + .contains("An object with this field already exists in the current organization")) { + throw new ConflictingUserException(); + } else { + throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); + } + } + + List claims = extractor.convertClaims(groupNamesToAdd); + if (claims.size() != 1) { + log.warn("User is in {} Okta organizations, not 1", claims.size()); + return Optional.empty(); + } + return Optional.of(claims.get(0)); + } + + private static void validateRequiredFields(IdentityAttributes userIdentity) { + if (StringUtils.isBlank(userIdentity.getLastName())) { + throw new IllegalGraphqlArgumentException("Cannot create Okta user without last name"); + } + if (StringUtils.isBlank(userIdentity.getUsername())) { + throw new IllegalGraphqlArgumentException("Cannot create Okta user without username"); + } + } + + @Override + public Set getAllUsersForOrganization(Organization org) { + return getAllUsersForOrg(org).stream() + .map(u -> u.getProfile().getLogin()) + .collect(Collectors.toUnmodifiableSet()); + } + + @Override + public Map getAllUsersWithStatusForOrganization(Organization org) { + return getAllUsersForOrg(org).stream() + .collect(Collectors.toMap(u -> u.getProfile().getLogin(), User::getStatus)); + } + + private List getAllUsersForOrg(Organization org) { + PagedList pagedUserList = new PagedList<>(); + List allUsers = new ArrayList<>(); + Group orgDefaultOktaGroup = getDefaultOktaGroup(org); + do { + pagedUserList = + (PagedList) + groupApi.listGroupUsers( + orgDefaultOktaGroup.getId(), pagedUserList.getAfter(), OKTA_PAGE_SIZE); + allUsers.addAll(pagedUserList); + } while (pagedUserList.hasMoreItems()); + return allUsers; + } + + private Group getDefaultOktaGroup(Organization org) { + final String orgDefaultGroupName = + generateRoleGroupName(org.getExternalId(), OrganizationRole.getDefault()); + final var oktaGroupList = + groupApi.listGroups(orgDefaultGroupName, null, null, null, null, null, null, null); + + return oktaGroupList.stream() + .filter(g -> orgDefaultGroupName.equals(g.getProfile().getName())) + .findFirst() + .orElseThrow(() -> new IllegalGraphqlArgumentException(OKTA_GROUP_NOT_FOUND)); + } + + @Override + public Optional updateUser(IdentityAttributes userIdentity) { + var user = + getUserOrThrowError( + userIdentity.getUsername(), "Cannot update Okta user with unrecognized username"); + updateUser(user, userIdentity); + + return getOrganizationRoleClaimsForUser(user); + } + + private void updateUser(User user, IdentityAttributes userIdentity) { + user.getProfile().setFirstName(userIdentity.getFirstName()); + user.getProfile().setMiddleName(userIdentity.getMiddleName()); + user.getProfile().setLastName(userIdentity.getLastName()); + // Is it our fault we don't accommodate honorific suffix? Or Okta's fault they + // don't have regular suffix? You decide. + user.getProfile().setHonorificSuffix(userIdentity.getSuffix()); + var updateRequest = new UpdateUserRequest(); + updateRequest.setProfile(user.getProfile()); + userApi.updateUser(user.getId(), updateRequest, false); + } + + @Override + public Optional updateUserEmail( + IdentityAttributes userIdentity, String email) { + var user = + getUserOrThrowError( + userIdentity.getUsername(), + "Cannot update email of Okta user with unrecognized username"); + UserProfile profile = user.getProfile(); + profile.setLogin(email); + profile.setEmail(email); + user.setProfile(profile); + var updateRequest = new UpdateUserRequest(); + updateRequest.setProfile(profile); + try { + userApi.updateUser(user.getId(), updateRequest, false); + } catch (ApiException e) { + if (e.getMessage() + .contains("An object with this field already exists in the current organization")) { + throw new ConflictingUserException(); + } else { + throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); + } + } + + return getOrganizationRoleClaimsForUser(user); + } + + @Override + public void reprovisionUser(IdentityAttributes userIdentity) { + var user = + getUserOrThrowError( + userIdentity.getUsername(), "Cannot reprovision Okta user with unrecognized username"); + UserStatus userStatus = user.getStatus(); + + // any org user "deleted" through our api will be in SUSPENDED state + if (userStatus != UserStatus.SUSPENDED) { + throw new ConflictingUserException(); + } + + updateUser(user, userIdentity); + userApi.resetFactors(user.getId()); + + // transitioning from SUSPENDED -> DEPROVISIONED -> ACTIVE will reset the user's password and + // password reset question. This cannot be done with `.reactivateUser()` because it requires the + // user to be in PROVISIONED state + userApi.deactivateUser(user.getId(), false); + userApi.activateUser(user.getId(), true); + } + + @Override + public List updateUserPrivilegesAndGroupAccess( + String username, + Organization org, + Set facilities, + OrganizationRole role, + boolean assignedToAllFacilities) { + + // unassign user from current groups + + User oktaUserToMove = getUserOrThrowError(username, "Couldn't find user"); + List groupsToUnassign = userApi.listUserGroups(oktaUserToMove.getId()); + + groupsToUnassign.stream() + // only match on the org-related group ids and not the Okta-wide orgs like "Everyone" + .filter(g -> g.getProfile().getName().contains("TENANT")) + .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), oktaUserToMove.getId())); + + // add them to the new groups + String organizationExternalId = org.getExternalId(); + EnumSet rolesToCreate = + assignedToAllFacilities + ? EnumSet.of(OrganizationRole.getDefault(), role, OrganizationRole.ALL_FACILITIES) + : EnumSet.of(OrganizationRole.getDefault(), role); + + Set groupNamesToAdd = new HashSet<>(); + groupNamesToAdd.addAll( + rolesToCreate.stream() + .map(r -> generateRoleGroupName(organizationExternalId, r)) + .collect(Collectors.toSet())); + + groupNamesToAdd.addAll( + facilities.stream() + .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) + .collect(Collectors.toSet())); + + String groupOrgPrefix = generateGroupOrgPrefix(org.getExternalId()); + Map orgsToAddUserToMap = + groupApi + .listGroups( + null, + null, + null, + null, + null, + OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", + null, + null) + .stream() + .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) + .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); + + orgsToAddUserToMap.forEach( + (name, group) -> groupApi.assignUserToGroup(group.getId(), oktaUserToMove.getId())); + return orgsToAddUserToMap.keySet().stream().toList(); + } + + @Override + public Optional updateUserPrivileges( + String username, Organization org, Set facilities, Set roles) { + User user = + getUserOrThrowError(username, "Cannot update role of Okta user with unrecognized username"); + + String orgId = org.getExternalId(); + + final String groupOrgPrefix = generateGroupOrgPrefix(orgId); + final String groupOrgDefaultName = generateRoleGroupName(orgId, OrganizationRole.getDefault()); + + // Map user's current Okta group memberships (Okta group name -> Okta Group). + // The Okta group name is our friendly role and facility group names + Map currentOrgGroupMapForUser = + userApi.listUserGroups(user.getId()).stream() + .filter( + g -> + GroupType.OKTA_GROUP == g.getType() + && g.getProfile().getName().startsWith(groupOrgPrefix)) + .collect(Collectors.toMap(g -> g.getProfile().getName(), g -> g)); + + if (!currentOrgGroupMapForUser.containsKey(groupOrgDefaultName)) { + // The user is not a member of the default group for this organization. If they happen + // to be in any of this organization's groups, remove the user from those groups. + currentOrgGroupMapForUser + .values() + .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), user.getId())); + throw new IllegalGraphqlArgumentException( + "Cannot update privileges of Okta user in organization they do not belong to."); + } + + Set expectedOrgGroupNamesForUser = new HashSet<>(); + expectedOrgGroupNamesForUser.add(groupOrgDefaultName); + expectedOrgGroupNamesForUser.addAll( + roles.stream().map(r -> generateRoleGroupName(orgId, r)).collect(Collectors.toSet())); + if (!PermissionHolder.grantsAllFacilityAccess(roles)) { + expectedOrgGroupNamesForUser.addAll( + facilities.stream() + .map(f -> generateFacilityGroupName(orgId, f.getInternalId())) + .collect(Collectors.toSet())); + } + + // to remove... + Set groupNamesToRemove = new HashSet<>(currentOrgGroupMapForUser.keySet()); + groupNamesToRemove.removeIf(expectedOrgGroupNamesForUser::contains); + + // to add... + Set groupNamesToAdd = new HashSet<>(expectedOrgGroupNamesForUser); + groupNamesToAdd.removeIf(currentOrgGroupMapForUser::containsKey); + + if (!groupNamesToRemove.isEmpty() || !groupNamesToAdd.isEmpty()) { + Map fullOrgGroupMap = + groupApi + .listGroups( + null, + null, + null, + null, + null, + OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", + null, + null) + .stream() + .filter(g -> GroupType.OKTA_GROUP == g.getType()) + .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); + if (fullOrgGroupMap.size() == 0) { + throw new IllegalGraphqlArgumentException( + String.format("Cannot add Okta user to nonexistent organization=%s", orgId)); + } + + for (String groupName : groupNamesToRemove) { + Group group = fullOrgGroupMap.get(groupName); + log.info("Removing {} from Okta group: {}", username, group.getProfile().getName()); + groupApi.unassignUserFromGroup(group.getId(), user.getId()); + } + + for (String groupName : groupNamesToAdd) { + if (!fullOrgGroupMap.containsKey(groupName)) { + throw new IllegalGraphqlArgumentException( + String.format("Cannot add Okta user to nonexistent group=%s", groupName)); } - - updateUser(user, userIdentity); - userApi.resetFactors(user.getId()); - - // transitioning from SUSPENDED -> DEPROVISIONED -> ACTIVE will reset the user's password and - // password reset question. This cannot be done with `.reactivateUser()` because it requires the - // user to be in PROVISIONED state - userApi.deactivateUser(user.getId(), false); - userApi.activateUser(user.getId(), true); - } - - @Override - public List updateUserPrivilegesAndGroupAccess( - String username, - Organization org, - Set facilities, - OrganizationRole role, - boolean assignedToAllFacilities) { - - // unassign user from current groups - - User oktaUserToMove = getUserOrThrowError(username, "Couldn't find user"); - List groupsToUnassign = userApi.listUserGroups(oktaUserToMove.getId()); - - groupsToUnassign.stream() - // only match on the org-related group ids and not the Okta-wide orgs like "Everyone" - .filter(g -> g.getProfile().getName().contains("TENANT")) - .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), oktaUserToMove.getId())); - - // add them to the new groups - String organizationExternalId = org.getExternalId(); - EnumSet rolesToCreate = - assignedToAllFacilities - ? EnumSet.of(OrganizationRole.getDefault(), role, OrganizationRole.ALL_FACILITIES) - : EnumSet.of(OrganizationRole.getDefault(), role); - - Set groupNamesToAdd = new HashSet<>(); - groupNamesToAdd.addAll( - rolesToCreate.stream() - .map(r -> generateRoleGroupName(organizationExternalId, r)) - .collect(Collectors.toSet())); - - groupNamesToAdd.addAll( - facilities.stream() - .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) - .collect(Collectors.toSet())); - - String groupOrgPrefix = generateGroupOrgPrefix(org.getExternalId()); - Map orgsToAddUserToMap = - groupApi - .listGroups( - null, - null, - null, - null, - null, - OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", - null, - null) - .stream() - .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) - .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); - - orgsToAddUserToMap.forEach( - (name, group) -> groupApi.assignUserToGroup(group.getId(), oktaUserToMove.getId())); - return orgsToAddUserToMap.keySet().stream().toList(); - } - - @Override - public Optional updateUserPrivileges( - String username, Organization org, Set facilities, Set roles) { - User user = - getUserOrThrowError(username, "Cannot update role of Okta user with unrecognized username"); - - String orgId = org.getExternalId(); - - final String groupOrgPrefix = generateGroupOrgPrefix(orgId); - final String groupOrgDefaultName = generateRoleGroupName(orgId, OrganizationRole.getDefault()); - - // Map user's current Okta group memberships (Okta group name -> Okta Group). - // The Okta group name is our friendly role and facility group names - Map currentOrgGroupMapForUser = - userApi.listUserGroups(user.getId()).stream() - .filter( - g -> - GroupType.OKTA_GROUP == g.getType() - && g.getProfile().getName().startsWith(groupOrgPrefix)) - .collect(Collectors.toMap(g -> g.getProfile().getName(), g -> g)); - - if (!currentOrgGroupMapForUser.containsKey(groupOrgDefaultName)) { - // The user is not a member of the default group for this organization. If they happen - // to be in any of this organization's groups, remove the user from those groups. - currentOrgGroupMapForUser - .values() - .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), user.getId())); - throw new IllegalGraphqlArgumentException( - "Cannot update privileges of Okta user in organization they do not belong to."); - } - - Set expectedOrgGroupNamesForUser = new HashSet<>(); - expectedOrgGroupNamesForUser.add(groupOrgDefaultName); - expectedOrgGroupNamesForUser.addAll( - roles.stream().map(r -> generateRoleGroupName(orgId, r)).collect(Collectors.toSet())); - if (!PermissionHolder.grantsAllFacilityAccess(roles)) { - expectedOrgGroupNamesForUser.addAll( - facilities.stream() - .map(f -> generateFacilityGroupName(orgId, f.getInternalId())) - .collect(Collectors.toSet())); - } - - // to remove... - Set groupNamesToRemove = new HashSet<>(currentOrgGroupMapForUser.keySet()); - groupNamesToRemove.removeIf(expectedOrgGroupNamesForUser::contains); - - // to add... - Set groupNamesToAdd = new HashSet<>(expectedOrgGroupNamesForUser); - groupNamesToAdd.removeIf(currentOrgGroupMapForUser::containsKey); - - if (!groupNamesToRemove.isEmpty() || !groupNamesToAdd.isEmpty()) { - Map fullOrgGroupMap = - groupApi - .listGroups( - null, - null, - null, - null, - null, - OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", - null, - null) - .stream() - .filter(g -> GroupType.OKTA_GROUP == g.getType()) - .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); - if (fullOrgGroupMap.size() == 0) { - throw new IllegalGraphqlArgumentException( - String.format("Cannot add Okta user to nonexistent organization=%s", orgId)); - } - - for (String groupName : groupNamesToRemove) { - Group group = fullOrgGroupMap.get(groupName); - log.info("Removing {} from Okta group: {}", username, group.getProfile().getName()); - groupApi.unassignUserFromGroup(group.getId(), user.getId()); - } - - for (String groupName : groupNamesToAdd) { - if (!fullOrgGroupMap.containsKey(groupName)) { - throw new IllegalGraphqlArgumentException( - String.format("Cannot add Okta user to nonexistent group=%s", groupName)); - } - Group group = fullOrgGroupMap.get(groupName); - log.info("Adding {} to Okta group: {}", username, group.getProfile().getName()); - groupApi.assignUserToGroup(group.getId(), user.getId()); - } - } - - return getOrganizationRoleClaimsForUser(user); - } - - @Override - public void resetUserPassword(String username) { - var user = - getUserOrThrowError( - username, "Cannot reset password for Okta user with unrecognized username"); - userApi.generateResetPasswordToken(user.getId(), true, false); - } - - @Override - public void resetUserMfa(String username) { - var user = - getUserOrThrowError(username, "Cannot reset MFA for Okta user with unrecognized username"); - userApi.resetFactors(user.getId()); - } - - @Override - public void setUserIsActive(String username, boolean active) { - var user = - getUserOrThrowError( - username, "Cannot update active status of Okta user with unrecognized username"); - - if (active && user.getStatus() == UserStatus.SUSPENDED) { - userApi.unsuspendUser(user.getId()); - } else if (!active && user.getStatus() != UserStatus.SUSPENDED) { - userApi.suspendUser(user.getId()); - } - } - - @Override - public UserStatus getUserStatus(String username) { - return getUserOrThrowError( - username, "Cannot retrieve Okta user's status with unrecognized username") - .getStatus(); - } - - @Override - public void reactivateUser(String username) { - var user = - getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); - userApi.unsuspendUser(user.getId()); - } - - @Override - public void resendActivationEmail(String username) { - var user = - getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); - if (user.getStatus() == UserStatus.PROVISIONED) { - userApi.reactivateUser(user.getId(), true); - } else if (user.getStatus() == UserStatus.STAGED) { - userApi.activateUser(user.getId(), true); - } else { - throw new IllegalGraphqlArgumentException( - "Cannot reactivate user with status: " + user.getStatus()); - } - } - - /** - * Iterates over all OrganizationRole's, creating new corresponding Okta groups for this - * organization where they do not already exist. For those OrganizationRole's that are in - * MIGRATION_DEST_ROLES and whose Okta groups are newly created, migrate all users from this org - * to those new Okta groups, where the migrated users are sourced from all pre-existing Okta - * groups for this organization. Separately, iterates over all facilities in this org, creating - * new corresponding Okta groups where they do not already exist. Does not perform any migration - * to these facility groups. - */ - @Override - public void createOrganization(Organization org) { - String name = org.getOrganizationName(); - String externalId = org.getExternalId(); - - for (OrganizationRole role : OrganizationRole.values()) { - String roleGroupName = generateRoleGroupName(externalId, role); - String roleGroupDescription = generateRoleGroupDescription(name, role); - Group g = - GroupBuilder.instance() - .setName(roleGroupName) - .setDescription(roleGroupDescription) - .buildAndCreate(groupApi); - applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); - - log.info("Created Okta group={}", roleGroupName); - } - } - - private List getOrgAdminUsers(Organization org) { - String externalId = org.getExternalId(); - String roleGroupName = generateRoleGroupName(externalId, OrganizationRole.ADMIN); - var groups = groupApi.listGroups(roleGroupName, null, null, null, null, null, null, null); - throwErrorIfEmpty(groups.stream(), "Cannot activate nonexistent Okta organization"); - Group group = groups.get(0); - return groupApi.listGroupUsers(group.getId(), null, null); - } - - private String activateUser(User user) { - if (user.getStatus() == UserStatus.PROVISIONED) { - // reactivates user and sends them an Okta email to reactivate their account - return userApi.reactivateUser(user.getId(), true).getActivationToken(); - } else if (user.getStatus() == UserStatus.STAGED) { - return userApi.activateUser(user.getId(), true).getActivationToken(); - } else { - throw new IllegalGraphqlArgumentException( - "Cannot activate Okta organization whose users have status=" + user.getStatus().name()); - } - } - - @Override - public void activateOrganization(Organization org) { - var users = getOrgAdminUsers(org); - for (User u : users) { - activateUser(u); - } - } - - @Override - public String activateOrganizationWithSingleUser(Organization org) { - User user = getOrgAdminUsers(org).get(0); - return activateUser(user); - } - - @Override - public List fetchAdminUserEmail(Organization org) { - var admins = getOrgAdminUsers(org); - return admins.stream().map(u -> u.getProfile().getLogin()).toList(); - } - - @Override - public void createFacility(Facility facility) { - // Only create the facility group if the facility's organization has already been created - String orgExternalId = facility.getOrganization().getExternalId(); - var orgGroups = - groupApi.listGroups( - generateGroupOrgPrefix(orgExternalId), null, null, null, null, null, null, null); - throwErrorIfEmpty( - orgGroups.stream(), - String.format( - "Cannot create Okta group for facility=%s: facility's org=%s, has not yet been created in Okta", - facility.getFacilityName(), facility.getOrganization().getExternalId())); - - String orgName = facility.getOrganization().getOrganizationName(); - String facilityGroupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); - Group g = - GroupBuilder.instance() - .setName(facilityGroupName) - .setDescription(generateFacilityGroupDescription(orgName, facility.getFacilityName())) - .buildAndCreate(groupApi); - applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); - - log.info("Created Okta group={}", facilityGroupName); - } - - public void deleteFacility(Facility facility) { - String orgExternalId = facility.getOrganization().getExternalId(); - String groupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); - var groups = groupApi.listGroups(groupName, null, null, null, null, null, null, null); - for (Group group : groups) { - groupApi.deleteGroup(group.getId()); - } - } - - @Override - public void deleteOrganization(Organization org) { - String externalId = org.getExternalId(); - var orgGroups = - groupApi.listGroups( - generateGroupOrgPrefix(externalId), null, null, null, null, null, null, null); - for (Group group : orgGroups) { - groupApi.deleteGroup(group.getId()); - } - } - - // returns the external ID of the organization the specified user belongs to - @Override - public Optional getOrganizationRoleClaimsForUser(String username) { - // When a site admin is using tenant data access, bypass okta and get org from the altered - // authorities. If the site admin is getting the claims for another site admin who also has - // active tenant data access, then reflect what is in Okta, not the temporary claims. - if (tenantDataContextHolder.hasBeenPopulated() - && username.equals(tenantDataContextHolder.getUsername())) { - return getOrganizationRoleClaimsFromAuthorities(tenantDataContextHolder.getAuthorities()); - } - - return getOrganizationRoleClaimsForUser( - getUserOrThrowError(username, "Cannot get org external ID for nonexistent user")); - } - - public Integer getUsersInSingleFacility(Facility facility) { - String facilityAccessGroupName = - generateFacilityGroupName( - facility.getOrganization().getExternalId(), facility.getInternalId()); - - List facilityAccessGroup = - groupApi.listGroups(facilityAccessGroupName, null, null, 1, "stats", null, null, null); - - if (facilityAccessGroup.isEmpty()) { - return 0; - } - - try { - LinkedHashMap stats = - (LinkedHashMap) facilityAccessGroup.get(0).getEmbedded().get("stats"); - return ((Integer) stats.get("usersCount")); - } catch (NullPointerException e) { - throw new BadRequestException("Unable to retrieve okta group stats", e); - } - } - - public PartialOktaUser findUser(String username) { - User user = - getUserOrThrowError( - username, "Cannot retrieve Okta user's status with unrecognized username"); - - List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); - - Optional orgClaims = convertToOrganizationRoleClaims(userGroups); - - return PartialOktaUser.builder() - .username(username) - .isSiteAdmin(isSiteAdmin(userGroups)) - .status(user.getStatus()) - .organizationRoleClaims(orgClaims) - .build(); - } - - public int getConnectTimeoutForHealthCheck() { - return applicationApi.getApiClient().getConnectTimeout(); - } - - private Optional getOrganizationRoleClaimsFromAuthorities( - Collection authorities) { - List claims = extractor.convertClaims(authorities); - - if (claims.size() != 1) { - log.warn("User's Tenant Data Access has claims in {} organizations, not 1", claims.size()); - return Optional.empty(); - } - return Optional.of(claims.get(0)); - } - - private Optional getOrganizationRoleClaimsForUser(User user) { - List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); - return convertToOrganizationRoleClaims(userGroups); - } - - private Optional convertToOrganizationRoleClaims(List userGroups) { - List groupNames = - userGroups.stream() - .filter(g -> g.getType() == GroupType.OKTA_GROUP) - .map(g -> g.getProfile().getName()) - .toList(); - List claims = extractor.convertClaims(groupNames); - - if (claims.size() != 1) { - log.warn("User is in {} Okta organizations, not 1", claims.size()); - return Optional.empty(); - } - return Optional.of(claims.get(0)); - } - - private boolean isSiteAdmin(List oktaGroups) { - return oktaGroups.stream() - .filter(g -> g.getType() == GroupType.OKTA_GROUP) - .anyMatch(g -> adminGroupName.equals(g.getProfile().getName())); - } - - private String generateGroupOrgPrefix(String orgExternalId) { - return String.format("%s%s", rolePrefix, orgExternalId); - } - - private String generateRoleGroupName(String orgExternalId, OrganizationRole role) { - return String.format("%s%s%s", rolePrefix, orgExternalId, generateRoleSuffix(role)); - } - - private String generateFacilityGroupName(String orgExternalId, UUID facilityId) { - return String.format( - "%s%s%s", rolePrefix, orgExternalId, generateFacilitySuffix(facilityId.toString())); - } - - private String generateRoleGroupDescription(String orgName, OrganizationRole role) { - return String.format("%s - %ss", orgName, role.getDescription()); - } - - private String generateFacilityGroupDescription(String orgName, String facilityName) { - return String.format("%s - Facility Access - %s", orgName, facilityName); - } - - private String generateRoleSuffix(OrganizationRole role) { - return ":" + role.name(); - } - - private String generateFacilitySuffix(String facilityId) { - return ":" + OrganizationExtractor.FACILITY_ACCESS_MARKER + ":" + facilityId; - } - - private User getUserOrThrowError(String email, String errorMessage) { - try { - return userApi.getUser(email); - } catch (ApiException e) { - throw new IllegalGraphqlArgumentException(errorMessage); - } - } - - private void throwErrorIfEmpty(Stream stream, String errorMessage) { - if (stream.findAny().isEmpty()) { - throw new IllegalGraphqlArgumentException(errorMessage); - } - } - - private String prettifyOktaError(ApiException e) { - var errorMessage = "Code: " + e.getCode() + "; Message: " + e.getMessage(); - if (e.getResponseBody() != null) { - Error error = ApiExceptionHelper.getError(e); - if (error != null) { - errorMessage = - "Okta Error: " + error.getErrorCode() + ", Error summary: " + error.getErrorSummary(); - if (error.getErrorCauses() != null) { - errorMessage += - ", Error Cause(s): " - + error.getErrorCauses().stream() - .map(ErrorErrorCausesInner::getErrorSummary) - .collect(Collectors.joining(", ")); - } - } + Group group = fullOrgGroupMap.get(groupName); + log.info("Adding {} to Okta group: {}", username, group.getProfile().getName()); + groupApi.assignUserToGroup(group.getId(), user.getId()); + } + } + + return getOrganizationRoleClaimsForUser(user); + } + + @Override + public void resetUserPassword(String username) { + var user = + getUserOrThrowError( + username, "Cannot reset password for Okta user with unrecognized username"); + userApi.generateResetPasswordToken(user.getId(), true, false); + } + + @Override + public void resetUserMfa(String username) { + var user = + getUserOrThrowError(username, "Cannot reset MFA for Okta user with unrecognized username"); + userApi.resetFactors(user.getId()); + } + + @Override + public void setUserIsActive(String username, boolean active) { + var user = + getUserOrThrowError( + username, "Cannot update active status of Okta user with unrecognized username"); + + if (active && user.getStatus() == UserStatus.SUSPENDED) { + userApi.unsuspendUser(user.getId()); + } else if (!active && user.getStatus() != UserStatus.SUSPENDED) { + userApi.suspendUser(user.getId()); + } + } + + @Override + public UserStatus getUserStatus(String username) { + return getUserOrThrowError( + username, "Cannot retrieve Okta user's status with unrecognized username") + .getStatus(); + } + + @Override + public void reactivateUser(String username) { + var user = + getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); + userApi.unsuspendUser(user.getId()); + } + + @Override + public void resendActivationEmail(String username) { + var user = + getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); + if (user.getStatus() == UserStatus.PROVISIONED) { + userApi.reactivateUser(user.getId(), true); + } else if (user.getStatus() == UserStatus.STAGED) { + userApi.activateUser(user.getId(), true); + } else { + throw new IllegalGraphqlArgumentException( + "Cannot reactivate user with status: " + user.getStatus()); + } + } + + /** + * Iterates over all OrganizationRole's, creating new corresponding Okta groups for this + * organization where they do not already exist. For those OrganizationRole's that are in + * MIGRATION_DEST_ROLES and whose Okta groups are newly created, migrate all users from this org + * to those new Okta groups, where the migrated users are sourced from all pre-existing Okta + * groups for this organization. Separately, iterates over all facilities in this org, creating + * new corresponding Okta groups where they do not already exist. Does not perform any migration + * to these facility groups. + */ + @Override + public void createOrganization(Organization org) { + String name = org.getOrganizationName(); + String externalId = org.getExternalId(); + + for (OrganizationRole role : OrganizationRole.values()) { + String roleGroupName = generateRoleGroupName(externalId, role); + String roleGroupDescription = generateRoleGroupDescription(name, role); + Group g = + GroupBuilder.instance() + .setName(roleGroupName) + .setDescription(roleGroupDescription) + .buildAndCreate(groupApi); + applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); + + log.info("Created Okta group={}", roleGroupName); + } + } + + private List getOrgAdminUsers(Organization org) { + String externalId = org.getExternalId(); + String roleGroupName = generateRoleGroupName(externalId, OrganizationRole.ADMIN); + var groups = groupApi.listGroups(roleGroupName, null, null, null, null, null, null, null); + throwErrorIfEmpty(groups.stream(), "Cannot activate nonexistent Okta organization"); + Group group = groups.get(0); + return groupApi.listGroupUsers(group.getId(), null, null); + } + + private String activateUser(User user) { + if (user.getStatus() == UserStatus.PROVISIONED) { + // reactivates user and sends them an Okta email to reactivate their account + return userApi.reactivateUser(user.getId(), true).getActivationToken(); + } else if (user.getStatus() == UserStatus.STAGED) { + return userApi.activateUser(user.getId(), true).getActivationToken(); + } else { + throw new IllegalGraphqlArgumentException( + "Cannot activate Okta organization whose users have status=" + user.getStatus().name()); + } + } + + @Override + public void activateOrganization(Organization org) { + var users = getOrgAdminUsers(org); + for (User u : users) { + activateUser(u); + } + } + + @Override + public String activateOrganizationWithSingleUser(Organization org) { + User user = getOrgAdminUsers(org).get(0); + return activateUser(user); + } + + @Override + public List fetchAdminUserEmail(Organization org) { + var admins = getOrgAdminUsers(org); + return admins.stream().map(u -> u.getProfile().getLogin()).toList(); + } + + @Override + public void createFacility(Facility facility) { + // Only create the facility group if the facility's organization has already been created + String orgExternalId = facility.getOrganization().getExternalId(); + var orgGroups = + groupApi.listGroups( + generateGroupOrgPrefix(orgExternalId), null, null, null, null, null, null, null); + throwErrorIfEmpty( + orgGroups.stream(), + String.format( + "Cannot create Okta group for facility=%s: facility's org=%s, has not yet been created in Okta", + facility.getFacilityName(), facility.getOrganization().getExternalId())); + + String orgName = facility.getOrganization().getOrganizationName(); + String facilityGroupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); + Group g = + GroupBuilder.instance() + .setName(facilityGroupName) + .setDescription(generateFacilityGroupDescription(orgName, facility.getFacilityName())) + .buildAndCreate(groupApi); + applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); + + log.info("Created Okta group={}", facilityGroupName); + } + + public void deleteFacility(Facility facility) { + String orgExternalId = facility.getOrganization().getExternalId(); + String groupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); + var groups = groupApi.listGroups(groupName, null, null, null, null, null, null, null); + for (Group group : groups) { + groupApi.deleteGroup(group.getId()); + } + } + + @Override + public void deleteOrganization(Organization org) { + String externalId = org.getExternalId(); + var orgGroups = + groupApi.listGroups( + generateGroupOrgPrefix(externalId), null, null, null, null, null, null, null); + for (Group group : orgGroups) { + groupApi.deleteGroup(group.getId()); + } + } + + // returns the external ID of the organization the specified user belongs to + @Override + public Optional getOrganizationRoleClaimsForUser(String username) { + // When a site admin is using tenant data access, bypass okta and get org from the altered + // authorities. If the site admin is getting the claims for another site admin who also has + // active tenant data access, then reflect what is in Okta, not the temporary claims. + if (tenantDataContextHolder.hasBeenPopulated() + && username.equals(tenantDataContextHolder.getUsername())) { + return getOrganizationRoleClaimsFromAuthorities(tenantDataContextHolder.getAuthorities()); + } + + return getOrganizationRoleClaimsForUser( + getUserOrThrowError(username, "Cannot get org external ID for nonexistent user")); + } + + public Integer getUsersInSingleFacility(Facility facility) { + String facilityAccessGroupName = + generateFacilityGroupName( + facility.getOrganization().getExternalId(), facility.getInternalId()); + + List facilityAccessGroup = + groupApi.listGroups(facilityAccessGroupName, null, null, 1, "stats", null, null, null); + + if (facilityAccessGroup.isEmpty()) { + return 0; + } + + try { + LinkedHashMap stats = + (LinkedHashMap) facilityAccessGroup.get(0).getEmbedded().get("stats"); + return ((Integer) stats.get("usersCount")); + } catch (NullPointerException e) { + throw new BadRequestException("Unable to retrieve okta group stats", e); + } + } + + public PartialOktaUser findUser(String username) { + User user = + getUserOrThrowError( + username, "Cannot retrieve Okta user's status with unrecognized username"); + + List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); + + Optional orgClaims = convertToOrganizationRoleClaims(userGroups); + + return PartialOktaUser.builder() + .username(username) + .isSiteAdmin(isSiteAdmin(userGroups)) + .status(user.getStatus()) + .organizationRoleClaims(orgClaims) + .build(); + } + + public int getConnectTimeoutForHealthCheck() { + return applicationApi.getApiClient().getConnectTimeout(); + } + + private Optional getOrganizationRoleClaimsFromAuthorities( + Collection authorities) { + List claims = extractor.convertClaims(authorities); + + if (claims.size() != 1) { + log.warn("User's Tenant Data Access has claims in {} organizations, not 1", claims.size()); + return Optional.empty(); + } + return Optional.of(claims.get(0)); + } + + private Optional getOrganizationRoleClaimsForUser(User user) { + List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); + return convertToOrganizationRoleClaims(userGroups); + } + + private Optional convertToOrganizationRoleClaims(List userGroups) { + List groupNames = + userGroups.stream() + .filter(g -> g.getType() == GroupType.OKTA_GROUP) + .map(g -> g.getProfile().getName()) + .toList(); + List claims = extractor.convertClaims(groupNames); + + if (claims.size() != 1) { + log.warn("User is in {} Okta organizations, not 1", claims.size()); + return Optional.empty(); + } + return Optional.of(claims.get(0)); + } + + private boolean isSiteAdmin(List oktaGroups) { + return oktaGroups.stream() + .filter(g -> g.getType() == GroupType.OKTA_GROUP) + .anyMatch(g -> adminGroupName.equals(g.getProfile().getName())); + } + + private String generateGroupOrgPrefix(String orgExternalId) { + return String.format("%s%s", rolePrefix, orgExternalId); + } + + private String generateRoleGroupName(String orgExternalId, OrganizationRole role) { + return String.format("%s%s%s", rolePrefix, orgExternalId, generateRoleSuffix(role)); + } + + private String generateFacilityGroupName(String orgExternalId, UUID facilityId) { + return String.format( + "%s%s%s", rolePrefix, orgExternalId, generateFacilitySuffix(facilityId.toString())); + } + + private String generateRoleGroupDescription(String orgName, OrganizationRole role) { + return String.format("%s - %ss", orgName, role.getDescription()); + } + + private String generateFacilityGroupDescription(String orgName, String facilityName) { + return String.format("%s - Facility Access - %s", orgName, facilityName); + } + + private String generateRoleSuffix(OrganizationRole role) { + return ":" + role.name(); + } + + private String generateFacilitySuffix(String facilityId) { + return ":" + OrganizationExtractor.FACILITY_ACCESS_MARKER + ":" + facilityId; + } + + private User getUserOrThrowError(String email, String errorMessage) { + try { + return userApi.getUser(email); + } catch (ApiException e) { + throw new IllegalGraphqlArgumentException(errorMessage); + } + } + + private void throwErrorIfEmpty(Stream stream, String errorMessage) { + if (stream.findAny().isEmpty()) { + throw new IllegalGraphqlArgumentException(errorMessage); + } + } + + private String prettifyOktaError(ApiException e) { + var errorMessage = "Code: " + e.getCode() + "; Message: " + e.getMessage(); + if (e.getResponseBody() != null) { + Error error = ApiExceptionHelper.getError(e); + if (error != null) { + errorMessage = + "Okta Error: " + error.getErrorCode() + ", Error summary: " + error.getErrorSummary(); + if (error.getErrorCauses() != null) { + errorMessage += + ", Error Cause(s): " + + error.getErrorCauses().stream() + .map(ErrorErrorCausesInner::getErrorSummary) + .collect(Collectors.joining(", ")); } - return errorMessage; + } } + return errorMessage; + } } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java index c03f2f674b..f7d38a2455 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java @@ -6,7 +6,6 @@ import gov.cdc.usds.simplereport.db.model.Facility; import gov.cdc.usds.simplereport.db.model.Organization; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; - import java.util.List; import java.util.Map; import java.util.Optional; @@ -19,65 +18,64 @@ */ public interface OktaRepository { - Optional createUser( - IdentityAttributes userIdentity, - Organization org, - Set facilities, - Set roles, - boolean active); - - Optional updateUser(IdentityAttributes userIdentity); + Optional createUser( + IdentityAttributes userIdentity, + Organization org, + Set facilities, + Set roles, + boolean active); - Optional updateUserEmail(IdentityAttributes userIdentity, String email); + Optional updateUser(IdentityAttributes userIdentity); - void reprovisionUser(IdentityAttributes userIdentity); + Optional updateUserEmail(IdentityAttributes userIdentity, String email); - Optional updateUserPrivileges( - String username, Organization org, Set facilities, Set roles); + void reprovisionUser(IdentityAttributes userIdentity); - List updateUserPrivilegesAndGroupAccess( - String username, - Organization org, - Set facilities, - OrganizationRole roles, - boolean assignedToAllFacilities); + Optional updateUserPrivileges( + String username, Organization org, Set facilities, Set roles); - void resetUserPassword(String username); + List updateUserPrivilegesAndGroupAccess( + String username, + Organization org, + Set facilities, + OrganizationRole roles, + boolean assignedToAllFacilities); - void resetUserMfa(String username); + void resetUserPassword(String username); - void setUserIsActive(String username, boolean active); + void resetUserMfa(String username); - void reactivateUser(String username); + void setUserIsActive(String username, boolean active); - void resendActivationEmail(String username); + void reactivateUser(String username); - UserStatus getUserStatus(String username); + void resendActivationEmail(String username); - Set getAllUsersForOrganization(Organization org); + UserStatus getUserStatus(String username); - Map getAllUsersWithStatusForOrganization(Organization org); + Set getAllUsersForOrganization(Organization org); - void createOrganization(Organization org); + Map getAllUsersWithStatusForOrganization(Organization org); - void activateOrganization(Organization org); + void createOrganization(Organization org); - String activateOrganizationWithSingleUser(Organization org); + void activateOrganization(Organization org); - List fetchAdminUserEmail(Organization org); + String activateOrganizationWithSingleUser(Organization org); - void createFacility(Facility facility); + List fetchAdminUserEmail(Organization org); - void deleteOrganization(Organization org); + void createFacility(Facility facility); - void deleteFacility(Facility facility); + void deleteOrganization(Organization org); - Optional getOrganizationRoleClaimsForUser(String username); + void deleteFacility(Facility facility); - Integer getUsersInSingleFacility(Facility facility); + Optional getOrganizationRoleClaimsForUser(String username); - PartialOktaUser findUser(String username); + Integer getUsersInSingleFacility(Facility facility); - int getConnectTimeoutForHealthCheck(); + PartialOktaUser findUser(String username); + int getConnectTimeoutForHealthCheck(); } From f80ccb3934c713618d67cc426deb8858948362d8 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 10:42:27 -0500 Subject: [PATCH 19/59] remove trailing slash --- .../actions/post-deploy-smoke-test/action.yml | 2 +- .github/workflows/smokeTestDeployManual.yml | 50 ------------------- 2 files changed, 1 insertion(+), 51 deletions(-) delete mode 100644 .github/workflows/smokeTestDeployManual.yml diff --git a/.github/actions/post-deploy-smoke-test/action.yml b/.github/actions/post-deploy-smoke-test/action.yml index 90d9a7039b..e83583d076 100644 --- a/.github/actions/post-deploy-smoke-test/action.yml +++ b/.github/actions/post-deploy-smoke-test/action.yml @@ -12,7 +12,7 @@ runs: working-directory: frontend run: | touch .env - echo REACT_APP_BASE_URL=https://${{ inputs.deploy-env }}.simplereport.gov/ >> .env.production.local + echo REACT_APP_BASE_URL=https://${{ inputs.deploy-env }}.simplereport.gov >> .env.production.local - name: Run smoke test script shell: bash working-directory: frontend diff --git a/.github/workflows/smokeTestDeployManual.yml b/.github/workflows/smokeTestDeployManual.yml deleted file mode 100644 index 4c6d64e72d..0000000000 --- a/.github/workflows/smokeTestDeployManual.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Smoke test deploy -run-name: Smoke test the deploy for a dev env by @${{ github.actor }} - -on: - # DELETE ME WHEN MERGING - push: - # UNCOMMENT ME WHEN MERGING -# workflow_dispatch: -# inputs: -# deploy_env: -# description: 'The environment to smoke test' -# required: true -# type: choice -# options: -# - "" -# - dev -# - dev2 -# - dev3 -# - dev4 -# - dev5 -# - dev6 -# - dev7 -# - pentest - -env: - NODE_VERSION: 18 - -jobs: - smoke-test-front-and-back-end: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Use Node.js - uses: actions/setup-node@v3 - with: - node-version: '18' - - name: Cache yarn - uses: actions/cache@v3.3.2 - with: - path: ~/.cache/yarn - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - - name: Set up dependencies - working-directory: frontend - run: yarn install --prefer-offline - - name: Smoke test the env - uses: ./.github/actions/post-deploy-smoke-test - with: - # REPLACE ME WITH deploy-env: ${{inputs.deploy_env}} - deploy-env: dev7 - From d89583a115a35d4236e4fdbfcb52e93540cfc893 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 10:42:27 -0500 Subject: [PATCH 20/59] remove trailing slash --- .github/actions/post-deploy-smoke-test/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/post-deploy-smoke-test/action.yml b/.github/actions/post-deploy-smoke-test/action.yml index d221374907..db90eb0c72 100644 --- a/.github/actions/post-deploy-smoke-test/action.yml +++ b/.github/actions/post-deploy-smoke-test/action.yml @@ -12,7 +12,7 @@ runs: working-directory: frontend run: | touch .env - echo REACT_APP_BASE_URL=https://${{ inputs.deploy-env }}.simplereport.gov/ >> .env.production.local + echo REACT_APP_BASE_URL=https://${{ inputs.deploy-env }}.simplereport.gov >> .env.production.local - name: Run smoke test script shell: bash working-directory: frontend From 8f287b15ec420aa9ca5f6004def13a74e757bc2d Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 10:44:55 -0500 Subject: [PATCH 21/59] remove empty var --- backend/src/main/resources/application.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 726ac9eaac..8f792d8cc0 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -88,7 +88,6 @@ okta: client: org-url: https://hhs-prime.okta.com token: ${OKTA_API_KEY:MISSING} - group: smarty-streets: id: ${SMARTY_AUTH_ID} token: ${SMARTY_AUTH_TOKEN} From 12f74c48d24cbfdbd664a8ad97c974a127062384 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 10:46:31 -0500 Subject: [PATCH 22/59] remove comment --- frontend/src/app/DeploySmokeTest.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/app/DeploySmokeTest.tsx b/frontend/src/app/DeploySmokeTest.tsx index e87fbbf1d3..1ddcbc3e9e 100644 --- a/frontend/src/app/DeploySmokeTest.tsx +++ b/frontend/src/app/DeploySmokeTest.tsx @@ -11,7 +11,6 @@ const DeploySmokeTest = (): JSX.Element => { .then((response) => { const status = JSON.parse(response); if (status.status === "UP") return setSuccess(true); - // log something using app insights setSuccess(false); }) .catch((e) => { From 347bd149b18a186303bbb028857be41ff6a06910 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 10:53:31 -0500 Subject: [PATCH 23/59] move url to one place --- frontend/deploy-smoke.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/deploy-smoke.js b/frontend/deploy-smoke.js index 7c3a1be92e..966774839b 100644 --- a/frontend/deploy-smoke.js +++ b/frontend/deploy-smoke.js @@ -9,8 +9,8 @@ let { Builder } = require("selenium-webdriver"); const Chrome = require("selenium-webdriver/chrome"); const appUrl = process.env.REACT_APP_BASE_URL.includes("localhost") - ? process.env.REACT_APP_BASE_URL - : `${process.env.REACT_APP_BASE_URL}/app`; + ? `${process.env.REACT_APP_BASE_URL}/health/deploy-smoke-test` + : `${process.env.REACT_APP_BASE_URL}/app/health/deploy-smoke-test`; console.log(`Running smoke test for ${appUrl}`); const options = new Chrome.Options(); @@ -20,7 +20,7 @@ const driver = new Builder() .build(); driver .navigate() - .to(`${appUrl}/health/deploy-smoke-test`) + .to(`${appUrl}`) .then(() => { let value = driver.findElement({ id: "root" }).getText(); return value; From 72d5e40bc5ef771e4de5eeaf5e76a00a54af6d82 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 10:53:31 -0500 Subject: [PATCH 24/59] move url to one place --- frontend/deploy-smoke.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/deploy-smoke.js b/frontend/deploy-smoke.js index 7c3a1be92e..966774839b 100644 --- a/frontend/deploy-smoke.js +++ b/frontend/deploy-smoke.js @@ -9,8 +9,8 @@ let { Builder } = require("selenium-webdriver"); const Chrome = require("selenium-webdriver/chrome"); const appUrl = process.env.REACT_APP_BASE_URL.includes("localhost") - ? process.env.REACT_APP_BASE_URL - : `${process.env.REACT_APP_BASE_URL}/app`; + ? `${process.env.REACT_APP_BASE_URL}/health/deploy-smoke-test` + : `${process.env.REACT_APP_BASE_URL}/app/health/deploy-smoke-test`; console.log(`Running smoke test for ${appUrl}`); const options = new Chrome.Options(); @@ -20,7 +20,7 @@ const driver = new Builder() .build(); driver .navigate() - .to(`${appUrl}/health/deploy-smoke-test`) + .to(`${appUrl}`) .then(() => { let value = driver.findElement({ id: "root" }).getText(); return value; From 6d8ecf357cc8e544cda51a0e7dcf4eef9b53eeef Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 11:14:21 -0500 Subject: [PATCH 25/59] use existing status check instead --- .../BackendAndDatabaseHealthIndicator.java | 7 ++++++- .../idp/repository/DemoOktaRepository.java | 7 ++++--- .../idp/repository/LiveOktaRepository.java | 8 +++----- .../idp/repository/OktaRepository.java | 2 +- ...BackendAndDatabaseHealthIndicatorTest.java | 20 ++++++++++++++----- 5 files changed, 29 insertions(+), 15 deletions(-) diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java index 71fbda9587..b77a3bb57b 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -21,7 +21,12 @@ public class BackendAndDatabaseHealthIndicator implements HealthIndicator { public Health health() { try { _ffRepo.findAll(); - _oktaRepo.getConnectTimeoutForHealthCheck(); + String oktaStatus = _oktaRepo.getApplicationStatusForHealthCheck(); + + if (oktaStatus != "ACTIVE") { + log.info("Okta status didn't return active, instead returned", oktaStatus); + return Health.down().build(); + } return Health.up().build(); } catch (JDBCConnectionException e) { diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java index 0bf9ec5c08..481e580e49 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java @@ -432,8 +432,9 @@ public Integer getUsersInSingleFacility(Facility facility) { return accessCount; } - public int getConnectTimeoutForHealthCheck() { - int FAKE_CONNECTION_TIMEOUT = 0; - return FAKE_CONNECTION_TIMEOUT; + @Override + public String getApplicationStatusForHealthCheck() { + String FAKE_STATUS = "ACTIVE"; + return FAKE_STATUS; } } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java index 7704a85786..be897fd1cf 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java @@ -67,8 +67,6 @@ public class LiveOktaRepository implements OktaRepository { private final Application app; private final OrganizationExtractor extractor; private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; - - private final ApplicationApi applicationApi; private final GroupApi groupApi; private final UserApi userApi; private final ApplicationGroupsApi applicationGroupsApi; @@ -89,7 +87,6 @@ public LiveOktaRepository( this.rolePrefix = authorizationProperties.getRolePrefix(); this.adminGroupName = authorizationProperties.getAdminGroupName(); - this.applicationApi = applicationApi; this.groupApi = groupApi; this.userApi = userApi; this.applicationGroupsApi = applicationGroupsApi; @@ -695,8 +692,9 @@ public PartialOktaUser findUser(String username) { .build(); } - public int getConnectTimeoutForHealthCheck() { - return applicationApi.getApiClient().getConnectTimeout(); + @Override + public String getApplicationStatusForHealthCheck() { + return app.getStatus().toString(); } private Optional getOrganizationRoleClaimsFromAuthorities( diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java index f7d38a2455..fc488573b7 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java @@ -77,5 +77,5 @@ List updateUserPrivilegesAndGroupAccess( PartialOktaUser findUser(String username); - int getConnectTimeoutForHealthCheck(); + String getApplicationStatusForHealthCheck(); } diff --git a/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java b/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java index 61ae972a84..97e67f0573 100644 --- a/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java +++ b/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java @@ -6,6 +6,7 @@ import gov.cdc.usds.simplereport.api.heathcheck.BackendAndDatabaseHealthIndicator; import gov.cdc.usds.simplereport.db.repository.BaseRepositoryTest; import gov.cdc.usds.simplereport.db.repository.FeatureFlagRepository; +import gov.cdc.usds.simplereport.idp.repository.OktaRepository; import java.sql.SQLException; import java.util.List; import lombok.RequiredArgsConstructor; @@ -20,22 +21,31 @@ @EnableConfigurationProperties class BackendAndDatabaseHealthIndicatorTest extends BaseRepositoryTest { - @SpyBean private FeatureFlagRepository mockRepo; + @SpyBean private FeatureFlagRepository mockFeatureFlagRepo; + @SpyBean private OktaRepository mockOktaRepo; @Autowired private BackendAndDatabaseHealthIndicator indicator; @Test - void health_succeedsWhenRepoDoesntThrow() { - when(mockRepo.findAll()).thenReturn(List.of()); + void health_succeedsWhenReposDoesntThrow() { + when(mockFeatureFlagRepo.findAll()).thenReturn(List.of()); + when(mockOktaRepo.getApplicationStatusForHealthCheck()).thenReturn("ACTIVE"); + assertThat(indicator.health()).isEqualTo(Health.up().build()); } @Test - void health_failsWhenRepoDoesntThrow() { + void health_failsWhenFeatureFlagRepoDoesntThrow() { JDBCConnectionException dbConnectionException = new JDBCConnectionException( "connection issue", new SQLException("some reason", "some state")); - when(mockRepo.findAll()).thenThrow(dbConnectionException); + when(mockFeatureFlagRepo.findAll()).thenThrow(dbConnectionException); + assertThat(indicator.health()).isEqualTo(Health.down().build()); + } + + @Test + void health_failsWhenOktaRepoDoesntReturnActive() { + when(mockOktaRepo.getApplicationStatusForHealthCheck()).thenReturn("INACTIVE"); assertThat(indicator.health()).isEqualTo(Health.down().build()); } } From 2eb0fee189387c6844592d62adb997b8cb1947d7 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 11:14:21 -0500 Subject: [PATCH 26/59] start merge pt 1 --- .../BackendAndDatabaseHealthIndicator.java | 10 ++++++---- .../idp/repository/DemoOktaRepository.java | 10 +++++----- .../idp/repository/OktaRepository.java | 3 +-- ...BackendAndDatabaseHealthIndicatorTest.java | 20 ++++++++++++++----- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java index 22badc2d6d..92e6d3bcc8 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -1,14 +1,11 @@ package gov.cdc.usds.simplereport.api.heathcheck; -import com.okta.sdk.resource.api.GroupApi; import com.okta.sdk.resource.client.ApiException; import gov.cdc.usds.simplereport.db.repository.FeatureFlagRepository; -import gov.cdc.usds.simplereport.idp.repository.LiveOktaRepository; import gov.cdc.usds.simplereport.idp.repository.OktaRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.JDBCConnectionException; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.stereotype.Component; @@ -24,7 +21,12 @@ public class BackendAndDatabaseHealthIndicator implements HealthIndicator { public Health health() { try { _ffRepo.findAll(); - _oktaRepo.getConnectTimeoutForHealthCheck(); + String oktaStatus = _oktaRepo.getApplicationStatusForHealthCheck(); + + if (oktaStatus.equals("ACTIVE")) { + log.info("Okta status didn't return active, instead returned", oktaStatus); + return Health.down().build(); + } return Health.up().build(); } catch (JDBCConnectionException e) { diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java index 7baf653582..f86c3a131a 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java @@ -435,9 +435,9 @@ public Integer getUsersInSingleFacility(Facility facility) { return accessCount; } - - public int getConnectTimeoutForHealthCheck() { - int FAKE_CONNECTION_TIMEOUT = 0; - return FAKE_CONNECTION_TIMEOUT; - } + @Override + public String getApplicationStatusForHealthCheck() { + String FAKE_STATUS = "ACTIVE"; + return FAKE_STATUS; + } } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java index c03f2f674b..3809a04fe1 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java @@ -78,6 +78,5 @@ List updateUserPrivilegesAndGroupAccess( PartialOktaUser findUser(String username); - int getConnectTimeoutForHealthCheck(); - + String getApplicationStatusForHealthCheck(); } diff --git a/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java b/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java index 61ae972a84..97e67f0573 100644 --- a/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java +++ b/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java @@ -6,6 +6,7 @@ import gov.cdc.usds.simplereport.api.heathcheck.BackendAndDatabaseHealthIndicator; import gov.cdc.usds.simplereport.db.repository.BaseRepositoryTest; import gov.cdc.usds.simplereport.db.repository.FeatureFlagRepository; +import gov.cdc.usds.simplereport.idp.repository.OktaRepository; import java.sql.SQLException; import java.util.List; import lombok.RequiredArgsConstructor; @@ -20,22 +21,31 @@ @EnableConfigurationProperties class BackendAndDatabaseHealthIndicatorTest extends BaseRepositoryTest { - @SpyBean private FeatureFlagRepository mockRepo; + @SpyBean private FeatureFlagRepository mockFeatureFlagRepo; + @SpyBean private OktaRepository mockOktaRepo; @Autowired private BackendAndDatabaseHealthIndicator indicator; @Test - void health_succeedsWhenRepoDoesntThrow() { - when(mockRepo.findAll()).thenReturn(List.of()); + void health_succeedsWhenReposDoesntThrow() { + when(mockFeatureFlagRepo.findAll()).thenReturn(List.of()); + when(mockOktaRepo.getApplicationStatusForHealthCheck()).thenReturn("ACTIVE"); + assertThat(indicator.health()).isEqualTo(Health.up().build()); } @Test - void health_failsWhenRepoDoesntThrow() { + void health_failsWhenFeatureFlagRepoDoesntThrow() { JDBCConnectionException dbConnectionException = new JDBCConnectionException( "connection issue", new SQLException("some reason", "some state")); - when(mockRepo.findAll()).thenThrow(dbConnectionException); + when(mockFeatureFlagRepo.findAll()).thenThrow(dbConnectionException); + assertThat(indicator.health()).isEqualTo(Health.down().build()); + } + + @Test + void health_failsWhenOktaRepoDoesntReturnActive() { + when(mockOktaRepo.getApplicationStatusForHealthCheck()).thenReturn("INACTIVE"); assertThat(indicator.health()).isEqualTo(Health.down().build()); } } From 3ef003b02726ab79aaa425f35918be3546fbae5d Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 11:24:33 -0500 Subject: [PATCH 27/59] lint --- .../BackendAndDatabaseHealthIndicator.java | 38 +- .../idp/repository/DemoOktaRepository.java | 695 ++++---- .../idp/repository/LiveOktaRepository.java | 1470 ++++++++--------- .../idp/repository/OktaRepository.java | 73 +- 4 files changed, 1135 insertions(+), 1141 deletions(-) diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java index 92e6d3bcc8..1da0ea64b5 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -14,27 +14,27 @@ @Slf4j @RequiredArgsConstructor public class BackendAndDatabaseHealthIndicator implements HealthIndicator { - private final FeatureFlagRepository _ffRepo; - private final OktaRepository _oktaRepo; + private final FeatureFlagRepository _ffRepo; + private final OktaRepository _oktaRepo; - @Override - public Health health() { - try { - _ffRepo.findAll(); - String oktaStatus = _oktaRepo.getApplicationStatusForHealthCheck(); + @Override + public Health health() { + try { + _ffRepo.findAll(); + String oktaStatus = _oktaRepo.getApplicationStatusForHealthCheck(); - if (oktaStatus.equals("ACTIVE")) { - log.info("Okta status didn't return active, instead returned", oktaStatus); - return Health.down().build(); - } + if (oktaStatus.equals("ACTIVE")) { + log.info("Okta status didn't return active, instead returned", oktaStatus); + return Health.down().build(); + } - return Health.up().build(); - } catch (JDBCConnectionException e) { - return Health.down().build(); - // Okta API call errored - } catch (ApiException e) { - log.info(e.getMessage()); - return Health.down().build(); - } + return Health.up().build(); + } catch (JDBCConnectionException e) { + return Health.down().build(); + // Okta API call errored + } catch (ApiException e) { + log.info(e.getMessage()); + return Health.down().build(); } + } } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java index f86c3a131a..481e580e49 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java @@ -13,7 +13,6 @@ import gov.cdc.usds.simplereport.db.model.Facility; import gov.cdc.usds.simplereport.db.model.Organization; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; - import java.util.Collection; import java.util.EnumSet; import java.util.HashMap; @@ -25,7 +24,6 @@ import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; - import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.support.ScopeNotActiveException; @@ -34,407 +32,406 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; -/** - * Handles all user/organization management in Okta - */ +/** Handles all user/organization management in Okta */ @Profile(BeanProfiles.NO_OKTA_MGMT) @Service @Slf4j public class DemoOktaRepository implements OktaRepository { - @Value("${simple-report.authorization.environment-name:DEV}") - private String environment; - - private final OrganizationExtractor organizationExtractor; - private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; - - Map usernameOrgRolesMap; - Map> orgUsernamesMap; - Map> orgFacilitiesMap; - Set inactiveUsernames; - Set allUsernames; - private final Set adminGroupMemberSet; - - public DemoOktaRepository( - OrganizationExtractor extractor, - CurrentTenantDataAccessContextHolder contextHolder, - DemoUserConfiguration demoUserConfiguration) { - this.usernameOrgRolesMap = new HashMap<>(); - this.orgUsernamesMap = new HashMap<>(); - this.orgFacilitiesMap = new HashMap<>(); - this.inactiveUsernames = new HashSet<>(); - this.allUsernames = new HashSet<>(); - - this.organizationExtractor = extractor; - this.tenantDataContextHolder = contextHolder; - this.adminGroupMemberSet = - demoUserConfiguration.getSiteAdminEmails().stream().collect(Collectors.toUnmodifiableSet()); - - log.info("Done initializing Demo Okta repository."); - } + @Value("${simple-report.authorization.environment-name:DEV}") + private String environment; + + private final OrganizationExtractor organizationExtractor; + private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; + + Map usernameOrgRolesMap; + Map> orgUsernamesMap; + Map> orgFacilitiesMap; + Set inactiveUsernames; + Set allUsernames; + private final Set adminGroupMemberSet; + + public DemoOktaRepository( + OrganizationExtractor extractor, + CurrentTenantDataAccessContextHolder contextHolder, + DemoUserConfiguration demoUserConfiguration) { + this.usernameOrgRolesMap = new HashMap<>(); + this.orgUsernamesMap = new HashMap<>(); + this.orgFacilitiesMap = new HashMap<>(); + this.inactiveUsernames = new HashSet<>(); + this.allUsernames = new HashSet<>(); + + this.organizationExtractor = extractor; + this.tenantDataContextHolder = contextHolder; + this.adminGroupMemberSet = + demoUserConfiguration.getSiteAdminEmails().stream().collect(Collectors.toUnmodifiableSet()); + + log.info("Done initializing Demo Okta repository."); + } - public Optional createUser( - IdentityAttributes userIdentity, - Organization org, - Set facilities, - Set roles, - boolean active) { - if (allUsernames.contains(userIdentity.getUsername())) { - throw new ConflictingUserException(); - } - - String organizationExternalId = org.getExternalId(); - Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); - rolesToCreate.addAll(roles); - Set facilityUUIDs = - PermissionHolder.grantsAllFacilityAccess(rolesToCreate) - // create an empty set of facilities if user can access all facilities anyway - ? Set.of() - : facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()); - if (!orgFacilitiesMap.containsKey(organizationExternalId)) { - throw new IllegalGraphqlArgumentException( - "Cannot add Okta user to nonexistent organization=" + organizationExternalId); - } else if (!orgFacilitiesMap.get(organizationExternalId).containsAll(facilityUUIDs)) { - throw new IllegalGraphqlArgumentException( - "Cannot add Okta user to one or more nonexistent facilities in facilities_set=" - + facilities.stream().map(f -> f.getFacilityName()).collect(Collectors.toSet()) - + " in organization=" - + organizationExternalId); - } - - OrganizationRoleClaims orgRoles = - new OrganizationRoleClaims(organizationExternalId, facilityUUIDs, rolesToCreate); - usernameOrgRolesMap.put(userIdentity.getUsername(), orgRoles); - allUsernames.add(userIdentity.getUsername()); - - orgUsernamesMap.get(organizationExternalId).add(userIdentity.getUsername()); - - if (!active) { - inactiveUsernames.add(userIdentity.getUsername()); - } - - return Optional.of(orgRoles); + public Optional createUser( + IdentityAttributes userIdentity, + Organization org, + Set facilities, + Set roles, + boolean active) { + if (allUsernames.contains(userIdentity.getUsername())) { + throw new ConflictingUserException(); } - // this method currently doesn't do much in a demo envt - public Optional updateUser(IdentityAttributes userIdentity) { - if (!usernameOrgRolesMap.containsKey(userIdentity.getUsername())) { - throw new IllegalGraphqlArgumentException( - "Cannot change name of Okta user with unrecognized username"); - } - OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(userIdentity.getUsername()); - return Optional.of(orgRoles); + String organizationExternalId = org.getExternalId(); + Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); + rolesToCreate.addAll(roles); + Set facilityUUIDs = + PermissionHolder.grantsAllFacilityAccess(rolesToCreate) + // create an empty set of facilities if user can access all facilities anyway + ? Set.of() + : facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()); + if (!orgFacilitiesMap.containsKey(organizationExternalId)) { + throw new IllegalGraphqlArgumentException( + "Cannot add Okta user to nonexistent organization=" + organizationExternalId); + } else if (!orgFacilitiesMap.get(organizationExternalId).containsAll(facilityUUIDs)) { + throw new IllegalGraphqlArgumentException( + "Cannot add Okta user to one or more nonexistent facilities in facilities_set=" + + facilities.stream().map(f -> f.getFacilityName()).collect(Collectors.toSet()) + + " in organization=" + + organizationExternalId); } - public Optional updateUserEmail( - IdentityAttributes userIdentity, String newEmail) { - String currentEmail = userIdentity.getUsername(); - if (!usernameOrgRolesMap.containsKey(currentEmail)) { - throw new IllegalGraphqlArgumentException( - "Cannot change email of Okta user with unrecognized username"); - } - - if (usernameOrgRolesMap.containsKey(newEmail)) { - throw new ConflictingUserException(); - } - - String org = usernameOrgRolesMap.get(userIdentity.getUsername()).getOrganizationExternalId(); - orgUsernamesMap.get(org).remove(currentEmail); - orgUsernamesMap.get(org).add(newEmail); - usernameOrgRolesMap.put(newEmail, usernameOrgRolesMap.remove(userIdentity.getUsername())); - OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(newEmail); - return Optional.of(orgRoles); - } + OrganizationRoleClaims orgRoles = + new OrganizationRoleClaims(organizationExternalId, facilityUUIDs, rolesToCreate); + usernameOrgRolesMap.put(userIdentity.getUsername(), orgRoles); + allUsernames.add(userIdentity.getUsername()); - public void reprovisionUser(IdentityAttributes userIdentity) { - final String username = userIdentity.getUsername(); - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reprovision Okta user with unrecognized username"); - } - if (!inactiveUsernames.contains(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reprovision user in unsupported state: (not deleted)"); - } - - // Only re-enable the user. If name attributes and credentials were supported here, then - // the name should be updated and credentials reset. - inactiveUsernames.remove(userIdentity.getUsername()); - } + orgUsernamesMap.get(organizationExternalId).add(userIdentity.getUsername()); - public Optional updateUserPrivileges( - String username, Organization org, Set facilities, Set roles) { - String orgId = org.getExternalId(); - if (!orgUsernamesMap.containsKey(orgId)) { - throw new IllegalGraphqlArgumentException( - "Cannot update Okta user privileges for nonexistent organization."); - } - if (!orgUsernamesMap.get(orgId).contains(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot update Okta user privileges for organization they are not in."); - } - Set newRoles = EnumSet.of(OrganizationRole.getDefault()); - newRoles.addAll(roles); - Set facilityUUIDs = - facilities.stream() - // create an empty set of facilities if user can access all facilities anyway - .filter(f -> !PermissionHolder.grantsAllFacilityAccess(newRoles)) - .map(f -> f.getInternalId()) - .collect(Collectors.toSet()); - OrganizationRoleClaims newRoleClaims = - new OrganizationRoleClaims(orgId, facilityUUIDs, newRoles); - usernameOrgRolesMap.put(username, newRoleClaims); - - return Optional.of(newRoleClaims); + if (!active) { + inactiveUsernames.add(userIdentity.getUsername()); } - @Override - public List updateUserPrivilegesAndGroupAccess( - String username, - Organization org, - Set facilities, - OrganizationRole roles, - boolean allFacilitiesAccess) { - - String oldOrgId = usernameOrgRolesMap.get(username).getOrganizationExternalId(); - orgUsernamesMap.get(oldOrgId).remove(username); - orgUsernamesMap.get(org.getExternalId()).add(username); - OrganizationRoleClaims newRoleClaims = - new OrganizationRoleClaims( - org.getExternalId(), - facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()), - Set.of(roles, OrganizationRole.getDefault())); - - usernameOrgRolesMap.replace(username, newRoleClaims); - - // Live Okta repository returns list of Group names, but our demo repo didn't implement - // group mappings and it didn't feel worth it to add that implementation since the return is - // used mostly for testing. Return the list of facility ID's in the new org instead - return orgFacilitiesMap.get(org.getExternalId()).stream().map(UUID::toString).toList(); - } + return Optional.of(orgRoles); + } - public void resetUserPassword(String username) { - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reset password for Okta user with unrecognized username"); - } + // this method currently doesn't do much in a demo envt + public Optional updateUser(IdentityAttributes userIdentity) { + if (!usernameOrgRolesMap.containsKey(userIdentity.getUsername())) { + throw new IllegalGraphqlArgumentException( + "Cannot change name of Okta user with unrecognized username"); } + OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(userIdentity.getUsername()); + return Optional.of(orgRoles); + } - public void resetUserMfa(String username) { - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reset MFA for Okta user with unrecognized username"); - } + public Optional updateUserEmail( + IdentityAttributes userIdentity, String newEmail) { + String currentEmail = userIdentity.getUsername(); + if (!usernameOrgRolesMap.containsKey(currentEmail)) { + throw new IllegalGraphqlArgumentException( + "Cannot change email of Okta user with unrecognized username"); } - public void setUserIsActive(String username, boolean active) { - if (active) { - inactiveUsernames.remove(username); - } else { - inactiveUsernames.add(username); - } + if (usernameOrgRolesMap.containsKey(newEmail)) { + throw new ConflictingUserException(); } - public UserStatus getUserStatus(String username) { - if (inactiveUsernames.contains(username)) { - return UserStatus.SUSPENDED; - } else { - return UserStatus.ACTIVE; - } - } + String org = usernameOrgRolesMap.get(userIdentity.getUsername()).getOrganizationExternalId(); + orgUsernamesMap.get(org).remove(currentEmail); + orgUsernamesMap.get(org).add(newEmail); + usernameOrgRolesMap.put(newEmail, usernameOrgRolesMap.remove(userIdentity.getUsername())); + OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(newEmail); + return Optional.of(orgRoles); + } - public void reactivateUser(String username) { - if (inactiveUsernames.contains(username)) { - inactiveUsernames.remove(username); - } + public void reprovisionUser(IdentityAttributes userIdentity) { + final String username = userIdentity.getUsername(); + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reprovision Okta user with unrecognized username"); + } + if (!inactiveUsernames.contains(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reprovision user in unsupported state: (not deleted)"); } - public void resendActivationEmail(String username) { - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reset password for Okta user with unrecognized username"); - } + // Only re-enable the user. If name attributes and credentials were supported here, then + // the name should be updated and credentials reset. + inactiveUsernames.remove(userIdentity.getUsername()); + } + + public Optional updateUserPrivileges( + String username, Organization org, Set facilities, Set roles) { + String orgId = org.getExternalId(); + if (!orgUsernamesMap.containsKey(orgId)) { + throw new IllegalGraphqlArgumentException( + "Cannot update Okta user privileges for nonexistent organization."); + } + if (!orgUsernamesMap.get(orgId).contains(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot update Okta user privileges for organization they are not in."); } + Set newRoles = EnumSet.of(OrganizationRole.getDefault()); + newRoles.addAll(roles); + Set facilityUUIDs = + facilities.stream() + // create an empty set of facilities if user can access all facilities anyway + .filter(f -> !PermissionHolder.grantsAllFacilityAccess(newRoles)) + .map(f -> f.getInternalId()) + .collect(Collectors.toSet()); + OrganizationRoleClaims newRoleClaims = + new OrganizationRoleClaims(orgId, facilityUUIDs, newRoles); + usernameOrgRolesMap.put(username, newRoleClaims); + + return Optional.of(newRoleClaims); + } + + @Override + public List updateUserPrivilegesAndGroupAccess( + String username, + Organization org, + Set facilities, + OrganizationRole roles, + boolean allFacilitiesAccess) { + + String oldOrgId = usernameOrgRolesMap.get(username).getOrganizationExternalId(); + orgUsernamesMap.get(oldOrgId).remove(username); + orgUsernamesMap.get(org.getExternalId()).add(username); + OrganizationRoleClaims newRoleClaims = + new OrganizationRoleClaims( + org.getExternalId(), + facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()), + Set.of(roles, OrganizationRole.getDefault())); + + usernameOrgRolesMap.replace(username, newRoleClaims); + + // Live Okta repository returns list of Group names, but our demo repo didn't implement + // group mappings and it didn't feel worth it to add that implementation since the return is + // used mostly for testing. Return the list of facility ID's in the new org instead + return orgFacilitiesMap.get(org.getExternalId()).stream().map(UUID::toString).toList(); + } - // returns ALL users including inactive ones - public Set getAllUsersForOrganization(Organization org) { - if (!orgUsernamesMap.containsKey(org.getExternalId())) { - throw new IllegalGraphqlArgumentException( - "Cannot get Okta users from nonexistent organization."); - } - return orgUsernamesMap.get(org.getExternalId()).stream() - .collect(Collectors.toUnmodifiableSet()); + public void resetUserPassword(String username) { + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reset password for Okta user with unrecognized username"); } + } - public Map getAllUsersWithStatusForOrganization(Organization org) { - if (!orgUsernamesMap.containsKey(org.getExternalId())) { - throw new IllegalGraphqlArgumentException( - "Cannot get Okta users from nonexistent organization."); - } - return orgUsernamesMap.get(org.getExternalId()).stream() - .collect(Collectors.toMap(u -> u, u -> getUserStatus(u))); + public void resetUserMfa(String username) { + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reset MFA for Okta user with unrecognized username"); } + } - // this method doesn't mean much in a demo env - public void createOrganization(Organization org) { - String externalId = org.getExternalId(); - orgUsernamesMap.putIfAbsent(externalId, new HashSet<>()); - orgFacilitiesMap.putIfAbsent(externalId, new HashSet<>()); + public void setUserIsActive(String username, boolean active) { + if (active) { + inactiveUsernames.remove(username); + } else { + inactiveUsernames.add(username); } + } - // this method means nothing in a demo env - public void activateOrganization(Organization org) { - inactiveUsernames.removeAll(orgUsernamesMap.get(org.getExternalId())); + public UserStatus getUserStatus(String username) { + if (inactiveUsernames.contains(username)) { + return UserStatus.SUSPENDED; + } else { + return UserStatus.ACTIVE; } + } - // this method means nothing in a demo env - public String activateOrganizationWithSingleUser(Organization org) { - activateOrganization(org); - return "activationToken"; + public void reactivateUser(String username) { + if (inactiveUsernames.contains(username)) { + inactiveUsernames.remove(username); } + } - public List fetchAdminUserEmail(Organization org) { - Set> admins = - usernameOrgRolesMap.entrySet().stream() - .filter(e -> e.getValue().getGrantedRoles().contains(OrganizationRole.ADMIN)) - .collect(Collectors.toSet()); - return admins.stream().map(Entry::getKey).collect(Collectors.toList()); + public void resendActivationEmail(String username) { + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reset password for Okta user with unrecognized username"); } + } - public void createFacility(Facility facility) { - String orgExternalId = facility.getOrganization().getExternalId(); - if (!orgFacilitiesMap.containsKey(orgExternalId)) { - throw new IllegalGraphqlArgumentException( - "Cannot create Okta facility in nonexistent organization."); - } - orgFacilitiesMap.get(orgExternalId).add(facility.getInternalId()); + // returns ALL users including inactive ones + public Set getAllUsersForOrganization(Organization org) { + if (!orgUsernamesMap.containsKey(org.getExternalId())) { + throw new IllegalGraphqlArgumentException( + "Cannot get Okta users from nonexistent organization."); } + return orgUsernamesMap.get(org.getExternalId()).stream() + .collect(Collectors.toUnmodifiableSet()); + } - public void deleteOrganization(Organization org) { - String externalId = org.getExternalId(); - orgUsernamesMap.remove(externalId); - orgFacilitiesMap.remove(externalId); - // remove all users from this map whose org roles are in the deleted org - usernameOrgRolesMap = - usernameOrgRolesMap.entrySet().stream() - .filter(e -> !(e.getValue().getOrganizationExternalId().equals(externalId))) - .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())); + public Map getAllUsersWithStatusForOrganization(Organization org) { + if (!orgUsernamesMap.containsKey(org.getExternalId())) { + throw new IllegalGraphqlArgumentException( + "Cannot get Okta users from nonexistent organization."); } + return orgUsernamesMap.get(org.getExternalId()).stream() + .collect(Collectors.toMap(u -> u, u -> getUserStatus(u))); + } + + // this method doesn't mean much in a demo env + public void createOrganization(Organization org) { + String externalId = org.getExternalId(); + orgUsernamesMap.putIfAbsent(externalId, new HashSet<>()); + orgFacilitiesMap.putIfAbsent(externalId, new HashSet<>()); + } + + // this method means nothing in a demo env + public void activateOrganization(Organization org) { + inactiveUsernames.removeAll(orgUsernamesMap.get(org.getExternalId())); + } + + // this method means nothing in a demo env + public String activateOrganizationWithSingleUser(Organization org) { + activateOrganization(org); + return "activationToken"; + } - public void deleteFacility(Facility facility) { - String orgExternalId = facility.getOrganization().getExternalId(); - if (!orgFacilitiesMap.containsKey(orgExternalId)) { - throw new IllegalGraphqlArgumentException( - "Cannot delete Okta facility from nonexistent organization."); - } - orgFacilitiesMap.get(orgExternalId).remove(facility.getInternalId()); - // remove this facility from every user's OrganizationRoleClaims, as necessary - usernameOrgRolesMap = - usernameOrgRolesMap.entrySet().stream() - .collect( - Collectors.toMap( - e -> e.getKey(), - e -> { - OrganizationRoleClaims oldRoleClaims = e.getValue(); - Set newFacilities = - oldRoleClaims.getFacilities().stream() - .filter(f -> !f.equals(facility.getInternalId())) - .collect(Collectors.toSet()); - return new OrganizationRoleClaims( - orgExternalId, newFacilities, oldRoleClaims.getGrantedRoles()); - })); + public List fetchAdminUserEmail(Organization org) { + Set> admins = + usernameOrgRolesMap.entrySet().stream() + .filter(e -> e.getValue().getGrantedRoles().contains(OrganizationRole.ADMIN)) + .collect(Collectors.toSet()); + return admins.stream().map(Entry::getKey).collect(Collectors.toList()); + } + + public void createFacility(Facility facility) { + String orgExternalId = facility.getOrganization().getExternalId(); + if (!orgFacilitiesMap.containsKey(orgExternalId)) { + throw new IllegalGraphqlArgumentException( + "Cannot create Okta facility in nonexistent organization."); } + orgFacilitiesMap.get(orgExternalId).add(facility.getInternalId()); + } - private Optional getOrganizationRoleClaimsFromTenantDataAccess( - Collection groupNames) { - List claims = organizationExtractor.convertClaims(groupNames); + public void deleteOrganization(Organization org) { + String externalId = org.getExternalId(); + orgUsernamesMap.remove(externalId); + orgFacilitiesMap.remove(externalId); + // remove all users from this map whose org roles are in the deleted org + usernameOrgRolesMap = + usernameOrgRolesMap.entrySet().stream() + .filter(e -> !(e.getValue().getOrganizationExternalId().equals(externalId))) + .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())); + } - if (claims.size() != 1) { - log.warn("User is in {} Okta organizations, not 1", claims.size()); - return Optional.empty(); - } - return Optional.of(claims.get(0)); + public void deleteFacility(Facility facility) { + String orgExternalId = facility.getOrganization().getExternalId(); + if (!orgFacilitiesMap.containsKey(orgExternalId)) { + throw new IllegalGraphqlArgumentException( + "Cannot delete Okta facility from nonexistent organization."); } + orgFacilitiesMap.get(orgExternalId).remove(facility.getInternalId()); + // remove this facility from every user's OrganizationRoleClaims, as necessary + usernameOrgRolesMap = + usernameOrgRolesMap.entrySet().stream() + .collect( + Collectors.toMap( + e -> e.getKey(), + e -> { + OrganizationRoleClaims oldRoleClaims = e.getValue(); + Set newFacilities = + oldRoleClaims.getFacilities().stream() + .filter(f -> !f.equals(facility.getInternalId())) + .collect(Collectors.toSet()); + return new OrganizationRoleClaims( + orgExternalId, newFacilities, oldRoleClaims.getGrantedRoles()); + })); + } + + private Optional getOrganizationRoleClaimsFromTenantDataAccess( + Collection groupNames) { + List claims = organizationExtractor.convertClaims(groupNames); - public Optional getOrganizationRoleClaimsForUser(String username) { - // when accessing tenant data, bypass okta and get org from the altered authorities - try { - if (tenantDataContextHolder.hasBeenPopulated() - && username.equals(tenantDataContextHolder.getUsername())) { - return getOrganizationRoleClaimsFromTenantDataAccess( - tenantDataContextHolder.getAuthorities()); - } - return Optional.ofNullable(usernameOrgRolesMap.get(username)); - } catch (ScopeNotActiveException e) { - // Tests are set up with a full SecurityContextHolder and should not rely on - // usernameOrgRolesMap as the source of truth. - if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { - return Optional.of(usernameOrgRolesMap.get(username)); - } - Set authorities = - SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.toSet()); - return getOrganizationRoleClaimsFromTenantDataAccess(authorities); - } + if (claims.size() != 1) { + log.warn("User is in {} Okta organizations, not 1", claims.size()); + return Optional.empty(); } + return Optional.of(claims.get(0)); + } - public PartialOktaUser findUser(String username) { - UserStatus status = - inactiveUsernames.contains(username) ? UserStatus.SUSPENDED : UserStatus.ACTIVE; - boolean isAdmin = adminGroupMemberSet.contains(username); - - Optional orgClaims; - - try { - orgClaims = Optional.ofNullable(usernameOrgRolesMap.get(username)); - } catch (ScopeNotActiveException e) { - // Tests are set up with a full SecurityContextHolder and should not rely on - // usernameOrgRolesMap as the source of truth. - if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { - orgClaims = Optional.of(usernameOrgRolesMap.get(username)); - } else { - Set authorities = - SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.toSet()); - orgClaims = getOrganizationRoleClaimsFromTenantDataAccess(authorities); - } - } - - return PartialOktaUser.builder() - .isSiteAdmin(isAdmin) - .status(status) - .username(username) - .organizationRoleClaims(orgClaims) - .build(); + public Optional getOrganizationRoleClaimsForUser(String username) { + // when accessing tenant data, bypass okta and get org from the altered authorities + try { + if (tenantDataContextHolder.hasBeenPopulated() + && username.equals(tenantDataContextHolder.getUsername())) { + return getOrganizationRoleClaimsFromTenantDataAccess( + tenantDataContextHolder.getAuthorities()); + } + return Optional.ofNullable(usernameOrgRolesMap.get(username)); + } catch (ScopeNotActiveException e) { + // Tests are set up with a full SecurityContextHolder and should not rely on + // usernameOrgRolesMap as the source of truth. + if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { + return Optional.of(usernameOrgRolesMap.get(username)); + } + Set authorities = + SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + return getOrganizationRoleClaimsFromTenantDataAccess(authorities); } + } - public void reset() { - usernameOrgRolesMap.clear(); - orgUsernamesMap.clear(); - orgFacilitiesMap.clear(); - inactiveUsernames.clear(); - allUsernames.clear(); + public PartialOktaUser findUser(String username) { + UserStatus status = + inactiveUsernames.contains(username) ? UserStatus.SUSPENDED : UserStatus.ACTIVE; + boolean isAdmin = adminGroupMemberSet.contains(username); + + Optional orgClaims; + + try { + orgClaims = Optional.ofNullable(usernameOrgRolesMap.get(username)); + } catch (ScopeNotActiveException e) { + // Tests are set up with a full SecurityContextHolder and should not rely on + // usernameOrgRolesMap as the source of truth. + if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { + orgClaims = Optional.of(usernameOrgRolesMap.get(username)); + } else { + Set authorities = + SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + orgClaims = getOrganizationRoleClaimsFromTenantDataAccess(authorities); + } } - public Integer getUsersInSingleFacility(Facility facility) { - Integer accessCount = 0; - - for (OrganizationRoleClaims existingClaims : usernameOrgRolesMap.values()) { - boolean hasAllFacilityAccess = - existingClaims.getGrantedRoles().stream() - .anyMatch(role -> OrganizationRole.ALL_FACILITIES.getName().equals(role.name())); - boolean hasSpecificFacilityAccess = - existingClaims.getFacilities().stream() - .anyMatch(facilityAccessId -> facility.getInternalId().equals(facilityAccessId)); - if (!hasAllFacilityAccess && hasSpecificFacilityAccess) { - accessCount++; - } - } - - return accessCount; + return PartialOktaUser.builder() + .isSiteAdmin(isAdmin) + .status(status) + .username(username) + .organizationRoleClaims(orgClaims) + .build(); + } + + public void reset() { + usernameOrgRolesMap.clear(); + orgUsernamesMap.clear(); + orgFacilitiesMap.clear(); + inactiveUsernames.clear(); + allUsernames.clear(); + } + + public Integer getUsersInSingleFacility(Facility facility) { + Integer accessCount = 0; + + for (OrganizationRoleClaims existingClaims : usernameOrgRolesMap.values()) { + boolean hasAllFacilityAccess = + existingClaims.getGrantedRoles().stream() + .anyMatch(role -> OrganizationRole.ALL_FACILITIES.getName().equals(role.name())); + boolean hasSpecificFacilityAccess = + existingClaims.getFacilities().stream() + .anyMatch(facilityAccessId -> facility.getInternalId().equals(facilityAccessId)); + if (!hasAllFacilityAccess && hasSpecificFacilityAccess) { + accessCount++; + } } + + return accessCount; + } + @Override public String getApplicationStatusForHealthCheck() { String FAKE_STATUS = "ACTIVE"; diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java index f1a5157353..1c617a1485 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java @@ -5,7 +5,6 @@ import com.okta.sdk.resource.api.ApplicationGroupsApi; import com.okta.sdk.resource.api.GroupApi; import com.okta.sdk.resource.api.UserApi; -import com.okta.sdk.resource.client.ApiClient; import com.okta.sdk.resource.client.ApiException; import com.okta.sdk.resource.common.PagedList; import com.okta.sdk.resource.group.GroupBuilder; @@ -33,7 +32,6 @@ import gov.cdc.usds.simplereport.db.model.Facility; import gov.cdc.usds.simplereport.db.model.Organization; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; - import java.util.ArrayList; import java.util.Collection; import java.util.EnumSet; @@ -47,7 +45,6 @@ import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; - import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; @@ -64,740 +61,741 @@ @Slf4j public class LiveOktaRepository implements OktaRepository { - private static final String OKTA_GROUP_NOT_FOUND = "Okta group not found for this organization"; - - private final String rolePrefix; - private final Application app; - private final OrganizationExtractor extractor; - private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; - - private final ApplicationApi applicationApi; - private final GroupApi groupApi; - private final UserApi userApi; - private final ApplicationGroupsApi applicationGroupsApi; - private final String adminGroupName; - - private static final String OKTA_ORG_PROFILE_MATCHER = "profile.name sw \""; - private static final int OKTA_PAGE_SIZE = 500; - - public LiveOktaRepository( - AuthorizationProperties authorizationProperties, - @Value("${okta.oauth2.client-id}") String oktaOAuth2ClientId, - OrganizationExtractor organizationExtractor, - CurrentTenantDataAccessContextHolder tenantDataContextHolder, - GroupApi groupApi, - ApplicationApi applicationApi, - UserApi userApi, - ApplicationGroupsApi applicationGroupsApi) { - this.rolePrefix = authorizationProperties.getRolePrefix(); - this.adminGroupName = authorizationProperties.getAdminGroupName(); - - this.applicationApi = applicationApi; - this.groupApi = groupApi; - this.userApi = userApi; - this.applicationGroupsApi = applicationGroupsApi; - - try { - this.app = applicationApi.getApplication(oktaOAuth2ClientId, null); - } catch (ApiException e) { - throw new MisconfiguredApplicationException( - "Cannot find Okta application with id=" + oktaOAuth2ClientId, e); - } - - this.extractor = organizationExtractor; - this.tenantDataContextHolder = tenantDataContextHolder; - } - - @Override - public Optional createUser( - IdentityAttributes userIdentity, - Organization org, - Set facilities, - Set roles, - boolean active) { - // By default, when creating a user, we give them privileges of a standard user - String organizationExternalId = org.getExternalId(); - Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); - rolesToCreate.addAll(roles); - - // Add user to new groups - Set groupNamesToAdd = new HashSet<>(); - groupNamesToAdd.addAll( - rolesToCreate.stream() - .map(r -> generateRoleGroupName(organizationExternalId, r)) - .collect(Collectors.toSet())); - groupNamesToAdd.addAll( - facilities.stream() - // use an empty set of facilities if user can access all facilities anyway - .filter(f -> !PermissionHolder.grantsAllFacilityAccess(rolesToCreate)) - .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) - .collect(Collectors.toSet())); - - // Search and q results need to be combined because search results have a delay of the newest - // added groups. - // https://github.com/okta/okta-sdk-java/issues/750 - var searchResults = - groupApi - .listGroups( - null, - null, - null, - null, - null, - OKTA_ORG_PROFILE_MATCHER + generateGroupOrgPrefix(organizationExternalId) + "\"", - null, - null) - .stream(); - var qResults = - groupApi - .listGroups( - generateGroupOrgPrefix(organizationExternalId), - null, - null, - null, - null, - null, - null, - null) - .stream(); - var orgGroups = Stream.concat(searchResults, qResults).distinct().toList(); - throwErrorIfEmpty( - orgGroups.stream(), - String.format( - "Cannot add Okta user to nonexistent organization=%s", organizationExternalId)); - Set orgGroupNames = - orgGroups.stream().map(g -> g.getProfile().getName()).collect(Collectors.toSet()); - groupNamesToAdd.stream() - .filter(n -> !orgGroupNames.contains(n)) - .forEach( - n -> { - throw new IllegalGraphqlArgumentException( - String.format("Cannot add Okta user to nonexistent group=%s", n)); - }); - Set groupIdsToAdd = - orgGroups.stream() - .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) - .map(Group::getId) - .collect(Collectors.toSet()); - validateRequiredFields(userIdentity); - try { - var user = - UserBuilder.instance() - .setFirstName(userIdentity.getFirstName()) - .setMiddleName(userIdentity.getMiddleName()) - .setLastName(userIdentity.getLastName()) - .setHonorificSuffix(userIdentity.getSuffix()) - .setEmail(userIdentity.getUsername()) - .setLogin(userIdentity.getUsername()) - .setActive(active) - .buildAndCreate(userApi); - groupIdsToAdd.forEach(groupId -> groupApi.assignUserToGroup(groupId, user.getId())); - } catch (ApiException e) { - if (e.getMessage() - .contains("An object with this field already exists in the current organization")) { - throw new ConflictingUserException(); - } else { - throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); - } - } - - List claims = extractor.convertClaims(groupNamesToAdd); - if (claims.size() != 1) { - log.warn("User is in {} Okta organizations, not 1", claims.size()); - return Optional.empty(); - } - return Optional.of(claims.get(0)); - } - - private static void validateRequiredFields(IdentityAttributes userIdentity) { - if (StringUtils.isBlank(userIdentity.getLastName())) { - throw new IllegalGraphqlArgumentException("Cannot create Okta user without last name"); - } - if (StringUtils.isBlank(userIdentity.getUsername())) { - throw new IllegalGraphqlArgumentException("Cannot create Okta user without username"); - } - } - - @Override - public Set getAllUsersForOrganization(Organization org) { - return getAllUsersForOrg(org).stream() - .map(u -> u.getProfile().getLogin()) - .collect(Collectors.toUnmodifiableSet()); - } - - @Override - public Map getAllUsersWithStatusForOrganization(Organization org) { - return getAllUsersForOrg(org).stream() - .collect(Collectors.toMap(u -> u.getProfile().getLogin(), User::getStatus)); - } - - private List getAllUsersForOrg(Organization org) { - PagedList pagedUserList = new PagedList<>(); - List allUsers = new ArrayList<>(); - Group orgDefaultOktaGroup = getDefaultOktaGroup(org); - do { - pagedUserList = - (PagedList) - groupApi.listGroupUsers( - orgDefaultOktaGroup.getId(), pagedUserList.getAfter(), OKTA_PAGE_SIZE); - allUsers.addAll(pagedUserList); - } while (pagedUserList.hasMoreItems()); - return allUsers; - } - - private Group getDefaultOktaGroup(Organization org) { - final String orgDefaultGroupName = - generateRoleGroupName(org.getExternalId(), OrganizationRole.getDefault()); - final var oktaGroupList = - groupApi.listGroups(orgDefaultGroupName, null, null, null, null, null, null, null); - - return oktaGroupList.stream() - .filter(g -> orgDefaultGroupName.equals(g.getProfile().getName())) - .findFirst() - .orElseThrow(() -> new IllegalGraphqlArgumentException(OKTA_GROUP_NOT_FOUND)); - } - - @Override - public Optional updateUser(IdentityAttributes userIdentity) { - var user = - getUserOrThrowError( - userIdentity.getUsername(), "Cannot update Okta user with unrecognized username"); - updateUser(user, userIdentity); - - return getOrganizationRoleClaimsForUser(user); - } - - private void updateUser(User user, IdentityAttributes userIdentity) { - user.getProfile().setFirstName(userIdentity.getFirstName()); - user.getProfile().setMiddleName(userIdentity.getMiddleName()); - user.getProfile().setLastName(userIdentity.getLastName()); - // Is it our fault we don't accommodate honorific suffix? Or Okta's fault they - // don't have regular suffix? You decide. - user.getProfile().setHonorificSuffix(userIdentity.getSuffix()); - var updateRequest = new UpdateUserRequest(); - updateRequest.setProfile(user.getProfile()); - userApi.updateUser(user.getId(), updateRequest, false); - } - - @Override - public Optional updateUserEmail( - IdentityAttributes userIdentity, String email) { - var user = - getUserOrThrowError( - userIdentity.getUsername(), - "Cannot update email of Okta user with unrecognized username"); - UserProfile profile = user.getProfile(); - profile.setLogin(email); - profile.setEmail(email); - user.setProfile(profile); - var updateRequest = new UpdateUserRequest(); - updateRequest.setProfile(profile); - try { - userApi.updateUser(user.getId(), updateRequest, false); - } catch (ApiException e) { - if (e.getMessage() - .contains("An object with this field already exists in the current organization")) { - throw new ConflictingUserException(); - } else { - throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); - } - } - - return getOrganizationRoleClaimsForUser(user); - } - - @Override - public void reprovisionUser(IdentityAttributes userIdentity) { - var user = - getUserOrThrowError( - userIdentity.getUsername(), "Cannot reprovision Okta user with unrecognized username"); - UserStatus userStatus = user.getStatus(); - - // any org user "deleted" through our api will be in SUSPENDED state - if (userStatus != UserStatus.SUSPENDED) { - throw new ConflictingUserException(); + private static final String OKTA_GROUP_NOT_FOUND = "Okta group not found for this organization"; + + private final String rolePrefix; + private final Application app; + private final OrganizationExtractor extractor; + private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; + + private final ApplicationApi applicationApi; + private final GroupApi groupApi; + private final UserApi userApi; + private final ApplicationGroupsApi applicationGroupsApi; + private final String adminGroupName; + + private static final String OKTA_ORG_PROFILE_MATCHER = "profile.name sw \""; + private static final int OKTA_PAGE_SIZE = 500; + + public LiveOktaRepository( + AuthorizationProperties authorizationProperties, + @Value("${okta.oauth2.client-id}") String oktaOAuth2ClientId, + OrganizationExtractor organizationExtractor, + CurrentTenantDataAccessContextHolder tenantDataContextHolder, + GroupApi groupApi, + ApplicationApi applicationApi, + UserApi userApi, + ApplicationGroupsApi applicationGroupsApi) { + this.rolePrefix = authorizationProperties.getRolePrefix(); + this.adminGroupName = authorizationProperties.getAdminGroupName(); + + this.applicationApi = applicationApi; + this.groupApi = groupApi; + this.userApi = userApi; + this.applicationGroupsApi = applicationGroupsApi; + + try { + this.app = applicationApi.getApplication(oktaOAuth2ClientId, null); + } catch (ApiException e) { + throw new MisconfiguredApplicationException( + "Cannot find Okta application with id=" + oktaOAuth2ClientId, e); + } + + this.extractor = organizationExtractor; + this.tenantDataContextHolder = tenantDataContextHolder; + } + + @Override + public Optional createUser( + IdentityAttributes userIdentity, + Organization org, + Set facilities, + Set roles, + boolean active) { + // By default, when creating a user, we give them privileges of a standard user + String organizationExternalId = org.getExternalId(); + Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); + rolesToCreate.addAll(roles); + + // Add user to new groups + Set groupNamesToAdd = new HashSet<>(); + groupNamesToAdd.addAll( + rolesToCreate.stream() + .map(r -> generateRoleGroupName(organizationExternalId, r)) + .collect(Collectors.toSet())); + groupNamesToAdd.addAll( + facilities.stream() + // use an empty set of facilities if user can access all facilities anyway + .filter(f -> !PermissionHolder.grantsAllFacilityAccess(rolesToCreate)) + .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) + .collect(Collectors.toSet())); + + // Search and q results need to be combined because search results have a delay of the newest + // added groups. + // https://github.com/okta/okta-sdk-java/issues/750 + var searchResults = + groupApi + .listGroups( + null, + null, + null, + null, + null, + OKTA_ORG_PROFILE_MATCHER + generateGroupOrgPrefix(organizationExternalId) + "\"", + null, + null) + .stream(); + var qResults = + groupApi + .listGroups( + generateGroupOrgPrefix(organizationExternalId), + null, + null, + null, + null, + null, + null, + null) + .stream(); + var orgGroups = Stream.concat(searchResults, qResults).distinct().toList(); + throwErrorIfEmpty( + orgGroups.stream(), + String.format( + "Cannot add Okta user to nonexistent organization=%s", organizationExternalId)); + Set orgGroupNames = + orgGroups.stream().map(g -> g.getProfile().getName()).collect(Collectors.toSet()); + groupNamesToAdd.stream() + .filter(n -> !orgGroupNames.contains(n)) + .forEach( + n -> { + throw new IllegalGraphqlArgumentException( + String.format("Cannot add Okta user to nonexistent group=%s", n)); + }); + Set groupIdsToAdd = + orgGroups.stream() + .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) + .map(Group::getId) + .collect(Collectors.toSet()); + validateRequiredFields(userIdentity); + try { + var user = + UserBuilder.instance() + .setFirstName(userIdentity.getFirstName()) + .setMiddleName(userIdentity.getMiddleName()) + .setLastName(userIdentity.getLastName()) + .setHonorificSuffix(userIdentity.getSuffix()) + .setEmail(userIdentity.getUsername()) + .setLogin(userIdentity.getUsername()) + .setActive(active) + .buildAndCreate(userApi); + groupIdsToAdd.forEach(groupId -> groupApi.assignUserToGroup(groupId, user.getId())); + } catch (ApiException e) { + if (e.getMessage() + .contains("An object with this field already exists in the current organization")) { + throw new ConflictingUserException(); + } else { + throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); + } + } + + List claims = extractor.convertClaims(groupNamesToAdd); + if (claims.size() != 1) { + log.warn("User is in {} Okta organizations, not 1", claims.size()); + return Optional.empty(); + } + return Optional.of(claims.get(0)); + } + + private static void validateRequiredFields(IdentityAttributes userIdentity) { + if (StringUtils.isBlank(userIdentity.getLastName())) { + throw new IllegalGraphqlArgumentException("Cannot create Okta user without last name"); + } + if (StringUtils.isBlank(userIdentity.getUsername())) { + throw new IllegalGraphqlArgumentException("Cannot create Okta user without username"); + } + } + + @Override + public Set getAllUsersForOrganization(Organization org) { + return getAllUsersForOrg(org).stream() + .map(u -> u.getProfile().getLogin()) + .collect(Collectors.toUnmodifiableSet()); + } + + @Override + public Map getAllUsersWithStatusForOrganization(Organization org) { + return getAllUsersForOrg(org).stream() + .collect(Collectors.toMap(u -> u.getProfile().getLogin(), User::getStatus)); + } + + private List getAllUsersForOrg(Organization org) { + PagedList pagedUserList = new PagedList<>(); + List allUsers = new ArrayList<>(); + Group orgDefaultOktaGroup = getDefaultOktaGroup(org); + do { + pagedUserList = + (PagedList) + groupApi.listGroupUsers( + orgDefaultOktaGroup.getId(), pagedUserList.getAfter(), OKTA_PAGE_SIZE); + allUsers.addAll(pagedUserList); + } while (pagedUserList.hasMoreItems()); + return allUsers; + } + + private Group getDefaultOktaGroup(Organization org) { + final String orgDefaultGroupName = + generateRoleGroupName(org.getExternalId(), OrganizationRole.getDefault()); + final var oktaGroupList = + groupApi.listGroups(orgDefaultGroupName, null, null, null, null, null, null, null); + + return oktaGroupList.stream() + .filter(g -> orgDefaultGroupName.equals(g.getProfile().getName())) + .findFirst() + .orElseThrow(() -> new IllegalGraphqlArgumentException(OKTA_GROUP_NOT_FOUND)); + } + + @Override + public Optional updateUser(IdentityAttributes userIdentity) { + var user = + getUserOrThrowError( + userIdentity.getUsername(), "Cannot update Okta user with unrecognized username"); + updateUser(user, userIdentity); + + return getOrganizationRoleClaimsForUser(user); + } + + private void updateUser(User user, IdentityAttributes userIdentity) { + user.getProfile().setFirstName(userIdentity.getFirstName()); + user.getProfile().setMiddleName(userIdentity.getMiddleName()); + user.getProfile().setLastName(userIdentity.getLastName()); + // Is it our fault we don't accommodate honorific suffix? Or Okta's fault they + // don't have regular suffix? You decide. + user.getProfile().setHonorificSuffix(userIdentity.getSuffix()); + var updateRequest = new UpdateUserRequest(); + updateRequest.setProfile(user.getProfile()); + userApi.updateUser(user.getId(), updateRequest, false); + } + + @Override + public Optional updateUserEmail( + IdentityAttributes userIdentity, String email) { + var user = + getUserOrThrowError( + userIdentity.getUsername(), + "Cannot update email of Okta user with unrecognized username"); + UserProfile profile = user.getProfile(); + profile.setLogin(email); + profile.setEmail(email); + user.setProfile(profile); + var updateRequest = new UpdateUserRequest(); + updateRequest.setProfile(profile); + try { + userApi.updateUser(user.getId(), updateRequest, false); + } catch (ApiException e) { + if (e.getMessage() + .contains("An object with this field already exists in the current organization")) { + throw new ConflictingUserException(); + } else { + throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); + } + } + + return getOrganizationRoleClaimsForUser(user); + } + + @Override + public void reprovisionUser(IdentityAttributes userIdentity) { + var user = + getUserOrThrowError( + userIdentity.getUsername(), "Cannot reprovision Okta user with unrecognized username"); + UserStatus userStatus = user.getStatus(); + + // any org user "deleted" through our api will be in SUSPENDED state + if (userStatus != UserStatus.SUSPENDED) { + throw new ConflictingUserException(); + } + + updateUser(user, userIdentity); + userApi.resetFactors(user.getId()); + + // transitioning from SUSPENDED -> DEPROVISIONED -> ACTIVE will reset the user's password and + // password reset question. This cannot be done with `.reactivateUser()` because it requires the + // user to be in PROVISIONED state + userApi.deactivateUser(user.getId(), false); + userApi.activateUser(user.getId(), true); + } + + @Override + public List updateUserPrivilegesAndGroupAccess( + String username, + Organization org, + Set facilities, + OrganizationRole role, + boolean assignedToAllFacilities) { + + // unassign user from current groups + + User oktaUserToMove = getUserOrThrowError(username, "Couldn't find user"); + List groupsToUnassign = userApi.listUserGroups(oktaUserToMove.getId()); + + groupsToUnassign.stream() + // only match on the org-related group ids and not the Okta-wide orgs like "Everyone" + .filter(g -> g.getProfile().getName().contains("TENANT")) + .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), oktaUserToMove.getId())); + + // add them to the new groups + String organizationExternalId = org.getExternalId(); + EnumSet rolesToCreate = + assignedToAllFacilities + ? EnumSet.of(OrganizationRole.getDefault(), role, OrganizationRole.ALL_FACILITIES) + : EnumSet.of(OrganizationRole.getDefault(), role); + + Set groupNamesToAdd = new HashSet<>(); + groupNamesToAdd.addAll( + rolesToCreate.stream() + .map(r -> generateRoleGroupName(organizationExternalId, r)) + .collect(Collectors.toSet())); + + groupNamesToAdd.addAll( + facilities.stream() + .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) + .collect(Collectors.toSet())); + + String groupOrgPrefix = generateGroupOrgPrefix(org.getExternalId()); + Map orgsToAddUserToMap = + groupApi + .listGroups( + null, + null, + null, + null, + null, + OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", + null, + null) + .stream() + .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) + .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); + + orgsToAddUserToMap.forEach( + (name, group) -> groupApi.assignUserToGroup(group.getId(), oktaUserToMove.getId())); + return orgsToAddUserToMap.keySet().stream().toList(); + } + + @Override + public Optional updateUserPrivileges( + String username, Organization org, Set facilities, Set roles) { + User user = + getUserOrThrowError(username, "Cannot update role of Okta user with unrecognized username"); + + String orgId = org.getExternalId(); + + final String groupOrgPrefix = generateGroupOrgPrefix(orgId); + final String groupOrgDefaultName = generateRoleGroupName(orgId, OrganizationRole.getDefault()); + + // Map user's current Okta group memberships (Okta group name -> Okta Group). + // The Okta group name is our friendly role and facility group names + Map currentOrgGroupMapForUser = + userApi.listUserGroups(user.getId()).stream() + .filter( + g -> + GroupType.OKTA_GROUP == g.getType() + && g.getProfile().getName().startsWith(groupOrgPrefix)) + .collect(Collectors.toMap(g -> g.getProfile().getName(), g -> g)); + + if (!currentOrgGroupMapForUser.containsKey(groupOrgDefaultName)) { + // The user is not a member of the default group for this organization. If they happen + // to be in any of this organization's groups, remove the user from those groups. + currentOrgGroupMapForUser + .values() + .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), user.getId())); + throw new IllegalGraphqlArgumentException( + "Cannot update privileges of Okta user in organization they do not belong to."); + } + + Set expectedOrgGroupNamesForUser = new HashSet<>(); + expectedOrgGroupNamesForUser.add(groupOrgDefaultName); + expectedOrgGroupNamesForUser.addAll( + roles.stream().map(r -> generateRoleGroupName(orgId, r)).collect(Collectors.toSet())); + if (!PermissionHolder.grantsAllFacilityAccess(roles)) { + expectedOrgGroupNamesForUser.addAll( + facilities.stream() + .map(f -> generateFacilityGroupName(orgId, f.getInternalId())) + .collect(Collectors.toSet())); + } + + // to remove... + Set groupNamesToRemove = new HashSet<>(currentOrgGroupMapForUser.keySet()); + groupNamesToRemove.removeIf(expectedOrgGroupNamesForUser::contains); + + // to add... + Set groupNamesToAdd = new HashSet<>(expectedOrgGroupNamesForUser); + groupNamesToAdd.removeIf(currentOrgGroupMapForUser::containsKey); + + if (!groupNamesToRemove.isEmpty() || !groupNamesToAdd.isEmpty()) { + Map fullOrgGroupMap = + groupApi + .listGroups( + null, + null, + null, + null, + null, + OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", + null, + null) + .stream() + .filter(g -> GroupType.OKTA_GROUP == g.getType()) + .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); + if (fullOrgGroupMap.size() == 0) { + throw new IllegalGraphqlArgumentException( + String.format("Cannot add Okta user to nonexistent organization=%s", orgId)); + } + + for (String groupName : groupNamesToRemove) { + Group group = fullOrgGroupMap.get(groupName); + log.info("Removing {} from Okta group: {}", username, group.getProfile().getName()); + groupApi.unassignUserFromGroup(group.getId(), user.getId()); + } + + for (String groupName : groupNamesToAdd) { + if (!fullOrgGroupMap.containsKey(groupName)) { + throw new IllegalGraphqlArgumentException( + String.format("Cannot add Okta user to nonexistent group=%s", groupName)); } - - updateUser(user, userIdentity); - userApi.resetFactors(user.getId()); - - // transitioning from SUSPENDED -> DEPROVISIONED -> ACTIVE will reset the user's password and - // password reset question. This cannot be done with `.reactivateUser()` because it requires the - // user to be in PROVISIONED state - userApi.deactivateUser(user.getId(), false); - userApi.activateUser(user.getId(), true); - } - - @Override - public List updateUserPrivilegesAndGroupAccess( - String username, - Organization org, - Set facilities, - OrganizationRole role, - boolean assignedToAllFacilities) { - - // unassign user from current groups - - User oktaUserToMove = getUserOrThrowError(username, "Couldn't find user"); - List groupsToUnassign = userApi.listUserGroups(oktaUserToMove.getId()); - - groupsToUnassign.stream() - // only match on the org-related group ids and not the Okta-wide orgs like "Everyone" - .filter(g -> g.getProfile().getName().contains("TENANT")) - .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), oktaUserToMove.getId())); - - // add them to the new groups - String organizationExternalId = org.getExternalId(); - EnumSet rolesToCreate = - assignedToAllFacilities - ? EnumSet.of(OrganizationRole.getDefault(), role, OrganizationRole.ALL_FACILITIES) - : EnumSet.of(OrganizationRole.getDefault(), role); - - Set groupNamesToAdd = new HashSet<>(); - groupNamesToAdd.addAll( - rolesToCreate.stream() - .map(r -> generateRoleGroupName(organizationExternalId, r)) - .collect(Collectors.toSet())); - - groupNamesToAdd.addAll( - facilities.stream() - .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) - .collect(Collectors.toSet())); - - String groupOrgPrefix = generateGroupOrgPrefix(org.getExternalId()); - Map orgsToAddUserToMap = - groupApi - .listGroups( - null, - null, - null, - null, - null, - OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", - null, - null) - .stream() - .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) - .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); - - orgsToAddUserToMap.forEach( - (name, group) -> groupApi.assignUserToGroup(group.getId(), oktaUserToMove.getId())); - return orgsToAddUserToMap.keySet().stream().toList(); - } - - @Override - public Optional updateUserPrivileges( - String username, Organization org, Set facilities, Set roles) { - User user = - getUserOrThrowError(username, "Cannot update role of Okta user with unrecognized username"); - - String orgId = org.getExternalId(); - - final String groupOrgPrefix = generateGroupOrgPrefix(orgId); - final String groupOrgDefaultName = generateRoleGroupName(orgId, OrganizationRole.getDefault()); - - // Map user's current Okta group memberships (Okta group name -> Okta Group). - // The Okta group name is our friendly role and facility group names - Map currentOrgGroupMapForUser = - userApi.listUserGroups(user.getId()).stream() - .filter( - g -> - GroupType.OKTA_GROUP == g.getType() - && g.getProfile().getName().startsWith(groupOrgPrefix)) - .collect(Collectors.toMap(g -> g.getProfile().getName(), g -> g)); - - if (!currentOrgGroupMapForUser.containsKey(groupOrgDefaultName)) { - // The user is not a member of the default group for this organization. If they happen - // to be in any of this organization's groups, remove the user from those groups. - currentOrgGroupMapForUser - .values() - .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), user.getId())); - throw new IllegalGraphqlArgumentException( - "Cannot update privileges of Okta user in organization they do not belong to."); - } - - Set expectedOrgGroupNamesForUser = new HashSet<>(); - expectedOrgGroupNamesForUser.add(groupOrgDefaultName); - expectedOrgGroupNamesForUser.addAll( - roles.stream().map(r -> generateRoleGroupName(orgId, r)).collect(Collectors.toSet())); - if (!PermissionHolder.grantsAllFacilityAccess(roles)) { - expectedOrgGroupNamesForUser.addAll( - facilities.stream() - .map(f -> generateFacilityGroupName(orgId, f.getInternalId())) - .collect(Collectors.toSet())); - } - - // to remove... - Set groupNamesToRemove = new HashSet<>(currentOrgGroupMapForUser.keySet()); - groupNamesToRemove.removeIf(expectedOrgGroupNamesForUser::contains); - - // to add... - Set groupNamesToAdd = new HashSet<>(expectedOrgGroupNamesForUser); - groupNamesToAdd.removeIf(currentOrgGroupMapForUser::containsKey); - - if (!groupNamesToRemove.isEmpty() || !groupNamesToAdd.isEmpty()) { - Map fullOrgGroupMap = - groupApi - .listGroups( - null, - null, - null, - null, - null, - OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", - null, - null) - .stream() - .filter(g -> GroupType.OKTA_GROUP == g.getType()) - .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); - if (fullOrgGroupMap.size() == 0) { - throw new IllegalGraphqlArgumentException( - String.format("Cannot add Okta user to nonexistent organization=%s", orgId)); - } - - for (String groupName : groupNamesToRemove) { - Group group = fullOrgGroupMap.get(groupName); - log.info("Removing {} from Okta group: {}", username, group.getProfile().getName()); - groupApi.unassignUserFromGroup(group.getId(), user.getId()); - } - - for (String groupName : groupNamesToAdd) { - if (!fullOrgGroupMap.containsKey(groupName)) { - throw new IllegalGraphqlArgumentException( - String.format("Cannot add Okta user to nonexistent group=%s", groupName)); - } - Group group = fullOrgGroupMap.get(groupName); - log.info("Adding {} to Okta group: {}", username, group.getProfile().getName()); - groupApi.assignUserToGroup(group.getId(), user.getId()); - } - } - - return getOrganizationRoleClaimsForUser(user); - } - - @Override - public void resetUserPassword(String username) { - var user = - getUserOrThrowError( - username, "Cannot reset password for Okta user with unrecognized username"); - userApi.generateResetPasswordToken(user.getId(), true, false); - } - - @Override - public void resetUserMfa(String username) { - var user = - getUserOrThrowError(username, "Cannot reset MFA for Okta user with unrecognized username"); - userApi.resetFactors(user.getId()); - } - - @Override - public void setUserIsActive(String username, boolean active) { - var user = - getUserOrThrowError( - username, "Cannot update active status of Okta user with unrecognized username"); - - if (active && user.getStatus() == UserStatus.SUSPENDED) { - userApi.unsuspendUser(user.getId()); - } else if (!active && user.getStatus() != UserStatus.SUSPENDED) { - userApi.suspendUser(user.getId()); - } - } - - @Override - public UserStatus getUserStatus(String username) { - return getUserOrThrowError( - username, "Cannot retrieve Okta user's status with unrecognized username") - .getStatus(); - } - - @Override - public void reactivateUser(String username) { - var user = - getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); - userApi.unsuspendUser(user.getId()); - } - - @Override - public void resendActivationEmail(String username) { - var user = - getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); - if (user.getStatus() == UserStatus.PROVISIONED) { - userApi.reactivateUser(user.getId(), true); - } else if (user.getStatus() == UserStatus.STAGED) { - userApi.activateUser(user.getId(), true); - } else { - throw new IllegalGraphqlArgumentException( - "Cannot reactivate user with status: " + user.getStatus()); - } - } - - /** - * Iterates over all OrganizationRole's, creating new corresponding Okta groups for this - * organization where they do not already exist. For those OrganizationRole's that are in - * MIGRATION_DEST_ROLES and whose Okta groups are newly created, migrate all users from this org - * to those new Okta groups, where the migrated users are sourced from all pre-existing Okta - * groups for this organization. Separately, iterates over all facilities in this org, creating - * new corresponding Okta groups where they do not already exist. Does not perform any migration - * to these facility groups. - */ - @Override - public void createOrganization(Organization org) { - String name = org.getOrganizationName(); - String externalId = org.getExternalId(); - - for (OrganizationRole role : OrganizationRole.values()) { - String roleGroupName = generateRoleGroupName(externalId, role); - String roleGroupDescription = generateRoleGroupDescription(name, role); - Group g = - GroupBuilder.instance() - .setName(roleGroupName) - .setDescription(roleGroupDescription) - .buildAndCreate(groupApi); - applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); - - log.info("Created Okta group={}", roleGroupName); - } - } - - private List getOrgAdminUsers(Organization org) { - String externalId = org.getExternalId(); - String roleGroupName = generateRoleGroupName(externalId, OrganizationRole.ADMIN); - var groups = groupApi.listGroups(roleGroupName, null, null, null, null, null, null, null); - throwErrorIfEmpty(groups.stream(), "Cannot activate nonexistent Okta organization"); - Group group = groups.get(0); - return groupApi.listGroupUsers(group.getId(), null, null); - } - - private String activateUser(User user) { - if (user.getStatus() == UserStatus.PROVISIONED) { - // reactivates user and sends them an Okta email to reactivate their account - return userApi.reactivateUser(user.getId(), true).getActivationToken(); - } else if (user.getStatus() == UserStatus.STAGED) { - return userApi.activateUser(user.getId(), true).getActivationToken(); - } else { - throw new IllegalGraphqlArgumentException( - "Cannot activate Okta organization whose users have status=" + user.getStatus().name()); - } - } - - @Override - public void activateOrganization(Organization org) { - var users = getOrgAdminUsers(org); - for (User u : users) { - activateUser(u); - } - } - - @Override - public String activateOrganizationWithSingleUser(Organization org) { - User user = getOrgAdminUsers(org).get(0); - return activateUser(user); - } - - @Override - public List fetchAdminUserEmail(Organization org) { - var admins = getOrgAdminUsers(org); - return admins.stream().map(u -> u.getProfile().getLogin()).toList(); - } - - @Override - public void createFacility(Facility facility) { - // Only create the facility group if the facility's organization has already been created - String orgExternalId = facility.getOrganization().getExternalId(); - var orgGroups = - groupApi.listGroups( - generateGroupOrgPrefix(orgExternalId), null, null, null, null, null, null, null); - throwErrorIfEmpty( - orgGroups.stream(), - String.format( - "Cannot create Okta group for facility=%s: facility's org=%s, has not yet been created in Okta", - facility.getFacilityName(), facility.getOrganization().getExternalId())); - - String orgName = facility.getOrganization().getOrganizationName(); - String facilityGroupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); - Group g = - GroupBuilder.instance() - .setName(facilityGroupName) - .setDescription(generateFacilityGroupDescription(orgName, facility.getFacilityName())) - .buildAndCreate(groupApi); - applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); - - log.info("Created Okta group={}", facilityGroupName); - } - - public void deleteFacility(Facility facility) { - String orgExternalId = facility.getOrganization().getExternalId(); - String groupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); - var groups = groupApi.listGroups(groupName, null, null, null, null, null, null, null); - for (Group group : groups) { - groupApi.deleteGroup(group.getId()); - } - } - - @Override - public void deleteOrganization(Organization org) { - String externalId = org.getExternalId(); - var orgGroups = - groupApi.listGroups( - generateGroupOrgPrefix(externalId), null, null, null, null, null, null, null); - for (Group group : orgGroups) { - groupApi.deleteGroup(group.getId()); - } - } - - // returns the external ID of the organization the specified user belongs to - @Override - public Optional getOrganizationRoleClaimsForUser(String username) { - // When a site admin is using tenant data access, bypass okta and get org from the altered - // authorities. If the site admin is getting the claims for another site admin who also has - // active tenant data access, then reflect what is in Okta, not the temporary claims. - if (tenantDataContextHolder.hasBeenPopulated() - && username.equals(tenantDataContextHolder.getUsername())) { - return getOrganizationRoleClaimsFromAuthorities(tenantDataContextHolder.getAuthorities()); - } - - return getOrganizationRoleClaimsForUser( - getUserOrThrowError(username, "Cannot get org external ID for nonexistent user")); - } - - public Integer getUsersInSingleFacility(Facility facility) { - String facilityAccessGroupName = - generateFacilityGroupName( - facility.getOrganization().getExternalId(), facility.getInternalId()); - - List facilityAccessGroup = - groupApi.listGroups(facilityAccessGroupName, null, null, 1, "stats", null, null, null); - - if (facilityAccessGroup.isEmpty()) { - return 0; - } - - try { - LinkedHashMap stats = - (LinkedHashMap) facilityAccessGroup.get(0).getEmbedded().get("stats"); - return ((Integer) stats.get("usersCount")); - } catch (NullPointerException e) { - throw new BadRequestException("Unable to retrieve okta group stats", e); - } - } - - public PartialOktaUser findUser(String username) { - User user = - getUserOrThrowError( - username, "Cannot retrieve Okta user's status with unrecognized username"); - - List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); - - Optional orgClaims = convertToOrganizationRoleClaims(userGroups); - - return PartialOktaUser.builder() - .username(username) - .isSiteAdmin(isSiteAdmin(userGroups)) - .status(user.getStatus()) - .organizationRoleClaims(orgClaims) - .build(); - } - - public int getConnectTimeoutForHealthCheck() { - return applicationApi.getApiClient().getConnectTimeout(); - } - - private Optional getOrganizationRoleClaimsFromAuthorities( - Collection authorities) { - List claims = extractor.convertClaims(authorities); - - if (claims.size() != 1) { - log.warn("User's Tenant Data Access has claims in {} organizations, not 1", claims.size()); - return Optional.empty(); - } - return Optional.of(claims.get(0)); - } - - private Optional getOrganizationRoleClaimsForUser(User user) { - List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); - return convertToOrganizationRoleClaims(userGroups); - } - - private Optional convertToOrganizationRoleClaims(List userGroups) { - List groupNames = - userGroups.stream() - .filter(g -> g.getType() == GroupType.OKTA_GROUP) - .map(g -> g.getProfile().getName()) - .toList(); - List claims = extractor.convertClaims(groupNames); - - if (claims.size() != 1) { - log.warn("User is in {} Okta organizations, not 1", claims.size()); - return Optional.empty(); - } - return Optional.of(claims.get(0)); - } - - private boolean isSiteAdmin(List oktaGroups) { - return oktaGroups.stream() - .filter(g -> g.getType() == GroupType.OKTA_GROUP) - .anyMatch(g -> adminGroupName.equals(g.getProfile().getName())); - } - - private String generateGroupOrgPrefix(String orgExternalId) { - return String.format("%s%s", rolePrefix, orgExternalId); - } - - private String generateRoleGroupName(String orgExternalId, OrganizationRole role) { - return String.format("%s%s%s", rolePrefix, orgExternalId, generateRoleSuffix(role)); - } - - private String generateFacilityGroupName(String orgExternalId, UUID facilityId) { - return String.format( - "%s%s%s", rolePrefix, orgExternalId, generateFacilitySuffix(facilityId.toString())); - } - - private String generateRoleGroupDescription(String orgName, OrganizationRole role) { - return String.format("%s - %ss", orgName, role.getDescription()); - } - - private String generateFacilityGroupDescription(String orgName, String facilityName) { - return String.format("%s - Facility Access - %s", orgName, facilityName); - } - - private String generateRoleSuffix(OrganizationRole role) { - return ":" + role.name(); - } - - private String generateFacilitySuffix(String facilityId) { - return ":" + OrganizationExtractor.FACILITY_ACCESS_MARKER + ":" + facilityId; - } - - private User getUserOrThrowError(String email, String errorMessage) { - try { - return userApi.getUser(email); - } catch (ApiException e) { - throw new IllegalGraphqlArgumentException(errorMessage); - } - } - - private void throwErrorIfEmpty(Stream stream, String errorMessage) { - if (stream.findAny().isEmpty()) { - throw new IllegalGraphqlArgumentException(errorMessage); - } - } - - private String prettifyOktaError(ApiException e) { - var errorMessage = "Code: " + e.getCode() + "; Message: " + e.getMessage(); - if (e.getResponseBody() != null) { - Error error = ApiExceptionHelper.getError(e); - if (error != null) { - errorMessage = - "Okta Error: " + error.getErrorCode() + ", Error summary: " + error.getErrorSummary(); - if (error.getErrorCauses() != null) { - errorMessage += - ", Error Cause(s): " - + error.getErrorCauses().stream() - .map(ErrorErrorCausesInner::getErrorSummary) - .collect(Collectors.joining(", ")); - } - } + Group group = fullOrgGroupMap.get(groupName); + log.info("Adding {} to Okta group: {}", username, group.getProfile().getName()); + groupApi.assignUserToGroup(group.getId(), user.getId()); + } + } + + return getOrganizationRoleClaimsForUser(user); + } + + @Override + public void resetUserPassword(String username) { + var user = + getUserOrThrowError( + username, "Cannot reset password for Okta user with unrecognized username"); + userApi.generateResetPasswordToken(user.getId(), true, false); + } + + @Override + public void resetUserMfa(String username) { + var user = + getUserOrThrowError(username, "Cannot reset MFA for Okta user with unrecognized username"); + userApi.resetFactors(user.getId()); + } + + @Override + public void setUserIsActive(String username, boolean active) { + var user = + getUserOrThrowError( + username, "Cannot update active status of Okta user with unrecognized username"); + + if (active && user.getStatus() == UserStatus.SUSPENDED) { + userApi.unsuspendUser(user.getId()); + } else if (!active && user.getStatus() != UserStatus.SUSPENDED) { + userApi.suspendUser(user.getId()); + } + } + + @Override + public UserStatus getUserStatus(String username) { + return getUserOrThrowError( + username, "Cannot retrieve Okta user's status with unrecognized username") + .getStatus(); + } + + @Override + public void reactivateUser(String username) { + var user = + getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); + userApi.unsuspendUser(user.getId()); + } + + @Override + public void resendActivationEmail(String username) { + var user = + getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); + if (user.getStatus() == UserStatus.PROVISIONED) { + userApi.reactivateUser(user.getId(), true); + } else if (user.getStatus() == UserStatus.STAGED) { + userApi.activateUser(user.getId(), true); + } else { + throw new IllegalGraphqlArgumentException( + "Cannot reactivate user with status: " + user.getStatus()); + } + } + + /** + * Iterates over all OrganizationRole's, creating new corresponding Okta groups for this + * organization where they do not already exist. For those OrganizationRole's that are in + * MIGRATION_DEST_ROLES and whose Okta groups are newly created, migrate all users from this org + * to those new Okta groups, where the migrated users are sourced from all pre-existing Okta + * groups for this organization. Separately, iterates over all facilities in this org, creating + * new corresponding Okta groups where they do not already exist. Does not perform any migration + * to these facility groups. + */ + @Override + public void createOrganization(Organization org) { + String name = org.getOrganizationName(); + String externalId = org.getExternalId(); + + for (OrganizationRole role : OrganizationRole.values()) { + String roleGroupName = generateRoleGroupName(externalId, role); + String roleGroupDescription = generateRoleGroupDescription(name, role); + Group g = + GroupBuilder.instance() + .setName(roleGroupName) + .setDescription(roleGroupDescription) + .buildAndCreate(groupApi); + applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); + + log.info("Created Okta group={}", roleGroupName); + } + } + + private List getOrgAdminUsers(Organization org) { + String externalId = org.getExternalId(); + String roleGroupName = generateRoleGroupName(externalId, OrganizationRole.ADMIN); + var groups = groupApi.listGroups(roleGroupName, null, null, null, null, null, null, null); + throwErrorIfEmpty(groups.stream(), "Cannot activate nonexistent Okta organization"); + Group group = groups.get(0); + return groupApi.listGroupUsers(group.getId(), null, null); + } + + private String activateUser(User user) { + if (user.getStatus() == UserStatus.PROVISIONED) { + // reactivates user and sends them an Okta email to reactivate their account + return userApi.reactivateUser(user.getId(), true).getActivationToken(); + } else if (user.getStatus() == UserStatus.STAGED) { + return userApi.activateUser(user.getId(), true).getActivationToken(); + } else { + throw new IllegalGraphqlArgumentException( + "Cannot activate Okta organization whose users have status=" + user.getStatus().name()); + } + } + + @Override + public void activateOrganization(Organization org) { + var users = getOrgAdminUsers(org); + for (User u : users) { + activateUser(u); + } + } + + @Override + public String activateOrganizationWithSingleUser(Organization org) { + User user = getOrgAdminUsers(org).get(0); + return activateUser(user); + } + + @Override + public List fetchAdminUserEmail(Organization org) { + var admins = getOrgAdminUsers(org); + return admins.stream().map(u -> u.getProfile().getLogin()).toList(); + } + + @Override + public void createFacility(Facility facility) { + // Only create the facility group if the facility's organization has already been created + String orgExternalId = facility.getOrganization().getExternalId(); + var orgGroups = + groupApi.listGroups( + generateGroupOrgPrefix(orgExternalId), null, null, null, null, null, null, null); + throwErrorIfEmpty( + orgGroups.stream(), + String.format( + "Cannot create Okta group for facility=%s: facility's org=%s, has not yet been created in Okta", + facility.getFacilityName(), facility.getOrganization().getExternalId())); + + String orgName = facility.getOrganization().getOrganizationName(); + String facilityGroupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); + Group g = + GroupBuilder.instance() + .setName(facilityGroupName) + .setDescription(generateFacilityGroupDescription(orgName, facility.getFacilityName())) + .buildAndCreate(groupApi); + applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); + + log.info("Created Okta group={}", facilityGroupName); + } + + public void deleteFacility(Facility facility) { + String orgExternalId = facility.getOrganization().getExternalId(); + String groupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); + var groups = groupApi.listGroups(groupName, null, null, null, null, null, null, null); + for (Group group : groups) { + groupApi.deleteGroup(group.getId()); + } + } + + @Override + public void deleteOrganization(Organization org) { + String externalId = org.getExternalId(); + var orgGroups = + groupApi.listGroups( + generateGroupOrgPrefix(externalId), null, null, null, null, null, null, null); + for (Group group : orgGroups) { + groupApi.deleteGroup(group.getId()); + } + } + + // returns the external ID of the organization the specified user belongs to + @Override + public Optional getOrganizationRoleClaimsForUser(String username) { + // When a site admin is using tenant data access, bypass okta and get org from the altered + // authorities. If the site admin is getting the claims for another site admin who also has + // active tenant data access, then reflect what is in Okta, not the temporary claims. + if (tenantDataContextHolder.hasBeenPopulated() + && username.equals(tenantDataContextHolder.getUsername())) { + return getOrganizationRoleClaimsFromAuthorities(tenantDataContextHolder.getAuthorities()); + } + + return getOrganizationRoleClaimsForUser( + getUserOrThrowError(username, "Cannot get org external ID for nonexistent user")); + } + + public Integer getUsersInSingleFacility(Facility facility) { + String facilityAccessGroupName = + generateFacilityGroupName( + facility.getOrganization().getExternalId(), facility.getInternalId()); + + List facilityAccessGroup = + groupApi.listGroups(facilityAccessGroupName, null, null, 1, "stats", null, null, null); + + if (facilityAccessGroup.isEmpty()) { + return 0; + } + + try { + LinkedHashMap stats = + (LinkedHashMap) facilityAccessGroup.get(0).getEmbedded().get("stats"); + return ((Integer) stats.get("usersCount")); + } catch (NullPointerException e) { + throw new BadRequestException("Unable to retrieve okta group stats", e); + } + } + + public PartialOktaUser findUser(String username) { + User user = + getUserOrThrowError( + username, "Cannot retrieve Okta user's status with unrecognized username"); + + List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); + + Optional orgClaims = convertToOrganizationRoleClaims(userGroups); + + return PartialOktaUser.builder() + .username(username) + .isSiteAdmin(isSiteAdmin(userGroups)) + .status(user.getStatus()) + .organizationRoleClaims(orgClaims) + .build(); + } + + @Override + public String getApplicationStatusForHealthCheck() { + return app.getStatus().toString(); + } + + private Optional getOrganizationRoleClaimsFromAuthorities( + Collection authorities) { + List claims = extractor.convertClaims(authorities); + + if (claims.size() != 1) { + log.warn("User's Tenant Data Access has claims in {} organizations, not 1", claims.size()); + return Optional.empty(); + } + return Optional.of(claims.get(0)); + } + + private Optional getOrganizationRoleClaimsForUser(User user) { + List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); + return convertToOrganizationRoleClaims(userGroups); + } + + private Optional convertToOrganizationRoleClaims(List userGroups) { + List groupNames = + userGroups.stream() + .filter(g -> g.getType() == GroupType.OKTA_GROUP) + .map(g -> g.getProfile().getName()) + .toList(); + List claims = extractor.convertClaims(groupNames); + + if (claims.size() != 1) { + log.warn("User is in {} Okta organizations, not 1", claims.size()); + return Optional.empty(); + } + return Optional.of(claims.get(0)); + } + + private boolean isSiteAdmin(List oktaGroups) { + return oktaGroups.stream() + .filter(g -> g.getType() == GroupType.OKTA_GROUP) + .anyMatch(g -> adminGroupName.equals(g.getProfile().getName())); + } + + private String generateGroupOrgPrefix(String orgExternalId) { + return String.format("%s%s", rolePrefix, orgExternalId); + } + + private String generateRoleGroupName(String orgExternalId, OrganizationRole role) { + return String.format("%s%s%s", rolePrefix, orgExternalId, generateRoleSuffix(role)); + } + + private String generateFacilityGroupName(String orgExternalId, UUID facilityId) { + return String.format( + "%s%s%s", rolePrefix, orgExternalId, generateFacilitySuffix(facilityId.toString())); + } + + private String generateRoleGroupDescription(String orgName, OrganizationRole role) { + return String.format("%s - %ss", orgName, role.getDescription()); + } + + private String generateFacilityGroupDescription(String orgName, String facilityName) { + return String.format("%s - Facility Access - %s", orgName, facilityName); + } + + private String generateRoleSuffix(OrganizationRole role) { + return ":" + role.name(); + } + + private String generateFacilitySuffix(String facilityId) { + return ":" + OrganizationExtractor.FACILITY_ACCESS_MARKER + ":" + facilityId; + } + + private User getUserOrThrowError(String email, String errorMessage) { + try { + return userApi.getUser(email); + } catch (ApiException e) { + throw new IllegalGraphqlArgumentException(errorMessage); + } + } + + private void throwErrorIfEmpty(Stream stream, String errorMessage) { + if (stream.findAny().isEmpty()) { + throw new IllegalGraphqlArgumentException(errorMessage); + } + } + + private String prettifyOktaError(ApiException e) { + var errorMessage = "Code: " + e.getCode() + "; Message: " + e.getMessage(); + if (e.getResponseBody() != null) { + Error error = ApiExceptionHelper.getError(e); + if (error != null) { + errorMessage = + "Okta Error: " + error.getErrorCode() + ", Error summary: " + error.getErrorSummary(); + if (error.getErrorCauses() != null) { + errorMessage += + ", Error Cause(s): " + + error.getErrorCauses().stream() + .map(ErrorErrorCausesInner::getErrorSummary) + .collect(Collectors.joining(", ")); } - return errorMessage; + } } + return errorMessage; + } } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java index 3809a04fe1..fc488573b7 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java @@ -6,7 +6,6 @@ import gov.cdc.usds.simplereport.db.model.Facility; import gov.cdc.usds.simplereport.db.model.Organization; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; - import java.util.List; import java.util.Map; import java.util.Optional; @@ -19,64 +18,64 @@ */ public interface OktaRepository { - Optional createUser( - IdentityAttributes userIdentity, - Organization org, - Set facilities, - Set roles, - boolean active); + Optional createUser( + IdentityAttributes userIdentity, + Organization org, + Set facilities, + Set roles, + boolean active); - Optional updateUser(IdentityAttributes userIdentity); + Optional updateUser(IdentityAttributes userIdentity); - Optional updateUserEmail(IdentityAttributes userIdentity, String email); + Optional updateUserEmail(IdentityAttributes userIdentity, String email); - void reprovisionUser(IdentityAttributes userIdentity); + void reprovisionUser(IdentityAttributes userIdentity); - Optional updateUserPrivileges( - String username, Organization org, Set facilities, Set roles); + Optional updateUserPrivileges( + String username, Organization org, Set facilities, Set roles); - List updateUserPrivilegesAndGroupAccess( - String username, - Organization org, - Set facilities, - OrganizationRole roles, - boolean assignedToAllFacilities); + List updateUserPrivilegesAndGroupAccess( + String username, + Organization org, + Set facilities, + OrganizationRole roles, + boolean assignedToAllFacilities); - void resetUserPassword(String username); + void resetUserPassword(String username); - void resetUserMfa(String username); + void resetUserMfa(String username); - void setUserIsActive(String username, boolean active); + void setUserIsActive(String username, boolean active); - void reactivateUser(String username); + void reactivateUser(String username); - void resendActivationEmail(String username); + void resendActivationEmail(String username); - UserStatus getUserStatus(String username); + UserStatus getUserStatus(String username); - Set getAllUsersForOrganization(Organization org); + Set getAllUsersForOrganization(Organization org); - Map getAllUsersWithStatusForOrganization(Organization org); + Map getAllUsersWithStatusForOrganization(Organization org); - void createOrganization(Organization org); + void createOrganization(Organization org); - void activateOrganization(Organization org); + void activateOrganization(Organization org); - String activateOrganizationWithSingleUser(Organization org); + String activateOrganizationWithSingleUser(Organization org); - List fetchAdminUserEmail(Organization org); + List fetchAdminUserEmail(Organization org); - void createFacility(Facility facility); + void createFacility(Facility facility); - void deleteOrganization(Organization org); + void deleteOrganization(Organization org); - void deleteFacility(Facility facility); + void deleteFacility(Facility facility); - Optional getOrganizationRoleClaimsForUser(String username); + Optional getOrganizationRoleClaimsForUser(String username); - Integer getUsersInSingleFacility(Facility facility); + Integer getUsersInSingleFacility(Facility facility); - PartialOktaUser findUser(String username); + PartialOktaUser findUser(String username); - String getApplicationStatusForHealthCheck(); + String getApplicationStatusForHealthCheck(); } From b678b5bd6796ba32ab09cacf509f2fa24de729ab Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 11:25:58 -0500 Subject: [PATCH 28/59] string format and equality --- .../api/heathcheck/BackendAndDatabaseHealthIndicator.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java index b77a3bb57b..e10bb0c315 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -23,8 +23,8 @@ public Health health() { _ffRepo.findAll(); String oktaStatus = _oktaRepo.getApplicationStatusForHealthCheck(); - if (oktaStatus != "ACTIVE") { - log.info("Okta status didn't return active, instead returned", oktaStatus); + if (oktaStatus.equals("ACTIVE")) { + log.info("Okta status didn't return active, instead returned %s", oktaStatus); return Health.down().build(); } From 22360291fa8bf477e49e5488d06d8d98cde45eb5 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 11:26:23 -0500 Subject: [PATCH 29/59] string --- .../BackendAndDatabaseHealthIndicator.java | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java index 1da0ea64b5..4695ce410e 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -14,27 +14,27 @@ @Slf4j @RequiredArgsConstructor public class BackendAndDatabaseHealthIndicator implements HealthIndicator { - private final FeatureFlagRepository _ffRepo; - private final OktaRepository _oktaRepo; + private final FeatureFlagRepository _ffRepo; + private final OktaRepository _oktaRepo; - @Override - public Health health() { - try { - _ffRepo.findAll(); - String oktaStatus = _oktaRepo.getApplicationStatusForHealthCheck(); + @Override + public Health health() { + try { + _ffRepo.findAll(); + String oktaStatus = _oktaRepo.getApplicationStatusForHealthCheck(); - if (oktaStatus.equals("ACTIVE")) { - log.info("Okta status didn't return active, instead returned", oktaStatus); - return Health.down().build(); - } + if (oktaStatus.equals("ACTIVE")) { + log.info("Okta status didn't return active, instead returned %s", oktaStatus); + return Health.down().build(); + } - return Health.up().build(); - } catch (JDBCConnectionException e) { - return Health.down().build(); - // Okta API call errored - } catch (ApiException e) { - log.info(e.getMessage()); - return Health.down().build(); + return Health.up().build(); + } catch (JDBCConnectionException e) { + return Health.down().build(); + // Okta API call errored + } catch (ApiException e) { + log.info(e.getMessage()); + return Health.down().build(); + } } - } } From 5703bba4eecfa8c31d197570bbac889ac9055c77 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 11:30:41 -0500 Subject: [PATCH 30/59] kick for a fresh deploy --- .github/workflows/smokeTestDeployProd.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/smokeTestDeployProd.yml b/.github/workflows/smokeTestDeployProd.yml index 795d92c35b..a25573c29f 100644 --- a/.github/workflows/smokeTestDeployProd.yml +++ b/.github/workflows/smokeTestDeployProd.yml @@ -10,7 +10,7 @@ on: env: NODE_VERSION: 18 - DEPLOY_ENV: dev7 + DEPLOY_ENV: dev6 jobs: smoke-test-front-and-back-end: @@ -36,7 +36,7 @@ jobs: slack_alert: runs-on: ubuntu-latest if: failure() - needs: [smoke-test-front-and-back-end] + needs: [ smoke-test-front-and-back-end ] steps: - uses: actions/checkout@v4 - name: Send alert to Slack From 2655882c6614dc1712e9ce126f6397da659552c8 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 10:23:02 -0500 Subject: [PATCH 31/59] frontend component and script --- frontend/deploy-smoke.js | 31 +++++++++++++++ frontend/package.json | 6 ++- frontend/src/app/HealthChecks.tsx | 3 ++ frontend/src/app/ProdSmokeTest.test.tsx | 30 +++++++++++++++ frontend/src/app/ProdSmokeTest.tsx | 29 ++++++++++++++ frontend/yarn.lock | 50 ++++++++++++++++++++++++- 6 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 frontend/deploy-smoke.js create mode 100644 frontend/src/app/ProdSmokeTest.test.tsx create mode 100644 frontend/src/app/ProdSmokeTest.tsx diff --git a/frontend/deploy-smoke.js b/frontend/deploy-smoke.js new file mode 100644 index 0000000000..b2740c3231 --- /dev/null +++ b/frontend/deploy-smoke.js @@ -0,0 +1,31 @@ +// Script that does a simple Selenium scrape of +// - A frontend page with a simple status message that hits a health check backend +// endpoint which does a simple ping to a non-sensitive DB table to verify +// all the connections are good. +// https://github.com/CDCgov/prime-simplereport/pull/7057 + +require("dotenv").config(); +let { Builder } = require("selenium-webdriver"); +const Chrome = require("selenium-webdriver/chrome"); + +console.log(`Running smoke test for ${process.env.REACT_APP_BASE_URL}`); +const options = new Chrome.Options(); +const driver = new Builder() + .forBrowser("chrome") + .setChromeOptions(options.addArguments("--headless=new")) + .build(); +driver + .navigate() + .to(`${process.env.REACT_APP_BASE_URL}app/health/prod-smoke-test`) + .then(() => { + let value = driver.findElement({ id: "root" }).getText(); + return value; + }) + .then((value) => { + driver.quit(); + return value; + }) + .then((value) => { + if (value.includes("success")) process.exit(0); + process.exit(1); + }); diff --git a/frontend/package.json b/frontend/package.json index 231bb777af..3f9d349b2c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -75,7 +75,9 @@ "storybook": "yarn create-storybook-public && SASS_PATH=$(cd ./node_modules && pwd):$(cd ./node_modules/@uswds && pwd):$(cd ./node_modules/@uswds/uswds/packages && pwd):$(cd ./src/scss && pwd) storybook dev -p 6006 -s ../storybook_public", "build-storybook": "yarn create-storybook-public && REACT_APP_BACKEND_URL=http://localhost:8080 SASS_PATH=$(cd ./node_modules && pwd):$(cd ./node_modules/@uswds && pwd):$(cd ./node_modules/@uswds/uswds/packages && pwd):$(cd ./src/scss && pwd) storybook build -s storybook_public", "maintenance:start": "[ -z \"$MAINTENANCE_MESSAGE\" ] && echo \"MAINTENANCE_MESSAGE must be set!\" || (echo $MAINTENANCE_MESSAGE > maintenance.json && yarn maintenance:deploy && rm maintenance.json)", - "maintenance:deploy": "[ -z \"$MAINTENANCE_ENV\" ] && echo \"MAINTENANCE_ENV must be set!\" || az storage blob upload -f maintenance.json -n maintenance.json -c '$web' --account-name simplereport${MAINTENANCE_ENV}app --overwrite" + "maintenance:deploy": "[ -z \"$MAINTENANCE_ENV\" ] && echo \"MAINTENANCE_ENV must be set!\" || az storage blob upload -f maintenance.json -n maintenance.json -c '$web' --account-name simplereport${MAINTENANCE_ENV}app --overwrite", + "smoke:env:deploy": "node deploy-smoke.js", + "smoke:env:deploy:ci": "node -r dotenv/config deploy-smoke.js dotenv_config_path=.env.production.local dotenv_config_debug=true" }, "prettier": { "singleQuote": false @@ -205,6 +207,7 @@ "chromatic": "^10.2.0", "dayjs": "^1.10.7", "depcheck": "^1.4.3", + "dotenv": "^16.3.1", "eslint-config-prettier": "^8.8.0", "eslint-plugin-graphql": "^4.0.0", "eslint-plugin-import": "^2.29.0", @@ -224,6 +227,7 @@ "prettier": "^2.8.4", "redux-mock-store": "^1.5.4", "sass": "^1.63.6", + "selenium-webdriver": "^4.16.0", "storybook": "^7.5.2", "storybook-addon-apollo-client": "^5.0.0", "stylelint": "^13.13.1", diff --git a/frontend/src/app/HealthChecks.tsx b/frontend/src/app/HealthChecks.tsx index 608113e171..3e8a7fdaff 100644 --- a/frontend/src/app/HealthChecks.tsx +++ b/frontend/src/app/HealthChecks.tsx @@ -1,5 +1,7 @@ import { Route, Routes } from "react-router-dom"; +import ProdSmokeTest from "./ProdSmokeTest"; + const HealthChecks = () => ( pong} /> @@ -7,6 +9,7 @@ const HealthChecks = () => ( path="commit" element={
{process.env.REACT_APP_CURRENT_COMMIT}
} /> + } />
); diff --git a/frontend/src/app/ProdSmokeTest.test.tsx b/frontend/src/app/ProdSmokeTest.test.tsx new file mode 100644 index 0000000000..c591ad23fd --- /dev/null +++ b/frontend/src/app/ProdSmokeTest.test.tsx @@ -0,0 +1,30 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { FetchMock } from "jest-fetch-mock"; + +import ProdSmokeTest from "./ProdSmokeTest"; + +describe("ProdSmokeTest", () => { + beforeEach(() => { + (fetch as FetchMock).resetMocks(); + }); + + it("renders success when returned from the API endpoint", async () => { + (fetch as FetchMock).mockResponseOnce(JSON.stringify({ status: "UP" })); + + render(); + await waitFor(() => + expect(screen.queryByText("Status loading...")).not.toBeInTheDocument() + ); + expect(screen.getByText("Status returned success :)")); + }); + + it("renders failure when returned from the API endpoint", async () => { + (fetch as FetchMock).mockResponseOnce(JSON.stringify({ status: "DOWN" })); + + render(); + await waitFor(() => + expect(screen.queryByText("Status loading...")).not.toBeInTheDocument() + ); + expect(screen.getByText("Status returned failure :(")); + }); +}); diff --git a/frontend/src/app/ProdSmokeTest.tsx b/frontend/src/app/ProdSmokeTest.tsx new file mode 100644 index 0000000000..ddba865338 --- /dev/null +++ b/frontend/src/app/ProdSmokeTest.tsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; + +import FetchClient from "./utils/api"; + +const api = new FetchClient(undefined, { mode: "cors" }); +const ProdSmokeTest = (): JSX.Element => { + const [success, setSuccess] = useState(); + useEffect(() => { + api + .getRequest("/actuator/health/prod-smoke-test") + .then((response) => { + console.log(response); + const status = JSON.parse(response); + if (status.status === "UP") return setSuccess(true); + // log something using app insights + setSuccess(false); + }) + .catch((e) => { + console.error(e); + setSuccess(false); + }); + }, []); + + if (success === undefined) return <>Status loading...; + if (success) return <> Status returned success :) ; + return <> Status returned failure :( ; +}; + +export default ProdSmokeTest; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index fcc3ac5d1b..9b05dc457b 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -8823,7 +8823,7 @@ dotenv@^10.0.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81" integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== -dotenv@^16.0.0: +dotenv@^16.0.0, dotenv@^16.3.1: version "16.3.1" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== @@ -10803,6 +10803,11 @@ ignore@^5.2.4: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78" integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg== +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== + immer@^9.0.7: version "9.0.16" resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.16.tgz#8e7caab80118c2b54b37ad43e05758cdefad0198" @@ -12547,6 +12552,16 @@ jsonpointer@^5.0.0: array-includes "^3.1.5" object.assign "^4.1.3" +jszip@^3.10.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" + integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + setimmediate "^1.0.5" + jwt-decode@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" @@ -12618,6 +12633,13 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + lilconfig@^2.0.3, lilconfig@^2.0.5, lilconfig@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4" @@ -13738,6 +13760,11 @@ pako@~0.2.0: resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA== +pako@~1.0.2: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + param-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5" @@ -15884,6 +15911,15 @@ select-hose@^2.0.0: resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" integrity sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg== +selenium-webdriver@^4.16.0: + version "4.16.0" + resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.16.0.tgz#2f1a2426d876aa389d1c937b00f034c2c7808360" + integrity sha512-IbqpRpfGE7JDGgXHJeWuCqT/tUqnLvZ14csSwt+S8o4nJo3RtQoE9VR4jB47tP/A8ArkYsh/THuMY6kyRP6kuA== + dependencies: + jszip "^3.10.1" + tmp "^0.2.1" + ws ">=8.14.2" + selfsigned@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-2.1.1.tgz#18a7613d714c0cd3385c48af0075abf3f266af61" @@ -16986,6 +17022,13 @@ tmp@^0.0.33: dependencies: os-tmpdir "~1.0.2" +tmp@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" + integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ== + dependencies: + rimraf "^3.0.0" + tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -18308,6 +18351,11 @@ ws@8.14.1: resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.1.tgz#4b9586b4f70f9e6534c7bb1d3dc0baa8b8cf01e0" integrity sha512-4OOseMUq8AzRBI/7SLMUwO+FEDnguetSk7KMb1sHwvF2w2Wv5Hoj0nlifx8vtGsftE/jWHojPy8sMMzYLJ2G/A== +ws@>=8.14.2: + version "8.15.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.15.0.tgz#db080a279260c5f532fc668d461b8346efdfcf86" + integrity sha512-H/Z3H55mrcrgjFwI+5jKavgXvwQLtfPCUEp6pi35VhoB0pfcHnSoyuTzkBEZpzq49g1193CUEwIvmsjcotenYw== + "ws@^5.2.0 || ^6.0.0 || ^7.0.0", ws@^7.4.6: version "7.5.9" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" From 26f789153dcfde96780e8fd50d81fde1ff7895f1 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 10:23:23 -0500 Subject: [PATCH 32/59] backend config --- .../BackendAndDatabaseHealthIndicator.java | 31 ++++++++++++++ .../config/SecurityConfiguration.java | 3 ++ backend/src/main/resources/application.yaml | 1 + ...BackendAndDatabaseHealthIndicatorTest.java | 41 +++++++++++++++++++ .../test_util/SliceTestConfiguration.java | 4 +- 5 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java create mode 100644 backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java new file mode 100644 index 0000000000..0bb039fef2 --- /dev/null +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -0,0 +1,31 @@ +package gov.cdc.usds.simplereport.api.heathcheck; + +import gov.cdc.usds.simplereport.db.repository.FeatureFlagRepository; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.exception.JDBCConnectionException; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +@Component("prod-smoke-test") +@Slf4j +@RequiredArgsConstructor +public class BackendAndDatabaseHealthIndicator implements HealthIndicator { + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final FeatureFlagRepository _repo; + + @Override + public Health health() { + try { + _repo.findAll(); + return Health.up().build(); + } catch (JDBCConnectionException e) { + return Health.down().build(); + } + } +} diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/config/SecurityConfiguration.java b/backend/src/main/java/gov/cdc/usds/simplereport/config/SecurityConfiguration.java index 15fdeee263..07a737850a 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/config/SecurityConfiguration.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/config/SecurityConfiguration.java @@ -1,6 +1,7 @@ package gov.cdc.usds.simplereport.config; import com.okta.spring.boot.oauth.Okta; +import gov.cdc.usds.simplereport.api.heathcheck.BackendAndDatabaseHealthIndicator; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; import gov.cdc.usds.simplereport.service.model.IdentitySupplier; import lombok.extern.slf4j.Slf4j; @@ -57,6 +58,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers(EndpointRequest.to(InfoEndpoint.class)) .permitAll() + .requestMatchers(EndpointRequest.to(BackendAndDatabaseHealthIndicator.class)) + .permitAll() // Patient experience authorization is handled in PatientExperienceController // If this configuration changes, please update the documentation on both sides .requestMatchers(HttpMethod.POST, WebConfiguration.PATIENT_EXPERIENCE) diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 1df6cb8e0a..8f792d8cc0 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -78,6 +78,7 @@ management: endpoint.health.probes.enabled: true endpoint.info.enabled: true endpoints.web.exposure.include: health, info + endpoint.health.show-components: always okta: oauth2: issuer: https://hhs-prime.okta.com/oauth2/default diff --git a/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java b/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java new file mode 100644 index 0000000000..61ae972a84 --- /dev/null +++ b/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java @@ -0,0 +1,41 @@ +package gov.cdc.usds.simplereport.api.healthcheck; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import gov.cdc.usds.simplereport.api.heathcheck.BackendAndDatabaseHealthIndicator; +import gov.cdc.usds.simplereport.db.repository.BaseRepositoryTest; +import gov.cdc.usds.simplereport.db.repository.FeatureFlagRepository; +import java.sql.SQLException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.hibernate.exception.JDBCConnectionException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.mock.mockito.SpyBean; + +@RequiredArgsConstructor +@EnableConfigurationProperties +class BackendAndDatabaseHealthIndicatorTest extends BaseRepositoryTest { + + @SpyBean private FeatureFlagRepository mockRepo; + + @Autowired private BackendAndDatabaseHealthIndicator indicator; + + @Test + void health_succeedsWhenRepoDoesntThrow() { + when(mockRepo.findAll()).thenReturn(List.of()); + assertThat(indicator.health()).isEqualTo(Health.up().build()); + } + + @Test + void health_failsWhenRepoDoesntThrow() { + JDBCConnectionException dbConnectionException = + new JDBCConnectionException( + "connection issue", new SQLException("some reason", "some state")); + when(mockRepo.findAll()).thenThrow(dbConnectionException); + assertThat(indicator.health()).isEqualTo(Health.down().build()); + } +} diff --git a/backend/src/test/java/gov/cdc/usds/simplereport/test_util/SliceTestConfiguration.java b/backend/src/test/java/gov/cdc/usds/simplereport/test_util/SliceTestConfiguration.java index 30e8144768..3ac3f7dd7d 100644 --- a/backend/src/test/java/gov/cdc/usds/simplereport/test_util/SliceTestConfiguration.java +++ b/backend/src/test/java/gov/cdc/usds/simplereport/test_util/SliceTestConfiguration.java @@ -5,6 +5,7 @@ import gov.cdc.usds.simplereport.api.CurrentOrganizationRolesContextHolder; import gov.cdc.usds.simplereport.api.CurrentTenantDataAccessContextHolder; import gov.cdc.usds.simplereport.api.WebhookContextHolder; +import gov.cdc.usds.simplereport.api.heathcheck.BackendAndDatabaseHealthIndicator; import gov.cdc.usds.simplereport.api.pxp.CurrentPatientContextHolder; import gov.cdc.usds.simplereport.config.AuditingConfig; import gov.cdc.usds.simplereport.config.AuthorizationProperties; @@ -100,7 +101,8 @@ CurrentTenantDataAccessContextHolder.class, WebhookContextHolder.class, TenantDataAccessService.class, - PatientSelfRegistrationLinkService.class + PatientSelfRegistrationLinkService.class, + BackendAndDatabaseHealthIndicator.class }) @EnableConfigurationProperties({InitialSetupProperties.class, AuthorizationProperties.class}) public class SliceTestConfiguration { From f749843b80c7daa70c0ea667fd902c74ec6ad0d2 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 10:23:28 -0500 Subject: [PATCH 33/59] actions --- .../actions/post-deploy-smoke-test/action.yml | 34 +++++++++++++ .github/workflows/smokeTestDeployDev.yml | 50 +++++++++++++++++++ .github/workflows/smokeTestDeployProd.yml | 34 +++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 .github/actions/post-deploy-smoke-test/action.yml create mode 100644 .github/workflows/smokeTestDeployDev.yml create mode 100644 .github/workflows/smokeTestDeployProd.yml diff --git a/.github/actions/post-deploy-smoke-test/action.yml b/.github/actions/post-deploy-smoke-test/action.yml new file mode 100644 index 0000000000..0b573ecaa2 --- /dev/null +++ b/.github/actions/post-deploy-smoke-test/action.yml @@ -0,0 +1,34 @@ +name: Smoke test post deploy +description: Ping a backend health endpoint that reaches into the db and return a status message to a frontend status page. Visit that page and ensure things are healthy +inputs: + deploy-env: + description: The environment being deployed (e.g. "prod" or "test") + required: true +runs: + using: composite + steps: + - name: create env file + shell: bash + working-directory: frontend + run: | + touch .env + echo REACT_APP_BASE_URL=https://${{ inputs.deploy-env }}.simplereport.gov/ >> .env.production.local + - name: Run smoke test script + shell: bash + working-directory: frontend + run: yarn smoke:env:deploy:ci + + # slack_alert: + # runs-on: ubuntu-latest + # if: failure() + # needs: [ smoke-test-front-and-back-end ] + # steps: + # - uses: actions/checkout@v4 + # - name: Send alert to Slack + # uses: ./.github/actions/slack-message + # with: + # username: ${{ github.actor }} + # description: | + # :siren-gif: Post-deploy smoke test couldn't verify that the frontend is talking to the backend. ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} :siren-gif: + # webhook_url: ${{ secrets.SR_ALERTS_SLACK_WEBHOOK_URL }} + # user_map: $${{ secrets.SR_ALERTS_GITHUB_SLACK_MAP }} \ No newline at end of file diff --git a/.github/workflows/smokeTestDeployDev.yml b/.github/workflows/smokeTestDeployDev.yml new file mode 100644 index 0000000000..4c6d64e72d --- /dev/null +++ b/.github/workflows/smokeTestDeployDev.yml @@ -0,0 +1,50 @@ +name: Smoke test deploy +run-name: Smoke test the deploy for a dev env by @${{ github.actor }} + +on: + # DELETE ME WHEN MERGING + push: + # UNCOMMENT ME WHEN MERGING +# workflow_dispatch: +# inputs: +# deploy_env: +# description: 'The environment to smoke test' +# required: true +# type: choice +# options: +# - "" +# - dev +# - dev2 +# - dev3 +# - dev4 +# - dev5 +# - dev6 +# - dev7 +# - pentest + +env: + NODE_VERSION: 18 + +jobs: + smoke-test-front-and-back-end: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + - name: Cache yarn + uses: actions/cache@v3.3.2 + with: + path: ~/.cache/yarn + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + - name: Set up dependencies + working-directory: frontend + run: yarn install --prefer-offline + - name: Smoke test the env + uses: ./.github/actions/post-deploy-smoke-test + with: + # REPLACE ME WITH deploy-env: ${{inputs.deploy_env}} + deploy-env: dev7 + diff --git a/.github/workflows/smokeTestDeployProd.yml b/.github/workflows/smokeTestDeployProd.yml new file mode 100644 index 0000000000..da442331a3 --- /dev/null +++ b/.github/workflows/smokeTestDeployProd.yml @@ -0,0 +1,34 @@ +name: Smoke test deploy Prod +run-name: Smoke test the deploy for prod by @${{ github.actor }} + +on: + workflow_run: + workflows: [ " Deploy Prod" ] + types: + - completed + +env: + NODE_VERSION: 18 + DEPLOY_ENV: prod + +jobs: + smoke-test-front-and-back-end: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + - name: Cache yarn + uses: actions/cache@v3.3.2 + with: + path: ~/.cache/yarn + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + - name: Set up dependencies + working-directory: frontend + run: yarn install --prefer-offline + - name: Smoke test the env + uses: ./.github/actions/post-deploy-smoke-test + with: + deploy-env: ${{ env.DEPLOY_ENV }} From 1b5612fd18e5970de26f0609b9d78c0c76e30b92 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 10:36:20 -0500 Subject: [PATCH 34/59] rename --- .../{smokeTestDeployDev.yml => smokeTestDeployManual.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{smokeTestDeployDev.yml => smokeTestDeployManual.yml} (100%) diff --git a/.github/workflows/smokeTestDeployDev.yml b/.github/workflows/smokeTestDeployManual.yml similarity index 100% rename from .github/workflows/smokeTestDeployDev.yml rename to .github/workflows/smokeTestDeployManual.yml From b984e00f400b804251a816bc3748d6c410e55b0e Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 17:14:12 -0500 Subject: [PATCH 35/59] some other stuff --- .github/workflows/smokeTestDeployManual.yml | 39 ++++++++++----------- .github/workflows/smokeTestDeployProd.yml | 2 +- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/.github/workflows/smokeTestDeployManual.yml b/.github/workflows/smokeTestDeployManual.yml index 4c6d64e72d..a803c0a0f8 100644 --- a/.github/workflows/smokeTestDeployManual.yml +++ b/.github/workflows/smokeTestDeployManual.yml @@ -2,25 +2,22 @@ name: Smoke test deploy run-name: Smoke test the deploy for a dev env by @${{ github.actor }} on: - # DELETE ME WHEN MERGING - push: - # UNCOMMENT ME WHEN MERGING -# workflow_dispatch: -# inputs: -# deploy_env: -# description: 'The environment to smoke test' -# required: true -# type: choice -# options: -# - "" -# - dev -# - dev2 -# - dev3 -# - dev4 -# - dev5 -# - dev6 -# - dev7 -# - pentest + workflow_dispatch: + inputs: + deploy_env: + description: 'The environment to smoke test' + required: true + type: choice + options: + - "" + - dev + - dev2 + - dev3 + - dev4 + - dev5 + - dev6 + - dev7 + - pentest env: NODE_VERSION: 18 @@ -45,6 +42,6 @@ jobs: - name: Smoke test the env uses: ./.github/actions/post-deploy-smoke-test with: - # REPLACE ME WITH deploy-env: ${{inputs.deploy_env}} - deploy-env: dev7 + deploy-env: ${{inputs.deploy_env}} + diff --git a/.github/workflows/smokeTestDeployProd.yml b/.github/workflows/smokeTestDeployProd.yml index da442331a3..60516bc31e 100644 --- a/.github/workflows/smokeTestDeployProd.yml +++ b/.github/workflows/smokeTestDeployProd.yml @@ -3,7 +3,7 @@ run-name: Smoke test the deploy for prod by @${{ github.actor }} on: workflow_run: - workflows: [ " Deploy Prod" ] + workflows: [ "Deploy Prod" ] types: - completed From 9e25175a1f584a9e5e7cab0abaf21ca16f509a0f Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 17:15:25 -0500 Subject: [PATCH 36/59] add slack alert back in --- .github/workflows/smokeTestDeployManual.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/smokeTestDeployManual.yml b/.github/workflows/smokeTestDeployManual.yml index a803c0a0f8..cdc10164d2 100644 --- a/.github/workflows/smokeTestDeployManual.yml +++ b/.github/workflows/smokeTestDeployManual.yml @@ -43,5 +43,17 @@ jobs: uses: ./.github/actions/post-deploy-smoke-test with: deploy-env: ${{inputs.deploy_env}} - - + slack_alert: + runs-on: ubuntu-latest + if: failure() + needs: [ smoke-test-front-and-back-end ] + steps: + - uses: actions/checkout@v4 + - name: Send alert to Slack + uses: ./.github/actions/slack-message + with: + username: ${{ github.actor }} + description: | + :siren-gif: Post-deploy smoke test couldn't verify that the frontend is talking to the backend. ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} :siren-gif: + webhook_url: ${{ secrets.SR_ALERTS_SLACK_WEBHOOK_URL }} + user_map: $${{ secrets.SR_ALERTS_GITHUB_SLACK_MAP }} \ No newline at end of file From f1d0d6e426668d87fc18ece07effeccde54e2abe Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 17:18:27 -0500 Subject: [PATCH 37/59] remove slack comment --- .../actions/post-deploy-smoke-test/action.yml | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/.github/actions/post-deploy-smoke-test/action.yml b/.github/actions/post-deploy-smoke-test/action.yml index 0b573ecaa2..545b16d49d 100644 --- a/.github/actions/post-deploy-smoke-test/action.yml +++ b/.github/actions/post-deploy-smoke-test/action.yml @@ -16,19 +16,4 @@ runs: - name: Run smoke test script shell: bash working-directory: frontend - run: yarn smoke:env:deploy:ci - - # slack_alert: - # runs-on: ubuntu-latest - # if: failure() - # needs: [ smoke-test-front-and-back-end ] - # steps: - # - uses: actions/checkout@v4 - # - name: Send alert to Slack - # uses: ./.github/actions/slack-message - # with: - # username: ${{ github.actor }} - # description: | - # :siren-gif: Post-deploy smoke test couldn't verify that the frontend is talking to the backend. ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} :siren-gif: - # webhook_url: ${{ secrets.SR_ALERTS_SLACK_WEBHOOK_URL }} - # user_map: $${{ secrets.SR_ALERTS_GITHUB_SLACK_MAP }} \ No newline at end of file + run: yarn smoke:env:deploy:ci \ No newline at end of file From 20f99da19f18c05e713e214fef4de8fa10761312 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Wed, 13 Dec 2023 17:20:47 -0500 Subject: [PATCH 38/59] move slack alert over --- .github/workflows/smokeTestDeployManual.yml | 59 --------------------- .github/workflows/smokeTestDeployProd.yml | 14 +++++ 2 files changed, 14 insertions(+), 59 deletions(-) delete mode 100644 .github/workflows/smokeTestDeployManual.yml diff --git a/.github/workflows/smokeTestDeployManual.yml b/.github/workflows/smokeTestDeployManual.yml deleted file mode 100644 index cdc10164d2..0000000000 --- a/.github/workflows/smokeTestDeployManual.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Smoke test deploy -run-name: Smoke test the deploy for a dev env by @${{ github.actor }} - -on: - workflow_dispatch: - inputs: - deploy_env: - description: 'The environment to smoke test' - required: true - type: choice - options: - - "" - - dev - - dev2 - - dev3 - - dev4 - - dev5 - - dev6 - - dev7 - - pentest - -env: - NODE_VERSION: 18 - -jobs: - smoke-test-front-and-back-end: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Use Node.js - uses: actions/setup-node@v3 - with: - node-version: '18' - - name: Cache yarn - uses: actions/cache@v3.3.2 - with: - path: ~/.cache/yarn - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - - name: Set up dependencies - working-directory: frontend - run: yarn install --prefer-offline - - name: Smoke test the env - uses: ./.github/actions/post-deploy-smoke-test - with: - deploy-env: ${{inputs.deploy_env}} - slack_alert: - runs-on: ubuntu-latest - if: failure() - needs: [ smoke-test-front-and-back-end ] - steps: - - uses: actions/checkout@v4 - - name: Send alert to Slack - uses: ./.github/actions/slack-message - with: - username: ${{ github.actor }} - description: | - :siren-gif: Post-deploy smoke test couldn't verify that the frontend is talking to the backend. ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} :siren-gif: - webhook_url: ${{ secrets.SR_ALERTS_SLACK_WEBHOOK_URL }} - user_map: $${{ secrets.SR_ALERTS_GITHUB_SLACK_MAP }} \ No newline at end of file diff --git a/.github/workflows/smokeTestDeployProd.yml b/.github/workflows/smokeTestDeployProd.yml index 60516bc31e..bd9bafc382 100644 --- a/.github/workflows/smokeTestDeployProd.yml +++ b/.github/workflows/smokeTestDeployProd.yml @@ -32,3 +32,17 @@ jobs: uses: ./.github/actions/post-deploy-smoke-test with: deploy-env: ${{ env.DEPLOY_ENV }} + slack_alert: + runs-on: ubuntu-latest + if: failure() + needs: [ smoke-test-front-and-back-end ] + steps: + - uses: actions/checkout@v4 + - name: Send alert to Slack + uses: ./.github/actions/slack-message + with: + username: ${{ github.actor }} + description: | + :siren-gif: Post-deploy smoke test couldn't verify that the frontend is talking to the backend. ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} :siren-gif: + webhook_url: ${{ secrets.SR_ALERTS_SLACK_WEBHOOK_URL }} + user_map: $${{ secrets.SR_ALERTS_GITHUB_SLACK_MAP }} \ No newline at end of file From e6757bb9bc886f0c84e7f1e8c73b0a959a53b7e0 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Thu, 14 Dec 2023 13:38:18 -0500 Subject: [PATCH 39/59] dan feedback --- .github/actions/post-deploy-smoke-test/action.yml | 2 +- .github/workflows/smokeTestDeployProd.yml | 3 ++- .../heathcheck/BackendAndDatabaseHealthIndicator.java | 9 ++------- frontend/deploy-smoke.js | 2 +- .../{ProdSmokeTest.test.tsx => DeploySmokeTest.test.tsx} | 8 ++++---- .../src/app/{ProdSmokeTest.tsx => DeploySmokeTest.tsx} | 4 ++-- frontend/src/app/HealthChecks.tsx | 4 ++-- 7 files changed, 14 insertions(+), 18 deletions(-) rename frontend/src/app/{ProdSmokeTest.test.tsx => DeploySmokeTest.test.tsx} (84%) rename frontend/src/app/{ProdSmokeTest.tsx => DeploySmokeTest.tsx} (91%) diff --git a/.github/actions/post-deploy-smoke-test/action.yml b/.github/actions/post-deploy-smoke-test/action.yml index 545b16d49d..df2f1d1f69 100644 --- a/.github/actions/post-deploy-smoke-test/action.yml +++ b/.github/actions/post-deploy-smoke-test/action.yml @@ -1,5 +1,5 @@ name: Smoke test post deploy -description: Ping a backend health endpoint that reaches into the db and return a status message to a frontend status page. Visit that page and ensure things are healthy +description: Invoke a script that visits a deploy smoke check page that displays whether the backend / db are healthy. inputs: deploy-env: description: The environment being deployed (e.g. "prod" or "test") diff --git a/.github/workflows/smokeTestDeployProd.yml b/.github/workflows/smokeTestDeployProd.yml index bd9bafc382..31055e18c6 100644 --- a/.github/workflows/smokeTestDeployProd.yml +++ b/.github/workflows/smokeTestDeployProd.yml @@ -9,7 +9,8 @@ on: env: NODE_VERSION: 18 - DEPLOY_ENV: prod + # prod env variable + DEPLOY_ENV: www jobs: smoke-test-front-and-back-end: diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java index 0bb039fef2..c5cfcdae43 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -1,10 +1,7 @@ package gov.cdc.usds.simplereport.api.heathcheck; import gov.cdc.usds.simplereport.db.repository.FeatureFlagRepository; -import lombok.AccessLevel; -import lombok.Getter; import lombok.RequiredArgsConstructor; -import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.JDBCConnectionException; import org.springframework.boot.actuate.health.Health; @@ -15,14 +12,12 @@ @Slf4j @RequiredArgsConstructor public class BackendAndDatabaseHealthIndicator implements HealthIndicator { - @Getter(AccessLevel.NONE) - @Setter(AccessLevel.NONE) - private final FeatureFlagRepository _repo; + private final FeatureFlagRepository _ffRepo; @Override public Health health() { try { - _repo.findAll(); + _ffRepo.findAll(); return Health.up().build(); } catch (JDBCConnectionException e) { return Health.down().build(); diff --git a/frontend/deploy-smoke.js b/frontend/deploy-smoke.js index b2740c3231..8ac643ed1a 100644 --- a/frontend/deploy-smoke.js +++ b/frontend/deploy-smoke.js @@ -16,7 +16,7 @@ const driver = new Builder() .build(); driver .navigate() - .to(`${process.env.REACT_APP_BASE_URL}app/health/prod-smoke-test`) + .to(`${process.env.REACT_APP_BASE_URL}app/health/deploy-smoke-test`) .then(() => { let value = driver.findElement({ id: "root" }).getText(); return value; diff --git a/frontend/src/app/ProdSmokeTest.test.tsx b/frontend/src/app/DeploySmokeTest.test.tsx similarity index 84% rename from frontend/src/app/ProdSmokeTest.test.tsx rename to frontend/src/app/DeploySmokeTest.test.tsx index c591ad23fd..0d3a8cbde0 100644 --- a/frontend/src/app/ProdSmokeTest.test.tsx +++ b/frontend/src/app/DeploySmokeTest.test.tsx @@ -1,9 +1,9 @@ import { render, screen, waitFor } from "@testing-library/react"; import { FetchMock } from "jest-fetch-mock"; -import ProdSmokeTest from "./ProdSmokeTest"; +import DeploySmokeTest from "./DeploySmokeTest"; -describe("ProdSmokeTest", () => { +describe("DeploySmokeTest", () => { beforeEach(() => { (fetch as FetchMock).resetMocks(); }); @@ -11,7 +11,7 @@ describe("ProdSmokeTest", () => { it("renders success when returned from the API endpoint", async () => { (fetch as FetchMock).mockResponseOnce(JSON.stringify({ status: "UP" })); - render(); + render(); await waitFor(() => expect(screen.queryByText("Status loading...")).not.toBeInTheDocument() ); @@ -21,7 +21,7 @@ describe("ProdSmokeTest", () => { it("renders failure when returned from the API endpoint", async () => { (fetch as FetchMock).mockResponseOnce(JSON.stringify({ status: "DOWN" })); - render(); + render(); await waitFor(() => expect(screen.queryByText("Status loading...")).not.toBeInTheDocument() ); diff --git a/frontend/src/app/ProdSmokeTest.tsx b/frontend/src/app/DeploySmokeTest.tsx similarity index 91% rename from frontend/src/app/ProdSmokeTest.tsx rename to frontend/src/app/DeploySmokeTest.tsx index ddba865338..d83a766b2c 100644 --- a/frontend/src/app/ProdSmokeTest.tsx +++ b/frontend/src/app/DeploySmokeTest.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import FetchClient from "./utils/api"; const api = new FetchClient(undefined, { mode: "cors" }); -const ProdSmokeTest = (): JSX.Element => { +const DeploySmokeTest = (): JSX.Element => { const [success, setSuccess] = useState(); useEffect(() => { api @@ -26,4 +26,4 @@ const ProdSmokeTest = (): JSX.Element => { return <> Status returned failure :( ; }; -export default ProdSmokeTest; +export default DeploySmokeTest; diff --git a/frontend/src/app/HealthChecks.tsx b/frontend/src/app/HealthChecks.tsx index 3e8a7fdaff..1f72c3a5b1 100644 --- a/frontend/src/app/HealthChecks.tsx +++ b/frontend/src/app/HealthChecks.tsx @@ -1,6 +1,6 @@ import { Route, Routes } from "react-router-dom"; -import ProdSmokeTest from "./ProdSmokeTest"; +import DeploySmokeTest from "./DeploySmokeTest"; const HealthChecks = () => ( @@ -9,7 +9,7 @@ const HealthChecks = () => ( path="commit" element={
{process.env.REACT_APP_CURRENT_COMMIT}
} /> - } /> + } />
); From edaf50e2819550f2f910864b550542413a2240ea Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 10:34:21 -0500 Subject: [PATCH 40/59] add okta call and update script config --- .../actions/post-deploy-smoke-test/action.yml | 2 +- .../BackendAndDatabaseHealthIndicator.java | 32 +- .../idp/repository/DemoOktaRepository.java | 715 ++++---- .../idp/repository/LiveOktaRepository.java | 1462 +++++++++-------- .../idp/repository/OktaRepository.java | 74 +- backend/src/main/resources/application.yaml | 1 + frontend/deploy-smoke.js | 8 +- frontend/package.json | 4 +- frontend/src/app/DeploySmokeTest.tsx | 3 +- 9 files changed, 1170 insertions(+), 1131 deletions(-) diff --git a/.github/actions/post-deploy-smoke-test/action.yml b/.github/actions/post-deploy-smoke-test/action.yml index df2f1d1f69..d221374907 100644 --- a/.github/actions/post-deploy-smoke-test/action.yml +++ b/.github/actions/post-deploy-smoke-test/action.yml @@ -16,4 +16,4 @@ runs: - name: Run smoke test script shell: bash working-directory: frontend - run: yarn smoke:env:deploy:ci \ No newline at end of file + run: yarn smoke:deploy:ci \ No newline at end of file diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java index c5cfcdae43..22badc2d6d 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -1,26 +1,38 @@ package gov.cdc.usds.simplereport.api.heathcheck; +import com.okta.sdk.resource.api.GroupApi; +import com.okta.sdk.resource.client.ApiException; import gov.cdc.usds.simplereport.db.repository.FeatureFlagRepository; +import gov.cdc.usds.simplereport.idp.repository.LiveOktaRepository; +import gov.cdc.usds.simplereport.idp.repository.OktaRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.JDBCConnectionException; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.stereotype.Component; -@Component("prod-smoke-test") +@Component("backend-and-db-smoke-test") @Slf4j @RequiredArgsConstructor public class BackendAndDatabaseHealthIndicator implements HealthIndicator { - private final FeatureFlagRepository _ffRepo; + private final FeatureFlagRepository _ffRepo; + private final OktaRepository _oktaRepo; - @Override - public Health health() { - try { - _ffRepo.findAll(); - return Health.up().build(); - } catch (JDBCConnectionException e) { - return Health.down().build(); + @Override + public Health health() { + try { + _ffRepo.findAll(); + _oktaRepo.getConnectTimeoutForHealthCheck(); + + return Health.up().build(); + } catch (JDBCConnectionException e) { + return Health.down().build(); + // Okta API call errored + } catch (ApiException e) { + log.info(e.getMessage()); + return Health.down().build(); + } } - } } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java index d9ef3b5fd8..7baf653582 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java @@ -13,6 +13,7 @@ import gov.cdc.usds.simplereport.db.model.Facility; import gov.cdc.usds.simplereport.db.model.Organization; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; + import java.util.Collection; import java.util.EnumSet; import java.util.HashMap; @@ -24,6 +25,7 @@ import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; + import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.support.ScopeNotActiveException; @@ -32,403 +34,410 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; -/** Handles all user/organization management in Okta */ +/** + * Handles all user/organization management in Okta + */ @Profile(BeanProfiles.NO_OKTA_MGMT) @Service @Slf4j public class DemoOktaRepository implements OktaRepository { - @Value("${simple-report.authorization.environment-name:DEV}") - private String environment; - - private final OrganizationExtractor organizationExtractor; - private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; - - Map usernameOrgRolesMap; - Map> orgUsernamesMap; - Map> orgFacilitiesMap; - Set inactiveUsernames; - Set allUsernames; - private final Set adminGroupMemberSet; - - public DemoOktaRepository( - OrganizationExtractor extractor, - CurrentTenantDataAccessContextHolder contextHolder, - DemoUserConfiguration demoUserConfiguration) { - this.usernameOrgRolesMap = new HashMap<>(); - this.orgUsernamesMap = new HashMap<>(); - this.orgFacilitiesMap = new HashMap<>(); - this.inactiveUsernames = new HashSet<>(); - this.allUsernames = new HashSet<>(); - - this.organizationExtractor = extractor; - this.tenantDataContextHolder = contextHolder; - this.adminGroupMemberSet = - demoUserConfiguration.getSiteAdminEmails().stream().collect(Collectors.toUnmodifiableSet()); - - log.info("Done initializing Demo Okta repository."); - } - - public Optional createUser( - IdentityAttributes userIdentity, - Organization org, - Set facilities, - Set roles, - boolean active) { - if (allUsernames.contains(userIdentity.getUsername())) { - throw new ConflictingUserException(); + @Value("${simple-report.authorization.environment-name:DEV}") + private String environment; + + private final OrganizationExtractor organizationExtractor; + private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; + + Map usernameOrgRolesMap; + Map> orgUsernamesMap; + Map> orgFacilitiesMap; + Set inactiveUsernames; + Set allUsernames; + private final Set adminGroupMemberSet; + + public DemoOktaRepository( + OrganizationExtractor extractor, + CurrentTenantDataAccessContextHolder contextHolder, + DemoUserConfiguration demoUserConfiguration) { + this.usernameOrgRolesMap = new HashMap<>(); + this.orgUsernamesMap = new HashMap<>(); + this.orgFacilitiesMap = new HashMap<>(); + this.inactiveUsernames = new HashSet<>(); + this.allUsernames = new HashSet<>(); + + this.organizationExtractor = extractor; + this.tenantDataContextHolder = contextHolder; + this.adminGroupMemberSet = + demoUserConfiguration.getSiteAdminEmails().stream().collect(Collectors.toUnmodifiableSet()); + + log.info("Done initializing Demo Okta repository."); } - String organizationExternalId = org.getExternalId(); - Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); - rolesToCreate.addAll(roles); - Set facilityUUIDs = - PermissionHolder.grantsAllFacilityAccess(rolesToCreate) - // create an empty set of facilities if user can access all facilities anyway - ? Set.of() - : facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()); - if (!orgFacilitiesMap.containsKey(organizationExternalId)) { - throw new IllegalGraphqlArgumentException( - "Cannot add Okta user to nonexistent organization=" + organizationExternalId); - } else if (!orgFacilitiesMap.get(organizationExternalId).containsAll(facilityUUIDs)) { - throw new IllegalGraphqlArgumentException( - "Cannot add Okta user to one or more nonexistent facilities in facilities_set=" - + facilities.stream().map(f -> f.getFacilityName()).collect(Collectors.toSet()) - + " in organization=" - + organizationExternalId); + public Optional createUser( + IdentityAttributes userIdentity, + Organization org, + Set facilities, + Set roles, + boolean active) { + if (allUsernames.contains(userIdentity.getUsername())) { + throw new ConflictingUserException(); + } + + String organizationExternalId = org.getExternalId(); + Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); + rolesToCreate.addAll(roles); + Set facilityUUIDs = + PermissionHolder.grantsAllFacilityAccess(rolesToCreate) + // create an empty set of facilities if user can access all facilities anyway + ? Set.of() + : facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()); + if (!orgFacilitiesMap.containsKey(organizationExternalId)) { + throw new IllegalGraphqlArgumentException( + "Cannot add Okta user to nonexistent organization=" + organizationExternalId); + } else if (!orgFacilitiesMap.get(organizationExternalId).containsAll(facilityUUIDs)) { + throw new IllegalGraphqlArgumentException( + "Cannot add Okta user to one or more nonexistent facilities in facilities_set=" + + facilities.stream().map(f -> f.getFacilityName()).collect(Collectors.toSet()) + + " in organization=" + + organizationExternalId); + } + + OrganizationRoleClaims orgRoles = + new OrganizationRoleClaims(organizationExternalId, facilityUUIDs, rolesToCreate); + usernameOrgRolesMap.put(userIdentity.getUsername(), orgRoles); + allUsernames.add(userIdentity.getUsername()); + + orgUsernamesMap.get(organizationExternalId).add(userIdentity.getUsername()); + + if (!active) { + inactiveUsernames.add(userIdentity.getUsername()); + } + + return Optional.of(orgRoles); } - OrganizationRoleClaims orgRoles = - new OrganizationRoleClaims(organizationExternalId, facilityUUIDs, rolesToCreate); - usernameOrgRolesMap.put(userIdentity.getUsername(), orgRoles); - allUsernames.add(userIdentity.getUsername()); + // this method currently doesn't do much in a demo envt + public Optional updateUser(IdentityAttributes userIdentity) { + if (!usernameOrgRolesMap.containsKey(userIdentity.getUsername())) { + throw new IllegalGraphqlArgumentException( + "Cannot change name of Okta user with unrecognized username"); + } + OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(userIdentity.getUsername()); + return Optional.of(orgRoles); + } - orgUsernamesMap.get(organizationExternalId).add(userIdentity.getUsername()); + public Optional updateUserEmail( + IdentityAttributes userIdentity, String newEmail) { + String currentEmail = userIdentity.getUsername(); + if (!usernameOrgRolesMap.containsKey(currentEmail)) { + throw new IllegalGraphqlArgumentException( + "Cannot change email of Okta user with unrecognized username"); + } + + if (usernameOrgRolesMap.containsKey(newEmail)) { + throw new ConflictingUserException(); + } + + String org = usernameOrgRolesMap.get(userIdentity.getUsername()).getOrganizationExternalId(); + orgUsernamesMap.get(org).remove(currentEmail); + orgUsernamesMap.get(org).add(newEmail); + usernameOrgRolesMap.put(newEmail, usernameOrgRolesMap.remove(userIdentity.getUsername())); + OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(newEmail); + return Optional.of(orgRoles); + } - if (!active) { - inactiveUsernames.add(userIdentity.getUsername()); + public void reprovisionUser(IdentityAttributes userIdentity) { + final String username = userIdentity.getUsername(); + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reprovision Okta user with unrecognized username"); + } + if (!inactiveUsernames.contains(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reprovision user in unsupported state: (not deleted)"); + } + + // Only re-enable the user. If name attributes and credentials were supported here, then + // the name should be updated and credentials reset. + inactiveUsernames.remove(userIdentity.getUsername()); } - return Optional.of(orgRoles); - } + public Optional updateUserPrivileges( + String username, Organization org, Set facilities, Set roles) { + String orgId = org.getExternalId(); + if (!orgUsernamesMap.containsKey(orgId)) { + throw new IllegalGraphqlArgumentException( + "Cannot update Okta user privileges for nonexistent organization."); + } + if (!orgUsernamesMap.get(orgId).contains(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot update Okta user privileges for organization they are not in."); + } + Set newRoles = EnumSet.of(OrganizationRole.getDefault()); + newRoles.addAll(roles); + Set facilityUUIDs = + facilities.stream() + // create an empty set of facilities if user can access all facilities anyway + .filter(f -> !PermissionHolder.grantsAllFacilityAccess(newRoles)) + .map(f -> f.getInternalId()) + .collect(Collectors.toSet()); + OrganizationRoleClaims newRoleClaims = + new OrganizationRoleClaims(orgId, facilityUUIDs, newRoles); + usernameOrgRolesMap.put(username, newRoleClaims); + + return Optional.of(newRoleClaims); + } - // this method currently doesn't do much in a demo envt - public Optional updateUser(IdentityAttributes userIdentity) { - if (!usernameOrgRolesMap.containsKey(userIdentity.getUsername())) { - throw new IllegalGraphqlArgumentException( - "Cannot change name of Okta user with unrecognized username"); + @Override + public List updateUserPrivilegesAndGroupAccess( + String username, + Organization org, + Set facilities, + OrganizationRole roles, + boolean allFacilitiesAccess) { + + String oldOrgId = usernameOrgRolesMap.get(username).getOrganizationExternalId(); + orgUsernamesMap.get(oldOrgId).remove(username); + orgUsernamesMap.get(org.getExternalId()).add(username); + OrganizationRoleClaims newRoleClaims = + new OrganizationRoleClaims( + org.getExternalId(), + facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()), + Set.of(roles, OrganizationRole.getDefault())); + + usernameOrgRolesMap.replace(username, newRoleClaims); + + // Live Okta repository returns list of Group names, but our demo repo didn't implement + // group mappings and it didn't feel worth it to add that implementation since the return is + // used mostly for testing. Return the list of facility ID's in the new org instead + return orgFacilitiesMap.get(org.getExternalId()).stream().map(UUID::toString).toList(); } - OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(userIdentity.getUsername()); - return Optional.of(orgRoles); - } - - public Optional updateUserEmail( - IdentityAttributes userIdentity, String newEmail) { - String currentEmail = userIdentity.getUsername(); - if (!usernameOrgRolesMap.containsKey(currentEmail)) { - throw new IllegalGraphqlArgumentException( - "Cannot change email of Okta user with unrecognized username"); + + public void resetUserPassword(String username) { + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reset password for Okta user with unrecognized username"); + } } - if (usernameOrgRolesMap.containsKey(newEmail)) { - throw new ConflictingUserException(); + public void resetUserMfa(String username) { + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reset MFA for Okta user with unrecognized username"); + } } - String org = usernameOrgRolesMap.get(userIdentity.getUsername()).getOrganizationExternalId(); - orgUsernamesMap.get(org).remove(currentEmail); - orgUsernamesMap.get(org).add(newEmail); - usernameOrgRolesMap.put(newEmail, usernameOrgRolesMap.remove(userIdentity.getUsername())); - OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(newEmail); - return Optional.of(orgRoles); - } - - public void reprovisionUser(IdentityAttributes userIdentity) { - final String username = userIdentity.getUsername(); - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reprovision Okta user with unrecognized username"); + public void setUserIsActive(String username, boolean active) { + if (active) { + inactiveUsernames.remove(username); + } else { + inactiveUsernames.add(username); + } } - if (!inactiveUsernames.contains(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reprovision user in unsupported state: (not deleted)"); + + public UserStatus getUserStatus(String username) { + if (inactiveUsernames.contains(username)) { + return UserStatus.SUSPENDED; + } else { + return UserStatus.ACTIVE; + } } - // Only re-enable the user. If name attributes and credentials were supported here, then - // the name should be updated and credentials reset. - inactiveUsernames.remove(userIdentity.getUsername()); - } - - public Optional updateUserPrivileges( - String username, Organization org, Set facilities, Set roles) { - String orgId = org.getExternalId(); - if (!orgUsernamesMap.containsKey(orgId)) { - throw new IllegalGraphqlArgumentException( - "Cannot update Okta user privileges for nonexistent organization."); + public void reactivateUser(String username) { + if (inactiveUsernames.contains(username)) { + inactiveUsernames.remove(username); + } } - if (!orgUsernamesMap.get(orgId).contains(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot update Okta user privileges for organization they are not in."); + + public void resendActivationEmail(String username) { + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reset password for Okta user with unrecognized username"); + } } - Set newRoles = EnumSet.of(OrganizationRole.getDefault()); - newRoles.addAll(roles); - Set facilityUUIDs = - facilities.stream() - // create an empty set of facilities if user can access all facilities anyway - .filter(f -> !PermissionHolder.grantsAllFacilityAccess(newRoles)) - .map(f -> f.getInternalId()) - .collect(Collectors.toSet()); - OrganizationRoleClaims newRoleClaims = - new OrganizationRoleClaims(orgId, facilityUUIDs, newRoles); - usernameOrgRolesMap.put(username, newRoleClaims); - - return Optional.of(newRoleClaims); - } - - @Override - public List updateUserPrivilegesAndGroupAccess( - String username, - Organization org, - Set facilities, - OrganizationRole roles, - boolean allFacilitiesAccess) { - - String oldOrgId = usernameOrgRolesMap.get(username).getOrganizationExternalId(); - orgUsernamesMap.get(oldOrgId).remove(username); - orgUsernamesMap.get(org.getExternalId()).add(username); - OrganizationRoleClaims newRoleClaims = - new OrganizationRoleClaims( - org.getExternalId(), - facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()), - Set.of(roles, OrganizationRole.getDefault())); - - usernameOrgRolesMap.replace(username, newRoleClaims); - - // Live Okta repository returns list of Group names, but our demo repo didn't implement - // group mappings and it didn't feel worth it to add that implementation since the return is - // used mostly for testing. Return the list of facility ID's in the new org instead - return orgFacilitiesMap.get(org.getExternalId()).stream().map(UUID::toString).toList(); - } - - public void resetUserPassword(String username) { - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reset password for Okta user with unrecognized username"); + + // returns ALL users including inactive ones + public Set getAllUsersForOrganization(Organization org) { + if (!orgUsernamesMap.containsKey(org.getExternalId())) { + throw new IllegalGraphqlArgumentException( + "Cannot get Okta users from nonexistent organization."); + } + return orgUsernamesMap.get(org.getExternalId()).stream() + .collect(Collectors.toUnmodifiableSet()); } - } - public void resetUserMfa(String username) { - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reset MFA for Okta user with unrecognized username"); + public Map getAllUsersWithStatusForOrganization(Organization org) { + if (!orgUsernamesMap.containsKey(org.getExternalId())) { + throw new IllegalGraphqlArgumentException( + "Cannot get Okta users from nonexistent organization."); + } + return orgUsernamesMap.get(org.getExternalId()).stream() + .collect(Collectors.toMap(u -> u, u -> getUserStatus(u))); } - } - public void setUserIsActive(String username, boolean active) { - if (active) { - inactiveUsernames.remove(username); - } else { - inactiveUsernames.add(username); + // this method doesn't mean much in a demo env + public void createOrganization(Organization org) { + String externalId = org.getExternalId(); + orgUsernamesMap.putIfAbsent(externalId, new HashSet<>()); + orgFacilitiesMap.putIfAbsent(externalId, new HashSet<>()); } - } - public UserStatus getUserStatus(String username) { - if (inactiveUsernames.contains(username)) { - return UserStatus.SUSPENDED; - } else { - return UserStatus.ACTIVE; + // this method means nothing in a demo env + public void activateOrganization(Organization org) { + inactiveUsernames.removeAll(orgUsernamesMap.get(org.getExternalId())); } - } - public void reactivateUser(String username) { - if (inactiveUsernames.contains(username)) { - inactiveUsernames.remove(username); + // this method means nothing in a demo env + public String activateOrganizationWithSingleUser(Organization org) { + activateOrganization(org); + return "activationToken"; } - } - public void resendActivationEmail(String username) { - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reset password for Okta user with unrecognized username"); + public List fetchAdminUserEmail(Organization org) { + Set> admins = + usernameOrgRolesMap.entrySet().stream() + .filter(e -> e.getValue().getGrantedRoles().contains(OrganizationRole.ADMIN)) + .collect(Collectors.toSet()); + return admins.stream().map(Entry::getKey).collect(Collectors.toList()); } - } - // returns ALL users including inactive ones - public Set getAllUsersForOrganization(Organization org) { - if (!orgUsernamesMap.containsKey(org.getExternalId())) { - throw new IllegalGraphqlArgumentException( - "Cannot get Okta users from nonexistent organization."); + public void createFacility(Facility facility) { + String orgExternalId = facility.getOrganization().getExternalId(); + if (!orgFacilitiesMap.containsKey(orgExternalId)) { + throw new IllegalGraphqlArgumentException( + "Cannot create Okta facility in nonexistent organization."); + } + orgFacilitiesMap.get(orgExternalId).add(facility.getInternalId()); } - return orgUsernamesMap.get(org.getExternalId()).stream() - .collect(Collectors.toUnmodifiableSet()); - } - - public Map getAllUsersWithStatusForOrganization(Organization org) { - if (!orgUsernamesMap.containsKey(org.getExternalId())) { - throw new IllegalGraphqlArgumentException( - "Cannot get Okta users from nonexistent organization."); + + public void deleteOrganization(Organization org) { + String externalId = org.getExternalId(); + orgUsernamesMap.remove(externalId); + orgFacilitiesMap.remove(externalId); + // remove all users from this map whose org roles are in the deleted org + usernameOrgRolesMap = + usernameOrgRolesMap.entrySet().stream() + .filter(e -> !(e.getValue().getOrganizationExternalId().equals(externalId))) + .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())); } - return orgUsernamesMap.get(org.getExternalId()).stream() - .collect(Collectors.toMap(u -> u, u -> getUserStatus(u))); - } - - // this method doesn't mean much in a demo env - public void createOrganization(Organization org) { - String externalId = org.getExternalId(); - orgUsernamesMap.putIfAbsent(externalId, new HashSet<>()); - orgFacilitiesMap.putIfAbsent(externalId, new HashSet<>()); - } - - // this method means nothing in a demo env - public void activateOrganization(Organization org) { - inactiveUsernames.removeAll(orgUsernamesMap.get(org.getExternalId())); - } - - // this method means nothing in a demo env - public String activateOrganizationWithSingleUser(Organization org) { - activateOrganization(org); - return "activationToken"; - } - - public List fetchAdminUserEmail(Organization org) { - Set> admins = - usernameOrgRolesMap.entrySet().stream() - .filter(e -> e.getValue().getGrantedRoles().contains(OrganizationRole.ADMIN)) - .collect(Collectors.toSet()); - return admins.stream().map(Entry::getKey).collect(Collectors.toList()); - } - - public void createFacility(Facility facility) { - String orgExternalId = facility.getOrganization().getExternalId(); - if (!orgFacilitiesMap.containsKey(orgExternalId)) { - throw new IllegalGraphqlArgumentException( - "Cannot create Okta facility in nonexistent organization."); + + public void deleteFacility(Facility facility) { + String orgExternalId = facility.getOrganization().getExternalId(); + if (!orgFacilitiesMap.containsKey(orgExternalId)) { + throw new IllegalGraphqlArgumentException( + "Cannot delete Okta facility from nonexistent organization."); + } + orgFacilitiesMap.get(orgExternalId).remove(facility.getInternalId()); + // remove this facility from every user's OrganizationRoleClaims, as necessary + usernameOrgRolesMap = + usernameOrgRolesMap.entrySet().stream() + .collect( + Collectors.toMap( + e -> e.getKey(), + e -> { + OrganizationRoleClaims oldRoleClaims = e.getValue(); + Set newFacilities = + oldRoleClaims.getFacilities().stream() + .filter(f -> !f.equals(facility.getInternalId())) + .collect(Collectors.toSet()); + return new OrganizationRoleClaims( + orgExternalId, newFacilities, oldRoleClaims.getGrantedRoles()); + })); } - orgFacilitiesMap.get(orgExternalId).add(facility.getInternalId()); - } - - public void deleteOrganization(Organization org) { - String externalId = org.getExternalId(); - orgUsernamesMap.remove(externalId); - orgFacilitiesMap.remove(externalId); - // remove all users from this map whose org roles are in the deleted org - usernameOrgRolesMap = - usernameOrgRolesMap.entrySet().stream() - .filter(e -> !(e.getValue().getOrganizationExternalId().equals(externalId))) - .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())); - } - - public void deleteFacility(Facility facility) { - String orgExternalId = facility.getOrganization().getExternalId(); - if (!orgFacilitiesMap.containsKey(orgExternalId)) { - throw new IllegalGraphqlArgumentException( - "Cannot delete Okta facility from nonexistent organization."); + + private Optional getOrganizationRoleClaimsFromTenantDataAccess( + Collection groupNames) { + List claims = organizationExtractor.convertClaims(groupNames); + + if (claims.size() != 1) { + log.warn("User is in {} Okta organizations, not 1", claims.size()); + return Optional.empty(); + } + return Optional.of(claims.get(0)); } - orgFacilitiesMap.get(orgExternalId).remove(facility.getInternalId()); - // remove this facility from every user's OrganizationRoleClaims, as necessary - usernameOrgRolesMap = - usernameOrgRolesMap.entrySet().stream() - .collect( - Collectors.toMap( - e -> e.getKey(), - e -> { - OrganizationRoleClaims oldRoleClaims = e.getValue(); - Set newFacilities = - oldRoleClaims.getFacilities().stream() - .filter(f -> !f.equals(facility.getInternalId())) - .collect(Collectors.toSet()); - return new OrganizationRoleClaims( - orgExternalId, newFacilities, oldRoleClaims.getGrantedRoles()); - })); - } - - private Optional getOrganizationRoleClaimsFromTenantDataAccess( - Collection groupNames) { - List claims = organizationExtractor.convertClaims(groupNames); - - if (claims.size() != 1) { - log.warn("User is in {} Okta organizations, not 1", claims.size()); - return Optional.empty(); + + public Optional getOrganizationRoleClaimsForUser(String username) { + // when accessing tenant data, bypass okta and get org from the altered authorities + try { + if (tenantDataContextHolder.hasBeenPopulated() + && username.equals(tenantDataContextHolder.getUsername())) { + return getOrganizationRoleClaimsFromTenantDataAccess( + tenantDataContextHolder.getAuthorities()); + } + return Optional.ofNullable(usernameOrgRolesMap.get(username)); + } catch (ScopeNotActiveException e) { + // Tests are set up with a full SecurityContextHolder and should not rely on + // usernameOrgRolesMap as the source of truth. + if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { + return Optional.of(usernameOrgRolesMap.get(username)); + } + Set authorities = + SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + return getOrganizationRoleClaimsFromTenantDataAccess(authorities); + } } - return Optional.of(claims.get(0)); - } - - public Optional getOrganizationRoleClaimsForUser(String username) { - // when accessing tenant data, bypass okta and get org from the altered authorities - try { - if (tenantDataContextHolder.hasBeenPopulated() - && username.equals(tenantDataContextHolder.getUsername())) { - return getOrganizationRoleClaimsFromTenantDataAccess( - tenantDataContextHolder.getAuthorities()); - } - return Optional.ofNullable(usernameOrgRolesMap.get(username)); - } catch (ScopeNotActiveException e) { - // Tests are set up with a full SecurityContextHolder and should not rely on - // usernameOrgRolesMap as the source of truth. - if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { - return Optional.of(usernameOrgRolesMap.get(username)); - } - Set authorities = - SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.toSet()); - return getOrganizationRoleClaimsFromTenantDataAccess(authorities); + + public PartialOktaUser findUser(String username) { + UserStatus status = + inactiveUsernames.contains(username) ? UserStatus.SUSPENDED : UserStatus.ACTIVE; + boolean isAdmin = adminGroupMemberSet.contains(username); + + Optional orgClaims; + + try { + orgClaims = Optional.ofNullable(usernameOrgRolesMap.get(username)); + } catch (ScopeNotActiveException e) { + // Tests are set up with a full SecurityContextHolder and should not rely on + // usernameOrgRolesMap as the source of truth. + if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { + orgClaims = Optional.of(usernameOrgRolesMap.get(username)); + } else { + Set authorities = + SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + orgClaims = getOrganizationRoleClaimsFromTenantDataAccess(authorities); + } + } + + return PartialOktaUser.builder() + .isSiteAdmin(isAdmin) + .status(status) + .username(username) + .organizationRoleClaims(orgClaims) + .build(); } - } - - public PartialOktaUser findUser(String username) { - UserStatus status = - inactiveUsernames.contains(username) ? UserStatus.SUSPENDED : UserStatus.ACTIVE; - boolean isAdmin = adminGroupMemberSet.contains(username); - - Optional orgClaims; - - try { - orgClaims = Optional.ofNullable(usernameOrgRolesMap.get(username)); - } catch (ScopeNotActiveException e) { - // Tests are set up with a full SecurityContextHolder and should not rely on - // usernameOrgRolesMap as the source of truth. - if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { - orgClaims = Optional.of(usernameOrgRolesMap.get(username)); - } else { - Set authorities = - SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.toSet()); - orgClaims = getOrganizationRoleClaimsFromTenantDataAccess(authorities); - } + + public void reset() { + usernameOrgRolesMap.clear(); + orgUsernamesMap.clear(); + orgFacilitiesMap.clear(); + inactiveUsernames.clear(); + allUsernames.clear(); } - return PartialOktaUser.builder() - .isSiteAdmin(isAdmin) - .status(status) - .username(username) - .organizationRoleClaims(orgClaims) - .build(); - } - - public void reset() { - usernameOrgRolesMap.clear(); - orgUsernamesMap.clear(); - orgFacilitiesMap.clear(); - inactiveUsernames.clear(); - allUsernames.clear(); - } - - public Integer getUsersInSingleFacility(Facility facility) { - Integer accessCount = 0; - - for (OrganizationRoleClaims existingClaims : usernameOrgRolesMap.values()) { - boolean hasAllFacilityAccess = - existingClaims.getGrantedRoles().stream() - .anyMatch(role -> OrganizationRole.ALL_FACILITIES.getName().equals(role.name())); - boolean hasSpecificFacilityAccess = - existingClaims.getFacilities().stream() - .anyMatch(facilityAccessId -> facility.getInternalId().equals(facilityAccessId)); - if (!hasAllFacilityAccess && hasSpecificFacilityAccess) { - accessCount++; - } + public Integer getUsersInSingleFacility(Facility facility) { + Integer accessCount = 0; + + for (OrganizationRoleClaims existingClaims : usernameOrgRolesMap.values()) { + boolean hasAllFacilityAccess = + existingClaims.getGrantedRoles().stream() + .anyMatch(role -> OrganizationRole.ALL_FACILITIES.getName().equals(role.name())); + boolean hasSpecificFacilityAccess = + existingClaims.getFacilities().stream() + .anyMatch(facilityAccessId -> facility.getInternalId().equals(facilityAccessId)); + if (!hasAllFacilityAccess && hasSpecificFacilityAccess) { + accessCount++; + } + } + + return accessCount; } - return accessCount; - } + public int getConnectTimeoutForHealthCheck() { + int FAKE_CONNECTION_TIMEOUT = 0; + return FAKE_CONNECTION_TIMEOUT; + } } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java index 7c61c63d3f..f1a5157353 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java @@ -5,6 +5,7 @@ import com.okta.sdk.resource.api.ApplicationGroupsApi; import com.okta.sdk.resource.api.GroupApi; import com.okta.sdk.resource.api.UserApi; +import com.okta.sdk.resource.client.ApiClient; import com.okta.sdk.resource.client.ApiException; import com.okta.sdk.resource.common.PagedList; import com.okta.sdk.resource.group.GroupBuilder; @@ -32,6 +33,7 @@ import gov.cdc.usds.simplereport.db.model.Facility; import gov.cdc.usds.simplereport.db.model.Organization; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; + import java.util.ArrayList; import java.util.Collection; import java.util.EnumSet; @@ -45,6 +47,7 @@ import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; + import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; @@ -61,733 +64,740 @@ @Slf4j public class LiveOktaRepository implements OktaRepository { - private static final String OKTA_GROUP_NOT_FOUND = "Okta group not found for this organization"; - - private final String rolePrefix; - private final Application app; - private final OrganizationExtractor extractor; - private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; - private final GroupApi groupApi; - private final UserApi userApi; - private final ApplicationGroupsApi applicationGroupsApi; - private final String adminGroupName; - - private static final String OKTA_ORG_PROFILE_MATCHER = "profile.name sw \""; - private static final int OKTA_PAGE_SIZE = 500; - - public LiveOktaRepository( - AuthorizationProperties authorizationProperties, - @Value("${okta.oauth2.client-id}") String oktaOAuth2ClientId, - OrganizationExtractor organizationExtractor, - CurrentTenantDataAccessContextHolder tenantDataContextHolder, - GroupApi groupApi, - ApplicationApi applicationApi, - UserApi userApi, - ApplicationGroupsApi applicationGroupsApi) { - this.rolePrefix = authorizationProperties.getRolePrefix(); - this.adminGroupName = authorizationProperties.getAdminGroupName(); - - this.groupApi = groupApi; - this.userApi = userApi; - this.applicationGroupsApi = applicationGroupsApi; - - try { - this.app = applicationApi.getApplication(oktaOAuth2ClientId, null); - } catch (ApiException e) { - throw new MisconfiguredApplicationException( - "Cannot find Okta application with id=" + oktaOAuth2ClientId, e); - } - - this.extractor = organizationExtractor; - this.tenantDataContextHolder = tenantDataContextHolder; - } - - @Override - public Optional createUser( - IdentityAttributes userIdentity, - Organization org, - Set facilities, - Set roles, - boolean active) { - // By default, when creating a user, we give them privileges of a standard user - String organizationExternalId = org.getExternalId(); - Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); - rolesToCreate.addAll(roles); - - // Add user to new groups - Set groupNamesToAdd = new HashSet<>(); - groupNamesToAdd.addAll( - rolesToCreate.stream() - .map(r -> generateRoleGroupName(organizationExternalId, r)) - .collect(Collectors.toSet())); - groupNamesToAdd.addAll( - facilities.stream() - // use an empty set of facilities if user can access all facilities anyway - .filter(f -> !PermissionHolder.grantsAllFacilityAccess(rolesToCreate)) - .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) - .collect(Collectors.toSet())); - - // Search and q results need to be combined because search results have a delay of the newest - // added groups. - // https://github.com/okta/okta-sdk-java/issues/750 - var searchResults = - groupApi - .listGroups( - null, - null, - null, - null, - null, - OKTA_ORG_PROFILE_MATCHER + generateGroupOrgPrefix(organizationExternalId) + "\"", - null, - null) - .stream(); - var qResults = - groupApi - .listGroups( - generateGroupOrgPrefix(organizationExternalId), - null, - null, - null, - null, - null, - null, - null) - .stream(); - var orgGroups = Stream.concat(searchResults, qResults).distinct().toList(); - throwErrorIfEmpty( - orgGroups.stream(), - String.format( - "Cannot add Okta user to nonexistent organization=%s", organizationExternalId)); - Set orgGroupNames = - orgGroups.stream().map(g -> g.getProfile().getName()).collect(Collectors.toSet()); - groupNamesToAdd.stream() - .filter(n -> !orgGroupNames.contains(n)) - .forEach( - n -> { - throw new IllegalGraphqlArgumentException( - String.format("Cannot add Okta user to nonexistent group=%s", n)); - }); - Set groupIdsToAdd = - orgGroups.stream() - .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) - .map(Group::getId) - .collect(Collectors.toSet()); - validateRequiredFields(userIdentity); - try { - var user = - UserBuilder.instance() - .setFirstName(userIdentity.getFirstName()) - .setMiddleName(userIdentity.getMiddleName()) - .setLastName(userIdentity.getLastName()) - .setHonorificSuffix(userIdentity.getSuffix()) - .setEmail(userIdentity.getUsername()) - .setLogin(userIdentity.getUsername()) - .setActive(active) - .buildAndCreate(userApi); - groupIdsToAdd.forEach(groupId -> groupApi.assignUserToGroup(groupId, user.getId())); - } catch (ApiException e) { - if (e.getMessage() - .contains("An object with this field already exists in the current organization")) { - throw new ConflictingUserException(); - } else { - throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); - } - } - - List claims = extractor.convertClaims(groupNamesToAdd); - if (claims.size() != 1) { - log.warn("User is in {} Okta organizations, not 1", claims.size()); - return Optional.empty(); - } - return Optional.of(claims.get(0)); - } - - private static void validateRequiredFields(IdentityAttributes userIdentity) { - if (StringUtils.isBlank(userIdentity.getLastName())) { - throw new IllegalGraphqlArgumentException("Cannot create Okta user without last name"); - } - if (StringUtils.isBlank(userIdentity.getUsername())) { - throw new IllegalGraphqlArgumentException("Cannot create Okta user without username"); - } - } - - @Override - public Set getAllUsersForOrganization(Organization org) { - return getAllUsersForOrg(org).stream() - .map(u -> u.getProfile().getLogin()) - .collect(Collectors.toUnmodifiableSet()); - } - - @Override - public Map getAllUsersWithStatusForOrganization(Organization org) { - return getAllUsersForOrg(org).stream() - .collect(Collectors.toMap(u -> u.getProfile().getLogin(), User::getStatus)); - } - - private List getAllUsersForOrg(Organization org) { - PagedList pagedUserList = new PagedList<>(); - List allUsers = new ArrayList<>(); - Group orgDefaultOktaGroup = getDefaultOktaGroup(org); - do { - pagedUserList = - (PagedList) - groupApi.listGroupUsers( - orgDefaultOktaGroup.getId(), pagedUserList.getAfter(), OKTA_PAGE_SIZE); - allUsers.addAll(pagedUserList); - } while (pagedUserList.hasMoreItems()); - return allUsers; - } - - private Group getDefaultOktaGroup(Organization org) { - final String orgDefaultGroupName = - generateRoleGroupName(org.getExternalId(), OrganizationRole.getDefault()); - final var oktaGroupList = - groupApi.listGroups(orgDefaultGroupName, null, null, null, null, null, null, null); - - return oktaGroupList.stream() - .filter(g -> orgDefaultGroupName.equals(g.getProfile().getName())) - .findFirst() - .orElseThrow(() -> new IllegalGraphqlArgumentException(OKTA_GROUP_NOT_FOUND)); - } - - @Override - public Optional updateUser(IdentityAttributes userIdentity) { - var user = - getUserOrThrowError( - userIdentity.getUsername(), "Cannot update Okta user with unrecognized username"); - updateUser(user, userIdentity); - - return getOrganizationRoleClaimsForUser(user); - } - - private void updateUser(User user, IdentityAttributes userIdentity) { - user.getProfile().setFirstName(userIdentity.getFirstName()); - user.getProfile().setMiddleName(userIdentity.getMiddleName()); - user.getProfile().setLastName(userIdentity.getLastName()); - // Is it our fault we don't accommodate honorific suffix? Or Okta's fault they - // don't have regular suffix? You decide. - user.getProfile().setHonorificSuffix(userIdentity.getSuffix()); - var updateRequest = new UpdateUserRequest(); - updateRequest.setProfile(user.getProfile()); - userApi.updateUser(user.getId(), updateRequest, false); - } - - @Override - public Optional updateUserEmail( - IdentityAttributes userIdentity, String email) { - var user = - getUserOrThrowError( - userIdentity.getUsername(), - "Cannot update email of Okta user with unrecognized username"); - UserProfile profile = user.getProfile(); - profile.setLogin(email); - profile.setEmail(email); - user.setProfile(profile); - var updateRequest = new UpdateUserRequest(); - updateRequest.setProfile(profile); - try { - userApi.updateUser(user.getId(), updateRequest, false); - } catch (ApiException e) { - if (e.getMessage() - .contains("An object with this field already exists in the current organization")) { - throw new ConflictingUserException(); - } else { - throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); - } - } - - return getOrganizationRoleClaimsForUser(user); - } - - @Override - public void reprovisionUser(IdentityAttributes userIdentity) { - var user = - getUserOrThrowError( - userIdentity.getUsername(), "Cannot reprovision Okta user with unrecognized username"); - UserStatus userStatus = user.getStatus(); - - // any org user "deleted" through our api will be in SUSPENDED state - if (userStatus != UserStatus.SUSPENDED) { - throw new ConflictingUserException(); - } - - updateUser(user, userIdentity); - userApi.resetFactors(user.getId()); - - // transitioning from SUSPENDED -> DEPROVISIONED -> ACTIVE will reset the user's password and - // password reset question. This cannot be done with `.reactivateUser()` because it requires the - // user to be in PROVISIONED state - userApi.deactivateUser(user.getId(), false); - userApi.activateUser(user.getId(), true); - } - - @Override - public List updateUserPrivilegesAndGroupAccess( - String username, - Organization org, - Set facilities, - OrganizationRole role, - boolean assignedToAllFacilities) { - - // unassign user from current groups - - User oktaUserToMove = getUserOrThrowError(username, "Couldn't find user"); - List groupsToUnassign = userApi.listUserGroups(oktaUserToMove.getId()); - - groupsToUnassign.stream() - // only match on the org-related group ids and not the Okta-wide orgs like "Everyone" - .filter(g -> g.getProfile().getName().contains("TENANT")) - .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), oktaUserToMove.getId())); - - // add them to the new groups - String organizationExternalId = org.getExternalId(); - EnumSet rolesToCreate = - assignedToAllFacilities - ? EnumSet.of(OrganizationRole.getDefault(), role, OrganizationRole.ALL_FACILITIES) - : EnumSet.of(OrganizationRole.getDefault(), role); - - Set groupNamesToAdd = new HashSet<>(); - groupNamesToAdd.addAll( - rolesToCreate.stream() - .map(r -> generateRoleGroupName(organizationExternalId, r)) - .collect(Collectors.toSet())); - - groupNamesToAdd.addAll( - facilities.stream() - .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) - .collect(Collectors.toSet())); - - String groupOrgPrefix = generateGroupOrgPrefix(org.getExternalId()); - Map orgsToAddUserToMap = - groupApi - .listGroups( - null, - null, - null, - null, - null, - OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", - null, - null) - .stream() - .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) - .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); - - orgsToAddUserToMap.forEach( - (name, group) -> groupApi.assignUserToGroup(group.getId(), oktaUserToMove.getId())); - return orgsToAddUserToMap.keySet().stream().toList(); - } - - @Override - public Optional updateUserPrivileges( - String username, Organization org, Set facilities, Set roles) { - User user = - getUserOrThrowError(username, "Cannot update role of Okta user with unrecognized username"); - - String orgId = org.getExternalId(); - - final String groupOrgPrefix = generateGroupOrgPrefix(orgId); - final String groupOrgDefaultName = generateRoleGroupName(orgId, OrganizationRole.getDefault()); - - // Map user's current Okta group memberships (Okta group name -> Okta Group). - // The Okta group name is our friendly role and facility group names - Map currentOrgGroupMapForUser = - userApi.listUserGroups(user.getId()).stream() - .filter( - g -> - GroupType.OKTA_GROUP == g.getType() - && g.getProfile().getName().startsWith(groupOrgPrefix)) - .collect(Collectors.toMap(g -> g.getProfile().getName(), g -> g)); - - if (!currentOrgGroupMapForUser.containsKey(groupOrgDefaultName)) { - // The user is not a member of the default group for this organization. If they happen - // to be in any of this organization's groups, remove the user from those groups. - currentOrgGroupMapForUser - .values() - .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), user.getId())); - throw new IllegalGraphqlArgumentException( - "Cannot update privileges of Okta user in organization they do not belong to."); - } - - Set expectedOrgGroupNamesForUser = new HashSet<>(); - expectedOrgGroupNamesForUser.add(groupOrgDefaultName); - expectedOrgGroupNamesForUser.addAll( - roles.stream().map(r -> generateRoleGroupName(orgId, r)).collect(Collectors.toSet())); - if (!PermissionHolder.grantsAllFacilityAccess(roles)) { - expectedOrgGroupNamesForUser.addAll( - facilities.stream() - .map(f -> generateFacilityGroupName(orgId, f.getInternalId())) - .collect(Collectors.toSet())); - } - - // to remove... - Set groupNamesToRemove = new HashSet<>(currentOrgGroupMapForUser.keySet()); - groupNamesToRemove.removeIf(expectedOrgGroupNamesForUser::contains); - - // to add... - Set groupNamesToAdd = new HashSet<>(expectedOrgGroupNamesForUser); - groupNamesToAdd.removeIf(currentOrgGroupMapForUser::containsKey); - - if (!groupNamesToRemove.isEmpty() || !groupNamesToAdd.isEmpty()) { - Map fullOrgGroupMap = - groupApi - .listGroups( - null, - null, - null, - null, - null, - OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", - null, - null) - .stream() - .filter(g -> GroupType.OKTA_GROUP == g.getType()) - .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); - if (fullOrgGroupMap.size() == 0) { - throw new IllegalGraphqlArgumentException( - String.format("Cannot add Okta user to nonexistent organization=%s", orgId)); - } - - for (String groupName : groupNamesToRemove) { - Group group = fullOrgGroupMap.get(groupName); - log.info("Removing {} from Okta group: {}", username, group.getProfile().getName()); - groupApi.unassignUserFromGroup(group.getId(), user.getId()); - } - - for (String groupName : groupNamesToAdd) { - if (!fullOrgGroupMap.containsKey(groupName)) { - throw new IllegalGraphqlArgumentException( - String.format("Cannot add Okta user to nonexistent group=%s", groupName)); + private static final String OKTA_GROUP_NOT_FOUND = "Okta group not found for this organization"; + + private final String rolePrefix; + private final Application app; + private final OrganizationExtractor extractor; + private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; + + private final ApplicationApi applicationApi; + private final GroupApi groupApi; + private final UserApi userApi; + private final ApplicationGroupsApi applicationGroupsApi; + private final String adminGroupName; + + private static final String OKTA_ORG_PROFILE_MATCHER = "profile.name sw \""; + private static final int OKTA_PAGE_SIZE = 500; + + public LiveOktaRepository( + AuthorizationProperties authorizationProperties, + @Value("${okta.oauth2.client-id}") String oktaOAuth2ClientId, + OrganizationExtractor organizationExtractor, + CurrentTenantDataAccessContextHolder tenantDataContextHolder, + GroupApi groupApi, + ApplicationApi applicationApi, + UserApi userApi, + ApplicationGroupsApi applicationGroupsApi) { + this.rolePrefix = authorizationProperties.getRolePrefix(); + this.adminGroupName = authorizationProperties.getAdminGroupName(); + + this.applicationApi = applicationApi; + this.groupApi = groupApi; + this.userApi = userApi; + this.applicationGroupsApi = applicationGroupsApi; + + try { + this.app = applicationApi.getApplication(oktaOAuth2ClientId, null); + } catch (ApiException e) { + throw new MisconfiguredApplicationException( + "Cannot find Okta application with id=" + oktaOAuth2ClientId, e); + } + + this.extractor = organizationExtractor; + this.tenantDataContextHolder = tenantDataContextHolder; + } + + @Override + public Optional createUser( + IdentityAttributes userIdentity, + Organization org, + Set facilities, + Set roles, + boolean active) { + // By default, when creating a user, we give them privileges of a standard user + String organizationExternalId = org.getExternalId(); + Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); + rolesToCreate.addAll(roles); + + // Add user to new groups + Set groupNamesToAdd = new HashSet<>(); + groupNamesToAdd.addAll( + rolesToCreate.stream() + .map(r -> generateRoleGroupName(organizationExternalId, r)) + .collect(Collectors.toSet())); + groupNamesToAdd.addAll( + facilities.stream() + // use an empty set of facilities if user can access all facilities anyway + .filter(f -> !PermissionHolder.grantsAllFacilityAccess(rolesToCreate)) + .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) + .collect(Collectors.toSet())); + + // Search and q results need to be combined because search results have a delay of the newest + // added groups. + // https://github.com/okta/okta-sdk-java/issues/750 + var searchResults = + groupApi + .listGroups( + null, + null, + null, + null, + null, + OKTA_ORG_PROFILE_MATCHER + generateGroupOrgPrefix(organizationExternalId) + "\"", + null, + null) + .stream(); + var qResults = + groupApi + .listGroups( + generateGroupOrgPrefix(organizationExternalId), + null, + null, + null, + null, + null, + null, + null) + .stream(); + var orgGroups = Stream.concat(searchResults, qResults).distinct().toList(); + throwErrorIfEmpty( + orgGroups.stream(), + String.format( + "Cannot add Okta user to nonexistent organization=%s", organizationExternalId)); + Set orgGroupNames = + orgGroups.stream().map(g -> g.getProfile().getName()).collect(Collectors.toSet()); + groupNamesToAdd.stream() + .filter(n -> !orgGroupNames.contains(n)) + .forEach( + n -> { + throw new IllegalGraphqlArgumentException( + String.format("Cannot add Okta user to nonexistent group=%s", n)); + }); + Set groupIdsToAdd = + orgGroups.stream() + .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) + .map(Group::getId) + .collect(Collectors.toSet()); + validateRequiredFields(userIdentity); + try { + var user = + UserBuilder.instance() + .setFirstName(userIdentity.getFirstName()) + .setMiddleName(userIdentity.getMiddleName()) + .setLastName(userIdentity.getLastName()) + .setHonorificSuffix(userIdentity.getSuffix()) + .setEmail(userIdentity.getUsername()) + .setLogin(userIdentity.getUsername()) + .setActive(active) + .buildAndCreate(userApi); + groupIdsToAdd.forEach(groupId -> groupApi.assignUserToGroup(groupId, user.getId())); + } catch (ApiException e) { + if (e.getMessage() + .contains("An object with this field already exists in the current organization")) { + throw new ConflictingUserException(); + } else { + throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); + } + } + + List claims = extractor.convertClaims(groupNamesToAdd); + if (claims.size() != 1) { + log.warn("User is in {} Okta organizations, not 1", claims.size()); + return Optional.empty(); + } + return Optional.of(claims.get(0)); + } + + private static void validateRequiredFields(IdentityAttributes userIdentity) { + if (StringUtils.isBlank(userIdentity.getLastName())) { + throw new IllegalGraphqlArgumentException("Cannot create Okta user without last name"); + } + if (StringUtils.isBlank(userIdentity.getUsername())) { + throw new IllegalGraphqlArgumentException("Cannot create Okta user without username"); + } + } + + @Override + public Set getAllUsersForOrganization(Organization org) { + return getAllUsersForOrg(org).stream() + .map(u -> u.getProfile().getLogin()) + .collect(Collectors.toUnmodifiableSet()); + } + + @Override + public Map getAllUsersWithStatusForOrganization(Organization org) { + return getAllUsersForOrg(org).stream() + .collect(Collectors.toMap(u -> u.getProfile().getLogin(), User::getStatus)); + } + + private List getAllUsersForOrg(Organization org) { + PagedList pagedUserList = new PagedList<>(); + List allUsers = new ArrayList<>(); + Group orgDefaultOktaGroup = getDefaultOktaGroup(org); + do { + pagedUserList = + (PagedList) + groupApi.listGroupUsers( + orgDefaultOktaGroup.getId(), pagedUserList.getAfter(), OKTA_PAGE_SIZE); + allUsers.addAll(pagedUserList); + } while (pagedUserList.hasMoreItems()); + return allUsers; + } + + private Group getDefaultOktaGroup(Organization org) { + final String orgDefaultGroupName = + generateRoleGroupName(org.getExternalId(), OrganizationRole.getDefault()); + final var oktaGroupList = + groupApi.listGroups(orgDefaultGroupName, null, null, null, null, null, null, null); + + return oktaGroupList.stream() + .filter(g -> orgDefaultGroupName.equals(g.getProfile().getName())) + .findFirst() + .orElseThrow(() -> new IllegalGraphqlArgumentException(OKTA_GROUP_NOT_FOUND)); + } + + @Override + public Optional updateUser(IdentityAttributes userIdentity) { + var user = + getUserOrThrowError( + userIdentity.getUsername(), "Cannot update Okta user with unrecognized username"); + updateUser(user, userIdentity); + + return getOrganizationRoleClaimsForUser(user); + } + + private void updateUser(User user, IdentityAttributes userIdentity) { + user.getProfile().setFirstName(userIdentity.getFirstName()); + user.getProfile().setMiddleName(userIdentity.getMiddleName()); + user.getProfile().setLastName(userIdentity.getLastName()); + // Is it our fault we don't accommodate honorific suffix? Or Okta's fault they + // don't have regular suffix? You decide. + user.getProfile().setHonorificSuffix(userIdentity.getSuffix()); + var updateRequest = new UpdateUserRequest(); + updateRequest.setProfile(user.getProfile()); + userApi.updateUser(user.getId(), updateRequest, false); + } + + @Override + public Optional updateUserEmail( + IdentityAttributes userIdentity, String email) { + var user = + getUserOrThrowError( + userIdentity.getUsername(), + "Cannot update email of Okta user with unrecognized username"); + UserProfile profile = user.getProfile(); + profile.setLogin(email); + profile.setEmail(email); + user.setProfile(profile); + var updateRequest = new UpdateUserRequest(); + updateRequest.setProfile(profile); + try { + userApi.updateUser(user.getId(), updateRequest, false); + } catch (ApiException e) { + if (e.getMessage() + .contains("An object with this field already exists in the current organization")) { + throw new ConflictingUserException(); + } else { + throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); + } + } + + return getOrganizationRoleClaimsForUser(user); + } + + @Override + public void reprovisionUser(IdentityAttributes userIdentity) { + var user = + getUserOrThrowError( + userIdentity.getUsername(), "Cannot reprovision Okta user with unrecognized username"); + UserStatus userStatus = user.getStatus(); + + // any org user "deleted" through our api will be in SUSPENDED state + if (userStatus != UserStatus.SUSPENDED) { + throw new ConflictingUserException(); } - Group group = fullOrgGroupMap.get(groupName); - log.info("Adding {} to Okta group: {}", username, group.getProfile().getName()); - groupApi.assignUserToGroup(group.getId(), user.getId()); - } - } - - return getOrganizationRoleClaimsForUser(user); - } - - @Override - public void resetUserPassword(String username) { - var user = - getUserOrThrowError( - username, "Cannot reset password for Okta user with unrecognized username"); - userApi.generateResetPasswordToken(user.getId(), true, false); - } - - @Override - public void resetUserMfa(String username) { - var user = - getUserOrThrowError(username, "Cannot reset MFA for Okta user with unrecognized username"); - userApi.resetFactors(user.getId()); - } - - @Override - public void setUserIsActive(String username, boolean active) { - var user = - getUserOrThrowError( - username, "Cannot update active status of Okta user with unrecognized username"); - - if (active && user.getStatus() == UserStatus.SUSPENDED) { - userApi.unsuspendUser(user.getId()); - } else if (!active && user.getStatus() != UserStatus.SUSPENDED) { - userApi.suspendUser(user.getId()); - } - } - - @Override - public UserStatus getUserStatus(String username) { - return getUserOrThrowError( - username, "Cannot retrieve Okta user's status with unrecognized username") - .getStatus(); - } - - @Override - public void reactivateUser(String username) { - var user = - getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); - userApi.unsuspendUser(user.getId()); - } - - @Override - public void resendActivationEmail(String username) { - var user = - getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); - if (user.getStatus() == UserStatus.PROVISIONED) { - userApi.reactivateUser(user.getId(), true); - } else if (user.getStatus() == UserStatus.STAGED) { - userApi.activateUser(user.getId(), true); - } else { - throw new IllegalGraphqlArgumentException( - "Cannot reactivate user with status: " + user.getStatus()); - } - } - - /** - * Iterates over all OrganizationRole's, creating new corresponding Okta groups for this - * organization where they do not already exist. For those OrganizationRole's that are in - * MIGRATION_DEST_ROLES and whose Okta groups are newly created, migrate all users from this org - * to those new Okta groups, where the migrated users are sourced from all pre-existing Okta - * groups for this organization. Separately, iterates over all facilities in this org, creating - * new corresponding Okta groups where they do not already exist. Does not perform any migration - * to these facility groups. - */ - @Override - public void createOrganization(Organization org) { - String name = org.getOrganizationName(); - String externalId = org.getExternalId(); - - for (OrganizationRole role : OrganizationRole.values()) { - String roleGroupName = generateRoleGroupName(externalId, role); - String roleGroupDescription = generateRoleGroupDescription(name, role); - Group g = - GroupBuilder.instance() - .setName(roleGroupName) - .setDescription(roleGroupDescription) - .buildAndCreate(groupApi); - applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); - - log.info("Created Okta group={}", roleGroupName); - } - } - - private List getOrgAdminUsers(Organization org) { - String externalId = org.getExternalId(); - String roleGroupName = generateRoleGroupName(externalId, OrganizationRole.ADMIN); - var groups = groupApi.listGroups(roleGroupName, null, null, null, null, null, null, null); - throwErrorIfEmpty(groups.stream(), "Cannot activate nonexistent Okta organization"); - Group group = groups.get(0); - return groupApi.listGroupUsers(group.getId(), null, null); - } - - private String activateUser(User user) { - if (user.getStatus() == UserStatus.PROVISIONED) { - // reactivates user and sends them an Okta email to reactivate their account - return userApi.reactivateUser(user.getId(), true).getActivationToken(); - } else if (user.getStatus() == UserStatus.STAGED) { - return userApi.activateUser(user.getId(), true).getActivationToken(); - } else { - throw new IllegalGraphqlArgumentException( - "Cannot activate Okta organization whose users have status=" + user.getStatus().name()); - } - } - - @Override - public void activateOrganization(Organization org) { - var users = getOrgAdminUsers(org); - for (User u : users) { - activateUser(u); - } - } - - @Override - public String activateOrganizationWithSingleUser(Organization org) { - User user = getOrgAdminUsers(org).get(0); - return activateUser(user); - } - - @Override - public List fetchAdminUserEmail(Organization org) { - var admins = getOrgAdminUsers(org); - return admins.stream().map(u -> u.getProfile().getLogin()).toList(); - } - - @Override - public void createFacility(Facility facility) { - // Only create the facility group if the facility's organization has already been created - String orgExternalId = facility.getOrganization().getExternalId(); - var orgGroups = - groupApi.listGroups( - generateGroupOrgPrefix(orgExternalId), null, null, null, null, null, null, null); - throwErrorIfEmpty( - orgGroups.stream(), - String.format( - "Cannot create Okta group for facility=%s: facility's org=%s, has not yet been created in Okta", - facility.getFacilityName(), facility.getOrganization().getExternalId())); - - String orgName = facility.getOrganization().getOrganizationName(); - String facilityGroupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); - Group g = - GroupBuilder.instance() - .setName(facilityGroupName) - .setDescription(generateFacilityGroupDescription(orgName, facility.getFacilityName())) - .buildAndCreate(groupApi); - applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); - - log.info("Created Okta group={}", facilityGroupName); - } - - public void deleteFacility(Facility facility) { - String orgExternalId = facility.getOrganization().getExternalId(); - String groupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); - var groups = groupApi.listGroups(groupName, null, null, null, null, null, null, null); - for (Group group : groups) { - groupApi.deleteGroup(group.getId()); - } - } - - @Override - public void deleteOrganization(Organization org) { - String externalId = org.getExternalId(); - var orgGroups = - groupApi.listGroups( - generateGroupOrgPrefix(externalId), null, null, null, null, null, null, null); - for (Group group : orgGroups) { - groupApi.deleteGroup(group.getId()); - } - } - - // returns the external ID of the organization the specified user belongs to - @Override - public Optional getOrganizationRoleClaimsForUser(String username) { - // When a site admin is using tenant data access, bypass okta and get org from the altered - // authorities. If the site admin is getting the claims for another site admin who also has - // active tenant data access, then reflect what is in Okta, not the temporary claims. - if (tenantDataContextHolder.hasBeenPopulated() - && username.equals(tenantDataContextHolder.getUsername())) { - return getOrganizationRoleClaimsFromAuthorities(tenantDataContextHolder.getAuthorities()); - } - - return getOrganizationRoleClaimsForUser( - getUserOrThrowError(username, "Cannot get org external ID for nonexistent user")); - } - - public Integer getUsersInSingleFacility(Facility facility) { - String facilityAccessGroupName = - generateFacilityGroupName( - facility.getOrganization().getExternalId(), facility.getInternalId()); - - List facilityAccessGroup = - groupApi.listGroups(facilityAccessGroupName, null, null, 1, "stats", null, null, null); - - if (facilityAccessGroup.isEmpty()) { - return 0; - } - - try { - LinkedHashMap stats = - (LinkedHashMap) facilityAccessGroup.get(0).getEmbedded().get("stats"); - return ((Integer) stats.get("usersCount")); - } catch (NullPointerException e) { - throw new BadRequestException("Unable to retrieve okta group stats", e); - } - } - - public PartialOktaUser findUser(String username) { - User user = - getUserOrThrowError( - username, "Cannot retrieve Okta user's status with unrecognized username"); - - List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); - - Optional orgClaims = convertToOrganizationRoleClaims(userGroups); - - return PartialOktaUser.builder() - .username(username) - .isSiteAdmin(isSiteAdmin(userGroups)) - .status(user.getStatus()) - .organizationRoleClaims(orgClaims) - .build(); - } - - private Optional getOrganizationRoleClaimsFromAuthorities( - Collection authorities) { - List claims = extractor.convertClaims(authorities); - - if (claims.size() != 1) { - log.warn("User's Tenant Data Access has claims in {} organizations, not 1", claims.size()); - return Optional.empty(); - } - return Optional.of(claims.get(0)); - } - - private Optional getOrganizationRoleClaimsForUser(User user) { - List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); - return convertToOrganizationRoleClaims(userGroups); - } - - private Optional convertToOrganizationRoleClaims(List userGroups) { - List groupNames = - userGroups.stream() - .filter(g -> g.getType() == GroupType.OKTA_GROUP) - .map(g -> g.getProfile().getName()) - .toList(); - List claims = extractor.convertClaims(groupNames); - - if (claims.size() != 1) { - log.warn("User is in {} Okta organizations, not 1", claims.size()); - return Optional.empty(); - } - return Optional.of(claims.get(0)); - } - - private boolean isSiteAdmin(List oktaGroups) { - return oktaGroups.stream() - .filter(g -> g.getType() == GroupType.OKTA_GROUP) - .anyMatch(g -> adminGroupName.equals(g.getProfile().getName())); - } - - private String generateGroupOrgPrefix(String orgExternalId) { - return String.format("%s%s", rolePrefix, orgExternalId); - } - - private String generateRoleGroupName(String orgExternalId, OrganizationRole role) { - return String.format("%s%s%s", rolePrefix, orgExternalId, generateRoleSuffix(role)); - } - - private String generateFacilityGroupName(String orgExternalId, UUID facilityId) { - return String.format( - "%s%s%s", rolePrefix, orgExternalId, generateFacilitySuffix(facilityId.toString())); - } - - private String generateRoleGroupDescription(String orgName, OrganizationRole role) { - return String.format("%s - %ss", orgName, role.getDescription()); - } - - private String generateFacilityGroupDescription(String orgName, String facilityName) { - return String.format("%s - Facility Access - %s", orgName, facilityName); - } - - private String generateRoleSuffix(OrganizationRole role) { - return ":" + role.name(); - } - - private String generateFacilitySuffix(String facilityId) { - return ":" + OrganizationExtractor.FACILITY_ACCESS_MARKER + ":" + facilityId; - } - - private User getUserOrThrowError(String email, String errorMessage) { - try { - return userApi.getUser(email); - } catch (ApiException e) { - throw new IllegalGraphqlArgumentException(errorMessage); - } - } - - private void throwErrorIfEmpty(Stream stream, String errorMessage) { - if (stream.findAny().isEmpty()) { - throw new IllegalGraphqlArgumentException(errorMessage); - } - } - - private String prettifyOktaError(ApiException e) { - var errorMessage = "Code: " + e.getCode() + "; Message: " + e.getMessage(); - if (e.getResponseBody() != null) { - Error error = ApiExceptionHelper.getError(e); - if (error != null) { - errorMessage = - "Okta Error: " + error.getErrorCode() + ", Error summary: " + error.getErrorSummary(); - if (error.getErrorCauses() != null) { - errorMessage += - ", Error Cause(s): " - + error.getErrorCauses().stream() - .map(ErrorErrorCausesInner::getErrorSummary) - .collect(Collectors.joining(", ")); + + updateUser(user, userIdentity); + userApi.resetFactors(user.getId()); + + // transitioning from SUSPENDED -> DEPROVISIONED -> ACTIVE will reset the user's password and + // password reset question. This cannot be done with `.reactivateUser()` because it requires the + // user to be in PROVISIONED state + userApi.deactivateUser(user.getId(), false); + userApi.activateUser(user.getId(), true); + } + + @Override + public List updateUserPrivilegesAndGroupAccess( + String username, + Organization org, + Set facilities, + OrganizationRole role, + boolean assignedToAllFacilities) { + + // unassign user from current groups + + User oktaUserToMove = getUserOrThrowError(username, "Couldn't find user"); + List groupsToUnassign = userApi.listUserGroups(oktaUserToMove.getId()); + + groupsToUnassign.stream() + // only match on the org-related group ids and not the Okta-wide orgs like "Everyone" + .filter(g -> g.getProfile().getName().contains("TENANT")) + .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), oktaUserToMove.getId())); + + // add them to the new groups + String organizationExternalId = org.getExternalId(); + EnumSet rolesToCreate = + assignedToAllFacilities + ? EnumSet.of(OrganizationRole.getDefault(), role, OrganizationRole.ALL_FACILITIES) + : EnumSet.of(OrganizationRole.getDefault(), role); + + Set groupNamesToAdd = new HashSet<>(); + groupNamesToAdd.addAll( + rolesToCreate.stream() + .map(r -> generateRoleGroupName(organizationExternalId, r)) + .collect(Collectors.toSet())); + + groupNamesToAdd.addAll( + facilities.stream() + .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) + .collect(Collectors.toSet())); + + String groupOrgPrefix = generateGroupOrgPrefix(org.getExternalId()); + Map orgsToAddUserToMap = + groupApi + .listGroups( + null, + null, + null, + null, + null, + OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", + null, + null) + .stream() + .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) + .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); + + orgsToAddUserToMap.forEach( + (name, group) -> groupApi.assignUserToGroup(group.getId(), oktaUserToMove.getId())); + return orgsToAddUserToMap.keySet().stream().toList(); + } + + @Override + public Optional updateUserPrivileges( + String username, Organization org, Set facilities, Set roles) { + User user = + getUserOrThrowError(username, "Cannot update role of Okta user with unrecognized username"); + + String orgId = org.getExternalId(); + + final String groupOrgPrefix = generateGroupOrgPrefix(orgId); + final String groupOrgDefaultName = generateRoleGroupName(orgId, OrganizationRole.getDefault()); + + // Map user's current Okta group memberships (Okta group name -> Okta Group). + // The Okta group name is our friendly role and facility group names + Map currentOrgGroupMapForUser = + userApi.listUserGroups(user.getId()).stream() + .filter( + g -> + GroupType.OKTA_GROUP == g.getType() + && g.getProfile().getName().startsWith(groupOrgPrefix)) + .collect(Collectors.toMap(g -> g.getProfile().getName(), g -> g)); + + if (!currentOrgGroupMapForUser.containsKey(groupOrgDefaultName)) { + // The user is not a member of the default group for this organization. If they happen + // to be in any of this organization's groups, remove the user from those groups. + currentOrgGroupMapForUser + .values() + .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), user.getId())); + throw new IllegalGraphqlArgumentException( + "Cannot update privileges of Okta user in organization they do not belong to."); + } + + Set expectedOrgGroupNamesForUser = new HashSet<>(); + expectedOrgGroupNamesForUser.add(groupOrgDefaultName); + expectedOrgGroupNamesForUser.addAll( + roles.stream().map(r -> generateRoleGroupName(orgId, r)).collect(Collectors.toSet())); + if (!PermissionHolder.grantsAllFacilityAccess(roles)) { + expectedOrgGroupNamesForUser.addAll( + facilities.stream() + .map(f -> generateFacilityGroupName(orgId, f.getInternalId())) + .collect(Collectors.toSet())); + } + + // to remove... + Set groupNamesToRemove = new HashSet<>(currentOrgGroupMapForUser.keySet()); + groupNamesToRemove.removeIf(expectedOrgGroupNamesForUser::contains); + + // to add... + Set groupNamesToAdd = new HashSet<>(expectedOrgGroupNamesForUser); + groupNamesToAdd.removeIf(currentOrgGroupMapForUser::containsKey); + + if (!groupNamesToRemove.isEmpty() || !groupNamesToAdd.isEmpty()) { + Map fullOrgGroupMap = + groupApi + .listGroups( + null, + null, + null, + null, + null, + OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", + null, + null) + .stream() + .filter(g -> GroupType.OKTA_GROUP == g.getType()) + .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); + if (fullOrgGroupMap.size() == 0) { + throw new IllegalGraphqlArgumentException( + String.format("Cannot add Okta user to nonexistent organization=%s", orgId)); + } + + for (String groupName : groupNamesToRemove) { + Group group = fullOrgGroupMap.get(groupName); + log.info("Removing {} from Okta group: {}", username, group.getProfile().getName()); + groupApi.unassignUserFromGroup(group.getId(), user.getId()); + } + + for (String groupName : groupNamesToAdd) { + if (!fullOrgGroupMap.containsKey(groupName)) { + throw new IllegalGraphqlArgumentException( + String.format("Cannot add Okta user to nonexistent group=%s", groupName)); + } + Group group = fullOrgGroupMap.get(groupName); + log.info("Adding {} to Okta group: {}", username, group.getProfile().getName()); + groupApi.assignUserToGroup(group.getId(), user.getId()); + } + } + + return getOrganizationRoleClaimsForUser(user); + } + + @Override + public void resetUserPassword(String username) { + var user = + getUserOrThrowError( + username, "Cannot reset password for Okta user with unrecognized username"); + userApi.generateResetPasswordToken(user.getId(), true, false); + } + + @Override + public void resetUserMfa(String username) { + var user = + getUserOrThrowError(username, "Cannot reset MFA for Okta user with unrecognized username"); + userApi.resetFactors(user.getId()); + } + + @Override + public void setUserIsActive(String username, boolean active) { + var user = + getUserOrThrowError( + username, "Cannot update active status of Okta user with unrecognized username"); + + if (active && user.getStatus() == UserStatus.SUSPENDED) { + userApi.unsuspendUser(user.getId()); + } else if (!active && user.getStatus() != UserStatus.SUSPENDED) { + userApi.suspendUser(user.getId()); + } + } + + @Override + public UserStatus getUserStatus(String username) { + return getUserOrThrowError( + username, "Cannot retrieve Okta user's status with unrecognized username") + .getStatus(); + } + + @Override + public void reactivateUser(String username) { + var user = + getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); + userApi.unsuspendUser(user.getId()); + } + + @Override + public void resendActivationEmail(String username) { + var user = + getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); + if (user.getStatus() == UserStatus.PROVISIONED) { + userApi.reactivateUser(user.getId(), true); + } else if (user.getStatus() == UserStatus.STAGED) { + userApi.activateUser(user.getId(), true); + } else { + throw new IllegalGraphqlArgumentException( + "Cannot reactivate user with status: " + user.getStatus()); + } + } + + /** + * Iterates over all OrganizationRole's, creating new corresponding Okta groups for this + * organization where they do not already exist. For those OrganizationRole's that are in + * MIGRATION_DEST_ROLES and whose Okta groups are newly created, migrate all users from this org + * to those new Okta groups, where the migrated users are sourced from all pre-existing Okta + * groups for this organization. Separately, iterates over all facilities in this org, creating + * new corresponding Okta groups where they do not already exist. Does not perform any migration + * to these facility groups. + */ + @Override + public void createOrganization(Organization org) { + String name = org.getOrganizationName(); + String externalId = org.getExternalId(); + + for (OrganizationRole role : OrganizationRole.values()) { + String roleGroupName = generateRoleGroupName(externalId, role); + String roleGroupDescription = generateRoleGroupDescription(name, role); + Group g = + GroupBuilder.instance() + .setName(roleGroupName) + .setDescription(roleGroupDescription) + .buildAndCreate(groupApi); + applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); + + log.info("Created Okta group={}", roleGroupName); + } + } + + private List getOrgAdminUsers(Organization org) { + String externalId = org.getExternalId(); + String roleGroupName = generateRoleGroupName(externalId, OrganizationRole.ADMIN); + var groups = groupApi.listGroups(roleGroupName, null, null, null, null, null, null, null); + throwErrorIfEmpty(groups.stream(), "Cannot activate nonexistent Okta organization"); + Group group = groups.get(0); + return groupApi.listGroupUsers(group.getId(), null, null); + } + + private String activateUser(User user) { + if (user.getStatus() == UserStatus.PROVISIONED) { + // reactivates user and sends them an Okta email to reactivate their account + return userApi.reactivateUser(user.getId(), true).getActivationToken(); + } else if (user.getStatus() == UserStatus.STAGED) { + return userApi.activateUser(user.getId(), true).getActivationToken(); + } else { + throw new IllegalGraphqlArgumentException( + "Cannot activate Okta organization whose users have status=" + user.getStatus().name()); + } + } + + @Override + public void activateOrganization(Organization org) { + var users = getOrgAdminUsers(org); + for (User u : users) { + activateUser(u); + } + } + + @Override + public String activateOrganizationWithSingleUser(Organization org) { + User user = getOrgAdminUsers(org).get(0); + return activateUser(user); + } + + @Override + public List fetchAdminUserEmail(Organization org) { + var admins = getOrgAdminUsers(org); + return admins.stream().map(u -> u.getProfile().getLogin()).toList(); + } + + @Override + public void createFacility(Facility facility) { + // Only create the facility group if the facility's organization has already been created + String orgExternalId = facility.getOrganization().getExternalId(); + var orgGroups = + groupApi.listGroups( + generateGroupOrgPrefix(orgExternalId), null, null, null, null, null, null, null); + throwErrorIfEmpty( + orgGroups.stream(), + String.format( + "Cannot create Okta group for facility=%s: facility's org=%s, has not yet been created in Okta", + facility.getFacilityName(), facility.getOrganization().getExternalId())); + + String orgName = facility.getOrganization().getOrganizationName(); + String facilityGroupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); + Group g = + GroupBuilder.instance() + .setName(facilityGroupName) + .setDescription(generateFacilityGroupDescription(orgName, facility.getFacilityName())) + .buildAndCreate(groupApi); + applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); + + log.info("Created Okta group={}", facilityGroupName); + } + + public void deleteFacility(Facility facility) { + String orgExternalId = facility.getOrganization().getExternalId(); + String groupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); + var groups = groupApi.listGroups(groupName, null, null, null, null, null, null, null); + for (Group group : groups) { + groupApi.deleteGroup(group.getId()); + } + } + + @Override + public void deleteOrganization(Organization org) { + String externalId = org.getExternalId(); + var orgGroups = + groupApi.listGroups( + generateGroupOrgPrefix(externalId), null, null, null, null, null, null, null); + for (Group group : orgGroups) { + groupApi.deleteGroup(group.getId()); + } + } + + // returns the external ID of the organization the specified user belongs to + @Override + public Optional getOrganizationRoleClaimsForUser(String username) { + // When a site admin is using tenant data access, bypass okta and get org from the altered + // authorities. If the site admin is getting the claims for another site admin who also has + // active tenant data access, then reflect what is in Okta, not the temporary claims. + if (tenantDataContextHolder.hasBeenPopulated() + && username.equals(tenantDataContextHolder.getUsername())) { + return getOrganizationRoleClaimsFromAuthorities(tenantDataContextHolder.getAuthorities()); + } + + return getOrganizationRoleClaimsForUser( + getUserOrThrowError(username, "Cannot get org external ID for nonexistent user")); + } + + public Integer getUsersInSingleFacility(Facility facility) { + String facilityAccessGroupName = + generateFacilityGroupName( + facility.getOrganization().getExternalId(), facility.getInternalId()); + + List facilityAccessGroup = + groupApi.listGroups(facilityAccessGroupName, null, null, 1, "stats", null, null, null); + + if (facilityAccessGroup.isEmpty()) { + return 0; + } + + try { + LinkedHashMap stats = + (LinkedHashMap) facilityAccessGroup.get(0).getEmbedded().get("stats"); + return ((Integer) stats.get("usersCount")); + } catch (NullPointerException e) { + throw new BadRequestException("Unable to retrieve okta group stats", e); + } + } + + public PartialOktaUser findUser(String username) { + User user = + getUserOrThrowError( + username, "Cannot retrieve Okta user's status with unrecognized username"); + + List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); + + Optional orgClaims = convertToOrganizationRoleClaims(userGroups); + + return PartialOktaUser.builder() + .username(username) + .isSiteAdmin(isSiteAdmin(userGroups)) + .status(user.getStatus()) + .organizationRoleClaims(orgClaims) + .build(); + } + + public int getConnectTimeoutForHealthCheck() { + return applicationApi.getApiClient().getConnectTimeout(); + } + + private Optional getOrganizationRoleClaimsFromAuthorities( + Collection authorities) { + List claims = extractor.convertClaims(authorities); + + if (claims.size() != 1) { + log.warn("User's Tenant Data Access has claims in {} organizations, not 1", claims.size()); + return Optional.empty(); + } + return Optional.of(claims.get(0)); + } + + private Optional getOrganizationRoleClaimsForUser(User user) { + List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); + return convertToOrganizationRoleClaims(userGroups); + } + + private Optional convertToOrganizationRoleClaims(List userGroups) { + List groupNames = + userGroups.stream() + .filter(g -> g.getType() == GroupType.OKTA_GROUP) + .map(g -> g.getProfile().getName()) + .toList(); + List claims = extractor.convertClaims(groupNames); + + if (claims.size() != 1) { + log.warn("User is in {} Okta organizations, not 1", claims.size()); + return Optional.empty(); + } + return Optional.of(claims.get(0)); + } + + private boolean isSiteAdmin(List oktaGroups) { + return oktaGroups.stream() + .filter(g -> g.getType() == GroupType.OKTA_GROUP) + .anyMatch(g -> adminGroupName.equals(g.getProfile().getName())); + } + + private String generateGroupOrgPrefix(String orgExternalId) { + return String.format("%s%s", rolePrefix, orgExternalId); + } + + private String generateRoleGroupName(String orgExternalId, OrganizationRole role) { + return String.format("%s%s%s", rolePrefix, orgExternalId, generateRoleSuffix(role)); + } + + private String generateFacilityGroupName(String orgExternalId, UUID facilityId) { + return String.format( + "%s%s%s", rolePrefix, orgExternalId, generateFacilitySuffix(facilityId.toString())); + } + + private String generateRoleGroupDescription(String orgName, OrganizationRole role) { + return String.format("%s - %ss", orgName, role.getDescription()); + } + + private String generateFacilityGroupDescription(String orgName, String facilityName) { + return String.format("%s - Facility Access - %s", orgName, facilityName); + } + + private String generateRoleSuffix(OrganizationRole role) { + return ":" + role.name(); + } + + private String generateFacilitySuffix(String facilityId) { + return ":" + OrganizationExtractor.FACILITY_ACCESS_MARKER + ":" + facilityId; + } + + private User getUserOrThrowError(String email, String errorMessage) { + try { + return userApi.getUser(email); + } catch (ApiException e) { + throw new IllegalGraphqlArgumentException(errorMessage); + } + } + + private void throwErrorIfEmpty(Stream stream, String errorMessage) { + if (stream.findAny().isEmpty()) { + throw new IllegalGraphqlArgumentException(errorMessage); + } + } + + private String prettifyOktaError(ApiException e) { + var errorMessage = "Code: " + e.getCode() + "; Message: " + e.getMessage(); + if (e.getResponseBody() != null) { + Error error = ApiExceptionHelper.getError(e); + if (error != null) { + errorMessage = + "Okta Error: " + error.getErrorCode() + ", Error summary: " + error.getErrorSummary(); + if (error.getErrorCauses() != null) { + errorMessage += + ", Error Cause(s): " + + error.getErrorCauses().stream() + .map(ErrorErrorCausesInner::getErrorSummary) + .collect(Collectors.joining(", ")); + } + } } - } + return errorMessage; } - return errorMessage; - } } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java index 92cf4c1dfa..c03f2f674b 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java @@ -6,6 +6,7 @@ import gov.cdc.usds.simplereport.db.model.Facility; import gov.cdc.usds.simplereport.db.model.Organization; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; + import java.util.List; import java.util.Map; import java.util.Optional; @@ -18,62 +19,65 @@ */ public interface OktaRepository { - Optional createUser( - IdentityAttributes userIdentity, - Organization org, - Set facilities, - Set roles, - boolean active); + Optional createUser( + IdentityAttributes userIdentity, + Organization org, + Set facilities, + Set roles, + boolean active); + + Optional updateUser(IdentityAttributes userIdentity); + + Optional updateUserEmail(IdentityAttributes userIdentity, String email); - Optional updateUser(IdentityAttributes userIdentity); + void reprovisionUser(IdentityAttributes userIdentity); - Optional updateUserEmail(IdentityAttributes userIdentity, String email); + Optional updateUserPrivileges( + String username, Organization org, Set facilities, Set roles); - void reprovisionUser(IdentityAttributes userIdentity); + List updateUserPrivilegesAndGroupAccess( + String username, + Organization org, + Set facilities, + OrganizationRole roles, + boolean assignedToAllFacilities); - Optional updateUserPrivileges( - String username, Organization org, Set facilities, Set roles); + void resetUserPassword(String username); - List updateUserPrivilegesAndGroupAccess( - String username, - Organization org, - Set facilities, - OrganizationRole roles, - boolean assignedToAllFacilities); + void resetUserMfa(String username); - void resetUserPassword(String username); + void setUserIsActive(String username, boolean active); - void resetUserMfa(String username); + void reactivateUser(String username); - void setUserIsActive(String username, boolean active); + void resendActivationEmail(String username); - void reactivateUser(String username); + UserStatus getUserStatus(String username); - void resendActivationEmail(String username); + Set getAllUsersForOrganization(Organization org); - UserStatus getUserStatus(String username); + Map getAllUsersWithStatusForOrganization(Organization org); - Set getAllUsersForOrganization(Organization org); + void createOrganization(Organization org); - Map getAllUsersWithStatusForOrganization(Organization org); + void activateOrganization(Organization org); - void createOrganization(Organization org); + String activateOrganizationWithSingleUser(Organization org); - void activateOrganization(Organization org); + List fetchAdminUserEmail(Organization org); - String activateOrganizationWithSingleUser(Organization org); + void createFacility(Facility facility); - List fetchAdminUserEmail(Organization org); + void deleteOrganization(Organization org); - void createFacility(Facility facility); + void deleteFacility(Facility facility); - void deleteOrganization(Organization org); + Optional getOrganizationRoleClaimsForUser(String username); - void deleteFacility(Facility facility); + Integer getUsersInSingleFacility(Facility facility); - Optional getOrganizationRoleClaimsForUser(String username); + PartialOktaUser findUser(String username); - Integer getUsersInSingleFacility(Facility facility); + int getConnectTimeoutForHealthCheck(); - PartialOktaUser findUser(String username); } diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 8f792d8cc0..726ac9eaac 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -88,6 +88,7 @@ okta: client: org-url: https://hhs-prime.okta.com token: ${OKTA_API_KEY:MISSING} + group: smarty-streets: id: ${SMARTY_AUTH_ID} token: ${SMARTY_AUTH_TOKEN} diff --git a/frontend/deploy-smoke.js b/frontend/deploy-smoke.js index 8ac643ed1a..7c3a1be92e 100644 --- a/frontend/deploy-smoke.js +++ b/frontend/deploy-smoke.js @@ -8,7 +8,11 @@ require("dotenv").config(); let { Builder } = require("selenium-webdriver"); const Chrome = require("selenium-webdriver/chrome"); -console.log(`Running smoke test for ${process.env.REACT_APP_BASE_URL}`); +const appUrl = process.env.REACT_APP_BASE_URL.includes("localhost") + ? process.env.REACT_APP_BASE_URL + : `${process.env.REACT_APP_BASE_URL}/app`; + +console.log(`Running smoke test for ${appUrl}`); const options = new Chrome.Options(); const driver = new Builder() .forBrowser("chrome") @@ -16,7 +20,7 @@ const driver = new Builder() .build(); driver .navigate() - .to(`${process.env.REACT_APP_BASE_URL}app/health/deploy-smoke-test`) + .to(`${appUrl}/health/deploy-smoke-test`) .then(() => { let value = driver.findElement({ id: "root" }).getText(); return value; diff --git a/frontend/package.json b/frontend/package.json index 3f9d349b2c..d53b4f7f28 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -76,8 +76,8 @@ "build-storybook": "yarn create-storybook-public && REACT_APP_BACKEND_URL=http://localhost:8080 SASS_PATH=$(cd ./node_modules && pwd):$(cd ./node_modules/@uswds && pwd):$(cd ./node_modules/@uswds/uswds/packages && pwd):$(cd ./src/scss && pwd) storybook build -s storybook_public", "maintenance:start": "[ -z \"$MAINTENANCE_MESSAGE\" ] && echo \"MAINTENANCE_MESSAGE must be set!\" || (echo $MAINTENANCE_MESSAGE > maintenance.json && yarn maintenance:deploy && rm maintenance.json)", "maintenance:deploy": "[ -z \"$MAINTENANCE_ENV\" ] && echo \"MAINTENANCE_ENV must be set!\" || az storage blob upload -f maintenance.json -n maintenance.json -c '$web' --account-name simplereport${MAINTENANCE_ENV}app --overwrite", - "smoke:env:deploy": "node deploy-smoke.js", - "smoke:env:deploy:ci": "node -r dotenv/config deploy-smoke.js dotenv_config_path=.env.production.local dotenv_config_debug=true" + "smoke:deploy:local": "node -r dotenv/config deploy-smoke.js dotenv_config_path=.env.local", + "smoke:deploy:ci": "node -r dotenv/config deploy-smoke.js dotenv_config_path=.env.production.local" }, "prettier": { "singleQuote": false diff --git a/frontend/src/app/DeploySmokeTest.tsx b/frontend/src/app/DeploySmokeTest.tsx index d83a766b2c..e87fbbf1d3 100644 --- a/frontend/src/app/DeploySmokeTest.tsx +++ b/frontend/src/app/DeploySmokeTest.tsx @@ -7,9 +7,8 @@ const DeploySmokeTest = (): JSX.Element => { const [success, setSuccess] = useState(); useEffect(() => { api - .getRequest("/actuator/health/prod-smoke-test") + .getRequest("/actuator/health/backend-and-db-smoke-test") .then((response) => { - console.log(response); const status = JSON.parse(response); if (status.status === "UP") return setSuccess(true); // log something using app insights From c640d3e746ab43175da34c096e8e76ead274a68a Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 10:40:46 -0500 Subject: [PATCH 41/59] lint --- .../BackendAndDatabaseHealthIndicator.java | 33 +- .../idp/repository/DemoOktaRepository.java | 720 ++++---- .../idp/repository/LiveOktaRepository.java | 1469 ++++++++--------- .../idp/repository/OktaRepository.java | 74 +- 4 files changed, 1142 insertions(+), 1154 deletions(-) diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java index 22badc2d6d..71fbda9587 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -1,14 +1,11 @@ package gov.cdc.usds.simplereport.api.heathcheck; -import com.okta.sdk.resource.api.GroupApi; import com.okta.sdk.resource.client.ApiException; import gov.cdc.usds.simplereport.db.repository.FeatureFlagRepository; -import gov.cdc.usds.simplereport.idp.repository.LiveOktaRepository; import gov.cdc.usds.simplereport.idp.repository.OktaRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.JDBCConnectionException; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.stereotype.Component; @@ -17,22 +14,22 @@ @Slf4j @RequiredArgsConstructor public class BackendAndDatabaseHealthIndicator implements HealthIndicator { - private final FeatureFlagRepository _ffRepo; - private final OktaRepository _oktaRepo; + private final FeatureFlagRepository _ffRepo; + private final OktaRepository _oktaRepo; - @Override - public Health health() { - try { - _ffRepo.findAll(); - _oktaRepo.getConnectTimeoutForHealthCheck(); + @Override + public Health health() { + try { + _ffRepo.findAll(); + _oktaRepo.getConnectTimeoutForHealthCheck(); - return Health.up().build(); - } catch (JDBCConnectionException e) { - return Health.down().build(); - // Okta API call errored - } catch (ApiException e) { - log.info(e.getMessage()); - return Health.down().build(); - } + return Health.up().build(); + } catch (JDBCConnectionException e) { + return Health.down().build(); + // Okta API call errored + } catch (ApiException e) { + log.info(e.getMessage()); + return Health.down().build(); } + } } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java index 7baf653582..0bf9ec5c08 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java @@ -13,7 +13,6 @@ import gov.cdc.usds.simplereport.db.model.Facility; import gov.cdc.usds.simplereport.db.model.Organization; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; - import java.util.Collection; import java.util.EnumSet; import java.util.HashMap; @@ -25,7 +24,6 @@ import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; - import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.support.ScopeNotActiveException; @@ -34,410 +32,408 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; -/** - * Handles all user/organization management in Okta - */ +/** Handles all user/organization management in Okta */ @Profile(BeanProfiles.NO_OKTA_MGMT) @Service @Slf4j public class DemoOktaRepository implements OktaRepository { - @Value("${simple-report.authorization.environment-name:DEV}") - private String environment; - - private final OrganizationExtractor organizationExtractor; - private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; - - Map usernameOrgRolesMap; - Map> orgUsernamesMap; - Map> orgFacilitiesMap; - Set inactiveUsernames; - Set allUsernames; - private final Set adminGroupMemberSet; - - public DemoOktaRepository( - OrganizationExtractor extractor, - CurrentTenantDataAccessContextHolder contextHolder, - DemoUserConfiguration demoUserConfiguration) { - this.usernameOrgRolesMap = new HashMap<>(); - this.orgUsernamesMap = new HashMap<>(); - this.orgFacilitiesMap = new HashMap<>(); - this.inactiveUsernames = new HashSet<>(); - this.allUsernames = new HashSet<>(); - - this.organizationExtractor = extractor; - this.tenantDataContextHolder = contextHolder; - this.adminGroupMemberSet = - demoUserConfiguration.getSiteAdminEmails().stream().collect(Collectors.toUnmodifiableSet()); - - log.info("Done initializing Demo Okta repository."); + @Value("${simple-report.authorization.environment-name:DEV}") + private String environment; + + private final OrganizationExtractor organizationExtractor; + private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; + + Map usernameOrgRolesMap; + Map> orgUsernamesMap; + Map> orgFacilitiesMap; + Set inactiveUsernames; + Set allUsernames; + private final Set adminGroupMemberSet; + + public DemoOktaRepository( + OrganizationExtractor extractor, + CurrentTenantDataAccessContextHolder contextHolder, + DemoUserConfiguration demoUserConfiguration) { + this.usernameOrgRolesMap = new HashMap<>(); + this.orgUsernamesMap = new HashMap<>(); + this.orgFacilitiesMap = new HashMap<>(); + this.inactiveUsernames = new HashSet<>(); + this.allUsernames = new HashSet<>(); + + this.organizationExtractor = extractor; + this.tenantDataContextHolder = contextHolder; + this.adminGroupMemberSet = + demoUserConfiguration.getSiteAdminEmails().stream().collect(Collectors.toUnmodifiableSet()); + + log.info("Done initializing Demo Okta repository."); + } + + public Optional createUser( + IdentityAttributes userIdentity, + Organization org, + Set facilities, + Set roles, + boolean active) { + if (allUsernames.contains(userIdentity.getUsername())) { + throw new ConflictingUserException(); } - public Optional createUser( - IdentityAttributes userIdentity, - Organization org, - Set facilities, - Set roles, - boolean active) { - if (allUsernames.contains(userIdentity.getUsername())) { - throw new ConflictingUserException(); - } - - String organizationExternalId = org.getExternalId(); - Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); - rolesToCreate.addAll(roles); - Set facilityUUIDs = - PermissionHolder.grantsAllFacilityAccess(rolesToCreate) - // create an empty set of facilities if user can access all facilities anyway - ? Set.of() - : facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()); - if (!orgFacilitiesMap.containsKey(organizationExternalId)) { - throw new IllegalGraphqlArgumentException( - "Cannot add Okta user to nonexistent organization=" + organizationExternalId); - } else if (!orgFacilitiesMap.get(organizationExternalId).containsAll(facilityUUIDs)) { - throw new IllegalGraphqlArgumentException( - "Cannot add Okta user to one or more nonexistent facilities in facilities_set=" - + facilities.stream().map(f -> f.getFacilityName()).collect(Collectors.toSet()) - + " in organization=" - + organizationExternalId); - } - - OrganizationRoleClaims orgRoles = - new OrganizationRoleClaims(organizationExternalId, facilityUUIDs, rolesToCreate); - usernameOrgRolesMap.put(userIdentity.getUsername(), orgRoles); - allUsernames.add(userIdentity.getUsername()); - - orgUsernamesMap.get(organizationExternalId).add(userIdentity.getUsername()); - - if (!active) { - inactiveUsernames.add(userIdentity.getUsername()); - } - - return Optional.of(orgRoles); + String organizationExternalId = org.getExternalId(); + Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); + rolesToCreate.addAll(roles); + Set facilityUUIDs = + PermissionHolder.grantsAllFacilityAccess(rolesToCreate) + // create an empty set of facilities if user can access all facilities anyway + ? Set.of() + : facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()); + if (!orgFacilitiesMap.containsKey(organizationExternalId)) { + throw new IllegalGraphqlArgumentException( + "Cannot add Okta user to nonexistent organization=" + organizationExternalId); + } else if (!orgFacilitiesMap.get(organizationExternalId).containsAll(facilityUUIDs)) { + throw new IllegalGraphqlArgumentException( + "Cannot add Okta user to one or more nonexistent facilities in facilities_set=" + + facilities.stream().map(f -> f.getFacilityName()).collect(Collectors.toSet()) + + " in organization=" + + organizationExternalId); } - // this method currently doesn't do much in a demo envt - public Optional updateUser(IdentityAttributes userIdentity) { - if (!usernameOrgRolesMap.containsKey(userIdentity.getUsername())) { - throw new IllegalGraphqlArgumentException( - "Cannot change name of Okta user with unrecognized username"); - } - OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(userIdentity.getUsername()); - return Optional.of(orgRoles); - } + OrganizationRoleClaims orgRoles = + new OrganizationRoleClaims(organizationExternalId, facilityUUIDs, rolesToCreate); + usernameOrgRolesMap.put(userIdentity.getUsername(), orgRoles); + allUsernames.add(userIdentity.getUsername()); - public Optional updateUserEmail( - IdentityAttributes userIdentity, String newEmail) { - String currentEmail = userIdentity.getUsername(); - if (!usernameOrgRolesMap.containsKey(currentEmail)) { - throw new IllegalGraphqlArgumentException( - "Cannot change email of Okta user with unrecognized username"); - } - - if (usernameOrgRolesMap.containsKey(newEmail)) { - throw new ConflictingUserException(); - } - - String org = usernameOrgRolesMap.get(userIdentity.getUsername()).getOrganizationExternalId(); - orgUsernamesMap.get(org).remove(currentEmail); - orgUsernamesMap.get(org).add(newEmail); - usernameOrgRolesMap.put(newEmail, usernameOrgRolesMap.remove(userIdentity.getUsername())); - OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(newEmail); - return Optional.of(orgRoles); - } + orgUsernamesMap.get(organizationExternalId).add(userIdentity.getUsername()); - public void reprovisionUser(IdentityAttributes userIdentity) { - final String username = userIdentity.getUsername(); - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reprovision Okta user with unrecognized username"); - } - if (!inactiveUsernames.contains(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reprovision user in unsupported state: (not deleted)"); - } - - // Only re-enable the user. If name attributes and credentials were supported here, then - // the name should be updated and credentials reset. - inactiveUsernames.remove(userIdentity.getUsername()); + if (!active) { + inactiveUsernames.add(userIdentity.getUsername()); } - public Optional updateUserPrivileges( - String username, Organization org, Set facilities, Set roles) { - String orgId = org.getExternalId(); - if (!orgUsernamesMap.containsKey(orgId)) { - throw new IllegalGraphqlArgumentException( - "Cannot update Okta user privileges for nonexistent organization."); - } - if (!orgUsernamesMap.get(orgId).contains(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot update Okta user privileges for organization they are not in."); - } - Set newRoles = EnumSet.of(OrganizationRole.getDefault()); - newRoles.addAll(roles); - Set facilityUUIDs = - facilities.stream() - // create an empty set of facilities if user can access all facilities anyway - .filter(f -> !PermissionHolder.grantsAllFacilityAccess(newRoles)) - .map(f -> f.getInternalId()) - .collect(Collectors.toSet()); - OrganizationRoleClaims newRoleClaims = - new OrganizationRoleClaims(orgId, facilityUUIDs, newRoles); - usernameOrgRolesMap.put(username, newRoleClaims); - - return Optional.of(newRoleClaims); - } + return Optional.of(orgRoles); + } - @Override - public List updateUserPrivilegesAndGroupAccess( - String username, - Organization org, - Set facilities, - OrganizationRole roles, - boolean allFacilitiesAccess) { - - String oldOrgId = usernameOrgRolesMap.get(username).getOrganizationExternalId(); - orgUsernamesMap.get(oldOrgId).remove(username); - orgUsernamesMap.get(org.getExternalId()).add(username); - OrganizationRoleClaims newRoleClaims = - new OrganizationRoleClaims( - org.getExternalId(), - facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()), - Set.of(roles, OrganizationRole.getDefault())); - - usernameOrgRolesMap.replace(username, newRoleClaims); - - // Live Okta repository returns list of Group names, but our demo repo didn't implement - // group mappings and it didn't feel worth it to add that implementation since the return is - // used mostly for testing. Return the list of facility ID's in the new org instead - return orgFacilitiesMap.get(org.getExternalId()).stream().map(UUID::toString).toList(); + // this method currently doesn't do much in a demo envt + public Optional updateUser(IdentityAttributes userIdentity) { + if (!usernameOrgRolesMap.containsKey(userIdentity.getUsername())) { + throw new IllegalGraphqlArgumentException( + "Cannot change name of Okta user with unrecognized username"); } - - public void resetUserPassword(String username) { - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reset password for Okta user with unrecognized username"); - } + OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(userIdentity.getUsername()); + return Optional.of(orgRoles); + } + + public Optional updateUserEmail( + IdentityAttributes userIdentity, String newEmail) { + String currentEmail = userIdentity.getUsername(); + if (!usernameOrgRolesMap.containsKey(currentEmail)) { + throw new IllegalGraphqlArgumentException( + "Cannot change email of Okta user with unrecognized username"); } - public void resetUserMfa(String username) { - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reset MFA for Okta user with unrecognized username"); - } + if (usernameOrgRolesMap.containsKey(newEmail)) { + throw new ConflictingUserException(); } - public void setUserIsActive(String username, boolean active) { - if (active) { - inactiveUsernames.remove(username); - } else { - inactiveUsernames.add(username); - } + String org = usernameOrgRolesMap.get(userIdentity.getUsername()).getOrganizationExternalId(); + orgUsernamesMap.get(org).remove(currentEmail); + orgUsernamesMap.get(org).add(newEmail); + usernameOrgRolesMap.put(newEmail, usernameOrgRolesMap.remove(userIdentity.getUsername())); + OrganizationRoleClaims orgRoles = usernameOrgRolesMap.get(newEmail); + return Optional.of(orgRoles); + } + + public void reprovisionUser(IdentityAttributes userIdentity) { + final String username = userIdentity.getUsername(); + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reprovision Okta user with unrecognized username"); } - - public UserStatus getUserStatus(String username) { - if (inactiveUsernames.contains(username)) { - return UserStatus.SUSPENDED; - } else { - return UserStatus.ACTIVE; - } + if (!inactiveUsernames.contains(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reprovision user in unsupported state: (not deleted)"); } - public void reactivateUser(String username) { - if (inactiveUsernames.contains(username)) { - inactiveUsernames.remove(username); - } + // Only re-enable the user. If name attributes and credentials were supported here, then + // the name should be updated and credentials reset. + inactiveUsernames.remove(userIdentity.getUsername()); + } + + public Optional updateUserPrivileges( + String username, Organization org, Set facilities, Set roles) { + String orgId = org.getExternalId(); + if (!orgUsernamesMap.containsKey(orgId)) { + throw new IllegalGraphqlArgumentException( + "Cannot update Okta user privileges for nonexistent organization."); } - - public void resendActivationEmail(String username) { - if (!usernameOrgRolesMap.containsKey(username)) { - throw new IllegalGraphqlArgumentException( - "Cannot reset password for Okta user with unrecognized username"); - } + if (!orgUsernamesMap.get(orgId).contains(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot update Okta user privileges for organization they are not in."); } - - // returns ALL users including inactive ones - public Set getAllUsersForOrganization(Organization org) { - if (!orgUsernamesMap.containsKey(org.getExternalId())) { - throw new IllegalGraphqlArgumentException( - "Cannot get Okta users from nonexistent organization."); - } - return orgUsernamesMap.get(org.getExternalId()).stream() - .collect(Collectors.toUnmodifiableSet()); + Set newRoles = EnumSet.of(OrganizationRole.getDefault()); + newRoles.addAll(roles); + Set facilityUUIDs = + facilities.stream() + // create an empty set of facilities if user can access all facilities anyway + .filter(f -> !PermissionHolder.grantsAllFacilityAccess(newRoles)) + .map(f -> f.getInternalId()) + .collect(Collectors.toSet()); + OrganizationRoleClaims newRoleClaims = + new OrganizationRoleClaims(orgId, facilityUUIDs, newRoles); + usernameOrgRolesMap.put(username, newRoleClaims); + + return Optional.of(newRoleClaims); + } + + @Override + public List updateUserPrivilegesAndGroupAccess( + String username, + Organization org, + Set facilities, + OrganizationRole roles, + boolean allFacilitiesAccess) { + + String oldOrgId = usernameOrgRolesMap.get(username).getOrganizationExternalId(); + orgUsernamesMap.get(oldOrgId).remove(username); + orgUsernamesMap.get(org.getExternalId()).add(username); + OrganizationRoleClaims newRoleClaims = + new OrganizationRoleClaims( + org.getExternalId(), + facilities.stream().map(Facility::getInternalId).collect(Collectors.toSet()), + Set.of(roles, OrganizationRole.getDefault())); + + usernameOrgRolesMap.replace(username, newRoleClaims); + + // Live Okta repository returns list of Group names, but our demo repo didn't implement + // group mappings and it didn't feel worth it to add that implementation since the return is + // used mostly for testing. Return the list of facility ID's in the new org instead + return orgFacilitiesMap.get(org.getExternalId()).stream().map(UUID::toString).toList(); + } + + public void resetUserPassword(String username) { + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reset password for Okta user with unrecognized username"); } + } - public Map getAllUsersWithStatusForOrganization(Organization org) { - if (!orgUsernamesMap.containsKey(org.getExternalId())) { - throw new IllegalGraphqlArgumentException( - "Cannot get Okta users from nonexistent organization."); - } - return orgUsernamesMap.get(org.getExternalId()).stream() - .collect(Collectors.toMap(u -> u, u -> getUserStatus(u))); + public void resetUserMfa(String username) { + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reset MFA for Okta user with unrecognized username"); } + } - // this method doesn't mean much in a demo env - public void createOrganization(Organization org) { - String externalId = org.getExternalId(); - orgUsernamesMap.putIfAbsent(externalId, new HashSet<>()); - orgFacilitiesMap.putIfAbsent(externalId, new HashSet<>()); + public void setUserIsActive(String username, boolean active) { + if (active) { + inactiveUsernames.remove(username); + } else { + inactiveUsernames.add(username); } + } - // this method means nothing in a demo env - public void activateOrganization(Organization org) { - inactiveUsernames.removeAll(orgUsernamesMap.get(org.getExternalId())); + public UserStatus getUserStatus(String username) { + if (inactiveUsernames.contains(username)) { + return UserStatus.SUSPENDED; + } else { + return UserStatus.ACTIVE; } + } - // this method means nothing in a demo env - public String activateOrganizationWithSingleUser(Organization org) { - activateOrganization(org); - return "activationToken"; + public void reactivateUser(String username) { + if (inactiveUsernames.contains(username)) { + inactiveUsernames.remove(username); } + } - public List fetchAdminUserEmail(Organization org) { - Set> admins = - usernameOrgRolesMap.entrySet().stream() - .filter(e -> e.getValue().getGrantedRoles().contains(OrganizationRole.ADMIN)) - .collect(Collectors.toSet()); - return admins.stream().map(Entry::getKey).collect(Collectors.toList()); + public void resendActivationEmail(String username) { + if (!usernameOrgRolesMap.containsKey(username)) { + throw new IllegalGraphqlArgumentException( + "Cannot reset password for Okta user with unrecognized username"); } + } - public void createFacility(Facility facility) { - String orgExternalId = facility.getOrganization().getExternalId(); - if (!orgFacilitiesMap.containsKey(orgExternalId)) { - throw new IllegalGraphqlArgumentException( - "Cannot create Okta facility in nonexistent organization."); - } - orgFacilitiesMap.get(orgExternalId).add(facility.getInternalId()); + // returns ALL users including inactive ones + public Set getAllUsersForOrganization(Organization org) { + if (!orgUsernamesMap.containsKey(org.getExternalId())) { + throw new IllegalGraphqlArgumentException( + "Cannot get Okta users from nonexistent organization."); } - - public void deleteOrganization(Organization org) { - String externalId = org.getExternalId(); - orgUsernamesMap.remove(externalId); - orgFacilitiesMap.remove(externalId); - // remove all users from this map whose org roles are in the deleted org - usernameOrgRolesMap = - usernameOrgRolesMap.entrySet().stream() - .filter(e -> !(e.getValue().getOrganizationExternalId().equals(externalId))) - .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())); + return orgUsernamesMap.get(org.getExternalId()).stream() + .collect(Collectors.toUnmodifiableSet()); + } + + public Map getAllUsersWithStatusForOrganization(Organization org) { + if (!orgUsernamesMap.containsKey(org.getExternalId())) { + throw new IllegalGraphqlArgumentException( + "Cannot get Okta users from nonexistent organization."); } - - public void deleteFacility(Facility facility) { - String orgExternalId = facility.getOrganization().getExternalId(); - if (!orgFacilitiesMap.containsKey(orgExternalId)) { - throw new IllegalGraphqlArgumentException( - "Cannot delete Okta facility from nonexistent organization."); - } - orgFacilitiesMap.get(orgExternalId).remove(facility.getInternalId()); - // remove this facility from every user's OrganizationRoleClaims, as necessary - usernameOrgRolesMap = - usernameOrgRolesMap.entrySet().stream() - .collect( - Collectors.toMap( - e -> e.getKey(), - e -> { - OrganizationRoleClaims oldRoleClaims = e.getValue(); - Set newFacilities = - oldRoleClaims.getFacilities().stream() - .filter(f -> !f.equals(facility.getInternalId())) - .collect(Collectors.toSet()); - return new OrganizationRoleClaims( - orgExternalId, newFacilities, oldRoleClaims.getGrantedRoles()); - })); + return orgUsernamesMap.get(org.getExternalId()).stream() + .collect(Collectors.toMap(u -> u, u -> getUserStatus(u))); + } + + // this method doesn't mean much in a demo env + public void createOrganization(Organization org) { + String externalId = org.getExternalId(); + orgUsernamesMap.putIfAbsent(externalId, new HashSet<>()); + orgFacilitiesMap.putIfAbsent(externalId, new HashSet<>()); + } + + // this method means nothing in a demo env + public void activateOrganization(Organization org) { + inactiveUsernames.removeAll(orgUsernamesMap.get(org.getExternalId())); + } + + // this method means nothing in a demo env + public String activateOrganizationWithSingleUser(Organization org) { + activateOrganization(org); + return "activationToken"; + } + + public List fetchAdminUserEmail(Organization org) { + Set> admins = + usernameOrgRolesMap.entrySet().stream() + .filter(e -> e.getValue().getGrantedRoles().contains(OrganizationRole.ADMIN)) + .collect(Collectors.toSet()); + return admins.stream().map(Entry::getKey).collect(Collectors.toList()); + } + + public void createFacility(Facility facility) { + String orgExternalId = facility.getOrganization().getExternalId(); + if (!orgFacilitiesMap.containsKey(orgExternalId)) { + throw new IllegalGraphqlArgumentException( + "Cannot create Okta facility in nonexistent organization."); } - - private Optional getOrganizationRoleClaimsFromTenantDataAccess( - Collection groupNames) { - List claims = organizationExtractor.convertClaims(groupNames); - - if (claims.size() != 1) { - log.warn("User is in {} Okta organizations, not 1", claims.size()); - return Optional.empty(); - } - return Optional.of(claims.get(0)); + orgFacilitiesMap.get(orgExternalId).add(facility.getInternalId()); + } + + public void deleteOrganization(Organization org) { + String externalId = org.getExternalId(); + orgUsernamesMap.remove(externalId); + orgFacilitiesMap.remove(externalId); + // remove all users from this map whose org roles are in the deleted org + usernameOrgRolesMap = + usernameOrgRolesMap.entrySet().stream() + .filter(e -> !(e.getValue().getOrganizationExternalId().equals(externalId))) + .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())); + } + + public void deleteFacility(Facility facility) { + String orgExternalId = facility.getOrganization().getExternalId(); + if (!orgFacilitiesMap.containsKey(orgExternalId)) { + throw new IllegalGraphqlArgumentException( + "Cannot delete Okta facility from nonexistent organization."); } - - public Optional getOrganizationRoleClaimsForUser(String username) { - // when accessing tenant data, bypass okta and get org from the altered authorities - try { - if (tenantDataContextHolder.hasBeenPopulated() - && username.equals(tenantDataContextHolder.getUsername())) { - return getOrganizationRoleClaimsFromTenantDataAccess( - tenantDataContextHolder.getAuthorities()); - } - return Optional.ofNullable(usernameOrgRolesMap.get(username)); - } catch (ScopeNotActiveException e) { - // Tests are set up with a full SecurityContextHolder and should not rely on - // usernameOrgRolesMap as the source of truth. - if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { - return Optional.of(usernameOrgRolesMap.get(username)); - } - Set authorities = - SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.toSet()); - return getOrganizationRoleClaimsFromTenantDataAccess(authorities); - } + orgFacilitiesMap.get(orgExternalId).remove(facility.getInternalId()); + // remove this facility from every user's OrganizationRoleClaims, as necessary + usernameOrgRolesMap = + usernameOrgRolesMap.entrySet().stream() + .collect( + Collectors.toMap( + e -> e.getKey(), + e -> { + OrganizationRoleClaims oldRoleClaims = e.getValue(); + Set newFacilities = + oldRoleClaims.getFacilities().stream() + .filter(f -> !f.equals(facility.getInternalId())) + .collect(Collectors.toSet()); + return new OrganizationRoleClaims( + orgExternalId, newFacilities, oldRoleClaims.getGrantedRoles()); + })); + } + + private Optional getOrganizationRoleClaimsFromTenantDataAccess( + Collection groupNames) { + List claims = organizationExtractor.convertClaims(groupNames); + + if (claims.size() != 1) { + log.warn("User is in {} Okta organizations, not 1", claims.size()); + return Optional.empty(); } - - public PartialOktaUser findUser(String username) { - UserStatus status = - inactiveUsernames.contains(username) ? UserStatus.SUSPENDED : UserStatus.ACTIVE; - boolean isAdmin = adminGroupMemberSet.contains(username); - - Optional orgClaims; - - try { - orgClaims = Optional.ofNullable(usernameOrgRolesMap.get(username)); - } catch (ScopeNotActiveException e) { - // Tests are set up with a full SecurityContextHolder and should not rely on - // usernameOrgRolesMap as the source of truth. - if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { - orgClaims = Optional.of(usernameOrgRolesMap.get(username)); - } else { - Set authorities = - SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.toSet()); - orgClaims = getOrganizationRoleClaimsFromTenantDataAccess(authorities); - } - } - - return PartialOktaUser.builder() - .isSiteAdmin(isAdmin) - .status(status) - .username(username) - .organizationRoleClaims(orgClaims) - .build(); + return Optional.of(claims.get(0)); + } + + public Optional getOrganizationRoleClaimsForUser(String username) { + // when accessing tenant data, bypass okta and get org from the altered authorities + try { + if (tenantDataContextHolder.hasBeenPopulated() + && username.equals(tenantDataContextHolder.getUsername())) { + return getOrganizationRoleClaimsFromTenantDataAccess( + tenantDataContextHolder.getAuthorities()); + } + return Optional.ofNullable(usernameOrgRolesMap.get(username)); + } catch (ScopeNotActiveException e) { + // Tests are set up with a full SecurityContextHolder and should not rely on + // usernameOrgRolesMap as the source of truth. + if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { + return Optional.of(usernameOrgRolesMap.get(username)); + } + Set authorities = + SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + return getOrganizationRoleClaimsFromTenantDataAccess(authorities); } - - public void reset() { - usernameOrgRolesMap.clear(); - orgUsernamesMap.clear(); - orgFacilitiesMap.clear(); - inactiveUsernames.clear(); - allUsernames.clear(); + } + + public PartialOktaUser findUser(String username) { + UserStatus status = + inactiveUsernames.contains(username) ? UserStatus.SUSPENDED : UserStatus.ACTIVE; + boolean isAdmin = adminGroupMemberSet.contains(username); + + Optional orgClaims; + + try { + orgClaims = Optional.ofNullable(usernameOrgRolesMap.get(username)); + } catch (ScopeNotActiveException e) { + // Tests are set up with a full SecurityContextHolder and should not rely on + // usernameOrgRolesMap as the source of truth. + if (!("UNITTEST".equals(environment)) && usernameOrgRolesMap.containsKey(username)) { + orgClaims = Optional.of(usernameOrgRolesMap.get(username)); + } else { + Set authorities = + SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + orgClaims = getOrganizationRoleClaimsFromTenantDataAccess(authorities); + } } - public Integer getUsersInSingleFacility(Facility facility) { - Integer accessCount = 0; - - for (OrganizationRoleClaims existingClaims : usernameOrgRolesMap.values()) { - boolean hasAllFacilityAccess = - existingClaims.getGrantedRoles().stream() - .anyMatch(role -> OrganizationRole.ALL_FACILITIES.getName().equals(role.name())); - boolean hasSpecificFacilityAccess = - existingClaims.getFacilities().stream() - .anyMatch(facilityAccessId -> facility.getInternalId().equals(facilityAccessId)); - if (!hasAllFacilityAccess && hasSpecificFacilityAccess) { - accessCount++; - } - } - - return accessCount; + return PartialOktaUser.builder() + .isSiteAdmin(isAdmin) + .status(status) + .username(username) + .organizationRoleClaims(orgClaims) + .build(); + } + + public void reset() { + usernameOrgRolesMap.clear(); + orgUsernamesMap.clear(); + orgFacilitiesMap.clear(); + inactiveUsernames.clear(); + allUsernames.clear(); + } + + public Integer getUsersInSingleFacility(Facility facility) { + Integer accessCount = 0; + + for (OrganizationRoleClaims existingClaims : usernameOrgRolesMap.values()) { + boolean hasAllFacilityAccess = + existingClaims.getGrantedRoles().stream() + .anyMatch(role -> OrganizationRole.ALL_FACILITIES.getName().equals(role.name())); + boolean hasSpecificFacilityAccess = + existingClaims.getFacilities().stream() + .anyMatch(facilityAccessId -> facility.getInternalId().equals(facilityAccessId)); + if (!hasAllFacilityAccess && hasSpecificFacilityAccess) { + accessCount++; + } } - public int getConnectTimeoutForHealthCheck() { - int FAKE_CONNECTION_TIMEOUT = 0; - return FAKE_CONNECTION_TIMEOUT; - } + return accessCount; + } + + public int getConnectTimeoutForHealthCheck() { + int FAKE_CONNECTION_TIMEOUT = 0; + return FAKE_CONNECTION_TIMEOUT; + } } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java index f1a5157353..7704a85786 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java @@ -5,7 +5,6 @@ import com.okta.sdk.resource.api.ApplicationGroupsApi; import com.okta.sdk.resource.api.GroupApi; import com.okta.sdk.resource.api.UserApi; -import com.okta.sdk.resource.client.ApiClient; import com.okta.sdk.resource.client.ApiException; import com.okta.sdk.resource.common.PagedList; import com.okta.sdk.resource.group.GroupBuilder; @@ -33,7 +32,6 @@ import gov.cdc.usds.simplereport.db.model.Facility; import gov.cdc.usds.simplereport.db.model.Organization; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; - import java.util.ArrayList; import java.util.Collection; import java.util.EnumSet; @@ -47,7 +45,6 @@ import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; - import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; @@ -64,740 +61,740 @@ @Slf4j public class LiveOktaRepository implements OktaRepository { - private static final String OKTA_GROUP_NOT_FOUND = "Okta group not found for this organization"; - - private final String rolePrefix; - private final Application app; - private final OrganizationExtractor extractor; - private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; - - private final ApplicationApi applicationApi; - private final GroupApi groupApi; - private final UserApi userApi; - private final ApplicationGroupsApi applicationGroupsApi; - private final String adminGroupName; - - private static final String OKTA_ORG_PROFILE_MATCHER = "profile.name sw \""; - private static final int OKTA_PAGE_SIZE = 500; - - public LiveOktaRepository( - AuthorizationProperties authorizationProperties, - @Value("${okta.oauth2.client-id}") String oktaOAuth2ClientId, - OrganizationExtractor organizationExtractor, - CurrentTenantDataAccessContextHolder tenantDataContextHolder, - GroupApi groupApi, - ApplicationApi applicationApi, - UserApi userApi, - ApplicationGroupsApi applicationGroupsApi) { - this.rolePrefix = authorizationProperties.getRolePrefix(); - this.adminGroupName = authorizationProperties.getAdminGroupName(); - - this.applicationApi = applicationApi; - this.groupApi = groupApi; - this.userApi = userApi; - this.applicationGroupsApi = applicationGroupsApi; - - try { - this.app = applicationApi.getApplication(oktaOAuth2ClientId, null); - } catch (ApiException e) { - throw new MisconfiguredApplicationException( - "Cannot find Okta application with id=" + oktaOAuth2ClientId, e); - } - - this.extractor = organizationExtractor; - this.tenantDataContextHolder = tenantDataContextHolder; - } - - @Override - public Optional createUser( - IdentityAttributes userIdentity, - Organization org, - Set facilities, - Set roles, - boolean active) { - // By default, when creating a user, we give them privileges of a standard user - String organizationExternalId = org.getExternalId(); - Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); - rolesToCreate.addAll(roles); - - // Add user to new groups - Set groupNamesToAdd = new HashSet<>(); - groupNamesToAdd.addAll( - rolesToCreate.stream() - .map(r -> generateRoleGroupName(organizationExternalId, r)) - .collect(Collectors.toSet())); - groupNamesToAdd.addAll( - facilities.stream() - // use an empty set of facilities if user can access all facilities anyway - .filter(f -> !PermissionHolder.grantsAllFacilityAccess(rolesToCreate)) - .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) - .collect(Collectors.toSet())); - - // Search and q results need to be combined because search results have a delay of the newest - // added groups. - // https://github.com/okta/okta-sdk-java/issues/750 - var searchResults = - groupApi - .listGroups( - null, - null, - null, - null, - null, - OKTA_ORG_PROFILE_MATCHER + generateGroupOrgPrefix(organizationExternalId) + "\"", - null, - null) - .stream(); - var qResults = - groupApi - .listGroups( - generateGroupOrgPrefix(organizationExternalId), - null, - null, - null, - null, - null, - null, - null) - .stream(); - var orgGroups = Stream.concat(searchResults, qResults).distinct().toList(); - throwErrorIfEmpty( - orgGroups.stream(), - String.format( - "Cannot add Okta user to nonexistent organization=%s", organizationExternalId)); - Set orgGroupNames = - orgGroups.stream().map(g -> g.getProfile().getName()).collect(Collectors.toSet()); - groupNamesToAdd.stream() - .filter(n -> !orgGroupNames.contains(n)) - .forEach( - n -> { - throw new IllegalGraphqlArgumentException( - String.format("Cannot add Okta user to nonexistent group=%s", n)); - }); - Set groupIdsToAdd = - orgGroups.stream() - .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) - .map(Group::getId) - .collect(Collectors.toSet()); - validateRequiredFields(userIdentity); - try { - var user = - UserBuilder.instance() - .setFirstName(userIdentity.getFirstName()) - .setMiddleName(userIdentity.getMiddleName()) - .setLastName(userIdentity.getLastName()) - .setHonorificSuffix(userIdentity.getSuffix()) - .setEmail(userIdentity.getUsername()) - .setLogin(userIdentity.getUsername()) - .setActive(active) - .buildAndCreate(userApi); - groupIdsToAdd.forEach(groupId -> groupApi.assignUserToGroup(groupId, user.getId())); - } catch (ApiException e) { - if (e.getMessage() - .contains("An object with this field already exists in the current organization")) { - throw new ConflictingUserException(); - } else { - throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); - } - } - - List claims = extractor.convertClaims(groupNamesToAdd); - if (claims.size() != 1) { - log.warn("User is in {} Okta organizations, not 1", claims.size()); - return Optional.empty(); - } - return Optional.of(claims.get(0)); - } - - private static void validateRequiredFields(IdentityAttributes userIdentity) { - if (StringUtils.isBlank(userIdentity.getLastName())) { - throw new IllegalGraphqlArgumentException("Cannot create Okta user without last name"); - } - if (StringUtils.isBlank(userIdentity.getUsername())) { - throw new IllegalGraphqlArgumentException("Cannot create Okta user without username"); - } - } - - @Override - public Set getAllUsersForOrganization(Organization org) { - return getAllUsersForOrg(org).stream() - .map(u -> u.getProfile().getLogin()) - .collect(Collectors.toUnmodifiableSet()); - } - - @Override - public Map getAllUsersWithStatusForOrganization(Organization org) { - return getAllUsersForOrg(org).stream() - .collect(Collectors.toMap(u -> u.getProfile().getLogin(), User::getStatus)); - } - - private List getAllUsersForOrg(Organization org) { - PagedList pagedUserList = new PagedList<>(); - List allUsers = new ArrayList<>(); - Group orgDefaultOktaGroup = getDefaultOktaGroup(org); - do { - pagedUserList = - (PagedList) - groupApi.listGroupUsers( - orgDefaultOktaGroup.getId(), pagedUserList.getAfter(), OKTA_PAGE_SIZE); - allUsers.addAll(pagedUserList); - } while (pagedUserList.hasMoreItems()); - return allUsers; - } - - private Group getDefaultOktaGroup(Organization org) { - final String orgDefaultGroupName = - generateRoleGroupName(org.getExternalId(), OrganizationRole.getDefault()); - final var oktaGroupList = - groupApi.listGroups(orgDefaultGroupName, null, null, null, null, null, null, null); - - return oktaGroupList.stream() - .filter(g -> orgDefaultGroupName.equals(g.getProfile().getName())) - .findFirst() - .orElseThrow(() -> new IllegalGraphqlArgumentException(OKTA_GROUP_NOT_FOUND)); - } - - @Override - public Optional updateUser(IdentityAttributes userIdentity) { - var user = - getUserOrThrowError( - userIdentity.getUsername(), "Cannot update Okta user with unrecognized username"); - updateUser(user, userIdentity); - - return getOrganizationRoleClaimsForUser(user); - } - - private void updateUser(User user, IdentityAttributes userIdentity) { - user.getProfile().setFirstName(userIdentity.getFirstName()); - user.getProfile().setMiddleName(userIdentity.getMiddleName()); - user.getProfile().setLastName(userIdentity.getLastName()); - // Is it our fault we don't accommodate honorific suffix? Or Okta's fault they - // don't have regular suffix? You decide. - user.getProfile().setHonorificSuffix(userIdentity.getSuffix()); - var updateRequest = new UpdateUserRequest(); - updateRequest.setProfile(user.getProfile()); - userApi.updateUser(user.getId(), updateRequest, false); - } - - @Override - public Optional updateUserEmail( - IdentityAttributes userIdentity, String email) { - var user = - getUserOrThrowError( - userIdentity.getUsername(), - "Cannot update email of Okta user with unrecognized username"); - UserProfile profile = user.getProfile(); - profile.setLogin(email); - profile.setEmail(email); - user.setProfile(profile); - var updateRequest = new UpdateUserRequest(); - updateRequest.setProfile(profile); - try { - userApi.updateUser(user.getId(), updateRequest, false); - } catch (ApiException e) { - if (e.getMessage() - .contains("An object with this field already exists in the current organization")) { - throw new ConflictingUserException(); - } else { - throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); - } - } - - return getOrganizationRoleClaimsForUser(user); - } - - @Override - public void reprovisionUser(IdentityAttributes userIdentity) { - var user = - getUserOrThrowError( - userIdentity.getUsername(), "Cannot reprovision Okta user with unrecognized username"); - UserStatus userStatus = user.getStatus(); - - // any org user "deleted" through our api will be in SUSPENDED state - if (userStatus != UserStatus.SUSPENDED) { - throw new ConflictingUserException(); + private static final String OKTA_GROUP_NOT_FOUND = "Okta group not found for this organization"; + + private final String rolePrefix; + private final Application app; + private final OrganizationExtractor extractor; + private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; + + private final ApplicationApi applicationApi; + private final GroupApi groupApi; + private final UserApi userApi; + private final ApplicationGroupsApi applicationGroupsApi; + private final String adminGroupName; + + private static final String OKTA_ORG_PROFILE_MATCHER = "profile.name sw \""; + private static final int OKTA_PAGE_SIZE = 500; + + public LiveOktaRepository( + AuthorizationProperties authorizationProperties, + @Value("${okta.oauth2.client-id}") String oktaOAuth2ClientId, + OrganizationExtractor organizationExtractor, + CurrentTenantDataAccessContextHolder tenantDataContextHolder, + GroupApi groupApi, + ApplicationApi applicationApi, + UserApi userApi, + ApplicationGroupsApi applicationGroupsApi) { + this.rolePrefix = authorizationProperties.getRolePrefix(); + this.adminGroupName = authorizationProperties.getAdminGroupName(); + + this.applicationApi = applicationApi; + this.groupApi = groupApi; + this.userApi = userApi; + this.applicationGroupsApi = applicationGroupsApi; + + try { + this.app = applicationApi.getApplication(oktaOAuth2ClientId, null); + } catch (ApiException e) { + throw new MisconfiguredApplicationException( + "Cannot find Okta application with id=" + oktaOAuth2ClientId, e); + } + + this.extractor = organizationExtractor; + this.tenantDataContextHolder = tenantDataContextHolder; + } + + @Override + public Optional createUser( + IdentityAttributes userIdentity, + Organization org, + Set facilities, + Set roles, + boolean active) { + // By default, when creating a user, we give them privileges of a standard user + String organizationExternalId = org.getExternalId(); + Set rolesToCreate = EnumSet.of(OrganizationRole.getDefault()); + rolesToCreate.addAll(roles); + + // Add user to new groups + Set groupNamesToAdd = new HashSet<>(); + groupNamesToAdd.addAll( + rolesToCreate.stream() + .map(r -> generateRoleGroupName(organizationExternalId, r)) + .collect(Collectors.toSet())); + groupNamesToAdd.addAll( + facilities.stream() + // use an empty set of facilities if user can access all facilities anyway + .filter(f -> !PermissionHolder.grantsAllFacilityAccess(rolesToCreate)) + .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) + .collect(Collectors.toSet())); + + // Search and q results need to be combined because search results have a delay of the newest + // added groups. + // https://github.com/okta/okta-sdk-java/issues/750 + var searchResults = + groupApi + .listGroups( + null, + null, + null, + null, + null, + OKTA_ORG_PROFILE_MATCHER + generateGroupOrgPrefix(organizationExternalId) + "\"", + null, + null) + .stream(); + var qResults = + groupApi + .listGroups( + generateGroupOrgPrefix(organizationExternalId), + null, + null, + null, + null, + null, + null, + null) + .stream(); + var orgGroups = Stream.concat(searchResults, qResults).distinct().toList(); + throwErrorIfEmpty( + orgGroups.stream(), + String.format( + "Cannot add Okta user to nonexistent organization=%s", organizationExternalId)); + Set orgGroupNames = + orgGroups.stream().map(g -> g.getProfile().getName()).collect(Collectors.toSet()); + groupNamesToAdd.stream() + .filter(n -> !orgGroupNames.contains(n)) + .forEach( + n -> { + throw new IllegalGraphqlArgumentException( + String.format("Cannot add Okta user to nonexistent group=%s", n)); + }); + Set groupIdsToAdd = + orgGroups.stream() + .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) + .map(Group::getId) + .collect(Collectors.toSet()); + validateRequiredFields(userIdentity); + try { + var user = + UserBuilder.instance() + .setFirstName(userIdentity.getFirstName()) + .setMiddleName(userIdentity.getMiddleName()) + .setLastName(userIdentity.getLastName()) + .setHonorificSuffix(userIdentity.getSuffix()) + .setEmail(userIdentity.getUsername()) + .setLogin(userIdentity.getUsername()) + .setActive(active) + .buildAndCreate(userApi); + groupIdsToAdd.forEach(groupId -> groupApi.assignUserToGroup(groupId, user.getId())); + } catch (ApiException e) { + if (e.getMessage() + .contains("An object with this field already exists in the current organization")) { + throw new ConflictingUserException(); + } else { + throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); + } + } + + List claims = extractor.convertClaims(groupNamesToAdd); + if (claims.size() != 1) { + log.warn("User is in {} Okta organizations, not 1", claims.size()); + return Optional.empty(); + } + return Optional.of(claims.get(0)); + } + + private static void validateRequiredFields(IdentityAttributes userIdentity) { + if (StringUtils.isBlank(userIdentity.getLastName())) { + throw new IllegalGraphqlArgumentException("Cannot create Okta user without last name"); + } + if (StringUtils.isBlank(userIdentity.getUsername())) { + throw new IllegalGraphqlArgumentException("Cannot create Okta user without username"); + } + } + + @Override + public Set getAllUsersForOrganization(Organization org) { + return getAllUsersForOrg(org).stream() + .map(u -> u.getProfile().getLogin()) + .collect(Collectors.toUnmodifiableSet()); + } + + @Override + public Map getAllUsersWithStatusForOrganization(Organization org) { + return getAllUsersForOrg(org).stream() + .collect(Collectors.toMap(u -> u.getProfile().getLogin(), User::getStatus)); + } + + private List getAllUsersForOrg(Organization org) { + PagedList pagedUserList = new PagedList<>(); + List allUsers = new ArrayList<>(); + Group orgDefaultOktaGroup = getDefaultOktaGroup(org); + do { + pagedUserList = + (PagedList) + groupApi.listGroupUsers( + orgDefaultOktaGroup.getId(), pagedUserList.getAfter(), OKTA_PAGE_SIZE); + allUsers.addAll(pagedUserList); + } while (pagedUserList.hasMoreItems()); + return allUsers; + } + + private Group getDefaultOktaGroup(Organization org) { + final String orgDefaultGroupName = + generateRoleGroupName(org.getExternalId(), OrganizationRole.getDefault()); + final var oktaGroupList = + groupApi.listGroups(orgDefaultGroupName, null, null, null, null, null, null, null); + + return oktaGroupList.stream() + .filter(g -> orgDefaultGroupName.equals(g.getProfile().getName())) + .findFirst() + .orElseThrow(() -> new IllegalGraphqlArgumentException(OKTA_GROUP_NOT_FOUND)); + } + + @Override + public Optional updateUser(IdentityAttributes userIdentity) { + var user = + getUserOrThrowError( + userIdentity.getUsername(), "Cannot update Okta user with unrecognized username"); + updateUser(user, userIdentity); + + return getOrganizationRoleClaimsForUser(user); + } + + private void updateUser(User user, IdentityAttributes userIdentity) { + user.getProfile().setFirstName(userIdentity.getFirstName()); + user.getProfile().setMiddleName(userIdentity.getMiddleName()); + user.getProfile().setLastName(userIdentity.getLastName()); + // Is it our fault we don't accommodate honorific suffix? Or Okta's fault they + // don't have regular suffix? You decide. + user.getProfile().setHonorificSuffix(userIdentity.getSuffix()); + var updateRequest = new UpdateUserRequest(); + updateRequest.setProfile(user.getProfile()); + userApi.updateUser(user.getId(), updateRequest, false); + } + + @Override + public Optional updateUserEmail( + IdentityAttributes userIdentity, String email) { + var user = + getUserOrThrowError( + userIdentity.getUsername(), + "Cannot update email of Okta user with unrecognized username"); + UserProfile profile = user.getProfile(); + profile.setLogin(email); + profile.setEmail(email); + user.setProfile(profile); + var updateRequest = new UpdateUserRequest(); + updateRequest.setProfile(profile); + try { + userApi.updateUser(user.getId(), updateRequest, false); + } catch (ApiException e) { + if (e.getMessage() + .contains("An object with this field already exists in the current organization")) { + throw new ConflictingUserException(); + } else { + throw new IllegalGraphqlArgumentException(prettifyOktaError(e)); + } + } + + return getOrganizationRoleClaimsForUser(user); + } + + @Override + public void reprovisionUser(IdentityAttributes userIdentity) { + var user = + getUserOrThrowError( + userIdentity.getUsername(), "Cannot reprovision Okta user with unrecognized username"); + UserStatus userStatus = user.getStatus(); + + // any org user "deleted" through our api will be in SUSPENDED state + if (userStatus != UserStatus.SUSPENDED) { + throw new ConflictingUserException(); + } + + updateUser(user, userIdentity); + userApi.resetFactors(user.getId()); + + // transitioning from SUSPENDED -> DEPROVISIONED -> ACTIVE will reset the user's password and + // password reset question. This cannot be done with `.reactivateUser()` because it requires the + // user to be in PROVISIONED state + userApi.deactivateUser(user.getId(), false); + userApi.activateUser(user.getId(), true); + } + + @Override + public List updateUserPrivilegesAndGroupAccess( + String username, + Organization org, + Set facilities, + OrganizationRole role, + boolean assignedToAllFacilities) { + + // unassign user from current groups + + User oktaUserToMove = getUserOrThrowError(username, "Couldn't find user"); + List groupsToUnassign = userApi.listUserGroups(oktaUserToMove.getId()); + + groupsToUnassign.stream() + // only match on the org-related group ids and not the Okta-wide orgs like "Everyone" + .filter(g -> g.getProfile().getName().contains("TENANT")) + .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), oktaUserToMove.getId())); + + // add them to the new groups + String organizationExternalId = org.getExternalId(); + EnumSet rolesToCreate = + assignedToAllFacilities + ? EnumSet.of(OrganizationRole.getDefault(), role, OrganizationRole.ALL_FACILITIES) + : EnumSet.of(OrganizationRole.getDefault(), role); + + Set groupNamesToAdd = new HashSet<>(); + groupNamesToAdd.addAll( + rolesToCreate.stream() + .map(r -> generateRoleGroupName(organizationExternalId, r)) + .collect(Collectors.toSet())); + + groupNamesToAdd.addAll( + facilities.stream() + .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) + .collect(Collectors.toSet())); + + String groupOrgPrefix = generateGroupOrgPrefix(org.getExternalId()); + Map orgsToAddUserToMap = + groupApi + .listGroups( + null, + null, + null, + null, + null, + OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", + null, + null) + .stream() + .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) + .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); + + orgsToAddUserToMap.forEach( + (name, group) -> groupApi.assignUserToGroup(group.getId(), oktaUserToMove.getId())); + return orgsToAddUserToMap.keySet().stream().toList(); + } + + @Override + public Optional updateUserPrivileges( + String username, Organization org, Set facilities, Set roles) { + User user = + getUserOrThrowError(username, "Cannot update role of Okta user with unrecognized username"); + + String orgId = org.getExternalId(); + + final String groupOrgPrefix = generateGroupOrgPrefix(orgId); + final String groupOrgDefaultName = generateRoleGroupName(orgId, OrganizationRole.getDefault()); + + // Map user's current Okta group memberships (Okta group name -> Okta Group). + // The Okta group name is our friendly role and facility group names + Map currentOrgGroupMapForUser = + userApi.listUserGroups(user.getId()).stream() + .filter( + g -> + GroupType.OKTA_GROUP == g.getType() + && g.getProfile().getName().startsWith(groupOrgPrefix)) + .collect(Collectors.toMap(g -> g.getProfile().getName(), g -> g)); + + if (!currentOrgGroupMapForUser.containsKey(groupOrgDefaultName)) { + // The user is not a member of the default group for this organization. If they happen + // to be in any of this organization's groups, remove the user from those groups. + currentOrgGroupMapForUser + .values() + .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), user.getId())); + throw new IllegalGraphqlArgumentException( + "Cannot update privileges of Okta user in organization they do not belong to."); + } + + Set expectedOrgGroupNamesForUser = new HashSet<>(); + expectedOrgGroupNamesForUser.add(groupOrgDefaultName); + expectedOrgGroupNamesForUser.addAll( + roles.stream().map(r -> generateRoleGroupName(orgId, r)).collect(Collectors.toSet())); + if (!PermissionHolder.grantsAllFacilityAccess(roles)) { + expectedOrgGroupNamesForUser.addAll( + facilities.stream() + .map(f -> generateFacilityGroupName(orgId, f.getInternalId())) + .collect(Collectors.toSet())); + } + + // to remove... + Set groupNamesToRemove = new HashSet<>(currentOrgGroupMapForUser.keySet()); + groupNamesToRemove.removeIf(expectedOrgGroupNamesForUser::contains); + + // to add... + Set groupNamesToAdd = new HashSet<>(expectedOrgGroupNamesForUser); + groupNamesToAdd.removeIf(currentOrgGroupMapForUser::containsKey); + + if (!groupNamesToRemove.isEmpty() || !groupNamesToAdd.isEmpty()) { + Map fullOrgGroupMap = + groupApi + .listGroups( + null, + null, + null, + null, + null, + OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", + null, + null) + .stream() + .filter(g -> GroupType.OKTA_GROUP == g.getType()) + .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); + if (fullOrgGroupMap.size() == 0) { + throw new IllegalGraphqlArgumentException( + String.format("Cannot add Okta user to nonexistent organization=%s", orgId)); + } + + for (String groupName : groupNamesToRemove) { + Group group = fullOrgGroupMap.get(groupName); + log.info("Removing {} from Okta group: {}", username, group.getProfile().getName()); + groupApi.unassignUserFromGroup(group.getId(), user.getId()); + } + + for (String groupName : groupNamesToAdd) { + if (!fullOrgGroupMap.containsKey(groupName)) { + throw new IllegalGraphqlArgumentException( + String.format("Cannot add Okta user to nonexistent group=%s", groupName)); } - - updateUser(user, userIdentity); - userApi.resetFactors(user.getId()); - - // transitioning from SUSPENDED -> DEPROVISIONED -> ACTIVE will reset the user's password and - // password reset question. This cannot be done with `.reactivateUser()` because it requires the - // user to be in PROVISIONED state - userApi.deactivateUser(user.getId(), false); - userApi.activateUser(user.getId(), true); - } - - @Override - public List updateUserPrivilegesAndGroupAccess( - String username, - Organization org, - Set facilities, - OrganizationRole role, - boolean assignedToAllFacilities) { - - // unassign user from current groups - - User oktaUserToMove = getUserOrThrowError(username, "Couldn't find user"); - List groupsToUnassign = userApi.listUserGroups(oktaUserToMove.getId()); - - groupsToUnassign.stream() - // only match on the org-related group ids and not the Okta-wide orgs like "Everyone" - .filter(g -> g.getProfile().getName().contains("TENANT")) - .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), oktaUserToMove.getId())); - - // add them to the new groups - String organizationExternalId = org.getExternalId(); - EnumSet rolesToCreate = - assignedToAllFacilities - ? EnumSet.of(OrganizationRole.getDefault(), role, OrganizationRole.ALL_FACILITIES) - : EnumSet.of(OrganizationRole.getDefault(), role); - - Set groupNamesToAdd = new HashSet<>(); - groupNamesToAdd.addAll( - rolesToCreate.stream() - .map(r -> generateRoleGroupName(organizationExternalId, r)) - .collect(Collectors.toSet())); - - groupNamesToAdd.addAll( - facilities.stream() - .map(f -> generateFacilityGroupName(organizationExternalId, f.getInternalId())) - .collect(Collectors.toSet())); - - String groupOrgPrefix = generateGroupOrgPrefix(org.getExternalId()); - Map orgsToAddUserToMap = - groupApi - .listGroups( - null, - null, - null, - null, - null, - OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", - null, - null) - .stream() - .filter(g -> groupNamesToAdd.contains(g.getProfile().getName())) - .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); - - orgsToAddUserToMap.forEach( - (name, group) -> groupApi.assignUserToGroup(group.getId(), oktaUserToMove.getId())); - return orgsToAddUserToMap.keySet().stream().toList(); - } - - @Override - public Optional updateUserPrivileges( - String username, Organization org, Set facilities, Set roles) { - User user = - getUserOrThrowError(username, "Cannot update role of Okta user with unrecognized username"); - - String orgId = org.getExternalId(); - - final String groupOrgPrefix = generateGroupOrgPrefix(orgId); - final String groupOrgDefaultName = generateRoleGroupName(orgId, OrganizationRole.getDefault()); - - // Map user's current Okta group memberships (Okta group name -> Okta Group). - // The Okta group name is our friendly role and facility group names - Map currentOrgGroupMapForUser = - userApi.listUserGroups(user.getId()).stream() - .filter( - g -> - GroupType.OKTA_GROUP == g.getType() - && g.getProfile().getName().startsWith(groupOrgPrefix)) - .collect(Collectors.toMap(g -> g.getProfile().getName(), g -> g)); - - if (!currentOrgGroupMapForUser.containsKey(groupOrgDefaultName)) { - // The user is not a member of the default group for this organization. If they happen - // to be in any of this organization's groups, remove the user from those groups. - currentOrgGroupMapForUser - .values() - .forEach(g -> groupApi.unassignUserFromGroup(g.getId(), user.getId())); - throw new IllegalGraphqlArgumentException( - "Cannot update privileges of Okta user in organization they do not belong to."); - } - - Set expectedOrgGroupNamesForUser = new HashSet<>(); - expectedOrgGroupNamesForUser.add(groupOrgDefaultName); - expectedOrgGroupNamesForUser.addAll( - roles.stream().map(r -> generateRoleGroupName(orgId, r)).collect(Collectors.toSet())); - if (!PermissionHolder.grantsAllFacilityAccess(roles)) { - expectedOrgGroupNamesForUser.addAll( - facilities.stream() - .map(f -> generateFacilityGroupName(orgId, f.getInternalId())) - .collect(Collectors.toSet())); - } - - // to remove... - Set groupNamesToRemove = new HashSet<>(currentOrgGroupMapForUser.keySet()); - groupNamesToRemove.removeIf(expectedOrgGroupNamesForUser::contains); - - // to add... - Set groupNamesToAdd = new HashSet<>(expectedOrgGroupNamesForUser); - groupNamesToAdd.removeIf(currentOrgGroupMapForUser::containsKey); - - if (!groupNamesToRemove.isEmpty() || !groupNamesToAdd.isEmpty()) { - Map fullOrgGroupMap = - groupApi - .listGroups( - null, - null, - null, - null, - null, - OKTA_ORG_PROFILE_MATCHER + groupOrgPrefix + "\"", - null, - null) - .stream() - .filter(g -> GroupType.OKTA_GROUP == g.getType()) - .collect(Collectors.toMap(g -> g.getProfile().getName(), Function.identity())); - if (fullOrgGroupMap.size() == 0) { - throw new IllegalGraphqlArgumentException( - String.format("Cannot add Okta user to nonexistent organization=%s", orgId)); - } - - for (String groupName : groupNamesToRemove) { - Group group = fullOrgGroupMap.get(groupName); - log.info("Removing {} from Okta group: {}", username, group.getProfile().getName()); - groupApi.unassignUserFromGroup(group.getId(), user.getId()); - } - - for (String groupName : groupNamesToAdd) { - if (!fullOrgGroupMap.containsKey(groupName)) { - throw new IllegalGraphqlArgumentException( - String.format("Cannot add Okta user to nonexistent group=%s", groupName)); - } - Group group = fullOrgGroupMap.get(groupName); - log.info("Adding {} to Okta group: {}", username, group.getProfile().getName()); - groupApi.assignUserToGroup(group.getId(), user.getId()); - } - } - - return getOrganizationRoleClaimsForUser(user); - } - - @Override - public void resetUserPassword(String username) { - var user = - getUserOrThrowError( - username, "Cannot reset password for Okta user with unrecognized username"); - userApi.generateResetPasswordToken(user.getId(), true, false); - } - - @Override - public void resetUserMfa(String username) { - var user = - getUserOrThrowError(username, "Cannot reset MFA for Okta user with unrecognized username"); - userApi.resetFactors(user.getId()); - } - - @Override - public void setUserIsActive(String username, boolean active) { - var user = - getUserOrThrowError( - username, "Cannot update active status of Okta user with unrecognized username"); - - if (active && user.getStatus() == UserStatus.SUSPENDED) { - userApi.unsuspendUser(user.getId()); - } else if (!active && user.getStatus() != UserStatus.SUSPENDED) { - userApi.suspendUser(user.getId()); - } - } - - @Override - public UserStatus getUserStatus(String username) { - return getUserOrThrowError( - username, "Cannot retrieve Okta user's status with unrecognized username") - .getStatus(); - } - - @Override - public void reactivateUser(String username) { - var user = - getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); - userApi.unsuspendUser(user.getId()); - } - - @Override - public void resendActivationEmail(String username) { - var user = - getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); - if (user.getStatus() == UserStatus.PROVISIONED) { - userApi.reactivateUser(user.getId(), true); - } else if (user.getStatus() == UserStatus.STAGED) { - userApi.activateUser(user.getId(), true); - } else { - throw new IllegalGraphqlArgumentException( - "Cannot reactivate user with status: " + user.getStatus()); - } - } - - /** - * Iterates over all OrganizationRole's, creating new corresponding Okta groups for this - * organization where they do not already exist. For those OrganizationRole's that are in - * MIGRATION_DEST_ROLES and whose Okta groups are newly created, migrate all users from this org - * to those new Okta groups, where the migrated users are sourced from all pre-existing Okta - * groups for this organization. Separately, iterates over all facilities in this org, creating - * new corresponding Okta groups where they do not already exist. Does not perform any migration - * to these facility groups. - */ - @Override - public void createOrganization(Organization org) { - String name = org.getOrganizationName(); - String externalId = org.getExternalId(); - - for (OrganizationRole role : OrganizationRole.values()) { - String roleGroupName = generateRoleGroupName(externalId, role); - String roleGroupDescription = generateRoleGroupDescription(name, role); - Group g = - GroupBuilder.instance() - .setName(roleGroupName) - .setDescription(roleGroupDescription) - .buildAndCreate(groupApi); - applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); - - log.info("Created Okta group={}", roleGroupName); - } - } - - private List getOrgAdminUsers(Organization org) { - String externalId = org.getExternalId(); - String roleGroupName = generateRoleGroupName(externalId, OrganizationRole.ADMIN); - var groups = groupApi.listGroups(roleGroupName, null, null, null, null, null, null, null); - throwErrorIfEmpty(groups.stream(), "Cannot activate nonexistent Okta organization"); - Group group = groups.get(0); - return groupApi.listGroupUsers(group.getId(), null, null); - } - - private String activateUser(User user) { - if (user.getStatus() == UserStatus.PROVISIONED) { - // reactivates user and sends them an Okta email to reactivate their account - return userApi.reactivateUser(user.getId(), true).getActivationToken(); - } else if (user.getStatus() == UserStatus.STAGED) { - return userApi.activateUser(user.getId(), true).getActivationToken(); - } else { - throw new IllegalGraphqlArgumentException( - "Cannot activate Okta organization whose users have status=" + user.getStatus().name()); - } - } - - @Override - public void activateOrganization(Organization org) { - var users = getOrgAdminUsers(org); - for (User u : users) { - activateUser(u); - } - } - - @Override - public String activateOrganizationWithSingleUser(Organization org) { - User user = getOrgAdminUsers(org).get(0); - return activateUser(user); - } - - @Override - public List fetchAdminUserEmail(Organization org) { - var admins = getOrgAdminUsers(org); - return admins.stream().map(u -> u.getProfile().getLogin()).toList(); - } - - @Override - public void createFacility(Facility facility) { - // Only create the facility group if the facility's organization has already been created - String orgExternalId = facility.getOrganization().getExternalId(); - var orgGroups = - groupApi.listGroups( - generateGroupOrgPrefix(orgExternalId), null, null, null, null, null, null, null); - throwErrorIfEmpty( - orgGroups.stream(), - String.format( - "Cannot create Okta group for facility=%s: facility's org=%s, has not yet been created in Okta", - facility.getFacilityName(), facility.getOrganization().getExternalId())); - - String orgName = facility.getOrganization().getOrganizationName(); - String facilityGroupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); - Group g = - GroupBuilder.instance() - .setName(facilityGroupName) - .setDescription(generateFacilityGroupDescription(orgName, facility.getFacilityName())) - .buildAndCreate(groupApi); - applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); - - log.info("Created Okta group={}", facilityGroupName); - } - - public void deleteFacility(Facility facility) { - String orgExternalId = facility.getOrganization().getExternalId(); - String groupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); - var groups = groupApi.listGroups(groupName, null, null, null, null, null, null, null); - for (Group group : groups) { - groupApi.deleteGroup(group.getId()); - } - } - - @Override - public void deleteOrganization(Organization org) { - String externalId = org.getExternalId(); - var orgGroups = - groupApi.listGroups( - generateGroupOrgPrefix(externalId), null, null, null, null, null, null, null); - for (Group group : orgGroups) { - groupApi.deleteGroup(group.getId()); - } - } - - // returns the external ID of the organization the specified user belongs to - @Override - public Optional getOrganizationRoleClaimsForUser(String username) { - // When a site admin is using tenant data access, bypass okta and get org from the altered - // authorities. If the site admin is getting the claims for another site admin who also has - // active tenant data access, then reflect what is in Okta, not the temporary claims. - if (tenantDataContextHolder.hasBeenPopulated() - && username.equals(tenantDataContextHolder.getUsername())) { - return getOrganizationRoleClaimsFromAuthorities(tenantDataContextHolder.getAuthorities()); - } - - return getOrganizationRoleClaimsForUser( - getUserOrThrowError(username, "Cannot get org external ID for nonexistent user")); - } - - public Integer getUsersInSingleFacility(Facility facility) { - String facilityAccessGroupName = - generateFacilityGroupName( - facility.getOrganization().getExternalId(), facility.getInternalId()); - - List facilityAccessGroup = - groupApi.listGroups(facilityAccessGroupName, null, null, 1, "stats", null, null, null); - - if (facilityAccessGroup.isEmpty()) { - return 0; - } - - try { - LinkedHashMap stats = - (LinkedHashMap) facilityAccessGroup.get(0).getEmbedded().get("stats"); - return ((Integer) stats.get("usersCount")); - } catch (NullPointerException e) { - throw new BadRequestException("Unable to retrieve okta group stats", e); - } - } - - public PartialOktaUser findUser(String username) { - User user = - getUserOrThrowError( - username, "Cannot retrieve Okta user's status with unrecognized username"); - - List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); - - Optional orgClaims = convertToOrganizationRoleClaims(userGroups); - - return PartialOktaUser.builder() - .username(username) - .isSiteAdmin(isSiteAdmin(userGroups)) - .status(user.getStatus()) - .organizationRoleClaims(orgClaims) - .build(); - } - - public int getConnectTimeoutForHealthCheck() { - return applicationApi.getApiClient().getConnectTimeout(); - } - - private Optional getOrganizationRoleClaimsFromAuthorities( - Collection authorities) { - List claims = extractor.convertClaims(authorities); - - if (claims.size() != 1) { - log.warn("User's Tenant Data Access has claims in {} organizations, not 1", claims.size()); - return Optional.empty(); - } - return Optional.of(claims.get(0)); - } - - private Optional getOrganizationRoleClaimsForUser(User user) { - List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); - return convertToOrganizationRoleClaims(userGroups); - } - - private Optional convertToOrganizationRoleClaims(List userGroups) { - List groupNames = - userGroups.stream() - .filter(g -> g.getType() == GroupType.OKTA_GROUP) - .map(g -> g.getProfile().getName()) - .toList(); - List claims = extractor.convertClaims(groupNames); - - if (claims.size() != 1) { - log.warn("User is in {} Okta organizations, not 1", claims.size()); - return Optional.empty(); - } - return Optional.of(claims.get(0)); - } - - private boolean isSiteAdmin(List oktaGroups) { - return oktaGroups.stream() - .filter(g -> g.getType() == GroupType.OKTA_GROUP) - .anyMatch(g -> adminGroupName.equals(g.getProfile().getName())); - } - - private String generateGroupOrgPrefix(String orgExternalId) { - return String.format("%s%s", rolePrefix, orgExternalId); - } - - private String generateRoleGroupName(String orgExternalId, OrganizationRole role) { - return String.format("%s%s%s", rolePrefix, orgExternalId, generateRoleSuffix(role)); - } - - private String generateFacilityGroupName(String orgExternalId, UUID facilityId) { - return String.format( - "%s%s%s", rolePrefix, orgExternalId, generateFacilitySuffix(facilityId.toString())); - } - - private String generateRoleGroupDescription(String orgName, OrganizationRole role) { - return String.format("%s - %ss", orgName, role.getDescription()); - } - - private String generateFacilityGroupDescription(String orgName, String facilityName) { - return String.format("%s - Facility Access - %s", orgName, facilityName); - } - - private String generateRoleSuffix(OrganizationRole role) { - return ":" + role.name(); - } - - private String generateFacilitySuffix(String facilityId) { - return ":" + OrganizationExtractor.FACILITY_ACCESS_MARKER + ":" + facilityId; - } - - private User getUserOrThrowError(String email, String errorMessage) { - try { - return userApi.getUser(email); - } catch (ApiException e) { - throw new IllegalGraphqlArgumentException(errorMessage); - } - } - - private void throwErrorIfEmpty(Stream stream, String errorMessage) { - if (stream.findAny().isEmpty()) { - throw new IllegalGraphqlArgumentException(errorMessage); - } - } - - private String prettifyOktaError(ApiException e) { - var errorMessage = "Code: " + e.getCode() + "; Message: " + e.getMessage(); - if (e.getResponseBody() != null) { - Error error = ApiExceptionHelper.getError(e); - if (error != null) { - errorMessage = - "Okta Error: " + error.getErrorCode() + ", Error summary: " + error.getErrorSummary(); - if (error.getErrorCauses() != null) { - errorMessage += - ", Error Cause(s): " - + error.getErrorCauses().stream() - .map(ErrorErrorCausesInner::getErrorSummary) - .collect(Collectors.joining(", ")); - } - } + Group group = fullOrgGroupMap.get(groupName); + log.info("Adding {} to Okta group: {}", username, group.getProfile().getName()); + groupApi.assignUserToGroup(group.getId(), user.getId()); + } + } + + return getOrganizationRoleClaimsForUser(user); + } + + @Override + public void resetUserPassword(String username) { + var user = + getUserOrThrowError( + username, "Cannot reset password for Okta user with unrecognized username"); + userApi.generateResetPasswordToken(user.getId(), true, false); + } + + @Override + public void resetUserMfa(String username) { + var user = + getUserOrThrowError(username, "Cannot reset MFA for Okta user with unrecognized username"); + userApi.resetFactors(user.getId()); + } + + @Override + public void setUserIsActive(String username, boolean active) { + var user = + getUserOrThrowError( + username, "Cannot update active status of Okta user with unrecognized username"); + + if (active && user.getStatus() == UserStatus.SUSPENDED) { + userApi.unsuspendUser(user.getId()); + } else if (!active && user.getStatus() != UserStatus.SUSPENDED) { + userApi.suspendUser(user.getId()); + } + } + + @Override + public UserStatus getUserStatus(String username) { + return getUserOrThrowError( + username, "Cannot retrieve Okta user's status with unrecognized username") + .getStatus(); + } + + @Override + public void reactivateUser(String username) { + var user = + getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); + userApi.unsuspendUser(user.getId()); + } + + @Override + public void resendActivationEmail(String username) { + var user = + getUserOrThrowError(username, "Cannot reactivate Okta user with unrecognized username"); + if (user.getStatus() == UserStatus.PROVISIONED) { + userApi.reactivateUser(user.getId(), true); + } else if (user.getStatus() == UserStatus.STAGED) { + userApi.activateUser(user.getId(), true); + } else { + throw new IllegalGraphqlArgumentException( + "Cannot reactivate user with status: " + user.getStatus()); + } + } + + /** + * Iterates over all OrganizationRole's, creating new corresponding Okta groups for this + * organization where they do not already exist. For those OrganizationRole's that are in + * MIGRATION_DEST_ROLES and whose Okta groups are newly created, migrate all users from this org + * to those new Okta groups, where the migrated users are sourced from all pre-existing Okta + * groups for this organization. Separately, iterates over all facilities in this org, creating + * new corresponding Okta groups where they do not already exist. Does not perform any migration + * to these facility groups. + */ + @Override + public void createOrganization(Organization org) { + String name = org.getOrganizationName(); + String externalId = org.getExternalId(); + + for (OrganizationRole role : OrganizationRole.values()) { + String roleGroupName = generateRoleGroupName(externalId, role); + String roleGroupDescription = generateRoleGroupDescription(name, role); + Group g = + GroupBuilder.instance() + .setName(roleGroupName) + .setDescription(roleGroupDescription) + .buildAndCreate(groupApi); + applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); + + log.info("Created Okta group={}", roleGroupName); + } + } + + private List getOrgAdminUsers(Organization org) { + String externalId = org.getExternalId(); + String roleGroupName = generateRoleGroupName(externalId, OrganizationRole.ADMIN); + var groups = groupApi.listGroups(roleGroupName, null, null, null, null, null, null, null); + throwErrorIfEmpty(groups.stream(), "Cannot activate nonexistent Okta organization"); + Group group = groups.get(0); + return groupApi.listGroupUsers(group.getId(), null, null); + } + + private String activateUser(User user) { + if (user.getStatus() == UserStatus.PROVISIONED) { + // reactivates user and sends them an Okta email to reactivate their account + return userApi.reactivateUser(user.getId(), true).getActivationToken(); + } else if (user.getStatus() == UserStatus.STAGED) { + return userApi.activateUser(user.getId(), true).getActivationToken(); + } else { + throw new IllegalGraphqlArgumentException( + "Cannot activate Okta organization whose users have status=" + user.getStatus().name()); + } + } + + @Override + public void activateOrganization(Organization org) { + var users = getOrgAdminUsers(org); + for (User u : users) { + activateUser(u); + } + } + + @Override + public String activateOrganizationWithSingleUser(Organization org) { + User user = getOrgAdminUsers(org).get(0); + return activateUser(user); + } + + @Override + public List fetchAdminUserEmail(Organization org) { + var admins = getOrgAdminUsers(org); + return admins.stream().map(u -> u.getProfile().getLogin()).toList(); + } + + @Override + public void createFacility(Facility facility) { + // Only create the facility group if the facility's organization has already been created + String orgExternalId = facility.getOrganization().getExternalId(); + var orgGroups = + groupApi.listGroups( + generateGroupOrgPrefix(orgExternalId), null, null, null, null, null, null, null); + throwErrorIfEmpty( + orgGroups.stream(), + String.format( + "Cannot create Okta group for facility=%s: facility's org=%s, has not yet been created in Okta", + facility.getFacilityName(), facility.getOrganization().getExternalId())); + + String orgName = facility.getOrganization().getOrganizationName(); + String facilityGroupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); + Group g = + GroupBuilder.instance() + .setName(facilityGroupName) + .setDescription(generateFacilityGroupDescription(orgName, facility.getFacilityName())) + .buildAndCreate(groupApi); + applicationGroupsApi.assignGroupToApplication(app.getId(), g.getId(), null); + + log.info("Created Okta group={}", facilityGroupName); + } + + public void deleteFacility(Facility facility) { + String orgExternalId = facility.getOrganization().getExternalId(); + String groupName = generateFacilityGroupName(orgExternalId, facility.getInternalId()); + var groups = groupApi.listGroups(groupName, null, null, null, null, null, null, null); + for (Group group : groups) { + groupApi.deleteGroup(group.getId()); + } + } + + @Override + public void deleteOrganization(Organization org) { + String externalId = org.getExternalId(); + var orgGroups = + groupApi.listGroups( + generateGroupOrgPrefix(externalId), null, null, null, null, null, null, null); + for (Group group : orgGroups) { + groupApi.deleteGroup(group.getId()); + } + } + + // returns the external ID of the organization the specified user belongs to + @Override + public Optional getOrganizationRoleClaimsForUser(String username) { + // When a site admin is using tenant data access, bypass okta and get org from the altered + // authorities. If the site admin is getting the claims for another site admin who also has + // active tenant data access, then reflect what is in Okta, not the temporary claims. + if (tenantDataContextHolder.hasBeenPopulated() + && username.equals(tenantDataContextHolder.getUsername())) { + return getOrganizationRoleClaimsFromAuthorities(tenantDataContextHolder.getAuthorities()); + } + + return getOrganizationRoleClaimsForUser( + getUserOrThrowError(username, "Cannot get org external ID for nonexistent user")); + } + + public Integer getUsersInSingleFacility(Facility facility) { + String facilityAccessGroupName = + generateFacilityGroupName( + facility.getOrganization().getExternalId(), facility.getInternalId()); + + List facilityAccessGroup = + groupApi.listGroups(facilityAccessGroupName, null, null, 1, "stats", null, null, null); + + if (facilityAccessGroup.isEmpty()) { + return 0; + } + + try { + LinkedHashMap stats = + (LinkedHashMap) facilityAccessGroup.get(0).getEmbedded().get("stats"); + return ((Integer) stats.get("usersCount")); + } catch (NullPointerException e) { + throw new BadRequestException("Unable to retrieve okta group stats", e); + } + } + + public PartialOktaUser findUser(String username) { + User user = + getUserOrThrowError( + username, "Cannot retrieve Okta user's status with unrecognized username"); + + List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); + + Optional orgClaims = convertToOrganizationRoleClaims(userGroups); + + return PartialOktaUser.builder() + .username(username) + .isSiteAdmin(isSiteAdmin(userGroups)) + .status(user.getStatus()) + .organizationRoleClaims(orgClaims) + .build(); + } + + public int getConnectTimeoutForHealthCheck() { + return applicationApi.getApiClient().getConnectTimeout(); + } + + private Optional getOrganizationRoleClaimsFromAuthorities( + Collection authorities) { + List claims = extractor.convertClaims(authorities); + + if (claims.size() != 1) { + log.warn("User's Tenant Data Access has claims in {} organizations, not 1", claims.size()); + return Optional.empty(); + } + return Optional.of(claims.get(0)); + } + + private Optional getOrganizationRoleClaimsForUser(User user) { + List userGroups = userApi.listUserGroups(user.getId()).stream().toList(); + return convertToOrganizationRoleClaims(userGroups); + } + + private Optional convertToOrganizationRoleClaims(List userGroups) { + List groupNames = + userGroups.stream() + .filter(g -> g.getType() == GroupType.OKTA_GROUP) + .map(g -> g.getProfile().getName()) + .toList(); + List claims = extractor.convertClaims(groupNames); + + if (claims.size() != 1) { + log.warn("User is in {} Okta organizations, not 1", claims.size()); + return Optional.empty(); + } + return Optional.of(claims.get(0)); + } + + private boolean isSiteAdmin(List oktaGroups) { + return oktaGroups.stream() + .filter(g -> g.getType() == GroupType.OKTA_GROUP) + .anyMatch(g -> adminGroupName.equals(g.getProfile().getName())); + } + + private String generateGroupOrgPrefix(String orgExternalId) { + return String.format("%s%s", rolePrefix, orgExternalId); + } + + private String generateRoleGroupName(String orgExternalId, OrganizationRole role) { + return String.format("%s%s%s", rolePrefix, orgExternalId, generateRoleSuffix(role)); + } + + private String generateFacilityGroupName(String orgExternalId, UUID facilityId) { + return String.format( + "%s%s%s", rolePrefix, orgExternalId, generateFacilitySuffix(facilityId.toString())); + } + + private String generateRoleGroupDescription(String orgName, OrganizationRole role) { + return String.format("%s - %ss", orgName, role.getDescription()); + } + + private String generateFacilityGroupDescription(String orgName, String facilityName) { + return String.format("%s - Facility Access - %s", orgName, facilityName); + } + + private String generateRoleSuffix(OrganizationRole role) { + return ":" + role.name(); + } + + private String generateFacilitySuffix(String facilityId) { + return ":" + OrganizationExtractor.FACILITY_ACCESS_MARKER + ":" + facilityId; + } + + private User getUserOrThrowError(String email, String errorMessage) { + try { + return userApi.getUser(email); + } catch (ApiException e) { + throw new IllegalGraphqlArgumentException(errorMessage); + } + } + + private void throwErrorIfEmpty(Stream stream, String errorMessage) { + if (stream.findAny().isEmpty()) { + throw new IllegalGraphqlArgumentException(errorMessage); + } + } + + private String prettifyOktaError(ApiException e) { + var errorMessage = "Code: " + e.getCode() + "; Message: " + e.getMessage(); + if (e.getResponseBody() != null) { + Error error = ApiExceptionHelper.getError(e); + if (error != null) { + errorMessage = + "Okta Error: " + error.getErrorCode() + ", Error summary: " + error.getErrorSummary(); + if (error.getErrorCauses() != null) { + errorMessage += + ", Error Cause(s): " + + error.getErrorCauses().stream() + .map(ErrorErrorCausesInner::getErrorSummary) + .collect(Collectors.joining(", ")); } - return errorMessage; + } } + return errorMessage; + } } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java index c03f2f674b..f7d38a2455 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java @@ -6,7 +6,6 @@ import gov.cdc.usds.simplereport.db.model.Facility; import gov.cdc.usds.simplereport.db.model.Organization; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; - import java.util.List; import java.util.Map; import java.util.Optional; @@ -19,65 +18,64 @@ */ public interface OktaRepository { - Optional createUser( - IdentityAttributes userIdentity, - Organization org, - Set facilities, - Set roles, - boolean active); - - Optional updateUser(IdentityAttributes userIdentity); + Optional createUser( + IdentityAttributes userIdentity, + Organization org, + Set facilities, + Set roles, + boolean active); - Optional updateUserEmail(IdentityAttributes userIdentity, String email); + Optional updateUser(IdentityAttributes userIdentity); - void reprovisionUser(IdentityAttributes userIdentity); + Optional updateUserEmail(IdentityAttributes userIdentity, String email); - Optional updateUserPrivileges( - String username, Organization org, Set facilities, Set roles); + void reprovisionUser(IdentityAttributes userIdentity); - List updateUserPrivilegesAndGroupAccess( - String username, - Organization org, - Set facilities, - OrganizationRole roles, - boolean assignedToAllFacilities); + Optional updateUserPrivileges( + String username, Organization org, Set facilities, Set roles); - void resetUserPassword(String username); + List updateUserPrivilegesAndGroupAccess( + String username, + Organization org, + Set facilities, + OrganizationRole roles, + boolean assignedToAllFacilities); - void resetUserMfa(String username); + void resetUserPassword(String username); - void setUserIsActive(String username, boolean active); + void resetUserMfa(String username); - void reactivateUser(String username); + void setUserIsActive(String username, boolean active); - void resendActivationEmail(String username); + void reactivateUser(String username); - UserStatus getUserStatus(String username); + void resendActivationEmail(String username); - Set getAllUsersForOrganization(Organization org); + UserStatus getUserStatus(String username); - Map getAllUsersWithStatusForOrganization(Organization org); + Set getAllUsersForOrganization(Organization org); - void createOrganization(Organization org); + Map getAllUsersWithStatusForOrganization(Organization org); - void activateOrganization(Organization org); + void createOrganization(Organization org); - String activateOrganizationWithSingleUser(Organization org); + void activateOrganization(Organization org); - List fetchAdminUserEmail(Organization org); + String activateOrganizationWithSingleUser(Organization org); - void createFacility(Facility facility); + List fetchAdminUserEmail(Organization org); - void deleteOrganization(Organization org); + void createFacility(Facility facility); - void deleteFacility(Facility facility); + void deleteOrganization(Organization org); - Optional getOrganizationRoleClaimsForUser(String username); + void deleteFacility(Facility facility); - Integer getUsersInSingleFacility(Facility facility); + Optional getOrganizationRoleClaimsForUser(String username); - PartialOktaUser findUser(String username); + Integer getUsersInSingleFacility(Facility facility); - int getConnectTimeoutForHealthCheck(); + PartialOktaUser findUser(String username); + int getConnectTimeoutForHealthCheck(); } From 4ee121529d1862d01623134cec2f9e29480eed51 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 10:42:27 -0500 Subject: [PATCH 42/59] remove trailing slash --- .github/actions/post-deploy-smoke-test/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/post-deploy-smoke-test/action.yml b/.github/actions/post-deploy-smoke-test/action.yml index d221374907..db90eb0c72 100644 --- a/.github/actions/post-deploy-smoke-test/action.yml +++ b/.github/actions/post-deploy-smoke-test/action.yml @@ -12,7 +12,7 @@ runs: working-directory: frontend run: | touch .env - echo REACT_APP_BASE_URL=https://${{ inputs.deploy-env }}.simplereport.gov/ >> .env.production.local + echo REACT_APP_BASE_URL=https://${{ inputs.deploy-env }}.simplereport.gov >> .env.production.local - name: Run smoke test script shell: bash working-directory: frontend From 87a62494d50c322abb63f4869fab0a02adcd5d9a Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 10:44:55 -0500 Subject: [PATCH 43/59] remove empty var --- backend/src/main/resources/application.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 726ac9eaac..8f792d8cc0 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -88,7 +88,6 @@ okta: client: org-url: https://hhs-prime.okta.com token: ${OKTA_API_KEY:MISSING} - group: smarty-streets: id: ${SMARTY_AUTH_ID} token: ${SMARTY_AUTH_TOKEN} From 6db06909e5b3e9d62975b1f1e826a14a4044a482 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 10:46:31 -0500 Subject: [PATCH 44/59] remove comment --- frontend/src/app/DeploySmokeTest.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/app/DeploySmokeTest.tsx b/frontend/src/app/DeploySmokeTest.tsx index e87fbbf1d3..1ddcbc3e9e 100644 --- a/frontend/src/app/DeploySmokeTest.tsx +++ b/frontend/src/app/DeploySmokeTest.tsx @@ -11,7 +11,6 @@ const DeploySmokeTest = (): JSX.Element => { .then((response) => { const status = JSON.parse(response); if (status.status === "UP") return setSuccess(true); - // log something using app insights setSuccess(false); }) .catch((e) => { From d30c568bfeb3e6c72db48945c132df8ea4159cff Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 10:53:31 -0500 Subject: [PATCH 45/59] move url to one place --- frontend/deploy-smoke.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/deploy-smoke.js b/frontend/deploy-smoke.js index 7c3a1be92e..966774839b 100644 --- a/frontend/deploy-smoke.js +++ b/frontend/deploy-smoke.js @@ -9,8 +9,8 @@ let { Builder } = require("selenium-webdriver"); const Chrome = require("selenium-webdriver/chrome"); const appUrl = process.env.REACT_APP_BASE_URL.includes("localhost") - ? process.env.REACT_APP_BASE_URL - : `${process.env.REACT_APP_BASE_URL}/app`; + ? `${process.env.REACT_APP_BASE_URL}/health/deploy-smoke-test` + : `${process.env.REACT_APP_BASE_URL}/app/health/deploy-smoke-test`; console.log(`Running smoke test for ${appUrl}`); const options = new Chrome.Options(); @@ -20,7 +20,7 @@ const driver = new Builder() .build(); driver .navigate() - .to(`${appUrl}/health/deploy-smoke-test`) + .to(`${appUrl}`) .then(() => { let value = driver.findElement({ id: "root" }).getText(); return value; From 03236e95f0e47a5ea065a17237bd75ae98194270 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 11:14:21 -0500 Subject: [PATCH 46/59] use existing status check instead --- .../BackendAndDatabaseHealthIndicator.java | 7 ++++++- .../idp/repository/DemoOktaRepository.java | 7 ++++--- .../idp/repository/LiveOktaRepository.java | 8 +++----- .../idp/repository/OktaRepository.java | 2 +- ...BackendAndDatabaseHealthIndicatorTest.java | 20 ++++++++++++++----- 5 files changed, 29 insertions(+), 15 deletions(-) diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java index 71fbda9587..b77a3bb57b 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -21,7 +21,12 @@ public class BackendAndDatabaseHealthIndicator implements HealthIndicator { public Health health() { try { _ffRepo.findAll(); - _oktaRepo.getConnectTimeoutForHealthCheck(); + String oktaStatus = _oktaRepo.getApplicationStatusForHealthCheck(); + + if (oktaStatus != "ACTIVE") { + log.info("Okta status didn't return active, instead returned", oktaStatus); + return Health.down().build(); + } return Health.up().build(); } catch (JDBCConnectionException e) { diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java index 0bf9ec5c08..481e580e49 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java @@ -432,8 +432,9 @@ public Integer getUsersInSingleFacility(Facility facility) { return accessCount; } - public int getConnectTimeoutForHealthCheck() { - int FAKE_CONNECTION_TIMEOUT = 0; - return FAKE_CONNECTION_TIMEOUT; + @Override + public String getApplicationStatusForHealthCheck() { + String FAKE_STATUS = "ACTIVE"; + return FAKE_STATUS; } } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java index 7704a85786..be897fd1cf 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/LiveOktaRepository.java @@ -67,8 +67,6 @@ public class LiveOktaRepository implements OktaRepository { private final Application app; private final OrganizationExtractor extractor; private final CurrentTenantDataAccessContextHolder tenantDataContextHolder; - - private final ApplicationApi applicationApi; private final GroupApi groupApi; private final UserApi userApi; private final ApplicationGroupsApi applicationGroupsApi; @@ -89,7 +87,6 @@ public LiveOktaRepository( this.rolePrefix = authorizationProperties.getRolePrefix(); this.adminGroupName = authorizationProperties.getAdminGroupName(); - this.applicationApi = applicationApi; this.groupApi = groupApi; this.userApi = userApi; this.applicationGroupsApi = applicationGroupsApi; @@ -695,8 +692,9 @@ public PartialOktaUser findUser(String username) { .build(); } - public int getConnectTimeoutForHealthCheck() { - return applicationApi.getApiClient().getConnectTimeout(); + @Override + public String getApplicationStatusForHealthCheck() { + return app.getStatus().toString(); } private Optional getOrganizationRoleClaimsFromAuthorities( diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java index f7d38a2455..fc488573b7 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/OktaRepository.java @@ -77,5 +77,5 @@ List updateUserPrivilegesAndGroupAccess( PartialOktaUser findUser(String username); - int getConnectTimeoutForHealthCheck(); + String getApplicationStatusForHealthCheck(); } diff --git a/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java b/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java index 61ae972a84..97e67f0573 100644 --- a/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java +++ b/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java @@ -6,6 +6,7 @@ import gov.cdc.usds.simplereport.api.heathcheck.BackendAndDatabaseHealthIndicator; import gov.cdc.usds.simplereport.db.repository.BaseRepositoryTest; import gov.cdc.usds.simplereport.db.repository.FeatureFlagRepository; +import gov.cdc.usds.simplereport.idp.repository.OktaRepository; import java.sql.SQLException; import java.util.List; import lombok.RequiredArgsConstructor; @@ -20,22 +21,31 @@ @EnableConfigurationProperties class BackendAndDatabaseHealthIndicatorTest extends BaseRepositoryTest { - @SpyBean private FeatureFlagRepository mockRepo; + @SpyBean private FeatureFlagRepository mockFeatureFlagRepo; + @SpyBean private OktaRepository mockOktaRepo; @Autowired private BackendAndDatabaseHealthIndicator indicator; @Test - void health_succeedsWhenRepoDoesntThrow() { - when(mockRepo.findAll()).thenReturn(List.of()); + void health_succeedsWhenReposDoesntThrow() { + when(mockFeatureFlagRepo.findAll()).thenReturn(List.of()); + when(mockOktaRepo.getApplicationStatusForHealthCheck()).thenReturn("ACTIVE"); + assertThat(indicator.health()).isEqualTo(Health.up().build()); } @Test - void health_failsWhenRepoDoesntThrow() { + void health_failsWhenFeatureFlagRepoDoesntThrow() { JDBCConnectionException dbConnectionException = new JDBCConnectionException( "connection issue", new SQLException("some reason", "some state")); - when(mockRepo.findAll()).thenThrow(dbConnectionException); + when(mockFeatureFlagRepo.findAll()).thenThrow(dbConnectionException); + assertThat(indicator.health()).isEqualTo(Health.down().build()); + } + + @Test + void health_failsWhenOktaRepoDoesntReturnActive() { + when(mockOktaRepo.getApplicationStatusForHealthCheck()).thenReturn("INACTIVE"); assertThat(indicator.health()).isEqualTo(Health.down().build()); } } From 9463564e3ed39daa0e7594a0728eae7b9fb46356 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 11:25:58 -0500 Subject: [PATCH 47/59] string format and equality --- .../api/heathcheck/BackendAndDatabaseHealthIndicator.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java index b77a3bb57b..e10bb0c315 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -23,8 +23,8 @@ public Health health() { _ffRepo.findAll(); String oktaStatus = _oktaRepo.getApplicationStatusForHealthCheck(); - if (oktaStatus != "ACTIVE") { - log.info("Okta status didn't return active, instead returned", oktaStatus); + if (oktaStatus.equals("ACTIVE")) { + log.info("Okta status didn't return active, instead returned %s", oktaStatus); return Health.down().build(); } From 286ccc50634c9216ac222c5d6ac20e031aa51049 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 11:34:16 -0500 Subject: [PATCH 48/59] move literal to left --- .../api/heathcheck/BackendAndDatabaseHealthIndicator.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java index e10bb0c315..8cbfc13e62 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -16,6 +16,7 @@ public class BackendAndDatabaseHealthIndicator implements HealthIndicator { private final FeatureFlagRepository _ffRepo; private final OktaRepository _oktaRepo; + private static final String ACTIVE_LITERAL = "ACTIVE"; @Override public Health health() { @@ -23,8 +24,8 @@ public Health health() { _ffRepo.findAll(); String oktaStatus = _oktaRepo.getApplicationStatusForHealthCheck(); - if (oktaStatus.equals("ACTIVE")) { - log.info("Okta status didn't return active, instead returned %s", oktaStatus); + if (ACTIVE_LITERAL.equals(oktaStatus)) { + log.info("Okta status didn't return active, instead returned " + oktaStatus); return Health.down().build(); } From bc226442e7ce4eef5f3b73ccd8607998cf008b79 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 11:53:51 -0500 Subject: [PATCH 49/59] lol it's friday alright --- .../api/heathcheck/BackendAndDatabaseHealthIndicator.java | 6 +++--- .../simplereport/idp/repository/DemoOktaRepository.java | 5 +++-- .../healthcheck/BackendAndDatabaseHealthIndicatorTest.java | 3 ++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java index 8cbfc13e62..0a8cb830b2 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -16,7 +16,7 @@ public class BackendAndDatabaseHealthIndicator implements HealthIndicator { private final FeatureFlagRepository _ffRepo; private final OktaRepository _oktaRepo; - private static final String ACTIVE_LITERAL = "ACTIVE"; + public static final String ACTIVE_LITERAL = "ACTIVE"; @Override public Health health() { @@ -24,8 +24,8 @@ public Health health() { _ffRepo.findAll(); String oktaStatus = _oktaRepo.getApplicationStatusForHealthCheck(); - if (ACTIVE_LITERAL.equals(oktaStatus)) { - log.info("Okta status didn't return active, instead returned " + oktaStatus); + if (!ACTIVE_LITERAL.equals(oktaStatus)) { + log.info("Okta status didn't return ACTIVE, instead returned " + oktaStatus); return Health.down().build(); } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java index 481e580e49..883963df9c 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/idp/repository/DemoOktaRepository.java @@ -1,5 +1,7 @@ package gov.cdc.usds.simplereport.idp.repository; +import static gov.cdc.usds.simplereport.api.heathcheck.BackendAndDatabaseHealthIndicator.ACTIVE_LITERAL; + import com.okta.sdk.resource.model.UserStatus; import gov.cdc.usds.simplereport.api.CurrentTenantDataAccessContextHolder; import gov.cdc.usds.simplereport.api.model.errors.ConflictingUserException; @@ -434,7 +436,6 @@ public Integer getUsersInSingleFacility(Facility facility) { @Override public String getApplicationStatusForHealthCheck() { - String FAKE_STATUS = "ACTIVE"; - return FAKE_STATUS; + return ACTIVE_LITERAL; } } diff --git a/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java b/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java index 97e67f0573..ca35828467 100644 --- a/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java +++ b/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java @@ -1,5 +1,6 @@ package gov.cdc.usds.simplereport.api.healthcheck; +import static gov.cdc.usds.simplereport.api.heathcheck.BackendAndDatabaseHealthIndicator.ACTIVE_LITERAL; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; @@ -29,7 +30,7 @@ class BackendAndDatabaseHealthIndicatorTest extends BaseRepositoryTest { @Test void health_succeedsWhenReposDoesntThrow() { when(mockFeatureFlagRepo.findAll()).thenReturn(List.of()); - when(mockOktaRepo.getApplicationStatusForHealthCheck()).thenReturn("ACTIVE"); + when(mockOktaRepo.getApplicationStatusForHealthCheck()).thenReturn(ACTIVE_LITERAL); assertThat(indicator.health()).isEqualTo(Health.up().build()); } From aa87bd71b617f908d26dc3e660697b45af0530b4 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 12:14:34 -0500 Subject: [PATCH 50/59] add comment to document workflow --- .github/workflows/deployProd.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/deployProd.yml b/.github/workflows/deployProd.yml index 53249ea09a..4de13ef347 100644 --- a/.github/workflows/deployProd.yml +++ b/.github/workflows/deployProd.yml @@ -98,3 +98,5 @@ jobs: :siren-gif: Deploy to ${{ env.DEPLOY_ENV }} failed. ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} :siren-gif: webhook_url: ${{ secrets.SR_ALERTS_SLACK_WEBHOOK_URL }} user_map: $${{ secrets.SR_ALERTS_GITHUB_SLACK_MAP }} + +# a post-prod health check workflow is defined in smokeTestDeployProd. See the wiki for more details \ No newline at end of file From f74a96fe35a03d9d4035d51d9d1ef94dd20a0855 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 12:25:19 -0500 Subject: [PATCH 51/59] better comment --- .github/workflows/deployProd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deployProd.yml b/.github/workflows/deployProd.yml index 4de13ef347..2b5f99b51a 100644 --- a/.github/workflows/deployProd.yml +++ b/.github/workflows/deployProd.yml @@ -99,4 +99,4 @@ jobs: webhook_url: ${{ secrets.SR_ALERTS_SLACK_WEBHOOK_URL }} user_map: $${{ secrets.SR_ALERTS_GITHUB_SLACK_MAP }} -# a post-prod health check workflow is defined in smokeTestDeployProd. See the wiki for more details \ No newline at end of file +# a post-prod health check workflow is defined in smokeTestDeployProd. See the Alert response wiki page for more details \ No newline at end of file From 503b3fdc8f056b9dc2a989cc7f9d4248ff35a7d4 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 14:51:21 -0500 Subject: [PATCH 52/59] use base domain env var instead --- .github/actions/post-deploy-smoke-test/action.yml | 6 +++--- .github/workflows/smokeTestDeployProd.yml | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/actions/post-deploy-smoke-test/action.yml b/.github/actions/post-deploy-smoke-test/action.yml index db90eb0c72..35236a7437 100644 --- a/.github/actions/post-deploy-smoke-test/action.yml +++ b/.github/actions/post-deploy-smoke-test/action.yml @@ -1,8 +1,8 @@ name: Smoke test post deploy description: Invoke a script that visits a deploy smoke check page that displays whether the backend / db are healthy. inputs: - deploy-env: - description: The environment being deployed (e.g. "prod" or "test") + base_domain_name: + description: The domain where the application is deployed (e.g. "simplereport.gov" or "test.simplereport.gov") required: true runs: using: composite @@ -12,7 +12,7 @@ runs: working-directory: frontend run: | touch .env - echo REACT_APP_BASE_URL=https://${{ inputs.deploy-env }}.simplereport.gov >> .env.production.local + echo REACT_APP_BASE_URL=${{ inputs.base_domain_name }}>> .env.production.local - name: Run smoke test script shell: bash working-directory: frontend diff --git a/.github/workflows/smokeTestDeployProd.yml b/.github/workflows/smokeTestDeployProd.yml index 31055e18c6..5d7e04121b 100644 --- a/.github/workflows/smokeTestDeployProd.yml +++ b/.github/workflows/smokeTestDeployProd.yml @@ -9,12 +9,11 @@ on: env: NODE_VERSION: 18 - # prod env variable - DEPLOY_ENV: www jobs: smoke-test-front-and-back-end: runs-on: ubuntu-latest + environment: Production steps: - uses: actions/checkout@v4 - name: Use Node.js @@ -32,7 +31,7 @@ jobs: - name: Smoke test the env uses: ./.github/actions/post-deploy-smoke-test with: - deploy-env: ${{ env.DEPLOY_ENV }} + base_domain_name: ${{ env.BASE_DOMAIN_NAME }} slack_alert: runs-on: ubuntu-latest if: failure() From 663933ad8dc0b7d42a09696c95b4b87e0c0d638e Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Fri, 15 Dec 2023 14:57:21 -0500 Subject: [PATCH 53/59] set env var --- .github/workflows/smokeTestDeployProd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/smokeTestDeployProd.yml b/.github/workflows/smokeTestDeployProd.yml index 5d7e04121b..dc3bd10d37 100644 --- a/.github/workflows/smokeTestDeployProd.yml +++ b/.github/workflows/smokeTestDeployProd.yml @@ -31,7 +31,7 @@ jobs: - name: Smoke test the env uses: ./.github/actions/post-deploy-smoke-test with: - base_domain_name: ${{ env.BASE_DOMAIN_NAME }} + base_domain_name: ${{ vars.BASE_DOMAIN_NAME }} slack_alert: runs-on: ubuntu-latest if: failure() From 215afe57859261aecb97c64e789f3f30f63fe64f Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Tue, 19 Dec 2023 09:42:13 -0600 Subject: [PATCH 54/59] don't hard code node version --- .github/workflows/smokeTestDeployProd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/smokeTestDeployProd.yml b/.github/workflows/smokeTestDeployProd.yml index dc3bd10d37..586fe3df79 100644 --- a/.github/workflows/smokeTestDeployProd.yml +++ b/.github/workflows/smokeTestDeployProd.yml @@ -19,7 +19,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v3 with: - node-version: '18' + node-version: ${{env.NODE_VERSION}} - name: Cache yarn uses: actions/cache@v3.3.2 with: From be2209fa5a8481e9e6e36b92238c6bd7a5dabfcf Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Mon, 8 Jan 2024 09:20:25 -0500 Subject: [PATCH 55/59] add endpoint annotation --- .../BackendAndDatabaseHealthIndicator.java | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java index 0a8cb830b2..8db7168706 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -6,36 +6,38 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.JDBCConnectionException; +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.stereotype.Component; @Component("backend-and-db-smoke-test") +@Endpoint @Slf4j @RequiredArgsConstructor public class BackendAndDatabaseHealthIndicator implements HealthIndicator { - private final FeatureFlagRepository _ffRepo; - private final OktaRepository _oktaRepo; - public static final String ACTIVE_LITERAL = "ACTIVE"; + private final FeatureFlagRepository _ffRepo; + private final OktaRepository _oktaRepo; + public static final String ACTIVE_LITERAL = "ACTIVE"; - @Override - public Health health() { - try { - _ffRepo.findAll(); - String oktaStatus = _oktaRepo.getApplicationStatusForHealthCheck(); + @Override + public Health health() { + try { + _ffRepo.findAll(); + String oktaStatus = _oktaRepo.getApplicationStatusForHealthCheck(); - if (!ACTIVE_LITERAL.equals(oktaStatus)) { - log.info("Okta status didn't return ACTIVE, instead returned " + oktaStatus); - return Health.down().build(); - } + if (!ACTIVE_LITERAL.equals(oktaStatus)) { + log.info("Okta status didn't return ACTIVE, instead returned " + oktaStatus); + return Health.down().build(); + } - return Health.up().build(); - } catch (JDBCConnectionException e) { - return Health.down().build(); - // Okta API call errored - } catch (ApiException e) { - log.info(e.getMessage()); - return Health.down().build(); + return Health.up().build(); + } catch (JDBCConnectionException e) { + return Health.down().build(); + // Okta API call errored + } catch (ApiException e) { + log.info(e.getMessage()); + return Health.down().build(); + } } - } } From f3a3ee3bbd34b4280562dd39a001a7b6576a98ee Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Mon, 8 Jan 2024 10:11:14 -0500 Subject: [PATCH 56/59] remove filter chain reference to endpoint --- .../BackendAndDatabaseHealthIndicator.java | 42 +++++++++---------- .../config/SecurityConfiguration.java | 3 -- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java index 8db7168706..0a8cb830b2 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -6,38 +6,36 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.JDBCConnectionException; -import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.stereotype.Component; @Component("backend-and-db-smoke-test") -@Endpoint @Slf4j @RequiredArgsConstructor public class BackendAndDatabaseHealthIndicator implements HealthIndicator { - private final FeatureFlagRepository _ffRepo; - private final OktaRepository _oktaRepo; - public static final String ACTIVE_LITERAL = "ACTIVE"; + private final FeatureFlagRepository _ffRepo; + private final OktaRepository _oktaRepo; + public static final String ACTIVE_LITERAL = "ACTIVE"; - @Override - public Health health() { - try { - _ffRepo.findAll(); - String oktaStatus = _oktaRepo.getApplicationStatusForHealthCheck(); + @Override + public Health health() { + try { + _ffRepo.findAll(); + String oktaStatus = _oktaRepo.getApplicationStatusForHealthCheck(); - if (!ACTIVE_LITERAL.equals(oktaStatus)) { - log.info("Okta status didn't return ACTIVE, instead returned " + oktaStatus); - return Health.down().build(); - } + if (!ACTIVE_LITERAL.equals(oktaStatus)) { + log.info("Okta status didn't return ACTIVE, instead returned " + oktaStatus); + return Health.down().build(); + } - return Health.up().build(); - } catch (JDBCConnectionException e) { - return Health.down().build(); - // Okta API call errored - } catch (ApiException e) { - log.info(e.getMessage()); - return Health.down().build(); - } + return Health.up().build(); + } catch (JDBCConnectionException e) { + return Health.down().build(); + // Okta API call errored + } catch (ApiException e) { + log.info(e.getMessage()); + return Health.down().build(); } + } } diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/config/SecurityConfiguration.java b/backend/src/main/java/gov/cdc/usds/simplereport/config/SecurityConfiguration.java index 07a737850a..15fdeee263 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/config/SecurityConfiguration.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/config/SecurityConfiguration.java @@ -1,7 +1,6 @@ package gov.cdc.usds.simplereport.config; import com.okta.spring.boot.oauth.Okta; -import gov.cdc.usds.simplereport.api.heathcheck.BackendAndDatabaseHealthIndicator; import gov.cdc.usds.simplereport.service.model.IdentityAttributes; import gov.cdc.usds.simplereport.service.model.IdentitySupplier; import lombok.extern.slf4j.Slf4j; @@ -58,8 +57,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers(EndpointRequest.to(InfoEndpoint.class)) .permitAll() - .requestMatchers(EndpointRequest.to(BackendAndDatabaseHealthIndicator.class)) - .permitAll() // Patient experience authorization is handled in PatientExperienceController // If this configuration changes, please update the documentation on both sides .requestMatchers(HttpMethod.POST, WebConfiguration.PATIENT_EXPERIENCE) From 38fb6194791d0a3047a940bc7cdb84c16af9a684 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Mon, 8 Jan 2024 10:52:13 -0500 Subject: [PATCH 57/59] change var pass in to match new version --- .github/workflows/smokeTestDeployProd.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/smokeTestDeployProd.yml b/.github/workflows/smokeTestDeployProd.yml index 46e054509e..a3377f36f7 100644 --- a/.github/workflows/smokeTestDeployProd.yml +++ b/.github/workflows/smokeTestDeployProd.yml @@ -9,8 +9,7 @@ on: # - completed env: - NODE_VERSION: 18 - DEPLOY_ENV: dev6 + BASE_DOMAIN_NAME: www.dev6.simplereport.gov jobs: smoke-test-front-and-back-end: @@ -32,11 +31,11 @@ jobs: - name: Smoke test the env uses: ./.github/actions/post-deploy-smoke-test with: - deploy-env: ${{ env.DEPLOY_ENV }} + base_domain_name: ${{ env.BASE_DOMAIN_NAME }} slack_alert: runs-on: ubuntu-latest if: failure() - needs: [smoke-test-front-and-back-end] + needs: [ smoke-test-front-and-back-end ] steps: - uses: actions/checkout@v4 - name: Send alert to Slack From d1410d5e064247dd4241674e0399bf4c63c124bc Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Mon, 8 Jan 2024 10:54:36 -0500 Subject: [PATCH 58/59] second try --- .github/workflows/smokeTestDeployProd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/smokeTestDeployProd.yml b/.github/workflows/smokeTestDeployProd.yml index a3377f36f7..3d2dec111e 100644 --- a/.github/workflows/smokeTestDeployProd.yml +++ b/.github/workflows/smokeTestDeployProd.yml @@ -9,7 +9,7 @@ on: # - completed env: - BASE_DOMAIN_NAME: www.dev6.simplereport.gov + BASE_DOMAIN_NAME: https://dev6.simplereport.gov jobs: smoke-test-front-and-back-end: From a501da62bb53c845f9750ec5718b8b2eabc8eb31 Mon Sep 17 00:00:00 2001 From: Bob Zhao Date: Mon, 8 Jan 2024 11:11:58 -0500 Subject: [PATCH 59/59] add a third argument catch --- .../heathcheck/BackendAndDatabaseHealthIndicator.java | 7 +++++-- .../BackendAndDatabaseHealthIndicatorTest.java | 10 +++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java index 0a8cb830b2..a2466765ac 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/heathcheck/BackendAndDatabaseHealthIndicator.java @@ -28,12 +28,15 @@ public Health health() { log.info("Okta status didn't return ACTIVE, instead returned " + oktaStatus); return Health.down().build(); } - return Health.up().build(); + } catch (IllegalArgumentException e) { + // reach into the ff repository returned a bad value + return Health.down().build(); } catch (JDBCConnectionException e) { + // db connection issue return Health.down().build(); - // Okta API call errored } catch (ApiException e) { + // Okta API call errored log.info(e.getMessage()); return Health.down().build(); } diff --git a/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java b/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java index ca35828467..9e0cfa936c 100644 --- a/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java +++ b/backend/src/test/java/gov/cdc/usds/simplereport/api/healthcheck/BackendAndDatabaseHealthIndicatorTest.java @@ -36,7 +36,7 @@ void health_succeedsWhenReposDoesntThrow() { } @Test - void health_failsWhenFeatureFlagRepoDoesntThrow() { + void health_failsWhenDBConnectionThrows() { JDBCConnectionException dbConnectionException = new JDBCConnectionException( "connection issue", new SQLException("some reason", "some state")); @@ -44,6 +44,14 @@ void health_failsWhenFeatureFlagRepoDoesntThrow() { assertThat(indicator.health()).isEqualTo(Health.down().build()); } + @Test + void health_failsWhenFeatureFlagRepoThrows() { + IllegalArgumentException dbConnectionException = + new IllegalArgumentException("some argument message"); + when(mockFeatureFlagRepo.findAll()).thenThrow(dbConnectionException); + assertThat(indicator.health()).isEqualTo(Health.down().build()); + } + @Test void health_failsWhenOktaRepoDoesntReturnActive() { when(mockOktaRepo.getApplicationStatusForHealthCheck()).thenReturn("INACTIVE");