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;