Skip to content

Commit a316d7e

Browse files
authored
Fix auth for sample upsert stream (#48)
The client currently gets auth and signing metadata from frequenz-client-base only for unary-unary and unary-stream RPCs. UpsertMarketLocationSamplesStream is a streaming upsert call, so it missed the key, timestamp, nonce, and signature metadata and the service rejected it with a missing-signature authentication error. This attaches the same metadata explicitly for the upsert stream call and adds a regression test covering the streaming path. Verified with: - uv run pytest tests/test_marketmetering.py -q - end-to-end client flow against market-metering.eu-1.production.api.frequenz.com covering list, create, update, deactivate, activate, upsert_samples, and stream_samples
2 parents 7aab10d + ea73ca8 commit a316d7e

6 files changed

Lines changed: 1224 additions & 1 deletion

File tree

RELEASE_NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,4 @@ Initial release of the Frequenz Market Metering API client for Python.
5353
## Bug Fixes
5454

5555
- `update_market_location()`: Add missing `expected_revision` parameter required for optimistic concurrency control.
56+
- `upsert_samples()`: Attach auth and signing metadata to the streaming upsert RPC so authenticated sample upserts work against services that require signed requests.

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,9 @@ testpaths = ["tests", "src"]
183183
asyncio_mode = "auto"
184184
asyncio_default_fixture_loop_scope = "function"
185185
required_plugins = ["pytest-asyncio", "pytest-mock"]
186+
markers = [
187+
"integration: integration tests requiring a running gRPC server",
188+
]
186189

187190
[tool.mypy]
188191
explicit_package_bases = true

src/frequenz/client/marketmetering/_client.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55

66
from __future__ import annotations
77

8+
import hmac
9+
import secrets
10+
import time
11+
from base64 import urlsafe_b64encode
812
from datetime import datetime, timedelta
913
from typing import AsyncIterator, cast
1014

@@ -152,6 +156,33 @@ def stub(self) -> marketmetering_pb2_grpc.MarketMeteringServiceStub:
152156
raise ClientNotConnected(server_url=self.server_url, operation="stub")
153157
return self._stub
154158

159+
def _metadata(self, method: str) -> tuple[tuple[str, str | bytes], ...] | None:
160+
"""Build request metadata for RPCs not covered by client-base interceptors."""
161+
if self._auth_key is None:
162+
return None
163+
164+
metadata: list[tuple[str, str | bytes]] = [("key", self._auth_key)]
165+
if self._sign_secret is None:
166+
return tuple(metadata)
167+
168+
ts = str(int(time.time())).encode()
169+
nonce = urlsafe_b64encode(secrets.token_bytes(16))
170+
171+
digest = hmac.new(self._sign_secret.encode(), digestmod="sha256")
172+
digest.update(self._auth_key.encode())
173+
digest.update(ts)
174+
digest.update(nonce)
175+
digest.update(method.encode())
176+
177+
metadata.extend(
178+
[
179+
("ts", ts),
180+
("nonce", nonce),
181+
("sig", urlsafe_b64encode(digest.digest()).rstrip(b"=")),
182+
]
183+
)
184+
return tuple(metadata)
185+
155186
async def create_market_location(
156187
self,
157188
*,
@@ -336,6 +367,7 @@ async def request_generator() -> (
336367
AsyncIterator[pb.UpsertMarketLocationSamplesStreamResponse],
337368
self.stub.UpsertMarketLocationSamplesStream(
338369
request_generator(), # type: ignore[arg-type]
370+
metadata=self._metadata("UpsertMarketLocationSamplesStream"),
339371
timeout=self._stream_timeout_seconds,
340372
),
341373
)

0 commit comments

Comments
 (0)