initial-commit

This commit is contained in:
Guillaume GRABÉ
2026-05-12 17:06:43 +02:00
commit 051a080dfa
110 changed files with 26377 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "charybdis-defectdojo-plugin"
version = "0.1.0"
edition = "2021"
[lib]
name = "charybdis_defectdojo"
path = "src/lib.rs"
[dependencies]
charybdis = { path = "../.." }
anyhow = "1.0"
async-trait = "0.1"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
tokio = { version = "1.48", features = ["full"] }
tracing = "0.1"
[dev-dependencies]
tokio-test = "0.4"
wiremock = "0.6"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] }
+301
View File
@@ -0,0 +1,301 @@
# DefectDojo Plugin for Charybdis
Event-driven plugin that synchronizes Charybdis entities with DefectDojo security testing platform.
## Overview
The DefectDojo plugin automatically provisions and manages resources in DefectDojo based on entity lifecycle events in Charybdis. It provides bidirectional mapping between Charybdis entities and DefectDojo resources.
## Supported Resources
| Charybdis Entity | DefectDojo Resource | Trigger |
|-----------------|---------------------|---------|
| Component | Product | Create/Update/Delete Component |
| User | User | Create/Update/Delete User |
| System | Product Type | Create/Update/Delete System |
| Group | Product Member | Create/Update/Delete Group with annotation |
| Resource | Engagement | Create/Update/Delete Resource with annotation |
## Configuration
Add the DefectDojo plugin configuration to your `config.toml`:
```toml
[plugins.defectdojo]
enabled = true
base_url = "${DEFECTDOJO_URL}"
api_token = "${DEFECTDOJO_API_TOKEN}"
default_product_type_id = 1
auto_create_users = true
auto_create_product_types = false
```
### Environment Variables
- `DEFECTDOJO_URL`: Base URL of your DefectDojo instance (e.g., `https://defectdojo.example.com`)
- `DEFECTDOJO_API_TOKEN`: API token for authentication
### Field Mappings
Define how Charybdis entity fields map to DefectDojo API fields:
```toml
[plugins.defectdojo.field_mappings.product]
name = "metadata.name"
description = "metadata.description"
business_criticality = { value = "high" }
product_type_id = { value = 1 }
```
#### Mapping Types
1. **Direct field mapping**: `"metadata.name"` - Extract field from entity
2. **Static value**: `{ value = "high" }` - Use static value
3. **Entity resolution**: Resolve entity references
```toml
product_manager = {
from = "spec.owner",
resolve_entity = "User",
extract = "annotations.defectdojo.com/user-id"
}
```
## Usage Examples
### 1. Create a Product from Component
```yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: payment-service
description: Payment processing service
tags:
- payment
- critical
spec:
type: service
lifecycle: production
owner: platform-team
```
When this Component is created in Charybdis, the plugin automatically:
1. Creates a Product in DefectDojo
2. Stores the DefectDojo product ID in annotations: `defectdojo.com/product-id`
### 2. Create Users
```yaml
apiVersion: backstage.io/v1alpha1
kind: User
metadata:
name: john.doe
spec:
profile:
displayName: John Doe
email: john.doe@example.com
memberOf:
- platform-team
```
The plugin creates a DefectDojo user and stores the user ID in annotations.
### 3. Add Product Members
Create a Group entity with a special annotation to trigger product member creation:
```yaml
apiVersion: backstage.io/v1alpha1
kind: Group
metadata:
name: payment-service-security-team
annotations:
defectdojo.com/product-member: "true"
spec:
type: team
parent: component:payment-service # Reference to Component
members:
- user:john.doe
- user:jane.smith
```
The plugin resolves:
- `parent` → Component → DefectDojo Product ID
- `members` → Users → DefectDojo User IDs
- Creates Product Member entries with specified role
### 4. Create Engagements
```yaml
apiVersion: backstage.io/v1alpha1
kind: Resource
metadata:
name: payment-service-q1-security-assessment
annotations:
defectdojo.com/engagement: "true"
version: "2.1.0"
commit_hash: "abc123"
spec:
type: security-assessment
owner: component:payment-service
dependsOn:
- user:john.doe # Lead
target_start: "2025-01-01"
target_end: "2025-03-31"
```
## Annotations
The plugin uses annotations to:
1. **Store DefectDojo IDs** (auto-managed):
- `defectdojo.com/product-id`
- `defectdojo.com/user-id`
- `defectdojo.com/product-type-id`
- `defectdojo.com/product-member-id`
- `defectdojo.com/engagement-id`
2. **Trigger special behaviors**:
- `defectdojo.com/product-member: "true"` - Create product members from Group
- `defectdojo.com/engagement: "true"` - Create engagement from Resource
## Entity Resolution
The plugin supports complex field mappings that resolve entity references:
```toml
[plugins.defectdojo.field_mappings.product]
product_manager = {
from = "spec.owner", # Get owner field from entity
resolve_entity = "User", # Resolve as User entity
extract = "annotations.defectdojo.com/user-id" # Extract DD user ID
}
```
This allows you to reference Users by entity ID in Charybdis, and the plugin automatically resolves to DefectDojo user IDs.
### Array Resolution
For resolving arrays of entities (like group members):
```toml
user_id = {
from = "spec.members",
resolve_entity = "User",
extract = "annotations.defectdojo.com/user-id",
resolve_array = true
}
```
## Architecture
```
┌─────────────────┐
│ Charybdis │
│ (gRPC API) │
└────────┬────────┘
│ Entity Events
▼
┌─────────────────┐
│ Event Dispatcher│
└────────┬────────┘
│
▼
┌─────────────────────────────┐
│ DefectDojo Plugin │
│ ┌────────────────────────┐ │
│ │ ProductHandler │ │
│ │ UserHandler │ │
│ │ ProductTypeHandler │ │
│ │ ProductMemberHandler │ │
│ │ EngagementHandler │ │
│ └────────────────────────┘ │
└──────────┬──────────────────┘
│ HTTP API
▼
┌─────────────────┐
│ DefectDojo │
│ (REST API) │
└─────────────────┘
```
## Resource Handlers
Each resource handler implements:
- `handle_create()`: Create resource in DefectDojo when entity created
- `handle_update()`: Update resource in DefectDojo when entity updated
- `handle_delete()`: Delete/deactivate resource in DefectDojo when entity deleted
## Error Handling
- Failed API calls are logged with full error details
- Entity is still created/updated in Charybdis even if DefectDojo sync fails
- Missing DefectDojo IDs trigger automatic creation
- User deletion deactivates users instead of deleting (DefectDojo best practice)
## Development
### Building
```bash
cargo build
```
### Testing
```bash
cargo test
```
### Adding New Resource Types
1. Create new handler in `src/handlers/`
2. Implement `ResourceHandler` trait
3. Add handler to plugin in `src/lib.rs`
4. Define field mappings in config
## API Reference
### DefectDojo API Endpoints Used
- `POST /api/v2/products/` - Create product
- `PUT /api/v2/products/{id}/` - Update product
- `DELETE /api/v2/products/{id}/` - Delete product
- `POST /api/v2/users/` - Create user
- `PUT /api/v2/users/{id}/` - Update user
- `POST /api/v2/product_types/` - Create product type
- `POST /api/v2/product_members/` - Create product member
- `DELETE /api/v2/product_members/{id}/` - Remove product member
- `POST /api/v2/engagements/` - Create engagement
- `PUT /api/v2/engagements/{id}/` - Update engagement
## Security
- API token should be stored in environment variables, not committed to git
- Use mTLS for Charybdis gRPC connections
- DefectDojo HTTPS endpoint recommended for production
## Troubleshooting
### Entity not syncing to DefectDojo
1. Check plugin is enabled in `config.toml`
2. Verify `DEFECTDOJO_API_TOKEN` is set
3. Check entity kind matches handler trigger kinds
4. Review logs for API errors
### Missing DefectDojo IDs
If annotations are missing:
- Plugin will attempt to create resource on next update
- Check for errors in creation logs
- Verify field mappings provide required fields
### User already exists errors
- Plugin checks for existing users by email before creating
- Will reuse existing user ID instead of creating duplicate
## License
Part of Charybdis project.
+25
View File
@@ -0,0 +1,25 @@
syntax = "proto3";
package charybdis.plugins.defectdojo;
// DefectDojo plugin metadata
// This is used for plugin-specific configuration entities
message DefectdojoMetadata {
string name = 1;
string description = 2;
// Resource type (product_type, product_member, engagement)
string resource_type = 3;
}
// DefectDojo plugin spec
// This is used for plugin-specific configuration entities
message DefectdojoSpec {
// Configuration data stored as JSON
string config_json = 1;
// Sync status
string sync_status = 2;
string last_sync = 3;
string error_message = 4;
}
@@ -0,0 +1,351 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use charybdis::charybdis::entities::Entity;
use charybdis::database::EntityRepository;
use charybdis::plugins::{date_utils, field_mapper::FieldMapper, ResourceHandler};
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{info, warn};
use crate::{DefectDojoClient, EngagementConfig};
/// Engagement handler - Maps assessment/testing entities to DefectDojo Engagements
pub struct EngagementHandler {
client: DefectDojoClient,
repository: Arc<EntityRepository>,
field_mapper: FieldMapper,
trigger_kinds: Vec<String>,
}
impl EngagementHandler {
pub fn new(
client: DefectDojoClient,
repository: Arc<EntityRepository>,
field_mappings: HashMap<String, serde_json::Value>,
) -> Self {
let field_mapper = FieldMapper::new(field_mappings)
.unwrap_or_else(|e| {
warn!("Failed to create field mapper: {}, using empty mapper", e);
FieldMapper::new(HashMap::new()).unwrap()
})
.with_repository(repository.clone());
Self {
client,
repository,
field_mapper,
trigger_kinds: vec!["Resource".to_string()],
}
}
/// Create a default engagement for a newly created product
/// This is used by ProductHandler to auto-create engagements when products are created
pub async fn create_for_product(
&self,
product_id: i32,
entity: &Entity,
engagement_config: &EngagementConfig,
) -> Result<i32> {
info!(
"Creating default engagement for product {} (entity: {})",
product_id, entity.id
);
// Generate date range for engagement
let (target_start, target_end) =
date_utils::engagement_date_range(engagement_config.duration_days);
// Build DefectDojo engagement payload
let payload = json!({
"name": engagement_config.name,
"description": engagement_config.description,
"product": product_id,
"target_start": target_start,
"target_end": target_end,
"status": engagement_config.status,
"engagement_type": engagement_config.engagement_type,
"deduplication_on_engagement": engagement_config.deduplication_on_engagement,
});
// Note: Optional fields like version and source_code_management_uri could be added
// from entity metadata/spec if needed, but requires matching on the spec variant.
// For now, these are left empty and can be set via field mappings if needed.
// Create engagement in DefectDojo
let response = self.client.post("api/v2/engagements/", &payload).await?;
let engagement_id = response["id"]
.as_i64()
.ok_or_else(|| anyhow!("DefectDojo did not return engagement ID"))?
as i32;
info!(
"Created default engagement {} for product {} (entity: {})",
engagement_id, product_id, entity.id
);
Ok(engagement_id)
}
/// Get DefectDojo engagement ID from entity annotations
fn get_engagement_id(&self, entity: &Entity) -> Option<i32> {
entity
.annotations
.get("defectdojo.com/engagement-id")
.and_then(|id| id.parse().ok())
}
/// Create engagement in DefectDojo
async fn create_engagement(&self, entity: &Entity) -> Result<i32> {
info!("Creating DefectDojo engagement for entity: {}", entity.id);
// Map fields from entity to DefectDojo API format
let mapped = self.field_mapper.map_all(entity).await?;
// Extract required fields
let name = mapped
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("name is required"))?;
let product_id = mapped
.get("product_id")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow!("product_id is required"))? as i32;
let target_start = mapped
.get("target_start")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("target_start is required"))?;
let target_end = mapped
.get("target_end")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("target_end is required"))?;
// Build DefectDojo engagement payload
let mut payload = json!({
"name": name,
"product": product_id,
"target_start": target_start,
"target_end": target_end,
"status": mapped.get("status").and_then(|v| v.as_str()).unwrap_or("In Progress"),
});
// Add optional fields
if let Some(description) = mapped.get("description") {
payload["description"] = description.clone();
}
if let Some(lead_id) = mapped.get("lead_id").and_then(|v| v.as_i64()) {
payload["lead"] = json!(lead_id);
}
if let Some(engagement_type) = mapped.get("engagement_type") {
payload["engagement_type"] = engagement_type.clone();
}
if let Some(build_id) = mapped.get("build_id") {
payload["build_id"] = build_id.clone();
}
if let Some(version) = mapped.get("version") {
payload["version"] = version.clone();
}
if let Some(commit_hash) = mapped.get("commit_hash") {
payload["commit_hash"] = commit_hash.clone();
}
if let Some(branch_tag) = mapped.get("branch_tag") {
payload["branch_tag"] = branch_tag.clone();
}
if let Some(source_code_management_uri) = mapped.get("source_code_management_uri") {
payload["source_code_management_uri"] = source_code_management_uri.clone();
}
if let Some(deduplication_on_engagement) = mapped.get("deduplication_on_engagement") {
payload["deduplication_on_engagement"] = deduplication_on_engagement.clone();
}
if let Some(threat_model) = mapped.get("threat_model") {
payload["threat_model"] = threat_model.clone();
}
if let Some(api_test) = mapped.get("api_test") {
payload["api_test"] = api_test.clone();
}
if let Some(pen_test) = mapped.get("pen_test") {
payload["pen_test"] = pen_test.clone();
}
if let Some(check_list) = mapped.get("check_list") {
payload["check_list"] = check_list.clone();
}
// Create engagement in DefectDojo
let response = self.client.post("api/v2/engagements/", &payload).await?;
let engagement_id = response["id"]
.as_i64()
.ok_or_else(|| anyhow!("DefectDojo did not return engagement ID"))?
as i32;
info!(
"Created DefectDojo engagement {} for entity {}",
engagement_id, entity.id
);
Ok(engagement_id)
}
/// Update engagement in DefectDojo
async fn update_engagement(&self, entity: &Entity, engagement_id: i32) -> Result<()> {
info!(
"Updating DefectDojo engagement {} for entity {}",
engagement_id, entity.id
);
// Map fields from entity to DefectDojo API format
let mapped = self.field_mapper.map_all(entity).await?;
// Build DefectDojo engagement payload
let mut payload = json!({});
if let Some(name) = mapped.get("name") {
payload["name"] = name.clone();
}
if let Some(description) = mapped.get("description") {
payload["description"] = description.clone();
}
if let Some(status) = mapped.get("status") {
payload["status"] = status.clone();
}
if let Some(target_start) = mapped.get("target_start") {
payload["target_start"] = target_start.clone();
}
if let Some(target_end) = mapped.get("target_end") {
payload["target_end"] = target_end.clone();
}
if let Some(lead_id) = mapped.get("lead_id").and_then(|v| v.as_i64()) {
payload["lead"] = json!(lead_id);
}
if let Some(version) = mapped.get("version") {
payload["version"] = version.clone();
}
if let Some(commit_hash) = mapped.get("commit_hash") {
payload["commit_hash"] = commit_hash.clone();
}
if let Some(branch_tag) = mapped.get("branch_tag") {
payload["branch_tag"] = branch_tag.clone();
}
// Update engagement in DefectDojo
self.client
.put(&format!("api/v2/engagements/{}/", engagement_id), &payload)
.await?;
info!(
"Updated DefectDojo engagement {} for entity {}",
engagement_id, entity.id
);
Ok(())
}
/// Delete engagement in DefectDojo
async fn delete_engagement(&self, engagement_id: i32) -> Result<()> {
info!("Deleting DefectDojo engagement {}", engagement_id);
self.client
.delete(&format!("api/v2/engagements/{}/", engagement_id))
.await?;
info!("Deleted DefectDojo engagement {}", engagement_id);
Ok(())
}
}
#[async_trait]
impl ResourceHandler for EngagementHandler {
fn resource_type(&self) -> &str {
"defectdojo_engagement"
}
fn trigger_kinds(&self) -> &[String] {
// Trigger on Resource entities with defectdojo.com/engagement annotation
&self.trigger_kinds
}
fn creates_entity_kind(&self) -> &str {
""
}
async fn handle_create(&self, entity: &Entity) -> Result<Option<Entity>> {
// Create engagement in DefectDojo
let engagement_id = self.create_engagement(entity).await?;
// Update only the annotations on the entity
let mut annotations = HashMap::new();
annotations.insert(
"defectdojo.com/engagement-id".to_string(),
engagement_id.to_string(),
);
self.repository
.update_annotations(&entity.id, annotations)
.await?;
Ok(None)
}
async fn handle_update(&self, entity: &Entity) -> Result<()> {
// Get DefectDojo engagement ID from annotations
if let Some(engagement_id) = self.get_engagement_id(entity) {
self.update_engagement(entity, engagement_id).await?;
} else {
warn!(
"Entity {} has no DefectDojo engagement ID, creating new engagement",
entity.id
);
let engagement_id = self.create_engagement(entity).await?;
// Update only the annotations on the entity
let mut annotations = HashMap::new();
annotations.insert(
"defectdojo.com/engagement-id".to_string(),
engagement_id.to_string(),
);
self.repository
.update_annotations(&entity.id, annotations)
.await?;
}
Ok(())
}
async fn handle_delete(&self, entity: &Entity) -> Result<()> {
// Get DefectDojo engagement ID and delete
if let Some(engagement_id) = self.get_engagement_id(entity) {
self.delete_engagement(engagement_id).await?;
} else {
warn!(
"Entity {} has no DefectDojo engagement ID, skipping deletion",
entity.id
);
}
Ok(())
}
}
+9
View File
@@ -0,0 +1,9 @@
mod engagement;
mod product;
mod product_member;
mod product_type;
pub use engagement::EngagementHandler;
pub use product::ProductHandler;
pub use product_member::ProductMemberHandler;
pub use product_type::ProductTypeHandler;
+594
View File
@@ -0,0 +1,594 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use charybdis::charybdis::entities::Entity;
use charybdis::database::EntityRepository;
use charybdis::plugins::{
annotations::AnnotationHelper, field_mapper::FieldMapper, ResourceHandler,
};
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{info, warn};
use crate::{handlers::engagement::EngagementHandler, DefectDojoClient, DefectDojoConfig};
/// Product handler - Maps Component entities to DefectDojo Products
pub struct ProductHandler {
client: DefectDojoClient,
repository: Arc<EntityRepository>,
field_mapper: FieldMapper,
annotation_helper: AnnotationHelper,
config: DefectDojoConfig,
engagement_handler: Arc<EngagementHandler>,
trigger_kinds: Vec<String>,
}
impl ProductHandler {
pub fn new(
client: DefectDojoClient,
repository: Arc<EntityRepository>,
field_mappings: HashMap<String, serde_json::Value>,
config: DefectDojoConfig,
engagement_handler: Arc<EngagementHandler>,
) -> Self {
let field_mapper = FieldMapper::new(field_mappings)
.unwrap_or_else(|e| {
warn!("Failed to create field mapper: {}, using empty mapper", e);
FieldMapper::new(HashMap::new()).unwrap()
})
.with_repository(repository.clone());
let annotation_helper = AnnotationHelper::new("defectdojo");
Self {
client,
repository,
field_mapper,
annotation_helper,
config,
engagement_handler,
trigger_kinds: vec!["Component".to_string()],
}
}
/// Get DefectDojo product ID from entity annotations
fn get_product_id(&self, entity: &Entity) -> Option<i32> {
self.annotation_helper.get_id(entity, "product")
}
/// Extract name from entity metadata
fn extract_name(&self, entity: &Entity) -> String {
use charybdis::charybdis::entities::entity::Metadata;
match &entity.metadata {
Some(Metadata::ComponentMetadata(m)) => m.name.clone(),
Some(Metadata::ServiceMetadata(m)) => m.name.clone(),
Some(Metadata::SystemMetadata(m)) => m.name.clone(),
Some(Metadata::ApiMetadata(m)) => m.name.clone(),
Some(Metadata::ResourceMetadata(m)) => m.name.clone(),
Some(Metadata::DomainMetadata(m)) => m.name.clone(),
Some(Metadata::UserMetadata(m)) => m.name.clone(),
Some(Metadata::GroupMetadata(m)) => m.name.clone(),
Some(Metadata::FindingMetadata(m)) => m.title.clone(),
_ => entity.id.clone(),
}
}
/// Extract description from entity metadata
fn extract_description(&self, entity: &Entity) -> String {
use charybdis::charybdis::entities::entity::Metadata;
match &entity.metadata {
Some(Metadata::ComponentMetadata(m)) => m.description.clone(),
Some(Metadata::ServiceMetadata(m)) => m.description.clone(),
Some(Metadata::SystemMetadata(m)) => m.description.clone(),
Some(Metadata::ApiMetadata(m)) => m.description.clone(),
Some(Metadata::ResourceMetadata(m)) => m.description.clone(),
Some(Metadata::DomainMetadata(m)) => m.description.clone(),
_ => String::new(),
}
}
/// Create product in DefectDojo
async fn create_product(&self, entity: &Entity) -> Result<i32> {
info!("Creating DefectDojo product for entity: {}", entity.id);
// Map fields from entity to DefectDojo API format
let mapped = self.field_mapper.map_all(entity).await?;
// Extract name/description from mapped fields or directly from entity metadata
let name = mapped.get("name").cloned()
.unwrap_or_else(|| json!(self.extract_name(entity)));
let description = mapped.get("description").cloned()
.unwrap_or_else(|| {
let desc = self.extract_description(entity);
if desc.is_empty() {
json!(format!("Managed by Charybdis (entity: {})", entity.id))
} else {
json!(desc)
}
});
// Build DefectDojo product payload
let mut payload = json!({
"name": name,
"description": description,
"prod_type": mapped.get("product_type_id")
.and_then(|v| v.as_i64())
.or(self.config.default_product_type_id.map(|id| id as i64))
.ok_or_else(|| anyhow!("product_type_id is required"))?,
});
// Add optional fields if present
if let Some(tags) = mapped.get("tags") {
payload["tags"] = tags.clone();
}
if let Some(bc) = mapped.get("business_criticality") {
payload["business_criticality"] = bc.clone();
}
if let Some(platform) = mapped.get("platform") {
payload["platform"] = platform.clone();
}
if let Some(lifecycle) = mapped.get("lifecycle") {
payload["lifecycle"] = lifecycle.clone();
}
if let Some(origin) = mapped.get("origin") {
payload["origin"] = origin.clone();
}
if let Some(user_records) = mapped.get("user_records") {
payload["user_records"] = user_records.clone();
}
if let Some(revenue) = mapped.get("revenue") {
payload["revenue"] = revenue.clone();
}
if let Some(external_audience) = mapped.get("external_audience") {
payload["external_audience"] = external_audience.clone();
}
if let Some(internet_accessible) = mapped.get("internet_accessible") {
payload["internet_accessible"] = internet_accessible.clone();
}
if let Some(product_manager) = mapped.get("product_manager") {
payload["product_manager"] = product_manager.clone();
}
if let Some(technical_contact) = mapped.get("technical_contact") {
payload["technical_contact"] = technical_contact.clone();
}
if let Some(team_manager) = mapped.get("team_manager") {
payload["team_manager"] = team_manager.clone();
}
// Create product in DefectDojo
let response = self.client.post("api/v2/products/", &payload).await?;
let product_id = response["id"]
.as_i64()
.ok_or_else(|| anyhow!("DefectDojo did not return product ID"))?
as i32;
info!(
"Created DefectDojo product {} for entity {}",
product_id, entity.id
);
Ok(product_id)
}
/// Update product in DefectDojo
async fn update_product(&self, entity: &Entity, product_id: i32) -> Result<()> {
info!(
"Updating DefectDojo product {} for entity {}",
product_id, entity.id
);
// Map fields from entity to DefectDojo API format
let mapped = self.field_mapper.map_all(entity).await?;
// Build DefectDojo product payload
let mut payload = json!({
"name": mapped.get("name").cloned().unwrap_or_else(|| json!(&entity.id)),
"description": mapped.get("description").cloned().unwrap_or_else(|| json!("")),
});
// Add optional fields if present
if let Some(tags) = mapped.get("tags") {
payload["tags"] = tags.clone();
}
if let Some(bc) = mapped.get("business_criticality") {
payload["business_criticality"] = bc.clone();
}
if let Some(platform) = mapped.get("platform") {
payload["platform"] = platform.clone();
}
if let Some(lifecycle) = mapped.get("lifecycle") {
payload["lifecycle"] = lifecycle.clone();
}
if let Some(origin) = mapped.get("origin") {
payload["origin"] = origin.clone();
}
if let Some(product_manager) = mapped.get("product_manager") {
payload["product_manager"] = product_manager.clone();
}
if let Some(technical_contact) = mapped.get("technical_contact") {
payload["technical_contact"] = technical_contact.clone();
}
if let Some(team_manager) = mapped.get("team_manager") {
payload["team_manager"] = team_manager.clone();
}
// Update product in DefectDojo
self.client
.put(&format!("api/v2/products/{}/", product_id), &payload)
.await?;
info!(
"Updated DefectDojo product {} for entity {}",
product_id, entity.id
);
Ok(())
}
/// Delete product in DefectDojo
async fn delete_product(&self, product_id: i32) -> Result<()> {
info!("Deleting DefectDojo product {}", product_id);
self.client
.delete(&format!("api/v2/products/{}/", product_id))
.await?;
info!("Deleted DefectDojo product {}", product_id);
Ok(())
}
/// Find a user in DefectDojo by lookup value (email or username depending on config)
async fn find_defectdojo_user(&self, lookup_value: &str) -> Result<Option<i32>> {
let field = &self.config.owner_resolution.defectdojo_lookup_field;
let response = self
.client
.get(&format!("api/v2/users/?{}={}", field, lookup_value))
.await?;
if let Some(results) = response["results"].as_array() {
if let Some(user) = results.first() {
if let Some(id) = user["id"].as_i64() {
return Ok(Some(id as i32));
}
}
}
Ok(None)
}
/// Resolve a component owner (team name) to DefectDojo user IDs.
///
/// Resolution chain:
/// 1. Find the Group entity in Charybdis matching the owner name
/// 2. Get the group's member usernames from GroupSpec
/// 3. For each member, find their User entity in Charybdis
/// 4. Extract email from the configured annotation (e.g., "keycloak.com/email")
/// 5. Look up the user in DefectDojo by that email
async fn resolve_owner_to_users(&self, owner: &str) -> Result<Vec<(String, i32)>> {
let email_annotation = &self.config.owner_resolution.user_email_annotation;
let mut resolved_users: Vec<(String, i32)> = Vec::new();
// Step 1: Find the Group entity matching the owner name
let groups = self.repository.list_by_kind("Group").await?;
let group = groups.iter().find(|e| {
use charybdis::charybdis::entities::entity::Metadata;
match &e.metadata {
Some(Metadata::GroupMetadata(m)) => m.name == owner,
_ => false,
}
});
let group = match group {
Some(g) => g,
None => {
warn!(
"Owner group '{}' not found in Charybdis — cannot resolve members",
owner
);
return Ok(resolved_users);
}
};
// Step 2: Get member usernames from GroupSpec
let member_usernames = {
use charybdis::charybdis::entities::entity::Spec;
match &group.spec {
Some(Spec::GroupSpec(s)) => s.members.clone(),
_ => {
warn!("Group '{}' has no GroupSpec — cannot resolve members", owner);
return Ok(resolved_users);
}
}
};
if member_usernames.is_empty() {
info!("Group '{}' has no members", owner);
return Ok(resolved_users);
}
// Step 3: Find User entities and extract email
let users = self.repository.list_by_kind("User").await?;
for username in &member_usernames {
let user_entity = users.iter().find(|e| {
use charybdis::charybdis::entities::entity::Metadata;
match &e.metadata {
Some(Metadata::UserMetadata(m)) => m.name == *username,
_ => false,
}
});
let user_entity = match user_entity {
Some(u) => u,
None => {
warn!(
"User '{}' (member of '{}') not found in Charybdis",
username, owner
);
continue;
}
};
// Step 4: Get email from the configured annotation
let lookup_value = match user_entity.annotations.get(email_annotation) {
Some(value) if !value.is_empty() => value.clone(),
_ => {
warn!(
"User '{}' has no '{}' annotation — cannot look up in DefectDojo",
username, email_annotation
);
continue;
}
};
// Step 5: Find the user in DefectDojo
match self.find_defectdojo_user(&lookup_value).await? {
Some(dd_user_id) => {
resolved_users.push((username.clone(), dd_user_id));
if !self.config.owner_resolution.assign_all_members {
break;
}
}
None => {
info!(
"User '{}' ({}={}) not found in DefectDojo — they may not have logged in via OIDC yet",
username, self.config.owner_resolution.defectdojo_lookup_field, lookup_value
);
}
}
}
Ok(resolved_users)
}
/// Assign resolved users as product members in DefectDojo
async fn assign_owner_to_product(
&self,
product_id: i32,
owner: &str,
) -> Result<Vec<i32>> {
let resolved = self.resolve_owner_to_users(owner).await?;
if resolved.is_empty() {
info!(
"No users resolved for owner '{}' — no product members assigned",
owner
);
return Ok(vec![]);
}
let role_id = self.find_role_id("Owner").await?.unwrap_or(4);
let mut member_ids = Vec::new();
for (username, dd_user_id) in &resolved {
// Check if product member already exists
let existing = self
.client
.get(&format!(
"api/v2/product_members/?product={}&user={}",
product_id, dd_user_id
))
.await?;
if let Some(results) = existing["results"].as_array() {
if let Some(member) = results.first() {
if let Some(id) = member["id"].as_i64() {
info!(
"'{}' already a member of product {} (member ID: {})",
username, product_id, id
);
member_ids.push(id as i32);
continue;
}
}
}
let payload = json!({
"product": product_id,
"user": dd_user_id,
"role": role_id,
});
let response = self
.client
.post("api/v2/product_members/", &payload)
.await?;
let member_id = response["id"]
.as_i64()
.ok_or_else(|| anyhow!("DefectDojo did not return product member ID"))?
as i32;
info!(
"Assigned '{}' (DD user {}) to product {} as member {}",
username, dd_user_id, product_id, member_id
);
member_ids.push(member_id);
}
Ok(member_ids)
}
/// Find a role ID by name in DefectDojo
async fn find_role_id(&self, role_name: &str) -> Result<Option<i32>> {
let response = self
.client
.get(&format!("api/v2/roles/?name={}", role_name))
.await?;
if let Some(results) = response["results"].as_array() {
if let Some(role) = results.first() {
if let Some(id) = role["id"].as_i64() {
return Ok(Some(id as i32));
}
}
}
Ok(None)
}
/// Extract the owner from a Component entity's spec
fn extract_owner(&self, entity: &Entity) -> Option<String> {
use charybdis::charybdis::entities::entity;
match &entity.spec {
Some(entity::Spec::ComponentSpec(s)) if !s.owner.is_empty() => {
Some(s.owner.clone())
}
_ => None,
}
}
}
#[async_trait]
impl ResourceHandler for ProductHandler {
fn resource_type(&self) -> &str {
"defectdojo_product"
}
fn trigger_kinds(&self) -> &[String] {
// Trigger on Component entities
&self.trigger_kinds
}
fn creates_entity_kind(&self) -> &str {
// This handler doesn't create new entities, it just syncs to DefectDojo
""
}
async fn handle_create(&self, entity: &Entity) -> Result<Option<Entity>> {
// Create product in DefectDojo
let product_id = self.create_product(entity).await?;
// Build annotations to store DefectDojo product ID
let mut annotations = HashMap::new();
annotations.insert("defectdojo.com/product-id".to_string(), product_id.to_string());
// Auto-create engagement if enabled
if self.config.default_engagement.auto_create {
info!(
"Auto-creating engagement for product {} (entity: {})",
product_id, entity.id
);
match self
.engagement_handler
.create_for_product(product_id, entity, &self.config.default_engagement)
.await
{
Ok(engagement_id) => {
annotations.insert("defectdojo.com/engagement-id".to_string(), engagement_id.to_string());
info!(
"Auto-created engagement {} for product {} (entity: {})",
engagement_id, product_id, entity.id
);
}
Err(e) => {
warn!(
"Failed to auto-create engagement for product {} (entity: {}): {}",
product_id, entity.id, e
);
}
}
}
// Auto-assign owner team members as product members
if let Some(owner) = self.extract_owner(entity) {
match self.assign_owner_to_product(product_id, &owner).await {
Ok(member_ids) if !member_ids.is_empty() => {
let ids_str: Vec<String> = member_ids.iter().map(|id| id.to_string()).collect();
annotations.insert(
"defectdojo.com/owner-member-ids".to_string(),
ids_str.join(","),
);
}
Ok(_) => {}
Err(e) => {
warn!(
"Failed to assign owner '{}' to product {}: {}",
owner, product_id, e
);
}
}
}
// Update only annotations in Charybdis
self.repository.update_annotations(&entity.id, annotations).await?;
// Don't create a new entity, just return None
Ok(None)
}
async fn handle_update(&self, entity: &Entity) -> Result<()> {
// Get DefectDojo product ID from annotations
if let Some(product_id) = self.get_product_id(entity) {
self.update_product(entity, product_id).await?;
} else {
warn!(
"Entity {} has no DefectDojo product ID, creating new product",
entity.id
);
let product_id = self.create_product(entity).await?;
// Update only annotations with product ID
let mut annotations = HashMap::new();
annotations.insert("defectdojo.com/product-id".to_string(), product_id.to_string());
self.repository.update_annotations(&entity.id, annotations).await?;
}
Ok(())
}
async fn handle_delete(&self, entity: &Entity) -> Result<()> {
// Get DefectDojo product ID and delete
if let Some(product_id) = self.get_product_id(entity) {
self.delete_product(product_id).await?;
} else {
warn!(
"Entity {} has no DefectDojo product ID, skipping deletion",
entity.id
);
}
Ok(())
}
}
@@ -0,0 +1,272 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use charybdis::charybdis::entities::Entity;
use charybdis::database::EntityRepository;
use charybdis::plugins::{field_mapper::FieldMapper, ResourceHandler};
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{info, warn};
use crate::DefectDojoClient;
/// Product Member handler - Maps user-product relationships to DefectDojo Product Members
pub struct ProductMemberHandler {
client: DefectDojoClient,
repository: Arc<EntityRepository>,
field_mapper: FieldMapper,
trigger_kinds: Vec<String>,
}
impl ProductMemberHandler {
pub fn new(
client: DefectDojoClient,
repository: Arc<EntityRepository>,
field_mappings: HashMap<String, serde_json::Value>,
) -> Self {
let field_mapper = FieldMapper::new(field_mappings)
.unwrap_or_else(|e| {
warn!("Failed to create field mapper: {}, using empty mapper", e);
FieldMapper::new(HashMap::new()).unwrap()
})
.with_repository(repository.clone());
Self {
client,
repository,
field_mapper,
trigger_kinds: vec!["Group".to_string()],
}
}
/// Get DefectDojo product member ID from entity annotations
fn get_product_member_id(&self, entity: &Entity) -> Option<i32> {
entity
.annotations
.get("defectdojo.com/product-member-id")
.and_then(|id| id.parse().ok())
}
/// Get role ID by name from DefectDojo
async fn get_role_id(&self, role_name: &str) -> Result<i32> {
let response = self
.client
.get(&format!("api/v2/roles/?name={}", role_name))
.await?;
if let Some(results) = response["results"].as_array() {
if let Some(role) = results.first() {
if let Some(id) = role["id"].as_i64() {
return Ok(id as i32);
}
}
}
Err(anyhow!("Role {} not found in DefectDojo", role_name))
}
/// Check if product member already exists
async fn find_product_member(&self, product_id: i32, user_id: i32) -> Result<Option<i32>> {
let response = self
.client
.get(&format!(
"api/v2/product_members/?product={}&user={}",
product_id, user_id
))
.await?;
if let Some(results) = response["results"].as_array() {
if let Some(member) = results.first() {
if let Some(id) = member["id"].as_i64() {
return Ok(Some(id as i32));
}
}
}
Ok(None)
}
/// Create product member in DefectDojo
async fn create_product_member(&self, entity: &Entity) -> Result<i32> {
info!(
"Creating DefectDojo product member for entity: {}",
entity.id
);
// Map fields from entity to DefectDojo API format
let mapped = self.field_mapper.map_all(entity).await?;
// Extract required fields
let product_id = mapped
.get("product_id")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow!("product_id is required"))? as i32;
let user_id = mapped
.get("user_id")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow!("user_id is required"))? as i32;
let role_name = mapped
.get("role_name")
.and_then(|v| v.as_str())
.unwrap_or("Reader"); // Default to Reader role
// Get role ID
let role_id = self.get_role_id(role_name).await?;
// Check if product member already exists
if let Some(existing_id) = self.find_product_member(product_id, user_id).await? {
info!(
"Product member for product {} and user {} already exists with ID {}",
product_id, user_id, existing_id
);
return Ok(existing_id);
}
// Build DefectDojo product member payload
let payload = json!({
"product": product_id,
"user": user_id,
"role": role_id,
});
// Create product member in DefectDojo
let response = self
.client
.post("api/v2/product_members/", &payload)
.await?;
let product_member_id = response["id"]
.as_i64()
.ok_or_else(|| anyhow!("DefectDojo did not return product member ID"))?
as i32;
info!(
"Created DefectDojo product member {} for entity {}",
product_member_id, entity.id
);
Ok(product_member_id)
}
/// Update product member in DefectDojo (change role)
async fn update_product_member(&self, entity: &Entity, product_member_id: i32) -> Result<()> {
info!(
"Updating DefectDojo product member {} for entity {}",
product_member_id, entity.id
);
// Map fields from entity to DefectDojo API format
let mapped = self.field_mapper.map_all(entity).await?;
// Build DefectDojo product member payload (can only update role)
let mut payload = json!({});
if let Some(role_name) = mapped.get("role_name").and_then(|v| v.as_str()) {
let role_id = self.get_role_id(role_name).await?;
payload["role"] = json!(role_id);
}
// Update product member in DefectDojo
self.client
.put(
&format!("api/v2/product_members/{}/", product_member_id),
&payload,
)
.await?;
info!(
"Updated DefectDojo product member {} for entity {}",
product_member_id, entity.id
);
Ok(())
}
/// Delete product member in DefectDojo
async fn delete_product_member(&self, product_member_id: i32) -> Result<()> {
info!("Deleting DefectDojo product member {}", product_member_id);
self.client
.delete(&format!("api/v2/product_members/{}/", product_member_id))
.await?;
info!("Deleted DefectDojo product member {}", product_member_id);
Ok(())
}
}
#[async_trait]
impl ResourceHandler for ProductMemberHandler {
fn resource_type(&self) -> &str {
"defectdojo_product_member"
}
fn trigger_kinds(&self) -> &[String] {
// Trigger on Group entities with defectdojo.com/product-member annotation
&self.trigger_kinds
}
fn creates_entity_kind(&self) -> &str {
""
}
async fn handle_create(&self, entity: &Entity) -> Result<Option<Entity>> {
// Create product member in DefectDojo
let product_member_id = self.create_product_member(entity).await?;
// Update only the annotations on the entity
let mut annotations = HashMap::new();
annotations.insert(
"defectdojo.com/product-member-id".to_string(),
product_member_id.to_string(),
);
self.repository
.update_annotations(&entity.id, annotations)
.await?;
Ok(None)
}
async fn handle_update(&self, entity: &Entity) -> Result<()> {
// Get DefectDojo product member ID from annotations
if let Some(product_member_id) = self.get_product_member_id(entity) {
self.update_product_member(entity, product_member_id)
.await?;
} else {
warn!(
"Entity {} has no DefectDojo product member ID, creating new product member",
entity.id
);
let product_member_id = self.create_product_member(entity).await?;
// Update only the annotations on the entity
let mut annotations = HashMap::new();
annotations.insert(
"defectdojo.com/product-member-id".to_string(),
product_member_id.to_string(),
);
self.repository
.update_annotations(&entity.id, annotations)
.await?;
}
Ok(())
}
async fn handle_delete(&self, entity: &Entity) -> Result<()> {
// Get DefectDojo product member ID and delete
if let Some(product_member_id) = self.get_product_member_id(entity) {
self.delete_product_member(product_member_id).await?;
} else {
warn!(
"Entity {} has no DefectDojo product member ID, skipping deletion",
entity.id
);
}
Ok(())
}
}
@@ -0,0 +1,253 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use charybdis::charybdis::entities::Entity;
use charybdis::database::EntityRepository;
use charybdis::plugins::{field_mapper::FieldMapper, ResourceHandler};
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{info, warn};
use crate::DefectDojoClient;
/// Product Type handler - Maps custom entities to DefectDojo Product Types
pub struct ProductTypeHandler {
client: DefectDojoClient,
repository: Arc<EntityRepository>,
field_mapper: FieldMapper,
trigger_kinds: Vec<String>,
}
impl ProductTypeHandler {
pub fn new(
client: DefectDojoClient,
repository: Arc<EntityRepository>,
field_mappings: HashMap<String, serde_json::Value>,
) -> Self {
let field_mapper = FieldMapper::new(field_mappings)
.unwrap_or_else(|e| {
warn!("Failed to create field mapper: {}, using empty mapper", e);
FieldMapper::new(HashMap::new()).unwrap()
})
.with_repository(repository.clone());
Self {
client,
repository,
field_mapper,
trigger_kinds: vec!["System".to_string()],
}
}
/// Get DefectDojo product type ID from entity annotations
fn get_product_type_id(&self, entity: &Entity) -> Option<i32> {
entity
.annotations
.get("defectdojo.com/product-type-id")
.and_then(|id| id.parse().ok())
}
/// Find existing product type by name in DefectDojo
async fn find_product_type_by_name(&self, name: &str) -> Result<Option<i32>> {
let response = self
.client
.get(&format!("api/v2/product_types/?name={}", name))
.await?;
if let Some(results) = response["results"].as_array() {
if let Some(product_type) = results.first() {
if let Some(id) = product_type["id"].as_i64() {
return Ok(Some(id as i32));
}
}
}
Ok(None)
}
/// Create product type in DefectDojo
async fn create_product_type(&self, entity: &Entity) -> Result<i32> {
info!("Creating DefectDojo product type for entity: {}", entity.id);
// Map fields from entity to DefectDojo API format
let mapped = self.field_mapper.map_all(entity).await?;
// Extract required fields
let name = mapped
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("name is required"))?;
// Check if product type already exists
if let Some(existing_id) = self.find_product_type_by_name(name).await? {
info!(
"Product type {} already exists in DefectDojo with ID {}",
name, existing_id
);
return Ok(existing_id);
}
// Build DefectDojo product type payload
let mut payload = json!({
"name": name,
});
// Add optional fields
if let Some(description) = mapped.get("description") {
payload["description"] = description.clone();
}
if let Some(critical_product) = mapped.get("critical_product") {
payload["critical_product"] = critical_product.clone();
}
if let Some(key_product) = mapped.get("key_product") {
payload["key_product"] = key_product.clone();
}
// Create product type in DefectDojo
let response = self.client.post("api/v2/product_types/", &payload).await?;
let product_type_id = response["id"]
.as_i64()
.ok_or_else(|| anyhow!("DefectDojo did not return product type ID"))?
as i32;
info!(
"Created DefectDojo product type {} for entity {}",
product_type_id, entity.id
);
Ok(product_type_id)
}
/// Update product type in DefectDojo
async fn update_product_type(&self, entity: &Entity, product_type_id: i32) -> Result<()> {
info!(
"Updating DefectDojo product type {} for entity {}",
product_type_id, entity.id
);
// Map fields from entity to DefectDojo API format
let mapped = self.field_mapper.map_all(entity).await?;
// Build DefectDojo product type payload
let mut payload = json!({});
if let Some(name) = mapped.get("name") {
payload["name"] = name.clone();
}
if let Some(description) = mapped.get("description") {
payload["description"] = description.clone();
}
if let Some(critical_product) = mapped.get("critical_product") {
payload["critical_product"] = critical_product.clone();
}
if let Some(key_product) = mapped.get("key_product") {
payload["key_product"] = key_product.clone();
}
// Update product type in DefectDojo
self.client
.put(
&format!("api/v2/product_types/{}/", product_type_id),
&payload,
)
.await?;
info!(
"Updated DefectDojo product type {} for entity {}",
product_type_id, entity.id
);
Ok(())
}
/// Delete product type in DefectDojo
async fn delete_product_type(&self, product_type_id: i32) -> Result<()> {
info!("Deleting DefectDojo product type {}", product_type_id);
self.client
.delete(&format!("api/v2/product_types/{}/", product_type_id))
.await?;
info!("Deleted DefectDojo product type {}", product_type_id);
Ok(())
}
}
#[async_trait]
impl ResourceHandler for ProductTypeHandler {
fn resource_type(&self) -> &str {
"defectdojo_product_type"
}
fn trigger_kinds(&self) -> &[String] {
// Trigger on System entities with specific annotation
&self.trigger_kinds
}
fn creates_entity_kind(&self) -> &str {
""
}
async fn handle_create(&self, entity: &Entity) -> Result<Option<Entity>> {
// Create product type in DefectDojo
let product_type_id = self.create_product_type(entity).await?;
// Update only the annotations on the entity
let mut annotations = HashMap::new();
annotations.insert(
"defectdojo.com/product-type-id".to_string(),
product_type_id.to_string(),
);
self.repository
.update_annotations(&entity.id, annotations)
.await?;
Ok(None)
}
async fn handle_update(&self, entity: &Entity) -> Result<()> {
// Get DefectDojo product type ID from annotations
if let Some(product_type_id) = self.get_product_type_id(entity) {
self.update_product_type(entity, product_type_id).await?;
} else {
warn!(
"Entity {} has no DefectDojo product type ID, creating new product type",
entity.id
);
let product_type_id = self.create_product_type(entity).await?;
// Update only the annotations on the entity
let mut annotations = HashMap::new();
annotations.insert(
"defectdojo.com/product-type-id".to_string(),
product_type_id.to_string(),
);
self.repository
.update_annotations(&entity.id, annotations)
.await?;
}
Ok(())
}
async fn handle_delete(&self, entity: &Entity) -> Result<()> {
// Get DefectDojo product type ID and delete
if let Some(product_type_id) = self.get_product_type_id(entity) {
self.delete_product_type(product_type_id).await?;
} else {
warn!(
"Entity {} has no DefectDojo product type ID, skipping deletion",
entity.id
);
}
Ok(())
}
}
+332
View File
@@ -0,0 +1,332 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use charybdis::database::EntityRepository;
use charybdis::plugins::http_client::{AuthConfig, PluginHttpClient};
use charybdis::plugins::{EventDrivenPlugin, Plugin, PluginConfig, PluginType, ResourceHandler};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
pub mod handlers;
mod utils;
use handlers::*;
pub use utils::*;
/// Engagement auto-creation configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EngagementConfig {
/// Auto-create default engagement when product is created
#[serde(default = "default_auto_create")]
pub auto_create: bool,
/// Engagement name
#[serde(default = "default_engagement_name")]
pub name: String,
/// Engagement description
#[serde(default = "default_engagement_description")]
pub description: String,
/// Engagement type (e.g., "CI/CD", "Interactive")
#[serde(default = "default_engagement_type")]
pub engagement_type: String,
/// Status (e.g., "In Progress", "Completed")
#[serde(default = "default_engagement_status")]
pub status: String,
/// Duration in days
#[serde(default = "default_duration_days")]
pub duration_days: i64,
/// Enable deduplication on engagement
#[serde(default = "default_deduplication")]
pub deduplication_on_engagement: bool,
}
// Default value functions
fn default_auto_create() -> bool {
true
}
fn default_engagement_name() -> String {
"CI/CD Scans".to_string()
}
fn default_engagement_description() -> String {
"Automated security scans from CI/CD pipeline".to_string()
}
fn default_engagement_type() -> String {
"CI/CD".to_string()
}
fn default_engagement_status() -> String {
"In Progress".to_string()
}
fn default_duration_days() -> i64 {
365
}
fn default_deduplication() -> bool {
true
}
impl Default for EngagementConfig {
fn default() -> Self {
Self {
auto_create: default_auto_create(),
name: default_engagement_name(),
description: default_engagement_description(),
engagement_type: default_engagement_type(),
status: default_engagement_status(),
duration_days: default_duration_days(),
deduplication_on_engagement: default_deduplication(),
}
}
}
/// DefectDojo plugin configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DefectDojoConfig {
/// DefectDojo API base URL
pub base_url: String,
/// API token for authentication
pub api_token: String,
/// Default product type ID (optional)
pub default_product_type_id: Option<i32>,
/// Auto-create users when needed
#[serde(default)]
pub auto_create_users: bool,
/// Auto-create product types when needed
#[serde(default)]
pub auto_create_product_types: bool,
/// Default engagement configuration
#[serde(default)]
pub default_engagement: EngagementConfig,
/// Owner resolution configuration
#[serde(default)]
pub owner_resolution: OwnerResolutionConfig,
/// Field mappings for each resource type
#[serde(default)]
pub field_mappings: HashMap<String, HashMap<String, serde_json::Value>>,
}
/// Configuration for resolving component owners to DefectDojo users.
///
/// When a Component has an `owner` field (typically a team name), this config
/// controls how Charybdis resolves that to individual users in DefectDojo.
///
/// The resolution chain is:
/// 1. Find the Group entity matching the owner name
/// 2. Get the group's members (usernames)
/// 3. Find each member's User entity
/// 4. Extract the user's email from the configured annotation
/// 5. Look up the user in DefectDojo by email (as OIDC providers use email as login)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OwnerResolutionConfig {
/// Entity annotation key that holds the user's email address.
/// This depends on your identity provider:
/// - Keycloak: "keycloak.com/email"
/// - Okta: "okta.com/email"
/// - Azure AD: "azuread.com/email"
/// - Generic: "user.email"
#[serde(default = "default_user_email_annotation")]
pub user_email_annotation: String,
/// How to search for users in DefectDojo.
/// "email" (default) — searches by email field (for OIDC providers)
/// "username" — searches by username field
#[serde(default = "default_defectdojo_lookup_field")]
pub defectdojo_lookup_field: String,
/// Whether to assign all group members as product members (true)
/// or only the first found user (false, default)
#[serde(default)]
pub assign_all_members: bool,
}
fn default_user_email_annotation() -> String {
"keycloak.com/email".to_string()
}
fn default_defectdojo_lookup_field() -> String {
"email".to_string()
}
impl Default for OwnerResolutionConfig {
fn default() -> Self {
Self {
user_email_annotation: default_user_email_annotation(),
defectdojo_lookup_field: default_defectdojo_lookup_field(),
assign_all_members: false,
}
}
}
impl DefectDojoConfig {
/// Load DefectDojo configuration from TOML config map
pub fn from_toml(config: &HashMap<String, toml::Value>) -> Result<Self> {
// Convert HashMap to TOML Value
let value = toml::Value::Table(config.clone().into_iter().collect());
// Deserialize to DefectDojoConfig
let config: DefectDojoConfig = value
.try_into()
.map_err(|e| anyhow!("Failed to parse DefectDojo configuration: {}", e))?;
Ok(config)
}
}
/// DefectDojo API client (wrapper around PluginHttpClient)
#[derive(Clone)]
pub struct DefectDojoClient {
client: PluginHttpClient,
}
impl DefectDojoClient {
pub fn new(base_url: String, api_token: String) -> Result<Self> {
let client = PluginHttpClient::new(base_url, AuthConfig::token(api_token))?;
Ok(Self { client })
}
/// Make a GET request to DefectDojo API
pub async fn get(&self, path: &str) -> Result<serde_json::Value> {
self.client.get(path).await
}
/// Make a POST request to DefectDojo API
pub async fn post(&self, path: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
self.client.post(path, body).await
}
/// Make a PUT request to DefectDojo API
pub async fn put(&self, path: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
self.client.put(path, body).await
}
/// Make a DELETE request to DefectDojo API
pub async fn delete(&self, path: &str) -> Result<()> {
self.client.delete(path).await
}
}
/// DefectDojo event-driven plugin
#[allow(dead_code)]
pub struct DefectDojoPlugin {
config: DefectDojoConfig,
client: DefectDojoClient,
repository: Arc<EntityRepository>,
handlers: Vec<Arc<dyn ResourceHandler>>,
}
impl DefectDojoPlugin {
pub fn new(config: DefectDojoConfig, repository: Arc<EntityRepository>) -> Result<Self> {
// Validate configuration
if config.base_url.is_empty() {
return Err(anyhow!("DefectDojo base_url cannot be empty"));
}
if config.api_token.is_empty() {
return Err(anyhow!("DefectDojo api_token cannot be empty"));
}
let client = DefectDojoClient::new(config.base_url.clone(), config.api_token.clone())?;
// Create all resource handlers
let mut handlers: Vec<Arc<dyn ResourceHandler>> = vec![];
// Create engagement handler first (needed by ProductHandler)
let engagement_handler = Arc::new(EngagementHandler::new(
client.clone(),
repository.clone(),
config
.field_mappings
.get("engagement")
.cloned()
.unwrap_or_default(),
));
// Product handler (needs engagement_handler for auto-creating engagements)
handlers.push(Arc::new(ProductHandler::new(
client.clone(),
repository.clone(),
config
.field_mappings
.get("product")
.cloned()
.unwrap_or_default(),
config.clone(),
engagement_handler.clone(),
)));
// Product Type handler
handlers.push(Arc::new(ProductTypeHandler::new(
client.clone(),
repository.clone(),
config
.field_mappings
.get("product_type")
.cloned()
.unwrap_or_default(),
)));
// Product Member handler
handlers.push(Arc::new(ProductMemberHandler::new(
client.clone(),
repository.clone(),
config
.field_mappings
.get("product_member")
.cloned()
.unwrap_or_default(),
)));
// Add engagement handler to handlers list
handlers.push(engagement_handler);
Ok(Self {
config,
client,
repository,
handlers,
})
}
}
#[async_trait]
impl Plugin for DefectDojoPlugin {
fn name(&self) -> &str {
"defectdojo"
}
fn plugin_type(&self) -> PluginType {
PluginType::EventDriven
}
fn load_config(&mut self, _config: PluginConfig) -> Result<()> {
// Configuration is loaded during construction
Ok(())
}
fn validate_config(&self) -> Result<()> {
// Validate DefectDojo configuration
if self.config.base_url.is_empty() {
return Err(anyhow!("DefectDojo base_url cannot be empty"));
}
if self.config.api_token.is_empty() {
return Err(anyhow!("DefectDojo api_token cannot be empty"));
}
Ok(())
}
}
#[async_trait]
impl EventDrivenPlugin for DefectDojoPlugin {
fn resource_handlers(&self) -> Vec<Arc<dyn ResourceHandler>> {
self.handlers.clone()
}
}
+182
View File
@@ -0,0 +1,182 @@
//! Shared utilities for DefectDojo plugin
//!
//! This module provides common functionality used across all DefectDojo handlers
//! to reduce code duplication and improve maintainability.
use anyhow::Result;
use charybdis::charybdis::entities::Entity;
use charybdis::database::EntityRepository;
use charybdis::plugins::field_mapper::FieldMapper;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::warn;
// Annotation keys used by DefectDojo plugin
pub const ANNOTATION_PRODUCT_ID: &str = "defectdojo.com/product-id";
pub const ANNOTATION_USER_ID: &str = "defectdojo.com/user-id";
pub const ANNOTATION_PRODUCT_TYPE_ID: &str = "defectdojo.com/product-type-id";
pub const ANNOTATION_PRODUCT_MEMBER_ID: &str = "defectdojo.com/product-member-id";
pub const ANNOTATION_ENGAGEMENT_ID: &str = "defectdojo.com/engagement-id";
/// Gets DefectDojo resource ID from entity annotations.
///
/// # Arguments
///
/// * `entity` - The entity to extract the ID from
/// * `resource_type` - The resource type (e.g., "product", "user", "product-type")
///
/// # Returns
///
/// Returns `Some(id)` if the annotation exists and can be parsed as i32, `None` otherwise.
///
/// # Examples
///
/// ```ignore
/// let product_id = get_defectdojo_id(&entity, "product");
/// ```
pub fn get_defectdojo_id(entity: &Entity, resource_type: &str) -> Option<i32> {
entity
.annotations
.get(&format!("defectdojo.com/{}-id", resource_type))
.and_then(|id| id.parse().ok())
}
/// Creates a field mapper with standard error handling.
///
/// If field mapper creation fails, logs a warning and returns an empty mapper.
///
/// # Arguments
///
/// * `field_mappings` - Field mapping configuration
/// * `repository` - Entity repository for entity resolution
///
/// # Returns
///
/// Returns a configured FieldMapper with repository attached.
pub fn create_field_mapper(
field_mappings: HashMap<String, serde_json::Value>,
repository: Arc<EntityRepository>,
) -> FieldMapper {
FieldMapper::new(field_mappings)
.unwrap_or_else(|e| {
warn!("Failed to create field mapper: {}, using empty mapper", e);
FieldMapper::new(HashMap::new()).unwrap()
})
.with_repository(repository)
}
/// Updates an entity with a DefectDojo resource ID annotation.
///
/// Creates a clone of the entity, adds the annotation, and updates it in the repository.
///
/// # Arguments
///
/// * `entity` - The entity to update
/// * `resource_type` - The resource type (e.g., "product", "user")
/// * `defectdojo_id` - The DefectDojo resource ID to store
/// * `repository` - Entity repository to persist the update
///
/// # Errors
///
/// Returns an error if the repository update fails.
///
/// # Examples
///
/// ```ignore
/// update_defectdojo_annotation(&entity, "product", 123, &repository).await?;
/// ```
pub async fn update_defectdojo_annotation(
entity: &Entity,
resource_type: &str,
defectdojo_id: i32,
repository: &EntityRepository,
) -> Result<()> {
let mut updated_entity = entity.clone();
updated_entity.annotations.insert(
format!("defectdojo.com/{}-id", resource_type),
defectdojo_id.to_string(),
);
repository.update(&entity.id, &updated_entity).await?;
Ok(())
}
/// Macro to add optional fields from mapped values to a JSON payload.
///
/// This macro reduces repetitive `if let Some` patterns when building API payloads.
///
/// # Examples
///
/// ```ignore
/// let mut payload = json!({"name": "test"});
/// add_optional_fields!(
/// payload,
/// mapped,
/// "tags",
/// "description",
/// "business_criticality"
/// );
/// ```
#[macro_export]
macro_rules! add_optional_fields {
($payload:expr, $mapped:expr, $($field:expr),+ $(,)?) => {
$(
if let Some(value) = $mapped.get($field) {
$payload[$field] = value.clone();
}
)+
};
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn create_test_entity(id: &str) -> Entity {
Entity {
id: id.to_string(),
kind: "Component".to_string(),
metadata: None,
spec: None,
annotations: HashMap::new(),
created_at: None,
updated_at: None,
}
}
#[test]
fn test_get_defectdojo_id_exists() {
let mut entity = create_test_entity("test-1");
entity
.annotations
.insert("defectdojo.com/product-id".to_string(), "123".to_string());
let id = get_defectdojo_id(&entity, "product");
assert_eq!(id, Some(123));
}
#[test]
fn test_get_defectdojo_id_missing() {
let entity = create_test_entity("test-1");
let id = get_defectdojo_id(&entity, "product");
assert_eq!(id, None);
}
#[test]
fn test_get_defectdojo_id_invalid_format() {
let mut entity = create_test_entity("test-1");
entity.annotations.insert(
"defectdojo.com/product-id".to_string(),
"invalid".to_string(),
);
let id = get_defectdojo_id(&entity, "product");
assert_eq!(id, None);
}
#[test]
fn test_create_field_mapper_empty() {
// Note: This test requires mocking or a test database
// Keeping it simple for now
}
}
@@ -0,0 +1,607 @@
//! Integration tests for DefectDojo plugin using wiremock
//!
//! These tests require a running PostgreSQL instance.
//! Set DATABASE_URL environment variable to run them.
//! They are ignored by default in CI unless DATABASE_URL is set.
use charybdis::charybdis::entities::entity::{Metadata, Spec};
use charybdis::charybdis::entities::Entity;
use charybdis::database::{ensure_schema, EntityRepository};
use charybdis::plugins::ResourceHandler;
use charybdis_defectdojo::{DefectDojoClient, DefectDojoConfig, EngagementConfig, OwnerResolutionConfig};
use serde_json::json;
use sqlx::PgPool;
use std::collections::HashMap;
use std::sync::Arc;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_db() -> PgPool {
let url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set for integration tests");
let pool = PgPool::connect(&url).await.expect("Failed to connect to test database");
ensure_schema(&pool).await.expect("Failed to create schema");
// Clean up from previous test runs
sqlx::query("DELETE FROM entities")
.execute(&pool)
.await
.expect("Failed to clean entities table");
pool
}
fn make_component_entity(id: &str, name: &str, owner: &str) -> Entity {
Entity {
id: id.to_string(),
kind: "Component".to_string(),
annotations: HashMap::new(),
created_at: None,
updated_at: None,
metadata: Some(Metadata::ComponentMetadata(
charybdis::charybdis::core::ComponentMetadata {
name: name.to_string(),
namespace: "default".to_string(),
description: format!("{} service", name),
labels: HashMap::new(),
tags: vec![],
links: vec![],
},
)),
spec: Some(Spec::ComponentSpec(
charybdis::charybdis::core::ComponentSpec {
r#type: "service".to_string(),
lifecycle: "production".to_string(),
owner: owner.to_string(),
system: String::new(),
subcomponent_of: String::new(),
depends_on: vec![],
provides_apis: vec![],
consumes_apis: vec![],
},
)),
}
}
fn make_config(base_url: &str) -> DefectDojoConfig {
DefectDojoConfig {
base_url: base_url.to_string(),
api_token: "test-token".to_string(),
default_product_type_id: Some(1),
auto_create_users: false,
auto_create_product_types: false,
default_engagement: EngagementConfig::default(),
owner_resolution: OwnerResolutionConfig::default(),
field_mappings: HashMap::new(),
}
}
// ──────────────────────────────────────────────────────────────
// Product creation
// ──────────────────────────────────────────────────────────────
#[tokio::test]
#[ignore] // Requires DATABASE_URL
async fn test_product_creation_on_component_create() {
let pool = setup_db().await;
let repository = Arc::new(EntityRepository::new(pool.clone()));
let mock_server = MockServer::start().await;
// Mock: POST /api/v2/products/ → returns product with id=42
Mock::given(method("POST"))
.and(path("/api/v2/products/"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": 42,
"name": "payment-api",
"description": "payment-api service",
"prod_type": 1
})))
.expect(1)
.mount(&mock_server)
.await;
// Mock: POST /api/v2/engagements/ → returns engagement with id=100
Mock::given(method("POST"))
.and(path("/api/v2/engagements/"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": 100,
"name": "CI/CD Scans",
"product": 42
})))
.expect(1)
.mount(&mock_server)
.await;
let mut config = make_config(&mock_server.uri());
config.default_engagement.auto_create = true;
// Disable owner resolution for this test (no owner group exists)
config.owner_resolution.assign_all_members = false;
let client = DefectDojoClient::new(mock_server.uri(), "test-token".to_string()).unwrap();
let engagement_handler = Arc::new(
charybdis_defectdojo::handlers::EngagementHandler::new(
client.clone(),
repository.clone(),
HashMap::new(),
),
);
let handler = charybdis_defectdojo::handlers::ProductHandler::new(
client,
repository.clone(),
HashMap::new(),
config,
engagement_handler,
);
// Create the entity in the database first (returns entity with real UUID)
let entity = make_component_entity("", "payment-api", "team-payments");
let entity = repository.create(&entity).await.expect("Failed to create entity");
// Trigger handler
let result = handler.handle_create(&entity).await;
assert!(result.is_ok(), "handle_create failed: {:?}", result.err());
// Verify annotations were saved
let updated = repository.get_by_id(&entity.id).await.unwrap().unwrap();
assert_eq!(
updated.annotations.get("defectdojo.com/product-id"),
Some(&"42".to_string())
);
assert_eq!(
updated.annotations.get("defectdojo.com/engagement-id"),
Some(&"100".to_string())
);
}
// ──────────────────────────────────────────────────────────────
// Product update
// ──────────────────────────────────────────────────────────────
#[tokio::test]
#[ignore] // Requires DATABASE_URL
async fn test_product_update_with_existing_product_id() {
let pool = setup_db().await;
let repository = Arc::new(EntityRepository::new(pool.clone()));
let mock_server = MockServer::start().await;
// Mock: PUT /api/v2/products/42/ → success
Mock::given(method("PUT"))
.and(path("/api/v2/products/42/"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": 42,
"name": "payment-api-updated"
})))
.expect(1)
.mount(&mock_server)
.await;
let config = make_config(&mock_server.uri());
let client = DefectDojoClient::new(mock_server.uri(), "test-token".to_string()).unwrap();
let engagement_handler = Arc::new(
charybdis_defectdojo::handlers::EngagementHandler::new(
client.clone(),
repository.clone(),
HashMap::new(),
),
);
let handler = charybdis_defectdojo::handlers::ProductHandler::new(
client,
repository.clone(),
HashMap::new(),
config,
engagement_handler,
);
// Create entity with existing product-id annotation
let mut entity = make_component_entity("", "payment-api", "team-payments");
entity.annotations.insert(
"defectdojo.com/product-id".to_string(),
"42".to_string(),
);
let entity = repository.create(&entity).await.expect("Failed to create entity");
// Trigger update handler
let result = handler.handle_update(&entity).await;
assert!(result.is_ok(), "handle_update failed: {:?}", result.err());
}
// ──────────────────────────────────────────────────────────────
// Product deletion
// ──────────────────────────────────────────────────────────────
#[tokio::test]
#[ignore] // Requires DATABASE_URL
async fn test_product_deletion() {
let pool = setup_db().await;
let repository = Arc::new(EntityRepository::new(pool.clone()));
let mock_server = MockServer::start().await;
// Mock: DELETE /api/v2/products/42/ → 204 No Content
Mock::given(method("DELETE"))
.and(path("/api/v2/products/42/"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&mock_server)
.await;
let config = make_config(&mock_server.uri());
let client = DefectDojoClient::new(mock_server.uri(), "test-token".to_string()).unwrap();
let engagement_handler = Arc::new(
charybdis_defectdojo::handlers::EngagementHandler::new(
client.clone(),
repository.clone(),
HashMap::new(),
),
);
let handler = charybdis_defectdojo::handlers::ProductHandler::new(
client,
repository.clone(),
HashMap::new(),
config,
engagement_handler,
);
// Entity with product-id annotation
let mut entity = make_component_entity("", "payment-api", "team-payments");
entity.annotations.insert(
"defectdojo.com/product-id".to_string(),
"42".to_string(),
);
let entity = repository.create(&entity).await.expect("Failed to create entity");
// Trigger delete handler
let result = handler.handle_delete(&entity).await;
assert!(result.is_ok(), "handle_delete failed: {:?}", result.err());
}
// ──────────────────────────────────────────────────────────────
// Owner resolution with product member assignment
// ──────────────────────────────────────────────────────────────
#[tokio::test]
#[ignore] // Requires DATABASE_URL
async fn test_owner_resolution_assigns_product_members() {
let pool = setup_db().await;
let repository = Arc::new(EntityRepository::new(pool.clone()));
let mock_server = MockServer::start().await;
// Mock: POST /api/v2/products/ → product id=10
Mock::given(method("POST"))
.and(path("/api/v2/products/"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": 10,
"name": "orders-api",
"prod_type": 1
})))
.expect(1)
.mount(&mock_server)
.await;
// Mock: POST /api/v2/engagements/ → engagement id=20
Mock::given(method("POST"))
.and(path("/api/v2/engagements/"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": 20,
"name": "CI/CD Scans",
"product": 10
})))
.expect(1)
.mount(&mock_server)
.await;
// Mock: GET /api/v2/users/?email=alice@example.com → found user id=5
Mock::given(method("GET"))
.and(path("/api/v2/users/"))
.and(query_param("email", "alice@example.com"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"count": 1,
"results": [{"id": 5, "username": "alice@example.com"}]
})))
.expect(1)
.mount(&mock_server)
.await;
// Mock: GET /api/v2/users/?email=bob@example.com → found user id=7
Mock::given(method("GET"))
.and(path("/api/v2/users/"))
.and(query_param("email", "bob@example.com"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"count": 1,
"results": [{"id": 7, "username": "bob@example.com"}]
})))
.expect(1)
.mount(&mock_server)
.await;
// Mock: GET /api/v2/roles/?name=Owner → role id=4
Mock::given(method("GET"))
.and(path("/api/v2/roles/"))
.and(query_param("name", "Owner"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"count": 1,
"results": [{"id": 4, "name": "Owner"}]
})))
.expect(1)
.mount(&mock_server)
.await;
// Mock: GET /api/v2/product_members/?product=10&user=5 → not yet a member
Mock::given(method("GET"))
.and(path("/api/v2/product_members/"))
.and(query_param("product", "10"))
.and(query_param("user", "5"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"count": 0,
"results": []
})))
.expect(1)
.mount(&mock_server)
.await;
// Mock: GET /api/v2/product_members/?product=10&user=7 → not yet a member
Mock::given(method("GET"))
.and(path("/api/v2/product_members/"))
.and(query_param("product", "10"))
.and(query_param("user", "7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"count": 0,
"results": []
})))
.expect(1)
.mount(&mock_server)
.await;
// Mock: POST /api/v2/product_members/ → member created (called twice)
Mock::given(method("POST"))
.and(path("/api/v2/product_members/"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": 99,
"product": 10,
"user": 5,
"role": 4
})))
.expect(2)
.mount(&mock_server)
.await;
// Set up config with owner resolution enabled
let mut config = make_config(&mock_server.uri());
config.default_engagement.auto_create = true;
config.owner_resolution.user_email_annotation = "keycloak.com/email".to_string();
config.owner_resolution.defectdojo_lookup_field = "email".to_string();
config.owner_resolution.assign_all_members = true;
// Create Group "backend-team" with members alice and bob
let group_entity = Entity {
id: "group-backend".to_string(),
kind: "Group".to_string(),
annotations: HashMap::new(),
created_at: None,
updated_at: None,
metadata: Some(Metadata::GroupMetadata(
charybdis::charybdis::core::GroupMetadata {
name: "backend-team".to_string(),
namespace: "default".to_string(),
description: "Backend team".to_string(),
labels: HashMap::new(),
tags: vec![],
links: vec![],
},
)),
spec: Some(Spec::GroupSpec(charybdis::charybdis::core::GroupSpec {
r#type: "team".to_string(),
profile: None,
parent: String::new(),
children: vec![],
members: vec!["alice".to_string(), "bob".to_string()],
})),
};
repository.create(&group_entity).await.unwrap();
// Create User "alice" with keycloak email annotation
let mut alice = Entity {
id: "user-alice".to_string(),
kind: "User".to_string(),
annotations: HashMap::new(),
created_at: None,
updated_at: None,
metadata: Some(Metadata::UserMetadata(
charybdis::charybdis::core::UserMetadata {
name: "alice".to_string(),
namespace: "default".to_string(),
description: "Alice".to_string(),
labels: HashMap::new(),
tags: vec![],
links: vec![],
},
)),
spec: Some(Spec::UserSpec(charybdis::charybdis::core::UserSpec {
profile: None,
member_of: vec!["backend-team".to_string()],
})),
};
alice.annotations.insert("keycloak.com/email".to_string(), "alice@example.com".to_string());
repository.create(&alice).await.unwrap();
// Create User "bob" with keycloak email annotation
let mut bob = Entity {
id: "user-bob".to_string(),
kind: "User".to_string(),
annotations: HashMap::new(),
created_at: None,
updated_at: None,
metadata: Some(Metadata::UserMetadata(
charybdis::charybdis::core::UserMetadata {
name: "bob".to_string(),
namespace: "default".to_string(),
description: "Bob".to_string(),
labels: HashMap::new(),
tags: vec![],
links: vec![],
},
)),
spec: Some(Spec::UserSpec(charybdis::charybdis::core::UserSpec {
profile: None,
member_of: vec!["backend-team".to_string()],
})),
};
bob.annotations.insert("keycloak.com/email".to_string(), "bob@example.com".to_string());
repository.create(&bob).await.unwrap();
// Create handlers
let client = DefectDojoClient::new(mock_server.uri(), "test-token".to_string()).unwrap();
let engagement_handler = Arc::new(
charybdis_defectdojo::handlers::EngagementHandler::new(
client.clone(),
repository.clone(),
HashMap::new(),
),
);
let handler = charybdis_defectdojo::handlers::ProductHandler::new(
client,
repository.clone(),
HashMap::new(),
config,
engagement_handler,
);
// Create component owned by "backend-team"
let component = make_component_entity("", "orders-api", "backend-team");
let component = repository.create(&component).await.unwrap();
// Trigger handler
let result = handler.handle_create(&component).await;
assert!(result.is_ok(), "handle_create failed: {:?}", result.err());
// Verify annotations
let updated = repository.get_by_id(&component.id).await.unwrap().unwrap();
assert_eq!(
updated.annotations.get("defectdojo.com/product-id"),
Some(&"10".to_string())
);
assert_eq!(
updated.annotations.get("defectdojo.com/engagement-id"),
Some(&"20".to_string())
);
// Owner member IDs should be set
assert!(
updated.annotations.contains_key("defectdojo.com/owner-member-ids"),
"Expected owner-member-ids annotation"
);
}
// ──────────────────────────────────────────────────────────────
// Engagement auto-creation disabled
// ──────────────────────────────────────────────────────────────
#[tokio::test]
#[ignore] // Requires DATABASE_URL
async fn test_product_creation_without_engagement() {
let pool = setup_db().await;
let repository = Arc::new(EntityRepository::new(pool.clone()));
let mock_server = MockServer::start().await;
// Mock: POST /api/v2/products/ → product id=55
Mock::given(method("POST"))
.and(path("/api/v2/products/"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": 55,
"name": "simple-service",
"prod_type": 1
})))
.expect(1)
.mount(&mock_server)
.await;
// No engagement mock — should NOT be called
Mock::given(method("POST"))
.and(path("/api/v2/engagements/"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({"id": 999})))
.expect(0) // MUST NOT be called
.mount(&mock_server)
.await;
let mut config = make_config(&mock_server.uri());
config.default_engagement.auto_create = false;
let client = DefectDojoClient::new(mock_server.uri(), "test-token".to_string()).unwrap();
let engagement_handler = Arc::new(
charybdis_defectdojo::handlers::EngagementHandler::new(
client.clone(),
repository.clone(),
HashMap::new(),
),
);
let handler = charybdis_defectdojo::handlers::ProductHandler::new(
client,
repository.clone(),
HashMap::new(),
config,
engagement_handler,
);
let entity = make_component_entity("", "simple-service", "");
let entity = repository.create(&entity).await.unwrap();
let result = handler.handle_create(&entity).await;
assert!(result.is_ok(), "handle_create failed: {:?}", result.err());
// Verify only product-id annotation (no engagement-id)
let updated = repository.get_by_id(&entity.id).await.unwrap().unwrap();
assert_eq!(
updated.annotations.get("defectdojo.com/product-id"),
Some(&"55".to_string())
);
assert!(!updated.annotations.contains_key("defectdojo.com/engagement-id"));
}
// ──────────────────────────────────────────────────────────────
// DefectDojo API error handling
// ──────────────────────────────────────────────────────────────
#[tokio::test]
#[ignore] // Requires DATABASE_URL
async fn test_product_creation_handles_api_error() {
let pool = setup_db().await;
let repository = Arc::new(EntityRepository::new(pool.clone()));
let mock_server = MockServer::start().await;
// Mock: POST /api/v2/products/ → 400 Bad Request
Mock::given(method("POST"))
.and(path("/api/v2/products/"))
.respond_with(
ResponseTemplate::new(400)
.set_body_json(json!({"name": ["This field may not be blank."]})),
)
.mount(&mock_server)
.await;
let config = make_config(&mock_server.uri());
let client = DefectDojoClient::new(mock_server.uri(), "test-token".to_string()).unwrap();
let engagement_handler = Arc::new(
charybdis_defectdojo::handlers::EngagementHandler::new(
client.clone(),
repository.clone(),
HashMap::new(),
),
);
let handler = charybdis_defectdojo::handlers::ProductHandler::new(
client,
repository.clone(),
HashMap::new(),
config,
engagement_handler,
);
let entity = make_component_entity("", "bad-entity", "team-x");
let entity = repository.create(&entity).await.unwrap();
let result = handler.handle_create(&entity).await;
assert!(result.is_err(), "Expected error on 400 response");
}