ethyca-fides 2.56.3b1__py2.py3-none-any.whl → 2.57.0__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.
Files changed (129) hide show
  1. {ethyca_fides-2.56.3b1.dist-info → ethyca_fides-2.57.0.dist-info}/METADATA +1 -1
  2. {ethyca_fides-2.56.3b1.dist-info → ethyca_fides-2.57.0.dist-info}/RECORD +120 -116
  3. fides/_version.py +3 -3
  4. fides/api/alembic/migrations/versions/69ad6d844e21_add_comments_and_comment_references.py +84 -0
  5. fides/api/alembic/migrations/versions/6ea2171c544f_change_attachment_storage_key_to_.py +77 -0
  6. fides/api/models/attachment.py +109 -49
  7. fides/api/models/comment.py +109 -0
  8. fides/api/service/connectors/query_configs/bigquery_query_config.py +46 -15
  9. fides/api/service/connectors/query_configs/query_config.py +118 -21
  10. fides/api/service/connectors/query_configs/saas_query_config.py +21 -15
  11. fides/api/service/storage/storage_uploader_service.py +4 -10
  12. fides/api/tasks/storage.py +106 -15
  13. fides/api/util/aws_util.py +19 -0
  14. fides/api/util/collection_util.py +142 -0
  15. fides/api/util/logger_context_utils.py +36 -1
  16. fides/api/util/saas_util.py +32 -56
  17. fides/data/language/languages.yml +2 -0
  18. fides/service/privacy_request/privacy_request_service.py +14 -5
  19. fides/ui-build/static/admin/404.html +1 -1
  20. fides/ui-build/static/admin/_next/static/D8O9QehPDr533GOFed3df/_buildManifest.js +1 -0
  21. fides/ui-build/static/admin/_next/static/chunks/5574-a4047e826a8cd4c3.js +1 -0
  22. fides/ui-build/static/admin/_next/static/chunks/5834-bd9ed01c4ab2ef5c.js +1 -0
  23. fides/ui-build/static/admin/_next/static/chunks/5973-67c7562997f7d5cb.js +1 -0
  24. fides/ui-build/static/admin/_next/static/chunks/6277-515c922445b8edc5.js +1 -0
  25. fides/ui-build/static/admin/_next/static/chunks/9767-4c591bd478c72650.js +1 -0
  26. fides/ui-build/static/admin/_next/static/chunks/pages/{_app-3c1a7742661d3a9e.js → _app-9afd7a4bc9b3eee5.js} +1 -1
  27. fides/ui-build/static/admin/_next/static/chunks/pages/consent/privacy-experience/{[id]-fdbafb5a47a6a28c.js → [id]-443e6dd191ce5588.js} +1 -1
  28. fides/ui-build/static/admin/_next/static/chunks/pages/consent/privacy-experience/{new-fc4635c6eea7165f.js → new-36162d5bea29dcc2.js} +1 -1
  29. fides/ui-build/static/admin/_next/static/chunks/pages/consent/privacy-notices/{[id]-bdc686761a0d2d60.js → [id]-2d651499c07d12d9.js} +1 -1
  30. fides/ui-build/static/admin/_next/static/chunks/pages/consent/privacy-notices/{new-8618ab5fa45cd074.js → new-36e57d2f2446be82.js} +1 -1
  31. fides/ui-build/static/admin/_next/static/chunks/pages/dataset/new-740824dfa6823e26.js +1 -0
  32. fides/ui-build/static/admin/_next/static/chunks/pages/dataset-5541ecd2f62711a5.js +1 -0
  33. fides/ui-build/static/admin/_next/static/chunks/pages/datastore-connection/{[id]-a6e793e9a8ed00a9.js → [id]-0616437e1a82d3ed.js} +1 -1
  34. fides/ui-build/static/admin/_next/static/chunks/pages/datastore-connection/{new-7d314bb7238af067.js → new-f8218440494e8532.js} +1 -1
  35. fides/ui-build/static/admin/_next/static/chunks/pages/{index-c9fa68dc0fa42c81.js → index-94e6d589c4edf360.js} +1 -1
  36. fides/ui-build/static/admin/_next/static/chunks/pages/integrations/[id]-a6448ce6ba7f0cf5.js +1 -0
  37. fides/ui-build/static/admin/_next/static/chunks/pages/integrations-1a6965d78bfb26b0.js +1 -0
  38. fides/ui-build/static/admin/_next/static/chunks/pages/privacy-requests/{[id]-c685d19a131d6960.js → [id]-bbe5854b7d19b7e9.js} +1 -1
  39. fides/ui-build/static/admin/_next/static/chunks/pages/systems/configure/{[id]-c6396feeefca887b.js → [id]-82d9af34546adaa2.js} +1 -1
  40. fides/ui-build/static/admin/_next/static/css/{bd5a72c010fa9c14.css → 957d0e4fea846ff0.css} +1 -1
  41. fides/ui-build/static/admin/add-systems/manual.html +1 -1
  42. fides/ui-build/static/admin/add-systems/multiple.html +1 -1
  43. fides/ui-build/static/admin/add-systems.html +1 -1
  44. fides/ui-build/static/admin/ant-poc.html +1 -1
  45. fides/ui-build/static/admin/consent/configure/add-vendors.html +1 -1
  46. fides/ui-build/static/admin/consent/configure.html +1 -1
  47. fides/ui-build/static/admin/consent/privacy-experience/[id].html +1 -1
  48. fides/ui-build/static/admin/consent/privacy-experience/new.html +1 -1
  49. fides/ui-build/static/admin/consent/privacy-experience.html +1 -1
  50. fides/ui-build/static/admin/consent/privacy-notices/[id].html +1 -1
  51. fides/ui-build/static/admin/consent/privacy-notices/new.html +1 -1
  52. fides/ui-build/static/admin/consent/privacy-notices.html +1 -1
  53. fides/ui-build/static/admin/consent/properties.html +1 -1
  54. fides/ui-build/static/admin/consent/reporting.html +1 -1
  55. fides/ui-build/static/admin/consent.html +1 -1
  56. fides/ui-build/static/admin/data-catalog/[systemId]/projects/[projectUrn]/[resourceUrn].html +1 -1
  57. fides/ui-build/static/admin/data-catalog/[systemId]/projects/[projectUrn].html +1 -1
  58. fides/ui-build/static/admin/data-catalog/[systemId]/projects.html +1 -1
  59. fides/ui-build/static/admin/data-catalog/[systemId]/resources/[resourceUrn].html +1 -1
  60. fides/ui-build/static/admin/data-catalog/[systemId]/resources.html +1 -1
  61. fides/ui-build/static/admin/data-catalog.html +1 -1
  62. fides/ui-build/static/admin/data-discovery/action-center/[monitorId]/[systemId].html +1 -1
  63. fides/ui-build/static/admin/data-discovery/action-center/[monitorId].html +1 -1
  64. fides/ui-build/static/admin/data-discovery/action-center.html +1 -1
  65. fides/ui-build/static/admin/data-discovery/activity.html +1 -1
  66. fides/ui-build/static/admin/data-discovery/detection/[resourceUrn].html +1 -1
  67. fides/ui-build/static/admin/data-discovery/detection.html +1 -1
  68. fides/ui-build/static/admin/data-discovery/discovery/[resourceUrn].html +1 -1
  69. fides/ui-build/static/admin/data-discovery/discovery.html +1 -1
  70. fides/ui-build/static/admin/datamap.html +1 -1
  71. fides/ui-build/static/admin/dataset/[datasetId]/[collectionName]/[...subfieldNames].html +1 -1
  72. fides/ui-build/static/admin/dataset/[datasetId]/[collectionName].html +1 -1
  73. fides/ui-build/static/admin/dataset/[datasetId].html +1 -1
  74. fides/ui-build/static/admin/dataset/new.html +1 -1
  75. fides/ui-build/static/admin/dataset.html +1 -1
  76. fides/ui-build/static/admin/datastore-connection/[id].html +1 -1
  77. fides/ui-build/static/admin/datastore-connection/new.html +1 -1
  78. fides/ui-build/static/admin/datastore-connection.html +1 -1
  79. fides/ui-build/static/admin/index.html +1 -1
  80. fides/ui-build/static/admin/integrations/[id].html +1 -1
  81. fides/ui-build/static/admin/integrations.html +1 -1
  82. fides/ui-build/static/admin/lib/fides-ext-gpp.js +1 -1
  83. fides/ui-build/static/admin/lib/fides-headless.js +1 -1
  84. fides/ui-build/static/admin/lib/fides-tcf.js +3 -3
  85. fides/ui-build/static/admin/lib/fides.js +2 -2
  86. fides/ui-build/static/admin/login/[provider].html +1 -1
  87. fides/ui-build/static/admin/login.html +1 -1
  88. fides/ui-build/static/admin/messaging/[id].html +1 -1
  89. fides/ui-build/static/admin/messaging/add-template.html +1 -1
  90. fides/ui-build/static/admin/messaging.html +1 -1
  91. fides/ui-build/static/admin/privacy-requests/[id].html +1 -1
  92. fides/ui-build/static/admin/privacy-requests/configure/messaging.html +1 -1
  93. fides/ui-build/static/admin/privacy-requests/configure/storage.html +1 -1
  94. fides/ui-build/static/admin/privacy-requests/configure.html +1 -1
  95. fides/ui-build/static/admin/privacy-requests.html +1 -1
  96. fides/ui-build/static/admin/properties/[id].html +1 -1
  97. fides/ui-build/static/admin/properties/add-property.html +1 -1
  98. fides/ui-build/static/admin/properties.html +1 -1
  99. fides/ui-build/static/admin/reporting/datamap.html +1 -1
  100. fides/ui-build/static/admin/settings/about.html +1 -1
  101. fides/ui-build/static/admin/settings/consent.html +1 -1
  102. fides/ui-build/static/admin/settings/custom-fields.html +1 -1
  103. fides/ui-build/static/admin/settings/domain-records.html +1 -1
  104. fides/ui-build/static/admin/settings/domains.html +1 -1
  105. fides/ui-build/static/admin/settings/email-templates.html +1 -1
  106. fides/ui-build/static/admin/settings/locations.html +1 -1
  107. fides/ui-build/static/admin/settings/organization.html +1 -1
  108. fides/ui-build/static/admin/settings/regulations.html +1 -1
  109. fides/ui-build/static/admin/systems/configure/[id]/test-datasets.html +1 -1
  110. fides/ui-build/static/admin/systems/configure/[id].html +1 -1
  111. fides/ui-build/static/admin/systems.html +1 -1
  112. fides/ui-build/static/admin/taxonomy.html +1 -1
  113. fides/ui-build/static/admin/user-management/new.html +1 -1
  114. fides/ui-build/static/admin/user-management/profile/[id].html +1 -1
  115. fides/ui-build/static/admin/user-management.html +1 -1
  116. fides/ui-build/static/admin/_next/static/LOp6RUpN795nyhXOv95wz/_buildManifest.js +0 -1
  117. fides/ui-build/static/admin/_next/static/chunks/2201-abd6092e6df98c26.js +0 -1
  118. fides/ui-build/static/admin/_next/static/chunks/6277-b55810d66362f56e.js +0 -1
  119. fides/ui-build/static/admin/_next/static/chunks/6362-8f79e403fdc2404a.js +0 -1
  120. fides/ui-build/static/admin/_next/static/chunks/7495-a61f0c9fcb458664.js +0 -1
  121. fides/ui-build/static/admin/_next/static/chunks/pages/dataset/new-fc00dbeb18a7b731.js +0 -1
  122. fides/ui-build/static/admin/_next/static/chunks/pages/dataset-295756e1fd640b59.js +0 -1
  123. fides/ui-build/static/admin/_next/static/chunks/pages/integrations/[id]-20489bceb7b068b8.js +0 -1
  124. fides/ui-build/static/admin/_next/static/chunks/pages/integrations-21c9902e7b098b39.js +0 -1
  125. {ethyca_fides-2.56.3b1.dist-info → ethyca_fides-2.57.0.dist-info}/LICENSE +0 -0
  126. {ethyca_fides-2.56.3b1.dist-info → ethyca_fides-2.57.0.dist-info}/WHEEL +0 -0
  127. {ethyca_fides-2.56.3b1.dist-info → ethyca_fides-2.57.0.dist-info}/entry_points.txt +0 -0
  128. {ethyca_fides-2.56.3b1.dist-info → ethyca_fides-2.57.0.dist-info}/top_level.txt +0 -0
  129. /fides/ui-build/static/admin/_next/static/{LOp6RUpN795nyhXOv95wz → D8O9QehPDr533GOFed3df}/_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 ###
@@ -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(String, nullable=False)
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
- async def upload_attachment_to_s3(self, attachment: bytes) -> None:
94
- """Upload an attachment to S3 to the storage_url."""
95
- raise NotImplementedError("This method is not yet implemented")
96
- # AuditLog.create(
97
- # db=db,
98
- # data={
99
- # "user_id": "system",
100
- # "privacy_request_id": privacy_request.id,
101
- # "action": AuditLogAction.attachment_uploaded,
102
- # "message": "",
103
- # },
104
- # )
105
-
106
- async def retrieve_attachment_from_s3(self) -> bytes:
107
- """Retrieve an attachment from S3."""
108
- raise NotImplementedError("This method is not yet implemented")
109
- # AuditLog.create(
110
- # db=db,
111
- # data={
112
- # "user_id": "system",
113
- # "privacy_request_id": privacy_request.id,
114
- # "action": AuditLogAction.attachment_retrieved,
115
- # "message": "",
116
- # },
117
- # )
118
-
119
- async def delete_attachment_from_s3(self) -> None:
120
- """Delete an attachment from S3."""
121
- raise NotImplementedError("This method is not yet implemented")
122
- # AuditLog.create(
123
- # db=db,
124
- # data={
125
- # "user_id": "system",
126
- # "privacy_request_id": privacy_request.id,
127
- # "action": AuditLogAction.attachment_deleted,
128
- # "message": "",
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 create(
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
- # attachment_record.upload_attachment_to_s3(db, attachment)
145
- # log.info(f"Uploaded attachment {attachment_record.id} to S3")
146
- return super().create(db=db, data=data, check_name=check_name)
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
- # attachment_record = cls.get(db, id)
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 Row, filter_nonempty_values
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
- TODO: DRY up this method and `generate_delete` a bit
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[fpath.string_path])
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 in row
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 update_value_map
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(**update_value_map)
192
+ .values(**final_update_map)
158
193
  )
159
194
 
160
195
  return partitioned_queries
161
196
 
162
- return [table.update().where(*where_clauses).values(**update_value_map)]
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
- """Returns field paths in a format they can be added into SQL queries.
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) > 1:
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
- truncation: MaskingTruncation = [
173
- MaskingTruncation(field.data_type_converter, field.length)
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
- paths_to_mask: List[str] = [
198
- join_detailed_path(path)
199
- for path in build_refined_target_paths(
200
- row, query_paths={rule_field_path: None}
201
- )
202
- ]
203
- for detailed_path in paths_to_mask:
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