Skip to main content

krabka_protocol/krabka/break_glass/
describe.rs

1//! `DescribeBreakGlass`, api key 1019.
2//!
3//! The request reads break-glass proposals. A nil `proposal_id` asks for every
4//! proposal that the controller holds, and `pending_only` drops the proposals
5//! that an action consumed, that an operator withdrew, or that expired.
6//!
7//! Each approval carries the `key_id` and the `signature` that the approver
8//! sent. The response gives them back on purpose, so an operator tool
9//! re-verifies each approval against the operator public keys on its own
10//! machine.
11
12use bytes::{Buf, BufMut};
13
14use crate::{
15    Decode, Encode, ProtocolError, ProtocolRequest, UnknownTaggedFields,
16    krabka::common::{
17        BOOL_LEN, I8_LEN, I16_LEN, I32_LEN, I64_LEN, UUID_LEN, array_len, check_version,
18        decode_array, encode_array, read_unknown_tagged_fields, unknown_tagged_fields_len,
19        write_unknown_tagged_fields,
20    },
21    primitives::{
22        fixed::{
23            get_bool, get_i8, get_i16, get_i32, get_i64, put_bool, put_i8, put_i16, put_i32,
24            put_i64,
25        },
26        string_bytes::{
27            compact_bytes_len, compact_nullable_string_len, compact_string_len,
28            get_compact_bytes_owned, get_compact_nullable_string_owned, get_compact_string_owned,
29            put_compact_bytes, put_compact_nullable_string, put_compact_string,
30        },
31        uuid::{Uuid, get_uuid, put_uuid},
32    },
33};
34
35/// Api key of `DescribeBreakGlass`.
36pub const API_KEY: i16 = 1019;
37/// Lowest version of `DescribeBreakGlass` that this build speaks.
38pub const MIN_VERSION: i16 = 0;
39/// Highest version of `DescribeBreakGlass` that this build speaks.
40pub const MAX_VERSION: i16 = 0;
41/// First version with flexible framing from KIP-482.
42pub const FLEXIBLE_MIN: i16 = 0;
43
44/// Reports whether `version` uses flexible framing from KIP-482.
45///
46/// The request header takes the same framing as the request body, so a caller
47/// that writes the header needs this answer.
48#[inline]
49#[must_use]
50pub fn is_flexible(version: i16) -> bool {
51    version >= FLEXIBLE_MIN
52}
53
54/// Reads break-glass proposals.
55#[derive(Debug, Clone, PartialEq, Eq, Default)]
56pub struct DescribeBreakGlassRequest {
57    /// `true` returns only the proposals that can still authorize an action.
58    pub pending_only: bool,
59    /// The proposal to read. A nil id asks for every proposal.
60    pub proposal_id: Uuid,
61    /// Tagged fields that this build does not know.
62    pub unknown_tagged_fields: UnknownTaggedFields,
63}
64
65impl Encode for DescribeBreakGlassRequest {
66    fn encode<B: BufMut>(&self, buf: &mut B, version: i16) -> Result<(), ProtocolError> {
67        check_version(API_KEY, version, MIN_VERSION, MAX_VERSION)?;
68        put_bool(buf, self.pending_only);
69        put_uuid(buf, self.proposal_id);
70        write_unknown_tagged_fields(buf, &self.unknown_tagged_fields);
71        Ok(())
72    }
73
74    fn encoded_len(&self, _version: i16) -> usize {
75        BOOL_LEN + UUID_LEN + unknown_tagged_fields_len(&self.unknown_tagged_fields)
76    }
77}
78
79impl Decode<'_> for DescribeBreakGlassRequest {
80    fn decode<B: Buf>(buf: &mut B, version: i16) -> Result<Self, ProtocolError> {
81        check_version(API_KEY, version, MIN_VERSION, MAX_VERSION)?;
82        Ok(Self {
83            pending_only: get_bool(buf)?,
84            proposal_id: get_uuid(buf)?,
85            unknown_tagged_fields: read_unknown_tagged_fields(buf)?,
86        })
87    }
88}
89
90impl ProtocolRequest for DescribeBreakGlassRequest {
91    const API_KEY: i16 = API_KEY;
92    const MIN_VERSION: i16 = MIN_VERSION;
93    const MAX_VERSION: i16 = MAX_VERSION;
94    const LATEST_STABLE_VERSION: i16 = MAX_VERSION;
95    const FLEXIBLE_MIN: i16 = FLEXIBLE_MIN;
96
97    type Response = DescribeBreakGlassResponse;
98}
99
100/// One approval of a break-glass proposal.
101#[derive(Debug, Clone, PartialEq, Eq, Default)]
102pub struct BreakGlassApproval {
103    /// The principal that the broker authenticated when it took the approval.
104    /// Every approval of one proposal names a different principal.
105    pub principal: String,
106    /// Milliseconds since the Unix epoch, at the moment the controller recorded
107    /// the approval.
108    pub approved_at_ms: i64,
109    /// The operator key that made `signature`. It is empty for an unsigned
110    /// approval.
111    pub key_id: String,
112    /// Detached Ed25519 signature over the canonical bytes of the proposal. It
113    /// is empty for an unsigned approval.
114    pub signature: Vec<u8>,
115    /// Tagged fields that this build does not know.
116    pub unknown_tagged_fields: UnknownTaggedFields,
117}
118
119impl Encode for BreakGlassApproval {
120    fn encode<B: BufMut>(&self, buf: &mut B, _version: i16) -> Result<(), ProtocolError> {
121        put_compact_string(buf, &self.principal);
122        put_i64(buf, self.approved_at_ms);
123        put_compact_string(buf, &self.key_id);
124        put_compact_bytes(buf, &self.signature);
125        write_unknown_tagged_fields(buf, &self.unknown_tagged_fields);
126        Ok(())
127    }
128
129    fn encoded_len(&self, _version: i16) -> usize {
130        compact_string_len(&self.principal)
131            + I64_LEN
132            + compact_string_len(&self.key_id)
133            + compact_bytes_len(&self.signature)
134            + unknown_tagged_fields_len(&self.unknown_tagged_fields)
135    }
136}
137
138impl Decode<'_> for BreakGlassApproval {
139    fn decode<B: Buf>(buf: &mut B, _version: i16) -> Result<Self, ProtocolError> {
140        Ok(Self {
141            principal: get_compact_string_owned(buf)?,
142            approved_at_ms: get_i64(buf)?,
143            key_id: get_compact_string_owned(buf)?,
144            signature: get_compact_bytes_owned(buf)?.to_vec(),
145            unknown_tagged_fields: read_unknown_tagged_fields(buf)?,
146        })
147    }
148}
149
150/// One break-glass proposal, and the approvals that it holds.
151#[derive(Debug, Clone, PartialEq, Eq, Default)]
152pub struct DescribedBreakGlassProposal {
153    /// Id of the proposal.
154    pub proposal_id: Uuid,
155    /// The privileged action to gate. The value is the wire value of the
156    /// broker's break-glass action, as [`super`] describes.
157    pub action: i8,
158    /// What the action applies to.
159    pub target: String,
160    /// The principal that opened the proposal. It cannot approve the proposal.
161    pub proposer: String,
162    /// Text that says why the operator needs the action.
163    pub reason: String,
164    /// Milliseconds since the Unix epoch, at the moment the controller opened
165    /// the proposal.
166    pub created_at_ms: i64,
167    /// Milliseconds since the Unix epoch, at the moment the proposal expires.
168    pub expires_at_ms: i64,
169    /// Milliseconds since the Unix epoch, at the moment an action consumed the
170    /// proposal. `0` means that no action consumed it. A proposal authorizes
171    /// one action only.
172    pub consumed_at_ms: i64,
173    /// `true` means that an operator withdrew the proposal.
174    pub withdrawn: bool,
175    /// One entry for each approval, in the order that the controller recorded
176    /// them.
177    pub approvals: Vec<BreakGlassApproval>,
178    /// Tagged fields that this build does not know.
179    pub unknown_tagged_fields: UnknownTaggedFields,
180}
181
182impl Encode for DescribedBreakGlassProposal {
183    fn encode<B: BufMut>(&self, buf: &mut B, version: i16) -> Result<(), ProtocolError> {
184        put_uuid(buf, self.proposal_id);
185        put_i8(buf, self.action);
186        put_compact_string(buf, &self.target);
187        put_compact_string(buf, &self.proposer);
188        put_compact_string(buf, &self.reason);
189        put_i64(buf, self.created_at_ms);
190        put_i64(buf, self.expires_at_ms);
191        put_i64(buf, self.consumed_at_ms);
192        put_bool(buf, self.withdrawn);
193        encode_array(buf, &self.approvals, version)?;
194        write_unknown_tagged_fields(buf, &self.unknown_tagged_fields);
195        Ok(())
196    }
197
198    fn encoded_len(&self, version: i16) -> usize {
199        UUID_LEN
200            + I8_LEN
201            + compact_string_len(&self.target)
202            + compact_string_len(&self.proposer)
203            + compact_string_len(&self.reason)
204            + I64_LEN
205            + I64_LEN
206            + I64_LEN
207            + BOOL_LEN
208            + array_len(&self.approvals, version)
209            + unknown_tagged_fields_len(&self.unknown_tagged_fields)
210    }
211}
212
213impl Decode<'_> for DescribedBreakGlassProposal {
214    fn decode<B: Buf>(buf: &mut B, version: i16) -> Result<Self, ProtocolError> {
215        Ok(Self {
216            proposal_id: get_uuid(buf)?,
217            action: get_i8(buf)?,
218            target: get_compact_string_owned(buf)?,
219            proposer: get_compact_string_owned(buf)?,
220            reason: get_compact_string_owned(buf)?,
221            created_at_ms: get_i64(buf)?,
222            expires_at_ms: get_i64(buf)?,
223            consumed_at_ms: get_i64(buf)?,
224            withdrawn: get_bool(buf)?,
225            approvals: decode_array(buf, version)?,
226            unknown_tagged_fields: read_unknown_tagged_fields(buf)?,
227        })
228    }
229}
230
231/// The proposals that a [`DescribeBreakGlassRequest`] asked for.
232#[derive(Debug, Clone, PartialEq, Eq, Default)]
233pub struct DescribeBreakGlassResponse {
234    /// Milliseconds that the broker held the request back for a quota.
235    pub throttle_time_ms: i32,
236    /// Kafka error code. `0` means `proposals` holds the answer.
237    pub error_code: i16,
238    /// Text that explains a non-zero `error_code`. `None` when the broker has
239    /// nothing to add to the code.
240    pub error_message: Option<String>,
241    /// One entry for each proposal that the request matched.
242    pub proposals: Vec<DescribedBreakGlassProposal>,
243    /// Tagged fields that this build does not know.
244    pub unknown_tagged_fields: UnknownTaggedFields,
245}
246
247impl Encode for DescribeBreakGlassResponse {
248    fn encode<B: BufMut>(&self, buf: &mut B, version: i16) -> Result<(), ProtocolError> {
249        check_version(API_KEY, version, MIN_VERSION, MAX_VERSION)?;
250        put_i32(buf, self.throttle_time_ms);
251        put_i16(buf, self.error_code);
252        put_compact_nullable_string(buf, self.error_message.as_deref());
253        encode_array(buf, &self.proposals, version)?;
254        write_unknown_tagged_fields(buf, &self.unknown_tagged_fields);
255        Ok(())
256    }
257
258    fn encoded_len(&self, version: i16) -> usize {
259        I32_LEN
260            + I16_LEN
261            + compact_nullable_string_len(self.error_message.as_deref())
262            + array_len(&self.proposals, version)
263            + unknown_tagged_fields_len(&self.unknown_tagged_fields)
264    }
265}
266
267impl Decode<'_> for DescribeBreakGlassResponse {
268    fn decode<B: Buf>(buf: &mut B, version: i16) -> Result<Self, ProtocolError> {
269        check_version(API_KEY, version, MIN_VERSION, MAX_VERSION)?;
270        Ok(Self {
271            throttle_time_ms: get_i32(buf)?,
272            error_code: get_i16(buf)?,
273            error_message: get_compact_nullable_string_owned(buf)?,
274            proposals: decode_array(buf, version)?,
275            unknown_tagged_fields: read_unknown_tagged_fields(buf)?,
276        })
277    }
278}
279
280#[cfg(test)]
281impl DescribeBreakGlassRequest {
282    /// Builds a request with every field set, for the round-trip tests.
283    fn populated() -> Self {
284        Self {
285            pending_only: true,
286            proposal_id: Uuid([
287                200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215,
288            ]),
289            unknown_tagged_fields: crate::krabka::test_support::sample_tagged_fields(),
290        }
291    }
292}
293
294#[cfg(test)]
295impl DescribeBreakGlassResponse {
296    /// Builds a response with every field set, for the round-trip tests.
297    fn populated() -> Self {
298        Self {
299            throttle_time_ms: 6,
300            error_code: 0,
301            error_message: None,
302            proposals: vec![
303                DescribedBreakGlassProposal {
304                    proposal_id: Uuid([
305                        200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214,
306                        215,
307                    ]),
308                    action: 6,
309                    target: "doomed".to_string(),
310                    proposer: "User:alice".to_string(),
311                    reason: "topic holds test data only".to_string(),
312                    created_at_ms: 1_770_000_000_000,
313                    expires_at_ms: 1_770_000_180_000,
314                    consumed_at_ms: 0,
315                    withdrawn: false,
316                    approvals: vec![
317                        BreakGlassApproval {
318                            principal: "User:bob".to_string(),
319                            approved_at_ms: 1_770_000_060_000,
320                            key_id: "bob-yubi".to_string(),
321                            signature: (0..64).collect(),
322                            unknown_tagged_fields:
323                                crate::krabka::test_support::sample_tagged_fields(),
324                        },
325                        BreakGlassApproval {
326                            principal: "User:carol".to_string(),
327                            approved_at_ms: 1_770_000_090_000,
328                            ..BreakGlassApproval::default()
329                        },
330                    ],
331                    unknown_tagged_fields: crate::krabka::test_support::sample_tagged_fields(),
332                },
333                DescribedBreakGlassProposal {
334                    proposal_id: Uuid([1; 16]),
335                    action: 1,
336                    target: "literal:orders".to_string(),
337                    proposer: "User:dave".to_string(),
338                    reason: "DR cutover finished".to_string(),
339                    created_at_ms: 1_769_000_000_000,
340                    expires_at_ms: 1_769_000_180_000,
341                    consumed_at_ms: 1_769_000_120_000,
342                    withdrawn: true,
343                    approvals: Vec::new(),
344                    unknown_tagged_fields: UnknownTaggedFields::default(),
345                },
346            ],
347            unknown_tagged_fields: crate::krabka::test_support::sample_tagged_fields(),
348        }
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use assert2::assert;
355
356    use super::*;
357    use crate::krabka::test_support::{reject_truncated, roundtrip_case, unsupported_versions};
358
359    #[test]
360    fn requests_roundtrip() {
361        let cases = [
362            (
363                "default asks for every proposal",
364                DescribeBreakGlassRequest::default(),
365            ),
366            (
367                "one pending proposal",
368                DescribeBreakGlassRequest::populated(),
369            ),
370            (
371                "every pending proposal",
372                DescribeBreakGlassRequest {
373                    pending_only: true,
374                    proposal_id: Uuid::ZERO,
375                    unknown_tagged_fields: UnknownTaggedFields::default(),
376                },
377            ),
378        ];
379        for (label, request) in &cases {
380            for version in MIN_VERSION..=MAX_VERSION {
381                roundtrip_case(label, request, version);
382            }
383        }
384    }
385
386    #[test]
387    fn responses_roundtrip() {
388        let cases = [
389            ("default", DescribeBreakGlassResponse::default()),
390            ("two proposals", DescribeBreakGlassResponse::populated()),
391            (
392                "refusal with no proposals",
393                DescribeBreakGlassResponse {
394                    throttle_time_ms: 2,
395                    error_code: 31,
396                    error_message: Some("cluster authorization failed".to_string()),
397                    proposals: Vec::new(),
398                    unknown_tagged_fields: UnknownTaggedFields::default(),
399                },
400            ),
401            (
402                "proposal with no approvals",
403                DescribeBreakGlassResponse {
404                    proposals: vec![DescribedBreakGlassProposal::default()],
405                    ..DescribeBreakGlassResponse::default()
406                },
407            ),
408        ];
409        for (label, response) in &cases {
410            for version in MIN_VERSION..=MAX_VERSION {
411                roundtrip_case(label, response, version);
412            }
413        }
414    }
415
416    #[test]
417    fn a_nil_proposal_id_asks_for_every_proposal() {
418        assert!(
419            DescribeBreakGlassRequest::default()
420                == DescribeBreakGlassRequest {
421                    pending_only: false,
422                    proposal_id: Uuid::ZERO,
423                    unknown_tagged_fields: UnknownTaggedFields::default(),
424                }
425        );
426    }
427
428    #[test]
429    fn an_unsigned_approval_carries_an_empty_signature() {
430        let response = DescribeBreakGlassResponse::populated();
431        let unsigned = &response.proposals[0].approvals[1];
432        assert!(unsigned.key_id.is_empty());
433        assert!(unsigned.signature.is_empty());
434    }
435
436    #[test]
437    fn truncated_input_is_rejected() {
438        reject_truncated(&DescribeBreakGlassRequest::populated(), MIN_VERSION);
439        reject_truncated(&DescribeBreakGlassResponse::populated(), MIN_VERSION);
440    }
441
442    #[test]
443    fn rejects_unsupported_versions() {
444        for version in unsupported_versions(MIN_VERSION, MAX_VERSION) {
445            let request = DescribeBreakGlassRequest::populated();
446            assert!(matches!(
447                request.encode(&mut Vec::<u8>::new(), version),
448                Err(ProtocolError::UnsupportedVersion {
449                    api_key: API_KEY,
450                    ..
451                })
452            ));
453            let response = DescribeBreakGlassResponse::populated();
454            assert!(matches!(
455                response.encode(&mut Vec::<u8>::new(), version),
456                Err(ProtocolError::UnsupportedVersion {
457                    api_key: API_KEY,
458                    ..
459                })
460            ));
461        }
462    }
463}