diff --git a/docs/src/deployment/configuration.md b/docs/src/deployment/configuration.md
index 276b89b6..bd769394 100644
--- a/docs/src/deployment/configuration.md
+++ b/docs/src/deployment/configuration.md
@@ -190,21 +190,47 @@ spring:
```yaml
management:
tracing:
- enabled: true
sampling:
- probability: 1.0
-
-otel:
- exporter:
- otlp:
- endpoint: http://otel-collector:4317
- service:
- name: idp-core
- resource:
- attributes:
- deployment.environment: production
+ probability: ${OTEL_TRACES_SAMPLER_ARG:0.1}
+ export:
+ enabled: ${OTEL_TRACING_ENABLED:false}
+
+ otlp:
+ metrics:
+ export:
+ url: ${OTEL_EXPORTER_OTLP_METRICS_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/metrics}
+ enabled: ${OTEL_METRICS_EXPORT_ENABLED:false}
+ transport: ${OTEL_EXPORTER_OTLP_METRICS_PROTOCOL:${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf}}
+
+ opentelemetry:
+ tracing:
+ export:
+ otlp:
+ endpoint: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/traces}
+ transport: ${OTEL_EXPORTER_OTLP_TRACES_TRANSPORT:${OTEL_EXPORTER_OTLP_PROTOCOL:http}}
+ resource-attributes:
+ deployment.environment: ${OTEL_ENV:local}
+ service.namespace: ${OTEL_SERVICE_NAMESPACE:idp-core}
+ service.name: ${OTEL_SERVICE_NAME:internal-developer-platform}
```
+The signal-specific OpenTelemetry variables win over the generic fallback, so you can set the standard OpenTelemetry environment variables without hard-coding vendor-specific values.
+
+### Common environment variables
+
+| Variable | Description |
+| --- | --- |
+| `OTEL_TRACING_ENABLED` | Enables tracing export |
+| `OTEL_METRICS_EXPORT_ENABLED` | Enables OTLP metrics export |
+| `OTEL_TRACES_SAMPLER_ARG` | Trace sampling ratio |
+| `OTEL_EXPORTER_OTLP_ENDPOINT` | Base OTLP endpoint |
+| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Full traces URL |
+| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | Full metrics URL |
+| `OTEL_EXPORTER_OTLP_PROTOCOL` | Generic OTLP protocol fallback |
+| `OTEL_EXPORTER_OTLP_TRACES_TRANSPORT` | Trace transport override |
+| `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` | Metrics transport override |
+| `OTEL_RESOURCE_ATTRIBUTES` | Resource metadata for the service |
+
---
### OAuth 2.0
diff --git a/docs/src/deployment/docker.md b/docs/src/deployment/docker.md
index e6254a3c..64e5b69b 100644
--- a/docs/src/deployment/docker.md
+++ b/docs/src/deployment/docker.md
@@ -224,77 +224,43 @@ ENTRYPOINT ["java", "-jar", "app.jar"]
### Observability
-| Variable | Description | Default |
-| ----------------------------- | -------------- | ---------- |
-| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP endpoint | - |
-| `OTEL_SERVICE_NAME` | Service name | `idp-core` |
-| `LOG_LEVEL` | Root log level | `INFO` |
-
----
-
-## Docker Compose Commands
-
-### Start Services
-
-```bash
-# Start all
-docker compose up -d
-
-# Start specific service
-docker compose up -d idp-core
-
-# View logs
-docker compose logs -f idp-core
-```
-
-### Stop Services
-
-```bash
-# Stop all
-docker compose down
-
-# Stop and remove volumes
-docker compose down -v
-```
-
-### Update
-
-```bash
-# Pull latest images
-docker compose pull
-
-# Recreate containers
-docker compose up -d --force-recreate
-```
-
-### Scale
-
-```bash
-# Run 3 instances
-docker compose up -d --scale idp-core=3
-```
-
----
-
-## Database Management
-
-### Initialize Database
-
-The database schema is managed by Flyway and applies automatically on startup.
-
-### Backup
-
-```bash
-docker exec postgres pg_dump -U idp idp > backup.sql
-```
-
-### Restore
-
-```bash
-cat backup.sql | docker exec -i postgres psql -U idp idp
-```
-
----
+IDP-Core follows the standard OpenTelemetry environment variables. Tracing and metrics export are opt-in, and the application keeps the usual OpenTelemetry fallbacks so you can set the standard variables without hard-coding a vendor-specific configuration.
+
+The effective precedence is:
+
+- `OTEL_EXPORTER_OTLP_TRACES_TRANSPORT` > `OTEL_EXPORTER_OTLP_PROTOCOL` > `http`
+- `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` > `OTEL_EXPORTER_OTLP_PROTOCOL` > `http/protobuf`
+
+This means the application can work in local Docker, in Kubernetes, or in a managed platform while still honoring the standard OpenTelemetry variables set by the operator or deployment pipeline.
+
+| Variable | Description | Default |
+|---------------------------------------|----------------------------------------------------------|------------------------------------------------|
+| `OTEL_TRACING_ENABLED` | Enables OpenTelemetry tracing (opt-in) | `false` |
+| `OTEL_EXPORTER_OTLP_ENDPOINT` | Base OTLP endpoint for all signals | `http://localhost:4318` |
+| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Full traces URL. It wins over the base endpoint | `/v1/traces` |
+| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | Full metrics URL. It wins over the base endpoint | `/v1/metrics` |
+| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP protocol used as the generic fallback | `http/protobuf` for metrics, `http` for traces |
+| `OTEL_EXPORTER_OTLP_TRACES_TRANSPORT` | Transport for traces. It wins over the generic protocol | `OTEL_EXPORTER_OTLP_PROTOCOL` or `http` |
+| `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` | Transport for metrics. It wins over the generic protocol | `OTEL_EXPORTER_OTLP_PROTOCOL` |
+| `OTEL_METRICS_EXPORT_ENABLED` | Enables OTLP metrics export (opt-in) | `false` |
+| `OTEL_TRACES_SAMPLER_ARG` | Fraction of traces kept, from `0.0` to `1.0` | `0.1` |
+| `OTEL_SERVICE_NAME` | Service name | `internal-developer-platform` |
+| `OTEL_SERVICE_NAMESPACE` | Service namespace | `idp-core` |
+| `OTEL_ENV` | Value of the `deployment.environment` attribute | `local` |
+| `OTEL_RESOURCE_ATTRIBUTES` | Extra resource attributes (`key=value,key=value`) | - |
+| `LOG_LEVEL` | Root log level | `INFO` |
+
+The signal-specific variable wins over the generic one, so the image works in the common deployments:
+
+- **Docker Compose or a simple container:** set `OTEL_EXPORTER_OTLP_ENDPOINT`
+- **Kubernetes with the OpenTelemetry Operator:** the platform injects `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`
+- **Datadog or another OTLP collector:** set the standard OpenTelemetry endpoint and resource attributes
+- **Local development:** leave the variables unset and use the built-in `localhost:4318` fallback
+
+> Note: Lower `OTEL_TRACES_SAMPLER_ARG` in high-throughput production. Capturing 100% of the traces increases CPU overhead
+> and vendor costs. For example, `0.1` keeps 10% of the traces.
+
+`OTEL_RESOURCE_ATTRIBUTES` is the right place to pass Datadog tags such as `env`, `service`, and `version`. The application does not need hard-coded Datadog values; it just emits the standard OpenTelemetry resource metadata.
## Troubleshooting
diff --git a/docs/src/deployment/observability.md b/docs/src/deployment/observability.md
index ccbea54a..e0d179e4 100644
--- a/docs/src/deployment/observability.md
+++ b/docs/src/deployment/observability.md
@@ -3,336 +3,124 @@ title: Observability
description: Monitoring, logging, and tracing for the Internal Developer Platform
---
-The Internal Developer Platform provides comprehensive observability through metrics, logs, and distributed tracing using OpenTelemetry.
-
-## Overview
+The Internal Developer Platform emits metrics, logs, and traces through Spring Boot and OpenTelemetry, with Camel instrumentation enabled for the webhook ingestion flow.
```mermaid
flowchart LR
- subgraph "Internal Developer Platform"
- M[Metrics]
- L[Logs]
- T[Traces]
+ subgraph "Application"
+ APP[IDP-Core]
+ CAMEL[Camel route]
+ OTEL[OpenTelemetry]
end
- subgraph "Collection"
- OTEL[OpenTelemetry Collector]
+ subgraph "Backends"
+ DD[Datadog / OTLP collector]
PROM[Prometheus]
end
- subgraph "Visualization"
- GRAF[Grafana]
- LOKI[Loki]
- TEMPO[Tempo / Jaeger]
- DATADOG[Datadog]
- end
-
- M --> PROM --> GRAF
- L --> OTEL --> LOKI --> GRAF
- T --> OTEL --> TEMPO --> GRAF
- OTEL --> DATADOG
- PROM --> DATADOG
+ APP --> CAMEL
+ CAMEL --> OTEL
+ OTEL --> DD
+ OTEL --> PROM
```
----
+The app exposes health endpoints and uses OTLP export for traces and metrics when enabled. The standard OpenTelemetry variables remain the integration point for the environment, not vendor-specific configuration.
## Metrics
-### Prometheus Endpoint
-
-The Internal Developer Platform exposes metrics at `/actuator/prometheus`:
-
-```bash
-curl http://localhost:8080/actuator/prometheus
-```
-
-### Key Metrics
-
-#### Application Metrics
-
-| Metric | Description |
-| -------- | ------------- |
-| `idp_entities_total` | Total entities by template |
-| `idp_entity_operations_total` | CRUD operations count |
-| `idp_webhook_invocations_total` | Webhook calls |
-| `idp_action_executions_total` | Self-service action runs |
-
-#### HTTP Metrics
-
-| Metric | Description |
-| -------- | ------------- |
-| `http_server_requests_seconds` | Request latency |
-| `http_server_requests_active` | Active requests |
-
-#### JVM Metrics
-
-| Metric | Description |
-| -------- | ------------- |
-| `jvm_memory_used_bytes` | Memory usage |
-| `jvm_gc_pause_seconds` | GC pause time |
-| `jvm_threads_live` | Thread count |
+### Metrics exported by Spring Boot and Micrometer
-#### Database Metrics
-
-| Metric | Description |
-| -------- | ------------- |
-| `hikaricp_connections_active` | Active DB connections |
-| `hikaricp_connections_pending` | Waiting connections |
-
-### Metrics Configuration
+The application exposes the standard Micrometer metrics and OTLP export can be enabled through environment variables.
```yaml
management:
- endpoints:
- web:
- exposure:
- include: health,info,metrics,prometheus
- metrics:
+ tracing:
+ sampling:
+ probability: ${OTEL_TRACES_SAMPLER_ARG:0.1}
export:
- prometheus:
- enabled: true
- distribution:
- percentiles-histogram:
- http.server.requests: true
- percentiles:
- http.server.requests: 0.5,0.95,0.99
- tags:
- application: idp-core
- environment: ${ENVIRONMENT:dev}
-```
-
-### Prometheus scrape configuration
-
-```yaml title="prometheus.yml"
-scrape_configs:
- - job_name: 'idp-core'
- metrics_path: '/actuator/prometheus'
- static_configs:
- - targets: ['idp-core:8080']
- scrape_interval: 15s
-```
-
-### Kubernetes ServiceMonitor
-
-```yaml title="servicemonitor.yaml"
-apiVersion: monitoring.coreos.com/v1
-kind: ServiceMonitor
-metadata:
- name: idp-core
- namespace: idp
-spec:
- selector:
- matchLabels:
- app: idp-core
- endpoints:
- - port: http
- path: /actuator/prometheus
- interval: 30s
-```
-
----
-
-## Logging
+ enabled: ${OTEL_TRACING_ENABLED:false}
-### Log Format
-
-#### Development (Human-readable)
-
-```text
-2024-01-15 10:30:45.123 INFO [http-nio-8080-exec-1] c.d.i.a.EntityController - Creating entity: payment-service
-```
-
-#### Production (JSON)
-
-```json
-{
- "timestamp": "2024-01-15T10:30:45.123Z",
- "level": "INFO",
- "logger": "c.d.i.a.EntityController",
- "message": "Creating entity: payment-service",
- "thread": "http-nio-8080-exec-1",
- "traceId": "abc123def456",
- "spanId": "789xyz"
-}
-```
-
-### Logging Configuration
-
-```yaml
-logging:
- level:
- root: INFO
- com.decathlon.idp_core: INFO
- org.springframework.web: WARN
- org.hibernate: WARN
-
- pattern:
- # JSON for production
- console: '{"timestamp":"%d{ISO8601}","level":"%level","logger":"%logger{36}","message":"%msg","thread":"%thread","traceId":"%X{traceId}","spanId":"%X{spanId}"}%n'
-```
-
-### Loki Integration
-
-Send logs to Loki via the OpenTelemetry Collector:
-
-```yaml title="otel-collector-config.yaml"
-receivers:
otlp:
- protocols:
- grpc:
- endpoint: 0.0.0.0:4317
-
-exporters:
- loki:
- endpoint: http://loki:3100/loki/api/v1/push
- labels:
- attributes:
- service.name: "service_name"
- deployment.environment: "environment"
-
-service:
- pipelines:
- logs:
- receivers: [otlp]
- exporters: [loki]
+ metrics:
+ export:
+ url: ${OTEL_EXPORTER_OTLP_METRICS_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/metrics}
+ enabled: ${OTEL_METRICS_EXPORT_ENABLED:false}
+ transport: ${OTEL_EXPORTER_OTLP_METRICS_PROTOCOL:${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf}}
+
+ opentelemetry:
+ tracing:
+ export:
+ otlp:
+ endpoint: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/traces}
+ transport: ${OTEL_EXPORTER_OTLP_TRACES_TRANSPORT:${OTEL_EXPORTER_OTLP_PROTOCOL:http}}
+ resource-attributes:
+ deployment.environment: ${OTEL_ENV:local}
+ service.namespace: ${OTEL_SERVICE_NAMESPACE:idp-core}
+ service.name: ${OTEL_SERVICE_NAME:internal-developer-platform}
```
-### Log Levels
+### Custom business metrics
-| Level | Usage |
-| ------- | -------------------------- |
-| `ERROR` | Errors requiring attention |
-| `WARN` | Potential issues |
-| `INFO` | Key business events |
-| `DEBUG` | Development debugging |
-| `TRACE` | Detailed tracing |
+Business-level metrics are intentionally separate from Camel route telemetry. The application adds custom counters only for functional decisions that Camel does not expose as a reusable business signal.
----
+Examples:
-## Distributed Tracing
+- `idp.webhook.security.requests.total{connector, security_type}`
+- `idp.webhook.security.result{connector, security_type, result}`
+- `idp.webhook.security.validation.errors.total{connector, security_type, reason}`
-### OpenTelemetry Setup
+These metrics reflect the real security decision made by the application and should not duplicate the standard Camel `route.*` metrics.
-```yaml
-management:
- tracing:
- enabled: true
- sampling:
- probability: 1.0 # Sample all requests (use 0.1 for 10% in prod)
-
-otel:
- exporter:
- otlp:
- endpoint: http://otel-collector:4317
- service:
- name: idp-core
- resource:
- attributes:
- deployment.environment: production
- service.version: "1.0.0"
-```
+## Logging
-### Trace Context
+Logs remain useful for debugging and operational incidents, but they are not the primary analytics signal. The main operational signals are traces and metrics.
-IDP-Core propagates trace context through:
+The application configuration keeps log filtering under `logging.level.*` and actuator health endpoints under `management.endpoint.health`.
-- HTTP headers (W3C Trace Context)
-- Kafka message headers
-- Webhook requests
+## Distributed tracing
-### Key Spans
+OpenTelemetry tracing is enabled through Spring Boot configuration. The application keeps the trace sampling rate and OTLP exporter configurable through environment variables.
-| Span | Description |
-| --------------------------- | -------------------- |
-| `HTTP GET /api/v1/entities` | Incoming API request |
-| `EntityRepository.findById` | Database query |
-| `WebhookService.invoke` | Outgoing webhook |
-| `KafkaProducer.send` | Kafka message |
+### Standard environment variables
-### Jaeger/Tempo Integration
+| Variable | Description | Default |
+| --- | --- | --- |
+| `OTEL_TRACING_ENABLED` | Enables tracing export | `false` |
+| `OTEL_METRICS_EXPORT_ENABLED` | Enables OTLP metrics export | `false` |
+| `OTEL_TRACES_SAMPLER_ARG` | Trace sampling ratio | `0.1` |
+| `OTEL_EXPORTER_OTLP_ENDPOINT` | Base OTLP endpoint | `http://localhost:4318` |
+| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Full traces endpoint | `/v1/traces` |
+| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | Full metrics endpoint | `/v1/metrics` |
+| `OTEL_EXPORTER_OTLP_PROTOCOL` | Generic OTLP protocol fallback | `http/protobuf` for metrics, `http` for traces |
+| `OTEL_EXPORTER_OTLP_TRACES_TRANSPORT` | Trace-specific transport override | `OTEL_EXPORTER_OTLP_PROTOCOL` or `http` |
+| `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` | Metrics-specific transport override | `OTEL_EXPORTER_OTLP_PROTOCOL` |
+| `OTEL_RESOURCE_ATTRIBUTES` | Resource metadata injected by the environment | - |
-```yaml title="otel-collector-config.yaml"
-receivers:
- otlp:
- protocols:
- grpc:
- endpoint: 0.0.0.0:4317
-
-exporters:
- otlp/tempo:
- endpoint: http://tempo:4317
- tls:
- insecure: true
-
-service:
- pipelines:
- traces:
- receivers: [otlp]
- exporters: [otlp/tempo]
-```
+The signal-specific variables win over the generic fallback, so local development and hosted deployments can both work without hard-coded vendor configuration.
----
+### Important trace attributes
-## Health Checks
+The application sets trace attributes for the webhook pipeline, including the connector identifier and the security decision.
-### Endpoints
+Examples:
-| Endpoint | Purpose |
-| ---------------------------- | --------------- |
-| `/actuator/health` | Overall health |
-| `/actuator/health/liveness` | Liveness probe |
-| `/actuator/health/readiness` | Readiness probe |
+- `idp.webhook.identifier`
+- `idp.webhook.security.result`
-### Health Configuration
+These attributes support trace correlation in Datadog APM and allow support teams to trace a request across route execution, security validation, and mapping.
-```yaml
-management:
- endpoint:
- health:
- show-details: when_authorized
- show-components: always
- probes:
- enabled: true
- health:
- db:
- enabled: true
- diskspace:
- enabled: true
- threshold: 100MB
-```
+## Health checks
-### Response Example
-
-```json
-{
- "status": "UP",
- "components": {
- "db": {
- "status": "UP",
- "details": {
- "database": "PostgreSQL",
- "validationQuery": "isValid()"
- }
- },
- "diskSpace": {
- "status": "UP",
- "details": {
- "total": 107374182400,
- "free": 85899345920,
- "threshold": 104857600
- }
- },
- "ping": {
- "status": "UP"
- }
- }
-}
-```
+The app exposes health endpoints used by Docker and Kubernetes.
----
+| Endpoint | Purpose |
+|------------------------------|----------------------|
+| `/actuator/health` | Overall health |
+| `/actuator/health/liveness` | Liveness probe |
+| `/actuator/health/readiness` | Readiness probe |
-## Next Steps
+## Next steps
-- **[Configuration](configuration.md)** - Observability settings
-- **[Kubernetes](kubernetes.md)** - Production monitoring
-- **[Docker](docker.md)** - Local observability setup
+- **[Configuration](configuration.md)** - Environment and Spring Boot settings
+- **[Docker](docker.md)** - Docker deployment and OTLP examples
+- **[Kubernetes](kubernetes.md)** - Production deployment patterns
diff --git a/docs/src/features/webhooks.md b/docs/src/features/webhooks.md
index ff4da621..0bb8b2f4 100644
--- a/docs/src/features/webhooks.md
+++ b/docs/src/features/webhooks.md
@@ -80,20 +80,19 @@ This validation keeps the connector configuration aligned with the current data
Each connector declares one security type. IDP-Core validates the configuration at creation time and validates requests
again at runtime.
-| Type | Required configuration keys | Optional configuration keys |
-| -------------- | ------------------------------------------------- | ------------------------------- |
-| `HMAC_SHA256` | `header_name`, `secret_alias` | `prefix` (default is `sha256=`) |
-| `STATIC_TOKEN` | `header_name`, `secret_alias` | None |
-| `BASIC_AUTH` | `username`, `secret_alias` | None |
-| `JWT_BEARER` | `jwks_uri`, `client_id_field`, `client_id_values` | `expected_audience` |
-| `NONE` | None | None |
+| Type | Required configuration keys | Runtime behavior |
+|------------------|-----------------------------------------------------|--------------------------------------------------------------------------------------------------|
+| `HMAC_SHA256` | `header_name`, `secret_alias`, `prefix` | Computes the SHA-256 HMAC of the raw body and compares it with the request header |
+| `STATIC_TOKEN` | `header_name`, `secret_alias` | Compares a header value with a secret loaded from the environment |
+| `BASIC_AUTH` | `username`, `secret_alias` | Compares the `Authorization: Basic ...` header with the configured username and secret |
+| `JWT_BEARER` | `jwks_uri`, `client_id_field`, `client_id_values` | Validates the bearer token against a JWKS endpoint, then checks caller identity claim allow-list |
+| `NONE` | none (`config` is optional) | Skips authentication |
> [!IMPORTANT]
> Configuration keys accept `snake_case` and `camelCase` variants where supported. HMAC prefix uses the `prefix` key and has a default set to `sha256=`.
> [!WARNING]
-> Secret aliases reference environment variables; they do not store the raw secret value in the connector configuration.
-> Use `MY_SECRET`, `env:MY_SECRET`, or `${MY_SECRET}` as the value. You can also use `secret_alias_env` or
-> `secretAliasEnv` as the key and set its value to the environment variable name.
+> `secret_alias` must reference an environment variable alias in `UPPER_SNAKE_CASE`. It does not store the raw secret
+value in the connector configuration.
### Example Security Configurations
@@ -148,25 +147,33 @@ again at runtime.
}
```
+=== "NONE"
+
+ ```json
+ {
+ "type": "NONE"
+ }
+ ```
+
+ For `NONE`, you can omit `config`, set it to `null`, or pass `{}`. Any non-empty `config` is rejected.
+
### JWT_BEARER Configuration Reference
- `jwks_uri` (required): literal HTTPS URL used to validate JWT signatures.
-- `client_id_field` (required): claim name used to identify the caller. Allowed values are only `azp` or `email`.
+- `client_id_field` (required): name of the JWT claim that identifies the caller. Any non-empty claim name is accepted, for example `client_id`, `sub`, `azp`, `email`, or a custom claim.
- `client_id_values` (required): comma-separated allow-list of accepted claim values.
-- `expected_audience` (optional): comma-separated allow-list for the `aud` claim. If present, at least one JWT audience must match. If omitted, IDP-Core does not check the audience.
+- `expected_audience` (optional): comma-separated allow-list for the `aud` claim. If present, at least one JWT audience must match.
+- `allowed-jwks-hosts` (optional, environment-backed): comma-separated allow-list of trusted JWKS hosts under `idp.security.webhook`. Set `ALLOWED_JWKS_HOSTS` when you need to permit a specific issuer host explicitly.
+
+The caller must send the JWT in an `Authorization: Bearer ` header. If the claim named by `client_id_field` is missing from the token, the request is rejected with `401 Unauthorized`. If the claim value is not in `client_id_values`, the request is rejected with `403 Forbidden`.
> [!WARNING]
> `jwks_uri` must use a resolvable public HTTPS host. Local hosts, private networks, link-local addresses, and group-address destinations are rejected at connector creation.
+> If you configure `allowed-jwks-hosts`, the listed hosts bypass this rejection path intentionally.
+> This allow-list is configured under `idp.security.webhook.allowed-jwks-hosts`.
`jwks_uri` is stored as a literal URL in the connector configuration. Environment references are not supported for this field.
-### JWKS Host Allow-List
-
-The optional server-level property `idp.security.webhook.allowed-jwks-hosts` defaults to an empty list. Set the
-`ALLOWED_JWKS_HOSTS` environment variable to a comma-separated list of hosts to allow. When the list is non-empty,
-`jwks_uri` must use one of those hosts; allow-listed hosts bypass the private and local address rejection checks.
-The URI must still use HTTPS.
-
## Runtime Flow
The webhook runtime uses a single generic endpoint:
@@ -192,7 +199,7 @@ The request flow is:
You manage webhook connectors through the inbound webhook management API, which exposes standard CRUD methods.
| HTTP Method | Endpoint | Purpose |
-| ----------- | --------------------------------------- | ---------------- |
+|-------------|-----------------------------------------|------------------|
| `POST` | `/api/v1/inbound_webhooks` | Create connector |
| `GET` | `/api/v1/inbound_webhooks` | List connectors |
| `GET` | `/api/v1/inbound_webhooks/{identifier}` | Get connector |
diff --git a/pom.xml b/pom.xml
index 456b4c59..50dff004 100644
--- a/pom.xml
+++ b/pom.xml
@@ -273,7 +273,7 @@
+ execution -->
com.schibsted.spt.data
jslt
@@ -337,6 +337,21 @@
camel-rest-starter
+
+ org.springframework.boot
+ spring-boot-starter-opentelemetry
+
+
+
+ org.apache.camel.springboot
+ camel-opentelemetry2-starter
+
+
+
+ org.apache.camel.springboot
+ camel-micrometer-starter
+
+
@@ -545,6 +560,7 @@
org.hibernate.orm:hibernate-core:6.6.29.Final
ch.qos.logback:logback-classic:1.5.34
ch.qos.logback:logback-core:1.5.34
+ org.codehaus.mojo:animal-sniffer-annotations:1.26
diff --git a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java
index 9aba1e97..afbda12c 100644
--- a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java
+++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java
@@ -14,9 +14,10 @@ public record WebhookSecurity(WebhookSecurityType type, Map conf
if (type == null) {
throw new WebhookSecurityConfigurationException(WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY);
}
- if (config == null) {
+ // NONE needs no config: a missing config is normalized to an empty map
+ if (config == null && type != WebhookSecurityType.NONE) {
throw new WebhookSecurityConfigurationException(WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY);
}
- config = Map.copyOf(config);
+ config = config == null ? Map.of() : Map.copyOf(config);
}
}
diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java
index 97f95b33..2da3d4de 100644
--- a/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java
+++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java
@@ -48,7 +48,7 @@ public void validateForCreation(WebhookSecurity security) {
}
private void validateNoSecurityConfig(Map config) {
- if (!config.isEmpty()) {
+ if (config != null && !config.isEmpty()) {
throw new WebhookSecurityConfigurationException(
"Webhook security config must be empty when type is NONE");
}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java
index ee59b552..8d78b6aa 100644
--- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java
@@ -1,17 +1,18 @@
package com.decathlon.idp_core.infrastructure.adapters.api.dto.in;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY;
import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY;
import java.util.Map;
import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.NotNull;
/// Security contract request payload represented as `{ type, config }`.
+///
+/// `config` is optional here: it is only mandatory for strategies other than
+/// `NONE`, which the domain enforces.
public record InboundWebhookSecurityContractDtoIn(
@NotBlank(message = WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY) String type,
- @NotNull(message = WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY) Map config) {
+ Map config) {
public InboundWebhookSecurityContractDtoIn {
config = config != null ? Map.copyOf(config) : null;
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessor.java
index 3b6c6cb2..7a88233f 100644
--- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessor.java
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/IngestionProcessor.java
@@ -12,6 +12,7 @@
import com.decathlon.idp_core.domain.port.MappingEnginePort;
import com.decathlon.idp_core.domain.service.entity.EntityService;
+import io.opentelemetry.api.trace.Span;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -20,6 +21,8 @@
@RequiredArgsConstructor
public class IngestionProcessor {
+ private static final String SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER = "idp.webhook.identifier";
+
private final MappingEnginePort mappingEngine;
private final EntityService entityService;
@@ -37,11 +40,12 @@ public class IngestionProcessor {
/// @param webhookConnectorConfiguration the connector with mapping definitions
/// @param payload the raw JSON payload from the webhook
public void ingest(String payload, WebhookConnector webhookConnectorConfiguration) {
- log.info("Starting ingestion for webhook connector: {}",
- webhookConnectorConfiguration.identifier());
+ String connectorIdentifier = webhookConnectorConfiguration.identifier();
+ Span.current().setAttribute(SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER, connectorIdentifier);
+
+ log.info("Starting ingestion for webhook connector: {}", connectorIdentifier);
webhookConnectorConfiguration.mappings().forEach(mapping -> applyMapping(payload, mapping));
- log.info("Completed ingestion for webhook connector: {}",
- webhookConnectorConfiguration.identifier());
+ log.info("Completed ingestion for webhook connector: {}", connectorIdentifier);
}
/// Applies a single mapping to the payload and persists the resulting entity.
@@ -59,7 +63,6 @@ private void applyMapping(String payload, EntityDynamicMapping mapping) {
mapping.entityTemplateIdentifier());
return;
}
-
switch (mapping.action()) {
case UPDATE_ENTITY -> handleUpdate(entity);
case UPDATE_PROPERTIES -> handleUpdateProperties(entity);
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/SecurityProcessor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/SecurityProcessor.java
index bed34cd3..104f4ea4 100644
--- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/SecurityProcessor.java
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/processor/SecurityProcessor.java
@@ -12,6 +12,7 @@
import com.decathlon.idp_core.infrastructure.adapters.ingestion.exception.WebhookSecurityException;
import com.decathlon.idp_core.infrastructure.adapters.ingestion.security.WebhookRequestAuthenticator;
+import io.opentelemetry.api.trace.Span;
import lombok.extern.slf4j.Slf4j;
@Component
@@ -19,6 +20,9 @@
public class SecurityProcessor {
private static final String WEBHOOK_AUTHENTICATION_FAILED_MESSAGE = "Webhook authentication failed";
+ private static final String SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER = "idp.webhook.identifier";
+ private static final String SPAN_ATTRIBUTE_SECURITY_RESULT = "idp.webhook.security.result";
+ private static final String RESULT_SUCCESS = "SUCCESS";
private final List strategies;
@@ -28,24 +32,34 @@ public SecurityProcessor(List strategies) {
public void validate(Map headers, byte[] rawPayload,
WebhookConnector webhookConnector) {
+ String webhookIdentifier = webhookConnector.identifier();
+ Span.current().setAttribute(SPAN_ATTRIBUTE_WEBHOOK_IDENTIFIER, webhookIdentifier);
+
WebhookSecurity security = webhookConnector.security();
if (security == null || security.type() == WebhookSecurityType.NONE) {
+ Span.current().setAttribute(SPAN_ATTRIBUTE_SECURITY_RESULT, RESULT_SUCCESS);
return;
}
- WebhookSecurityStrategy strategy = strategies.stream()
- .filter(candidate -> candidate.supports(security.type())).findFirst()
- .orElseThrow(() -> new WebhookSecurityException(WEBHOOK_AUTHENTICATION_FAILED_MESSAGE));
+ try {
+ WebhookSecurityStrategy strategy = strategies.stream()
+ .filter(candidate -> candidate.supports(security.type())).findFirst()
+ .orElseThrow(() -> new WebhookSecurityException(WEBHOOK_AUTHENTICATION_FAILED_MESSAGE));
- if (!(strategy instanceof WebhookRequestAuthenticator authenticator)) {
- throw new WebhookSecurityException(WEBHOOK_AUTHENTICATION_FAILED_MESSAGE);
- }
+ if (!(strategy instanceof WebhookRequestAuthenticator authenticator)) {
+ throw new WebhookSecurityException(WEBHOOK_AUTHENTICATION_FAILED_MESSAGE);
+ }
- authenticator.validateRequest(headers, rawPayload == null ? new byte[0] : rawPayload,
- security.config());
+ authenticator.validateRequest(headers, rawPayload == null ? new byte[0] : rawPayload,
+ security.config());
- log.debug("Webhook security validation passed for connector '{}' with strategy '{}'.",
- webhookConnector.identifier(), security.type());
+ Span.current().setAttribute(SPAN_ATTRIBUTE_SECURITY_RESULT, RESULT_SUCCESS);
+ log.debug("Webhook security validation passed for connector '{}' with strategy '{}'.",
+ webhookIdentifier, security.type());
+ } catch (RuntimeException exception) {
+ Span.current().setAttribute(SPAN_ATTRIBUTE_SECURITY_RESULT, "REJECTED");
+ throw exception;
+ }
}
}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidator.java
index 466e1d38..2ac0f026 100644
--- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidator.java
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidator.java
@@ -21,6 +21,9 @@
import com.decathlon.idp_core.infrastructure.adapters.ingestion.exception.WebhookAuthForbiddenException;
import com.decathlon.idp_core.infrastructure.adapters.ingestion.exception.WebhookAuthUnauthorizedException;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
@Component
public class JwtBearerSecurityValidator
implements
@@ -37,8 +40,10 @@ public class JwtBearerSecurityValidator
private static final String KEY_EXPECTED_AUDIENCE_CAMEL_CASE = "expectedAudience";
private static final String BEARER_PREFIX = "Bearer ";
- private static final String CLIENT_CLAIM_AZP = "azp";
- private static final String CLIENT_CLAIM_EMAIL = "email";
+ private static final String CALLER_RESULT_ACCEPTED = "ACCEPTED";
+ private static final String CALLER_RESULT_REJECTED = "REJECTED";
+ private static final String CLAIM_EMAIL = "email";
+ private static final int MAX_LOGGED_CALLER_LENGTH = 128;
private final WebhookJwtDecoderProvider jwtDecoderProvider;
private final Set allowedJwksHosts;
@@ -81,9 +86,9 @@ public void validateConfiguration(Map config) {
"Invalid client_id_field for JWT_BEARER security: runtime environment references are not supported");
}
- if (!CLIENT_CLAIM_AZP.equals(clientIdField) && !CLIENT_CLAIM_EMAIL.equals(clientIdField)) {
+ if (!StringUtils.hasText(clientIdField)) {
throw new WebhookSecurityConfigurationException(
- "Invalid client_id_field for JWT_BEARER security: must be 'azp' (Client ID) or 'email' (Service Account)");
+ "Invalid client_id_field for JWT_BEARER security: must be a non-empty claim name");
}
String clientIdValues = WebhookSecurityConfigurationUtils.required(config,
@@ -137,18 +142,52 @@ public void validateRequest(Map headers, byte[] rawPayload,
String actualCallerIdentity = jwt.getClaimAsString(clientIdField);
if (!StringUtils.hasText(actualCallerIdentity)) {
- throw new WebhookAuthForbiddenException("JWT missing required claim: " + clientIdField);
+ logCallerOutcome(clientIdField, null, CALLER_RESULT_REJECTED);
+ throw new WebhookAuthUnauthorizedException("JWT missing required claim: " + clientIdField);
}
Set allowedIdentities = parseAllowedClientIdValues(clientIdValues);
if (!allowedIdentities.contains(actualCallerIdentity)) {
+ logCallerOutcome(clientIdField, actualCallerIdentity, CALLER_RESULT_REJECTED);
throw new WebhookAuthForbiddenException(
"JWT client or service account identifier was rejected");
}
if (StringUtils.hasText(optionalExpectedAudience)) {
- validateAudienceClaim(jwt, optionalExpectedAudience);
+ try {
+ validateAudienceClaim(jwt, optionalExpectedAudience);
+ } catch (WebhookAuthForbiddenException exception) {
+ logCallerOutcome(clientIdField, actualCallerIdentity, CALLER_RESULT_REJECTED);
+ throw exception;
+ }
+ }
+
+ logCallerOutcome(clientIdField, actualCallerIdentity, CALLER_RESULT_ACCEPTED);
+ }
+
+ /// Logs the caller identity and the validation outcome. The token itself is
+ /// never logged. The caller value is omitted when the identifying claim is
+ /// `email`, because an email address is personal data.
+ private void logCallerOutcome(String claimName, String caller, String result) {
+ String loggedCaller = resolveLoggedCaller(claimName, caller);
+ if (CALLER_RESULT_ACCEPTED.equals(result)) {
+ log.info("webhook_jwt_caller claim={} caller={} result={}", claimName, loggedCaller, result);
+ } else {
+ log.warn("webhook_jwt_caller claim={} caller={} result={}", claimName, loggedCaller, result);
+ }
+ }
+
+ private String resolveLoggedCaller(String claimName, String caller) {
+ if (caller == null) {
+ return "";
+ }
+ if (CLAIM_EMAIL.equalsIgnoreCase(claimName)) {
+ return "";
}
+ String sanitized = caller.replaceAll("\\p{Cntrl}", "_");
+ return sanitized.length() > MAX_LOGGED_CALLER_LENGTH
+ ? sanitized.substring(0, MAX_LOGGED_CALLER_LENGTH)
+ : sanitized;
}
/// Validates the jwks_uri to prevent SSRF attacks.
@@ -220,8 +259,8 @@ private String resolveRequiredClientIdField(Map config) {
} catch (WebhookSecurityConfigurationException _) {
throw new WebhookSecurityConfigurationException(
"Missing required JWT_BEARER config key. Expected one of: client_id_field, clientIdField. "
- + "Allowed values: 'azp' (Client ID) or 'email' (Service Account). "
- + "Example: \"client_id_field\": \"email\"");
+ + "Value must be a non-empty JWT claim name (for example 'client_id', 'azp', 'email'). "
+ + "Example: \"client_id_field\": \"client_id\"");
}
}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index a443a8ec..83ac46c2 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -180,18 +180,34 @@ logging:
management:
tracing:
- # Tracing is disabled by default and only enabled via environment variables (OTEL_TRACING_ENABLED).
- enabled: false
sampling:
- probability: 1.0 # 100% des traces
+ # Fraction of traces kept (1.0 = 100%). Standard OTel variable.
+ probability: ${OTEL_TRACES_SAMPLER_ARG:0.1}
+ export:
+ enabled: ${OTEL_TRACING_ENABLED:false}
+ # Trace OTLP export lives under management.opentelemetry.tracing.export.otlp.* (see below).
otlp:
+ metrics:
+ export:
+ # Metrics export is opt-in. Signal-specific variables win over the generic ones.
+ url: ${OTEL_EXPORTER_OTLP_METRICS_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/metrics}
+ enabled: ${OTEL_METRICS_EXPORT_ENABLED:false}
+ transport: ${OTEL_EXPORTER_OTLP_METRICS_PROTOCOL:${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf}}
+ opentelemetry:
tracing:
- endpoint: ${OPEN_TELEMETRY_URL:""}
- headers:
- swagger:
- enabled: ${APP_SECURITY_SWAGGER_ENABLED:true}
- authorization: ${OPEN_TELEMETRY_TOKEN:""}
+ export:
+ otlp:
+ # Boot does not append /v1/traces itself: the full traces URL is required for HTTP.
+ endpoint: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/traces}
+ # Boot's transport enum only accepts http or grpc (not the OTel value http/protobuf).
+ transport: ${OTEL_EXPORTER_OTLP_TRACES_TRANSPORT:${OTEL_EXPORTER_OTLP_PROTOCOL:http}}
+ # Extra attributes (for example X-Datadog-Org, X-Backend) are set by the user
+ # through OTEL_RESOURCE_ATTRIBUTES, not hard-coded here.
+ resource-attributes:
+ deployment.environment: ${OTEL_ENV:local}
+ service.namespace: ${OTEL_SERVICE_NAMESPACE:idp-core}
+ service.name: ${OTEL_SERVICE_NAME:internal-developer-platform}
endpoints:
web:
@@ -222,22 +238,7 @@ management:
# Avoids leaking internal infrastructure info to unauthenticated callers.
show-details: when_authorized
+
camel:
- component:
- platform-http:
- engine: "servlet"
- # Never expose the health "details" map (e.g. database product name/version,
- # validation query, disk space paths). Only UP/DOWN status is shown.
- show-details: never
- # Still surface per-component status (e.g. db: UP/DOWN) so direct
- # dependencies can be checked, without leaking their details.
- show-components: always
- health:
- diskspace:
- # DiskSpace is not a direct dependency and its free/threshold values
- # are not relevant to consumers checking service health.
- enabled: false
- ssl:
- # SSL certificate/chain info is internal infrastructure detail and
- # must not be exposed via actuator.
- enabled: false
+ opentelemetry2:
+ enabled: true
diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java
index c916884b..69611574 100644
--- a/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java
+++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java
@@ -2,6 +2,7 @@
import static com.decathlon.idp_core.domain.model.enums.WebhookSecurityType.HMAC_SHA256;
import static com.decathlon.idp_core.domain.model.enums.WebhookSecurityType.JWT_BEARER;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.lenient;
@@ -96,6 +97,15 @@ void shouldPassForNoneTypeWithEmptyConfig() {
assertThatCode(() -> service.validateForCreation(security)).doesNotThrowAnyException();
}
+ @Test
+ @DisplayName("Should normalize a null config of a NONE type to an empty map and pass")
+ void shouldNormalizeNullConfigForNoneType() {
+ WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.NONE, null);
+
+ assertThat(security.config()).isNotNull().isEmpty();
+ assertThatCode(() -> service.validateForCreation(security)).doesNotThrowAnyException();
+ }
+
@Test
@DisplayName("Should throw for NONE type with non-empty config")
void shouldThrowForNoneTypeWithNonEmptyConfig() {
diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java
index d2e62838..df8a9e06 100644
--- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java
+++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java
@@ -602,6 +602,28 @@ void putWebhook_200() throws Exception {
assertWebhookTemplateMapping("connector-put-200");
}
+ @Test
+ @WithMockUser
+ @DisplayName("Should update to NONE security when config is omitted")
+ void putWebhook_200_none_security_without_config() throws Exception {
+ createWebhookConnector("connector-put-none-no-config", "Connector Put None Initial");
+
+ var payload = """
+ {
+ "name": "Connector Put None Updated",
+ "description": "updated without security config",
+ "enabled": false,
+ "mapping_identifiers": ["connector-put-none-no-config-mapping"],
+ "security": { "type": "NONE" }
+ }
+ """;
+ mockMvc
+ .perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/connector-put-none-no-config")
+ .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload))
+ .andExpect(status().isOk()).andExpect(jsonPath("$.security.type").value("NONE"))
+ .andExpect(jsonPath("$.security.config").isEmpty());
+ }
+
@Test
@WithMockUser
@DisplayName("Should return 404 when connector does not exist")
@@ -1028,19 +1050,71 @@ void security_none_empty_config_201() throws Exception {
@WithMockUser
@DisplayName("NONE — Should create connector without config field")
void security_none_no_config_201() throws Exception {
+ // The "config" key is truly omitted from the request body.
var payload = """
{
"identifier": "sec-none-no-config",
"name": "None No Config",
"enabled": false,
"mapping_identifiers": [],
- "security": { "type": "NONE", "config":{} }
+ "security": { "type": "NONE" }
}
""";
mockMvc
.perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON)
.accept(APPLICATION_JSON).with(csrf()).content(payload))
- .andExpect(status().isCreated()).andExpect(jsonPath("$.security.type").value("NONE"));
+ .andExpect(status().isCreated()).andExpect(jsonPath("$.security.type").value("NONE"))
+ .andExpect(jsonPath("$.security.config").isEmpty());
+ }
+
+ @Test
+ @WithMockUser
+ @DisplayName("NONE — Should create connector with a null config")
+ void security_none_null_config_201() throws Exception {
+ var payload = """
+ {
+ "identifier": "sec-none-null-config",
+ "name": "None Null Config",
+ "enabled": false,
+ "mapping_identifiers": [],
+ "security": { "type": "NONE", "config": null }
+ }
+ """;
+ mockMvc
+ .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON)
+ .accept(APPLICATION_JSON).with(csrf()).content(payload))
+ .andExpect(status().isCreated()).andExpect(jsonPath("$.security.type").value("NONE"))
+ .andExpect(jsonPath("$.security.config").isEmpty());
+ }
+
+ @Test
+ @WithMockUser
+ @DisplayName("NONE — Should return 400 when config is not empty")
+ void security_none_non_empty_config_400() throws Exception {
+ mockMvc
+ .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON)
+ .accept(APPLICATION_JSON).with(csrf()).content(buildSecurityPayload(
+ "sec-none-with-config", "None With Config", "NONE", "{ \"a\": \"b\" }")))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @WithMockUser
+ @DisplayName("HMAC_SHA256 — Should return 400 when config is omitted")
+ void security_hmac_no_config_400() throws Exception {
+ var payload = """
+ {
+ "identifier": "sec-hmac-no-config",
+ "name": "HMAC No Config",
+ "enabled": false,
+ "mapping_identifiers": [],
+ "security": { "type": "HMAC_SHA256" }
+ }
+ """;
+ mockMvc
+ .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON)
+ .accept(APPLICATION_JSON).with(csrf()).content(payload))
+ .andExpect(status().isBadRequest());
}
@Test
diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java
index 843cafc1..4e56a120 100644
--- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java
+++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java
@@ -96,6 +96,32 @@ void shouldMapNoneSecurityTypeExplicitly() {
assertThat(domain.security().config()).isEmpty();
}
+ @Test
+ @DisplayName("Should normalize an omitted (null) config of a NONE security to an empty map")
+ void shouldNormalizeNullConfigForNoneSecurity() {
+ InboundWebhookCreateDtoIn request = new InboundWebhookCreateDtoIn("my-connector", "No Auth",
+ "Webhook without authentication", true, List.of("deployment-mapping"),
+ new InboundWebhookSecurityContractDtoIn("NONE", null));
+
+ WebhookConnector domain = mapper.toDomain(request, List.of(resolvedMapping()));
+
+ assertThat(domain.security().type()).isEqualTo(WebhookSecurityType.NONE);
+ assertThat(domain.security().config()).isNotNull().isEmpty();
+ }
+
+ @Test
+ @DisplayName("Should normalize an omitted (null) config on update for a NONE security")
+ void shouldNormalizeNullConfigOnUpdateForNoneSecurity() {
+ InboundWebhookUpdateDtoIn request = new InboundWebhookUpdateDtoIn("No Auth",
+ "Webhook without authentication", true, List.of("deployment-mapping"),
+ new InboundWebhookSecurityContractDtoIn("NONE", null));
+
+ WebhookConnector domain = mapper.toDomainForUpdate("identifier_from_path", request,
+ List.of(resolvedMapping()));
+
+ assertThat(domain.security().config()).isNotNull().isEmpty();
+ }
+
@Test
@DisplayName("Should default to NONE when security section is missing")
void shouldDefaultToNoneWhenSecurityIsMissing() {
diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidatorTest.java
index a6da25bd..192ce240 100644
--- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidatorTest.java
+++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/security/JwtBearerSecurityValidatorTest.java
@@ -225,15 +225,29 @@ void shouldThrowWhenClientIdValuesIsEnvironmentReference() {
}
@Test
- @DisplayName("Should throw when client_id_field is neither azp nor email")
- void shouldThrowWhenClientIdFieldIsUnsupported() {
+ @DisplayName("Should accept any non-empty claim name as client_id_field")
+ void shouldAcceptAnyNonEmptyClientIdField() {
+ JwtBearerSecurityValidator safeValidator = validatorWithPublicHostResolution();
+
+ for (String field : List.of("client_id", "sub", "azp", "email", "custom_tenant_id")) {
+ Map config = Map.of("jwks_uri", "https://issuer/.well-known/jwks.json",
+ "client_id_field", field, "client_id_values", "expected@example.com");
+
+ assertThatCode(() -> safeValidator.validateConfiguration(config))
+ .doesNotThrowAnyException();
+ }
+ }
+
+ @Test
+ @DisplayName("Should throw when client_id_field is blank")
+ void shouldThrowWhenClientIdFieldIsBlank() {
JwtBearerSecurityValidator safeValidator = validatorWithPublicHostResolution();
Map config = Map.of("jwks_uri", "https://issuer/.well-known/jwks.json",
- "client_id_field", "sub", "client_id_values", "expected@example.com");
+ "client_id_field", " ", "client_id_values", "expected@example.com");
assertThatThrownBy(() -> safeValidator.validateConfiguration(config)).isInstanceOf(
com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException.class)
- .hasMessageContaining("azp").hasMessageContaining("email");
+ .hasMessageContaining("client_id_field");
}
@Test
@@ -394,6 +408,33 @@ void shouldUseAzpClaimWhenConfigured() {
.doesNotThrowAnyException();
}
+ @Test
+ @DisplayName("Should accept custom client_id claim when configured")
+ void shouldUseCustomClientIdClaim() {
+ when(jwtDecoderProvider.get("https://issuer/.well-known/jwks.json")).thenReturn(jwtDecoder);
+ Map config = Map.of("jwks_uri", "https://issuer/.well-known/jwks.json",
+ "client_id_field", "client_id", "client_id_values", "app_abc123");
+ Map headers = Map.of("Authorization", "Bearer signed-token");
+ when(jwtDecoder.decode("signed-token")).thenReturn(jwtWithClaim("client_id", "app_abc123"));
+
+ assertThatCode(() -> validator.validateRequest(headers, new byte[0], config))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("Should return unauthorized when configured claim is missing in JWT")
+ void shouldReturnUnauthorizedWhenConfiguredClaimMissing() {
+ when(jwtDecoderProvider.get("https://issuer/.well-known/jwks.json")).thenReturn(jwtDecoder);
+ Map config = Map.of("jwks_uri", "https://issuer/.well-known/jwks.json",
+ "client_id_field", "custom_tenant_id", "client_id_values", "tenant");
+ Map headers = Map.of("Authorization", "Bearer signed-token");
+ when(jwtDecoder.decode("signed-token")).thenReturn(jwtWithClaim("email", "a@b.c"));
+
+ assertThatThrownBy(() -> validator.validateRequest(headers, new byte[0], config))
+ .isInstanceOf(WebhookAuthUnauthorizedException.class)
+ .hasMessageContaining("custom_tenant_id");
+ }
+
@Test
@DisplayName("Should reject request when client_id_field is missing at runtime")
void shouldRejectWhenClientIdFieldMissingAtRuntime() {
diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml
index 3ffe9025..22564216 100644
--- a/src/test/resources/application-test.yml
+++ b/src/test/resources/application-test.yml
@@ -61,6 +61,9 @@ app:
jwks-env-key: JWKS_URI
jwks-env-value: https://auth.example.com/.well-known/jwks.json
management:
+ # Tests must not create or export traces.
+ tracing:
+ enabled: false
endpoint:
health:
probes: