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

Commit

Permalink
adds drp action type to db, adds docs, tests
Browse files Browse the repository at this point in the history
  • Loading branch information
eastandwestwind committed May 4, 2022
1 parent 337065a commit 3a97760
Show file tree
Hide file tree
Showing 10 changed files with 415 additions and 16 deletions.
11 changes: 10 additions & 1 deletion docs/fidesops/docs/guides/policies.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,21 @@ PATCH /api/v1/policy
[
{
"name": "User Email Address",
"key": "user_email_address_polcy"
"key": "user_email_address_polcy",
"drp_action": "access" // optional
}
]
```
This policy is subtly different from the concept of a Policy in [Fidesctl](https://github.com/ethyca/fides). A [Fidesctl policy](https://ethyca.github.io/fides/language/resources/policy/) dictates which data categories can be stored where. A Fidesops policy, on the other hand, dictates how to access, mask or erase data that matches specific data categories for privacy requests.

### Policy Attributes
- `Policy.name`: User-friendly name for your Policy.
- `Policy.key`: Unique key by which to reference the Policy.
- `Policy.drp_action` (optional): Which DRP action is this Policy handling? DRP action is only needed if you intend on using Fidesops as provider for the Data Rights Protocol (DRP). Read more about DRP [here](https://github.com/consumer-reports-digital-lab/data-rights-protocol).
- `access`: A data subject access request. Must be used with an `access` Rule.
- `deletion`: A data subject erasure request. Must be used with an `erasure` Rule.


## Add an Access Rule to your Policy
The policy creation operation returns a Policy key, which we'll use to add a Rule:

Expand Down
4 changes: 3 additions & 1 deletion src/fidesops/api/v1/endpoints/policy_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
PolicyValidationError,
RuleTargetValidationError,
RuleValidationError,
DrpActionValidationError,
)
from fidesops.models.client import ClientDetail
from fidesops.models.policy import ActionType, Policy, Rule, RuleTarget
Expand Down Expand Up @@ -114,9 +115,10 @@ def create_or_update_policies(
"name": policy_data["name"],
"key": policy_data.get("key"),
"client_id": client.id,
"drp_action": policy_data.get("drp_action"),
},
)
except KeyOrNameAlreadyExists as exc:
except (KeyOrNameAlreadyExists, DrpActionValidationError) as exc:
logger.warning("Create/update failed for policy: %s", exc)
failure = {
"message": exc.args[0],
Expand Down
4 changes: 4 additions & 0 deletions src/fidesops/common_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ class KeyOrNameAlreadyExists(Exception):
"""A resource already exists with this key or name."""


class DrpActionValidationError(Exception):
"""A resource already exists with this DRP Action."""


class KeyValidationError(Exception):
"""The resource you're trying to create has a key specified but not a name specified."""

Expand Down
120 changes: 107 additions & 13 deletions src/fidesops/models/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ class ActionType(EnumType):
update = "update"


class DrpAction(EnumType):
"""
Enum to hold valid DRP actions. For more details, see:
https://github.com/consumer-reports-digital-lab/data-rights-protocol#301-supported-rights-actions
"""

access = "access"
deletion = "deletion"
# below are not supported
sale_opt_out = "sale:opt_out"
sale_opt_in = "sale:opt_in"
access_categories = "access:categories"
access_specific = "access:specific"


PseudonymizationPolicy = SupportedMaskingStrategies
"""
*Deprecated*: The method by which to pseudonymize data.
Expand All @@ -70,33 +85,67 @@ class is referenced in multiple database migrations. This class is to be removed
"""


def _validate_drp_action(
drp_action: Optional[str], drp_action_exists: bool, class_name: str
) -> None:
"""Check that DRP action is supported"""
if not drp_action:
return
if drp_action_exists:
raise common_exceptions.DrpActionValidationError(
f"DRP Action {drp_action} already exists in {class_name}."
)
if drp_action in [
DrpAction.sale_opt_in.value,
DrpAction.sale_opt_out.value,
DrpAction.access_categories.value,
DrpAction.access_specific.value,
]:
raise common_exceptions.DrpActionValidationError(
f"{drp_action} action is not supported at this time."
)


def _validate_rule(
action_type: Optional[str],
storage_destination_id: Optional[str],
masking_strategy: Optional[Dict[str, Union[str, Dict[str, str]]]],
drp_action: Optional[DrpAction],
) -> None:
"""Check that the rule's action_type and storage_destination are valid."""
if not action_type:
raise common_exceptions.RuleValidationError("action_type is required.")

if action_type == ActionType.erasure.value and storage_destination_id is not None:
if drp_action:
_validate_rule_with_drp_action(action_type, drp_action)
if action_type == ActionType.erasure.value:
if storage_destination_id is not None:
raise common_exceptions.RuleValidationError(
"Erasure Rules cannot have storage destinations."
)
if masking_strategy is None:
raise common_exceptions.RuleValidationError(
"Erasure Rules must have masking strategies."
)
if action_type == ActionType.access.value:
if storage_destination_id is None:
raise common_exceptions.RuleValidationError(
"Access Rules must have a storage destination."
)
if action_type in [ActionType.consent.value, ActionType.update.value]:
raise common_exceptions.RuleValidationError(
"Erasure Rules cannot have storage destinations."
f"{action_type} Rules are not supported at this time."
)

if action_type == ActionType.erasure.value and masking_strategy is None:
raise common_exceptions.RuleValidationError(
"Erasure Rules must have masking strategies."
)

if action_type == ActionType.access.value and storage_destination_id is None:
def _validate_rule_with_drp_action(rule_action: str, drp_action: DrpAction) -> None:
"""Validate that rule action matches drp action"""
if drp_action == DrpAction.deletion and rule_action != ActionType.erasure.value:
raise common_exceptions.RuleValidationError(
"Access Rules must have a storage destination."
"Since the associated Policy has a DRP deletion action, this rule must have the matching access action_type."
)

if action_type in [ActionType.consent.value, ActionType.update.value]:
if drp_action == DrpAction.access and rule_action != ActionType.access.value:
raise common_exceptions.RuleValidationError(
f"{action_type} Rules are not supported at this time."
"Since the associated Policy has a DRP access action, this rule must have the matching erasure action_type."
)


Expand All @@ -105,6 +154,7 @@ class Policy(Base):

name = Column(String, unique=True, nullable=False)
key = Column(String, index=True, unique=True, nullable=False)
drp_action = Column(EnumColumn(DrpAction), index=True, unique=True, nullable=True)
client_id = Column(
String,
ForeignKey(ClientDetail.id_field_path),
Expand All @@ -115,6 +165,42 @@ class Policy(Base):
backref="policies",
) # Which client created the Policy

@classmethod
def create_or_update(cls, db: Session, *, data: Dict[str, Any]) -> FidesopsBase:
"""Overrides base create or update to add custom error for drp action already exists"""
db_obj = None
if data.get("id") is not None:
# If `id` has been included in `data`, preference that
db_obj = cls.get(db=db, id=data["id"])
elif data.get("key") is not None:
# Otherwise, try with `key`
db_obj = cls.get_by(db=db, field="key", value=data["key"])

if db_obj:
other_objs = db.query(cls).filter(id != db_obj.id) # pylint: disable=W0143
_validate_drp_action(
data["drp_action"],
bool(
other_objs
and other_objs.filter_by(drp_action=data["drp_action"]).first()
),
cls.name,
)
db_obj.update(db=db, data=data)
else:
if hasattr(cls, "drp_action"):
data["drp_action"] = data.get("drp_action", None)
_validate_drp_action(
data["drp_action"],
bool(
db.query(cls).filter_by(drp_action=data["drp_action"]).first()
),
cls.name,
)
db_obj = cls.create(db=db, data=data)

return db_obj

def delete(self, db: Session) -> Optional[FidesopsBase]:
"""Cascade delete all rules on deletion of a Policy."""
_ = [rule.delete(db=db) for rule in self.rules]
Expand Down Expand Up @@ -246,16 +332,19 @@ def save(self, db: Session) -> FidesopsBase:
action_type=self.action_type,
storage_destination_id=self.storage_destination_id,
masking_strategy=self.masking_strategy,
drp_action=self.policy.drp_action,
)
return super().save(db=db)

@classmethod
def create(cls, db: Session, *, data: Dict[str, Any]) -> FidesopsBase:
"""Validate this object's data before deferring to the superclass on update"""
associated_policy = db.query(Policy).filter_by(id=data["policy_id"]).first()
_validate_rule(
action_type=data.get("action_type"),
storage_destination_id=data.get("storage_destination_id"),
masking_strategy=data.get("masking_strategy"),
drp_action=associated_policy.drp_action if associated_policy else None,
)
return super().create(db=db, data=data)

Expand Down Expand Up @@ -293,6 +382,11 @@ def create_or_update(cls, db: Session, *, data: Dict[str, Any]) -> FidesopsBase:
raise common_exceptions.RuleValidationError(
f"Rule with identifier {identifier} belongs to another policy."
)
associated_policy = db.query(Policy).filter_by(id=data["policy_id"]).first()
if associated_policy and associated_policy.drp_action:
_validate_rule_with_drp_action(
data.get("action_type"), associated_policy.drp_action
)
db_obj.update(db=db, data=data)
else:
db_obj = cls.create(db=db, data=data)
Expand Down Expand Up @@ -419,7 +513,7 @@ def update(self, db: Session, *, data: Dict[str, Any]) -> FidesopsBase:
"""Validate data_category on object update."""
updated_data_category = data.get("data_category")
if data.get("name") is None:
# Don't pass explciit `None` through for `name` because the field
# Don't pass explcit `None` through for `name` because the field
# is non-nullable
del data["name"]

Expand Down
3 changes: 3 additions & 0 deletions src/fidesops/schemas/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from fidesops.models.policy import (
ActionType,
DrpAction,
)
from fidesops.schemas.api import BulkResponse, BulkUpdateFailed
from fidesops.schemas.base_class import BaseSchema
Expand Down Expand Up @@ -86,12 +87,14 @@ class Policy(BaseSchema):

name: str
key: Optional[FidesOpsKey]
drp_action: Optional[DrpAction]


class PolicyResponse(Policy):
"""A holistic view of a Policy record, including all foreign keys by default."""

rules: Optional[List[RuleResponse]]
drp_action: Optional[DrpAction]


class BulkPutRuleTargetResponse(BulkResponse):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""adds drp_policy column to policy
Revision ID: 0e0b346819d7
Revises: 530fb8533ca4
Create Date: 2022-04-28 20:36:01.314299
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.


revision = "0e0b346819d7"
down_revision = "530fb8533ca4"
branch_labels = None
depends_on = None


def upgrade():
drpaction = postgresql.ENUM(
"access",
"deletion",
"sale_opt_out",
"sale_opt_in",
"access_categories",
"access_specific",
name="drpaction",
create_type=False,
)
drpaction.create(op.get_bind())
op.add_column("policy", sa.Column("drp_action", drpaction, nullable=True))
op.create_index(op.f("ix_policy_drp_action"), "policy", ["drp_action"], unique=True)


def downgrade():
op.drop_index(op.f("ix_policy_drp_action"), table_name="policy")
op.drop_column("policy", "drp_action")
op.execute("DROP TYPE drpaction;")
Loading

0 comments on commit 3a97760

Please sign in to comment.