diff --git a/CHANGELOG.md b/CHANGELOG.md index 87cfc3881..53896f991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ The types of changes are: * Frontend - ability for users to manually enter PII to an IN PROGRESS subject request [#1016](https://github.com/ethyca/fidesops/pull/1377) * Enable retries on saas connectors for failures at the http request level [#1376](https://github.com/ethyca/fidesops/pull/1376) * Add consent request api [#1387](https://github.com/ethyca/fidesops/pull/1387) +* Add authenticated route to get consent preferences [#1402](https://github.com/ethyca/fidesops/pull/1402) ### Removed diff --git a/clients/ops/admin-ui/src/constants.ts b/clients/ops/admin-ui/src/constants.ts index 63cda62ba..1c2104598 100644 --- a/clients/ops/admin-ui/src/constants.ts +++ b/clients/ops/admin-ui/src/constants.ts @@ -41,6 +41,10 @@ export const USER_PRIVILEGES: UserPrivileges[] = [ privilege: "Delete datastore connections", scope: "connection:delete", }, + { + privilege: "View user consent preferences", + scope: "consent:read", + }, { privilege: "View Datasets", scope: "dataset:read", diff --git a/docs/fidesops/docs/postman/Fidesops.postman_collection.json b/docs/fidesops/docs/postman/Fidesops.postman_collection.json index adb6e11c1..3ec2a4ff0 100644 --- a/docs/fidesops/docs/postman/Fidesops.postman_collection.json +++ b/docs/fidesops/docs/postman/Fidesops.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "8f48b8e3-0a39-4e5e-b505-8087064dc1af", + "_postman_id": "002813f4-12b7-4467-9377-a57706b6dbc8", "name": "Fidesops", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -72,7 +72,7 @@ "header": [], "body": { "mode": "raw", - "raw": "[\n \"client:create\",\n \"client:update\",\n \"client:read\",\n \"client:delete\",\n \"config:read\",\n \"connection_type:read\",\n \"connection:read\",\n \"connection:create_or_update\",\n \"connection:delete\",\n \"connection:instantiate\",\n \"dataset:create_or_update\",\n \"dataset:delete\",\n \"dataset:read\",\n \"encryption:exec\",\n \"email:create_or_update\",\n \"email:read\",\n \"email:delete\",\n \"policy:create_or_update\",\n \"policy:read\",\n \"policy:delete\",\n \"privacy-request:read\",\n \"privacy-request:delete\",\n \"rule:create_or_update\",\n \"rule:read\",\n \"rule:delete\",\n \"scope:read\",\n \"storage:create_or_update\",\n \"storage:delete\",\n \"storage:read\",\n \"privacy-request:resume\",\n \"webhook:create_or_update\",\n \"webhook:read\",\n \"webhook:delete\",\n \"saas_config:create_or_update\",\n \"saas_config:read\",\n \"saas_config:delete\",\n \"privacy-request:review\",\n \"user:create\",\n \"user:delete\"\n]", + "raw": "[\n \"client:create\",\n \"client:update\",\n \"client:read\",\n \"client:delete\",\n \"config:read\",\n \"connection_type:read\",\n \"connection:read\",\n \"connection:create_or_update\",\n \"connection:delete\",\n \"connection:instantiate\",\n \"consent:read\",\n \"dataset:create_or_update\",\n \"dataset:delete\",\n \"dataset:read\",\n \"encryption:exec\",\n \"email:create_or_update\",\n \"email:read\",\n \"email:delete\",\n \"policy:create_or_update\",\n \"policy:read\",\n \"policy:delete\",\n \"privacy-request:read\",\n \"privacy-request:delete\",\n \"rule:create_or_update\",\n \"rule:read\",\n \"rule:delete\",\n \"scope:read\",\n \"storage:create_or_update\",\n \"storage:delete\",\n \"storage:read\",\n \"privacy-request:resume\",\n \"webhook:create_or_update\",\n \"webhook:read\",\n \"webhook:delete\",\n \"saas_config:create_or_update\",\n \"saas_config:read\",\n \"saas_config:delete\",\n \"privacy-request:review\",\n \"user:create\",\n \"user:delete\"\n]", "options": { "raw": { "language": "json" @@ -4815,6 +4815,38 @@ }, "response": [] }, + { + "name": "Authenticated Get Consent Preferences", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{client_token}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"phone_number\": \"{{phone_number}}\",\n \"email\": \"{{email}}\"\n}" + }, + "url": { + "raw": "{{host}}/consent-request/preferences", + "host": [ + "{{host}}" + ], + "path": [ + "consent-request", + "preferences" + ] + } + }, + "response": [] + }, { "name": "Verify Code and Save Preferences", "request": { @@ -5160,4 +5192,4 @@ "type": "string" } ] -} +} \ No newline at end of file diff --git a/src/fidesops/ops/api/v1/endpoints/consent_request_endpoints.py b/src/fidesops/ops/api/v1/endpoints/consent_request_endpoints.py index 0fd8461b9..a280d238f 100644 --- a/src/fidesops/ops/api/v1/endpoints/consent_request_endpoints.py +++ b/src/fidesops/ops/api/v1/endpoints/consent_request_endpoints.py @@ -2,7 +2,7 @@ import logging -from fastapi import Depends, HTTPException +from fastapi import Depends, HTTPException, Security from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from starlette.status import ( @@ -14,9 +14,11 @@ ) from fidesops.ops.api.deps import get_db +from fidesops.ops.api.v1.scope_registry import CONSENT_READ from fidesops.ops.api.v1.urn_registry import ( CONSENT_REQUEST, CONSENT_REQUEST_PREFERENCES, + CONSENT_REQUEST_PREFERENCES_WITH_ID, CONSENT_REQUEST_VERIFY, V1_URL_PREFIX, ) @@ -43,6 +45,7 @@ from fidesops.ops.service._verification import send_verification_code_to_user from fidesops.ops.util.api_router import APIRouter from fidesops.ops.util.logger import Pii +from fidesops.ops.util.oauth_util import verify_oauth_client router = APIRouter(tags=["Consent"], prefix=V1_URL_PREFIX) @@ -133,8 +136,41 @@ def consent_request_verify( return _prepare_consent_preferences(db, provided_identity) -@router.patch( +@router.post( CONSENT_REQUEST_PREFERENCES, + dependencies=[Security(verify_oauth_client, scopes=[CONSENT_READ])], + status_code=HTTP_200_OK, + response_model=ConsentPreferences, +) +def get_consent_preferences( + *, db: Session = Depends(get_db), data: Identity +) -> ConsentPreferences: + """Gets the consent preferences for the specified user.""" + if data.email: + lookup = data.email + elif data.phone_number: + lookup = data.phone_number + else: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, detail="No identity information provided" + ) + + identity = ProvidedIdentity.filter( + db, + conditions=( + (ProvidedIdentity.hashed_value == ProvidedIdentity.hash_value(lookup)) + & (ProvidedIdentity.privacy_request_id.is_(None)) + ), + ).first() + + if not identity: + raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="Identity not found") + + return _prepare_consent_preferences(db, identity) + + +@router.patch( + CONSENT_REQUEST_PREFERENCES_WITH_ID, status_code=HTTP_200_OK, response_model=ConsentPreferences, ) diff --git a/src/fidesops/ops/api/v1/scope_registry.py b/src/fidesops/ops/api/v1/scope_registry.py index 15e5ef99a..9b9e8d24e 100644 --- a/src/fidesops/ops/api/v1/scope_registry.py +++ b/src/fidesops/ops/api/v1/scope_registry.py @@ -17,6 +17,8 @@ CONNECTION_AUTHORIZE = "connection:authorize" SAAS_CONNECTION_INSTANTIATE = "connection:instantiate" +CONSENT_READ = "consent:read" + PRIVACY_REQUEST_READ = "privacy-request:read" PRIVACY_REQUEST_DELETE = "privacy-request:delete" PRIVACY_REQUEST_CALLBACK_RESUME = ( @@ -75,6 +77,7 @@ CONNECTION_DELETE, CONNECTION_AUTHORIZE, SAAS_CONNECTION_INSTANTIATE, + CONSENT_READ, CONNECTION_TYPE_READ, DATASET_CREATE_OR_UPDATE, DATASET_DELETE, diff --git a/src/fidesops/ops/api/v1/urn_registry.py b/src/fidesops/ops/api/v1/urn_registry.py index eab71907a..ee67d0a3e 100644 --- a/src/fidesops/ops/api/v1/urn_registry.py +++ b/src/fidesops/ops/api/v1/urn_registry.py @@ -7,7 +7,10 @@ # Consent request URLs CONSENT_REQUEST = "/consent-request" -CONSENT_REQUEST_PREFERENCES = "/consent-request/{consent_request_id}/preferences" +CONSENT_REQUEST_PREFERENCES = "/consent-request/preferences" +CONSENT_REQUEST_PREFERENCES_WITH_ID = ( + "/consent-request/{consent_request_id}/preferences" +) CONSENT_REQUEST_VERIFY = "/consent-request/{consent_request_id}/verify" diff --git a/tests/ops/api/v1/endpoints/test_consent_request_endpoints.py b/tests/ops/api/v1/endpoints/test_consent_request_endpoints.py index 5ca3f9605..e062e2962 100644 --- a/tests/ops/api/v1/endpoints/test_consent_request_endpoints.py +++ b/tests/ops/api/v1/endpoints/test_consent_request_endpoints.py @@ -6,9 +6,11 @@ import pytest +from fidesops.ops.api.v1.scope_registry import CONNECTION_READ, CONSENT_READ from fidesops.ops.api.v1.urn_registry import ( CONSENT_REQUEST, CONSENT_REQUEST_PREFERENCES, + CONSENT_REQUEST_PREFERENCES_WITH_ID, CONSENT_REQUEST_VERIFY, V1_URL_PREFIX, ) @@ -232,7 +234,7 @@ def test_set_consent_preferences_no_consent_request_id(self, api_client): } response = api_client.patch( - f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES.format(consent_request_id='abcd')}", + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES_WITH_ID.format(consent_request_id='abcd')}", json=data, ) assert response.status_code == 404 @@ -250,7 +252,7 @@ def test_set_consent_preferences_no_consent_code( } response = api_client.patch( - f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES.format(consent_request_id=consent_request.id)}", + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES_WITH_ID.format(consent_request_id=consent_request.id)}", json=data, ) assert response.status_code == 400 @@ -268,7 +270,7 @@ def test_set_consent_preferences_invalid_code( "consent": [{"data_use": "email", "opt_in": True}], } response = api_client.patch( - f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES.format(consent_request_id=consent_request.id)}", + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES_WITH_ID.format(consent_request_id=consent_request.id)}", json=data, ) assert response.status_code == 403 @@ -296,7 +298,7 @@ def test_set_consent_preferences_no_email_provided(self, db, api_client): "consent": [{"data_use": "email", "opt_in": True}], } response = api_client.patch( - f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES.format(consent_request_id=consent_request.id)}", + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES_WITH_ID.format(consent_request_id=consent_request.id)}", json=data, ) @@ -316,7 +318,7 @@ def test_set_consent_preferences_no_consent_present( "consent": None, } response = api_client.patch( - f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES.format(consent_request_id=consent_request.id)}", + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES_WITH_ID.format(consent_request_id=consent_request.id)}", json=data, ) assert response.status_code == 422 @@ -353,8 +355,82 @@ def test_set_consent_consent_preferences( "consent": consent_data, } response = api_client.patch( - f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES.format(consent_request_id=consent_request.id)}", + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES_WITH_ID.format(consent_request_id=consent_request.id)}", json=data, ) assert response.status_code == 200 assert response.json()["consent"] == consent_data + + +class TestGetConsentPreferences: + def test_get_consent_peferences_wrong_scope(self, generate_auth_header, api_client): + auth_header = generate_auth_header(scopes=[CONNECTION_READ]) + response = api_client.post( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES}", + headers=auth_header, + json={"email": "test@user.com"}, + ) + + assert response.status_code == 403 + + def test_get_consent_preferences_no_identity_data( + self, generate_auth_header, api_client + ): + auth_header = generate_auth_header(scopes=[CONSENT_READ]) + response = api_client.post( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES}", + headers=auth_header, + json={"email": None}, + ) + + assert response.status_code == 400 + assert "No identity information" in response.json()["detail"] + + def test_get_consent_preferences_identity_not_found( + self, generate_auth_header, api_client + ): + auth_header = generate_auth_header(scopes=[CONSENT_READ]) + response = api_client.post( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES}", + headers=auth_header, + json={"email": "test@email.com"}, + ) + + assert response.status_code == 404 + assert "Identity not found" in response.json()["detail"] + + def test_get_consent_preferences( + self, + provided_identity_and_consent_request, + db, + generate_auth_header, + api_client, + ): + provided_identity, _ = provided_identity_and_consent_request + + consent_data: list[dict[str, Any]] = [ + { + "data_use": "email", + "data_use_description": None, + "opt_in": True, + }, + { + "data_use": "location", + "data_use_description": "Location data", + "opt_in": False, + }, + ] + + for data in deepcopy(consent_data): + data["provided_identity_id"] = provided_identity.id + Consent.create(db, data=data) + + auth_header = generate_auth_header(scopes=[CONSENT_READ]) + response = api_client.post( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES}", + headers=auth_header, + json={"email": provided_identity.encrypted_value["value"]}, + ) + + assert response.status_code == 200 + assert response.json()["consent"] == consent_data