Public Access
initial-commit
This commit is contained in:
@@ -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)
|
||||
@@ -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"] }
|
||||
@@ -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.
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user