Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/adrs/00013-sbom-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<String>,
/// Group IDs to add to each SBOM's assignments.
#[serde(default)]
add: Vec<String>,
/// Group IDs to remove from each SBOM's assignments.
#[serde(default)]
remove: Vec<String>,
}
```

#### 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.
Expand Down
48 changes: 48 additions & 0 deletions modules/fundamental/src/common/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,54 @@ impl UpdateAssignments {
}
}

pub struct PatchAssignments {
sbom_ids: Vec<String>,
add: Vec<String>,
remove: Vec<String>,
expected_status: StatusCode,
}

impl PatchAssignments {
pub fn new(sbom_ids: Vec<String>) -> Self {
Self {
sbom_ids,
add: vec![],
remove: vec![],
expected_status: StatusCode::NO_CONTENT,
}
}

pub fn add_groups(mut self, group_ids: Vec<String>) -> Self {
self.add = group_ids;
self
}

pub fn remove_groups(mut self, group_ids: Vec<String>) -> 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<String>,
Expand Down
33 changes: 31 additions & 2 deletions modules/fundamental/src/sbom_group/endpoints/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The OpenAPI responses omit 404, even though the service can return NotFound.

patch_assignments can return Error::NotFound (e.g., when SBOM IDs fail to parse or SBOMs are missing), but the utoipa::path annotation only declares 204/400/401/403. This divergence between behavior and OpenAPI spec can mislead generated clients and integrators. Please either document a 404 response in the path or adjust the error handling to use one of the declared status codes.

Expand Down Expand Up @@ -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<SbomGroupService>,
db: web::Data<db::ReadWrite>,
web::Json(request): web::Json<PatchAssignmentRequest>,
_: Require<UpdateSbom>,
) -> Result<impl Responder, Error> {
let tx = db.begin().await?;
service
.patch_assignments(request.sbom_ids, request.add, request.remove, &tx)
.await?;
tx.commit().await?;

Ok(HttpResponse::NoContent().finish())
}
218 changes: 216 additions & 2 deletions modules/fundamental/src/sbom_group/endpoints/test/assignment.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
Comment on lines +446 to +455

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add coverage for PATCH semantics when multiple SBOM IDs are passed (cartesian product behavior)

Current tests only cover a single SBOM, but the PATCH endpoint is documented to apply cartesian product semantics across all sbom_ids and add/remove groups. Please add at least one integration test using PatchAssignments::new(vec![sbom1_id.clone(), sbom2_id.clone()]) with multiple groups in add and/or remove, and assert that each SBOM receives the expected assignments. This will verify the bulk semantics and help prevent regressions in multi-SBOM behavior.

Suggested implementation:

 // --- PATCH assignment tests ---
//
// Bulk PATCH assignment semantics: multiple SBOMs with multiple groups (cartesian product)
#[test_context(TrustifyContext)]
#[test_log::test(actix_web::test)]
async fn patch_sbom_group_assignments_bulk_add(ctx: &TrustifyContext) -> anyhow::Result<()> {
    let app = caller(ctx).await?;

    // create groups to be added
    let group1: GroupResponse = Create::new("Bulk Group 1").execute(&app).await?;
    let group2: GroupResponse = Create::new("Bulk Group 2").execute(&app).await?;

    // ingest two SBOMs
    let sbom1 = ctx.ingest_document("zookeeper-3.9.2-cyclonedx.json").await?;
    let sbom2 = ctx.ingest_document("zookeeper-3.9.2-cyclonedx.json").await?;

    let sbom1_id = sbom1.id.to_string();
    let sbom2_id = sbom2.id.to_string();

    // apply PATCH with multiple sbom_ids and multiple groups; should be cartesian product
    PatchAssignments::new(vec![sbom1_id.clone(), sbom2_id.clone()])
        .add_groups(vec![group1.id.clone(), group2.id.clone()])
        .execute(&app)
        .await?;

    // verify each SBOM has both groups assigned
    assert_assigned_groups(&app, &sbom1_id, &[&group1.id, &group2.id]).await?;
    assert_assigned_groups(&app, &sbom2_id, &[&group1.id, &group2.id]).await?;

    Ok(())
}

// --- PATCH assignment tests ---
  • This edit assumes GroupResponse, Create, PatchAssignments, and assert_assigned_groups are already imported in assignment.rs. If any are missing, add the appropriate use statements at the top of the file, consistent with existing conventions.
  • If the test module uses specific naming or grouping/ordering conventions for tests (e.g., all patch_* tests grouped together), you may want to move patch_sbom_group_assignments_bulk_add next to the other PATCH assignment tests to keep the file organized.
  • If there is an existing helper for creating multiple SBOMs or groups in tests, you can refactor the test to reuse that helper instead of calling ingest_document and Create::new directly, while keeping the core cartesian-product assertions unchanged.


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(())
}
Loading