ethyca-fides 2.56.3b1__py2.py3-none-any.whl → 2.57.0rc0__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ethyca_fides-2.56.3b1.dist-info → ethyca_fides-2.57.0rc0.dist-info}/METADATA +1 -1
- {ethyca_fides-2.56.3b1.dist-info → ethyca_fides-2.57.0rc0.dist-info}/RECORD +118 -114
- fides/_version.py +3 -3
- fides/api/alembic/migrations/versions/69ad6d844e21_add_comments_and_comment_references.py +84 -0
- fides/api/alembic/migrations/versions/6ea2171c544f_change_attachment_storage_key_to_.py +77 -0
- fides/api/models/attachment.py +109 -49
- fides/api/models/comment.py +109 -0
- fides/api/service/connectors/query_configs/bigquery_query_config.py +46 -15
- fides/api/service/connectors/query_configs/query_config.py +118 -21
- fides/api/service/connectors/query_configs/saas_query_config.py +21 -15
- fides/api/service/storage/storage_uploader_service.py +4 -10
- fides/api/tasks/storage.py +106 -15
- fides/api/util/aws_util.py +19 -0
- fides/api/util/collection_util.py +142 -0
- fides/api/util/logger_context_utils.py +36 -1
- fides/api/util/saas_util.py +32 -56
- fides/data/language/languages.yml +2 -0
- fides/service/privacy_request/privacy_request_service.py +14 -5
- fides/ui-build/static/admin/404.html +1 -1
- fides/ui-build/static/admin/_next/static/8xQRBeECrXm95hw4NKfpE/_buildManifest.js +1 -0
- fides/ui-build/static/admin/_next/static/chunks/5574-a4047e826a8cd4c3.js +1 -0
- fides/ui-build/static/admin/_next/static/chunks/5834-bd9ed01c4ab2ef5c.js +1 -0
- fides/ui-build/static/admin/_next/static/chunks/5973-67c7562997f7d5cb.js +1 -0
- fides/ui-build/static/admin/_next/static/chunks/6277-515c922445b8edc5.js +1 -0
- fides/ui-build/static/admin/_next/static/chunks/9767-4c591bd478c72650.js +1 -0
- fides/ui-build/static/admin/_next/static/chunks/pages/{_app-3c1a7742661d3a9e.js → _app-c580f4c12ff5a438.js} +1 -1
- fides/ui-build/static/admin/_next/static/chunks/pages/consent/privacy-experience/{[id]-fdbafb5a47a6a28c.js → [id]-443e6dd191ce5588.js} +1 -1
- fides/ui-build/static/admin/_next/static/chunks/pages/consent/privacy-experience/{new-fc4635c6eea7165f.js → new-36162d5bea29dcc2.js} +1 -1
- fides/ui-build/static/admin/_next/static/chunks/pages/consent/privacy-notices/{[id]-bdc686761a0d2d60.js → [id]-2d651499c07d12d9.js} +1 -1
- fides/ui-build/static/admin/_next/static/chunks/pages/consent/privacy-notices/{new-8618ab5fa45cd074.js → new-36e57d2f2446be82.js} +1 -1
- fides/ui-build/static/admin/_next/static/chunks/pages/dataset/new-740824dfa6823e26.js +1 -0
- fides/ui-build/static/admin/_next/static/chunks/pages/dataset-5541ecd2f62711a5.js +1 -0
- fides/ui-build/static/admin/_next/static/chunks/pages/datastore-connection/{[id]-a6e793e9a8ed00a9.js → [id]-0616437e1a82d3ed.js} +1 -1
- fides/ui-build/static/admin/_next/static/chunks/pages/datastore-connection/{new-7d314bb7238af067.js → new-f8218440494e8532.js} +1 -1
- fides/ui-build/static/admin/_next/static/chunks/pages/{index-c9fa68dc0fa42c81.js → index-94e6d589c4edf360.js} +1 -1
- fides/ui-build/static/admin/_next/static/chunks/pages/integrations/[id]-a6448ce6ba7f0cf5.js +1 -0
- fides/ui-build/static/admin/_next/static/chunks/pages/integrations-1a6965d78bfb26b0.js +1 -0
- fides/ui-build/static/admin/_next/static/chunks/pages/systems/configure/{[id]-c6396feeefca887b.js → [id]-82d9af34546adaa2.js} +1 -1
- fides/ui-build/static/admin/_next/static/css/{bd5a72c010fa9c14.css → b8dbda131f8d4c05.css} +1 -1
- fides/ui-build/static/admin/add-systems/manual.html +1 -1
- fides/ui-build/static/admin/add-systems/multiple.html +1 -1
- fides/ui-build/static/admin/add-systems.html +1 -1
- fides/ui-build/static/admin/ant-poc.html +1 -1
- fides/ui-build/static/admin/consent/configure/add-vendors.html +1 -1
- fides/ui-build/static/admin/consent/configure.html +1 -1
- fides/ui-build/static/admin/consent/privacy-experience/[id].html +1 -1
- fides/ui-build/static/admin/consent/privacy-experience/new.html +1 -1
- fides/ui-build/static/admin/consent/privacy-experience.html +1 -1
- fides/ui-build/static/admin/consent/privacy-notices/[id].html +1 -1
- fides/ui-build/static/admin/consent/privacy-notices/new.html +1 -1
- fides/ui-build/static/admin/consent/privacy-notices.html +1 -1
- fides/ui-build/static/admin/consent/properties.html +1 -1
- fides/ui-build/static/admin/consent/reporting.html +1 -1
- fides/ui-build/static/admin/consent.html +1 -1
- fides/ui-build/static/admin/data-catalog/[systemId]/projects/[projectUrn]/[resourceUrn].html +1 -1
- fides/ui-build/static/admin/data-catalog/[systemId]/projects/[projectUrn].html +1 -1
- fides/ui-build/static/admin/data-catalog/[systemId]/projects.html +1 -1
- fides/ui-build/static/admin/data-catalog/[systemId]/resources/[resourceUrn].html +1 -1
- fides/ui-build/static/admin/data-catalog/[systemId]/resources.html +1 -1
- fides/ui-build/static/admin/data-catalog.html +1 -1
- fides/ui-build/static/admin/data-discovery/action-center/[monitorId]/[systemId].html +1 -1
- fides/ui-build/static/admin/data-discovery/action-center/[monitorId].html +1 -1
- fides/ui-build/static/admin/data-discovery/action-center.html +1 -1
- fides/ui-build/static/admin/data-discovery/activity.html +1 -1
- fides/ui-build/static/admin/data-discovery/detection/[resourceUrn].html +1 -1
- fides/ui-build/static/admin/data-discovery/detection.html +1 -1
- fides/ui-build/static/admin/data-discovery/discovery/[resourceUrn].html +1 -1
- fides/ui-build/static/admin/data-discovery/discovery.html +1 -1
- fides/ui-build/static/admin/datamap.html +1 -1
- fides/ui-build/static/admin/dataset/[datasetId]/[collectionName]/[...subfieldNames].html +1 -1
- fides/ui-build/static/admin/dataset/[datasetId]/[collectionName].html +1 -1
- fides/ui-build/static/admin/dataset/[datasetId].html +1 -1
- fides/ui-build/static/admin/dataset/new.html +1 -1
- fides/ui-build/static/admin/dataset.html +1 -1
- fides/ui-build/static/admin/datastore-connection/[id].html +1 -1
- fides/ui-build/static/admin/datastore-connection/new.html +1 -1
- fides/ui-build/static/admin/datastore-connection.html +1 -1
- fides/ui-build/static/admin/index.html +1 -1
- fides/ui-build/static/admin/integrations/[id].html +1 -1
- fides/ui-build/static/admin/integrations.html +1 -1
- fides/ui-build/static/admin/lib/fides-headless.js +1 -1
- fides/ui-build/static/admin/lib/fides-tcf.js +3 -3
- fides/ui-build/static/admin/lib/fides.js +2 -2
- fides/ui-build/static/admin/login/[provider].html +1 -1
- fides/ui-build/static/admin/login.html +1 -1
- fides/ui-build/static/admin/messaging/[id].html +1 -1
- fides/ui-build/static/admin/messaging/add-template.html +1 -1
- fides/ui-build/static/admin/messaging.html +1 -1
- fides/ui-build/static/admin/privacy-requests/[id].html +1 -1
- fides/ui-build/static/admin/privacy-requests/configure/messaging.html +1 -1
- fides/ui-build/static/admin/privacy-requests/configure/storage.html +1 -1
- fides/ui-build/static/admin/privacy-requests/configure.html +1 -1
- fides/ui-build/static/admin/privacy-requests.html +1 -1
- fides/ui-build/static/admin/properties/[id].html +1 -1
- fides/ui-build/static/admin/properties/add-property.html +1 -1
- fides/ui-build/static/admin/properties.html +1 -1
- fides/ui-build/static/admin/reporting/datamap.html +1 -1
- fides/ui-build/static/admin/settings/about.html +1 -1
- fides/ui-build/static/admin/settings/consent.html +1 -1
- fides/ui-build/static/admin/settings/custom-fields.html +1 -1
- fides/ui-build/static/admin/settings/domain-records.html +1 -1
- fides/ui-build/static/admin/settings/domains.html +1 -1
- fides/ui-build/static/admin/settings/email-templates.html +1 -1
- fides/ui-build/static/admin/settings/locations.html +1 -1
- fides/ui-build/static/admin/settings/organization.html +1 -1
- fides/ui-build/static/admin/settings/regulations.html +1 -1
- fides/ui-build/static/admin/systems/configure/[id]/test-datasets.html +1 -1
- fides/ui-build/static/admin/systems/configure/[id].html +1 -1
- fides/ui-build/static/admin/systems.html +1 -1
- fides/ui-build/static/admin/taxonomy.html +1 -1
- fides/ui-build/static/admin/user-management/new.html +1 -1
- fides/ui-build/static/admin/user-management/profile/[id].html +1 -1
- fides/ui-build/static/admin/user-management.html +1 -1
- fides/ui-build/static/admin/_next/static/LOp6RUpN795nyhXOv95wz/_buildManifest.js +0 -1
- fides/ui-build/static/admin/_next/static/chunks/2201-abd6092e6df98c26.js +0 -1
- fides/ui-build/static/admin/_next/static/chunks/6277-b55810d66362f56e.js +0 -1
- fides/ui-build/static/admin/_next/static/chunks/6362-8f79e403fdc2404a.js +0 -1
- fides/ui-build/static/admin/_next/static/chunks/7495-a61f0c9fcb458664.js +0 -1
- fides/ui-build/static/admin/_next/static/chunks/pages/dataset/new-fc00dbeb18a7b731.js +0 -1
- fides/ui-build/static/admin/_next/static/chunks/pages/dataset-295756e1fd640b59.js +0 -1
- fides/ui-build/static/admin/_next/static/chunks/pages/integrations/[id]-20489bceb7b068b8.js +0 -1
- fides/ui-build/static/admin/_next/static/chunks/pages/integrations-21c9902e7b098b39.js +0 -1
- {ethyca_fides-2.56.3b1.dist-info → ethyca_fides-2.57.0rc0.dist-info}/LICENSE +0 -0
- {ethyca_fides-2.56.3b1.dist-info → ethyca_fides-2.57.0rc0.dist-info}/WHEEL +0 -0
- {ethyca_fides-2.56.3b1.dist-info → ethyca_fides-2.57.0rc0.dist-info}/entry_points.txt +0 -0
- {ethyca_fides-2.56.3b1.dist-info → ethyca_fides-2.57.0rc0.dist-info}/top_level.txt +0 -0
- /fides/ui-build/static/admin/_next/static/{LOp6RUpN795nyhXOv95wz → 8xQRBeECrXm95hw4NKfpE}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Change Attachment.storage_key to foreign key
|
|
2
|
+
|
|
3
|
+
Revision ID: 6ea2171c544f
|
|
4
|
+
Revises: 1152c1717849
|
|
5
|
+
Create Date: 2025-03-10 17:36:31.504831
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sqlalchemy as sa
|
|
10
|
+
from alembic import op
|
|
11
|
+
from sqlalchemy.dialects import postgresql
|
|
12
|
+
|
|
13
|
+
# revision identifiers, used by Alembic.
|
|
14
|
+
revision = "6ea2171c544f"
|
|
15
|
+
down_revision = "1152c1717849"
|
|
16
|
+
branch_labels = None
|
|
17
|
+
depends_on = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def upgrade():
|
|
21
|
+
|
|
22
|
+
# Alter the column type and add the new foreign key constraint
|
|
23
|
+
op.alter_column(
|
|
24
|
+
"attachment",
|
|
25
|
+
"storage_key",
|
|
26
|
+
existing_type=sa.String(),
|
|
27
|
+
type_=sa.String(),
|
|
28
|
+
nullable=False,
|
|
29
|
+
)
|
|
30
|
+
op.create_foreign_key(
|
|
31
|
+
"fk_attachment_storage_key",
|
|
32
|
+
"attachment",
|
|
33
|
+
"storageconfig",
|
|
34
|
+
["storage_key"],
|
|
35
|
+
["key"],
|
|
36
|
+
ondelete="CASCADE",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Add index on attachment_reference.reference_id
|
|
40
|
+
op.create_index(
|
|
41
|
+
"ix_attachment_reference_reference_id",
|
|
42
|
+
"attachment_reference",
|
|
43
|
+
["reference_id"],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Add index on attachment_reference.reference_type
|
|
47
|
+
op.create_index(
|
|
48
|
+
"ix_attachment_reference_reference_type",
|
|
49
|
+
"attachment_reference",
|
|
50
|
+
["reference_type"],
|
|
51
|
+
)
|
|
52
|
+
# ### end Alembic commands ###
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def downgrade():
|
|
56
|
+
# Drop the index on attachment_reference.reference_id
|
|
57
|
+
op.drop_index(
|
|
58
|
+
"ix_attachment_reference_reference_id", table_name="attachment_reference"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Drop the index on attachment_reference.reference_type
|
|
62
|
+
op.drop_index(
|
|
63
|
+
"ix_attachment_reference_reference_type", table_name="attachment_reference"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Drop the new foreign key constraint
|
|
67
|
+
op.drop_constraint("fk_attachment_storage_key", "attachment", type_="foreignkey")
|
|
68
|
+
|
|
69
|
+
# Revert the column type change
|
|
70
|
+
op.alter_column(
|
|
71
|
+
"attachment",
|
|
72
|
+
"storage_key",
|
|
73
|
+
existing_type=sa.String(),
|
|
74
|
+
type_=sa.String(),
|
|
75
|
+
nullable=True,
|
|
76
|
+
)
|
|
77
|
+
# ### end Alembic commands ###
|
fides/api/models/attachment.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import os
|
|
1
2
|
from enum import Enum as EnumType
|
|
2
3
|
from typing import Any, Optional
|
|
3
4
|
|
|
5
|
+
from loguru import logger as log
|
|
4
6
|
from sqlalchemy import Column
|
|
5
7
|
from sqlalchemy import Enum as EnumColumn
|
|
6
8
|
from sqlalchemy import ForeignKey, String, UniqueConstraint
|
|
@@ -9,6 +11,15 @@ from sqlalchemy.orm import Session, relationship
|
|
|
9
11
|
|
|
10
12
|
from fides.api.db.base_class import Base
|
|
11
13
|
from fides.api.models.fides_user import FidesUser # pylint: disable=unused-import
|
|
14
|
+
from fides.api.models.storage import StorageConfig # pylint: disable=unused-import
|
|
15
|
+
from fides.api.schemas.storage.storage import StorageDetails, StorageType
|
|
16
|
+
from fides.api.tasks.storage import (
|
|
17
|
+
LOCAL_FIDES_UPLOAD_DIRECTORY,
|
|
18
|
+
generic_delete_from_s3,
|
|
19
|
+
generic_retrieve_from_s3,
|
|
20
|
+
get_local_filename,
|
|
21
|
+
upload_to_s3,
|
|
22
|
+
)
|
|
12
23
|
|
|
13
24
|
|
|
14
25
|
class AttachmentType(str, EnumType):
|
|
@@ -74,7 +85,9 @@ class Attachment(Base):
|
|
|
74
85
|
)
|
|
75
86
|
file_name = Column(String, nullable=False)
|
|
76
87
|
attachment_type = Column(EnumColumn(AttachmentType), nullable=False)
|
|
77
|
-
storage_key = Column(
|
|
88
|
+
storage_key = Column(
|
|
89
|
+
String, ForeignKey("storageconfig.key", ondelete="CASCADE"), nullable=False
|
|
90
|
+
)
|
|
78
91
|
|
|
79
92
|
user = relationship(
|
|
80
93
|
"FidesUser",
|
|
@@ -90,64 +103,111 @@ class Attachment(Base):
|
|
|
90
103
|
uselist=True,
|
|
91
104
|
)
|
|
92
105
|
|
|
93
|
-
|
|
94
|
-
""
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
106
|
+
config = relationship(
|
|
107
|
+
"StorageConfig",
|
|
108
|
+
lazy="selectin",
|
|
109
|
+
uselist=False,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def upload(self, attachment: bytes) -> None:
|
|
113
|
+
"""Uploads an attachment to S3 or local storage."""
|
|
114
|
+
if self.config.type == StorageType.s3:
|
|
115
|
+
bucket_name = f"{self.config.details[StorageDetails.BUCKET.value]}"
|
|
116
|
+
auth_method = self.config.details[StorageDetails.AUTH_METHOD.value]
|
|
117
|
+
upload_to_s3(
|
|
118
|
+
storage_secrets=self.config.secrets,
|
|
119
|
+
data={},
|
|
120
|
+
bucket_name=bucket_name,
|
|
121
|
+
file_key=self.id,
|
|
122
|
+
resp_format=self.config.format,
|
|
123
|
+
privacy_request=None,
|
|
124
|
+
document=attachment,
|
|
125
|
+
auth_method=auth_method,
|
|
126
|
+
)
|
|
127
|
+
log.info(f"Uploaded {self.file_name} to S3 bucket {bucket_name}/{self.id}")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
if self.config.type == StorageType.local:
|
|
131
|
+
filename = get_local_filename(self.id)
|
|
132
|
+
with open(filename, "wb") as file:
|
|
133
|
+
file.write(attachment)
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
raise ValueError(f"Unsupported storage type: {self.config.type}")
|
|
137
|
+
|
|
138
|
+
def retrieve_attachment(self) -> Optional[bytes]:
|
|
139
|
+
"""Returns the attachment from S3 in bytes form."""
|
|
140
|
+
if self.config.type == StorageType.s3:
|
|
141
|
+
bucket_name = f"{self.config.details[StorageDetails.BUCKET.value]}"
|
|
142
|
+
auth_method = self.config.details[StorageDetails.AUTH_METHOD.value]
|
|
143
|
+
return generic_retrieve_from_s3(
|
|
144
|
+
storage_secrets=self.config.secrets,
|
|
145
|
+
bucket_name=bucket_name,
|
|
146
|
+
file_key=self.id,
|
|
147
|
+
auth_method=auth_method,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if self.config.type == StorageType.local:
|
|
151
|
+
filename = f"{LOCAL_FIDES_UPLOAD_DIRECTORY}/{self.id}"
|
|
152
|
+
with open(filename, "rb") as file:
|
|
153
|
+
return file.read()
|
|
154
|
+
|
|
155
|
+
raise ValueError(f"Unsupported storage type: {self.config.type}")
|
|
156
|
+
|
|
157
|
+
def delete_attachment_from_storage(self) -> None:
|
|
158
|
+
"""Deletes an attachment from S3 or local storage."""
|
|
159
|
+
if self.config.type == StorageType.s3:
|
|
160
|
+
bucket_name = f"{self.config.details[StorageDetails.BUCKET.value]}"
|
|
161
|
+
auth_method = self.config.details[StorageDetails.AUTH_METHOD.value]
|
|
162
|
+
generic_delete_from_s3(
|
|
163
|
+
storage_secrets=self.config.secrets,
|
|
164
|
+
bucket_name=bucket_name,
|
|
165
|
+
file_key=self.id,
|
|
166
|
+
auth_method=auth_method,
|
|
167
|
+
)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
if self.config.type == StorageType.local:
|
|
171
|
+
filename = f"{LOCAL_FIDES_UPLOAD_DIRECTORY}/{self.id}"
|
|
172
|
+
os.remove(filename)
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
raise ValueError(f"Unsupported storage type: {self.config.type}")
|
|
131
176
|
|
|
132
177
|
@classmethod
|
|
133
|
-
def
|
|
178
|
+
def create_and_upload(
|
|
134
179
|
cls,
|
|
135
180
|
db: Session,
|
|
136
181
|
*,
|
|
137
182
|
data: dict[str, Any],
|
|
183
|
+
attachment_file: bytes,
|
|
138
184
|
check_name: bool = False,
|
|
139
|
-
attachment: Optional[
|
|
140
|
-
bytes
|
|
141
|
-
] = None, # This will not be optional once the upload method is implemented.
|
|
142
185
|
) -> "Attachment":
|
|
143
186
|
"""Creates a new attachment record in the database and uploads the attachment to S3."""
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
187
|
+
if attachment_file is None:
|
|
188
|
+
raise ValueError("Attachment is required")
|
|
189
|
+
attachment_model = super().create(db=db, data=data, check_name=check_name)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
attachment_model.upload(attachment_file)
|
|
193
|
+
return attachment_model
|
|
194
|
+
except Exception as e:
|
|
195
|
+
log.error(f"Failed to upload attachment: {e}")
|
|
196
|
+
attachment_model.delete(db)
|
|
197
|
+
raise e
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def create(
|
|
201
|
+
cls,
|
|
202
|
+
db: Session,
|
|
203
|
+
*,
|
|
204
|
+
data: dict[str, Any],
|
|
205
|
+
check_name: bool = False,
|
|
206
|
+
) -> "Attachment":
|
|
207
|
+
"""Raises Error, provides information for user to create with upload instead."""
|
|
208
|
+
raise NotImplementedError("Please use create_and_upload method for Attachment")
|
|
147
209
|
|
|
148
210
|
def delete(self, db: Session) -> None:
|
|
149
211
|
"""Deletes an attachment record from the database and deletes the attachment from S3."""
|
|
150
|
-
|
|
151
|
-
# attachment_record.delete_attachment_from_s3(db)
|
|
152
|
-
# log.info(f"Deleted attachment {attachment_record.id} from S3")
|
|
212
|
+
self.delete_attachment_from_storage()
|
|
153
213
|
super().delete(db=db)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from enum import Enum as EnumType
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import Column
|
|
5
|
+
from sqlalchemy import Enum as EnumColumn
|
|
6
|
+
from sqlalchemy import ForeignKey, String, UniqueConstraint
|
|
7
|
+
from sqlalchemy.ext.declarative import declared_attr
|
|
8
|
+
from sqlalchemy.orm import Session, relationship
|
|
9
|
+
|
|
10
|
+
from fides.api.db.base_class import Base
|
|
11
|
+
from fides.api.models.attachment import Attachment, AttachmentReference
|
|
12
|
+
from fides.api.models.fides_user import FidesUser # pylint: disable=unused-import
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CommentType(str, EnumType):
|
|
16
|
+
"""
|
|
17
|
+
Enum for comment types. Indicates comment usage.
|
|
18
|
+
|
|
19
|
+
- notes are internal comments.
|
|
20
|
+
- reply comments are public and may cause an email or other communciation to be sent
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
note = "note"
|
|
24
|
+
reply = "reply"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CommentReferenceType(str, EnumType):
|
|
28
|
+
"""
|
|
29
|
+
Enum for comment reference types. Indicates where the comment is referenced.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
manual_step = "manual_step"
|
|
33
|
+
privacy_request = "privacy_request"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CommentReference(Base):
|
|
37
|
+
"""
|
|
38
|
+
Stores information about a comment and any other element which may reference that comment.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
@declared_attr
|
|
42
|
+
def __tablename__(cls) -> str:
|
|
43
|
+
"""Overriding base class method to set the table name."""
|
|
44
|
+
return "comment_reference"
|
|
45
|
+
|
|
46
|
+
comment_id = Column(String, ForeignKey("comment.id"), nullable=False)
|
|
47
|
+
reference_id = Column(String, nullable=False)
|
|
48
|
+
reference_type = Column(EnumColumn(CommentReferenceType), nullable=False)
|
|
49
|
+
|
|
50
|
+
__table_args__ = (
|
|
51
|
+
UniqueConstraint("comment_id", "reference_id", name="comment_reference_uc"),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
comment = relationship(
|
|
55
|
+
"Comment",
|
|
56
|
+
back_populates="references",
|
|
57
|
+
uselist=False,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def create(
|
|
62
|
+
cls, db: Session, *, data: dict[str, Any], check_name: bool = False
|
|
63
|
+
) -> "CommentReference":
|
|
64
|
+
"""Creates a new comment reference record in the database."""
|
|
65
|
+
return super().create(db=db, data=data, check_name=check_name)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Comment(Base):
|
|
69
|
+
"""
|
|
70
|
+
Stores information about a Comment.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
user_id = Column(
|
|
74
|
+
String, ForeignKey("fidesuser.id", ondelete="SET NULL"), nullable=True
|
|
75
|
+
)
|
|
76
|
+
comment_text = Column(String, nullable=False)
|
|
77
|
+
comment_type = Column(EnumColumn(CommentType), nullable=False)
|
|
78
|
+
|
|
79
|
+
user = relationship(
|
|
80
|
+
"FidesUser",
|
|
81
|
+
backref="comments",
|
|
82
|
+
lazy="selectin",
|
|
83
|
+
uselist=False,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
references = relationship(
|
|
87
|
+
"CommentReference",
|
|
88
|
+
back_populates="comment",
|
|
89
|
+
cascade="all, delete",
|
|
90
|
+
uselist=True,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def get_attachments(self, db: Session) -> list[Attachment]:
|
|
94
|
+
"""Retrieve all attachments associated with this comment."""
|
|
95
|
+
stmt = (
|
|
96
|
+
db.query(Attachment)
|
|
97
|
+
.join(
|
|
98
|
+
AttachmentReference, Attachment.id == AttachmentReference.attachment_id
|
|
99
|
+
)
|
|
100
|
+
.where(AttachmentReference.reference_id == self.id)
|
|
101
|
+
)
|
|
102
|
+
return db.execute(stmt).scalars().all()
|
|
103
|
+
|
|
104
|
+
def delete(self, db: Session) -> None:
|
|
105
|
+
"""Delete the comment and all associated references."""
|
|
106
|
+
attachments = self.get_attachments(db)
|
|
107
|
+
for attachment in attachments:
|
|
108
|
+
attachment.delete(db)
|
|
109
|
+
db.delete(self)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from typing import Any, Dict, List, Optional, Union, cast
|
|
2
2
|
|
|
3
|
+
import pydash
|
|
3
4
|
from fideslang.models import MaskingStrategies
|
|
4
5
|
from loguru import logger
|
|
5
6
|
from sqlalchemy import MetaData, Table, text
|
|
@@ -17,7 +18,14 @@ from fides.api.schemas.namespace_meta.bigquery_namespace_meta import (
|
|
|
17
18
|
from fides.api.service.connectors.query_configs.query_config import (
|
|
18
19
|
QueryStringWithoutTuplesOverrideQueryConfig,
|
|
19
20
|
)
|
|
20
|
-
from fides.api.util.collection_util import
|
|
21
|
+
from fides.api.util.collection_util import (
|
|
22
|
+
Row,
|
|
23
|
+
filter_nonempty_values,
|
|
24
|
+
flatten_dict,
|
|
25
|
+
merge_dicts,
|
|
26
|
+
replace_none_arrays,
|
|
27
|
+
unflatten_dict,
|
|
28
|
+
)
|
|
21
29
|
|
|
22
30
|
|
|
23
31
|
class BigQueryQueryConfig(QueryStringWithoutTuplesOverrideQueryConfig):
|
|
@@ -120,18 +128,45 @@ class BigQueryQueryConfig(QueryStringWithoutTuplesOverrideQueryConfig):
|
|
|
120
128
|
A List of multiple Update objects are returned for partitioned tables; for a non-partitioned table,
|
|
121
129
|
a single Update object is returned in a List for consistent typing.
|
|
122
130
|
|
|
123
|
-
|
|
131
|
+
This implementation handles nested fields by grouping them as JSON objects rather than
|
|
132
|
+
individual field updates.
|
|
133
|
+
|
|
134
|
+
See the README.md in this directory for a detailed example of how nested data is handled.
|
|
124
135
|
"""
|
|
136
|
+
|
|
137
|
+
# 1. Take update_value_map as-is (already flattened)
|
|
125
138
|
update_value_map: Dict[str, Any] = self.update_value_map(row, policy, request)
|
|
139
|
+
|
|
140
|
+
# 2. Flatten the row
|
|
141
|
+
flattened_row = flatten_dict(row)
|
|
142
|
+
|
|
143
|
+
# 3. Merge flattened_row with update_value_map (update_value_map takes precedence)
|
|
144
|
+
merged_dict = merge_dicts(flattened_row, update_value_map)
|
|
145
|
+
|
|
146
|
+
# 4. Unflatten the merged dictionary
|
|
147
|
+
nested_result = unflatten_dict(merged_dict)
|
|
148
|
+
|
|
149
|
+
# 5. Replace any arrays containing only None values with empty arrays
|
|
150
|
+
nested_result = replace_none_arrays(nested_result) # type: ignore
|
|
151
|
+
|
|
152
|
+
# 6. Only keep top-level keys that are in the update_value_map
|
|
153
|
+
top_level_keys = {key.split(".")[0] for key in update_value_map}
|
|
154
|
+
|
|
155
|
+
# Filter the nested result to only include those top-level keys
|
|
156
|
+
final_update_map = {
|
|
157
|
+
k: v for k, v in nested_result.items() if k in top_level_keys
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
# Use existing non-empty reference fields mechanism for WHERE clause
|
|
126
161
|
non_empty_reference_field_keys: Dict[str, Field] = filter_nonempty_values(
|
|
127
162
|
{
|
|
128
|
-
fpath.string_path: fld.cast(row
|
|
163
|
+
fpath.string_path: fld.cast(pydash.get(row, fpath.string_path))
|
|
129
164
|
for fpath, fld in self.reference_field_paths.items()
|
|
130
|
-
if fpath.string_path
|
|
165
|
+
if pydash.get(row, fpath.string_path) is not None
|
|
131
166
|
}
|
|
132
167
|
)
|
|
133
168
|
|
|
134
|
-
valid = len(non_empty_reference_field_keys) > 0 and
|
|
169
|
+
valid = len(non_empty_reference_field_keys) > 0 and final_update_map
|
|
135
170
|
if not valid:
|
|
136
171
|
logger.warning(
|
|
137
172
|
"There is not enough data to generate a valid update statement for {}",
|
|
@@ -154,12 +189,12 @@ class BigQueryQueryConfig(QueryStringWithoutTuplesOverrideQueryConfig):
|
|
|
154
189
|
partitioned_queries.append(
|
|
155
190
|
table.update()
|
|
156
191
|
.where(*(where_clauses + [text(partition_clause)]))
|
|
157
|
-
.values(**
|
|
192
|
+
.values(**final_update_map)
|
|
158
193
|
)
|
|
159
194
|
|
|
160
195
|
return partitioned_queries
|
|
161
196
|
|
|
162
|
-
return [table.update().where(*where_clauses).values(**
|
|
197
|
+
return [table.update().where(*where_clauses).values(**final_update_map)]
|
|
163
198
|
|
|
164
199
|
def generate_delete(self, row: Row, client: Engine) -> List[Delete]:
|
|
165
200
|
"""Returns a List of SQLAlchemy DELETE statements for BigQuery. Does not actually execute the delete statement.
|
|
@@ -213,18 +248,14 @@ class BigQueryQueryConfig(QueryStringWithoutTuplesOverrideQueryConfig):
|
|
|
213
248
|
self,
|
|
214
249
|
field_paths: List[FieldPath],
|
|
215
250
|
) -> List[str]:
|
|
216
|
-
"""
|
|
217
|
-
|
|
251
|
+
"""
|
|
252
|
+
Returns field paths in a format they can be added into SQL queries.
|
|
218
253
|
Only returns non-nested fields (fields with exactly one level).
|
|
219
|
-
Nested fields are skipped with a warning log.
|
|
220
254
|
"""
|
|
255
|
+
|
|
221
256
|
formatted_fields = []
|
|
222
257
|
for field_path in field_paths:
|
|
223
|
-
if len(field_path.levels)
|
|
224
|
-
logger.warning(
|
|
225
|
-
f"Skipping nested field '{'.'.join(field_path.levels)}' as nested fields are not supported"
|
|
226
|
-
)
|
|
227
|
-
else:
|
|
258
|
+
if len(field_path.levels) == 1:
|
|
228
259
|
formatted_fields.append(field_path.levels[0])
|
|
229
260
|
return formatted_fields
|
|
230
261
|
|
|
@@ -144,6 +144,45 @@ class QueryConfig(Generic[T], ABC):
|
|
|
144
144
|
|
|
145
145
|
return data
|
|
146
146
|
|
|
147
|
+
def _is_child_of_masked_parent(
|
|
148
|
+
self, path: str, masked_parent_paths: List[str]
|
|
149
|
+
) -> bool:
|
|
150
|
+
"""
|
|
151
|
+
Check if the given path is a child of any already masked parent object.
|
|
152
|
+
"""
|
|
153
|
+
for parent_path in masked_parent_paths:
|
|
154
|
+
if path == parent_path or path.startswith(parent_path + "."):
|
|
155
|
+
return True
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
def _is_object_or_array_with_data_category(
|
|
159
|
+
self, field_val: Any, field: Optional[Field]
|
|
160
|
+
) -> bool:
|
|
161
|
+
"""
|
|
162
|
+
Check if field is an object or array of objects with data categories
|
|
163
|
+
"""
|
|
164
|
+
# First check if the field has data categories
|
|
165
|
+
has_data_category = (
|
|
166
|
+
field and field.data_categories and len(field.data_categories) > 0
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if not has_data_category:
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
# Check if it's an object
|
|
173
|
+
is_object = isinstance(field_val, dict)
|
|
174
|
+
|
|
175
|
+
# Check if it's a non-empty array of objects
|
|
176
|
+
is_array_of_objects = False
|
|
177
|
+
if (
|
|
178
|
+
isinstance(field_val, list) and field_val
|
|
179
|
+
): # Check if it's a list and not empty
|
|
180
|
+
# Only check first element if the list is not empty
|
|
181
|
+
is_array_of_objects = isinstance(field_val[0], dict)
|
|
182
|
+
|
|
183
|
+
# Return true if it's either an object or an array of objects and has data categories
|
|
184
|
+
return is_object or is_array_of_objects
|
|
185
|
+
|
|
147
186
|
def update_value_map( # pylint: disable=R0914
|
|
148
187
|
self, row: Row, policy: Policy, request: PrivacyRequest
|
|
149
188
|
) -> Dict[str, Any]:
|
|
@@ -154,27 +193,44 @@ class QueryConfig(Generic[T], ABC):
|
|
|
154
193
|
In this example, a Null Masking Strategy was used to determine that the name/ccn/code fields, nested
|
|
155
194
|
workplace_info.employer field, and the first element in 'children' for a given customer_id will be replaced
|
|
156
195
|
with null values.
|
|
157
|
-
|
|
158
196
|
"""
|
|
159
197
|
rule_to_collection_field_paths: Dict[Rule, List[FieldPath]] = (
|
|
160
198
|
self.build_rule_target_field_paths(policy)
|
|
161
199
|
)
|
|
162
200
|
|
|
163
201
|
value_map: Dict[str, Any] = {}
|
|
202
|
+
# Track which parent paths have been masked as whole objects/arrays
|
|
203
|
+
masked_parent_paths: List[str] = []
|
|
204
|
+
|
|
164
205
|
for rule, field_paths in rule_to_collection_field_paths.items():
|
|
165
206
|
strategy_config = rule.masking_strategy
|
|
166
207
|
if not strategy_config:
|
|
167
208
|
continue
|
|
209
|
+
|
|
168
210
|
for rule_field_path in field_paths:
|
|
211
|
+
# Check if field is read-only before processing
|
|
212
|
+
field = self.field_map().get(rule_field_path)
|
|
213
|
+
if field and field.read_only:
|
|
214
|
+
logger.debug(
|
|
215
|
+
f"Skipping read-only field: {rule_field_path.string_path}"
|
|
216
|
+
)
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
# Skip if this field is a child of an already masked parent object
|
|
220
|
+
if self._is_child_of_masked_parent(
|
|
221
|
+
rule_field_path.string_path, masked_parent_paths
|
|
222
|
+
):
|
|
223
|
+
logger.debug(
|
|
224
|
+
f"Skipping field {rule_field_path.string_path} because its parent object has already been masked"
|
|
225
|
+
)
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
# Determine masking strategy
|
|
169
229
|
strategy: MaskingStrategy = MaskingStrategy.get_strategy(
|
|
170
230
|
strategy_config["strategy"], strategy_config["configuration"]
|
|
171
231
|
)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
for field_path, field in self.field_map().items()
|
|
175
|
-
if field_path == rule_field_path
|
|
176
|
-
][0]
|
|
177
|
-
field = self.field_map().get(rule_field_path)
|
|
232
|
+
|
|
233
|
+
# Apply field-level masking strategy override if present
|
|
178
234
|
if field and field.masking_strategy_override:
|
|
179
235
|
masking_strategy_override = field.masking_strategy_override
|
|
180
236
|
strategy = MaskingStrategy.get_strategy(
|
|
@@ -184,7 +240,14 @@ class QueryConfig(Generic[T], ABC):
|
|
|
184
240
|
logger.warning(
|
|
185
241
|
f"Using field-level masking override of type '{strategy.name}' for {rule_field_path.string_path}"
|
|
186
242
|
)
|
|
243
|
+
|
|
187
244
|
null_masking: bool = strategy.name == NullMaskingStrategy.name
|
|
245
|
+
truncation: MaskingTruncation = [
|
|
246
|
+
MaskingTruncation(field.data_type_converter, field.length)
|
|
247
|
+
for field_path, field in self.field_map().items()
|
|
248
|
+
if field_path == rule_field_path
|
|
249
|
+
][0]
|
|
250
|
+
|
|
188
251
|
if not self._supported_data_type(truncation, null_masking, strategy):
|
|
189
252
|
logger.warning(
|
|
190
253
|
"Unable to generate a query for field {}: data_type is either not present on the field or not supported for the {} masking strategy. Received data type: {}",
|
|
@@ -194,21 +257,55 @@ class QueryConfig(Generic[T], ABC):
|
|
|
194
257
|
)
|
|
195
258
|
continue
|
|
196
259
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
value_map[detailed_path] = self._generate_masked_value(
|
|
205
|
-
request_id=request.id,
|
|
206
|
-
strategy=strategy,
|
|
207
|
-
val=pydash.objects.get(row, detailed_path),
|
|
208
|
-
masking_truncation=truncation,
|
|
209
|
-
null_masking=null_masking,
|
|
210
|
-
str_field_path=detailed_path,
|
|
260
|
+
# Get the actual field value to determine its type
|
|
261
|
+
field_val = pydash.objects.get(row, rule_field_path.string_path)
|
|
262
|
+
|
|
263
|
+
# Handle objects and arrays with data categories - mask as whole entities
|
|
264
|
+
if self._is_object_or_array_with_data_category(field_val, field):
|
|
265
|
+
logger.info(
|
|
266
|
+
f"Field {rule_field_path.string_path} is an object or array of objects with data category. Masking entire field."
|
|
211
267
|
)
|
|
268
|
+
if field_val is not None:
|
|
269
|
+
value_map[rule_field_path.string_path] = (
|
|
270
|
+
self._generate_masked_value(
|
|
271
|
+
request_id=request.id,
|
|
272
|
+
strategy=strategy,
|
|
273
|
+
val=field_val,
|
|
274
|
+
masking_truncation=truncation,
|
|
275
|
+
null_masking=null_masking,
|
|
276
|
+
str_field_path=rule_field_path.string_path,
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
# Add this path to masked parent paths to skip its children later
|
|
280
|
+
masked_parent_paths.append(rule_field_path.string_path)
|
|
281
|
+
else:
|
|
282
|
+
# Standard approach: build refined target paths for individual fields
|
|
283
|
+
paths_to_mask = [
|
|
284
|
+
join_detailed_path(path)
|
|
285
|
+
for path in build_refined_target_paths(
|
|
286
|
+
row, query_paths={rule_field_path: None}
|
|
287
|
+
)
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
# Process each detailed path, skipping those that are children of masked parents
|
|
291
|
+
for detailed_path in paths_to_mask:
|
|
292
|
+
if self._is_child_of_masked_parent(
|
|
293
|
+
detailed_path, masked_parent_paths
|
|
294
|
+
):
|
|
295
|
+
logger.debug(
|
|
296
|
+
f"Skipping detailed path {detailed_path} because its parent object has already been masked"
|
|
297
|
+
)
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
value_map[detailed_path] = self._generate_masked_value(
|
|
301
|
+
request_id=request.id,
|
|
302
|
+
strategy=strategy,
|
|
303
|
+
val=pydash.objects.get(row, detailed_path),
|
|
304
|
+
masking_truncation=truncation,
|
|
305
|
+
null_masking=null_masking,
|
|
306
|
+
str_field_path=detailed_path,
|
|
307
|
+
)
|
|
308
|
+
|
|
212
309
|
return value_map
|
|
213
310
|
|
|
214
311
|
@staticmethod
|