Skip to content
30 changes: 30 additions & 0 deletions crates/registry-notary-core/src/config/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,36 @@ use super::{SigningKeyProviderConfig, SigningKeyStatus, StandaloneRegistryNotary
pub const CONFIG_SCHEMA_ID: &str =
"https://id.registrystack.org/schemas/registry-notary/registry-notary.config.schema.json";

/// Schema-only deployment-waiver reference contract shared with posture.
pub(crate) struct DeploymentWaiverReferenceSchema;

impl JsonSchema for DeploymentWaiverReferenceSchema {
fn schema_name() -> Cow<'static, str> {
"DeploymentWaiverReference".into()
}

fn json_schema(_: &mut SchemaGenerator) -> Schema {
registry_platform_ops::deployment_waiver_reference_schema_fragment()
.try_into()
.expect("the shared waiver-reference fragment is a valid JSON Schema")
}
}

/// Schema-only structural deployment-waiver summary contract shared with posture.
pub(crate) struct DeploymentWaiverSummarySchema;

impl JsonSchema for DeploymentWaiverSummarySchema {
fn schema_name() -> Cow<'static, str> {
"DeploymentWaiverSummary".into()
}

fn json_schema(_: &mut SchemaGenerator) -> Schema {
registry_platform_ops::deployment_waiver_summary_schema_fragment()
.try_into()
.expect("the shared waiver-summary fragment is a valid JSON Schema")
}
}

/// Schema-only contract for values parsed by `humantime_serde`.
pub(crate) struct HumantimeDurationSchema;

Expand Down
55 changes: 54 additions & 1 deletion crates/registry-notary-core/src/config/tests/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,8 @@ pub(super) fn deployment_block_round_trips_through_yaml() {
profile: production
waivers:
- finding: notary.openapi.public
reason: synthetic partner integration waiver
reference: OPS-2026-0042
summary: Synthetic partner integration waiver
expires: 2099-09-30
"#,
)
Expand All @@ -403,6 +404,58 @@ waivers:
.expect("production config with waivable waiver validates");
}

#[test]
pub(super) fn deployment_waiver_rejects_legacy_reason_as_unknown() {
let result: Result<crate::deployment::DeploymentConfig, _> = serde_norway::from_str(
r#"
profile: hosted_lab
waivers:
- finding: notary.openapi.public
reference: OPS-2026-0042
reason: legacy waiver text
expires: 2099-09-30
"#,
);
assert!(
result.is_err(),
"the removed reason field must fail strict deserialization"
);
}

#[test]
pub(super) fn deployment_waiver_rejects_missing_reference() {
let result: Result<crate::deployment::DeploymentConfig, _> = serde_norway::from_str(
r#"
profile: hosted_lab
waivers:
- finding: notary.openapi.public
expires: 2099-09-30
"#,
);
assert!(
result.is_err(),
"a waiver without the required reference must fail deserialization"
);
}

#[test]
pub(super) fn deployment_waiver_rejects_explicit_null_summary() {
let result: Result<crate::deployment::DeploymentConfig, _> = serde_norway::from_str(
r#"
profile: hosted_lab
waivers:
- finding: notary.openapi.public
reference: OPS-2026-0042
summary: null
expires: 2099-09-30
"#,
);
assert!(
result.is_err(),
"summary must be a string when present, not null: {result:?}"
);
}

#[test]
pub(super) fn deployment_evidence_block_round_trips_through_yaml() {
let mut config = minimal_config();
Expand Down
151 changes: 132 additions & 19 deletions crates/registry-notary-core/src/deployment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,23 +162,54 @@ impl DeploymentEvidenceConfig {

/// One operator-configured waiver.
///
/// A waiver names exactly one finding id, a free-text reason, and a mandatory
/// expiry date (`YYYY-MM-DD`). Reasons must not contain secrets.
/// A waiver names exactly one finding id, a required operator reference, an
/// optional summary, and a mandatory expiry date (`YYYY-MM-DD`). The shared
/// operations contract validates metadata before it can reach posture or logs.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[serde(deny_unknown_fields, from = "DeploymentWaiverConfigDocument")]
#[schemars(!from)]
pub struct DeploymentWaiverConfig {
pub finding: String,
pub reason: String,
#[schemars(with = "crate::config::schema::DeploymentWaiverReferenceSchema")]
pub reference: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "crate::config::schema::DeploymentWaiverSummarySchema")]
pub summary: Option<String>,
pub expires: String,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct DeploymentWaiverConfigDocument {
finding: String,
reference: String,
#[serde(default)]
summary: registry_platform_ops::OptionalDeploymentWaiverSummary,
expires: String,
}

impl From<DeploymentWaiverConfigDocument> for DeploymentWaiverConfig {
fn from(value: DeploymentWaiverConfigDocument) -> Self {
Self {
finding: value.finding,
reference: value.reference,
summary: value.summary.into(),
expires: value.expires,
}
}
}

/// Errors raised while validating the deployment block at config load.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DeploymentConfigError {
#[error("deployment.waivers[{index}].finding must not be empty")]
EmptyWaiverFinding { index: usize },
#[error("deployment.waivers[{index}].reason must not be empty")]
EmptyWaiverReason { index: usize },
#[error("deployment.waivers[{index}].{field} is invalid: {error}")]
InvalidWaiverMetadata {
index: usize,
field: &'static str,
error: registry_platform_ops::DeploymentWaiverMetadataError,
},
#[error("deployment.waivers[{index}].expires must be a YYYY-MM-DD date")]
InvalidWaiverExpiry { index: usize },
#[error(
Expand All @@ -194,19 +225,25 @@ pub enum DeploymentConfigError {
impl DeploymentConfig {
/// Validate the deployment block at config load.
///
/// This checks waiver shape (non-empty fields, parseable expiry) and the
/// hard rule that `startup_fail` and `readiness_fail` gates can never be
/// waived under the declared profile. An undeclared profile still validates
/// waiver shape here so typos are caught early; startup refusal is handled by
/// gate evaluation.
/// This checks waiver shape (shared metadata contract, parseable expiry)
/// and the hard rule that `startup_fail` and `readiness_fail` gates can
/// never be waived under the declared profile. An undeclared profile still
/// validates waiver shape here so typos are caught early; startup refusal
/// is handled by gate evaluation.
pub fn validate(&self) -> Result<(), DeploymentConfigError> {
for (index, waiver) in self.waivers.iter().enumerate() {
if waiver.finding.trim().is_empty() {
return Err(DeploymentConfigError::EmptyWaiverFinding { index });
}
if waiver.reason.trim().is_empty() {
return Err(DeploymentConfigError::EmptyWaiverReason { index });
}
registry_platform_ops::validate_deployment_waiver_metadata(
&waiver.reference,
waiver.summary.as_deref(),
)
.map_err(|error| DeploymentConfigError::InvalidWaiverMetadata {
index,
field: error.field(),
error,
})?;
if parse_iso_date(&waiver.expires).is_none() {
return Err(DeploymentConfigError::InvalidWaiverExpiry { index });
}
Expand Down Expand Up @@ -461,7 +498,8 @@ pub struct EvaluatedFinding {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EvaluatedWaiver {
pub finding: String,
pub reason: String,
pub reference: String,
pub summary: Option<String>,
pub expires: String,
}

Expand Down Expand Up @@ -518,7 +556,8 @@ pub fn evaluate_gates(
status: DeploymentFindingStatus::Active,
waiver: Some(EvaluatedWaiver {
finding: waiver.finding.clone(),
reason: waiver.reason.clone(),
reference: waiver.reference.clone(),
summary: waiver.summary.clone(),
expires: waiver.expires.clone(),
}),
});
Expand All @@ -536,7 +575,8 @@ pub fn evaluate_gates(
waived_findings.push(waiver);
evaluation.active_waivers.push(EvaluatedWaiver {
finding: waiver.finding.clone(),
reason: waiver.reason.clone(),
reference: waiver.reference.clone(),
summary: waiver.summary.clone(),
expires: waiver.expires.clone(),
});
}
Expand Down Expand Up @@ -568,7 +608,8 @@ pub fn evaluate_gates(
status: DeploymentFindingStatus::Waived,
waiver: Some(EvaluatedWaiver {
finding: waiver.finding.clone(),
reason: waiver.reason.clone(),
reference: waiver.reference.clone(),
summary: waiver.summary.clone(),
expires: waiver.expires.clone(),
}),
});
Expand Down Expand Up @@ -627,7 +668,8 @@ mod tests {
fn waiver(finding: &str, expires: &str) -> DeploymentWaiverConfig {
DeploymentWaiverConfig {
finding: finding.to_string(),
reason: "synthetic test waiver reason".to_string(),
reference: "OPS-TEST-DEPLOYMENT".to_string(),
summary: Some("Synthetic test waiver summary".to_string()),
expires: expires.to_string(),
}
}
Expand Down Expand Up @@ -834,6 +876,77 @@ mod tests {
));
}

#[test]
fn validate_accepts_absent_summary_and_ordinary_metadata() {
let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01");
waiver_config.reference = "OPS-2026:INC_42".to_string();
waiver_config.summary = None;
let config = DeploymentConfig {
profile: Some(DeploymentProfile::HostedLab),
multi_instance: false,
waivers: vec![waiver_config],
evidence: DeploymentEvidenceConfig::default(),
};
config
.validate()
.expect("ordinary reference with absent summary is valid");

let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01");
waiver_config.summary =
Some("Public API catalog approved in the operations ticket".to_string());
let config = DeploymentConfig {
profile: Some(DeploymentProfile::HostedLab),
multi_instance: false,
waivers: vec![waiver_config],
evidence: DeploymentEvidenceConfig::default(),
};
config.validate().expect("ordinary short summary is valid");
}

#[test]
fn validate_rejects_invalid_waiver_metadata_with_field_and_limit() {
let mut cases = Vec::new();
for reference in ["", " OPS-42", "OPS/42"] {
let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01");
waiver_config.reference = reference.to_string();
cases.push((waiver_config, "reference", "128"));
}
let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01");
waiver_config.reference = "x".repeat(129);
cases.push((waiver_config, "reference", "128"));

for summary in [
"",
" summary",
"summary\ncontinued",
"Bearer credential-value",
"Basic credential-value",
"rotated leaked Bearer abcdef",
"-----BEGIN OPENSSH PRIVATE KEY-----",
concat!("-----BEGIN PGP PRIVATE KEY ", "BLOCK-----"),
] {
let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01");
waiver_config.summary = Some(summary.to_string());
cases.push((waiver_config, "summary", "256"));
}
let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01");
waiver_config.summary = Some("x".repeat(257));
cases.push((waiver_config, "summary", "256"));

for (waiver, field, limit) in cases {
let config = DeploymentConfig {
profile: Some(DeploymentProfile::HostedLab),
multi_instance: false,
waivers: vec![waiver],
evidence: DeploymentEvidenceConfig::default(),
};
let error = config.validate().expect_err("invalid metadata rejected");
let rendered = error.to_string();
assert!(rendered.contains(&format!("deployment.waivers[0].{field}")));
assert!(rendered.contains(limit), "limit missing from: {rendered}");
}
}

#[test]
fn validate_rejects_missing_or_malformed_expiry() {
let config = DeploymentConfig {
Expand Down
42 changes: 26 additions & 16 deletions crates/registry-notary-server/src/posture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -558,8 +558,9 @@ fn audit_posture(

/// Render the operator-declared deployment profile, gate findings, and active
/// waivers as the posture `deployment` object. The default tier strips waiver
/// reasons via the allowlist; this object carries them so the restricted tier
/// can surface them to Trust Operations.
/// metadata via the allowlist; this object carries it so the restricted tier
/// can surface the validated reference and optional summary to Trust
/// Operations.
fn deployment_object(gates: &DeploymentGateState) -> Value {
let mut object = Map::new();
if let Some(profile) = gates.profile {
Expand All @@ -574,13 +575,7 @@ fn deployment_object(gates: &DeploymentGateState) -> Value {
entry.insert("severity".to_string(), json!(finding.severity.as_str()));
entry.insert("status".to_string(), json!(finding.status.as_str()));
if let Some(waiver) = &finding.waiver {
entry.insert(
"waiver".to_string(),
json!({
"reason": waiver.reason,
"expires": waiver.expires,
}),
);
entry.insert("waiver".to_string(), evaluated_waiver_metadata_json(waiver));
}
Value::Object(entry)
})
Expand All @@ -589,18 +584,33 @@ fn deployment_object(gates: &DeploymentGateState) -> Value {
let waivers = gates
.active_waivers
.iter()
.map(|waiver| {
json!({
"finding": waiver.finding,
"reason": waiver.reason,
"expires": waiver.expires,
})
})
.map(evaluated_waiver_json)
.collect::<Vec<_>>();
object.insert("waivers".to_string(), Value::Array(waivers));
Value::Object(object)
}

fn evaluated_waiver_metadata_json(
waiver: &registry_notary_core::deployment::EvaluatedWaiver,
) -> Value {
let mut object = Map::new();
object.insert("reference".to_string(), json!(waiver.reference));
if let Some(summary) = &waiver.summary {
object.insert("summary".to_string(), json!(summary));
}
object.insert("expires".to_string(), json!(waiver.expires));
Value::Object(object)
}

fn evaluated_waiver_json(waiver: &registry_notary_core::deployment::EvaluatedWaiver) -> Value {
let mut object = evaluated_waiver_metadata_json(waiver)
.as_object()
.expect("waiver metadata renders as an object")
.clone();
object.insert("finding".to_string(), json!(waiver.finding));
Value::Object(object)
}

/// Render the audit assurance object: eight separate facts so "audit exists"
/// cannot be overclaimed. Protected routes fail closed once a hash secret is
/// configured; the keyed integrity is HMAC whenever that secret is present.
Expand Down
Loading
Loading