Skip to content

Commit eecc47b

Browse files
committed
feat: API endpoint for fetching an SBOM's AI models
Fixes #2254
1 parent dd31dae commit eecc47b

File tree

11 files changed

+443
-18
lines changed

11 files changed

+443
-18
lines changed

entity/src/qualified_purl.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ pub enum Relation {
7070
to = "super::sbom_package_purl_ref::Column::QualifiedPurlId"
7171
)]
7272
SbomPackage,
73+
#[sea_orm(
74+
belongs_to = "super::sbom_ai::Entity",
75+
from = "Column::Id",
76+
to = "super::sbom_ai::Column::QualifiedPurlId"
77+
)]
78+
AI,
7379
}
7480

7581
impl Related<super::versioned_purl::Entity> for Entity {
@@ -84,6 +90,12 @@ impl Related<super::sbom_package_purl_ref::Entity> for Entity {
8490
}
8591
}
8692

93+
impl Related<super::sbom_ai::Entity> for Entity {
94+
fn to() -> RelationDef {
95+
Relation::AI.def()
96+
}
97+
}
98+
8799
impl Related<super::base_purl::Entity> for Entity {
88100
fn to() -> RelationDef {
89101
super::versioned_purl::Relation::BasePurl.def()

entity/src/sbom_ai.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub struct Model {
88
#[sea_orm(primary_key)]
99
pub node_id: String,
1010
pub properties: serde_json::Value,
11+
pub qualified_purl_id: Uuid,
1112
}
1213

1314
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -20,11 +21,7 @@ pub enum Relation {
2021
to = "super::sbom::Column::SbomId"
2122
)]
2223
Sbom,
23-
#[sea_orm(
24-
belongs_to = "super::sbom_package_purl_ref::Entity",
25-
from = "(Column::SbomId, Column::NodeId)",
26-
to = "(super::sbom_package_purl_ref::Column::SbomId, super::sbom_package_purl_ref::Column::NodeId)"
27-
)]
24+
#[sea_orm(has_one = "super::qualified_purl::Entity")]
2825
Purl,
2926
}
3027

@@ -40,7 +37,7 @@ impl Related<super::sbom::Entity> for Entity {
4037
}
4138
}
4239

43-
impl Related<super::sbom_package_purl_ref::Entity> for Entity {
40+
impl Related<super::qualified_purl::Entity> for Entity {
4441
fn to() -> RelationDef {
4542
Relation::Purl.def()
4643
}

migration/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ mod m0002080_add_cargo_version_scheme;
5050
mod m0002090_vulnerability_id_sort_index;
5151
mod m0002100_analysis_perf_indexes;
5252
mod m0002110_license_query_performance;
53+
mod m0002120_add_ai_model_purl;
5354

5455
pub trait MigratorExt: Send {
5556
fn build_migrations() -> Migrations;
@@ -116,6 +117,7 @@ impl MigratorExt for Migrator {
116117
.normal(m0002090_vulnerability_id_sort_index::Migration)
117118
.normal(m0002100_analysis_perf_indexes::Migration)
118119
.normal(m0002110_license_query_performance::Migration)
120+
.normal(m0002120_add_ai_model_purl::Migration)
119121
}
120122
}
121123

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
use sea_orm_migration::prelude::*;
2+
3+
#[derive(DeriveMigrationName)]
4+
pub struct Migration;
5+
6+
#[async_trait::async_trait]
7+
impl MigrationTrait for Migration {
8+
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
9+
manager
10+
.alter_table(
11+
Table::alter()
12+
.table(SbomAi::Table)
13+
.add_column(
14+
ColumnDef::new(SbomAi::QualifiedPurlId)
15+
.uuid()
16+
.not_null()
17+
.to_owned(),
18+
)
19+
.to_owned(),
20+
)
21+
.await?;
22+
23+
Ok(())
24+
}
25+
26+
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
27+
manager
28+
.alter_table(
29+
Table::alter()
30+
.table(SbomAi::Table)
31+
.drop_column(SbomAi::QualifiedPurlId)
32+
.to_owned(),
33+
)
34+
.await?;
35+
36+
Ok(())
37+
}
38+
}
39+
40+
#[derive(DeriveIden)]
41+
enum SbomAi {
42+
Table,
43+
QualifiedPurlId,
44+
}

modules/fundamental/src/sbom/endpoints/mod.rs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod query;
55
mod test;
66

77
pub use query::*;
8+
use uuid::Uuid;
89

910
use crate::{
1011
Error,
@@ -16,8 +17,8 @@ use crate::{
1617
},
1718
sbom::{
1819
model::{
19-
SbomExternalPackageReference, SbomNodeReference, SbomPackage, SbomPackageRelation,
20-
SbomSummary, Which, details::SbomAdvisory,
20+
SbomExternalPackageReference, SbomModel, SbomNodeReference, SbomPackage,
21+
SbomPackageRelation, SbomSummary, Which, details::SbomAdvisory,
2122
},
2223
service::{SbomService, sbom::FetchOptions},
2324
},
@@ -65,6 +66,7 @@ pub fn configure(
6566
.service(get_sbom_advisories)
6667
.service(delete)
6768
.service(packages)
69+
.service(models)
6870
.service(related)
6971
.service(upload)
7072
.service(download)
@@ -397,6 +399,36 @@ pub async fn packages(
397399
Ok(HttpResponse::Ok().json(result))
398400
}
399401

402+
/// Search for AI models associated with an SBOM
403+
#[utoipa::path(
404+
tag = "sbom",
405+
operation_id = "listModels",
406+
params(
407+
("id", Path, description = "ID of the SBOM to get models for"),
408+
Query,
409+
Paginated,
410+
),
411+
responses(
412+
(status = 200, description = "AI Models", body = PaginatedResults<SbomModel>),
413+
),
414+
)]
415+
#[get("/v2/sbom/{id}/models")]
416+
pub async fn models(
417+
fetch: web::Data<SbomService>,
418+
db: web::Data<Database>,
419+
id: web::Path<Uuid>,
420+
web::Query(search): web::Query<Query>,
421+
web::Query(paginated): web::Query<Paginated>,
422+
_: Require<ReadSbom>,
423+
) -> actix_web::Result<impl Responder> {
424+
let tx = db.begin_read().await?;
425+
let result = fetch
426+
.fetch_sbom_models(id.into_inner(), search, paginated, &tx)
427+
.await?;
428+
429+
Ok(HttpResponse::Ok().json(result))
430+
}
431+
400432
#[derive(Clone, Debug, serde::Deserialize, utoipa::IntoParams)]
401433
struct RelatedQuery {
402434
/// The Package to use as reference

modules/fundamental/src/sbom/endpoints/test.rs

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1679,7 +1679,7 @@ async fn get_cbom(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {
16791679

16801680
#[test_context(TrustifyContext)]
16811681
#[test(actix_web::test)]
1682-
async fn get_aibom(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {
1682+
async fn get_aibom_packages(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {
16831683
let app = caller(ctx).await?;
16841684

16851685
// First upload it via the normal SBOM endpoint
@@ -1759,6 +1759,82 @@ async fn get_aibom(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {
17591759
Ok(())
17601760
}
17611761

1762+
#[test_context(TrustifyContext)]
1763+
#[test(actix_web::test)]
1764+
async fn get_aibom_models(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {
1765+
let app = caller(ctx).await?;
1766+
1767+
let id = ctx
1768+
.ingest_document("cyclonedx/ai/ibm-granite_granite-docling-258M_aibom.json")
1769+
.await?
1770+
.id
1771+
.to_string();
1772+
1773+
let uri = format!("/api/v2/sbom/{id}/models");
1774+
let req = TestRequest::get().uri(&uri).to_request();
1775+
let response: Value = app.call_and_read_body_json(req).await;
1776+
log::debug!("response:\n{:#}", json!(response));
1777+
let expected = json!({
1778+
"items": [
1779+
{
1780+
"id": "pkg:huggingface/ibm-granite/granite-docling-258M@1.0",
1781+
"name": "granite-docling-258M",
1782+
"purl": "pkg:huggingface/ibm-granite/granite-docling-258M@1.0",
1783+
"properties": {
1784+
"version": "1.0.0",
1785+
"licenses": "apache-2.0",
1786+
"bomFormat": "CycloneDX",
1787+
"suppliedBy": "ibm-granite",
1788+
"specVersion": "1.6",
1789+
"typeOfModel": "idefics3",
1790+
"serialNumber": "urn:uuid:ibm-granite-granite-docling-258M",
1791+
"primaryPurpose": "image-text-to-text",
1792+
"downloadLocation": "https://huggingface.co/ibm-granite/granite-docling-258M/tree/main",
1793+
"external_references": "[{\"type\": \"website\", \"url\": \"https://huggingface.co/ibm-granite/granite-docling-258M\", \"comment\": \"Model repository\"}, {\"type\": \"distribution\", \"url\": \"https://huggingface.co/ibm-granite/granite-docling-258M/tree/main\", \"comment\": \"Model files\"}]",
1794+
"safetyRiskAssessment": "and fairness, misinformation, and autonomous decision-making, and ethical considerations, including but not limited to: bias and fairness, misinformation, and autonomous decision-making, considerations, the model may in some cases produce inaccurate, biased, offensive or unwanted responses to user prompts, in prompts and responses across key dimensions outlined in the IBM AI Risk Atlas, of triggering unwanted output"
1795+
},
1796+
},
1797+
],
1798+
"total": 1
1799+
});
1800+
assert_eq!(expected, response);
1801+
1802+
Ok(())
1803+
}
1804+
1805+
#[test_context(TrustifyContext)]
1806+
#[rstest]
1807+
#[case("hugging")]
1808+
#[case("granite")]
1809+
#[case("pkg:huggingface/ibm-granite")]
1810+
#[case("pkg:huggingface/ibm-granite/granite-docling-258M")]
1811+
#[case("pkg:huggingface/ibm-granite/granite-docling-258M@1.0")]
1812+
#[case("purl=pkg:huggingface/ibm-granite/granite-docling-258M@1.0")]
1813+
#[case("purl~granite")]
1814+
#[case("purl:namespace=ibm-granite&purl:version=1.0&purl:type=huggingface")]
1815+
#[case("name~granite")]
1816+
#[case("name=granite-docling-258M")]
1817+
#[case("properties:typeOfModel=idefics3")]
1818+
#[case("properties:typeOfModel=idefics3&properties:primaryPurpose=image-text-to-text")]
1819+
#[test_log::test(actix_web::test)]
1820+
async fn query_aibom_models(ctx: &TrustifyContext, #[case] q: &str) -> Result<(), anyhow::Error> {
1821+
let app = caller(ctx).await?;
1822+
1823+
let id = ctx
1824+
.ingest_document("cyclonedx/ai/ibm-granite_granite-docling-258M_aibom.json")
1825+
.await?
1826+
.id
1827+
.to_string();
1828+
1829+
let uri = format!("/api/v2/sbom/{id}/models?q={}", encode(q));
1830+
let req = TestRequest::get().uri(&uri).to_request();
1831+
let response: Value = app.call_and_read_body_json(req).await;
1832+
1833+
assert_eq!(response["total"].as_i64(), Some(1), "q: {q}");
1834+
1835+
Ok(())
1836+
}
1837+
17621838
#[test_context(TrustifyContext)]
17631839
#[rstest]
17641840
#[case::no_filter([], 3)]

modules/fundamental/src/sbom/model/mod.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ use crate::{
88
purl::model::summary::purl::PurlSummary,
99
source_document::model::SourceDocument,
1010
};
11-
use sea_orm::{ConnectionTrait, ModelTrait, PaginatorTrait, prelude::Uuid};
11+
use sea_orm::{ConnectionTrait, FromQueryResult, ModelTrait, PaginatorTrait, prelude::Uuid};
1212
use serde::{Deserialize, Serialize};
1313
use time::OffsetDateTime;
1414
use tracing::instrument;
1515
use trustify_common::{cpe::Cpe, purl::Purl};
1616
use trustify_entity::{
17-
labels::Labels, relationship::Relationship, sbom, sbom_node, sbom_package, source_document,
17+
labels::Labels, qualified_purl::CanonicalPurl, relationship::Relationship, sbom, sbom_node,
18+
sbom_package, source_document,
1819
};
1920
use utoipa::ToSchema;
2021

@@ -109,6 +110,34 @@ impl SbomSummary {
109110
}
110111
}
111112

113+
#[derive(FromQueryResult, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, ToSchema)]
114+
pub struct SbomModel {
115+
/// The internal ID of a model
116+
pub id: String,
117+
/// The name of the model in the SBOM
118+
pub name: String,
119+
/// The model's PURL
120+
pub purl: serde_json::Value,
121+
/// The properties associated with the model
122+
pub properties: serde_json::Value,
123+
}
124+
125+
impl SbomModel {
126+
pub fn stringify_purl(self) -> SbomModel {
127+
if self.purl.is_object() {
128+
let mut result = self.clone();
129+
let purl = match serde_json::from_value::<CanonicalPurl>(self.purl) {
130+
Ok(cp) => serde_json::Value::String(Purl::from(cp).to_string()),
131+
Err(_) => serde_json::Value::Null,
132+
};
133+
result.purl = purl;
134+
result
135+
} else {
136+
self
137+
}
138+
}
139+
}
140+
112141
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, ToSchema, Default)]
113142
pub struct SbomPackage {
114143
/// The SBOM internal ID of a package

0 commit comments

Comments
 (0)