diff --git a/CHANGELOG.md b/CHANGELOG.md index 008c0ce..2847695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,12 @@ commands, `--json` output and exit codes. Each release's notes start with its se details, secure use, updating, removal and where the SBOM is published ([#127](https://github.com/Hodeitek/hodeishield-cli/issues/127), [#128](https://github.com/Hodeitek/hodeishield-cli/issues/128)). +- The alerts `--severity` filter now shows in `--help` the values the API lists (`critical`, `high`, + `medium`, `low`, `info`), and warns on standard error about a value the API does not list or has + deprecated. For the deprecated values (`critico`, `alto`, `medio`, `bajo`, accepted until + 2027-01-14) the warning names the value to use instead. The value is still sent as typed, and exit + codes do not change. The CLI now follows version 2026-10-16 of the API document. + ([#53](https://github.com/Hodeitek/hodeishield-cli/issues/53)) - Errors that involve a client tenant now say so and what to do. An unknown client tenant (404 `TENANT_NOT_FOUND`) points to `hodeishield tenants list`; a portfolio key rejected for a missing scope points to an organisation administrator; a rate limit names whether it is the key's, the diff --git a/crates/hodeishield-api/src/client.rs b/crates/hodeishield-api/src/client.rs index 2259fb3..6f4612e 100644 --- a/crates/hodeishield-api/src/client.rs +++ b/crates/hodeishield-api/src/client.rs @@ -34,6 +34,10 @@ pub struct Operation { /// open: the server may accept a value that is not listed, so use this to help and to warn, /// never to refuse. Read it through [`Operation::known_values`]. pub known_values: &'static [(&'static str, &'static [&'static str])], + /// The values the document marks as deprecated for a query parameter + /// (`x-hs-deprecated-values`), only for the parameters that have some. The server still + /// accepts them until their sunset date. Read it through [`Operation::deprecated_values`]. + pub deprecated_values: &'static [DeprecatedValues], /// Whether a client built for one client tenant selects that tenant on this operation and /// checks the answer's confirmation. True for the data operations, false for the portfolio /// listing, which is never scoped. @@ -50,6 +54,25 @@ impl Operation { .find(|(name, _)| *name == param) .map(|(_, values)| *values) } + + /// The deprecated values the document lists for the query parameter `param`, or `None` when + /// it lists none. + #[must_use] + pub fn deprecated_values(&self, param: &str) -> Option<&'static DeprecatedValues> { + self.deprecated_values.iter().find(|d| d.param == param) + } +} + +/// Values of a query parameter that the API still accepts but has deprecated, as the document +/// lists them (`x-hs-deprecated-values` and `x-hs-deprecated-values-sunset`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DeprecatedValues { + /// The query parameter, e.g. `severity`. + pub param: &'static str, + /// The deprecated values, in document order. + pub values: &'static [&'static str], + /// The last day the API accepts them (`YYYY-MM-DD`), when the document gives one. + pub sunset: Option<&'static str>, } /// A successful answer: the typed body, the body exactly as received (for `--json`, so fields this @@ -660,6 +683,11 @@ mod tests { summary: "List things", scopes: &[], known_values: &[("severity", &["critical", "high"])], + deprecated_values: &[DeprecatedValues { + param: "severity", + values: &["alto"], + sunset: Some("2027-01-14"), + }], tenant_scoped: true, }; assert_eq!( @@ -667,6 +695,10 @@ mod tests { Some(&["critical", "high"][..]) ); assert_eq!(operation.known_values("page"), None); + let deprecated = operation.deprecated_values("severity").expect("listed"); + assert_eq!(deprecated.values, &["alto"]); + assert_eq!(deprecated.sunset, Some("2027-01-14")); + assert_eq!(operation.deprecated_values("page"), None); } fn client(base: &str) -> Client { diff --git a/crates/hodeishield-api/src/generated.rs b/crates/hodeishield-api/src/generated.rs index 85e8b98..db4a5c5 100644 --- a/crates/hodeishield-api/src/generated.rs +++ b/crates/hodeishield-api/src/generated.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; /// `info.version` of the OpenAPI document this client was generated from. -pub const OPENAPI_VERSION: &str = "2026-09-28"; +pub const OPENAPI_VERSION: &str = "2026-10-16"; /// The server the OpenAPI document declares. pub const DEFAULT_SERVER: &str = "https://api.hodeishield.com"; @@ -28,7 +28,7 @@ pub struct ErrorBody { pub message: String, /// Also the `X-Request-Id` response header. pub request_id: String, - /// Diagnostic. Not part of the frozen contract. + /// Diagnostic, except `code` and the keys the operation’s error descriptions name, which are contract. #[serde(default)] pub details: Option>, } @@ -121,7 +121,7 @@ pub struct Alert { pub vendor_id: String, /// Alert family, e.g. `breach`, `cve`, `rating_drop`. pub r#type: String, - /// critical | high | medium | low, when classified. + /// critical | high | medium | low | info, when classified. Open vocabulary: a new value is a compatible change. #[serde(default)] pub severity: Option, pub title: String, @@ -168,9 +168,9 @@ pub struct Risk { pub title: String, #[serde(default)] pub description: Option, - /// Risk domain, as configured by the tenant. + /// Risk domain: vendor | cyber | data | operational | compliance | concentration | strategic | legal | financial | reputational. pub domain: String, - /// identified | assessed | treated | accepted | closed | … + /// Risk lifecycle status: identified | assessed | treatment_planned | treatment_implemented | closed. pub status: String, /// mitigate | accept | transfer | avoid. #[serde(default)] @@ -337,6 +337,38 @@ pub struct ListEndpointsResponse { pub pagination: Pagination, } +/// The organisation the tenant belongs to: the MSSP itself for the tenants it holds directly (including its internal one), or the client organisation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Organization { + pub id: String, + pub name: String, +} + +/// A client tenant an MSSP portfolio key may select. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PortfolioTenant { + /// Stable identifier (lowercase). The ONLY value `X-HS-Tenant` accepts. + pub id: String, + /// Display name. Untrusted text. + pub name: String, + /// Short name, unique across the platform. A client may resolve a slug to an `id` from this list; the server never accepts a slug as a selector. + pub slug: String, + /// The organisation the tenant belongs to: the MSSP itself for the tenants it holds directly (including its internal one), or the client organisation. + pub organization: Organization, + /// True only for the MSSP’s own internal tenant. + pub internal: bool, + /// Whether the tenant is enabled. A deactivated tenant is listed but cannot be read. + pub active: bool, + /// Open vocabulary: `in_force` (licence in force), `grace` (grace period: reads are served), `inactive` (none, suspended, revoked or expired: data operations answer 403 `LICENCE_INACTIVE`). Treat an unknown value as "may not be readable". A tenant is readable when `active` is true and `licence_status` is not `inactive`. + pub licence_status: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ListPortfolioTenantsResponse { + pub data: Vec, + pub pagination: Pagination, +} + /// Accepted values of a query parameter. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum VendorSort { @@ -776,6 +808,47 @@ impl std::str::FromStr for EndpointStatus { } } +/// Accepted values of a query parameter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PortfolioTenantSort { + /// `name` + Name, + /// `slug` + Slug, +} + +impl PortfolioTenantSort { + /// Every accepted value, as sent on the wire. + pub const VALUES: &'static [&'static str] = &["name", "slug"]; + + /// The value as sent on the wire. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Name => "name", + Self::Slug => "slug", + } + } +} + +impl std::fmt::Display for PortfolioTenantSort { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for PortfolioTenantSort { + type Err = crate::ParseEnumError; + + fn from_str(value: &str) -> Result { + match value { + "name" => Ok(Self::Name), + "slug" => Ok(Self::Slug), + _ => Err(crate::ParseEnumError::new(value, Self::VALUES)), + } + } +} + /// Query parameters of `listVendors`. #[derive(Debug, Clone, Default, PartialEq)] pub struct ListVendorsParams { @@ -843,7 +916,7 @@ pub struct ListAlertsParams { pub order: Option, /// Filter by the derived status. pub status: Option, - /// Exact severity match. + /// Exact severity match, in the vocabulary the response uses (critical | high | medium | low | info). The legacy Spanish values (critico, alto, medio, bajo, info) are still accepted and mean the same; they are documented as accepted until 2027-01-14, so move to the English ones. pub severity: Option, /// Only alerts about this vendor. pub vendor_id: Option, @@ -1091,6 +1164,40 @@ impl ListEndpointsParams { } } +/// Query parameters of `listPortfolioTenants`. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ListPortfolioTenantsParams { + /// 1-based page number. Defaults to 1. + pub page: Option, + /// Items per page, 1..200. Defaults to 50. + pub per_page: Option, + /// Field to order by. One of: name, slug. + pub sort: Option, + /// Sort direction. Defaults to desc. + pub order: Option, +} + +impl ListPortfolioTenantsParams { + /// The query string, in document order, without the parameters that are unset. + #[must_use] + pub fn query_pairs(&self) -> Vec<(&'static str, String)> { + let mut pairs = Vec::new(); + if let Some(value) = &self.page { + pairs.push(("page", value.to_string())); + } + if let Some(value) = &self.per_page { + pairs.push(("per_page", value.to_string())); + } + if let Some(value) = &self.sort { + pairs.push(("sort", value.as_str().to_owned())); + } + if let Some(value) = &self.order { + pairs.push(("order", value.as_str().to_owned())); + } + pairs + } +} + /// Every `/v1` operation, as the OpenAPI document describes it. pub mod operations { use crate::Operation; @@ -1103,6 +1210,7 @@ pub mod operations { summary: "List third parties", scopes: &["supply_risk:read"], known_values: &[], + deprecated_values: &[], tenant_scoped: true, }; @@ -1114,6 +1222,7 @@ pub mod operations { summary: "Fetch one third party", scopes: &["supply_risk:read"], known_values: &[], + deprecated_values: &[], tenant_scoped: true, }; @@ -1124,7 +1233,12 @@ pub mod operations { path: "/v1/alerts", summary: "List supply-chain alerts", scopes: &["supply_risk:read"], - known_values: &[], + known_values: &[("severity", &["critical", "high", "medium", "low", "info"])], + deprecated_values: &[crate::DeprecatedValues { + param: "severity", + values: &["critico", "alto", "medio", "bajo"], + sunset: Some("2027-01-14"), + }], tenant_scoped: true, }; @@ -1136,6 +1250,7 @@ pub mod operations { summary: "Fetch one alert", scopes: &["supply_risk:read"], known_values: &[], + deprecated_values: &[], tenant_scoped: true, }; @@ -1147,6 +1262,7 @@ pub mod operations { summary: "List risk-register entries", scopes: &["supply_risk:read"], known_values: &[], + deprecated_values: &[], tenant_scoped: true, }; @@ -1158,6 +1274,7 @@ pub mod operations { summary: "Fetch one risk-register entry", scopes: &["supply_risk:read"], known_values: &[], + deprecated_values: &[], tenant_scoped: true, }; @@ -1169,6 +1286,7 @@ pub mod operations { summary: "List control status for a framework", scopes: &["compliance.:read"], known_values: &[], + deprecated_values: &[], tenant_scoped: true, }; @@ -1180,6 +1298,7 @@ pub mod operations { summary: "Framework posture summary", scopes: &["compliance.:read"], known_values: &[], + deprecated_values: &[], tenant_scoped: true, }; @@ -1191,6 +1310,7 @@ pub mod operations { summary: "List security evidence", scopes: &["evidence:read"], known_values: &[], + deprecated_values: &[], tenant_scoped: true, }; @@ -1202,6 +1322,7 @@ pub mod operations { summary: "Fetch one evidence item", scopes: &["evidence:read"], known_values: &[], + deprecated_values: &[], tenant_scoped: true, }; @@ -1213,9 +1334,22 @@ pub mod operations { summary: "List enrolled endpoint agents", scopes: &["endpoints:read"], known_values: &[], + deprecated_values: &[], tenant_scoped: true, }; + /// `GET /v1/portfolio/tenants`: List the client tenants of an MSSP portfolio. + pub const LIST_PORTFOLIO_TENANTS: Operation = Operation { + id: "listPortfolioTenants", + method: "GET", + path: "/v1/portfolio/tenants", + summary: "List the client tenants of an MSSP portfolio", + scopes: &["portfolio:read"], + known_values: &[], + deprecated_values: &[], + tenant_scoped: false, + }; + /// All operations, in document order. pub const ALL: &[Operation] = &[ LIST_VENDORS, @@ -1229,6 +1363,7 @@ pub mod operations { LIST_EVIDENCE, GET_EVIDENCE, LIST_ENDPOINTS, + LIST_PORTFOLIO_TENANTS, ]; } @@ -1463,4 +1598,25 @@ impl crate::Client { params.query_pairs(), ) } + + /// List the client tenants of an MSSP portfolio. + /// + /// Every tenant the presented PORTFOLIO key may select with `X-HS-Tenant`: the MSSP’s own tenants (its internal one included) and every tenant of its active client organisations, active or not, licensed or not, narrowed to the key’s client subset when it has one. Membership is evaluated live on every request. Only a portfolio key (`hsp_…`) can call this operation; this operation never takes `X-HS-Tenant`. + /// + /// Required scope: `portfolio:read`. Published in `/v1` since 2026-10-16. + /// + /// # Errors + /// + /// [`crate::Error`] when the request cannot be sent, the API answers with an error, or the + /// body does not match the document. + pub fn list_portfolio_tenants( + &self, + params: &ListPortfolioTenantsParams, + ) -> Result, crate::Error> { + self.get( + &operations::LIST_PORTFOLIO_TENANTS, + "/v1/portfolio/tenants".to_owned(), + params.query_pairs(), + ) + } } diff --git a/crates/hodeishield-api/src/lib.rs b/crates/hodeishield-api/src/lib.rs index 8c46e27..78b513e 100644 --- a/crates/hodeishield-api/src/lib.rs +++ b/crates/hodeishield-api/src/lib.rs @@ -30,7 +30,7 @@ pub mod portfolio; pub mod v1; pub use client::{ - ApiResponse, Client, ClientBuilder, MAX_RESPONSE_BYTES, Operation, RateLimit, RequestEvent, - ResponseMeta, RetryEvent, encode_path_segment, is_loopback_host, read_body, + ApiResponse, Client, ClientBuilder, DeprecatedValues, MAX_RESPONSE_BYTES, Operation, RateLimit, + RequestEvent, ResponseMeta, RetryEvent, encode_path_segment, is_loopback_host, read_body, }; pub use error::{ApiError, ApiErrorBody, Error, ParseEnumError}; diff --git a/crates/hodeishield-api/src/portfolio.rs b/crates/hodeishield-api/src/portfolio.rs index 5c2e800..1e104be 100644 --- a/crates/hodeishield-api/src/portfolio.rs +++ b/crates/hodeishield-api/src/portfolio.rs @@ -22,12 +22,12 @@ //! //! # Relation to the generated client //! -//! The vendored `openapi/v1.json` does not describe these operations yet, so this file is written -//! by hand. When the vendored document is synced to the contract version above, the listing -//! operation, [`Tenant`] and the paging types come from `cargo xtask codegen` (which first has to -//! learn header parameters and the new security scheme), and this file shrinks to what is not an -//! operation: [`ClientBuilder::tenant`] and its confirmation check, which the generated code does -//! not provide. +//! The listing operation is described by the vendored `openapi/v1.json`: its method, path, scope +//! and the fact that it is never tenant-scoped come from the generated +//! [`LIST_PORTFOLIO_TENANTS`](crate::v1::operations::LIST_PORTFOLIO_TENANTS). This file adds what +//! the generated code does not provide: [`ClientBuilder::tenant`] and its confirmation check, the +//! readability of a [`Tenant`], and the mapping of a missing route to +//! [`Error::PortfolioNotOffered`]. //! //! # Interface //! @@ -36,8 +36,9 @@ //! client then refuses any successful answer that does not name the same tenant, so data of one //! client is never returned as if it were another's. -use crate::client::{Operation, Scope}; +use crate::client::Scope; use crate::error::Error; +use crate::v1::operations::LIST_PORTFOLIO_TENANTS; use crate::v1::{Order, Pagination}; use crate::{ApiResponse, Client, ClientBuilder}; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; @@ -47,17 +48,6 @@ use std::sync::Arc; /// Request and response header that selects, and confirms, the client tenant. const TENANT_HEADER: HeaderName = HeaderName::from_static("x-hs-tenant"); -/// `GET /v1/portfolio/tenants`: List the client tenants of the credential's portfolio. -pub const LIST_TENANTS: Operation = Operation { - id: "listPortfolioTenants", - method: "GET", - path: "/v1/portfolio/tenants", - summary: "List the client tenants of the credential's portfolio", - scopes: &["portfolio:read"], - known_values: &[], - tenant_scoped: false, -}; - /// A client tenant of the portfolio. Text fields come from the server and are untrusted. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Tenant { @@ -210,8 +200,8 @@ impl Client { params: &ListTenantsParams, ) -> Result, Error> { self.get( - &LIST_TENANTS, - LIST_TENANTS.path.to_owned(), + &LIST_PORTFOLIO_TENANTS, + LIST_PORTFOLIO_TENANTS.path.to_owned(), params.query_pairs(), ) .map_err(|error| match error { diff --git a/crates/hodeishield-api/tests/api.rs b/crates/hodeishield-api/tests/api.rs index 85141e3..261797c 100644 --- a/crates/hodeishield-api/tests/api.rs +++ b/crates/hodeishield-api/tests/api.rs @@ -1180,12 +1180,18 @@ fn only_a_decodable_success_is_one_even_with_the_right_echo() { #[test] fn the_listing_is_unscoped_by_its_operation_and_the_data_operations_are_scoped() { - use hodeishield_api::portfolio::LIST_TENANTS; use hodeishield_api::v1::operations; - let listing = LIST_TENANTS; + let listing = operations::LIST_PORTFOLIO_TENANTS; + assert_eq!(listing.method, "GET"); + assert_eq!(listing.path, "/v1/portfolio/tenants"); + assert_eq!(listing.scopes, ["portfolio:read"]); assert!(!listing.tenant_scoped); - assert!(operations::ALL.len() >= 11); - assert!(operations::ALL.iter().all(|op| op.tenant_scoped)); + let data: Vec<_> = operations::ALL + .iter() + .filter(|op| op.id != listing.id) + .collect(); + assert!(data.len() >= 11); + assert!(data.iter().all(|op| op.tenant_scoped)); } #[test] diff --git a/crates/hodeishield-cli/src/auth/oauth.rs b/crates/hodeishield-cli/src/auth/oauth.rs index 6d620ee..0628c5f 100644 --- a/crates/hodeishield-cli/src/auth/oauth.rs +++ b/crates/hodeishield-cli/src/auth/oauth.rs @@ -10,6 +10,7 @@ use crate::failure::{Failure, Kind, Result, error_chain}; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use hodeishield_api::v1::operations; use secrecy::{ExposeSecret, SecretString}; use serde::Deserialize; use sha2::{Digest, Sha256}; @@ -46,8 +47,11 @@ const OFFLINE_ACCESS: &str = "offline_access"; pub fn default_scopes(metadata: &Metadata) -> Vec { let offered = metadata.scopes_supported.as_deref(); let mut scopes: Vec = Vec::new(); - let needed = hodeishield_api::v1::operations::ALL + let needed = operations::ALL .iter() + // The tenant listing answers a portfolio key only, never a sign-in: its scope is not + // asked for. + .filter(|operation| operation.id != operations::LIST_PORTFOLIO_TENANTS.id) .flat_map(|operation| operation.scopes.iter().copied()) .chain([OFFLINE_ACCESS]); for scope in needed { diff --git a/crates/hodeishield-cli/src/filters.rs b/crates/hodeishield-cli/src/filters.rs index ba891a2..7842793 100644 --- a/crates/hodeishield-cli/src/filters.rs +++ b/crates/hodeishield-cli/src/filters.rs @@ -4,6 +4,10 @@ //! The values the API document lists for a filter (`x-hs-known-values`): shown in `--help` and //! checked before a request. The lists are open vocabularies, so a value that is not listed is //! warned about and sent exactly as typed; nothing here refuses a value or rewrites it. +//! +//! The document may also mark values as deprecated (`x-hs-deprecated-values`, with the last day +//! they are accepted in `x-hs-deprecated-values-sunset`). Such a value gets its own warning, which +//! points to the listed values, and is still sent exactly as typed. use crate::output::clean; use hodeishield_api::Operation; @@ -19,63 +23,77 @@ pub fn help(base: &str, operation: &Operation, param: &str) -> String { } } -/// The legacy values of the alert `severity` filter and their current names. +/// Replacements for the deprecated values of the alert `severity` filter. /// -/// The API is moving alert severity from a legacy Spanish scale to an English vocabulary, and -/// for a transition period its filter accepts both. A legacy value typed by the user is still -/// sent exactly as typed (the server accepts it); this table only lets the CLI say what to use -/// instead. It applies to the alert severity filter alone and can be removed, with -/// [`legacy_hint`], after [`LEGACY_SEVERITY_END`]. -const LEGACY_SEVERITY: &[(&str, &str)] = &[ +/// This table exists only because the API document lists the deprecated values without their +/// replacements. Remove it, and its use in [`deprecated_hint`], after the sunset, 2027-01-14. +const SEVERITY_REPLACEMENTS: &[(&str, &str)] = &[ ("critico", "critical"), ("alto", "high"), ("medio", "medium"), ("bajo", "low"), - ("info", "info"), ]; -/// The last day the API accepts the legacy severity values. -const LEGACY_SEVERITY_END: &str = "2027-01-14"; - -/// The specific hint for a legacy severity value, or `None` when the generic warning applies. -/// -/// It fires only when the API document lists the English values, so only once the server has -/// switched. Until then the API still returns the legacy scale and the legacy values are the -/// working spelling; telling users to move away from them would send them to values that match -/// nothing. A document without a list stays silent, like every other filter. -fn legacy_hint(operation: &Operation, param: &str, value: &str, known: &[&str]) -> Option { +/// The listed value that replaces the deprecated `value`, when [`SEVERITY_REPLACEMENTS`] knows one +/// and the document lists it. +fn replacement(operation: &Operation, param: &str, value: &str) -> Option<&'static str> { if operation.id != "listAlerts" || param != "severity" { return None; } - let (_, current) = LEGACY_SEVERITY + let (_, current) = SEVERITY_REPLACEMENTS + .iter() + .find(|(deprecated, _)| deprecated.eq_ignore_ascii_case(value))?; + operation + .known_values(param) + .is_some_and(|known| known.contains(current)) + .then_some(*current) +} + +/// The warning for a value the document marks as deprecated for `param` (matched without regard +/// to case), or `None` when it does not. +fn deprecated_hint(operation: &Operation, param: &str, value: &str) -> Option { + let deprecated = operation.deprecated_values(param)?; + if !deprecated + .values .iter() - .find(|(legacy, _)| legacy.eq_ignore_ascii_case(value))?; - if !known.contains(current) { + .any(|d| d.eq_ignore_ascii_case(value)) + { return None; } + let until = deprecated.sunset.map_or_else(String::new, |date| { + format!(", accepted by the API until {date}") + }); + let known = operation.known_values(param).filter(|v| !v.is_empty()); + let advice = match (replacement(operation, param, value), known) { + (Some(current), _) => format!("; use `{current}`"), + (None, Some(known)) => format!("; use one of the listed values ({})", known.join(", ")), + (None, None) => String::new(), + }; Some(format!( - "warning: `{}` is a legacy severity value, accepted by the API until \ - {LEGACY_SEVERITY_END}; use `{current}`. Sending it as typed.", + "warning: `{}` is a deprecated value for --{}{until}{advice}. Sending it as typed.", clean(value), + param.replace('_', "-"), )) } -/// The warnings for the `(parameter, value)` pairs the user gave, one per value that is not in the -/// list the document publishes for that parameter. A parameter without a list never warns. The -/// match is exact and case-sensitive, as the API is; a value that differs only by case also gets -/// the listed spelling as a hint. +/// The warnings for the `(parameter, value)` pairs the user gave: one per value the document marks +/// as deprecated for that parameter, and one per value that is not in the list it publishes. A +/// parameter without either list never warns. The match against the list is exact and +/// case-sensitive, as the API is; a value that differs only by case also gets the listed spelling +/// as a hint. pub fn warnings(operation: &Operation, filters: &[(&str, Option<&str>)]) -> Vec { filters .iter() .filter_map(|&(param, value)| { let value = value?; - let known = operation.known_values(param).filter(|v| !v.is_empty())?; - if known.contains(&value) { + let known = operation.known_values(param).filter(|v| !v.is_empty()); + if known.is_some_and(|known| known.contains(&value)) { return None; } - if let Some(hint) = legacy_hint(operation, param, value, known) { + if let Some(hint) = deprecated_hint(operation, param, value) { return Some(hint); } + let known = known?; let tail = known .iter() .find(|k| k.eq_ignore_ascii_case(value)) @@ -103,6 +121,7 @@ pub fn warn_unknown(operation: &Operation, filters: &[(&str, Option<&str>)]) { #[cfg(test)] mod tests { use super::*; + use hodeishield_api::DeprecatedValues; const WITH_LISTS: Operation = Operation { id: "listAlerts", @@ -111,6 +130,7 @@ mod tests { summary: "List alerts", scopes: &[], known_values: &[("severity", &["critical", "high", "medium", "low"])], + deprecated_values: &[], tenant_scoped: true, }; @@ -153,87 +173,139 @@ mod tests { assert!(warnings(&WITHOUT_LISTS, &[("severity", Some("urgent"))]).is_empty()); } - const ENGLISH: Operation = Operation { + const ALERTS: Operation = Operation { known_values: &[("severity", &["critical", "high", "medium", "low", "info"])], - tenant_scoped: true, + deprecated_values: &[DeprecatedValues { + param: "severity", + values: &["critico", "alto", "medio", "bajo", "urgente"], + sunset: Some("2027-01-14"), + }], ..WITH_LISTS }; #[test] - fn a_legacy_severity_points_to_its_new_name() { + fn a_deprecated_value_with_a_replacement_points_to_it() { assert_eq!( - warnings(&ENGLISH, &[("severity", Some("alto"))]), + warnings(&ALERTS, &[("severity", Some("alto"))]), [ - "warning: `alto` is a legacy severity value, accepted by the API until \ + "warning: `alto` is a deprecated value for --severity, accepted by the API until \ 2027-01-14; use `high`. Sending it as typed." ] ); - for (legacy, current) in [ + for (deprecated, current) in [ ("critico", "critical"), ("medio", "medium"), ("bajo", "low"), ] { - let out = warnings(&ENGLISH, &[("severity", Some(legacy))]); + let out = warnings(&ALERTS, &[("severity", Some(deprecated))]); assert!(out[0].contains(&format!("use `{current}`")), "{out:?}"); } } #[test] - fn a_legacy_severity_is_matched_without_regard_to_case() { - let out = warnings(&ENGLISH, &[("severity", Some("ALTO"))]); + fn a_deprecated_value_is_matched_without_regard_to_case() { + let out = warnings(&ALERTS, &[("severity", Some("ALTO"))]); assert_eq!(out.len(), 1); - assert!(out[0].starts_with("warning: `ALTO` is a legacy severity value")); + assert!(out[0].starts_with("warning: `ALTO` is a deprecated value for --severity")); assert!(out[0].contains("use `high`"), "{out:?}"); } #[test] - fn a_current_severity_is_silent() { - for value in ["critical", "high", "medium", "low", "info"] { - assert!(warnings(&ENGLISH, &[("severity", Some(value))]).is_empty()); - } - } - - #[test] - fn an_unrelated_severity_keeps_the_generic_warning() { - let out = warnings(&ENGLISH, &[("severity", Some("urgent"))]); + fn a_deprecated_value_without_a_replacement_points_to_the_listed_values() { + assert_eq!( + warnings(&ALERTS, &[("severity", Some("urgente"))]), + [ + "warning: `urgente` is a deprecated value for --severity, accepted by the API \ + until 2027-01-14; use one of the listed values (critical, high, medium, low, \ + info). Sending it as typed." + ] + ); + // A replacement the document does not list is not offered. + let unlisted = Operation { + known_values: &[("severity", &["urgent"])], + ..ALERTS + }; + let out = warnings(&unlisted, &[("severity", Some("alto"))]); assert!( - out[0].contains("is not one of the values the API lists"), + out[0].ends_with("use one of the listed values (urgent). Sending it as typed."), "{out:?}" ); } #[test] - fn the_legacy_hint_waits_for_the_english_list() { - // No list yet: the API still speaks the legacy scale, so say nothing. - assert!(warnings(&WITHOUT_LISTS, &[("severity", Some("alto"))]).is_empty()); - // A list without the new name: nothing to point to, so the generic warning stays. - let other_list = Operation { - known_values: &[("severity", &["urgent"])], - tenant_scoped: true, + fn a_deprecated_value_without_a_sunset_or_a_list() { + let no_sunset = Operation { + id: "listRisks", + known_values: &[("min_score", &["low", "high"])], + deprecated_values: &[DeprecatedValues { + param: "min_score", + values: &["bajo"], + sunset: None, + }], ..WITH_LISTS }; - let out = warnings(&other_list, &[("severity", Some("bajo"))]); - assert!( - out[0].contains("is not one of the values the API lists"), - "{out:?}" + assert_eq!( + warnings(&no_sunset, &[("min_score", Some("bajo"))]), + [ + "warning: `bajo` is a deprecated value for --min-score; use one of the listed \ + values (low, high). Sending it as typed." + ] + ); + let no_list = Operation { + known_values: &[], + ..no_sunset + }; + assert_eq!( + warnings(&no_list, &[("min_score", Some("bajo"))]), + ["warning: `bajo` is a deprecated value for --min-score. Sending it as typed."] ); } #[test] - fn the_legacy_hint_is_scoped_to_the_alert_severity_filter() { + fn the_replacements_apply_to_the_alert_severity_filter_only() { let other = Operation { id: "listRisks", - known_values: &[("severity", &["high"])], - tenant_scoped: true, - ..WITH_LISTS + ..ALERTS }; let out = warnings(&other, &[("severity", Some("alto"))]); + assert!(out[0].contains("use one of the listed values"), "{out:?}"); + } + + #[test] + fn a_current_severity_is_silent() { + for value in ["critical", "high", "medium", "low", "info"] { + assert!(warnings(&ALERTS, &[("severity", Some(value))]).is_empty()); + } + } + + #[test] + fn an_unrelated_severity_keeps_the_generic_warning() { + let out = warnings(&ALERTS, &[("severity", Some("urgent"))]); assert!( out[0].contains("is not one of the values the API lists"), "{out:?}" ); } + /// Fails when the document adds or removes a deprecated severity, so the table above follows + /// it, and when a replacement is not a listed value. + #[test] + fn the_replacements_match_the_generated_alert_severity_filter() { + let operation = hodeishield_api::v1::operations::LIST_ALERTS; + let deprecated = operation + .deprecated_values("severity") + .expect("the document deprecates severity values"); + let mut listed: Vec<&str> = deprecated.values.to_vec(); + listed.sort_unstable(); + let mut mapped: Vec<&str> = SEVERITY_REPLACEMENTS.iter().map(|(d, _)| *d).collect(); + mapped.sort_unstable(); + assert_eq!(mapped, listed); + let known = operation.known_values("severity").expect("listed values"); + for (_, current) in SEVERITY_REPLACEMENTS { + assert!(known.contains(current), "{current}"); + } + } + #[test] fn control_characters_in_the_value_are_not_echoed() { let out = warnings(&WITH_LISTS, &[("severity", Some("a\u{1b}[31mb"))]); diff --git a/crates/hodeishield-cli/tests/cli.rs b/crates/hodeishield-cli/tests/cli.rs index d904ec9..0875f97 100644 --- a/crates/hodeishield-cli/tests/cli.rs +++ b/crates/hodeishield-cli/tests/cli.rs @@ -712,20 +712,14 @@ fn compliance_controls_csv() { assert_no_key(&output); } -/// The vendored document lists no values for these filters yet, so they are sent exactly as typed, +/// The vendored document lists no values for these filters, so they are sent exactly as typed, /// without a warning, and the command succeeds. (The warning itself is covered by the unit tests /// of `filters`, with operations that carry lists.) #[test] fn free_form_filters_are_sent_as_typed_without_a_warning() { for (args, path, param, value) in [ ( - &["alerts", "list", "--severity", "High"][..], - "/v1/alerts", - "severity", - "High", - ), - ( - &["vendors", "list", "--category", "Cloud Host"], + &["vendors", "list", "--category", "Cloud Host"][..], "/v1/vendors", "category", "Cloud Host", @@ -772,6 +766,47 @@ fn free_form_filters_are_sent_as_typed_without_a_warning() { } } +/// The vendored document lists the alert severities and deprecates some older ones: a value that +/// is not listed, or a deprecated one, gets a warning and is still sent exactly as typed. +#[test] +fn listed_and_deprecated_severities_warn_and_are_sent_as_typed() { + for (value, expected) in [ + ("high", None), + ("High", Some("did you mean `high`? Sending it anyway.")), + ( + "alto", + Some( + "warning: `alto` is a deprecated value for --severity, accepted by the API until \ + 2027-01-14; use `high`. Sending it as typed.", + ), + ), + ] { + let env = Env::new(); + let mut server = mockito::Server::new(); + let mock = server + .mock("GET", "/v1/alerts") + .match_query(mockito::Matcher::UrlEncoded( + "severity".into(), + value.into(), + )) + .with_body(page(&[], 1, 0, 1)) + .expect(1) + .create(); + let output = env + .api(&server) + .args(["alerts", "list", "--severity", value]) + .output() + .expect("run"); + assert!(output.status.success(), "{value}: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + match expected { + Some(warning) => assert!(stderr.contains(warning), "{value}: {stderr}"), + None => assert!(!stderr.contains("warning:"), "{value}: {stderr}"), + } + mock.assert(); + } +} + // Client tenants, against a mock of the published portfolio contract (version 2026-10-16). /// The shape of a portfolio key: `hsp_`, 12 hex, `_`, 64 hex. Its secret part must never be shown. diff --git a/openapi/README.md b/openapi/README.md index 761f17a..1282ecf 100644 --- a/openapi/README.md +++ b/openapi/README.md @@ -6,9 +6,9 @@ from it; nothing in that client is written by hand from memory. | Field | Value | |---|---| -| `info.version` | `2026-09-28` | -| SHA-256 | `103e86f38f7b3eff45a0f764c8142b4bdd2e8d576385b1b4ccf588cbf0518fb3` | -| Operations | 11, all `GET` | +| `info.version` | `2026-10-16` | +| SHA-256 | `1321a417aba28e7b60dbc28a35ce2dc3711d8973b985107f7701d70ff3be6b0d` | +| Operations | 12, all `GET` | ## Updating it diff --git a/openapi/v1.json b/openapi/v1.json index 6256d0f..5ab3c5f 100644 --- a/openapi/v1.json +++ b/openapi/v1.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"Hodei-Shield API","version":"2026-09-28","summary":"Third-party risk and compliance posture, for your own systems.","description":"## Versioning\n\n`/v1` is frozen. These changes are **compatible** and will happen inside it\nwithout notice:\n\n- a new operation;\n- a new **optional** query parameter;\n- a new field in a response object;\n- a new value in a field documented as an open vocabulary (`status`,\n `severity`, `evidence_type`, the keys of `by_status`);\n- a new `error.code`, always with the HTTP status its siblings already use;\n- anything under `error.details`, which is diagnostic and not part of the\n contract.\n\nWrite your client so those do not break it: ignore unknown fields, do not\nswitch exhaustively on a documented-open vocabulary, and treat an unknown\n`error.code` as \"an error of this HTTP status\".\n\nThese changes are **incompatible** and will NOT happen inside `/v1`. They go\nto `/v2`, and `/v1` keeps serving:\n\n- removing or renaming an operation, a parameter or a field;\n- changing the type, the unit or the meaning of a field;\n- making an optional parameter required, or narrowing what one accepts;\n- changing which scope an operation requires;\n- changing the HTTP status an existing `error.code` is sent with.\n\n## Read-only\n\n`/v1` only reads. Every operation is a `GET`. Any other method (`POST`, `PUT`,\n`PATCH`, `DELETE`) on a path that exists answers **405** with `Allow: GET`, and on\na path that does not exist answers **404**: neither changes any state. A write\nwill be offered only as a new operation, which is a compatible change, once a\ncustomer asks for a concrete one.\n\n## Authentication\n\nEvery operation takes `Authorization: Bearer `. A key belongs\nto ONE tenant and carries an explicit list of scopes; it can read nothing\noutside that tenant, whatever it asks for. Each operation below declares the\nscope it requires. A key that authenticates but lacks the scope is answered\n**403**, never 404: you are told what you are missing, not that the resource\ndoes not exist.\n\n## Errors\n\nEvery error has the same body:\n\n```json\n{ \"error\": { \"code\": \"forbidden\", \"message\": \"…\", \"request_id\": \"…\" } }\n```\n\nBranch on `code`, log `request_id`, show `message` to nobody but yourself.\n`request_id` is also the `X-Request-Id` response header, and it is what\nsupport will ask you for.\n\n## Rate limits\n\nEvery response that got past authentication carries the limit that is closest\nto being exhausted:\n\n- `RateLimit-Limit` — requests allowed in the window;\n- `RateLimit-Remaining` — requests left in the current window;\n- `RateLimit-Reset` — **seconds** until the window renews;\n- `RateLimit-Policy` — `;w=`.\n\nThree limits apply, each per one-minute window: per API key, per organisation\n(all of its keys together) and per service. The first two come from the licence\nof the organisation the key belongs to: a higher licence tier has more room, and\nan MSSP partner has more than a direct customer of the same tier. Read the\ncurrent limit from `RateLimit-Limit` and `RateLimit-Policy` rather than\nhard-coding it. When one is exhausted the answer is\n**429** with `error.code: \"rate_limited\"`, `Retry-After` (seconds) and\n`error.details.scope` (`key`, `tenant` or `service`) saying which. Your\norganisation's usage never counts against another's. Windows are fixed, so a\nclient can burst up to twice the limit across a window boundary: back off on\n`Retry-After` rather than pacing to the second. The limits are part of the\nservice, not of the frozen contract: they may be raised without notice.\n\n## Pagination, filtering and ordering\n\nEvery collection takes `page` and `per_page` (max 200) and answers with a\n`pagination` object carrying `total` and `total_pages`. Every collection\ntakes `sort` (an enum of the fields THAT collection can order by) and\n`order` (`asc`/`desc`, default `desc`). Filters are exact matches unless\nthe parameter says otherwise.","contact":{"name":"Hodeitek S.L.","url":"https://hodeishield.com"}},"servers":[{"url":"https://api.hodeishield.com"}],"tags":[{"name":"vendors"},{"name":"alerts"},{"name":"risks"},{"name":"compliance"},{"name":"evidence"},{"name":"endpoints"}],"paths":{"/v1/vendors":{"get":{"operationId":"listVendors","summary":"List third parties","description":"The tenant third-party inventory, newest first by default. Soft-deleted vendors are never returned.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["vendors"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: created_at, updated_at, name.","schema":{"default":"created_at","type":"string","enum":["created_at","updated_at","name"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"q","in":"query","required":false,"description":"Case-insensitive substring of the name or the domain.","schema":{"type":"string","minLength":1,"maxLength":120}},{"name":"category","in":"query","required":false,"description":"Exact category match.","schema":{"type":"string","minLength":1,"maxLength":120}},{"name":"active","in":"query","required":false,"description":"Filter by the operational flag.","schema":{"type":"string"}},{"name":"business_criticality","in":"query","required":false,"description":"Exact criticality match.","schema":{"type":"string","enum":["low","medium","high","critical"]}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"Stable identifier of the vendor in this tenant."},"name":{"type":"string","description":"Display name."},"legal_name":{"description":"Registered legal name, when it differs.","type":["string","null"]},"domain":{"description":"Primary domain, lowercase, no scheme.","type":["string","null"]},"category":{"description":"Free-text category assigned by the tenant.","type":["string","null"]},"vendor_type":{"description":"Kind of third party (provider, product, …).","type":["string","null"]},"access_level":{"description":"Level of access the vendor has to the tenant systems.","type":["string","null"]},"business_criticality":{"description":"low | medium | high | critical, as set by the tenant.","type":["string","null"]},"data_sensitivity":{"description":"public | internal | confidential | restricted.","type":["string","null"]},"economic_impact":{"description":"Estimated economic impact of an outage, in the tenant currency.","type":["number","null"]},"active":{"type":"boolean","description":"Whether the vendor is operationally enabled."},"monitoring_status":{"description":"Continuous-monitoring state.","type":["string","null"]},"last_scan_at":{"description":"RFC 3339, UTC. Null if never scanned.","type":["string","null"]},"lei":{"description":"Legal Entity Identifier (ISO 17442), for DORA RoI.","type":["string","null"]},"hq_country":{"description":"Head-office country, ISO 3166-1 alpha-2.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."},"updated_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","name","legal_name","domain","category","vendor_type","access_level","business_criticality","data_sensitivity","economic_impact","active","monitoring_status","last_scan_at","lei","hq_country","created_at","updated_at"],"additionalProperties":false,"description":"A third party in the tenant inventory."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range. `error.details` names it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key or an OAuth access token; the cases are not distinguished). The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but does not carry the scope this operation requires. `error.details.missing` lists the scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` (`key`, `tenant` or `service`) which limit it was.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/vendors/{id}":{"get":{"operationId":"getVendor","summary":"Fetch one third party","description":"The same object `listVendors` returns, for a single vendor.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["vendors"],"parameters":[{"name":"id","in":"path","required":true,"description":"Vendor identifier.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"Stable identifier of the vendor in this tenant."},"name":{"type":"string","description":"Display name."},"legal_name":{"description":"Registered legal name, when it differs.","type":["string","null"]},"domain":{"description":"Primary domain, lowercase, no scheme.","type":["string","null"]},"category":{"description":"Free-text category assigned by the tenant.","type":["string","null"]},"vendor_type":{"description":"Kind of third party (provider, product, …).","type":["string","null"]},"access_level":{"description":"Level of access the vendor has to the tenant systems.","type":["string","null"]},"business_criticality":{"description":"low | medium | high | critical, as set by the tenant.","type":["string","null"]},"data_sensitivity":{"description":"public | internal | confidential | restricted.","type":["string","null"]},"economic_impact":{"description":"Estimated economic impact of an outage, in the tenant currency.","type":["number","null"]},"active":{"type":"boolean","description":"Whether the vendor is operationally enabled."},"monitoring_status":{"description":"Continuous-monitoring state.","type":["string","null"]},"last_scan_at":{"description":"RFC 3339, UTC. Null if never scanned.","type":["string","null"]},"lei":{"description":"Legal Entity Identifier (ISO 17442), for DORA RoI.","type":["string","null"]},"hq_country":{"description":"Head-office country, ISO 3166-1 alpha-2.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."},"updated_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","name","legal_name","domain","category","vendor_type","access_level","business_criticality","data_sensitivity","economic_impact","active","monitoring_status","last_scan_at","lei","hq_country","created_at","updated_at"],"additionalProperties":false,"description":"A third party in the tenant inventory."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range. `error.details` names it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key or an OAuth access token; the cases are not distinguished). The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but does not carry the scope this operation requires. `error.details.missing` lists the scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` (`key`, `tenant` or `service`) which limit it was.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/alerts":{"get":{"operationId":"listAlerts","summary":"List supply-chain alerts","description":"Alerts raised against the tenant vendors. Poll incrementally with `since` plus `order=asc`.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["alerts"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: created_at.","schema":{"default":"created_at","type":"string","enum":["created_at"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"status","in":"query","required":false,"description":"Filter by the derived status.","schema":{"type":"string","enum":["open","acknowledged","resolved"]}},{"name":"severity","in":"query","required":false,"description":"Exact severity match.","schema":{"type":"string","minLength":1,"maxLength":40}},{"name":"vendor_id","in":"query","required":false,"description":"Only alerts about this vendor.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"since","in":"query","required":false,"description":"Only alerts created at or after this RFC 3339 instant.","schema":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"vendor_id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"The vendor this alert is about (`/v1/vendors/{id}`)."},"type":{"type":"string","description":"Alert family, e.g. `breach`, `cve`, `rating_drop`."},"severity":{"description":"critical | high | medium | low, when classified.","type":["string","null"]},"title":{"type":"string"},"description":{"type":["string","null"]},"source_url":{"description":"Where the finding came from, when public.","type":["string","null"]},"economic_impact":{"type":["number","null"]},"status":{"type":"string","enum":["open","acknowledged","resolved"],"description":"Derived: resolved if closed, acknowledged if taken, otherwise open."},"acknowledged_at":{"description":"RFC 3339, UTC.","type":["string","null"]},"resolved_at":{"description":"RFC 3339, UTC.","type":["string","null"]},"resolution_reason":{"description":"Why it was closed, when it was.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","vendor_id","type","severity","title","description","source_url","economic_impact","status","acknowledged_at","resolved_at","resolution_reason","created_at"],"additionalProperties":false,"description":"A supply-chain alert raised against one vendor."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range. `error.details` names it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key or an OAuth access token; the cases are not distinguished). The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but does not carry the scope this operation requires. `error.details.missing` lists the scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` (`key`, `tenant` or `service`) which limit it was.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/alerts/{id}":{"get":{"operationId":"getAlert","summary":"Fetch one alert","description":"The same object `listAlerts` returns, for a single alert.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["alerts"],"parameters":[{"name":"id","in":"path","required":true,"description":"Alert identifier.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"vendor_id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"The vendor this alert is about (`/v1/vendors/{id}`)."},"type":{"type":"string","description":"Alert family, e.g. `breach`, `cve`, `rating_drop`."},"severity":{"description":"critical | high | medium | low, when classified.","type":["string","null"]},"title":{"type":"string"},"description":{"type":["string","null"]},"source_url":{"description":"Where the finding came from, when public.","type":["string","null"]},"economic_impact":{"type":["number","null"]},"status":{"type":"string","enum":["open","acknowledged","resolved"],"description":"Derived: resolved if closed, acknowledged if taken, otherwise open."},"acknowledged_at":{"description":"RFC 3339, UTC.","type":["string","null"]},"resolved_at":{"description":"RFC 3339, UTC.","type":["string","null"]},"resolution_reason":{"description":"Why it was closed, when it was.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","vendor_id","type","severity","title","description","source_url","economic_impact","status","acknowledged_at","resolved_at","resolution_reason","created_at"],"additionalProperties":false,"description":"A supply-chain alert raised against one vendor."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range. `error.details` names it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key or an OAuth access token; the cases are not distinguished). The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but does not carry the scope this operation requires. `error.details.missing` lists the scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` (`key`, `tenant` or `service`) which limit it was.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/risks":{"get":{"operationId":"listRisks","summary":"List risk-register entries","description":"The tenant risk register, with inherent and residual figures.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["risks"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: created_at, updated_at, inherent_score, residual_score.","schema":{"default":"created_at","type":"string","enum":["created_at","updated_at","inherent_score","residual_score"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"status","in":"query","required":false,"description":"Exact status match.","schema":{"type":"string","minLength":1,"maxLength":24}},{"name":"domain","in":"query","required":false,"description":"Exact domain match.","schema":{"type":"string","minLength":1,"maxLength":20}},{"name":"vendor_id","in":"query","required":false,"description":"Only risks linked to this vendor.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"min_inherent_score","in":"query","required":false,"description":"Only risks whose inherent score is at least this.","schema":{"type":"integer","minimum":0,"maximum":9007199254740991}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"title":{"type":"string"},"description":{"type":["string","null"]},"domain":{"type":"string","description":"Risk domain, as configured by the tenant."},"status":{"type":"string","description":"identified | assessed | treated | accepted | closed | …"},"treatment":{"description":"mitigate | accept | transfer | avoid.","type":["string","null"]},"treatment_notes":{"type":["string","null"]},"threat_source":{"type":["string","null"]},"vulnerability":{"type":["string","null"]},"likelihood_inherent":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"impact_inherent":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"inherent_score":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"likelihood_residual":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"impact_residual":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"residual_score":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"source":{"type":"string","description":"manual | imported | derived."},"owner_user_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"User of this tenant who owns the risk."},"vendor_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The vendor this risk is about, when any."},"control_codes":{"type":"array","items":{"type":"string"},"description":"Framework control codes linked to the risk."},"review_date":{"description":"Calendar date, `YYYY-MM-DD`.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."},"updated_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","title","description","domain","status","treatment","treatment_notes","threat_source","vulnerability","likelihood_inherent","impact_inherent","inherent_score","likelihood_residual","impact_residual","residual_score","source","owner_user_id","vendor_id","control_codes","review_date","created_at","updated_at"],"additionalProperties":false,"description":"An entry of the tenant risk register."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range. `error.details` names it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key or an OAuth access token; the cases are not distinguished). The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but does not carry the scope this operation requires. `error.details.missing` lists the scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` (`key`, `tenant` or `service`) which limit it was.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/risks/{id}":{"get":{"operationId":"getRisk","summary":"Fetch one risk-register entry","description":"The same object `listRisks` returns, for a single entry.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["risks"],"parameters":[{"name":"id","in":"path","required":true,"description":"Risk identifier.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"title":{"type":"string"},"description":{"type":["string","null"]},"domain":{"type":"string","description":"Risk domain, as configured by the tenant."},"status":{"type":"string","description":"identified | assessed | treated | accepted | closed | …"},"treatment":{"description":"mitigate | accept | transfer | avoid.","type":["string","null"]},"treatment_notes":{"type":["string","null"]},"threat_source":{"type":["string","null"]},"vulnerability":{"type":["string","null"]},"likelihood_inherent":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"impact_inherent":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"inherent_score":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"likelihood_residual":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"impact_residual":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"residual_score":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"source":{"type":"string","description":"manual | imported | derived."},"owner_user_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"User of this tenant who owns the risk."},"vendor_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The vendor this risk is about, when any."},"control_codes":{"type":"array","items":{"type":"string"},"description":"Framework control codes linked to the risk."},"review_date":{"description":"Calendar date, `YYYY-MM-DD`.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."},"updated_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","title","description","domain","status","treatment","treatment_notes","threat_source","vulnerability","likelihood_inherent","impact_inherent","inherent_score","likelihood_residual","impact_residual","residual_score","source","owner_user_id","vendor_id","control_codes","review_date","created_at","updated_at"],"additionalProperties":false,"description":"An entry of the tenant risk register."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range. `error.details` names it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key or an OAuth access token; the cases are not distinguished). The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but does not carry the scope this operation requires. `error.details.missing` lists the scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` (`key`, `tenant` or `service`) which limit it was.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/compliance/controls":{"get":{"operationId":"listComplianceControls","summary":"List control status for a framework","description":"Per-control status of one framework. Requires the read scope OF THAT FRAMEWORK: `compliance.:read`.\n\nRequired scope: `compliance.:read`. Published in `/v1` since 2026-09-20.","tags":["compliance"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: control_code, last_evaluated_at.","schema":{"default":"control_code","type":"string","enum":["control_code","last_evaluated_at"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"framework","in":"query","required":true,"description":"Framework slug. One of: nis2, ens, iso27001, dora, gdpr, soc2, iso42001, nistcsf, pcidss, euaiact, hipaa, cmmc, cyberessentials, uaeia, sama, saudiecc, ndpa.","schema":{"type":"string"}},{"name":"status","in":"query","required":false,"description":"Exact status match.","schema":{"type":"string","minLength":1,"maxLength":20}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"framework":{"type":"string","description":"Framework slug the control belongs to."},"control_code":{"type":"string","description":"Control identifier inside the framework."},"status":{"type":"string","description":"missing | partial | covered, as evaluated by the platform."},"evidence_count":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Evidence items currently linked to the control."},"last_evaluated_at":{"description":"RFC 3339, UTC.","type":["string","null"]}},"required":["framework","control_code","status","evidence_count","last_evaluated_at"],"additionalProperties":false,"description":"The tenant status of one framework control."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range. `error.details` names it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key or an OAuth access token; the cases are not distinguished). The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but does not carry the scope this operation requires. `error.details.missing` lists the scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` (`key`, `tenant` or `service`) which limit it was.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":[]}],"x-hs-scope":{"template":"compliance.:read"}}},"/v1/compliance/frameworks/{framework}":{"get":{"operationId":"getFrameworkPosture","summary":"Framework posture summary","description":"Counts of evaluated controls by status for one framework, for a dashboard tile. Requires `compliance.:read`.\n\nRequired scope: `compliance.:read`. Published in `/v1` since 2026-09-20.","tags":["compliance"],"parameters":[{"name":"framework","in":"path","required":true,"description":"Framework slug. One of: nis2, ens, iso27001, dora, gdpr, soc2, iso42001, nistcsf, pcidss, euaiact, hipaa, cmmc, cyberessentials, uaeia, sama, saudiecc, ndpa.","schema":{"type":"string"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"framework":{"type":"string"},"controls_total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Controls the platform has evaluated."},"by_status":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"description":"Control count per status. Keys are whatever statuses exist; may grow."},"evidence_total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Sum of the evidence linked across the evaluated controls."},"last_evaluated_at":{"description":"Most recent control evaluation, RFC 3339 UTC. Null if never evaluated.","type":["string","null"]}},"required":["framework","controls_total","by_status","evidence_total","last_evaluated_at"],"additionalProperties":false,"description":"Posture summary for one framework."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range. `error.details` names it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key or an OAuth access token; the cases are not distinguished). The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but does not carry the scope this operation requires. `error.details.missing` lists the scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` (`key`, `tenant` or `service`) which limit it was.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":[]}],"x-hs-scope":{"template":"compliance.:read"}}},"/v1/evidence":{"get":{"operationId":"listEvidence","summary":"List security evidence","description":"Evidence held for the tenant. Use `expiring_before` plus `sort=valid_until&order=asc` for a renewal queue.\n\nRequired scope: `evidence:read`. Published in `/v1` since 2026-09-20.","tags":["evidence"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: created_at, valid_until.","schema":{"default":"created_at","type":"string","enum":["created_at","valid_until"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"evidence_type","in":"query","required":false,"description":"Exact type match.","schema":{"type":"string","minLength":1,"maxLength":50}},{"name":"source_type","in":"query","required":false,"description":"Exact producer match.","schema":{"type":"string","minLength":1,"maxLength":50}},{"name":"vendor_id","in":"query","required":false,"description":"Only evidence about this vendor.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"expiring_before","in":"query","required":false,"description":"Only evidence with a `valid_until` strictly before this `YYYY-MM-DD`.","schema":{"type":"string","format":"date","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"evidence_type":{"type":"string","description":"What kind of evidence this is."},"source_type":{"type":"string","description":"Which subsystem produced it."},"source_id":{"type":"string","description":"Its identifier inside that subsystem."},"title":{"type":"string"},"description":{"type":["string","null"]},"vendor_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The vendor it is about, when any."},"asset_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The asset it is about, when any."},"auto_generated":{"type":"boolean","description":"False when a person uploaded it."},"valid_until":{"description":"Calendar date, `YYYY-MM-DD`. Null = no expiry.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","evidence_type","source_type","source_id","title","description","vendor_id","asset_id","auto_generated","valid_until","created_at"],"additionalProperties":false,"description":"One item of security evidence."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range. `error.details` names it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key or an OAuth access token; the cases are not distinguished). The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but does not carry the scope this operation requires. `error.details.missing` lists the scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` (`key`, `tenant` or `service`) which limit it was.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["evidence:read"]}],"x-hs-scope":{"scopes":["evidence:read"]}}},"/v1/evidence/{id}":{"get":{"operationId":"getEvidence","summary":"Fetch one evidence item","description":"The same object `listEvidence` returns, for a single item.\n\nRequired scope: `evidence:read`. Published in `/v1` since 2026-09-20.","tags":["evidence"],"parameters":[{"name":"id","in":"path","required":true,"description":"Evidence identifier.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"evidence_type":{"type":"string","description":"What kind of evidence this is."},"source_type":{"type":"string","description":"Which subsystem produced it."},"source_id":{"type":"string","description":"Its identifier inside that subsystem."},"title":{"type":"string"},"description":{"type":["string","null"]},"vendor_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The vendor it is about, when any."},"asset_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The asset it is about, when any."},"auto_generated":{"type":"boolean","description":"False when a person uploaded it."},"valid_until":{"description":"Calendar date, `YYYY-MM-DD`. Null = no expiry.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","evidence_type","source_type","source_id","title","description","vendor_id","asset_id","auto_generated","valid_until","created_at"],"additionalProperties":false,"description":"One item of security evidence."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range. `error.details` names it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key or an OAuth access token; the cases are not distinguished). The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but does not carry the scope this operation requires. `error.details.missing` lists the scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` (`key`, `tenant` or `service`) which limit it was.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["evidence:read"]}],"x-hs-scope":{"scopes":["evidence:read"]}}},"/v1/endpoints":{"get":{"operationId":"listEndpoints","summary":"List enrolled endpoint agents","description":"The endpoint agents enrolled in this tenant. `silent_since` selects the ones that have gone quiet, including those that never reported.\n\nRequired scope: `endpoints:read`. Published in `/v1` since 2026-09-20.","tags":["endpoints"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: enrolled_at, last_seen_at, producer.","schema":{"default":"enrolled_at","type":"string","enum":["enrolled_at","last_seen_at","producer"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"status","in":"query","required":false,"description":"Exact status match.","schema":{"type":"string","enum":["active","revoked"]}},{"name":"silent_since","in":"query","required":false,"description":"Agents last seen before this RFC 3339 instant, or never seen at all.","schema":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"producer":{"type":"string","description":"The agent name, unique among the active ones of the tenant."},"producer_kind":{"type":"string","description":"What kind of producer it is."},"status":{"type":"string","description":"active | revoked."},"signature_alg":{"type":"string","description":"Algorithm this enrollment signs with."},"agent_version":{"type":["string","null"]},"hostname":{"description":"Last hostname the agent reported.","type":["string","null"]},"enrolled_at":{"type":"string","description":"RFC 3339, UTC."},"last_seen_at":{"description":"RFC 3339, UTC. Null until the first envelope.","type":["string","null"]},"revoked_at":{"description":"RFC 3339, UTC.","type":["string","null"]}},"required":["id","producer","producer_kind","status","signature_alg","agent_version","hostname","enrolled_at","last_seen_at","revoked_at"],"additionalProperties":false,"description":"An endpoint agent enrolled in this tenant."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range. `error.details` names it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key or an OAuth access token; the cases are not distinguished). The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but does not carry the scope this operation requires. `error.details.missing` lists the scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` (`key`, `tenant` or `service`) which limit it was.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["endpoints:read"]}],"x-hs-scope":{"scopes":["endpoints:read"]}}}},"components":{"schemas":{"Error":{"type":"object","required":["error"],"additionalProperties":false,"properties":{"error":{"type":"object","required":["code","message","request_id"],"properties":{"code":{"type":"string","enum":["invalid_request","unauthorized","forbidden","not_found","method_not_allowed","rate_limited","internal_error","service_unavailable"],"description":"Closed vocabulary; new members may be added (see Versioning)."},"message":{"type":"string","description":"For a human reading a log. Not stable; do not parse it."},"request_id":{"type":"string","description":"Also the `X-Request-Id` response header."},"details":{"type":"object","additionalProperties":true,"description":"Diagnostic. Not part of the frozen contract."}}}}}},"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"A tenant API key, created in the console under Settings → API keys with the minimum scopes it needs."},"oauth2":{"type":"oauth2","description":"A USER access token (`hs_at_…`) issued by the HodeiShield console, the OAuth 2.1 authorization server at `https://app.hodeishield.com/api/auth` (metadata: `/.well-known/oauth-authorization-server` on its origin). Authorization code with PKCE (S256, mandatory) for applications with a browser; the RFC 8628 device authorization grant (`https://app.hodeishield.com/api/auth/device/code`) for those without one. Every token is bound to ONE user and ONE tenant, chosen by the user on the consent screen, and carries read scopes only; the tenant is re-checked against what the user can see on every request. Public clients register themselves (RFC 7591) at `/oauth2/register`; the HodeiShield CLI is pre-registered as `hodeishield-cli`. Revocation: RFC 7009 at `/oauth2/revoke`, or by the user in the console under Account → Application access.","flows":{"authorizationCode":{"authorizationUrl":"https://app.hodeishield.com/api/auth/oauth2/authorize","tokenUrl":"https://app.hodeishield.com/api/auth/oauth2/token","refreshUrl":"https://app.hodeishield.com/api/auth/oauth2/token","scopes":{"supply_risk:read":"Read supply_risk data of the tenant the token is bound to.","compliance.nis2:read":"Read compliance.nis2 data of the tenant the token is bound to.","compliance.ens:read":"Read compliance.ens data of the tenant the token is bound to.","compliance.iso27001:read":"Read compliance.iso27001 data of the tenant the token is bound to.","compliance.dora:read":"Read compliance.dora data of the tenant the token is bound to.","compliance.gdpr:read":"Read compliance.gdpr data of the tenant the token is bound to.","compliance.soc2:read":"Read compliance.soc2 data of the tenant the token is bound to.","compliance.iso42001:read":"Read compliance.iso42001 data of the tenant the token is bound to.","compliance.nistcsf:read":"Read compliance.nistcsf data of the tenant the token is bound to.","compliance.pcidss:read":"Read compliance.pcidss data of the tenant the token is bound to.","compliance.euaiact:read":"Read compliance.euaiact data of the tenant the token is bound to.","compliance.hipaa:read":"Read compliance.hipaa data of the tenant the token is bound to.","compliance.cmmc:read":"Read compliance.cmmc data of the tenant the token is bound to.","compliance.cyberessentials:read":"Read compliance.cyberessentials data of the tenant the token is bound to.","compliance.uaeia:read":"Read compliance.uaeia data of the tenant the token is bound to.","compliance.sama:read":"Read compliance.sama data of the tenant the token is bound to.","compliance.saudiecc:read":"Read compliance.saudiecc data of the tenant the token is bound to.","compliance.ndpa:read":"Read compliance.ndpa data of the tenant the token is bound to.","radar:read":"Read radar data of the tenant the token is bound to.","connectors:read":"Read connectors data of the tenant the token is bound to.","endpoints:read":"Read endpoints data of the tenant the token is bound to.","hodeiai:read":"Read hodeiai data of the tenant the token is bound to.","virtual_ciso:read":"Read virtual_ciso data of the tenant the token is bound to.","simulator:read":"Read simulator data of the tenant the token is bound to.","evidence:read":"Read evidence data of the tenant the token is bound to.","reports:read":"Read reports data of the tenant the token is bound to.","management:read":"Read management data of the tenant the token is bound to.","offline_access":"Issue a refresh token (30 days, rotated on every use)."}}}}}},"security":[{"bearerAuth":[]},{"oauth2":[]}]} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"Hodei-Shield API","version":"2026-10-16","summary":"Third-party risk and compliance posture, for your own systems.","description":"## Versioning\n\n`/v1` is frozen. These changes are **compatible** and will happen inside it\nwithout notice:\n\n- a new operation;\n- a new **optional** query parameter;\n- a new field in a response object;\n- a new value in a field documented as an open vocabulary (`status`,\n `severity`, `evidence_type`, the keys of `by_status`);\n- a new `error.code`, always with the HTTP status its siblings already use;\n- anything under `error.details`, which is diagnostic and not part of the\n contract — except `error.details.code` and the keys the error table of\n the operation names (`missing`, `licence`, `parameter`, `scope`,\n `limit`, `window_seconds`, `retry_after_seconds`): those are contract.\n `error.details.code` is an OPEN vocabulary: a value, once published, keeps\n its meaning and the HTTP status it is sent with; new values may be added.\n\nWrite your client so those do not break it: ignore unknown fields, do not\nswitch exhaustively on a documented-open vocabulary, and treat an unknown\n`error.code` as \"an error of this HTTP status\".\n\nThese changes are **incompatible** and will NOT happen inside `/v1`. They go\nto `/v2`, and `/v1` keeps serving:\n\n- removing or renaming an operation, a parameter or a field;\n- changing the type, the unit or the meaning of a field;\n- making an optional parameter required, or narrowing what one accepts;\n- changing which scope an operation requires;\n- changing the HTTP status an existing `error.code` is sent with.\n\n## Read-only\n\n`/v1` only reads. Every operation is a `GET`. Any other method (`POST`, `PUT`,\n`PATCH`, `DELETE`) on a path that exists answers **405** with `Allow: GET`, and on\na path that does not exist answers **404**: neither changes any state. A write\nwill be offered only as a new operation, which is a compatible change, once a\ncustomer asks for a concrete one.\n\n## Authentication\n\nEvery operation takes `Authorization: Bearer `. Three credentials\nexist:\n\n- a **tenant API key** (`hsk_…`): belongs to ONE tenant and carries an\n explicit list of scopes; it can read nothing outside that tenant, whatever it\n asks for;\n- a user's **OAuth access token** (`hs_at_…`): one user, ONE tenant chosen\n at consent, read scopes only;\n- an MSSP **portfolio key** (`hsp_…`): held by a managed security service\n provider, it reads ANY of its client tenants, ONE per request, selected with\n the request header `X-HS-Tenant: ` (a UUID, never a slug).\n `GET /v1/portfolio/tenants` lists the tenants it may select. Read-only, with\n a mandatory expiry. A tenant outside the key's portfolio answers **404**\n (`error.details.code: \"TENANT_NOT_FOUND\"`), exactly as one that does not\n exist.\n\nEvery successful (2xx) answer of a data operation carries `X-HS-Tenant` with\nthe id of the tenant whose data the body holds, for every credential; no error\ncarries it. A tenant API key or OAuth token may send `X-HS-Tenant` only with\nits own tenant's id; any other value is a **400**\n(`TENANT_SELECTOR_NOT_SUPPORTED`).\n\nEach operation below declares the scope it requires. A credential that\nauthenticates but lacks the scope is answered **403**\n(`error.details.code: \"INSUFFICIENT_SCOPE\"`), never 404: you are told what you\nare missing, not that the resource does not exist.\n\n## Errors\n\nEvery error has the same body:\n\n```json\n{ \"error\": { \"code\": \"forbidden\", \"message\": \"…\", \"request_id\": \"…\" } }\n```\n\nBranch on `code`, log `request_id`, show `message` to nobody but yourself.\n`request_id` is also the `X-Request-Id` response header, and it is what\nsupport will ask you for.\n\n## Rate limits\n\nEvery response that got past authentication carries the limit that is closest\nto being exhausted:\n\n- `RateLimit-Limit` — requests allowed in the window;\n- `RateLimit-Remaining` — requests left in the current window;\n- `RateLimit-Reset` — **seconds** until the window renews;\n- `RateLimit-Policy` — `;w=`.\n\nThree limits apply, each per one-minute window: per API key, per organisation\n(all of its keys together) and per service. The first two come from the licence\nof the organisation the key belongs to: a higher licence tier has more room, and\nan MSSP partner has more than a direct customer of the same tier. Read the\ncurrent limit from `RateLimit-Limit` and `RateLimit-Policy` rather than\nhard-coding it. When one is exhausted the answer is\n**429** with `error.code: \"rate_limited\"`, `Retry-After` (seconds) and\n`error.details.scope` (`key`, `tenant` or `service`) saying which. A\nportfolio key is limited per key (`portfolio_key`), per provider across all\nits portfolio keys (`portfolio_org`) and per service; once the selected tenant\nis known to be in its portfolio, the client tenant's own limit (`tenant`) and\nthe provider's share of it (`portfolio_tenant`) apply too. Your\norganisation's usage never counts against another's. Windows are fixed, so a\nclient can burst up to twice the limit across a window boundary: back off on\n`Retry-After` rather than pacing to the second. The limits are part of the\nservice, not of the frozen contract: they may be raised without notice.\n\n## Pagination, filtering and ordering\n\nEvery collection takes `page` and `per_page` (max 200) and answers with a\n`pagination` object carrying `total` and `total_pages`. Every collection\ntakes `sort` (an enum of the fields THAT collection can order by) and\n`order` (`asc`/`desc`, default `desc`). Filters are exact matches unless\nthe parameter says otherwise. A parameter or field with a known vocabulary lists\nit in `x-hs-known-values` (an OpenAPI extension, not a JSON Schema `enum`: the\nserver still accepts what it accepted before). Values retired from a parameter\nare listed in `x-hs-deprecated-values`, with the date until which the server is\ndocumented to accept them in `x-hs-deprecated-values-sunset`.\n\n## Contract changes\n\nCorrections and additions to the published contract, newest first. They ship\nwith the contract version (`info.version`) named in each entry. Detect a\ncapability by the operation, not by this string: the portfolio contract is\npresent when `GET /v1` lists `listPortfolioTenants`.\n\n- **2026-10-16 — addition: MSSP portfolio keys.** A third credential, the\n portfolio key (`hsp_…`); the operation `GET /v1/portfolio/tenants`\n (`listPortfolioTenants`, scope `portfolio:read`); the optional request\n header `X-HS-Tenant` on every data operation, echoed on every 2xx answer of\n them for every credential, with `Vary: X-HS-Tenant`;\n `error.details.code` becomes contract (`TENANT_HEADER_INVALID`,\n `TENANT_REQUIRED`, `TENANT_SELECTOR_NOT_SUPPORTED`, `TENANT_NOT_FOUND`,\n `INSUFFICIENT_SCOPE`, `LICENCE_INACTIVE`), and every scope 403 now carries\n `INSUFFICIENT_SCOPE`; three new 429 `details.scope` values\n (`portfolio_key`, `portfolio_org`, `portfolio_tenant`). Nothing existing\n was removed, renamed or re-statused.\n\n- **2026-10-16 — correction: alert `severity` is English.** `GET /v1/alerts`\n and `GET /v1/alerts/{id}` returned `severity` as stored, in Spanish\n (`critico`, `alto`, `medio`, `bajo`, `info`), while this document\n promised `critical | high | medium | low`. They now return `critical`,\n `high`, `medium`, `low` and `info` (`info` is added to the documented\n values). **This changes what an integrator who adapted to the Spanish values\n receives.** The `severity` filter accepts both the English values and the\n Spanish ones; the Spanish ones are documented as accepted until\n **2027-01-14** (90 days after this version), so switch to the English ones\n before then. A value outside both lists is still matched exactly and matches\n nothing. `GET /v1/risks`: the descriptions of `domain` and `status` named\n values that do not exist; they now list the real ones (no value changed).","contact":{"name":"Hodeitek S.L.","url":"https://hodeishield.com"}},"servers":[{"url":"https://api.hodeishield.com"}],"tags":[{"name":"vendors"},{"name":"alerts"},{"name":"risks"},{"name":"compliance"},{"name":"evidence"},{"name":"endpoints"},{"name":"Portfolio"}],"paths":{"/v1/vendors":{"get":{"operationId":"listVendors","summary":"List third parties","description":"The tenant third-party inventory, newest first by default. Soft-deleted vendors are never returned.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["vendors"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: created_at, updated_at, name.","schema":{"default":"created_at","type":"string","enum":["created_at","updated_at","name"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"q","in":"query","required":false,"description":"Case-insensitive substring of the name or the domain.","schema":{"type":"string","minLength":1,"maxLength":120}},{"name":"category","in":"query","required":false,"description":"Exact category match.","schema":{"type":"string","minLength":1,"maxLength":120}},{"name":"active","in":"query","required":false,"description":"Filter by the operational flag.","schema":{"type":"string"}},{"name":"business_criticality","in":"query","required":false,"description":"Exact criticality match.","schema":{"type":"string","enum":["low","medium","high","critical"]}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"Stable identifier of the vendor in this tenant."},"name":{"type":"string","description":"Display name."},"legal_name":{"description":"Registered legal name, when it differs.","type":["string","null"]},"domain":{"description":"Primary domain, lowercase, no scheme.","type":["string","null"]},"category":{"description":"Free-text category assigned by the tenant.","type":["string","null"]},"vendor_type":{"description":"Kind of third party (provider, product, …).","type":["string","null"]},"access_level":{"description":"Level of access the vendor has to the tenant systems.","type":["string","null"]},"business_criticality":{"description":"low | medium | high | critical, as set by the tenant.","type":["string","null"]},"data_sensitivity":{"description":"public | internal | confidential | restricted.","type":["string","null"]},"economic_impact":{"description":"Estimated economic impact of an outage, in the tenant currency.","type":["number","null"]},"active":{"type":"boolean","description":"Whether the vendor is operationally enabled."},"monitoring_status":{"description":"Continuous-monitoring state.","type":["string","null"]},"last_scan_at":{"description":"RFC 3339, UTC. Null if never scanned.","type":["string","null"]},"lei":{"description":"Legal Entity Identifier (ISO 17442), for DORA RoI.","type":["string","null"]},"hq_country":{"description":"Head-office country, ISO 3166-1 alpha-2.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."},"updated_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","name","legal_name","domain","category","vendor_type","access_level","business_criticality","data_sensitivity","economic_impact","active","monitoring_status","last_scan_at","lei","hq_country","created_at","updated_at"],"additionalProperties":false,"description":"A third party in the tenant inventory."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/vendors/{id}":{"get":{"operationId":"getVendor","summary":"Fetch one third party","description":"The same object `listVendors` returns, for a single vendor.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["vendors"],"parameters":[{"name":"id","in":"path","required":true,"description":"Vendor identifier.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"Stable identifier of the vendor in this tenant."},"name":{"type":"string","description":"Display name."},"legal_name":{"description":"Registered legal name, when it differs.","type":["string","null"]},"domain":{"description":"Primary domain, lowercase, no scheme.","type":["string","null"]},"category":{"description":"Free-text category assigned by the tenant.","type":["string","null"]},"vendor_type":{"description":"Kind of third party (provider, product, …).","type":["string","null"]},"access_level":{"description":"Level of access the vendor has to the tenant systems.","type":["string","null"]},"business_criticality":{"description":"low | medium | high | critical, as set by the tenant.","type":["string","null"]},"data_sensitivity":{"description":"public | internal | confidential | restricted.","type":["string","null"]},"economic_impact":{"description":"Estimated economic impact of an outage, in the tenant currency.","type":["number","null"]},"active":{"type":"boolean","description":"Whether the vendor is operationally enabled."},"monitoring_status":{"description":"Continuous-monitoring state.","type":["string","null"]},"last_scan_at":{"description":"RFC 3339, UTC. Null if never scanned.","type":["string","null"]},"lei":{"description":"Legal Entity Identifier (ISO 17442), for DORA RoI.","type":["string","null"]},"hq_country":{"description":"Head-office country, ISO 3166-1 alpha-2.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."},"updated_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","name","legal_name","domain","category","vendor_type","access_level","business_criticality","data_sensitivity","economic_impact","active","monitoring_status","last_scan_at","lei","hq_country","created_at","updated_at"],"additionalProperties":false,"description":"A third party in the tenant inventory."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/alerts":{"get":{"operationId":"listAlerts","summary":"List supply-chain alerts","description":"Alerts raised against the tenant vendors. Poll incrementally with `since` plus `order=asc`.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["alerts"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: created_at.","schema":{"default":"created_at","type":"string","enum":["created_at"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"status","in":"query","required":false,"description":"Filter by the derived status.","schema":{"type":"string","enum":["open","acknowledged","resolved"]}},{"name":"severity","in":"query","required":false,"description":"Exact severity match, in the vocabulary the response uses (critical | high | medium | low | info). The legacy Spanish values (critico, alto, medio, bajo, info) are still accepted and mean the same; they are documented as accepted until 2027-01-14, so move to the English ones.","schema":{"x-hs-known-values":["critical","high","medium","low","info"],"x-hs-deprecated-values":["critico","alto","medio","bajo"],"x-hs-deprecated-values-sunset":"2027-01-14","type":"string","minLength":1,"maxLength":40}},{"name":"vendor_id","in":"query","required":false,"description":"Only alerts about this vendor.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"since","in":"query","required":false,"description":"Only alerts created at or after this RFC 3339 instant.","schema":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"vendor_id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"The vendor this alert is about (`/v1/vendors/{id}`)."},"type":{"type":"string","description":"Alert family, e.g. `breach`, `cve`, `rating_drop`."},"severity":{"description":"critical | high | medium | low | info, when classified. Open vocabulary: a new value is a compatible change.","x-hs-known-values":["critical","high","medium","low","info"],"type":["string","null"]},"title":{"type":"string"},"description":{"type":["string","null"]},"source_url":{"description":"Where the finding came from, when public.","type":["string","null"]},"economic_impact":{"type":["number","null"]},"status":{"type":"string","enum":["open","acknowledged","resolved"],"description":"Derived: resolved if closed, acknowledged if taken, otherwise open."},"acknowledged_at":{"description":"RFC 3339, UTC.","type":["string","null"]},"resolved_at":{"description":"RFC 3339, UTC.","type":["string","null"]},"resolution_reason":{"description":"Why it was closed, when it was.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","vendor_id","type","severity","title","description","source_url","economic_impact","status","acknowledged_at","resolved_at","resolution_reason","created_at"],"additionalProperties":false,"description":"A supply-chain alert raised against one vendor."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/alerts/{id}":{"get":{"operationId":"getAlert","summary":"Fetch one alert","description":"The same object `listAlerts` returns, for a single alert.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["alerts"],"parameters":[{"name":"id","in":"path","required":true,"description":"Alert identifier.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"vendor_id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"The vendor this alert is about (`/v1/vendors/{id}`)."},"type":{"type":"string","description":"Alert family, e.g. `breach`, `cve`, `rating_drop`."},"severity":{"description":"critical | high | medium | low | info, when classified. Open vocabulary: a new value is a compatible change.","x-hs-known-values":["critical","high","medium","low","info"],"type":["string","null"]},"title":{"type":"string"},"description":{"type":["string","null"]},"source_url":{"description":"Where the finding came from, when public.","type":["string","null"]},"economic_impact":{"type":["number","null"]},"status":{"type":"string","enum":["open","acknowledged","resolved"],"description":"Derived: resolved if closed, acknowledged if taken, otherwise open."},"acknowledged_at":{"description":"RFC 3339, UTC.","type":["string","null"]},"resolved_at":{"description":"RFC 3339, UTC.","type":["string","null"]},"resolution_reason":{"description":"Why it was closed, when it was.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","vendor_id","type","severity","title","description","source_url","economic_impact","status","acknowledged_at","resolved_at","resolution_reason","created_at"],"additionalProperties":false,"description":"A supply-chain alert raised against one vendor."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/risks":{"get":{"operationId":"listRisks","summary":"List risk-register entries","description":"The tenant risk register, with inherent and residual figures.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["risks"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: created_at, updated_at, inherent_score, residual_score.","schema":{"default":"created_at","type":"string","enum":["created_at","updated_at","inherent_score","residual_score"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"status","in":"query","required":false,"description":"Exact status match.","schema":{"type":"string","minLength":1,"maxLength":24}},{"name":"domain","in":"query","required":false,"description":"Exact domain match.","schema":{"type":"string","minLength":1,"maxLength":20}},{"name":"vendor_id","in":"query","required":false,"description":"Only risks linked to this vendor.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"min_inherent_score","in":"query","required":false,"description":"Only risks whose inherent score is at least this.","schema":{"type":"integer","minimum":0,"maximum":9007199254740991}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"title":{"type":"string"},"description":{"type":["string","null"]},"domain":{"type":"string","description":"Risk domain: vendor | cyber | data | operational | compliance | concentration | strategic | legal | financial | reputational."},"status":{"type":"string","description":"Risk lifecycle status: identified | assessed | treatment_planned | treatment_implemented | closed."},"treatment":{"description":"mitigate | accept | transfer | avoid.","type":["string","null"]},"treatment_notes":{"type":["string","null"]},"threat_source":{"type":["string","null"]},"vulnerability":{"type":["string","null"]},"likelihood_inherent":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"impact_inherent":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"inherent_score":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"likelihood_residual":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"impact_residual":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"residual_score":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"source":{"type":"string","description":"manual | imported | derived."},"owner_user_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"User of this tenant who owns the risk."},"vendor_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The vendor this risk is about, when any."},"control_codes":{"type":"array","items":{"type":"string"},"description":"Framework control codes linked to the risk."},"review_date":{"description":"Calendar date, `YYYY-MM-DD`.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."},"updated_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","title","description","domain","status","treatment","treatment_notes","threat_source","vulnerability","likelihood_inherent","impact_inherent","inherent_score","likelihood_residual","impact_residual","residual_score","source","owner_user_id","vendor_id","control_codes","review_date","created_at","updated_at"],"additionalProperties":false,"description":"An entry of the tenant risk register."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/risks/{id}":{"get":{"operationId":"getRisk","summary":"Fetch one risk-register entry","description":"The same object `listRisks` returns, for a single entry.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["risks"],"parameters":[{"name":"id","in":"path","required":true,"description":"Risk identifier.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"title":{"type":"string"},"description":{"type":["string","null"]},"domain":{"type":"string","description":"Risk domain: vendor | cyber | data | operational | compliance | concentration | strategic | legal | financial | reputational."},"status":{"type":"string","description":"Risk lifecycle status: identified | assessed | treatment_planned | treatment_implemented | closed."},"treatment":{"description":"mitigate | accept | transfer | avoid.","type":["string","null"]},"treatment_notes":{"type":["string","null"]},"threat_source":{"type":["string","null"]},"vulnerability":{"type":["string","null"]},"likelihood_inherent":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"impact_inherent":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"inherent_score":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"likelihood_residual":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"impact_residual":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"residual_score":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"source":{"type":"string","description":"manual | imported | derived."},"owner_user_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"User of this tenant who owns the risk."},"vendor_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The vendor this risk is about, when any."},"control_codes":{"type":"array","items":{"type":"string"},"description":"Framework control codes linked to the risk."},"review_date":{"description":"Calendar date, `YYYY-MM-DD`.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."},"updated_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","title","description","domain","status","treatment","treatment_notes","threat_source","vulnerability","likelihood_inherent","impact_inherent","inherent_score","likelihood_residual","impact_residual","residual_score","source","owner_user_id","vendor_id","control_codes","review_date","created_at","updated_at"],"additionalProperties":false,"description":"An entry of the tenant risk register."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/compliance/controls":{"get":{"operationId":"listComplianceControls","summary":"List control status for a framework","description":"Per-control status of one framework. Requires the read scope OF THAT FRAMEWORK: `compliance.:read`.\n\nRequired scope: `compliance.:read`. Published in `/v1` since 2026-09-20.","tags":["compliance"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: control_code, last_evaluated_at.","schema":{"default":"control_code","type":"string","enum":["control_code","last_evaluated_at"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"framework","in":"query","required":true,"description":"Framework slug. One of: nis2, ens, iso27001, dora, gdpr, soc2, iso42001, nistcsf, pcidss, euaiact, hipaa, cmmc, cyberessentials, uaeia, sama, saudiecc, ndpa.","schema":{"type":"string"}},{"name":"status","in":"query","required":false,"description":"Exact status match.","schema":{"type":"string","minLength":1,"maxLength":20}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"framework":{"type":"string","description":"Framework slug the control belongs to."},"control_code":{"type":"string","description":"Control identifier inside the framework."},"status":{"type":"string","description":"missing | partial | covered, as evaluated by the platform."},"evidence_count":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Evidence items currently linked to the control."},"last_evaluated_at":{"description":"RFC 3339, UTC.","type":["string","null"]}},"required":["framework","control_code","status","evidence_count","last_evaluated_at"],"additionalProperties":false,"description":"The tenant status of one framework control."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":[]},{"portfolioKey":[]}],"x-hs-scope":{"template":"compliance.:read"}}},"/v1/compliance/frameworks/{framework}":{"get":{"operationId":"getFrameworkPosture","summary":"Framework posture summary","description":"Counts of evaluated controls by status for one framework, for a dashboard tile. Requires `compliance.:read`.\n\nRequired scope: `compliance.:read`. Published in `/v1` since 2026-09-20.","tags":["compliance"],"parameters":[{"name":"framework","in":"path","required":true,"description":"Framework slug. One of: nis2, ens, iso27001, dora, gdpr, soc2, iso42001, nistcsf, pcidss, euaiact, hipaa, cmmc, cyberessentials, uaeia, sama, saudiecc, ndpa.","schema":{"type":"string"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"framework":{"type":"string"},"controls_total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Controls the platform has evaluated."},"by_status":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"description":"Control count per status. Keys are whatever statuses exist; may grow."},"evidence_total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Sum of the evidence linked across the evaluated controls."},"last_evaluated_at":{"description":"Most recent control evaluation, RFC 3339 UTC. Null if never evaluated.","type":["string","null"]}},"required":["framework","controls_total","by_status","evidence_total","last_evaluated_at"],"additionalProperties":false,"description":"Posture summary for one framework."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":[]},{"portfolioKey":[]}],"x-hs-scope":{"template":"compliance.:read"}}},"/v1/evidence":{"get":{"operationId":"listEvidence","summary":"List security evidence","description":"Evidence held for the tenant. Use `expiring_before` plus `sort=valid_until&order=asc` for a renewal queue.\n\nRequired scope: `evidence:read`. Published in `/v1` since 2026-09-20.","tags":["evidence"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: created_at, valid_until.","schema":{"default":"created_at","type":"string","enum":["created_at","valid_until"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"evidence_type","in":"query","required":false,"description":"Exact type match.","schema":{"type":"string","minLength":1,"maxLength":50}},{"name":"source_type","in":"query","required":false,"description":"Exact producer match.","schema":{"type":"string","minLength":1,"maxLength":50}},{"name":"vendor_id","in":"query","required":false,"description":"Only evidence about this vendor.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"expiring_before","in":"query","required":false,"description":"Only evidence with a `valid_until` strictly before this `YYYY-MM-DD`.","schema":{"type":"string","format":"date","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"evidence_type":{"type":"string","description":"What kind of evidence this is."},"source_type":{"type":"string","description":"Which subsystem produced it."},"source_id":{"type":"string","description":"Its identifier inside that subsystem."},"title":{"type":"string"},"description":{"type":["string","null"]},"vendor_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The vendor it is about, when any."},"asset_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The asset it is about, when any."},"auto_generated":{"type":"boolean","description":"False when a person uploaded it."},"valid_until":{"description":"Calendar date, `YYYY-MM-DD`. Null = no expiry.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","evidence_type","source_type","source_id","title","description","vendor_id","asset_id","auto_generated","valid_until","created_at"],"additionalProperties":false,"description":"One item of security evidence."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["evidence:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["evidence:read"]}}},"/v1/evidence/{id}":{"get":{"operationId":"getEvidence","summary":"Fetch one evidence item","description":"The same object `listEvidence` returns, for a single item.\n\nRequired scope: `evidence:read`. Published in `/v1` since 2026-09-20.","tags":["evidence"],"parameters":[{"name":"id","in":"path","required":true,"description":"Evidence identifier.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"evidence_type":{"type":"string","description":"What kind of evidence this is."},"source_type":{"type":"string","description":"Which subsystem produced it."},"source_id":{"type":"string","description":"Its identifier inside that subsystem."},"title":{"type":"string"},"description":{"type":["string","null"]},"vendor_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The vendor it is about, when any."},"asset_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The asset it is about, when any."},"auto_generated":{"type":"boolean","description":"False when a person uploaded it."},"valid_until":{"description":"Calendar date, `YYYY-MM-DD`. Null = no expiry.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","evidence_type","source_type","source_id","title","description","vendor_id","asset_id","auto_generated","valid_until","created_at"],"additionalProperties":false,"description":"One item of security evidence."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["evidence:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["evidence:read"]}}},"/v1/endpoints":{"get":{"operationId":"listEndpoints","summary":"List enrolled endpoint agents","description":"The endpoint agents enrolled in this tenant. `silent_since` selects the ones that have gone quiet, including those that never reported.\n\nRequired scope: `endpoints:read`. Published in `/v1` since 2026-09-20.","tags":["endpoints"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: enrolled_at, last_seen_at, producer.","schema":{"default":"enrolled_at","type":"string","enum":["enrolled_at","last_seen_at","producer"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"status","in":"query","required":false,"description":"Exact status match.","schema":{"type":"string","enum":["active","revoked"]}},{"name":"silent_since","in":"query","required":false,"description":"Agents last seen before this RFC 3339 instant, or never seen at all.","schema":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"producer":{"type":"string","description":"The agent name, unique among the active ones of the tenant."},"producer_kind":{"type":"string","description":"What kind of producer it is."},"status":{"type":"string","description":"active | revoked."},"signature_alg":{"type":"string","description":"Algorithm this enrollment signs with."},"agent_version":{"type":["string","null"]},"hostname":{"description":"Last hostname the agent reported.","type":["string","null"]},"enrolled_at":{"type":"string","description":"RFC 3339, UTC."},"last_seen_at":{"description":"RFC 3339, UTC. Null until the first envelope.","type":["string","null"]},"revoked_at":{"description":"RFC 3339, UTC.","type":["string","null"]}},"required":["id","producer","producer_kind","status","signature_alg","agent_version","hostname","enrolled_at","last_seen_at","revoked_at"],"additionalProperties":false,"description":"An endpoint agent enrolled in this tenant."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["endpoints:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["endpoints:read"]}}},"/v1/portfolio/tenants":{"get":{"operationId":"listPortfolioTenants","summary":"List the client tenants of an MSSP portfolio","description":"Every tenant the presented PORTFOLIO key may select with `X-HS-Tenant`: the MSSP’s own tenants (its internal one included) and every tenant of its active client organisations, active or not, licensed or not, narrowed to the key’s client subset when it has one. Membership is evaluated live on every request. Only a portfolio key (`hsp_…`) can call this operation; this operation never takes `X-HS-Tenant`.\n\nRequired scope: `portfolio:read`. Published in `/v1` since 2026-10-16.","tags":["Portfolio"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: name, slug.","schema":{"default":"name","type":"string","enum":["name","slug"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"Stable identifier (lowercase). The ONLY value `X-HS-Tenant` accepts."},"name":{"type":"string","description":"Display name. Untrusted text."},"slug":{"type":"string","description":"Short name, unique across the platform. A client may resolve a slug to an `id` from this list; the server never accepts a slug as a selector."},"organization":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":false,"description":"The organisation the tenant belongs to: the MSSP itself for the tenants it holds directly (including its internal one), or the client organisation."},"internal":{"type":"boolean","description":"True only for the MSSP’s own internal tenant."},"active":{"type":"boolean","description":"Whether the tenant is enabled. A deactivated tenant is listed but cannot be read."},"licence_status":{"type":"string","description":"Open vocabulary: `in_force` (licence in force), `grace` (grace period: reads are served), `inactive` (none, suspended, revoked or expired: data operations answer 403 `LICENCE_INACTIVE`). Treat an unknown value as \"may not be readable\". A tenant is readable when `active` is true and `licence_status` is not `inactive`.","x-hs-known-values":["in_force","grace","inactive"]}},"required":["id","name","slug","organization","internal","active","licence_status"],"additionalProperties":false,"description":"A client tenant an MSSP portfolio key may select."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"portfolioKey":[]}],"x-hs-scope":{"scopes":["portfolio:read"]}}}},"components":{"schemas":{"Error":{"type":"object","required":["error"],"additionalProperties":false,"properties":{"error":{"type":"object","required":["code","message","request_id"],"properties":{"code":{"type":"string","enum":["invalid_request","unauthorized","forbidden","not_found","method_not_allowed","rate_limited","internal_error","service_unavailable"],"description":"Closed vocabulary; new members may be added (see Versioning)."},"message":{"type":"string","description":"For a human reading a log. Not stable; do not parse it."},"request_id":{"type":"string","description":"Also the `X-Request-Id` response header."},"details":{"type":"object","additionalProperties":true,"description":"Diagnostic, except `code` and the keys the operation’s error descriptions name, which are contract.","properties":{"code":{"type":"string","description":"OPEN vocabulary (a value keeps its meaning and its HTTP status; new values may be added): `TENANT_HEADER_INVALID`, `TENANT_REQUIRED`, `TENANT_SELECTOR_NOT_SUPPORTED` (400), `INSUFFICIENT_SCOPE`, `LICENCE_INACTIVE` (403), `TENANT_NOT_FOUND` (404).","x-hs-known-values":["TENANT_HEADER_INVALID","TENANT_REQUIRED","TENANT_SELECTOR_NOT_SUPPORTED","INSUFFICIENT_SCOPE","LICENCE_INACTIVE","TENANT_NOT_FOUND"]}}}}}}}},"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"A tenant API key, created in the console under Settings → API keys with the minimum scopes it needs."},"portfolioKey":{"type":"http","scheme":"bearer","description":"An MSSP portfolio key (`hsp_…`), created by an organisation administrator of an active managed security service provider in the console’s MSSP area (API keys). It lists the provider’s client tenants (`portfolio:read`) and reads ONE of them per request, selected with `X-HS-Tenant`, with the same data scopes a tenant API key uses. Read-only; mandatory expiry (default 90 days, at most 365); optional client subset and source IP ranges. Refused with 401 everywhere but `/v1`."},"oauth2":{"type":"oauth2","description":"A USER access token (`hs_at_…`) issued by the HodeiShield console, the OAuth 2.1 authorization server at `https://app.hodeishield.com/api/auth` (metadata: `/.well-known/oauth-authorization-server` on its origin). Authorization code with PKCE (S256, mandatory) for applications with a browser; the RFC 8628 device authorization grant (`https://app.hodeishield.com/api/auth/device/code`) for those without one. Every token is bound to ONE user and ONE tenant, chosen by the user on the consent screen, and carries read scopes only; the tenant is re-checked against what the user can see on every request. Public clients register themselves (RFC 7591) at `/oauth2/register`; the HodeiShield CLI is pre-registered as `hodeishield-cli`. Revocation: RFC 7009 at `/oauth2/revoke`, or by the user in the console under Account → Application access.","flows":{"authorizationCode":{"authorizationUrl":"https://app.hodeishield.com/api/auth/oauth2/authorize","tokenUrl":"https://app.hodeishield.com/api/auth/oauth2/token","refreshUrl":"https://app.hodeishield.com/api/auth/oauth2/token","scopes":{"supply_risk:read":"Read supply_risk data of the tenant the token is bound to.","compliance.nis2:read":"Read compliance.nis2 data of the tenant the token is bound to.","compliance.ens:read":"Read compliance.ens data of the tenant the token is bound to.","compliance.iso27001:read":"Read compliance.iso27001 data of the tenant the token is bound to.","compliance.dora:read":"Read compliance.dora data of the tenant the token is bound to.","compliance.gdpr:read":"Read compliance.gdpr data of the tenant the token is bound to.","compliance.soc2:read":"Read compliance.soc2 data of the tenant the token is bound to.","compliance.iso42001:read":"Read compliance.iso42001 data of the tenant the token is bound to.","compliance.nistcsf:read":"Read compliance.nistcsf data of the tenant the token is bound to.","compliance.pcidss:read":"Read compliance.pcidss data of the tenant the token is bound to.","compliance.euaiact:read":"Read compliance.euaiact data of the tenant the token is bound to.","compliance.hipaa:read":"Read compliance.hipaa data of the tenant the token is bound to.","compliance.cmmc:read":"Read compliance.cmmc data of the tenant the token is bound to.","compliance.cyberessentials:read":"Read compliance.cyberessentials data of the tenant the token is bound to.","compliance.uaeia:read":"Read compliance.uaeia data of the tenant the token is bound to.","compliance.sama:read":"Read compliance.sama data of the tenant the token is bound to.","compliance.saudiecc:read":"Read compliance.saudiecc data of the tenant the token is bound to.","compliance.ndpa:read":"Read compliance.ndpa data of the tenant the token is bound to.","radar:read":"Read radar data of the tenant the token is bound to.","connectors:read":"Read connectors data of the tenant the token is bound to.","endpoints:read":"Read endpoints data of the tenant the token is bound to.","hodeiai:read":"Read hodeiai data of the tenant the token is bound to.","virtual_ciso:read":"Read virtual_ciso data of the tenant the token is bound to.","simulator:read":"Read simulator data of the tenant the token is bound to.","evidence:read":"Read evidence data of the tenant the token is bound to.","reports:read":"Read reports data of the tenant the token is bound to.","ai_governance:read":"Read ai_governance data of the tenant the token is bound to.","management:read":"Read management data of the tenant the token is bound to.","offline_access":"Issue a refresh token (30 days, rotated on every use)."}}}}}},"security":[{"bearerAuth":[]},{"oauth2":[]},{"portfolioKey":[]}]} \ No newline at end of file diff --git a/xtask/src/main.rs b/xtask/src/main.rs index a6304ea..b91e5c5 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -13,7 +13,8 @@ //! //! Rules it applies: //! - Response objects become structs that ignore unknown fields, so a newer `/v1` (which only adds) -//! does not break an older client. +//! does not break an older client. An object declared open (`additionalProperties: true`) stays +//! a JSON map instead, so none of its keys is lost. //! - Enums in responses stay `String`: the contract says vocabularies may grow. //! - Enums in query parameters become Rust enums: the client only sends what the document lists. //! - Two schemas with the same shape become one type; two different shapes wanting the same name @@ -31,7 +32,7 @@ use std::process::{Command, ExitCode, Stdio}; const SPEC: &str = "openapi/v1.json"; const OUT: &str = "crates/hodeishield-api/src/generated.rs"; /// SHA-256 of `openapi/v1.json`. Updating the document means updating this and `openapi/README.md`. -const EXPECTED_SHA256: &str = "103e86f38f7b3eff45a0f764c8142b4bdd2e8d576385b1b4ccf588cbf0518fb3"; +const EXPECTED_SHA256: &str = "1321a417aba28e7b60dbc28a35ce2dc3711d8973b985107f7701d70ff3be6b0d"; /// Type names that the default naming rules would get wrong, keyed by the schema's location. const RENAMES: &[(&str, &str)] = &[ @@ -176,6 +177,10 @@ struct Param { is_enum: bool, /// The values `x-hs-known-values` lists, in document order; empty without the extension. known_values: Vec, + /// The values `x-hs-deprecated-values` lists, in document order; empty without the extension. + deprecated_values: Vec, + /// `x-hs-deprecated-values-sunset`: the last day the deprecated values are accepted. + sunset: Option, doc: Option, } @@ -188,6 +193,8 @@ struct OperationDef { path_params: Vec, query_params: Vec, response: String, + /// Whether the operation takes the `X-HS-Tenant` header that selects a client tenant. + tenant_scoped: bool, } struct Generator<'a> { @@ -378,6 +385,7 @@ impl<'a> Generator<'a> { let mut path_params = Vec::new(); let mut query_params = Vec::new(); + let mut tenant_scoped = false; for param in operation .get("parameters") .and_then(Value::as_array) @@ -389,6 +397,12 @@ impl<'a> Generator<'a> { .and_then(Value::as_str) .ok_or_else(|| format!("{id}: parameter without name"))?; let location = param.get("in").and_then(Value::as_str).unwrap_or_default(); + // The tenant selection is not an argument of the generated function: the client adds it + // to every request of an operation that takes it (see `ClientBuilder::tenant`). + if location == "header" && name.eq_ignore_ascii_case("X-HS-Tenant") { + tenant_scoped = true; + continue; + } let required = param .get("required") .and_then(Value::as_bool) @@ -414,6 +428,8 @@ impl<'a> Generator<'a> { required, is_enum, known_values: known_values(&id, name, param)?, + deprecated_values: deprecated_values(&id, name, param)?, + sunset: sunset(&id, name, param)?, doc: param .get("description") .and_then(Value::as_str) @@ -448,6 +464,7 @@ impl<'a> Generator<'a> { path_params, query_params, response, + tenant_scoped, }); Ok(()) } @@ -514,6 +531,11 @@ impl<'a> Generator<'a> { } fn object_type(&mut self, schema: &Value, hint: &str, location: &str) -> Result { + // An object declared open keeps every key it is sent: a struct would drop the ones it does + // not list. Its listed properties are documented, not typed. + if schema.get("additionalProperties") == Some(&Value::Bool(true)) { + return Ok("serde_json::Map".to_owned()); + } let Some(properties) = schema.get("properties").and_then(Value::as_object) else { return match schema.get("additionalProperties") { Some(additional @ Value::Object(_)) => { @@ -756,9 +778,27 @@ impl<'a> Generator<'a> { format!("({:?}, &[{}])", p.json_name, values.join(", ")) }) .collect(); + let deprecated: Vec = op + .query_params + .iter() + .filter(|p| !p.deprecated_values.is_empty()) + .map(|p| { + let values: Vec = p + .deprecated_values + .iter() + .map(|v| format!("{v:?}")) + .collect(); + format!( + "crate::DeprecatedValues {{ param: {:?}, values: &[{}], sunset: {:?} }}", + p.json_name, + values.join(", "), + p.sunset + ) + }) + .collect(); let _ = writeln!( out, - " /// `GET {}`: {}.\n pub const {}: Operation = Operation {{ id: {:?}, method: \"GET\", path: {:?}, summary: {:?}, scopes: &[{}], known_values: &[{}], tenant_scoped: {} }};\n", + " /// `GET {}`: {}.\n pub const {}: Operation = Operation {{ id: {:?}, method: \"GET\", path: {:?}, summary: {:?}, scopes: &[{}], known_values: &[{}], deprecated_values: &[{}], tenant_scoped: {} }};\n", op.path, op.summary, screaming(&op.id), @@ -767,9 +807,8 @@ impl<'a> Generator<'a> { op.summary, scopes.join(", "), known.join(", "), - // A client tenant is selected on the data operations only; anything under the - // portfolio path (the tenant listing) is outside the selection. - !op.path.starts_with("/v1/portfolio/") + deprecated.join(", "), + op.tenant_scoped ); } let all: Vec = self.operations.iter().map(|op| screaming(&op.id)).collect(); @@ -835,10 +874,19 @@ fn render_doc(out: &mut String, doc: Option<&str>, indent: &str) { } } -/// The vocabulary `x-hs-known-values` publishes for a query parameter, in document order. The -/// extension is optional; when present it must be an array of strings, anything else is an error. -fn known_values(operation: &str, name: &str, param: &Value) -> Result> { - let Some(list) = param.get("x-hs-known-values") else { +/// The extension `key` of a parameter: on its `schema`, where the document puts it, or else on the +/// parameter object itself. +fn extension<'v>(param: &'v Value, key: &str) -> Option<&'v Value> { + param + .get("schema") + .and_then(|schema| schema.get(key)) + .or_else(|| param.get(key)) +} + +/// The extension `key` of a parameter as a list of strings, in document order; empty when it is +/// absent. When present it must be an array of strings, anything else is an error. +fn string_list(operation: &str, name: &str, param: &Value, key: &str) -> Result> { + let Some(list) = extension(param, key) else { return Ok(Vec::new()); }; list.as_array() @@ -848,7 +896,43 @@ fn known_values(operation: &str, name: &str, param: &Value) -> Result>>() }) - .ok_or_else(|| format!("{operation}.{name}: x-hs-known-values must be an array of strings")) + .ok_or_else(|| format!("{operation}.{name}: {key} must be an array of strings")) +} + +/// The vocabulary `x-hs-known-values` publishes for a query parameter, in document order. +fn known_values(operation: &str, name: &str, param: &Value) -> Result> { + string_list(operation, name, param, "x-hs-known-values") +} + +/// The values `x-hs-deprecated-values` lists for a query parameter, in document order: still +/// accepted by the API, but on their way out. +fn deprecated_values(operation: &str, name: &str, param: &Value) -> Result> { + string_list(operation, name, param, "x-hs-deprecated-values") +} + +/// `x-hs-deprecated-values-sunset`: the last day the deprecated values are accepted. Optional; when +/// present it must be a `YYYY-MM-DD` date and the parameter must list deprecated values. +fn sunset(operation: &str, name: &str, param: &Value) -> Result> { + let key = "x-hs-deprecated-values-sunset"; + let Some(value) = extension(param, key) else { + return Ok(None); + }; + let date = value + .as_str() + .filter(|date| { + date.len() == 10 + && date.bytes().enumerate().all(|(i, b)| match i { + 4 | 7 => b == b'-', + _ => b.is_ascii_digit(), + }) + }) + .ok_or_else(|| format!("{operation}.{name}: {key} must be a YYYY-MM-DD date"))?; + if deprecated_values(operation, name, param)?.is_empty() { + return Err(format!( + "{operation}.{name}: {key} without x-hs-deprecated-values" + )); + } + Ok(Some(date.to_owned())) } fn operation_id(operation: &Value) -> Result<&str> { @@ -1001,6 +1085,17 @@ mod tests { if let Some(extension) = extension { severity["x-hs-known-values"] = extension; } + document_with(severity, &[]) + } + + /// The same document with the `severity` parameter given whole, and `headers` added after the + /// query parameters. + fn document_with(severity: Value, headers: &[Value]) -> Value { + let mut parameters = vec![ + severity, + serde_json::json!({"name": "page", "in": "query", "schema": {"type": "string"}}), + ]; + parameters.extend(headers.iter().cloned()); serde_json::json!({ "info": {"version": "test"}, "servers": [{"url": "https://api.example.test"}], @@ -1009,7 +1104,7 @@ mod tests { "operationId": "listThings", "summary": "List things", "x-hs-scope": {"scopes": ["things:read"]}, - "parameters": [severity, {"name": "page", "in": "query", "schema": {"type": "string"}}], + "parameters": parameters, "responses": {"200": {"content": {"application/json": {"schema": { "type": "object", "properties": {"ok": {"type": "boolean"}}, @@ -1020,12 +1115,24 @@ mod tests { } fn generate(extension: Option) -> Result { - let spec = document(extension); - Generator::new(&spec) + generate_from(&document(extension)) + } + + fn generate_from(spec: &Value) -> Result { + Generator::new(spec) .run() .map(|out| out.split_whitespace().collect::>().join(" ")) } + /// A `severity` parameter whose schema carries `extensions`, as the published document does. + fn severity_schema(extensions: Value) -> Value { + let mut schema = serde_json::json!({"type": "string"}); + for (key, value) in extensions.as_object().expect("object") { + schema[key] = value.clone(); + } + serde_json::json!({"name": "severity", "in": "query", "schema": schema}) + } + #[test] fn known_values_are_emitted_in_document_order() { let out = @@ -1064,4 +1171,159 @@ mod tests { ); } } + + #[test] + fn known_values_are_read_from_the_parameter_schema() { + let spec = document_with( + severity_schema(serde_json::json!({"x-hs-known-values": ["critical", "high"]})), + &[], + ); + let out = generate_from(&spec).expect("generates"); + assert!( + out.contains(r#"known_values: &[("severity", &["critical", "high"])]"#), + "{out}" + ); + let spec = document_with( + severity_schema(serde_json::json!({"x-hs-known-values": "critical"})), + &[], + ); + assert_eq!( + generate_from(&spec).expect_err("malformed"), + "listThings.severity: x-hs-known-values must be an array of strings" + ); + } + + #[test] + fn deprecated_values_and_their_sunset_are_emitted_in_document_order() { + let spec = document_with( + severity_schema(serde_json::json!({ + "x-hs-known-values": ["critical", "high"], + "x-hs-deprecated-values": ["critico", "alto"], + "x-hs-deprecated-values-sunset": "2027-01-14" + })), + &[], + ); + let out = generate_from(&spec).expect("generates"); + assert!( + out.contains(concat!( + r#"deprecated_values: &[crate::DeprecatedValues { param: "severity", "#, + r#"values: &["critico", "alto"], sunset: Some("2027-01-14") }]"# + )), + "{out}" + ); + let spec = document_with( + severity_schema(serde_json::json!({"x-hs-deprecated-values": ["alto"]})), + &[], + ); + let out = generate_from(&spec).expect("generates"); + assert!(out.contains("values: &[\"alto\"], sunset: None"), "{out}"); + } + + #[test] + fn a_parameter_without_deprecated_values_has_none() { + let out = generate(Some(serde_json::json!(["critical"]))).expect("generates"); + assert!(out.contains("deprecated_values: &[]"), "{out}"); + let spec = document_with( + severity_schema(serde_json::json!({"x-hs-deprecated-values": []})), + &[], + ); + let out = generate_from(&spec).expect("generates"); + assert!(out.contains("deprecated_values: &[]"), "{out}"); + } + + #[test] + fn malformed_deprecated_values_are_a_generator_error() { + for (extensions, expected) in [ + ( + serde_json::json!({"x-hs-deprecated-values": "alto"}), + "listThings.severity: x-hs-deprecated-values must be an array of strings", + ), + ( + serde_json::json!({"x-hs-deprecated-values": ["alto", 1]}), + "listThings.severity: x-hs-deprecated-values must be an array of strings", + ), + ( + serde_json::json!({ + "x-hs-deprecated-values": ["alto"], + "x-hs-deprecated-values-sunset": "14/01/2027" + }), + "listThings.severity: x-hs-deprecated-values-sunset must be a YYYY-MM-DD date", + ), + ( + serde_json::json!({ + "x-hs-deprecated-values": ["alto"], + "x-hs-deprecated-values-sunset": 20_270_114 + }), + "listThings.severity: x-hs-deprecated-values-sunset must be a YYYY-MM-DD date", + ), + ( + serde_json::json!({"x-hs-deprecated-values-sunset": "2027-01-14"}), + "listThings.severity: x-hs-deprecated-values-sunset without \ + x-hs-deprecated-values", + ), + ( + serde_json::json!({ + "x-hs-deprecated-values": [], + "x-hs-deprecated-values-sunset": "2027-01-14" + }), + "listThings.severity: x-hs-deprecated-values-sunset without \ + x-hs-deprecated-values", + ), + ] { + let spec = document_with(severity_schema(extensions.clone()), &[]); + let err = generate_from(&spec).expect_err("malformed"); + assert_eq!(err, expected, "{extensions}"); + } + } + + #[test] + fn the_tenant_header_marks_the_operation_tenant_scoped() { + let severity = + || serde_json::json!({"name": "severity", "in": "query", "schema": {"type": "string"}}); + for name in ["X-HS-Tenant", "x-hs-tenant"] { + let header = serde_json::json!({ + "name": name, "in": "header", "required": false, + "schema": {"type": "string", "format": "uuid"} + }); + let out = generate_from(&document_with(severity(), &[header])).expect("generates"); + assert!(out.contains("tenant_scoped: true"), "{out}"); + // The header is not an argument of the generated function. + assert!(!out.to_lowercase().contains("x_hs_tenant"), "{out}"); + } + let out = generate_from(&document_with(severity(), &[])).expect("generates"); + assert!(out.contains("tenant_scoped: false"), "{out}"); + } + + #[test] + fn an_open_object_stays_a_map_even_when_it_lists_properties() { + let mut spec = document(None); + let response = "/paths/~1v1~1things/get/responses/200/content/application~1json/schema"; + let properties = &mut spec.pointer_mut(response).expect("response schema")["properties"]; + properties["details"] = serde_json::json!({ + "type": "object", + "additionalProperties": true, + "properties": {"code": {"type": "string"}} + }); + let out = generate_from(&spec).expect("generates"); + assert!( + out.contains("pub details: Option>"), + "{out}" + ); + assert!(!out.contains("pub struct Details"), "{out}"); + } + + #[test] + fn any_other_header_parameter_is_a_generator_error() { + let header = serde_json::json!({ + "name": "X-Other", "in": "header", "schema": {"type": "string"} + }); + let spec = document_with( + serde_json::json!({"name": "severity", "in": "query", "schema": {"type": "string"}}), + &[header], + ); + assert_eq!( + generate_from(&spec).expect_err("unsupported"), + "listThings.X-Other: parameters in `header` are not supported" + ); + } }