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: