Skip to content
Merged
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
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -578,7 +578,9 @@ RUN make test
FROM openfeature-provider-python.test AS openfeature-provider-python.test_e2e

RUN --mount=type=secret,id=confidence_client_secret \
--mount=type=secret,id=confidence_client_encryption_key \
CONFIDENCE_CLIENT_SECRET=$(cat /run/secrets/confidence_client_secret) \
CONFIDENCE_CLIENT_ENCRYPTION_KEY=$(cat /run/secrets/confidence_client_encryption_key) \
make test-e2e

# ==============================================================================
Expand Down
1 change: 1 addition & 0 deletions openfeature-provider/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies = [
"protobuf>=5.0.0",
"grpcio>=1.60.0",
"httpx>=0.27.0",
"cryptography>=42.0.0",
]

[project.optional-dependencies]
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions openfeature-provider/python/src/confidence/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ class ConfidenceProvider(AbstractProvider):
def __init__(
self,
client_secret: str,
encryption_key: Optional[str] = None,
state_poll_interval: float = DEFAULT_STATE_POLL_INTERVAL,
log_poll_interval: float = DEFAULT_LOG_POLL_INTERVAL,
assign_poll_interval: float = DEFAULT_ASSIGN_POLL_INTERVAL,
Expand Down Expand Up @@ -150,6 +151,7 @@ def __init__(
wasm_bytes: Optional WASM bytes for testing.
"""
self._client_secret = client_secret
self._encryption_key = encryption_key
self._state_poll_interval = state_poll_interval
self._log_poll_interval = log_poll_interval
self._assign_poll_interval = assign_poll_interval
Expand Down Expand Up @@ -215,6 +217,12 @@ def initialize(self, evaluation_context: EvaluationContext) -> None:
Raises:
Exception: If initialization fails.
"""
if not self._encryption_key:
logger.warning(
"No encryption_key provided. Falling back to unencrypted state. "
"An encryption key will be required in an upcoming version."
)

# Load WASM bytes if not provided
if self._wasm_bytes is None:
self._wasm_bytes = _load_wasm_from_resources()
Expand All @@ -229,6 +237,7 @@ def initialize(self, evaluation_context: EvaluationContext) -> None:
self._state_fetcher = StateFetcher(
client_secret=self._client_secret,
http_client=self._http_client,
encryption_key=self._encryption_key,
)

# Create flag logger if not injected
Expand Down
30 changes: 22 additions & 8 deletions openfeature-provider/python/src/confidence/state_fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import hashlib
import logging
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from typing import Optional, Tuple

import httpx
Expand Down Expand Up @@ -39,6 +40,7 @@ def __init__(
self,
client_secret: str,
http_client: Optional[httpx.Client] = None,
encryption_key: Optional[str] = None,
) -> None:
"""Initialize the StateFetcher.

Expand All @@ -47,6 +49,7 @@ def __init__(
http_client: Optional httpx.Client for custom HTTP configuration or testing.
"""
self._client_secret = client_secret
self._encryption_key = encryption_key
self._http_client = http_client
self._owns_client = http_client is None

Expand All @@ -57,7 +60,8 @@ def __init__(

# Build CDN URL from SHA256 hash of client secret
hash_hex = hashlib.sha256(client_secret.encode()).hexdigest()
self._cdn_url = f"{CDN_BASE_URL}/{hash_hex}"
suffix = f"{hash_hex}.enc" if encryption_key else hash_hex
self._cdn_url = f"{CDN_BASE_URL}/{suffix}"

@property
def state(self) -> Optional[bytes]:
Expand Down Expand Up @@ -114,23 +118,33 @@ def fetch(self) -> Tuple[bytes, str, bool]:
f"Failed to fetch state: HTTP {response.status_code}"
)

# Parse the protobuf response
state_request = SetResolverStateRequest()
state_request.ParseFromString(response.content)
self._etag = response.headers.get("ETag")

content = response.content
if self._encryption_key:
content = _decrypt_aes_gcm(content, self._encryption_key)

# Cache the state, account ID, and ETag
state_request = SetResolverStateRequest()
state_request.ParseFromString(content)
self._state = state_request.state
self._account_id = state_request.account_id
self._etag = response.headers.get("ETag")

logger.info(
"Loaded resolver state for account=%s, etag=%s",
self._account_id,
self._etag,
)

return self._state, self._account_id, True

finally:
if should_close:
client.close()


def _decrypt_aes_gcm(data: bytes, hex_key: str) -> bytes:
key = bytes.fromhex(hex_key)
nonce_len = 12
if len(data) < nonce_len:
raise StateFetcherError("Encrypted state too short (missing nonce)")
nonce = data[:nonce_len]
ciphertext = data[nonce_len:]
return AESGCM(key).decrypt(nonce, ciphertext, None)
53 changes: 51 additions & 2 deletions openfeature-provider/python/tests/e2e_test.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
"""End-to-end tests that verify flag resolution with the real backend."""

import os

from openfeature import api
from openfeature.api import set_provider_and_wait
from openfeature.evaluation_context import EvaluationContext

import os

from confidence import ConfidenceProvider

# E2E test configuration - matches Go e2e_test.go
E2E_CLIENT_SECRET = os.environ["CONFIDENCE_CLIENT_SECRET"]
E2E_ENCRYPTION_KEY = os.environ["CONFIDENCE_CLIENT_ENCRYPTION_KEY"]
E2E_INCLUDED_TARGETING_KEY = "user-a"
E2E_EXCLUDED_TARGETING_KEY = "user-x"

Expand Down Expand Up @@ -108,3 +109,51 @@ def test_resolve_without_materialization_store_uses_bloom_filter(self) -> None:
)
finally:
provider.shutdown()


class TestFlagResolveWithEncryptedState:
"""E2E tests for flag resolution with encrypted CDN state."""

def test_resolve_boolean_via_encrypted_state(self) -> None:

provider = ConfidenceProvider(
client_secret=E2E_CLIENT_SECRET,
encryption_key=E2E_ENCRYPTION_KEY,
)
try:
set_provider_and_wait(provider)
client = api.get_client()
ctx = EvaluationContext(
targeting_key="test-a",
attributes={"sticky": False},
)
result = client.get_boolean_details(
flag_key="web-sdk-e2e-flag.bool",
default_value=True,
evaluation_context=ctx,
)
assert result.value is False
finally:
provider.shutdown()

def test_resolve_string_via_encrypted_state(self) -> None:

provider = ConfidenceProvider(
client_secret=E2E_CLIENT_SECRET,
encryption_key=E2E_ENCRYPTION_KEY,
)
try:
set_provider_and_wait(provider)
client = api.get_client()
ctx = EvaluationContext(
targeting_key="test-a",
attributes={"sticky": False},
)
result = client.get_string_details(
flag_key="web-sdk-e2e-flag.str",
default_value="default",
evaluation_context=ctx,
)
assert result.value == "control"
finally:
provider.shutdown()