From b2328de1fed981c72074656b59e68f8652013f28 Mon Sep 17 00:00:00 2001 From: Jens Reimann Date: Mon, 6 Jul 2026 11:53:49 +0200 Subject: [PATCH 1/3] feat: add PATCH endpoint for partial SBOM group assignment updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing PUT endpoint uses replace semantics — assigning an SBOM to a new group removes it from all previous groups. This adds a PATCH endpoint (`PATCH /v3/group/sbom-assignment`) that supports additive and subtractive assignment changes, preserving existing assignments. Uses cartesian product semantics: each SBOM in sbom_ids gets all add groups added and all remove groups removed. Idempotent via ON CONFLICT DO NOTHING for adds and targeted DELETE for removes. Fixes: TC-5036 Co-Authored-By: Claude Opus 4.6 --- docs/adrs/00013-sbom-groups.md | 38 +++ modules/fundamental/src/common/test.rs | 48 ++++ .../src/sbom_group/endpoints/mod.rs | 33 ++- .../sbom_group/endpoints/test/assignment.rs | 218 +++++++++++++++++- modules/fundamental/src/sbom_group/model.rs | 16 ++ modules/fundamental/src/sbom_group/service.rs | 83 ++++++- 6 files changed, 431 insertions(+), 5 deletions(-) diff --git a/docs/adrs/00013-sbom-groups.md b/docs/adrs/00013-sbom-groups.md index 1bc7ef0ae..efc8f1d3e 100644 --- a/docs/adrs/00013-sbom-groups.md +++ b/docs/adrs/00013-sbom-groups.md @@ -4,6 +4,8 @@ APPROVED +* 2026-07-06: Added PATCH endpoint for partial assignment updates, API version is now v3 + ## Context In order to better organize SBOMs, we do want to group them. The idea is to start out with a simple "folder style" @@ -366,6 +368,42 @@ Create a new endpoint to assign an SBOM to groups. * 403 - if the user was authenticated but not authorized * 412 - if the `IfMatch` header was present, but its value didn't match the stored revision +### PATCH `/api/v3/group/sbom-assignment` + +Partially update SBOM group assignments. Unlike PUT (which replaces all assignments), +this endpoint adds and/or removes specific group assignments while preserving the rest. + +Uses cartesian product semantics: each SBOM in `sbom_ids` gets all `add` groups added +and all `remove` groups removed. + +#### Request + +| part | name | type | description | +|------|------|--------------------------|-----------------------------| +| body | - | `PatchAssignmentRequest` | Groups to add and/or remove | + +```rust +#[derive(Serialize, Deserialize)] +struct PatchAssignmentRequest { + /// The IDs of the SBOMs to update. + sbom_ids: Vec, + /// Group IDs to add to each SBOM's assignments. + #[serde(default)] + add: Vec, + /// Group IDs to remove from each SBOM's assignments. + #[serde(default)] + remove: Vec, +} +``` + +#### Response + +* 204 - if the SBOM assignments were successfully updated +* 400 - if the request could not be understood, or one or more group IDs do not exist +* 401 - if the user was not authenticated +* 403 - if the user was authenticated but not authorized +* 404 - if one or more SBOM IDs do not exist + ### GET `/api/v2/sbom` Extend existing endpoint for finding SBOMs to limit search by group. diff --git a/modules/fundamental/src/common/test.rs b/modules/fundamental/src/common/test.rs index e362c0483..650fe890a 100644 --- a/modules/fundamental/src/common/test.rs +++ b/modules/fundamental/src/common/test.rs @@ -258,6 +258,54 @@ impl UpdateAssignments { } } +pub struct PatchAssignments { + sbom_ids: Vec, + add: Vec, + remove: Vec, + expected_status: StatusCode, +} + +impl PatchAssignments { + pub fn new(sbom_ids: Vec) -> Self { + Self { + sbom_ids, + add: vec![], + remove: vec![], + expected_status: StatusCode::NO_CONTENT, + } + } + + pub fn add_groups(mut self, group_ids: Vec) -> Self { + self.add = group_ids; + self + } + + pub fn remove_groups(mut self, group_ids: Vec) -> Self { + self.remove = group_ids; + self + } + + pub fn expect_status(mut self, status: StatusCode) -> Self { + self.expected_status = status; + self + } + + pub async fn execute(self, app: &impl CallService) -> anyhow::Result<()> { + let request = TestRequest::patch() + .uri("/api/v3/group/sbom-assignment") + .set_json(json!({ + "sbom_ids": self.sbom_ids, + "add": self.add, + "remove": self.remove, + })); + + let response = app.call_service(request.to_request()).await; + assert_eq!(response.status(), self.expected_status); + + Ok(()) + } +} + pub struct Group { pub name: String, pub description: Option, diff --git a/modules/fundamental/src/sbom_group/endpoints/mod.rs b/modules/fundamental/src/sbom_group/endpoints/mod.rs index cd7a76bf7..aa44cdb9b 100644 --- a/modules/fundamental/src/sbom_group/endpoints/mod.rs +++ b/modules/fundamental/src/sbom_group/endpoints/mod.rs @@ -9,7 +9,7 @@ use crate::Error; use actix_web::{ HttpRequest, HttpResponse, Responder, delete, get, http::header::{self, ETag, EntityTag, IfMatch}, - post, put, web, + patch, post, put, web, }; use sea_orm::TransactionTrait; use serde::Serialize; @@ -45,7 +45,8 @@ pub fn configure( .service(delete) .service(read_assignments) .service(update_assignments) - .service(bulk_update_assignments); + .service(bulk_update_assignments) + .service(patch_assignments); } #[utoipa::path( @@ -335,3 +336,31 @@ async fn bulk_update_assignments( Ok(HttpResponse::NoContent().finish()) } + +#[utoipa::path( + tag = "sbomGroup", + operation_id = "patchSbomGroupAssignments", + request_body = PatchAssignmentRequest, + responses( + (status = 204, description = "The SBOM assignments were updated"), + (status = 400, description = "The request was not valid"), + (status = 401, description = "The user was not authenticated"), + (status = 403, description = "The user authenticated, but not authorized for this operation"), + ) +)] +#[patch("/v3/group/sbom-assignment")] +/// Partially update SBOM group assignments +async fn patch_assignments( + service: web::Data, + db: web::Data, + web::Json(request): web::Json, + _: Require, +) -> Result { + let tx = db.begin().await?; + service + .patch_assignments(request.sbom_ids, request.add, request.remove, &tx) + .await?; + tx.commit().await?; + + Ok(HttpResponse::NoContent().finish()) +} diff --git a/modules/fundamental/src/sbom_group/endpoints/test/assignment.rs b/modules/fundamental/src/sbom_group/endpoints/test/assignment.rs index ea59219e6..e1c0638a2 100644 --- a/modules/fundamental/src/sbom_group/endpoints/test/assignment.rs +++ b/modules/fundamental/src/sbom_group/endpoints/test/assignment.rs @@ -1,7 +1,7 @@ use crate::{ common::test::{ - Create, Group, GroupResponse, IfMatchType, UpdateAssignments, create_groups, locate_id, - locate_ids, read_assignments, + Create, Group, GroupResponse, IfMatchType, PatchAssignments, UpdateAssignments, + create_groups, locate_id, locate_ids, read_assignments, }, sbom_group::model::BulkAssignmentRequest, test::caller, @@ -438,3 +438,217 @@ async fn concurrent_sbom_group_assignment_race( Ok(()) } + +// --- PATCH assignment tests --- + +#[test_context(TrustifyContext)] +#[test_log::test(actix_web::test)] +async fn patch_sbom_group_assignments_add(ctx: &TrustifyContext) -> anyhow::Result<()> { + let app = caller(ctx).await?; + + let group1: GroupResponse = Create::new("Group 1").execute(&app).await?; + let group2: GroupResponse = Create::new("Group 2").execute(&app).await?; + + let sbom = ctx + .ingest_document("zookeeper-3.9.2-cyclonedx.json") + .await?; + let sbom_id = sbom.id.to_string(); + + PatchAssignments::new(vec![sbom_id.clone()]) + .add_groups(vec![group1.id.clone(), group2.id.clone()]) + .execute(&app) + .await?; + + assert_assigned_groups(&app, &sbom_id, &[&group1.id, &group2.id]).await?; + + Ok(()) +} + +#[test_context(TrustifyContext)] +#[test_log::test(actix_web::test)] +async fn patch_sbom_group_assignments_remove(ctx: &TrustifyContext) -> anyhow::Result<()> { + let app = caller(ctx).await?; + + let group1: GroupResponse = Create::new("Group 1").execute(&app).await?; + let group2: GroupResponse = Create::new("Group 2").execute(&app).await?; + + let sbom = ctx + .ingest_document("zookeeper-3.9.2-cyclonedx.json") + .await?; + let sbom_id = sbom.id.to_string(); + + // Set up initial assignments via PUT + let assignments = read_assignments(&app, &sbom_id).await?; + UpdateAssignments::new(&sbom_id) + .etag(&assignments.etag) + .group_ids(vec![group1.id.clone(), group2.id.clone()]) + .execute(&app) + .await?; + + // Remove one group via PATCH + PatchAssignments::new(vec![sbom_id.clone()]) + .remove_groups(vec![group1.id.clone()]) + .execute(&app) + .await?; + + assert_assigned_groups(&app, &sbom_id, &[&group2.id]).await?; + + Ok(()) +} + +#[test_context(TrustifyContext)] +#[test_log::test(actix_web::test)] +async fn patch_sbom_group_assignments_add_and_remove(ctx: &TrustifyContext) -> anyhow::Result<()> { + let app = caller(ctx).await?; + + let group1: GroupResponse = Create::new("Group 1").execute(&app).await?; + let group2: GroupResponse = Create::new("Group 2").execute(&app).await?; + let group3: GroupResponse = Create::new("Group 3").execute(&app).await?; + + let sbom = ctx + .ingest_document("zookeeper-3.9.2-cyclonedx.json") + .await?; + let sbom_id = sbom.id.to_string(); + + // Set up initial assignment to group1 and group2 + let assignments = read_assignments(&app, &sbom_id).await?; + UpdateAssignments::new(&sbom_id) + .etag(&assignments.etag) + .group_ids(vec![group1.id.clone(), group2.id.clone()]) + .execute(&app) + .await?; + + // PATCH: add group3, remove group1 + PatchAssignments::new(vec![sbom_id.clone()]) + .add_groups(vec![group3.id.clone()]) + .remove_groups(vec![group1.id.clone()]) + .execute(&app) + .await?; + + assert_assigned_groups(&app, &sbom_id, &[&group2.id, &group3.id]).await?; + + Ok(()) +} + +#[test_context(TrustifyContext)] +#[test_log::test(actix_web::test)] +async fn patch_sbom_group_assignments_idempotent(ctx: &TrustifyContext) -> anyhow::Result<()> { + let app = caller(ctx).await?; + + let group: GroupResponse = Create::new("Group 1").execute(&app).await?; + + let sbom = ctx + .ingest_document("zookeeper-3.9.2-cyclonedx.json") + .await?; + let sbom_id = sbom.id.to_string(); + + // Add group + PatchAssignments::new(vec![sbom_id.clone()]) + .add_groups(vec![group.id.clone()]) + .execute(&app) + .await?; + + // Add same group again (idempotent, should not error) + PatchAssignments::new(vec![sbom_id.clone()]) + .add_groups(vec![group.id.clone()]) + .execute(&app) + .await?; + + assert_assigned_groups(&app, &sbom_id, &[&group.id]).await?; + + // Remove a group that is not assigned (idempotent, should not error) + PatchAssignments::new(vec![sbom_id.clone()]) + .remove_groups(vec!["00000000-0000-0000-0000-000000000099".to_string()]) + .execute(&app) + .await?; + + assert_assigned_groups(&app, &sbom_id, &[&group.id]).await?; + + Ok(()) +} + +/// Core regression test for TC-5036: adding Group B must preserve existing Group A. +#[test_context(TrustifyContext)] +#[test_log::test(actix_web::test)] +async fn patch_sbom_group_assignments_preserves_existing( + ctx: &TrustifyContext, +) -> anyhow::Result<()> { + let app = caller(ctx).await?; + + let group_a: GroupResponse = Create::new("Group A").execute(&app).await?; + let group_b: GroupResponse = Create::new("Group B").execute(&app).await?; + + let sbom = ctx + .ingest_document("zookeeper-3.9.2-cyclonedx.json") + .await?; + let sbom_id = sbom.id.to_string(); + + // Assign to Group A + PatchAssignments::new(vec![sbom_id.clone()]) + .add_groups(vec![group_a.id.clone()]) + .execute(&app) + .await?; + + // Now add to Group B -- Group A must be preserved + PatchAssignments::new(vec![sbom_id.clone()]) + .add_groups(vec![group_b.id.clone()]) + .execute(&app) + .await?; + + assert_assigned_groups(&app, &sbom_id, &[&group_a.id, &group_b.id]).await?; + + Ok(()) +} + +#[test_context(TrustifyContext)] +#[rstest] +#[case::nonexistent_sbom( + vec!["00000000-0000-0000-0000-000000000000"], + vec![], + StatusCode::NOT_FOUND +)] +#[case::invalid_sbom_uuid( + vec!["not-a-valid-uuid"], + vec![], + StatusCode::NOT_FOUND +)] +#[case::empty_sbom_ids( + vec![], + vec![], + StatusCode::NO_CONTENT +)] +#[test_log::test(actix_web::test)] +async fn patch_sbom_group_assignments_errors( + ctx: &TrustifyContext, + #[case] sbom_ids: Vec<&str>, + #[case] add_groups: Vec<&str>, + #[case] expected_status: StatusCode, +) -> anyhow::Result<()> { + let app = caller(ctx).await?; + + PatchAssignments::new(sbom_ids.into_iter().map(String::from).collect()) + .add_groups(add_groups.into_iter().map(String::from).collect()) + .expect_status(expected_status) + .execute(&app) + .await?; + + Ok(()) +} + +#[test_context(TrustifyContext)] +#[test_log::test(actix_web::test)] +async fn patch_sbom_group_assignments_invalid_group(ctx: &TrustifyContext) -> anyhow::Result<()> { + let app = caller(ctx).await?; + + let sbom = ctx + .ingest_document("zookeeper-3.9.2-cyclonedx.json") + .await?; + + PatchAssignments::new(vec![sbom.id.to_string()]) + .add_groups(vec!["00000000-0000-0000-0000-000000000099".to_string()]) + .expect_status(StatusCode::BAD_REQUEST) + .execute(&app) + .await?; + + Ok(()) +} diff --git a/modules/fundamental/src/sbom_group/model.rs b/modules/fundamental/src/sbom_group/model.rs index e7cf12d6b..27c8d9922 100644 --- a/modules/fundamental/src/sbom_group/model.rs +++ b/modules/fundamental/src/sbom_group/model.rs @@ -113,3 +113,19 @@ pub struct BulkAssignmentRequest { /// The group IDs to assign to each SBOM (replaces existing assignments). pub group_ids: Vec, } + +/// Request to partially update SBOM group assignments (add and/or remove). +/// +/// Applies cartesian product semantics: each SBOM in `sbom_ids` gets all `add` groups +/// added and all `remove` groups removed, while preserving other existing assignments. +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema, PartialEq, Eq)] +pub struct PatchAssignmentRequest { + /// The IDs of the SBOMs to update. + pub sbom_ids: Vec, + /// Group IDs to add to each SBOM's assignments. + #[serde(default)] + pub add: Vec, + /// Group IDs to remove from each SBOM's assignments. + #[serde(default)] + pub remove: Vec, +} diff --git a/modules/fundamental/src/sbom_group/service.rs b/modules/fundamental/src/sbom_group/service.rs index acd47e856..cd74c1d2b 100644 --- a/modules/fundamental/src/sbom_group/service.rs +++ b/modules/fundamental/src/sbom_group/service.rs @@ -8,7 +8,7 @@ use sea_orm::{ ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QuerySelect, SelectGetableTuple, Selector, Set, Statement, query::QueryFilter, }; -use sea_query::{ArrayType, Expr, SimpleExpr, Value}; +use sea_query::{ArrayType, Expr, OnConflict, SimpleExpr, Value}; use std::{ borrow::Cow, collections::{HashMap, HashSet}, @@ -715,6 +715,87 @@ WHERE parent IS NULL Ok(()) } + /// Partially update SBOM group assignments by adding and/or removing specific groups. + /// + /// Unlike `bulk_update_assignments`, this preserves existing assignments that are not + /// explicitly listed in `remove`. Uses ON CONFLICT DO NOTHING for idempotent adds + /// and silently ignores removals of non-existent assignments. + pub async fn patch_assignments( + &self, + sbom_ids: Vec, + add_group_ids: Vec, + remove_group_ids: Vec, + db: &impl ConnectionTrait, + ) -> Result<(), Error> { + if sbom_ids.is_empty() { + return Ok(()); + } + + let sbom_uuids: HashSet = sbom_ids + .iter() + .map(|id| Uuid::parse_str(id).map_err(|_| Error::NotFound(id.to_string()))) + .collect::>()?; + + let add_uuids = parse_group_ids(&add_group_ids)?; + let remove_uuids = parse_group_ids(&remove_group_ids)?; + + let result = sbom::Entity::update_many() + .filter(sbom::Column::SbomId.is_in(sbom_uuids.clone())) + .col_expr(sbom::Column::Revision, Expr::value(Uuid::now_v7())) + .exec(db) + .await?; + + if result.rows_affected != sbom_uuids.len() as u64 { + return Err(Error::NotFound( + "One or more SBOMs do not exist".to_string(), + )); + } + + if !remove_uuids.is_empty() { + sbom_group_assignment::Entity::delete_many() + .filter(sbom_group_assignment::Column::SbomId.is_in(sbom_uuids.clone())) + .filter(sbom_group_assignment::Column::GroupId.is_in(remove_uuids)) + .exec(db) + .await?; + } + + if !add_uuids.is_empty() { + let assignments: Vec<_> = sbom_uuids + .iter() + .flat_map(|sbom_uuid| { + add_uuids + .iter() + .map(move |group_uuid| sbom_group_assignment::ActiveModel { + sbom_id: Set(*sbom_uuid), + group_id: Set(*group_uuid), + }) + }) + .collect(); + + sbom_group_assignment::Entity::insert_many(assignments) + .on_conflict( + OnConflict::columns([ + sbom_group_assignment::Column::SbomId, + sbom_group_assignment::Column::GroupId, + ]) + .do_nothing() + .to_owned(), + ) + .do_nothing() + .exec(db) + .await + .map_err(|err| { + if err.is_foreign_key_violation() { + Error::BadRequest("One or more group IDs do not exist".into(), None) + } else { + err.into() + } + })?; + } + + Ok(()) + } + async fn bump_sbom_revision( sbom_uuid: Uuid, revision: Option<&str>, From f3f9c3219768b8dbd54e65e0d30f2619d84899b9 Mon Sep 17 00:00:00 2001 From: Jens Reimann Date: Mon, 6 Jul 2026 15:34:47 +0200 Subject: [PATCH 2/3] chore: regenerate OpenAPI spec for PATCH SBOM group assignments endpoint Co-Authored-By: Claude Opus 4.6 --- openapi.yaml | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/openapi.yaml b/openapi.yaml index 93525342c..d844043d0 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1223,6 +1223,26 @@ paths: description: The user was not authenticated '403': description: The user authenticated, but not authorized for this operation + patch: + tags: + - sbomGroup + summary: Partially update SBOM group assignments + operationId: patchSbomGroupAssignments + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchAssignmentRequest' + required: true + responses: + '204': + description: The SBOM assignments were updated + '400': + description: The request was not valid + '401': + description: The user was not authenticated + '403': + description: The user authenticated, but not authorized for this operation /api/v3/group/sbom-assignment/{id}: get: tags: @@ -5731,6 +5751,31 @@ components: - 'null' format: int64 minimum: 0 + PatchAssignmentRequest: + type: object + description: |- + Request to partially update SBOM group assignments (add and/or remove). + + Applies cartesian product semantics: each SBOM in `sbom_ids` gets all `add` groups + added and all `remove` groups removed, while preserving other existing assignments. + required: + - sbom_ids + properties: + add: + type: array + items: + type: string + description: Group IDs to add to each SBOM's assignments. + remove: + type: array + items: + type: string + description: Group IDs to remove from each SBOM's assignments. + sbom_ids: + type: array + items: + type: string + description: The IDs of the SBOMs to update. ProductDetails: allOf: - $ref: '#/components/schemas/ProductHead' From 0be4083daeaede757a0b43df2df5a9b850c2bff5 Mon Sep 17 00:00:00 2001 From: Jens Reimann Date: Tue, 7 Jul 2026 14:12:23 +0200 Subject: [PATCH 3/3] fix: add missing 404 response to PATCH endpoint and bulk assignment test Address review comments: declare 404 in the OpenAPI annotation for patchSbomGroupAssignments, and add a multi-SBOM PATCH test to verify cartesian product semantics. Assisted-by: Claude Code Co-Authored-By: Claude Opus 4.6 --- .../src/sbom_group/endpoints/mod.rs | 1 + .../sbom_group/endpoints/test/assignment.rs | 27 +++++++++++++++++++ openapi.yaml | 2 ++ 3 files changed, 30 insertions(+) diff --git a/modules/fundamental/src/sbom_group/endpoints/mod.rs b/modules/fundamental/src/sbom_group/endpoints/mod.rs index aa44cdb9b..83c66c171 100644 --- a/modules/fundamental/src/sbom_group/endpoints/mod.rs +++ b/modules/fundamental/src/sbom_group/endpoints/mod.rs @@ -346,6 +346,7 @@ async fn bulk_update_assignments( (status = 400, description = "The request was not valid"), (status = 401, description = "The user was not authenticated"), (status = 403, description = "The user authenticated, but not authorized for this operation"), + (status = 404, description = "One or more SBOMs were not found"), ) )] #[patch("/v3/group/sbom-assignment")] diff --git a/modules/fundamental/src/sbom_group/endpoints/test/assignment.rs b/modules/fundamental/src/sbom_group/endpoints/test/assignment.rs index e1c0638a2..d49ea85dd 100644 --- a/modules/fundamental/src/sbom_group/endpoints/test/assignment.rs +++ b/modules/fundamental/src/sbom_group/endpoints/test/assignment.rs @@ -652,3 +652,30 @@ async fn patch_sbom_group_assignments_invalid_group(ctx: &TrustifyContext) -> an Ok(()) } + +#[test_context(TrustifyContext)] +#[test_log::test(actix_web::test)] +async fn patch_sbom_group_assignments_bulk(ctx: &TrustifyContext) -> anyhow::Result<()> { + let app = caller(ctx).await?; + + let group1: GroupResponse = Create::new("Bulk Group 1").execute(&app).await?; + let group2: GroupResponse = Create::new("Bulk Group 2").execute(&app).await?; + + let sbom1 = ctx + .ingest_document("zookeeper-3.9.2-cyclonedx.json") + .await?; + let sbom2 = ctx.ingest_document("spdx/simple.json").await?; + + let sbom1_id = sbom1.id.to_string(); + let sbom2_id = sbom2.id.to_string(); + + PatchAssignments::new(vec![sbom1_id.clone(), sbom2_id.clone()]) + .add_groups(vec![group1.id.clone(), group2.id.clone()]) + .execute(&app) + .await?; + + assert_assigned_groups(&app, &sbom1_id, &[&group1.id, &group2.id]).await?; + assert_assigned_groups(&app, &sbom2_id, &[&group1.id, &group2.id]).await?; + + Ok(()) +} diff --git a/openapi.yaml b/openapi.yaml index d844043d0..653e46cfe 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1243,6 +1243,8 @@ paths: description: The user was not authenticated '403': description: The user authenticated, but not authorized for this operation + '404': + description: One or more SBOMs were not found /api/v3/group/sbom-assignment/{id}: get: tags: