Skip to main content

krabka_guard/
cli.rs

1//! The command line this tool accepts, and the values its arguments resolve to.
2//!
3//! `clap` derives the parser from these types, so the doc comment beside a
4//! field is the help text an operator reads. Two flag groups say more than the
5//! parser can state on its own: `--topic` against `--prefix`, and the three
6//! signing flags that travel together. A resolver turns each group into one
7//! value, and it refuses the combination the parser already rules out rather
8//! than falling back to a default that would mean something else.
9
10use {
11    super::failure::Failure,
12    clap::{Args, Parser, Subcommand, ValueEnum},
13    krabka_protocol::krabka::freeze::{PATTERN_TYPE_LITERAL, PATTERN_TYPE_PREFIXED},
14    krabka_units::{Time, parse},
15    std::path::{Path, PathBuf},
16};
17
18/// The tool's command line.
19///
20/// Shared by the binary and by [`run_from_args`], so both accept exactly the
21/// same flags.
22///
23/// [`run_from_args`]: crate::run_from_args
24#[krabka_macros::bootstrap_cli_fields]
25#[derive(Parser)]
26#[command(
27    name = "krabka-guard",
28    version,
29    about = "Freeze topic writes, lift a freeze, and run the break-glass two-person rule"
30)]
31pub struct Cli {}
32
33/// The two halves of the tool.
34#[derive(Subcommand)]
35pub enum Command {
36    /// Administer the topic write-freeze registry.
37    Freeze {
38        /// What to do to the registry.
39        #[command(subcommand)]
40        command: FreezeCommand,
41    },
42    /// Administer the break-glass two-person rule.
43    #[command(name = "break-glass")]
44    BreakGlass {
45        /// What to do to the proposals.
46        #[command(subcommand)]
47        command: BreakGlassCommand,
48    },
49}
50
51/// The freeze registry subcommands.
52#[derive(Subcommand)]
53pub enum FreezeCommand {
54    /// Freeze a topic or a topic-name prefix.
55    ///
56    /// A freeze is the safe direction, so one command sets it. The broker takes
57    /// an unsigned freeze unless `freeze.require_signature` is on, and an
58    /// unsigned entry is an attestation rather than a proof.
59    Set {
60        /// The scope to freeze.
61        #[command(flatten)]
62        scope: ScopeArgs,
63        /// Free text that says why. The broker keeps it in the metadata log and
64        /// in the audit event.
65        #[arg(long)]
66        reason: String,
67        /// The detached operator signature, if the operator makes one.
68        #[command(flatten)]
69        signing: FreezeSigningArgs,
70    },
71    /// Print the live registry entries.
72    List {
73        /// Only print the entries whose scope is exactly this.
74        #[arg(long)]
75        scope: Option<String>,
76        /// Check every returned entry against operator public keys on this
77        /// machine, rather than taking the broker's word for the registry.
78        #[arg(long, requires = "operator_keys")]
79        verify_signatures: bool,
80        /// A TOML file carrying the `[[operator_keys]]` block, which may be the
81        /// `broker.toml` the cluster runs on.
82        #[arg(long)]
83        operator_keys: Option<PathBuf>,
84    },
85    /// Lift a freeze.
86    ///
87    /// A thaw is the dangerous direction. The broker refuses one that names no
88    /// approved break-glass proposal, and it refuses one that carries no
89    /// signature whatever its configuration says.
90    Clear {
91        /// The scope to thaw. It must name the entry exactly: a freeze on
92        /// `--prefix tenant-a.` is not lifted by naming one topic under it.
93        #[command(flatten)]
94        scope: ScopeArgs,
95        /// The approved break-glass proposal that authorizes the thaw.
96        #[arg(long, value_parser = parse_uuid)]
97        proposal: uuid::Uuid,
98        /// Free text that says why the freeze is lifted. The metadata log and
99        /// the audit event keep it, so the record names the reason as well as
100        /// the person.
101        #[arg(long, default_value = "")]
102        reason: String,
103        /// The detached operator signature. A thaw always needs one.
104        #[command(flatten)]
105        signing: FreezeSigningArgs,
106    },
107}
108
109/// The break-glass subcommands.
110#[derive(Subcommand)]
111pub enum BreakGlassCommand {
112    /// Open a proposal, which a second person then approves.
113    Propose {
114        /// The transition the proposal authorizes.
115        #[arg(long, value_enum)]
116        action: Action,
117        /// What the transition applies to: a topic, a broker id, or a
118        /// partition, depending on the action.
119        #[arg(long)]
120        target: String,
121        /// Free text that says why the transition is needed.
122        #[arg(long)]
123        reason: String,
124        /// How long the proposal stays open. Takes any time unit, so `30m`,
125        /// `1h` and `90s` all work, and a number with no unit is refused. The
126        /// broker caps it at `break_glass.proposal_ttl`. Omit to take that
127        /// value.
128        #[arg(long, value_parser = parse_time)]
129        ttl: Option<Time>,
130    },
131    /// Add one approval to a proposal.
132    ///
133    /// The broker refuses the proposer, a principal outside
134    /// `break_glass.approvers`, and a principal that already approved. Those
135    /// three checks are what make this a two-person rule rather than a
136    /// two-click rule.
137    Approve {
138        /// The proposal to approve.
139        #[arg(long, value_parser = parse_uuid)]
140        proposal: uuid::Uuid,
141        /// The PKCS#8 Ed25519 key that signs the approval. The broker demands
142        /// one for every action in `break_glass.signed_actions`.
143        #[arg(long, requires = "key_id")]
144        sign_with: Option<PathBuf>,
145        /// The operator key that `--sign-with` holds, as `[[operator_keys]]`
146        /// names it.
147        #[arg(long, requires = "sign_with")]
148        key_id: Option<String>,
149    },
150    /// Withdraw a proposal, so nothing can spend it.
151    ///
152    /// A withdraw rides the approve api key with the request's `withdraw` flag
153    /// set. Approve and withdraw both name a proposal that exists and both act
154    /// on it, so they share one request. A propose names no proposal, because a
155    /// propose is what creates one.
156    Withdraw {
157        /// The proposal to withdraw.
158        #[arg(long, value_parser = parse_uuid)]
159        proposal: uuid::Uuid,
160    },
161    /// Print the proposals and their approvals.
162    List {
163        /// Drop the proposals that a transition consumed, that an operator
164        /// withdrew, and that expired.
165        #[arg(long)]
166        pending: bool,
167        /// Print this proposal alone.
168        #[arg(long, value_parser = parse_uuid)]
169        proposal: Option<uuid::Uuid>,
170    },
171}
172
173/// The scope a freeze command acts on.
174///
175/// Exactly one of the two is given. A literal scope names one topic, and a
176/// prefixed scope names every topic whose name starts with it, including one
177/// the cluster creates later. The two words are Kafka's ACL pattern types, so a
178/// namespace freeze uses the vocabulary an operator knows from `kafka-acls`.
179#[derive(Args, Debug, Clone)]
180#[group(required = true, multiple = false)]
181pub struct ScopeArgs {
182    /// One topic, by name.
183    #[arg(long)]
184    pub topic: Option<String>,
185    /// Every topic whose name starts with this prefix.
186    #[arg(long)]
187    pub prefix: Option<String>,
188}
189
190/// The detached operator signature on a freeze or a thaw.
191///
192/// The three flags travel together. The broker binds the signature to the
193/// principal on the connection and to the principal that `[[operator_keys]]`
194/// binds the key to, and both of those have to be the name the signed bytes
195/// carry. Only the operator knows what their listener authenticates them as, so
196/// the tool asks rather than guesses.
197#[derive(Args, Debug, Clone)]
198pub struct FreezeSigningArgs {
199    /// The PKCS#8 Ed25519 key file. It is read here, used here, and never
200    /// sent.
201    #[arg(long, requires_all = ["key_id", "principal"])]
202    pub sign_with: Option<PathBuf>,
203    /// The operator key that `--sign-with` holds, as `[[operator_keys]]` names
204    /// it.
205    #[arg(long, requires = "sign_with")]
206    pub key_id: Option<String>,
207    /// The principal the broker authenticates on this connection, which is the
208    /// author the record carries.
209    #[arg(long, requires = "sign_with")]
210    pub principal: Option<String>,
211}
212
213/// A gated transition that a break-glass proposal authorizes.
214///
215/// The wire values are the values of the broker's own action type. The names
216/// here are the same names `break_glass.signed_actions` and the audit event
217/// use, spelled with dashes as a command line spells a word.
218#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
219pub enum Action {
220    /// Lift a topic write freeze.
221    ThawTopicFreeze,
222    /// Elect a leader that is not in the in-sync replica set.
223    UncleanElectLeaders,
224    /// Recover a partition from a replica that may be behind.
225    UncleanRecovery,
226    /// Remove a broker from the cluster.
227    UnregisterBroker,
228    /// Cancel a partition reassignment that is in flight.
229    CancelReassignment,
230    /// Delete a topic.
231    DeleteTopic,
232    /// Delete records from the head of a partition.
233    DeleteRecords,
234}
235
236impl Action {
237    /// The wire value the private api keys carry for this action.
238    #[must_use]
239    pub fn wire(self) -> i8 {
240        match self {
241            Self::ThawTopicFreeze => 1,
242            Self::UncleanElectLeaders => 2,
243            Self::UncleanRecovery => 3,
244            Self::UnregisterBroker => 4,
245            Self::CancelReassignment => 5,
246            Self::DeleteTopic => 6,
247            Self::DeleteRecords => 7,
248        }
249    }
250}
251
252/// The name of an action wire value, in the spelling every krabka surface uses.
253#[must_use]
254pub fn action_name(wire: i8) -> &'static str {
255    match wire {
256        1 => "thaw_topic_freeze",
257        2 => "unclean_elect_leaders",
258        3 => "unclean_recovery",
259        4 => "unregister_broker",
260        5 => "cancel_reassignment",
261        6 => "delete_topic",
262        7 => "delete_records",
263        _ => "unknown",
264    }
265}
266
267/// The word for a pattern-type wire value.
268#[must_use]
269pub fn pattern_name(pattern_type: i8) -> &'static str {
270    match pattern_type {
271        PATTERN_TYPE_LITERAL => "literal",
272        PATTERN_TYPE_PREFIXED => "prefixed",
273        _ => "unknown",
274    }
275}
276
277/// Parse a time argument.
278///
279/// Delegates to `krabka_units`, so every unit the broker's own configuration
280/// accepts works here too: `ns`, `us`, `ms`, `s`, `m`, `h`, `d` and their long
281/// forms.
282///
283/// A number with no unit is refused, and only `0` is exempt. That is the units
284/// crate's rule and it is the right one here: `--ttl 30` from someone who meant
285/// minutes would otherwise open a proposal for thirty milliseconds.
286fn parse_time(raw: &str) -> Result<Time, String> { parse::time(raw).map_err(|e| e.to_string()) }
287
288/// Parse a proposal id.
289fn parse_uuid(raw: &str) -> Result<uuid::Uuid, String> {
290    uuid::Uuid::parse_str(raw).map_err(|e| format!("{raw:?} is not a proposal id: {e}"))
291}
292
293impl ScopeArgs {
294    /// The scope these arguments name.
295    ///
296    /// # Errors
297    ///
298    /// Returns a refusal when neither or both of `--topic` and `--prefix` are
299    /// given. The parser already rules that out, and this keeps the function
300    /// total rather than defaulting to an empty prefix that would name every
301    /// topic in the cluster.
302    pub(super) fn resolve(&self) -> Result<Scope, Failure> {
303        match (self.topic.as_deref(), self.prefix.as_deref()) {
304            (Some(topic), None) => Ok(Scope {
305                name: topic.to_owned(),
306                pattern_type: PATTERN_TYPE_LITERAL,
307            }),
308            (None, Some(prefix)) => Ok(Scope {
309                name: prefix.to_owned(),
310                pattern_type: PATTERN_TYPE_PREFIXED,
311            }),
312            _ => Err(Failure::Refused(
313                "name exactly one of --topic and --prefix".to_owned(),
314            )),
315        }
316    }
317}
318
319/// One resolved freeze scope.
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub struct Scope {
322    /// A topic name, or a topic-name prefix.
323    pub(super) name: String,
324    /// `3` literal, `4` prefixed.
325    pub(super) pattern_type: i8,
326}
327
328impl FreezeSigningArgs {
329    /// The signing material, when the operator asked for a signature.
330    ///
331    /// # Errors
332    ///
333    /// Returns a refusal when `--sign-with` is given without both of the other
334    /// two. The parser already rules that out, and this keeps the function
335    /// total.
336    pub(super) fn resolve(&self) -> Result<Option<FreezeSigning<'_>>, Failure> {
337        let Some(key_path) = self.sign_with.as_deref() else {
338            return Ok(None);
339        };
340        match (self.key_id.as_deref(), self.principal.as_deref()) {
341            (Some(key_id), Some(principal)) => Ok(Some(FreezeSigning {
342                key_path,
343                key_id,
344                principal,
345            })),
346            _ => Err(Failure::Refused(
347                "--sign-with also needs --key-id and --principal".to_owned(),
348            )),
349        }
350    }
351}
352
353/// The resolved signing material for one freeze or thaw.
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub struct FreezeSigning<'a> {
356    /// The PKCS#8 key file, which never leaves this machine.
357    pub(super) key_path: &'a Path,
358    /// The key id that the request carries.
359    pub(super) key_id: &'a str,
360    /// The author name the signed bytes carry.
361    pub(super) principal: &'a str,
362}
363
364#[cfg(test)]
365mod tests;