diff --git a/Dockerfile b/Dockerfile index 7b6f9ad..a2a5b8a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,24 @@ FROM python:3.10 -COPY drp_aa_mvp/requirements.txt /requirements.txt -RUN pip install -r /requirements.txt -COPY docker-entrypoint.sh /entrypoint.sh + +ARG USER=osiraa +ENV user=${USER} + +RUN groupadd -g 1000 ${USER} && \ + useradd -m -u 1000 -g ${USER} -s /bin/bash ${USER} && \ + mkdir -p /code && \ + chown -R ${USER}:${USER} \ + /code + + +COPY --chown=${USER}:${USER} drp_aa_mvp/requirements.txt /requirements.txt +COPY --chown=${USER}:${USER} docker-entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -COPY drp_aa_mvp/ /code +COPY --chown=${USER}:${USER} drp_aa_mvp/ /code + +USER ${USER} WORKDIR /code + +RUN pip install -r /requirements.txt + CMD "/entrypoint.sh" EXPOSE 8000:8000 diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..d279f45 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,183 @@ +@Library(['k8s-jenkins-il-shared-lib@master', 'k8s-jenkins-common-shared-lib@master']) _ +import com.consumerreports.common.Config +import com.consumerreports.common.Kubectl +import com.consumerreports.common.ContainerImage +import com.consumerreports.common.Slack +import com.consumerreports.common.Utility +import com.consumerreports.common.Kustomize + +pipelineConfig = loadVariables(path: 'com/consumerreports/il/osiraa/osiraa.yaml') + +pipeline { + agent { + kubernetes { + label "build-${pipelineConfig.application.name}-${BUILD_NUMBER}" + yaml devopsPodTemplate.kanikoDeploy() + } + } + options { + timeout(time: 20, unit: 'MINUTES') + disableConcurrentBuilds() + ansiColor('xterm') + timestamps() + skipDefaultCheckout() + } + stages { + stage('Checkout code') { + steps { + container('build'){ + script { + // Checkout jenkinsfiles-il from stash.consumer.org + checkoutCodeInFolder(codeDir: WORKSPACE, repositoryUrl: pipelineConfig['buildCode']['repositoryUrl'], branch: pipelineConfig['buildCode']['repositoryBranch']) + // Checkout application code from github.com + dir(pipelineConfig['application']['codeDir']){ + checkout scm + } + } + } + } + } + stage('Set environment variables'){ + steps { + container('deploy'){ + script { + switch (env.BRANCH_NAME) { + case ~/(?i)^develop$/: + env.DEPLOYMENT_ENVIRONMENT = 'dev' + env.DEPLOYMENT_ENABLED = true + break + case ~/(?i)^main$/: + env.DEPLOYMENT_ENVIRONMENT = 'stage' + env.DEPLOYMENT_ENABLED = true + break + // case ~/(?i)^production$/: + // env.DEPLOYMENT_ENVIRONMENT = 'prod' + // env.DEPLOYMENT_ENABLED = true + // break + default: + env.DEPLOYMENT_ENVIRONMENT = 'dev' + env.DEPLOYMENT_ENABLED = false + break + } + // Pipeline config: https://stash.consumer.org/projects/K8S/repos/k8s-jenkins-il-shared-lib/browse/resources/com/consumerreports/il/datarightsprotocol-website + env.KUBECTL_CONTEXT = pipelineConfig['environments'][env.DEPLOYMENT_ENVIRONMENT]['kubectlContext'] + env.DOCKER_IMAGE_NAME = pipelineConfig['application']['name'] + env.BUILD_CODE_SUB_DIR = pipelineConfig['application']['name'] + env.SLACK_WEBHOOK = pipelineConfig['slack']['webhook'] + env.SLACK_CHANNEL = pipelineConfig['slack']['channel'] + env.SLACK_TOKEN = pipelineConfig['slack']['token'] + env.KUBECTL_CONTEXT = pipelineConfig['environments'][env.DEPLOYMENT_ENVIRONMENT]['kubectlContext'] + env.KUBECTL_NAMESPACE = pipelineConfig['environments'][env.DEPLOYMENT_ENVIRONMENT]['kubectlNamespace'] + env.CERTIFICATE_NAME = pipelineConfig['environments'][env.DEPLOYMENT_ENVIRONMENT]['certificateName'] + env.DEPLOYMENT_TIMEOUT = pipelineConfig['environments'][env.DEPLOYMENT_ENVIRONMENT]['deployment']['timeout'] + env.DEPLOYMENT_DRY_RUN = pipelineConfig['environments'][env.DEPLOYMENT_ENVIRONMENT]['deployment']['dryRun']['enabled'] + env.NOTIFICATIONS_DEPLOYMENT_SLACK = pipelineConfig['environments'][env.DEPLOYMENT_ENVIRONMENT]['notifications']['deployment']['slack']['enabled'] + env.CONTAINER_SCANNER_ENABLED = pipelineConfig['environments'][env.DEPLOYMENT_ENVIRONMENT]['containerScanner']['enabled'] + env.APPLICATION_CODE_DIR = pipelineConfig['application']['codeDir'] + dir(env.APPLICATION_CODE_DIR) { + sh "git config --global --add safe.directory '*'" + env.DOCKER_IMAGE_TAG = getCommitHash() + } + // If we re-run the job we want to redeploy but we don't want to rebuild the image + if (ContainerImage.isPresentDocker(this, env.DOCKER_IMAGE_NAME, env.DOCKER_IMAGE_TAG)){ + env.BUILD_ENABLED = false + } else { + env.BUILD_ENABLED = true + } + } + } + } + } + stage('Test build container image') { + when { + anyOf { + expression { env.BRANCH_NAME ==~ /(?i)^pr-(\d)*$/ } + expression { env.BRANCH_NAME ==~ /(?i)^feature\/.*$/ } + } + } + environment { + PATH = "/busybox:/kaniko:$PATH" + } + steps { + container('kaniko'){ + script { + // Try to build the image without pusing it to the docker registry + ContainerImage.buildKanikoDockerfile(this, [buildMode: "no-push", contextPath: "`pwd`/${env.APPLICATION_CODE_DIR}", dockerfilePath: "`pwd`/${env.APPLICATION_CODE_DIR}/Dockerfile", imageName: env.DOCKER_IMAGE_NAME, imageTag: env.DOCKER_IMAGE_TAG, args: ""]) + } + } + } + } + stage('Build container image') { + when { + allOf { + expression { env.BUILD_ENABLED.toBoolean() } + expression { env.DEPLOYMENT_ENABLED.toBoolean() } + } + } + environment { + PATH = "/busybox:/kaniko:$PATH" + } + steps { + container('kaniko'){ + script { + ContainerImage.buildKanikoDockerfile(this, [contextPath: "`pwd`/${env.APPLICATION_CODE_DIR}", dockerfilePath: "`pwd`/${env.APPLICATION_CODE_DIR}/Dockerfile", imageName: env.DOCKER_IMAGE_NAME, imageTag: env.DOCKER_IMAGE_TAG, args: ""]) + } + } + } + } + stage('Deploy') { + when { + expression { env.DEPLOYMENT_ENABLED.toBoolean() } + } + steps { + container('deploy'){ + script { + Slack.slackSendDeployment(this, [status: 'start']) + Kubectl.showClusterInfo(this, env.KUBECTL_CONTEXT) + Kustomize.deployKustomize( + this, [ + kubectlContext : env.KUBECTL_CONTEXT, + deploymentEnvironment : env.DEPLOYMENT_ENVIRONMENT, + kustomizeManifestsPath: "${env.BUILD_CODE_SUB_DIR}/kustomize", + baseManifestFilename : 'deployment.yaml', + basePath : 'base', + overlayPath : 'overlays', + imageTag : env.DOCKER_IMAGE_TAG, + imageTagPlaceholder : 'APP_CONTAINER_TAG', + dryRun : env.DEPLOYMENT_DRY_RUN + ]) + } + } + } + post { + success { + script { + Slack.slackSendDeployment(this, [status: 'finish']) + } + } + failure { + script { + Slack.slackSendDeployment(this, [status: 'failed']) + } + } + } + } + stage('Validate Deployment') { + when { + expression { env.DEPLOYMENT_ENABLED.toBoolean() } + } + steps { + container('deploy'){ + script { + validateDeployment(deploymentEnvironment: env.DEPLOYMENT_ENVIRONMENT, deploymentTimeout: env.DEPLOYMENT_TIMEOUT) + } + } + } + } + } + post { + cleanup { + cleanWs() + } + } +} diff --git a/README.md b/README.md index 429a858..81ddace 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ ``` -cd drp_aa_mvp pip install -r requirements.txt sudo apt update sudo apt install postgresql @@ -9,7 +8,8 @@ python manage.py migrate Create superuser named vscode with password `vscode` ``` -python manage.py createsuperuser +python3 manage.py createsuperuser +python3 manage.py collectstatic ``` Run/deploy the app: diff --git a/deploy/drp.yml b/deploy/drp.yml index 3f530f3..6a68e3f 100644 --- a/deploy/drp.yml +++ b/deploy/drp.yml @@ -5,8 +5,8 @@ roles: - role: docker-ce become: yes - - role: fidesdemo - become: no + # - role: fidesdemo + # become: no - role: osiraa become: no @@ -16,15 +16,17 @@ osiraa_service_id: osiraa osiraa_service_domain: osiraa.datarightsprotocol.org osiraa_port: 8000 + osiraa_version: 'main' + # osiraa_version: 1954a9d6e09c8d15c1a4fe185b8e9874c99bdbed - - role: osiraa - become: no - vars: - osiraa_source_dir: "~/Code/osiraa05" - osiraa_remote_dir: "/home/ubuntu/osiraa05" - osiraa_service_id: "osiraa05" - osiraa_service_domain: "osiraa05.datarightsprotocol.org" - osiraa_port: 8001 + # - role: osiraa + # become: no + # vars: + # osiraa_source_dir: "~/Code/osiraa05" + # osiraa_remote_dir: "/home/ubuntu/osiraa05" + # osiraa_service_id: "osiraa05" + # osiraa_service_domain: "osiraa05.datarightsprotocol.org" + # osiraa_port: 8001 pre_tasks: - name: install SSH keys diff --git a/deploy/files/keys/john.pub b/deploy/files/keys/john.pub index 8e2c2da..f0c1210 100644 --- a/deploy/files/keys/john.pub +++ b/deploy/files/keys/john.pub @@ -1 +1,2 @@ -ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC/vQLIAe2suhSPEzPQyrRfzLkhqst96Y3MtTTb0kG3GqpbHi+ezVHgQTJ0zPzSyAKazC4JLSmpVwHRri4M+4c7Xl+j9VWhh1ECN60gthzzPDHDhWfp49pOFDK6PmnrMoCp7ZudyNgv/+DGc4Cf1DsLaaTKIdRdSASppZTJqCy1rxQZakMpOnuyEprUfRVz7o3sEWyLAjfwB2aEUq/ejd8Ner5uTkx4V1vM5FrP8B/PVJktmFGeY1C0rlDlbHD6tppDEQz36fJbKAswcuuotXAx3ZJ/yqIwnJBGrQiFW/ZwWvmnzxgxm7pJlJ4uuwQJPfRGZp2Z4CqmGDHWqqocO4R/IjfDDU/rzsOIIl50pL3ZTB66fnq1SJSdF9tpa3CVAd0hGtP6hrJlPxh+NDK/EKhO7abuok7/LxULIVBw7M4IXB7e2Ol/LJy2pNrG9EiSndD1VSkCxmuOGEDV4K49UhbAONpL6AvW6lvF2m3cV28TVnxBaFZoDxgasC2L9E39EM0= johnnyz@JohnnyZs-MacBook-Pro.local + ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCYsG4P3zVWVzXmf3BQ85EOjYzCLHcNfPWF5Ajr1NvN7wI0StebrVXwyQZ7onq/2wLT0Vk5rqy3Gn2y9AxPLHsp9LfXFlK/IS2DmocScA/uKL4k0wke4Q3XRjETULHZC/1KqrliLosWSfjo3Zga0lP+YqggCjjBfv/ycH636DdNs4CK7D5Dzv715LDTO1d/Slevn7pM+/QhyDEkKsACW+iDDWHi73bzI2O2s7Dign3vI5VAgrBnQu01zR0y3WjZOuMlGzWs7PBCZDgCdevsh8iQK2IT/pjBvophTG9J0Hkk/qVmRy/0JUGP3VECJXd4z2upVXLU0pggW55efUJP2fqH6dGzPRON5r/Q/tUzngEHvT+dBLhUl4dPLkp85MSQwQOAhdhCklhmlYGehgPg45eGz3azrGa44lZ6EmRfVDzHARHXU6IHZ7c5/GPQCt5vDVRw+6aL+3AYaoNKiBz9alqU432IcKHKAjU6lffEFi5EpwoEmx1L1b893zhHmJKazTE= szinjo@MACL12300 + \ No newline at end of file diff --git a/deploy/inventory b/deploy/inventory index 6a8fea7..7a2f2cb 100644 --- a/deploy/inventory +++ b/deploy/inventory @@ -1,5 +1,5 @@ all: hosts: drp-test: - ansible_host: 44.209.94.186 + ansible_host: osiraa.datarightsprotocol.org ansible_user: ubuntu diff --git a/deploy/roles/osiraa/tasks/main.yml b/deploy/roles/osiraa/tasks/main.yml index 1349500..fc64ffa 100644 --- a/deploy/roles/osiraa/tasks/main.yml +++ b/deploy/roles/osiraa/tasks/main.yml @@ -5,11 +5,18 @@ state: directory path: '{{osiraa_remote_dir}}' -- name: copy local osiraa checkout to instance host - synchronize: - src: '{{osiraa_source_dir}}/' +# - name: copy local osiraa checkout to instance host +# synchronize: +# src: '{{osiraa_source_dir}}/' +# dest: '{{osiraa_remote_dir}}/' +# delete: yes +# register: copy_osiraa + +- name: clone osiraa from github + git: + repo: https://github.com/consumer-reports-innovation-lab/osiraa dest: '{{osiraa_remote_dir}}/' - delete: yes + version: '{{osiraa_version}}' register: copy_osiraa # TODO: install an environment file with secrets, override settings.py SECURITY WARNINGs diff --git a/deploy/roles/osiraa/templates/osiraa.service b/deploy/roles/osiraa/templates/osiraa.service index 7a3eabb..95841d7 100644 --- a/deploy/roles/osiraa/templates/osiraa.service +++ b/deploy/roles/osiraa/templates/osiraa.service @@ -1,5 +1,5 @@ [Unit] -Description=github.com/consumer-reports-digital-lab/osiraa +Description=github.com/consumer-reports-innovation-lab/osiraa [Service] Type=simple diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6743ca2..7fe3b3c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,7 +1,13 @@ +volumes: + osiraa: services: web: ports: - "8000:8000" + environment: + - OSIRAA_KEY_FILE=/var/lib/osiraa/keys.json + volumes: + - osiraa:/var/lib/osiraa db: ports: - "15432:5432" diff --git a/drp_aa_mvp/agent_keys/__init__.py b/drp_aa_mvp/agent_keys/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/drp_aa_mvp/agent_keys/admin.py b/drp_aa_mvp/agent_keys/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/drp_aa_mvp/agent_keys/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/drp_aa_mvp/agent_keys/apps.py b/drp_aa_mvp/agent_keys/apps.py new file mode 100644 index 0000000..ee53d13 --- /dev/null +++ b/drp_aa_mvp/agent_keys/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AgentKeysConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'agent_keys' diff --git a/drp_aa_mvp/agent_keys/migrations/__init__.py b/drp_aa_mvp/agent_keys/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/drp_aa_mvp/agent_keys/models.py b/drp_aa_mvp/agent_keys/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/drp_aa_mvp/agent_keys/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/drp_aa_mvp/agent_keys/templates/auth_keys.html b/drp_aa_mvp/agent_keys/templates/auth_keys.html new file mode 100644 index 0000000..d3752fd --- /dev/null +++ b/drp_aa_mvp/agent_keys/templates/auth_keys.html @@ -0,0 +1,17 @@ +
+

OSIRAA - Open Source Implementer's Reference Authorized Agent

+
+ +

New Auth Agent Signing Key (64-bit encoded):
+ {{agent_signing_key_b64}}

+ +

New Auth Agent Verify Key (64-bit encoded):
+ {{agent_verify_key_b64}}

+ +

You can copy these keys into the config settings of your app. If you update your app's keys, you must notify the DRP team so we can update your entry in the Service Directory.

+ +
{% csrf_token %} + +
+ +
\ No newline at end of file diff --git a/drp_aa_mvp/agent_keys/templates/index.html b/drp_aa_mvp/agent_keys/templates/index.html new file mode 100644 index 0000000..45d79ee --- /dev/null +++ b/drp_aa_mvp/agent_keys/templates/index.html @@ -0,0 +1,10 @@ +
+

OSIRAA - Open Source Implementer's Reference Authorized Agent

+
+ +

Generate Auth Agent Signing and Verify Keys

+
{% csrf_token %} + +
+ +
\ No newline at end of file diff --git a/drp_aa_mvp/agent_keys/tests.py b/drp_aa_mvp/agent_keys/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/drp_aa_mvp/agent_keys/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/drp_aa_mvp/agent_keys/urls.py b/drp_aa_mvp/agent_keys/urls.py new file mode 100644 index 0000000..f3bb016 --- /dev/null +++ b/drp_aa_mvp/agent_keys/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path('', views.index, name='index'), + path('generate_auth_agent_keys', views.generate_auth_agent_keys, name='generate_auth_agent_keys'), + path('generate_auth_agent_keys_return', views.generate_auth_agent_keys_return, name='generate_auth_agent_keys_return'), +] \ No newline at end of file diff --git a/drp_aa_mvp/agent_keys/views.py b/drp_aa_mvp/agent_keys/views.py new file mode 100644 index 0000000..5bb9c61 --- /dev/null +++ b/drp_aa_mvp/agent_keys/views.py @@ -0,0 +1,77 @@ +from django.shortcuts import render + +import logging +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +from nacl import signing +from nacl.encoding import Base64Encoder +from nacl.public import PrivateKey + +from typing import Tuple + + +# AA keys should be generated offline before we start using the app and fetched from +# the service directory. + +def generate_new_pynacl_keys() -> Tuple[signing.SigningKey, signing.VerifyKey]: + + logger.info('** agent_keys.generate_new_pynacl_keys()') + + new_signing_key = signing.SigningKey.generate() + new_verify_key = new_signing_key.verify_key + + keys_json = { + "signing_key": new_signing_key.encode(encoder=Base64Encoder).decode(), + "verify_key": new_verify_key.encode(encoder=Base64Encoder).decode() + } + + logger.info(f"** new_signing_key = {new_signing_key}") + logger.info(f"** new_verify_key = {new_verify_key}") + + new_signing_key_b64 = new_signing_key.encode(encoder=Base64Encoder) + new_verify_key_b64 = new_verify_key.encode(encoder=Base64Encoder) + + logger.info(f"** new_signing_key_b64 = {new_signing_key_b64}") + logger.info(f"** new_verify_key_b64 = {new_verify_key_b64}") + + return (signing.SigningKey(keys_json["signing_key"], encoder=Base64Encoder), + signing.VerifyKey(keys_json["verify_key"], encoder=Base64Encoder)) + + +''' +signing_key, verify_key = generate_new_pynacl_keys() + +logger.debug(f"** signing_key = {signing_key}") +logger.debug(f"** verify_key = {verify_key}") + +# the public key and signing key as b64 strings +signing_key_b64 = signing_key.encode(encoder=Base64Encoder) +verify_key_b64 = verify_key.encode(encoder=Base64Encoder) + +logger.debug(f"signing_key_b64 = {signing_key_b64}") +logger.debug(f"verify_key_b64 = {verify_key_b64}") +''' + + +def index(request): + return render(request, 'index.html', {}) + +def generate_auth_agent_keys(request): + logger.info("agent_keys.generate_auth_agent_keys()") + + signing_key, verify_key = generate_new_pynacl_keys() + + signing_key_b64 = signing_key.encode(encoder=Base64Encoder) + verify_key_b64 = verify_key.encode(encoder=Base64Encoder) + + context = { + 'agent_signing_key_b64': signing_key_b64, + 'agent_verify_key_b64': verify_key_b64, + } + + return render(request, 'auth_keys.html', context) + +def generate_auth_agent_keys_return(request): + return render(request, 'index.html', {}) + diff --git a/drp_aa_mvp/agents.json b/drp_aa_mvp/agents.json new file mode 100644 index 0000000..e70b7fc --- /dev/null +++ b/drp_aa_mvp/agents.json @@ -0,0 +1,11 @@ +[ + { + "id": "CR_AA_DRP_ID_001", + "name": "OSIRAA Prod Instance", + "verify_key": "aa3543a2fa54a9c977c416077ed28ecb651f6465f30d85fe342ab27c0b29e689", + "web_url": "https://osiraa.datarightsprotocol.org", + "technical_contact": "john.szinger@consumer.org", + "business_contact": "ginny.fahs@consumer.org", + "identity_assurance_url": "https://permissionslipcr.com/XXX" + } +] \ No newline at end of file diff --git a/drp_aa_mvp/business.json b/drp_aa_mvp/business.json new file mode 100644 index 0000000..8d066e5 --- /dev/null +++ b/drp_aa_mvp/business.json @@ -0,0 +1,39 @@ +[ + { + "id": "onetrust_staging_001", + "name": "OneTrust Test Instance", + "logo": "", + "api_base": "https://staging.1trust.ninja/api/datasubject/v2/crp", + "supported_actions": [ + "access", + "deletion" + ], + "web_url": "https://onetrust.com", + "technical_contact": "fixme", + "business_contact": "fixme" + }, + { + "id": "TRANSCEND_TEST_001", + "name": "Transcend Test Instance", + "logo": "", + "api_base": "https://drp.staging.transcen.dental", + "supported_actions": [ + "access" + ], + "web_url": "https://transcend.io", + "technical_contact": "", + "business_contact": "" + }, + { + "id": "Test_Entry_007", + "name": "Testy McTest Instance", + "logo": "", + "api_base": "https://test.foo.com", + "supported_actions": [ + "access" + ], + "web_url": "https://test.foo.com", + "technical_contact": "", + "business_contact": "" + } +] \ No newline at end of file diff --git a/drp_aa_mvp/data_rights_request/models.py b/drp_aa_mvp/data_rights_request/models.py index 2151bf9..90e208b 100644 --- a/drp_aa_mvp/data_rights_request/models.py +++ b/drp_aa_mvp/data_rights_request/models.py @@ -29,7 +29,6 @@ (ACCESS_SPEC , 'access:specific ') ] - CCPA = 'ccpa' VOLUNTARY = 'voluntary' @@ -40,10 +39,9 @@ """ class RequestMetaData(): - version = "0.7" + version = "0.9.3" """ - IN_PROGRESS = 'in_progress' OPEN = 'open' FULFILLED = 'fulfilled' @@ -100,7 +98,7 @@ class RequestReason(str, Enum): other = "other" none = "" - + class StateReasons(TypedDict): status: RequestStatus reasons: List[RequestReason] @@ -170,7 +168,7 @@ class DataRightsRequest(models.Model): identity = models.ForeignKey(IdentityPayload, null=True, on_delete=models.CASCADE) def __str__(self): - return f"{self.request_id} asking {self.exercise} for {self.identity}" + return f"{self.request_id} asking {self.right} for {self.identity}" class DataRightsStatus(models.Model): diff --git a/drp_aa_mvp/data_rights_request/pynacl_validator_submit.py b/drp_aa_mvp/data_rights_request/pynacl_validator_submit.py index 0a4b32d..29d89d2 100644 --- a/drp_aa_mvp/data_rights_request/pynacl_validator_submit.py +++ b/drp_aa_mvp/data_rights_request/pynacl_validator_submit.py @@ -6,28 +6,31 @@ import urllib.error import logging +logging.basicConfig(level=logging.WARN) +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + from nacl import signing from nacl.public import PrivateKey from nacl.encoding import Base64Encoder -LOCAL_VALIDATOR_URL = "http://localhost:8000/data_rights_request/pynacl_validate" +#LOCAL_VALIDATOR_URL = "http://localhost:8000/data_rights_request/pynacl_validate" +# return a tuple with signing and verify key def generate_keys() -> Tuple[str, str]: - ''' - Returns tuple with signing and verify key - ''' - # Generate a private key and a signing key - # these are `bytes' objects + logger.debug("** pynacl_validator_submit.generate_keys()") + + # Generate a private key and a signing key, these are `bytes' objects signing_key = signing.SigningKey.generate() verify_key = signing_key.verify_key return (signing_key, verify_key) + +# return a serialized JSON blob signed with the given PyNaCl signing key def make_req(signing_key): - ''' - return a serialized JSON blob signed with the given PyNaCl signing key - ''' + logger.debug("** pynacl_validator_submit.make_req()") # Create the object to sign obj = { @@ -40,19 +43,22 @@ def make_req(signing_key): # Sign the object signed_obj = signing_key.sign(json.dumps(obj).encode()) - print(signed_obj) + logger.debug(f"** pynacl_validator_submit.make_req(): signed_obj = {signed_obj}") return signed_obj + def submit_signed_request(validator_url): + logger.debug("** pynacl_validator_submit.submit_signed_request()") + signing_key, verify_key = generate_keys() # Get the public key and signing key as b64 strings signing_key_b64 = signing_key.encode(encoder=Base64Encoder) verify_key_b64 = verify_key.encode(encoder=Base64Encoder) - # Print the signing key - print(f"Signing key: {signing_key_b64}") - print(f"Verify key: {verify_key_b64}") + # Print the signing key - why? + logger.debug(f"** pynacl_validator_submit.submit_signed_request(): signing_key_b64 = {signing_key_b64}") + logger.debug(f"** pynacl_validator_submit.submit_signed_request(): verify_key_b64 =: {verify_key_b64}") signed_obj = make_req(signing_key) @@ -61,6 +67,7 @@ def submit_signed_request(validator_url): request.add_header("content-type", "application/octet-stream") # smuggle DRP verify key in-band. This is NOT sufficient for production security! + # what is this? how we make it production ready? request.add_header("X-DRP-VerifyKey", verify_key_b64) try: @@ -72,7 +79,8 @@ def submit_signed_request(validator_url): return resp - +''' if __name__ == "__main__": print("Running...") print(submit_signed_request(LOCAL_VALIDATOR_URL)) +''' diff --git a/drp_aa_mvp/data_rights_request/templates/data_rights_request/data_rights.json b/drp_aa_mvp/data_rights_request/templates/data_rights_request/data_rights.json index c9746ad..1765840 100644 --- a/drp_aa_mvp/data_rights_request/templates/data_rights_request/data_rights.json +++ b/drp_aa_mvp/data_rights_request/templates/data_rights_request/data_rights.json @@ -1,6 +1,6 @@ { - "version": "0.7", + "version": "0.9.3", "api_base": "https://example.com/data-rights", - "actions": ["sale:opt-out", "sale:opt-in", "access", "deletion"], + "actions": [ "sale:opt-out", "sale:opt-in", "access", "deletion" ], "user_relationships": [ ] -} \ No newline at end of file +} diff --git a/drp_aa_mvp/data_rights_request/templates/data_rights_request/identity_verification.html b/drp_aa_mvp/data_rights_request/templates/data_rights_request/identity_verification.html new file mode 100644 index 0000000..fb989aa --- /dev/null +++ b/drp_aa_mvp/data_rights_request/templates/data_rights_request/identity_verification.html @@ -0,0 +1,36 @@ + + + + + + + OSIRAA User Identity Verification + + + +
+

OSIRAA User Identity Verification

+
+ +

OSIRAA is an interoperability testing and reference implementation tool of the Data Rights Protocol. It's used to ensure that implementations of the protocol adhere to interoperable norms rather than to submit actionable Data Rights Requests.

+ +

1. Desired Level of Assurance

+ +
+

OSIRAA makes no guarantee of the validity of identity attributes presented in Data Rights Requests, and even requests sent with "verified" attributes should be assumed to contain synthetic identities.

+ +

Operators of OSIRAA shall not present Data Rights Requests to counterparties without notice and consent of the counterparty's technical contacts.

+ +

2. Validation Steps for Each Attribute

+ +
+

2.1. Email
+ The Consumer's email address is not validated by the Agent.

+ +

2.2. Phone
+ The Consumer's phone number is not validated by the Agent.

+
+
+ + diff --git a/drp_aa_mvp/data_rights_request/templates/data_rights_request/index.html b/drp_aa_mvp/data_rights_request/templates/data_rights_request/index.html index 667cd9d..e708d88 100644 --- a/drp_aa_mvp/data_rights_request/templates/data_rights_request/index.html +++ b/drp_aa_mvp/data_rights_request/templates/data_rights_request/index.html @@ -1,130 +1,120 @@ -
-

OSIRAA - Open Source Implementer's Reference Authorized Agent

- -

View test criteria

-
- -

Send a DRP Data Privacy Request

-
- -
- {% csrf_token %} -

Select a Privacy Infrstructure Provider or Covered Business

-

PIP or Covered Business - -

- -
- -
-
-
- -
- {% csrf_token %} -

Discover Data Rights (GET /.well-known/data-rights.json)

-

PIP or Covered Business {{selected_covered_biz}}

- - -
- -
-
-
- -
- {% csrf_token %} -

Setup Pair-wise Key (POST /v1/agent/{agent-id})

-

Covered Business {{selected_covered_biz}}

- - -
- -
-
-
- -
- {% csrf_token %} -

Get Agent Information (GET /v1/agent/{agent-id})

-

Covered Business {{selected_covered_biz}}

- - -
- -
-
-
- -
- {% csrf_token %} -

Excercise Data Rights (POST /v1/data-right-request/)

-

PIP or Covered Business {{selected_covered_biz}}

-

User Identity - -

-

Request Action - -

-

Covered Regime - -

- - -
- -
-
-
- -
- {% csrf_token %} -

Get Status for a Data Rights Request (GET /v1/data-rights-request/{request_id})

-

Covered Business {{selected_covered_biz}}

-

User Identity - -

- - -
- -
-
-
- -
- {% csrf_token %} -

Revoke a Data Rights Request (DELETE /v1/data-rights-request/{request_id})

-

Covered Business {{selected_covered_biz}}

-

User Identity - -

- - -
- -
-
+
+

OSIRAA - Open Source Implementer's Reference Authorized Agent

+ +

View test criteria

+
+ +

Refresh Data from the Service Directory

+
{% csrf_token %} + +
+ +
+
+
Send a DRP Data Privacy Request +
+ +
{% csrf_token %} +

Select a Privacy Infrstructure Provider or Covered Business

+

PIP or Covered Business + +

+ +
+ +
+
+
+ +
{% csrf_token %} +

Setup Pair-wise Key (POST /v1/agent/{agent-id})

+

Covered Business {{selected_covered_biz}}

+ + +
+ +
+
+
+ +
{% csrf_token %} +

Get Agent Information (GET /v1/agent/{agent-id})

+

Covered Business {{selected_covered_biz}}

+ + +
+ +
+
+
+ +
{% csrf_token %} +

Exercise Data Rights (POST /v1/data-rights-request)

+

PIP or Covered Business {{selected_covered_biz}}

+

User Identity + +

+

Request Action + +

+

Covered Regime + +

+ + +
+ +
+
+
+ +
{% csrf_token %} +

Get Status for a Data Rights Request (GET /v1/data-rights-request/{request_id})

+

Covered Business {{selected_covered_biz}}

+

User Identity + +

+ + +
+ +
+
+
+ +
{% csrf_token %} +

Revoke a Data Rights Request (DELETE /v1/data-rights-request/{request_id})

+

Covered Business {{selected_covered_biz}}

+

User Identity + +

+ + +
+ +
diff --git a/drp_aa_mvp/data_rights_request/templates/data_rights_request/request_sent.html b/drp_aa_mvp/data_rights_request/templates/data_rights_request/request_sent.html index 24a371f..6538695 100644 --- a/drp_aa_mvp/data_rights_request/templates/data_rights_request/request_sent.html +++ b/drp_aa_mvp/data_rights_request/templates/data_rights_request/request_sent.html @@ -1,28 +1,35 @@ -
-

OSIRAA - Open Source Implementer's Reference Authorized Agent

-
-

DRP Data Privacy Request Sent

- -

Covered Business: {{covered_biz.name}}

-

Request Url: {{request_url}}

-

Response Code: {{response_code}}

-
- -

Response Payload:

-

{{response_payload}}

-
- -

Test Results:

-

- {% for test_result in test_results %} - {{test_result.name}}:{{test_result.result}}
- {% endfor %} -

-
- -
- {% csrf_token %} - - -
-
+
+

OSIRAA - Open Source Implementer's Reference Authorized Agent

+
+

DRP Data Privacy Request Sent

+ +

Covered Business: {{covered_biz.name}}

+ +

Request Url: {{request_url}}

+ +

Agent Verify Key: {{agent_verify_key}}

+ +

Request Payload:
+ {{request_obj}}

+ +

Encoded Payload (Signed Request):
+ {{signed_request}}

+ +

Response Code: {{response_code}}

+ +

Response Payload:
+ {{response_payload}}

+ +

Test Results:

+

+ {% for test_result in test_results %} + {{test_result.name}}:{{test_result.result}}
+ {% endfor %} +

+
+ +
{% csrf_token %} + + +
+
diff --git a/drp_aa_mvp/data_rights_request/templates/drp_aa_mvp/index.html b/drp_aa_mvp/data_rights_request/templates/drp_aa_mvp/index.html index 3ee2661..34c0c1a 100644 --- a/drp_aa_mvp/data_rights_request/templates/drp_aa_mvp/index.html +++ b/drp_aa_mvp/data_rights_request/templates/drp_aa_mvp/index.html @@ -1,20 +1,24 @@ -
-

OSIRAA - Open Source Implementer's Reference Authorized Agent

-
+
+

OSIRAA - Open Source Implementer's Reference Authorized Agent

+
-

Version 0.7.0 - Updated March 2023

+

Version 0.9.3 - Updated November 2024

-

How to Use this App:
-OSIRAA (Open Source Implementer's Reference Authorized Agent) is test suite designed to simulate the role of an Authorized Agent in a Digital Rights Protocol (DRP) environment. The application tests for the availability, correctness and completeness of API endpoints of a Privacy Infrastructure Provider (PIP) or Covered Business (CB) partner application. See https://github.com/consumer-reports-digital-lab/data-rights-protocol/blob/main/data-rights-protocol.md for more info on DRP system roles and API specification.

+

How to Use this App:
+ OSIRAA (Open Source Implementer's Reference Authorized Agent) is test suite designed to simulate the role of an Authorized Agent in a Digital Rights Protocol (DRP) environment. The application tests for the availability, correctness and completeness of API endpoints of a Privacy Infrastructure Provider (PIP) or Covered Business (CB) partner application. See https://github.com/consumer-reports-innovation-lab/data-rights-protocol/blob/main/data-rights-protocol.md for more info on DRP system roles and API specification.

-

Admin Tool
-A user may model a PIP or Covered Business in the Admin Tool, along with any number of users. This is a standard Python app, so you must first create an admin superuser in the usual way before you can administer data configurations. For version 0.7, a Covered Business requires a Discovery Endpoint, and Auth Bearer Token, to be supplied by the PIP/CB partner. NOTE: The previously required API Secret has been depricated. The Authorized Agent will be using a public/private key instead of shared api secret. -

+

Admin Tool
+ A user may model a PIP or Covered Business in the Admin Tool, along with any number of users. This is a standard Python app, so you must first create an admin superuser in the usual way before you can administer data configurations. For version 0.9.3, the Discovery Endpoint for a Covered Business has been depricated; it has been replaced by a Service Directory. The Service Directory holds discoverable information for all DPR impelementers in a common place. This information is periodically queried and the database automatically updated. +

-

Cert Tests Definitions
-The Digital Rights Protocol is centered on a set of API calls between an Authorized Agent and a PIP or Covered Business, on behalf of a User exercising his or her digital rights.

+

Cert Tests Definitions
+ The Digital Rights Protocol is centered on a set of API calls between an Authorized Agent and a PIP or Covered Business, on behalf of a User exercising his or her digital rights.

-

Run Tests Against a PIP or Covered Business API Endpoint
-First select a PIP or Covered Business from the dropdown. You can then test the API endpoints for that PIP/CB for the following calls: Discover, Exercise and Status. Some calls require additional parameters such as a User or Covered Regime. These can be set via dropdowns above the button in each section to trigger the call. Users (Identity Users) can be configured in the Admin Tool.

+

Run Tests Against a PIP or Covered Business API Endpoint
+ First select a PIP or Covered Business from the dropdown. You can then test the API endpoints for that PIP/CB for the following calls: Discover, Exercise and Status. Some calls require additional parameters such as a User or Covered Regime. These can be set via dropdowns above the button in each section to trigger the call. Users (Identity Users) can be configured in the Admin Tool.

-

Once the call is made, the app presents an analysis of the response. It shows the request url, the response code, and the response payload. If the the response is valid json, it test for required fields, fields in the response specific to the request params, etc. Note that you must first call Exercise for a given PIP/User combination before you can call Status. This is because the Exercise call returns a request_id, which is used for subsequent Status calls.

+

Once the call is made, the app presents an analysis of the response. It shows the request url, the response code, and the response payload. If the the response is valid json, it test for required fields, fields in the response specific to the request params, etc. Note that you must first call Exercise for a given PIP/User combination before you can call Status. This is because the Exercise call returns a request_id, which is used for subsequent Status calls.

+ +

Generate Agent Auth Keys
+ Every Auhorized Agent requires a pair of auth keys - the secret signing key and the public verify key - to function in the DRP ecosystem. For memebers of the DRP consortium, the public key is published in the Service Directory for Authorized Agents at https://discovery.datarightsprotocol.org/agents.json. This utility will generate a pair of secure keys in compliance with the technical requirements of the DRP spec for use in your app.

+
\ No newline at end of file diff --git a/drp_aa_mvp/data_rights_request/urls.py b/drp_aa_mvp/data_rights_request/urls.py index 54ca140..6455a91 100644 --- a/drp_aa_mvp/data_rights_request/urls.py +++ b/drp_aa_mvp/data_rights_request/urls.py @@ -4,25 +4,13 @@ urlpatterns = [ path('', views.index, name='index'), - - path('make_request', views.index, name='make_request'), - + path('identity_verification.html', views.identity_verification, name='identity_verification'), + path('refresh_service_directory_data', views.refresh_service_directory_data, name='refresh_service_directory_data'), path('select_covered_business', views.select_covered_business, name='select_covered_business'), - path('setup_pairwise_key', views.setup_pairwise_key, name='setup_pairwise_key'), - path('get_agent_information', views.get_agent_information, name='get_agent_information'), - - path('send_request_discover_data_rights', views.send_request_discover_data_rights, - name='send_request_discover_data_rights'), - - path('send_request_excercise_rights', views.send_request_excercise_rights, - name='send_request_excercise_rights'), - + path('send_request_exercise_rights', views.send_request_exercise_rights, name='send_request_exercise_rights'), path('send_request_get_status', views.send_request_get_status, name='send_request_get_status'), - path('send_request_revoke', views.send_request_revoke, name='send_request_revoke'), - - path('data_rights_request_sent_return', views.data_rights_request_sent_return, - name='data_rights_request_sent_return'), + path('data_rights_request_sent_return', views.data_rights_request_sent_return, name='data_rights_request_sent_return'), ] diff --git a/drp_aa_mvp/data_rights_request/views.py b/drp_aa_mvp/data_rights_request/views.py index f6431e7..2e2a55e 100644 --- a/drp_aa_mvp/data_rights_request/views.py +++ b/drp_aa_mvp/data_rights_request/views.py @@ -1,64 +1,79 @@ +import arrow import base64 -import datetime +from datetime import datetime, timezone, timedelta + import json -import os -import re +import re # regex library +import requests +import validators + from typing import Optional, Tuple import logging -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) -import arrow -import requests -import validators -from covered_business.models import CoveredBusiness +from django.conf import settings from django.core import serializers from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import render + from nacl import signing -from nacl.encoding import HexEncoder +from nacl.encoding import Base64Encoder from nacl.public import PrivateKey -from reporting.views import (test_agent_information_endpoint, test_discovery_endpoint, test_excercise_endpoint, - test_status_endpoint, test_revoked_endpoint, test_pairwise_key_setup_endpoint) -from user_identity.models import IdentityUser from .models import (DataRightsRequest, DataRightsStatus, DrpRequestStatusPair, DrpRequestTransaction, IdentityPayload) -#root_utl = os.environ['REQUEST_URI'] -#print (f"**** root_url = {root_utl}") +from covered_business.models import CoveredBusiness +from reporting.views import (test_agent_information_endpoint, test_exercise_endpoint, #test_discovery_endpoint, + test_status_endpoint, test_revoked_endpoint, test_pairwise_key_setup_endpoint) +from user_identity.models import IdentityUser + +import drp_pip.models + + +auth_agent_drp_id = settings.AUTHORIZED_AGENT_ID +auth_agent_drp_name = settings.AUTHORIZED_AGENT_NAME +auth_agent_callback_url = settings.WEB_URL + '/update_status' #'http://127.0.0.1:8003/update_status' -auth_agent_drp_id = os.environ.get('OSIRAA_AA_ID', 'CR_AA_DRP_ID_001') -auth_agent_callback_url = "http://127.0.0.1:8001/update_status" #f"{os.environ.get('SERVER_NAME')}/update_status" +service_directory_agents_url = settings.SERVICE_DIRECTORY_AGENT_URL +service_directory_businesses_url = settings.SERVICE_DIRECTORY_BUSINESS_URL -# todo: these keys actually should be generated offline before we start using the app -# and get them from the v0.0 service direcotry which will be a part of this dhango app, along with OSIRPIP -# for now we'll generate the keys one-time only -def load_pynacl_keys() -> Tuple[signing.SigningKey, signing.VerifyKey]: - path = os.environ.get("OSIRAA_KEY_FILE", "./keys.json") - if not os.path.exists(path): - with open(path, "w") as f: - signing_key = signing.SigningKey.generate() - verify_key = signing_key.verify_key - json.dump({ - "signing_key": signing_key.encode(encoder=HexEncoder).decode(), - "verify_key": verify_key.encode(encoder=HexEncoder).decode() - }, f) - with open(path, "r") as f: - jason = json.load(f) - return (signing.SigningKey(jason["signing_key"], encoder=HexEncoder), - signing.VerifyKey(jason["verify_key"], encoder=HexEncoder)) +# get the (b64-encoded) keys from environment vars (or the vault if the app is deployed) ... +# signing key must remain secret +settings_signing_key = settings.AGENT_SIGNING_KEY_B64 +# verify key must match the key stored in the service directory +settings_verify_key = settings.AGENT_VERIFY_KEY_B64 -signing_key, verify_key = load_pynacl_keys() +logger.info(f"** settings_signing_key = {settings_signing_key}") +logger.info(f"** settings_verify_key = {settings_verify_key}") +# create encoded keys from the strings loaded in from the settings ... +def encode_keys() -> Tuple[signing.SigningKey, signing.VerifyKey]: + return (signing.SigningKey(settings_signing_key, encoder=Base64Encoder), + signing.VerifyKey(settings_verify_key, encoder=Base64Encoder)) + +signing_key, verify_key = encode_keys() + +#logger.debug(f"signing_key = {signing_key}") +#logger.debug(f"verify_key = {verify_key}") # the public key and signing key as b64 strings -signing_key_hex = signing_key.encode(encoder=HexEncoder) # remains secret, never shared, but remains with AA model -verify_key_hex = verify_key.encode(encoder=HexEncoder) # we're going to store hex encoded verify key in the service directory -logger.debug(f"verify_key is {verify_key_hex}") +signing_key_b64 = signing_key.encode(encoder=Base64Encoder) +verify_key_b64 = verify_key.encode(encoder=Base64Encoder) + +auth_agent_signing_key = signing_key_b64 +auth_agent_verify_key = verify_key_b64 + +logger.info(f"** auth_agent_drp_id = {auth_agent_drp_id}") +logger.info(f"** auth_agent_drp_name = {auth_agent_drp_name}") +logger.info(f"** auth_agent_callback_url = {auth_agent_callback_url}") +logger.info(f"** auth_agent_signing_key = {auth_agent_signing_key}") +logger.info(f"** auth_agent_verify_key = {auth_agent_verify_key}") + selected_covered_biz: Optional[CoveredBusiness] = None @@ -78,6 +93,48 @@ def index(request): return render(request, 'data_rights_request/index.html', context) +# call to the service directory returns the info for all CB's, same as what was in the .well_known endpoint for each CB +def refresh_service_directory_data (request): + request_url = service_directory_businesses_url + response = get_service_directory_covered_biz(request_url) + + try: + response_json = json.loads(response.text) + except ValueError as e: + logger.warn('** WARNING - refresh_service_directory_data(): NOT valid json **') + return False + + for response_item in response_json: + covered_biz_cb_id = str(response_item['id']) # corresponds to cb_id in the CovereredBusiness model + covered_biz_id = get_covered_biz_id_from_cb_id(covered_biz_cb_id) # index to lookup the object + + if covered_biz_id is not None: + covered_biz = CoveredBusiness.objects.get(pk=covered_biz_id) + set_covered_biz_params_from_service_directory(covered_biz, response_item) + else: + create_covered_biz_db_entry_from_service_directory_params(response_item) + + # todo: handle case where SD enrty is removed - mark CB in DB as 'removed' ... + + drp_pip.models.AuthorizedAgent.refresh_from_directory(service_directory_agents_url) + + user_identities = IdentityUser.objects.all() + covered_businesses = CoveredBusiness.objects.all() + covered_biz_id = request.POST.get('sel_covered_biz_id') + #selected_covered_biz = CoveredBusiness.objects.get(pk=covered_biz_id) + #covered_biz_form_display = get_covered_biz_form_display(covered_businesses, selected_covered_biz) + request_actions = get_request_actions_form_display(selected_covered_biz) + + context = { + 'user_identities': user_identities, + 'covered_businesses': covered_businesses, #covered_biz_form_display, + 'selected_covered_biz': selected_covered_biz, + 'request_actions': request_actions + } + + return render(request, 'data_rights_request/index.html', context) + + def select_covered_business(request): user_identities = IdentityUser.objects.all() covered_businesses = CoveredBusiness.objects.all() @@ -96,58 +153,27 @@ def select_covered_business(request): return render(request, 'data_rights_request/index.html', context) -def send_request_discover_data_rights(request): - covered_biz_id = request.POST.get('sel_covered_biz_id') - covered_biz = CoveredBusiness.objects.get(pk=covered_biz_id) - request_url = covered_biz.discovery_endpoint # + ".well-known/data-rights.json" - bearer_token = covered_biz.auth_bearer_token or "" - - if (validators.url(request_url)): - unauthed_response = get_well_known(request_url) - response = get_well_known(request_url, bearer_token) - set_covered_biz_well_known_params(covered_biz, response) - - discover_test_results = test_discovery_endpoint(request_url, { - 'unauthed': unauthed_response, - 'authed': response - }) - - request_sent_context = { - 'covered_biz': covered_biz, - 'request_url': request_url, - 'response_code': response.status_code, - 'response_payload': response.text, - 'test_results': discover_test_results, - } - - else: - request_sent_context = { - 'covered_biz': covered_biz, - 'request_url': request_url, - 'response_code': 'invalid url for /discover, no response', - 'response_payload': '', - 'test_results': [], - } - - return render(request, 'data_rights_request/request_sent.html', request_sent_context) - - -def setup_pairwise_key(request): +def setup_pairwise_key(request): # a.k.a. regsiter agent covered_biz_id = request.POST.get('sel_covered_biz_id') covered_biz = CoveredBusiness.objects.get(pk=covered_biz_id) request_url = covered_biz.api_root_endpoint + f"/v1/agent/{auth_agent_drp_id}" - request_obj = create_setup_pairwise_key_request_json(covered_biz.cb_id) - + request_obj = create_setup_pairwise_key_request_json(covered_biz.cb_id) signed_request = sign_request(signing_key, request_obj) + logger.info('** setup_pairwise_key(): request_url = ' + request_url) + if (validators.url(request_url)): response = post_agent(request_url, signed_request) pairwise_setup_test_results = test_pairwise_key_setup_endpoint(request_obj, response) - set_covered_biz_pairwise_key_params(covered_biz, response, signing_key, verify_key) + + set_covered_biz_pairwise_key_params(covered_biz, response) request_sent_context = { 'covered_biz': covered_biz, 'request_url': request_url, + 'agent_verify_key': auth_agent_verify_key, + 'request_obj': request_obj, + 'signed_request': signed_request, 'response_code': response.status_code, 'response_payload': response.text, 'test_results': pairwise_setup_test_results, @@ -157,6 +183,9 @@ def setup_pairwise_key(request): request_sent_context = { 'covered_biz': covered_biz, 'request_url': request_url, + 'agent_verify_key': auth_agent_verify_key, + 'request_obj': request_obj, + 'signed_request': signed_request, 'response_code': 'invalid url for /create_pairwise_key, no response', 'response_payload': '', 'test_results': [], @@ -165,13 +194,15 @@ def setup_pairwise_key(request): return render(request, 'data_rights_request/request_sent.html', request_sent_context) - def get_agent_information(request): covered_biz_id = request.POST.get('sel_covered_biz_id') covered_biz = CoveredBusiness.objects.get(pk=covered_biz_id) request_url = covered_biz.api_root_endpoint + f"/v1/agent/{auth_agent_drp_id}" bearer_token = covered_biz.auth_bearer_token or "" + + logger.info('** get_agent_information(): request_url = ' + request_url) + if (validators.url(request_url)): response = get_agent(request_url, bearer_token) agent_info_test_results = test_agent_information_endpoint(request_url, response) @@ -180,6 +211,7 @@ def get_agent_information(request): request_sent_context = { 'covered_biz': covered_biz, 'request_url': request_url, + 'agent_verify_key': auth_agent_verify_key, 'response_code': response.status_code, 'response_payload': response.text, 'test_results': agent_info_test_results, @@ -189,7 +221,8 @@ def get_agent_information(request): request_sent_context = { 'covered_biz': covered_biz, 'request_url': request_url, - 'response_code': 'invalid url for /create_pairwise_key, no response', + 'agent_verify_key': auth_agent_verify_key, + 'response_code': 'invalid url for /get_agent_information, no response', 'response_payload': '', 'test_results': [], } @@ -197,8 +230,7 @@ def get_agent_information(request): return render(request, 'data_rights_request/request_sent.html', request_sent_context) - -def send_request_excercise_rights(request): +def send_request_exercise_rights(request): covered_biz_id = request.POST.get('sel_covered_biz_id') covered_biz = CoveredBusiness.objects.get(pk=covered_biz_id) user_id_id = request.POST.get('user_identity') @@ -206,16 +238,20 @@ def send_request_excercise_rights(request): request_action = request.POST.get('request_action') covered_regime = request.POST.get('covered_regime') - request_url = covered_biz.api_root_endpoint + "/v1/data-right-request/" + # note - removed trailing slash + request_url = covered_biz.api_root_endpoint + "/v1/data-rights-request" bearer_token = covered_biz.auth_bearer_token + logger.info(f'** setup_pairwise_key(): request_url = {request_url}') + # todo: a missing param in the request_json could cause trouble ... - #print('** send_request_excercise_rights(): request_action = ' + request_action) + #print('** send_request_exercise_rights(): request_action = ' + request_action) - request_json = create_excercise_request_json(user_identity, covered_biz, - request_action, covered_regime) + request_json = create_exercise_request_json(user_identity, covered_biz, request_action, covered_regime) + logger.info(f'** setup_pairwise_key(): request_json = {request_json}') signed_request = sign_request(signing_key, request_json) + logger.info(f'** setup_pairwise_key(): signed_request = {signed_request}') if (validators.url(request_url)): response = post_exercise_rights(request_url, bearer_token, signed_request) @@ -226,8 +262,11 @@ def send_request_excercise_rights(request): request_sent_context = { 'covered_biz': covered_biz, 'request_url': request_url, + 'agent_verify_key': auth_agent_verify_key, + 'request_obj': request_json, + 'signed_request': signed_request, 'response_code': response.status_code, - 'response_payload': 'invalid json in response for /v1/data-right-request/', + 'response_payload': 'invalid json in response for /v1/data-rights-request', 'test_results': [], } @@ -239,20 +278,26 @@ def send_request_excercise_rights(request): data_rights_transaction: DrpRequestTransaction = create_drp_request_transaction(user_identity, covered_biz, request_json, response_json) - excercise_test_results = test_excercise_endpoint(request_json, response) + exercise_test_results = test_exercise_endpoint(request_json, response) request_sent_context = { 'covered_biz': covered_biz, 'request_url': request_url, + 'agent_verify_key': auth_agent_verify_key, + 'request_obj': request_json, + 'signed_request': signed_request, 'response_code': response.status_code, 'response_payload': response.text, - 'test_results': excercise_test_results + 'test_results': exercise_test_results } else: request_sent_context = { 'covered_biz': covered_biz, 'request_url': request_url, + 'agent_verify_key': auth_agent_verify_key, + 'request_obj': request_json, + 'signed_request': signed_request, 'response_code': 'invalid url for /excecise , no response', 'response_payload': '', 'test_results': [], @@ -272,24 +317,24 @@ def send_request_get_status(request): if (request_id != None): if (validators.url(request_url)): - # todo: This request SHALL contain an Bearer Token header containing the key for this AA-CB pairwise relationship in it in the form Authorization: Bearer . This token is generated by calling POST /agent/{id} in section 2.06. + # This request SHALL contain an Bearer Token header containing the key for this AA-CB pairwise relationship in it in the form Authorization: Bearer . This token is generated by calling POST /agent/{id} in section 2.06. response = get_status(request_url, request_id, bearer_token) - # todo: log request to DB, setup status callback ... - status_test_results = test_status_endpoint(request_url, response) request_sent_context = { 'covered_biz': covered_biz, - 'request_url': response.request.url, - 'response_code': response.status_code, + 'request_url': response.request.url, + 'agent_verify_key': auth_agent_verify_key, + 'response_code': response.status_code, 'response_payload': response.text, - 'test_results': status_test_results + 'test_results': status_test_results } else: request_sent_context = { 'covered_biz': covered_biz, 'request_url': request_url, + 'agent_verify_key': auth_agent_verify_key, 'response_code': 'invalid url for /status , no response', 'response_payload': '', 'test_results': [], @@ -298,6 +343,7 @@ def send_request_get_status(request): request_sent_context = { 'covered_biz': covered_biz, 'request_url': request_url, + 'agent_verify_key': auth_agent_verify_key, 'response_code': 'no request id for this user and covered business, request not sent', 'response_payload': '', 'test_results': [], @@ -322,14 +368,13 @@ def send_request_revoke(request): if (validators.url(request_url)): response = post_revoke(request_url, bearer_token, signed_request) - - # todo: log request to DB, stop status ping ... - revoke_test_results = test_revoked_endpoint(request_url, response) context = { 'covered_biz': covered_biz, 'request_url': response.request.url, + 'request_obj': request_json, + 'signed_request': signed_request, 'response_code': response.status_code, 'response_payload': response.text, 'test_results': revoke_test_results, @@ -339,6 +384,9 @@ def send_request_revoke(request): request_sent_context = { 'covered_biz': covered_biz, 'request_url': request_url, + 'agent_verify_key': auth_agent_verify_key, + 'request_obj': request_json, + 'signed_request': signed_request, 'response_code': 'invalid url for /revoke , no response', 'response_payload': '', 'test_results': [], @@ -347,6 +395,9 @@ def send_request_revoke(request): request_sent_context = { 'covered_biz': covered_biz, 'request_url': "/v1/data-rights-request/{{None}}", + 'agent_verify_key': auth_agent_verify_key, + 'request_obj': request_json, + 'signed_request': signed_request, 'response_code': 'no request id for this user and covered business, request not sent', 'response_payload': '', 'test_results': [], @@ -375,21 +426,38 @@ def data_rights_request_sent_return(request): #-------------------------------------------------------------------------------------------------# -def set_covered_biz_well_known_params(covered_biz, response): - try: - json.loads(response.text) - except ValueError as e: - logger.warn('** WARNING - set_covered_biz_well_known_params(): NOT valid json **') - return False +def get_covered_biz_id_from_cb_id(covered_biz_cb_id): + covered_businesses = CoveredBusiness.objects.all() + + for covered_biz in covered_businesses: + if covered_biz.cb_id == covered_biz_cb_id: + return covered_biz.id + + return None + +def set_covered_biz_params_from_service_directory(covered_biz, params_json): try: - reponse_json = response.json() - covered_biz.api_root = reponse_json['api_base'] - covered_biz.supported_actions = reponse_json['actions'] + covered_biz.api_root = params_json['api_base'] + covered_biz.supported_actions = params_json['supported_actions'] covered_biz.save() except KeyError as e: - logger.warn('** WARNING - set_covered_biz_well_known_params(): missing keys **') - return False + logger.warn('** WARNING - set_covered_biz_params_from_service_directory(): missing keys **') + raise e + +def create_covered_biz_db_entry_from_service_directory_params (params_json): + try: + cb_id = params_json['id'] + name = params_json['name'] + logo = params_json['logo'] + api_root_endpoint = params_json['api_base'] + supported_actions = params_json['supported_actions'] + + new_covered_biz = CoveredBusiness.objects.create(name=name, cb_id=cb_id, logo=logo, api_root_endpoint=api_root_endpoint, supported_actions=supported_actions) + + except KeyError as e: + logger.warn('** WARNING - set_covered_biz_params_from_service_directory(): missing keys **') + raise e def get_covered_biz_form_display(covered_businesses, selected_biz): @@ -449,26 +517,30 @@ def get_request_actions_form_display (covered_biz): def sign_request(signing_key, request_obj): signed_obj = signing_key.sign(json.dumps(request_obj).encode()) - bencoded = base64.b64encode(signed_obj) + b64encoded = base64.b64encode(signed_obj) - return bencoded + return b64encoded def create_setup_pairwise_key_request_json(covered_biz_id): - issued_time = arrow.get() - expires_time = issued_time.shift(minutes=15) + issued_time = datetime.now(timezone.utc) + expires_time = issued_time + timedelta(minutes=15) # 15 minutes from now + issued_timestamp = issued_time.isoformat(timespec='milliseconds') + expires_timestamp = expires_time.isoformat(timespec='milliseconds') request_json = { "agent-id": auth_agent_drp_id, "business-id": covered_biz_id, - "expires-at": str(expires_time), - "issued-at": str(issued_time), + "issued-at": issued_timestamp, + "expires-at": expires_timestamp, } + #logger.info(f"** create_setup_pairwise_key_request_json(): request_json = {request_json}") + return request_json -def set_covered_biz_pairwise_key_params(covered_biz, response, signing_key, verify_key): +def set_covered_biz_pairwise_key_params(covered_biz, response): try: json.loads(response.text) except ValueError as e: @@ -488,20 +560,24 @@ def set_covered_biz_pairwise_key_params(covered_biz, response, signing_key, veri covered_biz.save() except KeyError as e: - logger.warn('** WARNING - set_covered_biz_pairwise_key_params(): missing keys **') + logger.warn('** WARNING - set_covered_biz_pairwise_key_params(): missing token **') return False def create_agent_key_setup_json(agent_id, business_id): - issued_time = datetime.datetime.now() - expires_time = issued_time + datetime.timedelta(min=15) # 15 minutes from now + issued_time = datetime.now(timezone.utc) + expires_time = issued_time + timedelta(minutes=15) # 15 minutes from now + issued_timestamp = issued_time.isoformat(timespec='milliseconds') + expires_timestamp = expires_time.isoformat(timespec='milliseconds') agent_key_setup_json = { "agent-id": agent_id, "business-id": business_id, - "expires-at": expires_time, - "issued-at": issued_time + "issued-at": issued_timestamp, + "expires-at": expires_timestamp, } + + #logger.info(f"** create_agent_key_setup_json(): agent_key_setup_json = {agent_key_setup_json}") return agent_key_setup_json @@ -522,34 +598,44 @@ def set_agent_info_params(response): logger.warn('** WARNING - set_agent_info_params(): missing keys **') return False + #--------------------------------------------------------------------------------------------------# -def create_excercise_request_json(user_identity, covered_biz, request_action, covered_regime): - issued_time = arrow.get() - expires_time = issued_time.shift(days=45) +def create_exercise_request_json(user_identity, covered_biz, request_action, covered_regime): + issued_time = datetime.now(timezone.utc) + expires_time = issued_time + timedelta(minutes=15) # 15 minutes from now + issued_timestamp = issued_time.isoformat(timespec='milliseconds') + expires_timestamp = expires_time.isoformat(timespec='milliseconds') request_obj = { # 1 "agent-id": auth_agent_drp_id, "business-id": covered_biz.cb_id, - "expires-at": str(expires_time), - "issued-at": str(issued_time), + "issued-at": issued_timestamp, + "expires-at": expires_timestamp, # 2 - "drp.version": "0.7", + "drp.version": "0.9.3", "exercise": request_action, "regime": covered_regime, "relationships": [], "status_callback": auth_agent_callback_url, # 3 - # claims in IANA JSON Web Token Claims page, see https://www.iana.org/assignments/jwt/jwt.xhtml#claims for details + # claims in IANA JSON Web Token Claims page, see + # https://www.iana.org/assignments/jwt/jwt.xhtml#claims for details + "name": (user_identity.last_name + ", " + user_identity.first_name), "email": user_identity.email, + "email_verified": user_identity.email_verified, "phone_number": user_identity.phone_number, + "phone_number_verified": user_identity.phone_verified, "address": user_identity.get_address(), + "address_verified": user_identity.address_verified, } + #logger.debug(f"** create_exercise_request_json(): request_obj = {request_obj}") + return request_obj @@ -579,7 +665,7 @@ def create_drp_request_transaction(user_identity, covered_biz, request_json, res ) data_rights_request = DataRightsRequest.objects.create( - #request_id not sent on /excercise call + #request_id not sent on /exercise call #meta = request_json['meta'], relationships = request_json['relationships'], status_callback = request_json['status_callback'], @@ -596,14 +682,14 @@ def create_drp_request_transaction(user_identity, covered_biz, request_json, res processing_details = response_json.get('processing_details'), reason = response_json.get('reason'), user_verification_url = response_json.get('user_verification_url'), - # these fields need to be coerced to a datetime from arbitrary timestamps + # coerce to a datetime object from timestamp string received_at = enrich_date(response_json.get('received_at')), expected_by = enrich_date(response_json.get('expected_by')), # expires_at? ) # todo: this doesn't seem to work ... - #excercise_request = DrpRequestStatusPair.create(data_rights_request.id, data_rights_status.id) + #exercise_request = DrpRequestStatusPair.create(data_rights_request.id, data_rights_status.id) transaction = DrpRequestTransaction.objects.create( user_ref = user_identity, @@ -611,22 +697,19 @@ def create_drp_request_transaction(user_identity, covered_biz, request_json, res request_id = data_rights_status.request_id, current_status = data_rights_status.status, # expires_date = data_rights_status.expires_date, - is_final = False, - #excer_request = excercise_request + #exer_request = exercise_request ) return transaction def enrich_date(dt: Optional[str]): - ''' - arrow.get returns "now" if you pass it None -- we want to just not persist anything in that case. - - additionally, munge the input string to drop RFC3339 characters which are incorrectly parsed as timestamps - ''' + # arrow.get returns "now" if you pass it None -- we want to just not persist anything in that case. if dt is None: return None + + # additionally, munge the input string to drop RFC3339 characters which are incorrectly parsed as timestamps if re.search(r'-[0-9]{4}$', dt): dt = dt[:-5] # sickos.jpg @@ -647,7 +730,14 @@ def get_request_id (covered_biz, user_identity): #-------------------------------------------------------------------------------------------------# +#GET https://discovery.datarightsprotocol.org/businesses.json +def get_service_directory_covered_biz (service_dir_biz_url): + response = requests.get(service_dir_biz_url) + return response + + #GET /.well-known/data-rights.json +''' def get_well_known(discovery_url, bearer_token=""): if bearer_token != "": request_headers = {'Authorization': f"Bearer {bearer_token}"} @@ -656,9 +746,10 @@ def get_well_known(discovery_url, bearer_token=""): response = requests.get(discovery_url) return response +''' -#POST /v1/data-right-request/ +#POST /v1/data-rights-request/ def post_exercise_rights(request_url, bearer_token, signed_request): request_headers = { 'Authorization': f"Bearer {bearer_token}", @@ -702,3 +793,6 @@ def get_agent(request_url, bearer_token): response = requests.get(request_url, headers=request_headers) return response + +def identity_verification(request): + return render(request, 'data_rights_request/identity_verification.html', {}) diff --git a/drp_aa_mvp/drp_aa_mvp/settings.py b/drp_aa_mvp/drp_aa_mvp/settings.py index 7076d93..b5b8188 100644 --- a/drp_aa_mvp/drp_aa_mvp/settings.py +++ b/drp_aa_mvp/drp_aa_mvp/settings.py @@ -24,6 +24,17 @@ ENV = os.environ.get('DJANGO_ENV', DEV) +def get(variable, default=''): + """ + To be used over os.environ.get() to avoid deploying local/dev keys in production. Forced + env vars to be present. + """ + if ENV == PRODUCTION and variable not in os.environ: + raise Exception('Required environment variable not set: {}'.format(variable)) + + return os.environ.get(variable, default) + + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ @@ -31,18 +42,17 @@ SECRET_KEY = 'django-insecure-%k6u+v8prz33iu179r=u^x=nqgf3eaged+x5h93rs(kob^t6u)' # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = False #True ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'drp-authorized-agent.herokuapp.com', '44.209.94.186', 'osiraa.datarightsprotocol.org'] - # Application definition - INSTALLED_APPS = [ 'user_identity.apps.UserIdentityConfig', 'covered_business.apps.CoveredBusinessConfig', 'data_rights_request.apps.DataRightsRequestConfig', 'reporting.apps.ReportingConfig', + 'agent_keys.apps.AgentKeysConfig', 'drp_pip.apps.DrpPipConfig', 'django.contrib.admin', 'django.contrib.auth', @@ -88,20 +98,18 @@ # https://docs.djangoproject.com/en/3.2/ref/settings/#databases if ENV in [STAGING, PRODUCTION]: - # for heroku deploy ... import dj_database_url DATABASES = { 'default': dj_database_url.config(conn_max_age=500, ssl_require=False), } - else: # for local use only ... DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': os.environ.get('POSTGRES_NAME') or 'authorizedagent', + 'NAME': os.environ.get('POSTGRES_NAME') or 'authorizedagent09', 'USER': os.environ.get('POSTGRES_USER') or 'postgres', - 'PASSWORD': os.environ.get('POSTGRES_PASSWORD') or 'rootz', + 'PASSWORD': os.environ.get('POSTGRES_PASSWORD') or 'postgres', 'HOST': os.environ.get('POSTGRES_HOST') or 'localhost', 'PORT': os.environ.get('POSTGRES_PORT') or '5432' }, @@ -112,21 +120,34 @@ # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, + { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator' }, + { 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator' }, + { 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator' }, + { 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator' }, ] +# Authorized Agent ID and Name, should match entries in service directory +AUTHORIZED_AGENT_ID = get('AUTHORIZED_AGENT_ID', 'CR_AA_DRP_ID_001_LOCAL') +AUTHORIZED_AGENT_NAME = get('AUTHORIZED_AGENT_NAME', 'OSIRAA Local Test Instance') +WEB_URL = get('WEB_URL', 'http://127.0.0.1:8003') + +# Authorized Agent Signing Key (64-bit encoded). Must remain secret. +AGENT_SIGNING_KEY_B64 = get('AGENT_SIGNING_KEY_B64', '098LMB1ayJW1N45oQ4J22ddU96gXr3/x5hEmKnPFpP0=') + +# Authorized Agent Verify Key (64-bit encoded) +# This is the public verify key for use in sending DRP requests to CB and PIP partners. +# It must match the key decalared in the Service Directory, or else partners' attempt +# to validate DRP messages they receive will fail +AGENT_VERIFY_KEY_B64 = get('AGENT_VERIFY_KEY_B64', 'jkX15E7+NA/0E7K5YAp7+GndMP6/Fa0dJJYyr1GJPoQ=') + +SERVICE_DIRECTORY_AGENT_URL = 'https://discovery.datarightsprotocol.org/agents.json' +SERVICE_DIRECTORY_BUSINESS_URL = 'https://discovery.datarightsprotocol.org/businesses.json' + +# CB ID and Name for OSIRPIP +OSIRAA_PIP_CB_ID = get('OSIRAA_PIP_CB_ID', "osirpip-cb-local-01") +OSIRAA_PIP_CB_NAME = get('OSIRAA_PIP_CB_NAME', 'OSIRPIP Local Test Instance') + # Internationalization # https://docs.djangoproject.com/en/3.2/topics/i18n/ diff --git a/drp_aa_mvp/drp_aa_mvp/urls.py b/drp_aa_mvp/drp_aa_mvp/urls.py index 8a26a59..39177de 100644 --- a/drp_aa_mvp/drp_aa_mvp/urls.py +++ b/drp_aa_mvp/drp_aa_mvp/urls.py @@ -23,6 +23,7 @@ urlpatterns = [ path('', views.index, name='index'), path('admin/', admin.site.urls), + path('agent_keys/', include('agent_keys.urls')), path('user_identity/', include('user_identity.urls')), path('covered_business/', include('covered_business.urls')), path('data_rights_request/', include('data_rights_request.urls')), diff --git a/drp_aa_mvp/drp_pip/migrations/0001_initial.py b/drp_aa_mvp/drp_pip/migrations/0001_initial.py index f3e38d6..ba35977 100644 --- a/drp_aa_mvp/drp_pip/migrations/0001_initial.py +++ b/drp_aa_mvp/drp_pip/migrations/0001_initial.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): ('logo', models.ImageField(blank=True, upload_to='company-logos', verbose_name='Logo Image')), ('logo_thumbnail', models.ImageField(blank=True, upload_to='company-logos/thumbnails')), ('subtitle_description', models.TextField(blank=True)), - ('verify_key', models.TextField(verbose_name='Hex encoded key to verify signed requests')), + ('verify_key', models.TextField(verbose_name='Base64 encoded key to verify signed requests')), ], ), ] diff --git a/drp_aa_mvp/drp_pip/models.py b/drp_aa_mvp/drp_pip/models.py index f4e97e9..f261c15 100644 --- a/drp_aa_mvp/drp_pip/models.py +++ b/drp_aa_mvp/drp_pip/models.py @@ -1,19 +1,20 @@ from django.db import models import data_rights_request.models as drr +import json +import logging +import requests + class MessageValidationException(Exception): pass -# TKTKTK I should really be thinking hard about just using the one in -# the OSIRAA side... not sue how that would effect an "internal" end -# to end test tho right now so just duplicating. class DataRightsRequest(drr.DataRightsRequest): aa_id = models.CharField(max_length=63, blank=True, default='') + class DataRightsStatus(drr.DataRightsStatus): aa_id = models.CharField(max_length=63, blank=True, default='') - class AuthorizedAgent(models.Model): name = models.CharField(max_length=63, blank=True, default='') brand_name = models.CharField(max_length=63, blank=True, default='') @@ -21,8 +22,10 @@ class AuthorizedAgent(models.Model): logo = models.ImageField('Logo Image', upload_to='company-logos', blank=True) logo_thumbnail = models.ImageField(upload_to='company-logos/thumbnails', blank=True) subtitle_description = models.TextField(blank=True) + + # todo: confirm that it's the correct, base64 encoded key ... + verify_key = models.TextField('Base64 encoded key to verify signed requests') - verify_key = models.TextField('Hex encoded key to verify signed requests') bearer_token = models.TextField('pair-wise token between AA and CB', blank=True) def __str__(self): @@ -35,3 +38,43 @@ def fetch_by_id(cls, aa_id: str): @classmethod def fetch_by_bearer_token(cls, token: str): return cls.objects.get(bearer_token=token) + + @classmethod + def refresh_from_directory(cls, directory_url): + response = requests.get(directory_url) + + try: + response_json = json.loads(response.text) + except ValueError as e: + logging.warn('** WARNING - refresh_service_directory(): NOT valid json **') + return False + + # loop thru entries and update the CB's in the DB + for item in response_json: + agent_id = str(item['id']) # this field corresponds to cb_id in the CovereredBusiness model + + aa_id = item["id"] + name = item["name"] + logo = item.get("logo") + + # todo: confirm that it's the correct, base64 encoded key ... + verify_key = item["verify_key"] + + try: + agent_model = cls.fetch_by_id(agent_id) + except cls.DoesNotExist as e: + agent_model = None + + if agent_model is not None: + agent_model.aa_id = aa_id + agent_model.name = name + agent_model.logo = logo + agent_model.verify_key = verify_key + agent_model.save() + else: + new_agent = cls.objects.create( + aa_id = aa_id, + name = name, + logo = logo, + verify_key = verify_key, + ) diff --git a/drp_aa_mvp/drp_pip/urls.py b/drp_aa_mvp/drp_pip/urls.py index d7d240f..1055a19 100644 --- a/drp_aa_mvp/drp_pip/urls.py +++ b/drp_aa_mvp/drp_pip/urls.py @@ -4,10 +4,7 @@ urlpatterns = [ # path('', views.index, name='index'), - - path('.well-known/data-rights.json', views.static_discovery, name='discovery'), - # TKTKTK fix path to match - path('v1/data-right-request/', views.exercise, name='receive_request'), + path('v1/data-rights-request/', views.exercise, name='receive_request'), path('v1/data-rights-request/', views.get_status, name='get_status'), - path('v1/agent/', views.agent, name='agent_router_ugghhh'), + path('v1/agent/', views.agent, name='agent_router'), ] diff --git a/drp_aa_mvp/drp_pip/views.py b/drp_aa_mvp/drp_pip/views.py index 38bc8dd..70f9198 100644 --- a/drp_aa_mvp/drp_pip/views.py +++ b/drp_aa_mvp/drp_pip/views.py @@ -1,3 +1,4 @@ +import arrow import base64 import json import os @@ -5,12 +6,13 @@ from typing import Optional import uuid -import arrow +from django.conf import settings +from django.http import JsonResponse, HttpResponse, HttpRequest from django.shortcuts import render from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods -from django.http import JsonResponse, HttpResponse, HttpRequest -from nacl.encoding import HexEncoder + +from nacl.encoding import Base64Encoder from nacl.signing import VerifyKey from nacl.utils import random import nacl.exceptions @@ -19,23 +21,17 @@ DataRightsRequest, DataRightsStatus) from data_rights_request.models import ACTION_CHOICES, REGIME_CHOICES -# TKTKTK cross-module import +# todo: cross-module import ... # from data_rights_request.models import ACTION_CHOICES, REGIME_CHOICES import logging logging.basicConfig(level=logging.WARN) logger = logging.getLogger(__name__) -logger.setLevel(logging.WARN) +logger.setLevel(logging.DEBUG) -OSIRAA_PIP_CB_ID = os.environ.get("OSIRAA_PIP_CB_ID", "osiraa-local-001") +OSIRAA_PIP_CB_ID = settings.OSIRAA_PIP_CB_ID +#OSIRAA_PIP_CB_ID = os.environ.get("OSIRAA_PIP_CB_ID", "osirpip-cb-local-01") -@csrf_exempt -def static_discovery(request): - return JsonResponse({ - "version": "0.7", - "actions": ["sale:opt-out", "sale:opt-in", "access", "deletion"], - "api_base": f"{request.scheme}://{request.get_host()}/pip/", - }) """ Privacy Infrastructure Providers MUST validate the message in this order: @@ -47,23 +43,30 @@ def static_discovery(request): - That the current time is before the Expiration expires-at claim """ +# the /agent endpoint, supports two different methods GET and POST @csrf_exempt @require_http_methods(["GET", "POST"]) def agent(request, aa_id: str): - """ - urlconfs can't choose a route based on method so we'll do it ourselves - I really do hate django. - """ - if request.method == 'GET': - return agent_status(request, aa_id) - elif request.method == 'POST': - return register_agent(request, aa_id) + logger.info('** drp_pip.agent()') + # urlconfs can't choose a route based on method so we'll do it ourselves + if request.method == 'POST': + return register_agent(request, aa_id) + elif request.method == 'GET': + return agent_information(request, aa_id) + +@csrf_exempt +# when an agent sends call to setup pairwise keys, it goes here. +# we validate their agent id and verify key, and return a bearer token def register_agent(request, aa_id: str): + logger.info('** drp_pip.register_agent()') + agent = AuthorizedAgent.fetch_by_id(aa_id) + logger.info(f'** drp_pip.register_agent(): agent = {agent}') + if agent is None: - # Validate That the signature validates to the key associated with the out of band Authorized Agent identity presented in the request path. + # validate that the signature validates to the key associated with the out of band Authorized Agent identity presented in the request path logger.error(f"could not find authorized agent for {aa_id}") return HttpResponse(status=403) @@ -73,33 +76,38 @@ def register_agent(request, aa_id: str): return HttpResponse(status=403) # make a token and persist it... - agent.bearer_token = HexEncoder.encode(random(size=64)).decode() + agent.bearer_token = Base64Encoder.encode(random(size=64)).decode() + try: agent.save() + return JsonResponse({ "agent-id": message["agent-id"], "token": agent.bearer_token }) except: - return HttpResponse(b"Something went wonky! Token did not persist.", status=500) + return HttpResponse(b"Something went wrong, token did not persist.", status=500) + def validate_auth_header(request) -> Optional[str]: auth_header = request.headers.get("Authorization") - extractor = r"Bearer ([a-zA-Z0-9=+\-_/]+)" - matches = re.match(extractor, auth_header) + extractor = r"Bearer ([a-zA-Z0-9=+\-_/]+)" + matches = re.match(extractor, auth_header) + if matches is None: - logger.error(f"Auth header did not parse.") - logger.error(f"header '{auth_header}'") + logger.error(f"validate_auth_header(): Auth header '{auth_header}' did not parse") return None + #logger.debug(f"** validate_auth_header(): matches = {matches}") + return matches.group(1) @csrf_exempt -def agent_status(request, aa_id: str): - """ - This method just looks to see that the bearer token is in the DB. - """ +def agent_information(request, aa_id: str): + logger.info('** drp_pip.agent_information()') + + # this method just looks to see that the bearer token is in the DB ... bearer_token = validate_auth_header(request) if not bearer_token: return HttpResponse(status=403) @@ -107,25 +115,52 @@ def agent_status(request, aa_id: str): agent = AuthorizedAgent.fetch_by_bearer_token(bearer_token) if agent.aa_id != aa_id: - logger.error(f"bearer token did not match expected AA {aa_id}???") + logger.error(f"bearer token did not match expected AA {aa_id}") return HttpResponse(status=403) if agent is None: - logger.error(f"tok did not resolve to agent; caller expected {aa_id}") + logger.error(f"token did not resolve to agent; caller expected {aa_id}") return HttpResponse(status=403) return JsonResponse({}) - +# the /exercise endpoint - POST only @csrf_exempt def exercise(request: HttpRequest): + logger.info('** drp_pip.exercise()') + + logger.debug(f"** drp_pip.exercise(): request = {request}") + + # todo: why is the body empty when the message arrives ??? + logger.info(f'** drp_pip.exercise(): request.body = {request.body}') + + logger.info(f'** drp_pip.exercise(): data = {request.POST.data}') + + body_str = request.body.decode(); + logger.info(f'** drp_pip.exercise(): body_str = {body_str}') + + #bytestring = b'Hello, world!' + #string = bytestring.decode('utf-8') + bearer_token = validate_auth_header(request) + + logger.info(f'** drp_pip.exercise(): bearer_token = {bearer_token}') + if not bearer_token: return HttpResponse(status=403) agent = AuthorizedAgent.fetch_by_bearer_token(bearer_token) + logger.info(f'** drp_pip.exercise(): agent = {agent.name}') + + logger.info(f'** drp_pip.exercise(): request.body = {request.body}') + + #logger.info(f'** drp_pip.exercise(): request.encoding = {request.encoding}') + #logger.info(f'** drp_pip.exercise(): request.POST = {request.POST}') + + #or, we can use HttpRequest.read() or even HttpRequest.POST + try: message = validate_message_to_agent(agent, request) except: @@ -145,7 +180,7 @@ def exercise(request: HttpRequest): status_callback = message['status_callback'], regime = db_regime, right = db_right, - # persist claims...? + # todo: persist claims ... ? ) status = dict( @@ -156,7 +191,6 @@ def exercise(request: HttpRequest): # processing_details = response_json.get('processing_details'), # reason = response_json.get('reason'), # user_verification_url = response_json.get('user_verification_url'), - # these fields need to be coerced to a datetime from arbitrary timestamps received_at = str(arrow.get()) # expected_by = enrich_date(response_json.get('expected_by')), ) @@ -197,52 +231,76 @@ def get_status(request, request_id: str): def validate_message_to_agent(agent: AuthorizedAgent, request: HttpRequest) -> dict: - """Validate the message is coming from the specified agent and - destined to us in a reasonable time window. Returns the - deserialized message or raises. - """ - now = arrow.get() + # validate that the message is coming from the specified agent and destined to us in a reasonable time window + # returns the deserialized message or raises an exception + + logger.info(f"** validate_message_to_agent()") aa_id = agent.aa_id - verify_key_hex = agent.verify_key - verify_key = VerifyKey(verify_key_hex, encoder=HexEncoder) + + # todo: verify this is the correctly encoded verify key + verify_key_b64 = agent.verify_key - logger.debug(f"vk is {verify_key_hex}") - logger.debug(f"agent is {aa_id}") + # todo: what does this do? why to we need to unencode it? + verify_key = VerifyKey(verify_key_b64, encoder=Base64Encoder) - decoded = base64.b64decode(request.body) - logger.debug(f"decoded is {decoded}") - logger.debug(f"encoded is {request.body}") + logger.debug(f"** authourized_agent_id = {aa_id}") + logger.debug(f"** verify_key_b64 = {verify_key_b64}") + + logger.debug(f"** encoded request.body = {request.body}") + decoded_body = request.body.decode() + logger.debug(f"** decoded_body = {decoded_body}") + + b64decoded_body = base64.b64decode(request.body) + logger.debug(f"** b64decoded_body = {b64decoded_body}") + try: - # don't need to do anything here -- if it doesn't raise it's verified! - serialized_message = verify_key.verify(decoded) + serialized_message = verify_key.verify(decoded_body) + logger.debug(f"** serialized_message = {serialized_message}") + except nacl.exceptions.BadSignatureError as e: - # Validate That the signature validates to the key associated with the out of band Authorized Agent identity presented in the request path. - logger.error(f"bad signature from {aa_id}: {e}") + # Validate the signature to the key associated with the out of band + # Authorized Agent identity presented in the request path + logger.error(f"** validate_message_to_agent(): bad signature from {aa_id}: {e}") + raise e message = json.loads(serialized_message) - + logger.debug(f"** message = {message}") + aa_id_claim = message["agent-id"] if aa_id_claim != aa_id: - # Validate that the Authorized Agent specified in the agent-id claim in the request matches the Authorized Agent associated with the presented Bearer Token - raise MessageValidationException(f"outer aa {aa_id} doesn't match claim {aa_id_claim}!!") + # validate that the Authorized Agent specified in the agent-id claim in the request matches + # the Authorized Agent associated with the presented Bearer Token + logger.error(f"** validate_message_to_agent(): outer aa {aa_id} doesn't match claim {aa_id_claim}") + + raise MessageValidationException(f"outer aa {aa_id} doesn't match claim {aa_id_claim}") business_id_claim = message["business-id"] if business_id_claim != OSIRAA_PIP_CB_ID: - # - That they are the Covered Business specified inside the business-id claim + # validate that they are the Covered Business specified inside the business-id claim + logger.error(f"** validate_message_to_agent(): claimed business-id {business_id_claim} does not match expected {OSIRAA_PIP_CB_ID}") + raise MessageValidationException(f"claimed business-id {business_id_claim} does not match expected {OSIRAA_PIP_CB_ID}") + now = arrow.get() + expires_at_claim = message["expires-at"] if now > arrow.get(expires_at_claim): - # TKTKTK: maybe worth checking that it's within like 15 minutes or something just to be sure the AA is compliant? - # - That the current time is after the Timestamp issued-at claim + # validate that the current time is after the Timestamp issued-at claim + # todo: check that it's within like 15 minutes or so just to be sure the AA is compliant ... ? + logger.error(f"** validate_message_to_agent(): message has expired {expires_at_claim}") + raise MessageValidationException(f"Message has expired! {expires_at_claim}") issued_at_claim = message["issued-at"] if arrow.get(issued_at_claim) > now: - # - That the current time is before the Expiration expires-at claim - raise MessageValidationException(f"Message from the future??? {issued_at_claim}") + # validate that the current time is before the Expiration expires-at claim + logger.error(f"** validate_message_to_agent(): message from the future? {issued_at_claim}") + + raise MessageValidationException(f"Message from the future? {issued_at_claim}") + + logger.info(f"** validate_message_to_agent(): message = {message}") return message diff --git a/drp_aa_mvp/fixtures/fixture.json b/drp_aa_mvp/fixtures/fixture.json index ea1babb..7686c4f 100644 --- a/drp_aa_mvp/fixtures/fixture.json +++ b/drp_aa_mvp/fixtures/fixture.json @@ -1,52 +1,52 @@ [ -{ - "model": "covered_business.coveredbusiness", - "pk": 3, - "fields": { - "name": "osiraa-local", - "brand_name": "", - "cb_id": "osiraa-local-001", - "logo": "", - "logo_thumbnail": "", - "subtitle_description": "", - "discovery_endpoint": "http://localhost:8000/pip/.well-known/data-rights.json", - "api_root_endpoint": "http://localhost:8000/pip", - "supported_actions": "[\"sale:opt-out\", \"sale:opt-in\", \"access\", \"deletion\"]", - "auth_bearer_token": "_" + { + "model": "covered_business.coveredbusiness", + "pk": 3, + "fields": { + "name": "osiraa-local", + "brand_name": "", + "cb_id": "osiraa-local-001", + "logo": "", + "logo_thumbnail": "", + "subtitle_description": "", + "discovery_endpoint": "http://localhost:8000/pip/.well-known/data-rights.json", + "api_root_endpoint": "http://localhost:8000/pip", + "supported_actions": "[ \"sale:opt-out\", \"sale:opt-in\", \"access\", \"deletion\" ]", + "auth_bearer_token": "_" + } + }, + { + "model": "drp_pip.authorizedagent", + "pk": 1, + "fields": { + "name": "osiraa-local", + "brand_name": "", + "aa_id": "CR_AA_DRP_ID_001", + "logo": "", + "logo_thumbnail": "", + "subtitle_description": "", + "verify_key": "b0409a509cf611a0529ff25df555f692e896eaeb599bfc6e402fa18e523ad7a0", + "bearer_token": "" + } + }, + { + "model": "user_identity.identityuser", + "pk": 1, + "fields": { + "first_name": "John", + "last_name": "Random", + "email": "jrandom1980@gmail.com", + "email_verified": true, + "phone_number": "", + "phone_verified": false, + "city": "", + "country": "", + "address1": "", + "address2": "", + "state_province": "", + "zip_postal": "", + "address_verified": false, + "power_of_attorney": false + } } -}, -{ - "model": "drp_pip.authorizedagent", - "pk": 1, - "fields": { - "name": "osiraa-local", - "brand_name": "", - "aa_id": "CR_AA_DRP_ID_001", - "logo": "", - "logo_thumbnail": "", - "subtitle_description": "", - "verify_key": "b0409a509cf611a0529ff25df555f692e896eaeb599bfc6e402fa18e523ad7a0", - "bearer_token": "" - } -}, -{ - "model": "user_identity.identityuser", - "pk": 1, - "fields": { - "first_name": "John", - "last_name": "Random", - "email": "jrandom1980@gmail.com", - "email_verified": true, - "phone_number": "", - "phone_verified": false, - "city": "", - "country": "", - "address1": "", - "address2": "", - "state_province": "", - "zip_postal": "", - "address_verified": false, - "power_of_attorney": false - } -} ] diff --git a/drp_aa_mvp/reporting/models.py b/drp_aa_mvp/reporting/models.py index 890a1ae..476e391 100644 --- a/drp_aa_mvp/reporting/models.py +++ b/drp_aa_mvp/reporting/models.py @@ -43,7 +43,7 @@ class ReportEntry(models.Model): covered_biz_name = models.CharField(max_length=127, blank=True, default='') is_pip = models.BooleanField(default=False) - # action (for excercise only) + # action (for exercise only) request_action = models.CharField(max_length=10, blank=True, default='', choices=REQUEST_ACTION_CHOICES) covered_regime = models.CharField(max_length=31, blank=True, default='', diff --git a/drp_aa_mvp/reporting/templates/reporting/index.html b/drp_aa_mvp/reporting/templates/reporting/index.html index d684c5a..455658d 100644 --- a/drp_aa_mvp/reporting/templates/reporting/index.html +++ b/drp_aa_mvp/reporting/templates/reporting/index.html @@ -1,327 +1,304 @@ -
-

OSIRAA - Open Source Implementer's Reference Authorized Agent

-
- -

DRP Cert Test Suite

+
+

OSIRAA - Open Source Implementer's Reference Authorized Agent

+
-

Version 0.7.0 - Updated March 2023

+

DRP Cert Test Suite

-

See also https://github.com/consumer-reports-digital-lab/data-rights-protocol/blob/main/data-rights-protocol.md

+

Version 0.9.3 - Updated November 2024

-
+

See also https://github.com/consumer-reports-innovation-lab/data-rights-protocol/blob/main/data-rights-protocol.md

+
-

1.   GET /.well-known/data-rights.json (“Data Rights Discovery” endpoint)

-
    -
  • Covered Business's domain SHOULD have a /.well-known/data-rights.json
  • -
  • Discovery Endpoint MUST be valid JSON
  • -
  • Discovery Endpoint MUST contain a version field (currently 0.7)
  • -
  • Discovery Endpoint MUST provide a field “api_base” -
      -
    • “api_base” url MUST be a well-formed url
    • -
    • “api_base” url MUST be valid for subsequent calls
    • -
    -
  • -
  • Discovery Endpoint MUST provide a list of supported “actions”
  • -
  • Discovery Endpoint MAY contain a hint set “user_relationships”
  • -
  • Discovery Endpoint SHOULD NOT contain additional undefined fields
  • -
-
+

2.   POST /v1/data-rights-request (“Data Rights Exercise” endpoint)

+
    +
  • A Data Rights Exercise request SHALL contain a JSON-encoded message body
  • +
  • The message body SHALL have a libsodium/NaCl/ED25119 binary signature immediately prepended to it
  • +
    + +
  • The message body MUST containing the following fields: +
      +
    • “agent-id” - a string identifying the Authorized Agent which is submitting the data rights request
    • +
    • “business-id” - a string identifying the Covered Business which the request is being sent to
    • +
    • “issued-at” - an ISO 8601-encoded timestamp expressing when the request was created.
    • +
    • “expires-at” - an ISO 8601-encoded timestamp expressing when the request should no longer be considered viable
    • +
    • “drp.version” - a string referencing the current protocol version "0.9.3"
    • +
    • “exercise” - string specifying the Rights Action: [ access | deletion | sale:opt_out | sale:opt_in | access:categories | access:specific ]
    • +
    • “regime” (optional) - a string specifying the legal regime under which the Data Request is being taken: [ ccpa | voluntary ]
    • +
    • “relationships” (optional) - a list of string 'hints' for the Covered Business
    • +
    • “status_callback” (optional) - a URL that the Status Callback can be sent to
    • +
    • “name” (str) - if known, claim SHALL contain the user's full name most likely known by the Covered Business
    • +
    • “email” (str) - if known, claim SHALL contain the user's email address
    • +
    • “email_verified” (bool) - TRUE if the user's email address has been affirmatively verified according to the System Rules
    • +
    • “phone_number” (str) - if known, claim SHALL contain the user's phone number in E.164 encoding
    • +
    • “phone_number_verified” (bool) - TRUE if the user's phone number has been affirmatively verified according to the System Rules
    • +
    • “address” (str) - if known, claim SHALL contain the user's preferred address, asspecified in OpenID Connect Core 1.0 section 5.1.1
    • +
    • “address_verified” (bool) - TRUE if the user's address has been affirmatively verified according to the System Rules
    • +
    • “power_of_attorney” (str) - MAY contain a reference to a User-signed document delegating power of attorney to the submitting AA
    • +
    +
  • +
    + +
  • The Privacy Infrastructure Provider SHALL validate the message is signed according to the guidance in section 3.07
  • +
    + +
  • All calls MUST return a Data Rights Status object for all actions listed in the Service Directory for the Covered Business
  • +
  • Values of fields may vary, see below. Test all supported permutations: +
      +
    • POST /v1/data-rights-request { action: “access”, regime: “ccpa” }
    • +
    • POST /v1/data-rights-request { action: “access”, regime: “voluntary” }
    • +
    • POST /v1/data-rights-request { action: “deletion”, regime: “ccpa” }
    • +
    • POST /v1/data-rights-request { action: “deletion”, regime: “voluntary” }
    • +
    • POST /v1/data-rights-request { action: “sale:opt_out”, regime: “ccpa” }
    • +
    • POST /v1/data-rights-request { action: “sale:opt_out”, regime: “voluntary” }
    • +
    • POST /v1/data-rights-request { action: “sale:opt_in”, regime: “ccpa” }
    • +
    • POST /v1/data-rights-request { action: “sale:opt_in”, regime: “voluntary” }
    • +
    +
  • +
-

2.   POST /v1/data-right-request/ (“Data Rights Exercise” endpoint)

-
    -
  • A Data Rights Exercise request SHALL contain a JSON-encoded message body
  • -
  • The message body SHALL have a libsodium/NaCl/ED25119 binary signature immediately prepended to it
  • +
      +
    • Returns a Data Rights Status object +
        +
      • MUST contain field “request_id”
      • +
      • “request_id” is globally unique
      • +
      • MUST contain field “status”
      • +
      • “status” allowable values: [ "in_progress" | "open" | "fulfilled" | "revoked" | "denied" | "expired" ]
      • +
      • allowable status values vary with action; see below
      • +
      • MAY contain field “reason”
      • +
      • “reason” allowable values: "need_verification" | "suspected_fraud" | “insuf_verification” | "no_match" | "claim_not_covered" | "too_many_requests" | "outside_jurisdiction" | "other" | “none”
      • +
      • allowable reason values vary with status; see below
      • +
      • MUST contain field “received_at”
      • +
      • “received_at” is an ISO 8601 string (ISO time format)
      • +
      • SHOULD contain field “expected_by”
      • +
      • “expected_by” is an ISO 8601 string (ISO time format)
      • +
      • MAY contain a field "processing_details", a text string - @RR is there a max character length?
      • +
      • MAY contain a field "user_verification_url"
      • +
      • "user_verification_url" must be a well formatted URI
      • +
      • "user_verification_url" must a valid endmpoint which correctly returns data about the request
      • +
      • MAY contain a field "expires_at"
      • +
      • "expires_at" should be an [ISO 8601]-encoded time
      • +
      • Additional optional/unknown fields - throw a warning
      • +
      +
    • +

    -
  • The message body MUST containing the following fields: -
      - The first set of claims describes the parties and time frame -
    • “agent-id” - a string identifying the Authorized Agent which is submitting the data rights request
    • -
    • “business-id” - a string identifying the Covered Business which the request is being sent to
    • -
    • “expires-at” - an ISO 8601-encoded timestamp expressing when the request should no longer be considered viable
    • -
    • “issued-at” - an ISO 8601-encoded timestamp expressing when the request was created.
    • -
      +

      2.1.   POST /v1/data-rights-request { action: “access”, regime: “ccpa” }

      +
        +
      • Status: “open” | “in_progress”
      • +
      • If status == “open”, reason SHOULD be “none”
      • +
      • If status == “in_progress”, reason SHOULD be “need_verification” | “none”
      • +
      • If status == “in_progress”, response MUST contain "expires-at"
      • +
      +
      - The second grouping contains data about the Data Rights Request -
    • “drp.version” - a string referencing the current protocol version "0.7"
    • -
    • “exercise” - string specifying the Rights Action: [ access | deletion | sale:opt_out | sale:opt_in | access:categories | access:specific ]
    • -
    • “regime” (optional) - a string specifying the legal regime under which the Data Request is being taken: [ ccpa | voluntary ]
    • -
    • “relationships” (optional) - a list of string 'hints' for the Covered Business
    • -
    • “status_callback” (optional) - a URL that the Status Callback can be sent to
    • -
      +

      2.2.   POST /v1/data-rights-request { action: “access”, regime: “voluntary” }

      +
        +
      • Status: “open” | “in_progress” | “denied”
      • +
      • If status == “open”, reason SHOULD be “none”
      • +
      • If status == “in_progress”, reason SHOULD be “need_verification” | “none”
      • +
      • If status == “in_progress”, response MUST contain "expires-at"
      • +
      • If status == “denied”, reason SHOULD be “outside_jurisdiction”
      • +
      +
      - The JSON object minimally must contain the claims outlined in section 3.04 regarding identity encapsulation -
    • “name” (str) - if known, claim SHALL contain the user's full name most likely known by the Covered Business
    • -
    • “email” (str) - if known, claim SHALL contain the user's email address
    • -
    • “email_verified” (bool) - TRUE if the user's email address has been affirmatively verified according to the System Rules
    • -
    • “phone_number” (str) - if known, claim SHALL contain the user's phone number in E.164 encoding
    • -
    • “phone_number_verified” (bool) - TRUE if the user's phone number has been affirmatively verified according to the System Rules
    • -
    • “address” (str) - if known, claim SHALL contain the user's preferred address, asspecified in OpenID Connect Core 1.0 section 5.1.1
    • -
    • “address_verified” (bool) - TRUE if the user's address has been affirmatively verified according to the System Rules
    • -
    • “power_of_attorney” (str) - MAY contain a reference to a User-signed document delegating power of attorney to the submitting AA
    • +

      2.3.   POST /v1/data-rights-request { action: “deletion”, regime: “ccpa” }

      +
        +
      • Status: “open” | “in_progress”
      • +
      • If status == “open”, reason SHOULD be “none”
      • +
      • If status == “in_progress”, reason SHOULD be “need_verification” | “none”
      • +
      • If status == “in_progress”, response MUST contain "expires-at"
      • +
      +
      -
    -
  • +

    2.4.   POST /v1/data-rights-request { action: “deletion”, regime: “voluntary” }

    +
      +
    • Status: “open” | “in_progress” | “denied”
    • +
    • If status == “open”, reason SHOULD be “none”
    • +
    • If status == “in_progress”, reason SHOULD be “need_verification” | “none”
    • +
    • If status == “in_progress”, response MUST contain "expires-at"
    • +
    • If status == “denied”, reason SHOULD be “outside_jurisdiction”
    • +

    -
  • The Privacy Infrastructure Provider SHALL validate the message is signed according to the guidance in section 3.07
  • +

    2.5.   POST /v1/data-rights-request { action: “sale:opt_out”, regime: “ccpa” }

    +
      +
    • Status: “open” | “in_progress”
    • +
    • If status == “open”, reason SHOULD be “none”
    • +
    • If status == “in_progress”, reason SHOULD be “need_verification” | “none”
    • +
    • If status == “in_progress”, response MUST contain "expires-at"
    • +

    -
  • All calls MUST return a Data Rights Status object for all actions listed in .well-known/data-rights.json
  • -
  • Values of fields may vary, see below
  • -
  • Test all supported permutations: -
      -
    • POST /v1/data-right-request/ { action: “access”, regime: “ccpa” }
    • -
    • POST /v1/data-right-request/ { action: “access”, regime: “voluntary” }
    • -
    • POST /v1/data-right-request/ { action: “deletion”, regime: “ccpa” }
    • -
    • POST /v1/data-right-request/ { action: “deletion”, regime: “voluntary” }
    • -
    • POST /v1/data-right-request/ { action: “sale:opt_out”, regime: “ccpa” }
    • -
    • POST /v1/data-right-request/ { action: “sale:opt_out”, regime: “voluntary” }
    • -
    • POST /v1/data-right-request/ { action: “sale:opt_in”, regime: “ccpa” }
    • -
    • POST /v1/data-right-request/ { action: “sale:opt_in”, regime: “voluntary” }
    • -
    -
  • -
+

2.6.   POST /v1/data-rights-request { action: “sale:opt_out”, regime: “voluntary” }

+
    +
  • Status: “open” | “in_progress” | “denied”
  • +
  • If status == “open”, reason SHOULD be “none”
  • +
  • If status == “in_progress”, reason SHOULD be “need_verification” | “none”
  • +
  • If status == “in_progress”, response MUST contain "expires-at"
  • +
  • If status == “denied”, reason SHOULD be “outside_jurisdiction”
  • +
+
-
    -
  • Returns a Data Rights Status object -
      -
    • MUST contain field “request_id”
    • -
    • “request_id” is globally unique
    • -
    • MUST contain field “status”
    • -
    • “status” allowable values: [ "in_progress" | "open" | "fulfilled" | "revoked" | "denied" | "expired" ]
    • -
    • allowable status values vary with action; see below
    • -
    • MAY contain field “reason”
    • -
    • “reason” allowable values: "need_verification" | "suspected_fraud" | “insufficient_verification” | "no_match" | "claim_not_covered" | "too_many_requests" | "outside_jurisdiction" | "other" | “none”
    • -
    • allowable reason values vary with status; see below
    • -
    • MUST contain field “received_at”
    • -
    • “received_at” is an ISO 8601 string (ISO time format)
    • -
    • SHOULD contain field “expected_by”
    • -
    • “expected_by” is an ISO 8601 string (ISO time format)
    • -
    • MAY contain a field "processing_details", a text string - @RR is there a max character length?
    • -
    • MAY contain a field "user_verification_url"
    • -
    • "user_verification_url" must be a well formatted URI
    • -
    • "user_verification_url" must a valid endmpoint which correctly returns data about the request
    • -
    • MAY contain a field "expires_at"
    • -
    • "expires_at" should be an [ISO 8601]-encoded time
    • -
    • Additional optional/unknown fields - throw a warning
    • -
    -
  • -
-
+

2.7.   POST /v1/data-rights-request { action: “sale:opt_in”, regime: “ccpa” }

+
    +
  • Status: “open” | “in_progress”
  • +
  • If status == “open”, reason SHOULD be “none”
  • +
  • If status == “in_progress”, reason SHOULD be “need_verification” | “none”
  • +
  • If status == “in_progress”, response MUST contain "expires-at"
  • +
+
-

2.1.   POST /v1/data-right-request/ { action: “access”, regime: “ccpa” }

-
    -
  • Status: “open” | “in_progress”
  • -
  • If status == “open”, reason SHOULD be “none”
  • -
  • If status == “in_progress”, reason SHOULD be “need_verification” | “none”
  • -
-
+

2.8.   POST /v1/data-rights-request { action: “sale:opt_in”, regime: “voluntary” }

+
    +
  • Status: “open” | “in_progress” | “denied”
  • +
  • If status == “open”, reason SHOULD be “none”
  • +
  • If status == “in_progress”, reason SHOULD be “need_verification” | “none”
  • +
  • If status == “in_progress”, response MUST contain "expires-at"
  • +
  • If status == “denied”, reason SHOULD be “outside_jurisdiction”
  • +
+
-

2.2.   POST /v1/data-right-request/ { action: “access”, regime: “voluntary” }

-
    -
  • Status: “open” | “in_progress” | “denied”
  • -
  • If status == “open”, reason SHOULD be “none”
  • -
  • If status == “in_progress”, reason SHOULD be “need_verification” | “none”
  • -
  • If status == “denied”, reason SHOULD be “outside_jurisdiction”
  • -
-
-

2.3.   POST /v1/data-right-request/ { action: “deletion”, regime: “ccpa” }

-
    -
  • Status: “open” | “in_progress”
  • -
  • If status == “open”, reason SHOULD be “none”
  • -
  • If status == “in_progress”, reason SHOULD be “need_verification” | “none”
  • -
-
+

3.   GET /v1/data-rights-request/{request_id} (“Data Rights Status” endpoint)

+
    +
  • Returns a Data Rights Status object
  • +
  • Data Rights Status object MUST contain field “request_id” +
      +
    • “request_id” value should match the value passed in
    • +
    +
  • +
  • Data Rights Status object SHOULD contain field “received_at” +
      +
    • “received_at” is an ISO 8601 string (ISO time format)
    • +
    +
  • +
  • Data Rights Status object SHOULD contain field “expected_by” +
      +
    • “expected_by” is an ISO 8601 string (ISO time format)
    • +
    +
  • +
  • Data Rights Status object MUST contain field “status” +
      +
    • “status” allowable values: [ "in_progress" | "open" | "fulfilled" | "revoked" | "denied" | "expired" ]
    • +
    • allowable status values vary with action; see below
    • +
    +
  • +
  • Data Rights Status object MAY contain “reason” field +
      +
    • “reason” allowable values: “need_verification” | "suspected_fraud" | “insuf_verification” | "no_match" | "claim_not_covered" | "too_many_requests" | "outside_jurisdiction" | "other" | “none”
    • +
    • allowable reason values vary with status; see below
    • +
    +
  • +
  • Data Rights Status object MAY contain field “processing_details” +
      +
    • unconstrained text - @RR is the there a max character limit?
    • +
    +
  • +
  • Data Rights Status object MAY contain field “user_verification_url” +
      +
    • “user_verification_url” is a well-formatted url
    • +
    • “user_verification_url” is a an returns the correct data when called
    • +
    +
  • +
  • Data Rights Status object MAY contain field “expires_at” +
      +
    • “expires_at” is an ISO 8601 string (ISO time format)
    • +
    +
  • + +
  • Additional optional/unknown fields - throw a warning
  • +
+
-

2.4.   POST /v1/data-right-request/ { action: “deletion”, regime: “voluntary” }

-
    -
  • Status: “open” | “in_progress” | “denied”
  • -
  • If status == “open”, reason SHOULD be “none”
  • -
  • If status == “in_progress”, reason SHOULD be “need_verification” | “none”
  • -
  • If status == “denied”, reason SHOULD be “outside_jurisdiction”
  • -
-
+

3.1   GET /status fulfilled

+
    +
  • Additional fields: “results_url”, “expires_at”
  • +
  • Final
  • +
+
-

2.5.   POST /v1/data-right-request/ { action: “sale:opt_out”, regime: “ccpa” }

-
    -
  • Status: “open” | “in_progress”
  • -
  • If status == “open”, reason SHOULD be “none”
  • -
  • If status == “in_progress”, reason SHOULD be “need_verification” | “none”
  • -
-
+

3.2   GET /status denied

+
    +
  • “reason” allowable values: "suspected_fraud" | “insuf_verification” | "no_match" | "claim_not_covered" | "too_many_requests" | "outside_jurisdiction" | "other" | “none”
  • +
  • Additional fields: “processing_details”
  • +
  • Final for all reasons except "too_many_requests"
  • +
+
-

2.6.   POST /v1/data-right-request/ { action: “sale:opt_out”, regime: “voluntary” }

-
    -
  • Status: “open” | “in_progress” | “denied”
  • -
  • If status == “open”, reason SHOULD be “none”
  • -
  • If status == “in_progress”, reason SHOULD be “need_verification” | “none”
  • -
  • If status == “denied”, reason SHOULD be “outside_jurisdiction”
  • -
-
+

3.3   GET /status expired

+
    +
  • The time is currently after “expires_at” in the request
  • +
  • Final
  • +
+
-

2.7.   POST /v1/data-right-request/ { action: “sale:opt_in”, regime: “ccpa” }

-
    -
  • Status: “open” | “in_progress”
  • -
  • If status == “open”, reason SHOULD be “none”
  • -
  • If status == “in_progress”, reason SHOULD be “need_verification” | “none”
  • -
-
+

3.4   GET /status revoked

+
    +
  • Final
  • +
+
-

2.8.   POST /v1/data-right-request/ { action: “sale:opt_in”, regime: “voluntary” }

-
    -
  • Status: “open” | “in_progress” | “denied”
  • -
  • If status == “open”, reason SHOULD be “none”
  • -
  • If status == “in_progress”, reason SHOULD be “need_verification” | “none”
  • -
  • If status == “denied”, reason SHOULD be “outside_jurisdiction”
  • -
-
+

3.5   POST $status_callback (“Data Rights Status Callback” endpoint)

+
    +
  • SHOULD be implemented by Authorized Agents which will be exercising data rights for multiple Users
  • +
  • The request body MUST adhere to the Exercise Status Schema
  • +
  • THe PIP SHOULD make a best effort to ensure that a 200 response is issued for the most recent status update
  • +
  • The body of the callback's response SHOULD be discarded and not be considered for parsing
  • +
+
-

3.   GET /v1/data-rights-request/{request_id} (“Data Rights Status” endpoint)

-
    +

    4   DELETE /v1/data-rights-request/{request_id} (“Data Rights Revoke” endpoint)

    +
    • Returns a Data Rights Status object
    • Data Rights Status object MUST contain field “request_id”
        -
      • “request_id” value should match the value passed in
      • +
      • “request_id” value should match the value passed in
    • -
    • Data Rights Status object SHOULD contain field “received_at” +
    • Data Rights Status object MUST contain field “received_at”
      • “received_at” is an ISO 8601 string (ISO time format)
    • -
    • Data Rights Status object SHOULD contain field “expected_by” +
    • Data Rights Status object MUST contain “status” field
        -
      • “expected_by” is an ISO 8601 string (ISO time format)
      • -
      -
    • -
    • Data Rights Status object MUST contain field “status” -
        -
      • “status” allowable values: [ "in_progress" | "open" | "fulfilled" | "revoked" | "denied" | "expired" ]
      • +
      • “status” allowable values: [ "in_progress" | "open" | "revoked" | "denied" | "expired" ]
      • allowable status values vary with action; see below
      • +
      • Responses MUST contain the new revoked state - @RR this is underspecified, in what field is "revoked" contained?
    • Data Rights Status object MAY contain “reason” field -
        -
      • “reason” allowable values: “need_verification” | "suspected_fraud" | “insufficient_verification” | "no_match" | "claim_not_covered" | "too_many_requests" | "outside_jurisdiction" | "other" | “none”
      • +
          +
        • “reason” allowable values: “need_verification” | "suspected_fraud" | “insuf_verification” | "no_match" | "claim_not_covered" | "too_many_requests" | "outside_jurisdiction" | "other" | “none”
        • allowable reason values vary with status; see below
        -
      • Data Rights Status object MAY contain field “processing_details” -
          -
        • unconstrained text - @RR is the there a max character limit?
        • -
        -
      • -
      • Data Rights Status object MAY contain field “user_verification_url” +
      • Additional optional fields
          -
        • “user_verification_url” is a well-formatted url
        • -
        • “user_verification_url” is a an returns the correct data when called
        • +
        • TBD - enumerated in DRP spec, some have enumerated values for their fields
      • -
      • Data Rights Status object MAY contain field “expires_at” -
          -
        • “expires_at” is an ISO 8601 string (ISO time format)
        • -
        -
      • -
      • Additional optional/unknown fields - throw a warning
      • -
      -
      - -

      3.1   GET /status fulfilled

      -
        -
      • Additional fields: “results_url”, “expires_at”
      • -
      • Final
      • -
      -
      - -

      3.2   GET /status denied

      -
        -
      • “reason” allowable values: "suspected_fraud" | “insufficient_verification” | "no_match" | "claim_not_covered" | "too_many_requests" | "outside_jurisdiction" | "other" | “none”
      • -
      • Additional fields: “processing_details”
      • -
      • Final for all reasons except "too_many_requests"
      • -
      -
      - -

      3.3   GET /status expired

      -
        -
      • The time is currently after “expires_at” in the request
      • -
      • Final
      • -
      -
      - -

      3.4   GET /status revoked

      -
        -
      • Final
      • -
      -
      - - -

      3.5   POST $status_callback (“Data Rights Status Callback” endpoint)

      -
        -
      • SHOULD be implemented by Authorized Agents which will be exercising data rights for multiple Users
      • -
      • The request body MUST adhere to the Exercise Status Schema
      • -
      • THe PIP SHOULD make a best effort to ensure that a 200 response is issued for the most recent status update
      • -
      • The body of the callback's response SHOULD be discarded and not be considered for parsing
      • -
      -
      - - -

      4   DELETE /v1/data-rights-request/{request_id} (“Data Rights Revoke” endpoint)

      -
        -
      • Returns a Data Rights Status object
      • -
      • Data Rights Status object MUST contain field “request_id” -
          -
        • “request_id” value should match the value passed in
        • -
        -
      • -
      • Data Rights Status object MUST contain field “received_at” -
          -
        • “received_at” is an ISO 8601 string (ISO time format)
        -
      • -
      • Data Rights Status object MUST contain “status” field +
        + +

        5   POST /v1/agent/{agent-id} (“Pair-wise Key Setup” endpoint)

          -
        • “status” allowable values: [ "in_progress" | "open" | "revoked" | "denied" | "expired" ]
        • -
        • allowable status values vary with action; see below
        • -
        • Responses MUST contain the new revoked state - @RR this is underspecified, in what field is "revoked" contained?
        • -
        -
      • -
      • Data Rights Status object MAY contain “reason” field -
          -
        • “reason” allowable values: “need_verification” | "suspected_fraud" | “insufficient_verification” | "no_match" | "claim_not_covered" | "too_many_requests" | "outside_jurisdiction" | "other" | “none”
        • -
        • allowable reason values vary with status; see below
        • +
        • returns a JSON response
        • +
        • response has a field “agent-id”
        • +
        • “agent-id” key SHALL match the agent-id presented in the signed request
        • +
        • response has a field "token"
        • +
        • PIPs SHOULD generate this token using a cryptographically secure source such as libsodium's CSPRNG
        • +
        • if validation fails, the PIP SHALL return an HTTP 403 Forbidden response with no response body
        -
      • -
      • Additional optional fields +
        + +

        5.1   GET /v1/agent/{agent-id} (“Agent Information” endpoint)

          -
        • TBD - enumerated in DRP spec, some have enumerated values for their fields
        • +
        • does not need to return anything more than an empty JSON document and HTTP 200 response code
        • +
        • if the agent-id presented does not match the presented Bearer Token, the PIP MUST return a 403 Forbidden response
        -
      • -
      • Additional optional/unknown fields - throw a warning
      • -
      -
      - -

      5   POST /v1/agent/{agent-id} (“Pair-wise Key Setup” endpoint)

      -
        -
      • returns a JSON response
      • -
      • response has a field “agent-id”
      • -
      • “agent-id” key SHALL match the agent-id presented in the signed request
      • -
      • response has a field "token"
      • -
      • PIPs SHOULD generate this token using a cryptographically secure source such as libsodium's CSPRNG
      • -
      • if validation fails, the PIP SHALL return an HTTP 403 Forbidden response with no response body
      • -
      -
      - -

      5.1   GET /v1/agent/{agent-id} (“Agent Information” endpoint)

      -
        -
      • does not need to return anything more than an empty JSON document and HTTP 200 response code
      • -
      • if the agent-id presented does not match the presented Bearer Token, the PIP MUST return a 403 Forbidden response
      • -
      -
      - -
      -
      \ No newline at end of file +
diff --git a/drp_aa_mvp/reporting/views.py b/drp_aa_mvp/reporting/views.py index fb0081b..aec0fec 100644 --- a/drp_aa_mvp/reporting/views.py +++ b/drp_aa_mvp/reporting/views.py @@ -13,172 +13,23 @@ def index(request): return render(request, 'reporting/index.html', context) -#---------------------------------------------------------------------------------------------------------------------# -# test_discovery_endpoint - -def test_discovery_endpoint(request_url, responses): - test_results = [] - - """ - 1. GET /.well-known/data-rights.json ("Data Rights Discovery" endpoint) - - Covered Business's domain SHOULD have a /.well-known/data-rights.json - - Discovery Endpoint MUST be valid JSON - - Discovery Endpoint MUST contain a version field (currently 0.7) - - Discovery Endpoint MUST provide a field “api_base” - - “api_base” url MUST be a well-formed url - - “api_base” url MUST be valid for subsequent calls - - Discovery Endpoint MUST provide a list of supported “actions” - - listed “actions” MUST be valid values (from list) - - supported “actions” MUST be implemented by "Data Rights Exercise" endpoint - - Discovery Endpoint MAY contain a hint set “user_relationships” - - Discovery Endpoint SHOULD NOT contain additional undefined fields - """ - test_results = [] - - # unauthed response SHOULD be a 200 response code - unauthed = responses['unauthed'] - authed = responses['authed'] - - is_endpoint_auth_not_required = unauthed.status_code == 200 - test_results.append({'name': 'Endpoint auth is not required', 'result': is_endpoint_auth_not_required}) - - if is_endpoint_auth_not_required: - # request sent without authorization headers - response = unauthed - else: - # request sent with authorization headers - response = authed - - # test Covered Business's domain SHOULD have a discovery endpoint - is_valid_endpoint = test_is_discovery_endpoint_valid_url(request_url, response) - test_results.append({'name': 'Is valid enpoint', 'result': is_valid_endpoint}) - - # test Covered Business's domain a discovery endpoint SHOULD be /.well-known/data-rights.json - is_compliant_endpoint = test_is_discovery_endpoint_compliant_url(request_url) - test_results.append({'name': 'Is compliant enpoint', 'result': is_compliant_endpoint}) - - # test Discovery Endpoint MUST be valid JSON - is_valid_json = test_is_valid_json(response) - test_results.append({'name': 'Is valid json', 'result': is_valid_json}) - - # test Discovery Endpoint MUST contain a version field (currently 0.7) - contains_version_field = test_contains_version_field(response) - test_results.append({'name': 'Contains version field', 'result': contains_version_field}) - - # Discovery Endpoint MUST provide a field “api_base” - contains_api_base = test_contains_api_base(response) - test_results.append({'name': 'Contains field “api_base”', 'result': contains_api_base}) - - # test API base MUST be valid for subsequent calls - is_valid_api_base = test_is_valid_api_base(response) - test_results.append({'name': 'Is valid “api_base”', 'result': is_valid_api_base}) - - # test Discovery Endpoint MUST provide a list of supported actions - enumerates_supported_actions = test_enumerates_supported_actions(response) - test_results.append({'name': 'Enumerates supported “actions”', 'result': enumerates_supported_actions}) - - # test supported actions MUST be valid (on list) - supported_actions_valid = test_supported_actions_valid(response) - test_results.append({'name': 'Supported “actions” are valid', 'result': supported_actions_valid}) - - # test supported actions MUST be supported by /excerise endpoint - supported_actions_implemented = test_supported_actions_implemented(response) - test_results.append({'name': 'Supported “actions” are implemented', 'result': supported_actions_implemented}) - - # test Discovery Endpoint MAY contain a user_relationships hint set - contains_relationships = test_conatins_relationships(response) - test_results.append({'name': 'Contains user “user_relationships”', 'result': contains_relationships}) - - # test Discovery Endpoint SHOULD NOT contain additional undefined fields - contains_no_unknown_fields = test_discovery_contains_no_unknown_fields(response) - test_results.append({'name': 'Contains no unknown fields', 'result': contains_no_unknown_fields}) - - return test_results - - -def test_is_discovery_endpoint_valid_url(request_url, response): - return response.status_code == 200 - -def test_is_discovery_endpoint_compliant_url(request_url): - return '/.well-known/data-rights.json' in request_url - def test_is_valid_json(response): try: json.loads(response.text) except ValueError as e: return False - return (response.text[0:1] == '{' or response.text[0:1] == '[') - -def test_contains_version_field(response): - try: - response_json = json.loads(response.text) - except ValueError as e: - return False - return 'version' in response_json and response_json['version'] == '0.7' - -def test_contains_api_base(response): - try: - response_json = json.loads(response.text) - except ValueError as e: - return False - return 'api_base' in response_json and response_json['api_base'][0:8] == 'https://' - -def test_is_valid_api_base(response): - # todo: can only test for this by calling /excercise and /status ... - return 'Unknown' - -def test_enumerates_supported_actions(response): - try: - response_json = json.loads(response.text) - except ValueError as e: - return False - return 'actions' in response_json and type(response_json['actions']) == list and len(response_json['actions']) > 0 - -def test_supported_actions_valid(response): - known_action_values = ['sale:opt-out', 'sale:opt-in', 'access', 'deletion', 'access:categories ', 'access:specific '] - try: - response_json = json.loads(response.text) - except ValueError as e: - return False - if 'actions' in response_json and type(response_json['actions'] == list): - actions = response_json['actions'] - for action in actions: - if action not in known_action_values : - return False - return True - return False -def test_supported_actions_implemented(response): - # todo: can only test for this by calling /excercise for each supported action ... - return 'Unknown' - -def test_conatins_relationships(response): - try: - response_json = json.loads(response.text) - except ValueError as e: - return False - return 'user_relationships' in response_json and type(response_json['user_relationships']) == list and len(response_json['user_relationships']) > 0 - -def test_discovery_contains_no_unknown_fields(response): - known_fields = ['version', 'api_base', 'actions', 'user_relationships'] - try: - response_json = json.loads(response.text) - except ValueError as e: - return False - for field in response_json: - if field not in known_fields: - return False - return True + return (response.text[0:1] == '{' or response.text[0:1] == '[') #---------------------------------------------------------------------------------------------------------------------# -# test_excercise_endpoint +# test_exercise_endpoint -def test_excercise_endpoint(request_json, response): +def test_exercise_endpoint(request_json, response): test_results = [] """ - 2 POST /v1/data-right-request/ (“Data Rights Exercise” endpoint) + 2 POST /v1/data-rights-request (“Data Rights Exercise” endpoint) - a Data Rights Exercise request SHALL contain a JSON-encoded message body - the message body SHALL have a libsodium/NaCl/ED25119 binary signature immediately prepended to it @@ -186,9 +37,9 @@ def test_excercise_endpoint(request_json, response): The message body MUST containing the following fields: - “agent-id” - a string identifying the Authorized Agent which is submitting the data rights request - “business-id” - a string identifying the Covered Business which the request is being sent to + - “issued-at” - an ISO 8601-encoded timestamp expressing when the request was created - “expires-at” - an ISO 8601-encoded timestamp expressing when the request should no longer be considered viable - - “issued-at” - an ISO 8601-encoded timestamp expressing when the request was created. - - “drp.version” - a string referencing the current protocol version "0.7" + - “drp.version” - a string referencing the current protocol version "0.9.3" - “exercise” - string specifying the Rights Action: [ access | deletion | sale:opt_out | sale:opt_in | access:categories | access:specific ] - “regime” (optional) - a string specifying the legal regime under which the Data Request is being taken: [ ccpa | voluntary ] - “relationships” (optional) - a list of string 'hints' for the Covered Business @@ -204,7 +55,7 @@ def test_excercise_endpoint(request_json, response): - the Privacy Infrastructure Provider SHALL validate the message is signed - - all calls MUST return a Data Rights Status object for all actions listed in .well-known/data-rights.json + - all calls MUST return a Data Rights Status object for all suppoerted actions listed in the company's Service Directory entry - values of fields may vary, see below Returns a Data Rights Status object @@ -218,7 +69,7 @@ def test_excercise_endpoint(request_json, response): - SHOULD contain field “expected_by” - “expected_by” is an ISO 8601 string (ISO time format) - MAY contain field “reason” - - “reason” allowable values: "need_verification" | "suspected_fraud" | “insufficient_verification” | "no_match" | "claim_not_covered" | "too_many_requests" | "outside_jurisdiction" | "other" | “none” + - “reason” allowable values: "need_verification" | "suspected_fraud" | “insuf_verification” | "no_match" | "claim_not_covered" | "too_many_requests" | "outside_jurisdiction" | "other" | “none” - allowable reason values vary with status; see below - MAY contain field “processing_details” - TBD: any contstraint on this ... ? @@ -226,7 +77,8 @@ def test_excercise_endpoint(request_json, response): - "user_verification_url" must be a well formatted URI - "user_verification_url" must be a valid endmpoint which correctly returns data about the request - MAY contain a field "expires_at" - - "expires_at" should be an [ISO 8601]-encoded time + - "expires_at" is an ISO 8601 string (ISO time format) + - for requests whose status is "in_progress", the response MUST contain "expires_at" - Additional optional/unknown fields - throw a warning """ @@ -274,7 +126,7 @@ def test_excercise_endpoint(request_json, response): contains_reason_field = test_contains_reason_field(response) test_results.append({'name': 'Contains field “reason”', 'result': contains_reason_field}) - # test “reason” allowable values: "need_verification" | "suspected_fraud" | “insufficient_verification” | "no_match" | "claim_not_covered" | "too_many_requests" | "outside_jurisdiction" | "other" | “none” + # test “reason” allowable values: "need_verification" | "suspected_fraud" | “insuf_verification” | "no_match" | "claim_not_covered" | "too_many_requests" | "outside_jurisdiction" | "other" | “none” is_reason_valid = test_is_reason_valid(response) test_results.append({'name': 'Is “reason” valid', 'result': is_reason_valid}) @@ -305,50 +157,50 @@ def test_excercise_endpoint(request_json, response): test_results.append({'name': 'Is “expires_at” as ISO time format', 'result': is_expires_at_iso_time_format}) # test additional optional/unknown fields - no additional fields allowed - contains_no_unknown_fields = test_excercise_contains_no_unknown_fields(response) + contains_no_unknown_fields = test_exercise_contains_no_unknown_fields(response) test_results.append({'name': 'Contains no unknown fields', 'result': contains_no_unknown_fields}) """ - 2.1. POST /exercise, { action: “access”, regime: “ccpa” } + 2.1. POST /v1/data-rights-request, { action: “access”, regime: “ccpa” } - status: “open” | “in_progress” - if status == “open”, reason SHOULD be “none” - if status == “in_progress”, reason SHOULD be “need_verification” | “none” - 2.2. POST /exercise, { action: “access”, regime: “voluntary” } + 2.2. POST /v1/data-rights-request, { action: “access”, regime: “voluntary” } - status: “open” | “in_progress” | “denied” - if status == “open”, reason SHOULD be “none” - if status == “in_progress”, reason SHOULD be “need_verification” | “none” - if status == “denied”, reason SHOULD be “outside_jurisdiction” - 2.3. POST /exercise, { action: “deletion”, regime: “ccpa” } + 2.3. POST /v1/data-rights-request, { action: “deletion”, regime: “ccpa” } - status: “open” | “in_progress” - if status == “open”, reason SHOULD be “none” - if status == “in_progress”, reason SHOULD be “need_verification” | “none” - 2.4. POST /exercise, { action: “deletion”, regime: “voluntary” } + 2.4. POST /v1/data-rights-request, { action: “deletion”, regime: “voluntary” } - status: “open” | “in_progress” | “denied” - if status == “open”, reason SHOULD be “none” - if status == “in_progress”, reason SHOULD be “need_verification” | “none” - if status == “denied”, reason SHOULD be “outside_jurisdiction” - 2.5. POST /exercise, { action: “sale:opt_out”, regime: “ccpa” } + 2.5. POST /v1/data-rights-request, { action: “sale:opt_out”, regime: “ccpa” } - status: “open” | “in_progress” - if status == “open”, reason SHOULD be “none” - if status == “in_progress”, reason SHOULD be “need_verification” | “none” - 2.6. POST /exercise, { action: “sale:opt_out”, regime: “voluntary” } + 2.6. POST /v1/data-rights-request, { action: “sale:opt_out”, regime: “voluntary” } - status: “open” | “in_progress” | “denied” - if status == “open”, reason SHOULD be “none” - if status == “in_progress”, reason SHOULD be “need_verification” | “none” - if status == “denied”, reason SHOULD be “outside_jurisdiction” - 2.7. POST /exercise, { action: “sale:opt_in”, regime: “ccpa” } + 2.7. POST /v1/data-rights-request, { action: “sale:opt_in”, regime: “ccpa” } - status: “open” | “in_progress” - if status == “open”, reason SHOULD be “none” - if status == “in_progress”, reason SHOULD be “need_verification” | “none” - 2.8. POST /exercise, { action: “sale:opt_in”, regime: “voluntary” } + 2.8. POST /v1/data-rights-request, { action: “sale:opt_in”, regime: “voluntary” } - status: “open” | “in_progress” | “denied” - if status == “open”, reason SHOULD be “none” - if status == “in_progress”, reason SHOULD be “need_verification” | “none” @@ -403,53 +255,67 @@ def test_validate_message_is_signed(response): # todo: run same test using valid and invalid keys - bad one should retrun 403 - return False + return "Unknown" #False + def test_is_data_rights_status_obj(response): required_fields = ['request_id', 'received_at', 'status'] + try: response_json = json.loads(response.text) except ValueError as e: return False + # todo: for now we just check for requried fields; is there a better way, maybe by using types ... ? for field in required_fields: if field not in response_json: return False + return True + def test_contains_request_id(response): try: response_json = json.loads(response.text) except ValueError as e: return False + return 'request_id' in response_json and response_json['request_id'] != '' + def test_is_request_id_unique_string(response): try: response_json = json.loads(response.text) except ValueError as e: return False + # todo: test by comparing to other id's for this CR in the DB ... return "Unknown" + def test_contains_received_at(response): try: response_json = json.loads(response.text) except ValueError as e: return False + return 'received_at' in response_json and response_json['received_at'] != '' + def test_is_received_at_time_format(response): try: response_json = json.loads(response.text) except ValueError as e: return False + try: datetime.fromisoformat(response_json['received_at']) except: return False + return True + def test_contains_status_field(response): try: response_json = json.loads(response.text) @@ -465,67 +331,86 @@ def test_is_status_valid(response): return False return 'status' in response_json and response_json['status'] in known_status_values + def test_contains_reason_field(response): try: response_json = json.loads(response.text) except ValueError as e: return False + return 'reason' in response_json and response_json['reason'] != '' + def test_is_reason_valid(response): - known_reason_values = [ 'need_verification', 'suspected_fraud', 'insufficient_verification', 'no_match', 'claim_not_covered', 'too_many_requests', 'outside_jurisdiction', 'other', '“none' ] + known_reason_values = [ 'need_verification', 'suspected_fraud', 'insuf_verification', 'no_match', 'claim_not_covered', 'too_many_requests', 'outside_jurisdiction', 'other', 'none' ] + try: response_json = json.loads(response.text) except ValueError as e: return False + return 'reason' in response_json and response_json['reason'] in known_reason_values + def test_contains_expected_by(response): try: response_json = json.loads(response.text) except ValueError as e: return False + return 'expected_by' in response_json and response_json['expected_by'] != '' + def test_is_expected_by_iso_time_format(response): try: response_json = json.loads(response.text) except ValueError as e: return False + try: datetime.fromisoformat(response_json['expected_by']) except: return False + return True + def test_contains_processing_details(response): try: response_json = json.loads(response.text) except ValueError as e: return False + return 'processing_details' in response_json and response_json['processing_details'] != '' + def test_contains_verification_url_valid(response): try: response_json = json.loads(response.text) except ValueError as e: return False + return 'user_verification_url' in response_json and response_json['user_verification_url'] != '' + def test_has_user_verification_url_valid_format(response): try: response_json = json.loads(response.text) except ValueError as e: return False + if 'user_verification_url' not in response_json or response_json['user_verification_url'] == None: return 'N/A' + return validators.url(response_json['user_verification_url']) + def test_contains_expires_at(response): try: response_json = json.loads(response.text) except ValueError as e: return False + return 'expires_at' in response_json and response_json['expires_at'] != '' @@ -534,136 +419,192 @@ def test_is_expires_at_iso_time_format(response): response_json = json.loads(response.text) except ValueError as e: return False + try: datetime.fromisoformat(response_json['expires_at']) except: return False + return True -def test_excercise_contains_no_unknown_fields(response): + +def test_exercise_contains_no_unknown_fields(response): known_fields = ['request_id', 'received_at', 'expected_by', 'status', 'reason', 'processing_details', 'user_verification_url'] + try: response_json = json.loads(response.text) except ValueError as e: return False + for field in response_json: if field not in known_fields: return False + return True def test_is_reponse_valid_for_access_ccpa(response): valid_status_values = [ 'in_progress', 'open' ] + try: response_json = json.loads(response.text) except ValueError as e: return False + is_valid_status = 'status' in response_json and response_json['status'] in valid_status_values + if is_valid_status and response_json['status'] == 'open': return 'reason' in response_json and response_json['reason'] == None - if is_valid_status and response_json['status'] == '“in_progress”': - return 'reason' in response_json and (response_json['reason'] == 'need_verification' or response_json['reason'] == None) + + if is_valid_status and response_json['status'] == 'in_progress': + return 'reason' in response_json and (response_json['reason'] == 'need_verification' or response_json['reason'] == None) and test_contains_expires_at(response) + return False + def test_is_reponse_valid_for_access_voluntary(response): valid_status_values = [ 'in_progress', 'open', 'denied' ] + try: response_json = json.loads(response.text) except ValueError as e: return False + is_valid_status = 'status' in response_json and response_json['status'] in valid_status_values + if is_valid_status and response_json['status'] == 'open': return 'reason' in response_json and response_json['reason'] == None - if is_valid_status and response_json['status'] == '“in_progress”': - return 'reason' in response_json and (response_json['reason'] == 'need_verification' or response_json['reason'] == None) + + if is_valid_status and response_json['status'] == 'in_progress': + return 'reason' in response_json and (response_json['reason'] == 'need_verification' or response_json['reason'] == None) and test_contains_expires_at(response) + if is_valid_status and response_json['status'] == 'denied': return 'reason' in response_json and response_json['reason'] == 'outside_jurisdiction' + return False + def test_is_reponse_valid_for_deletion_ccpa(response): valid_status_values = [ 'in_progress', 'open' ] + try: response_json = json.loads(response.text) except ValueError as e: return False + is_valid_status = 'status' in response_json and response_json['status'] in valid_status_values + if is_valid_status and response_json['status'] == 'open': return 'reason' in response_json and response_json['reason'] == None - if is_valid_status and response_json['status'] == '“in_progress”': - return 'reason' in response_json and (response_json['reason'] == 'need_verification' or response_json['reason'] == None) + + if is_valid_status and response_json['status'] == 'in_progress': + return 'reason' in response_json and (response_json['reason'] == 'need_verification' or response_json['reason'] == None) and test_contains_expires_at(response) + return False + def test_is_reponse_valid_for_deletion_voluntary(response): valid_status_values = [ 'in_progress', 'open', 'denied' ] + try: response_json = json.loads(response.text) except ValueError as e: return False + is_valid_status = 'status' in response_json and response_json['status'] in valid_status_values + if is_valid_status and response_json['status'] == 'open': return 'reason' in response_json and response_json['reason'] == None - if is_valid_status and response_json['status'] == '“in_progress”': - return 'reason' in response_json and (response_json['reason'] == 'need_verification' or response_json['reason'] == None) + + if is_valid_status and response_json['status'] == 'in_progress': + return 'reason' in response_json and (response_json['reason'] == 'need_verification' or response_json['reason'] == None) and test_contains_expires_at(response) + if is_valid_status and response_json['status'] == 'denied': return 'reason' in response_json and response_json['reason'] == 'outside_jurisdiction' + return False + def test_is_reponse_valid_for_optout_ccpa(response): valid_status_values = [ 'in_progress', 'open' ] + try: response_json = json.loads(response.text) except ValueError as e: return False + is_valid_status = 'status' in response_json and response_json['status'] in valid_status_values + if is_valid_status and response_json['status'] == 'open': return 'reason' in response_json and response_json['reason'] == None - if is_valid_status and response_json['status'] == '“in_progress”': - return 'reason' in response_json and (response_json['reason'] == 'need_verification' or response_json['reason'] == None) + + if is_valid_status and response_json['status'] == 'in_progress': + return 'reason' in response_json and (response_json['reason'] == 'need_verification' or response_json['reason'] == None) and test_contains_expires_at(response) + return False + def test_is_reponse_valid_for_optout_voluntary(response): valid_status_values = [ 'in_progress', 'open', 'denied' ] + try: response_json = json.loads(response.text) except ValueError as e: return False + is_valid_status = 'status' in response_json and response_json['status'] in valid_status_values + if is_valid_status and response_json['status'] == 'open': return 'reason' in response_json and response_json['reason'] == None - if is_valid_status and response_json['status'] == '“in_progress”': - return 'reason' in response_json and (response_json['reason'] == 'need_verification' or response_json['reason'] == None) + + if is_valid_status and response_json['status'] == 'in_progress': + return 'reason' in response_json and (response_json['reason'] == 'need_verification' or response_json['reason'] == None) and test_contains_expires_at(response) + if is_valid_status and response_json['status'] == 'denied': return 'reason' in response_json and response_json['reason'] == 'outside_jurisdiction' + return False + def test_is_reponse_valid_for_optin_ccpa(response): valid_status_values = [ 'in_progress', 'open' ] + try: response_json = json.loads(response.text) except ValueError as e: return False + is_valid_status = 'status' in response_json and response_json['status'] in valid_status_values + if is_valid_status and response_json['status'] == 'open': return 'reason' in response_json and response_json['reason'] == None - if is_valid_status and response_json['status'] == '“in_progress”': - return 'reason' in response_json and (response_json['reason'] == 'need_verification' or response_json['reason'] == None) + + if is_valid_status and response_json['status'] == 'in_progress': + return 'reason' in response_json and (response_json['reason'] == 'need_verification' or response_json['reason'] == None) and test_contains_expires_at(response) + return False + def test_is_reponse_valid_for_optin_voluntary(response): valid_status_values = [ 'in_progress', 'open', 'denied' ] + try: response_json = json.loads(response.text) except ValueError as e: return False + is_valid_status = 'status' in response_json and response_json['status'] in valid_status_values + if is_valid_status and response_json['status'] == 'open': return 'reason' in response_json and response_json['reason'] == None - if is_valid_status and response_json['status'] == '“in_progress”': - return 'reason' in response_json and (response_json['reason'] == 'need_verification' or response_json['reason'] == None) + + if is_valid_status and response_json['status'] == 'in_progress': + return 'reason' in response_json and (response_json['reason'] == 'need_verification' or response_json['reason'] == None) and test_contains_expires_at(response) + if is_valid_status and response_json['status'] == 'denied': return 'reason' in response_json and response_json['reason'] == 'outside_jurisdiction' - return False + return False #---------------------------------------------------------------------------------------------------------------------# @@ -695,6 +636,7 @@ def test_status_endpoint(request_url, response): - “user_verification_url” returns the correct data when called - MAY contain field “expires_at” - “expires_at” is an ISO 8601 string (ISO time format) + - for requests whose status is "in_progress", the response MUST contain "expires_at" - Additional unknown fields - throw a warning """ @@ -706,10 +648,17 @@ def test_status_endpoint(request_url, response): contains_request_id = test_contains_request_id(response) test_results.append({'name': 'Contains field “request_id”', 'result': contains_request_id}) + + ###### + + # todo: is this test correct? # test “request_id” value should match the value passed in from request request_id_matches_request = test_request_id_matches_request(response, request_url) test_results.append({'name': '“request_id” matches request', 'result': request_id_matches_request}) + ###### + + # test Data Rights Status object MUST contain field “received_at” contains_received_at = test_contains_received_at(response) test_results.append({'name': 'Contains field “received_at”', 'result': contains_received_at}) @@ -753,7 +702,7 @@ def test_status_endpoint(request_url, response): contains_verification_url_valid = test_contains_verification_url_valid(response) test_results.append({'name': 'Contains field “user_verification_url”', 'result': contains_verification_url_valid}) - # test “user_verification_url”is a valid url + # test “user_verification_url” is a valid url is_user_verification_url_valid_format = test_has_user_verification_url_valid_format(response) test_results.append({'name': 'Has “user_verification_url” in valid format', 'result': is_user_verification_url_valid_format}) @@ -769,7 +718,7 @@ def test_status_endpoint(request_url, response): test_results.append({'name': 'Is “expires_at” as ISO time format', 'result': is_expires_at_iso_time_format}) # test Additional, optional or unknown fields - no additional fields allowed - contains_no_unknown_fields = test_excercise_contains_no_unknown_fields(response) + contains_no_unknown_fields = test_exercise_contains_no_unknown_fields(response) test_results.append({ 'name': 'Contains no unknown fields', 'result': contains_no_unknown_fields }) @@ -814,60 +763,87 @@ def test_status_endpoint(request_url, response): return test_results +###### + +# todo: is this test correct? + def test_request_id_matches_request(response, request_url): try: response_json = json.loads(response.text) except ValueError as e: + print('** test_request_id_matches_request(): response_json not valid') return False + if 'results_url' not in response_json: + print('** test_request_id_matches_request(): results_url not in response_json') return False - response_request_id = response_json['request_id'] + request_request_id = request_url.GET.get('request_id', '') + response_request_id = response_json['request_id'] + + print('** test_request_id_matches_request(): request_request_id = ' + request_request_id) + print('** test_request_id_matches_request(): response_request_id = ' + response_request_id) + return response_request_id == request_request_id +###### + + def test_is_reponse_valid_for_status_fulfilled(response): try: response_json = json.loads(response.text) except ValueError as e: return False + has_valid_fields = 'results_url' in response_json and 'expires_at' in response_json is_final = 'final' in response_json and response_json['final'] == 'true' + return has_valid_fields and is_final + def test_is_reponse_valid_for_status_denied(response): - valid_reason_values = [ 'suspected_fraud', 'insufficient_verification', 'no_match', 'claim_not_covered', 'too_many_requests', 'outside_jurisdiction', 'other', '“none' ] + valid_reason_values = [ 'suspected_fraud', 'insuf_verification', 'no_match', 'claim_not_covered', 'too_many_requests', 'outside_jurisdiction', 'other', 'none' ] + try: response_json = json.loads(response.text) except ValueError as e: return False - has_valid_fields = '“processing_details”' in response_json + + has_valid_fields = 'processing_details' in response_json has_valid_reason = 'reason' in response_json and response_json['reason'] in valid_reason_values is_final = 'final' in response_json and response_json['final'] == 'true' and response_json['reason'] != 'too_many_requests' + return has_valid_fields and has_valid_reason and is_final + def test_is_reponse_valid_for_status_expired(response, request): - valid_reason_values = [ 'suspected_fraud', 'insufficient_verification', 'no_match', 'claim_not_covered', 'too_many_requests', 'outside_jurisdiction', 'other', '“none' ] + valid_reason_values = [ 'suspected_fraud', 'insuf_verification', 'no_match', 'claim_not_covered', 'too_many_requests', 'outside_jurisdiction', 'other', 'none' ] + try: response_json = json.loads(response.text) except ValueError as e: return False + # The time is currently after “expires_at” in the request # todo: can't write a meaningful test of this; the request does not contain a param 'expires_at' is_time_after_expires_at = False is_final = 'final' in response_json and response_json['final'] == 'true' + return is_time_after_expires_at and is_final + def test_is_reponse_valid_for_status_revoked(response): try: response_json = json.loads(response.text) except ValueError as e: return False + is_final = 'final' in response_json and response_json['final'] == 'true' return is_final #-------------------------------------------------------------------------------------------------# -# test_revoked_endpoint +# test_revoke_endpoint def test_revoked_endpoint(request_url, response): test_results = [] @@ -931,20 +907,24 @@ def test_contains_agent_id_field(response): response_json = json.loads(response.text) except ValueError as e: return False - return 'agent_id' in response_json and response_json['agent_id'] != '' + + return 'agent-id' in response_json and response_json['agent-id'] != '' def test_agent_id_matches_request(response, request_url): # todo: write the test ... - return True + return "Unknown" #True + def test_contains_token_field(response): try: response_json = json.loads(response.text) except ValueError as e: return False + return 'token' in response_json and response_json['token'] != '' + def test_returns_403_on_failed_validation(response): # todo: write the test ... return True @@ -975,6 +955,5 @@ def test_agent_information_endpoint(request_url, response): def test_returns_200_and_empty_json(response): # todo: write the test ... - return True - - + return "Unknown" #True + \ No newline at end of file diff --git a/drp_aa_mvp/requirements.txt b/drp_aa_mvp/requirements.txt index 894b927..32b4351 100644 --- a/drp_aa_mvp/requirements.txt +++ b/drp_aa_mvp/requirements.txt @@ -8,7 +8,6 @@ cachetools==4.2.4 certifi==2021.5.30 cffi==1.15.0 charset-normalizer==2.0.5 -da-vinci==0.3.0 decorator==5.1.1 distlib==0.3.4 dj-database-url==0.5.0 @@ -31,36 +30,20 @@ djangorestframework-simplejwt==4.8.0 drf-api-tracking==1.8.0 factory-boy==3.2.0 Faker==8.13.2 -fcm-django==1.0.5 filelock==3.7.1 -firebase-admin==5.1.0 flake8==3.9.2 -google-api-core==2.2.2 -google-api-python-client==2.31.0 -google-auth==2.3.3 -google-auth-httplib2==0.1.0 -google-cloud-core==2.2.1 -google-cloud-firestore==2.3.4 -google-cloud-storage==1.43.0 -google-crc32c==1.3.0 -google-resumable-media==2.1.0 -googleapis-common-protos==1.53.0 grpcio==1.42.0 grpcio-status==1.42.0 gunicorn==20.1.0 httplib2==0.20.2 idna==3.2 jmespath==0.10.0 -mailchimp-transactional==1.0.46 mccabe==0.6.1 msgpack==1.0.3 packaging==21.3 phonenumbers==8.12.35 Pillow==9.0.1 platformdirs==2.5.2 -postmarker==0.18.2 -proto-plus==1.19.8 -protobuf==3.19.1 psycopg2==2.9.1 psycopg2-binary==2.9.1 pyasn1==0.4.8 @@ -69,28 +52,21 @@ pycodestyle==2.7.0 pycparser==2.21 pyflakes==2.3.1 Pygments==2.10.0 -PyJWT==2.1.0 PyNaCl==1.5.0 pyparsing==3.0.6 python-dateutil==2.8.2 pytz==2021.1 -raven==6.10.0 reportlab==3.6.8 requests==2.26.0 rsa==4.8 -s3transfer==0.5.0 shortuuid==1.0.1 six==1.16.0 sqlparse==0.4.2 text-unidecode==1.3 toml==0.10.2 -twilio==7.2.0 uritemplate==4.1.1 urllib3==1.26.6 validators==0.20.0 virtualenv==20.14.1 Werkzeug==2.0.1 whitenoise==5.3.0 -woopra==0.3.2 -woopra-tracker==0.3 - diff --git a/drp_aa_mvp/views.py b/drp_aa_mvp/views.py index d29ec99..7a2e6e7 100644 --- a/drp_aa_mvp/views.py +++ b/drp_aa_mvp/views.py @@ -4,6 +4,5 @@ def index(request): context = {} - return render(request, 'drp_aa_mvp/index.html', context)