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
+340
View File
@@ -0,0 +1,340 @@
# Charybdis Plugins
This directory contains plugin implementations that extend Charybdis with integrations to external security and development tools.
## What are Plugins?
Charybdis supports **two types of plugins**, both compile-time integrated:
### **1. Event-Driven Plugins**
React to entity lifecycle events (create, update, delete):
- **Example**: DefectDojo, DependencyTrack
- **Implement**: `EventDrivenPlugin` trait + `ResourceHandler`
- **Triggered by**: Entity CRUD operations
- **Use case**: Auto-create resources in external tools when entities are created
### **2. Sync Plugins**
Pull data from external sources on a schedule:
- **Example**: Okta, Keycloak, Active Directory
- **Implement**: `SyncPlugin` trait
- **Triggered by**: Cron schedule or manual API call
- **Use case**: Sync users/groups from identity providers
## Generic Utilities
All plugins have access to reusable utilities in `src/plugins/`:
- **`PluginHttpClient`** - Multi-auth HTTP client (Token, Bearer, API Key, Basic Auth)
- **`AnnotationHelper`** - Consistent API for storing/retrieving plugin metadata
- **`FieldMapper`** - Maps entity fields to external tool formats
- **`DateUtils`** - Common date/time operations
## Plugin Structure
Each plugin follows this structure:
```
plugins/
└── my_plugin/
├── Cargo.toml # Plugin crate definition
├── README.md # Plugin-specific documentation
├── proto/
│ └── my_plugin.proto # Protobuf entity definitions
└── src/
├── lib.rs # Plugin entry point
├── handler.rs # Event handler implementation
├── client.rs # External API client
└── config.rs # Plugin configuration
```
## Available Plugins
### DefectDojo
- **Type**: Event-Driven
- **Purpose**: Security vulnerability management integration
- **External API**: DefectDojo REST API v2
- **Handlers**: ProductHandler, EngagementHandler, ProductTypeHandler, ProductMemberHandler
- **Features**:
- Automatic product creation on Component/Service creation
- Product updates on entity changes
- Auto-create CI/CD engagements
- Owner resolution (Group → members → email annotation → DefectDojo user lookup)
- Configurable OIDC provider mapping (Keycloak, Okta, Azure AD)
- Annotation storage (`defectdojo.com/product-id`, `defectdojo.com/engagement-id`, `defectdojo.com/owner-member-ids`)
### Keycloak
- **Type**: Sync
- **Purpose**: Identity provider sync (users and groups)
- **External API**: Keycloak Admin REST API
- **Features**:
- User sync with profile and annotations (display_name, email, picture)
- Group sync with hierarchy (parent, children, members)
- Configurable annotations (e.g., `keycloak.com/email` for OIDC mapping)
- On-startup sync, manual trigger via gRPC
### DependencyTrack (Scaffolded)
- **Type**: Event-Driven
- **Purpose**: Software supply chain security
- **External API**: DependencyTrack REST API
- **Status**: Scaffolded (proto + crate structure), handler logic not implemented
- **Planned**:
- Auto-create projects for components
- SBOM ingestion
## Creating a New Plugin
### Quick Start
1. **Create plugin directory structure:**
```bash
mkdir -p plugins/my_plugin/{proto,src}
```
2. **Add to plugins.toml:**
```toml
[plugins.my_plugin]
enabled = true
proto_path = "plugins/my_plugin/proto"
metadata_field_number = 102 # Use next available number
spec_field_number = 102
description = "My custom integration"
```
3. **Define protobuf schema:**
Create `plugins/my_plugin/proto/my_plugin.proto`:
```protobuf
syntax = "proto3";
package charybdis.plugins.my_plugin;
message MyPluginMetadata {
string name = 1;
string description = 2;
}
message MyPluginSpec {
string integration_type = 1;
bool enabled = 2;
}
```
4. **Create plugin crate:**
Create `plugins/my_plugin/Cargo.toml`:
```toml
[package]
name = "charybdis-plugin-my-plugin"
version = "0.1.0"
edition = "2021"
[dependencies]
charybdis = { path = "../.." }
async-trait = "0.1"
tokio = { version = "1.0", features = ["full"] }
tracing = "0.1"
```
5. **Implement event handler:**
Create `plugins/my_plugin/src/lib.rs`:
```rust
use async_trait::async_trait;
use charybdis::events::{EventHandler, EntityEvent, EventResult};
pub struct MyPluginHandler;
#[async_trait]
impl EventHandler for MyPluginHandler {
async fn handle_event(&self, event: &EntityEvent) -> EventResult<()> {
// React to entity changes
Ok(())
}
}
```
6. **Build and test:**
```bash
cargo build
cargo test
```
## Plugin Field Number Allocation
Field numbers must be unique across all plugins to avoid protobuf conflicts:
| Range | Allocation |
|----------|-----------------------|
| 1-99 | Core entity types |
| 100 | DefectDojo |
| 101 | DependencyTrack |
| 102-199 | Available for plugins |
When creating a new plugin, use the next available number in the 102+ range.
## Plugin Configuration
Plugins are configured in `config.toml` with `${VAR}` env var substitution for secrets:
```toml
[plugins.defectdojo]
enabled = true
base_url = "${DEFECTDOJO_API_URL}"
api_token = "${DEFECTDOJO_API_TOKEN}"
[plugins.defectdojo.default_engagement]
auto_create = true
name = "CI/CD Pipeline"
[plugins.keycloak]
enabled = true
base_url = "${KEYCLOAK_URL}"
realm = "master"
client_id = "charybdis-sync"
client_secret = "${KEYCLOAK_CLIENT_SECRET}"
```
See `config.toml.example` for all options.
## Plugin Lifecycle
1. **Build Time:**
- `build.rs` reads `plugins.toml`
- Generates `proto/entities.proto` with plugin types
- Compiles all protobuf files
- Generates Rust code
2. **Runtime:**
- Plugin handlers registered with event bus
- Events published on entity CRUD operations
- Handlers react asynchronously
- External APIs called as needed
3. **Event Flow:**
```
Client creates entity
→ Entity stored in database
→ Event published to event bus
→ Plugin handler receives event
→ Plugin calls external API
→ Plugin stores external ID in annotations
```
## Using Annotations
Plugins store external tool IDs in entity annotations:
```rust
// Store external ID
entity.annotations.insert(
"my-plugin.com/resource-id".to_string(),
"ext-12345".to_string(),
);
// Query by external ID in SQL
SELECT * FROM entities
WHERE annotations->>'my-plugin.com/resource-id' = 'ext-12345';
```
### Annotation Naming Convention
Use reverse-DNS style:
- `defectdojo.com/product-id`
- `dependencytrack.com/project-uuid`
- `github.com/repo-slug`
- `{tool}.com/{resource}-{attribute}`
## Best Practices
### 1. Error Handling
- Don't panic - return errors
- Log but continue on non-critical failures
- Implement retry logic for transient errors
### 2. Idempotency
- Check if resource exists before creating
- Make operations safe to retry
- Handle duplicate creation gracefully
### 3. Performance
- Don't block event handlers
- Use `tokio::spawn` for long operations
- Batch operations when possible
### 4. Testing
- Unit test event handlers
- Mock external API clients
- Integration tests with real APIs (optional)
### 5. Documentation
- Document required environment variables
- Provide example configurations
- Explain entity model and annotations
## Contributing Plugins
We welcome plugin contributions! To contribute:
1. Fork the repository
2. Create your plugin following the structure above
3. Add comprehensive tests
4. Document configuration and usage
5. Submit a pull request
### Plugin Requirements
- [ ] Protobuf definitions with Metadata and Spec messages
- [ ] Event handler implementation
- [ ] External API client (if applicable)
- [ ] Configuration via environment variables
- [ ] README with setup instructions
- [ ] Unit tests for event handler
- [ ] Example usage in documentation
## Plugin Distribution Models
### In-Tree (Current)
Plugins live in the `plugins/` directory and are enabled via `plugins.toml`.
**Pros:**
- Easy to discover
- Consistent quality
- Tested together
**Cons:**
- Requires core repo access
- All plugins built together
### External Crates (Future)
Plugins distributed as separate Rust crates.
**Example:**
```toml
[dependencies]
charybdis-plugin-custom = "0.1"
```
**Pros:**
- Independent versioning
- Community contributions
- Optional dependencies
**Cons:**
- Discovery harder
- Compatibility challenges
### Plugin Marketplace (Future)
Central registry of available plugins (like Backstage).
## Resources
- [Plugin Configuration Guide](../docs/PLUGIN_CONFIGURATION_GUIDE.md)
- [Core Concepts](../docs/core-concepts.md)
- [Architecture](../docs/architecture.md)
- [Protobuf Style Guide](https://protobuf.dev/programming-guides/style/)
## Support
- Open an issue for bug reports
- Discussions for questions
- PRs for contributions
## License
Same as Charybdis core (see LICENSE file in repository root)
+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");
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "charybdis-dependencytrack-plugin"
version = "0.1.0"
edition = "2021"
[lib]
name = "charybdis_dependencytrack"
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"
@@ -0,0 +1,23 @@
syntax = "proto3";
package charybdis.plugins.dependencytrack;
// Dependency-Track plugin metadata
message DependencytrackMetadata {
string name = 1;
string description = 2;
// Resource type (project)
string resource_type = 3;
}
// Dependency-Track plugin spec
message DependencytrackSpec {
// Sync status information
string sync_status = 1;
string last_sync = 2;
string error_message = 3;
// Configuration stored as JSON
string config_json = 4;
}
@@ -0,0 +1,3 @@
mod project;
pub use project::ProjectHandler;
@@ -0,0 +1,172 @@
use anyhow::Result;
use async_trait::async_trait;
use charybdis::charybdis::entities::{entity, Entity};
use charybdis::database::EntityRepository;
use charybdis::plugins::ResourceHandler;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{info, warn};
use crate::{DependencyTrackClient, DependencyTrackConfig};
/// Project handler — maps Component entities to Dependency-Track Projects
pub struct ProjectHandler {
client: DependencyTrackClient,
repository: Arc<EntityRepository>,
config: DependencyTrackConfig,
trigger_kinds: Vec<String>,
}
impl ProjectHandler {
pub fn new(
client: DependencyTrackClient,
repository: Arc<EntityRepository>,
config: DependencyTrackConfig,
) -> Self {
Self {
client,
repository,
config,
trigger_kinds: vec!["Component".to_string()],
}
}
/// Get project UUID from entity annotations
fn get_project_uuid(&self, entity: &Entity) -> Option<String> {
entity
.annotations
.get("dependencytrack.com/project-uuid")
.cloned()
}
/// Extract metadata fields from a Component entity
fn extract_component_fields(&self, entity: &Entity) -> (String, String, Vec<String>) {
let mut name = entity.id.clone();
let mut description = String::new();
let mut tags = Vec::new();
if let Some(entity::Metadata::ComponentMetadata(m)) = &entity.metadata {
name = m.name.clone();
description = m.description.clone();
tags.extend(m.tags.clone());
}
if let Some(entity::Spec::ComponentSpec(s)) = &entity.spec {
if !s.lifecycle.is_empty() {
tags.push(format!("lifecycle:{}", s.lifecycle));
}
if !s.r#type.is_empty() {
tags.push(format!("type:{}", s.r#type));
}
if !s.owner.is_empty() {
tags.push(format!("owner:{}", s.owner));
}
}
(name, description, tags)
}
/// Extract version from entity (defaults to "latest")
fn extract_version(&self, entity: &Entity) -> String {
// Check annotations first
if let Some(version) = entity.annotations.get("app.kubernetes.io/version") {
return version.clone();
}
if let Some(version) = entity.annotations.get("version") {
return version.clone();
}
"latest".to_string()
}
}
#[async_trait]
impl ResourceHandler for ProjectHandler {
fn resource_type(&self) -> &str {
"dependencytrack_project"
}
fn trigger_kinds(&self) -> &[String] {
&self.trigger_kinds
}
fn creates_entity_kind(&self) -> &str {
""
}
async fn handle_create(&self, entity: &Entity) -> Result<Option<Entity>> {
let (name, description, tags) = self.extract_component_fields(entity);
let version = self.extract_version(entity);
let project_uuid = self
.client
.create_project(&name, &version, &description, &tags)
.await?;
// Store project UUID in annotations (annotation-only update, no double-write)
let mut annotations = HashMap::new();
annotations.insert(
"dependencytrack.com/project-uuid".to_string(),
project_uuid.clone(),
);
self.repository
.update_annotations(&entity.id, annotations)
.await?;
info!(
"Linked entity {} to Dependency-Track project {}",
entity.id, project_uuid
);
Ok(None)
}
async fn handle_update(&self, entity: &Entity) -> Result<()> {
if let Some(project_uuid) = self.get_project_uuid(entity) {
let (name, description, tags) = self.extract_component_fields(entity);
let version = self.extract_version(entity);
self.client
.update_project(&project_uuid, &name, &version, &description, &tags)
.await?;
} else {
warn!(
"Entity {} has no Dependency-Track project UUID — creating one",
entity.id
);
let (name, description, tags) = self.extract_component_fields(entity);
let version = self.extract_version(entity);
let project_uuid = self
.client
.create_project(&name, &version, &description, &tags)
.await?;
let mut annotations = HashMap::new();
annotations.insert(
"dependencytrack.com/project-uuid".to_string(),
project_uuid,
);
self.repository
.update_annotations(&entity.id, annotations)
.await?;
}
Ok(())
}
async fn handle_delete(&self, entity: &Entity) -> Result<()> {
if let Some(project_uuid) = self.get_project_uuid(entity) {
self.client.delete_project(&project_uuid).await?;
} else {
warn!(
"Entity {} has no Dependency-Track project UUID — skipping deletion",
entity.id
);
}
Ok(())
}
}
+162
View File
@@ -0,0 +1,162 @@
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;
use tracing::info;
/// Dependency-Track plugin configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyTrackConfig {
/// Dependency-Track API base URL
pub base_url: String,
/// API key for authentication
pub api_key: String,
/// Request timeout in seconds
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
/// Auto-create projects for new Component entities
#[serde(default = "default_auto_create_project")]
pub auto_create_project: bool,
/// Default team UUID to assign to new projects (optional)
#[serde(default)]
pub default_team_uuid: Option<String>,
/// Field mappings for each resource type
#[serde(default)]
pub field_mappings: HashMap<String, HashMap<String, serde_json::Value>>,
}
fn default_timeout() -> u64 {
30
}
fn default_auto_create_project() -> bool {
true
}
impl DependencyTrackConfig {
/// Load Dependency-Track 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 DependencyTrackConfig
let config: DependencyTrackConfig = value
.try_into()
.map_err(|e| anyhow!("Failed to parse Dependency-Track configuration: {}", e))?;
Ok(config)
}
}
/// Dependency-Track API client (wrapper around PluginHttpClient)
#[derive(Clone)]
pub struct DependencyTrackClient {
client: PluginHttpClient,
}
impl DependencyTrackClient {
pub fn new(base_url: String, api_key: String) -> Result<Self> {
let client = PluginHttpClient::new(base_url, AuthConfig::api_key("X-Api-Key", api_key))?;
Ok(Self { client })
}
/// Make a GET request to Dependency-Track API
pub async fn get(&self, path: &str) -> Result<serde_json::Value> {
self.client.get(path).await
}
/// Make a POST request to Dependency-Track 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 Dependency-Track 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 Dependency-Track API
pub async fn delete(&self, path: &str) -> Result<()> {
self.client.delete(path).await
}
}
/// Dependency-Track event-driven plugin
#[allow(dead_code)]
pub struct DependencyTrackPlugin {
config: DependencyTrackConfig,
client: DependencyTrackClient,
repository: Arc<EntityRepository>,
handlers: Vec<Arc<dyn ResourceHandler>>,
}
impl DependencyTrackPlugin {
pub fn new(config: DependencyTrackConfig, repository: Arc<EntityRepository>) -> Result<Self> {
// Validate configuration
if config.base_url.is_empty() {
return Err(anyhow!("Dependency-Track base_url cannot be empty"));
}
if config.api_key.is_empty() {
return Err(anyhow!("Dependency-Track api_key cannot be empty"));
}
let client = DependencyTrackClient::new(config.base_url.clone(), config.api_key.clone())?;
// Resource handlers will be registered here as they are implemented
let handlers: Vec<Arc<dyn ResourceHandler>> = vec![];
info!(
"Dependency-Track plugin initialized (base_url={})",
config.base_url
);
Ok(Self {
config,
client,
repository,
handlers,
})
}
}
#[async_trait]
impl Plugin for DependencyTrackPlugin {
fn name(&self) -> &str {
"dependencytrack"
}
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<()> {
if self.config.base_url.is_empty() {
return Err(anyhow!("Dependency-Track base_url cannot be empty"));
}
if self.config.api_key.is_empty() {
return Err(anyhow!("Dependency-Track api_key cannot be empty"));
}
Ok(())
}
}
#[async_trait]
impl EventDrivenPlugin for DependencyTrackPlugin {
fn resource_handlers(&self) -> Vec<Arc<dyn ResourceHandler>> {
self.handlers.clone()
}
}
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "charybdis-keycloak-plugin"
version = "0.1.0"
edition = "2021"
[lib]
name = "charybdis_keycloak"
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"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.18", features = ["v4"] }
[dev-dependencies]
tokio-test = "0.4"
+27
View File
@@ -0,0 +1,27 @@
syntax = "proto3";
package charybdis.plugins.keycloak;
// Keycloak plugin metadata
// Used for plugin-specific sync status entities
message KeycloakMetadata {
string name = 1;
string description = 2;
// Resource type (user, group)
string resource_type = 3;
// Keycloak realm this resource belongs to
string realm = 4;
}
// Keycloak plugin spec
message KeycloakSpec {
// Sync status information
string sync_status = 1;
string last_sync = 2;
string error_message = 3;
// Configuration stored as JSON
string config_json = 4;
}
+355
View File
@@ -0,0 +1,355 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use charybdis::database::EntityRepository;
use charybdis::plugins::http_client::{AuthConfig, PluginHttpClient};
use charybdis::plugins::{Plugin, PluginConfig, PluginType, SyncConfig, SyncPlugin, SyncResult};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tracing::info;
mod sync;
/// Keycloak plugin configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeycloakConfig {
/// Keycloak server base URL (e.g., "https://keycloak.company.com")
pub base_url: String,
/// Keycloak realm to sync from
pub realm: String,
/// Client ID for service account authentication
pub client_id: String,
/// Client secret for service account authentication
pub client_secret: String,
/// Sync configuration
#[serde(default)]
pub sync: SyncOptions,
}
/// Sync options
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncOptions {
/// Cron schedule (e.g., "0 */5 * * * *" for every 5 minutes)
#[serde(default)]
pub schedule: Option<String>,
/// Sync on startup
#[serde(default = "default_true")]
pub on_startup: bool,
/// Allow manual trigger via API
#[serde(default = "default_true")]
pub manual_trigger: bool,
/// Sync users
#[serde(default = "default_true")]
pub sync_users: bool,
/// Sync groups
#[serde(default = "default_true")]
pub sync_groups: bool,
/// Namespace to assign to synced entities
#[serde(default = "default_namespace")]
pub namespace: String,
/// Max results per API page
#[serde(default = "default_page_size")]
pub page_size: i32,
}
fn default_true() -> bool {
true
}
fn default_namespace() -> String {
"keycloak".to_string()
}
fn default_page_size() -> i32 {
100
}
impl Default for SyncOptions {
fn default() -> Self {
Self {
schedule: None,
on_startup: true,
manual_trigger: true,
sync_users: true,
sync_groups: true,
namespace: default_namespace(),
page_size: default_page_size(),
}
}
}
impl KeycloakConfig {
/// Load Keycloak configuration from TOML config map
pub fn from_toml(config: &HashMap<String, toml::Value>) -> Result<Self> {
let value = toml::Value::Table(config.clone().into_iter().collect());
let config: KeycloakConfig = value
.try_into()
.map_err(|e| anyhow!("Failed to parse Keycloak configuration: {}", e))?;
Ok(config)
}
}
/// Keycloak API client
#[derive(Clone)]
pub struct KeycloakClient {
http: PluginHttpClient,
raw_client: reqwest::Client,
realm: String,
token_url: String,
client_id: String,
client_secret: String,
/// Cached access token
access_token: Arc<tokio::sync::RwLock<Option<String>>>,
}
/// Keycloak user representation from the Admin REST API
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KeycloakUser {
pub id: String,
pub username: String,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub first_name: Option<String>,
#[serde(default)]
pub last_name: Option<String>,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub email_verified: bool,
#[serde(default)]
pub attributes: Option<HashMap<String, Vec<String>>>,
}
/// Keycloak group representation from the Admin REST API
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KeycloakGroup {
pub id: String,
pub name: String,
#[serde(default)]
pub path: String,
#[serde(default)]
pub sub_groups: Vec<KeycloakGroup>,
#[serde(default)]
pub attributes: Option<HashMap<String, Vec<String>>>,
}
impl KeycloakClient {
pub fn new(config: &KeycloakConfig) -> Result<Self> {
let base_url = config.base_url.trim_end_matches('/');
let admin_url = format!("{}/admin/realms/{}", base_url, config.realm);
// Create HTTP client without auth — we'll add the token manually after obtaining it
let http = PluginHttpClient::new(admin_url, AuthConfig::None)?;
let raw_client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| anyhow!("Failed to create HTTP client for Keycloak: {}", e))?;
let token_url = format!(
"{}/realms/{}/protocol/openid-connect/token",
base_url, config.realm
);
Ok(Self {
http,
raw_client,
realm: config.realm.clone(),
token_url,
client_id: config.client_id.clone(),
client_secret: config.client_secret.clone(),
access_token: Arc::new(tokio::sync::RwLock::new(None)),
})
}
/// Obtain an access token using client credentials grant
pub async fn authenticate(&self) -> Result<()> {
info!("Authenticating with Keycloak (client credentials grant)");
let response = self.raw_client
.post(&self.token_url)
.form(&[
("grant_type", "client_credentials"),
("client_id", &self.client_id),
("client_secret", &self.client_secret),
])
.send()
.await
.map_err(|e| anyhow!("Keycloak token request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(anyhow!(
"Keycloak authentication failed ({}): {}",
status,
body
));
}
let token_response: serde_json::Value = response.json().await?;
let access_token = token_response["access_token"]
.as_str()
.ok_or_else(|| anyhow!("No access_token in Keycloak response"))?
.to_string();
let mut token = self.access_token.write().await;
*token = Some(access_token);
info!("Keycloak authentication successful");
Ok(())
}
/// Make an authenticated GET request to Keycloak Admin API
async fn get(&self, path: &str) -> Result<serde_json::Value> {
let token = self.access_token.read().await;
let token = token
.as_ref()
.ok_or_else(|| anyhow!("Not authenticated — call authenticate() first"))?;
let url = format!("{}/{}", self.http.base_url(), path.trim_start_matches('/'));
let response = self.raw_client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/json")
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(anyhow!("Keycloak API error ({}): {}", status, body));
}
Ok(response.json().await?)
}
/// Fetch all users from the realm (paginated)
pub async fn fetch_users(&self, page_size: i32) -> Result<Vec<KeycloakUser>> {
let mut all_users = Vec::new();
let mut first = 0;
loop {
let response = self
.get(&format!("users?first={}&max={}", first, page_size))
.await?;
let users: Vec<KeycloakUser> = serde_json::from_value(response)?;
let count = users.len();
all_users.extend(users);
if (count as i32) < page_size {
break;
}
first += page_size;
}
info!("Fetched {} users from Keycloak realm '{}'", all_users.len(), self.realm);
Ok(all_users)
}
/// Fetch all groups from the realm
pub async fn fetch_groups(&self) -> Result<Vec<KeycloakGroup>> {
let response = self.get("groups?briefRepresentation=false").await?;
let groups: Vec<KeycloakGroup> = serde_json::from_value(response)?;
info!("Fetched {} top-level groups from Keycloak realm '{}'", groups.len(), self.realm);
Ok(groups)
}
/// Fetch members of a group
pub async fn fetch_group_members(&self, group_id: &str) -> Result<Vec<KeycloakUser>> {
let response = self.get(&format!("groups/{}/members", group_id)).await?;
let members: Vec<KeycloakUser> = serde_json::from_value(response)?;
Ok(members)
}
}
/// Keycloak sync plugin
pub struct KeycloakPlugin {
config: KeycloakConfig,
client: KeycloakClient,
repository: Arc<EntityRepository>,
sync_config: SyncConfig,
}
impl KeycloakPlugin {
pub fn new(config: KeycloakConfig, repository: Arc<EntityRepository>) -> Result<Self> {
if config.base_url.is_empty() {
return Err(anyhow!("Keycloak base_url cannot be empty"));
}
if config.realm.is_empty() {
return Err(anyhow!("Keycloak realm cannot be empty"));
}
if config.client_id.is_empty() {
return Err(anyhow!("Keycloak client_id cannot be empty"));
}
if config.client_secret.is_empty() {
return Err(anyhow!("Keycloak client_secret cannot be empty"));
}
let client = KeycloakClient::new(&config)?;
let sync_config = SyncConfig {
schedule: config.sync.schedule.clone(),
on_startup: config.sync.on_startup,
manual_trigger: config.sync.manual_trigger,
};
Ok(Self {
config,
client,
repository,
sync_config,
})
}
}
#[async_trait]
impl Plugin for KeycloakPlugin {
fn name(&self) -> &str {
"keycloak"
}
fn plugin_type(&self) -> PluginType {
PluginType::Sync
}
fn load_config(&mut self, _config: PluginConfig) -> Result<()> {
Ok(())
}
fn validate_config(&self) -> Result<()> {
if self.config.base_url.is_empty() {
return Err(anyhow!("Keycloak base_url cannot be empty"));
}
if self.config.realm.is_empty() {
return Err(anyhow!("Keycloak realm cannot be empty"));
}
Ok(())
}
}
#[async_trait]
impl SyncPlugin for KeycloakPlugin {
fn sync_config(&self) -> &SyncConfig {
&self.sync_config
}
async fn sync(&self) -> Result<SyncResult> {
sync::run_sync(&self.client, &self.repository, &self.config).await
}
}
+545
View File
@@ -0,0 +1,545 @@
use anyhow::Result;
use charybdis::charybdis::core::{
GroupMetadata, GroupProfile, GroupSpec, UserMetadata, UserProfile, UserSpec,
};
use charybdis::charybdis::entities::{entity, Entity};
use charybdis::database::EntityRepository;
use std::collections::HashMap;
use tracing::{debug, error, info, warn};
use crate::{KeycloakClient, KeycloakConfig, KeycloakGroup, KeycloakUser};
use charybdis::plugins::SyncResult;
/// Run a full sync cycle: fetch users and groups from Keycloak, reconcile with Charybdis.
///
/// This is a pure pull operation — it only reads from Keycloak and writes to Charybdis.
/// Nothing is ever pushed back to Keycloak.
pub async fn run_sync(
client: &KeycloakClient,
repository: &EntityRepository,
config: &KeycloakConfig,
) -> Result<SyncResult> {
info!("Starting Keycloak sync for realm '{}'", config.realm);
// Authenticate with Keycloak
client.authenticate().await?;
let mut result = SyncResult {
entities_created: 0,
entities_updated: 0,
entities_deleted: 0,
errors: Vec::new(),
};
// Load existing Charybdis entities that were previously synced from Keycloak
let existing_entities = repository.list_all().await?;
let existing_users: HashMap<String, Entity> = existing_entities
.iter()
.filter(|e| e.kind == "User")
.filter_map(|e| {
e.annotations
.get("keycloak.com/user-id")
.map(|kc_id| (kc_id.clone(), e.clone()))
})
.collect();
let existing_groups: HashMap<String, Entity> = existing_entities
.iter()
.filter(|e| e.kind == "Group")
.filter_map(|e| {
e.annotations
.get("keycloak.com/group-id")
.map(|kc_id| (kc_id.clone(), e.clone()))
})
.collect();
// Sync users
if config.sync.sync_users {
match client.fetch_users(config.sync.page_size).await {
Ok(users) => {
info!("Syncing {} users from Keycloak", users.len());
for user in &users {
match sync_user(user, &existing_users, repository, config).await {
Ok(SyncAction::Created) => result.entities_created += 1,
Ok(SyncAction::Updated) => result.entities_updated += 1,
Err(e) => {
let msg = format!("Failed to sync user '{}': {}", user.username, e);
warn!("{}", msg);
result.errors.push(msg);
}
}
}
}
Err(e) => {
let msg = format!("Failed to fetch users from Keycloak: {}", e);
error!("{}", msg);
result.errors.push(msg);
}
}
}
// Sync groups
if config.sync.sync_groups {
match client.fetch_groups().await {
Ok(groups) => {
let flat_groups = flatten_groups(&groups);
info!(
"Syncing {} groups from Keycloak (flattened)",
flat_groups.len()
);
for group in &flat_groups {
match sync_group(group, &existing_groups, client, repository, config).await {
Ok(SyncAction::Created) => result.entities_created += 1,
Ok(SyncAction::Updated) => result.entities_updated += 1,
Err(e) => {
let msg = format!("Failed to sync group '{}': {}", group.name, e);
warn!("{}", msg);
result.errors.push(msg);
}
}
}
}
Err(e) => {
let msg = format!("Failed to fetch groups from Keycloak: {}", e);
error!("{}", msg);
result.errors.push(msg);
}
}
}
info!(
"Keycloak sync complete: created={}, updated={}, errors={}",
result.entities_created,
result.entities_updated,
result.errors.len()
);
Ok(result)
}
/// Result of syncing a single entity
enum SyncAction {
Created,
Updated,
}
/// Build a Charybdis User entity from a Keycloak user
fn build_user_entity(kc_user: &KeycloakUser, config: &KeycloakConfig) -> Entity {
let display_name = match (&kc_user.first_name, &kc_user.last_name) {
(Some(first), Some(last)) => format!("{} {}", first, last),
(Some(first), None) => first.clone(),
(None, Some(last)) => last.clone(),
(None, None) => kc_user.username.clone(),
};
let description = format!("User synced from Keycloak realm '{}'", config.realm);
let mut labels = HashMap::new();
labels.insert("keycloak.realm".to_string(), config.realm.clone());
labels.insert("enabled".to_string(), kc_user.enabled.to_string());
if kc_user.email_verified {
labels.insert("email-verified".to_string(), "true".to_string());
}
let mut annotations = HashMap::new();
annotations.insert("keycloak.com/user-id".to_string(), kc_user.id.clone());
annotations.insert(
"keycloak.com/username".to_string(),
kc_user.username.clone(),
);
annotations.insert("keycloak.com/realm".to_string(), config.realm.clone());
if let Some(email) = &kc_user.email {
annotations.insert("keycloak.com/email".to_string(), email.clone());
}
Entity {
id: String::new(), // Will be set by server on create, or overwritten for updates
kind: "User".to_string(),
metadata: Some(entity::Metadata::UserMetadata(UserMetadata {
name: kc_user.username.clone(),
namespace: config.sync.namespace.clone(),
description,
labels,
tags: vec!["keycloak".to_string(), "synced".to_string()],
links: vec![],
})),
spec: Some(entity::Spec::UserSpec(UserSpec {
profile: Some(UserProfile {
display_name,
email: kc_user.email.clone().unwrap_or_default(),
picture: String::new(),
}),
member_of: vec![], // Populated during group sync via annotations
})),
annotations,
created_at: None,
updated_at: None,
}
}
/// Build a Charybdis Group entity from a Keycloak group
fn build_group_entity(
kc_group: &KeycloakGroup,
member_usernames: Vec<String>,
config: &KeycloakConfig,
) -> Entity {
let description = format!(
"Group synced from Keycloak realm '{}' (path: {})",
config.realm, kc_group.path
);
let mut labels = HashMap::new();
labels.insert("keycloak.realm".to_string(), config.realm.clone());
labels.insert("keycloak.path".to_string(), kc_group.path.clone());
let mut annotations = HashMap::new();
annotations.insert("keycloak.com/group-id".to_string(), kc_group.id.clone());
annotations.insert(
"keycloak.com/group-path".to_string(),
kc_group.path.clone(),
);
annotations.insert("keycloak.com/realm".to_string(), config.realm.clone());
annotations.insert(
"keycloak.com/member-count".to_string(),
member_usernames.len().to_string(),
);
let parent = extract_parent_group(&kc_group.path);
let children: Vec<String> = kc_group.sub_groups.iter().map(|g| g.name.clone()).collect();
Entity {
id: String::new(),
kind: "Group".to_string(),
metadata: Some(entity::Metadata::GroupMetadata(GroupMetadata {
name: kc_group.name.clone(),
namespace: config.sync.namespace.clone(),
description,
labels,
tags: vec!["keycloak".to_string(), "synced".to_string()],
links: vec![],
})),
spec: Some(entity::Spec::GroupSpec(GroupSpec {
r#type: "team".to_string(),
profile: Some(GroupProfile {
display_name: kc_group.name.clone(),
email: String::new(),
picture: String::new(),
}),
parent: parent.unwrap_or_default(),
children,
members: member_usernames,
})),
annotations,
created_at: None,
updated_at: None,
}
}
/// Sync a single Keycloak user to a Charybdis User entity.
///
/// If the entity already exists (matched by `keycloak.com/user-id` annotation),
/// it is always updated to reflect the current Keycloak state.
/// Non-Keycloak annotations on the existing entity are preserved.
async fn sync_user(
kc_user: &KeycloakUser,
existing: &HashMap<String, Entity>,
repository: &EntityRepository,
config: &KeycloakConfig,
) -> Result<SyncAction> {
let entity = build_user_entity(kc_user, config);
if let Some(existing_entity) = existing.get(&kc_user.id) {
debug!(
"Updating user '{}' (Keycloak ID: {})",
kc_user.username, kc_user.id
);
let mut updated = entity;
updated.id = existing_entity.id.clone();
// Preserve annotations that aren't managed by this plugin
for (k, v) in &existing_entity.annotations {
if !k.starts_with("keycloak.com/") {
updated.annotations.entry(k.clone()).or_insert(v.clone());
}
}
repository.update(&existing_entity.id, &updated).await?;
return Ok(SyncAction::Updated);
}
debug!(
"Creating user '{}' (Keycloak ID: {})",
kc_user.username, kc_user.id
);
repository.create(&entity).await?;
Ok(SyncAction::Created)
}
/// Sync a single Keycloak group to a Charybdis Group entity.
///
/// If the entity already exists (matched by `keycloak.com/group-id` annotation),
/// it is always updated to reflect the current Keycloak state.
/// Non-Keycloak annotations on the existing entity are preserved.
async fn sync_group(
kc_group: &KeycloakGroup,
existing: &HashMap<String, Entity>,
client: &KeycloakClient,
repository: &EntityRepository,
config: &KeycloakConfig,
) -> Result<SyncAction> {
// Fetch current group members from Keycloak
let members = client.fetch_group_members(&kc_group.id).await?;
let member_usernames: Vec<String> = members.iter().map(|m| m.username.clone()).collect();
let entity = build_group_entity(kc_group, member_usernames, config);
if let Some(existing_entity) = existing.get(&kc_group.id) {
debug!(
"Updating group '{}' (Keycloak ID: {})",
kc_group.name, kc_group.id
);
let mut updated = entity;
updated.id = existing_entity.id.clone();
// Preserve annotations that aren't managed by this plugin
for (k, v) in &existing_entity.annotations {
if !k.starts_with("keycloak.com/") {
updated.annotations.entry(k.clone()).or_insert(v.clone());
}
}
repository.update(&existing_entity.id, &updated).await?;
return Ok(SyncAction::Updated);
}
debug!(
"Creating group '{}' (Keycloak ID: {})",
kc_group.name, kc_group.id
);
repository.create(&entity).await?;
Ok(SyncAction::Created)
}
/// Flatten a nested group tree into a flat list
fn flatten_groups(groups: &[KeycloakGroup]) -> Vec<KeycloakGroup> {
let mut flat = Vec::new();
for group in groups {
flat.push(group.clone());
if !group.sub_groups.is_empty() {
flat.extend(flatten_groups(&group.sub_groups));
}
}
flat
}
/// Extract parent group name from a Keycloak group path.
/// e.g., "/engineering/backend" -> Some("engineering")
fn extract_parent_group(path: &str) -> Option<String> {
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
if parts.len() > 1 {
Some(parts[parts.len() - 2].to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_flatten_groups() {
let groups = vec![KeycloakGroup {
id: "1".to_string(),
name: "engineering".to_string(),
path: "/engineering".to_string(),
sub_groups: vec![
KeycloakGroup {
id: "2".to_string(),
name: "backend".to_string(),
path: "/engineering/backend".to_string(),
sub_groups: vec![],
attributes: None,
},
KeycloakGroup {
id: "3".to_string(),
name: "frontend".to_string(),
path: "/engineering/frontend".to_string(),
sub_groups: vec![],
attributes: None,
},
],
attributes: None,
}];
let flat = flatten_groups(&groups);
assert_eq!(flat.len(), 3);
assert_eq!(flat[0].name, "engineering");
assert_eq!(flat[1].name, "backend");
assert_eq!(flat[2].name, "frontend");
}
#[test]
fn test_extract_parent_group() {
assert_eq!(extract_parent_group("/engineering"), None);
assert_eq!(
extract_parent_group("/engineering/backend"),
Some("engineering".to_string())
);
assert_eq!(
extract_parent_group("/org/engineering/backend"),
Some("engineering".to_string())
);
}
#[test]
fn test_build_user_entity() {
let kc_user = KeycloakUser {
id: "kc-user-123".to_string(),
username: "jdoe".to_string(),
email: Some("john.doe@example.com".to_string()),
first_name: Some("John".to_string()),
last_name: Some("Doe".to_string()),
enabled: true,
email_verified: true,
attributes: None,
};
let config = KeycloakConfig {
base_url: "https://keycloak.example.com".to_string(),
realm: "test-realm".to_string(),
client_id: "test-client".to_string(),
client_secret: "secret".to_string(),
sync: crate::SyncOptions::default(),
};
let entity = build_user_entity(&kc_user, &config);
assert_eq!(entity.kind, "User");
assert_eq!(
entity.annotations.get("keycloak.com/user-id"),
Some(&"kc-user-123".to_string())
);
assert_eq!(
entity.annotations.get("keycloak.com/email"),
Some(&"john.doe@example.com".to_string())
);
// Check metadata
match &entity.metadata {
Some(entity::Metadata::UserMetadata(m)) => {
assert_eq!(m.name, "jdoe");
assert_eq!(m.namespace, "keycloak");
assert!(m.tags.contains(&"keycloak".to_string()));
}
_ => panic!("Expected UserMetadata"),
}
// Check spec
match &entity.spec {
Some(entity::Spec::UserSpec(s)) => {
let profile = s.profile.as_ref().unwrap();
assert_eq!(profile.display_name, "John Doe");
assert_eq!(profile.email, "john.doe@example.com");
}
_ => panic!("Expected UserSpec"),
}
}
#[test]
fn test_build_group_entity() {
let kc_group = KeycloakGroup {
id: "kc-group-456".to_string(),
name: "backend".to_string(),
path: "/engineering/backend".to_string(),
sub_groups: vec![],
attributes: None,
};
let config = KeycloakConfig {
base_url: "https://keycloak.example.com".to_string(),
realm: "test-realm".to_string(),
client_id: "test-client".to_string(),
client_secret: "secret".to_string(),
sync: crate::SyncOptions::default(),
};
let members = vec!["jdoe".to_string(), "asmith".to_string()];
let entity = build_group_entity(&kc_group, members, &config);
assert_eq!(entity.kind, "Group");
assert_eq!(
entity.annotations.get("keycloak.com/group-id"),
Some(&"kc-group-456".to_string())
);
assert_eq!(
entity.annotations.get("keycloak.com/member-count"),
Some(&"2".to_string())
);
// Check metadata
match &entity.metadata {
Some(entity::Metadata::GroupMetadata(m)) => {
assert_eq!(m.name, "backend");
assert_eq!(m.namespace, "keycloak");
}
_ => panic!("Expected GroupMetadata"),
}
// Check spec
match &entity.spec {
Some(entity::Spec::GroupSpec(s)) => {
assert_eq!(s.parent, "engineering");
assert_eq!(s.members, vec!["jdoe", "asmith"]);
assert_eq!(s.r#type, "team");
}
_ => panic!("Expected GroupSpec"),
}
}
#[test]
fn test_build_user_entity_minimal() {
let kc_user = KeycloakUser {
id: "kc-user-minimal".to_string(),
username: "ghost".to_string(),
email: None,
first_name: None,
last_name: None,
enabled: false,
email_verified: false,
attributes: None,
};
let config = KeycloakConfig {
base_url: "https://keycloak.example.com".to_string(),
realm: "minimal".to_string(),
client_id: "c".to_string(),
client_secret: "s".to_string(),
sync: crate::SyncOptions::default(),
};
let entity = build_user_entity(&kc_user, &config);
// No email annotation when email is None
assert!(!entity.annotations.contains_key("keycloak.com/email"));
// Display name falls back to username
match &entity.spec {
Some(entity::Spec::UserSpec(s)) => {
let profile = s.profile.as_ref().unwrap();
assert_eq!(profile.display_name, "ghost");
assert_eq!(profile.email, "");
}
_ => panic!("Expected UserSpec"),
}
// Labels should reflect disabled state
match &entity.metadata {
Some(entity::Metadata::UserMetadata(m)) => {
assert_eq!(m.labels.get("enabled"), Some(&"false".to_string()));
assert!(!m.labels.contains_key("email-verified"));
}
_ => panic!("Expected UserMetadata"),
}
}
}