Repository: ethyca/fides
Pull request #6027: LJ-672
Author: login: tvandort
Created: 2025-04-10T06:03:37Z | Merged: 2025-04-18T16:28:00Z

Closes [LJ-672](https://ethyca.atlassian.net/browse/LJ-672?atlOrigin=eyJpIjoiNzA2NzYyNjE1Y2Y5NGI4MGE1MTFlMTkwODRlOWViYWEiLCJwIjoiaiJ9)

### Description Of Changes

This PR adds the ability to have DSRs use an assumed role to communicate with AWS S3 storage. It keeps backwards compatibility with existing configurations while adding the ability to assume roles when:

- Configuring a storage secret
- Using environment variables

### Code Changes

- **Assume role arn and region config.**
- **Update required secrets?**
- **Optional fields.**
- **Don't import things we don't need.**
- **Use config.**

### Steps to Confirm

1.  Update the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` values in your .env file to the ones for `sandboxProgrammaticUserS3Assumer` in 1password and start up fides!

2. Add an S3 storage config 
```json
{
  "type": "s3",
  "details": {
    "naming": "request_id",
    "auth_method": "automatic",
    "bucket": "test_bucket",
    "max_retries": 0
  },
  "format": "json"
}
```
![Screenshot 2025-04-15 at 3 54 08 PM](https://github.com/user-attachments/assets/ae625537-ca9d-4eec-9f98-298b1aa3f51d)

2. Add default storage secrets
```json
{
  "aws_access_key_id": "<available in 1password>",
  "aws_secret_access_key": "<available in 1password>",
  "region_name": "us-east-1",
  "assume_role_arn": "arn:aws:iam::<available in 1password>:role/S3FullAccessRole"
}
```
3. Verify that the secrets authenticated in logs and result:

Logs:

This part especially!!
` Assumed role arn:aws:iam::<account number>:role/S3FullAccessRole and got temporary credentials.`

```bash
2025-04-15 16:47:34 2025-04-15 22:47:34.943 | INFO     | fides.api.api.v1.endpoints.storage_endpoints:put_default_config_secrets:499 - Finding default config of storage type 's3'
2025-04-15 16:47:34 2025-04-15 22:47:34.965 | INFO     | fides.api.api.v1.endpoints.storage_endpoints:put_default_config_secrets:522 - Updating storage config secrets for default config of storage type 's3'
2025-04-15 16:47:34 2025-04-15 22:47:34.992 | INFO     | fides.api.util.aws_util:get_aws_session:44 - Successfully created automatic session
2025-04-15 16:47:35 2025-04-15 22:47:35.671 | INFO     | fides.api.util.aws_util:get_assumed_role_session:77 - Assumed role arn:aws:iam::<account number>:role/S3FullAccessRole and got temporary credentials.
2025-04-15 16:47:35 2025-04-15 22:47:35.680 | INFO     | fides.api.api.v1.endpoints.storage_endpoints:put_default_config_secrets:538 - Storage secrets are valid for default config of storage type 's3'
```

Result:
```bash

{
   "msg": "Secrets updated for default config of storage type: s3.",   
   "test_status": "succeeded",   
   "failure_reason": null 
}

```

### Pre-Merge Checklist

* [x] Issue requirements met
* [x] All CI pipelines succeeded
* [x] `CHANGELOG.md` updated
  * [ ] Add a https://github.com/ethyca/fides/labels/db-migration label to the entry if your change includes a DB migration
  * [ ] Add a https://github.com/ethyca/fides/labels/high-risk label to the entry if your change includes a high-risk change (i.e. potential for performance impact or unexpected regression) that should be flagged
* Followup issues:
  * [ ] Followup issues created (include link)
  * [ ] No followup issues
* Database migrations:
  * [ ] Ensure that your downrev is up to date with the latest revision on `main`
  * [ ] Ensure that your `downgrade()` migration is correct and works
    * [ ] If a downgrade migration is not possible for this change, please call this out in the PR description!
  * [ ] No migrations
* Documentation:
  * [ ] Documentation complete, [PR opened in fidesdocs](https://github.com/ethyca/fidesdocs/pulls)
  * [ ] Documentation [issue created in fidesdocs](https://github.com/ethyca/fidesdocs/issues/new/choose)
  * [ ] If there are any new client scopes created as part of the pull request, remember to update public-facing documentation that references our scope registry
  * [ ] No documentation updates required


[LJ-672]: https://ethyca.atlassian.net/browse/LJ-672?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ


Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a45c182c1c..fa412c1be72 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,9 @@ Changes can also be flagged with a GitHub label for tracking purposes. The URL o
 - Added PostgreSQL connection config form to the "integrations" page to support use with discovery monitors [#6018](https://github.com/ethyca/fides/pull/6018)
 - Update the Datahub Permissions section to include required permissions from Datahub [#6052](https://github.com/ethyca/fides/pull/6052)
 
+### Added
+- Added assumed role arn capabilities to aws Storage [#6027](https://github.com/ethyca/fides/pull/6027)
+
 ### Changed
 - Changed how TCF Publisher Overrides gets configured in consent settings [#6013](https://github.com/ethyca/fides/pull/6013)
 - Frontend now do not generate `key` when creating a Website Monitor [#6041](https://github.com/ethyca/fides/pull/6041)
diff --git a/src/fides/api/api/v1/endpoints/storage_endpoints.py b/src/fides/api/api/v1/endpoints/storage_endpoints.py
index 7b0f2333361..51abc16634e 100644
--- a/src/fides/api/api/v1/endpoints/storage_endpoints.py
+++ b/src/fides/api/api/v1/endpoints/storage_endpoints.py
@@ -220,7 +220,7 @@ def put_config_secrets(
 
     msg = f"Secrets updated for StorageConfig with key: {config_key}."
     if verify:
-        status = secrets_are_valid(secrets_schema, storage_config.type)
+        status = secrets_are_valid(secrets_schema, storage_config)
         if status:
             logger.info(
                 "Storage secrets are valid for config with key '{}'", config_key
@@ -534,7 +534,7 @@ def put_default_config_secrets(
 
     msg = f"Secrets updated for default config of storage type: {storage_type.value}."
     if verify:
-        status = secrets_are_valid(secrets_schema, storage_config.type)
+        status = secrets_are_valid(secrets_schema, storage_config)
         if status:
             logger.info(
                 "Storage secrets are valid for default config of storage type '{}'",
diff --git a/src/fides/api/schemas/storage/storage.py b/src/fides/api/schemas/storage/storage.py
index 70c7fc50f5c..5bd00eb21b4 100644
--- a/src/fides/api/schemas/storage/storage.py
+++ b/src/fides/api/schemas/storage/storage.py
@@ -85,6 +85,8 @@ class StorageSecrets(Enum):
     # s3-specific
     AWS_ACCESS_KEY_ID = "aws_access_key_id"
     AWS_SECRET_ACCESS_KEY = "aws_secret_access_key"
+    REGION_NAME = "region_name"
+    AWS_ASSUME_ROLE = "assume_role_arn"
 
 
 class StorageSecretsLocal(BaseModel):
@@ -94,10 +96,10 @@ class StorageSecretsLocal(BaseModel):
 
 
 class StorageSecretsS3(BaseModel):
-    """The secrets required to connect to an S3 bucket."""
-
-    aws_access_key_id: str
-    aws_secret_access_key: str
+    aws_access_key_id: Optional[str] = None
+    aws_secret_access_key: Optional[str] = None
+    region_name: Optional[str] = None
+    assume_role_arn: Optional[str] = None
     model_config = ConfigDict(extra="forbid")
 
 
diff --git a/src/fides/api/service/storage/storage_authenticator_service.py b/src/fides/api/service/storage/storage_authenticator_service.py
index 9da63c21560..592e463c1fb 100644
--- a/src/fides/api/service/storage/storage_authenticator_service.py
+++ b/src/fides/api/service/storage/storage_authenticator_service.py
@@ -1,4 +1,4 @@
-from typing import Any, Dict, Union
+from typing import Any
 
 from botocore.exceptions import ClientError
 from google.auth.exceptions import GoogleAuthError
@@ -6,6 +6,7 @@
 from google.oauth2 import service_account
 from loguru import logger
 
+from fides.api.models.storage import StorageConfig
 from fides.api.schemas.storage.storage import (
     SUPPORTED_STORAGE_SECRETS,
     AWSAuthMethod,
@@ -17,31 +18,25 @@
 
 def secrets_are_valid(
     secrets: SUPPORTED_STORAGE_SECRETS,
-    storage_type: Union[StorageType, str],
+    storage_config: StorageConfig,
 ) -> bool:
     """Authenticates upload destination with appropriate upload method"""
-    if not isinstance(storage_type, StorageType):
-        # try to coerce into an enum
-        try:
-            storage_type = StorageType[storage_type]
-        except KeyError:
-            raise ValueError(
-                "storage_type argument must be a valid StorageType enum member."
-            )
-    uploader: Any = _get_authenticator_from_config(storage_type)
-    return uploader(secrets)
+    uploader: Any = _get_authenticator_from_config(storage_config.type)  # type: ignore
+    return uploader(storage_config, secrets)
 
 
-def _s3_authenticator(secrets: Dict[StorageSecrets, Any]) -> bool:
+def _s3_authenticator(
+    config: StorageConfig, secrets: dict[StorageSecrets, Any]
+) -> bool:
     """Authenticates secrets for s3, returns true if secrets are valid"""
     try:
-        get_aws_session(AWSAuthMethod.SECRET_KEYS.value, secrets.model_dump(mode="json"))  # type: ignore
+        get_aws_session(config.details["auth_method"] or AWSAuthMethod.SECRET_KEYS.value, secrets.model_dump(mode="json"))  # type: ignore
         return True
     except ClientError:
         return False
 
 
-def _gcs_authenticator(secrets: Dict) -> bool:
+def _gcs_authenticator(storage_config: StorageConfig, secrets: dict) -> bool:
     """Autenticates secrets for Google Cloud Storage, returns true if secrets are valid"""
     try:
         credentials = service_account.Credentials.from_service_account_info(
diff --git a/src/fides/api/tasks/storage.py b/src/fides/api/tasks/storage.py
index 6eb86a86c4f..bbc2c785585 100644
--- a/src/fides/api/tasks/storage.py
+++ b/src/fides/api/tasks/storage.py
@@ -131,7 +131,11 @@ def upload_to_s3(  # pylint: disable=R0913
         raise ValueError("Privacy request must be provided")
 
     try:
-        s3_client = get_s3_client(auth_method, storage_secrets)
+        s3_client = get_s3_client(
+            auth_method,
+            storage_secrets,
+            assume_role_arn=CONFIG.credentials["storage"].get("aws_s3_assume_role_arn"),
+        )
 
         # handles file chunking
         try:
diff --git a/src/fides/api/util/aws_util.py b/src/fides/api/util/aws_util.py
index bab3f9f51c8..bf10657fd0e 100644
--- a/src/fides/api/util/aws_util.py
+++ b/src/fides/api/util/aws_util.py
@@ -22,6 +22,10 @@ def get_aws_session(
     if storage_secrets is None:
         # set to an empty dict to allow for more dynamic code downstream
         storage_secrets = {}
+
+    stored_region_name = storage_secrets.get("region_name")  # type: ignore
+    stored_assume_role_arn: Optional[str] = storage_secrets.get("assume_role_arn")  # type: ignore
+
     if auth_method == AWSAuthMethod.SECRET_KEYS.value:
         if not storage_secrets:
             err_msg = "Storage secrets not found for S3 storage."
@@ -33,12 +37,10 @@ def get_aws_session(
             aws_secret_access_key=storage_secrets[
                 StorageSecrets.AWS_SECRET_ACCESS_KEY.value  # type: ignore
             ],
-            region_name=storage_secrets.get("region_name"),  # type: ignore
+            region_name=stored_region_name,
         )
     elif auth_method == AWSAuthMethod.AUTOMATIC.value:
-        session = Session(
-            region_name=storage_secrets.get("region_name"),  # type: ignore
-        )
+        session = Session(region_name=stored_region_name)
         logger.info("Successfully created automatic session")
     else:
         logger.error("AWS auth method not supported: {}", auth_method)
@@ -48,20 +50,13 @@ def get_aws_session(
     sts_client = session.client("sts")
     sts_client.get_caller_identity()
 
-    if assume_role_arn:
+    target_assume_role_arn = assume_role_arn or stored_assume_role_arn
+    if target_assume_role_arn:
         try:
-            response = sts_client.assume_role(
-                RoleArn=assume_role_arn, RoleSessionName="FidesAssumeRoleSession"
-            )
-            temp_credentials = response["Credentials"]
-            logger.info(
-                f"Assumed role {assume_role_arn} and got temporary credentials."
-            )
-            return Session(
-                aws_access_key_id=temp_credentials["AccessKeyId"],
-                aws_secret_access_key=temp_credentials["SecretAccessKey"],
-                aws_session_token=temp_credentials["SessionToken"],
-                region_name=storage_secrets.get("region_name"),  # type: ignore
+            return get_assumed_role_session(
+                target_assume_role_arn,
+                sts_client,
+                stored_region_name,
             )
         except ClientError as error:
             logger.exception(
@@ -72,6 +67,22 @@ def get_aws_session(
         return session
 
 
+def get_assumed_role_session(
+    assume_role_arn: str, sts_client: Any, region_name: Optional[str] = None
+) -> Session:
+    response = sts_client.assume_role(
+        RoleArn=assume_role_arn, RoleSessionName="FidesAssumeRoleSession"
+    )
+    temp_credentials = response["Credentials"]
+    logger.info(f"Assumed role {assume_role_arn} and got temporary credentials.")
+    return Session(
+        aws_access_key_id=temp_credentials["AccessKeyId"],
+        aws_secret_access_key=temp_credentials["SecretAccessKey"],
+        aws_session_token=temp_credentials["SessionToken"],
+        region_name=region_name,
+    )
+
+
 def get_s3_client(
     auth_method: str,
     storage_secrets: Optional[Dict[StorageSecrets, Any]],
diff --git a/tests/ops/api/v1/endpoints/test_storage_endpoints.py b/tests/ops/api/v1/endpoints/test_storage_endpoints.py
index d5c8f6afef7..8613dc5d65c 100644
--- a/tests/ops/api/v1/endpoints/test_storage_endpoints.py
+++ b/tests/ops/api/v1/endpoints/test_storage_endpoints.py
@@ -365,8 +365,6 @@ def test_update_with_invalid_secrets_key(
         )
         assert response.status_code == 400
         assert response.json()["detail"] == [
-            "Field required ('aws_access_key_id',)",
-            "Field required ('aws_secret_access_key',)",
             "Extra inputs are not permitted ('bad_key',)",
         ]
 
@@ -459,8 +457,10 @@ def test_put_s3_config_secrets_and_verify(
         get_aws_session_mock.assert_called_once_with(
             AWSAuthMethod.SECRET_KEYS.value,
             {
+                "assume_role_arn": None,
                 "aws_access_key_id": payload["aws_access_key_id"],
                 "aws_secret_access_key": payload["aws_secret_access_key"],
+                "region_name": None,
             },
         )
 
@@ -1110,8 +1110,6 @@ def test_update_default_with_invalid_secrets_key(
         )
 
         assert response.json()["detail"] == [
-            "Field required ('aws_access_key_id',)",
-            "Field required ('aws_secret_access_key',)",
             "Extra inputs are not permitted ('bad_key',)",
         ]
         assert response.status_code == 400
@@ -1221,10 +1219,12 @@ def test_put_default_s3_config_secrets_and_verify(
         response = api_client.put(url, headers=auth_header, json=payload)
         assert 200 == response.status_code
         get_aws_session_mock.assert_called_once_with(
-            AWSAuthMethod.SECRET_KEYS.value,
+            AWSAuthMethod.AUTOMATIC.value,
             {
                 "aws_access_key_id": payload["aws_access_key_id"],
                 "aws_secret_access_key": payload["aws_secret_access_key"],
+                "region_name": None,
+                "assume_role_arn": None,
             },
         )
 
diff --git a/tests/ops/service/storage/test_storage_authenticator_service.py b/tests/ops/service/storage/test_storage_authenticator_service.py
new file mode 100644
index 00000000000..033efd0e487
--- /dev/null
+++ b/tests/ops/service/storage/test_storage_authenticator_service.py
@@ -0,0 +1,393 @@
+import json
+from typing import Dict, Generator
+from unittest import mock
+from unittest.mock import MagicMock, create_autospec, patch
+
+import boto3
+import pytest
+from google.auth.exceptions import GoogleAuthError
+from google.oauth2.service_account import Credentials
+from moto import mock_aws
+from sqlalchemy.orm import Session
+
+from fides.api.models.storage import StorageConfig
+from fides.api.schemas.storage.storage import (
+    AWSAuthMethod,
+    GCSAuthMethod,
+    StorageDetails,
+    StorageSecrets,
+    StorageSecretsS3,
+    StorageType,
+)
+from fides.api.service.storage.storage_authenticator_service import (
+    _s3_authenticator,
+    secrets_are_valid,
+)
+
+
+@pytest.fixture
+def mock_aws_account_id() -> Generator[str, None, None]:
+    """Fixture that returns a mock AWS account ID."""
+    with mock_aws():
+        sts = boto3.client("sts")
+        identity = sts.get_caller_identity()
+        yield identity["Account"]
+
+
+@pytest.fixture
+def mock_iam_role(mock_aws_account_id: str) -> Generator[str, None, None]:
+    """Fixture that creates a mock IAM role and returns its ARN."""
+    with mock_aws():
+        iam = boto3.client("iam")
+        role_name = "S3FullAccessRole"
+
+        # Create the role that will be assumed
+        assume_role_policy = {
+            "Version": "2012-10-17",
+            "Statement": [
+                {
+                    "Effect": "Allow",
+                    "Principal": {"AWS": f"arn:aws:iam::{mock_aws_account_id}:root"},
+                    "Action": "sts:AssumeRole",
+                }
+            ],
+        }
+
+        # Create the role
+        iam.create_role(
+            RoleName=role_name, AssumeRolePolicyDocument=json.dumps(assume_role_policy)
+        )
+
+        # Create and attach a policy that allows assuming this role
+        policy_document = {
+            "Version": "2012-10-17",
+            "Statement": [
+                {
+                    "Effect": "Allow",
+                    "Action": "sts:AssumeRole",
+                    "Resource": f"arn:aws:iam::{mock_aws_account_id}:role/{role_name}",
+                }
+            ],
+        }
+
+        policy = iam.create_policy(
+            PolicyName="AssumeRolePolicy", PolicyDocument=json.dumps(policy_document)
+        )
+
+        # Attach the policy to the role
+        iam.attach_role_policy(RoleName=role_name, PolicyArn=policy["Policy"]["Arn"])
+
+        role_arn = f"arn:aws:iam::{mock_aws_account_id}:role/{role_name}"
+        yield role_arn
+
+
+@pytest.fixture
+def mock_role_credentials(mock_iam_role: str) -> Generator[Dict[str, str], None, None]:
+    """Fixture that assumes a role and returns the temporary credentials."""
+    with mock_aws():
+        sts = boto3.client("sts")
+        creds = sts.assume_role(RoleArn=mock_iam_role, RoleSessionName="test-session")[
+            "Credentials"
+        ]
+
+        yield {
+            "access_key_id": creds["AccessKeyId"],
+            "secret_access_key": creds["SecretAccessKey"],
+        }
+
+
+@pytest.fixture
+def mock_aws_role(
+    mock_iam_role: str, mock_role_credentials: Dict[str, str]
+) -> Generator[Dict[str, str], None, None]:
+    """Combined fixture that provides all role-related information."""
+    yield {
+        "role_arn": mock_iam_role,
+        "access_key_id": mock_role_credentials["access_key_id"],
+        "secret_access_key": mock_role_credentials["secret_access_key"],
+    }
+
+
+@pytest.fixture
+def s3_storage_config(db: Session) -> Generator[StorageConfig, None, None]:
+    """Fixture that creates and yields an S3 storage config."""
+    storage_config = StorageConfig.create(
+        db=db,
+        data={
+            "name": "Test S3 Storage",
+            "type": StorageType.s3,
+            "details": {
+                StorageDetails.AUTH_METHOD.value: AWSAuthMethod.SECRET_KEYS.value,
+                StorageDetails.NAMING.value: "request_id",
+                StorageDetails.BUCKET.value: "test_bucket",
+            },
+            "key": "test_s3_storage",
+            "format": "json",
+        },
+    )
+    yield storage_config
+    storage_config.delete(db)
+
+
+@mock.patch(
+    "fides.api.service.storage.storage_authenticator_service.get_aws_session",
+    autospec=True,
+)
+class TestS3Authenticator:
+    """Tests for S3 storage authenticator"""
+
+    @pytest.mark.parametrize(
+        "has_role",
+        [True, False],
+    )
+    def test_storage_config_can_assume_role(
+        self,
+        mock_get_aws_session: MagicMock,
+        db: Session,
+        mock_aws_role: Dict[str, str],
+        s3_storage_config: StorageConfig,
+        has_role: bool,
+    ) -> None:
+        """
+        Test that a storage config can handle both cases:
+        1. With assume_role_arn - verifies role assumption occurs
+        2. Without assume_role_arn - verifies direct access works
+        """
+        # Set the secrets with the role ARN and credentials
+        secrets = {
+            StorageSecrets.AWS_ACCESS_KEY_ID.value: mock_aws_role["access_key_id"],
+            StorageSecrets.AWS_SECRET_ACCESS_KEY.value: mock_aws_role[
+                "secret_access_key"
+            ],
+        }
+        if has_role:
+            secrets["assume_role_arn"] = mock_aws_role["role_arn"]
+            s3_storage_config.details[StorageDetails.AUTH_METHOD.value] = (
+                AWSAuthMethod.AUTOMATIC.value
+            )
+
+        s3_storage_config.set_secrets(
+            db=db,
+            storage_secrets=secrets,
+        )
+
+        # Verify that the secrets are valid
+        assert secrets_are_valid(
+            secrets=StorageSecretsS3(
+                aws_access_key_id=mock_aws_role["access_key_id"],
+                aws_secret_access_key=mock_aws_role["secret_access_key"],
+                assume_role_arn=mock_aws_role["role_arn"] if has_role else None,
+            ),
+            storage_config=s3_storage_config,
+        )
+
+        # Verify the AWS session was created with the correct parameters
+        if has_role:
+            # When role assumption is enabled, verify the session was created with the role ARN
+            mock_get_aws_session.assert_called_once_with(
+                AWSAuthMethod.AUTOMATIC.value,
+                {
+                    "aws_access_key_id": mock_aws_role["access_key_id"],
+                    "aws_secret_access_key": mock_aws_role["secret_access_key"],
+                    "assume_role_arn": mock_aws_role["role_arn"],
+                    "region_name": None,
+                },
+            )
+        else:
+            # When role assumption is disabled, verify direct access with secret keys
+            mock_get_aws_session.assert_called_once_with(
+                AWSAuthMethod.SECRET_KEYS.value,
+                {
+                    "aws_access_key_id": mock_aws_role["access_key_id"],
+                    "aws_secret_access_key": mock_aws_role["secret_access_key"],
+                    "assume_role_arn": None,
+                    "region_name": None,
+                },
+            )
+
+    @pytest.mark.parametrize(
+        "has_role",
+        [True, False],
+    )
+    def test_s3_authenticator_calls(
+        self,
+        mock_get_aws_session: MagicMock,
+        mock_aws_role: Dict[str, str],
+        s3_storage_config: StorageConfig,
+        has_role: bool,
+    ) -> None:
+        """
+        Test that _s3_authenticator is called with the correct parameters for both cases:
+        1. With assume_role_arn
+        2. Without assume_role_arn
+        """
+        # Create secrets object
+        secrets = StorageSecretsS3(
+            aws_access_key_id=mock_aws_role["access_key_id"],
+            aws_secret_access_key=mock_aws_role["secret_access_key"],
+            assume_role_arn=mock_aws_role["role_arn"] if has_role else None,
+        )
+
+        # Call _s3_authenticator directly
+        _s3_authenticator(s3_storage_config, secrets)
+
+        # Verify get_aws_session was called with correct parameters
+        expected_secrets = {
+            "aws_access_key_id": mock_aws_role["access_key_id"],
+            "aws_secret_access_key": mock_aws_role["secret_access_key"],
+            "region_name": None,
+        }
+        if has_role:
+            expected_secrets["assume_role_arn"] = mock_aws_role["role_arn"]
+            s3_storage_config.details[StorageDetails.AUTH_METHOD.value] = (
+                AWSAuthMethod.AUTOMATIC.value
+            )
+        else:
+            expected_secrets["assume_role_arn"] = None
+
+        mock_get_aws_session.assert_called_once_with(
+            AWSAuthMethod.SECRET_KEYS.value,
+            expected_secrets,
+        )
+
+
+@mock.patch(
+    "fides.api.service.storage.storage_authenticator_service.Request", autospec=True
+)
+@mock.patch(
+    "fides.api.service.storage.storage_authenticator_service.service_account.Credentials",
+    autospec=True,
+)
+class TestGCSAuthenticator:
+    """Tests for GCS storage authenticator"""
+
+    @pytest.fixture
+    def gcs_storage_config(self, db: Session) -> Generator[StorageConfig, None, None]:
+        """Fixture that creates and yields a GCS storage config."""
+        storage_config = StorageConfig.create(
+            db=db,
+            data={
+                "name": "Test GCS Storage",
+                "type": StorageType.gcs,
+                "details": {
+                    StorageDetails.NAMING.value: "request_id",
+                    StorageDetails.AUTH_METHOD.value: GCSAuthMethod.SERVICE_ACCOUNT_KEYS.value,
+                    StorageDetails.BUCKET.value: "prj-sandbox-55855-test-bucket",
+                    StorageDetails.MAX_RETRIES.value: 0,
+                },
+                "format": "json",
+            },
+        )
+        yield storage_config
+        storage_config.delete(db)
+
+    @pytest.fixture
+    def mock_creds_instance(self):
+        """Create a mock credentials instance"""
+        mock_instance = create_autospec(Credentials)
+        return mock_instance
+
+    def test_secrets_are_valid_for_gcs(
+        self, mock_credentials, mock_request, mock_creds_instance, gcs_storage_config
+    ):
+        """Test GCS credentials validation with valid credentials"""
+        mock_credentials.from_service_account_info.return_value = mock_creds_instance
+
+        test_secrets = {
+            "type": "service_account",
+            "project_id": "test-project-123",
+            "private_key_id": "test-key-id-456",
+            "private_key": (
+                "-----BEGIN PRIVATE KEY-----\nMIItest\n-----END PRIVATE KEY-----\n"
+            ),
+            "client_email": "test-service@test-project-123.iam.gserviceaccount.com",
+            "client_id": "123456789",
+            "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+            "token_uri": "https://oauth2.googleapis.com/token",
+            "auth_provider_x509_cert_url": (
+                "https://www.googleapis.com/oauth2/v1/certs"
+            ),
+            "client_x509_cert_url": (
+                "https://www.googleapis.com/robot/v1/metadata/x509/"
+                "test-service%40test-project-123.iam.gserviceaccount.com"
+            ),
+            "universe_domain": "googleapis.com",
+        }
+
+        assert secrets_are_valid(test_secrets, gcs_storage_config)
+        mock_credentials.from_service_account_info.assert_called_once_with(
+            test_secrets,
+            scopes=["https://www.googleapis.com/auth/devstorage.read_only"],
+        )
+        mock_creds_instance.refresh.assert_called_once_with(mock_request())
+
+    @mock.patch(
+        "fides.api.service.storage.storage_authenticator_service.logger",
+        autospec=True,
+    )
+    def test_secrets_are_invalid_for_gcs(
+        self,
+        mock_logger,
+        mock_credentials,
+        mock_request,
+        mock_creds_instance,
+        gcs_storage_config,
+    ):
+        """Test GCS credentials validation with invalid credentials"""
+        mock_credentials.from_service_account_info.return_value = mock_creds_instance
+        error = GoogleAuthError("Invalid credentials")
+        mock_creds_instance.refresh.side_effect = error
+
+        test_secrets = {
+            "type": "service_account",
+            "project_id": "test-project-123",
+            "private_key_id": "invalid-key",
+            "private_key": "invalid-key-data",
+            "client_email": "test@test.com",
+        }
+
+        assert not secrets_are_valid(test_secrets, gcs_storage_config)
+        mock_credentials.from_service_account_info.assert_called_once_with(
+            test_secrets,
+            scopes=["https://www.googleapis.com/auth/devstorage.read_only"],
+        )
+        mock_creds_instance.refresh.assert_called_once_with(mock_request())
+        mock_logger.warning.assert_called_once_with(
+            "Google authentication error trying to authenticate GCS secrets: {}",
+            error,
+        )
+
+    @mock.patch(
+        "fides.api.service.storage.storage_authenticator_service.logger",
+        autospec=True,
+    )
+    def test_secrets_validation_unexpected_error(
+        self,
+        mock_logger,
+        mock_credentials,
+        mock_request,
+        mock_creds_instance,
+        gcs_storage_config,
+    ):
+        """Test GCS credentials validation with unexpected error"""
+        mock_credentials.from_service_account_info.return_value = mock_creds_instance
+        error = Exception("Unexpected error")
+        mock_creds_instance.refresh.side_effect = error
+
+        test_secrets = {
+            "type": "service_account",
+            "project_id": "test-project-123",
+            "private_key_id": "test-key",
+            "private_key": "test-key-data",
+            "client_email": "test@test.com",
+        }
+
+        assert not secrets_are_valid(test_secrets, gcs_storage_config)
+        mock_credentials.from_service_account_info.assert_called_once_with(
+            test_secrets,
+            scopes=["https://www.googleapis.com/auth/devstorage.read_only"],
+        )
+        mock_creds_instance.refresh.assert_called_once_with(mock_request())
+        mock_logger.warning.assert_called_once_with(
+            "Unexpected error authenticating GCS secrets: {}", error
+        )
diff --git a/tests/ops/util/test_storage_authenticator.py b/tests/ops/util/test_storage_authenticator.py
index aa751dd04a5..c5ff763bcee 100644
--- a/tests/ops/util/test_storage_authenticator.py
+++ b/tests/ops/util/test_storage_authenticator.py
@@ -1,11 +1,6 @@
-from unittest import mock
-from unittest.mock import create_autospec
-
 import boto3
 import pytest
 from botocore.exceptions import NoCredentialsError
-from google.auth.exceptions import GoogleAuthError
-from google.oauth2.service_account import Credentials
 from moto import mock_aws
 
 from fides.api.common_exceptions import StorageUploadError
@@ -13,10 +8,7 @@
     AWSAuthMethod,
     StorageDetails,
     StorageSecrets,
-    StorageSecretsS3,
-    StorageType,
 )
-from fides.api.service.storage.storage_authenticator_service import secrets_are_valid
 from fides.api.util.aws_util import get_aws_session, get_s3_client
 
 
@@ -48,44 +40,6 @@ def tests_unsupported_storage_secret_type_error(self):
                 "bad", {StorageSecrets.AWS_ACCESS_KEY_ID: "aws_access_key_id"}  # type: ignore
             )
 
-    def tests_automatic_auth_method(self, loguru_caplog):
-        # credentials error raised by AWS since runtime doesn't have env credentials set up -
-        # but ensure we don't raise an exception from our own code in parsing.
-        with pytest.raises(NoCredentialsError):
-            get_aws_session(
-                AWSAuthMethod.AUTOMATIC.value,  # type: ignore
-                None,
-            )
-
-    def test_secrets_are_valid_bad_storage_type(self):
-        with pytest.raises(ValueError):
-            secrets_are_valid(
-                StorageSecretsS3(
-                    aws_access_key_id="aws_access_key_id",
-                    aws_secret_access_key="aws_secret_access_key",
-                ),
-                "fake_storage_type",
-            )
-
-    def test_secrets_are_valid(self):
-        # just test we don't error out
-        assert not secrets_are_valid(
-            StorageSecretsS3(
-                aws_access_key_id="aws_access_key_id",
-                aws_secret_access_key="aws_secret_access_key",
-            ),
-            "s3",
-        )  # we expect this to fail because they're not real secret values
-
-        # just test we don't error out
-        assert not secrets_are_valid(
-            StorageSecretsS3(
-                aws_access_key_id="aws_access_key_id",
-                aws_secret_access_key="aws_secret_access_key",
-            ),
-            StorageType.s3,
-        )  # we expect this to fail because they're not real secret values
-
 
 @mock_aws
 def test_get_s3_client(storage_secrets):
@@ -102,115 +56,3 @@ def test_get_s3_client_with_assume_role(storage_secrets):
     )
     assert s3_client is not None
     assert s3_client.list_buckets() is not None
-
-
-@mock.patch(
-    "fides.api.service.storage.storage_authenticator_service.Request", autospec=True
-)
-@mock.patch(
-    "fides.api.service.storage.storage_authenticator_service.service_account.Credentials",
-    autospec=True,
-)
-class TestGCSAuthenticator:
-    """Tests for GCS storage authenticator"""
-
-    @pytest.fixture
-    def mock_creds_instance(self):
-        """Create a mock credentials instance"""
-        mock_instance = create_autospec(Credentials)
-        return mock_instance
-
-    def test_secrets_are_valid_for_gcs(
-        self, mock_credentials, mock_request, mock_creds_instance
-    ):
-        """Test GCS credentials validation with valid credentials"""
-        mock_credentials.from_service_account_info.return_value = mock_creds_instance
-
-        test_secrets = {
-            "type": "service_account",
-            "project_id": "test-project-123",
-            "private_key_id": "test-key-id-456",
-            "private_key": (
-                "-----BEGIN PRIVATE KEY-----\nMIItest\n-----END PRIVATE KEY-----\n"
-            ),
-            "client_email": "test-service@test-project-123.iam.gserviceaccount.com",
-            "client_id": "123456789",
-            "auth_uri": "https://accounts.google.com/o/oauth2/auth",
-            "token_uri": "https://oauth2.googleapis.com/token",
-            "auth_provider_x509_cert_url": (
-                "https://www.googleapis.com/oauth2/v1/certs"
-            ),
-            "client_x509_cert_url": (
-                "https://www.googleapis.com/robot/v1/metadata/x509/"
-                "test-service%40test-project-123.iam.gserviceaccount.com"
-            ),
-            "universe_domain": "googleapis.com",
-        }
-
-        assert secrets_are_valid(test_secrets, StorageType.gcs)
-        mock_credentials.from_service_account_info.assert_called_once_with(
-            test_secrets,
-            scopes=["https://www.googleapis.com/auth/devstorage.read_only"],
-        )
-        mock_creds_instance.refresh.assert_called_once_with(mock_request())
-
-    @mock.patch(
-        "fides.api.service.storage.storage_authenticator_service.logger",
-        autospec=True,
-    )
-    def test_secrets_are_invalid_for_gcs(
-        self, mock_logger, mock_credentials, mock_request, mock_creds_instance
-    ):
-        """Test GCS credentials validation with invalid credentials"""
-        mock_credentials.from_service_account_info.return_value = mock_creds_instance
-        error = GoogleAuthError("Invalid credentials")
-        mock_creds_instance.refresh.side_effect = error
-
-        test_secrets = {
-            "type": "service_account",
-            "project_id": "test-project-123",
-            "private_key_id": "invalid-key",
-            "private_key": "invalid-key-data",
-            "client_email": "test@test.com",
-        }
-
-        assert not secrets_are_valid(test_secrets, StorageType.gcs)
-        mock_credentials.from_service_account_info.assert_called_once_with(
-            test_secrets,
-            scopes=["https://www.googleapis.com/auth/devstorage.read_only"],
-        )
-        mock_creds_instance.refresh.assert_called_once_with(mock_request())
-        mock_logger.warning.assert_called_once_with(
-            "Google authentication error trying to authenticate GCS secrets: {}",
-            error,
-        )
-
-    @mock.patch(
-        "fides.api.service.storage.storage_authenticator_service.logger",
-        autospec=True,
-    )
-    def test_secrets_validation_unexpected_error(
-        self, mock_logger, mock_credentials, mock_request, mock_creds_instance
-    ):
-        """Test GCS credentials validation with unexpected error"""
-        mock_credentials.from_service_account_info.return_value = mock_creds_instance
-        error = Exception("Unexpected error")
-        mock_creds_instance.refresh.side_effect = error
-
-        test_secrets = {
-            "type": "service_account",
-            "project_id": "test-project-123",
-            "private_key_id": "test-key",
-            "private_key": "test-key-data",
-            "client_email": "test@test.com",
-        }
-
-        assert not secrets_are_valid(test_secrets, StorageType.gcs)
-        mock_credentials.from_service_account_info.assert_called_once_with(
-            test_secrets,
-            scopes=["https://www.googleapis.com/auth/devstorage.read_only"],
-        )
-        mock_creds_instance.refresh.assert_called_once_with(mock_request())
-        mock_logger.warning.assert_called_once_with(
-            "Unexpected error authenticating GCS secrets: {}", error
-        )


Reviews:
user: login: tvandort
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T14:11:24Z
user: login: JadeCara
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T16:56:32Z
user: login: JadeCara
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T16:57:29Z
user: login: tvandort
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T18:47:12Z
user: login: JadeCara
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T19:56:34Z
user: login: tvandort
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T21:27:12Z
user: login: tvandort
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T21:29:37Z
user: login: JadeCara
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T21:36:44Z
user: login: JadeCara
body: :ship:
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T15:30:49Z

Review comments:
diff_hunk: @@ -80,10 +82,10 @@ class StorageSecretsLocal(BaseModel):
 
 
 class StorageSecretsS3(BaseModel):
-    """The secrets required to connect to an S3 bucket."""
-
-    aws_access_key_id: str
-    aws_secret_access_key: str
+    aws_access_key_id: Optional[str] = None
+    aws_secret_access_key: Optional[str] = None
+    region_name: Optional[str] = None
+    assume_role_arn: Optional[str] = None
path: src/fides/api/schemas/storage/storage.py
user: login: tvandort
body: Better way to union this type?
created_at: 2025-04-10T14:11:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -80,10 +82,10 @@ class StorageSecretsLocal(BaseModel):
 
 
 class StorageSecretsS3(BaseModel):
-    """The secrets required to connect to an S3 bucket."""
-
-    aws_access_key_id: str
-    aws_secret_access_key: str
+    aws_access_key_id: Optional[str] = None
+    aws_secret_access_key: Optional[str] = None
+    region_name: Optional[str] = None
+    assume_role_arn: Optional[str] = None
path: src/fides/api/schemas/storage/storage.py
user: login: JadeCara
body: We might want to add some kind of validator here that says we need one or the other.
Something like this: 
```suggestion
    aws_access_key_id: Optional[str] = None
    aws_secret_access_key: Optional[str] = None
    region_name: Optional[str] = None
    assume_role_arn: Optional[str] = None
    
    @model_validator(mode='after')
    def validate_aws_credentials(self) -> 'StorageSecretsS3':
        """
        Validate that either:
        1. Both AWS access key and secret key are provided, or
        2. Both region name and assume role ARN are provided
        """
        has_aws_secrets = all(
            [
                self.aws_access_key_id,
                self.aws_secret_access_key
            ]
        )
        has_assume_role = all(
            [
                self.region_name,
                self.assume_role_arn
            ]
        )

        if not (has_aws_secrets or has_assume_role):
            raise ValueError(
                "Must provide either both AWS credentials "
                "(aws_access_key_id and aws_secret_access_key) "
                "or both role assumption details "
                "(region_name and assume_role_arn)"
            )

        # I think we would want totally different storage configs for these
        # Meaning If they have the option to use either it would be one that is role based
        # and one that is secrets based.
        if has_aws_secrets and has_assume_role:
            raise ValueError(
                "Cannot provide both AWS credentials and role assumption details"
            )

        return self
```

created_at: 2025-04-10T16:56:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -80,10 +82,10 @@ class StorageSecretsLocal(BaseModel):
 
 
 class StorageSecretsS3(BaseModel):
-    """The secrets required to connect to an S3 bucket."""
-
-    aws_access_key_id: str
-    aws_secret_access_key: str
+    aws_access_key_id: Optional[str] = None
+    aws_secret_access_key: Optional[str] = None
+    region_name: Optional[str] = None
+    assume_role_arn: Optional[str] = None
path: src/fides/api/schemas/storage/storage.py
user: login: JadeCara
body: Another option might be to have a different class that is just the role_arn stuff and say the secrets would be either/or, but I think this works for now.
created_at: 2025-04-10T16:57:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -80,10 +82,10 @@ class StorageSecretsLocal(BaseModel):
 
 
 class StorageSecretsS3(BaseModel):
-    """The secrets required to connect to an S3 bucket."""
-
-    aws_access_key_id: str
-    aws_secret_access_key: str
+    aws_access_key_id: Optional[str] = None
+    aws_secret_access_key: Optional[str] = None
+    region_name: Optional[str] = None
+    assume_role_arn: Optional[str] = None
path: src/fides/api/schemas/storage/storage.py
user: login: tvandort
body: @JadeCara I don't _think_ assume role arn and access key / secret key are mutually exclusive. 

I think we'd want: 
- Any time access key is defined, so is secret key (and vice versa)
- The rest of the props are optional

I think I can adapt the above to that format.
created_at: 2025-04-10T18:47:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -80,10 +82,10 @@ class StorageSecretsLocal(BaseModel):
 
 
 class StorageSecretsS3(BaseModel):
-    """The secrets required to connect to an S3 bucket."""
-
-    aws_access_key_id: str
-    aws_secret_access_key: str
+    aws_access_key_id: Optional[str] = None
+    aws_secret_access_key: Optional[str] = None
+    region_name: Optional[str] = None
+    assume_role_arn: Optional[str] = None
path: src/fides/api/schemas/storage/storage.py
user: login: JadeCara
body: The one question I have is - based on the storage config, how would it know which auth option to use? Can we assume users would for sure want one auth option over the other?
created_at: 2025-04-10T19:56:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -80,10 +82,10 @@ class StorageSecretsLocal(BaseModel):
 
 
 class StorageSecretsS3(BaseModel):
-    """The secrets required to connect to an S3 bucket."""
-
-    aws_access_key_id: str
-    aws_secret_access_key: str
+    aws_access_key_id: Optional[str] = None
+    aws_secret_access_key: Optional[str] = None
+    region_name: Optional[str] = None
+    assume_role_arn: Optional[str] = None
path: src/fides/api/schemas/storage/storage.py
user: login: tvandort
body: When you post to: 
`/api/v1/storage/default`

The body looks like:
```json
{
    "type": "s3",
    "details": {
        "bucket": "something",
        "max_retries": 0,
        "naming": "request_id",
        "auth_method": "automatic"
    },
    "format": "html"
}
```

So the user chooses at that point. They then get yelled at if they haven't provided the matching secrets when you try to upload using a mode that you do not have the right secrets for.

I actually this that there's a strong argument to say that `assume_role_arn` should be posted to the above endpoint instead of `/api/v1/storage/default/:storage_type/secret`. Same with `region` since neither are secrets. But that being said there seems to be history for reading these values from secrets so I kept it working that way. Plus `get_aws_session` accepts the secrets but not the config and I didn't want to have to refactor _everywhere_ `get_aws_session` was used because it would make this a larger / riskier change (I think.)
created_at: 2025-04-10T21:27:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -80,10 +82,10 @@ class StorageSecretsLocal(BaseModel):
 
 
 class StorageSecretsS3(BaseModel):
-    """The secrets required to connect to an S3 bucket."""
-
-    aws_access_key_id: str
-    aws_secret_access_key: str
+    aws_access_key_id: Optional[str] = None
+    aws_secret_access_key: Optional[str] = None
+    region_name: Optional[str] = None
+    assume_role_arn: Optional[str] = None
path: src/fides/api/schemas/storage/storage.py
user: login: tvandort
body: Oh! User would also get barked at here: https://github.com/ethyca/fides/blob/f6134146a57fafcb9033528edf62c7d5786b9297/src/fides/api/schemas/connection_configuration/connection_secrets_base_aws.py#L39-L51

So I think I'm fine to proceed with the change in this file.
created_at: 2025-04-10T21:29:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -80,10 +82,10 @@ class StorageSecretsLocal(BaseModel):
 
 
 class StorageSecretsS3(BaseModel):
-    """The secrets required to connect to an S3 bucket."""
-
-    aws_access_key_id: str
-    aws_secret_access_key: str
+    aws_access_key_id: Optional[str] = None
+    aws_secret_access_key: Optional[str] = None
+    region_name: Optional[str] = None
+    assume_role_arn: Optional[str] = None
path: src/fides/api/schemas/storage/storage.py
user: login: JadeCara
body: The good news is I have a ticket to totally update the storage stuff into service. So we need to make sure it works, but I can fine tune it when I get to that ticket :D
created_at: 2025-04-10T21:36:43Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-04-10T06:03:42Z
body: [vc]: #4HNv1OLh6dC+uocvMUnwUae9nNbvmD4klSsr7v3k/Ws=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJmaWRlcy1wbHVzLW5pZ2h0bHkiLCJyb290RGlyZWN0b3J5IjoiY2xpZW50cy9hZG1pbi11aSIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2V0aHljYS9maWRlcy1wbHVzLW5pZ2h0bHkvQTdyYTJvUlNCQjNaU043ZERIQVkzNHd3eU01MyIsIm5leHRDb21taXRTdGF0dXMiOiJJR05PUkVEIiwicHJldmlld1VybCI6ImZpZGVzLXBsdXMtbmlnaHRseS1naXQtbGotNjcyLWV0aHljYS52ZXJjZWwuYXBwIn0seyJuYW1lIjoiZmlkZXMtcHJpdmFjeS1jZW50ZXIiLCJyb290RGlyZWN0b3J5IjoiY2xpZW50cy9wcml2YWN5LWNlbnRlciIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJmaWRlcy1wcml2YWN5LWNlbnRlci1naXQtbGotNjcyLWV0aHljYS52ZXJjZWwuYXBwIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9ldGh5Y2EvZmlkZXMtcHJpdmFjeS1jZW50ZXIvM0pheVFNb1prVURXZmIzWEZaa0dmRW00eHVNaiIsInByZXZpZXdVcmwiOiJmaWRlcy1wcml2YWN5LWNlbnRlci1naXQtbGotNjcyLWV0aHljYS52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **fides-privacy-center** | ✅ Ready ([Inspect](https://vercel.com/ethyca/fides-privacy-center/3JayQMoZkUDWfb3XFZkGfEm4xuMj)) | [Visit Preview](https://fides-privacy-center-git-lj-672-ethyca.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/fides-privacy-center-git-lj-672-ethyca.vercel.app?via=pr-comment-feedback-link) | Apr 18, 2025 3:35pm |

<details><summary>1 Skipped Deployment</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **fides-plus-nightly** | ⬜️ Ignored ([Inspect](https://vercel.com/ethyca/fides-plus-nightly/A7ra2oRSBB3ZSN7dDHAY34wwyM53)) | [Visit Preview](https://fides-plus-nightly-git-lj-672-ethyca.vercel.app) |  | Apr 18, 2025 3:35pm |
</details>



author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-04-14T14:49:48Z
body: ## [Codecov](https://app.codecov.io/gh/ethyca/fides/pull/6027?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ethyca) Report
Attention: Patch coverage is `92.59259%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 86.87%. Comparing base [(`58fd426`)](https://app.codecov.io/gh/ethyca/fides/commit/58fd4263f3b795c9954555326445bec375a34885?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ethyca) to head [(`36da59f`)](https://app.codecov.io/gh/ethyca/fides/commit/36da59f8833349e024a9e4c5d553eeb71e18e0df?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ethyca).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/ethyca/fides/pull/6027?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ethyca) | Patch % | Lines |
|---|---|---|
| [src/fides/api/tasks/storage.py](https://app.codecov.io/gh/ethyca/fides/pull/6027?src=pr&el=tree&filepath=src%2Ffides%2Fapi%2Ftasks%2Fstorage.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ethyca#diff-c3JjL2ZpZGVzL2FwaS90YXNrcy9zdG9yYWdlLnB5) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/ethyca/fides/pull/6027?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ethyca) |
| [src/fides/api/util/aws\_util.py](https://app.codecov.io/gh/ethyca/fides/pull/6027?src=pr&el=tree&filepath=src%2Ffides%2Fapi%2Futil%2Faws_util.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ethyca#diff-c3JjL2ZpZGVzL2FwaS91dGlsL2F3c191dGlsLnB5) | 90.90% | [1 Missing :warning: ](https://app.codecov.io/gh/ethyca/fides/pull/6027?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ethyca) |

:x: Your patch status has failed because the patch coverage (92.59%) is below the target coverage (100.00%). You can increase the patch coverage or adjust the [target](https://docs.codecov.com/docs/commit-status#target) coverage.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #6027      +/-   ##
==========================================
- Coverage   86.89%   86.87%   -0.02%     
==========================================
  Files         419      419              
  Lines       25978    25983       +5     
  Branches     2828     2827       -1     
==========================================
  Hits        22574    22574              
- Misses       2785     2789       +4     
- Partials      619      620       +1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/ethyca/fides/pull/6027?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ethyca).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ethyca).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: cypress[bot]
created_at: 2025-04-18T16:44:41Z
body: 
<h3>
  <a href="https://cloud.cypress.io/projects/bauzeh?utm_source=github&utm_medium=PASSED&utm_campaign=project">fides</a>&nbsp;
  <img src="https://assets.cypress.io/github-pr-comment-icons/chevron.svg" alt="" height="14" width="14" />&nbsp;
  <a href="https://cloud.cypress.io/projects/bauzeh/runs/12835/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">Run #12835</a>
</h3>

<details>
  <summary>
    <strong>Run Properties:</strong>&nbsp;
    <code><img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="status check passed" height="10" width="10" />&nbsp;Passed&nbsp;<a href="https://cloud.cypress.io/projects/bauzeh/runs/12835/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">#12835</a></code> &nbsp;&bull;&nbsp;
    <code><img src="https://assets.cypress.io/github-pr-comment-icons/git-commit.svg" alt="git commit" width="10" height="10" /> <a href="https://github.com/ethyca/fides/commit/43b6ea9134b12fe233330f847ec7b22c2298e97a">43b6ea9134</a>: LJ-672 (#6027)</code>
  </summary><br />

  <table width="744">
    <tbody>
      <tr />
      <tr height="48">
        <td width="224">
          <strong>Project</strong>
        </td>
        <td width="520">
          <code><a href="https://cloud.cypress.io/projects/bauzeh?utm_source=github&utm_medium=PASSED&utm_campaign=project">fides</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td width="224">
          <strong>Branch Review</strong>
        </th>
        <td>
          <code><a href="https://cloud.cypress.io/projects/bauzeh/branches/main/review?utm_source=github&utm_medium=PASSED&utm_campaign=review%20changes&changeRequest=6027">main</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Run status</strong></td>
        <td>
          <code><img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="status check passed" height="10" width="10" />&nbsp;Passed&nbsp;<a href="https://cloud.cypress.io/projects/bauzeh/runs/12835/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">#12835</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Run duration</strong></td>
        <td><code>00m 51s</code></td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Commit</strong></td>
        <td>
          <code><img src="https://assets.cypress.io/github-pr-comment-icons/git-commit.svg" alt="git commit" width="10" height="10" /> <a href="https://github.com/ethyca/fides/commit/43b6ea9134b12fe233330f847ec7b22c2298e97a">43b6ea9134</a>: LJ-672 (#6027)</code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Committer</strong></td>
        <td><code>Tom Van Dort</code></td>
      </tr>
    </tbody>
    <tfoot>
      <tr />
      <tr height="48">
        <td colspan="2">
          <a href="https://cloud.cypress.io/projects/bauzeh/runs/12835/properties?utm_source=github&utm_medium=PASSED&utm_campaign=properties">View all properties for this run ↗︎</a>
        </td>
      </tr>
    </tfoot>
  </table>
  <hr />
</details>

<table width="744">
  <thead>
    <tr />
    <tr height="48">
      <th colspan="2" align="left" width="744">Test results</th>
    </tr>
  </thead>
  <tbody>
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/failed.svg" alt="Tests that failed" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/bauzeh/runs/12835/overview?reviewViewBy=FAILED&utm_source=github&utm_medium=PASSED&utm_campaign=failed%20tests">
          <strong>Failures</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/flaky-v3.svg" alt="Tests that were flaky" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/bauzeh/runs/12835/overview?reviewViewBy=FLAKY&utm_source=github&utm_medium=PASSED&utm_campaign=flaky%20tests">
          <strong>Flaky</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/pending.svg" alt="Tests that did not run due to a developer annotating a test with .skip" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/bauzeh/runs/12835/test-results?utm_source=github&utm_medium=PASSED&utm_campaign=pending%20tests&statuses=%5B%7B%22value%22%3A%22PENDING%22%2C%22label%22%3A%22PENDING%22%7D%5D">
          <strong>Pending</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/skipped-v3.svg" alt="Tests that did not run due to a failure in a mocha hook" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/bauzeh/runs/12835/test-results?utm_source=github&utm_medium=PASSED&utm_campaign=skipped%20tests&statuses=%5B%7B%22value%22%3A%22SKIPPED%22%2C%22label%22%3A%22SKIPPED%22%7D%5D">
          <strong>Skipped</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="Tests that passed" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/bauzeh/runs/12835/overview?utm_source=github&utm_medium=PASSED&utm_campaign=passed%20tests">
          <strong>Passing</strong>
        </a>
      </td>
      <td>
        <code><strong>5</strong></code>
      </td>
    </tr><tfoot>
    <tr height="48"><td colspan="2"><a href="https://cloud.cypress.io/projects/bauzeh/branches/main/review?utm_source=github&utm_medium=PASSED&utm_campaign=review%20changes&changeRequest=6027">View all changes introduced in this branch ↗︎</a></td></tr></tfoot>
  </tbody>
</table>






author_association: NONE