From 1454c9cb2a400fe7a00f908243cc6415fd46ead8 Mon Sep 17 00:00:00 2001 From: jasonchung1871 <101672465+jasonchung1871@users.noreply.github.com> Date: Wed, 14 Feb 2024 10:23:08 -0800 Subject: [PATCH 01/14] Update action.yaml (#1284) added missing shell --- .github/actions/build-push-container/action.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/build-push-container/action.yaml b/.github/actions/build-push-container/action.yaml index 3306d76e3..20c17a090 100644 --- a/.github/actions/build-push-container/action.yaml +++ b/.github/actions/build-push-container/action.yaml @@ -78,6 +78,7 @@ runs: if: ${{ inputs.ref == '' }} - name: Set variables + shell: bash run: | echo "SHA=sha-$(git rev-parse --short HEAD)" >> $GITHUB_ENV echo "IMAGE_REVISION=$(git rev-parse HEAD)" >> $GITHUB_ENV From 4ce2fae29a3a896963997a519e545283f822896c Mon Sep 17 00:00:00 2001 From: jasonchung1871 <101672465+jasonchung1871@users.noreply.github.com> Date: Wed, 14 Feb 2024 10:33:37 -0800 Subject: [PATCH 02/14] Update action.yaml (#1285) fixing the raw tags when generating the build --- .github/actions/build-push-container/action.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/actions/build-push-container/action.yaml b/.github/actions/build-push-container/action.yaml index 20c17a090..59dcde5b3 100644 --- a/.github/actions/build-push-container/action.yaml +++ b/.github/actions/build-push-container/action.yaml @@ -149,9 +149,8 @@ runs: latest=true # Creates tags based off of branch names and semver tags tags: | - type=raw,value=ghcr.io/${{ env.GH_USERNAME }}/${{ inputs.image_name }}:${{ env.IMAGE_VERSION }} - type=raw,value=ghcr.io/${{ env.GH_USERNAME }}/${{ inputs.image_name }}:${{ env.SHA }} - type=raw,value=ghcr.io/${{ env.GH_USERNAME }}/${{ inputs.image_name }}:latest + type=raw,value=${{ env.IMAGE_VERSION }} + type=raw,value=${{ env.SHA }} labels: | org.opencontainers.image.revision=${{ env.IMAGE_REVISION }} org.opencontainers.image.version=${{ env.IMAGE_VERSION }} From f6fe95995232553a7cc640c85a645888379a025a Mon Sep 17 00:00:00 2001 From: jasonchung1871 <101672465+jasonchung1871@users.noreply.github.com> Date: Wed, 14 Feb 2024 11:01:25 -0800 Subject: [PATCH 03/14] Update .deploy.yaml (#1286) adds the pr number to the github comment when deploying --- .github/workflows/.deploy.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/.deploy.yaml b/.github/workflows/.deploy.yaml index 1fed1c4e1..a50ad476e 100644 --- a/.github/workflows/.deploy.yaml +++ b/.github/workflows/.deploy.yaml @@ -135,4 +135,5 @@ jobs: with: header: release message: | - Release ${{ github.sha }} deployed at \ No newline at end of file + Release ${{ github.sha }} deployed at + number: github.event.inputs.pr-number \ No newline at end of file From 8fa20283310bf1a0974f87b50fe724ac17cedfe5 Mon Sep 17 00:00:00 2001 From: jasonchung1871 <101672465+jasonchung1871@users.noreply.github.com> Date: Wed, 14 Feb 2024 11:17:49 -0800 Subject: [PATCH 04/14] pr number was not injected (#1287) pr number was not injected because the syntax was not correct --- .github/workflows/.deploy.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/.deploy.yaml b/.github/workflows/.deploy.yaml index a50ad476e..0bab229c2 100644 --- a/.github/workflows/.deploy.yaml +++ b/.github/workflows/.deploy.yaml @@ -131,9 +131,9 @@ jobs: ref: ${{ needs.set-vars.outputs.ref }} - name: Release Comment on PR uses: marocchino/sticky-pull-request-comment@v2 - if: github.event.inputs.pr-number != '' && success() + if: ${{ github.event.inputs.pr-number }} != '' && success() with: header: release message: | Release ${{ github.sha }} deployed at - number: github.event.inputs.pr-number \ No newline at end of file + number: ${{ github.event.inputs.pr-number }} \ No newline at end of file From 65579340eb16842a46f65518a2700359ab7f181a Mon Sep 17 00:00:00 2001 From: Walter Moar Date: Tue, 20 Feb 2024 08:17:07 -0800 Subject: [PATCH 05/14] refactor: FORMS-965 user access cleanup (#1263) * refactor: FORMS-965 user access cleanup * try to fix code climate complaints * randomize to fix code climate complaints * move token locations into the getters * documentation consistency * added a todo in the tests for fixing status code * documentation clarification --- app/src/forms/auth/middleware/userAccess.js | 72 ++++-- .../forms/auth/middleware/userAccess.spec.js | 227 ++++++++++++------ 2 files changed, 208 insertions(+), 91 deletions(-) diff --git a/app/src/forms/auth/middleware/userAccess.js b/app/src/forms/auth/middleware/userAccess.js index 1f622cdbb..ea8296075 100644 --- a/app/src/forms/auth/middleware/userAccess.js +++ b/app/src/forms/auth/middleware/userAccess.js @@ -7,38 +7,66 @@ const Roles = require('../../common/constants').Roles; const service = require('../service'); const rbacService = require('../../rbac/service'); -const getToken = (req) => { - try { - return req.kauth.grant.access_token; - } catch (err) { - return null; +/** + * Gets the access token, if it exists, from the request. + * + * @param {*} req the Express object representing the HTTP request. + * @returns a string that is the access token, or undefined if it doesn't exist. + */ +const _getAccessToken = (req) => { + return req.kauth?.grant?.access_token; +}; + +/** + * Gets the bearer token, if it exists, from the request. + * + * @param {*} req the Express object representing the HTTP request. + * @returns a string that is the bearer token, or undefined if it doesn't exist. + */ +const _getBearerToken = (req) => { + const authorization = req.headers?.authorization; + + let token; + if (authorization && authorization.startsWith('Bearer ')) { + token = authorization.substring(7); } + + return token; }; -const setUser = async (req, _res, next) => { +/** + * Express middleware that adds the user information as the res.currentUser + * attribute so that all downstream middleware and business logic can use it. + * + * This will fall through if everything is OK. If the Bearer auth is not valid, + * this will produce a 403 error. + * + * @param {*} req the Express object representing the HTTP request. + * @param {*} _res the Express object representing the HTTP response - unused. + * @param {*} next the Express chaining function. + */ +const currentUser = async (req, _res, next) => { try { - const token = getToken(req); - req.currentUser = await service.login(token); + // Validate bearer tokens before anything else - failure means no access. + const bearerToken = _getBearerToken(req); + if (bearerToken) { + const ok = await keycloak.grantManager.validateAccessToken(bearerToken); + if (!ok) { + throw new Problem(403, { detail: 'Authorization token is invalid.' }); + } + } + + // Add the request element that contains the current user's parsed info. It + // is ok if the access token isn't defined: then we'll have a public user. + const accessToken = _getAccessToken(req); + req.currentUser = await service.login(accessToken); + next(); } catch (error) { next(error); } }; -const currentUser = async (req, res, next) => { - // Check if authorization header is a bearer token - if (req.headers && req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) { - // need to check keycloak, ensure the bearer token is valid - const token = req.headers.authorization.substring(7); - const ok = await keycloak.grantManager.validateAccessToken(token); - if (!ok) { - return new Problem(403, { detail: 'Authorization token is invalid.' }).send(res); - } - } - - return setUser(req, res, next); -}; - const _getForm = async (currentUser, formId) => { const forms = await service.getUserForms(currentUser, { active: true, formId: formId }); let form = forms.find((f) => f.formId === formId); diff --git a/app/tests/unit/forms/auth/middleware/userAccess.spec.js b/app/tests/unit/forms/auth/middleware/userAccess.spec.js index a3bbd5f75..9e8c70a41 100644 --- a/app/tests/unit/forms/auth/middleware/userAccess.spec.js +++ b/app/tests/unit/forms/auth/middleware/userAccess.spec.js @@ -7,12 +7,6 @@ const keycloak = require('../../../../../src/components/keycloak'); const service = require('../../../../../src/forms/auth/service'); const rbacService = require('../../../../../src/forms/rbac/service'); -const kauth = { - grant: { - access_token: 'fsdfhsd08f0283hr', - }, -}; - const userId = 'c6455376-382c-439d-a811-0381a012d695'; const userId2 = 'c6455376-382c-439d-a811-0381a012d696'; const formId = 'c6455376-382c-439d-a811-0381a012d697'; @@ -25,13 +19,6 @@ const Roles = { FORM_SUBMITTER: 'form_submitter', }; -// Mock the token validation in the KC lib -keycloak.grantManager.validateAccessToken = jest.fn().mockReturnValue('yeah ok'); - -// Mock the service login -const mockUser = { user: 'me' }; -service.login = jest.fn().mockReturnValue(mockUser); - const testRes = { writeHead: jest.fn(), end: jest.fn(), @@ -41,94 +28,196 @@ afterEach(() => { jest.clearAllMocks(); }); +// External dependencies used by the implementation are: +// - keycloak.grantmanager.validateAccessToken: to validate a Bearer token +// - service.login: to create the object for req.currentUser +// describe('currentUser', () => { - it('gets the current user with valid request', async () => { - const testReq = { - params: { - formId: 2, + // Default mock of the token validation in the KC lib + keycloak.grantManager.validateAccessToken = jest.fn().mockReturnValue('yeah ok'); + + // Default mock of the service login + const mockUser = { user: 'me' }; + service.login = jest.fn().mockReturnValue(mockUser); + + // Keycloak info to be used in request headers + const kauth = { + grant: { + // Static analyzers will complain about hard-coded tokens - randomize. + access_token: Math.random().toString(36).substring(2), + }, + }; + + // Bearer token and its authorization header + const bearerToken = Math.random().toString(36).substring(2); + const authorizationHeader = { authorization: 'Bearer ' + bearerToken }; + + // TODO: Shouldn't this be a 401? + it('403s if the bearer token is invalid', async () => { + keycloak.grantManager.validateAccessToken.mockReturnValueOnce(false); + const req = getMockReq({ + headers: { + ...authorizationHeader, }, + }); + const { res, next } = getMockRes(); + + await currentUser(req, res, next); + + expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(1); + expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledWith(bearerToken); + expect(service.login).toHaveBeenCalledTimes(0); + expect(req.currentUser).toEqual(undefined); + expect(next).toHaveBeenCalledTimes(1); + expect.objectContaining({ status: 403 }); + }); + + it('passes on the error if the service login fails unexpectedly', async () => { + service.login.mockRejectedValueOnce(new Error()); + const req = getMockReq({ headers: { - authorization: 'Bearer hjvds0uds', + ...authorizationHeader, }, - kauth: kauth, - }; + }); + const { res, next } = getMockRes(); - const nxt = jest.fn(); + await currentUser(req, res, next); - await currentUser(testReq, testRes, nxt); expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(1); - expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledWith('hjvds0uds'); + expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledWith(bearerToken); expect(service.login).toHaveBeenCalledTimes(1); - expect(service.login).toHaveBeenCalledWith(kauth.grant.access_token); - expect(testReq.currentUser).toEqual(mockUser); - expect(nxt).toHaveBeenCalledTimes(1); - expect(nxt).toHaveBeenCalledWith(); + expect(req.currentUser).toEqual(undefined); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(expect.any(Error)); }); - it('prioritizes the url param if both url and query are provided', async () => { - const testReq = { - params: { - formId: 2, - }, - query: { - formId: 99, + it('gets the current user with no bearer token', async () => { + const req = getMockReq({}); + const { res, next } = getMockRes(); + + await currentUser(req, res, next); + + expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(0); + expect(service.login).toHaveBeenCalledTimes(1); + expect(service.login).toHaveBeenCalledWith(undefined); + expect(req.currentUser).toEqual(mockUser); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); + }); + + it('does not keycloak validate with basic auth header', async () => { + const req = getMockReq({ + headers: { + authorization: 'Basic XYZ', }, + }); + const { res, next } = getMockRes(); + + await currentUser(req, res, next); + + expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(0); + expect(req.currentUser).toEqual(mockUser); + expect(service.login).toHaveBeenCalledTimes(1); + expect(service.login).toHaveBeenCalledWith(undefined); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); + }); + + it('does not keycloak validate with unexpected auth header', async () => { + const req = getMockReq({ headers: { - authorization: 'Bearer hjvds0uds', + authorization: Math.random().toString(36).substring(2), }, - kauth: kauth, - }; + }); + const { res, next } = getMockRes(); - await currentUser(testReq, testRes, jest.fn()); - expect(service.login).toHaveBeenCalledWith(kauth.grant.access_token); + await currentUser(req, res, next); + + expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(0); + expect(req.currentUser).toEqual(mockUser); + expect(service.login).toHaveBeenCalledTimes(1); + expect(service.login).toHaveBeenCalledWith(undefined); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); }); - it('uses the query param if both if that is whats provided', async () => { - const testReq = { - query: { - formId: 99, + it('does handle missing kauth attribute', async () => { + const req = getMockReq({ + headers: { + ...authorizationHeader, }, + }); + const { res, next } = getMockRes(); + + await currentUser(req, res, next); + + expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(1); + expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledWith(bearerToken); + expect(service.login).toHaveBeenCalledTimes(1); + expect(service.login).toHaveBeenCalledWith(undefined); + expect(req.currentUser).toEqual(mockUser); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); + }); + + it('does handle missing kauth.grant attribute', async () => { + const req = getMockReq({ headers: { - authorization: 'Bearer hjvds0uds', + ...authorizationHeader, }, - kauth: kauth, - }; + kauth: {}, + }); + const { res, next } = getMockRes(); - await currentUser(testReq, testRes, jest.fn()); - expect(service.login).toHaveBeenCalledWith(kauth.grant.access_token); + await currentUser(req, res, next); + + expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(1); + expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledWith(bearerToken); + expect(service.login).toHaveBeenCalledTimes(1); + expect(service.login).toHaveBeenCalledWith(undefined); + expect(req.currentUser).toEqual(mockUser); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); }); - it('403s if the token is invalid', async () => { - const testReq = { + it('does handle missing kauth.grant.access_token attribute', async () => { + const req = getMockReq({ headers: { - authorization: 'Bearer hjvds0uds', + ...authorizationHeader, }, - }; + kauth: { grant: {} }, + }); + const { res, next } = getMockRes(); - const nxt = jest.fn(); - keycloak.grantManager.validateAccessToken = jest.fn().mockReturnValue(undefined); + await currentUser(req, res, next); - await currentUser(testReq, testRes, nxt); expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(1); - expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledWith('hjvds0uds'); - expect(service.login).toHaveBeenCalledTimes(0); - expect(testReq.currentUser).toEqual(undefined); - expect(nxt).toHaveBeenCalledTimes(0); - // expect(nxt).toHaveBeenCalledWith(new Problem(403, { detail: 'Authorization token is invalid.' })); + expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledWith(bearerToken); + expect(service.login).toHaveBeenCalledTimes(1); + expect(service.login).toHaveBeenCalledWith(undefined); + expect(req.currentUser).toEqual(mockUser); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); }); -}); -describe('getToken', () => { - it('returns a null token if no kauth in the request', async () => { - const testReq = { - params: { - formId: 2, + it('does keycloak validate with bearer token', async () => { + const req = getMockReq({ + headers: { + ...authorizationHeader, }, - }; + kauth: kauth, + }); + const { res, next } = getMockRes(); + + await currentUser(req, res, next); - await currentUser(testReq, testRes, jest.fn()); + expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(1); + expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledWith(bearerToken); expect(service.login).toHaveBeenCalledTimes(1); - expect(service.login).toHaveBeenCalledWith(null); + expect(service.login).toHaveBeenCalledWith(kauth.grant.access_token); + expect(req.currentUser).toEqual(mockUser); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); }); }); From 6a3ff2d015401c4c54f2e13c650a89813fc9a1d0 Mon Sep 17 00:00:00 2001 From: jasonchung1871 <101672465+jasonchung1871@users.noreply.github.com> Date: Tue, 20 Feb 2024 09:28:05 -0800 Subject: [PATCH 06/14] restores the old workflow for deploying to dev/test/prod (#1289) restores the old workflow for promoting builds up to production. --- .github/workflows/.deploy.yaml | 22 ++----- .github/workflows/on_push.yaml | 115 +++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/on_push.yaml diff --git a/.github/workflows/.deploy.yaml b/.github/workflows/.deploy.yaml index 0bab229c2..02ec935e7 100644 --- a/.github/workflows/.deploy.yaml +++ b/.github/workflows/.deploy.yaml @@ -10,15 +10,6 @@ on: description: Pull request number, leave blank for dev/test/prod deployment required: false type: string - environment: - description: Environment name; choose dev for PR - required: true - type: choice - options: - - dev - - test - - prod - default: dev concurrency: group: ${{ github.workflow }}-${{ github.event.inputs.pr-number || github.ref }} @@ -30,7 +21,6 @@ jobs: runs-on: ubuntu-latest outputs: APP_TITLE: ${{ steps.vars.outputs.APP_TITLE }} - ENVIRONMENT: ${{ steps.vars.outputs.ENVIRONMENT }} JOB_NAME: ${{ steps.vars.outputs.JOB_NAME }} ROUTE_PATH: ${{ steps.vars.outputs.ROUTE_PATH }} URL: ${{ steps.vars.outputs.URL }} @@ -40,14 +30,12 @@ jobs: id: default-vars env: PR_NUMBER: ${{ github.event.inputs.pr-number }} - ENVIRONMENT: ${{ github.event.inputs.environment }} ACRONYM: ${{ env.ACRONYM }} run: | echo "APP_TITLE=Common Hosted Forms" >> "$GITHUB_OUTPUT" - echo "ENVIRONMENT=$ENVIRONMENT" >> "$GITHUB_OUTPUT" echo "JOB_NAME=master" >> "$GITHUB_OUTPUT" echo "ROUTE_PATH=/app" >> "$GITHUB_OUTPUT" - echo "URL=https://$ACRONYM-$ENVIRONMENT.apps.silver.devops.gov.bc.ca" >> "$GITHUB_OUTPUT" + echo "URL=https://$ACRONYM-dev.apps.silver.devops.gov.bc.ca" >> "$GITHUB_OUTPUT" - name: Final variables id: vars env: @@ -58,13 +46,11 @@ jobs: echo "ref=$REF" >> $GITHUB_OUTPUT if [[ "$PR_NUMBER" != '' ]]; then echo "APP_TITLE=${{ steps.default-vars.outputs.APP_TITLE }} - PR-$PR_NUMBER" >> "$GITHUB_OUTPUT" - echo "ENVIRONMENT=pr" >> "$GITHUB_OUTPUT" echo "JOB_NAME=pr-$PR_NUMBER" >> "$GITHUB_OUTPUT" echo "ROUTE_PATH=/pr-$PR_NUMBER" >> "$GITHUB_OUTPUT" echo "URL=${{ steps.default-vars.outputs.URL }}/pr-$PR_NUMBER" >> "$GITHUB_OUTPUT" else echo "APP_TITLE=${{ steps.default-vars.outputs.APP_TITLE }}" >> "$GITHUB_OUTPUT" - echo "ENVIRONMENT=${{ steps.default-vars.outputs.ENVIRONMENT }}" >> "$GITHUB_OUTPUT" echo "JOB_NAME=${{ steps.default-vars.outputs.JOB_NAME }}" >> "$GITHUB_OUTPUT" echo "ROUTE_PATH=${{ steps.default-vars.outputs.ROUTE_PATH }}" >> "$GITHUB_OUTPUT" echo "URL=${{ steps.default-vars.outputs.URL }}/app" >> "$GITHUB_OUTPUT" @@ -100,7 +86,7 @@ jobs: deploy: name: Deploys to selected environment environment: - name: ${{ needs.set-vars.outputs.ENVIRONMENT }} + name: pr url: ${{ needs.set-vars.outputs.URL }} runs-on: ubuntu-latest needs: [set-vars, build] @@ -119,10 +105,10 @@ jobs: with: app_name: ${{ vars.APP_NAME }} acronym: ${{ env.ACRONYM }} - environment: ${{ needs.set-vars.outputs.ENVIRONMENT }} + environment: pr job_name: ${{ needs.set-vars.outputs.JOB_NAME }} namespace_prefix: ${{ vars.NAMESPACE_PREFIX }} - namespace_environment: ${{ github.event.inputs.environment }} + namespace_environment: dev openshift_server: ${{ secrets.OPENSHIFT_SERVER }} openshift_token: ${{ secrets.OPENSHIFT_TOKEN }} server_host: ${{ vars.SERVER_HOST }} diff --git a/.github/workflows/on_push.yaml b/.github/workflows/on_push.yaml new file mode 100644 index 000000000..ecddda514 --- /dev/null +++ b/.github/workflows/on_push.yaml @@ -0,0 +1,115 @@ +name: Push + +env: + ACRONYM: chefs + +on: + push: + branches: + - main + tags: + - v*.*.* + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build & Push + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Build & Push + uses: ./.github/actions/build-push-container + with: + context: . + image_name: ${{ vars.APP_NAME }} + github_username: ${{ github.repository_owner }} + github_token: ${{ secrets.GITHUB_TOKEN }} + app_contact: ${{ secrets.VITE_CONTACT }} + + deploy-dev: + name: Deploy to Dev + environment: + name: dev + url: https://${{ env.ACRONYM }}-dev.apps.silver.devops.gov.bc.ca/app + runs-on: ubuntu-latest + needs: build + timeout-minutes: 12 + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Deploy to Dev + uses: ./.github/actions/deploy-to-environment + with: + app_name: ${{ vars.APP_NAME }} + acronym: ${{ env.ACRONYM }} + environment: dev + job_name: master + namespace_prefix: ${{ vars.NAMESPACE_PREFIX }} + namespace_environment: dev + openshift_server: ${{ secrets.OPENSHIFT_SERVER }} + openshift_token: ${{ secrets.OPENSHIFT_TOKEN }} + server_host: ${{ vars.SERVER_HOST }} + route_path: /app + route_prefix: ${{ vars.ROUTE_PREFIX }} + + deploy-test: + name: Deploy to Test + environment: + name: test + url: https://${{ env.ACRONYM }}-test.apps.silver.devops.gov.bc.ca/app + runs-on: ubuntu-latest + needs: + - build + - deploy-dev + timeout-minutes: 12 + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Deploy to Test + uses: ./.github/actions/deploy-to-environment + with: + app_name: ${{ vars.APP_NAME }} + acronym: ${{ env.ACRONYM }} + environment: test + job_name: master + namespace_prefix: ${{ vars.NAMESPACE_PREFIX }} + namespace_environment: test + openshift_server: ${{ secrets.OPENSHIFT_SERVER }} + openshift_token: ${{ secrets.OPENSHIFT_TOKEN }} + server_host: ${{ vars.SERVER_HOST }} + route_path: /app + route_prefix: ${{ vars.ROUTE_PREFIX }} + + deploy-prod: + name: Deploy to Prod + environment: + name: prod + url: https://${{ env.ACRONYM }}.apps.silver.devops.gov.bc.ca/app + runs-on: ubuntu-latest + needs: + - build + - deploy-dev + - deploy-test + timeout-minutes: 12 + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Deploy to Prod + uses: ./.github/actions/deploy-to-environment + with: + app_name: ${{ vars.APP_NAME }} + acronym: ${{ env.ACRONYM }} + environment: prod + job_name: master + namespace_prefix: ${{ vars.NAMESPACE_PREFIX }} + namespace_environment: prod + openshift_server: ${{ secrets.OPENSHIFT_SERVER }} + openshift_token: ${{ secrets.OPENSHIFT_TOKEN }} + server_host: ${{ vars.SERVER_HOST }} + route_path: /app + route_prefix: ${{ vars.ROUTE_PREFIX }} From f5b83e11e771640a3c04ef32ccc8578c21c433e7 Mon Sep 17 00:00:00 2001 From: Walter Moar Date: Tue, 20 Feb 2024 10:43:48 -0800 Subject: [PATCH 07/14] chore/FORMS-998 devcontainers tweaks (#1288) * chore/FORMS-998 devcontainers tweaks * rollback formatting for unchanged file * fix the vscode settings --- .devcontainer/Dockerfile | 5 +++ .devcontainer/README.md | 53 ++++++++++++++----------- .devcontainer/devcontainer.json | 70 ++++++++++++++++++++++----------- vetur.config.js | 10 ----- 4 files changed, 82 insertions(+), 56 deletions(-) delete mode 100644 vetur.config.js diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f7448abe2..798ffa7da 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -3,3 +3,8 @@ FROM node:${VARIANT} # not much in here, could acheive this another way for sure... # but this allows us a prepared place to add other things to the container OS. + +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + # For interactive git rebases + vim diff --git a/.devcontainer/README.md b/.devcontainer/README.md index fc50bfc97..223a6b7d9 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -1,8 +1,9 @@ # CHEFS Development with Dev Container -The following guide will get you up and running and developing/debugging CHEFS as quickly as possible. + +The following guide will get you up and running and developing/debugging CHEFS as quickly as possible. We provide a [`devcontainer`](https://containers.dev) and will use [`VS Code`](https://code.visualstudio.com) to illustrate. -By no means is CHEFS development limited to these tools; they are merely examples. +By no means is CHEFS development limited to these tools; they are merely examples. ## Caveats @@ -11,6 +12,7 @@ The primary use case for this `devcontainer` is for developing, debugging and un There are limitations running this devcontainer, such as all networking is within this container. This container has [docker-in-docker](https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/docker-in-docker.md) which allows running demos, building docker images, running `docker compose` all within this container. ## Files + The `.devcontainer` folder contains the `devcontainer.json` file which defines this container. We are using a `Dockerfile` and `post-install.sh` to build and configure the container run image. The `Dockerfile` is simple but in place for simplifying image enhancements. The `post-install.sh` will install the required node libraries for CHEFS including the frontend and formio components. In order to run CHEFS you require Keycloak (configured), Postgresql (seeded) and the CHEFS backend/API and frontend/UX. Previously, this was a series of downloads and configuration updates and numerous commands to run. See `.devcontainer/chefs_local` files. @@ -19,42 +21,46 @@ Also included are convenient launch tasks to run and debug CHEFS. ## Open CHEFS in the devcontainer -To open CHEFS in a devcontainer, we open the *root* of this repository. We can open in 2 ways: +To open CHEFS in a devcontainer, we open the _root_ of this repository. We can open in 2 ways: 1. Open Visual Studio Code, and use the Command Palette and use `Dev Containers: Open Folder in Container...` 2. Open Visual Studio Code and `File|Open Folder...`, you should be prompted to `Reopen in Container`. - ## Running CHEFS locally + Keycloak and Postgresql will be launched using docker compose. These will run inside of the devcontainer (docker-in-docker) but the ports are forwarded to the host machine and are accessible on the local host. CHEFS API and Frontend are running as node applications on the devcontainer - again, ports are forwarded to the host. ### Configuring CHEFS locally -When the devcontainer is built, it copies `.devcontainer/chefs_local/local.json.sample` and `.devcontainer/chefs_local/realm-export.json.sample` to `.devcontainer/chefs_local/local.json` and `.devcontainer/chefs_local/realm-export.json` respectively. These copies are not checked in and allow the developer to make changes and tweaks without impacting other developers or accidentially committing passwords. + +When the devcontainer is built, it copies `.devcontainer/chefs_local/local.json.sample` and `.devcontainer/chefs_local/realm-export.json.sample` to `.devcontainer/chefs_local/local.json` and `.devcontainer/chefs_local/realm-export.json` respectively. These copies are not checked in and allow the developer to make changes and tweaks without impacting other developers or accidentially committing passwords. ### Authorization Prerequisites -1. An IDIR account is required to access CHEFS. -2. Request an SSO Integration from the Common Hosted Single Sign-on (CSS) page in order to obtain a resource and secret that will be used for authentication when building CHEFS. View the [detailed documentation](https://bcdevex.atlassian.net/wiki/spaces/CCP/pages/961675282) about requesting the Pathfinder SSO integration. -3. Open realm-export.json located at chefs_build/docker/imports/keycloak and search for `XXXXXXXXXXXX`. This value must match the `clientSecret` value in `local.json` so that the CHEFS API can connect to your Keycloak instance. By default, these are set to be equal and don’t need to be altered. -4. Navigate to the CSS page, login with your IDIR, and download the ‘Development’ Installation JSON from your SSO Integration. -5. Back in the `realm-export.json` file, search for all instances of `YYYYYYYYYYYY` and replace it with the `resource` you obtained from the downloaded JSON file. Search for all instances of `ZZZZZZZZZZZZ` and replace it with the `secret`. + +1. An IDIR account is required to access CHEFS. +2. Request an SSO Integration from the Common Hosted Single Sign-on (CSS) page in order to obtain a resource and secret that will be used for authentication when building CHEFS. View the [detailed documentation](https://bcdevex.atlassian.net/wiki/spaces/CCP/pages/961675282) about requesting the Pathfinder SSO integration. +3. Open realm-export.json located at build/docker/imports/keycloak and search for `XXXXXXXXXXXX`. This value must match the `clientSecret` value in `local.json` so that the CHEFS API can connect to your Keycloak instance. By default, these are set to be equal and don’t need to be altered. +4. Navigate to the CSS page, login with your IDIR, and download the ‘Development’ Installation JSON from your SSO Integration. +5. Back in the `realm-export.json` file, search for all instances of `YYYYYYYYYYYY` and replace it with the `resource` you obtained from the downloaded JSON file. Search for all instances of `ZZZZZZZZZZZZ` and replace it with the `secret`. ### Run/Debug -1. start Keycloak and Postgresql. Many ways to start... - - right click on `.devcontainer/chefs_local/docker-compose.yml` and select `Compose up` - - or use command palette `Docker: Compose Up` then select `.devcontainer/chefs_local/docker-compose.yml` - - or `Terminal | Run Task...|chefs_local up` + +1. start Keycloak and Postgresql. Many ways to start... + - right click on `.devcontainer/chefs_local/docker-compose.yml` and select `Compose up` + - or use command palette `Docker: Compose Up` then select `.devcontainer/chefs_local/docker-compose.yml` + - or `Terminal | Run Task...|chefs_local up` 2. start CHEFS - - Run and Debug, select 'CHEFS' which will start both the API and the frontend. + - Run and Debug, select 'CHEFS' which will start both the API and the frontend. 3. debug Frontend with Chrome - - Run and Debug, select 'CHEFS Frontend - chrome' which will start a Chrome browser against the frontend, will allow breakpoints in `/app/frontend/src` -4. stop Keycloak and Postgresql. Many ways to stop... - - right click on `.devcontainer/chefs_local/docker-compose.yml` and select `Compose down` - - or use command palette `Docker: Compose Down` then select `.devcontainer/chefs_local/docker-compose.yml` - - or `Terminal | Run Task...|chefs_local down` + - Run and Debug, select 'CHEFS Frontend - chrome' which will start a Chrome browser against the frontend, will allow breakpoints in `/app/frontend/src` +4. stop Keycloak and Postgresql. Many ways to stop... + - right click on `.devcontainer/chefs_local/docker-compose.yml` and select `Compose down` + - or use command palette `Docker: Compose Down` then select `.devcontainer/chefs_local/docker-compose.yml` + - or `Terminal | Run Task...|chefs_local down` + +_Notes_ -*Notes* - `CHEFS Frontend` launch configuration is using the `chefs-frontend-local` client in Keycloak, not `chefs-frontend` client as we do in production. - `CHEFS API` will use the configuration found at `.devcontainer/chefs_local/local.json` - `Postgres DB`: localhost:5432 @@ -63,12 +69,15 @@ When the devcontainer is built, it copies `.devcontainer/chefs_local/local.json. - `CHEFS API`: http://localhost:5173/app/api/v1 ## Formio Components + If you are developing the formio components, you should build and redeploy them before running your local debug instances of CHEFS. Use tasks `Components build` and `Components Deploy`. ## Troubleshooting + All development machines are unique and here we will document problems that have been encountered and how to fix them. ### Failure during load of devcontainer when running webpack (Segmentation Fault) + Encountered on Mac Ventura 13.6, with Mac Docker Desktop 4.26.1 when running `npm run build:formio` on load, we hit a `Segmentation Fault`. The issue was resolved when turning off the virtualization settings in Docker Desktop. -Under Settings, select `gRPC Fuse` instead of `VirtioFS` then unselect `Use Virtualization framework`. Restart Docker and VS Code. \ No newline at end of file +Under Settings, select `gRPC Fuse` instead of `VirtioFS` then unselect `Use Virtualization framework`. Restart Docker and VS Code. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 84375ad6d..6f7cebd58 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,33 +1,55 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the { - "name": "common-hosted-form-service", - - "build": { - "dockerfile": "Dockerfile", - "context": "..", - "args": { - "VARIANT": "18.18.2-bullseye" - } - }, + "name": "common-hosted-form-service", - "features": { - "ghcr.io/devcontainers/features/docker-in-docker:2": {} - }, + "build": { + "dockerfile": "Dockerfile", + "context": "..", + "args": { + "VARIANT": "18.18.2-bullseye" + } + }, - // Use this environment variable if you need to bind mount your local source code into a new container. - "remoteEnv": { - "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" - }, + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {} + }, - // Use 'forwardPorts' to make a list of ports inside the container available locally. - "forwardPorts": [8082, 8081, 8080, 5432, 5173], + // Use this environment variable if you need to bind mount your local source code into a new container. + "remoteEnv": { + "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" + }, - // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "bash ./.devcontainer/post-install.sh", + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [ + 5173, // CHEFS Frontend + 5432, // PostgreSQL + 8080, // CHEFS Backend + 8081, + 8082 // Keycloak + ], - // Configure tool-specific properties. - // "customizations": {}, + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "bash ./.devcontainer/post-install.sh", - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - //"remoteUser": "root" + // Configure tool-specific properties. + "customizations": { + "vscode": { + "extensions": [ + "cweijan.vscode-postgresql-client2", // PostgreSQL client + "dbaeumer.vscode-eslint", // ESLint to catch problems early + "esbenp.prettier-vscode", // Prettier to format files on save + "postman.postman-for-vscode", // Postman for integration tests + "redocly.openapi-vs-code", // ReDocly to catch OpenAPI errors + "vue.volar" // Vue 3 recommended extension + ], + "settings": { + "database-client.telemetry.usesOnlineServices": false, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + } + } + } + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + //"remoteUser": "root" } diff --git a/vetur.config.js b/vetur.config.js deleted file mode 100644 index 8beed2455..000000000 --- a/vetur.config.js +++ /dev/null @@ -1,10 +0,0 @@ -/** @type {import('vls').VeturConfig} */ -module.exports = { - settings: { - 'vetur.useWorkspaceDependencies': true, - 'vetur.experimental.templateInterpolationService': true - }, - projects: [ - './app/frontend' - ] -}; From 378012cc4fb8cbeb1e441af4b4ff692400d00c01 Mon Sep 17 00:00:00 2001 From: Vijaivir Dhaliwal <91633223+vijaivir@users.noreply.github.com> Date: Tue, 20 Feb 2024 15:51:57 -0800 Subject: [PATCH 08/14] feat: Frontend component for GET /files API (#1253) * feat: Frontend component for GET /files API (#1252) * add files-api-access migration file * added new filesApiAccess endpoint * remove console statements * add translations * add unit tests * fix unit test * update api documentation (#1255) * add files-api-access migration file * added new filesApiAccess endpoint * remove console statements * add translations * add unit tests * fix unit test * update api docs * GET /files Update: Passing variable from frontend (#1277) * add files-api-access migration file * added new filesApiAccess endpoint * remove console statements * add translations * add unit tests * fix unit test * update api docs * passing filesApiAccess variable from frontend instead of toggling it * update documentation and added boolean validation (#1278) * add files-api-access migration file * added new filesApiAccess endpoint * remove console statements * add translations * add unit tests * fix unit test * update api docs * passing filesApiAccess variable from frontend instead of toggling it * added check for boolean value and updated API docs * fix spacing * Update v1.api-spec.yaml * Update error code from 422 to 400 * Update v1.api-spec.yaml --------- Co-authored-by: jasonchung1871 <101672465+jasonchung1871@users.noreply.github.com> --- .../src/components/forms/manage/ApiKey.vue | 24 +++++++++++++ .../trans/chefs/ar/ar.json | 4 ++- .../trans/chefs/de/de.json | 4 ++- .../trans/chefs/en/en.json | 4 ++- .../trans/chefs/es/es.json | 4 ++- .../trans/chefs/fa/fa.json | 4 ++- .../trans/chefs/fr/fr.json | 4 ++- .../trans/chefs/hi/hi.json | 4 ++- .../trans/chefs/it/it.json | 4 ++- .../trans/chefs/ja/ja.json | 4 ++- .../trans/chefs/ko/ko.json | 4 ++- .../trans/chefs/pa/pa.json | 4 ++- .../trans/chefs/pt/pt.json | 4 ++- .../trans/chefs/ru/ru.json | 4 ++- .../trans/chefs/tl/tl.json | 4 ++- .../trans/chefs/uk/uk.json | 4 ++- .../trans/chefs/vi/vi.json | 4 ++- .../trans/chefs/zh/zh.json | 4 ++- .../trans/chefs/zhTW/zh-TW.json | 4 ++- app/frontend/src/services/apiKeyService.js | 13 +++++++ app/frontend/src/store/form.js | 20 +++++++++++ app/frontend/src/utils/constants.js | 1 + .../tests/unit/utils/constants.spec.js | 1 + .../20240115201832_files-api-access.js | 21 ++++++++++++ app/src/docs/v1.api-spec.yaml | 29 ++++++++++++++++ app/src/forms/auth/middleware/apiAccess.js | 7 ++-- .../forms/common/models/tables/formApiKey.js | 1 + app/src/forms/form/controller.js | 8 +++++ app/src/forms/form/routes.js | 4 +++ app/src/forms/form/service.js | 29 ++++++++++++++++ .../forms/auth/middleware/apiAccess.spec.js | 34 +++++++++++++++++++ 31 files changed, 243 insertions(+), 21 deletions(-) create mode 100644 app/src/db/migrations/20240115201832_files-api-access.js diff --git a/app/frontend/src/components/forms/manage/ApiKey.vue b/app/frontend/src/components/forms/manage/ApiKey.vue index 75782ec56..9d1aef3e5 100644 --- a/app/frontend/src/components/forms/manage/ApiKey.vue +++ b/app/frontend/src/components/forms/manage/ApiKey.vue @@ -17,6 +17,7 @@ export default { showConfirmationDialog: false, showDeleteDialog: false, showSecret: false, + filesApiAccess: false, }; }, computed: { @@ -55,6 +56,7 @@ export default { 'deleteApiKey', 'generateApiKey', 'readApiKey', + 'filesApiKeyAccess', ]), async createKey() { this.loading = true; @@ -67,6 +69,7 @@ export default { async deleteKey() { this.loading = true; await this.deleteApiKey(this.form.id); + this.filesApiAccess = false; this.loading = false; this.showDeleteDialog = false; }, @@ -74,8 +77,16 @@ export default { async readKey() { this.loading = true; await this.readApiKey(this.form.id); + this.filesApiAccess = this.apiKey?.filesApiAccess; this.loading = false; }, + + async updateKey() { + this.loading = true; + await this.filesApiKeyAccess(this.form.id, this.filesApiAccess); + this.loading = false; + }, + showHideKey() { this.showSecret = !this.showSecret; }, @@ -100,6 +111,9 @@ export default {
  • {{ $t('trans.apiKey.infoC') }}
  • +
  • + {{ $t('trans.apiKey.infoD') }} +
  • @@ -234,4 +248,14 @@ export default { + + + + + diff --git a/app/frontend/src/internationalization/trans/chefs/ar/ar.json b/app/frontend/src/internationalization/trans/chefs/ar/ar.json index d216913f6..5236e9b40 100644 --- a/app/frontend/src/internationalization/trans/chefs/ar/ar.json +++ b/app/frontend/src/internationalization/trans/chefs/ar/ar.json @@ -229,6 +229,7 @@ "infoA": "تأكد من تخزين سر مفتاح واجهة برمجة التطبيقات في مكان آمن (أي مخزن المفاتيح).", "infoB": "يمنحك مفتاح API وصولاً غير مقيد إلى النموذج الخاص بك. لا تعطي مفتاح API الخاص بك لأي شخص.", "infoC": "يجب استخدام مفتاح API فقط لتفاعلات النظام المؤتمتة. لا تستخدم مفتاح API الخاص بك للوصول المستند إلى المستخدم", + "infoD": "إذا كنت ترغب في الوصول إلى الملفات المرسلة عبر مفتاح API، فيرجى تمكين مربع الاختيار التالي بعد إنشاء المفتاح.", "deleteKey": "مفتاح الحذف", "apiKey": "مفتاح API", "hideSecret": "إخفاء السر", @@ -245,7 +246,8 @@ "formOwnerKeyAcess": "يجب أن تكون مالك النموذج لإدارة مفاتيح API.", "regenerate": "تجديد", "generate": "يولد", - "secret": "سر" + "secret": "سر", + "filesAPIAccess":"السماح لمفتاح API هذا بالوصول إلى الملفات المرسلة" }, "manageVersions": { "important": "مهم!", diff --git a/app/frontend/src/internationalization/trans/chefs/de/de.json b/app/frontend/src/internationalization/trans/chefs/de/de.json index 8561f37eb..8ca28094c 100644 --- a/app/frontend/src/internationalization/trans/chefs/de/de.json +++ b/app/frontend/src/internationalization/trans/chefs/de/de.json @@ -229,6 +229,7 @@ "infoA": "Stellen Sie sicher, dass Ihr API-Schlüsselgeheimnis an einem sicheren Ort (d. h. im Schlüsseltresor) gespeichert wird.", "infoB": "Ihr API-Schlüssel gewährt uneingeschränkten Zugriff auf Ihr Formular. Geben Sie Ihren API-Schlüssel nicht an Dritte weiter.", "infoC": "Der API-Schlüssel sollte NUR für automatisierte Systeminteraktionen verwendet werden. Verwenden Sie Ihren API-Schlüssel nicht für den benutzerbasierten Zugriff", + "infoD": "Wenn Sie über den API-Schlüssel auf übermittelte Dateien zugreifen möchten, aktivieren Sie bitte nach der Schlüsselgenerierung das folgende Kontrollkästchen.", "deleteKey": "Schlüssel löschen", "apiKey": "API-Schlüssel", "hideSecret": "Geheimnis verbergen", @@ -245,7 +246,8 @@ "formOwnerKeyAcess": "Sie müssen der Formulareigentümer sein, um API-Schlüssel verwalten zu können.", "regenerate": "Regenerieren", "generate": "Generieren", - "secret": "Geheimnis" + "secret": "Geheimnis", + "filesAPIAccess": "Erlauben Sie diesem API-Schlüssel den Zugriff auf übermittelte Dateien" }, "manageVersions": { "important": "WICHTIG!", diff --git a/app/frontend/src/internationalization/trans/chefs/en/en.json b/app/frontend/src/internationalization/trans/chefs/en/en.json index 288b06d88..df405db20 100644 --- a/app/frontend/src/internationalization/trans/chefs/en/en.json +++ b/app/frontend/src/internationalization/trans/chefs/en/en.json @@ -225,6 +225,7 @@ "infoA": "Ensure that your API key secret is stored in a secure location (i.e. key vault).", "infoB": "Your API key grants unrestricted access to your form. Do not give out your API key to anyone.", "infoC": "The API key should ONLY be used for automated system interactions. Do not use your API key for user based access", + "infoD": "If you wish to access submitted files via the API Key, please enable the following checkbox after generating the key.", "deleteKey": "Delete Key", "apiKey": "api key", "hideSecret": "Hide Secret", @@ -241,7 +242,8 @@ "formOwnerKeyAcess": "You must be the Form Owner to manage API Keys.", "regenerate": "Regenerate", "generate": "Generate", - "secret": "Secret" + "secret": "Secret", + "filesAPIAccess": "Allow this API key to access submitted files" }, "manageVersions": { "important": "IMPORTANT!", diff --git a/app/frontend/src/internationalization/trans/chefs/es/es.json b/app/frontend/src/internationalization/trans/chefs/es/es.json index 4f2b44a9e..a2b2116f6 100644 --- a/app/frontend/src/internationalization/trans/chefs/es/es.json +++ b/app/frontend/src/internationalization/trans/chefs/es/es.json @@ -229,6 +229,7 @@ "infoA": "Asegúrese de que el secreto de su clave de API esté almacenado en una ubicación segura (es decir, un almacén de claves).", "infoB": "Su clave API otorga acceso sin restricciones a su formulario. No proporcione su clave API a nadie.", "infoC": "La clave API SÓLO debe usarse para interacciones automatizadas del sistema. No utilice su clave API para el acceso basado en usuarios", + "infoD": "Si desea acceder a los archivos enviados a través de la clave API, habilite la siguiente casilla de verificación después de generar la clave.", "deleteKey": "Eliminar clave", "apiKey": "Clave API", "hideSecret": "Ocultar secreto", @@ -245,7 +246,8 @@ "formOwnerKeyAcess": "Debe ser el propietario del formulario para administrar las claves API.", "regenerate": "Regenerado", "generate": "Generar", - "secret": "Secreto" + "secret": "Secreto", + "filesAPIAccess": "Permitir que esta clave API acceda a los archivos enviados" }, "manageVersions": { "important": "¡IMPORTANTE!", diff --git a/app/frontend/src/internationalization/trans/chefs/fa/fa.json b/app/frontend/src/internationalization/trans/chefs/fa/fa.json index 4e55afbc6..96e7269d0 100644 --- a/app/frontend/src/internationalization/trans/chefs/fa/fa.json +++ b/app/frontend/src/internationalization/trans/chefs/fa/fa.json @@ -229,6 +229,7 @@ "infoA": "اطمینان حاصل کنید که رمز کلید API شما در یک مکان امن (به عنوان مثال صندوق کلید) ذخیره شده است.", "infoB": "کلید API شما به فرم شما دسترسی نامحدود می دهد. کلید API خود را به کسی ندهید.", "infoC": "کلید API فقط باید برای تعاملات سیستم خودکار استفاده شود. از کلید API خود برای دسترسی مبتنی بر کاربر استفاده نکنید", + "infoD": "اگر می‌خواهید از طریق کلید API به فایل‌های ارسالی دسترسی داشته باشید، لطفاً پس از تولید کلید، کادر زیر را فعال کنید.", "deleteKey": "حذف کلید", "apiKey": "کلید ای پی ای", "hideSecret": "پنهان کردن راز", @@ -245,7 +246,8 @@ "formOwnerKeyAcess": "برای مدیریت کلیدهای API باید مالک فرم باشید.", "regenerate": "بازسازی کنید", "generate": "تولید می کنند", - "secret": "راز" + "secret": "راز", + "filesAPIAccess":"به این کلید API اجازه دهید به فایل های ارسالی دسترسی داشته باشد" }, "manageVersions": { "important": "مهم!", diff --git a/app/frontend/src/internationalization/trans/chefs/fr/fr.json b/app/frontend/src/internationalization/trans/chefs/fr/fr.json index 2f7290588..52e3276fe 100644 --- a/app/frontend/src/internationalization/trans/chefs/fr/fr.json +++ b/app/frontend/src/internationalization/trans/chefs/fr/fr.json @@ -229,6 +229,7 @@ "infoA": "Assurez-vous que la clé secrète de votre API est stockée dans un emplacement sécurisé (c'est-à-dire un coffre de clés).", "infoB": "Votre clé API accorde un accès illimité à votre formulaire. Ne donnez votre clé API à personne.", "infoC": "La clé API doit UNIQUEMENT être utilisée pour les interactions système automatisées. N'utilisez pas votre clé API pour un accès basé sur l'utilisateur", + "infoD": "Si vous souhaitez accéder aux fichiers soumis via la clé API, veuillez cocher la case suivante après avoir généré la clé.", "deleteKey": "Supprimer la clé", "apiKey": "clé API", "hideSecret": "Cacher le secret", @@ -245,7 +246,8 @@ "formOwnerKeyAcess": "Vous devez être le propriétaire du formulaire pour gérer les clés API.", "regenerate": "Régénérer", "generate": "Générer", - "secret": "Secret" + "secret": "Secret", + "filesAPIAccess":"Autoriser cette clé API à accéder aux fichiers soumis" }, "manageVersions": { "important": "IMPORTANT!", diff --git a/app/frontend/src/internationalization/trans/chefs/hi/hi.json b/app/frontend/src/internationalization/trans/chefs/hi/hi.json index 55858556f..8629492ac 100644 --- a/app/frontend/src/internationalization/trans/chefs/hi/hi.json +++ b/app/frontend/src/internationalization/trans/chefs/hi/hi.json @@ -229,6 +229,7 @@ "infoA": "सुनिश्चित करें कि आपकी एपीआई कुंजी रहस्य एक सुरक्षित स्थान (यानी कुंजी वॉल्ट) में संग्रहीत है।", "infoB": "आपकी एपीआई कुंजी आपके फॉर्म तक अप्रतिबंधित पहुंच प्रदान करती है। अपनी एपीआई कुंजी किसी को न दें।", "infoC": "एपीआई कुंजी का उपयोग केवल स्वचालित सिस्टम इंटरैक्शन के लिए किया जाना चाहिए। उपयोगकर्ता आधारित पहुंच के लिए अपनी एपीआई कुंजी का उपयोग न करें", + "infoD": "यदि आप एपीआई कुंजी के माध्यम से सबमिट की गई फ़ाइलों तक पहुंच चाहते हैं, तो कृपया कुंजी उत्पन्न करने के बाद निम्नलिखित चेकबॉक्स को सक्षम करें।", "deleteKey": "कुंजी हटाएँ", "apiKey": "एपीआई कुंजी", "hideSecret": "गुप्त छिपाएँ", @@ -245,7 +246,8 @@ "formOwnerKeyAcess": "एपीआई कुंजी प्रबंधित करने के लिए आपको फॉर्म स्वामी होना चाहिए।", "regenerate": "पुनः जेनरेट", "generate": "बनाना", - "secret": "गुप्त" + "secret": "गुप्त", + "filesAPIAccess": "इस एपीआई कुंजी को सबमिट की गई फ़ाइलों तक पहुंचने की अनुमति दें" }, "manageVersions": { "important": "महत्वपूर्ण!", diff --git a/app/frontend/src/internationalization/trans/chefs/it/it.json b/app/frontend/src/internationalization/trans/chefs/it/it.json index 7f700a239..c3381b065 100644 --- a/app/frontend/src/internationalization/trans/chefs/it/it.json +++ b/app/frontend/src/internationalization/trans/chefs/it/it.json @@ -229,6 +229,7 @@ "infoA": "Assicurati che il segreto della tua chiave API sia archiviato in un luogo sicuro (ad es. Key Vault).", "infoB": "La tua chiave API garantisce l'accesso illimitato al tuo modulo. Non dare la tua chiave API a nessuno.", "infoC": "La chiave API deve essere utilizzata SOLO per le interazioni di sistema automatizzate. Non utilizzare la chiave API per l'accesso basato sull'utente", + "infoD":"Se desideri accedere ai file inviati tramite la chiave API, abilita la seguente casella di controllo dopo aver generato la chiave.", "deleteKey": "Elimina chiave", "apiKey": "chiave API", "hideSecret": "Nascondi segreto", @@ -245,7 +246,8 @@ "formOwnerKeyAcess": "Devi essere il proprietario del modulo per gestire le chiavi API.", "regenerate": "Rigenerare", "generate": "creare", - "secret": "Segreto" + "secret": "Segreto", + "filesAPIAccess": "Consenti a questa chiave API di accedere ai file inviati" }, "manageVersions": { "important": "IMPORTANTE!", diff --git a/app/frontend/src/internationalization/trans/chefs/ja/ja.json b/app/frontend/src/internationalization/trans/chefs/ja/ja.json index 2e3031d75..7b1660d99 100644 --- a/app/frontend/src/internationalization/trans/chefs/ja/ja.json +++ b/app/frontend/src/internationalization/trans/chefs/ja/ja.json @@ -229,6 +229,7 @@ "infoA": "API キー シークレットが安全な場所 (キー コンテナーなど) に保存されていることを確認してください。", "infoB": "API キーにより、フォームへの無制限のアクセスが許可されます。 API キーを誰にも渡さないでください。", "infoC": "API キーは、自動化されたシステム操作にのみ使用してください。ユーザーベースのアクセスには API キーを使用しないでください", + "infoD": "API キーを介して送信されたファイルにアクセスしたい場合は、キーを生成した後、次のチェックボックスをオンにしてください。", "deleteKey": "キーの削除", "apiKey": "APIキー", "hideSecret": "秘密を隠す", @@ -245,7 +246,8 @@ "formOwnerKeyAcess": "API キーを管理するには、フォーム所有者である必要があります。", "regenerate": "再生する", "generate": "生成", - "secret": "ひみつ" + "secret": "ひみつ", + "filesAPIAccess": "この API キーに送信されたファイルへのアクセスを許可します" }, "manageVersions": { "important": "重要!", diff --git a/app/frontend/src/internationalization/trans/chefs/ko/ko.json b/app/frontend/src/internationalization/trans/chefs/ko/ko.json index c95a5b9d9..51540d020 100644 --- a/app/frontend/src/internationalization/trans/chefs/ko/ko.json +++ b/app/frontend/src/internationalization/trans/chefs/ko/ko.json @@ -229,6 +229,7 @@ "infoA": "API 키 비밀이 안전한 위치(예: 키 자격 증명 모음)에 저장되어 있는지 확인합니다.", "infoB": "API 키는 양식에 대한 무제한 액세스 권한을 부여합니다. 누구에게도 API 키를 제공하지 마십시오.", "infoC": "API 키는 자동화된 시스템 상호 작용에만 사용해야 합니다. 사용자 기반 액세스에 API 키를 사용하지 마십시오.", + "infoD": "API 키를 통해 제출된 파일에 액세스하려면 키를 생성한 후 다음 확인란을 활성화하세요.", "deleteKey": "키 삭제", "apiKey": "API 키", "hideSecret": "비밀 숨기기", @@ -245,7 +246,8 @@ "formOwnerKeyAcess": "API 키를 관리하려면 양식 소유자 여야 합니다.", "regenerate": "재생하다", "generate": "생성하다", - "secret": "비밀" + "secret": "비밀", + "filesAPIAccess": "이 API 키가 제출된 파일에 액세스하도록 허용" }, "manageVersions": { "important": "중요한!", diff --git a/app/frontend/src/internationalization/trans/chefs/pa/pa.json b/app/frontend/src/internationalization/trans/chefs/pa/pa.json index 5ec033ee3..3e90ad301 100644 --- a/app/frontend/src/internationalization/trans/chefs/pa/pa.json +++ b/app/frontend/src/internationalization/trans/chefs/pa/pa.json @@ -229,6 +229,7 @@ "infoA": "ਯਕੀਨੀ ਬਣਾਓ ਕਿ ਤੁਹਾਡੀ API ਕੁੰਜੀ ਗੁਪਤ ਇੱਕ ਸੁਰੱਖਿਅਤ ਸਥਾਨ (ਜਿਵੇਂ ਕਿ ਕੁੰਜੀ ਵਾਲਟ) ਵਿੱਚ ਸਟੋਰ ਕੀਤੀ ਗਈ ਹੈ।", "infoB": "ਤੁਹਾਡੀ API ਕੁੰਜੀ ਤੁਹਾਡੇ ਫਾਰਮ ਤੱਕ ਅਪ੍ਰਬੰਧਿਤ ਪਹੁੰਚ ਪ੍ਰਦਾਨ ਕਰਦੀ ਹੈ। ਆਪਣੀ API ਕੁੰਜੀ ਕਿਸੇ ਨੂੰ ਨਾ ਦਿਓ।", "infoC": "API ਕੁੰਜੀ ਦੀ ਵਰਤੋਂ ਸਿਰਫ਼ ਆਟੋਮੇਟਿਡ ਸਿਸਟਮ ਪਰਸਪਰ ਕ੍ਰਿਆਵਾਂ ਲਈ ਕੀਤੀ ਜਾਣੀ ਚਾਹੀਦੀ ਹੈ। ਉਪਭੋਗਤਾ ਅਧਾਰਤ ਪਹੁੰਚ ਲਈ ਆਪਣੀ API ਕੁੰਜੀ ਦੀ ਵਰਤੋਂ ਨਾ ਕਰੋ", + "infoD": "ਜੇਕਰ ਤੁਸੀਂ API ਕੁੰਜੀ ਰਾਹੀਂ ਸਪੁਰਦ ਕੀਤੀਆਂ ਫਾਈਲਾਂ ਤੱਕ ਪਹੁੰਚ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ, ਤਾਂ ਕਿਰਪਾ ਕਰਕੇ ਕੁੰਜੀ ਬਣਾਉਣ ਤੋਂ ਬਾਅਦ ਹੇਠਾਂ ਦਿੱਤੇ ਚੈਕਬਾਕਸ ਨੂੰ ਸਮਰੱਥ ਬਣਾਓ।", "deleteKey": "ਕੁੰਜੀ ਮਿਟਾਓ", "apiKey": "api ਕੁੰਜੀ", "hideSecret": "ਗੁਪਤ ਲੁਕਾਓ", @@ -245,7 +246,8 @@ "formOwnerKeyAcess": "API ਕੁੰਜੀਆਂ ਦਾ ਪ੍ਰਬੰਧਨ ਕਰਨ ਲਈ ਤੁਹਾਨੂੰ ਫਾਰਮ ਦਾ ਮਾਲਕ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ।", "regenerate": "ਪੁਨਰਜਨਮ", "generate": "ਪੈਦਾ ਕਰੋ", - "secret": "ਗੁਪਤ" + "secret": "ਗੁਪਤ", + "filesAPIAccess": "ਇਸ API ਕੁੰਜੀ ਨੂੰ ਸਪੁਰਦ ਕੀਤੀਆਂ ਫ਼ਾਈਲਾਂ ਤੱਕ ਪਹੁੰਚ ਕਰਨ ਦਿਓ" }, "manageVersions": { "important": "ਮਹੱਤਵਪੂਰਨ!", diff --git a/app/frontend/src/internationalization/trans/chefs/pt/pt.json b/app/frontend/src/internationalization/trans/chefs/pt/pt.json index 983c5fdb6..1daa0c598 100644 --- a/app/frontend/src/internationalization/trans/chefs/pt/pt.json +++ b/app/frontend/src/internationalization/trans/chefs/pt/pt.json @@ -229,6 +229,7 @@ "infoA": "Certifique-se de que o segredo da sua chave de API esteja armazenado em um local seguro (ou seja, cofre de chaves).", "infoB": "Sua chave de API concede acesso irrestrito ao seu formulário. Não dê sua chave de API para ninguém.", "infoC": "A chave de API APENAS deve ser usada para interações automatizadas do sistema. Não use sua chave de API para acesso baseado no usuário", + "infoD": "Se você deseja acessar os arquivos enviados por meio da chave API, marque a caixa de seleção a seguir após gerar a chave.", "deleteKey": "Excluir chave", "apiKey": "Chave API", "hideSecret": "Ocultar Segredo", @@ -245,7 +246,8 @@ "formOwnerKeyAcess": "Você deve ser o proprietário do formulário para gerenciar chaves de API.", "regenerate": "Regenerado", "generate": "Gerar", - "secret": "Segredo" + "secret": "Segredo", + "filesAPIAccess": "Permitir que esta chave de API acesse os arquivos enviados" }, "manageVersions": { "important": "IMPORTANTE!", diff --git a/app/frontend/src/internationalization/trans/chefs/ru/ru.json b/app/frontend/src/internationalization/trans/chefs/ru/ru.json index acae1fd01..89ed19306 100644 --- a/app/frontend/src/internationalization/trans/chefs/ru/ru.json +++ b/app/frontend/src/internationalization/trans/chefs/ru/ru.json @@ -229,6 +229,7 @@ "infoA": "Убедитесь, что ваш секрет ключа API хранится в безопасном месте (например, в хранилище ключей).", "infoB": "Ваш ключ API предоставляет неограниченный доступ к вашей форме. Никому не передавайте свой ключ API.", "infoC": "Ключ API следует использовать ТОЛЬКО для взаимодействия с автоматизированной системой. Не используйте свой ключ API для пользовательского доступа", + "infoD": "Если вы хотите получить доступ к отправленным файлам через ключ API, установите следующий флажок после создания ключа.", "deleteKey": "Удалить ключ", "apiKey": "API-ключ", "hideSecret": "Скрыть секрет", @@ -245,7 +246,8 @@ "formOwnerKeyAcess": "Вы должны быть владельцем формы для управления ключами API.", "regenerate": "Регенерировать", "generate": "Создать", - "secret": "Секрет" + "secret": "Секрет", + "filesAPIAccess": "Разрешить этому ключу API доступ к отправленным файлам" }, "manageVersions": { "important": "ВАЖНО!", diff --git a/app/frontend/src/internationalization/trans/chefs/tl/tl.json b/app/frontend/src/internationalization/trans/chefs/tl/tl.json index 9ab1f3313..1c0dce63a 100644 --- a/app/frontend/src/internationalization/trans/chefs/tl/tl.json +++ b/app/frontend/src/internationalization/trans/chefs/tl/tl.json @@ -229,6 +229,7 @@ "infoA": "Tiyaking nakaimbak ang iyong lihim ng API key sa isang secure na lokasyon (ibig sabihin, key vault).", "infoB": "Ang iyong API key ay nagbibigay ng walang limitasyong pag-access sa iyong form. Huwag ibigay ang iyong API key sa sinuman.", "infoC": "Dapat LAMANG gamitin ang API key para sa mga automated na pakikipag-ugnayan ng system. Huwag gamitin ang iyong API key para sa user based na access", + "infoD": "Kung gusto mong i-access ang mga isinumiteng file sa pamamagitan ng API Key, mangyaring paganahin ang sumusunod na checkbox pagkatapos mabuo ang key.", "deleteKey": "Tanggalin ang Susi", "apiKey": "susi ng api", "hideSecret": "Itago ang Lihim", @@ -245,7 +246,8 @@ "formOwnerKeyAcess": "Ikaw dapat ang May-ari ng Form para pamahalaan ang Mga API Key.", "regenerate": "Magbagong-buhay", "generate": "Bumuo", - "secret": "Lihim" + "secret": "Lihim", + "filesAPIAccess": "Payagan ang API key na ito na ma-access ang mga isinumiteng file" }, "manageVersions": { "important": "MAHALAGA!", diff --git a/app/frontend/src/internationalization/trans/chefs/uk/uk.json b/app/frontend/src/internationalization/trans/chefs/uk/uk.json index 41f5122c0..396be261d 100644 --- a/app/frontend/src/internationalization/trans/chefs/uk/uk.json +++ b/app/frontend/src/internationalization/trans/chefs/uk/uk.json @@ -229,6 +229,7 @@ "infoA": "Переконайтеся, що ваш секретний ключ API зберігається в безпечному місці (тобто в сховищі ключів).", "infoB": "Ваш ключ API надає необмежений доступ до вашої форми. Нікому не передавайте свій ключ API.", "infoC": "Ключ API слід використовувати ЛИШЕ для автоматизованої взаємодії з системою. Не використовуйте свій ключ API для доступу на основі користувача", + "infoD": "Якщо ви бажаєте отримати доступ до надісланих файлів за допомогою ключа API, увімкніть наступний прапорець після створення ключа.", "deleteKey": "Видалити ключ", "apiKey": "ключ API", "hideSecret": "Приховати секрет", @@ -245,7 +246,8 @@ "formOwnerKeyAcess": "Ви повинні бути власником форми , щоб керувати ключами API.", "regenerate": "Регенерувати", "generate": "Генерувати", - "secret": "Секрет" + "secret": "Секрет", + "filesAPIAccess": "Дозволити цьому ключу API доступ до надісланих файлів" }, "manageVersions": { "important": "ВАЖЛИВО!", diff --git a/app/frontend/src/internationalization/trans/chefs/vi/vi.json b/app/frontend/src/internationalization/trans/chefs/vi/vi.json index 7f440b7d9..50e937f9c 100644 --- a/app/frontend/src/internationalization/trans/chefs/vi/vi.json +++ b/app/frontend/src/internationalization/trans/chefs/vi/vi.json @@ -229,6 +229,7 @@ "infoA": "Đảm bảo rằng khóa bí mật API của bạn được lưu trữ ở một vị trí an toàn (tức là kho khóa).", "infoB": "Khóa API của bạn cấp quyền truy cập không hạn chế vào biểu mẫu của bạn. Không cung cấp khóa API của bạn cho bất kỳ ai.", "infoC": "Khóa API CHỈ nên được sử dụng cho các tương tác hệ thống tự động. Không sử dụng khóa API của bạn để truy cập dựa trên người dùng", + "infoD": "Nếu bạn muốn truy cập các tệp đã gửi qua Khóa API, vui lòng bật hộp kiểm sau sau khi tạo khóa.", "deleteKey": "Phím xoá", "apiKey": "Mã API", "hideSecret": "Ẩn bí mật", @@ -245,7 +246,8 @@ "formOwnerKeyAcess": "Bạn phải là Chủ sở hữu biểu mẫu để quản lý Khóa API.", "regenerate": "tái tạo", "generate": "Phát ra", - "secret": "Bí mật" + "secret": "Bí mật", + "filesAPIAccess": "Cho phép khóa API này truy cập vào các tệp đã gửi" }, "manageVersions": { "important": "QUAN TRỌNG!", diff --git a/app/frontend/src/internationalization/trans/chefs/zh/zh.json b/app/frontend/src/internationalization/trans/chefs/zh/zh.json index 4bdb517a1..b7729c32f 100644 --- a/app/frontend/src/internationalization/trans/chefs/zh/zh.json +++ b/app/frontend/src/internationalization/trans/chefs/zh/zh.json @@ -229,6 +229,7 @@ "infoA": "确保您的 API 密钥秘密存储在安全位置(即密钥保管库)。", "infoB": "您的 API 密钥可以不受限制地访问您的表单。请勿将您的 API 密钥透露给任何人。", "infoC": "API 密钥只能用于自动化系统交互。请勿使用您的 API 密钥进行基于用户的访问", + "infoD": "如果您希望通过 API 密钥访问提交的文件,请在生成密钥后启用以下复选框。", "deleteKey": "删除键", "apiKey": "API密钥", "hideSecret": "隐藏秘密", @@ -245,7 +246,8 @@ "formOwnerKeyAcess": "您必须是表单所有者才能管理 API 密钥。", "regenerate": "再生", "generate": "产生", - "secret": "秘密" + "secret": "秘密", + "filesAPIAccess": "允许此 API 密钥访问提交的文件" }, "manageVersions": { "important": "重要的!", diff --git a/app/frontend/src/internationalization/trans/chefs/zhTW/zh-TW.json b/app/frontend/src/internationalization/trans/chefs/zhTW/zh-TW.json index 2a01e8e1d..0907fad5d 100644 --- a/app/frontend/src/internationalization/trans/chefs/zhTW/zh-TW.json +++ b/app/frontend/src/internationalization/trans/chefs/zhTW/zh-TW.json @@ -229,6 +229,7 @@ "infoA": "確保您的 API 密鑰秘密存儲在安全位置(即密鑰保管庫)。", "infoB": "您的 API 密鑰可以不受限制地訪問您的表單。請勿將您的 API 密鑰透露給任何人。", "infoC": "API 密鑰只能用於自動化系統交互。請勿使用您的 API 密鑰進行基於用戶的訪問", + "infoD": "如果您希望透過 API 金鑰存取提交的文件,請在產生金鑰後啟用下列複選框。", "deleteKey": "刪除鍵", "apiKey": "API密鑰", "hideSecret": "隱藏秘密", @@ -245,7 +246,8 @@ "formOwnerKeyAcess": "您必須是表單所有者才能管理 API 密鑰。", "regenerate": "再生", "generate": "產生", - "secret": "秘密" + "secret": "秘密", + "filesAPIAccess": "允許此 API 金鑰存取提交的文件" }, "manageVersions": { "important": "重要的!", diff --git a/app/frontend/src/services/apiKeyService.js b/app/frontend/src/services/apiKeyService.js index 175a288a7..8e4887ff1 100644 --- a/app/frontend/src/services/apiKeyService.js +++ b/app/frontend/src/services/apiKeyService.js @@ -31,4 +31,17 @@ export default { deleteApiKey(formId) { return appAxios().delete(`${ApiRoutes.FORMS}/${formId}${ApiRoutes.APIKEY}`); }, + + /** + * @function filesApiKeyAccess + * Set the boolean for the API key to access files + * @param {string} formId The form uuid, {boolean} filesApiAcces true/false to allow/deny access + * @returns {Promise} An axios response + */ + filesApiKeyAccess(formId, filesApiAccess) { + return appAxios().put( + `${ApiRoutes.FORMS}/${formId}${ApiRoutes.APIKEY}${ApiRoutes.FILES_API_ACCESS}`, + { filesApiAccess } + ); + }, }; diff --git a/app/frontend/src/store/form.js b/app/frontend/src/store/form.js index 0df4cd129..cbe93e100 100644 --- a/app/frontend/src/store/form.js +++ b/app/frontend/src/store/form.js @@ -741,6 +741,26 @@ export const useFormStore = defineStore('form', { }); } }, + async filesApiKeyAccess(formId, filesApiAccess) { + const notificationStore = useNotificationStore(); + try { + const { data } = await apiKeyService.filesApiKeyAccess( + formId, + filesApiAccess + ); + this.apiKey = data; + notificationStore.addNotification({ + text: 'API Key updated successfully.', + ...NotificationTypes.SUCCESS, + }); + } catch (error) { + const notificationStore = useNotificationStore(); + notificationStore.addNotification({ + text: 'An error occurred while trying to update the API Key.', + consoleError: `Error updating API Key for form ${formId}: ${error}`, + }); + } + }, async getFCProactiveHelpImageUrl(componentId) { try { diff --git a/app/frontend/src/utils/constants.js b/app/frontend/src/utils/constants.js index fbc12e476..08f432ecb 100755 --- a/app/frontend/src/utils/constants.js +++ b/app/frontend/src/utils/constants.js @@ -13,6 +13,7 @@ export const ApiRoutes = Object.freeze({ USERS: '/users', FILES: '/files', UTILS: '/utils', + FILES_API_ACCESS: '/filesApiAccess', }); /** Roles a user can have on a form. These are defined in the DB and sent from the API */ diff --git a/app/frontend/tests/unit/utils/constants.spec.js b/app/frontend/tests/unit/utils/constants.spec.js index 512b9a2c4..5c0fba6c8 100644 --- a/app/frontend/tests/unit/utils/constants.spec.js +++ b/app/frontend/tests/unit/utils/constants.spec.js @@ -13,6 +13,7 @@ describe('Constants', () => { SUBMISSION: '/submissions', USERS: '/users', UTILS: '/utils', + FILES_API_ACCESS: '/filesApiAccess', }); }); diff --git a/app/src/db/migrations/20240115201832_files-api-access.js b/app/src/db/migrations/20240115201832_files-api-access.js new file mode 100644 index 000000000..0e6b773ca --- /dev/null +++ b/app/src/db/migrations/20240115201832_files-api-access.js @@ -0,0 +1,21 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function(knex) { + return Promise.resolve() + .then(() => knex.schema.alterTable('form_api_key', table => { + table.boolean('filesApiAccess').defaultTo(false).comment('Keeps track of whether files can be accessed using the API key'); + })); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function(knex) { + return Promise.resolve() + .then(() => knex.schema.alterTable('form_api_key', table => { + table.dropColumn('filesApiAccess'); + })); +}; diff --git a/app/src/docs/v1.api-spec.yaml b/app/src/docs/v1.api-spec.yaml index f32dde7b7..d42b1cb76 100755 --- a/app/src/docs/v1.api-spec.yaml +++ b/app/src/docs/v1.api-spec.yaml @@ -337,6 +337,32 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /forms/{formId}/apiKey/filesApiAccess: + put: + summary: Set the API key access to submitted files + description: Enable/disable access to submitted files using the API key. + operationId: filesApiAccess + tags: + - Form API + parameters: + - $ref: '#/components/parameters/formIdParam' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/FormApiKey' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /forms/{formId}/export: get: summary: Export submissions for a form @@ -3198,6 +3224,9 @@ components: secret: type: string example: dd7d1699-61ec-4037-aa33-727f8aa79c0a + filesApiAccess: + type: boolean + example: true - $ref: '#/components/schemas/TimeStampUserData' FormApiDetails: allOf: diff --git a/app/src/forms/auth/middleware/apiAccess.js b/app/src/forms/auth/middleware/apiAccess.js index 8821e544d..b93d7aa24 100644 --- a/app/src/forms/auth/middleware/apiAccess.js +++ b/app/src/forms/auth/middleware/apiAccess.js @@ -7,6 +7,7 @@ const submissionService = require('../../submission/service'); const fileService = require('../../file/service'); const HTTP_401_DETAIL = 'Invalid authorization credentials.'; +const HTTP_403_DETAIL = 'You do not have access to this resource.'; /** * Gets the Form ID from the request parameters. Handles the cases where instead @@ -93,9 +94,9 @@ module.exports = async (req, res, next) => { throw new Problem(401, { detail: HTTP_401_DETAIL }); } - // if (params.id && apiKey.filesApiAccess === false) { - // throw new Problem(401, { detail: HTTP_401_DETAIL }); - // } + if (params.id && apiKey.filesApiAccess === false) { + throw new Problem(403, { detail: HTTP_403_DETAIL }); + } const secret = apiKey.secret; diff --git a/app/src/forms/common/models/tables/formApiKey.js b/app/src/forms/common/models/tables/formApiKey.js index f7a3b72db..87ac808bc 100644 --- a/app/src/forms/common/models/tables/formApiKey.js +++ b/app/src/forms/common/models/tables/formApiKey.js @@ -26,6 +26,7 @@ class FormApiKey extends Timestamps(Model) { id: { type: 'integer' }, formId: { type: 'string', pattern: Regex.UUID }, secret: { type: 'string', pattern: Regex.UUID }, + filesApiAccess: { type: 'boolean', default: false }, ...stamps, }, additionalProperties: false, diff --git a/app/src/forms/form/controller.js b/app/src/forms/form/controller.js index 74712539a..77515823d 100644 --- a/app/src/forms/form/controller.js +++ b/app/src/forms/form/controller.js @@ -250,6 +250,14 @@ module.exports = { next(error); } }, + filesApiKeyAccess: async (req, res, next) => { + try { + const response = await service.filesApiKeyAccess(req.params.formId, req.body.filesApiAccess); + res.status(200).json(response); + } catch (error) { + next(error); + } + }, deleteApiKey: async (req, res, next) => { try { const response = await service.deleteApiKey(req.params.formId); diff --git a/app/src/forms/form/routes.js b/app/src/forms/form/routes.js index cddf0c9e3..8f620b92e 100644 --- a/app/src/forms/form/routes.js +++ b/app/src/forms/form/routes.js @@ -127,6 +127,10 @@ routes.put('/:formId/apiKey', hasFormPermissions(P.FORM_API_CREATE), async (req, await controller.createOrReplaceApiKey(req, res, next); }); +routes.put('/:formId/apiKey/filesApiAccess', hasFormPermissions(P.FORM_API_CREATE), async (req, res, next) => { + await controller.filesApiKeyAccess(req, res, next); +}); + routes.delete('/:formId/apiKey', hasFormPermissions(P.FORM_API_DELETE), async (req, res, next) => { await controller.deleteApiKey(req, res, next); }); diff --git a/app/src/forms/form/service.js b/app/src/forms/form/service.js index 2507c076d..01ad71249 100644 --- a/app/src/forms/form/service.js +++ b/app/src/forms/form/service.js @@ -729,6 +729,7 @@ const service = { formId: formId, secret: uuidv4(), updatedBy: currentUser.usernameIdp, + filesApiAccess: false, }); } else { // Add new API key for the form @@ -736,6 +737,7 @@ const service = { formId: formId, secret: uuidv4(), createdBy: currentUser.usernameIdp, + filesApiAccess: false, }); } @@ -747,6 +749,33 @@ const service = { } }, + // Set the filesApiAccess boolean for the api key + filesApiKeyAccess: async (formId, filesApiAccess) => { + let trx; + try { + if (typeof filesApiAccess !== 'boolean') { + throw new Problem(400, `filesApiAccess must be a boolean`); + } + const currentKey = await service.readApiKey(formId); + trx = await FormApiKey.startTransaction(); + + if (currentKey) { + await FormApiKey.query(trx).modify('filterFormId', formId).update({ + formId: formId, + filesApiAccess: filesApiAccess, + }); + } else { + throw new Problem(404, `No API key found for form ${formId}`); + } + + await trx.commit(); + return service.readApiKey(formId); + } catch (err) { + if (trx) await trx.rollback(); + throw err; + } + }, + // Hard delete the current key for a form deleteApiKey: async (formId) => { const currentKey = await service.readApiKey(formId); diff --git a/app/tests/unit/forms/auth/middleware/apiAccess.spec.js b/app/tests/unit/forms/auth/middleware/apiAccess.spec.js index abb6e01a1..dd41280e5 100644 --- a/app/tests/unit/forms/auth/middleware/apiAccess.spec.js +++ b/app/tests/unit/forms/auth/middleware/apiAccess.spec.js @@ -417,5 +417,39 @@ describe('apiAccess', () => { expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenCalledWith(); }); + + it('should be forbidden if filesApiAccess is false', async () => { + mockReadApiKey.mockResolvedValue({ secret: secret, filesApiAccess: false }); + fileService.read = jest.fn().mockResolvedValue({ formSubmissionId: formSubmissionId }); + submissionService.read = jest.fn().mockResolvedValue({ form: { id: formId } }); + const req = { + headers: { authorization: authHeader }, + params: { id: fileId }, + }; + const { res, next } = getMockRes(); + + await apiAccess(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 403 })); + expect(res.status).not.toHaveBeenCalled(); + expect(req.apiUser).toBeUndefined(); + expect(mockReadApiKey).toHaveBeenCalledTimes(1); + }); + + it('should allow access to files if filesAPIAccess is true', async () => { + mockReadApiKey.mockResolvedValue({ secret: secret, filesAPIAccess: true }); + const req = { + headers: { authorization: authHeader }, + params: { formId: formId }, + }; + const { res, next } = getMockRes(); + + await apiAccess(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(mockReadApiKey).toHaveBeenCalledTimes(1); + expect(req.apiUser).toBeTruthy(); + }); }); }); From f8c14573bdbe30d76fcd8018965bcbd3fcb8a43c Mon Sep 17 00:00:00 2001 From: Walter Moar Date: Thu, 22 Feb 2024 08:40:45 -0800 Subject: [PATCH 09/14] refactor: FORMS-965 update hasFormPermissions tests (#1290) * refactor: FORMS-965 has form permissions cleanup * add a comment to explain the validate call * reduce cognitive complexity * fix comments and use imports more clearly --- app/src/forms/auth/middleware/userAccess.js | 132 +++++++---- app/src/forms/form/routes.js | 14 +- app/src/forms/rbac/routes.js | 8 +- .../forms/auth/middleware/userAccess.spec.js | 212 +++++++++++------- 4 files changed, 224 insertions(+), 142 deletions(-) diff --git a/app/src/forms/auth/middleware/userAccess.js b/app/src/forms/auth/middleware/userAccess.js index ea8296075..127887a62 100644 --- a/app/src/forms/auth/middleware/userAccess.js +++ b/app/src/forms/auth/middleware/userAccess.js @@ -1,5 +1,5 @@ const Problem = require('api-problem'); -const { validate } = require('uuid'); +const uuid = require('uuid'); const keycloak = require('../../../components/keycloak'); const Permissions = require('../../common/constants').Permissions; @@ -7,6 +7,23 @@ const Roles = require('../../common/constants').Roles; const service = require('../service'); const rbacService = require('../../rbac/service'); +/** + * Checks that every permission is in the user's form permissions. + * + * @param {*} form the user's form metadata including permissions. + * @param {string[]} permissions the permissions needed for access. + * @returns true if every permissions value is in the user's form permissions. + */ +const _formHasPermissions = (form, permissions) => { + // Get the intersection of the two sets of permissions. If it's the same + // size as permissions then the user has all the needed permissions. + const intersection = permissions.filter((p) => { + return form.permissions.includes(p); + }); + + return intersection.length === permissions.length; +}; + /** * Gets the access token, if it exists, from the request. * @@ -34,6 +51,38 @@ const _getBearerToken = (req) => { return token; }; +/** + * Gets the form metadata for the given formId from the forms available to the + * current user. + * + * @param {*} currentUser the user that is currently logged in; may be public. + * @param {uuid} formId the ID of the form to retrieve for the current user. + * @param {boolean} includeDeleted if active form not found, look for a deleted + * form. + * @returns the form metadata. + * @throws Problem if the form metadata for the formId cannot be retrieved. + */ +const _getForm = async (currentUser, formId, includeDeleted) => { + if (!uuid.validate(formId)) { + throw new Problem(400, { detail: 'Bad formId' }); + } + + const forms = await service.getUserForms(currentUser, { active: true, formId: formId }); + let form = forms.find((f) => f.formId === formId); + + if (!form && includeDeleted) { + const deletedForms = await service.getUserForms(currentUser, { active: false, formId: formId }); + form = deletedForms.find((f) => f.formId === formId); + } + + // Cannot find the form: either it doesn't exist or we don't have access. + if (!form) { + throw new Problem(401, { detail: 'Current user has no access to form' }); + } + + return form; +}; + /** * Express middleware that adds the user information as the res.currentUser * attribute so that all downstream middleware and business logic can use it. @@ -67,53 +116,46 @@ const currentUser = async (req, _res, next) => { } }; -const _getForm = async (currentUser, formId) => { - const forms = await service.getUserForms(currentUser, { active: true, formId: formId }); - let form = forms.find((f) => f.formId === formId); - - if (!form) { - const deletedForms = await service.getUserForms(currentUser, { active: false, formId: formId }); - form = deletedForms.find((f) => f.formId === formId); - } - - return form; -}; - +/** + * Express middleware to check that a user has all the given permissions for a + * form. This will fall through if everything is OK, otherwise it will call + * next() with a Problem that describes the error. + * + * @param {string[]} permissions the form permissions that the user must have. + * @returns nothing + */ const hasFormPermissions = (permissions) => { - return async (req, res, next) => { - // Skip permission checks if requesting as API entity - if (req.apiUser) { - return next(); - } + return async (req, _res, next) => { + try { + // Skip permission checks if req is already validated using an API key. + if (req.apiUser) { + next(); - if (!req.currentUser) { - // cannot find the currentUser... guess we don't have access... FAIL! - return new Problem(401, { detail: 'Current user not found on request.' }).send(res); - } - // If we invoke this middleware and the caller is acting on a specific formId, whether in a param or query (precedence to param) - const formId = req.params.formId || req.query.formId; - if (!formId) { - // No form provided to this route that secures based on form... that's a problem! - return new Problem(401, { detail: 'Form Id not found on request.' }).send(res); - } - let form = await _getForm(req.currentUser, formId); - if (!form) { - // cannot find the form... guess we don't have access... FAIL! - return new Problem(401, { detail: 'Current user has no access to form.' }).send(res); - } + return; + } - if (!Array.isArray(permissions)) { - permissions = [permissions]; - } + // If the currentUser does not exist it means that the route is not set up + // correctly - the currentUser middleware must be called before this + // middleware. + if (!req.currentUser) { + throw new Problem(500, { + detail: 'Current user not found on request', + }); + } - const intersection = permissions.filter((p) => { - return form.permissions.includes(p); - }); + // The request must include a formId, either in params or query, but give + // precedence to params. + const form = await _getForm(req.currentUser, req.params.formId || req.query.formId, true); - if (intersection.length !== permissions.length) { - return new Problem(401, { detail: 'Current user does not have required permission(s) on form' }).send(res); - } else { - return next(); + if (!_formHasPermissions(form, permissions)) { + throw new Problem(401, { + detail: 'Current user does not have required permission(s) on form', + }); + } + + next(); + } catch (error) { + next(error); } }; }; @@ -201,12 +243,12 @@ const filterMultipleSubmissions = () => { } //validate form id - if (!validate(formId)) { + if (!uuid.validate(formId)) { return next(new Problem(401, { detail: 'Not a valid form id' })); } //validate all submission ids - const isValidSubmissionId = submissionIds.every((submissionId) => validate(submissionId)); + const isValidSubmissionId = submissionIds.every((submissionId) => uuid.validate(submissionId)); if (!isValidSubmissionId) { return next(new Problem(401, { detail: 'Invalid submissionId(s) in the submissionIds list.' })); } diff --git a/app/src/forms/form/routes.js b/app/src/forms/form/routes.js index 8f620b92e..895b10aff 100644 --- a/app/src/forms/form/routes.js +++ b/app/src/forms/form/routes.js @@ -23,7 +23,7 @@ routes.post('/', async (req, res, next) => { await controller.createForm(req, res, next); }); -routes.get('/:formId', rateLimiter, apiAccess, hasFormPermissions(P.FORM_READ), async (req, res, next) => { +routes.get('/:formId', rateLimiter, apiAccess, hasFormPermissions([P.FORM_READ]), async (req, res, next) => { await controller.readForm(req, res, next); }); @@ -35,7 +35,7 @@ routes.post('/:formId/export/fields', rateLimiter, apiAccess, hasFormPermissions await controller.exportWithFields(req, res, next); }); -routes.get('/:formId/emailTemplates', hasFormPermissions(P.EMAIL_TEMPLATE_READ), async (req, res, next) => { +routes.get('/:formId/emailTemplates', hasFormPermissions([P.EMAIL_TEMPLATE_READ]), async (req, res, next) => { await controller.readEmailTemplates(req, res, next); }); @@ -47,7 +47,7 @@ routes.get('/:formId/options', async (req, res, next) => { await controller.readFormOptions(req, res, next); }); -routes.get('/:formId/version', rateLimiter, apiAccess, hasFormPermissions(P.FORM_READ), async (req, res, next) => { +routes.get('/:formId/version', rateLimiter, apiAccess, hasFormPermissions([P.FORM_READ]), async (req, res, next) => { await controller.readPublishedForm(req, res, next); }); @@ -119,19 +119,19 @@ routes.get('/:formId/statusCodes', rateLimiter, apiAccess, hasFormPermissions([P await controller.getStatusCodes(req, res, next); }); -routes.get('/:formId/apiKey', hasFormPermissions(P.FORM_API_READ), async (req, res, next) => { +routes.get('/:formId/apiKey', hasFormPermissions([P.FORM_API_READ]), async (req, res, next) => { await controller.readApiKey(req, res, next); }); -routes.put('/:formId/apiKey', hasFormPermissions(P.FORM_API_CREATE), async (req, res, next) => { +routes.put('/:formId/apiKey', hasFormPermissions([P.FORM_API_CREATE]), async (req, res, next) => { await controller.createOrReplaceApiKey(req, res, next); }); -routes.put('/:formId/apiKey/filesApiAccess', hasFormPermissions(P.FORM_API_CREATE), async (req, res, next) => { +routes.put('/:formId/apiKey/filesApiAccess', hasFormPermissions([P.FORM_API_CREATE]), async (req, res, next) => { await controller.filesApiKeyAccess(req, res, next); }); -routes.delete('/:formId/apiKey', hasFormPermissions(P.FORM_API_DELETE), async (req, res, next) => { +routes.delete('/:formId/apiKey', hasFormPermissions([P.FORM_API_DELETE]), async (req, res, next) => { await controller.deleteApiKey(req, res, next); }); diff --git a/app/src/forms/rbac/routes.js b/app/src/forms/rbac/routes.js index 35c463f3b..a751f1f1b 100644 --- a/app/src/forms/rbac/routes.js +++ b/app/src/forms/rbac/routes.js @@ -21,11 +21,11 @@ routes.get('/idps', async (req, res, next) => { await controller.getIdentityProviders(req, res, next); }); -routes.get('/forms', hasFormPermissions(P.TEAM_READ), async (req, res, next) => { +routes.get('/forms', hasFormPermissions([P.TEAM_READ]), async (req, res, next) => { await controller.getFormUsers(req, res, next); }); -routes.put('/forms', hasFormPermissions(P.TEAM_UPDATE), async (req, res, next) => { +routes.put('/forms', hasFormPermissions([P.TEAM_UPDATE]), async (req, res, next) => { await controller.setFormUsers(req, res, next); }); @@ -41,11 +41,11 @@ routes.get('/users', keycloak.protect(`${config.get('server.keycloak.clientId')} await controller.getUserForms(req, res, next); }); -routes.put('/users', hasFormPermissions(P.TEAM_UPDATE), hasFormRoles([R.OWNER, R.TEAM_MANAGER]), hasRolePermissions(false), async (req, res, next) => { +routes.put('/users', hasFormPermissions([P.TEAM_UPDATE]), hasFormRoles([R.OWNER, R.TEAM_MANAGER]), hasRolePermissions(false), async (req, res, next) => { await controller.setUserForms(req, res, next); }); -routes.delete('/users', hasFormPermissions(P.TEAM_UPDATE), hasFormRoles([R.OWNER, R.TEAM_MANAGER]), hasRolePermissions(true), async (req, res, next) => { +routes.delete('/users', hasFormPermissions([P.TEAM_UPDATE]), hasFormRoles([R.OWNER, R.TEAM_MANAGER]), hasRolePermissions(true), async (req, res, next) => { await controller.removeMultiUsers(req, res, next); }); diff --git a/app/tests/unit/forms/auth/middleware/userAccess.spec.js b/app/tests/unit/forms/auth/middleware/userAccess.spec.js index 9e8c70a41..fe16d6313 100644 --- a/app/tests/unit/forms/auth/middleware/userAccess.spec.js +++ b/app/tests/unit/forms/auth/middleware/userAccess.spec.js @@ -1,5 +1,6 @@ const { getMockReq, getMockRes } = require('@jest-mock/express'); const Problem = require('api-problem'); +const uuid = require('uuid'); const { currentUser, hasFormPermissions, hasSubmissionPermissions, hasFormRoles, hasRolePermissions } = require('../../../../../src/forms/auth/middleware/userAccess'); @@ -7,9 +8,10 @@ const keycloak = require('../../../../../src/components/keycloak'); const service = require('../../../../../src/forms/auth/service'); const rbacService = require('../../../../../src/forms/rbac/service'); +const formId = uuid.v4(); +const formSubmissionId = uuid.v4(); const userId = 'c6455376-382c-439d-a811-0381a012d695'; const userId2 = 'c6455376-382c-439d-a811-0381a012d696'; -const formId = 'c6455376-382c-439d-a811-0381a012d697'; const Roles = { OWNER: 'owner', @@ -221,173 +223,211 @@ describe('currentUser', () => { }); }); +// External dependencies used by the implementation are: +// - service.getUserForms: gets the forms that the user can access +// describe('hasFormPermissions', () => { - it('returns a middleware function', async () => { - const mw = hasFormPermissions(['abc']); - expect(mw).toBeInstanceOf(Function); - }); + // Default mock value where the user has no access to forms + service.getUserForms = jest.fn().mockReturnValue([]); - it('401s if the request has no current user', async () => { - const mw = hasFormPermissions(['abc']); - const nxt = jest.fn(); - const req = { params: { formId: 1 } }; + it('returns a middleware function', async () => { + const middleware = hasFormPermissions(['FORM_READ']); - await mw(req, testRes, nxt); - expect(nxt).toHaveBeenCalledTimes(0); - // expect(nxt).toHaveBeenCalledWith(new Problem(401, { detail: 'Current user not found on request.' })); + expect(middleware).toBeInstanceOf(Function); }); - it('401s if the request has no formId', async () => { - const mw = hasFormPermissions(['abc']); - const nxt = jest.fn(); - const req = { + it('400s if the request has no formId', async () => { + const req = getMockReq({ currentUser: {}, params: { - submissionId: 123, + submissionId: formSubmissionId, }, query: { - otherQueryThing: 'abc', + otherQueryThing: 'SOMETHING', }, - }; + }); + const { res, next } = getMockRes(); - await mw(req, testRes, nxt); - expect(nxt).toHaveBeenCalledTimes(0); - // expect(nxt).toHaveBeenCalledWith(new Problem(401, { detail: 'Form Id not found on request.' })); + await hasFormPermissions(['FORM_READ'])(req, res, next); + + expect(service.getUserForms).toHaveBeenCalledTimes(0); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 400 })); }); - it('401s if the user does not have access to the form', async () => { - service.getUserForms = jest.fn().mockReturnValue([]); + it('400s if the formId is not a uuid', async () => { const req = getMockReq({ currentUser: {}, params: { - formId: '123', + formId: 'undefined', + }, + query: { + otherQueryThing: 'SOMETHING', }, }); const { res, next } = getMockRes(); - const mw = hasFormPermissions(['abc']); - await mw(req, res, next); + await hasFormPermissions(['FORM_READ'])(req, res, next); - expect(res.end).toHaveBeenCalledWith(expect.stringContaining('401')); - expect(next).toHaveBeenCalledTimes(0); + expect(service.getUserForms).toHaveBeenCalledTimes(0); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 400 })); }); - it('401s if the user does not have access to the form nor is it in their deleted', async () => { - const mw = hasFormPermissions(['abc']); - const nxt = jest.fn(); - const req = { + // TODO: This should be a 403, but bundle all breaking changes in a small PR. + it('401s if the user does not have access to the form', async () => { + const req = getMockReq({ currentUser: {}, params: { - formId: '123', + formId: formId, }, - }; + }); + const { res, next } = getMockRes(); - await mw(req, testRes, nxt); - expect(nxt).toHaveBeenCalledTimes(0); - // expect(nxt).toHaveBeenCalledWith(new Problem(401, { detail: 'Current user has no access to form.' })); + await hasFormPermissions(['FORM_READ'])(req, res, next); + + expect(service.getUserForms).toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 401 })); }); - it('does not 401 if the user has deleted form access', async () => { - service.getUserForms = jest - .fn() - .mockReturnValueOnce([]) - .mockReturnValueOnce([ - { - formId: '123', - permissions: ['abc'], - }, - ]); + // TODO: This should be a 403, but bundle all breaking changes in a small PR. + it('401s if the expected permissions are not included', async () => { + service.getUserForms.mockReturnValueOnce([ + { + formId: formId, + permissions: ['DESIGN_CREATE', 'FORM_READ', 'SUBMISSION_READ'], + }, + ]); const req = getMockReq({ currentUser: {}, params: { - formId: '123', + formId: formId, }, }); const { res, next } = getMockRes(); - const mw = hasFormPermissions(['abc']); - await mw(req, res, next); + await hasFormPermissions(['DESIGN_CREATE', 'FORM_READ', 'SUBMISSION_DELETE'])(req, res, next); + expect(service.getUserForms).toHaveBeenCalled(); expect(next).toHaveBeenCalledTimes(1); - expect(next).toHaveBeenCalledWith(); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 401 })); }); - it('401s if the expected permissions are not included', async () => { - service.getUserForms = jest.fn().mockReturnValue([ + // TODO: This should be a 403, but bundle all breaking changes in a small PR. + it('401s if the permissions are a subset but not including everything', async () => { + service.getUserForms.mockReturnValueOnce([ { - formId: '123', - permissions: ['FORM_READ', 'SUBMISSION_READ', 'DESIGN_CREATE'], + formId: formId, + permissions: ['DESIGN_CREATE', 'FORM_READ'], }, ]); const req = getMockReq({ currentUser: {}, params: { - formId: '123', + formId: formId, }, }); const { res, next } = getMockRes(); - const mw = hasFormPermissions(['FORM_READ', 'SUBMISSION_DELETE', 'DESIGN_CREATE']); - await mw(req, res, next); + await hasFormPermissions(['DESIGN_CREATE', 'FORM_READ', 'SUBMISSION_DELETE'])(req, res, next); - expect(res.end).toHaveBeenCalledWith(expect.stringContaining('401')); - expect(next).toHaveBeenCalledTimes(0); + expect(service.getUserForms).toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 401 })); }); - it('401s if the expected permissions are not included (string, not array check)', async () => { - service.getUserForms = jest.fn().mockReturnValue([ + it('500s if the request has no current user', async () => { + const req = getMockReq({ + params: { formId: formId }, + }); + const { res, next } = getMockRes(); + + await hasFormPermissions(['FORM_READ'])(req, res, next); + + expect(service.getUserForms).toHaveBeenCalledTimes(0); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 500 })); + }); + + it('moves on if a valid API key user has already been set', async () => { + const req = getMockReq({ + apiUser: true, + params: { formId: formId }, + }); + const { res, next } = getMockRes(); + + await hasFormPermissions(['FORM_READ'])(req, res, next); + + expect(service.getUserForms).toHaveBeenCalledTimes(0); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); + }); + + it('moves on if the expected permissions are included', async () => { + service.getUserForms.mockReturnValueOnce([ { - formId: '123', - permissions: ['FORM_DELETE'], + formId: formId, + permissions: ['DESIGN_CREATE', 'FORM_READ', 'SUBMISSION_DELETE'], }, ]); const req = getMockReq({ currentUser: {}, params: { - formId: '123', + formId: formId, }, }); const { res, next } = getMockRes(); - const mw = hasFormPermissions('FORM_READ'); - await mw(req, res, next); + await hasFormPermissions(['DESIGN_CREATE', 'FORM_READ', 'SUBMISSION_DELETE'])(req, res, next); - expect(res.end).toHaveBeenCalledWith(expect.stringContaining('401')); - expect(next).toHaveBeenCalledTimes(0); + expect(service.getUserForms).toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); }); - it('moves on if the expected permissions are included', async () => { - service.getUserForms = jest.fn().mockReturnValue([ + it('moves on if the expected permissions are included with query formId', async () => { + service.getUserForms.mockReturnValueOnce([ { - formId: '123', + formId: formId, permissions: ['DESIGN_CREATE', 'FORM_READ', 'SUBMISSION_DELETE'], }, ]); const req = getMockReq({ currentUser: {}, - params: { - formId: '123', + query: { + formId: formId, }, }); const { res, next } = getMockRes(); - const mw = hasFormPermissions(['DESIGN_CREATE', 'FORM_READ', 'SUBMISSION_DELETE']); - await mw(req, res, next); + await hasFormPermissions(['DESIGN_CREATE', 'FORM_READ', 'SUBMISSION_DELETE'])(req, res, next); + expect(service.getUserForms).toHaveBeenCalled(); expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenCalledWith(); }); - it('moves on if a valid API key user has already been set', async () => { - const mw = hasFormPermissions(['abc']); - const nxt = jest.fn(); - const req = { - apiUser: 1, - }; + it('moves on if the user has deleted form access', async () => { + service.getUserForms.mockReturnValueOnce([]).mockReturnValueOnce([ + { + formId: formId, + permissions: ['FORM_READ'], + }, + ]); + const req = getMockReq({ + currentUser: {}, + params: { + formId: formId, + }, + }); + const { res, next } = getMockRes(); - await mw(req, testRes, nxt); - expect(nxt).toHaveBeenCalledTimes(1); - expect(nxt).toHaveBeenCalledWith(); + await hasFormPermissions(['FORM_READ'])(req, res, next); + + expect(service.getUserForms).toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); }); }); From 0e17a1e0f5175ed7de1ae113614c3be854998c48 Mon Sep 17 00:00:00 2001 From: Walter Moar Date: Fri, 23 Feb 2024 10:44:56 -0800 Subject: [PATCH 10/14] ci/update .deploy.yaml input arg message (#1296) --- .github/workflows/.deploy.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/.deploy.yaml b/.github/workflows/.deploy.yaml index 02ec935e7..6463683cb 100644 --- a/.github/workflows/.deploy.yaml +++ b/.github/workflows/.deploy.yaml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: pr-number: - description: Pull request number, leave blank for dev/test/prod deployment + description: Pull request number required: false type: string @@ -122,4 +122,4 @@ jobs: header: release message: | Release ${{ github.sha }} deployed at - number: ${{ github.event.inputs.pr-number }} \ No newline at end of file + number: ${{ github.event.inputs.pr-number }} From ed28eed9533d1268d542991e0fb26183360f3ee0 Mon Sep 17 00:00:00 2001 From: Walter Moar Date: Fri, 23 Feb 2024 10:53:30 -0800 Subject: [PATCH 11/14] ci/update .deploy.yaml checkout version (#1297) --- .github/workflows/.deploy.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/.deploy.yaml b/.github/workflows/.deploy.yaml index 6463683cb..f37356dcc 100644 --- a/.github/workflows/.deploy.yaml +++ b/.github/workflows/.deploy.yaml @@ -63,7 +63,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout repository from pull request - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ needs.set-vars.outputs.ref }} if: ${{ needs.set-vars.outputs.ref != '' }} @@ -93,12 +93,12 @@ jobs: timeout-minutes: 12 steps: - name: Checkout repository from pull request - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ needs.set-vars.outputs.ref }} if: ${{ needs.set-vars.outputs.ref != '' }} - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 if: ${{ needs.set-vars.outputs.ref == '' }} - name: Deploy to environment uses: ./.github/actions/deploy-to-environment From 93c5faa4491b8267e656bc978c2a90003b83938b Mon Sep 17 00:00:00 2001 From: Walter Moar Date: Fri, 23 Feb 2024 11:00:13 -0800 Subject: [PATCH 12/14] ci/update .deploy.yaml checkout version Missed one of them :( --- .github/workflows/.deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/.deploy.yaml b/.github/workflows/.deploy.yaml index f37356dcc..149bab13e 100644 --- a/.github/workflows/.deploy.yaml +++ b/.github/workflows/.deploy.yaml @@ -68,7 +68,7 @@ jobs: ref: ${{ needs.set-vars.outputs.ref }} if: ${{ needs.set-vars.outputs.ref != '' }} - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 if: ${{ needs.set-vars.outputs.ref == '' }} - name: Build & Push uses: ./.github/actions/build-push-container From 299e0731b17046036ad0cfbd5f581f57e27caca3 Mon Sep 17 00:00:00 2001 From: Walter Moar Date: Fri, 23 Feb 2024 11:12:19 -0800 Subject: [PATCH 13/14] ci/update build push checkout version --- .github/actions/build-push-container/action.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/build-push-container/action.yaml b/.github/actions/build-push-container/action.yaml index 59dcde5b3..c1e589f2b 100644 --- a/.github/actions/build-push-container/action.yaml +++ b/.github/actions/build-push-container/action.yaml @@ -69,12 +69,12 @@ runs: using: composite steps: - name: Checkout repository from pull request - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ inputs.ref }} if: ${{ inputs.ref != '' }} - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 if: ${{ inputs.ref == '' }} - name: Set variables From e15c93aa31ebc64f05b248ba46367766ca1f1396 Mon Sep 17 00:00:00 2001 From: Walter Moar Date: Fri, 23 Feb 2024 11:13:53 -0800 Subject: [PATCH 14/14] ci/update close-pr checkout version --- .github/workflows/.close-pr.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/.close-pr.yaml b/.github/workflows/.close-pr.yaml index 0c5f94f9f..bc0ac2d69 100644 --- a/.github/workflows/.close-pr.yaml +++ b/.github/workflows/.close-pr.yaml @@ -30,7 +30,7 @@ jobs: timeout-minutes: 12 steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Login to OpenShift Cluster uses: redhat-actions/oc-login@v1 with: @@ -48,4 +48,4 @@ jobs: with: header: release delete: true - number: ${{ github.event.inputs.pr-number }} \ No newline at end of file + number: ${{ github.event.inputs.pr-number }}