diff --git a/jobs/batch-permit-validator/devops/vaults.gcp.env b/jobs/batch-permit-validator/devops/vaults.gcp.env index a4330c7db..7a8add1e5 100644 --- a/jobs/batch-permit-validator/devops/vaults.gcp.env +++ b/jobs/batch-permit-validator/devops/vaults.gcp.env @@ -5,7 +5,6 @@ DATABASE_PORT="op://database/$APP_ENV/strr-db/DATABASE_PORT" DATABASE_UNIX_SOCKET="op://database/$APP_ENV/strr-db/DATABASE_UNIX_SOCKET" GCP_CS_PROJECT_ID="op://buckets/$APP_ENV/strr/GCP_CS_PROJECT_ID" GCP_CS_SA_SCOPE="op://buckets/$APP_ENV/strr/GCP_CS_SA_SCOPE" -GCP_AUTH_KEY="op://buckets/$APP_ENV/strr/GCP_AUTH_KEY" STR_DATA_API_CLIENT_ID="op://keycloak/$APP_ENV/str-data-api/STR_DATA_API_CLIENT_ID" STR_DATA_API_CLIENT_SECRET="op://keycloak/$APP_ENV/str-data-api/STR_DATA_API_CLIENT_SECRET" STR_DATA_API_TOKEN_URL="op://keycloak/$APP_ENV/str-data-api/STR_DATA_API_TOKEN_URL" diff --git a/strr-api/src/strr_api/services/gcp_storage_service.py b/strr-api/src/strr_api/services/gcp_storage_service.py index 9603dfba9..6a72d9d13 100644 --- a/strr-api/src/strr_api/services/gcp_storage_service.py +++ b/strr-api/src/strr_api/services/gcp_storage_service.py @@ -40,6 +40,7 @@ from datetime import timedelta from flask import current_app, has_app_context +from google.auth.transport.requests import Request from google.cloud import storage from google.oauth2 import service_account @@ -192,9 +193,27 @@ def get_presigned_url(cls, bucket_id, blob_name, expiration_minutes): """Gets the presigned url for a file.""" bucket = cls.get_bucket(bucket_id) blob = bucket.blob(blob_name) + signed_url_kwargs = {} + + credentials = bucket.client._credentials # pylint: disable=protected-access + if credentials and not isinstance(credentials, service_account.Credentials): + # IAM signBlob is called outside the storage client's authenticated transport, + # so make sure ADC has a current access token before generating the URL. + if not credentials.valid: + credentials.refresh(Request()) + if service_account_email := getattr(credentials, "service_account_email", None): + signed_url_kwargs = { + "service_account_email": service_account_email, + "access_token": credentials.token, + } # Generate the signed URL - url = blob.generate_signed_url(version="v4", expiration=timedelta(minutes=expiration_minutes), method="GET") + url = blob.generate_signed_url( + version="v4", + expiration=timedelta(minutes=expiration_minutes), + method="GET", + **signed_url_kwargs, + ) return url diff --git a/strr-api/tests/unit/services/test_gcp_storage_service.py b/strr-api/tests/unit/services/test_gcp_storage_service.py index c0f0dff52..4a351bed0 100644 --- a/strr-api/tests/unit/services/test_gcp_storage_service.py +++ b/strr-api/tests/unit/services/test_gcp_storage_service.py @@ -187,6 +187,7 @@ def test_file_upload_and_presigned_url_succeed(mock_get_bucket, mock_uuid): mock_blob = MagicMock() mock_blob.generate_signed_url.return_value = "https://signed-url" mock_get_bucket.return_value.blob.return_value = mock_blob + mock_get_bucket.return_value.client.configure_mock(_credentials=None) upload_key = GCPStorageService.upload_file("text/csv", b"file contents", "target-bucket") url = GCPStorageService.get_presigned_url("target-bucket", "file-key", 10) @@ -194,7 +195,8 @@ def test_file_upload_and_presigned_url_succeed(mock_get_bucket, mock_uuid): assert upload_key == "file-key" assert url == "https://signed-url" mock_uuid.assert_called_once() - mock_get_bucket.assert_any_call("target-bucket") + assert mock_get_bucket.call_count == 2 + mock_get_bucket.assert_called_with("target-bucket") mock_get_bucket.return_value.blob.assert_any_call("file-key") mock_blob.upload_from_string.assert_called_once_with(data=b"file contents", content_type="text/csv") mock_blob.generate_signed_url.assert_called_once_with( @@ -204,6 +206,62 @@ def test_file_upload_and_presigned_url_succeed(mock_get_bucket, mock_uuid): ) +@patch("strr_api.services.gcp_storage_service.Request") +@patch("strr_api.services.gcp_storage_service.GCPStorageService.get_bucket") +def test_presigned_url_refreshes_invalid_adc_credentials(mock_get_bucket, mock_request): + """get_presigned_url supports keyless runtime credentials for signed URLs.""" + mock_credentials = MagicMock() + mock_credentials.valid = False + mock_credentials.service_account_email = "sa-job@example.iam.gserviceaccount.com" + mock_credentials.token = "access-token" + + mock_blob = MagicMock() + mock_blob.generate_signed_url.return_value = "https://signed-url" + mock_get_bucket.return_value.client.configure_mock(_credentials=mock_credentials) + mock_get_bucket.return_value.blob.return_value = mock_blob + + url = GCPStorageService.get_presigned_url("target-bucket", "file-key", 10) + + assert url == "https://signed-url" + mock_get_bucket.assert_called_once_with("target-bucket") + mock_credentials.refresh.assert_called_once_with(mock_request.return_value) + mock_blob.generate_signed_url.assert_called_once_with( + version="v4", + expiration=timedelta(minutes=10), + method="GET", + service_account_email="sa-job@example.iam.gserviceaccount.com", + access_token="access-token", + ) + + +@patch("strr_api.services.gcp_storage_service.Request") +@patch("strr_api.services.gcp_storage_service.GCPStorageService.get_bucket") +def test_presigned_url_reuses_valid_adc_credentials(mock_get_bucket, mock_request): + """get_presigned_url reuses a valid ADC access token.""" + mock_credentials = MagicMock() + mock_credentials.valid = True + mock_credentials.service_account_email = "sa-job@example.iam.gserviceaccount.com" + mock_credentials.token = "access-token" + + mock_blob = MagicMock() + mock_blob.generate_signed_url.return_value = "https://signed-url" + mock_get_bucket.return_value.client.configure_mock(_credentials=mock_credentials) + mock_get_bucket.return_value.blob.return_value = mock_blob + + url = GCPStorageService.get_presigned_url("target-bucket", "file-key", 10) + + assert url == "https://signed-url" + mock_credentials.refresh.assert_not_called() + mock_request.assert_not_called() + mock_blob.generate_signed_url.assert_called_once_with( + version="v4", + expiration=timedelta(minutes=10), + method="GET", + service_account_email="sa-job@example.iam.gserviceaccount.com", + access_token="access-token", + ) + + @patch("strr_api.services.gcp_storage_service.GCPStorageService.get_bucket") def test_upload_file_raises_external_service_exception_on_failure(mock_get_bucket): """upload_file wraps storage errors in ExternalServiceException."""