Skip to content

Commit 900bcc0

Browse files
authored
Add CloudFront signed URL support (#531)
1 parent 62db01e commit 900bcc0

File tree

6 files changed

+250
-4
lines changed

6 files changed

+250
-4
lines changed

backend/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ The backend expects the following environment variables (or entries in `.env.dev
3030
- `SHOPIFY_STORE` – Shopify store subdomain used to build Admin and Storefront API URLs.
3131
- `SHOPIFY_TOKEN` – Private access token used for Shopify Admin REST and Storefront GraphQL calls.
3232
- `SHOPIFY_WEBHOOK_SECRET` – Secret shared with Shopify to verify webhook signatures.
33+
- `S3_USE_CLOUDFRONT_SIGNER` – Enable to serve assets via CloudFront using signed URLs.
34+
- `CLOUDFRONT_KEY_PAIR_ID` – AWS CloudFront key pair identifier used for signing URLs.
35+
- `CLOUDFRONT_PRIVATE_KEY` – PEM-encoded private key that pairs with the CloudFront key pair ID.
36+
- `CLOUDFRONT_SIGNED_URL_TTL_SECONDS` – Optional override for the CloudFront signed URL expiration (defaults to one hour).
3337

3438
## Project Structure
3539

backend/api/config.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from functools import lru_cache
77
from typing import Any
88

9-
from pydantic import Field, field_validator
9+
from pydantic import Field, field_validator, model_validator
1010
from pydantic_settings import BaseSettings, SettingsConfigDict
1111

1212

@@ -123,6 +123,24 @@ class Settings(BaseSettings):
123123
default="cnc-exports",
124124
description="Object key prefix applied to uploaded CNC artifacts.",
125125
)
126+
s3_use_cloudfront_signer: bool = Field(
127+
default=False,
128+
description="When true, generate signed URLs for the public S3 base URL via CloudFront.",
129+
)
130+
cloudfront_key_pair_id: str | None = Field(
131+
default=None,
132+
description="CloudFront key pair identifier used to sign URLs.",
133+
)
134+
cloudfront_private_key: str | None = Field(
135+
default=None,
136+
description="PEM-encoded private key associated with the CloudFront key pair.",
137+
repr=False,
138+
)
139+
cloudfront_signed_url_ttl_seconds: int = Field(
140+
default=3600,
141+
description="Default expiration, in seconds, for signed CloudFront URLs.",
142+
ge=1,
143+
)
126144

127145
model_config = SettingsConfigDict(
128146
env_file=".env.development",
@@ -176,6 +194,7 @@ def _normalize_api_write_token(cls, value: str | None) -> str | None:
176194
"supabase_anon_key",
177195
"supabase_service_role_key",
178196
"supabase_jwt_audience",
197+
"cloudfront_key_pair_id",
179198
)
180199
@classmethod
181200
def _trim_optional_str(cls, value: str | None) -> str | None:
@@ -184,6 +203,33 @@ def _trim_optional_str(cls, value: str | None) -> str | None:
184203
value = value.strip()
185204
return value or None
186205

206+
@field_validator("cloudfront_private_key")
207+
@classmethod
208+
def _normalize_cloudfront_private_key(cls, value: str | None) -> str | None:
209+
if value is None:
210+
return None
211+
value = value.strip()
212+
if not value:
213+
return None
214+
return value.replace("\\n", "\n")
215+
216+
@model_validator(mode="after")
217+
def _validate_cloudfront_config(self) -> "Settings":
218+
if self.s3_use_cloudfront_signer:
219+
if not self.s3_public_base_url:
220+
raise ValueError(
221+
"S3_USE_CLOUDFRONT_SIGNER requires S3_PUBLIC_BASE_URL to be set."
222+
)
223+
if not self.cloudfront_key_pair_id:
224+
raise ValueError(
225+
"CLOUDFRONT_KEY_PAIR_ID must be provided when CloudFront signing is enabled."
226+
)
227+
if not self.cloudfront_private_key:
228+
raise ValueError(
229+
"CLOUDFRONT_PRIVATE_KEY must be provided when CloudFront signing is enabled."
230+
)
231+
return self
232+
187233

188234
@lru_cache(maxsize=1)
189235
def _settings_cache(secret: str | None, shopify_secret: str | None) -> Settings:

backend/api/storage_dependencies.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
from fastapi import Depends
66

77
from api.config import Settings, get_settings
8-
from backend.services.storage import StorageClient, create_storage
8+
from backend.services.storage import (
9+
CloudFrontSigningConfig,
10+
StorageClient,
11+
create_storage,
12+
)
913

1014

1115
def get_storage_client(settings: Settings = Depends(get_settings)) -> StorageClient:
@@ -14,6 +18,14 @@ def get_storage_client(settings: Settings = Depends(get_settings)) -> StorageCli
1418
return create_storage(
1519
bucket=settings.s3_bucket_name,
1620
base_url=settings.s3_public_base_url,
21+
cloudfront_signing=
22+
CloudFrontSigningConfig(
23+
key_pair_id=settings.cloudfront_key_pair_id,
24+
private_key=settings.cloudfront_private_key,
25+
url_ttl_seconds=settings.cloudfront_signed_url_ttl_seconds,
26+
)
27+
if settings.s3_use_cloudfront_signer
28+
else None,
1729
)
1830

1931

backend/services/storage.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,19 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass
6+
from datetime import datetime, timedelta, timezone
67
from typing import BinaryIO, Dict, Protocol
78

89
try: # pragma: no cover - optional dependency
910
import boto3
1011
except Exception: # pragma: no cover - dependency may be absent in tests
1112
boto3 = None # type: ignore[assignment]
1213

14+
try: # pragma: no cover - optional dependency
15+
from botocore.signers import CloudFrontSigner
16+
except Exception: # pragma: no cover - dependency may be absent in tests
17+
CloudFrontSigner = None # type: ignore[assignment]
18+
1319

1420
@dataclass(frozen=True)
1521
class StoredObject:
@@ -19,6 +25,19 @@ class StoredObject:
1925
content_type: str | None = None
2026

2127

28+
@dataclass(frozen=True)
29+
class CloudFrontSigningConfig:
30+
"""Configuration describing how to sign CloudFront URLs."""
31+
32+
key_pair_id: str
33+
private_key: str
34+
url_ttl_seconds: int = 3600
35+
36+
def __post_init__(self) -> None: # pragma: no cover - trivial validation
37+
if self.url_ttl_seconds <= 0:
38+
raise ValueError("CloudFront signed URL TTL must be positive")
39+
40+
2241
class StorageClient(Protocol):
2342
"""Minimal protocol for uploading file-like objects."""
2443

@@ -71,12 +90,16 @@ def __init__(
7190
*,
7291
client: "boto3.client" | None = None,
7392
base_url: str | None = None,
93+
cloudfront_signer: "CloudFrontSigner" | None = None,
94+
signed_url_ttl_seconds: int | None = None,
7495
) -> None:
7596
if boto3 is None: # pragma: no cover - only triggered when boto3 missing
7697
raise RuntimeError("boto3 is required for S3 uploads but is not installed")
7798
self._bucket = bucket
7899
self._client = client or boto3.client("s3")
79100
self._base_url = base_url.rstrip("/") if base_url else None
101+
self._cloudfront_signer = cloudfront_signer
102+
self._signed_url_ttl_seconds = signed_url_ttl_seconds
80103

81104
def upload_fileobj(
82105
self,
@@ -94,7 +117,14 @@ def upload_fileobj(
94117

95118
def _build_url(self, key: str) -> str:
96119
if self._base_url:
97-
return f"{self._base_url}/{key}"
120+
url = f"{self._base_url}/{key}"
121+
if self._cloudfront_signer:
122+
expires_in = self._signed_url_ttl_seconds or 3600
123+
expiration = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
124+
return self._cloudfront_signer.generate_presigned_url(
125+
url, date_less_than=expiration
126+
)
127+
return url
98128
region = getattr(self._client.meta, "region_name", "us-east-1")
99129
return f"https://{self._bucket}.s3.{region}.amazonaws.com/{key}"
100130

@@ -103,12 +133,26 @@ def create_storage(
103133
*,
104134
bucket: str | None,
105135
base_url: str | None = None,
136+
cloudfront_signing: CloudFrontSigningConfig | None = None,
106137
) -> StorageClient:
107138
"""Instantiate the preferred storage backend."""
108139

109140
if bucket:
110141
try:
111-
return S3Storage(bucket=bucket, base_url=base_url)
142+
cloudfront_signer = None
143+
signed_url_ttl_seconds = None
144+
if cloudfront_signing and base_url:
145+
cloudfront_signer = _create_cloudfront_signer(
146+
key_pair_id=cloudfront_signing.key_pair_id,
147+
private_key_pem=cloudfront_signing.private_key,
148+
)
149+
signed_url_ttl_seconds = cloudfront_signing.url_ttl_seconds
150+
return S3Storage(
151+
bucket=bucket,
152+
base_url=base_url,
153+
cloudfront_signer=cloudfront_signer,
154+
signed_url_ttl_seconds=signed_url_ttl_seconds,
155+
)
112156
except RuntimeError:
113157
# Fall back to in-memory storage when boto3 is unavailable.
114158
return InMemoryStorage(bucket=bucket, base_url=base_url)
@@ -120,5 +164,32 @@ def create_storage(
120164
"StorageClient",
121165
"InMemoryStorage",
122166
"S3Storage",
167+
"CloudFrontSigningConfig",
123168
"create_storage",
124169
]
170+
171+
172+
def _create_cloudfront_signer(
173+
*, key_pair_id: str, private_key_pem: str
174+
) -> "CloudFrontSigner" | None:
175+
"""Instantiate a CloudFront signer from the provided credentials."""
176+
177+
if CloudFrontSigner is None: # pragma: no cover - dependency may be absent
178+
raise RuntimeError("botocore is required for CloudFront signing but is not installed")
179+
180+
try: # pragma: no cover - dependency may be absent in tests
181+
from cryptography.hazmat.primitives import hashes, serialization
182+
from cryptography.hazmat.primitives.asymmetric import padding
183+
except Exception as exc: # pragma: no cover - dependency may be absent
184+
raise RuntimeError(
185+
"cryptography is required for CloudFront signing but is not installed"
186+
) from exc
187+
188+
private_key = serialization.load_pem_private_key(
189+
private_key_pem.encode("utf-8"), password=None
190+
)
191+
192+
def _rsa_signer(message: bytes) -> bytes:
193+
return private_key.sign(message, padding.PKCS1v15(), hashes.SHA1())
194+
195+
return CloudFrontSigner(key_pair_id, _rsa_signer)

backend/tests/test_config.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,23 @@ def test_get_settings_cache_clear(monkeypatch):
1919
refreshed_settings = get_settings()
2020
assert refreshed_settings is not settings_initial
2121
assert refreshed_settings.api_write_token == "updated"
22+
23+
24+
def test_cloudfront_private_key_normalization(monkeypatch):
25+
monkeypatch.setenv("HYGRAPH_WEBHOOK_SECRET", "secret")
26+
monkeypatch.setenv("API_WRITE_TOKEN", "token")
27+
monkeypatch.setenv("S3_USE_CLOUDFRONT_SIGNER", "true")
28+
monkeypatch.setenv("S3_PUBLIC_BASE_URL", "https://d111111abcdef8.cloudfront.net")
29+
monkeypatch.setenv("CLOUDFRONT_KEY_PAIR_ID", "K123")
30+
monkeypatch.setenv(
31+
"CLOUDFRONT_PRIVATE_KEY",
32+
"-----BEGIN PRIVATE KEY-----\\nline1\\nline2\\n-----END PRIVATE KEY-----",
33+
)
34+
35+
get_settings.cache_clear()
36+
settings = get_settings()
37+
38+
assert "\n" in settings.cloudfront_private_key
39+
assert settings.s3_use_cloudfront_signer is True
40+
41+
get_settings.cache_clear()

backend/tests/test_storage.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import datetime
2+
from types import SimpleNamespace
3+
4+
import pytest
5+
6+
from backend.services import storage
7+
8+
9+
class DummyS3Client:
10+
def __init__(self, region: str = "us-west-2") -> None:
11+
self.meta = SimpleNamespace(region_name=region)
12+
13+
def upload_fileobj(self, *_args, **_kwargs) -> None: # pragma: no cover - unused
14+
return None
15+
16+
17+
class DummySigner:
18+
def __init__(self) -> None:
19+
self.calls: list[tuple[str, datetime.datetime]] = []
20+
21+
def generate_presigned_url(self, url: str, date_less_than: datetime.datetime) -> str:
22+
self.calls.append((url, date_less_than))
23+
return f"signed:{url}"
24+
25+
26+
@pytest.fixture(autouse=True)
27+
def fake_boto3(monkeypatch: pytest.MonkeyPatch) -> None:
28+
monkeypatch.setattr(
29+
storage,
30+
"boto3",
31+
SimpleNamespace(client=lambda _service: DummyS3Client()),
32+
)
33+
34+
35+
def test_s3_storage_cloudfront_signing() -> None:
36+
signer = DummySigner()
37+
client = DummyS3Client()
38+
s3_storage = storage.S3Storage(
39+
bucket="example-bucket",
40+
client=client,
41+
base_url="https://d111111abcdef8.cloudfront.net",
42+
cloudfront_signer=signer,
43+
signed_url_ttl_seconds=600,
44+
)
45+
46+
url = s3_storage._build_url("folder/file.txt")
47+
48+
assert url == "signed:https://d111111abcdef8.cloudfront.net/folder/file.txt"
49+
assert len(signer.calls) == 1
50+
called_url, expires_at = signer.calls[0]
51+
assert called_url.endswith("folder/file.txt")
52+
assert isinstance(expires_at, datetime.datetime)
53+
assert expires_at.tzinfo is not None
54+
remaining = (expires_at - datetime.datetime.now(tz=datetime.timezone.utc)).total_seconds()
55+
assert 0 < remaining <= 600
56+
57+
58+
def test_s3_storage_base_url_without_signer() -> None:
59+
client = DummyS3Client(region="us-east-1")
60+
s3_storage = storage.S3Storage(
61+
bucket="example-bucket",
62+
client=client,
63+
base_url="https://assets.example.com",
64+
)
65+
66+
url = s3_storage._build_url("a/b.txt")
67+
68+
assert url == "https://assets.example.com/a/b.txt"
69+
70+
71+
def test_create_storage_with_cloudfront_config(monkeypatch: pytest.MonkeyPatch) -> None:
72+
dummy_signer = DummySigner()
73+
74+
def fake_create_signer(*_args, **_kwargs):
75+
return dummy_signer
76+
77+
monkeypatch.setattr(storage, "_create_cloudfront_signer", fake_create_signer)
78+
79+
config = storage.CloudFrontSigningConfig(
80+
key_pair_id="K12345",
81+
private_key="dummy",
82+
url_ttl_seconds=120,
83+
)
84+
85+
client = storage.create_storage(
86+
bucket="example-bucket",
87+
base_url="https://d111111abcdef8.cloudfront.net",
88+
cloudfront_signing=config,
89+
)
90+
91+
assert isinstance(client, storage.S3Storage)
92+
assert client._cloudfront_signer is dummy_signer
93+
assert client._signed_url_ttl_seconds == 120

0 commit comments

Comments
 (0)