diff --git a/.aws/ecs-task.json b/.aws/ecs-task.json deleted file mode 100644 index 4360cec0a..000000000 --- a/.aws/ecs-task.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "executionRoleArn": "arn:aws:iam:::role/cal-itp-benefits-client-task-execution-role", - "taskRoleArn": "arn:aws:iam:::role/cal-itp-benefits-client-task-role", - "containerDefinitions": [ - { - "name": "cal-itp-benefits-client", - "logConfiguration": { - "logDriver": "awslogs", - "options": { - "awslogs-group": "/ecs/cal-itp-benefits-client", - "awslogs-region": "", - "awslogs-stream-prefix": "ecs" - } - }, - "portMappings": [ - { - "hostPort": 8000, - "protocol": "tcp", - "containerPort": 8000 - } - ], - "mountPoints": [ - { - "containerPath": "/home/calitp/app/config", - "sourceVolume": "cal-itp-config-volume" - } - ], - "environment": [], - "environmentFiles": [ - { - "value": "arn:aws:s3:::/.env", - "type": "s3" - } - ], - "secrets": [], - "essential": true, - "dependsOn": [ - { - "containerName": "cal-itp-benefits-client-config", - "condition": "SUCCESS" - } - ] - }, - { - "name": "cal-itp-benefits-client-config", - "logConfiguration": { - "logDriver": "awslogs", - "options": { - "awslogs-group": "/ecs/cal-itp-benefits-client", - "awslogs-region": "", - "awslogs-stream-prefix": "ecs" - } - }, - "essential": false, - "entryPoint": ["/bin/sh"], - "command": ["-c", "aws s3 cp s3://${AWS_BUCKET}/${CONFIG_FILE} /aws"], - "environmentFiles": [ - { - "value": "arn:aws:s3:::/.env", - "type": "s3" - } - ], - "secrets": [], - "mountPoints": [ - { - "containerPath": "/aws", - "sourceVolume": "cal-itp-config-volume" - } - ] - } - ], - "placementConstraints": [], - "memory": "1024", - "family": "cal-itp-benefits-client", - "requiresCompatibilities": ["FARGATE"], - "networkMode": "awsvpc", - "cpu": "512", - "volumes": [ - { - "name": "cal-itp-config-volume", - "host": {} - } - ] -} diff --git a/.aws/set-env.sh b/.aws/set-env.sh deleted file mode 100755 index f4caf9494..000000000 --- a/.aws/set-env.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -set -eu - -task_file=$1 - -# make replacements using env vars - -sed -i "s//$AWS_ACCOUNT/g" $task_file -sed -i "s//$AWS_BUCKET/g" $task_file -sed -i "s//$AWS_REGION/g" $task_file diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..f6d87f1c9 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit = benefits/core/migrations/* diff --git a/.devcontainer/.env.sample b/.devcontainer/.env.sample deleted file mode 100644 index 5dfc1546b..000000000 --- a/.devcontainer/.env.sample +++ /dev/null @@ -1,37 +0,0 @@ -# Docker Compose config -COMPOSE_PROJECT_NAME=benefits - -# Amplitude config -ANALYTICS_KEY=amplitude-api-key - -# AWS config -AWS_DEFAULT_REGION=us-west-2 -AWS_ACCESS_KEY_ID=access-key-id -AWS_SECRET_ACCESS_KEY=secret-access-key -AWS_BUCKET=bucket-name -CONFIG_FILE=??_*.json - -# Django config -DJANGO_ADMIN=false -DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,[::1] -DJANGO_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1 -DJANGO_CSP_CONNECT_SRC=https://example1.com,https://example2.com -DJANGO_CSP_FONT_SRC=https://example1.com,https://example2.com -DJANGO_CSP_FRAME_SRC=https://example1.com,https://example2.com -DJANGO_CSP_SCRIPT_SRC=https://example1.com,https://example2.com -DJANGO_CSP_STYLE_SRC=https://example1.com,https://example2.com -DJANGO_DB=django -DJANGO_DEBUG=true -DJANGO_INIT_PATH=fixtures/??_*.json -DJANGO_LOG_LEVEL=DEBUG -DJANGO_RECAPTCHA_API_URL=https://www.google.com/recaptcha/api.js -DJANGO_RECAPTCHA_SITE_KEY=recaptcha-site-key -DJANGO_RECAPTCHA_SECRET_KEY=recaptcha-secret-key -DJANGO_RECAPTCHA_VERIFY_URL=https://www.google.com/recaptcha/api/siteverify -DJANGO_SECRET_KEY=secret -DJANGO_RATE_LIMIT=0 -DJANGO_RATE_LIMIT_METHODS=GET,POST,PUT,DELETE -DJANGO_RATE_LIMIT_PERIOD=0 - -# tests config -CYPRESS_baseUrl=http://client:8000 diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index bc14b6dd7..bcfe0ceb8 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -2,13 +2,8 @@ FROM benefits_client:latest USER root -# install node.js -# see https://github.com/nodesource/distributions#installation-instructions - -RUN curl -fsSL https://deb.nodesource.com/setup_16.x | bash - -RUN apt-get install -qq nodejs npm libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev \ - libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb curl git jq ssh +# install Linux CLI tools for development +RUN apt-get install -qq curl jq ssh USER $USER @@ -17,3 +12,13 @@ RUN python -m pip install --upgrade pip && \ COPY docs/requirements.txt docs/requirements.txt RUN pip install -r docs/requirements.txt + +COPY tests/pytest/requirements.txt tests/pytest/requirements.txt +RUN pip install -r tests/pytest/requirements.txt + +# install pre-commit environments in throwaway Git repository +# https://stackoverflow.com/a/68758943 +COPY .pre-commit-config.yaml . +RUN git init . && \ + pre-commit install-hooks && \ + rm -rf .git diff --git a/.devcontainer/compose.yml b/.devcontainer/compose.yml index 97ef8652b..1a8d60e21 100644 --- a/.devcontainer/compose.yml +++ b/.devcontainer/compose.yml @@ -1,3 +1,4 @@ +name: benefits version: "3.8" services: @@ -6,10 +7,9 @@ services: image: benefits_client:latest env_file: .env ports: - - "8000" + - "${DJANGO_LOCAL_PORT:-8000}:8000" volumes: - - ../.aws/config:/home/calitp/app/config:ro - - ../fixtures:/home/calitp/app/fixtures:cached + - ../fixtures:/home/calitp/app/fixtures:ro dev: build: @@ -17,47 +17,25 @@ services: dockerfile: .devcontainer/Dockerfile image: benefits_client:dev env_file: .env - entrypoint: [] - command: sleep infinity + # https://code.visualstudio.com/docs/remote/create-dev-container#_use-docker-compose + entrypoint: sleep infinity depends_on: - server ports: - - "8000" + - "${DJANGO_LOCAL_PORT:-8000}:8000" volumes: - - ../:/home/calitp/app:cached + - ../:/home/calitp/app docs: image: benefits_client:dev entrypoint: mkdocs - command: serve --dev-addr "0.0.0.0:8000" + command: serve --dev-addr "0.0.0.0:8001" ports: - - "8000" + - "8001" volumes: - - ../:/home/calitp/app:cached + - ../:/home/calitp/app server: - image: ghcr.io/cal-itp/eligibility-server:main + image: ghcr.io/cal-itp/eligibility-server@sha256:337d5b2beb1e458980be49a778efd4a47f8daa8decc5e8329c0d528596e2f196 ports: - "5000" - - s3pull: - image: amazon/aws-cli - entrypoint: [ "/bin/sh" ] - command: [ "-c", "aws s3 sync s3://${AWS_BUCKET} ." ] - environment: - - AWS_ACCESS_KEY_ID - - AWS_SECRET_ACCESS_KEY - - AWS_DEFAULT_REGION - volumes: - - ../.aws/config:/aws - - s3push: - image: amazon/aws-cli - entrypoint: [ "/bin/sh" ] - command: [ "-c", "aws s3 sync . s3://${AWS_BUCKET}" ] - environment: - - AWS_ACCESS_KEY_ID - - AWS_SECRET_ACCESS_KEY - - AWS_DEFAULT_REGION - volumes: - - ../.aws/config:/aws diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6cfa3d5d9..fd9f93c3b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -32,6 +32,7 @@ "extensions": [ "batisteo.vscode-django", "eamodio.gitlens", + "mhutchie.git-graph", "ms-python.python", "ms-python.vscode-pylance", "esbenp.prettier-vscode" diff --git a/.devcontainer/postAttach.sh b/.devcontainer/postAttach.sh index 861833194..46a243aa6 100755 --- a/.devcontainer/postAttach.sh +++ b/.devcontainer/postAttach.sh @@ -1,11 +1,5 @@ #!/usr/bin/env bash set -eu -# initialize hook environments -pre-commit install --install-hooks --overwrite - -# manage commit-msg hooks -pre-commit install --hook-type commit-msg - -# install cypress -cd tests/cypress && npm install && npx cypress install +# initialize pre-commit +pre-commit install --overwrite diff --git a/.dockerignore b/.dockerignore index ec4774133..ceb086d9c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,3 @@ -.aws/ .devcontainer/ .git/ .github/ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..3e299db41 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# force LF for everyone +text eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..f31c93958 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# default to benefits-admin team +* @cal-itp/benefits-admin diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 000000000..6ef94a135 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,41 @@ +--- +name: Bug report +about: Report a bug +labels: bug +--- + +A clear and concise description of what the bug is. + +## To Reproduce + +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +## Expected behavior + +A clear and concise description of what you expected to happen. + +## Screenshots + +If applicable, add screenshots to help explain your problem. + +## Desktop (please complete the following information) + +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] + +## Smartphone (please complete the following information) + +- Device: [e.g. iPhone6] +- OS: [e.g. iOS8.1] +- Browser [e.g. stock browser, safari] +- Version [e.g. 22] + +## Additional context + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/deliverable.md b/.github/ISSUE_TEMPLATE/deliverable.md new file mode 100644 index 000000000..4cdb0f4cb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/deliverable.md @@ -0,0 +1,15 @@ +--- +name: Deliverable +about: Create a task for a non-code deliverable (e.g. Google doc) +labels: deliverable +--- + +## Acceptance Criteria + +- [ ] + +## Additional context + + + +## What is the definition of done? diff --git a/.github/ISSUE_TEMPLATE/design.md b/.github/ISSUE_TEMPLATE/design.md new file mode 100644 index 000000000..a80a58d1c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/design.md @@ -0,0 +1,6 @@ +--- +name: Design issue +about: Create a task for the UI/UX team +labels: design +assignees: srhhnry, Indiajar +--- diff --git a/.github/ISSUE_TEMPLATE/epic-decision-record.md b/.github/ISSUE_TEMPLATE/epic-decision-record.md new file mode 100644 index 000000000..ef8ac76e8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/epic-decision-record.md @@ -0,0 +1,57 @@ +--- +name: Epic / Decision Record +about: A basic structure for epics +labels: epic +--- + + + +**Date**: +**Writers**: +**Status**: + +## Background + + + +## Decision drivers + + + +## Considered options + + + +## Decision outcome + + + +## Acceptance criteria + +- [ ] Acceptance criteria + +## Definition of Done + +A clear and concise description of what "Done" means for this Epic. + +## User Stories + +- [ ] As a User Story, I want to be verifiable, so that this epic can be satisfied + +## Tasks + +- [ ] Code and non-code tasks that work towards a solution to to this epic + +## Implementation Ready Checklist + +- [ ] Acceptance criteria defined +- [ ] Team understands acceptance criteria +- [ ] Acceptance criteria is verifiable +- [ ] User workflows defined / designed +- [ ] External / 3rd Party dependencies identified + +## Links, resources + +- Other links here diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md new file mode 100644 index 000000000..b65ceda15 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.md @@ -0,0 +1,16 @@ +--- +name: Engineering task +about: Create a task for the Engineering team +--- + +A clear and concise description of the task. + +## Acceptance Criteria + + + +- [ ] + +## Additional context + + diff --git a/.github/ISSUE_TEMPLATE/user-story.md b/.github/ISSUE_TEMPLATE/user-story.md new file mode 100644 index 000000000..50230798d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/user-story.md @@ -0,0 +1,23 @@ +--- +name: User story +about: Create a user story for a feature. +labels: user story +title: As a [persona], I [want to], [so that]. +--- + + +## Describe alternatives you've considered + +A clear and concise description of any alternative solutions or features you've considered. + +## Acceptance criteria + +- [ ] + +## Definition of "Done" + +A clear and concise description of what "Done" means for this User Story. + +## Links to user research, designs and resources + + diff --git a/.github/workflows/.python-version b/.github/workflows/.python-version new file mode 100644 index 000000000..bd28b9c5c --- /dev/null +++ b/.github/workflows/.python-version @@ -0,0 +1 @@ +3.9 diff --git a/.github/workflows/add-to-project-dependabot.yml b/.github/workflows/add-to-project-dependabot.yml new file mode 100644 index 000000000..be934daec --- /dev/null +++ b/.github/workflows/add-to-project-dependabot.yml @@ -0,0 +1,16 @@ +name: "Project triage: Dependabot" + +on: + pull_request: + types: [opened] + +jobs: + add-to-project-dependabot: + runs-on: ubuntu-latest + # see https://docs.github.com/en/code-security/dependabot/working-with-dependabot/automating-dependabot-with-github-actions#responding-to-events + if: github.actor == 'dependabot[bot]' + steps: + - uses: actions/add-to-project@main + with: + project-url: https://github.com/orgs/cal-itp/projects/${{ secrets.GH_PROJECT }} + github-token: ${{ secrets.GH_PROJECTS_TOKEN }} diff --git a/.github/workflows/add-to-project-issues.yml b/.github/workflows/add-to-project-issues.yml new file mode 100644 index 000000000..c72696ff5 --- /dev/null +++ b/.github/workflows/add-to-project-issues.yml @@ -0,0 +1,14 @@ +name: "Project triage: Issues" + +on: + issues: + types: [opened, transferred] + +jobs: + add-to-project-issues: + runs-on: ubuntu-latest + steps: + - uses: actions/add-to-project@main + with: + project-url: https://github.com/orgs/cal-itp/projects/${{ secrets.GH_PROJECT }} + github-token: ${{ secrets.GH_PROJECTS_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 17cdd37cf..d55c255a6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,6 +2,7 @@ name: CodeQL on: + push: pull_request: branches: [ dev, test, prod ] schedule: @@ -23,10 +24,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -35,4 +36,4 @@ jobs: # queries: ./path/to/local/query, your-org/your-repo/queries@main - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 312e839ee..81946d59d 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -1,4 +1,4 @@ -name: Deploy to Amazon ECS (dev) +name: Deploy (dev) on: workflow_dispatch: @@ -6,7 +6,6 @@ on: branches: - dev paths: - - '.aws/**' - '.github/workflows/deploy-*.yml' - 'benefits/**' - 'bin/**' @@ -27,39 +26,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ secrets.AWS_REGION }} - - - name: AWS Login to Amazon ECR - id: aws-login-ecr - uses: aws-actions/amazon-ecr-login@v1 - - - name: Define image paths - id: define-image-paths - env: - ECR_REGISTRY: ${{ steps.aws-login-ecr.outputs.registry }} - GIT_SHA: ${{ github.sha }} - AWS_CLI_TAG: ${{ secrets.AWS_CLI_TAG }} - run: | - echo "::set-output name=client::$ECR_REGISTRY/cal-itp-benefits-client:$GIT_SHA" - echo "::set-output name=config::$ECR_REGISTRY/aws-cli:$AWS_CLI_TAG" - - - name: Docker Login to Amazon ECR - id: docker-login-ecr - uses: docker/login-action@v1 - with: - registry: ${{ steps.aws-login-ecr.outputs.registry }} - username: ${{ secrets.AWS_ACCESS_KEY_ID }} - password: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + uses: actions/checkout@v3 - name: Docker Login to GitHub Container Registry - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} @@ -67,57 +37,16 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v1 - - - name: Build, tag, and push image to Amazon ECR - id: build-client-image - uses: docker/build-push-action@v2 - with: - builder: ${{ steps.buildx.outputs.name }} - cache-from: type=gha,scope=cal-itp - cache-to: type=gha,scope=cal-itp,mode=max - context: . - push: true - tags: ${{ steps.define-image-paths.outputs.client }} + uses: docker/setup-buildx-action@v2 - name: Build, tag, and push image to GitHub Container Registry - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: builder: ${{ steps.buildx.outputs.name }} cache-from: type=gha,scope=cal-itp cache-to: type=gha,scope=cal-itp,mode=max context: . push: true - tags: ghcr.io/${{github.repository}}:dev - - - name: Add environment-specific config to ECS task - env: - AWS_ACCOUNT: ${{ secrets.AWS_ACCOUNT }} - AWS_BUCKET: ${{ secrets.AWS_BUCKET }} - AWS_REGION: ${{ secrets.AWS_REGION }} - run: | - .aws/set-env.sh .aws/ecs-task.json - - - name: Fill in client image ID in ECS task - id: client-task-def - uses: aws-actions/amazon-ecs-render-task-definition@v1 - with: - task-definition: .aws/ecs-task.json - container-name: cal-itp-benefits-client - image: ${{ steps.define-image-paths.outputs.client }} - - - name: Fill in config image ID in ECS task - id: config-task-def - uses: aws-actions/amazon-ecs-render-task-definition@v1 - with: - task-definition: ${{ steps.client-task-def.outputs.task-definition }} - container-name: cal-itp-benefits-client-config - image: ${{ steps.define-image-paths.outputs.config }} - - - name: Deploy Amazon ECS task definition - uses: aws-actions/amazon-ecs-deploy-task-definition@v1 - with: - task-definition: ${{ steps.config-task-def.outputs.task-definition }} - service: cal-itp-benefits-client - cluster: cal-itp-clientCluster - wait-for-service-stability: true + tags: | + ghcr.io/${{ github.repository }}:dev + ghcr.io/${{ github.repository }}:${{ github.sha }} diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 0bf510e31..f8d17edab 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -1,4 +1,4 @@ -name: Deploy to Amazon ECS (prod) +name: Deploy (prod) on: workflow_dispatch: @@ -18,39 +18,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ secrets.AWS_REGION }} - - - name: AWS Login to Amazon ECR - id: aws-login-ecr - uses: aws-actions/amazon-ecr-login@v1 - - - name: Define image paths - id: define-image-paths - env: - ECR_REGISTRY: ${{ steps.aws-login-ecr.outputs.registry }} - GIT_SHA: ${{ github.sha }} - AWS_CLI_TAG: ${{ secrets.AWS_CLI_TAG }} - run: | - echo "::set-output name=client::$ECR_REGISTRY/cal-itp-benefits-client:$GIT_SHA" - echo "::set-output name=config::$ECR_REGISTRY/aws-cli:$AWS_CLI_TAG" - - - name: Docker Login to Amazon ECR - id: docker-login-ecr - uses: docker/login-action@v1 - with: - registry: ${{ steps.aws-login-ecr.outputs.registry }} - username: ${{ secrets.AWS_ACCESS_KEY_ID }} - password: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + uses: actions/checkout@v3 - name: Docker Login to GitHub Container Registry - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} @@ -58,57 +29,16 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v1 - - - name: Build, tag, and push image to Amazon ECR - id: build-client-image - uses: docker/build-push-action@v2 - with: - builder: ${{ steps.buildx.outputs.name }} - cache-from: type=gha,scope=cal-itp - cache-to: type=gha,scope=cal-itp,mode=max - context: . - push: true - tags: ${{ steps.define-image-paths.outputs.client }} + uses: docker/setup-buildx-action@v2 - name: Build, tag, and push image to GitHub Container Registry - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: builder: ${{ steps.buildx.outputs.name }} cache-from: type=gha,scope=cal-itp cache-to: type=gha,scope=cal-itp,mode=max context: . push: true - tags: ghcr.io/${{github.repository}}:prod - - - name: Add environment-specific config to ECS task - env: - AWS_ACCOUNT: ${{ secrets.AWS_ACCOUNT }} - AWS_BUCKET: ${{ secrets.AWS_BUCKET }} - AWS_REGION: ${{ secrets.AWS_REGION }} - run: | - .aws/set-env.sh .aws/ecs-task.json - - - name: Fill in client image ID in ECS task - id: client-task-def - uses: aws-actions/amazon-ecs-render-task-definition@v1 - with: - task-definition: .aws/ecs-task.json - container-name: cal-itp-benefits-client - image: ${{ steps.define-image-paths.outputs.client }} - - - name: Fill in config image ID in ECS task - id: config-task-def - uses: aws-actions/amazon-ecs-render-task-definition@v1 - with: - task-definition: ${{ steps.client-task-def.outputs.task-definition }} - container-name: cal-itp-benefits-client-config - image: ${{ steps.define-image-paths.outputs.config }} - - - name: Deploy Amazon ECS task definition - uses: aws-actions/amazon-ecs-deploy-task-definition@v1 - with: - task-definition: ${{ steps.config-task-def.outputs.task-definition }} - service: cal-itp-benefits-client - cluster: cal-itp-clientCluster - wait-for-service-stability: true + tags: | + ghcr.io/${{ github.repository }}:prod + ghcr.io/${{ github.repository }}:${{ github.sha }} diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml index 7cac8ef87..ce5358580 100644 --- a/.github/workflows/deploy-test.yml +++ b/.github/workflows/deploy-test.yml @@ -1,4 +1,4 @@ -name: Deploy to Amazon ECS (test) +name: Deploy (test) on: workflow_dispatch: @@ -18,39 +18,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ secrets.AWS_REGION }} - - - name: AWS Login to Amazon ECR - id: aws-login-ecr - uses: aws-actions/amazon-ecr-login@v1 - - - name: Define image paths - id: define-image-paths - env: - ECR_REGISTRY: ${{ steps.aws-login-ecr.outputs.registry }} - GIT_SHA: ${{ github.sha }} - AWS_CLI_TAG: ${{ secrets.AWS_CLI_TAG }} - run: | - echo "::set-output name=client::$ECR_REGISTRY/cal-itp-benefits-client:$GIT_SHA" - echo "::set-output name=config::$ECR_REGISTRY/aws-cli:$AWS_CLI_TAG" - - - name: Docker Login to Amazon ECR - id: docker-login-ecr - uses: docker/login-action@v1 - with: - registry: ${{ steps.aws-login-ecr.outputs.registry }} - username: ${{ secrets.AWS_ACCESS_KEY_ID }} - password: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + uses: actions/checkout@v3 - name: Docker Login to GitHub Container Registry - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} @@ -58,57 +29,16 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v1 - - - name: Build, tag, and push image to Amazon ECR - id: build-client-image - uses: docker/build-push-action@v2 - with: - builder: ${{ steps.buildx.outputs.name }} - cache-from: type=gha,scope=cal-itp - cache-to: type=gha,scope=cal-itp,mode=max - context: . - push: true - tags: ${{ steps.define-image-paths.outputs.client }} + uses: docker/setup-buildx-action@v2 - name: Build, tag, and push image to GitHub Container Registry - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: builder: ${{ steps.buildx.outputs.name }} cache-from: type=gha,scope=cal-itp cache-to: type=gha,scope=cal-itp,mode=max context: . push: true - tags: ghcr.io/${{github.repository}}:test - - - name: Add environment-specific config to ECS task - env: - AWS_ACCOUNT: ${{ secrets.AWS_ACCOUNT }} - AWS_BUCKET: ${{ secrets.AWS_BUCKET }} - AWS_REGION: ${{ secrets.AWS_REGION }} - run: | - .aws/set-env.sh .aws/ecs-task.json - - - name: Fill in client image ID in ECS task - id: client-task-def - uses: aws-actions/amazon-ecs-render-task-definition@v1 - with: - task-definition: .aws/ecs-task.json - container-name: cal-itp-benefits-client - image: ${{ steps.define-image-paths.outputs.client }} - - - name: Fill in config image ID in ECS task - id: config-task-def - uses: aws-actions/amazon-ecs-render-task-definition@v1 - with: - task-definition: ${{ steps.client-task-def.outputs.task-definition }} - container-name: cal-itp-benefits-client-config - image: ${{ steps.define-image-paths.outputs.config }} - - - name: Deploy Amazon ECS task definition - uses: aws-actions/amazon-ecs-deploy-task-definition@v1 - with: - task-definition: ${{ steps.config-task-def.outputs.task-definition }} - service: cal-itp-benefits-client - cluster: cal-itp-clientCluster - wait-for-service-stability: true + tags: | + ghcr.io/${{ github.repository }}:test + ghcr.io/${{ github.repository }}:${{ github.sha }} diff --git a/.github/workflows/labeler-actions.yml b/.github/workflows/labeler-actions.yml new file mode 100644 index 000000000..bcf210344 --- /dev/null +++ b/.github/workflows/labeler-actions.yml @@ -0,0 +1,18 @@ +name: Label actions + +on: + pull_request: + types: [opened] + paths: + - '.github/dependabot.yml' + - '.github/lighthouserc.json' + - '.github/workflows/**' + +jobs: + label-actions: + runs-on: ubuntu-latest + steps: + - name: add-label + uses: andymckay/labeler@master + with: + add-labels: "actions" diff --git a/.github/workflows/labeler-deploy-dev.yml b/.github/workflows/labeler-deploy-dev.yml index b4c3f5a24..2e2f060a3 100644 --- a/.github/workflows/labeler-deploy-dev.yml +++ b/.github/workflows/labeler-deploy-dev.yml @@ -5,7 +5,6 @@ on: branches: [dev] types: [opened] paths: - - '.aws/**' - '.github/workflows/deploy-*.yml' - 'benefits/**' - 'bin/**' diff --git a/.github/workflows/labeler-infrastructure.yml b/.github/workflows/labeler-infrastructure.yml new file mode 100644 index 000000000..bf6090286 --- /dev/null +++ b/.github/workflows/labeler-infrastructure.yml @@ -0,0 +1,18 @@ +name: Label infrastructure + +on: + pull_request: + branches: [dev] + types: [opened] + paths: + - 'docs/deployment/**' + - 'terraform/**' + +jobs: + label-docs: + runs-on: ubuntu-latest + steps: + - name: add-label + uses: andymckay/labeler@master + with: + add-labels: "infrastructure" diff --git a/.github/workflows/labeler-tests.yml b/.github/workflows/labeler-tests.yml new file mode 100644 index 000000000..d31699a58 --- /dev/null +++ b/.github/workflows/labeler-tests.yml @@ -0,0 +1,17 @@ +name: Label tests + +on: + pull_request: + types: [opened] + paths: + - 'tests/**' + - '.github/workflows/tests-*.yml' + +jobs: + label-tests: + runs-on: ubuntu-latest + steps: + - name: add-label + uses: andymckay/labeler@master + with: + add-labels: "tests" diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index 23129b140..c6c525d0b 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -1,6 +1,13 @@ name: Publish docs on: workflow_dispatch: + workflow_run: + workflows: + - Pytest + types: + - completed + branches: + - dev push: branches: - dev @@ -13,9 +20,19 @@ jobs: docs: name: Publish docs runs-on: ubuntu-latest + if: github.event.workflow_run == null || github.event.workflow_run.conclusion == 'success' steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 + + - name: Download coverage report + uses: dawidd6/action-download-artifact@v2 + with: + workflow: tests-pytest.yml + branch: dev + event: push + name: coverage-report + path: docs/tests/coverage - name: Deploy docs uses: mhausenblas/mkdocs-deploy-gh-pages@master diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 3a5a14618..37985058e 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -8,6 +8,10 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - uses: pre-commit/action@v2.0.3 + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v4 + with: + python-version-file: .github/workflows/.python-version + + - uses: pre-commit/action@v3.0.0 diff --git a/.github/workflows/tests-feature.yml b/.github/workflows/tests-feature.yml index 82935c172..c3e08bb3a 100644 --- a/.github/workflows/tests-feature.yml +++ b/.github/workflows/tests-feature.yml @@ -12,15 +12,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Build Dockerfile id: build-client-image - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: builder: ${{ steps.buildx.outputs.name }} cache-from: type=gha,scope=cal-itp @@ -55,7 +55,7 @@ jobs: working-directory: tests/cypress wait-on: http://localhost:8000/healthcheck - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v3 if: failure() with: name: cypress-screenshots diff --git a/.github/workflows/tests-pytest.yml b/.github/workflows/tests-pytest.yml new file mode 100644 index 000000000..b7490a859 --- /dev/null +++ b/.github/workflows/tests-pytest.yml @@ -0,0 +1,36 @@ +name: Pytest + +on: push + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v3 + + - name: Install system packages + run: | + sudo apt-get update -y + sudo apt-get install -y gettext + + - uses: actions/setup-python@v4 + with: + python-version-file: .github/workflows/.python-version + cache: pip + cache-dependency-path: '**/requirements.txt' + + - name: Install Python dependencies + run: pip install -r requirements.txt -r tests/pytest/requirements.txt + + - name: Run setup + run: ./bin/init.sh + + - name: Run tests + run: ./tests/pytest/run.sh + + - name: Upload coverage report + uses: actions/upload-artifact@v3 + with: + name: coverage-report + path: benefits/static/coverage diff --git a/.github/workflows/tests-ui.yml b/.github/workflows/tests-ui.yml index f858582a5..f892635f1 100644 --- a/.github/workflows/tests-ui.yml +++ b/.github/workflows/tests-ui.yml @@ -14,15 +14,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Build Dockerfile id: build-client-image - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: builder: ${{ steps.buildx.outputs.name }} cache-from: type=gha,scope=cal-itp @@ -36,7 +36,6 @@ jobs: run: | docker run \ --detach \ - --env-file tests/cypress/.env.tests \ -p 8000:8000 \ -v ${{ github.workspace }}/fixtures:/home/calitp/app/fixtures \ benefits_client:${{ github.sha }} @@ -57,14 +56,20 @@ jobs: working-directory: tests/cypress wait-on: http://localhost:8000/healthcheck - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v3 if: failure() with: name: cypress-screenshots path: tests/cypress/screenshots + - uses: actions/upload-artifact@v3 + if: failure() + with: + name: cypress-videos + path: tests/cypress/videos + - name: Run Lighthouse tests for a11y - uses: treosh/lighthouse-ci-action@v8 + uses: treosh/lighthouse-ci-action@9.3.0 with: urls: | http://localhost:8000 diff --git a/.gitignore b/.gitignore index ccfb562da..c2e99a47b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ fixtures/*.json static/ !benefits/static __pycache__/ +.coverage +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 11488defe..ae42079e5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,6 @@ +default_install_hook_types: + - pre-commit + - commit-msg repos: - repo: https://github.com/compilerla/conventional-pre-commit rev: v1.2.0 @@ -17,7 +20,7 @@ repos: - id: check-added-large-files - repo: https://github.com/psf/black - rev: 20.8b1 + rev: 22.3.0 hooks: - id: black types: diff --git a/.vscode/launch.json b/.vscode/launch.json index 7bf63376d..c4bee8381 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,10 @@ "request": "launch", "program": "${workspaceFolder}/manage.py", "args": ["runserver", "--insecure", "0.0.0.0:8000"], - "django": true + "django": true, + "env": { + "DJANGO_DEBUG": "true" + } } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 628bfc319..9c0ea57f4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,5 +27,10 @@ "python.languageServer": "Pylance", "[python]": { "editor.formatOnSave": true - } + }, + "python.testing.pytestArgs": [ + "tests/pytest", "--import-mode=importlib" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true } diff --git a/Dockerfile b/Dockerfile index dfc58e725..f6e883834 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,10 +24,18 @@ RUN useradd --create-home --shell /bin/bash $USER && \ chown -R $USER /home/$USER && \ # install server components apt-get update && \ - apt-get install -qq --no-install-recommends gettext nginx + apt-get install -qq --no-install-recommends gettext nginx && \ + # install git for use when installing dependencies from VCS (https://pip.pypa.io/en/stable/topics/vcs-support/) + apt-get install -qq --no-install-recommends git # enter app directory WORKDIR /home/$USER/app +# switch to non-root $USER +USER $USER + +# install python dependencies +COPY requirements.txt requirements.txt +RUN pip install -r requirements.txt # copy config files COPY gunicorn.conf.py gunicorn.conf.py @@ -36,23 +44,18 @@ COPY manage.py manage.py # overwrite default nginx.conf COPY nginx.conf /etc/nginx/nginx.conf +# update PATH for local pip installs +ENV PATH "$PATH:/home/$USER/.local/bin" + # copy source files COPY bin/ bin/ COPY benefits/ benefits/ # ensure $USER can compile messages in the locale directories +USER root RUN chmod -R 777 benefits/locale - -# switch to non-root $USER USER $USER -# update PATH for local pip installs -ENV PATH "$PATH:/home/$USER/.local/bin" - -# install python dependencies -COPY requirements.txt requirements.txt -RUN pip install -r requirements.txt - # configure container executable ENTRYPOINT ["/bin/bash"] CMD ["bin/start.sh"] diff --git a/benefits/core/__init__.py b/benefits/core/__init__.py index 1da91736b..4f8b9243d 100644 --- a/benefits/core/__init__.py +++ b/benefits/core/__init__.py @@ -8,6 +8,3 @@ class CoreAppConfig(AppConfig): name = "benefits.core" label = "core" verbose_name = "Core" - - -default_app_config = "benefits.core.CoreAppConfig" diff --git a/benefits/core/admin.py b/benefits/core/admin.py index 121c7f621..8de5ac2ec 100644 --- a/benefits/core/admin.py +++ b/benefits/core/admin.py @@ -1,10 +1,10 @@ """ The core application: Admin interface configuration. """ -from benefits.settings import ADMIN +from django.conf import settings -if ADMIN: +if settings.ADMIN: import logging from django.contrib import admin from . import models diff --git a/benefits/core/analytics.py b/benefits/core/analytics.py index 80bf79d1d..f549c7c7d 100644 --- a/benefits/core/analytics.py +++ b/benefits/core/analytics.py @@ -8,10 +8,10 @@ import time import uuid +from django.conf import settings import requests from benefits import VERSION -from benefits.settings import ANALYTICS_KEY from . import session @@ -19,19 +19,24 @@ class Event: + """Base analytics event of a given type, including attributes from request's session.""" + _counter = itertools.count() _domain_re = re.compile(r"^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:\/\n?]+)", re.IGNORECASE) def __init__(self, request, event_type, **kwargs): - """Analytics event of the given type, including attributes from request's session.""" self.app_version = VERSION + # device_id is generated based on the user_id, and both are set explicitly (per session) self.device_id = session.did(request) self.event_properties = {} self.event_type = str(event_type).lower() self.insert_id = str(uuid.uuid4()) self.language = session.language(request) + # Amplitude tracks sessions using the start time as the session_id self.session_id = session.start(request) self.time = int(time.time() * 1000) + # Although Amplitude advises *against* setting user_id for anonymous users, here a value is set on anonymous + # users anyway, as the users never sign-in and become de-anonymized to this app / Amplitude. self.user_id = session.uid(request) self.user_properties = {} self.__dict__.update(kwargs) @@ -65,21 +70,24 @@ def update_user_properties(self, **kwargs): class ViewedPageEvent(Event): + """Analytics event representing a single page view.""" + def __init__(self, request): - """Analytics event representing a single page view.""" super().__init__(request, "viewed page") class ChangedLanguageEvent(Event): + """Analytics event representing a change in the app's language.""" + def __init__(self, request, new_lang): - """Analytics event representing a change in the app's language.""" super().__init__(request, "changed language") self.update_event_properties(language=new_lang) class Client: + """Analytics API client""" + def __init__(self, api_key): - """Analytics API client""" self.api_key = api_key self.headers = {"Accept": "*/*", "Content-type": "application/json"} self.url = "https://api2.amplitude.com/2/httpapi" @@ -119,9 +127,12 @@ def send(self, event): logger.error(f"Failed to send event: {event}") +client = Client(settings.ANALYTICS_KEY) + + def send_event(event): """Send an analytics event.""" if isinstance(event, Event): - Client(ANALYTICS_KEY).send(event) + client.send(event) else: raise ValueError("event must be an Event instance") diff --git a/benefits/core/context_processors.py b/benefits/core/context_processors.py index da9e093f2..4e0140bf6 100644 --- a/benefits/core/context_processors.py +++ b/benefits/core/context_processors.py @@ -1,14 +1,36 @@ """ The core application: context processors for enriching request context data. """ -from benefits.settings import ANALYTICS_KEY, RECAPTCHA_API_URL, RECAPTCHA_ENABLED, RECAPTCHA_SITE_KEY +from django.conf import settings +from django.urls import reverse from . import session def analytics(request): """Context processor adds some analytics information to request context.""" - return {"analytics": {"api_key": ANALYTICS_KEY, "uid": session.uid(request), "did": session.did(request)}} + return {"analytics": {"api_key": settings.ANALYTICS_KEY, "uid": session.uid(request), "did": session.did(request)}} + + +def authentication(request): + """Context processor adds authentication information to request context.""" + verifier = session.verifier(request) + + if verifier: + data = { + "required": verifier.requires_authentication, + "logged_in": session.logged_in(request), + "sign_out_route": reverse("oauth:logout"), + } + + if verifier.requires_authentication: + auth_provider = verifier.auth_provider + data["sign_in_button_label"] = auth_provider.sign_in_button_label + data["sign_out_button_label"] = auth_provider.sign_out_button_label + + return {"authentication": data} + else: + return {} def debug(request): @@ -18,4 +40,10 @@ def debug(request): def recaptcha(request): """Context processor adds recaptcha information to request context.""" - return {"recaptcha": {"api_url": RECAPTCHA_API_URL, "enabled": RECAPTCHA_ENABLED, "site_key": RECAPTCHA_SITE_KEY}} + return { + "recaptcha": { + "api_url": settings.RECAPTCHA_API_URL, + "enabled": settings.RECAPTCHA_ENABLED, + "site_key": settings.RECAPTCHA_SITE_KEY, + } + } diff --git a/benefits/core/middleware.py b/benefits/core/middleware.py index d8adbadda..94fb659fa 100644 --- a/benefits/core/middleware.py +++ b/benefits/core/middleware.py @@ -4,17 +4,19 @@ import logging import time +from django.conf import settings from django.http import HttpResponse, HttpResponseBadRequest +from django.shortcuts import redirect from django.template import loader from django.utils.decorators import decorator_from_middleware from django.utils.deprecation import MiddlewareMixin from django.views import i18n -from benefits.settings import RATE_LIMIT, RATE_LIMIT_METHODS, RATE_LIMIT_PERIOD, DEBUG from . import analytics, session, viewmodels logger = logging.getLogger(__name__) +HEALTHCHECK_PATH = "/healthcheck" class AgencySessionRequired(MiddlewareMixin): @@ -32,11 +34,11 @@ class RateLimit(MiddlewareMixin): """Middleware checks settings and session to ensure rate limit is respected.""" def process_request(self, request): - if any((RATE_LIMIT < 1, len(RATE_LIMIT_METHODS) < 1, RATE_LIMIT_PERIOD < 1)): - logger.debug("RATE_LIMIT, RATE_LIMIT_METHODS, or RATE_LIMIT_PERIOD are not configured") + if not settings.RATE_LIMIT_ENABLED: + logger.debug("Rate Limiting is not configured") return None - if request.method in RATE_LIMIT_METHODS: + if request.method in settings.RATE_LIMIT_METHODS: session.increment_rate_limit_counter(request) else: # bail early if the request method doesn't match @@ -46,9 +48,9 @@ def process_request(self, request): reset_time = session.rate_limit_time(request) now = int(time.time()) - if counter > RATE_LIMIT: + if counter > settings.RATE_LIMIT: if reset_time > now: - logger.warn("Rate limit exceeded") + logger.warning("Rate limit exceeded") home = viewmodels.Button.home(request) page = viewmodels.ErrorPage.error( title="Rate limit error", @@ -80,7 +82,7 @@ class DebugSession(MiddlewareMixin): """Middleware to configure debug context in the request session.""" def process_request(self, request): - session.update(request, debug=DEBUG) + session.update(request, debug=settings.DEBUG) return None @@ -91,11 +93,22 @@ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): - if request.path == "/healthcheck": + if request.path == HEALTHCHECK_PATH: return HttpResponse("Healthy", content_type="text/plain") return self.get_response(request) +class VerifierSessionRequired(MiddlewareMixin): + """Middleware raises an exception for sessions lacking an eligibility verifier configuration.""" + + def process_request(self, request): + if session.verifier(request): + logger.debug("Session configured with eligibility verifier") + return None + else: + raise AttributeError("Session not configured with eligibility verifier") + + class ViewedPageEvent(MiddlewareMixin): """Middleware sends an analytics event for page views.""" @@ -121,3 +134,16 @@ def process_view(self, request, view_func, view_args, view_kwargs): event = analytics.ChangedLanguageEvent(request, new_lang) analytics.send_event(event) return None + + +class LoginRequired(MiddlewareMixin): + """Middleware that checks whether a user is logged in.""" + + def process_view(self, request, view_func, view_args, view_kwargs): + # only require login if verifier requires it + verifier = session.verifier(request) + if not verifier or not verifier.requires_authentication or session.logged_in(request): + # pass through + return None + + return redirect("oauth:login") diff --git a/benefits/core/migrations/0001_initial.py b/benefits/core/migrations/0001_initial.py index e3222a526..ac83c0da4 100644 --- a/benefits/core/migrations/0001_initial.py +++ b/benefits/core/migrations/0001_initial.py @@ -1,4 +1,7 @@ +# Generated by Django 3.2.13 on 2022-06-13 23:20 + from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): @@ -9,43 +12,79 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name="EligibilityType", + name="AuthProvider", fields=[ - ("id", models.AutoField(primary_key=True, serialize=False, verbose_name="ID")), - ("name", models.TextField()), - ("label", models.TextField()), - ("group_id", models.TextField()), + ("id", models.AutoField(primary_key=True, serialize=False)), + ("sign_in_button_label", models.TextField()), + ("sign_out_button_label", models.TextField()), ], ), migrations.CreateModel( - name="PemData", + name="EligibilityType", fields=[ ("id", models.AutoField(primary_key=True, serialize=False)), - ("text", models.TextField(help_text="The data in utf-8 encoded PEM text format.")), - ("label", models.TextField(help_text="Human description of the PEM data.")), + ("name", models.TextField()), + ("label", models.TextField()), + ("group_id", models.TextField()), ], ), migrations.CreateModel( name="EligibilityVerifier", fields=[ - ("id", models.AutoField(primary_key=True, serialize=False, verbose_name="ID")), + ("id", models.AutoField(primary_key=True, serialize=False)), ("name", models.TextField()), ("api_url", models.TextField()), ("api_auth_header", models.TextField()), ("api_auth_key", models.TextField()), - # fmt: off - ("public_key", models.ForeignKey(help_text="The Verifier's public key, used to encrypt requests targeted at this Verifier and to verify signed responses from this verifier.", on_delete=models.deletion.PROTECT, related_name="+", to="core.PemData")), # noqa: E501 - ("jwe_cek_enc", models.TextField(help_text="The JWE-compatible Content Encryption Key (CEK) key-length and mode")), # noqa: E501 + ( + "jwe_cek_enc", + models.TextField(help_text="The JWE-compatible Content Encryption Key (CEK) key-length and mode"), + ), ("jwe_encryption_alg", models.TextField(help_text="The JWE-compatible encryption algorithm")), - # fmt: on ("jws_signing_alg", models.TextField(help_text="The JWS-compatible signing algorithm")), + ("auth_scope", models.TextField(null=True)), + ("auth_claim", models.TextField(null=True)), + ("selection_label", models.TextField()), + ("selection_label_description", models.TextField(null=True)), + ("start_content_title", models.TextField()), + ("start_item_name", models.TextField()), + ("start_item_description", models.TextField()), + ("start_blurb", models.TextField()), + ("form_title", models.TextField()), + ("form_content_title", models.TextField()), + ("form_blurb", models.TextField()), + ("form_sub_label", models.TextField()), + ("form_sub_placeholder", models.TextField()), + ( + "form_sub_pattern", + models.TextField( + help_text="A regular expression used to validate the 'sub' API field before sending to this verifier", + null=True, + ), + ), + ("form_name_label", models.TextField()), + ("form_name_placeholder", models.TextField()), + ( + "form_name_max_length", + models.PositiveSmallIntegerField( + help_text="The maximum length accepted for the 'name' API field before sending to this verifier", + null=True, + ), + ), + ("unverified_title", models.TextField()), + ("unverified_content_title", models.TextField()), + ("unverified_blurb", models.TextField()), + ( + "auth_provider", + models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to="core.authprovider"), + ), ("eligibility_types", models.ManyToManyField(to="core.EligibilityType")), ], ), migrations.CreateModel( name="PaymentProcessor", fields=[ - ("id", models.AutoField(primary_key=True, serialize=False, verbose_name="ID")), + ("id", models.AutoField(primary_key=True, serialize=False)), ("name", models.TextField()), ("api_base_url", models.TextField()), ("api_access_token_endpoint", models.TextField()), @@ -54,20 +93,23 @@ class Migration(migrations.Migration): ("card_tokenize_url", models.TextField()), ("card_tokenize_func", models.TextField()), ("card_tokenize_env", models.TextField()), - # fmt: off - ("client_cert", models.ForeignKey(help_text="The certificate used for client certificate authentication to the API.", on_delete=models.deletion.PROTECT, related_name="+", to="core.PemData")), # noqa: E501 - ("client_cert_private_key", models.ForeignKey(help_text="The private key used to sign the certificate.", on_delete=models.deletion.PROTECT, related_name="+", to="core.PemData")), # noqa: E501 - ("client_cert_root_ca", models.ForeignKey(help_text="The root CA bundle used to verify the server.", on_delete=models.deletion.PROTECT, related_name="+", to="core.PemData")), # noqa: E501 ("customer_endpoint", models.TextField()), - # fmt: on ("customers_endpoint", models.TextField()), ("group_endpoint", models.TextField()), ], ), + migrations.CreateModel( + name="PemData", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("text", models.TextField(help_text="The data in utf-8 encoded PEM text format.")), + ("label", models.TextField(help_text="Human description of the PEM data.")), + ], + ), migrations.CreateModel( name="TransitAgency", fields=[ - ("id", models.AutoField(primary_key=True, serialize=False, verbose_name="ID")), + ("id", models.AutoField(primary_key=True, serialize=False)), ("slug", models.TextField()), ("short_name", models.TextField()), ("long_name", models.TextField()), @@ -76,13 +118,62 @@ class Migration(migrations.Migration): ("info_url", models.URLField()), ("phone", models.TextField()), ("active", models.BooleanField(default=False)), - # fmt: off - ("private_key", models.ForeignKey(help_text="The Agency's private key, used to sign tokens created on behalf of this Agency.", on_delete=models.deletion.PROTECT, related_name="+", to="core.PemData")), # noqa: E501 ("jws_signing_alg", models.TextField(help_text="The JWS-compatible signing algorithm.")), - ("payment_processor", models.ForeignKey(on_delete=models.deletion.PROTECT, to="core.paymentprocessor")), ("eligibility_types", models.ManyToManyField(to="core.EligibilityType")), - ("eligibility_verifier", models.ForeignKey(on_delete=models.deletion.PROTECT, to="core.eligibilityverifier")), - # fmt: on + ("eligibility_verifiers", models.ManyToManyField(to="core.EligibilityVerifier")), + ( + "payment_processor", + models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="core.paymentprocessor"), + ), + ( + "private_key", + models.ForeignKey( + help_text="The Agency's private key, used to sign tokens created on behalf of this Agency.", + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="core.pemdata", + ), + ), ], ), + migrations.AddField( + model_name="paymentprocessor", + name="client_cert", + field=models.ForeignKey( + help_text="The certificate used for client certificate authentication to the API.", + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="core.pemdata", + ), + ), + migrations.AddField( + model_name="paymentprocessor", + name="client_cert_private_key", + field=models.ForeignKey( + help_text="The private key, used to sign the certificate.", + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="core.pemdata", + ), + ), + migrations.AddField( + model_name="paymentprocessor", + name="client_cert_root_ca", + field=models.ForeignKey( + help_text="The root CA bundle, used to verify the server.", + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="core.pemdata", + ), + ), + migrations.AddField( + model_name="eligibilityverifier", + name="public_key", + field=models.ForeignKey( + help_text="The Verifier's public key, used to encrypt requests targeted at this Verifier and to verify signed responses from this verifier.", # noqa + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="core.pemdata", + ), + ), ] diff --git a/benefits/core/models.py b/benefits/core/models.py index 072c1f702..a397dbf2c 100644 --- a/benefits/core/models.py +++ b/benefits/core/models.py @@ -6,8 +6,6 @@ from django.db import models from django.urls import reverse -from jwcrypto import jwk - logger = logging.getLogger(__name__) @@ -22,11 +20,13 @@ class PemData(models.Model): def __str__(self): return self.label - @property - def jwk(self): - """jwcrypto.jwk.JWK instance from this PemData.""" - pem_bytes = bytes(self.text, "utf-8") - return jwk.JWK.from_pem(pem_bytes) + +class AuthProvider(models.Model): + """An entity that provides authentication for eligibility verifiers.""" + + id = models.AutoField(primary_key=True) + sign_in_button_label = models.TextField() + sign_out_button_label = models.TextField() class EligibilityType(models.Model): @@ -67,15 +67,46 @@ class EligibilityVerifier(models.Model): jwe_cek_enc = models.TextField(help_text="The JWE-compatible Content Encryption Key (CEK) key-length and mode") jwe_encryption_alg = models.TextField(help_text="The JWE-compatible encryption algorithm") jws_signing_alg = models.TextField(help_text="The JWS-compatible signing algorithm") + auth_provider = models.ForeignKey(AuthProvider, on_delete=models.PROTECT, null=True) + auth_scope = models.TextField(null=True) + auth_claim = models.TextField(null=True) + selection_label = models.TextField() + selection_label_description = models.TextField(null=True) + start_content_title = models.TextField() + start_item_name = models.TextField() + start_item_description = models.TextField() + start_blurb = models.TextField() + form_title = models.TextField() + form_content_title = models.TextField() + form_blurb = models.TextField() + form_sub_label = models.TextField() + form_sub_placeholder = models.TextField() + form_sub_pattern = models.TextField(null=True, help_text="A regular expression used to validate the 'sub' API field before sending to this verifier") # noqa: 503 + form_name_label = models.TextField() + form_name_placeholder = models.TextField() + form_name_max_length = models.PositiveSmallIntegerField(null=True, help_text="The maximum length accepted for the 'name' API field before sending to this verifier") # noqa: 503 + unverified_title = models.TextField() + unverified_content_title = models.TextField() + unverified_blurb = models.TextField() # fmt: on def __str__(self): return self.name @property - def public_jwk(self): - """jwcrypto.jwk.JWK instance of this Verifier's public key""" - return self.public_key.jwk + def public_key_data(self): + """This Verifier's public key as a string.""" + return self.public_key.text + + @property + def requires_authentication(self): + return self.auth_provider is not None + + @staticmethod + def by_id(id): + """Get an EligibilityVerifier instance by its ID.""" + logger.debug(f"Get {EligibilityVerifier.__name__} by id: {id}") + return EligibilityVerifier.objects.get(id=id) class PaymentProcessor(models.Model): @@ -117,7 +148,7 @@ class TransitAgency(models.Model): phone = models.TextField() active = models.BooleanField(default=False) eligibility_types = models.ManyToManyField(EligibilityType) - eligibility_verifier = models.ForeignKey(EligibilityVerifier, on_delete=models.PROTECT) + eligibility_verifiers = models.ManyToManyField(EligibilityVerifier) payment_processor = models.ForeignKey(PaymentProcessor, on_delete=models.PROTECT) private_key = models.ForeignKey(PemData, help_text="The Agency's private key, used to sign tokens created on behalf of this Agency.", related_name="+", on_delete=models.PROTECT) # noqa: 503 jws_signing_alg = models.TextField(help_text="The JWS-compatible signing algorithm.") @@ -138,11 +169,11 @@ def supports_type(self, eligibility_type): """True if the eligibility_type is one of this agency's types. False otherwise.""" return isinstance(eligibility_type, EligibilityType) and eligibility_type in self.eligibility_types.all() - def types_to_verify(self): + def types_to_verify(self, eligibility_verifier): """List of eligibility types to verify for this agency.""" # compute set intersection of agency and verifier type ids agency_types = set(self.eligibility_types.values_list("id", flat=True)) - verifier_types = set(self.eligibility_verifier.eligibility_types.values_list("id", flat=True)) + verifier_types = set(eligibility_verifier.eligibility_types.values_list("id", flat=True)) supported_types = list(agency_types & verifier_types) return EligibilityType.get_many(supported_types) @@ -152,9 +183,9 @@ def index_url(self): return reverse("core:agency_index", args=[self.slug]) @property - def private_jwk(self): - """jwcrypto.jwk.JWK instance of this Agency's private key""" - return self.private_key.jwk + def private_key_data(self): + """This Agency's private key as a string.""" + return self.private_key.text @staticmethod def by_id(id): diff --git a/benefits/core/recaptcha.py b/benefits/core/recaptcha.py index 3ac5ec629..dd3a9a036 100644 --- a/benefits/core/recaptcha.py +++ b/benefits/core/recaptcha.py @@ -3,7 +3,7 @@ """ import requests -from benefits.settings import RECAPTCHA_ENABLED, RECAPTCHA_SECRET_KEY, RECAPTCHA_VERIFY_URL +from django.conf import settings _POST_DATA = "g-recaptcha-response" @@ -19,13 +19,13 @@ def verify(form_data: dict) -> bool: Check with Google reCAPTCHA if the given response is a valid user. See https://developers.google.com/recaptcha/docs/verify """ - if not RECAPTCHA_ENABLED: + if not settings.RECAPTCHA_ENABLED: return True if not form_data or _POST_DATA not in form_data: return False - payload = dict(secret=RECAPTCHA_SECRET_KEY, response=form_data[_POST_DATA]) - response = requests.post(RECAPTCHA_VERIFY_URL, payload).json() + payload = dict(secret=settings.RECAPTCHA_SECRET_KEY, response=form_data[_POST_DATA]) + response = requests.post(settings.RECAPTCHA_VERIFY_URL, payload).json() return bool(response["success"]) diff --git a/benefits/core/session.py b/benefits/core/session.py index 49437a9e5..ff8a0a30c 100644 --- a/benefits/core/session.py +++ b/benefits/core/session.py @@ -6,9 +6,9 @@ import time import uuid +from django.conf import settings from django.urls import reverse -from benefits.settings import RATE_LIMIT_PERIOD from . import models @@ -19,17 +19,16 @@ _DEBUG = "debug" _DID = "did" _ELIGIBILITY = "eligibility" +_ENROLLMENT_TOKEN = "enrollment_token" +_ENROLLMENT_TOKEN_EXP = "enrollment_token_exp" _LANG = "lang" _LIMITCOUNTER = "limitcounter" _LIMITUNTIL = "limituntil" +_OAUTH_TOKEN = "oauth_token" _ORIGIN = "origin" _START = "start" _UID = "uid" - -# ignore bandit B105:hardcoded_password_string -# as these are not passwords, but keys for the session dict -_TOKEN = "token" # nosec -_TOKEN_EXP = "token_exp" # nosec +_VERIFIER = "verifier" def agency(request): @@ -58,13 +57,15 @@ def context_dict(request): _DEBUG: debug(request), _DID: did(request), _ELIGIBILITY: eligibility(request), + _ENROLLMENT_TOKEN: enrollment_token(request), + _ENROLLMENT_TOKEN_EXP: enrollment_token_expiry(request), _LANG: language(request), + _OAUTH_TOKEN: oauth_token(request), _ORIGIN: origin(request), _LIMITUNTIL: rate_limit_time(request), _START: start(request), - _TOKEN: token(request), - _TOKEN_EXP: token_expiry(request), _UID: uid(request), + _VERIFIER: verifier(request), } @@ -75,7 +76,16 @@ def debug(request): def did(request): - """Get the session's device ID, a hashed version of the unique ID.""" + """ + Get the session's device ID, a hashed version of the unique ID. If unset, + the session is reset to initialize a value. + + This value, like UID, is randomly generated per session and is needed for + Amplitude to accurately track that a sequence of events came from a unique + user. + + See more: https://help.amplitude.com/hc/en-us/articles/115003135607-Track-unique-users-in-Amplitude + """ logger.debug("Get session did") d = request.session.get(_DID) if not d: @@ -100,11 +110,32 @@ def eligible(request): return active_agency(request) and agency(request).supports_type(eligibility(request)) -def increment_rate_limit_counter(request): - """Adds 1 to this session's rate limit counter.""" - logger.debug("Increment rate limit counter") - c = rate_limit_counter(request) - request.session[_LIMITCOUNTER] = int(c) + 1 +def enrollment_token(request): + """Get the enrollment token from the request's session, or None.""" + logger.debug("Get session enrollment token") + return request.session.get(_ENROLLMENT_TOKEN) + + +def enrollment_token_expiry(request): + """Get the enrollment token's expiry time from the request's session, or None.""" + logger.debug("Get session enrollment token expiry") + return request.session.get(_ENROLLMENT_TOKEN_EXP) + + +def enrollment_token_valid(request): + """True if the request's session is configured with a valid token. False otherwise.""" + if bool(enrollment_token(request)): + logger.debug("Session contains an enrollment token") + exp = enrollment_token_expiry(request) + + # ensure token does not expire in the next 5 seconds + valid = exp is None or exp > (time.time() + 5) + + logger.debug(f"Session enrollment token is {'valid' if valid else 'expired'}") + return valid + else: + logger.debug("Session does not contain a valid enrollment token") + return False def language(request): @@ -113,6 +144,22 @@ def language(request): return request.LANGUAGE_CODE +def logged_in(request): + """Check if the current session has an OAuth token.""" + return bool(oauth_token(request)) + + +def logout(request): + """Reset the session tokens.""" + update(request, oauth_token=False, enrollment_token=False) + + +def oauth_token(request): + """Get the oauth token from the request's session, or None""" + logger.debug("Get session oauth token") + return request.session.get(_OAUTH_TOKEN) + + def origin(request): """Get the origin for the request's session, or None.""" logger.debug("Get session origin") @@ -125,6 +172,21 @@ def rate_limit_counter(request): return request.session.get(_LIMITCOUNTER) +def increment_rate_limit_counter(request): + """Adds 1 to this session's rate limit counter.""" + logger.debug("Increment rate limit counter") + c = rate_limit_counter(request) + request.session[_LIMITCOUNTER] = int(c) + 1 + + +def reset_rate_limit(request): + """Reset this session's rate limit counter and time.""" + logger.debug("Reset rate limit") + request.session[_LIMITCOUNTER] = 0 + # get the current time in Unix seconds, then add RATE_LIMIT_PERIOD seconds + request.session[_LIMITUNTIL] = int(time.time()) + settings.RATE_LIMIT_PERIOD + + def rate_limit_time(request): """Get this session's rate limit time, a Unix timestamp after which the session's rate limt resets.""" logger.debug("Get rate limit time") @@ -137,8 +199,10 @@ def reset(request): request.session[_AGENCY] = None request.session[_ELIGIBILITY] = None request.session[_ORIGIN] = reverse("core:index") - request.session[_TOKEN] = None - request.session[_TOKEN_EXP] = None + request.session[_ENROLLMENT_TOKEN] = None + request.session[_ENROLLMENT_TOKEN_EXP] = None + request.session[_OAUTH_TOKEN] = None + request.session[_VERIFIER] = None if _UID not in request.session or not request.session[_UID]: logger.debug("Reset session time and uid") @@ -149,16 +213,17 @@ def reset(request): reset_rate_limit(request) -def reset_rate_limit(request): - """Reset this session's rate limit counter and time.""" - logger.debug("Reset rate limit") - request.session[_LIMITCOUNTER] = 0 - # get the current time in Unix seconds, then add RATE_LIMIT_PERIOD seconds - request.session[_LIMITUNTIL] = int(time.time()) + RATE_LIMIT_PERIOD +def start(request): + """ + Get the start time from the request's session, as integer milliseconds since + Epoch. If unset, the session is reset to initialize a value. + Once started, does not reset after subsequent calls to session.reset() or + session.start(). This value is needed for Amplitude to accurately track + sessions. -def start(request): - """Get the start time from the request's session, as integer milliseconds since Epoch.""" + See more: https://help.amplitude.com/hc/en-us/articles/115002323627-Tracking-Sessions + """ logger.debug("Get session time") s = request.session.get(_START) if not s: @@ -167,20 +232,20 @@ def start(request): return s -def token(request): - """Get the token from the request's session, or None.""" - logger.debug("Get session token") - return request.session.get(_TOKEN) - +def uid(request): + """ + Get the session's unique ID, a randomly generated UUID4 string. If unset, + the session is reset to initialize a value. -def token_expiry(request): - """Get the token's expiry time from the request's session, or None.""" - logger.debug("Get session token expiry") - return request.session.get(_TOKEN_EXP) + This value, like DID, is needed for Amplitude to accurately track that a + sequence of events came from a unique user. + See more: https://help.amplitude.com/hc/en-us/articles/115003135607-Track-unique-users-in-Amplitude -def uid(request): - """Get the session's unique ID, generating a new one if necessary.""" + Although Amplitude advises *against* setting user_id for anonymous users, + here a value is set on anonymous users anyway, as the users never sign-in + and become de-anonymized to this app / Amplitude. + """ logger.debug("Get session uid") u = request.session.get(_UID) if not u: @@ -189,7 +254,17 @@ def uid(request): return u -def update(request, agency=None, debug=None, eligibility_types=None, origin=None, token=None, token_exp=None): +def update( + request, + agency=None, + debug=None, + eligibility_types=None, + enrollment_token=None, + enrollment_token_exp=None, + oauth_token=None, + origin=None, + verifier=None, +): """Update the request's session with non-null values.""" if agency is not None and isinstance(agency, models.TransitAgency): logger.debug(f"Update session {_AGENCY}") @@ -206,26 +281,29 @@ def update(request, agency=None, debug=None, eligibility_types=None, origin=None a = models.TransitAgency.by_id(request.session[_AGENCY]) t = str(eligibility_types[0]).strip() request.session[_ELIGIBILITY] = a.get_type_id(t) + else: + # empty list, clear session eligibility + request.session[_ELIGIBILITY] = None + if enrollment_token is not None: + logger.debug(f"Update session {_ENROLLMENT_TOKEN}") + request.session[_ENROLLMENT_TOKEN] = enrollment_token + request.session[_ENROLLMENT_TOKEN_EXP] = enrollment_token_exp + if oauth_token is not None: + logger.debug(f"Update session {_OAUTH_TOKEN}") + request.session[_OAUTH_TOKEN] = oauth_token if origin is not None: logger.debug(f"Update session {_ORIGIN}") request.session[_ORIGIN] = origin - if token is not None: - logger.debug(f"Update session {_TOKEN}") - request.session[_TOKEN] = token - request.session[_TOKEN_EXP] = token_exp - + if verifier is not None and isinstance(verifier, models.EligibilityVerifier): + logger.debug(f"Update session {_VERIFIER}") + request.session[_VERIFIER] = verifier.id -def valid_token(request): - """True if the request's session is configured with a valid token. False otherwise.""" - if token(request) is not None: - logger.debug("Session contains a token") - exp = token_expiry(request) - # ensure token does not expire in the next 5 seconds - valid = exp is None or exp > (time.time() + 5) - - logger.debug(f"Session token is {'valid' if valid else 'expired'}") - return valid - else: - logger.debug("Session does not contain a valid token") - return False +def verifier(request): + """Get the verifier from the request's session, or None""" + logger.debug("Get session verifier") + try: + return models.EligibilityVerifier.by_id(request.session[_VERIFIER]) + except (KeyError, models.EligibilityVerifier.DoesNotExist): + logger.debug("Can't get verifier from session") + return None diff --git a/benefits/core/templates/core/agency_index.html b/benefits/core/templates/core/agency_index.html new file mode 100644 index 000000000..4ade22959 --- /dev/null +++ b/benefits/core/templates/core/agency_index.html @@ -0,0 +1,16 @@ +{% extends 'core/page.html' %} +{% load i18n %} +{% block classes %}{{ block.super |add:" agency-index"}}{% endblock %} + +{% block container_content %} +

{{ page.content_title }}

+ +

{% blocktranslate with info_link=info_link%}core.pages.agency_index.p[0]{{ info_link }}{% endblocktranslate %}

+

{% translate "core.pages.agency_index.p[1]" %}

+

{% translate "core.pages.agency_index.p[2]" %}

+ +{% block buttons%} +{{ block.super }} +{% endblock buttons %} + +{% endblock container_content %} diff --git a/benefits/core/templates/core/base.html b/benefits/core/templates/core/base.html index ccab18e26..e4f87ec7f 100644 --- a/benefits/core/templates/core/base.html +++ b/benefits/core/templates/core/base.html @@ -6,7 +6,7 @@ - + {% block page_title %}{% endblock %}Cal-ITP @@ -14,14 +14,17 @@ - + {% include "core/includes/analytics.html" with api_key=analytics.api_key uid=analytics.uid did=analytics.did %} + {% if debug %} + {% include "core/includes/debug.html" %} + {% endif %}