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