From b49913265d0432e0b6d4c2557ca2eb1d4e6f2098 Mon Sep 17 00:00:00 2001 From: Jens Reimann Date: Thu, 30 Jul 2026 11:12:11 +0200 Subject: [PATCH] feat(validation): add document validation module with entity, migration, and API Introduces the trustify-module-validation crate with entity definition, database migration (m0002310), service layer, and REST API endpoints for tracking document validation results. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 20 ++ Cargo.toml | 2 + entity/src/document_validation.rs | 21 ++ entity/src/lib.rs | 1 + migration/src/lib.rs | 2 + .../m0002310_create_document_validation.rs | 141 ++++++++++++++ modules/validation/Cargo.toml | 23 +++ modules/validation/src/endpoints.rs | 184 ++++++++++++++++++ modules/validation/src/lib.rs | 3 + modules/validation/src/model.rs | 70 +++++++ modules/validation/src/service.rs | 140 +++++++++++++ server/Cargo.toml | 1 + server/src/profile/api.rs | 1 + 13 files changed, 609 insertions(+) create mode 100644 entity/src/document_validation.rs create mode 100644 migration/src/m0002310_create_document_validation.rs create mode 100644 modules/validation/Cargo.toml create mode 100644 modules/validation/src/endpoints.rs create mode 100644 modules/validation/src/lib.rs create mode 100644 modules/validation/src/model.rs create mode 100644 modules/validation/src/service.rs diff --git a/Cargo.lock b/Cargo.lock index bf82f994f..e3d873f31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8804,6 +8804,25 @@ dependencies = [ "utoipa-actix-web", ] +[[package]] +name = "trustify-module-validation" +version = "0.5.0-rc.1" +dependencies = [ + "actix-web", + "sea-orm", + "sea-query", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "trustify-auth", + "trustify-common", + "trustify-entity", + "utoipa", + "utoipa-actix-web", + "uuid", +] + [[package]] name = "trustify-query" version = "0.5.0-rc.1" @@ -8852,6 +8871,7 @@ dependencies = [ "trustify-module-storage", "trustify-module-ui", "trustify-module-user", + "trustify-module-validation", "trustify-test-context", "url", "urlencoding", diff --git a/Cargo.toml b/Cargo.toml index 4255cb123..6eb7b31fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "modules/storage", "modules/ui", "modules/user", + "modules/validation", "query", "query/query-derive", "server", @@ -175,6 +176,7 @@ trustify-module-ingestor = { path = "modules/ingestor" } trustify-module-storage = { path = "modules/storage" } trustify-module-ui = { path = "modules/ui", default-features = false } trustify-module-user = { path = "modules/user" } +trustify-module-validation = { path = "modules/validation" } trustify-query = {path = "query" } trustify-query-derive = {path = "query/query-derive" } trustify-server = { path = "server", default-features = false } diff --git a/entity/src/document_validation.rs b/entity/src/document_validation.rs new file mode 100644 index 000000000..24cd3a7cc --- /dev/null +++ b/entity/src/document_validation.rs @@ -0,0 +1,21 @@ +use sea_orm::entity::prelude::*; +use time::OffsetDateTime; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "document_validation")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub entity_type: String, + pub entity_id: Uuid, + pub level: String, + pub message: String, + pub source: String, + pub key: String, + pub timestamp: OffsetDateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/entity/src/lib.rs b/entity/src/lib.rs index 1acaedeef..2cb3e69e7 100644 --- a/entity/src/lib.rs +++ b/entity/src/lib.rs @@ -42,6 +42,7 @@ pub mod sbom_node_purl_ref; pub mod sbom_package; pub mod sbom_package_license; pub mod source_document; +pub mod document_validation; pub mod status; pub mod user_preferences; pub mod version_range; diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 8cbd7da80..35b75e42f 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -71,6 +71,7 @@ mod m0002260_cpe_part_vendor_product_index; mod m0002270_fix_vulnerability_base_score_type; mod m0002280_backfill_sbom_suppliers; mod m0002290_create_exploit_intelligence_job; +mod m0002310_create_document_validation; pub trait MigratorExt: Send { fn build_migrations() -> Migrations; @@ -157,6 +158,7 @@ impl MigratorExt for Migrator { .normal(m0002270_fix_vulnerability_base_score_type::Migration) .data(m0002280_backfill_sbom_suppliers::Migration) .normal(m0002290_create_exploit_intelligence_job::Migration) + .normal(m0002310_create_document_validation::Migration) } } diff --git a/migration/src/m0002310_create_document_validation.rs b/migration/src/m0002310_create_document_validation.rs new file mode 100644 index 000000000..43cdba119 --- /dev/null +++ b/migration/src/m0002310_create_document_validation.rs @@ -0,0 +1,141 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(DocumentValidation::Table) + .if_not_exists() + .col( + ColumnDef::new(DocumentValidation::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(DocumentValidation::EntityType) + .text() + .not_null(), + ) + .col( + ColumnDef::new(DocumentValidation::EntityId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(DocumentValidation::Level) + .text() + .not_null(), + ) + .col( + ColumnDef::new(DocumentValidation::Message) + .text() + .not_null(), + ) + .col( + ColumnDef::new(DocumentValidation::Source) + .text() + .not_null(), + ) + .col( + ColumnDef::new(DocumentValidation::Key) + .text() + .not_null(), + ) + .col( + ColumnDef::new(DocumentValidation::Timestamp) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .table(DocumentValidation::Table) + .name(Indexes::UqDocumentValidationSourceKey.to_string()) + .col(DocumentValidation::EntityType) + .col(DocumentValidation::EntityId) + .col(DocumentValidation::Source) + .col(DocumentValidation::Key) + .unique() + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .table(DocumentValidation::Table) + .name(Indexes::IdxDocumentValidationEntity.to_string()) + .col(DocumentValidation::EntityType) + .col(DocumentValidation::EntityId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .if_exists() + .table(DocumentValidation::Table) + .name(Indexes::IdxDocumentValidationEntity.to_string()) + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .if_exists() + .table(DocumentValidation::Table) + .name(Indexes::UqDocumentValidationSourceKey.to_string()) + .to_owned(), + ) + .await?; + + manager + .drop_table( + Table::drop() + .if_exists() + .table(DocumentValidation::Table) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum DocumentValidation { + Table, + Id, + EntityType, + EntityId, + Level, + Message, + Source, + Key, + Timestamp, +} + +#[derive(DeriveIden)] +enum Indexes { + UqDocumentValidationSourceKey, + IdxDocumentValidationEntity, +} diff --git a/modules/validation/Cargo.toml b/modules/validation/Cargo.toml new file mode 100644 index 000000000..2d0e5f1b6 --- /dev/null +++ b/modules/validation/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "trustify-module-validation" +version.workspace = true +edition.workspace = true +publish.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +trustify-auth = { workspace = true } +trustify-common = { workspace = true } +trustify-entity = { workspace = true } + +actix-web = { workspace = true } +sea-orm = { workspace = true, features = ["sea-query-binder", "sqlx-postgres", "runtime-tokio-rustls", "macros", "debug-print"] } +sea-query = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +time = { workspace = true, features = ["serde-well-known"] } +utoipa = { workspace = true, features = ["actix_extras", "time", "url", "uuid"] } +utoipa-actix-web = { workspace = true } +uuid = { workspace = true } diff --git a/modules/validation/src/endpoints.rs b/modules/validation/src/endpoints.rs new file mode 100644 index 000000000..e48562505 --- /dev/null +++ b/modules/validation/src/endpoints.rs @@ -0,0 +1,184 @@ +use crate::model::{ValidationRequest, ValidationResult}; +use crate::service::{Error, ValidationService}; +use actix_web::{HttpResponse, Responder, delete, get, put, web}; +use sea_orm::TransactionTrait; +use trustify_auth::{ReadAdvisory, ReadSbom, UpdateAdvisory, UpdateSbom, authorizer::Require}; +use trustify_common::db; +use uuid::Uuid; + +/// Mount the "validation" module. +pub fn configure(svc: &mut utoipa_actix_web::service_config::ServiceConfig) { + svc.app_data(web::Data::new(ValidationService::new())) + .service(list_sbom_validations) + .service(upsert_sbom_validation) + .service(delete_sbom_validation) + .service(list_advisory_validations) + .service(upsert_advisory_validation) + .service(delete_advisory_validation); +} + +// --- SBOM validation endpoints --- + +#[utoipa::path( + tag = "validation", + operation_id = "listSbomValidations", + params( + ("id" = String, Path, description = "The SBOM identifier"), + ), + responses( + (status = 200, description = "Validation results for the SBOM", body = Vec), + ) +)] +#[get("/v3/sbom/{id}/validation")] +async fn list_sbom_validations( + service: web::Data, + db: web::Data, + id: web::Path, + _: Require, +) -> Result { + let entity_id = parse_entity_id(&id)?; + let tx = db.begin().await?; + let results = service.list("sbom", entity_id, &tx).await?; + Ok(HttpResponse::Ok().json(results)) +} + +#[utoipa::path( + tag = "validation", + operation_id = "upsertSbomValidation", + params( + ("id" = String, Path, description = "The SBOM identifier"), + ), + request_body = ValidationRequest, + responses( + (status = 200, description = "Validation result created or updated", body = ValidationResult), + ) +)] +#[put("/v3/sbom/{id}/validation")] +async fn upsert_sbom_validation( + service: web::Data, + db: web::Data, + id: web::Path, + web::Json(request): web::Json, + _: Require, +) -> Result { + let entity_id = parse_entity_id(&id)?; + let tx = db.begin().await?; + let result = service.upsert("sbom", entity_id, request, &tx).await?; + tx.commit().await?; + Ok(HttpResponse::Ok().json(result)) +} + +#[utoipa::path( + tag = "validation", + operation_id = "deleteSbomValidation", + params( + ("id" = String, Path, description = "The SBOM identifier"), + ("validation_id" = Uuid, Path, description = "The validation result ID"), + ), + responses( + (status = 204, description = "Validation result deleted"), + (status = 404, description = "Validation result not found"), + ) +)] +#[delete("/v3/sbom/{id}/validation/{validation_id}")] +async fn delete_sbom_validation( + service: web::Data, + db: web::Data, + path: web::Path<(String, Uuid)>, + _: Require, +) -> Result { + let (_, validation_id) = path.into_inner(); + let tx = db.begin().await?; + if service.delete(validation_id, &tx).await? { + tx.commit().await?; + Ok(HttpResponse::NoContent().finish()) + } else { + Ok(HttpResponse::NotFound().finish()) + } +} + +// --- Advisory validation endpoints --- + +#[utoipa::path( + tag = "validation", + operation_id = "listAdvisoryValidations", + params( + ("id" = String, Path, description = "The advisory identifier"), + ), + responses( + (status = 200, description = "Validation results for the advisory", body = Vec), + ) +)] +#[get("/v3/advisory/{id}/validation")] +async fn list_advisory_validations( + service: web::Data, + db: web::Data, + id: web::Path, + _: Require, +) -> Result { + let entity_id = parse_entity_id(&id)?; + let tx = db.begin().await?; + let results = service.list("advisory", entity_id, &tx).await?; + Ok(HttpResponse::Ok().json(results)) +} + +#[utoipa::path( + tag = "validation", + operation_id = "upsertAdvisoryValidation", + params( + ("id" = String, Path, description = "The advisory identifier"), + ), + request_body = ValidationRequest, + responses( + (status = 200, description = "Validation result created or updated", body = ValidationResult), + ) +)] +#[put("/v3/advisory/{id}/validation")] +async fn upsert_advisory_validation( + service: web::Data, + db: web::Data, + id: web::Path, + web::Json(request): web::Json, + _: Require, +) -> Result { + let entity_id = parse_entity_id(&id)?; + let tx = db.begin().await?; + let result = service.upsert("advisory", entity_id, request, &tx).await?; + tx.commit().await?; + Ok(HttpResponse::Ok().json(result)) +} + +#[utoipa::path( + tag = "validation", + operation_id = "deleteAdvisoryValidation", + params( + ("id" = String, Path, description = "The advisory identifier"), + ("validation_id" = Uuid, Path, description = "The validation result ID"), + ), + responses( + (status = 204, description = "Validation result deleted"), + (status = 404, description = "Validation result not found"), + ) +)] +#[delete("/v3/advisory/{id}/validation/{validation_id}")] +async fn delete_advisory_validation( + service: web::Data, + db: web::Data, + path: web::Path<(String, Uuid)>, + _: Require, +) -> Result { + let (_, validation_id) = path.into_inner(); + let tx = db.begin().await?; + if service.delete(validation_id, &tx).await? { + tx.commit().await?; + Ok(HttpResponse::NoContent().finish()) + } else { + Ok(HttpResponse::NotFound().finish()) + } +} + +/// Extracts a UUID from an ID path parameter (handles `urn:uuid:` prefix). +fn parse_entity_id(id: &str) -> Result { + let raw = id.strip_prefix("urn:uuid:").unwrap_or(id); + Uuid::parse_str(raw).map_err(|e| Error::Database(sea_orm::DbErr::Custom(e.to_string()))) +} diff --git a/modules/validation/src/lib.rs b/modules/validation/src/lib.rs new file mode 100644 index 000000000..9f071c2d8 --- /dev/null +++ b/modules/validation/src/lib.rs @@ -0,0 +1,3 @@ +pub mod endpoints; +pub mod model; +pub mod service; diff --git a/modules/validation/src/model.rs b/modules/validation/src/model.rs new file mode 100644 index 000000000..3b84c5c73 --- /dev/null +++ b/modules/validation/src/model.rs @@ -0,0 +1,70 @@ +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use utoipa::ToSchema; +use uuid::Uuid; + +/// Severity level of a validation result. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum ValidationLevel { + Ok, + Information, + Warning, + Error, +} + +impl ValidationLevel { + pub fn as_str(&self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Information => "information", + Self::Warning => "warning", + Self::Error => "error", + } + } + + /// Parses a level string into a ValidationLevel. + pub fn parse(s: &str) -> Option { + match s { + "ok" => Some(Self::Ok), + "information" => Some(Self::Information), + "warning" => Some(Self::Warning), + "error" => Some(Self::Error), + _ => None, + } + } +} + +/// Request body for upserting a validation result. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct ValidationRequest { + pub level: ValidationLevel, + pub message: String, + pub source: String, + pub key: String, +} + +/// A validation result as returned by the API. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct ValidationResult { + pub id: Uuid, + pub level: ValidationLevel, + pub message: String, + pub source: String, + pub key: String, + #[serde(with = "time::serde::rfc3339")] + pub timestamp: OffsetDateTime, +} + +impl From for ValidationResult { + fn from(m: trustify_entity::document_validation::Model) -> Self { + Self { + id: m.id, + level: ValidationLevel::parse(&m.level).unwrap_or(ValidationLevel::Information), + message: m.message, + source: m.source, + key: m.key, + timestamp: m.timestamp, + } + } +} diff --git a/modules/validation/src/service.rs b/modules/validation/src/service.rs new file mode 100644 index 000000000..c4d60d9d8 --- /dev/null +++ b/modules/validation/src/service.rs @@ -0,0 +1,140 @@ +use actix_web::{HttpResponse, ResponseError, body::BoxBody}; +use sea_orm::{ + ActiveValue::Set, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, +}; +use sea_query::OnConflict; +use time::OffsetDateTime; +use trustify_common::{db::DatabaseErrors, error::ErrorInformation}; +use trustify_entity::document_validation; +use uuid::Uuid; + +use crate::model::{ValidationRequest, ValidationResult}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("database error: {0}")] + Database(#[source] sea_orm::DbErr), + #[error("unavailable")] + Unavailable, + #[error("database error: {0}")] + Db(#[from] trustify_common::db::DbError), +} + +impl From for Error { + fn from(value: sea_orm::DbErr) -> Self { + if value.is_read_only() { + Error::Unavailable + } else { + Error::Database(value) + } + } +} + +impl ResponseError for Error { + fn error_response(&self) -> HttpResponse { + match self { + Self::Unavailable => HttpResponse::ServiceUnavailable().json(ErrorInformation { + error: "Unavailable".into(), + message: self.to_string(), + details: None, + }), + _ => HttpResponse::InternalServerError().json(ErrorInformation { + error: "Internal".into(), + message: self.to_string(), + details: None, + }), + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct ValidationService; + +impl ValidationService { + /// Creates a new validation service. + pub fn new() -> Self { + Self + } + + /// Upserts a validation result (insert or update on conflict of entity+source+key). + pub async fn upsert( + &self, + entity_type: &str, + entity_id: Uuid, + request: ValidationRequest, + connection: &impl ConnectionTrait, + ) -> Result { + let id = Uuid::new_v4(); + let now = OffsetDateTime::now_utc(); + + let on_conflict = OnConflict::columns([ + document_validation::Column::EntityType, + document_validation::Column::EntityId, + document_validation::Column::Source, + document_validation::Column::Key, + ]) + .values([ + ( + document_validation::Column::Level, + request.level.as_str().into(), + ), + ( + document_validation::Column::Message, + request.message.clone().into(), + ), + (document_validation::Column::Timestamp, now.into()), + ]) + .to_owned(); + + document_validation::Entity::insert(document_validation::ActiveModel { + id: Set(id), + entity_type: Set(entity_type.to_string()), + entity_id: Set(entity_id), + level: Set(request.level.as_str().to_string()), + message: Set(request.message.clone()), + source: Set(request.source.clone()), + key: Set(request.key.clone()), + timestamp: Set(now), + }) + .on_conflict(on_conflict) + .exec_without_returning(connection) + .await?; + + // Fetch the actual row (may be the updated existing one, not our new id) + let result = document_validation::Entity::find() + .filter(document_validation::Column::EntityType.eq(entity_type)) + .filter(document_validation::Column::EntityId.eq(entity_id)) + .filter(document_validation::Column::Source.eq(&request.source)) + .filter(document_validation::Column::Key.eq(&request.key)) + .one(connection) + .await? + .expect("row must exist after upsert"); + + Ok(result.into()) + } + + /// Lists all validation results for a given entity. + pub async fn list( + &self, + entity_type: &str, + entity_id: Uuid, + connection: &impl ConnectionTrait, + ) -> Result, Error> { + let results = document_validation::Entity::find() + .filter(document_validation::Column::EntityType.eq(entity_type)) + .filter(document_validation::Column::EntityId.eq(entity_id)) + .order_by_desc(document_validation::Column::Timestamp) + .all(connection) + .await?; + + Ok(results.into_iter().map(ValidationResult::from).collect()) + } + + /// Deletes a single validation result by ID. + pub async fn delete(&self, id: Uuid, connection: &impl ConnectionTrait) -> Result { + let result = document_validation::Entity::delete_by_id(id) + .exec(connection) + .await?; + Ok(result.rows_affected > 0) + } +} diff --git a/server/Cargo.toml b/server/Cargo.toml index 3f517653b..eb3250600 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -19,6 +19,7 @@ trustify-module-ingestor = { workspace = true } trustify-module-storage = { workspace = true } trustify-module-ui = { workspace = true } trustify-module-user = { workspace = true } +trustify-module-validation = { workspace = true } actix-web = { workspace = true } anyhow = { workspace = true } diff --git a/server/src/profile/api.rs b/server/src/profile/api.rs index 07f01c259..c77b002e8 100644 --- a/server/src/profile/api.rs +++ b/server/src/profile/api.rs @@ -654,6 +654,7 @@ pub(crate) fn configure(svc: &mut utoipa_actix_web::service_config::ServiceConfi ); trustify_module_analysis::endpoints::configure(svc, db_ro.clone(), analysis); trustify_module_user::endpoints::configure(svc); + trustify_module_validation::endpoints::configure(svc); trustify_module_ui::endpoints::configure(svc, ui) }), );