blob: 5f00c588cc1fcbc63ba0e3bd7766a947ef46a389 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! The two pre-consensus request-rewrite chains, side by side.
//!
//! [`tcp_chain`] serves the TCP funnel, [`http_chain`] the HTTP submit. Each
//! step rewrites or validates a request BEFORE consensus, so a rejected
//! request burns no replicated log entry and no plaintext secret enters
//! consensus. The chains enter the PAT rewrite through different functions
//! on purpose: TCP resolves the acting user from the transport
//! `SessionManager` ([`maybe_rewrite_pat_request`]), HTTP authenticates
//! against its own session table and passes the resolved `user_id`
//! ([`rewrite_pat_request_for_user`]).
//!
//! Three more links complete the chains but live in their spines, because
//! they fire partition-read mesh RPCs or are plane-specific. Two are async:
//! the consumer-group Join/Leave enrichment ([`crate::consumer_group`], the
//! TCP funnel calls it after [`tcp_chain`]) and the `DeleteSegments` ->
//! `TruncatePartition` resolution
//! (`dispatch::partition::resolve_delete_segments_truncate`, called by both
//! spines). The third, the consumer-offset rewrite on the partition path
//! (`consumer_group::maybe_rewrite_consumer_offset_request`, called by
//! `dispatch::partition`), is synchronous.
use crate::pat::{maybe_rewrite_pat_request, rewrite_pat_request_for_user};
use crate::segment_cleaner::UNENFORCEABLE_TOPIC_SIZE_WARN;
use crate::session_manager::SessionManager;
use crate::shell::{ShellBus, ShellShard};
use crate::users::maybe_rewrite_user_password_request;
use crate::wire::request_body;
use consensus::MetadataHandle;
use iggy_binary_protocol::requests::partitions::{
CreatePartitionsRequest, DeletePartitionsRequest,
};
use iggy_binary_protocol::requests::streams::{CreateStreamRequest, UpdateStreamRequest};
use iggy_binary_protocol::requests::topics::{CreateTopicRequest, UpdateTopicRequest};
use iggy_binary_protocol::requests::users::{CreateUserRequest, UpdateUserRequest};
use iggy_binary_protocol::{
MAX_PARTITIONS_PER_REQUEST, Operation, PrepareHeader, RoutedRequestHeader, WireDecode,
WireIdentifier, WireOptions,
};
use iggy_common::{
IggyByteSize, IggyError, MaxTopicSize, TopicCreateOptions, UPDATABLE_STREAM_OPTION_KEYS,
UPDATABLE_TOPIC_OPTION_KEYS, UPDATABLE_USER_OPTION_KEYS, validate_preallocated_topic_bytes,
validate_topic_segment_size,
};
use journal::superblock::SuperblockStore;
use journal::{Journal, JournalHandle};
use metadata::impls::metadata::StreamsFrontend;
use metadata::stm::stream::Streams;
use server_common::Message;
use std::cell::RefCell;
use std::rc::Rc;
use tracing::warn;
/// The pre-consensus rewrite stages, in chain order.
///
/// One owner for the set: the funnel's consumer-group rewrite runs outside
/// [`tcp_chain`] but denies through the same path, so it names a variant
/// here instead of passing a bare literal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RewriteStage {
PersonalAccessToken,
UserPassword,
StaticBounds,
ConsumerGroup,
}
impl RewriteStage {
/// The `context` log label, in the same `snake_case` shape as every other
/// frame context.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::PersonalAccessToken => "personal_access_token",
Self::UserPassword => "user_password",
Self::StaticBounds => "static_bounds",
Self::ConsumerGroup => "consumer_group",
}
}
}
/// A staged pre-consensus rejection: `stage` labels the chain step for the
/// deny log line, `error` is the typed code the deny reply carries.
pub struct RewriteDeny {
pub stage: RewriteStage,
pub error: IggyError,
}
/// The TCP funnel's pre-consensus rewrite chain, in order: the PAT rewrite
/// (resolving the acting user from the transport `sessions` binding), the
/// password rewrite, then the static bounds gate. Mirrors [`http_chain`]
/// plus that bounds step, which the binary wire needs because it has no
/// `command.validate()` layer. Returns the rewritten request and the raw
/// PAT token the funnel substitutes into the committed reply; a rejection
/// names the failing stage for the deny log line.
pub fn tcp_chain<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
max_tokens_per_user: u32,
request: Message<RoutedRequestHeader>,
) -> Result<(Message<RoutedRequestHeader>, Option<String>), RewriteDeny>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let (request, raw_pat_token) = maybe_rewrite_pat_request(
sessions,
transport_client_id,
max_tokens_per_user,
|user_id| {
shard
.plane
.metadata()
.mux_stm
.users()
.read(|users| users.pat_count_of(user_id))
},
request,
)
// Token cap reached, malformed body, or a lost session binding.
.map_err(|error| RewriteDeny {
stage: RewriteStage::PersonalAccessToken,
error,
})?;
// Hash raw passwords and, for ChangePassword, verify the current password
// on the primary before replication; see `crate::users`. Replicas store the
// hash directly. A wrong current password is not denied here: it rides
// consensus and applies as a committed InvalidCredentials no-op, so the only
// Err returned is a malformed body.
let request =
maybe_rewrite_user_password_request(shard, request).map_err(|error| RewriteDeny {
stage: RewriteStage::UserPassword,
error,
})?;
static_bounds(shard, &request).map_err(|error| RewriteDeny {
stage: RewriteStage::StaticBounds,
error,
})?;
Ok((request, raw_pat_token))
}
/// The HTTP submit's pre-consensus rewrite chain: the PAT rewrite for the
/// already-authenticated `user_id`, then the password rewrite. Mirrors
/// [`tcp_chain`] minus the session lookup (the HTTP listener authenticates
/// against its own session table and resolves the acting user itself) and
/// minus the static bounds step: HTTP enforces the same bounds in its
/// handlers via `command.validate()` plus the validators below. Returns the
/// rewritten request and the raw PAT token the caller substitutes into the
/// committed reply.
pub fn http_chain<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
user_id: u32,
max_tokens_per_user: u32,
request: Message<RoutedRequestHeader>,
) -> Result<(Message<RoutedRequestHeader>, Option<String>), IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let (request, raw_token) = rewrite_pat_request_for_user(
user_id,
max_tokens_per_user,
|user_id| {
shard
.plane
.metadata()
.mux_stm
.users()
.read(|users| users.pat_count_of(user_id))
},
request,
)?;
let request = maybe_rewrite_user_password_request(shard, request)?;
Ok((request, raw_token))
}
/// Per-request partitions-count cap, shared by create-topic, create-partitions
/// and delete-partitions admission. Runs pre-consensus like
/// [`validate_topic_bounds`]: an oversized count must not burn a replicated
/// log entry (create-partitions admission would also allocate that many
/// consensus-group ids before replicating).
///
/// Zero passes here because a zero-partition TOPIC is legal (legacy
/// `create_topic` admits `0..=MAX`); the add/remove requests reject it in
/// [`validate_partitions_change_count`].
const fn validate_partitions_count(partitions_count: u32) -> Result<(), IggyError> {
// The two transports carry the cap under different names: this one on the
// binary path, `MAX_PARTITIONS_COUNT` in the HTTP DTO validators. The
// parity is a documented contract ([`http_chain`]), so it fails the build
// rather than the next cross-transport test.
const _: () = assert!(
MAX_PARTITIONS_PER_REQUEST == iggy_common::MAX_PARTITIONS_COUNT,
"the binary and HTTP partitions-count caps must stay equal"
);
if partitions_count > MAX_PARTITIONS_PER_REQUEST {
return Err(IggyError::TooManyPartitions);
}
Ok(())
}
/// [`validate_partitions_count`] plus the zero rejection that create-partitions
/// and delete-partitions carry: adding or removing zero partitions is a no-op
/// that would still burn a replicated log entry, bump `Streams::revision` and
/// force every shard through a rebalance pass. Legacy rejects it with
/// `TooManyPartitions` in both handlers (`1..=MAX` on create, `== 0` on
/// delete), so the code matches rather than inventing a new one.
const fn validate_partitions_change_count(partitions_count: u32) -> Result<(), IggyError> {
if partitions_count == 0 {
return Err(IggyError::TooManyPartitions);
}
validate_partitions_count(partitions_count)
}
/// Static create-topic bounds shared by the TCP and HTTP ingresses. Runs
/// pre-consensus: a rejected request must not burn a replicated log entry,
/// and `prepare_request` errors evict the session instead of denying typed.
/// `ServerDefault` is exempt from the size floor (it resolves against server
/// config at admission, matching legacy); `Unlimited` passes numerically.
/// `segment_size_bytes` is the topic's RESOLVED segment size (explicit
/// option, else this node's default), so a per-topic segment above the
/// global default still floors the topic cap.
pub fn validate_topic_bounds(
partitions_count: u32,
max_topic_size: MaxTopicSize,
segment_size_bytes: u64,
) -> Result<(), IggyError> {
validate_partitions_count(partitions_count)?;
validate_topic_size_floor(max_topic_size, segment_size_bytes)
}
/// A topic cap below one segment can never be enforced: the first segment
/// already exceeds it. Split out of [`validate_topic_bounds`] because update
/// admission checks the cap without a partitions count to check.
pub fn validate_topic_size_floor(
max_topic_size: MaxTopicSize,
segment_size_bytes: u64,
) -> Result<(), IggyError> {
if !matches!(max_topic_size, MaxTopicSize::ServerDefault)
&& max_topic_size.as_bytes_u64() < segment_size_bytes
{
return Err(IggyError::InvalidTopicSize(
max_topic_size,
IggyByteSize::from(segment_size_bytes),
));
}
Ok(())
}
/// Announce an accepted `max_topic_size` the server cannot enforce as written.
///
/// [`validate_topic_size_floor`] admits any cap of one segment or more, but
/// retention runs PER PARTITION and floors each partition's share at one SEALED
/// segment, which reaches up to one maximum bus frame past `segment_size`. A cap
/// between the two is stored and echoed back verbatim while the server actually
/// keeps `(segment_size + max_message_size) * partitions_count`, so the only
/// moment an operator can be told is the one where they set it.
///
/// Warns rather than rejects: which caps are accepted is client-visible wire
/// behavior, and tightening it would break topics that already exist.
pub fn warn_unenforceable_topic_size(
max_topic_size: MaxTopicSize,
segment_size_bytes: u64,
max_message_size_bytes: usize,
partitions_count: u32,
) {
let MaxTopicSize::Custom(configured) = max_topic_size else {
return;
};
let max_message_size_bytes = u64::try_from(max_message_size_bytes).unwrap_or(u64::MAX);
let per_partition_floor = segment_size_bytes.saturating_add(max_message_size_bytes);
let topic_floor = per_partition_floor.saturating_mul(u64::from(partitions_count));
if configured.as_bytes_u64() >= topic_floor {
return;
}
warn!(
max_topic_size = configured.as_bytes_u64(),
partitions_count,
segment_size = segment_size_bytes,
enforced_per_partition = per_partition_floor,
"{UNENFORCEABLE_TOPIC_SIZE_WARN}"
);
}
/// Announce the same unenforceable cap when partitions are ADDED to a topic.
///
/// The cap is topic-wide but enforcement is per partition, so every added
/// partition shrinks the share: a cap that cleared the floor when the topic was
/// created can stop clearing it here. The request carries only the delta, so
/// the stored cap, segment size and current partition count come from metadata.
pub fn warn_unenforceable_topic_size_on_partition_add(
streams: &Streams,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
max_message_size_bytes: usize,
added_partitions_count: u32,
) {
let Some(((stream_slab, topic_slab), _)) = streams.partition_count_context(stream_id, topic_id)
else {
return;
};
let Some((_, max_topic_size, partitions_count, segment_size)) =
streams.topic_retention_config(stream_slab, topic_slab)
else {
return;
};
warn_unenforceable_topic_size(
max_topic_size,
segment_size.map_or(iggy_common::DEFAULT_SEGMENT_SIZE, |segment_size| {
segment_size.as_bytes_u64()
}),
max_message_size_bytes,
u32::try_from(partitions_count)
.unwrap_or(u32::MAX)
.saturating_add(added_partitions_count),
);
}
/// Reject option keys outside the resource's catalog, pre-consensus. Unknown
/// keys are rejected rather than skipped: a silently ignored knob would hand
/// the client server defaults without it ever learning. Streams and users
/// have no catalog keys yet, so `known` is empty for both until one lands.
pub fn validate_option_keys(options: &WireOptions, known: &[&str]) -> Result<(), IggyError> {
for entry in options {
// Wire validation already enforced UTF-8 string keys.
let key = String::from_utf8_lossy(entry.key);
if !known.contains(&key.as_ref()) {
return Err(IggyError::UnsupportedOptionKey(key.into_owned()));
}
}
Ok(())
}
/// Static bounds run pre-consensus so a rejected request burns no
/// replicated log entry; HTTP covers the same bounds via
/// `command.validate()`. A body that fails to decode denies typed too
/// (`InvalidCommand`), instead of riding consensus just to fail there.
#[allow(clippy::too_many_lines)]
fn static_bounds<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: &Message<RoutedRequestHeader>,
) -> Result<(), IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
match request.header().operation {
Operation::CreateTopic => CreateTopicRequest::decode_from(request_body(request))
.map_err(|_| IggyError::InvalidCommand)
.and_then(|create_topic| {
// `parse` doubles as the catalog gate: an unknown key or a
// malformed value denies typed here, pre-consensus.
let options = TopicCreateOptions::parse(&create_topic.options)?;
if let Some(segment_size) = options.segment_size {
validate_topic_segment_size(
segment_size.as_bytes_u64(),
iggy_common::MAX_TOPIC_SEGMENT_SIZE,
)?;
}
let segment_size = options.segment_size.map_or_else(
|| iggy_common::DEFAULT_SEGMENT_SIZE,
|segment_size| segment_size.as_bytes_u64(),
);
if options
.preallocate_segments
.unwrap_or(iggy_common::DEFAULT_PREALLOCATE_SEGMENTS)
{
validate_preallocated_topic_bytes(segment_size, create_topic.partitions_count)?;
}
let max_topic_size = options
.max_topic_size
.unwrap_or(MaxTopicSize::ServerDefault);
validate_topic_bounds(create_topic.partitions_count, max_topic_size, segment_size)?;
warn_unenforceable_topic_size(
max_topic_size,
segment_size,
shard.bus_max_message_size(),
create_topic.partitions_count,
);
Ok(())
}),
Operation::CreatePartitions => CreatePartitionsRequest::decode_from(request_body(request))
.map_err(|_| IggyError::InvalidCommand)
.and_then(|create_partitions| {
validate_partitions_change_count(create_partitions.partitions_count)?;
let metadata = shard.plane.metadata();
warn_unenforceable_topic_size_on_partition_add(
metadata.mux_stm.streams(),
&create_partitions.stream_id,
&create_partitions.topic_id,
shard.bus_max_message_size(),
create_partitions.partitions_count,
);
Ok(())
}),
Operation::DeletePartitions => DeletePartitionsRequest::decode_from(request_body(request))
.map_err(|_| IggyError::InvalidCommand)
.and_then(|delete_partitions| {
validate_partitions_change_count(delete_partitions.partitions_count)
}),
// Only the updatable subset: the create-time knobs are pushed to
// partitions when the topic is built and nothing re-pushes them, so
// accepting one here would store a value no partition ever sees.
Operation::UpdateTopic => UpdateTopicRequest::decode_from(request_body(request))
.map_err(|_| IggyError::InvalidCommand)
.and_then(|update_topic| {
validate_option_keys(&update_topic.options, UPDATABLE_TOPIC_OPTION_KEYS)?;
let options = TopicCreateOptions::parse(&update_topic.options)?;
let Some(max_topic_size) = options.max_topic_size else {
return Ok(());
};
// An update can lower the cap below one segment just as a
// create can, and the stored map would then report a size the
// topic can never enforce. The floor is this topic's own
// segment size, since that key is create-only.
let metadata = shard.plane.metadata();
let streams = metadata.mux_stm.streams();
let segment_size = streams
.topic_segment_size(&update_topic.stream_id, &update_topic.topic_id)
.map_or_else(
|| iggy_common::DEFAULT_SEGMENT_SIZE,
|segment_size| segment_size.as_bytes_u64(),
);
validate_topic_size_floor(max_topic_size, segment_size)?;
let partitions_count = streams
.topic_partitions_count(&update_topic.stream_id, &update_topic.topic_id)
.unwrap_or(0);
warn_unenforceable_topic_size(
max_topic_size,
segment_size,
shard.bus_max_message_size(),
u32::try_from(partitions_count).unwrap_or(u32::MAX),
);
Ok(())
}),
Operation::UpdateStream => UpdateStreamRequest::decode_from(request_body(request))
.map_err(|_| IggyError::InvalidCommand)
.and_then(|update_stream| {
validate_option_keys(&update_stream.options, UPDATABLE_STREAM_OPTION_KEYS)
}),
Operation::UpdateUser => UpdateUserRequest::decode_from(request_body(request))
.map_err(|_| IggyError::InvalidCommand)
.and_then(|update_user| {
validate_option_keys(&update_user.options, UPDATABLE_USER_OPTION_KEYS)
}),
Operation::CreateStream => CreateStreamRequest::decode_from(request_body(request))
.map_err(|_| IggyError::InvalidCommand)
.and_then(|create_stream| validate_option_keys(&create_stream.options, &[])),
Operation::CreateUser => CreateUserRequest::decode_from(request_body(request))
.map_err(|_| IggyError::InvalidCommand)
.and_then(|create_user| validate_option_keys(&create_user.options, &[])),
_ => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_topic_bounds_deny_pre_consensus() {
let segment_size = iggy_common::DEFAULT_SEGMENT_SIZE;
assert!(segment_size > 0, "default segment size must be nonzero");
assert!(
validate_topic_bounds(
MAX_PARTITIONS_PER_REQUEST,
MaxTopicSize::ServerDefault,
segment_size
)
.is_ok(),
"the partition cap itself is admissible"
);
assert!(
matches!(
validate_topic_bounds(
MAX_PARTITIONS_PER_REQUEST + 1,
MaxTopicSize::ServerDefault,
segment_size
),
Err(IggyError::TooManyPartitions)
),
"one past the partition cap must deny"
);
// ServerDefault is numerically 0 yet exempt from the segment-size
// floor: it resolves against server config, matching legacy.
assert!(validate_topic_bounds(1, MaxTopicSize::ServerDefault, segment_size).is_ok());
assert!(validate_topic_bounds(1, MaxTopicSize::Unlimited, segment_size).is_ok());
let below_floor = MaxTopicSize::Custom((segment_size - 1).into());
assert!(
matches!(
validate_topic_bounds(1, below_floor, segment_size),
Err(IggyError::InvalidTopicSize(size, floor))
if size == below_floor && floor == IggyByteSize::from(segment_size)
),
"custom size below the segment size must deny with the bounds"
);
let at_floor = MaxTopicSize::Custom(IggyByteSize::from(segment_size));
assert!(
validate_topic_bounds(1, at_floor, segment_size).is_ok(),
"a topic exactly one segment large is admissible"
);
}
#[test]
fn partitions_count_cap_denies_pre_consensus() {
assert!(
validate_partitions_count(MAX_PARTITIONS_PER_REQUEST).is_ok(),
"the cap itself is admissible"
);
assert!(
matches!(
validate_partitions_count(MAX_PARTITIONS_PER_REQUEST + 1),
Err(IggyError::TooManyPartitions)
),
"one past the cap must deny"
);
// Zero passes the shared cap because a zero-partition TOPIC is legal
// (legacy `create_topic` admits `0..=MAX`).
assert!(validate_partitions_count(0).is_ok());
}
#[test]
fn zero_partitions_change_denies_pre_consensus() {
// Adding or removing zero partitions is a no-op that would still burn
// a replicated log entry and force a rebalance. Legacy rejects it with
// `TooManyPartitions` in both handlers, so the code matches.
assert!(
matches!(
validate_partitions_change_count(0),
Err(IggyError::TooManyPartitions)
),
"adding or removing zero partitions must deny"
);
assert!(validate_partitions_change_count(1).is_ok());
assert!(validate_partitions_change_count(MAX_PARTITIONS_PER_REQUEST).is_ok());
assert!(
matches!(
validate_partitions_change_count(MAX_PARTITIONS_PER_REQUEST + 1),
Err(IggyError::TooManyPartitions)
),
"the cap still applies"
);
}
}