Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion jobs/batch-permit-validator/devops/vaults.gcp.env
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 20 additions & 1 deletion strr-api/src/strr_api/services/gcp_storage_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
60 changes: 59 additions & 1 deletion strr-api/tests/unit/services/test_gcp_storage_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,14 +187,16 @@ 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)

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(
Expand All @@ -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."""
Expand Down
Loading