From bbbc3e14d0ad455e0d46ba46d30b06498be635be Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Fri, 20 Dec 2024 07:55:59 -0700 Subject: [PATCH 01/18] Frontend/vue i18n update (#380) --- webroot/package.json | 2 +- webroot/src/models/Compact/Compact.model.ts | 26 +----------- webroot/src/models/License/License.model.ts | 13 +----- webroot/src/models/Licensee/Licensee.model.ts | 13 +----- webroot/src/models/State/State.model.ts | 13 +----- webroot/vue.config.js | 13 ------ webroot/yarn.lock | 42 +++++++++---------- 7 files changed, 27 insertions(+), 95 deletions(-) diff --git a/webroot/package.json b/webroot/package.json index 2d4bb68a6..eed3011fd 100644 --- a/webroot/package.json +++ b/webroot/package.json @@ -32,7 +32,7 @@ "uuid": "^8.3.2", "vue": "^3.1.0", "vue-facing-decorator": "^3.0.4", - "vue-i18n": "^9.13.1", + "vue-i18n": "^10.0.5", "vue-responsiveness": "^0.2.0", "vue-router": "^4.3.0", "vue3-lazyload": "^0.3.8", diff --git a/webroot/src/models/Compact/Compact.model.ts b/webroot/src/models/Compact/Compact.model.ts index 2811af28b..0ffc3cb3f 100644 --- a/webroot/src/models/Compact/Compact.model.ts +++ b/webroot/src/models/Compact/Compact.model.ts @@ -60,18 +60,7 @@ export class Compact implements InterfaceCompactCreate { // Helper methods public name(): string { - let compacts = this.$tm('compacts') || []; - - /* istanbul ignore next */ // i18n translations are not functions in the test runner environment, so this block won't be traversed - if (typeof compacts[0]?.key === 'function') { - const normalize = ([value]) => value; - - compacts = compacts.map((translate) => ({ - key: translate.key({ normalize }), - name: translate.name({ normalize }), - })); - } - + const compacts = this.$tm('compacts') || []; const compact = compacts.find((translate) => translate.key === this.type); const compactName = compact?.name || ''; @@ -79,18 +68,7 @@ export class Compact implements InterfaceCompactCreate { } public abbrev(): string { - let compacts = this.$tm('compacts') || []; - - /* istanbul ignore next */ // i18n translations are not functions in the test runner environment, so this block won't be traversed - if (typeof compacts[0]?.key === 'function') { - const normalize = ([value]) => value; - - compacts = compacts.map((translate) => ({ - key: translate.key({ normalize }), - abbrev: translate.abbrev({ normalize }), - })); - } - + const compacts = this.$tm('compacts') || []; const compact = compacts.find((translate) => translate.key === this.type); const compactAbbrev = compact?.abbrev || ''; diff --git a/webroot/src/models/License/License.model.ts b/webroot/src/models/License/License.model.ts index a1643787e..80a5495d9 100644 --- a/webroot/src/models/License/License.model.ts +++ b/webroot/src/models/License/License.model.ts @@ -97,18 +97,7 @@ export class License implements InterfaceLicense { } public occupationName(): string { - let occupations = this.$tm('licensing.occupations') || []; - - /* istanbul ignore next */ // i18n translations are not functions in the test runner environment, so this block won't be traversed - if (typeof occupations[0]?.key === 'function') { - const normalize = ([value]) => value; - - occupations = occupations.map((translate) => ({ - key: translate.key({ normalize }), - name: translate.name({ normalize }), - })); - } - + const occupations = this.$tm('licensing.occupations') || []; const occupation = occupations.find((translate) => translate.key === this.occupation); const occupationName = occupation?.name || ''; diff --git a/webroot/src/models/Licensee/Licensee.model.ts b/webroot/src/models/Licensee/Licensee.model.ts index 48f696118..21a9915fd 100644 --- a/webroot/src/models/Licensee/Licensee.model.ts +++ b/webroot/src/models/Licensee/Licensee.model.ts @@ -176,18 +176,7 @@ export class Licensee implements InterfaceLicensee { } public occupationName(): string { - let occupations = this.$tm('licensing.occupations') || []; - - /* istanbul ignore next */ // i18n translations are not functions in the test runner environment, so this block won't be traversed - if (typeof occupations[0]?.key === 'function') { - const normalize = ([value]) => value; - - occupations = occupations.map((translate) => ({ - key: translate.key({ normalize }), - name: translate.name({ normalize }), - })); - } - + const occupations = this.$tm('licensing.occupations') || []; const occupation = occupations.find((translate) => translate.key === this.occupation); const occupationName = occupation?.name || ''; diff --git a/webroot/src/models/State/State.model.ts b/webroot/src/models/State/State.model.ts index 30f66112c..91c6462c3 100644 --- a/webroot/src/models/State/State.model.ts +++ b/webroot/src/models/State/State.model.ts @@ -40,18 +40,7 @@ export class State implements InterfaceStateCreate { // Helper methods public name(): string { const abbrev = (this.abbrev || '').toUpperCase() || ''; - let states = this.$tm('common.states') || []; - - /* istanbul ignore next */ // i18n translations are not functions in the test runner environment, so this block won't be traversed - if (typeof states[0]?.abbrev === 'function') { - const normalize = ([value]) => value; - - states = states.map((st) => ({ - abbrev: st.abbrev({ normalize }), - full: st.full({ normalize }), - })); - } - + const states = this.$tm('common.states') || []; const state = states.find((st) => st.abbrev === abbrev); const stateName = state?.full || this.$t('common.stateUnknown'); diff --git a/webroot/vue.config.js b/webroot/vue.config.js index c667af4b6..8c4a00d26 100644 --- a/webroot/vue.config.js +++ b/webroot/vue.config.js @@ -13,7 +13,6 @@ const path = require('path'); const fs = require('fs'); const FaviconsWebpackPlugin = require('favicons-webpack-plugin'); const StyleLintPlugin = require('stylelint-webpack-plugin'); -const VueI18nPlugin = require('@intlify/unplugin-vue-i18n/lib/webpack.cjs'); const env = process.env.NODE_ENV; const ENV_PRODUCTION = 'production'; @@ -147,15 +146,6 @@ const stylelintPlugin = new StyleLintPlugin({ files: 'src/**/*.less', }); -/** - * vue-i18n advanced configuration (https://vue-i18n.intlify.dev/guide/advanced/optimization#configure-plugin-for-webpack) - * @intlify/unplugin-vue-i18n configuration (https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n#intlifyunplugin-vue-i18n) - * @type {VueI18nPlugin} - */ -const vueI18nPlugin = VueI18nPlugin({ - include: path.resolve(__dirname, './src/locales/*.json'), -}); - /** * Inject common style resources into a module rule. * @param {WebpackModuleRule} rule The webpack module rule. @@ -306,9 +296,6 @@ module.exports = { plugins: [ faviconsPlugin, stylelintPlugin, - // The runtime-only version of vue-i18n conflicts with test-runner, but is required for built server versions due to CSP. - // Conditionally adding the runtime-ify plugin with a linter exception for the automated test context. - ... (env !== ENV_TEST) ? [vueI18nPlugin] : [], // eslint-disable-line rest-spread-spacing ], resolve: { alias: { diff --git a/webroot/yarn.lock b/webroot/yarn.lock index 37040f53d..d9b789b13 100644 --- a/webroot/yarn.lock +++ b/webroot/yarn.lock @@ -2320,20 +2320,20 @@ source-map "0.6.1" yaml-eslint-parser "^0.3.2" -"@intlify/core-base@9.14.2": - version "9.14.2" - resolved "https://registry.yarnpkg.com/@intlify/core-base/-/core-base-9.14.2.tgz#2c074506ea72425e937f911c95c0d845b43f7fdf" - integrity sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ== +"@intlify/core-base@10.0.5": + version "10.0.5" + resolved "https://registry.yarnpkg.com/@intlify/core-base/-/core-base-10.0.5.tgz#c4d992381f8c3a50c79faf67be3404b399c3be28" + integrity sha512-F3snDTQs0MdvnnyzTDTVkOYVAZOE/MHwRvF7mn7Jw1yuih4NrFYLNYIymGlLmq4HU2iIdzYsZ7f47bOcwY73XQ== dependencies: - "@intlify/message-compiler" "9.14.2" - "@intlify/shared" "9.14.2" + "@intlify/message-compiler" "10.0.5" + "@intlify/shared" "10.0.5" -"@intlify/message-compiler@9.14.2": - version "9.14.2" - resolved "https://registry.yarnpkg.com/@intlify/message-compiler/-/message-compiler-9.14.2.tgz#7217842ea1875d80bbf0f708e9b3ef5ad7c57a03" - integrity sha512-YsKKuV4Qv4wrLNsvgWbTf0E40uRv+Qiw1BeLQ0LAxifQuhiMe+hfTIzOMdWj/ZpnTDj4RSZtkXjJM7JDiiB5LQ== +"@intlify/message-compiler@10.0.5": + version "10.0.5" + resolved "https://registry.yarnpkg.com/@intlify/message-compiler/-/message-compiler-10.0.5.tgz#4eeace9f4560020d5e5d77f32bed7755e71d8efd" + integrity sha512-6GT1BJ852gZ0gItNZN2krX5QAmea+cmdjMvsWohArAZ3GmHdnNANEcF9JjPXAMRtQ6Ux5E269ymamg/+WU6tQA== dependencies: - "@intlify/shared" "9.14.2" + "@intlify/shared" "10.0.5" source-map-js "^1.0.2" "@intlify/message-compiler@next": @@ -2349,10 +2349,10 @@ resolved "https://registry.yarnpkg.com/@intlify/shared/-/shared-10.0.0-beta.5.tgz#4b87237ba2091f53275368a7ecacc321b37cf258" integrity sha512-g9bq5Y1bOcC9qxtNk4UWtF3sXm6Wh0fGISb7vD5aLyF7yQv7ZFjxQjJzBP2GqG/9+PAGYutqjP1GGadNqFtyAQ== -"@intlify/shared@9.14.2": - version "9.14.2" - resolved "https://registry.yarnpkg.com/@intlify/shared/-/shared-9.14.2.tgz#f7dceea32db44c9253e3f965745a42a5cb3a1883" - integrity sha512-uRAHAxYPeF+G5DBIboKpPgC/Waecd4Jz8ihtkpJQD5ycb5PwXp0k/+hBGl5dAjwF7w+l74kz/PKA8r8OK//RUw== +"@intlify/shared@10.0.5": + version "10.0.5" + resolved "https://registry.yarnpkg.com/@intlify/shared/-/shared-10.0.5.tgz#1b46ca8b541f03508fe28da8f34e4bb85506d6bc" + integrity sha512-bmsP4L2HqBF6i6uaMqJMcFBONVjKt+siGluRq4Ca4C0q7W2eMaVZr8iCgF9dKbcVXutftkC7D6z2SaSMmLiDyA== "@intlify/unplugin-vue-i18n@^0.6.2": version "0.6.2" @@ -12339,13 +12339,13 @@ vue-hot-reload-api@^2.3.0: resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz#532955cc1eb208a3d990b3a9f9a70574657e08f2" integrity sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog== -vue-i18n@^9.13.1: - version "9.14.2" - resolved "https://registry.yarnpkg.com/vue-i18n/-/vue-i18n-9.14.2.tgz#e7f657664fcb3ccf99ecea684fc56e0f8e5335ce" - integrity sha512-JK9Pm80OqssGJU2Y6F7DcM8RFHqVG4WkuCqOZTVsXkEzZME7ABejAUqUdA931zEBedc4thBgSUWxeQh4uocJAQ== +vue-i18n@^10.0.5: + version "10.0.5" + resolved "https://registry.yarnpkg.com/vue-i18n/-/vue-i18n-10.0.5.tgz#fdf4e6c7b669e80cfa3a12ed9625e2b46671cdf0" + integrity sha512-9/gmDlCblz3i8ypu/afiIc/SUIfTTE1mr0mZhb9pk70xo2csHAM9mp2gdQ3KD2O0AM3Hz/5ypb+FycTj/lHlPQ== dependencies: - "@intlify/core-base" "9.14.2" - "@intlify/shared" "9.14.2" + "@intlify/core-base" "10.0.5" + "@intlify/shared" "10.0.5" "@vue/devtools-api" "^6.5.0" vue-loader@^17.0.0: From dd33c7896e035bbf407e1bbc7baaa6437604bcdf Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Fri, 20 Dec 2024 07:56:24 -0700 Subject: [PATCH 02/18] Frontend/Fix search paging case (#384) --- .../LicenseeList/LicenseeList.spec.ts | 3 +- .../Licensee/LicenseeList/LicenseeList.ts | 18 ++++-- .../Licensee/LicenseeSearch/LicenseeSearch.ts | 4 +- webroot/src/store/license/license.actions.ts | 6 ++ .../src/store/license/license.mutations.ts | 20 ++++++ webroot/src/store/license/license.spec.ts | 61 +++++++++++++++++++ webroot/src/store/license/license.state.ts | 8 +++ 7 files changed, 112 insertions(+), 8 deletions(-) diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts b/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts index 0543c5c6f..f1c92a722 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts @@ -132,8 +132,9 @@ describe('LicenseeList component', async () => { }; await component.$store.dispatch('user/setCurrentCompact', new Compact({ type: CompactType.ASLP })); + await component.$store.dispatch('license/setStoreSearch', testParams); - const requestConfig = await component.fetchListData(testParams); + const requestConfig = await component.fetchListData(); expect(requestConfig).to.matchPattern({ compact: CompactType.ASLP, diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts b/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts index e094909ee..7635e1fa3 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts @@ -38,7 +38,6 @@ class LicenseeList extends Vue { // hasSearched = false; shouldShowSearchModal = false; - searchParams: LicenseSearch = {}; isInitialFetchCompleted = false; prevKey = ''; nextKey = ''; @@ -78,6 +77,10 @@ class LicenseeList extends Vue { return this.$store.state.license; } + get searchParams(): LicenseSearch { + return this.licenseStore.search; + } + get searchDisplayFirstName(): string { return this.searchParams.firstName || ''; } @@ -164,8 +167,12 @@ class LicenseeList extends Vue { } handleSearch(params: LicenseSearch): void { - this.fetchListData(params); - this.searchParams = params; + this.$store.dispatch('license/setStoreSearch', params); + this.$store.dispatch('pagination/updatePaginationPage', { + paginationId: this.listId, + newPage: 1, + }); + this.fetchListData(); if (!this.hasSearched) { this.hasSearched = true; @@ -175,7 +182,7 @@ class LicenseeList extends Vue { } resetSearch(): void { - this.searchParams = {}; + this.$store.dispatch('license/resetStoreSearch'); this.toggleSearch(); } @@ -223,7 +230,8 @@ class LicenseeList extends Vue { } } - async fetchListData(searchParams?: LicenseSearch) { + async fetchListData() { + const { searchParams } = this; const sorting = this.sortingStore.sortingMap[this.listId]; const { option, direction } = sorting || {}; const pagination = this.paginationStore.paginationMap[this.listId]; diff --git a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts index 12c5cd379..8e5565da1 100644 --- a/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts +++ b/webroot/src/components/Licensee/LicenseeSearch/LicenseeSearch.ts @@ -24,8 +24,8 @@ import Joi from 'joi'; export interface LicenseSearch { firstName?: string; lastName?: string; - ssn?: string, - state?: string, + ssn?: string; + state?: string; } @Component({ diff --git a/webroot/src/store/license/license.actions.ts b/webroot/src/store/license/license.actions.ts index 1c0205d40..053fe4f43 100644 --- a/webroot/src/store/license/license.actions.ts +++ b/webroot/src/store/license/license.actions.ts @@ -75,6 +75,12 @@ export default { setStoreLicensee: ({ commit }, licensee) => { commit(MutationTypes.STORE_UPDATE_LICENSEE, licensee); }, + setStoreSearch: ({ commit }, search) => { + commit(MutationTypes.STORE_UPDATE_SEARCH, search); + }, + resetStoreSearch: ({ commit }) => { + commit(MutationTypes.STORE_RESET_SEARCH); + }, // RESET LICENSEES STORE STATE resetStoreLicense: ({ commit }) => { commit(MutationTypes.STORE_RESET_LICENSE); diff --git a/webroot/src/store/license/license.mutations.ts b/webroot/src/store/license/license.mutations.ts index 20989d0a9..2099e90b5 100644 --- a/webroot/src/store/license/license.mutations.ts +++ b/webroot/src/store/license/license.mutations.ts @@ -4,6 +4,7 @@ // // Created by InspiringApps on 7/2/24. // +import { LicenseSearch } from '@components/Licensee/LicenseeSearch/LicenseeSearch.vue'; export enum MutationTypes { GET_LICENSEES_REQUEST = '[License] Get Licensees Request', @@ -18,6 +19,8 @@ export enum MutationTypes { GET_LICENSEE_SUCCESS = '[License] Get Licensee Success', STORE_UPDATE_LICENSEE = '[License] Updated Licensee in store', STORE_REMOVE_LICENSEE = '[License] Remove Licensee from store', + STORE_UPDATE_SEARCH = '[License] Update search params', + STORE_RESET_SEARCH = '[License] Reset search params', STORE_RESET_LICENSE = '[License] Reset license store', } @@ -91,10 +94,27 @@ export default { console.warn('Cannot remove Licensee with null ID from the store:'); } }, + [MutationTypes.STORE_UPDATE_SEARCH]: (state: any, search: LicenseSearch) => { + state.search = search; + }, + [MutationTypes.STORE_RESET_SEARCH]: (state: any) => { + state.search = { + firstName: '', + lastName: '', + ssn: '', + state: '', + }; + }, [MutationTypes.STORE_RESET_LICENSE]: (state: any) => { state.model = null; state.total = 0; state.isLoading = false; state.error = null; + state.search = { + firstName: '', + lastName: '', + ssn: '', + state: '', + }; }, }; diff --git a/webroot/src/store/license/license.spec.ts b/webroot/src/store/license/license.spec.ts index 42c3d59be..93b5444b0 100644 --- a/webroot/src/store/license/license.spec.ts +++ b/webroot/src/store/license/license.spec.ts @@ -137,12 +137,50 @@ describe('License Store Mutations', () => { expect(state.model).to.matchPattern([]); }); + it('should successfully update license search values', () => { + const state = {}; + const search = { + firstName: 'test', + lastName: 'test', + ssn: 'test', + state: 'test', + }; + + mutations[MutationTypes.STORE_UPDATE_SEARCH](state, search); + + expect(state.search).to.matchPattern(search); + }); + it('should successfully reset license search values', () => { + const state = { + search: { + firstName: 'test', + lastName: 'test', + ssn: 'test', + state: 'test', + }, + }; + + mutations[MutationTypes.STORE_RESET_SEARCH](state); + + expect(state.search).to.matchPattern({ + firstName: '', + lastName: '', + ssn: '', + state: '', + }); + }); it('should successfully reset license store', () => { const state = { model: [{ id: 1 }], total: 1, isLoading: true, error: new Error(), + search: { + firstName: 'test', + lastName: 'test', + ssn: 'test', + state: 'test', + }, }; mutations[MutationTypes.STORE_RESET_LICENSE](state); @@ -152,6 +190,12 @@ describe('License Store Mutations', () => { total: 0, isLoading: false, error: null, + search: { + firstName: '', + lastName: '', + ssn: '', + state: '', + }, }); }); }); @@ -269,6 +313,23 @@ describe('License Store Actions', async () => { expect(commit.calledOnce).to.equal(true); expect(commit.firstCall.args).to.matchPattern([MutationTypes.STORE_UPDATE_LICENSEE, licensee]); }); + it('should successfully update search', () => { + const commit = sinon.spy(); + const search = { firstName: 'test' }; + + actions.setStoreSearch({ commit }, search); + + expect(commit.calledOnce).to.equal(true); + expect(commit.firstCall.args).to.matchPattern([MutationTypes.STORE_UPDATE_SEARCH, search]); + }); + it('should successfully reset search', () => { + const commit = sinon.spy(); + + actions.resetStoreSearch({ commit }); + + expect(commit.calledOnce).to.equal(true); + expect(commit.firstCall.args).to.matchPattern([MutationTypes.STORE_RESET_SEARCH]); + }); it('should successfully reset store', () => { const commit = sinon.spy(); diff --git a/webroot/src/store/license/license.state.ts b/webroot/src/store/license/license.state.ts index a0d91f6f7..4a292cb19 100644 --- a/webroot/src/store/license/license.state.ts +++ b/webroot/src/store/license/license.state.ts @@ -4,6 +4,7 @@ // // Created by InspiringApps on 7/2/24. // +import { LicenseSearch } from '@components/Licensee/LicenseeSearch/LicenseeSearch.vue'; export interface State { model: Array | null; @@ -12,6 +13,7 @@ export interface State { lastKey: string | null; isLoading: boolean; error: any | null; + search: LicenseSearch; } export const state: State = { @@ -21,4 +23,10 @@ export const state: State = { lastKey: null, isLoading: false, error: null, + search: { + firstName: '', + lastName: '', + ssn: '', + state: '', + }, }; From 5977b089daf6be818ac486f9caa5570e007d7e5f Mon Sep 17 00:00:00 2001 From: Dana Stiefel <35410938+ChiefStief@users.noreply.github.com> Date: Fri, 20 Dec 2024 12:58:36 -0700 Subject: [PATCH 03/18] Feat/auth tweaks (#388) ### Requirements List - Python 3.12+ - A working sandbox account, once you have access you can get it set up by: 0) Create AWS CLI profile that has access to your sandbox and login as that 1) Follow the modified readme in compact-connect 2) Run the create_staff_user.py script 3) Validate emails to your ia account in ses so that you can get your temp passwords for your licensee user. 4) Manually create a licensee user in the relevant user pool and associated db records (licensee and license). I can help with this when needed. 5) You may need to do your initial deploy twice as there is a race condition that fails the first time but is met the second time ### Description List - Added mock login support - Added route guarding based on role - Modified logout flow to hit both hosted logout pages by hopping to and from logout page that will handle logout cases - Fixed 401 interceptor handler to actually fully logout ### Testing List - `yarn test:unit:all` should run without errors or warnings - `yarn serve` should run without errors or warnings - `yarn build` should run without errors or warnings - For API configuration changes: CDK tests back/bin/run_tests.sh - Code review - Try logging in as a licensee, staff, both and logout and verify logging out logs out all users - Try the mockAPI flow in the same way - Produce a 401 error by modifying your access or id token (staff and licensee respectively) and refreshing and confirm the 401 causes the user to totally logout - Try going to routes that should not be available to a logged in user (both staff and licensee) and confirm they are redirected to their part of the site Closes #321 --------- Co-authored-by: Dana Stiefel Co-authored-by: Joshua Kravitz --- backend/compact-connect/README.md | 4 +- .../compact-connect/bin/create_staff_user.py | 1 + .../common_constructs/user_pool.py | 37 +++++++++- .../python/common/requirements-dev.txt | 12 ++-- .../lambdas/python/common/requirements.txt | 6 +- .../custom-resources/requirements-dev.txt | 12 ++-- .../python/data-events/requirements-dev.in | 2 +- .../python/data-events/requirements-dev.txt | 16 +++-- .../provider-data-v1/requirements-dev.txt | 12 ++-- .../python/purchases/requirements-dev.txt | 12 ++-- .../lambdas/python/purchases/requirements.txt | 2 +- .../staff-user-pre-token/requirements-dev.txt | 12 ++-- .../python/staff-users/requirements-dev.txt | 14 ++-- backend/compact-connect/requirements-dev.txt | 12 ++-- backend/compact-connect/requirements.txt | 12 ++-- .../stacks/persistent_stack/provider_users.py | 17 +---- .../stacks/persistent_stack/staff_users.py | 17 +---- backend/multi-account/requirements-dev.txt | 2 +- backend/multi-account/requirements.txt | 8 +-- webroot/README.md | 24 +++++++ webroot/src/app.config.ts | 3 +- webroot/src/components/App/App.ts | 18 +++-- .../Page/PageMainNav/PageMainNav.ts | 16 ++++- webroot/src/main.ts | 4 +- webroot/src/network/data.api.ts | 12 ++-- .../src/network/exampleApi/interceptors.ts | 9 ++- .../src/network/licenseApi/interceptors.ts | 6 +- webroot/src/network/stateApi/interceptors.ts | 6 +- webroot/src/network/userApi/interceptors.ts | 6 +- .../src/pages/AuthCallback/AuthCallback.ts | 8 +-- webroot/src/pages/Home/Home.ts | 4 +- webroot/src/pages/Login/Login.ts | 39 ++++++++++- webroot/src/pages/Login/Login.vue | 16 ++++- webroot/src/pages/Logout/Logout.ts | 68 +++++++++++-------- webroot/src/router/index.ts | 9 ++- webroot/src/router/routes.ts | 24 +++---- webroot/src/store/user/user.actions.ts | 36 ++++++++-- webroot/src/store/user/user.getters.ts | 12 ---- webroot/src/store/user/user.mutations.ts | 5 +- webroot/src/store/user/user.spec.ts | 57 +++++----------- webroot/src/store/user/user.state.ts | 11 ++- 41 files changed, 366 insertions(+), 237 deletions(-) diff --git a/backend/compact-connect/README.md b/backend/compact-connect/README.md index d52f9d80a..52eaf0201 100644 --- a/backend/compact-connect/README.md +++ b/backend/compact-connect/README.md @@ -63,7 +63,7 @@ $ cdk synth ``` For development work there are additional requirements in `requirements-dev.txt` to install with -`pip install -r requirements.txt`. +`pip install -r requirements-dev.txt`. To add additional dependencies, for example other CDK libraries, just add them to the `requirements.in` file and rerun `pip-compile requirements.in`, then `pip install -r requirements.txt` command. @@ -118,7 +118,7 @@ its environment: your app. See [About Route53 Hosted Zones](#about-route53-hosted-zones) for more. Note: Without this step, you will not be able to log in to the UI hosted in CloudFront. The Oauth2 authentication process requires a predictable callback url to be pre-configured, which the domain name provides. You can still run a local UI against this app, - so long as you leave the `allow_local_ui` context value set to `true` in your environment's context. + so long as you leave the `allow_local_ui` context value set to `true` and remove the `domain_name` param in your environment's context. 2) *Optional if testing SES email notifications with custom domain:* By default, AWS does not allow sending emails to unverified email addresses. If you need to test SES email notifications and do not want to request AWS to remove your account from the SES sandbox, you will need to set up a verified SES email identity for each address you want to send emails to. diff --git a/backend/compact-connect/bin/create_staff_user.py b/backend/compact-connect/bin/create_staff_user.py index 499721bcc..fe547068e 100755 --- a/backend/compact-connect/bin/create_staff_user.py +++ b/backend/compact-connect/bin/create_staff_user.py @@ -17,6 +17,7 @@ provider_data_path = os.path.join('lambdas', 'python', 'staff-users') common_lib_path = os.path.join('lambdas', 'python', 'common') + sys.path.append(provider_data_path) sys.path.append(common_lib_path) diff --git a/backend/compact-connect/common_constructs/user_pool.py b/backend/compact-connect/common_constructs/user_pool.py index 3a6545b19..88b547b99 100644 --- a/backend/compact-connect/common_constructs/user_pool.py +++ b/backend/compact-connect/common_constructs/user_pool.py @@ -117,7 +117,8 @@ def __init__( # pylint: disable=too-many-arguments def add_ui_client( self, - callback_urls: list[str], + ui_domain_name: str, + environment_context: dict, read_attributes: ClientAttributes, write_attributes: ClientAttributes, ui_scopes: list[OAuthScope] = None, @@ -125,11 +126,42 @@ def add_ui_client( """ Creates an app client for the UI to authenticate with the user pool. - :param callback_urls: The URLs that Cognito allows the UI to redirect to after authentication. + :param ui_domain_name: The ui domain name used to determine acceptable redirects. + :param environment_context: The environment context used to determine acceptable redirects. :param read_attributes: The attributes that the UI can read. :param write_attributes: The attributes that the UI can write. :param ui_scopes: OAuth scopes that are allowed with this client """ + callback_urls = [] + if ui_domain_name is not None: + callback_urls.append(f'https://{ui_domain_name}/auth/callback') + # This toggle will allow front-end devs to point their local UI at this environment's user pool to support + # authenticated actions. + if environment_context.get('allow_local_ui', False): + local_ui_port = environment_context.get('local_ui_port', '3018') + callback_urls.append(f'http://localhost:{local_ui_port}/auth/callback') + if not callback_urls: + raise ValueError( + "This app requires a callback url for its authentication path. Either provide 'domain_name' or set " + "'allow_local_ui' to true in this environment's context." + ) + + logout_urls = [] + if ui_domain_name is not None: + logout_urls.append(f'https://{ui_domain_name}/Login') + logout_urls.append(f'https://{ui_domain_name}/Logout') + # This toggle will allow front-end devs to point their local UI at this environment's user pool to support + # authenticated actions. + if environment_context.get('allow_local_ui', False): + local_ui_port = environment_context.get('local_ui_port', '3018') + logout_urls.append(f'http://localhost:{local_ui_port}/Login') + logout_urls.append(f'http://localhost:{local_ui_port}/Logout') + if not logout_urls: + raise ValueError( + "This app requires a logout url for its logout function. Either provide 'domain_name' or set " + "'allow_local_ui' to true in this environment's context." + ) + return self.add_client( 'UIClient', auth_flows=AuthFlow( @@ -140,6 +172,7 @@ def add_ui_client( ), o_auth=OAuthSettings( callback_urls=callback_urls, + logout_urls=logout_urls, flows=OAuthFlows(authorization_code_grant=True, implicit_code_grant=False), scopes=ui_scopes, ), diff --git a/backend/compact-connect/lambdas/python/common/requirements-dev.txt b/backend/compact-connect/lambdas/python/common/requirements-dev.txt index 4cbcd70e7..dfa088cee 100644 --- a/backend/compact-connect/lambdas/python/common/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/common/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/common/requirements-dev.in # -boto3==1.35.67 +boto3==1.35.81 # via moto -botocore==1.35.67 +botocore==1.35.81 # via # boto3 # moto # s3transfer -certifi==2024.8.30 +certifi==2024.12.14 # via requests cffi==1.17.1 # via cryptography charset-normalizer==3.4.0 # via requests -cryptography==43.0.3 +cryptography==44.0.0 # via moto docker==7.1.0 # via moto @@ -35,7 +35,7 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.0.21 +moto[dynamodb,s3]==5.0.23 # via -r compact-connect/lambdas/python/common/requirements-dev.in py-partiql-parser==0.5.6 # via moto @@ -59,7 +59,7 @@ responses==0.25.3 # via moto s3transfer==0.10.4 # via boto3 -six==1.16.0 +six==1.17.0 # via python-dateutil urllib3==2.2.3 # via diff --git a/backend/compact-connect/lambdas/python/common/requirements.txt b/backend/compact-connect/lambdas/python/common/requirements.txt index e263dd643..a55f9c346 100644 --- a/backend/compact-connect/lambdas/python/common/requirements.txt +++ b/backend/compact-connect/lambdas/python/common/requirements.txt @@ -6,9 +6,9 @@ # aws-lambda-powertools==2.43.1 # via -r compact-connect/lambdas/python/common/requirements.in -boto3==1.35.67 +boto3==1.35.81 # via -r compact-connect/lambdas/python/common/requirements.in -botocore==1.35.67 +botocore==1.35.81 # via # boto3 # s3transfer @@ -25,7 +25,7 @@ python-dateutil==2.9.0.post0 # via botocore s3transfer==0.10.4 # via boto3 -six==1.16.0 +six==1.17.0 # via python-dateutil typing-extensions==4.12.2 # via aws-lambda-powertools diff --git a/backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt b/backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt index c16ab1663..b6b4b61ae 100644 --- a/backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/custom-resources/requirements-dev.in # -boto3==1.35.67 +boto3==1.35.81 # via moto -botocore==1.35.67 +botocore==1.35.81 # via # boto3 # moto # s3transfer -certifi==2024.8.30 +certifi==2024.12.14 # via requests cffi==1.17.1 # via cryptography charset-normalizer==3.4.0 # via requests -cryptography==43.0.3 +cryptography==44.0.0 # via moto docker==7.1.0 # via moto @@ -33,7 +33,7 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.0.21 +moto[dynamodb,s3]==5.0.23 # via -r compact-connect/lambdas/python/custom-resources/requirements-dev.in py-partiql-parser==0.5.6 # via moto @@ -56,7 +56,7 @@ responses==0.25.3 # via moto s3transfer==0.10.4 # via boto3 -six==1.16.0 +six==1.17.0 # via python-dateutil urllib3==2.2.3 # via diff --git a/backend/compact-connect/lambdas/python/data-events/requirements-dev.in b/backend/compact-connect/lambdas/python/data-events/requirements-dev.in index 17377230b..5a61b7b0d 100644 --- a/backend/compact-connect/lambdas/python/data-events/requirements-dev.in +++ b/backend/compact-connect/lambdas/python/data-events/requirements-dev.in @@ -1 +1 @@ -moto[s3]>=5.0.12, <6 +moto[dynamodb, s3]>=5.0.12, <6 diff --git a/backend/compact-connect/lambdas/python/data-events/requirements-dev.txt b/backend/compact-connect/lambdas/python/data-events/requirements-dev.txt index 30fea6d15..023b473c1 100644 --- a/backend/compact-connect/lambdas/python/data-events/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/data-events/requirements-dev.txt @@ -4,20 +4,22 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/data-events/requirements-dev.in # -boto3==1.35.67 +boto3==1.35.81 # via moto -botocore==1.35.67 +botocore==1.35.81 # via # boto3 # moto # s3transfer -certifi==2024.8.30 +certifi==2024.12.14 # via requests cffi==1.17.1 # via cryptography charset-normalizer==3.4.0 # via requests -cryptography==43.0.3 +cryptography==44.0.0 + # via moto +docker==7.1.0 # via moto idna==3.10 # via requests @@ -31,7 +33,7 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[s3]==5.0.21 +moto[dynamodb,s3]==5.0.23 # via -r compact-connect/lambdas/python/data-events/requirements-dev.in py-partiql-parser==0.5.6 # via moto @@ -47,17 +49,19 @@ pyyaml==6.0.2 # responses requests==2.32.3 # via + # docker # moto # responses responses==0.25.3 # via moto s3transfer==0.10.4 # via boto3 -six==1.16.0 +six==1.17.0 # via python-dateutil urllib3==2.2.3 # via # botocore + # docker # requests # responses werkzeug==3.1.3 diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/requirements-dev.txt b/backend/compact-connect/lambdas/python/provider-data-v1/requirements-dev.txt index f1962dc4e..b2ec3256b 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/provider-data-v1/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/provider-data-v1/requirements-dev.in # -boto3==1.35.67 +boto3==1.35.81 # via moto -botocore==1.35.67 +botocore==1.35.81 # via # boto3 # moto # s3transfer -certifi==2024.8.30 +certifi==2024.12.14 # via requests cffi==1.17.1 # via cryptography charset-normalizer==3.4.0 # via requests -cryptography==43.0.3 +cryptography==44.0.0 # via moto docker==7.1.0 # via moto @@ -35,7 +35,7 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.0.21 +moto[dynamodb,s3]==5.0.23 # via -r compact-connect/lambdas/python/provider-data-v1/requirements-dev.in py-partiql-parser==0.5.6 # via moto @@ -59,7 +59,7 @@ responses==0.25.3 # via moto s3transfer==0.10.4 # via boto3 -six==1.16.0 +six==1.17.0 # via python-dateutil urllib3==2.2.3 # via diff --git a/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt b/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt index 9fac71ffd..e2434f27e 100644 --- a/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/purchases/requirements-dev.in # -boto3==1.35.67 +boto3==1.35.81 # via moto -botocore==1.35.67 +botocore==1.35.81 # via # boto3 # moto # s3transfer -certifi==2024.8.30 +certifi==2024.12.14 # via requests cffi==1.17.1 # via cryptography charset-normalizer==3.4.0 # via requests -cryptography==43.0.3 +cryptography==44.0.0 # via moto docker==7.1.0 # via moto @@ -33,7 +33,7 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.0.21 +moto[dynamodb,s3]==5.0.23 # via -r compact-connect/lambdas/python/purchases/requirements-dev.in py-partiql-parser==0.5.6 # via moto @@ -56,7 +56,7 @@ responses==0.25.3 # via moto s3transfer==0.10.4 # via boto3 -six==1.16.0 +six==1.17.0 # via python-dateutil urllib3==2.2.3 # via diff --git a/backend/compact-connect/lambdas/python/purchases/requirements.txt b/backend/compact-connect/lambdas/python/purchases/requirements.txt index 81c409b26..9ee3bb9df 100644 --- a/backend/compact-connect/lambdas/python/purchases/requirements.txt +++ b/backend/compact-connect/lambdas/python/purchases/requirements.txt @@ -6,7 +6,7 @@ # authorizenet==1.1.5 # via -r compact-connect/lambdas/python/purchases/requirements.in -certifi==2024.8.30 +certifi==2024.12.14 # via requests charset-normalizer==3.4.0 # via requests diff --git a/backend/compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.txt b/backend/compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.txt index 4f112aff9..34d076ebf 100644 --- a/backend/compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.in # -boto3==1.35.67 +boto3==1.35.81 # via moto -botocore==1.35.67 +botocore==1.35.81 # via # boto3 # moto # s3transfer -certifi==2024.8.30 +certifi==2024.12.14 # via requests cffi==1.17.1 # via cryptography charset-normalizer==3.4.0 # via requests -cryptography==43.0.3 +cryptography==44.0.0 # via moto docker==7.1.0 # via moto @@ -33,7 +33,7 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.0.21 +moto[dynamodb,s3]==5.0.23 # via -r compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.in py-partiql-parser==0.5.6 # via moto @@ -56,7 +56,7 @@ responses==0.25.3 # via moto s3transfer==0.10.4 # via boto3 -six==1.16.0 +six==1.17.0 # via python-dateutil urllib3==2.2.3 # via diff --git a/backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt b/backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt index a43c369b8..6d965cef9 100644 --- a/backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/staff-users/requirements-dev.in # -boto3==1.35.67 +boto3==1.35.81 # via moto -botocore==1.35.67 +botocore==1.35.81 # via # boto3 # moto # s3transfer -certifi==2024.8.30 +certifi==2024.12.14 # via requests cffi==1.17.1 # via cryptography charset-normalizer==3.4.0 # via requests -cryptography==43.0.3 +cryptography==44.0.0 # via # joserfc # moto @@ -33,13 +33,13 @@ jmespath==1.0.1 # via # boto3 # botocore -joserfc==1.0.0 +joserfc==1.0.1 # via moto markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[cognitoidp,dynamodb,s3]==5.0.21 +moto[cognitoidp,dynamodb,s3]==5.0.23 # via -r compact-connect/lambdas/python/staff-users/requirements-dev.in py-partiql-parser==0.5.6 # via moto @@ -63,7 +63,7 @@ responses==0.25.3 # via moto s3transfer==0.10.4 # via boto3 -six==1.16.0 +six==1.17.0 # via python-dateutil urllib3==2.2.3 # via diff --git a/backend/compact-connect/requirements-dev.txt b/backend/compact-connect/requirements-dev.txt index a4dbea512..8ac650471 100644 --- a/backend/compact-connect/requirements-dev.txt +++ b/backend/compact-connect/requirements-dev.txt @@ -12,13 +12,13 @@ cachecontrol[filecache]==0.14.1 # via # cachecontrol # pip-audit -certifi==2024.8.30 +certifi==2024.12.14 # via requests charset-normalizer==3.4.0 # via requests click==8.1.7 # via pip-tools -coverage[toml]==7.6.7 +coverage[toml]==7.6.9 # via # -r compact-connect/requirements-dev.in # pytest-cov @@ -72,7 +72,7 @@ pyproject-hooks==1.2.0 # via # build # pip-tools -pytest==8.3.3 +pytest==8.3.4 # via # -r compact-connect/requirements-dev.in # pytest-cov @@ -86,9 +86,9 @@ requests==2.32.3 # pip-audit rich==13.9.4 # via pip-audit -ruff==0.7.4 +ruff==0.8.3 # via -r compact-connect/requirements-dev.in -six==1.16.0 +six==1.17.0 # via # html5lib # python-dateutil @@ -100,7 +100,7 @@ urllib3==2.2.3 # via requests webencodings==0.5.1 # via html5lib -wheel==0.45.0 +wheel==0.45.1 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/backend/compact-connect/requirements.txt b/backend/compact-connect/requirements.txt index dc3d575f6..974b1c450 100644 --- a/backend/compact-connect/requirements.txt +++ b/backend/compact-connect/requirements.txt @@ -4,28 +4,28 @@ # # pip-compile --no-emit-index-url compact-connect/requirements.in # -attrs==24.2.0 +attrs==24.3.0 # via # cattrs # jsii -aws-cdk-asset-awscli-v1==2.2.212 +aws-cdk-asset-awscli-v1==2.2.215 # via aws-cdk-lib aws-cdk-asset-kubectl-v20==2.1.3 # via aws-cdk-lib aws-cdk-asset-node-proxy-agent-v6==2.1.0 # via aws-cdk-lib -aws-cdk-aws-lambda-python-alpha==2.169.0a0 +aws-cdk-aws-lambda-python-alpha==2.173.1a0 # via -r compact-connect/requirements.in aws-cdk-cloud-assembly-schema==38.0.1 # via aws-cdk-lib -aws-cdk-lib==2.169.0 +aws-cdk-lib==2.173.1 # via # -r compact-connect/requirements.in # aws-cdk-aws-lambda-python-alpha # cdk-nag cattrs==24.1.2 # via jsii -cdk-nag==2.34.3 +cdk-nag==2.34.23 # via -r compact-connect/requirements.in constructs==10.4.2 # via @@ -60,7 +60,7 @@ python-dateutil==2.9.0.post0 # via jsii pyyaml==6.0.2 # via -r compact-connect/requirements.in -six==1.16.0 +six==1.17.0 # via python-dateutil typeguard==2.13.3 # via diff --git a/backend/compact-connect/stacks/persistent_stack/provider_users.py b/backend/compact-connect/stacks/persistent_stack/provider_users.py index eef3444a5..7ddc5d618 100644 --- a/backend/compact-connect/stacks/persistent_stack/provider_users.py +++ b/backend/compact-connect/stacks/persistent_stack/provider_users.py @@ -46,23 +46,10 @@ def __init__( ) stack: ps.PersistentStack = ps.PersistentStack.of(self) - callback_urls = [] - if stack.ui_domain_name is not None: - callback_urls.append(f'https://{stack.ui_domain_name}/auth/callback') - # This toggle will allow front-end devs to point their local UI at this environment's user pool to support - # authenticated actions. - if environment_context.get('allow_local_ui', False): - local_ui_port = environment_context.get('local_ui_port', '3018') - callback_urls.append(f'http://localhost:{local_ui_port}/auth/callback') - if not callback_urls: - raise ValueError( - "This app requires a callback url for its authentication path. Either provide 'domain_name' or set " - "'allow_local_ui' to true in this environment's context." - ) - # Create an app client to allow the front-end to authenticate. self.ui_client = self.add_ui_client( - callback_urls=callback_urls, + ui_domain_name=stack.ui_domain_name, + environment_context=environment_context, # For now, we are allowing the user to read and update their email, given name, and family name. # we only allow the user to be able to see their providerId and compact, which are custom attributes. # If we ever want other attributes to be read or written, they must be added here. diff --git a/backend/compact-connect/stacks/persistent_stack/staff_users.py b/backend/compact-connect/stacks/persistent_stack/staff_users.py index d83671b7b..21ea338bb 100644 --- a/backend/compact-connect/stacks/persistent_stack/staff_users.py +++ b/backend/compact-connect/stacks/persistent_stack/staff_users.py @@ -57,24 +57,11 @@ def __init__( self._add_resource_servers() self._add_scope_customization(persistent_stack=stack) - callback_urls = [] - if stack.ui_domain_name is not None: - callback_urls.append(f'https://{stack.ui_domain_name}/auth/callback') - # This toggle will allow front-end devs to point their local UI at this environment's user pool to support - # authenticated actions. - if environment_context.get('allow_local_ui', False): - local_ui_port = environment_context.get('local_ui_port', '3018') - callback_urls.append(f'http://localhost:{local_ui_port}/auth/callback') - if not callback_urls: - raise ValueError( - "This app requires a callback url for its authentication path. Either provide 'domain_name' or set " - "'allow_local_ui' to true in this environment's context.", - ) - # Do not allow resource server scopes via the client - they are assigned via token customization # to allow for user attribute-based access self.ui_client = self.add_ui_client( - callback_urls=callback_urls, + ui_domain_name=stack.ui_domain_name, + environment_context=environment_context, # We have to provide one True value or CFn will make every attribute writeable write_attributes=ClientAttributes().with_standard_attributes(email=True), # We want to limit the attributes that this app can read and write so only email is visible. diff --git a/backend/multi-account/requirements-dev.txt b/backend/multi-account/requirements-dev.txt index cc40e19bf..ca859d020 100644 --- a/backend/multi-account/requirements-dev.txt +++ b/backend/multi-account/requirements-dev.txt @@ -10,5 +10,5 @@ packaging==24.2 # via pytest pluggy==1.5.0 # via pytest -pytest==8.3.3 +pytest==8.3.4 # via -r multi-account/requirements-dev.in diff --git a/backend/multi-account/requirements.txt b/backend/multi-account/requirements.txt index 129b5370c..8095cd239 100644 --- a/backend/multi-account/requirements.txt +++ b/backend/multi-account/requirements.txt @@ -4,11 +4,11 @@ # # pip-compile --no-emit-index-url multi-account/requirements.in # -attrs==24.2.0 +attrs==24.3.0 # via # cattrs # jsii -aws-cdk-asset-awscli-v1==2.2.212 +aws-cdk-asset-awscli-v1==2.2.215 # via aws-cdk-lib aws-cdk-asset-kubectl-v20==2.1.3 # via aws-cdk-lib @@ -16,7 +16,7 @@ aws-cdk-asset-node-proxy-agent-v6==2.1.0 # via aws-cdk-lib aws-cdk-cloud-assembly-schema==38.0.1 # via aws-cdk-lib -aws-cdk-lib==2.169.0 +aws-cdk-lib==2.173.1 # via -r multi-account/requirements.in cattrs==24.1.2 # via jsii @@ -45,7 +45,7 @@ publication==0.0.3 # jsii python-dateutil==2.9.0.post0 # via jsii -six==1.16.0 +six==1.17.0 # via python-dateutil typeguard==2.13.3 # via diff --git a/webroot/README.md b/webroot/README.md index 07089c8ad..51fe3adde 100644 --- a/webroot/README.md +++ b/webroot/README.md @@ -8,6 +8,7 @@ - **[Local Development](#local-development)** - **[Tests](#tests)** - **[Build](#build)** +- **[Auth](#auth)** --- ## Key @@ -227,3 +228,26 @@ Note that testing the **built** app locally will require a running web server; f - _Otherwise the PWA will get cached and testing a clean state or volatile in-progress work will require lots of manual cache storage clearing._ --- +## Auth +- We use two cognito user pools with hosted login pages to authenticate users as Provider users and Staff users respectively +- Cognito's somewhat opinionated functionality combined with this set up introduces some complexity to make the app function in a secure and expected way + +- **Cognito's functionality:** + - When a user logs in to Cognito, it saves an http only cookie allowing the user to log back in without entering credentials for an hour + - Cognito has no way to totally log a user out except for visiting the hosted logout url in the browser + - Using the token revocation endpoint, the user can invalidate their reresh token, but the http only cookie is not removed so if + they logged in within the last hour they are not totally logged out + +- **Two user pools with their own hosted login pages:** + - If a user is logged in to one user pool, they can still log in to the second as they are not aware of each other + - The downstream effect of this point is that the app needs to handle users being logged in to both user pools in an expected and secure way: + - When a user logs out they must be logged out from all user pools they are logged into + - When a user logs in to the second user pool, the app must treat them as only logged into the second user pool + +- **How the app handles this situation:** + - Firstly, the user being logged into both pools is very unlikely as there is not natural way to do this within the app, they would need to manually visit the + second hosted login page after logging in normally to the first + - When the user logs in as to the second user pool we record that the app should treat them as the second user type and save the initial access token + - When the user logs out we check the existence of the access tokens to see which user pools we need to log out, and then chain logout redirects + to visit all necessary logout pages +--- \ No newline at end of file diff --git a/webroot/src/app.config.ts b/webroot/src/app.config.ts index 5b9c358de..ced45d5d5 100644 --- a/webroot/src/app.config.ts +++ b/webroot/src/app.config.ts @@ -28,7 +28,6 @@ export enum FeeTypes { export const authStorage = sessionStorage; export const tokens = { staff: { - AUTH_TYPE: 'auth_type', AUTH_TOKEN: 'auth_token_staff', AUTH_TOKEN_TYPE: 'auth_token_type_staff', AUTH_TOKEN_EXPIRY: 'auth_token_expiry_staff', @@ -36,7 +35,6 @@ export const tokens = { REFRESH_TOKEN: 'refresh_token_staff', }, licensee: { - AUTH_TYPE: 'auth_type', AUTH_TOKEN: 'auth_token_licensee', AUTH_TOKEN_TYPE: 'auth_token_type_licensee', AUTH_TOKEN_EXPIRY: 'auth_token_expiry_licensee', @@ -45,6 +43,7 @@ export const tokens = { }, }; export const AUTH_LOGIN_GOTO_PATH = 'login_goto'; +export const AUTH_TYPE = 'auth_type'; // ==================== // = User Languages = diff --git a/webroot/src/components/App/App.ts b/webroot/src/components/App/App.ts index d9a92079b..47718cfda 100644 --- a/webroot/src/components/App/App.ts +++ b/webroot/src/components/App/App.ts @@ -16,7 +16,7 @@ import { authStorage, AuthTypes, relativeTimeFormats, - tokens + AUTH_TYPE } from '@/app.config'; import { CompactType } from '@models/Compact/Compact.model'; import PageContainer from '@components/Page/PageContainer/PageContainer.vue'; @@ -97,9 +97,9 @@ class App extends Vue { setAuthType() { let authType: AuthTypes; - if (authStorage.getItem(tokens?.staff?.AUTH_TOKEN)) { + if (authStorage.getItem(AUTH_TYPE) === AuthTypes.STAFF) { authType = AuthTypes.STAFF; - } else if (authStorage.getItem(tokens?.licensee?.AUTH_TOKEN)) { + } else if (authStorage.getItem(AUTH_TYPE) === AuthTypes.LICENSEE) { authType = AuthTypes.LICENSEE; } else { authType = AuthTypes.PUBLIC; @@ -171,10 +171,18 @@ class App extends Vue { this.body.style.overflow = (this.globalStore.isModalOpen) ? 'hidden' : 'visible'; } - @Watch('userStore.isLoggedIn') async loginState() { + @Watch('userStore.isLoggedInAsLicensee') async handleLicenseeLogin() { if (!this.userStore.isLoggedIn) { this.$router.push({ name: 'Logout' }); - } else { + } else if (this.userStore.isLoggedInAsLicensee) { + await this.handleAuth(); + } + } + + @Watch('userStore.isLoggedInAsStaff') async handleStaffLogin() { + if (!this.userStore.isLoggedIn) { + this.$router.push({ name: 'Logout' }); + } else if (this.userStore.isLoggedInAsStaff) { await this.handleAuth(); } } diff --git a/webroot/src/components/Page/PageMainNav/PageMainNav.ts b/webroot/src/components/Page/PageMainNav/PageMainNav.ts index 1e2e4ca15..f98b2a5ea 100644 --- a/webroot/src/components/Page/PageMainNav/PageMainNav.ts +++ b/webroot/src/components/Page/PageMainNav/PageMainNav.ts @@ -31,6 +31,14 @@ class PageMainNav extends Vue { return !this.isDesktop; } + get globalStore(): any { + return this.$store.state; + } + + get authType(): string { + return this.globalStore.authType; + } + get isMainNavVisible(): boolean { return this.isDesktop || this.isMainNavToggled; } @@ -48,7 +56,11 @@ class PageMainNav extends Vue { } get isLoggedInAsStaff(): boolean { - return this.isLoggedIn && this.$store.getters['user/highestPermissionAuthType']() === AuthTypes.STAFF; + return this.authType === AuthTypes.STAFF; + } + + get isLoggedInAsLicensee(): boolean { + return this.authType === AuthTypes.LICENSEE; } get staffPermission(): CompactPermission | null { @@ -120,7 +132,7 @@ class PageMainNav extends Vue { to: 'LicenseeDashboard', params: { compact: this.currentCompact?.type }, label: computed(() => this.$t('navigation.dashboard')), - isEnabled: Boolean(this.currentCompact) && !this.isLoggedInAsStaff, + isEnabled: Boolean(this.currentCompact) && this.isLoggedInAsLicensee, isExternal: false, isExactActive: false, }, diff --git a/webroot/src/main.ts b/webroot/src/main.ts index 4eda9a9d4..f031a5e9c 100644 --- a/webroot/src/main.ts +++ b/webroot/src/main.ts @@ -26,8 +26,8 @@ const app = createApp(App); // Enable vue-devtools. Can make environment-specific if needed. app.config.performance = true; -// Inject store into API interceptors (avoids circular dependency) -network.dataApi.initInterceptors(store); +// Inject router into API interceptors (avoids circular dependency) +network.dataApi.initInterceptors(router); // // INJECT PLUGINS diff --git a/webroot/src/network/data.api.ts b/webroot/src/network/data.api.ts index 572a6b07d..1ebff2bd1 100644 --- a/webroot/src/network/data.api.ts +++ b/webroot/src/network/data.api.ts @@ -14,13 +14,13 @@ import { PaymentProcessorConfig } from '@models/Compact/Compact.model'; export class DataApi { /** * Initialize API Axios interceptors with injected store context. - * @param {Store} store + * @param {Router} router */ - public initInterceptors(store) { - stateDataApi.initInterceptors(store); - licenseDataApi.initInterceptors(store); - userDataApi.initInterceptors(store); - exampleDataApi.initInterceptors(store); + public initInterceptors(router) { + stateDataApi.initInterceptors(router); + licenseDataApi.initInterceptors(router); + userDataApi.initInterceptors(router); + exampleDataApi.initInterceptors(router); } // ======================================================================== diff --git a/webroot/src/network/exampleApi/interceptors.ts b/webroot/src/network/exampleApi/interceptors.ts index 5f5bbb145..cbd3af6c4 100644 --- a/webroot/src/network/exampleApi/interceptors.ts +++ b/webroot/src/network/exampleApi/interceptors.ts @@ -6,7 +6,6 @@ // import { config } from '@plugins/EnvConfig/envConfig.plugin'; -import { createResponseMessage } from '@network/helpers'; // ============================================================================ // = REQUEST INTERCEPTORS = @@ -49,10 +48,10 @@ export const responseSuccess = () => (response) => { /** * Get Axios API response error interceptor. - * @param {Store} store The app store context. + * @param {Router} router The vue router * @return {AxiosInterceptor} Function that extracts the incoming server API response (from within the Axios response wrapper). */ -export const responseError = (store) => (error) => { +export const responseError = (router) => (error) => { const axiosResponse = error.response; let serverResponse = (axiosResponse) ? axiosResponse.data : null; @@ -62,7 +61,7 @@ export const responseError = (store) => (error) => { switch (axiosResponse.status) { case 401: - store.dispatch('user/logoutRequest'); + router.push({ name: 'Logout' }); break; case 404: // Endpoint / object not found // Continue @@ -70,7 +69,7 @@ export const responseError = (store) => (error) => { // We won't dispatch a UI alert, just let the components handle missing store data as appropriate break; default: - store.dispatch('addMessage', createResponseMessage(axiosResponse)); + // Continue } } else { // API unavailable diff --git a/webroot/src/network/licenseApi/interceptors.ts b/webroot/src/network/licenseApi/interceptors.ts index 19d8731f0..130e448b5 100644 --- a/webroot/src/network/licenseApi/interceptors.ts +++ b/webroot/src/network/licenseApi/interceptors.ts @@ -46,10 +46,10 @@ export const responseSuccess = () => (response) => { /** * Get Axios API response error interceptor. - * @param {Store} store The app store context. + * @param {Router} router The vue router * @return {AxiosInterceptor} Function that extracts the incoming server API response (from within the Axios response wrapper). */ -export const responseError = (store) => (error) => { +export const responseError = (router) => (error) => { const axiosResponse = error.response; let serverResponse = (axiosResponse) ? axiosResponse.data : null; @@ -59,7 +59,7 @@ export const responseError = (store) => (error) => { switch (axiosResponse.status) { case 401: - store.dispatch('user/logoutRequest'); + router.push({ name: 'Logout' }); break; default: // Continue diff --git a/webroot/src/network/stateApi/interceptors.ts b/webroot/src/network/stateApi/interceptors.ts index 19d8731f0..130e448b5 100644 --- a/webroot/src/network/stateApi/interceptors.ts +++ b/webroot/src/network/stateApi/interceptors.ts @@ -46,10 +46,10 @@ export const responseSuccess = () => (response) => { /** * Get Axios API response error interceptor. - * @param {Store} store The app store context. + * @param {Router} router The vue router * @return {AxiosInterceptor} Function that extracts the incoming server API response (from within the Axios response wrapper). */ -export const responseError = (store) => (error) => { +export const responseError = (router) => (error) => { const axiosResponse = error.response; let serverResponse = (axiosResponse) ? axiosResponse.data : null; @@ -59,7 +59,7 @@ export const responseError = (store) => (error) => { switch (axiosResponse.status) { case 401: - store.dispatch('user/logoutRequest'); + router.push({ name: 'Logout' }); break; default: // Continue diff --git a/webroot/src/network/userApi/interceptors.ts b/webroot/src/network/userApi/interceptors.ts index 98aac622f..89fb80646 100644 --- a/webroot/src/network/userApi/interceptors.ts +++ b/webroot/src/network/userApi/interceptors.ts @@ -61,10 +61,10 @@ export const responseSuccess = () => (response) => { /** * Get Axios API response error interceptor. - * @param {Store} store The app store context. + * @param {Router} router The vue router * @return {AxiosInterceptor} Function that extracts the incoming server API response (from within the Axios response wrapper). */ -export const responseError = (store) => (error) => { +export const responseError = (router) => (error) => { const axiosResponse = error.response; let serverResponse; @@ -74,7 +74,7 @@ export const responseError = (store) => (error) => { switch (axiosResponse.status) { case 401: - store.dispatch('user/logoutRequest'); + router.push({ name: 'Logout' }); break; default: // Continue diff --git a/webroot/src/pages/AuthCallback/AuthCallback.ts b/webroot/src/pages/AuthCallback/AuthCallback.ts index fa4c7dccd..5e7905626 100644 --- a/webroot/src/pages/AuthCallback/AuthCallback.ts +++ b/webroot/src/pages/AuthCallback/AuthCallback.ts @@ -103,8 +103,8 @@ export default class AuthCallback extends Vue { const { data } = await axios.post(`${cognitoAuthDomainStaff}/oauth2/token`, params); - await this.$store.dispatch('user/storeAuthTokens', { tokenResponse: data, authType: AuthTypes.STAFF }); - await this.$store.dispatch('user/loginSuccess'); + await this.$store.dispatch('user/updateAuthTokens', { tokenResponse: data, authType: AuthTypes.STAFF }); + await this.$store.dispatch('user/loginSuccess', AuthTypes.STAFF); } async getTokensLicensee(): Promise { @@ -118,8 +118,8 @@ export default class AuthCallback extends Vue { const { data } = await axios.post(`${cognitoAuthDomainLicensee}/oauth2/token`, params); - await this.$store.dispatch('user/storeAuthTokens', { tokenResponse: data, authType: AuthTypes.LICENSEE }); - await this.$store.dispatch('user/loginSuccess'); + await this.$store.dispatch('user/updateAuthTokens', { tokenResponse: data, authType: AuthTypes.LICENSEE }); + await this.$store.dispatch('user/loginSuccess', AuthTypes.LICENSEE); } async redirectUser(): Promise { diff --git a/webroot/src/pages/Home/Home.ts b/webroot/src/pages/Home/Home.ts index dbbda23d6..aaa550999 100644 --- a/webroot/src/pages/Home/Home.ts +++ b/webroot/src/pages/Home/Home.ts @@ -12,7 +12,7 @@ import { toNative } from 'vue-facing-decorator'; import { Compact } from '@models/Compact/Compact.model'; -import { AuthTypes } from '@/app.config'; +import { AuthTypes, authStorage, AUTH_TYPE } from '@/app.config'; @Component({ name: 'HomePage', @@ -41,7 +41,7 @@ class Home extends Vue { if (currentCompact) { const compactType = currentCompact.type; - const authType = this.$store.getters['user/highestPermissionAuthType'](); + const authType = authStorage.getItem(AUTH_TYPE); if (authType === AuthTypes.STAFF) { this.$router.push({ name: 'Licensing', params: { compact: compactType }}); diff --git a/webroot/src/pages/Login/Login.ts b/webroot/src/pages/Login/Login.ts index bf36b7ed2..c981e2be2 100644 --- a/webroot/src/pages/Login/Login.ts +++ b/webroot/src/pages/Login/Login.ts @@ -7,10 +7,13 @@ import { Component, Vue } from 'vue-facing-decorator'; import { AuthTypes } from '@/app.config'; +import InputButton from '@components/Forms/InputButton/InputButton.vue'; @Component({ name: 'Login', - components: {} + components: { + InputButton + } }) export default class Login extends Vue { // @@ -58,10 +61,44 @@ export default class Login extends Vue { return loginUri; } + get isUsingMockApi(): boolean { + return this.$envConfig.isUsingMockApi || false; + } + // // Methods // redirectToHostedLogin(): void { window.location.replace(this.hostedLoginUriStaff); } + + async mockStaffLogin(): Promise { + const data = { + access_token: 'mock_access_token', + token_type: 'Bearer', + expires_in: '100000000', + id_token: 'mock_id_token', + refresh_token: 'mock_refresh_token' + }; + + await this.$store.dispatch('user/updateAuthTokens', { tokenResponse: data, authType: AuthTypes.STAFF }); + this.$store.dispatch('user/loginSuccess', AuthTypes.STAFF); + + this.$router.push({ name: 'Home' }); + } + + async mockLicenseeLogin(): Promise { + const data = { + access_token: 'mock_access_token', + token_type: 'Bearer', + expires_in: '100000000', + id_token: 'mock_id_token', + refresh_token: 'mock_refresh_token' + }; + + await this.$store.dispatch('user/updateAuthTokens', { tokenResponse: data, authType: AuthTypes.LICENSEE }); + this.$store.dispatch('user/loginSuccess', AuthTypes.LICENSEE); + + this.$router.push({ name: 'Home' }); + } } diff --git a/webroot/src/pages/Login/Login.vue b/webroot/src/pages/Login/Login.vue index 6d5152a32..e90953cd7 100644 --- a/webroot/src/pages/Login/Login.vue +++ b/webroot/src/pages/Login/Login.vue @@ -6,7 +6,7 @@ --> diff --git a/webroot/src/pages/Logout/Logout.ts b/webroot/src/pages/Logout/Logout.ts index 613e0c864..6b867e400 100644 --- a/webroot/src/pages/Logout/Logout.ts +++ b/webroot/src/pages/Logout/Logout.ts @@ -6,7 +6,12 @@ // import { Component, Vue } from 'vue-facing-decorator'; -import { authStorage, AuthTypes, AUTH_LOGIN_GOTO_PATH } from '@/app.config'; +import { + authStorage, + AUTH_LOGIN_GOTO_PATH, + AuthTypes, + tokens +} from '@/app.config'; @Component({ name: 'Logout', @@ -33,54 +38,59 @@ export default class Logout extends Vue { get hostedLogoutUriStaff(): string { const { domain, cognitoAuthDomainStaff, cognitoClientIdStaff } = this.$envConfig; - const loginScopes = 'email openid phone profile aws.cognito.signin.user.admin'; - const loginResponseType = 'code'; - const loginRedirectPath = '/auth/callback'; - const loginUriQuery = [ + const logoutLink = encodeURIComponent(`${(domain as string)}/Logout`); + const logoutUriQuery = [ `?client_id=${cognitoClientIdStaff}`, - `&response_type=${loginResponseType}`, - `&scope=${encodeURIComponent(loginScopes)}`, - `&state=${AuthTypes.STAFF}`, - `&redirect_uri=${encodeURIComponent(`${domain}${loginRedirectPath}`)}`, + `&logout_uri=${logoutLink}` ].join(''); const idpPath = '/logout'; - const loginUri = `${cognitoAuthDomainStaff}${idpPath}${loginUriQuery}`; + const logoutUri = `${cognitoAuthDomainStaff}${idpPath}${logoutUriQuery}`; - return loginUri; + return logoutUri; + } + + get loginURL(): string { + const { domain } = this.$envConfig; + + return `${(domain as string)}/Login`; } get hostedLogoutUriLicensee(): string { - const { domain, cognitoAuthDomainLicensee, cognitoClientIdLicensee } = this.$envConfig; - const loginScopes = 'email openid phone profile aws.cognito.signin.user.admin'; - const loginResponseType = 'code'; - const loginRedirectPath = '/auth/callback'; - const loginUriQuery = [ + const { cognitoAuthDomainLicensee, cognitoClientIdLicensee } = this.$envConfig; + const logoutUriQuery = [ `?client_id=${cognitoClientIdLicensee}`, - `&response_type=${loginResponseType}`, - `&scope=${encodeURIComponent(loginScopes)}`, - `&state=${AuthTypes.LICENSEE}`, - `&redirect_uri=${encodeURIComponent(`${domain}${loginRedirectPath}`)}`, + `&logout_uri=${encodeURIComponent(this.loginURL)}` ].join(''); const idpPath = '/logout'; - const loginUri = `${cognitoAuthDomainLicensee}${idpPath}${loginUriQuery}`; + const logoutUri = `${cognitoAuthDomainLicensee}${idpPath}${logoutUriQuery}`; - return loginUri; + return logoutUri; + } + + get isLoggedIn(): boolean { + return this.userStore.isLoggedIn; } // // Methods // async logout(): Promise { - const isLoggedInAsLicenseeOnly = this.$store.getters['user/highestPermissionAuthType']() === AuthTypes.LICENSEE; + if (this.isLoggedIn) { + const isRemoteLoggedInAsLicenseeOnly = !authStorage.getItem(tokens.staff.AUTH_TOKEN); - await this.logoutChecklist(); - this.redirectToHighestPermissionHostedLogout(isLoggedInAsLicenseeOnly); + await this.logoutChecklist(isRemoteLoggedInAsLicenseeOnly); + this.beginLogoutRedirectChain(isRemoteLoggedInAsLicenseeOnly); + } else { + window.location.replace(this.loginURL); + } } - async logoutChecklist(): Promise { + async logoutChecklist(isRemoteLoggedInAsLicenseeOnly): Promise { + const authType = isRemoteLoggedInAsLicenseeOnly ? AuthTypes.LICENSEE : AuthTypes.STAFF; + this.$store.dispatch('user/clearRefreshTokenTimeout'); this.stashWorkingUri(); - await this.$store.dispatch('user/logoutRequest', this.$store.getters['user/highestPermissionAuthType']()); + await this.$store.dispatch('user/logoutRequest', authType); } stashWorkingUri(): void { @@ -91,10 +101,10 @@ export default class Logout extends Vue { } } - redirectToHighestPermissionHostedLogout(isLoggedInAsLicenseeOnly): void { + beginLogoutRedirectChain(isRemoteLoggedInAsLicenseeOnly): void { let logOutUrl = this.hostedLogoutUriStaff; - if (isLoggedInAsLicenseeOnly) { + if (isRemoteLoggedInAsLicenseeOnly) { logOutUrl = this.hostedLogoutUriLicensee; } diff --git a/webroot/src/router/index.ts b/webroot/src/router/index.ts index bc7e17e21..a23234c38 100644 --- a/webroot/src/router/index.ts +++ b/webroot/src/router/index.ts @@ -8,6 +8,7 @@ import { createRouter, createWebHistory, RouteLocationNormalized as Route } from 'vue-router'; import routes from '@router/routes'; import store from '@/store'; +import { authStorage, AUTH_TYPE, AuthTypes } from '@/app.config'; import { CompactSerializer } from '@models/Compact/Compact.model'; const router = createRouter({ @@ -29,6 +30,8 @@ const router = createRouter({ router.beforeEach(async (to, from, next) => { const isAuthGuardedRoute = to.matched.some((route) => route.meta.requiresAuth); + const isLicenseeRoute = to.matched.some((route) => route.meta.licenseeAccess); + const isStaffRoute = to.matched.some((route) => route.meta.staffAccess); const routeParamCompactType = to.params?.compact; // If the store does not have the requested compact, set it from the route (e.g. page refreshes) @@ -46,8 +49,12 @@ router.beforeEach(async (to, from, next) => { if (!isLoggedIn) { next({ name: 'Logout' }); - } else { + } else if ((isLicenseeRoute && isStaffRoute) + || (isLicenseeRoute && authStorage.getItem(AUTH_TYPE) === AuthTypes.LICENSEE) + || (isStaffRoute && authStorage.getItem(AUTH_TYPE) === AuthTypes.STAFF)) { next(); + } else { + next({ name: 'Home' }); } } else { next(); diff --git a/webroot/src/router/routes.ts b/webroot/src/router/routes.ts index b16d3c4ab..42b873db5 100644 --- a/webroot/src/router/routes.ts +++ b/webroot/src/router/routes.ts @@ -36,73 +36,73 @@ const routes: Array = [ path: '/Home', name: 'Home', component: () => import(/* webpackChunkName: "home" */ '@pages/Home/Home.vue'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, licenseeAccess: true, staffAccess: true }, }, { path: '/Account', name: 'Account', component: () => import(/* webpackChunkName: "home" */ '@pages/Account/Account.vue'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, licenseeAccess: true, staffAccess: true }, }, { path: '/:compact/Licensing', name: 'Licensing', component: () => import(/* webpackChunkName: "licensing" */ '@pages/LicensingList/LicensingList.vue'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, staffAccess: true }, }, { path: '/:compact/Licensing/:licenseeId', name: 'LicensingDetail', component: () => import(/* webpackChunkName: "licensing" */ '@pages/LicensingDetail/LicensingDetail.vue'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, staffAccess: true }, }, { path: '/:compact/Settings', name: 'CompactSettings', component: () => import(/* webpackChunkName: "licensing" */ '@pages/CompactSettings/CompactSettings.vue'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, staffAccess: true }, }, { path: '/:compact/StateUpload', name: 'StateUpload', component: () => import(/* webpackChunkName: "upload" */ '@pages/StateUpload/StateUpload.vue'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, staffAccess: true }, }, { path: '/:compact/Users', name: 'Users', component: () => import(/* webpackChunkName: "users" */ '@pages/UserList/UserList.vue'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, staffAccess: true }, }, { path: '/:compact/LicenseeDashboard', name: 'LicenseeDashboard', component: () => import(/* webpackChunkName: "licenseeDashboard" */ '@pages/LicenseeDashboard/LicenseeDashboard.vue'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, licenseeAccess: true, }, }, { path: '/:compact/Privileges/SelectPrivileges', name: 'SelectPrivileges', component: () => import(/* webpackChunkName: "privilegePurchase" */ '@pages/SelectPrivileges/SelectPrivileges.vue'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, licenseeAccess: true, }, }, { path: '/:compact/Privileges/FinalizePurchase', name: 'FinalizePrivilegePurchase', component: () => import(/* webpackChunkName: "privilegePurchase" */ '@pages/FinalizePrivilegePurchase/FinalizePrivilegePurchase.vue'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, licenseeAccess: true, }, }, { path: '/:compact/Privileges/PurchaseSuccessful', name: 'PurchaseSuccessful', component: () => import(/* webpackChunkName: "privilegePurchase" */ '@pages/PurchaseSuccessful/PurchaseSuccessful.vue'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, licenseeAccess: true, }, }, { path: '/:compact/Privileges/Attestation', name: 'PrivilegePurchaseAttestation', component: () => import(/* webpackChunkName: "privilegePurchase" */ '@pages/PrivilegePurchaseAttestation/PrivilegePurchaseAttestation.vue'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, licenseeAccess: true, }, }, { path: '/styleguide', diff --git a/webroot/src/store/user/user.actions.ts b/webroot/src/store/user/user.actions.ts index 1ecd28cb0..e07cf3960 100644 --- a/webroot/src/store/user/user.actions.ts +++ b/webroot/src/store/user/user.actions.ts @@ -7,7 +7,12 @@ import { dataApi } from '@network/data.api'; import { config } from '@plugins/EnvConfig/envConfig.plugin'; -import { authStorage, AuthTypes, tokens } from '@/app.config'; +import { + authStorage, + AuthTypes, + tokens, + AUTH_TYPE +} from '@/app.config'; import localStorage from '@store/local.storage'; import { Compact } from '@models/Compact/Compact.model'; import moment from 'moment'; @@ -25,8 +30,8 @@ export default { loginRequest: ({ commit }) => { commit(MutationTypes.LOGIN_REQUEST); }, - loginSuccess: async ({ commit }) => { - commit(MutationTypes.LOGIN_SUCCESS); + loginSuccess: async ({ commit }, authType) => { + commit(MutationTypes.LOGIN_SUCCESS, authType); }, loginFailure: async ({ commit }, error: Error) => { commit(MutationTypes.LOGIN_FAILURE, error); @@ -99,6 +104,10 @@ export default { resetStoreUser: ({ commit }) => { commit(MutationTypes.STORE_RESET_USER); }, + updateAuthTokens: ({ dispatch }, { tokenResponse, authType }) => { + dispatch('clearAllNonAccessTokens'); + dispatch('storeAuthTokens', { tokenResponse, authType }); + }, storeAuthTokens: ({ dispatch }, { tokenResponse, authType }) => { const { access_token: accessToken, @@ -108,7 +117,7 @@ export default { refresh_token: refreshToken, } = tokenResponse || {}; - authStorage.setItem(tokens[authType].AUTH_TYPE, authType); + authStorage.setItem(AUTH_TYPE, authType); if (accessToken) { authStorage.setItem(tokens[authType].AUTH_TOKEN, accessToken); @@ -192,7 +201,26 @@ export default { dispatch('sorting/resetStoreSorting', null, { root: true }); dispatch('reset', null, { root: true }); }, + clearAllNonAccessTokens: () => { + authStorage.removeItem(AUTH_TYPE); + + /* istanbul ignore next */ + Object.keys(tokens[AuthTypes.STAFF]).forEach((key) => { + if (key !== 'AUTH_TOKEN') { + authStorage.removeItem(tokens[AuthTypes.STAFF][key]); + } + }); + + /* istanbul ignore next */ + Object.keys(tokens[AuthTypes.LICENSEE]).forEach((key) => { + if (key !== 'AUTH_TOKEN') { + authStorage.removeItem(tokens[AuthTypes.LICENSEE][key]); + } + }); + }, clearAuthToken: (def, authType) => { + authStorage.removeItem(AUTH_TYPE); + /* istanbul ignore next */ Object.keys(tokens[authType]).forEach((key) => { authStorage.removeItem(tokens[authType][key]); diff --git a/webroot/src/store/user/user.getters.ts b/webroot/src/store/user/user.getters.ts index 61d06560e..745e1d4b6 100644 --- a/webroot/src/store/user/user.getters.ts +++ b/webroot/src/store/user/user.getters.ts @@ -4,20 +4,8 @@ // // Created by InspiringApps on 4/12/20. // -import { authStorage, AuthTypes, tokens } from '@/app.config'; export default { state: (state: any) => state, currentCompact: (state: any) => state.currentCompact, - highestPermissionAuthType: () => () => { - let loggedInAsType = ''; - - if (authStorage.getItem(tokens[AuthTypes.STAFF]?.AUTH_TOKEN)) { - loggedInAsType = AuthTypes.STAFF; - } else if (authStorage.getItem(tokens[AuthTypes.LICENSEE]?.AUTH_TOKEN)) { - loggedInAsType = AuthTypes.LICENSEE; - } - - return loggedInAsType; - }, }; diff --git a/webroot/src/store/user/user.mutations.ts b/webroot/src/store/user/user.mutations.ts index 82b3ebe6e..dff6846a7 100644 --- a/webroot/src/store/user/user.mutations.ts +++ b/webroot/src/store/user/user.mutations.ts @@ -7,6 +7,7 @@ import { Compact } from '@models/Compact/Compact.model'; import { LicenseeUser } from '@/models/LicenseeUser/LicenseeUser.model'; import { StaffUser } from '@/models/StaffUser/StaffUser.model'; +import { AuthTypes } from '@/app.config'; export enum MutationTypes { LOGIN_REQUEST = '[User] Login Request', @@ -45,8 +46,10 @@ export default { state.isLoadingAccount = false; state.error = error; }, - [MutationTypes.LOGIN_SUCCESS]: (state: any) => { + [MutationTypes.LOGIN_SUCCESS]: (state: any, authType: AuthTypes) => { state.isLoggedIn = true; + state.isLoggedInAsLicensee = Boolean(authType === AuthTypes.LICENSEE); + state.isLoggedInAsStaff = Boolean(authType === AuthTypes.STAFF); state.isLoadingAccount = false; state.error = null; }, diff --git a/webroot/src/store/user/user.spec.ts b/webroot/src/store/user/user.spec.ts index e204963f1..18fc38519 100644 --- a/webroot/src/store/user/user.spec.ts +++ b/webroot/src/store/user/user.spec.ts @@ -5,7 +5,12 @@ // Created by InspiringApps on 6/12/24. // -import { authStorage, tokens, FeeTypes } from '@/app.config'; +import { + authStorage, + tokens, + FeeTypes, + AuthTypes +} from '@/app.config'; import chaiMatchPattern from 'chai-match-pattern'; import chai from 'chai'; import { Compact } from '@models/Compact/Compact.model'; @@ -13,7 +18,6 @@ import { PrivilegePurchaseOption } from '@models/PrivilegePurchaseOption/Privile import { State } from '@models/State/State.model'; import mutations, { MutationTypes } from './user.mutations'; import actions from './user.actions'; -import getters from './user.getters'; chai.use(chaiMatchPattern); const sinon = require('sinon'); @@ -41,10 +45,12 @@ describe('Use Store Mutations', () => { it('should successfully get login success', () => { const state = {}; - mutations[MutationTypes.LOGIN_SUCCESS](state); + mutations[MutationTypes.LOGIN_SUCCESS](state, AuthTypes.LICENSEE); expect(state.isLoadingAccount).to.equal(false); expect(state.isLoggedIn).to.equal(true); + expect(state.isLoggedInAsLicensee).to.equal(true); + expect(state.isLoggedInAsStaff).to.equal(false); expect(state.error).to.equal(null); }); it('should successfully get login reset', () => { @@ -169,10 +175,10 @@ describe('User Store Actions', async () => { const commit = sinon.spy(); const dispatch = sinon.spy(); - await actions.loginSuccess({ commit, dispatch }); + await actions.loginSuccess({ commit, dispatch }, AuthTypes.LICENSEE); expect(commit.calledOnce).to.equal(true); - expect(commit.firstCall.args).to.matchPattern([MutationTypes.LOGIN_SUCCESS]); + expect(commit.firstCall.args).to.matchPattern([MutationTypes.LOGIN_SUCCESS, AuthTypes.LICENSEE]); expect(dispatch.calledOnce).to.equal(false); }); it('should successfully start login failure', () => { @@ -349,19 +355,9 @@ describe('User Store Actions', async () => { expect(commit.calledOnce).to.equal(true); expect(commit.firstCall.args).to.matchPattern([MutationTypes.SET_REFRESH_TIMEOUT_ID, null]); }); - it('should successfully clear session stores', () => { - const dispatch = sinon.spy(); - - actions.clearSessionStores({ dispatch }); - - expect(dispatch.callCount).to.equal(5); - }); - it('should successfully use the authType getter for licensee auth', () => { + it('should successfully clear and update auth tokens by clearing existing ones except for auth token, then storing', () => { const dispatch = sinon.spy(); - - authStorage.removeItem(tokens.staff.AUTH_TOKEN); - - const authType = 'licensee'; + const authType = 'staff'; const tokenResponse = { access_token: 'test_access_token', @@ -371,34 +367,17 @@ describe('User Store Actions', async () => { refresh_token: 'test_refresh_token', }; - actions.storeAuthTokens({ dispatch }, { tokenResponse, authType }); + actions.updateAuthTokens({ dispatch }, { tokenResponse, authType }); - const authTypeReturned = getters.highestPermissionAuthType()(); - - expect(authTypeReturned).to.equal('licensee'); + expect(dispatch.callCount).to.equal(2); }); - it('should successfully use the authType getter for staff auth', () => { + it('should successfully clear session stores', () => { const dispatch = sinon.spy(); - const authType = 'staff'; - - authStorage.removeItem(tokens.licensee.AUTH_TOKEN); - - const tokenResponse = { - access_token: 'test_access_token', - token_type: 'test_token_type', - expires_in: 1, - id_token: 'test_id_token', - refresh_token: 'test_refresh_token', - }; - - actions.storeAuthTokens({ dispatch }, { tokenResponse, authType }); - - const authTypeReturned = getters.highestPermissionAuthType()(); + actions.clearSessionStores({ dispatch }); - expect(authTypeReturned).to.equal('staff'); + expect(dispatch.callCount).to.equal(5); }); - it('should successfully start get privilege purchase information request', () => { const commit = sinon.spy(); const dispatch = sinon.spy(); diff --git a/webroot/src/store/user/user.state.ts b/webroot/src/store/user/user.state.ts index 85f52d87e..0fb510518 100644 --- a/webroot/src/store/user/user.state.ts +++ b/webroot/src/store/user/user.state.ts @@ -8,11 +8,18 @@ import { LicenseeUser } from '@models/LicenseeUser/LicenseeUser.model'; import { StaffUser } from '@models/StaffUser/StaffUser.model'; import { Compact } from '@models/Compact/Compact.model'; -import { authStorage, tokens } from '@/app.config'; +import { + authStorage, + tokens, + AuthTypes, + AUTH_TYPE +} from '@/app.config'; export interface State { model: StaffUser | LicenseeUser | null; isLoggedIn: boolean; + isLoggedInAsLicensee: boolean; + isLoggedInAsStaff: boolean; isLoadingAccount: boolean; isLoadingPrivilegePurchaseOptions: boolean; refreshTokenTimeoutId: number | null; @@ -25,6 +32,8 @@ export interface State { export const state: State = { model: null, isLoggedIn: (!!authStorage.getItem(tokens.staff.AUTH_TOKEN) || !!authStorage.getItem(tokens.licensee.AUTH_TOKEN)), + isLoggedInAsLicensee: Boolean(authStorage.getItem(AUTH_TYPE) === AuthTypes.LICENSEE), + isLoggedInAsStaff: Boolean(authStorage.getItem(AUTH_TYPE) === AuthTypes.STAFF), isLoadingAccount: false, isLoadingPrivilegePurchaseOptions: false, arePurchaseAttestationsAccepted: false, From 8ff3a188dcc049892e4ce843f096dc0832d940b8 Mon Sep 17 00:00:00 2001 From: Justin Frahm Date: Thu, 26 Dec 2024 09:34:26 -0700 Subject: [PATCH 04/18] Feat/ssn table (#411) --- .../common_constructs/nodejs_function.py | 8 +- .../common_constructs/python_function.py | 43 +++-- .../queued_lambda_processor.py | 50 ++++- .../lambdas/nodejs/lib/lambda.ts | 4 +- .../nodejs/lib/models/event-records.ts | 10 +- .../lambdas/nodejs/lib/report-emailer.ts | 30 +-- .../lambdas/python/common/cc_common/config.py | 12 ++ .../common/cc_common/data_model/client.py | 6 +- .../lambdas/python/common/cc_common/utils.py | 1 + .../python/common/requirements-dev.txt | 10 +- .../lambdas/python/common/requirements.txt | 8 +- .../custom-resources/requirements-dev.txt | 10 +- .../python/data-events/requirements-dev.txt | 10 +- .../provider-data-v1/requirements-dev.txt | 10 +- .../python/provider-data-v1/tests/__init__.py | 2 + .../tests/function/__init__.py | 28 ++- .../function/test_data_model/test_client.py | 2 +- .../test_provider_transformations.py | 2 +- .../python/purchases/requirements-dev.txt | 10 +- .../lambdas/python/purchases/requirements.txt | 2 +- .../staff-user-pre-token/requirements-dev.txt | 10 +- .../python/staff-users/requirements-dev.txt | 10 +- backend/compact-connect/requirements-dev.txt | 6 +- backend/compact-connect/requirements.txt | 8 +- .../stacks/api_stack/cc_api.py | 17 +- .../stacks/api_stack/v1_api/api.py | 1 + .../api_stack/v1_api/query_providers.py | 24 ++- .../compact-connect/stacks/ingest_stack.py | 38 +++- .../stacks/persistent_stack/__init__.py | 3 + .../persistent_stack/bulk_uploads_bucket.py | 4 +- .../compact_configuration_upload.py | 4 +- .../persistent_stack/data_event_table.py | 25 ++- .../persistent_stack/provider_users_bucket.py | 4 +- .../stacks/persistent_stack/ssn_table.py | 172 ++++++++++++++++++ .../stacks/ui_stack/distribution.py | 2 +- backend/compact-connect/tests/app/base.py | 67 +++++++ .../snapshots/SSN_TABLE_RESOURCE_POLICY.json | 22 +++ backend/multi-account/requirements.txt | 6 +- webroot/README.md | 2 +- 39 files changed, 550 insertions(+), 133 deletions(-) create mode 100644 backend/compact-connect/stacks/persistent_stack/ssn_table.py create mode 100644 backend/compact-connect/tests/resources/snapshots/SSN_TABLE_RESOURCE_POLICY.json diff --git a/backend/compact-connect/common_constructs/nodejs_function.py b/backend/compact-connect/common_constructs/nodejs_function.py index 65cf82408..aec44de91 100644 --- a/backend/compact-connect/common_constructs/nodejs_function.py +++ b/backend/compact-connect/common_constructs/nodejs_function.py @@ -71,7 +71,7 @@ def __init__( suppressions=[ { 'id': 'AwsSolutions-IAM4', - 'applies_to': [ + 'appliesTo': [ 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', ], 'reason': 'The BasicExecutionRole policy is appropriate for these lambdas', @@ -84,7 +84,9 @@ def __init__( suppressions=[ { 'id': 'AwsSolutions-IAM4', - 'applies_to': 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', # noqa: E501 line-too-long + 'appliesTo': [ + 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' + ], # noqa: E501 line-too-long 'reason': 'This policy is appropriate for the log retention lambda', }, ], @@ -95,7 +97,7 @@ def __init__( suppressions=[ { 'id': 'AwsSolutions-IAM5', - 'applies_to': ['Resource::*'], + 'appliesTo': ['Resource::*'], 'reason': 'This lambda needs to be able to configure log groups across the account, though the' ' actions it is allowed are scoped specifically for this task.', }, diff --git a/backend/compact-connect/common_constructs/python_function.py b/backend/compact-connect/common_constructs/python_function.py index 5839ba156..0038d9a07 100644 --- a/backend/compact-connect/common_constructs/python_function.py +++ b/backend/compact-connect/common_constructs/python_function.py @@ -2,10 +2,10 @@ import os -import stacks.persistent_stack as ps from aws_cdk import Duration, Stack from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Stats, TreatMissingData from aws_cdk.aws_cloudwatch_actions import SnsAction +from aws_cdk.aws_iam import IRole from aws_cdk.aws_lambda import ILayerVersion, Runtime from aws_cdk.aws_lambda_python_alpha import PythonFunction as CdkPythonFunction from aws_cdk.aws_lambda_python_alpha import PythonLayerVersion @@ -14,6 +14,7 @@ from aws_cdk.aws_ssm import StringParameter from cdk_nag import NagSuppressions from constructs import Construct +from stacks import persistent_stack as ps COMMON_PYTHON_LAMBDA_LAYER_SSM_PARAMETER_NAME = '/deployment/lambda/layers/common-python-layer-arn' @@ -31,8 +32,9 @@ def __init__( construct_id: str, *, lambda_dir: str, - log_retention: RetentionDays = RetentionDays.ONE_MONTH, + log_retention: RetentionDays = RetentionDays.INFINITE, alarm_topic: ITopic = None, + role: IRole = None, **kwargs, ): defaults = { @@ -46,6 +48,7 @@ def __init__( entry=os.path.join('lambdas', 'python', lambda_dir), runtime=Runtime.PYTHON_3_12, log_retention=log_retention, + role=role, **defaults, ) self.add_layers(self._get_common_layer()) @@ -72,26 +75,32 @@ def __init__( }, ], ) - NagSuppressions.add_resource_suppressions_by_path( - stack, - path=f'{self.node.path}/ServiceRole/Resource', - suppressions=[ - { - 'id': 'AwsSolutions-IAM4', - 'applies_to': [ - 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', - ], - 'reason': 'The BasicExecutionRole policy is appropriate for these lambdas', - }, - ], - ) + + # If a role is provided from elsewhere for this lambda (role is not None), we don't need to run suppressions for + # the role that this construct normally creates. + if role is None: + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{self.node.path}/ServiceRole/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM4', + 'appliesTo': [ + 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', + ], + 'reason': 'The BasicExecutionRole policy is appropriate for these lambdas', + }, + ], + ) NagSuppressions.add_resource_suppressions_by_path( stack, path=f'{stack.node.path}/LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/ServiceRole/Resource', suppressions=[ { 'id': 'AwsSolutions-IAM4', - 'applies_to': 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', # noqa: E501 line-too-long + 'appliesTo': [ + 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' + ], # noqa: E501 line-too-long 'reason': 'This policy is appropriate for the log retention lambda', }, ], @@ -102,7 +111,7 @@ def __init__( suppressions=[ { 'id': 'AwsSolutions-IAM5', - 'applies_to': ['Resource::*'], + 'appliesTo': ['Resource::*'], 'reason': 'This lambda needs to be able to configure log groups across the account, though the' ' actions it is allowed are scoped specifically for this task.', }, diff --git a/backend/compact-connect/common_constructs/queued_lambda_processor.py b/backend/compact-connect/common_constructs/queued_lambda_processor.py index 02645a5de..4630f77f2 100644 --- a/backend/compact-connect/common_constructs/queued_lambda_processor.py +++ b/backend/compact-connect/common_constructs/queued_lambda_processor.py @@ -1,9 +1,9 @@ -from aws_cdk import Duration +from aws_cdk import Duration, Names from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, TreatMissingData from aws_cdk.aws_cloudwatch_actions import SnsAction +from aws_cdk.aws_iam import Effect, PolicyStatement from aws_cdk.aws_kms import IKey from aws_cdk.aws_lambda import IFunction -from aws_cdk.aws_lambda_event_sources import SqsEventSource from aws_cdk.aws_logs import QueryDefinition, QueryString from aws_cdk.aws_sns import ITopic from aws_cdk.aws_sqs import DeadLetterQueue, IQueue, Queue, QueueEncryption @@ -51,14 +51,46 @@ def __init__( dead_letter_queue=DeadLetterQueue(max_receive_count=max_receive_count, queue=self.dlq), ) - process_function.add_event_source( - SqsEventSource( - self.queue, - batch_size=batch_size, - max_batching_window=max_batching_window, - report_batch_item_failures=True, - ), + # The following section of code is equivalent to: + # process_function.add_event_source( + # SqsEventSource( + # self.queue, + # batch_size=batch_size, + # max_batching_window=max_batching_window, + # report_batch_item_failures=True, + # ), + # ) + # + # Except that we are granting the lambda permission to consume SQS messages via resource policy + # on the queue, rather than the more conventional approach of principal policy on the IAM role. + # + # We use a lower-level add_event_source_mapping method here so that we can control how those + # permissions are granted. In this case, we need to grant permissions via resource policy on + # the Queue rather than principal policy on the role to avoid creating a dependency from the + # role on the queue. In some cases, adding the dependency on the role can cause a circular + # dependency. + process_function.add_event_source_mapping( + f'SqsEventSource:{Names.node_unique_id(self.queue.node)}', + batch_size=batch_size, + max_batching_window=max_batching_window, + report_batch_item_failures=True, + event_source_arn=self.queue.queue_arn, + ) + self.queue.add_to_resource_policy( + PolicyStatement( + effect=Effect.ALLOW, + principals=[process_function.role], + actions=[ + 'sqs:ReceiveMessage', + 'sqs:ChangeMessageVisibility', + 'sqs:GetQueueUrl', + 'sqs:DeleteMessage', + 'sqs:GetQueueAttributes', + ], + resources=[self.queue.queue_arn], + ) ) + self._add_queue_alarms( retention_period=retention_period, queue=self.queue, dlq=self.dlq, alarm_topic=alarm_topic ) diff --git a/backend/compact-connect/lambdas/nodejs/lib/lambda.ts b/backend/compact-connect/lambdas/nodejs/lib/lambda.ts index cee91529c..4b5c43d22 100644 --- a/backend/compact-connect/lambdas/nodejs/lib/lambda.ts +++ b/backend/compact-connect/lambdas/nodejs/lib/lambda.ts @@ -97,8 +97,8 @@ export class Lambda implements LambdaInterface { ); // verify that the jurisdiction uploaded licenses within the last week without any errors - if (!weeklyIngestEvents.ingestFailures.length - && !weeklyIngestEvents.validationErrors.length + if (!weeklyIngestEvents.ingestFailures.length + && !weeklyIngestEvents.validationErrors.length && weeklyIngestEvents.ingestSuccesses.length ) { const messageId = await this.reportEmailer.sendAllsWellEmail( diff --git a/backend/compact-connect/lambdas/nodejs/lib/models/event-records.ts b/backend/compact-connect/lambdas/nodejs/lib/models/event-records.ts index 960761c92..3e15b58fb 100644 --- a/backend/compact-connect/lambdas/nodejs/lib/models/event-records.ts +++ b/backend/compact-connect/lambdas/nodejs/lib/models/event-records.ts @@ -34,11 +34,11 @@ export interface IIngestSuccessEventRecord { sk: string, eventType: string, eventTime: string; - compact: string; - jurisdiction: string; - licenseType: string; - status: 'active' | 'inactive'; + compact: string; + jurisdiction: string; + licenseType: string; + status: 'active' | 'inactive'; dateOfIssuance: string; dateOfRenewal: string; - dateOfExpiration: string; + dateOfExpiration: string; } diff --git a/backend/compact-connect/lambdas/nodejs/lib/report-emailer.ts b/backend/compact-connect/lambdas/nodejs/lib/report-emailer.ts index b0109ad82..833d9d9b6 100644 --- a/backend/compact-connect/lambdas/nodejs/lib/report-emailer.ts +++ b/backend/compact-connect/lambdas/nodejs/lib/report-emailer.ts @@ -86,11 +86,11 @@ export class ReportEmailer { // Generate the HTML report const htmlContent = this.generateReport(events, compact, jurisdiction); - return this.sendEmail({ - htmlContent, - subject: `License Data Error Summary: ${compact} / ${jurisdiction}`, - recipients, - errorMessage: 'Error sending report email' + return this.sendEmail({ + htmlContent, + subject: `License Data Error Summary: ${compact} / ${jurisdiction}`, + recipients, + errorMessage: 'Error sending report email' }); } @@ -107,11 +107,11 @@ export class ReportEmailer { const htmlContent = renderToStaticMarkup(report, { rootBlockId: 'root' }); - return this.sendEmail({ - htmlContent, - subject: `License Data Summary: ${compact} / ${jurisdiction}`, - recipients, - errorMessage: 'Error sending alls well email' + return this.sendEmail({ + htmlContent, + subject: `License Data Summary: ${compact} / ${jurisdiction}`, + recipients, + errorMessage: 'Error sending alls well email' }); } @@ -128,11 +128,11 @@ export class ReportEmailer { const htmlContent = renderToStaticMarkup(report, { rootBlockId: 'root' }); - return this.sendEmail({ - htmlContent, - subject: `No License Updates for Last 7 Days: ${compact} / ${jurisdiction}`, - recipients, - errorMessage: 'Error sending no license updates email' + return this.sendEmail({ + htmlContent, + subject: `No License Updates for Last 7 Days: ${compact} / ${jurisdiction}`, + recipients, + errorMessage: 'Error sending no license updates email' }); } diff --git a/backend/compact-connect/lambdas/python/common/cc_common/config.py b/backend/compact-connect/lambdas/python/common/cc_common/config.py index 59bce70ee..4bc9c43ef 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/config.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/config.py @@ -65,6 +65,10 @@ def event_bus_name(self): def provider_table(self): return boto3.resource('dynamodb').Table(self.provider_table_name) + @cached_property + def ssn_table(self): + return boto3.resource('dynamodb').Table(self.ssn_table_name) + @property def compact_configuration_table_name(self): return os.environ['COMPACT_CONFIGURATION_TABLE_NAME'] @@ -92,6 +96,10 @@ def license_types_for_compact(self, compact): def provider_table_name(self): return os.environ['PROVIDER_TABLE_NAME'] + @property + def ssn_table_name(self): + return os.environ['SSN_TABLE_NAME'] + @property def fam_giv_mid_index_name(self): return os.environ['PROV_FAM_GIV_MID_INDEX_NAME'] @@ -100,6 +108,10 @@ def fam_giv_mid_index_name(self): def date_of_update_index_name(self): return os.environ['PROV_DATE_OF_UPDATE_INDEX_NAME'] + @property + def ssn_inverted_index_name(self): + return os.environ['SSN_INVERTED_INDEX_NAME'] + @property def bulk_bucket_name(self): return os.environ['BULK_BUCKET_NAME'] diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/client.py index bb4bb80fa..f2564807a 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/client.py @@ -29,7 +29,7 @@ def get_provider_id(self, *, compact: str, ssn: str) -> str: """Get all records associated with a given SSN.""" logger.info('Getting provider id by ssn') try: - resp = self.config.provider_table.get_item( + resp = self.config.ssn_table.get_item( Key={'pk': f'{compact}#SSN#{ssn}', 'sk': f'{compact}#SSN#{ssn}'}, ConsistentRead=True, )['Item'] @@ -44,7 +44,7 @@ def get_or_create_provider_id(self, *, compact: str, ssn: str) -> str: # This is an 'ask forgiveness' approach to provider id assignment: # Try to create a new provider, conditional on it not already existing try: - self.config.provider_table.put_item( + self.config.ssn_table.put_item( Item={ 'pk': f'{compact}#SSN#{ssn}', 'sk': f'{compact}#SSN#{ssn}', @@ -61,6 +61,8 @@ def get_or_create_provider_id(self, *, compact: str, ssn: str) -> str: # The provider already exists, so grab their providerId provider_id = TypeDeserializer().deserialize(e.response['Item']['providerId']) logger.info('Found existing provider', provider_id=provider_id) + else: + raise return provider_id @paginated_query diff --git a/backend/compact-connect/lambdas/python/common/cc_common/utils.py b/backend/compact-connect/lambdas/python/common/cc_common/utils.py index be1f19fec..1702efadb 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/utils.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/utils.py @@ -61,6 +61,7 @@ def caught_handler(event, context: LambdaContext): 'Incoming request', method=event['httpMethod'], path=event['requestContext']['resourcePath'], + identity={'user': event['requestContext'].get('authorizer', {}).get('claims', {}).get('sub')}, query_params=event['queryStringParameters'], username=event['requestContext'].get('authorizer', {}).get('claims', {}).get('cognito:username'), context=context, diff --git a/backend/compact-connect/lambdas/python/common/requirements-dev.txt b/backend/compact-connect/lambdas/python/common/requirements-dev.txt index dfa088cee..4038df16e 100644 --- a/backend/compact-connect/lambdas/python/common/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/common/requirements-dev.txt @@ -4,9 +4,9 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/common/requirements-dev.in # -boto3==1.35.81 +boto3==1.35.87 # via moto -botocore==1.35.81 +botocore==1.35.87 # via # boto3 # moto @@ -25,7 +25,7 @@ faker==28.4.1 # via -r compact-connect/lambdas/python/common/requirements-dev.in idna==3.10 # via requests -jinja2==3.1.4 +jinja2==3.1.5 # via moto jmespath==1.0.1 # via @@ -35,7 +35,7 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.0.23 +moto[dynamodb,s3]==5.0.24 # via -r compact-connect/lambdas/python/common/requirements-dev.in py-partiql-parser==0.5.6 # via moto @@ -61,7 +61,7 @@ s3transfer==0.10.4 # via boto3 six==1.17.0 # via python-dateutil -urllib3==2.2.3 +urllib3==2.3.0 # via # botocore # docker diff --git a/backend/compact-connect/lambdas/python/common/requirements.txt b/backend/compact-connect/lambdas/python/common/requirements.txt index a55f9c346..62ebc0fb5 100644 --- a/backend/compact-connect/lambdas/python/common/requirements.txt +++ b/backend/compact-connect/lambdas/python/common/requirements.txt @@ -6,9 +6,9 @@ # aws-lambda-powertools==2.43.1 # via -r compact-connect/lambdas/python/common/requirements.in -boto3==1.35.81 +boto3==1.35.87 # via -r compact-connect/lambdas/python/common/requirements.in -botocore==1.35.81 +botocore==1.35.87 # via # boto3 # s3transfer @@ -17,7 +17,7 @@ jmespath==1.0.1 # aws-lambda-powertools # boto3 # botocore -marshmallow==3.23.1 +marshmallow==3.23.2 # via -r compact-connect/lambdas/python/common/requirements.in packaging==24.2 # via marshmallow @@ -29,5 +29,5 @@ six==1.17.0 # via python-dateutil typing-extensions==4.12.2 # via aws-lambda-powertools -urllib3==2.2.3 +urllib3==2.3.0 # via botocore diff --git a/backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt b/backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt index b6b4b61ae..9e4cc12e5 100644 --- a/backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt @@ -4,9 +4,9 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/custom-resources/requirements-dev.in # -boto3==1.35.81 +boto3==1.35.87 # via moto -botocore==1.35.81 +botocore==1.35.87 # via # boto3 # moto @@ -23,7 +23,7 @@ docker==7.1.0 # via moto idna==3.10 # via requests -jinja2==3.1.4 +jinja2==3.1.5 # via moto jmespath==1.0.1 # via @@ -33,7 +33,7 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.0.23 +moto[dynamodb,s3]==5.0.24 # via -r compact-connect/lambdas/python/custom-resources/requirements-dev.in py-partiql-parser==0.5.6 # via moto @@ -58,7 +58,7 @@ s3transfer==0.10.4 # via boto3 six==1.17.0 # via python-dateutil -urllib3==2.2.3 +urllib3==2.3.0 # via # botocore # docker diff --git a/backend/compact-connect/lambdas/python/data-events/requirements-dev.txt b/backend/compact-connect/lambdas/python/data-events/requirements-dev.txt index 023b473c1..ac473bbde 100644 --- a/backend/compact-connect/lambdas/python/data-events/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/data-events/requirements-dev.txt @@ -4,9 +4,9 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/data-events/requirements-dev.in # -boto3==1.35.81 +boto3==1.35.87 # via moto -botocore==1.35.81 +botocore==1.35.87 # via # boto3 # moto @@ -23,7 +23,7 @@ docker==7.1.0 # via moto idna==3.10 # via requests -jinja2==3.1.4 +jinja2==3.1.5 # via moto jmespath==1.0.1 # via @@ -33,7 +33,7 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.0.23 +moto[dynamodb,s3]==5.0.24 # via -r compact-connect/lambdas/python/data-events/requirements-dev.in py-partiql-parser==0.5.6 # via moto @@ -58,7 +58,7 @@ s3transfer==0.10.4 # via boto3 six==1.17.0 # via python-dateutil -urllib3==2.2.3 +urllib3==2.3.0 # via # botocore # docker diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/requirements-dev.txt b/backend/compact-connect/lambdas/python/provider-data-v1/requirements-dev.txt index b2ec3256b..88ac77aa2 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/provider-data-v1/requirements-dev.txt @@ -4,9 +4,9 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/provider-data-v1/requirements-dev.in # -boto3==1.35.81 +boto3==1.35.87 # via moto -botocore==1.35.81 +botocore==1.35.87 # via # boto3 # moto @@ -25,7 +25,7 @@ faker==28.4.1 # via -r compact-connect/lambdas/python/provider-data-v1/requirements-dev.in idna==3.10 # via requests -jinja2==3.1.4 +jinja2==3.1.5 # via moto jmespath==1.0.1 # via @@ -35,7 +35,7 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.0.23 +moto[dynamodb,s3]==5.0.24 # via -r compact-connect/lambdas/python/provider-data-v1/requirements-dev.in py-partiql-parser==0.5.6 # via moto @@ -61,7 +61,7 @@ s3transfer==0.10.4 # via boto3 six==1.17.0 # via python-dateutil -urllib3==2.2.3 +urllib3==2.3.0 # via # botocore # docker diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/__init__.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/__init__.py index c3fdec1ac..cb119513e 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/__init__.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/__init__.py @@ -17,6 +17,7 @@ def setUpClass(cls): 'BULK_BUCKET_NAME': 'cc-license-data-bulk-bucket', 'EVENT_BUS_NAME': 'license-data-events', 'PROVIDER_TABLE_NAME': 'provider-table', + 'SSN_TABLE_NAME': 'ssn-table', 'COMPACT_CONFIGURATION_TABLE_NAME': 'compact-configuration-table', 'ENVIRONMENT_NAME': 'test', 'PROV_FAM_GIV_MID_INDEX_NAME': 'providerFamGivMid', @@ -24,6 +25,7 @@ def setUpClass(cls): 'USER_POOL_ID': 'us-east-1-12345', 'USERS_TABLE_NAME': 'provider-table', 'PROV_DATE_OF_UPDATE_INDEX_NAME': 'providerDateOfUpdate', + 'SSN_INVERTED_INDEX_NAME': 'inverted', 'PROVIDER_USER_BUCKET_NAME': 'provider-user-bucket', 'COMPACTS': '["aslp", "octp", "coun"]', 'JURISDICTIONS': '["ne", "oh", "ky"]', diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/__init__.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/__init__.py index b66aea92e..e458d3122 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/__init__.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/__init__.py @@ -37,6 +37,7 @@ def setUp(self): # noqa: N801 invalid-name def build_resources(self): self._bucket = boto3.resource('s3').create_bucket(Bucket=os.environ['BULK_BUCKET_NAME']) self.create_provider_table() + self.create_ssn_table() boto3.client('events').create_event_bus(Name=os.environ['EVENT_BUS_NAME']) @@ -71,10 +72,32 @@ def create_provider_table(self): ], ) + def create_ssn_table(self): + self._ssn_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['SSN_TABLE_NAME'], + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + GlobalSecondaryIndexes=[ + { + 'IndexName': os.environ['SSN_INVERTED_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'sk', 'KeyType': 'HASH'}, + {'AttributeName': 'pk', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + ], + ) + def delete_resources(self): self._bucket.objects.delete() self._bucket.delete() self._provider_table.delete() + self._ssn_table.delete() boto3.client('events').delete_event_bus(Name=os.environ['EVENT_BUS_NAME']) def _load_provider_data(self): @@ -91,7 +114,10 @@ def privilege_jurisdictions_to_set(obj: dict): record = json.load(f, object_hook=privilege_jurisdictions_to_set, parse_float=Decimal) logger.debug('Loading resource, %s: %s', resource, str(record)) - self._provider_table.put_item(Item=record) + if record['type'] == 'provider-ssn': + self._ssn_table.put_item(Item=record) + else: + self._provider_table.put_item(Item=record) def _generate_providers(self, *, home: str, privilege: str, start_serial: int, names: tuple[tuple[str, str]] = ()): """Generate 10 providers with one license and one privilege diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_client.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_client.py index 9f705943b..bf4ee30ef 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_client.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_client.py @@ -17,7 +17,7 @@ def test_get_provider_id(self): provider_ssn = record['ssn'] expected_provider_id = record['providerId'] - self._provider_table.put_item( + self._ssn_table.put_item( # We'll use the schema/serializer to populate index fields for us Item=record, ) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_provider_transformations.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_provider_transformations.py index bfcfa26b1..19df93cff 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_provider_transformations.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_provider_transformations.py @@ -21,7 +21,7 @@ def test_transformations(self): with open('../common/tests/resources/dynamo/provider-ssn.json') as f: provider_ssn = json.load(f) - self._provider_table.put_item(Item=provider_ssn) + self._ssn_table.put_item(Item=provider_ssn) expected_provider_id = provider_ssn['providerId'] # license data as it comes in from a board, in this case, as POSTed through the API diff --git a/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt b/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt index e2434f27e..c716f9f9b 100644 --- a/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt @@ -4,9 +4,9 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/purchases/requirements-dev.in # -boto3==1.35.81 +boto3==1.35.87 # via moto -botocore==1.35.81 +botocore==1.35.87 # via # boto3 # moto @@ -23,7 +23,7 @@ docker==7.1.0 # via moto idna==3.10 # via requests -jinja2==3.1.4 +jinja2==3.1.5 # via moto jmespath==1.0.1 # via @@ -33,7 +33,7 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.0.23 +moto[dynamodb,s3]==5.0.24 # via -r compact-connect/lambdas/python/purchases/requirements-dev.in py-partiql-parser==0.5.6 # via moto @@ -58,7 +58,7 @@ s3transfer==0.10.4 # via boto3 six==1.17.0 # via python-dateutil -urllib3==2.2.3 +urllib3==2.3.0 # via # botocore # docker diff --git a/backend/compact-connect/lambdas/python/purchases/requirements.txt b/backend/compact-connect/lambdas/python/purchases/requirements.txt index 9ee3bb9df..b88fcce33 100644 --- a/backend/compact-connect/lambdas/python/purchases/requirements.txt +++ b/backend/compact-connect/lambdas/python/purchases/requirements.txt @@ -18,5 +18,5 @@ pyxb-x==1.2.6.2 # via authorizenet requests==2.32.3 # via authorizenet -urllib3==2.2.3 +urllib3==2.3.0 # via requests diff --git a/backend/compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.txt b/backend/compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.txt index 34d076ebf..98494f3b7 100644 --- a/backend/compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.txt @@ -4,9 +4,9 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.in # -boto3==1.35.81 +boto3==1.35.87 # via moto -botocore==1.35.81 +botocore==1.35.87 # via # boto3 # moto @@ -23,7 +23,7 @@ docker==7.1.0 # via moto idna==3.10 # via requests -jinja2==3.1.4 +jinja2==3.1.5 # via moto jmespath==1.0.1 # via @@ -33,7 +33,7 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.0.23 +moto[dynamodb,s3]==5.0.24 # via -r compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.in py-partiql-parser==0.5.6 # via moto @@ -58,7 +58,7 @@ s3transfer==0.10.4 # via boto3 six==1.17.0 # via python-dateutil -urllib3==2.2.3 +urllib3==2.3.0 # via # botocore # docker diff --git a/backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt b/backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt index 6d965cef9..94812d8a0 100644 --- a/backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt @@ -4,9 +4,9 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/staff-users/requirements-dev.in # -boto3==1.35.81 +boto3==1.35.87 # via moto -botocore==1.35.81 +botocore==1.35.87 # via # boto3 # moto @@ -27,7 +27,7 @@ faker==28.4.1 # via -r compact-connect/lambdas/python/staff-users/requirements-dev.in idna==3.10 # via requests -jinja2==3.1.4 +jinja2==3.1.5 # via moto jmespath==1.0.1 # via @@ -39,7 +39,7 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[cognitoidp,dynamodb,s3]==5.0.23 +moto[cognitoidp,dynamodb,s3]==5.0.24 # via -r compact-connect/lambdas/python/staff-users/requirements-dev.in py-partiql-parser==0.5.6 # via moto @@ -65,7 +65,7 @@ s3transfer==0.10.4 # via boto3 six==1.17.0 # via python-dateutil -urllib3==2.2.3 +urllib3==2.3.0 # via # botocore # docker diff --git a/backend/compact-connect/requirements-dev.txt b/backend/compact-connect/requirements-dev.txt index 8ac650471..8f285a0ad 100644 --- a/backend/compact-connect/requirements-dev.txt +++ b/backend/compact-connect/requirements-dev.txt @@ -16,7 +16,7 @@ certifi==2024.12.14 # via requests charset-normalizer==3.4.0 # via requests -click==8.1.7 +click==8.1.8 # via pip-tools coverage[toml]==7.6.9 # via @@ -86,7 +86,7 @@ requests==2.32.3 # pip-audit rich==13.9.4 # via pip-audit -ruff==0.8.3 +ruff==0.8.4 # via -r compact-connect/requirements-dev.in six==1.17.0 # via @@ -96,7 +96,7 @@ sortedcontainers==2.4.0 # via cyclonedx-python-lib toml==0.10.2 # via pip-audit -urllib3==2.2.3 +urllib3==2.3.0 # via requests webencodings==0.5.1 # via html5lib diff --git a/backend/compact-connect/requirements.txt b/backend/compact-connect/requirements.txt index 974b1c450..5ea2259a4 100644 --- a/backend/compact-connect/requirements.txt +++ b/backend/compact-connect/requirements.txt @@ -8,17 +8,17 @@ attrs==24.3.0 # via # cattrs # jsii -aws-cdk-asset-awscli-v1==2.2.215 +aws-cdk-asset-awscli-v1==2.2.216 # via aws-cdk-lib aws-cdk-asset-kubectl-v20==2.1.3 # via aws-cdk-lib aws-cdk-asset-node-proxy-agent-v6==2.1.0 # via aws-cdk-lib -aws-cdk-aws-lambda-python-alpha==2.173.1a0 +aws-cdk-aws-lambda-python-alpha==2.173.2a0 # via -r compact-connect/requirements.in aws-cdk-cloud-assembly-schema==38.0.1 # via aws-cdk-lib -aws-cdk-lib==2.173.1 +aws-cdk-lib==2.173.2 # via # -r compact-connect/requirements.in # aws-cdk-aws-lambda-python-alpha @@ -35,7 +35,7 @@ constructs==10.4.2 # cdk-nag importlib-resources==6.4.5 # via jsii -jsii==1.105.0 +jsii==1.106.0 # via # aws-cdk-asset-awscli-v1 # aws-cdk-asset-kubectl-v20 diff --git a/backend/compact-connect/stacks/api_stack/cc_api.py b/backend/compact-connect/stacks/api_stack/cc_api.py index c507fb3d7..da28471a8 100644 --- a/backend/compact-connect/stacks/api_stack/cc_api.py +++ b/backend/compact-connect/stacks/api_stack/cc_api.py @@ -116,14 +116,13 @@ def __init__( { 'source_ip': '$context.identity.sourceIp', 'identity': { - 'caller': '$context.identity.caller', - 'user': '$context.identity.user', + 'user': '$context.authorizer.claims.sub', 'user_agent': '$context.identity.userAgent', }, 'level': 'INFO', 'message': 'API Access log', 'request_time': '[$context.requestTime]', - 'http_method': '$context.httpMethod', + 'method': '$context.httpMethod', 'domain_name': '$context.domainName', 'resource_path': '$context.resourcePath', 'path': '$context.path', @@ -131,6 +130,9 @@ def __init__( 'status': '$context.status', 'response_length': '$context.responseLength', 'request_id': '$context.requestId', + 'xray_trace_id': '$context.xrayTraceId', + 'waf_evaluation': '$context.wafResponseCode', + 'waf_status': '$context.waf.status', } ) ), @@ -195,7 +197,7 @@ def __init__( 'RuntimeQuery', query_definition_name=f'{construct_id}/Lambdas', query_string=QueryString( - fields=['@timestamp', '@log', 'level', 'status', 'message', 'http_method', 'path', '@message'], + fields=['@timestamp', '@log', 'level', 'status', 'message', 'method', 'path', '@message'], filter_statements=['level in ["INFO", "WARNING", "ERROR"]'], sort='@timestamp desc', ), @@ -209,7 +211,7 @@ def __init__( suppressions=[ { 'id': 'AwsSolutions-IAM4', - 'applies_to': [ + 'appliesTo': [ 'Policy::arn::iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs' ], 'reason': 'This policy is crafted specifically for the account-level role created here.', @@ -234,7 +236,10 @@ def __init__( @cached_property def staff_users_authorizer(self): return CognitoUserPoolsAuthorizer( - self, 'StaffPoolsAuthorizer', cognito_user_pools=[self._persistent_stack.staff_users] + self, + 'StaffPoolsAuthorizer', + cognito_user_pools=[self._persistent_stack.staff_users], + results_cache_ttl=Duration.minutes(5), # Default ttl is 5 minutes. We're setting this just to be explicit ) @cached_property diff --git a/backend/compact-connect/stacks/api_stack/v1_api/api.py b/backend/compact-connect/stacks/api_stack/v1_api/api.py index 456655634..81fb8554f 100644 --- a/backend/compact-connect/stacks/api_stack/v1_api/api.py +++ b/backend/compact-connect/stacks/api_stack/v1_api/api.py @@ -89,6 +89,7 @@ def __init__(self, root: IResource, persistent_stack: ps.PersistentStack): method_options=read_auth_method_options, data_encryption_key=persistent_stack.shared_encryption_key, provider_data_table=persistent_stack.provider_table, + ssn_table=persistent_stack.ssn_table, api_model=self.api_model, ) diff --git a/backend/compact-connect/stacks/api_stack/v1_api/query_providers.py b/backend/compact-connect/stacks/api_stack/v1_api/query_providers.py index 67d3aa0ae..6557d62b1 100644 --- a/backend/compact-connect/stacks/api_stack/v1_api/query_providers.py +++ b/backend/compact-connect/stacks/api_stack/v1_api/query_providers.py @@ -11,7 +11,7 @@ # Importing module level to allow lazy loading for typing from stacks.api_stack import cc_api -from stacks.persistent_stack import ProviderTable +from stacks.persistent_stack import ProviderTable, SSNTable from .api_model import ApiModel @@ -23,6 +23,7 @@ def __init__( resource: Resource, method_options: MethodOptions, data_encryption_key: IKey, + ssn_table: SSNTable, provider_data_table: ProviderTable, api_model: ApiModel, ): @@ -35,8 +36,10 @@ def __init__( stack: Stack = Stack.of(resource) lambda_environment = { 'PROVIDER_TABLE_NAME': provider_data_table.table_name, - 'PROV_FAM_GIV_MID_INDEX_NAME': 'providerFamGivMid', - 'PROV_DATE_OF_UPDATE_INDEX_NAME': 'providerDateOfUpdate', + 'PROV_FAM_GIV_MID_INDEX_NAME': provider_data_table.provider_fam_giv_mid_index_name, + 'PROV_DATE_OF_UPDATE_INDEX_NAME': provider_data_table.provider_date_of_update_index_name, + 'SSN_TABLE_NAME': ssn_table.table_name, + 'SSN_INVERTED_INDEX_NAME': ssn_table.inverted_index_name, **stack.common_env_vars, } @@ -44,6 +47,7 @@ def __init__( method_options=method_options, data_encryption_key=data_encryption_key, provider_data_table=provider_data_table, + ssn_table=ssn_table, lambda_environment=lambda_environment, ) self._add_get_provider( @@ -88,6 +92,7 @@ def _add_query_providers( method_options: MethodOptions, data_encryption_key: IKey, provider_data_table: ProviderTable, + ssn_table: SSNTable, lambda_environment: dict, ): query_resource = self.resource.add_resource('query') @@ -95,6 +100,7 @@ def _add_query_providers( handler = self._query_providers_handler( data_encryption_key=data_encryption_key, provider_data_table=provider_data_table, + ssn_table=ssn_table, lambda_environment=lambda_environment, ) self.api.log_groups.append(handler.log_group) @@ -153,13 +159,14 @@ def _query_providers_handler( self, data_encryption_key: IKey, provider_data_table: ProviderTable, + ssn_table: SSNTable, lambda_environment: dict, ) -> PythonFunction: - stack = Stack.of(self.api) handler = PythonFunction( self.resource, 'QueryProvidersHandler', description='Query providers handler', + role=ssn_table.api_query_role, lambda_dir='provider-data-v1', index=os.path.join('handlers', 'providers.py'), handler='query_providers', @@ -170,11 +177,16 @@ def _query_providers_handler( provider_data_table.grant_read_data(handler) NagSuppressions.add_resource_suppressions_by_path( - stack, - path=f'{handler.node.path}/ServiceRole/DefaultPolicy/Resource', + Stack.of(handler.role), + path=f'{handler.role.node.path}/DefaultPolicy/Resource', suppressions=[ { 'id': 'AwsSolutions-IAM5', + 'appliesTo': [ + 'Action::kms:GenerateDataKey*', + 'Action::kms:ReEncrypt*', + 'Resource::/index/*', + ], 'reason': 'The actions in this policy are specifically what this lambda needs to read ' 'and is scoped to one table and encryption key.', }, diff --git a/backend/compact-connect/stacks/ingest_stack.py b/backend/compact-connect/stacks/ingest_stack.py index 9f9dc1314..ac73f4676 100644 --- a/backend/compact-connect/stacks/ingest_stack.py +++ b/backend/compact-connect/stacks/ingest_stack.py @@ -3,14 +3,14 @@ import os from aws_cdk import Duration -from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Stats, TreatMissingData +from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Metric, Stats, TreatMissingData from aws_cdk.aws_cloudwatch_actions import SnsAction from aws_cdk.aws_events import EventPattern, Rule from aws_cdk.aws_events_targets import SqsQueue from cdk_nag import NagSuppressions from common_constructs.python_function import PythonFunction from common_constructs.queued_lambda_processor import QueuedLambdaProcessor -from common_constructs.stack import AppStack +from common_constructs.stack import AppStack, Stack from constructs import Construct from stacks import persistent_stack as ps @@ -29,18 +29,21 @@ def _add_v1_ingest_chain(self, persistent_stack: ps.PersistentStack): lambda_dir='provider-data-v1', index=os.path.join('handlers', 'ingest.py'), handler='ingest_license_message', + role=persistent_stack.ssn_table.ingest_role, timeout=Duration.minutes(1), environment={ 'EVENT_BUS_NAME': persistent_stack.data_event_bus.event_bus_name, 'PROVIDER_TABLE_NAME': persistent_stack.provider_table.table_name, + 'SSN_TABLE_NAME': persistent_stack.ssn_table.table_name, + 'SSN_INVERTED_INDEX_NAME': persistent_stack.ssn_table.inverted_index_name, **self.common_env_vars, }, alarm_topic=persistent_stack.alarm_topic, ) persistent_stack.provider_table.grant_read_write_data(ingest_handler) NagSuppressions.add_resource_suppressions_by_path( - self, - f'{ingest_handler.node.path}/ServiceRole/DefaultPolicy/Resource', + Stack.of(ingest_handler.role), + f'{ingest_handler.role.node.path}/DefaultPolicy/Resource', suppressions=[ { 'id': 'AwsSolutions-IAM5', @@ -73,14 +76,37 @@ def _add_v1_ingest_chain(self, persistent_stack: ps.PersistentStack): max_batching_window=Duration.minutes(5), max_receive_count=3, batch_size=50, - encryption_key=persistent_stack.shared_encryption_key, + # Note that we're using the ssn key here, which has a much more restrictive policy. + # The messages on this queue include SSN, so we want it just as locked down as our + # permanent storage of SSN data. + encryption_key=persistent_stack.ssn_table.key, alarm_topic=persistent_stack.alarm_topic, ) - Rule( + ingest_rule = Rule( self, 'V1IngestEventRule', event_bus=persistent_stack.data_event_bus, event_pattern=EventPattern(detail_type=['license.ingest']), targets=[SqsQueue(processor.queue, dead_letter_queue=processor.dlq)], ) + + # We will want to alert on failure of this rule to deliver events to the ingest queue + Alarm( + self, + 'V1IngestRuleFailedInvocations', + metric=Metric( + namespace='AWS/Events', + metric_name='FailedInvocations', + dimensions_map={ + 'EventBusName': persistent_stack.data_event_bus.event_bus_name, + 'RuleName': ingest_rule.rule_name, + }, + period=Duration.minutes(5), + statistic='Sum', + ), + evaluation_periods=1, + threshold=1, + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) diff --git a/backend/compact-connect/stacks/persistent_stack/__init__.py b/backend/compact-connect/stacks/persistent_stack/__init__.py index 22a9862a9..1d091dc23 100644 --- a/backend/compact-connect/stacks/persistent_stack/__init__.py +++ b/backend/compact-connect/stacks/persistent_stack/__init__.py @@ -21,6 +21,7 @@ from stacks.persistent_stack.provider_table import ProviderTable from stacks.persistent_stack.provider_users import ProviderUsers from stacks.persistent_stack.provider_users_bucket import ProviderUsersBucket +from stacks.persistent_stack.ssn_table import SSNTable from stacks.persistent_stack.staff_users import StaffUsers from stacks.persistent_stack.user_email_notifications import UserEmailNotifications @@ -220,6 +221,8 @@ def _add_data_resources(self, removal_policy: RemovalPolicy): self, 'ProviderTable', encryption_key=self.shared_encryption_key, removal_policy=removal_policy ) + self.ssn_table = SSNTable(self, 'SSNTable', removal_policy=removal_policy) + self.data_event_table = DataEventTable( self, 'DataEventTable', diff --git a/backend/compact-connect/stacks/persistent_stack/bulk_uploads_bucket.py b/backend/compact-connect/stacks/persistent_stack/bulk_uploads_bucket.py index 0dae0b499..6b31a363e 100644 --- a/backend/compact-connect/stacks/persistent_stack/bulk_uploads_bucket.py +++ b/backend/compact-connect/stacks/persistent_stack/bulk_uploads_bucket.py @@ -132,7 +132,7 @@ def _add_v1_ingest_object_events(self, event_bus: EventBus): suppressions=[ { 'id': 'AwsSolutions-IAM5', - 'applies_to': ['Resource::*'], + 'appliesTo': ['Resource::*'], 'reason': """ The lambda policy is scoped specifically to the PutBucketNotification action, which suits its purpose. @@ -146,7 +146,7 @@ def _add_v1_ingest_object_events(self, event_bus: EventBus): suppressions=[ { 'id': 'AwsSolutions-IAM4', - 'applies_to': [ + 'appliesTo': [ 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', ], 'reason': 'The BasicExecutionRole policy is appropriate for this lambda', diff --git a/backend/compact-connect/stacks/persistent_stack/compact_configuration_upload.py b/backend/compact-connect/stacks/persistent_stack/compact_configuration_upload.py index b44ebcb13..0656fb110 100644 --- a/backend/compact-connect/stacks/persistent_stack/compact_configuration_upload.py +++ b/backend/compact-connect/stacks/persistent_stack/compact_configuration_upload.py @@ -106,7 +106,9 @@ def __init__( suppressions=[ { 'id': 'AwsSolutions-IAM4', - 'applies_to': 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', # noqa: E501 line-too-long + 'appliesTo': [ + 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' + ], # noqa: E501 line-too-long 'reason': 'This policy is appropriate for the log retention lambda', }, ], diff --git a/backend/compact-connect/stacks/persistent_stack/data_event_table.py b/backend/compact-connect/stacks/persistent_stack/data_event_table.py index 0e8987bdd..13ecd2d52 100644 --- a/backend/compact-connect/stacks/persistent_stack/data_event_table.py +++ b/backend/compact-connect/stacks/persistent_stack/data_event_table.py @@ -3,7 +3,7 @@ import os from aws_cdk import Duration, RemovalPolicy -from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Stats, TreatMissingData +from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Metric, Stats, TreatMissingData from aws_cdk.aws_cloudwatch_actions import SnsAction from aws_cdk.aws_dynamodb import Attribute, AttributeType, BillingMode, Table, TableEncryption from aws_cdk.aws_events import EventPattern, IEventBus, Match, Rule @@ -98,7 +98,7 @@ def __init__( alarm_topic=alarm_topic, ) - Rule( + event_receiver_rule = Rule( self, 'EventReceiverRule', event_bus=event_bus, @@ -107,6 +107,27 @@ def __init__( event_pattern=EventPattern(detail_type=Match.prefix('')), targets=[SqsQueue(self.event_processor.queue, dead_letter_queue=self.event_processor.dlq)], ) + + # We will want to alert on failure of this rule to deliver events to the data events queue + Alarm( + self, + 'DataSourceRuleFailedInvocations', + metric=Metric( + namespace='AWS/Events', + metric_name='FailedInvocations', + dimensions_map={ + 'EventBusName': stack.data_event_bus.event_bus_name, + 'RuleName': event_receiver_rule.rule_name, + }, + period=Duration.minutes(5), + statistic='Sum', + ), + evaluation_periods=1, + threshold=1, + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ).add_alarm_action(SnsAction(stack.alarm_topic)) + NagSuppressions.add_resource_suppressions( self, suppressions=[ diff --git a/backend/compact-connect/stacks/persistent_stack/provider_users_bucket.py b/backend/compact-connect/stacks/persistent_stack/provider_users_bucket.py index 68f255d65..17b896039 100644 --- a/backend/compact-connect/stacks/persistent_stack/provider_users_bucket.py +++ b/backend/compact-connect/stacks/persistent_stack/provider_users_bucket.py @@ -131,7 +131,7 @@ def _add_v1_object_events(self, provider_table: Table, encryption_key: IKey): suppressions=[ { 'id': 'AwsSolutions-IAM5', - 'applies_to': ['Resource::*'], + 'appliesTo': ['Resource::*'], 'reason': """ The lambda policy is scoped specifically to the PutBucketNotification action, which suits its purpose. @@ -145,7 +145,7 @@ def _add_v1_object_events(self, provider_table: Table, encryption_key: IKey): suppressions=[ { 'id': 'AwsSolutions-IAM4', - 'applies_to': [ + 'appliesTo': [ 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', ], 'reason': 'The BasicExecutionRole policy is appropriate for this lambda', diff --git a/backend/compact-connect/stacks/persistent_stack/ssn_table.py b/backend/compact-connect/stacks/persistent_stack/ssn_table.py new file mode 100644 index 000000000..c8348374f --- /dev/null +++ b/backend/compact-connect/stacks/persistent_stack/ssn_table.py @@ -0,0 +1,172 @@ +from aws_cdk import RemovalPolicy +from aws_cdk.aws_dynamodb import Attribute, AttributeType, BillingMode, ProjectionType, Table, TableEncryption +from aws_cdk.aws_iam import ( + Effect, + ManagedPolicy, + PolicyDocument, + PolicyStatement, + Role, + ServicePrincipal, + StarPrincipal, +) +from aws_cdk.aws_kms import Key +from cdk_nag import NagSuppressions +from common_constructs.stack import Stack +from constructs import Construct + + +class SSNTable(Table): + """DynamoDB table to house provider Social Security Numbers""" + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + removal_policy: RemovalPolicy, + **kwargs, + ): + self.key = Key( + scope, + 'SSNKey', + enable_key_rotation=True, + alias='ssn-key', + removal_policy=removal_policy, + ) + + super().__init__( + scope, + construct_id, + # Forcing this resource name to comply with a naming-pattern-based CloudTrail log, to be + # implemented in issue https://github.com/csg-org/CompactConnect/issues/397 + table_name='ssn-table-DataEventsLog', + encryption=TableEncryption.CUSTOMER_MANAGED, + encryption_key=self.key, + billing_mode=BillingMode.PAY_PER_REQUEST, + removal_policy=removal_policy, + point_in_time_recovery=True, + partition_key=Attribute(name='pk', type=AttributeType.STRING), + sort_key=Attribute(name='sk', type=AttributeType.STRING), + resource_policy=PolicyDocument( + statements=[ + PolicyStatement( + # No actions that involve reading/writing more than one record at a time. In the event of a + # compromise, this slows down a potential data extraction, since each record would need to be + # pulled, one at a time + effect=Effect.DENY, + actions=[ + 'dynamodb:BatchGetItem', + 'dynamodb:BatchWriteItem', + 'dynamodb:PartiQL*', + 'dynamodb:Query', + 'dynamodb:Scan', + ], + principals=[StarPrincipal()], + resources=['*'], + conditions={ + 'StringNotEquals': { + # We will allow DynamoDB itself, so it can do internal operations like backups + 'aws:PrincipalServiceName': 'dynamodb.amazonaws.com', + } + }, + ) + ] + ), + **kwargs, + ) + + # We create a GSI here in anticipation of a future change, where we will need to facilitate a lookup + # of provider_id -> ssn, in addition to our current ssn -> provider_id pattern. + self.inverted_index_name = 'inverted' + self.add_global_secondary_index( + index_name=self.inverted_index_name, + partition_key=Attribute(name='sk', type=AttributeType.STRING), + sort_key=Attribute(name='pk', type=AttributeType.STRING), + projection_type=ProjectionType.ALL, + ) + + NagSuppressions.add_resource_suppressions( + self, + suppressions=[ + { + 'id': 'HIPAA.Security-DynamoDBInBackupPlan', + 'reason': 'We will implement data back-ups after we better understand regulatory data deletion' + ' requirements', + }, + ], + ) + + self._configure_access() + + def _configure_access(self): + self.ingest_role = Role( + self, + 'LicenseIngestRole', + assumed_by=ServicePrincipal('lambda.amazonaws.com'), + description='Dedicated role for license ingest, with access to full SSNs', + managed_policies=[ManagedPolicy.from_aws_managed_policy_name('service-role/AWSLambdaBasicExecutionRole')], + ) + self.grant_read_write_data(self.ingest_role) + self._role_suppressions(self.ingest_role) + + # This role is to be removed, once full SSN access is removed from the /query API endpoint + # (https://github.com/csg-org/CompactConnect/issues/391). In the meantime, we will need to have a role the + # corresponding lambda can use. + self.api_query_role = Role( + self, + 'ProviderQueryRole', + assumed_by=ServicePrincipal('lambda.amazonaws.com'), + description='Dedicated role for API provider queries, with access to full SSNs', + managed_policies=[ManagedPolicy.from_aws_managed_policy_name('service-role/AWSLambdaBasicExecutionRole')], + ) + self.grant_read_data(self.api_query_role) + self._role_suppressions(self.api_query_role) + + # This explicitly blocks any principals (including account admins) from reading data + # encrypted with this key other than our IAM roles declared here and dynamodb itself + self.key.add_to_resource_policy( + PolicyStatement( + effect=Effect.DENY, + actions=['kms:Decrypt', 'kms:Encrypt', 'kms:GenerateDataKey*', 'kms:ReEncrypt*'], + principals=[StarPrincipal()], + resources=['*'], + conditions={ + 'StringNotEquals': { + 'aws:PrincipalArn': [self.ingest_role.role_arn, self.api_query_role.role_arn], + 'aws:PrincipalServiceName': ['dynamodb.amazonaws.com', 'events.amazonaws.com'], + } + }, + ) + ) + self.key.grant_decrypt(self.api_query_role) + self.key.grant_encrypt_decrypt(self.ingest_role) + + def _role_suppressions(self, role: Role): + stack = Stack.of(role) + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{role.node.path}/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM4', + 'appliesTo': [ + 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' + ], + 'reason': 'The BasicExecutionRole policy is appropriate for these lambdas', + }, + ], + ) + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'appliesTo': [f'Resource::<{stack.get_logical_id(self.node.default_child)}.Arn>/index/*'], + 'reason': """ + This policy contains wild-carded actions and resources but they are scoped to the + specific actions, KMS key and Table that this lambda specifically needs access to. + """, + }, + ], + ) diff --git a/backend/compact-connect/stacks/ui_stack/distribution.py b/backend/compact-connect/stacks/ui_stack/distribution.py index def326097..8e6333b86 100644 --- a/backend/compact-connect/stacks/ui_stack/distribution.py +++ b/backend/compact-connect/stacks/ui_stack/distribution.py @@ -84,7 +84,7 @@ def __init__( suppressions=[ { 'id': 'AwsSolutions-IAM4', - 'applies_to': [ + 'appliesTo': [ 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' ], 'reason': 'This policy enables CloudWatch logging and is appropriate for this lambda', diff --git a/backend/compact-connect/tests/app/base.py b/backend/compact-connect/tests/app/base.py index 44632eec1..b8ff8b373 100644 --- a/backend/compact-connect/tests/app/base.py +++ b/backend/compact-connect/tests/app/base.py @@ -12,6 +12,7 @@ from aws_cdk.aws_cognito import CfnUserPool, CfnUserPoolClient from aws_cdk.aws_dynamodb import CfnTable from aws_cdk.aws_events import CfnRule +from aws_cdk.aws_kms import CfnKey from aws_cdk.aws_lambda import CfnEventSourceMapping from aws_cdk.aws_s3 import CfnBucket from aws_cdk.aws_sqs import CfnQueue @@ -144,6 +145,72 @@ def _inspect_persistent_stack( provider_users_user_pool_app_client['WriteAttributes'], ['email', 'family_name', 'given_name'] ) self._inspect_data_events_table(persistent_stack, persistent_stack_template) + self._inspect_ssn_table(persistent_stack, persistent_stack_template) + + def _inspect_ssn_table(self, persistent_stack: PersistentStack, persistent_stack_template: Template): + ssn_key_logical_id = persistent_stack.get_logical_id(persistent_stack.ssn_table.key.node.default_child) + ingest_role_logical_id = persistent_stack.get_logical_id( + persistent_stack.ssn_table.ingest_role.node.default_child + ) + api_query_role_logical_id = persistent_stack.get_logical_id( + persistent_stack.ssn_table.api_query_role.node.default_child + ) + ssn_table_template = self.get_resource_properties_by_logical_id( + persistent_stack.get_logical_id(persistent_stack.ssn_table.node.default_child), + persistent_stack_template.find_resources(CfnTable.CFN_RESOURCE_TYPE_NAME), + ) + ssn_key_template = self.get_resource_properties_by_logical_id( + ssn_key_logical_id, persistent_stack_template.find_resources(CfnKey.CFN_RESOURCE_TYPE_NAME) + ) + # This naming convention is important for opting into future CloudTrail organization access logging + self.assertTrue(ssn_table_template['TableName'].endswith('-DataEventsLog')) + # Ensure our SSN Key is locked down by resource policy + self.assertEqual( + ssn_key_template['KeyPolicy'], + { + 'Statement': [ + { + 'Action': 'kms:*', + 'Effect': 'Allow', + 'Principal': {'AWS': f'arn:aws:iam::{persistent_stack.account}:root'}, + 'Resource': '*', + }, + { + 'Action': ['kms:Decrypt', 'kms:Encrypt', 'kms:GenerateDataKey*', 'kms:ReEncrypt*'], + 'Condition': { + 'StringNotEquals': { + 'aws:PrincipalArn': [ + {'Fn::GetAtt': [ingest_role_logical_id, 'Arn']}, + {'Fn::GetAtt': [api_query_role_logical_id, 'Arn']}, + ], + 'aws:PrincipalServiceName': ['dynamodb.amazonaws.com', 'events.amazonaws.com'], + } + }, + 'Effect': 'Deny', + 'Principal': '*', + 'Resource': '*', + }, + { + 'Action': ['kms:Decrypt', 'kms:Encrypt', 'kms:GenerateDataKey*', 'kms:ReEncrypt*'], + 'Condition': {'StringEquals': {'aws:SourceAccount': persistent_stack.account}}, + 'Effect': 'Allow', + 'Principal': {'Service': 'events.amazonaws.com'}, + 'Resource': '*', + }, + ], + 'Version': '2012-10-17', + }, + ) + # Ensure we're using our locked down KMS key for encryption + self.assertEqual( + ssn_table_template['SSESpecification'], + {'KMSMasterKeyId': {'Fn::GetAtt': [ssn_key_logical_id, 'Arn']}, 'SSEEnabled': True, 'SSEType': 'KMS'}, + ) + self.compare_snapshot( + ssn_table_template['ResourcePolicy']['PolicyDocument'], + 'SSN_TABLE_RESOURCE_POLICY', + overwrite_snapshot=False, + ) def _inspect_data_events_table(self, persistent_stack: PersistentStack, persistent_stack_template: Template): # Ensure our DataEventTable and queues are created diff --git a/backend/compact-connect/tests/resources/snapshots/SSN_TABLE_RESOURCE_POLICY.json b/backend/compact-connect/tests/resources/snapshots/SSN_TABLE_RESOURCE_POLICY.json new file mode 100644 index 000000000..90e49f531 --- /dev/null +++ b/backend/compact-connect/tests/resources/snapshots/SSN_TABLE_RESOURCE_POLICY.json @@ -0,0 +1,22 @@ +{ + "Statement": [ + { + "Action": [ + "dynamodb:BatchGetItem", + "dynamodb:BatchWriteItem", + "dynamodb:PartiQL*", + "dynamodb:Query", + "dynamodb:Scan" + ], + "Condition": { + "StringNotEquals": { + "aws:PrincipalServiceName": "dynamodb.amazonaws.com" + } + }, + "Effect": "Deny", + "Principal": "*", + "Resource": "*" + } + ], + "Version": "2012-10-17" +} diff --git a/backend/multi-account/requirements.txt b/backend/multi-account/requirements.txt index 8095cd239..ff90a86db 100644 --- a/backend/multi-account/requirements.txt +++ b/backend/multi-account/requirements.txt @@ -8,7 +8,7 @@ attrs==24.3.0 # via # cattrs # jsii -aws-cdk-asset-awscli-v1==2.2.215 +aws-cdk-asset-awscli-v1==2.2.216 # via aws-cdk-lib aws-cdk-asset-kubectl-v20==2.1.3 # via aws-cdk-lib @@ -16,7 +16,7 @@ aws-cdk-asset-node-proxy-agent-v6==2.1.0 # via aws-cdk-lib aws-cdk-cloud-assembly-schema==38.0.1 # via aws-cdk-lib -aws-cdk-lib==2.173.1 +aws-cdk-lib==2.173.2 # via -r multi-account/requirements.in cattrs==24.1.2 # via jsii @@ -26,7 +26,7 @@ constructs==10.4.2 # aws-cdk-lib importlib-resources==6.4.5 # via jsii -jsii==1.105.0 +jsii==1.106.0 # via # aws-cdk-asset-awscli-v1 # aws-cdk-asset-kubectl-v20 diff --git a/webroot/README.md b/webroot/README.md index 51fe3adde..4e89d4955 100644 --- a/webroot/README.md +++ b/webroot/README.md @@ -250,4 +250,4 @@ Note that testing the **built** app locally will require a running web server; f - When the user logs in as to the second user pool we record that the app should treat them as the second user type and save the initial access token - When the user logs out we check the existence of the access tokens to see which user pools we need to log out, and then chain logout redirects to visit all necessary logout pages ---- \ No newline at end of file +--- From fb49f60c476ac7cb3d5b8d03fba30ea29f0bc97c Mon Sep 17 00:00:00 2001 From: landonshumway-ia Date: Fri, 27 Dec 2024 12:21:50 -0600 Subject: [PATCH 05/18] Feat/read access updates (#387) --- .../common_constructs/user_pool.py | 3 +- backend/compact-connect/docs/design/README.md | 81 ++++--- .../cc_common/data_model/schema/license.py | 28 ++- .../data_model/schema/military_affiliation.py | 17 ++ .../cc_common/data_model/schema/privilege.py | 17 +- .../cc_common/data_model/schema/provider.py | 61 ++++- .../cc_common/data_model/user_client.py | 4 +- .../lambdas/python/common/cc_common/utils.py | 82 ++++++- .../resources/api/provider-response.json | 2 - .../test_authorize_compact_jurisdiction.py | 12 +- .../tests/unit/test_sanitize_provider_data.py | 74 +++++++ .../python/delete-objects/tests/__init__.py | 0 .../provider-data-v1/handlers/bulk_upload.py | 14 +- .../provider-data-v1/handlers/providers.py | 28 ++- .../test_provider_transformations.py | 2 +- .../function/test_handlers/test_ingest.py | 13 +- .../function/test_handlers/test_licenses.py | 8 +- .../function/test_handlers/test_providers.py | 69 ++++-- .../staff-user-pre-token/tests/test_main.py | 3 +- .../tests/test_user_scopes.py | 42 ++-- .../staff-user-pre-token/user_scopes.py | 25 ++- .../function/test_data_model/test_client.py | 4 +- .../function/test_handlers/test_patch_user.py | 53 ++++- .../function/test_handlers/test_post_user.py | 4 +- .../tests/resources/api/user-post.json | 2 +- .../tests/resources/api/user-response.json | 2 +- .../tests/resources/dynamo/user.json | 2 +- .../tests/unit/test_authorize_compact.py | 12 +- .../tests/unit/test_collect_changes.py | 8 +- .../stacks/api_stack/v1_api/api.py | 12 +- .../stacks/api_stack/v1_api/api_model.py | 6 +- .../stacks/api_stack/v1_api/staff_users.py | 14 +- .../stacks/persistent_stack/staff_users.py | 15 +- .../tests/app/test_api/test_purchases_api.py | 8 +- .../app/test_api/test_staff_users_api.py | 194 ++++++++++++++++ .../tests/app/test_pipeline.py | 30 ++- .../PATCH_STAFF_USERS_REQUEST_SCHEMA.json | 53 +++++ .../PATCH_STAFF_USERS_RESPONSE_SCHEMA.json | 87 ++++++++ .../POST_STAFF_USERS_REQUEST_SCHEMA.json | 83 +++++++ .../POST_STAFF_USERS_RESPONSE_SCHEMA.json | 87 ++++++++ backend/compact-connect/tests/smoke/config.py | 67 ++++++ .../tests/smoke/license_upload_smoke_tests.py | 24 +- .../smoke/military_affiliation_smoke_tests.py | 9 +- .../purchasing_privileges_smoke_tests.py | 28 ++- .../tests/smoke/query_provider_smoke_tests.py | 209 ++++++++++++++++++ .../tests/smoke/smoke_common.py | 150 ++++++++++++- .../tests/smoke/smoke_tests_env_example.json | 9 +- 47 files changed, 1574 insertions(+), 183 deletions(-) create mode 100644 backend/compact-connect/lambdas/python/common/tests/unit/test_sanitize_provider_data.py delete mode 100644 backend/compact-connect/lambdas/python/delete-objects/tests/__init__.py create mode 100644 backend/compact-connect/tests/app/test_api/test_staff_users_api.py create mode 100644 backend/compact-connect/tests/resources/snapshots/PATCH_STAFF_USERS_REQUEST_SCHEMA.json create mode 100644 backend/compact-connect/tests/resources/snapshots/PATCH_STAFF_USERS_RESPONSE_SCHEMA.json create mode 100644 backend/compact-connect/tests/resources/snapshots/POST_STAFF_USERS_REQUEST_SCHEMA.json create mode 100644 backend/compact-connect/tests/resources/snapshots/POST_STAFF_USERS_RESPONSE_SCHEMA.json create mode 100644 backend/compact-connect/tests/smoke/config.py create mode 100644 backend/compact-connect/tests/smoke/query_provider_smoke_tests.py diff --git a/backend/compact-connect/common_constructs/user_pool.py b/backend/compact-connect/common_constructs/user_pool.py index 88b547b99..b77366346 100644 --- a/backend/compact-connect/common_constructs/user_pool.py +++ b/backend/compact-connect/common_constructs/user_pool.py @@ -165,7 +165,8 @@ def add_ui_client( return self.add_client( 'UIClient', auth_flows=AuthFlow( - admin_user_password=False, + # we allow this in test environments for automated testing + admin_user_password=self.security_profile == SecurityProfile.VULNERABLE, custom=False, user_srp=self.security_profile == SecurityProfile.VULNERABLE, user_password=False, diff --git a/backend/compact-connect/docs/design/README.md b/backend/compact-connect/docs/design/README.md index e66ab6639..afe7b56b7 100644 --- a/backend/compact-connect/docs/design/README.md +++ b/backend/compact-connect/docs/design/README.md @@ -72,29 +72,49 @@ the accompanying [architecture diagram](./users-arch-diagram.pdf) for an illustr ### Staff Users -Staff users come with a variety of different permissions, depending on their role. There are Compact Executive -Directors, Compact ED Staff, Board Executive Directors, Board ED Staff, and CSG Admins, each with different levels -of ability to read and write data, and to administrate users. Read permissions are granted to a user for an entire -compact or not at all. Data writing and user administration permissions can each be granted to a user per -compact/jurisdiction combination. All of a compact user's permissions are stored in a DynamoDB record that is associated -with their own Cognito user id. That record will be used to generate scopes in the Oauth2 token issued to them on login. -See [Implementation of scopes](#implementation-of-scopes) for a detailed explanation of the design for exactly how -permissions will be represented by scopes in an access token. See +Staff users will be granted a variety of different permissions, depending on their role. Read permissions are granted +to a user for an entire compact or not at all. Data writing and user administration permissions can each be granted to +a user per compact/jurisdiction combination. All of a compact user's permissions are stored in a DynamoDB record that is +associated with their own Cognito user id. That record will be used to generate scopes in the Oauth2 token issued to them +on login. See [Implementation of scopes](#implementation-of-scopes) for a detailed explanation of the design for exactly +how permissions will be represented by scopes in an access token. See [Implementation of permissions](#implementation-of-permissions) for a detailed explanation of the design for exactly how permissions are stored and translated into scopes. -#### Compact Executive Directors and Staff +#### Common Staff User Types +The system permissions are designed around several common types of staff users. It is important to note that these user +types are an abstraction which do not correlate directly to specific roles or access within the system. All access is +controlled by the specific permissions associated with a user. Still, these abstractions are useful for understanding +the system's design. -Compact ED level staff can have permission to read all compact data as well as to create and manage users and their -permissions. They can grant other users the ability to write data for a particular jurisdiction and to create more -users associated with a particular jurisdiction. They can also delete any user within their compact, so long as that -user does not have permissions associated with a different compact. +##### Compact Executive Directors and Staff -#### Board Executive Directors and Staff +Compact ED level staff will typically be granted the following permissions at the compact level: -Board ED level staff can have permission to read all compact data, write data to for their own jurisdiction, and to -create more users that have permissions within their own jurisdiction. They can also delete any user within their -jurisdiction, so long as that user does not have permissions associated with a different compact or jurisdiction. +- `admin` - grants access to administrative functions for the compact, such as creating and managing users and their + permissions. +- `readPrivate` - grants access to view all data for any licensee within the compact. + +With the `admin` permission, they can grant other users the ability to write data for a particular +jurisdiction and to create more users associated with a particular jurisdiction. They can also delete any user within +their compact, so long as that user does not have permissions associated with a different compact, in which case the +permissions from the other compact would have to be removed first. + +Users granted any of these permissions will also be implicitly granted the `readGeneral` scope for the associated compact, +which allows them to read any licensee data within that compact that is not considered private. + +##### Board Executive Directors and Staff + +Board ED level staff may be granted the following permissions at a jurisdiction level: + +- `admin` - grants access to administrative functions for the jurisdiction, such as creating and managing users and +their permissions. +- `write` - grants access to write data for their particular jurisdiction (ie uploading license information). +- `readPrivate` - grants access to view all information for any licensee that has either a license or privilege +within their jurisdiction. + +Users granted any of these permissions will also be implicitly granted the `readGeneral` scope for the associated compact, +which allows them to read any licensee data that is not considered private. #### Implementation of Scopes @@ -109,17 +129,22 @@ require more than 100 scopes per resource server. To design around the 100 scope limit, we will have to split authorization into two layers: coarse- and fine-grained. We can rely on the Cognito authorizers to protect our API endpoints based on fewer coarse-grained scopes, then -protect the more fine-grained access within the API endpoint logic. The Staff User pool resource servers will are -configured with `read`, `write`, and `admin` scopes. `read` scopes indicate that the user is allowed to read the -entire compact's licensee data. `write` and `admin` scopes, however, indicate only that the user is allowed to write -or administrate _something_ in the compact respectively, thus giving them access to the write or administrative -API endpoints. We will then rely on the API endpoint logic to refine their access based on the more fine-grained -access scopes. - -To compliment each of the `write` and `admin` scopes, there will be at least one, more specific, scope, to indicate -_what_ within the compact they are allowed to write or administrate, respectively. In the case of `write` scopes, -a jurisdiction-specific scope will control what jurisdiction they are able to write data for (i.e. `al.write` grants -permission to write data for the Alabama jurisdiction). Similarly, `admin` scopes can have a jurisdiction-specific +protect the more fine-grained access within the API endpoint logic. The Staff User pool resource servers are +configured with `readGeneral`, `write`, and `admin` scopes. The `readGeneral` scope is implicitly granted to all users in +the system, and is used to indicate that the user is allowed to read any compact's licensee data that is not considered +private. The `write` and `admin` scopes, however, indicate only that the user is allowed to write or administrate +_something_ in the compact respectively, thus giving them access to the write or administrative API endpoints. We will +then rely on the API endpoint logic to refine their access based on the more fine-grained access scopes. + +In addition to the `readGeneral` scope, there is a `readPrivate` scope, which can be granted at both compact and +jurisdiction levels. This permission indicates the user can read all of a compact's provider data (licenses and privileges), +so long as the provider has at least one license or privilege within their jurisdiction or the user has compact-wide +permissions. + +To compliment each of the `write` and `admin` scopes, there will be at least one, more specific, scope, +to indicate _what_ within the compact they are allowed to write or administrate, respectively. In the case of `write` +scopes, a jurisdiction-specific scope will control what jurisdiction they are able to write data for (i.e. `al.write` +grants permission to write data for the Alabama jurisdiction). Similarly, `admin` scopes can have a jurisdiction-specific scope like `al.admin` and can also have a compact-wide scope like `aslp.admin`, which grants permission for a compact executive director to perform the administrative functions for the Audiology and Speech Language Pathology compact. diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/license.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/license.py index 08f492f41..56a0dd410 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/license.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/license.py @@ -14,21 +14,41 @@ ) -class LicensePublicSchema(ForgivingSchema): - """Schema for license data that can be shared with the public""" +class LicenseReadGeneralSchema(ForgivingSchema): + """ + License fields available for staff users with the 'readGeneral' permission. + + This schema is explicitly separated from the common schema to ensure we know what + fields are being returned for users with this permission. + """ - birthMonthDay = String(required=False, allow_none=False, validate=Regexp('^[0-1]{1}[0-9]{1}-[0-3]{1}[0-9]{1}')) + providerId = UUID(required=True, allow_none=False) + type = String(required=True, allow_none=False) + dateOfUpdate = DateTime(required=True, allow_none=False) compact = String(required=True, allow_none=False, validate=OneOf(config.compacts)) jurisdiction = String(required=True, allow_none=False, validate=OneOf(config.jurisdictions)) licenseType = String(required=True, allow_none=False) - status = String(required=True, allow_none=False, validate=OneOf(['active', 'inactive'])) + jurisdictionStatus = String(required=True, allow_none=False, validate=OneOf(['active', 'inactive'])) + npi = String(required=False, allow_none=False, validate=Regexp('^[0-9]{10}$')) givenName = String(required=True, allow_none=False, validate=Length(1, 100)) middleName = String(required=False, allow_none=False, validate=Length(1, 100)) familyName = String(required=True, allow_none=False, validate=Length(1, 100)) suffix = String(required=False, allow_none=False, validate=Length(1, 100)) + # These date values are determined by the license records uploaded by a state + # they do not include a timestamp, so we use the Date field type dateOfIssuance = Date(required=True, allow_none=False) dateOfRenewal = Date(required=True, allow_none=False) dateOfExpiration = Date(required=True, allow_none=False) + homeAddressStreet1 = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressStreet2 = String(required=False, allow_none=False, validate=Length(1, 100)) + homeAddressCity = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressState = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressPostalCode = String(required=True, allow_none=False, validate=Length(5, 7)) + emailAddress = Email(required=False, allow_none=False, validate=Length(1, 100)) + phoneNumber = ITUTE164PhoneNumber(required=False, allow_none=False) + status = String(required=True, allow_none=False, validate=OneOf(['active', 'inactive'])) + + militaryWaiver = Boolean(required=False, allow_none=False) class LicenseCommonSchema(ForgivingSchema): diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/military_affiliation.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/military_affiliation.py index acd5a18b3..10b41b767 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/military_affiliation.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/military_affiliation.py @@ -68,3 +68,20 @@ class PostMilitaryAffiliationResponseSchema(ForgivingSchema): documentUploadFields = List( Nested(S3PresignedPostSchema(), required=True, allow_none=False), required=True, allow_none=False ) + + +class MilitaryAffiliationGeneralResponseSchema(ForgivingSchema): + """ + Schema defining fields available to all staff users with the 'readGeneral' permission. + """ + + type = String(required=True, allow_none=False) + dateOfUpdate = DateTime(required=True, allow_none=False) + providerId = UUID(required=True, allow_none=False) + compact = String(required=True, allow_none=False, validate=OneOf(config.compacts)) + fileNames = List(String(required=True, allow_none=False), required=True, allow_none=False) + affiliationType = String( + required=True, allow_none=False, validate=OneOf([e.value for e in MilitaryAffiliationType]) + ) + dateOfUpload = DateTime(required=True, allow_none=False) + status = String(required=True, allow_none=False, validate=OneOf([e.value for e in MilitaryAffiliationStatus])) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/privilege.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/privilege.py index 6b54ecfb4..a60dae34a 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/privilege.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/privilege.py @@ -5,7 +5,7 @@ from marshmallow.validate import Length, OneOf from cc_common.config import config -from cc_common.data_model.schema.base_record import BaseRecordSchema, CalculatedStatusRecordSchema +from cc_common.data_model.schema.base_record import BaseRecordSchema, CalculatedStatusRecordSchema, ForgivingSchema from cc_common.data_model.schema.common import ensure_value_is_datetime @@ -50,3 +50,18 @@ def _enforce_datetimes(self, in_data, **kwargs): in_data['dateOfIssuance'] = ensure_value_is_datetime(in_data['dateOfIssuance']) return in_data + + +class PrivilegeGeneralResponseSchema(ForgivingSchema): + type = String(required=True, allow_none=False) + dateOfUpdate = DateTime(required=True, allow_none=False) + providerId = UUID(required=True, allow_none=False) + compact = String(required=True, allow_none=False, validate=OneOf(config.compacts)) + jurisdiction = String(required=True, allow_none=False, validate=OneOf(config.jurisdictions)) + dateOfIssuance = DateTime(required=True, allow_none=False) + dateOfRenewal = DateTime(required=True, allow_none=False) + # this is determined by the license expiration date, which is a date field, so this is also a date field + dateOfExpiration = Date(required=True, allow_none=False) + # the id of the transaction that was made when the user purchased the privilege + compactTransactionId = String(required=False, allow_none=False) + status = String(required=True, allow_none=False, validate=OneOf(['active', 'inactive'])) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/provider.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/provider.py index 8307a10e0..e5a6ee567 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/provider.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/provider.py @@ -2,7 +2,7 @@ from urllib.parse import quote from marshmallow import ValidationError, post_load, pre_dump, pre_load, validates_schema -from marshmallow.fields import UUID, Boolean, Date, DateTime, Email, String +from marshmallow.fields import UUID, Boolean, Date, DateTime, Email, List, Nested, String from marshmallow.validate import Length, OneOf, Regexp from cc_common.config import config @@ -15,10 +15,14 @@ SocialSecurityNumber, ) from cc_common.data_model.schema.common import ensure_value_is_datetime +from cc_common.data_model.schema.license import LicenseReadGeneralSchema +from cc_common.data_model.schema.military_affiliation import MilitaryAffiliationGeneralResponseSchema +from cc_common.data_model.schema.privilege import PrivilegeGeneralResponseSchema -class ProviderPublicSchema(ForgivingSchema): - """Schema for license data that can be shared with the public""" +class ProviderPrivateSchema(ForgivingSchema): + """Schema for provider data that can be shared with the staff users with the appropriate permissions, as well as + the provider themselves""" # Provided fields providerId = UUID(required=True, allow_none=False) @@ -57,7 +61,7 @@ def validate_license_type(self, data, **kwargs): # noqa: ARG001 unused-argument @BaseRecordSchema.register_schema('provider') -class ProviderRecordSchema(CalculatedStatusRecordSchema, ProviderPublicSchema): +class ProviderRecordSchema(CalculatedStatusRecordSchema, ProviderPrivateSchema): """Schema for license records in the license data table""" _record_type = 'provider' @@ -105,3 +109,52 @@ def populate_fam_giv_mid(self, in_data, **kwargs): # noqa: ARG001 unused-argume def drop_fam_giv_mid(self, in_data, **kwargs): # noqa: ARG001 unused-argument del in_data['providerFamGivMid'] return in_data + + +class SanitizedProviderReadGeneralSchema(ForgivingSchema): + """ + Provider record fields that are sanitized for users with the 'readGeneral' permission. + + This schema is intended to be used to dump records from the database in order to remove all fields not defined here. + It should NEVER be used to load data into the database. Use the ProviderRecordSchema for that. + + This schema should be used by any endpoint that returns provider information to staff users (ie the query provider + and GET provider endpoints). + """ + + providerId = UUID(required=True, allow_none=False) + type = String(required=True, allow_none=False) + + dateOfUpdate = DateTime(required=True, allow_none=False) + compact = String(required=True, allow_none=False, validate=OneOf(config.compacts)) + licenseJurisdiction = String(required=True, allow_none=False, validate=OneOf(config.jurisdictions)) + npi = String(required=False, allow_none=False, validate=Regexp('^[0-9]{10}$')) + licenseType = String(required=True, allow_none=False) + jurisdictionStatus = String(required=True, allow_none=False, validate=OneOf(['active', 'inactive'])) + givenName = String(required=True, allow_none=False, validate=Length(1, 100)) + middleName = String(required=False, allow_none=False, validate=Length(1, 100)) + familyName = String(required=True, allow_none=False, validate=Length(1, 100)) + suffix = String(required=False, allow_none=False, validate=Length(1, 100)) + # This date is determined by the license records uploaded by a state + # they do not include a timestamp, so we use the Date field type + dateOfExpiration = Date(required=True, allow_none=False) + homeAddressStreet1 = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressStreet2 = String(required=False, allow_none=False, validate=Length(1, 100)) + homeAddressCity = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressState = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressPostalCode = String(required=True, allow_none=False, validate=Length(5, 7)) + emailAddress = Email(required=False, allow_none=False, validate=Length(1, 100)) + phoneNumber = ITUTE164PhoneNumber(required=False, allow_none=False) + status = String(required=True, allow_none=False, validate=OneOf(['active', 'inactive'])) + militaryWaiver = Boolean(required=False, allow_none=False) + + privilegeJurisdictions = Set(String, required=False, allow_none=False, load_default=set()) + providerFamGivMid = String(required=False, allow_none=False, validate=Length(2, 400)) + providerDateOfUpdate = DateTime(required=False, allow_none=False) + birthMonthDay = String(required=False, allow_none=False, validate=Regexp('^[0-1]{1}[0-9]{1}-[0-3]{1}[0-9]{1}')) + + # these records are present when getting provider information from the GET endpoint + # so we check for them here and sanitize them if they are present + licenses = List(Nested(LicenseReadGeneralSchema(), required=False, allow_none=False)) + privileges = List(Nested(PrivilegeGeneralResponseSchema(), required=False, allow_none=False)) + militaryAffiliations = List(Nested(MilitaryAffiliationGeneralResponseSchema(), required=False, allow_none=False)) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/user_client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/user_client.py index ca1264bdf..3e59e83aa 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/user_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/user_client.py @@ -96,10 +96,10 @@ def update_user_permissions( ```json { "permissions": { - "actions": { "admin" } + "actions": { "admin", "readPrivate" }, "jurisdictions": { "oh": { - "actions": { "admin", "write" } + "actions": { "admin", "write", "readPrivate" } } } } diff --git a/backend/compact-connect/lambdas/python/common/cc_common/utils.py b/backend/compact-connect/lambdas/python/common/cc_common/utils.py index 1702efadb..ff1e9ee84 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/utils.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/utils.py @@ -11,6 +11,7 @@ from botocore.exceptions import ClientError from cc_common.config import logger +from cc_common.data_model.schema.provider import SanitizedProviderReadGeneralSchema from cc_common.exceptions import ( CCAccessDeniedException, CCInvalidRequestException, @@ -120,7 +121,7 @@ def caught_handler(event, context: LambdaContext): class authorize_compact: # noqa: N801 invalid-name - """Authorize endpoint by matching path parameter compact to the expected scope, (i.e. aslp/read)""" + """Authorize endpoint by matching path parameter compact to the expected scope, (i.e. aslp/readGeneral)""" def __init__(self, action: str): super().__init__() @@ -165,8 +166,9 @@ def _authorize_compact_with_scope(event: dict, resource_parameter: str, scope_pa For each of these actions, specific rules apply to the scope required to perform the action, which are as follows: - Read - granted at compact level, allows read access to all jurisdictions within the compact. - i.e. aslp/read would allow read access to all jurisdictions within the aslp compact. + ReadGeneral - granted at compact level, allows read access to all generally available (not private) jurisdiction + data within the compact. + i.e. aslp/readGeneral would allow read access to all generally available jurisdiction data within the aslp compact. Write - granted at jurisdiction level, allows write access to a specific jurisdiction within the compact. i.e. aslp/oh.write would allow write access to the ohio jurisdiction within the aslp compact. @@ -309,6 +311,12 @@ def get_allowed_jurisdictions(*, compact: str, scopes: set[str]) -> list[str] | def get_event_scopes(event: dict): + """ + Get the scopes from the event object and return them as a list. + + :param dict event: The event object passed to the lambda function. + :return: The scopes from the event object. + """ return set(event['requestContext']['authorizer']['claims']['scope'].split(' ')) @@ -345,6 +353,13 @@ def collect_and_authorize_changes(*, path_compact: str, scopes: set, compact_cha for action, value in compact_changes.get('actions', {}).items(): if action == 'admin' and f'{path_compact}/{path_compact}.admin' not in scopes: raise CCAccessDeniedException('Only compact admins can affect compact-level admin permissions') + if action == 'readPrivate' and f'{path_compact}/{path_compact}.admin' not in scopes: + raise CCAccessDeniedException('Only compact admins can affect compact-level access to private information') + + # dropping the read action as this is now implicitly granted to all users + if action == 'read': + logger.info('Dropping "read" action as this is implicitly granted to all users') + continue # Any admin in the compact can affect read permissions, so no read-specific check is necessary here if value: compact_action_additions.add(action) @@ -360,6 +375,11 @@ def collect_and_authorize_changes(*, path_compact: str, scopes: set, compact_cha ) for action, value in jurisdiction_changes.get('actions', {}).items(): + # dropping the read action as this is now implicitly granted to all users + if action == 'read': + logger.info('Dropping "read" action as this is implicitly granted to all users') + continue + if value: jurisdiction_action_additions.setdefault(jurisdiction, set()).add(action) else: @@ -378,3 +398,59 @@ def get_sub_from_user_attributes(attributes: list): if attribute['Name'] == 'sub': return attribute['Value'] raise ValueError('Failed to find user sub!') + + +def _user_has_private_read_access_for_provider(compact: str, provider_information: dict, scopes: set[str]) -> bool: + if f'{compact}/{compact}.readPrivate' in scopes: + logger.debug( + 'User has readPrivate permission at compact level', + compact=compact, + provider_id=provider_information['providerId'], + ) + return True + + # iterate through the users privileges and licenses and create a set out of all the jurisdictions + relevant_provider_jurisdictions = set() + for privilege in provider_information.get('privileges', []): + relevant_provider_jurisdictions.add(privilege['jurisdiction']) + for license_record in provider_information.get('licenses', []): + relevant_provider_jurisdictions.add(license_record['jurisdiction']) + + for jurisdiction in relevant_provider_jurisdictions: + if f'{compact}/{jurisdiction}.readPrivate' in scopes: + logger.debug( + 'User has readPrivate permission at jurisdiction level', + compact=compact, + provider_id=provider_information['providerId'], + jurisdiction=jurisdiction, + ) + return True + + logger.debug( + 'Caller does not have readPrivate permission at compact or jurisdiction level', + provider_id=provider_information['providerId'], + ) + return False + + +def sanitize_provider_data_based_on_caller_scopes(compact: str, provider: dict, scopes: set[str]) -> dict: + """ + Take a provider and a set of user scopes, then return a provider, with information sanitized based on what + the user is authorized to view. + + :param str compact: The compact the user is trying to access. + :param dict provider: The provider record to be sanitized. + :param set scopes: The user's scopes from the request. + :return: The provider record, sanitized based on the user's scopes. + """ + if _user_has_private_read_access_for_provider(compact=compact, provider_information=provider, scopes=scopes): + # return full object since caller has 'readPrivate' access for provider + return provider + + logger.debug( + 'Caller does not have readPrivate at compact or jurisdiction level, removing private information', + provider_id=provider['providerId'], + ) + provider_read_general_schema = SanitizedProviderReadGeneralSchema() + # we dump the record to ensure that the schema is applied to the record to remove private fields + return provider_read_general_schema.dump(provider) diff --git a/backend/compact-connect/lambdas/python/common/tests/resources/api/provider-response.json b/backend/compact-connect/lambdas/python/common/tests/resources/api/provider-response.json index 0b7b95072..7919bf431 100644 --- a/backend/compact-connect/lambdas/python/common/tests/resources/api/provider-response.json +++ b/backend/compact-connect/lambdas/python/common/tests/resources/api/provider-response.json @@ -1,7 +1,6 @@ { "type": "provider", "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570", - "ssn": "123-12-1234", "npi": "0608337260", "givenName": "Björk", "middleName": "Gunnar", @@ -20,7 +19,6 @@ "militaryWaiver": false, "emailAddress": "björk@example.com", "phoneNumber": "+13213214321", - "dateOfBirth": "2024-06-06", "dateOfUpdate": "2024-07-08T23:59:59+00:00", "dateOfExpiration": "2050-06-06", "birthMonthDay": "06-06" diff --git a/backend/compact-connect/lambdas/python/common/tests/unit/test_authorize_compact_jurisdiction.py b/backend/compact-connect/lambdas/python/common/tests/unit/test_authorize_compact_jurisdiction.py index da0d68269..6d0709c86 100644 --- a/backend/compact-connect/lambdas/python/common/tests/unit/test_authorize_compact_jurisdiction.py +++ b/backend/compact-connect/lambdas/python/common/tests/unit/test_authorize_compact_jurisdiction.py @@ -83,14 +83,14 @@ class TestAuthorizeCompact(TstLambdas): def test_authorize_compact(self): from cc_common.utils import authorize_compact - @authorize_compact(action='read') + @authorize_compact(action='readGeneral') def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument return {'body': 'Hurray!'} with open('tests/resources/api-event.json') as f: event = json.load(f) - event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/readGeneral' event['pathParameters'] = { 'compact': 'aslp', } @@ -101,13 +101,13 @@ def test_no_path_param(self): from cc_common.exceptions import CCInvalidRequestException from cc_common.utils import authorize_compact - @authorize_compact(action='read') + @authorize_compact(action='readGeneral') def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument return {'body': 'Hurray!'} with open('tests/resources/api-event.json') as f: event = json.load(f) - event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/readGeneral' event['pathParameters'] = {} with self.assertRaises(CCInvalidRequestException): @@ -117,7 +117,7 @@ def test_no_authorizer(self): from cc_common.exceptions import CCUnauthorizedException from cc_common.utils import authorize_compact - @authorize_compact(action='read') + @authorize_compact(action='readGeneral') def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument return {'body': 'Hurray!'} @@ -133,7 +133,7 @@ def test_missing_scope(self): from cc_common.exceptions import CCAccessDeniedException from cc_common.utils import authorize_compact - @authorize_compact(action='read') + @authorize_compact(action='readGeneral') def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument return {'body': 'Hurray!'} diff --git a/backend/compact-connect/lambdas/python/common/tests/unit/test_sanitize_provider_data.py b/backend/compact-connect/lambdas/python/common/tests/unit/test_sanitize_provider_data.py new file mode 100644 index 000000000..b0b05969a --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/tests/unit/test_sanitize_provider_data.py @@ -0,0 +1,74 @@ +import json + +from tests import TstLambdas + + +class TestSanitizeProviderData(TstLambdas): + def when_expecting_full_provider_record_returned(self, scopes: set[str]): + from cc_common.utils import sanitize_provider_data_based_on_caller_scopes + + with open('tests/resources/api/provider-detail-response.json') as f: + expected_provider = json.load(f) + + test_provider = expected_provider.copy() + + resp = sanitize_provider_data_based_on_caller_scopes(compact='aslp', provider=test_provider, scopes=scopes) + + self.assertEqual(resp, expected_provider) + + def test_full_provider_record_returned_if_caller_has_compact_read_private_permissions(self): + self.when_expecting_full_provider_record_returned( + scopes={'openid', 'email', 'aslp/readGeneral', 'aslp/aslp.readPrivate'} + ) + + def test_full_provider_record_returned_if_caller_has_read_private_permissions_for_license_jurisdiction(self): + self.when_expecting_full_provider_record_returned( + scopes={'openid', 'email', 'aslp/readGeneral', 'aslp/oh.readPrivate'} + ) + + def test_full_provider_record_returned_if_caller_has_read_private_permissions_for_privileges_jurisdiction(self): + self.when_expecting_full_provider_record_returned( + scopes={'openid', 'email', 'aslp/readGeneral', 'aslp/ne.readPrivate'} + ) + + def when_testing_general_provider_info_returned(self, scopes: set[str]): + from cc_common.data_model.schema.provider import SanitizedProviderReadGeneralSchema + from cc_common.utils import sanitize_provider_data_based_on_caller_scopes + + with open('tests/resources/api/provider-detail-response.json') as f: + full_provider = json.load(f) + expected_provider = full_provider.copy() + mock_ssn = full_provider['ssn'] + mock_dob = full_provider['dateOfBirth'] + mock_doc_keys = full_provider['militaryAffiliations'][0]['documentKeys'] + # simplest way to set up mock test user as returned from the db + loaded_provider = SanitizedProviderReadGeneralSchema().load(full_provider) + loaded_provider['ssn'] = mock_ssn + loaded_provider['dateOfBirth'] = mock_dob + loaded_provider['militaryAffiliations'][0]['documentKeys'] = mock_doc_keys + loaded_provider['licenses'][0]['ssn'] = mock_ssn + loaded_provider['licenses'][0]['dateOfBirth'] = mock_dob + + # test provider has a license in oh and privilege in ne + resp = sanitize_provider_data_based_on_caller_scopes(compact='aslp', provider=loaded_provider, scopes=scopes) + + # now create expected provider record with the ssn and dob removed + expected_provider.pop('ssn') + expected_provider.pop('dateOfBirth') + # we do not return the military affiliation document keys if the caller does not have read private scope + expected_provider['militaryAffiliations'][0].pop('documentKeys') + # also remove the ssn from the license record + expected_provider['licenses'][0].pop('ssn') + expected_provider['licenses'][0].pop('dateOfBirth') + # cast to set to match schema + expected_provider['privilegeJurisdictions'] = set(expected_provider['privilegeJurisdictions']) + + self.assertEqual(expected_provider, resp) + + def test_sanitized_provider_record_returned_if_caller_does_not_have_read_private_permissions_for_jurisdiction(self): + self.when_testing_general_provider_info_returned( + scopes={'openid', 'email', 'aslp/readGeneral', 'aslp/az.readPrivate'} + ) + + def test_sanitized_provider_record_returned_if_caller_does_not_have_any_read_private_permissions(self): + self.when_testing_general_provider_info_returned(scopes={'openid', 'email', 'aslp/readGeneral'}) diff --git a/backend/compact-connect/lambdas/python/delete-objects/tests/__init__.py b/backend/compact-connect/lambdas/python/delete-objects/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/bulk_upload.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/bulk_upload.py index 05f03db38..943d5b1e5 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/bulk_upload.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/bulk_upload.py @@ -7,7 +7,7 @@ from botocore.exceptions import ClientError from botocore.response import StreamingBody from cc_common.config import config, logger -from cc_common.data_model.schema.license import LicensePostSchema, LicensePublicSchema +from cc_common.data_model.schema.license import LicensePostSchema, LicenseReadGeneralSchema from cc_common.exceptions import CCInternalException from cc_common.utils import ResponseEncoder, api_handler, authorize_compact_jurisdiction from event_batch_writer import EventBatchWriter @@ -114,7 +114,7 @@ def process_bulk_upload_file( """ Stream each line of the new CSV file, validating it then publishing an ingest event for each line. """ - public_schema = LicensePublicSchema() + general_schema = LicenseReadGeneralSchema() schema = LicensePostSchema() reader = LicenseCSVReader() @@ -127,16 +127,16 @@ def process_bulk_upload_file( except ValidationError as e: # This CSV line has failed validation. We will carefully collect what information we can # and publish it as a failure event. Because this data may eventually be sent back over - # an email, we will only include the public values that we can still validate. + # an email, we will only include the generally available values that we can still validate. try: - public_license_data = public_schema.load(raw_license) + general_license_data = general_schema.load(raw_license) except ValidationError as exc_second_try: - public_license_data = exc_second_try.valid_data + general_license_data = exc_second_try.valid_data logger.info( 'Invalid license in line %s uploaded: %s', i + 1, str(e), - valid_data=public_license_data, + valid_data=general_license_data, exc_info=e, ) event_writer.put_event( @@ -149,7 +149,7 @@ def process_bulk_upload_file( 'compact': compact, 'jurisdiction': jurisdiction, 'recordNumber': i + 1, - 'validData': public_license_data, + 'validData': general_license_data, 'errors': e.messages, }, cls=ResponseEncoder, diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/providers.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/providers.py index caeb3635b..f0dda366d 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/providers.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/providers.py @@ -2,14 +2,20 @@ from aws_lambda_powertools.utilities.typing import LambdaContext from cc_common.config import config, logger +from cc_common.data_model.schema.provider import SanitizedProviderReadGeneralSchema from cc_common.exceptions import CCInvalidRequestException -from cc_common.utils import api_handler, authorize_compact +from cc_common.utils import ( + api_handler, + authorize_compact, + get_event_scopes, + sanitize_provider_data_based_on_caller_scopes, +) from . import get_provider_information @api_handler -@authorize_compact(action='read') +@authorize_compact(action='readGeneral') def query_providers(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument """Query providers data :param event: Standard API Gateway event, API schema documented in the CDK ApiStack @@ -84,13 +90,19 @@ def query_providers(event: dict, context: LambdaContext): # noqa: ARG001 unused case _: # This shouldn't happen unless our api validation gets misconfigured raise CCInvalidRequestException(f"Invalid sort key: '{sorting_key}'") - # Convert generic field to more specific one for this API - resp['providers'] = resp.pop('items', []) + # Convert generic field to more specific one for this API and sanitize data + pre_sanitized_providers = resp.pop('items', []) + # for the query endpoint, we only return generally available data, regardless of the caller's scopes + general_schema = SanitizedProviderReadGeneralSchema() + sanitized_providers = [general_schema.dump(provider) for provider in pre_sanitized_providers] + + resp['providers'] = sanitized_providers + return resp @api_handler -@authorize_compact(action='read') +@authorize_compact(action='readGeneral') def get_provider(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument """Return one provider's data :param event: Standard API Gateway event, API schema documented in the CDK ApiStack @@ -104,4 +116,8 @@ def get_provider(event: dict, context: LambdaContext): # noqa: ARG001 unused-ar logger.error(f'Missing parameter: {e}') raise CCInvalidRequestException('Missing required field') from e - return get_provider_information(compact=compact, provider_id=provider_id) + provider_information = get_provider_information(compact=compact, provider_id=provider_id) + + return sanitize_provider_data_based_on_caller_scopes( + compact=compact, provider=provider_information, scopes=get_event_scopes(event) + ) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_provider_transformations.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_provider_transformations.py index 19df93cff..7adb549c1 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_provider_transformations.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_provider_transformations.py @@ -165,7 +165,7 @@ def test_transformations(self): event = json.load(f) event['pathParameters'] = {'compact': 'aslp', 'providerId': provider_id} - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral aslp/aslp.readPrivate' resp = get_provider(event, self.mock_context) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py index 765bb2470..190ca6d93 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py @@ -26,13 +26,14 @@ def test_new_provider_ingest(self): event = json.load(f) event['pathParameters'] = {'compact': 'aslp'} - event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/readGeneral' event['body'] = json.dumps({'query': {'ssn': '123-12-1234'}}) resp = query_providers(event, self.mock_context) self.assertEqual(resp['statusCode'], 200) with open('../common/tests/resources/api/provider-response.json') as f: expected_provider = json.load(f) + # The canned response resource assumes that the provider will be given a privilege in NE. We didn't do that, # so we'll reset the privilege array. expected_provider['privilegeJurisdictions'] = [] @@ -71,7 +72,9 @@ def test_existing_provider_ingest(self): event = json.load(f) event['pathParameters'] = {'compact': 'aslp', 'providerId': provider_id} - event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = ( + 'openid email stuff aslp/readGeneral aslp/aslp.readPrivate' + ) resp = get_provider(event, self.mock_context) self.assertEqual(resp['statusCode'], 200) @@ -131,7 +134,9 @@ def test_old_inactive_license(self): event = json.load(f) event['pathParameters'] = {'compact': 'aslp', 'providerId': provider_id} - event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = ( + 'openid email stuff aslp/readGeneral aslp/aslp.readPrivate' + ) resp = get_provider(event, self.mock_context) self.assertEqual(resp['statusCode'], 200) @@ -188,7 +193,7 @@ def test_newer_active_license(self): event = json.load(f) event['pathParameters'] = {'compact': 'aslp', 'providerId': provider_id} - event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/readGeneral' resp = get_provider(event, self.mock_context) self.assertEqual(resp['statusCode'], 200) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_licenses.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_licenses.py index bde29ff64..169ae7b9d 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_licenses.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_licenses.py @@ -14,7 +14,9 @@ def test_post_licenses(self): event = json.load(f) # The user has write permission for aslp/oh - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read aslp/write aslp/oh.write' + event['requestContext']['authorizer']['claims']['scope'] = ( + 'openid email aslp/readGeneral aslp/write aslp/oh.write' + ) event['pathParameters'] = {'compact': 'aslp', 'jurisdiction': 'oh'} with open('../common/tests/resources/api/license-post.json') as f: event['body'] = json.dumps([json.load(f)]) @@ -30,7 +32,9 @@ def test_post_licenses_invalid_license_type(self): event = json.load(f) # The user has write permission for aslp/oh - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read aslp/write aslp/oh.write' + event['requestContext']['authorizer']['claims']['scope'] = ( + 'openid email aslp/readGeneral aslp/write aslp/oh.write' + ) event['pathParameters'] = {'compact': 'aslp', 'jurisdiction': 'oh'} with open('../common/tests/resources/api/license-post.json') as f: license_data = json.load(f) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_providers.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_providers.py index 9bc8702ea..afb511c9b 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_providers.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_providers.py @@ -17,7 +17,7 @@ def test_query_by_ssn(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral aslp/aslp.readPrivate' event['pathParameters'] = {'compact': 'aslp'} event['body'] = json.dumps({'query': {'ssn': '123-12-1234'}}) @@ -39,7 +39,7 @@ def test_query_by_ssn(self): body, ) - def test_query_by_provider_id(self): + def test_query_by_provider_id_sanitizes_data_even_with_read_private_permission(self): self._load_provider_data() from handlers.providers import query_providers @@ -48,7 +48,7 @@ def test_query_by_provider_id(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral aslp/aslp.readPrivate' event['pathParameters'] = {'compact': 'aslp'} event['body'] = json.dumps({'query': {'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570'}}) @@ -80,7 +80,7 @@ def test_query_providers_updated_sorting(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral aslp/aslp.readPrivate' event['pathParameters'] = {'compact': 'aslp'} event['body'] = json.dumps( {'sorting': {'key': 'dateOfUpdate'}, 'query': {'jurisdiction': 'oh'}, 'pagination': {'pageSize': 10}}, @@ -109,7 +109,7 @@ def test_query_providers_family_name_sorting(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral' event['pathParameters'] = {'compact': 'aslp'} event['body'] = json.dumps( {'sorting': {'key': 'familyName'}, 'query': {'jurisdiction': 'oh'}, 'pagination': {'pageSize': 10}}, @@ -143,7 +143,7 @@ def test_query_providers_by_family_name(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral' event['pathParameters'] = {'compact': 'aslp'} event['body'] = json.dumps( { @@ -175,7 +175,7 @@ def test_query_providers_given_name_only_not_allowed(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral' event['pathParameters'] = {'compact': 'aslp'} event['body'] = json.dumps( { @@ -201,7 +201,7 @@ def test_query_providers_default_sorting(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral' event['pathParameters'] = {'compact': 'aslp'} event['body'] = json.dumps({'query': {}}) @@ -229,7 +229,7 @@ def test_query_providers_invalid_sorting(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral' event['pathParameters'] = {'compact': 'aslp'} event['body'] = json.dumps({'query': {'jurisdiction': 'oh'}, 'sorting': {'key': 'invalid'}}) @@ -241,8 +241,7 @@ def test_query_providers_invalid_sorting(self): @mock_aws class TestGetProvider(TstFunction): - def test_get_provider(self): - """Provider detail response""" + def _when_testing_get_provider_response_based_on_read_access(self, scopes: str, expected_provider: dict): self._load_provider_data() from handlers.providers import get_provider @@ -251,19 +250,39 @@ def test_get_provider(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = scopes event['pathParameters'] = {'compact': 'aslp', 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570'} event['queryStringParameters'] = None - with open('../common/tests/resources/api/provider-detail-response.json') as f: - expected_provider = json.load(f) - resp = get_provider(event, self.mock_context) self.assertEqual(200, resp['statusCode']) provider_data = json.loads(resp['body']) self.assertEqual(expected_provider, provider_data) + def _when_testing_get_provider_with_read_private_access(self, scopes: str): + with open('../common/tests/resources/api/provider-detail-response.json') as f: + expected_provider = json.load(f) + + self._when_testing_get_provider_response_based_on_read_access(scopes, expected_provider) + + def test_get_provider_with_compact_level_read_private_access(self): + self._when_testing_get_provider_with_read_private_access( + scopes='openid email aslp/readGeneral aslp/aslp.readPrivate', + ) + + def test_get_provider_with_matching_license_jurisdiction_level_read_private_access(self): + # test provider has a license in oh and a privilege in ne + self._when_testing_get_provider_with_read_private_access( + scopes='openid email aslp/readGeneral aslp/oh.readPrivate' + ) + + def test_get_provider_with_matching_privilege_jurisdiction_level_read_private_access(self): + # test provider has a license in oh and a privilege in ne + self._when_testing_get_provider_with_read_private_access( + scopes='openid email aslp/readGeneral aslp/ne.readPrivate' + ) + def test_get_provider_wrong_compact(self): """Provider detail response""" self._load_provider_data() @@ -274,7 +293,7 @@ def test_get_provider_wrong_compact(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral' event['pathParameters'] = {'compact': 'octp', 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570'} event['queryStringParameters'] = None @@ -289,7 +308,7 @@ def test_get_provider_missing_provider_id(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral' # providerId _should_ be included in these pathParameters. We're leaving it out for this test. event['pathParameters'] = {'compact': 'aslp'} event['queryStringParameters'] = None @@ -297,3 +316,19 @@ def test_get_provider_missing_provider_id(self): resp = get_provider(event, self.mock_context) self.assertEqual(400, resp['statusCode']) + + def test_get_provider_returns_expected_general_response_when_caller_does_not_have_read_private_scope(self): + """Provider detail response""" + with open('../common/tests/resources/api/provider-detail-response.json') as f: + expected_provider = json.load(f) + expected_provider.pop('ssn') + expected_provider.pop('dateOfBirth') + # we do not return the military affiliation document keys if the caller does not have read private scope + expected_provider['militaryAffiliations'][0].pop('documentKeys') + # also remove the ssn from the license record + expected_provider['licenses'][0].pop('ssn') + expected_provider['licenses'][0].pop('dateOfBirth') + + self._when_testing_get_provider_response_based_on_read_access( + scopes='openid email aslp/readGeneral', expected_provider=expected_provider + ) diff --git a/backend/compact-connect/lambdas/python/staff-user-pre-token/tests/test_main.py b/backend/compact-connect/lambdas/python/staff-user-pre-token/tests/test_main.py index 2dbf94ce7..57e47c139 100644 --- a/backend/compact-connect/lambdas/python/staff-user-pre-token/tests/test_main.py +++ b/backend/compact-connect/lambdas/python/staff-user-pre-token/tests/test_main.py @@ -22,7 +22,6 @@ def test_happy_path(self): 'sk': 'COMPACT#aslp', 'compact': 'aslp', 'permissions': { - 'actions': {'read'}, 'jurisdictions': { # should correspond to the 'aslp/write' and 'aslp/al.write' scopes 'al': {'write'} @@ -34,7 +33,7 @@ def test_happy_path(self): resp = customize_scopes(event, self.mock_context) self.assertEqual( - sorted(['profile', 'aslp/read', 'aslp/write', 'aslp/al.write']), + sorted(['profile', 'aslp/readGeneral', 'aslp/write', 'aslp/al.write']), sorted(resp['response']['claimsAndScopeOverrideDetails']['accessTokenGeneration']['scopesToAdd']), ) diff --git a/backend/compact-connect/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py b/backend/compact-connect/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py index 39bb1a516..c3a1905f1 100644 --- a/backend/compact-connect/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py +++ b/backend/compact-connect/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py @@ -20,13 +20,15 @@ def test_compact_ed_user(self): 'pk': f'USER#{self._user_sub}', 'sk': 'COMPACT#aslp', 'compact': 'aslp', - 'permissions': {'actions': {'read', 'admin'}, 'jurisdictions': {}}, + 'permissions': {'actions': {'read', 'admin', 'readPrivate'}, 'jurisdictions': {}}, } ) scopes = UserScopes(self._user_sub) - self.assertEqual({'profile', 'aslp/read', 'aslp/admin', 'aslp/aslp.admin'}, scopes) + self.assertEqual( + {'profile', 'aslp/readGeneral', 'aslp/admin', 'aslp/aslp.admin', 'aslp/aslp.readPrivate'}, scopes + ) def test_board_ed_user(self): from user_scopes import UserScopes @@ -37,13 +39,24 @@ def test_board_ed_user(self): 'pk': f'USER#{self._user_sub}', 'sk': 'COMPACT#aslp', 'compact': 'aslp', - 'permissions': {'actions': {'read'}, 'jurisdictions': {'al': {'write', 'admin'}}}, + 'permissions': {'jurisdictions': {'al': {'write', 'admin', 'readPrivate'}}}, } ) scopes = UserScopes(self._user_sub) - self.assertEqual({'profile', 'aslp/read', 'aslp/admin', 'aslp/write', 'aslp/al.admin', 'aslp/al.write'}, scopes) + self.assertEqual( + { + 'profile', + 'aslp/readGeneral', + 'aslp/admin', + 'aslp/write', + 'aslp/al.admin', + 'aslp/al.write', + 'aslp/al.readPrivate', + }, + scopes, + ) def test_board_ed_user_multi_compact(self): """ @@ -58,7 +71,7 @@ def test_board_ed_user_multi_compact(self): 'pk': f'USER#{self._user_sub}', 'sk': 'COMPACT#aslp', 'compact': 'aslp', - 'permissions': {'actions': {'read'}, 'jurisdictions': {'al': {'write', 'admin'}}}, + 'permissions': {'jurisdictions': {'al': {'write', 'admin'}}}, } ) self._table.put_item( @@ -66,7 +79,7 @@ def test_board_ed_user_multi_compact(self): 'pk': f'USER#{self._user_sub}', 'sk': 'COMPACT#octp', 'compact': 'octp', - 'permissions': {'actions': {'read'}, 'jurisdictions': {'al': {'write', 'admin'}}}, + 'permissions': {'jurisdictions': {'al': {'write', 'admin'}}}, } ) @@ -75,12 +88,12 @@ def test_board_ed_user_multi_compact(self): self.assertEqual( { 'profile', - 'aslp/read', + 'aslp/readGeneral', 'aslp/admin', 'aslp/write', 'aslp/al.admin', 'aslp/al.write', - 'octp/read', + 'octp/readGeneral', 'octp/admin', 'octp/write', 'octp/al.admin', @@ -99,7 +112,6 @@ def test_board_staff(self): 'sk': 'COMPACT#aslp', 'compact': 'aslp', 'permissions': { - 'actions': {'read'}, 'jurisdictions': { 'al': {'write'} # should correspond to the 'aslp/al.write' scope }, @@ -109,7 +121,7 @@ def test_board_staff(self): scopes = UserScopes(self._user_sub) - self.assertEqual({'profile', 'aslp/read', 'aslp/write', 'aslp/al.write'}, scopes) + self.assertEqual({'profile', 'aslp/readGeneral', 'aslp/write', 'aslp/al.write'}, scopes) def test_missing_user(self): from user_scopes import UserScopes @@ -131,7 +143,7 @@ def test_disallowed_compact(self): 'pk': f'USER#{self._user_sub}', 'sk': 'COMPACT#aslp', 'compact': 'aslp', - 'permissions': {'actions': {'read'}, 'jurisdictions': {'al': {'write', 'admin'}}}, + 'permissions': {'jurisdictions': {'al': {'write', 'admin'}}}, } ) self._table.put_item( @@ -139,7 +151,7 @@ def test_disallowed_compact(self): 'pk': f'USER#{self._user_sub}', 'sk': 'COMPACT#aslp', 'compact': 'abc', - 'permissions': {'actions': {'read'}, 'jurisdictions': {'al': {'write', 'admin'}}}, + 'permissions': {'jurisdictions': {'al': {'write', 'admin'}}}, } ) @@ -161,7 +173,7 @@ def test_disallowed_compact_action(self): 'compact': 'aslp', 'permissions': { # Write is jurisdiction-specific - 'actions': {'read', 'write'}, + 'actions': {'write'}, 'jurisdictions': {'al': {'write', 'admin'}}, }, } @@ -183,7 +195,7 @@ def test_disallowed_jurisdiction(self): 'pk': f'USER#{self._user_sub}', 'sk': 'COMPACT#aslp', 'compact': 'aslp', - 'permissions': {'actions': {'read'}, 'jurisdictions': {'ab': {'write', 'admin'}}}, + 'permissions': {'jurisdictions': {'ab': {'write', 'admin'}}}, } ) @@ -203,7 +215,7 @@ def test_disallowed_action(self): 'pk': f'USER#{self._user_sub}', 'sk': 'COMPACT#aslp', 'compact': 'aslp', - 'permissions': {'actions': {'read'}, 'jurisdictions': {'al': {'write', 'hack'}}}, + 'permissions': {'jurisdictions': {'al': {'write', 'hack'}}}, } ) diff --git a/backend/compact-connect/lambdas/python/staff-user-pre-token/user_scopes.py b/backend/compact-connect/lambdas/python/staff-user-pre-token/user_scopes.py index 5db28567a..d5ed845e3 100644 --- a/backend/compact-connect/lambdas/python/staff-user-pre-token/user_scopes.py +++ b/backend/compact-connect/lambdas/python/staff-user-pre-token/user_scopes.py @@ -21,7 +21,7 @@ def _get_scopes_from_db(self, sub: str): user_data = self._get_user_data(sub) permissions = { compact_record['compact']: { - 'actions': set(compact_record['permissions']['actions']), + 'actions': set(compact_record['permissions'].get('actions', [])), 'jurisdictions': compact_record['permissions']['jurisdictions'], } for compact_record in user_data @@ -48,13 +48,18 @@ def _process_compact_permissions(self, compact_name, compact_permissions): compact_actions = compact_permissions.get('actions', set()) # Ensure included actions are limited to supported values - disallowed_actions = compact_actions - {'read', 'admin'} + # Note we are keeping in the 'read' permission for backwards compatibility + # Though we are not using it in the codebase + disallowed_actions = compact_actions - {'read', 'admin', 'readPrivate'} if disallowed_actions: raise ValueError(f'User {compact_name} permissions include disallowed actions: {disallowed_actions}') - # Read is the only truly compact-level permission - if 'read' in compact_actions: - self.add(f'{compact_name}/read') + # readGeneral is always added an implicit permission granted to all staff users at the compact level + self.add(f'{compact_name}/readGeneral') + + if 'readPrivate' in compact_actions: + # This action only has one level of authz, since there is no external scope for it + self.add(f'{compact_name}/{compact_name}.readPrivate') if 'admin' in compact_actions: # Two levels of authz for admin @@ -74,13 +79,17 @@ def _process_compact_permissions(self, compact_name, compact_permissions): def _process_jurisdiction_permissions(self, compact_name, jurisdiction_name, jurisdiction_actions): # Ensure included actions are limited to supported values - disallowed_actions = jurisdiction_actions - {'write', 'admin'} + disallowed_actions = jurisdiction_actions - {'write', 'admin', 'readPrivate'} if disallowed_actions: raise ValueError( f'User {compact_name}/{jurisdiction_name} permissions include disallowed actions: ' f'{disallowed_actions}', ) for action in jurisdiction_actions: - # Two levels of authz - self.add(f'{compact_name}/{action}') self.add(f'{compact_name}/{jurisdiction_name}.{action}') + + # Grant coarse-grain scope, which provides access to the API itself + # Since `readGeneral` is implicitly granted to all users, we do not + # need to grant a coarse-grain scope for the `readPrivate` action. + if action != 'readPrivate': + self.add(f'{compact_name}/{action}') diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/function/test_data_model/test_client.py b/backend/compact-connect/lambdas/python/staff-users/tests/function/test_data_model/test_client.py index 0a78933be..f1ceb543f 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/function/test_data_model/test_client.py +++ b/backend/compact-connect/lambdas/python/staff-users/tests/function/test_data_model/test_client.py @@ -142,7 +142,7 @@ def test_update_user_permissions_jurisdiction_actions(self): self.assertEqual(user_id, resp['userId']) self.assertEqual( - {'actions': {'read'}, 'jurisdictions': {'oh': {'admin'}, 'ky': {'write'}}}, + {'actions': {'readPrivate'}, 'jurisdictions': {'oh': {'admin'}, 'ky': {'write'}}}, resp['permissions'], ) # Just checking that we're getting the whole object, not just changes @@ -164,7 +164,7 @@ def test_update_user_permissions_board_to_compact_admin(self): ) self.assertEqual(user_id, resp['userId']) - self.assertEqual({'actions': {'read', 'admin'}, 'jurisdictions': {}}, resp['permissions']) + self.assertEqual({'actions': {'readPrivate', 'admin'}, 'jurisdictions': {}}, resp['permissions']) # Checking that we're getting the whole object, not just changes self.assertFalse({'type', 'userId', 'compact', 'attributes', 'permissions', 'dateOfUpdate'} - resp.keys()) diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py b/backend/compact-connect/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py index f7eb983f5..d17cf3dd7 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py +++ b/backend/compact-connect/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py @@ -30,7 +30,7 @@ def test_patch_user(self): 'dateOfUpdate': '2024-09-12T23:59:59+00:00', 'permissions': { 'aslp': { - 'actions': {'read': True}, + 'actions': {'readPrivate': True}, 'jurisdictions': {'oh': {'actions': {'admin': True, 'write': True}}}, }, }, @@ -128,7 +128,7 @@ def test_patch_user_add_to_empty_actions(self): # Add compact read and oh admin permissions to the user event['pathParameters'] = {'compact': 'aslp', 'userId': user_id} api_user['permissions'] = { - 'aslp': {'actions': {'read': True}, 'jurisdictions': {'oh': {'actions': {'admin': True}}}} + 'aslp': {'actions': {'readPrivate': True}, 'jurisdictions': {'oh': {'actions': {'admin': True}}}} } event['body'] = json.dumps(api_user) @@ -163,7 +163,7 @@ def test_patch_user_remove_all_actions(self): # Remove all the permissions from the user event['pathParameters'] = {'compact': 'aslp', 'userId': user_id} api_user['permissions'] = { - 'aslp': {'actions': {'read': False}, 'jurisdictions': {'oh': {'actions': {'write': False}}}} + 'aslp': {'actions': {'readPrivate': False}, 'jurisdictions': {'oh': {'actions': {'write': False}}}} } event['body'] = json.dumps(api_user) @@ -194,3 +194,50 @@ def test_patch_user_forbidden(self): resp = patch_user(event, self.mock_context) self.assertEqual(403, resp['statusCode']) + + def test_patch_user_allows_adding_read_private_permission(self): + self._load_user_data() + + from handlers.users import patch_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for aslp/oh + event['requestContext']['authorizer']['claims']['scope'] = ( + 'openid email aslp/admin aslp/oh.admin aslp/aslp.admin' + ) + event['pathParameters'] = {'compact': 'aslp', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = json.dumps( + { + 'permissions': { + 'aslp': { + 'actions': { + 'readPrivate': True, + }, + 'jurisdictions': {'oh': {'actions': {'readPrivate': True}}}, + } + } + } + ) + + resp = patch_user(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + user = json.loads(resp['body']) + self.assertEqual( + { + 'attributes': {'email': 'justin@example.org', 'familyName': 'Williams', 'givenName': 'Justin'}, + 'dateOfUpdate': '2024-09-12T23:59:59+00:00', + 'permissions': { + 'aslp': { + 'actions': {'readPrivate': True}, + # test user starts with the write permission, so it should still be there + 'jurisdictions': {'oh': {'actions': {'write': True, 'readPrivate': True}}}, + }, + }, + 'type': 'user', + 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797', + }, + user, + ) diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/function/test_handlers/test_post_user.py b/backend/compact-connect/lambdas/python/staff-users/tests/function/test_handlers/test_post_user.py index c6af4ca19..656e6f95b 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/function/test_handlers/test_post_user.py +++ b/backend/compact-connect/lambdas/python/staff-users/tests/function/test_handlers/test_post_user.py @@ -19,7 +19,9 @@ def test_post_user(self): api_user = json.load(f) # The user has admin permission for aslp/oh - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/admin aslp/oh.admin' + event['requestContext']['authorizer']['claims']['scope'] = ( + 'openid email aslp/admin aslp/aslp.admin aslp/oh.admin' + ) event['pathParameters'] = {'compact': 'aslp'} resp = post_user(event, self.mock_context) diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/resources/api/user-post.json b/backend/compact-connect/lambdas/python/staff-users/tests/resources/api/user-post.json index 9cda12771..f234d5355 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/resources/api/user-post.json +++ b/backend/compact-connect/lambdas/python/staff-users/tests/resources/api/user-post.json @@ -8,7 +8,7 @@ "permissions": { "aslp": { "actions": { - "read": true + "readPrivate": true }, "jurisdictions": { "oh": { diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/resources/api/user-response.json b/backend/compact-connect/lambdas/python/staff-users/tests/resources/api/user-response.json index cbf7f145b..18426954f 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/resources/api/user-response.json +++ b/backend/compact-connect/lambdas/python/staff-users/tests/resources/api/user-response.json @@ -10,7 +10,7 @@ "permissions": { "aslp": { "actions": { - "read": true + "readPrivate": true }, "jurisdictions": { "oh": { diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/resources/dynamo/user.json b/backend/compact-connect/lambdas/python/staff-users/tests/resources/dynamo/user.json index ef6b9435c..cc2552e4a 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/resources/dynamo/user.json +++ b/backend/compact-connect/lambdas/python/staff-users/tests/resources/dynamo/user.json @@ -37,7 +37,7 @@ "M": { "actions": { "SS": [ - "read" + "readPrivate" ] }, "jurisdictions": { diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_authorize_compact.py b/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_authorize_compact.py index 68a4b3c3e..2f69d3a78 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_authorize_compact.py +++ b/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_authorize_compact.py @@ -9,14 +9,14 @@ class TestAuthorizeCompact(TstLambdas): def test_authorize_compact(self): from cc_common.utils import authorize_compact - @authorize_compact(action='read') + @authorize_compact(action='readGeneral') def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument return {'body': 'Hurray!'} with open('tests/resources/api-event.json') as f: event = json.load(f) - event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/readGeneral' event['pathParameters'] = { 'compact': 'aslp', } @@ -27,13 +27,13 @@ def test_no_path_param(self): from cc_common.exceptions import CCInvalidRequestException from cc_common.utils import authorize_compact - @authorize_compact(action='read') + @authorize_compact(action='readGeneral') def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument return {'body': 'Hurray!'} with open('tests/resources/api-event.json') as f: event = json.load(f) - event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/readGeneral' event['pathParameters'] = {} with self.assertRaises(CCInvalidRequestException): @@ -43,7 +43,7 @@ def test_no_authorizer(self): from cc_common.exceptions import CCUnauthorizedException from cc_common.utils import authorize_compact - @authorize_compact(action='read') + @authorize_compact(action='readGeneral') def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument return {'body': 'Hurray!'} @@ -59,7 +59,7 @@ def test_missing_scope(self): from cc_common.exceptions import CCAccessDeniedException from cc_common.utils import authorize_compact - @authorize_compact(action='read') + @authorize_compact(action='readGeneral') def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument return {'body': 'Hurray!'} diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_collect_changes.py b/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_collect_changes.py index bf1199b92..a41fc9850 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_collect_changes.py +++ b/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_collect_changes.py @@ -10,12 +10,12 @@ def test_compact_changes(self): resp = collect_and_authorize_changes( path_compact='aslp', scopes={'openid', 'email', 'aslp/admin', 'aslp/aslp.admin'}, - compact_changes={'actions': {'admin': True, 'read': False}, 'jurisdictions': {}}, + compact_changes={'actions': {'admin': True, 'readPrivate': False}, 'jurisdictions': {}}, ) self.assertEqual( { 'compact_action_additions': {'admin'}, - 'compact_action_removals': {'read'}, + 'compact_action_removals': {'readPrivate'}, 'jurisdiction_action_additions': {}, 'jurisdiction_action_removals': {}, }, @@ -69,14 +69,14 @@ def test_compact_and_jurisdiction_changes(self): path_compact='aslp', scopes={'openid', 'email', 'aslp/admin', 'aslp/aslp.admin'}, compact_changes={ - 'actions': {'admin': True, 'read': False}, + 'actions': {'admin': True, 'readPrivate': False}, 'jurisdictions': {'oh': {'actions': {'admin': True, 'write': False}}}, }, ) self.assertEqual( { 'compact_action_additions': {'admin'}, - 'compact_action_removals': {'read'}, + 'compact_action_removals': {'readPrivate'}, 'jurisdiction_action_additions': {'oh': {'admin'}}, 'jurisdiction_action_removals': {'oh': {'write'}}, }, diff --git a/backend/compact-connect/stacks/api_stack/v1_api/api.py b/backend/compact-connect/stacks/api_stack/v1_api/api.py index 81fb8554f..ed79e857b 100644 --- a/backend/compact-connect/stacks/api_stack/v1_api/api.py +++ b/backend/compact-connect/stacks/api_stack/v1_api/api.py @@ -25,7 +25,7 @@ def __init__(self, root: IResource, persistent_stack: ps.PersistentStack): self.api: cc_api.CCApi = root.api self.api_model = ApiModel(api=self.api) read_scopes = [ - f'{resource_server}/read' for resource_server in persistent_stack.staff_users.resource_servers.keys() + f'{resource_server}/readGeneral' for resource_server in persistent_stack.staff_users.resource_servers.keys() ] write_scopes = [ f'{resource_server}/write' for resource_server in persistent_stack.staff_users.resource_servers.keys() @@ -112,17 +112,17 @@ def __init__(self, root: IResource, persistent_stack: ps.PersistentStack): ) # /v1/staff-users - staff_users_admin_resource = self.compact_resource.add_resource('staff-users') - staff_users_self_resource = self.resource.add_resource('staff-users') + self.staff_users_admin_resource = self.compact_resource.add_resource('staff-users') + self.staff_users_self_resource = self.resource.add_resource('staff-users') # GET /v1/staff-users/me # PATCH /v1/staff-users/me # GET /v1/compacts/{compact}/staff-users # POST /v1/compacts/{compact}/staff-users # GET /v1/compacts/{compact}/staff-users/{userId} # PATCH /v1/compacts/{compact}/staff-users/{userId} - StaffUsers( - admin_resource=staff_users_admin_resource, - self_resource=staff_users_self_resource, + self.staff_users = StaffUsers( + admin_resource=self.staff_users_admin_resource, + self_resource=self.staff_users_self_resource, admin_scopes=admin_scopes, persistent_stack=persistent_stack, api_model=self.api_model, diff --git a/backend/compact-connect/stacks/api_stack/v1_api/api_model.py b/backend/compact-connect/stacks/api_stack/v1_api/api_model.py index c2cb9992b..5d37cdb47 100644 --- a/backend/compact-connect/stacks/api_stack/v1_api/api_model.py +++ b/backend/compact-connect/stacks/api_stack/v1_api/api_model.py @@ -294,8 +294,11 @@ def _staff_user_permissions_schema(self): 'actions': JsonSchema( type=JsonSchemaType.OBJECT, properties={ - 'read': JsonSchema(type=JsonSchemaType.BOOLEAN), + 'readPrivate': JsonSchema(type=JsonSchemaType.BOOLEAN), 'admin': JsonSchema(type=JsonSchemaType.BOOLEAN), + # TODO keeping 'read' action for backwards compatibility # noqa: FIX002 + # this should be removed after the frontend is updated + 'read': JsonSchema(type=JsonSchemaType.BOOLEAN), }, ), 'jurisdictions': JsonSchema( @@ -309,6 +312,7 @@ def _staff_user_permissions_schema(self): properties={ 'write': JsonSchema(type=JsonSchemaType.BOOLEAN), 'admin': JsonSchema(type=JsonSchemaType.BOOLEAN), + 'readPrivate': JsonSchema(type=JsonSchemaType.BOOLEAN), }, ), }, diff --git a/backend/compact-connect/stacks/api_stack/v1_api/staff_users.py b/backend/compact-connect/stacks/api_stack/v1_api/staff_users.py index 093b64049..10b006209 100644 --- a/backend/compact-connect/stacks/api_stack/v1_api/staff_users.py +++ b/backend/compact-connect/stacks/api_stack/v1_api/staff_users.py @@ -51,10 +51,10 @@ def __init__( self._add_get_users(self.admin_resource, admin_scopes, env_vars=env_vars, persistent_stack=persistent_stack) self._add_post_user(self.admin_resource, admin_scopes, env_vars=env_vars, persistent_stack=persistent_stack) - user_id_resource = self.admin_resource.add_resource('{userId}') + self.user_id_resource = self.admin_resource.add_resource('{userId}') # /{userId} - self._add_get_user(user_id_resource, admin_scopes, env_vars=env_vars, persistent_stack=persistent_stack) - self._add_patch_user(user_id_resource, admin_scopes, env_vars=env_vars, persistent_stack=persistent_stack) + self._add_get_user(self.user_id_resource, admin_scopes, env_vars=env_vars, persistent_stack=persistent_stack) + self._add_patch_user(self.user_id_resource, admin_scopes, env_vars=env_vars, persistent_stack=persistent_stack) self.me_resource = self_resource.add_resource('me') # /me @@ -301,7 +301,7 @@ def _add_patch_user( env_vars: dict, persistent_stack: ps.PersistentStack, ): - patch_user_handler = self._patch_user_handler( + self.patch_user_handler = self._patch_user_handler( env_vars=env_vars, data_encryption_key=persistent_stack.shared_encryption_key, users_table=persistent_stack.staff_users.user_table, @@ -310,7 +310,7 @@ def _add_patch_user( # Add the PATCH method to the me_resource user_resource.add_method( 'PATCH', - integration=LambdaIntegration(patch_user_handler), + integration=LambdaIntegration(self.patch_user_handler), request_validator=self.api.parameter_body_validator, request_models={'application/json': self.api_model.patch_staff_user_model}, method_responses=[ @@ -359,7 +359,7 @@ def _add_post_user( env_vars: dict, persistent_stack: ps.PersistentStack, ): - post_user_handler = self._post_user_handler( + self.post_user_handler = self._post_user_handler( env_vars=env_vars, data_encryption_key=persistent_stack.shared_encryption_key, users_table=persistent_stack.staff_users.user_table, @@ -369,7 +369,7 @@ def _add_post_user( # Add the POST method to the me_resource users_resource.add_method( 'POST', - integration=LambdaIntegration(post_user_handler), + integration=LambdaIntegration(self.post_user_handler), request_validator=self.api.parameter_body_validator, request_models={'application/json': self.api_model.post_staff_user_model}, method_responses=[ diff --git a/backend/compact-connect/stacks/persistent_stack/staff_users.py b/backend/compact-connect/stacks/persistent_stack/staff_users.py index 21ea338bb..a53036990 100644 --- a/backend/compact-connect/stacks/persistent_stack/staff_users.py +++ b/backend/compact-connect/stacks/persistent_stack/staff_users.py @@ -70,11 +70,11 @@ def __init__( def _add_resource_servers(self): """Add scopes for all compact/jurisdictions""" - # {compact}.write, {compact}.admin, {compact}.read for every compact - # Note: the .write and .admin scopes will control access to API endpoints via the Cognito authorizer, however - # there will be a secondary level of authorization within the business logic that controls further granularity - # of authorization (i.e. 'aslp/write' will grant access to POST license data, but the business logic inside - # the endpoint also expects an 'aslp/co.write' if the POST includes data for Colorado.) + # {compact}.write, {compact}.admin, {compact}.readGeneral for every compact + # Note: the .readGeneral .write and .admin scopes will control access to API endpoints via the Cognito + # authorizer, however there will be a secondary level of authorization within the business logic that controls + # further granularity of authorization (i.e. 'aslp/write' will grant access to POST license data, but the + # business logic inside the endpoint also expects an 'aslp/co.write' if the POST includes data for Colorado.) self.write_scope = ResourceServerScope( scope_name='write', scope_description='Write access for the compact, paired with a more specific scope', @@ -83,7 +83,10 @@ def _add_resource_servers(self): scope_name='admin', scope_description='Admin access for the compact, paired with a more specific scope', ) - self.read_scope = ResourceServerScope(scope_name='read', scope_description='Read access for the compact') + self.read_scope = ResourceServerScope( + scope_name='readGeneral', + scope_description='Read access for generally available data (not private) in the compact', + ) # One resource server for each compact self.resource_servers = { diff --git a/backend/compact-connect/tests/app/test_api/test_purchases_api.py b/backend/compact-connect/tests/app/test_api/test_purchases_api.py index db923e024..92589818b 100644 --- a/backend/compact-connect/tests/app/test_api/test_purchases_api.py +++ b/backend/compact-connect/tests/app/test_api/test_purchases_api.py @@ -86,15 +86,13 @@ def test_synth_generates_post_purchases_privileges_handler_with_required_secret_ ) # We need to ensure the lambda can read these secrets, else all transactions will fail + # sort the compact names to ensure the order is consistent + self.context['compacts'].sort() self.assertIn( { 'Action': 'secretsmanager:GetSecretValue', 'Effect': 'Allow', - 'Resource': [ - _generate_expected_secret_arn('aslp'), - _generate_expected_secret_arn('coun'), - _generate_expected_secret_arn('octp'), - ], + 'Resource': [_generate_expected_secret_arn(compact) for compact in self.context['compacts']], }, policy['Properties']['PolicyDocument']['Statement'], ) diff --git a/backend/compact-connect/tests/app/test_api/test_staff_users_api.py b/backend/compact-connect/tests/app/test_api/test_staff_users_api.py new file mode 100644 index 000000000..81eedaf4d --- /dev/null +++ b/backend/compact-connect/tests/app/test_api/test_staff_users_api.py @@ -0,0 +1,194 @@ +from aws_cdk.assertions import Capture, Template +from aws_cdk.aws_apigateway import CfnMethod, CfnModel, CfnResource +from aws_cdk.aws_lambda import CfnFunction + +from tests.app.test_api import TestApi + + +class TestStaffUsersApi(TestApi): + """These tests are focused on checking that the API endpoints for the `staff-users` path are + configured correctly. + + When adding or modifying API resources, a test should be added to ensure that the + resource is created as expected. The pattern for these tests includes the following checks: + 1. The path and parent id of the API Gateway resource matches expected values. + 2. If the resource has a lambda function associated with it, the function is present with the expected + module and function. + 3. Check the methods associated with the resource, ensuring they are all present and have the correct handlers. + 4. Ensure the request and response models for the endpoint are present and match the expected schemas. + """ + + def test_synth_generates_staff_users_resources(self): + api_stack = self.app.sandbox_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + + # Ensure the resource is created with expected path for self-service endpoints + # /v1/staff-users + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + # Verify the parent id matches the expected 'v1' resource + 'Ref': api_stack.get_logical_id(api_stack.api.v1_api.resource.node.default_child), + }, + 'PathPart': 'staff-users', + }, + ) + + # Ensure the resource is created with expected path for self-service endpoints + # /v1/compacts/{compact}/staff-users + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + # Verify the parent id matches the expected 'v1' resource + 'Ref': api_stack.get_logical_id(api_stack.api.v1_api.compact_resource.node.default_child), + }, + 'PathPart': 'staff-users', + }, + ) + + def test_synth_generates_patch_staff_users_endpoint_resource(self): + api_stack = self.app.sandbox_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + + # Ensure the resource is created with expected path + # /v1/compacts/{compact}/staff-users/{userId} + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + # Verify the parent id matches the expected 'staff-users' resource + 'Ref': api_stack.get_logical_id(api_stack.api.v1_api.staff_users_admin_resource.node.default_child), + }, + 'PathPart': '{userId}', + }, + ) + + patch_handler_properties = self.get_resource_properties_by_logical_id( + logical_id=api_stack.get_logical_id(api_stack.api.v1_api.staff_users.patch_user_handler.node.default_child), + resources=api_stack_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + 'handlers.users.patch_user', + patch_handler_properties['Handler'], + ) + patch_method_request_model_logical_id_capture = Capture() + patch_method_response_model_logical_id_capture = Capture() + + # ensure the GET method is configured with the lambda integration and authorizer + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'PATCH', + # the provider users endpoints uses a separate authorizer from the staff endpoints + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + # ensure the lambda integration is configured with the expected handler + 'Integration': TestApi.generate_expected_integration_object( + api_stack.get_logical_id( + api_stack.api.v1_api.staff_users.patch_user_handler.node.default_child, + ), + ), + 'RequestModels': { + 'application/json': {'Ref': patch_method_request_model_logical_id_capture}, + }, + 'MethodResponses': [ + { + 'ResponseModels': {'application/json': {'Ref': patch_method_response_model_logical_id_capture}}, + 'StatusCode': '200', + }, + ], + }, + ) + + # now check the model matches expected contract + patch_request_model = TestApi.get_resource_properties_by_logical_id( + patch_method_request_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + patch_request_model['Schema'], + 'PATCH_STAFF_USERS_REQUEST_SCHEMA', + overwrite_snapshot=False, + ) + + patch_response_model = TestApi.get_resource_properties_by_logical_id( + patch_method_response_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + patch_response_model['Schema'], + 'PATCH_STAFF_USERS_RESPONSE_SCHEMA', + overwrite_snapshot=False, + ) + + def test_synth_generates_post_staff_user_endpoint_resource(self): + api_stack = self.app.sandbox_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + + post_user_handler_properties = self.get_resource_properties_by_logical_id( + logical_id=api_stack.get_logical_id(api_stack.api.v1_api.staff_users.post_user_handler.node.default_child), + resources=api_stack_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + 'handlers.users.post_user', + post_user_handler_properties['Handler'], + ) + post_method_request_model_logical_id_capture = Capture() + post_method_response_model_logical_id_capture = Capture() + + # ensure the GET method is configured with the lambda integration and authorizer + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'POST', + # the provider users endpoints uses a separate authorizer from the staff endpoints + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + # ensure the lambda integration is configured with the expected handler + 'Integration': TestApi.generate_expected_integration_object( + api_stack.get_logical_id( + api_stack.api.v1_api.staff_users.post_user_handler.node.default_child, + ), + ), + 'RequestModels': { + 'application/json': {'Ref': post_method_request_model_logical_id_capture}, + }, + 'MethodResponses': [ + { + 'ResponseModels': {'application/json': {'Ref': post_method_response_model_logical_id_capture}}, + 'StatusCode': '200', + }, + ], + }, + ) + + # now check the model matches expected contract + post_request_model = TestApi.get_resource_properties_by_logical_id( + post_method_request_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + post_request_model['Schema'], + 'POST_STAFF_USERS_REQUEST_SCHEMA', + overwrite_snapshot=False, + ) + + post_response_model = TestApi.get_resource_properties_by_logical_id( + post_method_response_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + post_response_model['Schema'], + 'POST_STAFF_USERS_RESPONSE_SCHEMA', + overwrite_snapshot=False, + ) diff --git a/backend/compact-connect/tests/app/test_pipeline.py b/backend/compact-connect/tests/app/test_pipeline.py index f1429e609..a29d877e4 100644 --- a/backend/compact-connect/tests/app/test_pipeline.py +++ b/backend/compact-connect/tests/app/test_pipeline.py @@ -5,7 +5,12 @@ from app import CompactConnectApp from aws_cdk.assertions import Match, Template -from aws_cdk.aws_cognito import CfnUserPool, CfnUserPoolClient, CfnUserPoolRiskConfigurationAttachment +from aws_cdk.aws_cognito import ( + CfnUserPool, + CfnUserPoolClient, + CfnUserPoolResourceServer, + CfnUserPoolRiskConfigurationAttachment, +) from aws_cdk.aws_lambda import CfnFunction, CfnLayerVersion from aws_cdk.aws_ssm import CfnParameter @@ -53,6 +58,29 @@ def test_synth_pipeline(self): for ui_stack in (self.app.pipeline_stack.test_stage.ui_stack, self.app.pipeline_stack.prod_stage.ui_stack): self._inspect_ui_stack(ui_stack) + def test_synth_generates_resource_servers_with_expected_scopes_for_staff_users(self): + persistent_stack = self.app.pipeline_stack.test_stage.persistent_stack + persistent_stack_template = Template.from_stack(persistent_stack) + + # Get the resource servers created in the persistent stack + resource_servers = persistent_stack.staff_users.resource_servers + # We must confirm that these scopes are being explicitly created for each compact + # which are absolutely critical for the system to function as expected. + self.assertEqual(self.context['compacts'], list(resource_servers.keys())) + + for compact, resource_server in resource_servers.items(): + # for this test, we just get the 'aslp' compact resource server + resource_server_properties = self.get_resource_properties_by_logical_id( + persistent_stack.get_logical_id(resource_server.node.default_child), + persistent_stack_template.find_resources(CfnUserPoolResourceServer.CFN_RESOURCE_TYPE_NAME), + ) + # Ensure the resource servers are created with the expected scopes + self.assertEqual( + ['admin', 'write', 'readGeneral'], + [scope['ScopeName'] for scope in resource_server_properties['Scopes']], + msg=f'Expected scopes for compact {compact} not found', + ) + def test_cognito_using_recommended_security_in_prod(self): stack = self.app.pipeline_stack.prod_stage.persistent_stack template = Template.from_stack(stack) diff --git a/backend/compact-connect/tests/resources/snapshots/PATCH_STAFF_USERS_REQUEST_SCHEMA.json b/backend/compact-connect/tests/resources/snapshots/PATCH_STAFF_USERS_REQUEST_SCHEMA.json new file mode 100644 index 000000000..f0705dc35 --- /dev/null +++ b/backend/compact-connect/tests/resources/snapshots/PATCH_STAFF_USERS_REQUEST_SCHEMA.json @@ -0,0 +1,53 @@ +{ + "additionalProperties": false, + "properties": { + "permissions": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "actions": { + "properties": { + "readPrivate": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + }, + "read": { + "type": "boolean" + } + }, + "type": "object" + }, + "jurisdictions": { + "additionalProperties": { + "properties": { + "actions": { + "additionalProperties": false, + "properties": { + "write": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + }, + "readPrivate": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "object" + } + }, + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/compact-connect/tests/resources/snapshots/PATCH_STAFF_USERS_RESPONSE_SCHEMA.json b/backend/compact-connect/tests/resources/snapshots/PATCH_STAFF_USERS_RESPONSE_SCHEMA.json new file mode 100644 index 000000000..31dd2a592 --- /dev/null +++ b/backend/compact-connect/tests/resources/snapshots/PATCH_STAFF_USERS_RESPONSE_SCHEMA.json @@ -0,0 +1,87 @@ +{ + "additionalProperties": false, + "properties": { + "userId": { + "type": "string" + }, + "attributes": { + "additionalProperties": false, + "properties": { + "email": { + "maxLength": 100, + "minLength": 5, + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "email", + "givenName", + "familyName" + ], + "type": "object" + }, + "permissions": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "actions": { + "properties": { + "readPrivate": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + }, + "read": { + "type": "boolean" + } + }, + "type": "object" + }, + "jurisdictions": { + "additionalProperties": { + "properties": { + "actions": { + "additionalProperties": false, + "properties": { + "write": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + }, + "readPrivate": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "object" + } + }, + "required": [ + "userId", + "attributes", + "permissions" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/compact-connect/tests/resources/snapshots/POST_STAFF_USERS_REQUEST_SCHEMA.json b/backend/compact-connect/tests/resources/snapshots/POST_STAFF_USERS_REQUEST_SCHEMA.json new file mode 100644 index 000000000..ddbcdefd8 --- /dev/null +++ b/backend/compact-connect/tests/resources/snapshots/POST_STAFF_USERS_REQUEST_SCHEMA.json @@ -0,0 +1,83 @@ +{ + "additionalProperties": false, + "properties": { + "attributes": { + "additionalProperties": false, + "properties": { + "email": { + "maxLength": 100, + "minLength": 5, + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "email", + "givenName", + "familyName" + ], + "type": "object" + }, + "permissions": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "actions": { + "properties": { + "readPrivate": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + }, + "read": { + "type": "boolean" + } + }, + "type": "object" + }, + "jurisdictions": { + "additionalProperties": { + "properties": { + "actions": { + "additionalProperties": false, + "properties": { + "write": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + }, + "readPrivate": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "object" + } + }, + "required": [ + "attributes", + "permissions" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/compact-connect/tests/resources/snapshots/POST_STAFF_USERS_RESPONSE_SCHEMA.json b/backend/compact-connect/tests/resources/snapshots/POST_STAFF_USERS_RESPONSE_SCHEMA.json new file mode 100644 index 000000000..31dd2a592 --- /dev/null +++ b/backend/compact-connect/tests/resources/snapshots/POST_STAFF_USERS_RESPONSE_SCHEMA.json @@ -0,0 +1,87 @@ +{ + "additionalProperties": false, + "properties": { + "userId": { + "type": "string" + }, + "attributes": { + "additionalProperties": false, + "properties": { + "email": { + "maxLength": 100, + "minLength": 5, + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "email", + "givenName", + "familyName" + ], + "type": "object" + }, + "permissions": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "actions": { + "properties": { + "readPrivate": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + }, + "read": { + "type": "boolean" + } + }, + "type": "object" + }, + "jurisdictions": { + "additionalProperties": { + "properties": { + "actions": { + "additionalProperties": false, + "properties": { + "write": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + }, + "readPrivate": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "object" + } + }, + "required": [ + "userId", + "attributes", + "permissions" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/compact-connect/tests/smoke/config.py b/backend/compact-connect/tests/smoke/config.py new file mode 100644 index 000000000..06d41fad7 --- /dev/null +++ b/backend/compact-connect/tests/smoke/config.py @@ -0,0 +1,67 @@ +import json +import logging +import os +from functools import cached_property + +import boto3 +from aws_lambda_powertools import Logger + +logging.basicConfig() +logger = Logger() +logger.setLevel(logging.DEBUG if os.environ.get('DEBUG', 'false').lower() == 'true' else logging.INFO) + + +class _Config: + @property + def api_base_url(self): + return os.environ['CC_TEST_API_BASE_URL'] + + @property + def provider_user_dynamodb_table(self): + return boto3.resource('dynamodb').Table(os.environ['CC_TEST_PROVIDER_DYNAMO_TABLE_NAME']) + + @property + def data_events_dynamodb_table(self): + return boto3.resource('dynamodb').Table(os.environ['CC_TEST_DATA_EVENT_DYNAMO_TABLE_NAME']) + + @property + def staff_users_dynamodb_table(self): + return boto3.resource('dynamodb').Table(os.environ['CC_TEST_STAFF_USER_DYNAMO_TABLE_NAME']) + + @property + def cognito_staff_user_client_id(self): + return os.environ['CC_TEST_COGNITO_STAFF_USER_POOL_CLIENT_ID'] + + @property + def cognito_staff_user_pool_id(self): + return os.environ['CC_TEST_COGNITO_STAFF_USER_POOL_ID'] + + @property + def cognito_provider_user_client_id(self): + return os.environ['CC_TEST_COGNITO_PROVIDER_USER_POOL_CLIENT_ID'] + + @property + def cognito_provider_user_pool_id(self): + return os.environ['CC_TEST_COGNITO_PROVIDER_USER_POOL_ID'] + + @property + def test_provider_user_username(self): + return os.environ['CC_TEST_PROVIDER_USER_USERNAME'] + + @property + def test_provider_user_password(self): + return os.environ['CC_TEST_PROVIDER_USER_PASSWORD'] + + @cached_property + def cognito_client(self): + return boto3.client('cognito-idp') + + +def load_smoke_test_env(): + with open(os.path.join(os.path.dirname(__file__), 'smoke_tests_env.json')) as env_file: + env_vars = json.load(env_file) + os.environ.update(env_vars) + + +load_smoke_test_env() +config = _Config() diff --git a/backend/compact-connect/tests/smoke/license_upload_smoke_tests.py b/backend/compact-connect/tests/smoke/license_upload_smoke_tests.py index 7edaf54e3..10c1e637e 100644 --- a/backend/compact-connect/tests/smoke/license_upload_smoke_tests.py +++ b/backend/compact-connect/tests/smoke/license_upload_smoke_tests.py @@ -6,6 +6,8 @@ import requests from smoke_common import ( SmokeTestFailureException, + create_test_staff_user, + delete_test_staff_user, get_api_base_url, get_data_events_dynamodb_table, get_provider_user_dynamodb_table, @@ -19,9 +21,12 @@ # This script can be run locally to test the license upload/ingest flow against a sandbox environment # of the Compact Connect API. +# Your sandbox account must be deployed with the "security_profile": "VULNERABLE" setting in your cdk.context.json # To run this script, create a smoke_tests_env.json file in the same directory as this script using the # 'smoke_tests_env_example.json' file as a template. +TEST_STAFF_USER_EMAIL = 'testStaffUserLicenseUploader@smokeTestFakeEmail.com' + def upload_licenses_record(): """ @@ -33,7 +38,7 @@ def upload_licenses_record(): Step 3: Verify the license record is recorded in the data events table. """ - headers = get_staff_user_auth_headers() + headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) # Step 1: Upload a license record through the POST '/v1/compacts/aslp/jurisdictions/ne/licenses' endpoint. post_body = [ @@ -149,5 +154,18 @@ def upload_licenses_record(): if __name__ == '__main__': load_smoke_test_env() - upload_licenses_record() - print('License record upload smoke test passed') + # Create staff user with permission to upload licenses + test_user_sub = create_test_staff_user( + email=TEST_STAFF_USER_EMAIL, + compact=COMPACT, + jurisdiction=JURISDICTION, + permissions={'actions': {'admin'}, 'jurisdictions': {JURISDICTION: {'write', 'admin'}}}, + ) + try: + upload_licenses_record() + print('License record upload smoke test passed') + except SmokeTestFailureException as e: + print(f'License record upload smoke test failed: {str(e)}') + finally: + # Clean up the test staff user + delete_test_staff_user(TEST_STAFF_USER_EMAIL, user_sub=test_user_sub, compact=COMPACT) diff --git a/backend/compact-connect/tests/smoke/military_affiliation_smoke_tests.py b/backend/compact-connect/tests/smoke/military_affiliation_smoke_tests.py index e24991cdc..e10f8ec8e 100644 --- a/backend/compact-connect/tests/smoke/military_affiliation_smoke_tests.py +++ b/backend/compact-connect/tests/smoke/military_affiliation_smoke_tests.py @@ -8,12 +8,13 @@ from smoke_common import ( SmokeTestFailureException, get_api_base_url, - get_provider_user_auth_headers, + get_provider_user_auth_headers_cached, load_smoke_test_env, ) # This script is used to test the military affiliations upload flow against a sandbox environment -# # of the Compact Connect API. +# of the Compact Connect API. It requires that you have a provider user set up in the sandbox environment. +# Your sandbox account must also be deployed with the "security_profile": "VULNERABLE" setting in your cdk.context.json # To run this script, create a smoke_tests_env.json file in the same directory as this script using the # 'smoke_tests_env_example.json' file as a template. @@ -26,7 +27,7 @@ def test_military_affiliation_upload(): # Step 3: Verify that the test pdf file was uploaded successfully by checking the response status code. # Step 4: Get the provider data from the GET '/v1/provider-users/me' endpoint and verify that the military # affiliation record is active. - headers = get_provider_user_auth_headers() + headers = get_provider_user_auth_headers_cached() post_body = { 'fileNames': ['military_affiliation.pdf'], @@ -111,7 +112,7 @@ def test_military_affiliation_patch_update(): # '/v1/provider-users/me/military-affiliation' endpoint. # Step 4: Get the provider data from the GET '/v1/provider-users/me' endpoint and verify that all the military # affiliation records are inactive. - headers = get_provider_user_auth_headers() + headers = get_provider_user_auth_headers_cached() patch_body = { 'status': 'inactive', diff --git a/backend/compact-connect/tests/smoke/purchasing_privileges_smoke_tests.py b/backend/compact-connect/tests/smoke/purchasing_privileges_smoke_tests.py index 534db37d2..a67d0495e 100644 --- a/backend/compact-connect/tests/smoke/purchasing_privileges_smoke_tests.py +++ b/backend/compact-connect/tests/smoke/purchasing_privileges_smoke_tests.py @@ -1,15 +1,14 @@ # ruff: noqa: T201 we use print statements for smoke testing #!/usr/bin/env python3 +import time from datetime import UTC, datetime import requests +from config import config from smoke_common import ( SmokeTestFailureException, call_provider_users_me_endpoint, - get_api_base_url, - get_provider_user_auth_headers, - get_provider_user_dynamodb_table, - load_smoke_test_env, + get_provider_user_auth_headers_cached, ) # This script can be run locally to test the privilege purchasing flow against a sandbox environment @@ -29,16 +28,22 @@ def test_purchasing_privilege(): if original_privileges: provider_id = original_provider_data.get('providerId') compact = original_provider_data.get('compact') - dynamodb_table = get_provider_user_dynamodb_table() + dynamodb_table = config.provider_user_dynamodb_table for privilege in original_privileges: - print(f'Deleting privilege record: {privilege}') + privilege_pk = f'{compact}#PROVIDER#{provider_id}' + privilege_sk = ( + f'{compact}#PROVIDER#privilege/{privilege["jurisdiction"]}#' + f'{datetime.fromisoformat(privilege["dateOfRenewal"]).date().isoformat()}' + ) + print(f'Deleting privilege record:\n{privilege_pk}\n{privilege_sk}') dynamodb_table.delete_item( Key={ - 'pk': f'{compact}#PROVIDER#{provider_id}', - 'sk': f'{compact}#PROVIDER#privilege/{privilege["jurisdiction"]}' - f'#{datetime.fromisoformat(privilege["dateOfRenewal"]).date().isoformat()}', + 'pk': privilege_pk, + 'sk': privilege_sk, } ) + # give dynamodb time to propagate + time.sleep(1) post_body = { 'orderInformation': { @@ -61,9 +66,9 @@ def test_purchasing_privilege(): 'selectedJurisdictions': ['ne'], } - headers = get_provider_user_auth_headers() + headers = get_provider_user_auth_headers_cached() post_api_response = requests.post( - url=get_api_base_url() + '/v1/purchases/privileges', headers=headers, json=post_body, timeout=20 + url=config.api_base_url + '/v1/purchases/privileges', headers=headers, json=post_body, timeout=20 ) if post_api_response.status_code != 200: @@ -93,5 +98,4 @@ def test_purchasing_privilege(): if __name__ == '__main__': - load_smoke_test_env() test_purchasing_privilege() diff --git a/backend/compact-connect/tests/smoke/query_provider_smoke_tests.py b/backend/compact-connect/tests/smoke/query_provider_smoke_tests.py new file mode 100644 index 000000000..ae8a1863b --- /dev/null +++ b/backend/compact-connect/tests/smoke/query_provider_smoke_tests.py @@ -0,0 +1,209 @@ +# ruff: noqa: S101 T201 we use asserts and print statements for smoke testing +import json + +import requests +from config import config, logger +from smoke_common import ( + SmokeTestFailureException, + call_provider_users_me_endpoint, + create_test_staff_user, + delete_test_staff_user, + get_staff_user_auth_headers, + load_smoke_test_env, +) + +# This script can be run locally to test the Query/Get Provider flow against a sandbox environment of the Compact +# Connect API. It requires that you have a provider user set up in the same compact of the sandbox environment. +# Your sandbox account must also be deployed with the "security_profile": "VULNERABLE" setting in your cdk.context.json +# file, which allows you to log in users using the boto3 Cognito client. + +# The staff user should be created **without** any 'readPrivate' permissions, as this flow is intended to test +# the general provider data retrieval flow. + +# To run this script, create a smoke_tests_env.json file in the same directory as this script using the +# 'smoke_tests_env_example.json' file as a template. + + +TEST_STAFF_USER_EMAIL = 'testStaffUserQuerySmokeTests@smokeTestFakeEmail.com' + + +def get_general_provider_user_data_smoke_test(): + """ + Verifies that a provider record can be fetched from the GET provider users endpoint with private fields sanitized. + + Step 1: Get the provider id of the provider user profile information. + Step 2: The staff user calls the GET provider users endpoint with the provider id. + Step 3: Verify the Provider response matches the profile. + """ + # Step 1: Get the provider id of the provider user profile information. + test_user_profile = call_provider_users_me_endpoint() + provider_id = provider_user_profile['providerId'] + compact = provider_user_profile['compact'] + + # Step 2: The staff user calls the GET provider users endpoint with the provider id. + staff_users_headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) + + get_provider_response = requests.get( + url=config.api_base_url + f'/v1/compacts/{compact}/providers/{provider_id}', + headers=staff_users_headers, + timeout=10, + ) + + if get_provider_response.status_code != 200: + raise SmokeTestFailureException(f'Failed to query provider. Response: {get_provider_response.json()}') + logger.info('Received success response from GET endpoint') + + # Step 3: Verify the Provider response matches the profile. + provider_object = get_provider_response.json() + + # verify the ssn is NOT in the response + if 'ssn' in provider_object: + raise SmokeTestFailureException(f'unexpected ssn field returned. Response: {get_provider_response.json()}') + + # remove the fields from the user profile that are not in the query response + test_user_profile.pop('ssn', None) + test_user_profile.pop('dateOfBirth', None) + for provider_license in test_user_profile['licenses']: + provider_license.pop('ssn', None) + provider_license.pop('dateOfBirth', None) + for military_affiliation in test_user_profile['militaryAffiliations']: + military_affiliation.pop('documentKeys', None) + + if provider_object != test_user_profile: + raise SmokeTestFailureException( + f'Provider object does not match the profile.\n' + f'Profile response: {json.dumps(test_user_profile)}\n' + f'Get Provider response: {json.dumps(provider_object)}' + ) + logger.info('Successfully fetched expected provider records.') + + +def query_provider_user_smoke_test(): + """ + Verifies that a provider record can be queried . + + Step 1: Get the provider id of the provider user profile information. + Step 2: Have the staff user query for that provider using the profile information. + Step 3: Verify the Provider response matches the profile. + """ + + # Step 1: Get the provider id of the provider user profile information. + test_user_profile = call_provider_users_me_endpoint() + provider_id = provider_user_profile['providerId'] + compact = provider_user_profile['compact'] + + # Step 2: Have the staff user query for that provider using the profile information. + staff_users_headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) + post_body = {'query': {'providerId': provider_id}} + + post_response = requests.post( + url=config.api_base_url + f'/v1/compacts/{compact}/providers/query', + headers=staff_users_headers, + json=post_body, + timeout=10, + ) + + if post_response.status_code != 200: + raise SmokeTestFailureException(f'Failed to query provider. Response: {post_response.json()}') + logger.info('Received success response from query endpoint') + # Step 3: Verify the Provider response matches the profile. + providers = post_response.json()['providers'] + if not providers: + raise SmokeTestFailureException(f'No providers returned by query. Response: {post_response.json()}') + + provider_object = providers[0] + + # verify the ssn is NOT in the response + if 'ssn' in provider_object: + raise SmokeTestFailureException(f'unexpected ssn field returned. Response: {post_response.json()}') + + # remove the fields from the user profile that are not in the query response + test_user_profile.pop('ssn', None) + test_user_profile.pop('dateOfBirth', None) + test_user_profile.pop('licenses') + test_user_profile.pop('militaryAffiliations') + test_user_profile.pop('privileges') + + if provider_object != test_user_profile: + raise SmokeTestFailureException( + f'Provider object does not match the profile.\n' + f'Profile response: {test_user_profile}\n' + f'Query Provider object: {provider_object}' + ) + + logger.info('Successfully queried expected provider record.') + + +def get_provider_data_with_read_private_access_smoke_test(test_staff_user_id: str): + """ + Verifies that a staff user can read private fields of a provider record if they have the 'readPrivate' permission. + + Step 1: Update the staff user's permissions using the PATCH '/v1/staff-users/me/permissions' endpoint to include + the 'readPrivate' permission. + Step 2: Generate a new token and call the GET provider users endpoint with the new token. + Step 3: Verify the Provider response matches the profile. + """ + + # Step 1: Get the provider user profile information. + test_user_profile = call_provider_users_me_endpoint() + provider_id = provider_user_profile['providerId'] + compact = provider_user_profile['compact'] + # Step 1: Update the staff user's permissions using the PATCH '/v1/staff-users/me/permissions' endpoint. + staff_users_headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) + patch_body = {'permissions': {'aslp': {'actions': {'readPrivate': True}}}} + patch_response = requests.patch( + url=config.api_base_url + f'/v1/compacts/{compact}/staff-users/{test_staff_user_id}', + headers=staff_users_headers, + json=patch_body, + timeout=10, + ) + + if patch_response.status_code != 200: + raise SmokeTestFailureException(f'Failed to PATCH staff user permissions. Response: {patch_response.json()}') + logger.info('Successfully updated staff user permissions.') + + # Step 2: Generate a new token and call the GET provider users endpoint with the new token. + staff_users_headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) + get_provider_response = requests.get( + url=config.api_base_url + f'/v1/compacts/{compact}/providers/{provider_id}', + headers=staff_users_headers, + timeout=10, + ) + + if get_provider_response.status_code != 200: + raise SmokeTestFailureException(f'Failed to GET staff user. Response: {get_provider_response.json()}') + + logger.info('Received success response from GET endpoint') + + # Step 3: Verify the Provider response matches the profile. + provider_object = get_provider_response.json() + if provider_object != test_user_profile: + raise SmokeTestFailureException( + f'Provider object does not match the profile.\n' + f'Profile response: {test_user_profile}\n' + f'Get Provider response: {provider_object}' + ) + + logger.info('Successfully fetched expected user profile.') + + +if __name__ == '__main__': + load_smoke_test_env() + provider_user_profile = call_provider_users_me_endpoint() + provider_compact = provider_user_profile['compact'] + # ensure the test staff user is in the same compact as the test provider user without 'readPrivate' permissions + test_user_sub = create_test_staff_user( + email=TEST_STAFF_USER_EMAIL, + compact=provider_compact, + jurisdiction='oh', + permissions={'actions': {'admin'}, 'jurisdictions': {'oh': {'write', 'admin'}}}, + ) + try: + get_general_provider_user_data_smoke_test() + query_provider_user_smoke_test() + get_provider_data_with_read_private_access_smoke_test(test_staff_user_id=test_user_sub) + logger.info('Query provider smoke tests passed') + except SmokeTestFailureException as e: + logger.error(f'Query provider smoke tests failed: {str(e)}') + finally: + delete_test_staff_user(TEST_STAFF_USER_EMAIL, user_sub=test_user_sub, compact=provider_compact) diff --git a/backend/compact-connect/tests/smoke/smoke_common.py b/backend/compact-connect/tests/smoke/smoke_common.py index 89974cbed..53a710383 100644 --- a/backend/compact-connect/tests/smoke/smoke_common.py +++ b/backend/compact-connect/tests/smoke/smoke_common.py @@ -1,8 +1,11 @@ import json import os +import sys import boto3 import requests +from botocore.exceptions import ClientError +from config import config, logger class SmokeTestFailureException(Exception): @@ -14,15 +17,154 @@ def __init__(self, message): super().__init__(message) -def get_provider_user_auth_headers(): +provider_data_path = os.path.join('lambdas', 'python', 'staff-users') +common_lib_path = os.path.join('lambdas', 'python', 'common') +sys.path.append(provider_data_path) +sys.path.append(common_lib_path) + +with open('cdk.json') as context_file: + _context = json.load(context_file)['context'] +JURISDICTIONS = _context['jurisdictions'] +COMPACTS = _context['compacts'] + +os.environ['COMPACTS'] = json.dumps(COMPACTS) +os.environ['JURISDICTIONS'] = json.dumps(JURISDICTIONS) + +# We have to import this after we've added the common lib to our path and environment +from cc_common.data_model.schema.user import UserRecordSchema # noqa: E402 + +_TEST_STAFF_USER_PASSWORD = 'TestPass123!' # noqa: S105 test credential for test staff user +_TEMP_STAFF_PASSWORD = 'TempPass123!' # noqa: S105 temporary password for creating test staff users + + +def _create_staff_user_in_cognito(*, email: str) -> str: + """ + Creates a staff user in Cognito and returns the user's sub. + """ + + def get_sub_from_attributes(user_attributes: list): + for attribute in user_attributes: + if attribute['Name'] == 'sub': + return attribute['Value'] + raise ValueError('Failed to find user sub!') + + try: + user_data = config.cognito_client.admin_create_user( + UserPoolId=config.cognito_staff_user_pool_id, + Username=email, + UserAttributes=[{'Name': 'email', 'Value': email}], + TemporaryPassword=_TEMP_STAFF_PASSWORD, + ) + logger.info(f"Created staff user, '{email}'. Setting password.") + # set this to simplify login flow for user + config.cognito_client.admin_set_user_password( + UserPoolId=config.cognito_staff_user_pool_id, + Username=email, + Password=_TEST_STAFF_USER_PASSWORD, + Permanent=True, + ) + logger.info(f"Set password for staff user, '{email}' in Cognito. New user data: {user_data}") + return get_sub_from_attributes(user_data['User']['Attributes']) + + except ClientError as e: + if e.response['Error']['Code'] == 'UsernameExistsException': + logger.info(f"Staff user, '{email}', already exists in Cognito. Getting user data.") + user_data = config.cognito_client.admin_get_user( + UserPoolId=config.cognito_staff_user_pool_id, Username=email + ) + return get_sub_from_attributes(user_data['UserAttributes']) + + raise e + + +def delete_test_staff_user(email, user_sub: str, compact: str): + """ + Deletes a test staff user from Cognito. + """ + try: + logger.info(f"Deleting staff user from cognito, '{email}'") + config.cognito_client.admin_delete_user(UserPoolId=config.cognito_staff_user_pool_id, Username=email) + # now clean up the user record in DynamoDB + pk = f'USER#{user_sub}' + sk = f'COMPACT#{compact}' + logger.info(f"Deleting staff user record from DynamoDB, PK: '{pk}', SK: '{sk}'") + config.staff_users_dynamodb_table.delete_item(Key={'pk': pk, 'sk': sk}) + logger.info(f"Deleted staff user, '{email}', from Cognito and DynamoDB") + except ClientError as e: + logger.error(f"Failed to delete staff user data, '{email}': {str(e)}") + raise e + + +def create_test_staff_user(*, email: str, compact: str, jurisdiction: str, permissions: dict): + """ + Creates a test staff user in Cognito, stores their data in DynamoDB, and returns their user sub id. + """ + logger.info(f"Creating staff user, '{email}', in {compact}/{jurisdiction}") + user_attributes = {'email': email, 'familyName': 'Dokes', 'givenName': 'Joe'} + sub = _create_staff_user_in_cognito(email=email) + schema = UserRecordSchema() + config.staff_users_dynamodb_table.put_item( + Item=schema.dump( + { + 'type': 'user', + 'userId': sub, + 'compact': compact, + 'attributes': user_attributes, + 'permissions': permissions, + }, + ), + ) + logger.info(f'Created staff user record in DynamoDB. User data: {user_attributes}') + + return sub + + +def get_user_tokens(email, password=_TEST_STAFF_USER_PASSWORD, is_staff=False): + """ + Gets Cognito tokens for a user. + { + 'IdToken': 'string', + 'AccessToken': 'string', + 'RefreshToken': 'string', + 'ExpiresIn': 123, + 'TokenType': 'string', + 'NewDeviceMetadata': { + 'DeviceKey': 'string', + 'DeviceGroupKey': 'string' + } + } + """ + try: + logger.info('Getting tokens for user: ' + email + ' user type: ' + ('staff' if is_staff else 'provider')) + response = config.cognito_client.admin_initiate_auth( + UserPoolId=config.cognito_staff_user_pool_id if is_staff else config.cognito_provider_user_pool_id, + ClientId=config.cognito_staff_user_client_id if is_staff else config.cognito_provider_user_client_id, + AuthFlow='ADMIN_USER_PASSWORD_AUTH', + AuthParameters={'USERNAME': email, 'PASSWORD': password}, + ) + + return response['AuthenticationResult'] + + except ClientError as e: + logger.info(f'Failed to get tokens for user {email}: {str(e)}') + raise e + + +def get_provider_user_auth_headers_cached(): + provider_token = os.environ.get('TEST_PROVIDER_USER_ID_TOKEN') + if not provider_token: + tokens = get_user_tokens(config.test_provider_user_username, config.test_provider_user_password, is_staff=False) + os.environ['TEST_PROVIDER_USER_ID_TOKEN'] = tokens['IdToken'] + return { 'Authorization': 'Bearer ' + os.environ['TEST_PROVIDER_USER_ID_TOKEN'], } -def get_staff_user_auth_headers(): +def get_staff_user_auth_headers(username: str, password: str = _TEST_STAFF_USER_PASSWORD): + tokens = get_user_tokens(username, password, is_staff=True) return { - 'Authorization': 'Bearer ' + os.environ['TEST_STAFF_USER_ACCESS_TOKEN'], + 'Authorization': 'Bearer ' + tokens['AccessToken'], } @@ -47,7 +189,7 @@ def load_smoke_test_env(): def call_provider_users_me_endpoint(): # Get the provider data from the GET '/v1/provider-users/me' endpoint. get_provider_data_response = requests.get( - url=get_api_base_url() + '/v1/provider-users/me', headers=get_provider_user_auth_headers(), timeout=10 + url=config.api_base_url + '/v1/provider-users/me', headers=get_provider_user_auth_headers_cached(), timeout=10 ) if get_provider_data_response.status_code != 200: raise SmokeTestFailureException(f'Failed to GET provider data. Response: {get_provider_data_response.json()}') diff --git a/backend/compact-connect/tests/smoke/smoke_tests_env_example.json b/backend/compact-connect/tests/smoke/smoke_tests_env_example.json index d40aabe4c..70448717c 100644 --- a/backend/compact-connect/tests/smoke/smoke_tests_env_example.json +++ b/backend/compact-connect/tests/smoke/smoke_tests_env_example.json @@ -2,6 +2,11 @@ "CC_TEST_API_BASE_URL": "https://api.test.compactconnect.org", "CC_TEST_PROVIDER_DYNAMO_TABLE_NAME": "Sandbox-PersistentStack-ProviderTable12345", "CC_TEST_DATA_EVENT_DYNAMO_TABLE_NAME": "Sandbox-PersistentStack-DataEventTable1234", - "TEST_PROVIDER_USER_ID_TOKEN": "This can be fetched by logging in as a licensee into the Compact Connect UI of your test environment and copying the value of 'id_token_licensee' from the browser's local storage.", - "TEST_STAFF_USER_ACCESS_TOKEN": "This can be fetched by logging in as a staff user into the Compact Connect UI of your test environment and copying the value of 'auth_token_staff' from the browser's local storage." + "CC_TEST_STAFF_USER_DYNAMO_TABLE_NAME": "Sandbox-PersistentStack-StaffUserTable1234", + "CC_TEST_COGNITO_STAFF_USER_POOL_ID": "us-east-1_12345", + "CC_TEST_COGNITO_STAFF_USER_POOL_CLIENT_ID": "72612345", + "CC_TEST_COGNITO_PROVIDER_USER_POOL_ID": "us-east-1_12345", + "CC_TEST_COGNITO_PROVIDER_USER_POOL_CLIENT_ID": "72612345", + "CC_TEST_PROVIDER_USER_USERNAME": "example@example.com", + "CC_TEST_PROVIDER_USER_PASSWORD": "examplePassword" } From b9bdfb374199fe744a516bed08a97d3dd22ec5ee Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Mon, 6 Jan 2025 07:56:02 -0700 Subject: [PATCH 06/18] Frontend/hifi style (#419) ### Requirements List - _None_ ### Description List - Swapped out the existing lo-fi app style for the hi-fi design version - https://www.figma.com/design/SYM0uWszsu8Sf0YfxAhIMY/JCC?node-id=1-12&p=f&t=etxgVUar50LKNAGL-0 - Also updated the green text color to be WCAG AA - The designs as-of 12/20 aren't crystal clear on the nav menu on phone size; but the design team and I discussed and concluded the following: - On tablet & desktop the nav menu will always be partially expanded (icons only) - On tablet & desktop the nav menu will expand fully when hovered / focused - On phone the nav menu will not be present, but instead a fixed header with a hamburger toggle - On phone the nav menu will expand fully when toggled, similar to tablet & desktop - Note that these updates were only to layout & style - not new screen features / updates; with the few exceptions below - Licensee credit card payment screen: - Re-arranged the first / last name form fields to match the current designs billing address section, - Added a mock populate for local environments - Note that the overhaul of the Staff Licensing Data Detail page is covered in a separate ticket #361 ### Testing List - `yarn test:unit:all` should run without errors or warnings - `yarn serve` should run without errors or warnings - `yarn build` should run without errors or warnings - Code review - Smoke test all app screens; feel free to reach out on Slack w/ initial questions if it's easier than piling up this PR with a lot of small threads. - Depending on how nit any findings are, they _may_ be separate tickets since we were just squeezing this update in between feature enhancements. Closes #313 Closes #405 --- .../src/assets/icons/ico-select-arrow-alt.svg | 3 + .../logos/compact-connect-logo-white.svg | 9 + .../Forms/InputButton/InputButton.ts | 3 +- .../Forms/InputButton/InputButton.vue | 1 + .../Forms/InputCheckbox/InputCheckbox.less | 46 ++-- .../components/Forms/InputFile/InputFile.less | 9 + .../Forms/InputSearch/InputSearch.less | 8 +- .../HomeStateBlock/HomeStateBlock.less | 2 +- .../src/components/Icons/Account/Account.less | 6 + .../components/Icons/Account/Account.spec.ts | 19 ++ .../src/components/Icons/Account/Account.ts | 18 ++ .../src/components/Icons/Account/Account.vue | 36 +++ .../Icons/CheckCircle/CheckCircle.vue | 3 +- .../components/Icons/Dashboard/Dashboard.less | 6 + .../Icons/Dashboard/Dashboard.spec.ts | 19 ++ .../components/Icons/Dashboard/Dashboard.ts | 18 ++ .../components/Icons/Dashboard/Dashboard.vue | 17 ++ .../Icons/LicenseSearch/LicenseSearch.less | 6 + .../Icons/LicenseSearch/LicenseSearch.spec.ts | 19 ++ .../Icons/LicenseSearch/LicenseSearch.ts | 18 ++ .../Icons/LicenseSearch/LicenseSearch.vue | 28 ++ .../LicenseSearchAlt/LicenseSearchAlt.less | 6 + .../LicenseSearchAlt/LicenseSearchAlt.spec.ts | 19 ++ .../LicenseSearchAlt/LicenseSearchAlt.ts | 18 ++ .../LicenseSearchAlt/LicenseSearchAlt.vue | 17 ++ .../src/components/Icons/Logout/Logout.less | 6 + .../components/Icons/Logout/Logout.spec.ts | 19 ++ webroot/src/components/Icons/Logout/Logout.ts | 18 ++ .../src/components/Icons/Logout/Logout.vue | 22 ++ .../components/Icons/Purchase/Purchase.less | 6 + .../Icons/Purchase/Purchase.spec.ts | 19 ++ .../src/components/Icons/Purchase/Purchase.ts | 18 ++ .../components/Icons/Purchase/Purchase.vue | 19 ++ .../src/components/Icons/Reports/Reports.less | 6 + .../components/Icons/Reports/Reports.spec.ts | 19 ++ .../src/components/Icons/Reports/Reports.ts | 18 ++ .../src/components/Icons/Reports/Reports.vue | 13 + .../components/Icons/Settings/Settings.less | 6 + .../Icons/Settings/Settings.spec.ts | 19 ++ .../src/components/Icons/Settings/Settings.ts | 18 ++ .../components/Icons/Settings/Settings.vue | 39 +++ .../src/components/Icons/Upload/Upload.less | 6 + .../components/Icons/Upload/Upload.spec.ts | 19 ++ webroot/src/components/Icons/Upload/Upload.ts | 18 ++ .../src/components/Icons/Upload/Upload.vue | 21 ++ webroot/src/components/Icons/Users/Users.less | 6 + .../src/components/Icons/Users/Users.spec.ts | 19 ++ webroot/src/components/Icons/Users/Users.ts | 18 ++ webroot/src/components/Icons/Users/Users.vue | 24 ++ .../Licensee/LicenseeList/LicenseeList.less | 5 +- .../LicenseeList/LicenseeList.spec.ts | 1 - .../Licensee/LicenseeList/LicenseeList.vue | 40 +-- .../Licensee/LicenseeRow/LicenseeRow.less | 4 - .../LicenseeSearch/LicenseeSearch.less | 7 +- .../Licensee/LicenseeSearch/LicenseeSearch.ts | 2 +- .../Lists/ListContainer/ListContainer.less | 1 - .../Lists/Pagination/Pagination.less | 11 +- .../Lists/Pagination/Pagination.vue | 3 +- webroot/src/components/Modal/Modal.less | 4 - .../Page/PageContainer/PageContainer.less | 11 +- .../Page/PageContainer/PageContainer.spec.ts | 27 -- .../Page/PageContainer/PageContainer.ts | 49 ++-- .../Page/PageContainer/PageContainer.vue | 7 +- .../Page/PageFooter/PageFooter.less | 4 +- .../Page/PageHeader/PageHeader.less | 90 ++++++- .../Page/PageHeader/PageHeader.spec.ts | 7 - .../components/Page/PageHeader/PageHeader.ts | 29 ++- .../components/Page/PageHeader/PageHeader.vue | 30 ++- .../Page/PageMainNav/PageMainNav.less | 169 ++++++++---- .../Page/PageMainNav/PageMainNav.spec.ts | 9 +- .../Page/PageMainNav/PageMainNav.ts | 138 +++++++--- .../Page/PageMainNav/PageMainNav.vue | 132 +++++++--- .../PaymentProcessorConfig.ts | 2 +- .../PrivilegeCard/PrivilegeCard.less | 2 +- .../SelectedStatePurchaseInformation.less | 84 +++--- .../SelectedStatePurchaseInformation.ts | 29 ++- .../SelectedStatePurchaseInformation.vue | 13 +- .../components/StateUpload/StateUpload.less | 131 +++++----- .../src/components/StateUpload/StateUpload.ts | 2 +- .../components/StateUpload/StateUpload.vue | 61 ++--- .../Users/UserInvite/UserInvite.less | 2 +- .../components/Users/UserInvite/UserInvite.ts | 3 +- .../components/Users/UserList/UserList.less | 42 ++- .../src/components/Users/UserList/UserList.ts | 1 + .../components/Users/UserList/UserList.vue | 2 +- .../src/components/Users/UserRow/UserRow.less | 7 +- .../Users/UserRowEdit/UserRowEdit.less | 2 +- webroot/src/locales/en.json | 24 +- webroot/src/locales/es.json | 24 +- webroot/src/pages/Account/Account.less | 6 + .../FinalizePrivilegePurchase.less | 75 ++++-- .../FinalizePrivilegePurchase.ts | 246 +++++++++--------- .../FinalizePrivilegePurchase.vue | 91 ++----- .../LicensingDetail/LicensingDetail.spec.ts | 6 +- .../pages/LicensingList/LicensingList.less | 1 - .../SelectPrivileges/SelectPrivileges.less | 11 +- .../SelectPrivileges/SelectPrivileges.vue | 21 +- .../src/pages/StateUpload/StateUpload.less | 14 +- webroot/src/pages/StateUpload/StateUpload.vue | 2 +- webroot/src/pages/UserList/UserList.less | 1 - webroot/src/router/index.ts | 6 + webroot/src/store/global/global.actions.ts | 6 + webroot/src/store/global/global.mutations.ts | 8 + webroot/src/store/global/global.spec.ts | 30 +++ webroot/src/store/global/global.state.ts | 2 + webroot/src/styles.common/_colors.less | 16 +- webroot/src/styles.common/_fonts.less | 6 +- webroot/src/styles.common/_inputs.less | 12 +- webroot/src/styles.common/_lists.less | 44 +++- webroot/src/styles.common/_sizes.less | 7 +- webroot/src/styles.common/_transitions.less | 1 + webroot/src/styles.common/mixins/buttons.less | 2 + .../src/styles.common/transitions/list.less | 26 ++ 113 files changed, 1877 insertions(+), 724 deletions(-) create mode 100644 webroot/src/assets/icons/ico-select-arrow-alt.svg create mode 100644 webroot/src/assets/logos/compact-connect-logo-white.svg create mode 100644 webroot/src/components/Icons/Account/Account.less create mode 100644 webroot/src/components/Icons/Account/Account.spec.ts create mode 100644 webroot/src/components/Icons/Account/Account.ts create mode 100644 webroot/src/components/Icons/Account/Account.vue create mode 100644 webroot/src/components/Icons/Dashboard/Dashboard.less create mode 100644 webroot/src/components/Icons/Dashboard/Dashboard.spec.ts create mode 100644 webroot/src/components/Icons/Dashboard/Dashboard.ts create mode 100644 webroot/src/components/Icons/Dashboard/Dashboard.vue create mode 100644 webroot/src/components/Icons/LicenseSearch/LicenseSearch.less create mode 100644 webroot/src/components/Icons/LicenseSearch/LicenseSearch.spec.ts create mode 100644 webroot/src/components/Icons/LicenseSearch/LicenseSearch.ts create mode 100644 webroot/src/components/Icons/LicenseSearch/LicenseSearch.vue create mode 100644 webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.less create mode 100644 webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.spec.ts create mode 100644 webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.ts create mode 100644 webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.vue create mode 100644 webroot/src/components/Icons/Logout/Logout.less create mode 100644 webroot/src/components/Icons/Logout/Logout.spec.ts create mode 100644 webroot/src/components/Icons/Logout/Logout.ts create mode 100644 webroot/src/components/Icons/Logout/Logout.vue create mode 100644 webroot/src/components/Icons/Purchase/Purchase.less create mode 100644 webroot/src/components/Icons/Purchase/Purchase.spec.ts create mode 100644 webroot/src/components/Icons/Purchase/Purchase.ts create mode 100644 webroot/src/components/Icons/Purchase/Purchase.vue create mode 100644 webroot/src/components/Icons/Reports/Reports.less create mode 100644 webroot/src/components/Icons/Reports/Reports.spec.ts create mode 100644 webroot/src/components/Icons/Reports/Reports.ts create mode 100644 webroot/src/components/Icons/Reports/Reports.vue create mode 100644 webroot/src/components/Icons/Settings/Settings.less create mode 100644 webroot/src/components/Icons/Settings/Settings.spec.ts create mode 100644 webroot/src/components/Icons/Settings/Settings.ts create mode 100644 webroot/src/components/Icons/Settings/Settings.vue create mode 100644 webroot/src/components/Icons/Upload/Upload.less create mode 100644 webroot/src/components/Icons/Upload/Upload.spec.ts create mode 100644 webroot/src/components/Icons/Upload/Upload.ts create mode 100644 webroot/src/components/Icons/Upload/Upload.vue create mode 100644 webroot/src/components/Icons/Users/Users.less create mode 100644 webroot/src/components/Icons/Users/Users.spec.ts create mode 100644 webroot/src/components/Icons/Users/Users.ts create mode 100644 webroot/src/components/Icons/Users/Users.vue create mode 100644 webroot/src/styles.common/transitions/list.less diff --git a/webroot/src/assets/icons/ico-select-arrow-alt.svg b/webroot/src/assets/icons/ico-select-arrow-alt.svg new file mode 100644 index 000000000..8faa8c233 --- /dev/null +++ b/webroot/src/assets/icons/ico-select-arrow-alt.svg @@ -0,0 +1,3 @@ + + + diff --git a/webroot/src/assets/logos/compact-connect-logo-white.svg b/webroot/src/assets/logos/compact-connect-logo-white.svg new file mode 100644 index 000000000..02b31cdbe --- /dev/null +++ b/webroot/src/assets/logos/compact-connect-logo-white.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/webroot/src/components/Forms/InputButton/InputButton.ts b/webroot/src/components/Forms/InputButton/InputButton.ts index 46f2e3ca3..2a417a000 100644 --- a/webroot/src/components/Forms/InputButton/InputButton.ts +++ b/webroot/src/components/Forms/InputButton/InputButton.ts @@ -17,8 +17,9 @@ import { }) class InputButton extends Vue { @Prop({ required: true }) private label!: string; - @Prop({ default: true }) private isEnabled?: boolean; @Prop({ required: true }) private onClick?: () => void; + @Prop({ default: '' }) private id?: string; + @Prop({ default: true }) private isEnabled?: boolean; @Prop({ default: false }) private shouldTransformText?: boolean; @Prop({ default: false }) private shouldHideMargin?: boolean; @Prop({ default: false }) private isTransparent?: boolean; diff --git a/webroot/src/components/Forms/InputButton/InputButton.vue b/webroot/src/components/Forms/InputButton/InputButton.vue index 89905eb9b..db8194924 100644 --- a/webroot/src/components/Forms/InputButton/InputButton.vue +++ b/webroot/src/components/Forms/InputButton/InputButton.vue @@ -9,6 +9,7 @@
{ + it('should mount the component', async () => { + const wrapper = await mountShallow(Account); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(Account).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Icons/Account/Account.ts b/webroot/src/components/Icons/Account/Account.ts new file mode 100644 index 000000000..fa5e8b3fc --- /dev/null +++ b/webroot/src/components/Icons/Account/Account.ts @@ -0,0 +1,18 @@ +// +// Account.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; + +@Component({ + name: 'Account', +}) +class Account extends Vue { +} + +export default toNative(Account); + +// export default Account; diff --git a/webroot/src/components/Icons/Account/Account.vue b/webroot/src/components/Icons/Account/Account.vue new file mode 100644 index 000000000..baa8f39f5 --- /dev/null +++ b/webroot/src/components/Icons/Account/Account.vue @@ -0,0 +1,36 @@ + + + + + + diff --git a/webroot/src/components/Icons/CheckCircle/CheckCircle.vue b/webroot/src/components/Icons/CheckCircle/CheckCircle.vue index 70a1eed76..bb90c8f33 100644 --- a/webroot/src/components/Icons/CheckCircle/CheckCircle.vue +++ b/webroot/src/components/Icons/CheckCircle/CheckCircle.vue @@ -8,7 +8,8 @@ + + + diff --git a/webroot/src/components/Icons/LicenseSearch/LicenseSearch.less b/webroot/src/components/Icons/LicenseSearch/LicenseSearch.less new file mode 100644 index 000000000..b9b91d273 --- /dev/null +++ b/webroot/src/components/Icons/LicenseSearch/LicenseSearch.less @@ -0,0 +1,6 @@ +// +// LicenseSearch.less +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// diff --git a/webroot/src/components/Icons/LicenseSearch/LicenseSearch.spec.ts b/webroot/src/components/Icons/LicenseSearch/LicenseSearch.spec.ts new file mode 100644 index 000000000..6206d287e --- /dev/null +++ b/webroot/src/components/Icons/LicenseSearch/LicenseSearch.spec.ts @@ -0,0 +1,19 @@ +// +// LicenseSearch.spec.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import LicenseSearch from '@components/Icons/LicenseSearch/LicenseSearch.vue'; + +describe('LicenseSearch component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(LicenseSearch); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(LicenseSearch).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Icons/LicenseSearch/LicenseSearch.ts b/webroot/src/components/Icons/LicenseSearch/LicenseSearch.ts new file mode 100644 index 000000000..b990f9596 --- /dev/null +++ b/webroot/src/components/Icons/LicenseSearch/LicenseSearch.ts @@ -0,0 +1,18 @@ +// +// LicenseSearch.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; + +@Component({ + name: 'LicenseSearch', +}) +class LicenseSearch extends Vue { +} + +export default toNative(LicenseSearch); + +// export default LicenseSearch; diff --git a/webroot/src/components/Icons/LicenseSearch/LicenseSearch.vue b/webroot/src/components/Icons/LicenseSearch/LicenseSearch.vue new file mode 100644 index 000000000..c0917a727 --- /dev/null +++ b/webroot/src/components/Icons/LicenseSearch/LicenseSearch.vue @@ -0,0 +1,28 @@ + + + + + + diff --git a/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.less b/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.less new file mode 100644 index 000000000..558f6abba --- /dev/null +++ b/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.less @@ -0,0 +1,6 @@ +// +// LicenseSearchAlt.less +// CompactConnect +// +// Created by InspiringApps on 1/2/2025. +// diff --git a/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.spec.ts b/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.spec.ts new file mode 100644 index 000000000..87bdc4c7e --- /dev/null +++ b/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.spec.ts @@ -0,0 +1,19 @@ +// +// LicenseSearchAlt.spec.ts +// CompactConnect +// +// Created by InspiringApps on 1/2/2025. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import LicenseSearchAlt from '@components/Icons/LicenseSearchAlt/LicenseSearchAlt.vue'; + +describe('LicenseSearchAlt component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(LicenseSearchAlt); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(LicenseSearchAlt).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.ts b/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.ts new file mode 100644 index 000000000..0825cff96 --- /dev/null +++ b/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.ts @@ -0,0 +1,18 @@ +// +// LicenseSearchAlt.ts +// CompactConnect +// +// Created by InspiringApps on 1/2/2025. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; + +@Component({ + name: 'LicenseSearchAlt', +}) +class LicenseSearchAlt extends Vue { +} + +export default toNative(LicenseSearchAlt); + +// export default LicenseSearchAlt; diff --git a/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.vue b/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.vue new file mode 100644 index 000000000..5bcc723a6 --- /dev/null +++ b/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.vue @@ -0,0 +1,17 @@ + + + + + + diff --git a/webroot/src/components/Icons/Logout/Logout.less b/webroot/src/components/Icons/Logout/Logout.less new file mode 100644 index 000000000..7c0a66079 --- /dev/null +++ b/webroot/src/components/Icons/Logout/Logout.less @@ -0,0 +1,6 @@ +// +// Logout.less +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// diff --git a/webroot/src/components/Icons/Logout/Logout.spec.ts b/webroot/src/components/Icons/Logout/Logout.spec.ts new file mode 100644 index 000000000..600fa8e82 --- /dev/null +++ b/webroot/src/components/Icons/Logout/Logout.spec.ts @@ -0,0 +1,19 @@ +// +// Logout.spec.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import Logout from '@components/Icons/Logout/Logout.vue'; + +describe('Logout component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(Logout); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(Logout).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Icons/Logout/Logout.ts b/webroot/src/components/Icons/Logout/Logout.ts new file mode 100644 index 000000000..2a7f34863 --- /dev/null +++ b/webroot/src/components/Icons/Logout/Logout.ts @@ -0,0 +1,18 @@ +// +// Logout.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; + +@Component({ + name: 'Logout', +}) +class Logout extends Vue { +} + +export default toNative(Logout); + +// export default Logout; diff --git a/webroot/src/components/Icons/Logout/Logout.vue b/webroot/src/components/Icons/Logout/Logout.vue new file mode 100644 index 000000000..ab6104b36 --- /dev/null +++ b/webroot/src/components/Icons/Logout/Logout.vue @@ -0,0 +1,22 @@ + + + + + + diff --git a/webroot/src/components/Icons/Purchase/Purchase.less b/webroot/src/components/Icons/Purchase/Purchase.less new file mode 100644 index 000000000..2a4839234 --- /dev/null +++ b/webroot/src/components/Icons/Purchase/Purchase.less @@ -0,0 +1,6 @@ +// +// Purchase.less +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// diff --git a/webroot/src/components/Icons/Purchase/Purchase.spec.ts b/webroot/src/components/Icons/Purchase/Purchase.spec.ts new file mode 100644 index 000000000..02cfdd33e --- /dev/null +++ b/webroot/src/components/Icons/Purchase/Purchase.spec.ts @@ -0,0 +1,19 @@ +// +// Purchase.spec.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import Purchase from '@components/Icons/Purchase/Purchase.vue'; + +describe('Purchase component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(Purchase); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(Purchase).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Icons/Purchase/Purchase.ts b/webroot/src/components/Icons/Purchase/Purchase.ts new file mode 100644 index 000000000..e8b21f9ef --- /dev/null +++ b/webroot/src/components/Icons/Purchase/Purchase.ts @@ -0,0 +1,18 @@ +// +// Purchase.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; + +@Component({ + name: 'Purchase', +}) +class Purchase extends Vue { +} + +export default toNative(Purchase); + +// export default Purchase; diff --git a/webroot/src/components/Icons/Purchase/Purchase.vue b/webroot/src/components/Icons/Purchase/Purchase.vue new file mode 100644 index 000000000..9d5282835 --- /dev/null +++ b/webroot/src/components/Icons/Purchase/Purchase.vue @@ -0,0 +1,19 @@ + + + + + + diff --git a/webroot/src/components/Icons/Reports/Reports.less b/webroot/src/components/Icons/Reports/Reports.less new file mode 100644 index 000000000..e0c0721d1 --- /dev/null +++ b/webroot/src/components/Icons/Reports/Reports.less @@ -0,0 +1,6 @@ +// +// Reports.less +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// diff --git a/webroot/src/components/Icons/Reports/Reports.spec.ts b/webroot/src/components/Icons/Reports/Reports.spec.ts new file mode 100644 index 000000000..d0effdfb0 --- /dev/null +++ b/webroot/src/components/Icons/Reports/Reports.spec.ts @@ -0,0 +1,19 @@ +// +// Reports.spec.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import Reports from '@components/Icons/Reports/Reports.vue'; + +describe('Reports component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(Reports); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(Reports).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Icons/Reports/Reports.ts b/webroot/src/components/Icons/Reports/Reports.ts new file mode 100644 index 000000000..c6ad0817f --- /dev/null +++ b/webroot/src/components/Icons/Reports/Reports.ts @@ -0,0 +1,18 @@ +// +// Reports.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; + +@Component({ + name: 'Reports', +}) +class Reports extends Vue { +} + +export default toNative(Reports); + +// export default Reports; diff --git a/webroot/src/components/Icons/Reports/Reports.vue b/webroot/src/components/Icons/Reports/Reports.vue new file mode 100644 index 000000000..9c8fe2375 --- /dev/null +++ b/webroot/src/components/Icons/Reports/Reports.vue @@ -0,0 +1,13 @@ + + + + + + diff --git a/webroot/src/components/Icons/Settings/Settings.less b/webroot/src/components/Icons/Settings/Settings.less new file mode 100644 index 000000000..061017a6c --- /dev/null +++ b/webroot/src/components/Icons/Settings/Settings.less @@ -0,0 +1,6 @@ +// +// Settings.less +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// diff --git a/webroot/src/components/Icons/Settings/Settings.spec.ts b/webroot/src/components/Icons/Settings/Settings.spec.ts new file mode 100644 index 000000000..e6fd29536 --- /dev/null +++ b/webroot/src/components/Icons/Settings/Settings.spec.ts @@ -0,0 +1,19 @@ +// +// Settings.spec.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import Settings from '@components/Icons/Settings/Settings.vue'; + +describe('Settings component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(Settings); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(Settings).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Icons/Settings/Settings.ts b/webroot/src/components/Icons/Settings/Settings.ts new file mode 100644 index 000000000..e3e2b1a6d --- /dev/null +++ b/webroot/src/components/Icons/Settings/Settings.ts @@ -0,0 +1,18 @@ +// +// Settings.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; + +@Component({ + name: 'Settings', +}) +class Settings extends Vue { +} + +export default toNative(Settings); + +// export default Settings; diff --git a/webroot/src/components/Icons/Settings/Settings.vue b/webroot/src/components/Icons/Settings/Settings.vue new file mode 100644 index 000000000..b9509f9e2 --- /dev/null +++ b/webroot/src/components/Icons/Settings/Settings.vue @@ -0,0 +1,39 @@ + + + + + + diff --git a/webroot/src/components/Icons/Upload/Upload.less b/webroot/src/components/Icons/Upload/Upload.less new file mode 100644 index 000000000..9ec4d18f0 --- /dev/null +++ b/webroot/src/components/Icons/Upload/Upload.less @@ -0,0 +1,6 @@ +// +// Upload.less +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// diff --git a/webroot/src/components/Icons/Upload/Upload.spec.ts b/webroot/src/components/Icons/Upload/Upload.spec.ts new file mode 100644 index 000000000..53b265ff1 --- /dev/null +++ b/webroot/src/components/Icons/Upload/Upload.spec.ts @@ -0,0 +1,19 @@ +// +// Upload.spec.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import Upload from '@components/Icons/Upload/Upload.vue'; + +describe('Upload component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(Upload); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(Upload).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Icons/Upload/Upload.ts b/webroot/src/components/Icons/Upload/Upload.ts new file mode 100644 index 000000000..a6052043b --- /dev/null +++ b/webroot/src/components/Icons/Upload/Upload.ts @@ -0,0 +1,18 @@ +// +// Upload.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; + +@Component({ + name: 'Upload', +}) +class Upload extends Vue { +} + +export default toNative(Upload); + +// export default Upload; diff --git a/webroot/src/components/Icons/Upload/Upload.vue b/webroot/src/components/Icons/Upload/Upload.vue new file mode 100644 index 000000000..5e18e1384 --- /dev/null +++ b/webroot/src/components/Icons/Upload/Upload.vue @@ -0,0 +1,21 @@ + + + + + + diff --git a/webroot/src/components/Icons/Users/Users.less b/webroot/src/components/Icons/Users/Users.less new file mode 100644 index 000000000..5d6b8759d --- /dev/null +++ b/webroot/src/components/Icons/Users/Users.less @@ -0,0 +1,6 @@ +// +// Users.less +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// diff --git a/webroot/src/components/Icons/Users/Users.spec.ts b/webroot/src/components/Icons/Users/Users.spec.ts new file mode 100644 index 000000000..8abb18463 --- /dev/null +++ b/webroot/src/components/Icons/Users/Users.spec.ts @@ -0,0 +1,19 @@ +// +// Users.spec.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import Users from '@components/Icons/Users/Users.vue'; + +describe('Users component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(Users); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(Users).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Icons/Users/Users.ts b/webroot/src/components/Icons/Users/Users.ts new file mode 100644 index 000000000..4a1284c2d --- /dev/null +++ b/webroot/src/components/Icons/Users/Users.ts @@ -0,0 +1,18 @@ +// +// Users.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; + +@Component({ + name: 'Users', +}) +class Users extends Vue { +} + +export default toNative(Users); + +// export default Users; diff --git a/webroot/src/components/Icons/Users/Users.vue b/webroot/src/components/Icons/Users/Users.vue new file mode 100644 index 000000000..741bdc3de --- /dev/null +++ b/webroot/src/components/Icons/Users/Users.vue @@ -0,0 +1,24 @@ + + + + + + diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.less b/webroot/src/components/Licensee/LicenseeList/LicenseeList.less index f4acaccf5..ad348e5eb 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.less +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.less @@ -6,14 +6,13 @@ // .licensee-list-container { - background-color: @white; + background-color: transparent; .search-toggle-container { display: flex; flex-direction: column; flex-wrap: wrap; align-items: flex-end; - margin-bottom: 2.4rem; @media @desktopWidth { flex-direction: row; @@ -30,7 +29,7 @@ padding: 0.4rem 1rem; border-radius: @borderRadiusPillShape; color: @white; - background-color: @primaryColor; + background-color: @darkBlue; @media @desktopWidth { margin-right: 2.4rem; diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts b/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts index f1c92a722..3ccdc5df8 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts @@ -113,7 +113,6 @@ describe('LicenseeList component', async () => { const requestConfig = await component.fetchListData(); expect(requestConfig).to.matchPattern({ - compact: undefined, jurisdiction: undefined, licenseeFirstName: undefined, licenseeLastName: undefined, diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue b/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue index 0e9ba2cef..6019c3c0b 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue @@ -7,32 +7,32 @@