Skip to content

Commit fcc36e1

Browse files
Implement Auditlog NG client
1 parent 7ad7d31 commit fcc36e1

35 files changed

Lines changed: 6200 additions & 1 deletion

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,19 @@ dependencies = [
2020
"opentelemetry-processor-baggage~=0.61b0",
2121
"traceloop-sdk~=0.52.0",
2222
"PyJWT~=2.10.1",
23+
"protobuf>=4.25.0",
24+
"protovalidate>=0.13.0",
25+
"grpcio>=1.60.0",
26+
"opentelemetry-api>=1.28.0",
27+
"opentelemetry-sdk>=1.28.0",
2328
]
2429

2530
[build-system]
2631
requires = ["hatchling"]
2732
build-backend = "hatchling.build"
2833

2934
[tool.hatch.build.targets.wheel]
30-
packages = ["src/sap_cloud_sdk"]
35+
packages = ["src/sap_cloud_sdk", "src/buf"]
3136

3237
[dependency-groups]
3338
dev = [

src/buf/__init__.py

Whitespace-only changes.

src/buf/validate/__init__.py

Whitespace-only changes.

src/buf/validate/validate_pb2.py

Lines changed: 465 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/buf/validate/validate_pb2.pyi

Lines changed: 650 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""SAP Cloud SDK for Python - Audit Log NG (OTLP/gRPC) module
2+
3+
Sends audit log events as OpenTelemetry LogRecords over gRPC.
4+
Supports mTLS (client certificates) and insecure (no-auth) modes.
5+
6+
The create_client() function accepts an AuditLogNGConfig and returns a
7+
ready-to-use AuditClient.
8+
9+
Usage:
10+
from sap_cloud_sdk.core.auditlog_ng import create_client, AuditLogNGConfig
11+
12+
config = AuditLogNGConfig(
13+
endpoint="audit.example.com:443",
14+
deployment_id="my-deployment",
15+
namespace="namespace-123",
16+
cert_file="client.pem",
17+
key_file="client.key",
18+
)
19+
client = create_client(config=config)
20+
21+
# Send an audit event (protobuf message)
22+
event_id = client.send(event, "DataAccess")
23+
client.close()
24+
"""
25+
26+
from typing import Optional
27+
28+
from sap_cloud_sdk.core.auditlog_ng.client import AuditClient
29+
from sap_cloud_sdk.core.auditlog_ng.config import (
30+
AuditLogNGConfig,
31+
SCHEMA_URL,
32+
validate_source_arg,
33+
)
34+
from sap_cloud_sdk.core.auditlog_ng.exceptions import (
35+
AuditLogNGError,
36+
ClientCreationError,
37+
TransportError,
38+
ValidationError,
39+
)
40+
41+
42+
def create_client(
43+
*,
44+
config: Optional[AuditLogNGConfig] = None,
45+
endpoint: Optional[str] = None,
46+
deployment_id: Optional[str] = None,
47+
namespace: Optional[str] = None,
48+
cert_file: Optional[str] = None,
49+
key_file: Optional[str] = None,
50+
ca_file: Optional[str] = None,
51+
insecure: bool = False,
52+
service_name: str = "audit-client",
53+
batch: bool = False,
54+
compression: bool = True,
55+
schema_url: str = SCHEMA_URL,
56+
) -> AuditClient:
57+
"""Create an AuditClient for sending audit events over OTLP/gRPC.
58+
59+
Either pass a pre-built ``config`` **or** the individual keyword arguments.
60+
When ``config`` is provided the remaining keyword arguments are ignored.
61+
62+
Args:
63+
config: Optional explicit configuration. If provided, all other
64+
keyword arguments are ignored.
65+
endpoint: OTLP gRPC endpoint (``host:port``).
66+
deployment_id: Deployment identifier.
67+
namespace: Namespace identifier.
68+
cert_file: Path to client certificate (PEM) for mTLS.
69+
key_file: Path to client private key (PEM) for mTLS.
70+
ca_file: Path to CA certificate (PEM) for server verification.
71+
insecure: Use insecure connection (no TLS).
72+
service_name: OpenTelemetry ``service.name`` resource attribute.
73+
batch: Use batch processing (better throughput, slight delay).
74+
compression: Enable gzip compression.
75+
schema_url: OpenTelemetry schema URL for the logger.
76+
77+
Returns:
78+
AuditClient: Configured client ready for audit operations.
79+
80+
Raises:
81+
ClientCreationError: If client creation fails.
82+
ValueError: If required parameters are missing.
83+
"""
84+
try:
85+
if config is None:
86+
if not endpoint or not deployment_id or not namespace:
87+
raise ValueError(
88+
"endpoint, deployment_id, and namespace are required "
89+
"when config is not provided"
90+
)
91+
config = AuditLogNGConfig(
92+
endpoint=endpoint,
93+
deployment_id=deployment_id,
94+
namespace=namespace,
95+
cert_file=cert_file,
96+
key_file=key_file,
97+
ca_file=ca_file,
98+
insecure=insecure,
99+
service_name=service_name,
100+
batch=batch,
101+
compression=compression,
102+
schema_url=schema_url,
103+
)
104+
105+
return AuditClient(config)
106+
107+
except (ValueError, ValidationError) as e:
108+
raise e
109+
except Exception as e:
110+
raise ClientCreationError(f"Failed to create audit log NG client: {e}") from e
111+
112+
113+
__all__ = [
114+
# Factory function
115+
"create_client",
116+
# Client
117+
"AuditClient",
118+
# Configuration
119+
"AuditLogNGConfig",
120+
"SCHEMA_URL",
121+
# Exceptions
122+
"AuditLogNGError",
123+
"ClientCreationError",
124+
"TransportError",
125+
"ValidationError",
126+
]
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
"""Audit Log OTLP Client.
2+
3+
Sends audit log events as OpenTelemetry LogRecords over gRPC.
4+
Supports mTLS (client certificates) and insecure (no-auth) modes.
5+
"""
6+
7+
import json
8+
import uuid
9+
from typing import Optional
10+
11+
import protovalidate
12+
from protovalidate import ValidationError as ProtoValidationError
13+
14+
import grpc
15+
from google.protobuf.message import Message
16+
from google.protobuf.json_format import MessageToDict
17+
from opentelemetry.sdk._logs import LoggerProvider
18+
from opentelemetry.sdk._logs.export import (
19+
SimpleLogRecordProcessor,
20+
BatchLogRecordProcessor,
21+
)
22+
from opentelemetry.sdk.resources import Resource
23+
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
24+
from opentelemetry._logs.severity import SeverityNumber
25+
26+
from sap_cloud_sdk.core.auditlog_ng.config import (
27+
AuditLogNGConfig,
28+
validate_source_arg,
29+
)
30+
from sap_cloud_sdk.core.auditlog_ng.exceptions import (
31+
TransportError,
32+
ValidationError,
33+
)
34+
from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics
35+
36+
37+
class AuditClient:
38+
"""OTLP-based audit log client.
39+
40+
Wraps protobuf audit events in OpenTelemetry LogRecords and sends
41+
them over gRPC to an OTLP-compatible endpoint.
42+
43+
Note:
44+
Do not instantiate this class directly. Use the
45+
:func:`~sap_cloud_sdk.core.auditlog_ng.create_client` factory function
46+
instead, which handles proper configuration.
47+
48+
Example::
49+
50+
from sap_cloud_sdk.core.auditlog_ng import create_client
51+
52+
client = create_client(config=AuditLogNGConfig(
53+
endpoint="audit.example.com:443",
54+
deployment_id="my-deployment",
55+
namespace="namespace-123",
56+
cert_file="client.pem",
57+
key_file="client.key",
58+
))
59+
60+
event_id = client.send(event, "DataAccess")
61+
client.close()
62+
"""
63+
64+
def __init__(self, config: AuditLogNGConfig, _telemetry_source: Optional[Module] = None) -> None:
65+
"""Initialize the audit client from a config object.
66+
67+
Args:
68+
config: Fully-validated :class:`AuditLogNGConfig`.
69+
"""
70+
self._config = config
71+
self._telemetry_source = _telemetry_source
72+
self._closed = False
73+
74+
# Build gRPC credentials
75+
credentials = self._build_credentials(config)
76+
77+
# Create OTLP exporter
78+
self._exporter = OTLPLogExporter(
79+
endpoint=config.endpoint,
80+
insecure=config.insecure,
81+
credentials=credentials,
82+
compression=(
83+
grpc.Compression.Gzip
84+
if config.compression
85+
else grpc.Compression.NoCompression
86+
),
87+
)
88+
89+
# Create logger provider
90+
self._provider = LoggerProvider(
91+
resource=Resource.create(
92+
{
93+
"service.name": config.service_name,
94+
"sap.ucl.deployment_id": config.deployment_id,
95+
"sap.ucl.system_namespace": config.namespace,
96+
}
97+
)
98+
)
99+
100+
# Add processor
101+
processor = (
102+
BatchLogRecordProcessor(self._exporter)
103+
if config.batch
104+
else SimpleLogRecordProcessor(self._exporter)
105+
)
106+
self._provider.add_log_record_processor(processor)
107+
108+
self._logger = self._provider.get_logger(
109+
"auditlog",
110+
schema_url=config.schema_url,
111+
)
112+
113+
# ------------------------------------------------------------------
114+
# Public API
115+
# ------------------------------------------------------------------
116+
117+
def send(
118+
self,
119+
event: Message,
120+
event_type: Optional[str] = None,
121+
format: str = "protobuf-binary",
122+
) -> str:
123+
"""Send an audit log event.
124+
125+
Args:
126+
event: Protobuf message (audit event).
127+
event_type: Event type name (defaults to message type name).
128+
format: Serialization format (``"protobuf-binary"`` or ``"json"``).
129+
130+
Returns:
131+
Generated event ID (UUID).
132+
133+
Raises:
134+
RuntimeError: If the client has already been closed.
135+
ValueError: If *format* is not a supported value.
136+
ValidationError: If the protobuf event fails validation.
137+
"""
138+
if self._closed:
139+
raise RuntimeError("Client is closed")
140+
141+
if format not in {"protobuf-binary", "json"}:
142+
raise ValueError("format must be 'protobuf-binary' or 'json'")
143+
144+
try:
145+
protovalidate.validate(event)
146+
except ProtoValidationError as e:
147+
raise ValidationError(f"Audit event validation failed: {e}") from e
148+
149+
tenant_id = event.common.tenant_id
150+
validate_source_arg(tenant_id, "tenant_id")
151+
152+
event_id = str(uuid.uuid4())
153+
154+
# Determine event type from message descriptor if not provided
155+
if event_type is None:
156+
event_type = event.DESCRIPTOR.name
157+
158+
event_type = f"sap.als.AuditEvent.{event_type}.v2"
159+
160+
if format == "json":
161+
mime_type = "application/json"
162+
event_dict = MessageToDict(event, preserving_proto_field_name=False)
163+
body = json.dumps(event_dict)
164+
else:
165+
mime_type = "application/protobuf"
166+
body = event.SerializeToString()
167+
168+
# Emit log record
169+
self._logger.emit(
170+
severity_number=SeverityNumber.INFO,
171+
event_name=event_type,
172+
body=body,
173+
attributes={
174+
"cloudevents.event_id": event_id,
175+
"sap.tenancy.tenant_id": tenant_id,
176+
"sap.auditlogging.mime_type": mime_type,
177+
},
178+
)
179+
180+
return event_id
181+
182+
def send_json(self, event: Message, event_type: Optional[str] = None) -> str:
183+
"""Send event in JSON format."""
184+
return self.send(event, event_type, format="json")
185+
186+
def flush(self) -> None:
187+
"""Flush pending events (for batch mode)."""
188+
if not self._closed:
189+
self._provider.force_flush()
190+
191+
def close(self) -> None:
192+
"""Shutdown the client and flush pending events."""
193+
if not self._closed:
194+
self._provider.shutdown()
195+
self._closed = True
196+
197+
# ------------------------------------------------------------------
198+
# Context manager
199+
# ------------------------------------------------------------------
200+
201+
def __enter__(self) -> "AuditClient":
202+
return self
203+
204+
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
205+
self.close()
206+
return False
207+
208+
# ------------------------------------------------------------------
209+
# Internal helpers
210+
# ------------------------------------------------------------------
211+
212+
@staticmethod
213+
def _build_credentials(
214+
config: AuditLogNGConfig,
215+
) -> Optional[grpc.ChannelCredentials]:
216+
"""Build gRPC channel credentials from config."""
217+
if config.insecure:
218+
return None
219+
220+
root_certs = None
221+
private_key = None
222+
cert_chain = None
223+
224+
if config.ca_file:
225+
with open(config.ca_file, "rb") as f:
226+
root_certs = f.read()
227+
228+
if config.cert_file and config.key_file:
229+
with open(config.key_file, "rb") as f:
230+
private_key = f.read()
231+
with open(config.cert_file, "rb") as f:
232+
cert_chain = f.read()
233+
234+
return grpc.ssl_channel_credentials(
235+
root_certificates=root_certs,
236+
private_key=private_key,
237+
certificate_chain=cert_chain,
238+
)

0 commit comments

Comments
 (0)