Skip to content
This repository has been archived by the owner on Nov 30, 2022. It is now read-only.

416 Adds DRP exercise endpoint #496

Merged
merged 9 commits into from
May 12, 2022
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ pylint: compose-build
mypy: compose-build
@echo "Running mypy checks..."
@docker-compose run $(IMAGE_NAME) \
mypy --ignore-missing-imports src/
mypy src/

pytest: compose-build
@echo "Running pytest unit tests..."
Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ PyMySQL==1.0.2
sqlalchemy-redshift==0.8.8
snowflake-sqlalchemy==1.3.2
sqlalchemy-bigquery==1.3.0
multidimensional_urlencode==0.0.4
multidimensional_urlencode==0.0.4
pyjwt
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is also a dep of fidesctl, so I've kept version out here to let package manager handle it. Maybe there's a better way to avoid package conflicts?

2 changes: 2 additions & 0 deletions src/fidesops/api/v1/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
config_endpoints,
connection_endpoints,
dataset_endpoints,
drp_endpoints,
encryption_endpoints,
health_endpoints,
masking_endpoints,
Expand All @@ -22,6 +23,7 @@
api_router.include_router(config_endpoints.router)
api_router.include_router(connection_endpoints.router)
api_router.include_router(dataset_endpoints.router)
api_router.include_router(drp_endpoints.router)
api_router.include_router(encryption_endpoints.router)
api_router.include_router(health_endpoints.router)
api_router.include_router(masking_endpoints.router)
Expand Down
154 changes: 154 additions & 0 deletions src/fidesops/api/v1/endpoints/drp_endpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import json
import logging
from typing import Dict, Any, Optional

import jwt
from fastapi import HTTPException, Depends, APIRouter, Security
from sqlalchemy.orm import Session
from starlette.status import (
HTTP_404_NOT_FOUND,
HTTP_422_UNPROCESSABLE_ENTITY,
HTTP_424_FAILED_DEPENDENCY,
HTTP_200_OK,
HTTP_400_BAD_REQUEST,
)

from fidesops import common_exceptions
from fidesops.api import deps
from fidesops.api.v1 import scope_registry as scopes
from fidesops.api.v1 import urn_registry as urls
from fidesops.core.config import config
from fidesops.models.policy import Policy
from fidesops.models.privacy_request import PrivacyRequest
from fidesops.schemas.drp_privacy_request import DrpPrivacyRequestCreate, DrpIdentity
from fidesops.schemas.privacy_request import PrivacyRequestDRPStatusResponse
from fidesops.schemas.redis_cache import PrivacyRequestIdentity
from fidesops.service.drp.drp_fidesops_mapper import DrpFidesopsMapper
from fidesops.service.privacy_request.request_runner_service import PrivacyRequestRunner
from fidesops.service.privacy_request.request_service import (
build_required_privacy_request_kwargs,
cache_data,
)
from fidesops.util.cache import FidesopsRedis
from fidesops.util.oauth_util import verify_oauth_client

logger = logging.getLogger(__name__)
router = APIRouter(tags=["DRP"], prefix=urls.V1_URL_PREFIX)
EMBEDDED_EXECUTION_LOG_LIMIT = 50


@router.post(
urls.DRP_EXERCISE,
status_code=HTTP_200_OK,
response_model=PrivacyRequestDRPStatusResponse,
)
def create_drp_privacy_request(
*,
cache: FidesopsRedis = Depends(deps.get_cache),
db: Session = Depends(deps.get_db),
data: DrpPrivacyRequestCreate,
) -> PrivacyRequestDRPStatusResponse:
"""
Given a drp privacy request body, create and execute
a corresponding Fidesops PrivacyRequest
"""

jwt_key: str = config.security.DRP_JWT_SECRET
if jwt_key is None:
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
detail="JWT key must be provided",
eastandwestwind marked this conversation as resolved.
Show resolved Hide resolved
)

logger.info(f"Finding policy with drp action '{data.exercise[0]}'")
policy: Optional[Policy] = Policy.get_by(
db=db,
field="drp_action",
value=data.exercise[0],
)

if not policy:
raise HTTPException(
status_code=HTTP_404_NOT_FOUND,
detail=f"No policy found with drp action '{data.exercise}'.",
)

privacy_request_kwargs: Dict[str, Any] = build_required_privacy_request_kwargs(
None, policy.id
)

try:
privacy_request: PrivacyRequest = PrivacyRequest.create(
db=db, data=privacy_request_kwargs
)

logger.info(f"Decrypting identity for DRP privacy request {privacy_request.id}")

decrypted_identity: DrpIdentity = DrpIdentity(
**jwt.decode(data.identity, jwt_key, algorithms=["HS256"])
)

mapped_identity: PrivacyRequestIdentity = DrpFidesopsMapper.map_identity(
drp_identity=decrypted_identity
)

cache_data(privacy_request, policy, mapped_identity, None, data)

PrivacyRequestRunner(
cache=cache,
privacy_request=privacy_request,
).submit()

return PrivacyRequestDRPStatusResponse(
request_id=privacy_request.id,
received_at=privacy_request.requested_at,
status=DrpFidesopsMapper.map_status(privacy_request.status),
)

except common_exceptions.RedisConnectionError as exc:
logger.error("RedisConnectionError: %s", exc)
# Thrown when cache.ping() fails on cache connection retrieval
raise HTTPException(
status_code=HTTP_424_FAILED_DEPENDENCY,
detail=exc.args[0],
)
except Exception as exc:
Copy link
Contributor

@seanpreston seanpreston May 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These except blocks should surround only the minimal code, and we should avoid using broad excepts — unless there's a specific reason?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was to be consistent with what we have for POST "/privacy-request". But I can remove it, just lemme know your preference

logger.error(f"Exception: {exc}")
raise HTTPException(
status_code=HTTP_422_UNPROCESSABLE_ENTITY,
detail="DRP privacy request could not be exercised",
)


@router.get(
urls.DRP_STATUS,
dependencies=[Security(verify_oauth_client, scopes=[scopes.PRIVACY_REQUEST_READ])],
response_model=PrivacyRequestDRPStatusResponse,
)
def get_request_status_drp(
*, db: Session = Depends(deps.get_db), request_id: str
) -> PrivacyRequestDRPStatusResponse:
"""
Returns PrivacyRequest information where the respective privacy request is associated with
a policy that implements a Data Rights Protocol action.
"""

logger.info(f"Finding request for DRP with ID: {request_id}")
request = PrivacyRequest.get(
db=db,
id=request_id,
)
if not request or not request.policy or not request.policy.drp_action:
# If no request is found with this ID, or that request has no policy,
# or that request's policy has no associated drp_action.
raise HTTPException(
status_code=HTTP_404_NOT_FOUND,
detail=f"Privacy request with ID {request_id} does not exist, or is not associated with a data rights protocol action.",
)

logger.info(f"Privacy request with ID: {request_id} found for DRP status.")
return PrivacyRequestDRPStatusResponse(
request_id=request.id,
received_at=request.requested_at,
status=DrpFidesopsMapper.map_status(request.status),
)
eastandwestwind marked this conversation as resolved.
Show resolved Hide resolved
60 changes: 1 addition & 59 deletions src/fidesops/api/v1/endpoints/privacy_request_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import logging
from collections import defaultdict
from datetime import datetime
from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Union
from typing import Any, Callable, DefaultDict, Dict, List, Optional, Union

from fastapi import APIRouter, Body, Depends, HTTPException, Security
from fastapi_pagination import Page, Params
Expand Down Expand Up @@ -56,8 +56,6 @@
PrivacyRequestVerboseResponse,
ReviewPrivacyRequestIds,
DenyPrivacyRequests,
PrivacyRequestDRPStatusResponse,
PrivacyRequestDRPStatus,
)
from fidesops.service.privacy_request.request_runner_service import PrivacyRequestRunner
from fidesops.service.privacy_request.request_service import (
Expand Down Expand Up @@ -428,62 +426,6 @@ def get_request_status(
return paginated


def _map_fidesops_status_to_drp_status(
status: PrivacyRequestStatus,
) -> PrivacyRequestDRPStatus:
PRIVACY_REQUEST_STATUS_TO_DRP_MAPPING: Dict[
PrivacyRequestStatus, PrivacyRequestDRPStatus
] = {
PrivacyRequestStatus.pending: PrivacyRequestDRPStatus.open,
PrivacyRequestStatus.approved: PrivacyRequestDRPStatus.in_progress,
PrivacyRequestStatus.denied: PrivacyRequestDRPStatus.denied,
PrivacyRequestStatus.in_processing: PrivacyRequestDRPStatus.in_progress,
PrivacyRequestStatus.complete: PrivacyRequestDRPStatus.fulfilled,
PrivacyRequestStatus.paused: PrivacyRequestDRPStatus.in_progress,
PrivacyRequestStatus.error: PrivacyRequestDRPStatus.expired,
}
try:
return PRIVACY_REQUEST_STATUS_TO_DRP_MAPPING[status]
except KeyError:
raise ValueError(f"Request has invalid DRP request status: {status.value}")


@router.get(
urls.REQUEST_STATUS_DRP,
dependencies=[Security(verify_oauth_client, scopes=[scopes.PRIVACY_REQUEST_READ])],
response_model=PrivacyRequestDRPStatusResponse,
)
def get_request_status_drp(
privacy_request_id: str,
*,
db: Session = Depends(deps.get_db),
) -> PrivacyRequestDRPStatusResponse:
"""
Returns PrivacyRequest information where the respective privacy request is associated with
a policy that implements a Data Rights Protocol action.
"""

logger.info(f"Finding request for DRP with ID: {privacy_request_id}")
request = PrivacyRequest.get(
db=db,
id=privacy_request_id,
)
if not request or not request.policy or not request.policy.drp_action:
# If no request is found with this ID, or that request has no policy,
# or that request's policy has no associated drp_action.
raise HTTPException(
status_code=HTTP_404_NOT_FOUND,
detail=f"Privacy request with ID {privacy_request_id} does not exist, or is not associated with a data rights protocol action.",
)

logger.info(f"Privacy request with ID: {privacy_request_id} found for DRP status.")
return PrivacyRequestDRPStatusResponse(
request_id=request.id,
received_at=request.requested_at,
status=_map_fidesops_status_to_drp_status(request.status),
)


@router.get(
urls.REQUEST_STATUS_LOGS,
dependencies=[Security(verify_oauth_client, scopes=[scopes.PRIVACY_REQUEST_READ])],
Expand Down
6 changes: 5 additions & 1 deletion src/fidesops/api/v1/urn_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@
REQUEST_STATUS_LOGS = "/privacy-request/{privacy_request_id}/log"
PRIVACY_REQUEST_RESUME = "/privacy-request/{privacy_request_id}/resume"
REQUEST_PREVIEW = "/privacy-request/preview"
REQUEST_STATUS_DRP = "/privacy-request/{privacy_request_id}/drp"

# Rule URLs
RULE_LIST = "/policy/{policy_key}/rule"
Expand Down Expand Up @@ -88,3 +87,8 @@

# Health URL
HEALTH = "/health"

# DRP
DRP = "/drp"
DRP_EXERCISE = DRP + "/exercise"
DRP_STATUS = DRP + "/status"
eastandwestwind marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions src/fidesops/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ class SecuritySettings(FidesSettings):
AES_ENCRYPTION_KEY_LENGTH: int = 16
AES_GCM_NONCE_LENGTH: int = 12
APP_ENCRYPTION_KEY: str
DRP_JWT_SECRET: Optional[str]
eastandwestwind marked this conversation as resolved.
Show resolved Hide resolved

@validator("APP_ENCRYPTION_KEY")
def validate_encryption_key_length(
Expand Down
15 changes: 11 additions & 4 deletions src/fidesops/models/privacy_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,17 @@ def cache_drp_request_body(self, drp_request_body: DrpPrivacyRequestCreate) -> N
drp_request_body_dict: Dict[str, Any] = dict(drp_request_body)
for key, value in drp_request_body_dict.items():
if value is not None:
cache.set_with_autoexpire(
get_drp_request_body_cache_key(self.id, key),
value,
)
# handle nested dict/objects
if not isinstance(value, (bytes, str, int, float)):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cache doesn't handle vals as bytes/str/int/float. Perhaps we should instead cache the whole req body as a str (json.loads()) instead of separating the top level props as cache keys. But this works as far as caching for debugging purposes only.

cache.set_with_autoexpire(
get_drp_request_body_cache_key(self.id, key),
repr(value),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this probably feels hacky but good choice in effort/reward trade-off if this is only for debug purposes as you say.

Looks like value can only ever be a key from this schema, which should be just fine.

If we want to do more with this data though let's make sure we're breaking it down and storing it by key. We could write a util method to handle that automatically for Pydantic schemas for instance.

)
else:
cache.set_with_autoexpire(
get_drp_request_body_cache_key(self.id, key),
value,
)

def cache_encryption(self, encryption_key: Optional[str] = None) -> None:
"""Sets the encryption key in the Fidesops app cache if provided"""
Expand Down
26 changes: 25 additions & 1 deletion src/fidesops/schemas/drp_privacy_request.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from enum import Enum
from typing import Optional, List

from pydantic import validator

from fidesops.models.policy import DrpAction
from fidesops.schemas.base_class import BaseSchema

Expand All @@ -22,7 +24,7 @@ class DrpPrivacyRequestCreate(BaseSchema):

meta: DrpMeta
regime: Optional[DrpRegime]
exercise: DrpAction
exercise: List[DrpAction]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DRP spec dictates that this is a list. So we'll take in a list even though we don't support > 1 item

relationships: Optional[List[str]]
identity: str
status_callback: Optional[str]
Expand All @@ -31,3 +33,25 @@ class Config:
"""Populate models with the raw value of enum fields, rather than the enum itself"""

use_enum_values = True

@validator("exercise")
def check_exercise_length(cls, exercise: [List[DrpAction]]) -> List[DrpAction]:
"""Validate the only one exercise action is provided"""
if len(exercise) > 1:
raise ValueError("Multiple exercise actions are not supported at this time")
return exercise


class DrpIdentity(BaseSchema):
"""Drp identity props"""

aud: Optional[str]
sub: Optional[str]
name: Optional[str]
email: Optional[str]
email_verified: Optional[bool]
phone_number: Optional[str]
phone_number_verified: Optional[bool]
address: Optional[str]
address_verified: Optional[bool]
owner_of_attorney: Optional[str]
Loading