Skip to content

Commit 1b9f699

Browse files
Merge branch 'main' into auto-tenant-auditlog
# Conflicts: # pyproject.toml
2 parents 4ac98a3 + f30b648 commit 1b9f699

7 files changed

Lines changed: 196 additions & 30 deletions

File tree

‎src/sap_cloud_sdk/core/auditlog_ng/client.py‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
import json
88
import os
99
import uuid
10-
from typing import Optional
10+
from datetime import datetime, timezone
11+
from typing import Any, Optional, cast
1112

1213
import protovalidate
1314
from protovalidate import ValidationError as ProtoValidationError
@@ -37,6 +38,28 @@
3738
from sap_cloud_sdk.core.auditlog_ng.exceptions import ValidationError
3839
from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics
3940
from sap_cloud_sdk.core.telemetry.config import ENV_OTLP_PROTOCOL
41+
from sap_cloud_sdk.ias._context import get_auth_context
42+
43+
44+
def _fill_common_from_auth_context(event: Message) -> None:
45+
"""Back-fill common fields from the request auth context.
46+
47+
Accesses event.common directly — protobuf creates the sub-message on first
48+
mutation, so callers that never touched common still get it populated.
49+
Sets tenant_id and user_initiator_id from IAS claims (if present and not
50+
already set), and sets timestamp to now if the caller left it at zero.
51+
"""
52+
if not hasattr(event, "common"):
53+
return
54+
common = cast(Any, event.common)
55+
claims = get_auth_context()
56+
if claims is not None:
57+
if claims.app_tid and not common.tenant_id:
58+
common.tenant_id = claims.app_tid
59+
if claims.user_uuid and not common.user_initiator_id:
60+
common.user_initiator_id = claims.user_uuid
61+
if common.timestamp.seconds == 0:
62+
common.timestamp.FromDatetime(datetime.now(timezone.utc))
4063

4164

4265
def _create_log_exporter(
@@ -178,6 +201,8 @@ def send(
178201
if format not in {"protobuf-binary", "json"}:
179202
raise ValueError("format must be 'protobuf-binary' or 'json'")
180203

204+
_fill_common_from_auth_context(event)
205+
181206
try:
182207
protovalidate.validate(event)
183208
except ProtoValidationError as e:

‎src/sap_cloud_sdk/core/auditlog_ng/user-guide.md‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ event.object_type = "resource"
168168
event.object_id = "resource-001"
169169
```
170170

171+
> **Tip:** When using `StarletteIASTelemetryMiddleware` (see [Automatic tenant and user injection](#automatic-tenant-and-user-injection)), `common.tenant_id` and `common.user_initiator_id` are filled automatically from the incoming IAS JWT. You only need to set them explicitly if you want to override the values from the token.
172+
171173
### Step 4: Send the Event
172174

173175
**Binary protobuf:**
@@ -284,6 +286,55 @@ Events are validated against protobuf constraints using `protovalidate` before s
284286

285287
---
286288

289+
## Automatic tenant and user injection
290+
291+
When `StarletteIASTelemetryMiddleware` is registered on your app, it parses the
292+
incoming `Authorization: Bearer <token>` header on every request and stores the
293+
IAS claims in the current async context.
294+
295+
`AuditClient.send()` reads that context automatically before validation and
296+
back-fills two fields on the event's `common` block — only if they are not
297+
already set by the caller:
298+
299+
| Field populated | IAS claim used |
300+
|---|---|
301+
| `common.tenant_id` | `app_tid` |
302+
| `common.user_initiator_id` | `user_uuid` |
303+
304+
### Setup
305+
306+
Register the middleware once when your app starts:
307+
308+
```python
309+
from sap_cloud_sdk.core.telemetry import auto_instrument
310+
from sap_cloud_sdk.core.telemetry.middleware import StarletteIASTelemetryMiddleware
311+
312+
app = FastAPI(...)
313+
auto_instrument(middlewares=[StarletteIASTelemetryMiddleware(app=app)])
314+
```
315+
316+
### Usage
317+
318+
With the middleware in place, you can omit `tenant_id` and `user_initiator_id`
319+
from every event — they are injected automatically:
320+
321+
```python
322+
event = pb.DataAccess()
323+
event.common.timestamp.FromDatetime(datetime.now(timezone.utc))
324+
# tenant_id and user_initiator_id are filled from the IAS JWT automatically
325+
event.channel_type = "API"
326+
event.channel_id = "agent-v1"
327+
event.object_type = "resource"
328+
event.object_id = "resource-001"
329+
330+
event_id = client.send(event)
331+
```
332+
333+
If neither the middleware nor an explicit value provides `tenant_id`, the event
334+
will fail `protovalidate` validation and raise a `ValidationError`.
335+
336+
---
337+
287338
## Running the Unit Tests
288339

289340
```bash

‎src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py‎

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
ATTR_USER_ID,
1111
)
1212
from sap_cloud_sdk.core.telemetry.middleware.base import TelemetryMiddleware
13-
from sap_cloud_sdk.ias import parse_token
13+
from sap_cloud_sdk.ias import parse_token, set_auth_context
1414

1515
try:
1616
from starlette.middleware.base import BaseHTTPMiddleware
@@ -31,7 +31,9 @@ def __init__(self, app: Any, attrs_var: ContextVar[Dict[str, Any]]) -> None:
3131
self._attrs_var = attrs_var
3232

3333
async def dispatch(self, request: Request, call_next: Any) -> Response:
34-
token = self._attrs_var.set(_extract_ias_attrs(request))
34+
claims, attrs = _parse_request(request)
35+
set_auth_context(claims)
36+
token = self._attrs_var.set(attrs)
3537
try:
3638
return await call_next(request)
3739
finally:
@@ -78,23 +80,22 @@ def get_attributes(self) -> Dict[str, Any]:
7880
return self._attrs_var.get()
7981

8082

81-
def _extract_ias_attrs(request: Request) -> Dict[str, Any]:
82-
"""Parse the Authorization header and return telemetry attributes."""
83+
def _parse_request(request: Request):
84+
"""Parse the Authorization header and return (IASClaims, telemetry_attrs)."""
8385
auth = request.headers.get("authorization", "")
84-
if not auth:
85-
return {}
86-
try:
87-
claims = parse_token(auth)
88-
except Exception as e:
89-
logger.debug("IAS token parsing failed, skipping telemetry attrs: %s", e)
90-
return {}
91-
86+
claims = None
9287
attrs: Dict[str, Any] = {}
93-
if claims.sap_gtid:
94-
attrs[ATTR_SAP_TENANT_ID] = claims.sap_gtid
95-
if claims.user_uuid:
96-
attrs[ATTR_USER_ID] = claims.user_uuid
88+
if auth:
89+
try:
90+
claims = parse_token(auth)
91+
except Exception as e:
92+
logger.debug("IAS token parsing failed, skipping telemetry attrs: %s", e)
93+
if claims is not None:
94+
if claims.sap_gtid:
95+
attrs[ATTR_SAP_TENANT_ID] = claims.sap_gtid
96+
if claims.user_uuid:
97+
attrs[ATTR_USER_ID] = claims.user_uuid
9798
origin = request.headers.get("x-sap-origin")
9899
if origin:
99100
attrs[ATTR_SAP_TRIGGER_TYPE] = origin
100-
return attrs
101+
return claims, attrs

‎src/sap_cloud_sdk/ias/__init__.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@
1212
print(claims.email) # user email (when email scope requested)
1313
"""
1414

15+
from sap_cloud_sdk.ias._context import get_auth_context, set_auth_context
1516
from sap_cloud_sdk.ias._token import IASClaims, parse_token
1617
from sap_cloud_sdk.ias.exceptions import IASTokenError
1718

1819
__all__ = [
1920
"IASClaims",
20-
"parse_token",
2121
"IASTokenError",
22+
"get_auth_context",
23+
"parse_token",
24+
"set_auth_context",
2225
]

‎src/sap_cloud_sdk/ias/_context.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Per-request IAS auth context via ContextVar."""
2+
3+
from contextvars import ContextVar
4+
from typing import Optional
5+
6+
from sap_cloud_sdk.ias._token import IASClaims
7+
8+
_auth_context_var: ContextVar[Optional[IASClaims]] = ContextVar(
9+
"ias_auth_context", default=None
10+
)
11+
12+
13+
def set_auth_context(claims: Optional[IASClaims]) -> None:
14+
"""Store IAS claims for the current async context."""
15+
_auth_context_var.set(claims)
16+
17+
18+
def get_auth_context() -> Optional[IASClaims]:
19+
"""Return the IAS claims set for the current async context, or None."""
20+
return _auth_context_var.get()

‎tests/core/unit/auditlog_ng/unit/test_client.py‎

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22

33
from __future__ import annotations
44

5+
from datetime import datetime, timezone
56
from typing import TypedDict, Unpack
67
from unittest.mock import MagicMock, Mock, patch
78

89
import pytest
910

10-
from sap_cloud_sdk.core.auditlog_ng.client import AuditClient
11+
from sap_cloud_sdk.core.auditlog_ng.client import AuditClient, _fill_common_from_auth_context
1112
from sap_cloud_sdk.core.auditlog_ng.config import AuditLogNGConfig, SCHEMA_URL
1213
from sap_cloud_sdk.core.auditlog_ng.exceptions import ValidationError
14+
from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import auditevent_pb2 as pb
1315
from sap_cloud_sdk.core.telemetry import Module, Operation
16+
from sap_cloud_sdk.ias._context import set_auth_context
1417

1518

1619
class ConfigKwargs(TypedDict, total=False):
@@ -335,3 +338,57 @@ def test_unsupported_protocol_raises(
335338
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/json")
336339
with pytest.raises(ValueError, match="Unsupported OTEL_EXPORTER_OTLP_PROTOCOL"):
337340
AuditClient(_make_config())
341+
342+
343+
class TestFillCommonFromAuthContext:
344+
def setup_method(self):
345+
set_auth_context(None)
346+
347+
def teardown_method(self):
348+
set_auth_context(None)
349+
350+
def _make_claims(self, app_tid="tid-123", user_uuid="uid-456"):
351+
claims = MagicMock()
352+
claims.app_tid = app_tid
353+
claims.user_uuid = user_uuid
354+
return claims
355+
356+
def test_no_context_sets_timestamp_only(self):
357+
event = pb.DataAccess()
358+
_fill_common_from_auth_context(event)
359+
assert event.common.timestamp.seconds > 0
360+
assert event.common.tenant_id == ""
361+
assert event.common.user_initiator_id == ""
362+
363+
def test_with_context_fills_tenant_and_user(self):
364+
set_auth_context(self._make_claims())
365+
event = pb.DataAccess()
366+
_fill_common_from_auth_context(event)
367+
assert event.common.tenant_id == "tid-123"
368+
assert event.common.user_initiator_id == "uid-456"
369+
assert event.common.timestamp.seconds > 0
370+
371+
def test_does_not_overwrite_explicit_tenant(self):
372+
set_auth_context(self._make_claims(app_tid="ctx-tid"))
373+
event = pb.DataAccess()
374+
event.common.tenant_id = "explicit-tid"
375+
_fill_common_from_auth_context(event)
376+
assert event.common.tenant_id == "explicit-tid"
377+
378+
def test_does_not_overwrite_explicit_user(self):
379+
set_auth_context(self._make_claims(user_uuid="ctx-uid"))
380+
event = pb.DataAccess()
381+
event.common.user_initiator_id = "explicit-uid"
382+
_fill_common_from_auth_context(event)
383+
assert event.common.user_initiator_id == "explicit-uid"
384+
385+
def test_does_not_overwrite_explicit_timestamp(self):
386+
fixed = datetime(2024, 1, 1, tzinfo=timezone.utc)
387+
event = pb.DataAccess()
388+
event.common.timestamp.FromDatetime(fixed)
389+
_fill_common_from_auth_context(event)
390+
assert event.common.timestamp.seconds == int(fixed.timestamp())
391+
392+
def test_event_without_common_field_is_skipped(self):
393+
event = MagicMock(spec=[]) # no 'common' attribute
394+
_fill_common_from_auth_context(event) # must not raise

‎tests/core/unit/telemetry/middleware/test_starlette_a2a.py‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from sap_cloud_sdk.core.telemetry.constants import ATTR_SAP_TRIGGER_TYPE, ATTR_SAP_TENANT_ID, ATTR_USER_ID
77
from sap_cloud_sdk.core.telemetry.middleware.starlette_a2a import (
88
StarletteIASTelemetryMiddleware,
9-
_extract_ias_attrs,
9+
_parse_request,
1010
)
1111

1212
_PATCH_PARSE = "sap_cloud_sdk.core.telemetry.middleware.starlette_a2a.parse_token"
@@ -55,64 +55,73 @@ def test_two_instances_do_not_interfere(self):
5555
mw2._attrs_var.reset(t2)
5656

5757

58-
class TestExtractIasAttrs:
58+
class TestParseRequest:
5959
def test_extracts_tenant_and_user(self):
6060
claims = _make_claims(sap_gtid="t1", user_uuid="u1")
6161
request = _make_request({"authorization": "Bearer tok"})
6262
with patch(_PATCH_PARSE, return_value=claims):
63-
result = _extract_ias_attrs(request)
63+
_, result = _parse_request(request)
6464
assert result == {ATTR_SAP_TENANT_ID: "t1", ATTR_USER_ID: "u1"}
6565

6666
def test_omits_missing_tenant(self):
6767
claims = _make_claims(sap_gtid=None, user_uuid="u1")
6868
request = _make_request({"authorization": "Bearer tok"})
6969
with patch(_PATCH_PARSE, return_value=claims):
70-
result = _extract_ias_attrs(request)
70+
_, result = _parse_request(request)
7171
assert result == {ATTR_USER_ID: "u1"}
7272
assert ATTR_SAP_TENANT_ID not in result
7373

7474
def test_omits_missing_user(self):
7575
claims = _make_claims(sap_gtid="t1", user_uuid=None)
7676
request = _make_request({"authorization": "Bearer tok"})
7777
with patch(_PATCH_PARSE, return_value=claims):
78-
result = _extract_ias_attrs(request)
78+
_, result = _parse_request(request)
7979
assert result == {ATTR_SAP_TENANT_ID: "t1"}
8080
assert ATTR_USER_ID not in result
8181

8282
def test_returns_empty_when_no_auth_header(self):
8383
request = _make_request({})
8484
with patch(_PATCH_PARSE) as mock_parse:
85-
result = _extract_ias_attrs(request)
85+
parsed_claims, result = _parse_request(request)
8686
mock_parse.assert_not_called()
8787
assert result == {}
88+
assert parsed_claims is None
8889

8990
def test_returns_empty_on_parse_error(self):
9091
request = _make_request({"authorization": "Bearer bad"})
9192
with patch(_PATCH_PARSE, side_effect=ValueError("bad token")):
92-
result = _extract_ias_attrs(request)
93+
parsed_claims, result = _parse_request(request)
9394
assert result == {}
95+
assert parsed_claims is None
9496

9597
def test_returns_empty_when_both_claims_absent(self):
9698
claims = _make_claims(sap_gtid=None, user_uuid=None)
9799
request = _make_request({"authorization": "Bearer tok"})
98100
with patch(_PATCH_PARSE, return_value=claims):
99-
result = _extract_ias_attrs(request)
101+
_, result = _parse_request(request)
100102
assert result == {}
101103

102104
def test_includes_origin_header_when_present(self):
103105
claims = _make_claims(sap_gtid="t1", user_uuid="u1")
104106
request = _make_request({"authorization": "Bearer tok", "x-sap-origin": "ui5"})
105107
with patch(_PATCH_PARSE, return_value=claims):
106-
result = _extract_ias_attrs(request)
108+
_, result = _parse_request(request)
107109
assert result[ATTR_SAP_TRIGGER_TYPE] == "ui5"
108110

109111
def test_omits_origin_attr_when_header_absent(self):
110112
claims = _make_claims(sap_gtid="t1", user_uuid="u1")
111113
request = _make_request({"authorization": "Bearer tok"})
112114
with patch(_PATCH_PARSE, return_value=claims):
113-
result = _extract_ias_attrs(request)
115+
_, result = _parse_request(request)
114116
assert ATTR_SAP_TRIGGER_TYPE not in result
115117

118+
def test_returns_claims_object(self):
119+
claims = _make_claims(sap_gtid="t1", user_uuid="u1")
120+
request = _make_request({"authorization": "Bearer tok"})
121+
with patch(_PATCH_PARSE, return_value=claims):
122+
parsed_claims, _ = _parse_request(request)
123+
assert parsed_claims is claims
124+
116125

117126
class TestInnerMiddlewareDispatch:
118127
def _get_inner_class_and_kwargs(self, mw: StarletteIASTelemetryMiddleware):

0 commit comments

Comments
 (0)