diff --git a/docs/development/routing-design.md b/docs/development/routing-design.md index aafc8251..a8c613be 100644 --- a/docs/development/routing-design.md +++ b/docs/development/routing-design.md @@ -70,7 +70,7 @@ state and the remaining validation work. | Surface | Current implementation | Target status | | --- | --- | --- | | Queue concrete route depth | Rejects trailing segments | Aligned | -| Lease concrete route depth | Uses the first three segments and tolerates a trailing suffix | Gap: reject trailing segments | +| Lease concrete route depth | Rejects trailing segments | Aligned | | Stream selector classification | All eight literal/`*` kinds and two aliases | Aligned | | Resource, area, and realm reads | Dedicated offset spaces and visibility frontiers | Aligned | | Global and global-filter reads | Direct family-global pages in assigned commit order | Aligned | diff --git a/src/api/runtime_ingress/domain_frame_dispatcher.rs b/src/api/runtime_ingress/domain_frame_dispatcher.rs index 56978adf..c4d67d8e 100644 --- a/src/api/runtime_ingress/domain_frame_dispatcher.rs +++ b/src/api/runtime_ingress/domain_frame_dispatcher.rs @@ -722,18 +722,18 @@ impl DomainFrameDispatcher<'_> { "RPC request parse failed", ); } - // Domain validation owns Lease observation selector errors: - // SUBSCRIBE/UNSUBSCRIBE return 5010 and LIST returns 5012, - // including on auth-required brokers. Dispatching these - // malformed selectors is safe because the Lease sink compiles - // and rejects them before retaining subscription state or - // inspecting inventory. Other auth-required domains keep the - // fail-closed behavior below. - let lease_observation_validation = matches!( + // Domain validation owns malformed Lease request errors: + // exact operations return 5008, SUBSCRIBE/UNSUBSCRIBE return + // 5010, and LIST returns 5012, including on auth-required + // brokers. Dispatching these malformed routes is safe because + // the Lease decoder rejects them before ownership, retained + // subscription state, or inventory can be touched. Other + // auth-required domains keep the fail-closed behavior below. + let lease_request_validation = matches!( (dispatch.domain, dispatch.msg_type.as_u16()), - (DispatchDomain::Lease, 407 | 408 | 410) + (DispatchDomain::Lease, 400..=403 | 407 | 408 | 410) ); - if lease_observation_validation + if lease_request_validation || (!self.ingress.auth_required && is_subscription_registration_message( dispatch.domain, diff --git a/src/api/runtime_ingress/tests/authorization_routes_lease.rs b/src/api/runtime_ingress/tests/authorization_routes_lease.rs index b4c62b20..16237b59 100644 --- a/src/api/runtime_ingress/tests/authorization_routes_lease.rs +++ b/src/api/runtime_ingress/tests/authorization_routes_lease.rs @@ -105,6 +105,88 @@ async fn malformed_authenticated_lease_observation(msg_type: u16) -> (IngressDec ) } +async fn malformed_authenticated_lease_operation( + msg_type: u16, + payload: Bytes, +) -> (IngressDecision, u16) { + let family = RouteFamily::new(1); + let session_id = 700 + u64::from(msg_type); + let router = Arc::new(crate::runtime::Router::new()); + let lease_sink = Arc::new(crate::domains::lease::sink::LeaseDomainSink::new( + router.clone(), + crate::control::admin::read_model::AdminReadModel::new(), + )); + let inbox_mailbox = Arc::new(Mailbox::new(8)); + router.register_domain_pattern("lease", lease_sink); + router.register( + RouteAddress::new(family, Route::new(format!("inbox://session/{session_id}"))), + inbox_mailbox.clone(), + ); + let ingress = runtime_ingress_with_jwks_auth().with_router(router); + let session = + make_authenticated_session_info(session_id, TransportKind::Tcp, family, &["lease://**#*"]); + ingress.on_open(session).await.unwrap(); + + let decision = ingress + .on_frame( + session_id, + ChannelId::Lease, + MessageType::new(msg_type), + payload, + ) + .await; + let response = tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if !inbox_mailbox.receiver().is_empty() { + break receive_frame(&inbox_mailbox, "typed malformed Lease operation response"); + } + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + } + }) + .await + .expect("typed malformed Lease operation response"); + ( + decision, + decode_domain_error_code(response.payload.as_ref()), + ) +} + +#[tokio::test] +async fn should_return_typed_error_for_authenticated_malformed_lease_operations() { + // Arrange + let malformed = "lease://acme/locks/resource/extra"; + let mut renew = PayloadEncoder::new(); + renew.put_string(malformed); + renew.put_string("owner"); + renew.put_u64(1); + renew.put_u64(30); + let mut release = PayloadEncoder::new(); + release.put_string(malformed); + release.put_string("owner"); + release.put_u64(1); + let mut query = PayloadEncoder::new(); + query.put_string(malformed); + let requests = [ + (400, encode_lease_acquire(malformed, "owner", 30)), + (401, Bytes::from(renew.finish())), + (402, Bytes::from(release.finish())), + (403, Bytes::from(query.finish())), + ]; + + for (msg_type, payload) in requests { + // Act + let (decision, code) = malformed_authenticated_lease_operation(msg_type, payload).await; + + // Assert + assert_eq!(decision, IngressDecision::Accept); + assert_eq!( + code, + crate::protocol::error_codes::lease::ERR_BAD_REQUEST, + "message type {msg_type}" + ); + } +} + #[tokio::test] async fn should_return_typed_error_for_authenticated_malformed_lease_subscribe() { // Arrange diff --git a/src/domains/lease/sink/ingress.rs b/src/domains/lease/sink/ingress.rs index 4d02d6b0..82764494 100644 --- a/src/domains/lease/sink/ingress.rs +++ b/src/domains/lease/sink/ingress.rs @@ -316,7 +316,7 @@ impl LeaseDomainRuntime<'_> { channel: meta.channel, route_family: meta.route_family, }), - None => LeaseResponse::NotFound, + None => Self::error_response("invalid lease route"), } } LeaseMessage::Extend { @@ -332,7 +332,7 @@ impl LeaseDomainRuntime<'_> { *fencing_token, *ttl_secs, ), - None => LeaseResponse::NotFound, + None => Self::error_response("invalid lease route"), }, LeaseMessage::Release { family_id, @@ -345,12 +345,12 @@ impl LeaseDomainRuntime<'_> { scoped_owner_id.expect("release owner must be scoped before dispatch"), *fencing_token, ), - None => LeaseResponse::NotFound, + None => Self::error_response("invalid lease route"), }, LeaseMessage::Query { family_id, route } => { match LeaseKey::from_route(*family_id, route) { Some(key) => self.handle_query(&key), - None => LeaseResponse::NotFound, + None => Self::error_response("invalid lease route"), } } LeaseMessage::List {