Close transition replay loopholes

This commit is contained in:
2026-05-25 15:48:40 -04:00
parent dd9fc3de3a
commit a71a7ce84f
+77 -16
View File
@@ -48,8 +48,7 @@ struct PolicyAppliedPayload {
struct TransitionStartedPayload { struct TransitionStartedPayload {
schema_version: u16, schema_version: u16,
commitment_id: String, commitment_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")] transition_policy_id: String,
transition_policy_id: Option<String>,
reason: String, reason: String,
return_target: String, return_target: String,
expected_duration_secs: u64, expected_duration_secs: u64,
@@ -62,7 +61,7 @@ struct TransitionStartedPayload {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
struct PendingTransitionPolicy { struct PendingTransitionPolicy {
commitment_id: String, commitment_id: String,
policy_id: Option<String>, policy_id: String,
runtime_state: RuntimeState, runtime_state: RuntimeState,
enforcement_level: EnforcementLevel, enforcement_level: EnforcementLevel,
allowed_context: AllowedContext, allowed_context: AllowedContext,
@@ -238,7 +237,7 @@ impl SessionController {
let transition_payload = TransitionStartedPayload { let transition_payload = TransitionStartedPayload {
schema_version: POLICY_SCHEMA_VERSION, schema_version: POLICY_SCHEMA_VERSION,
commitment_id: next_commitment.id.clone(), commitment_id: next_commitment.id.clone(),
transition_policy_id: Some(transition_policy.id.clone()), transition_policy_id: transition_policy.id.clone(),
reason: reason.into().trim().to_string(), reason: reason.into().trim().to_string(),
return_target: return_target.into().trim().to_string(), return_target: return_target.into().trim().to_string(),
expected_duration_secs: expected_duration.as_secs(), expected_duration_secs: expected_duration.as_secs(),
@@ -408,15 +407,9 @@ fn hydrate_session(
.with_context(|| { .with_context(|| {
format!("replay policy application at sequence {}", record.sequence) format!("replay policy application at sequence {}", record.sequence)
})?, })?,
CommitmentState::Paused
if runtime_state == RuntimeState::Transition
&& payload.runtime_state == RuntimeState::Transition =>
{
RuntimeState::Transition
}
_ => { _ => {
return Err(anyhow!( return Err(anyhow!(
"policy requires active or transition commitment at sequence {}", "policy requires active commitment at sequence {}",
record.sequence record.sequence
)); ));
} }
@@ -503,7 +496,7 @@ fn hydrate_session(
} }
let mut commitment = active.clone(); let mut commitment = active.clone();
commitment.state = composite.commitment_state; commitment.state = composite.commitment_state;
commitment.transition_policy_id = payload.transition_policy_id.clone(); commitment.transition_policy_id = Some(payload.transition_policy_id.clone());
pending_transition_policy = Some(PendingTransitionPolicy { pending_transition_policy = Some(PendingTransitionPolicy {
commitment_id: commitment.id.clone(), commitment_id: commitment.id.clone(),
policy_id: payload.transition_policy_id, policy_id: payload.transition_policy_id,
@@ -583,14 +576,12 @@ fn apply_pending_transition_policy(
record.sequence record.sequence
)); ));
} }
if let Some(expected_policy_id) = expected.policy_id.as_ref() { if payload.policy_id != expected.policy_id {
if payload.policy_id != *expected_policy_id {
return Err(anyhow!( return Err(anyhow!(
"transition policy id mismatch at sequence {}", "transition policy id mismatch at sequence {}",
record.sequence record.sequence
)); ));
} }
}
let policy = PolicySnapshot { let policy = PolicySnapshot {
id: payload.policy_id, id: payload.policy_id,
@@ -662,6 +653,9 @@ fn ensure_record_commitment(record: &EventRecord, commitment_id: &str) -> Result
} }
fn validate_transition_started_payload(payload: &TransitionStartedPayload) -> Result<()> { fn validate_transition_started_payload(payload: &TransitionStartedPayload) -> Result<()> {
if payload.transition_policy_id.trim().is_empty() {
return Err(anyhow!("transition policy id is required"));
}
if payload.reason.trim().is_empty() { if payload.reason.trim().is_empty() {
return Err(anyhow!("transition reason is required")); return Err(anyhow!("transition reason is required"));
} }
@@ -748,7 +742,7 @@ mod tests {
serde_json::to_value(TransitionStartedPayload { serde_json::to_value(TransitionStartedPayload {
schema_version: POLICY_SCHEMA_VERSION, schema_version: POLICY_SCHEMA_VERSION,
commitment_id: "commitment-test".to_string(), commitment_id: "commitment-test".to_string(),
transition_policy_id: Some("policy-transition-test".to_string()), transition_policy_id: "policy-transition-test".to_string(),
reason: "Need to inspect failures".to_string(), reason: "Need to inspect failures".to_string(),
return_target: "Return to session controller".to_string(), return_target: "Return to session controller".to_string(),
expected_duration_secs: 300, expected_duration_secs: 300,
@@ -1317,6 +1311,73 @@ mod tests {
fs::remove_file(path).unwrap(); fs::remove_file(path).unwrap();
} }
#[test]
fn rejects_extra_transition_policy_after_valid_transition_pair_on_replay() {
let path = temp_log_path("transition-extra-policy");
let commitment_id = "commitment-test";
append_active_session_with_transition_and_policy_payload(
&path,
transition_started_payload(),
transition_policy_payload(commitment_id),
);
{
let mut payload = transition_policy_payload(commitment_id);
payload["policy_id"] = json!("policy-transition-extra");
let mut log = EventLog::open(&path).unwrap();
log.append(
EventType::PolicyApplied,
RuntimeState::Transition,
Some(commitment_id.to_string()),
payload,
)
.unwrap();
}
let err = SessionController::new(&path)
.expect_err("extra transition policy outside the transition pair must be rejected");
assert!(error_chain_contains(
&err,
"policy requires active commitment"
));
fs::remove_file(path).unwrap();
}
#[test]
fn rejects_transition_started_missing_policy_id_on_replay() {
let path = temp_log_path("transition-missing-policy-id");
let mut payload = transition_started_payload();
payload
.as_object_mut()
.unwrap()
.remove("transition_policy_id");
append_active_session_with_transition_and_policy_payload(
&path,
payload,
transition_policy_payload("commitment-test"),
);
let err = SessionController::new(&path)
.expect_err("transition policy id is required in transition payload");
assert!(error_chain_contains(&err, "transition_policy_id"));
fs::remove_file(path).unwrap();
}
#[test]
fn rejects_transition_started_empty_policy_id_on_replay() {
let path = temp_log_path("transition-empty-policy-id");
let mut payload = transition_started_payload();
payload["transition_policy_id"] = json!("");
append_active_session_with_transition_payload(&path, payload);
let err =
SessionController::new(&path).expect_err("empty transition policy id must be rejected");
assert!(error_chain_contains(&err, "transition policy id"));
fs::remove_file(path).unwrap();
}
#[test] #[test]
fn transition_started_payload_has_stable_schema() { fn transition_started_payload_has_stable_schema() {
let path = temp_log_path("transition-payload"); let path = temp_log_path("transition-payload");