Skip to main content

chio_kernel/admission_operation/
identity.rs

1use super::*;
2
3#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
4#[serde(transparent)]
5pub struct BoundedAdmissionText<const MAX: usize>(String);
6
7impl<const MAX: usize> BoundedAdmissionText<MAX> {
8    pub fn try_new(
9        field: &'static str,
10        value: impl Into<String>,
11    ) -> Result<Self, AdmissionOperationError> {
12        let value = value.into();
13        if value.is_empty() {
14            return Err(AdmissionOperationError::Empty { field });
15        }
16        if value.len() > MAX {
17            return Err(AdmissionOperationError::TooLong {
18                field,
19                maximum: MAX,
20            });
21        }
22        if value.chars().any(char::is_control) {
23            return Err(AdmissionOperationError::ControlCharacter { field });
24        }
25        if value.trim() != value {
26            return Err(AdmissionOperationError::Padded { field });
27        }
28        Ok(Self(value))
29    }
30
31    #[must_use]
32    pub fn as_str(&self) -> &str {
33        &self.0
34    }
35}
36
37impl<'de, const MAX: usize> Deserialize<'de> for BoundedAdmissionText<MAX> {
38    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
39    where
40        D: Deserializer<'de>,
41    {
42        let value = String::deserialize(deserializer)?;
43        Self::try_new("persisted_admission_text", value).map_err(serde::de::Error::custom)
44    }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
48#[serde(transparent)]
49pub struct AdmissionDigest(String);
50
51impl AdmissionDigest {
52    pub fn try_new(
53        field: &'static str,
54        value: impl Into<String>,
55    ) -> Result<Self, AdmissionOperationError> {
56        let value = value.into();
57        if !is_sha256_hex(&value) {
58            return Err(AdmissionOperationError::InvalidDigest { field });
59        }
60        Ok(Self(value))
61    }
62
63    #[must_use]
64    pub fn as_str(&self) -> &str {
65        &self.0
66    }
67
68    fn from_derived(value: String) -> Self {
69        debug_assert!(is_sha256_hex(&value));
70        Self(value)
71    }
72}
73
74impl<'de> Deserialize<'de> for AdmissionDigest {
75    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
76    where
77        D: Deserializer<'de>,
78    {
79        let value = String::deserialize(deserializer)?;
80        Self::try_new("persisted_admission_digest", value).map_err(serde::de::Error::custom)
81    }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
85#[serde(transparent)]
86pub struct AdmissionOperationId(AdmissionDigest);
87
88impl AdmissionOperationId {
89    pub fn from_persisted(value: impl Into<String>) -> Result<Self, AdmissionOperationError> {
90        AdmissionDigest::try_new("operation_id", value).map(Self)
91    }
92
93    #[must_use]
94    pub fn as_str(&self) -> &str {
95        self.0.as_str()
96    }
97
98    fn from_derived(value: String) -> Self {
99        Self(AdmissionDigest::from_derived(value))
100    }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
104#[serde(transparent)]
105pub struct RequestNamespaceDigest(AdmissionDigest);
106
107impl RequestNamespaceDigest {
108    pub fn from_persisted(value: impl Into<String>) -> Result<Self, AdmissionOperationError> {
109        AdmissionDigest::try_new("request_namespace_digest", value).map(Self)
110    }
111
112    #[must_use]
113    pub fn as_str(&self) -> &str {
114        self.0.as_str()
115    }
116
117    fn from_derived(value: String) -> Self {
118        Self(AdmissionDigest::from_derived(value))
119    }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct AuthenticatedRequestNamespace {
124    coordinator_authority_id: AdmissionIdentifier,
125    authenticated_tenant_id: BoundedAdmissionText<MAX_ADMISSION_TENANT_BYTES>,
126    digest: RequestNamespaceDigest,
127}
128
129impl AuthenticatedRequestNamespace {
130    pub fn for_local_system(
131        coordinator_authority_id: AdmissionIdentifier,
132    ) -> Result<Self, AdmissionOperationError> {
133        Self::bind(
134            coordinator_authority_id,
135            BoundedAdmissionText::try_new("authenticated_tenant_id", LOCAL_SYSTEM_TENANT_ID)?,
136        )
137    }
138
139    #[allow(dead_code)]
140    pub(crate) fn from_authentication_context(
141        coordinator_authority_id: AdmissionIdentifier,
142        authenticated_tenant_id: impl Into<String>,
143    ) -> Result<Self, AdmissionOperationError> {
144        let authenticated_tenant_id = authenticated_tenant_id.into();
145        if authenticated_tenant_id == LOCAL_SYSTEM_TENANT_ID {
146            return Err(AdmissionOperationError::ReservedLocalSystemTenant);
147        }
148        Self::bind(
149            coordinator_authority_id,
150            BoundedAdmissionText::try_new("authenticated_tenant_id", authenticated_tenant_id)?,
151        )
152    }
153
154    fn bind(
155        coordinator_authority_id: AdmissionIdentifier,
156        authenticated_tenant_id: BoundedAdmissionText<MAX_ADMISSION_TENANT_BYTES>,
157    ) -> Result<Self, AdmissionOperationError> {
158        #[derive(Serialize)]
159        struct NamespaceBody<'a> {
160            authenticated_tenant_id: &'a str,
161            coordinator_authority_id: &'a str,
162        }
163
164        let body = NamespaceBody {
165            authenticated_tenant_id: authenticated_tenant_id.as_str(),
166            coordinator_authority_id: coordinator_authority_id.as_str(),
167        };
168        let digest = domain_separated_digest(ADMISSION_REQUEST_NAMESPACE_DOMAIN, &body)?;
169        Ok(Self {
170            coordinator_authority_id,
171            authenticated_tenant_id,
172            digest: RequestNamespaceDigest::from_derived(digest),
173        })
174    }
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum AdmissionOperationKind {
180    ToolDispatch,
181    GovernedActiveResponse,
182    GovernedEconomicMutation,
183}
184
185impl AdmissionOperationKind {
186    pub const ALL: [Self; 3] = [
187        Self::ToolDispatch,
188        Self::GovernedActiveResponse,
189        Self::GovernedEconomicMutation,
190    ];
191
192    pub(super) fn uses_dispatch(self) -> bool {
193        matches!(self, Self::ToolDispatch | Self::GovernedActiveResponse)
194    }
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
198#[serde(rename_all = "snake_case")]
199pub enum SideEffectClass {
200    ReadOnly,
201    SideEffecting,
202    Monetary,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
206#[serde(deny_unknown_fields)]
207pub struct AdmissionParticipantRequirements {
208    pub broker_attempt: bool,
209    pub budget_capture: bool,
210    pub approval: bool,
211    pub execution_nonce: bool,
212    pub outcome_eligibility: bool,
213    pub payment: bool,
214    pub authorization_consumption: bool,
215    pub observation_attempt_zero: bool,
216    pub obligation: bool,
217    #[serde(default, skip_serializing_if = "is_false")]
218    pub channel: bool,
219    #[serde(default, skip_serializing_if = "is_false")]
220    pub credit_exposure: bool,
221    #[serde(default, skip_serializing_if = "is_false")]
222    pub supplemental_authorization: bool,
223}
224
225impl AdmissionParticipantRequirements {
226    pub const NONE: Self = Self {
227        broker_attempt: false,
228        budget_capture: false,
229        approval: false,
230        execution_nonce: false,
231        outcome_eligibility: false,
232        payment: false,
233        authorization_consumption: false,
234        observation_attempt_zero: false,
235        obligation: false,
236        channel: false,
237        credit_exposure: false,
238        supplemental_authorization: false,
239    };
240
241    fn validate(self) -> Result<(), AdmissionOperationError> {
242        if (self.broker_attempt || self.payment || self.channel) && !self.budget_capture
243            || self.channel && (self.payment || !self.obligation)
244            || self.credit_exposure
245                && (!self.budget_capture || !self.obligation || self.payment || self.channel)
246        {
247            return Err(AdmissionOperationError::InvalidParticipantRequirements);
248        }
249        Ok(())
250    }
251
252    pub(super) fn validate_for_kind(
253        self,
254        kind: AdmissionOperationKind,
255    ) -> Result<(), AdmissionOperationError> {
256        self.validate()?;
257        let kind_valid = match kind {
258            AdmissionOperationKind::ToolDispatch => {
259                self.budget_capture && self.broker_attempt && !self.supplemental_authorization
260            }
261            AdmissionOperationKind::GovernedActiveResponse => {
262                self == (Self {
263                    approval: true,
264                    ..Self::NONE
265                })
266            }
267            AdmissionOperationKind::GovernedEconomicMutation => {
268                self == Self::NONE
269                    || self
270                        == (Self {
271                            supplemental_authorization: true,
272                            ..Self::NONE
273                        })
274            }
275        };
276        if kind_valid {
277            Ok(())
278        } else {
279            Err(AdmissionOperationError::InvalidParticipantRequirements)
280        }
281    }
282}
283
284const fn is_false(value: &bool) -> bool {
285    !*value
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
289pub struct AdmissionRequestBindingV1 {
290    pub(super) immutable_request_hash: AdmissionDigest,
291    pub(super) action_parameter_hash: AdmissionDigest,
292    pub(super) participant_requirements: AdmissionParticipantRequirements,
293    pub(super) request_binding_hash: AdmissionDigest,
294}
295
296impl AdmissionRequestBindingV1 {
297    pub fn new(
298        immutable_request_hash: AdmissionDigest,
299        participant_requirements: AdmissionParticipantRequirements,
300    ) -> Result<Self, AdmissionOperationError> {
301        Self::new_with_action_parameter_hash(
302            immutable_request_hash.clone(),
303            immutable_request_hash,
304            participant_requirements,
305        )
306    }
307
308    pub fn new_with_action_parameter_hash(
309        immutable_request_hash: AdmissionDigest,
310        action_parameter_hash: AdmissionDigest,
311        participant_requirements: AdmissionParticipantRequirements,
312    ) -> Result<Self, AdmissionOperationError> {
313        participant_requirements.validate()?;
314        let request_binding_hash = derive_request_binding_hash(
315            &immutable_request_hash,
316            &action_parameter_hash,
317            participant_requirements,
318        )?;
319        Ok(Self {
320            immutable_request_hash,
321            action_parameter_hash,
322            participant_requirements,
323            request_binding_hash,
324        })
325    }
326
327    fn validate(&self) -> Result<(), AdmissionOperationError> {
328        self.participant_requirements.validate()?;
329        if derive_request_binding_hash(
330            &self.immutable_request_hash,
331            &self.action_parameter_hash,
332            self.participant_requirements,
333        )? != self.request_binding_hash
334        {
335            return Err(AdmissionOperationError::RequestBindingMismatch);
336        }
337        Ok(())
338    }
339}
340
341impl<'de> Deserialize<'de> for AdmissionRequestBindingV1 {
342    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
343    where
344        D: Deserializer<'de>,
345    {
346        #[derive(Deserialize)]
347        #[serde(deny_unknown_fields)]
348        struct Persisted {
349            immutable_request_hash: AdmissionDigest,
350            action_parameter_hash: AdmissionDigest,
351            participant_requirements: AdmissionParticipantRequirements,
352            request_binding_hash: AdmissionDigest,
353        }
354        let value = Persisted::deserialize(deserializer)?;
355        let binding = Self {
356            immutable_request_hash: value.immutable_request_hash,
357            action_parameter_hash: value.action_parameter_hash,
358            participant_requirements: value.participant_requirements,
359            request_binding_hash: value.request_binding_hash,
360        };
361        binding.validate().map_err(serde::de::Error::custom)?;
362        Ok(binding)
363    }
364}
365
366#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
367#[serde(rename_all = "snake_case")]
368pub enum DurableAdmissionMode {
369    Off,
370    Monetary,
371    #[default]
372    SideEffecting,
373    All,
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
377pub enum AdmissionReceiptPersistence {
378    Durable,
379    Ephemeral,
380}
381
382impl DurableAdmissionMode {
383    #[must_use]
384    pub fn covers(self, effect_class: SideEffectClass) -> bool {
385        match self {
386            Self::Off => false,
387            Self::Monetary => effect_class == SideEffectClass::Monetary,
388            Self::SideEffecting => effect_class != SideEffectClass::ReadOnly,
389            Self::All => true,
390        }
391    }
392
393    pub fn validate_configuration(
394        self,
395        unsafe_development: bool,
396        receipts: AdmissionReceiptPersistence,
397    ) -> Result<Self, AdmissionOperationError> {
398        if self == Self::Off
399            && (!unsafe_development || receipts != AdmissionReceiptPersistence::Ephemeral)
400        {
401            return Err(AdmissionOperationError::UnsafeDurableAdmissionOff);
402        }
403        Ok(self)
404    }
405}
406
407#[derive(Debug, Clone, PartialEq, Eq)]
408pub struct AdmissionOperationBindingInputV1 {
409    pub kind: AdmissionOperationKind,
410    pub namespace: AuthenticatedRequestNamespace,
411    pub request_id: AdmissionIdentifier,
412    pub capability_id: AdmissionIdentifier,
413    pub authorization_capability_hash: AdmissionDigest,
414    pub request_binding: AdmissionRequestBindingV1,
415    pub policy_hash: AdmissionDigest,
416    pub effect_class: SideEffectClass,
417}
418
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
420#[serde(deny_unknown_fields)]
421pub struct PersistedAdmissionOperationBindingV1 {
422    pub kind: AdmissionOperationKind,
423    pub operation_id: AdmissionOperationId,
424    pub coordinator_authority_id: AdmissionIdentifier,
425    pub authenticated_tenant_id: BoundedAdmissionText<MAX_ADMISSION_TENANT_BYTES>,
426    pub request_namespace_digest: RequestNamespaceDigest,
427    pub request_id: AdmissionIdentifier,
428    pub capability_id: AdmissionIdentifier,
429    pub authorization_capability_hash: AdmissionDigest,
430    pub request_binding: AdmissionRequestBindingV1,
431    pub policy_hash: AdmissionDigest,
432    pub effect_class: SideEffectClass,
433}
434
435#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
436pub struct AdmissionOperationBindingV1 {
437    pub(super) kind: AdmissionOperationKind,
438    pub(super) operation_id: AdmissionOperationId,
439    pub(super) coordinator_authority_id: AdmissionIdentifier,
440    pub(super) authenticated_tenant_id: BoundedAdmissionText<MAX_ADMISSION_TENANT_BYTES>,
441    pub(super) request_namespace_digest: RequestNamespaceDigest,
442    pub(super) request_id: AdmissionIdentifier,
443    pub(super) capability_id: AdmissionIdentifier,
444    pub(super) authorization_capability_hash: AdmissionDigest,
445    pub(super) request_binding: AdmissionRequestBindingV1,
446    pub(super) policy_hash: AdmissionDigest,
447    pub(super) effect_class: SideEffectClass,
448}
449
450impl AdmissionOperationBindingV1 {
451    pub fn new(input: AdmissionOperationBindingInputV1) -> Result<Self, AdmissionOperationError> {
452        input
453            .request_binding
454            .participant_requirements
455            .validate_for_kind(input.kind)?;
456        let operation_id = derive_operation_id(OperationIdInput {
457            kind: input.kind,
458            coordinator_authority_id: &input.namespace.coordinator_authority_id,
459            request_namespace_digest: &input.namespace.digest,
460            request_id: &input.request_id,
461            capability_id: &input.capability_id,
462            authorization_capability_hash: &input.authorization_capability_hash,
463            request_binding_hash: &input.request_binding.request_binding_hash,
464            policy_hash: &input.policy_hash,
465            effect_class: input.effect_class,
466        })?;
467        Ok(Self {
468            kind: input.kind,
469            operation_id,
470            coordinator_authority_id: input.namespace.coordinator_authority_id,
471            authenticated_tenant_id: input.namespace.authenticated_tenant_id,
472            request_namespace_digest: input.namespace.digest,
473            request_id: input.request_id,
474            capability_id: input.capability_id,
475            authorization_capability_hash: input.authorization_capability_hash,
476            request_binding: input.request_binding,
477            policy_hash: input.policy_hash,
478            effect_class: input.effect_class,
479        })
480    }
481
482    pub fn from_persisted(
483        input: PersistedAdmissionOperationBindingV1,
484    ) -> Result<Self, AdmissionOperationError> {
485        let namespace = AuthenticatedRequestNamespace::bind(
486            input.coordinator_authority_id.clone(),
487            input.authenticated_tenant_id.clone(),
488        )?;
489        if namespace.digest != input.request_namespace_digest {
490            return Err(AdmissionOperationError::RequestNamespaceMismatch);
491        }
492        let binding = Self {
493            kind: input.kind,
494            operation_id: input.operation_id,
495            coordinator_authority_id: input.coordinator_authority_id,
496            authenticated_tenant_id: input.authenticated_tenant_id,
497            request_namespace_digest: input.request_namespace_digest,
498            request_id: input.request_id,
499            capability_id: input.capability_id,
500            authorization_capability_hash: input.authorization_capability_hash,
501            request_binding: input.request_binding,
502            policy_hash: input.policy_hash,
503            effect_class: input.effect_class,
504        };
505        binding.validate()?;
506        Ok(binding)
507    }
508
509    pub fn validate(&self) -> Result<(), AdmissionOperationError> {
510        self.request_binding.validate()?;
511        self.request_binding
512            .participant_requirements
513            .validate_for_kind(self.kind)?;
514        let namespace = AuthenticatedRequestNamespace::bind(
515            self.coordinator_authority_id.clone(),
516            self.authenticated_tenant_id.clone(),
517        )?;
518        if namespace.digest != self.request_namespace_digest {
519            return Err(AdmissionOperationError::RequestNamespaceMismatch);
520        }
521        let expected = derive_operation_id(OperationIdInput {
522            kind: self.kind,
523            coordinator_authority_id: &self.coordinator_authority_id,
524            request_namespace_digest: &self.request_namespace_digest,
525            request_id: &self.request_id,
526            capability_id: &self.capability_id,
527            authorization_capability_hash: &self.authorization_capability_hash,
528            request_binding_hash: &self.request_binding.request_binding_hash,
529            policy_hash: &self.policy_hash,
530            effect_class: self.effect_class,
531        })?;
532        if expected != self.operation_id {
533            return Err(AdmissionOperationError::OperationIdMismatch);
534        }
535        Ok(())
536    }
537
538    #[must_use]
539    pub fn kind(&self) -> AdmissionOperationKind {
540        self.kind
541    }
542
543    #[must_use]
544    pub fn operation_id(&self) -> &AdmissionOperationId {
545        &self.operation_id
546    }
547
548    #[must_use]
549    pub fn coordinator_authority_id(&self) -> &AdmissionIdentifier {
550        &self.coordinator_authority_id
551    }
552
553    #[must_use]
554    pub fn request_namespace_digest(&self) -> &RequestNamespaceDigest {
555        &self.request_namespace_digest
556    }
557
558    #[must_use]
559    pub fn request_id(&self) -> &AdmissionIdentifier {
560        &self.request_id
561    }
562
563    #[must_use]
564    pub fn capability_id(&self) -> &AdmissionIdentifier {
565        &self.capability_id
566    }
567
568    #[must_use]
569    pub fn request_binding_hash(&self) -> &AdmissionDigest {
570        &self.request_binding.request_binding_hash
571    }
572
573    #[must_use]
574    pub fn immutable_request_hash(&self) -> &AdmissionDigest {
575        &self.request_binding.immutable_request_hash
576    }
577
578    #[must_use]
579    pub fn action_parameter_hash(&self) -> &AdmissionDigest {
580        &self.request_binding.action_parameter_hash
581    }
582
583    #[must_use]
584    pub fn participant_requirements(&self) -> AdmissionParticipantRequirements {
585        self.request_binding.participant_requirements
586    }
587
588    #[must_use]
589    pub fn policy_hash(&self) -> &AdmissionDigest {
590        &self.policy_hash
591    }
592
593    #[must_use]
594    pub fn effect_class(&self) -> SideEffectClass {
595        self.effect_class
596    }
597
598    #[must_use]
599    pub fn replay_key(&self) -> AdmissionReplayKey {
600        AdmissionReplayKey {
601            request_namespace_digest: self.request_namespace_digest.clone(),
602            request_id: self.request_id.clone(),
603        }
604    }
605
606    #[must_use]
607    pub fn to_persisted(&self) -> PersistedAdmissionOperationBindingV1 {
608        self.into()
609    }
610}
611
612impl From<&AdmissionOperationBindingV1> for PersistedAdmissionOperationBindingV1 {
613    fn from(binding: &AdmissionOperationBindingV1) -> Self {
614        Self {
615            kind: binding.kind,
616            operation_id: binding.operation_id.clone(),
617            coordinator_authority_id: binding.coordinator_authority_id.clone(),
618            authenticated_tenant_id: binding.authenticated_tenant_id.clone(),
619            request_namespace_digest: binding.request_namespace_digest.clone(),
620            request_id: binding.request_id.clone(),
621            capability_id: binding.capability_id.clone(),
622            authorization_capability_hash: binding.authorization_capability_hash.clone(),
623            request_binding: binding.request_binding.clone(),
624            policy_hash: binding.policy_hash.clone(),
625            effect_class: binding.effect_class,
626        }
627    }
628}
629
630#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
631pub struct AdmissionReplayKey {
632    pub request_namespace_digest: RequestNamespaceDigest,
633    pub request_id: AdmissionIdentifier,
634}
635
636pub(super) struct OperationIdInput<'a> {
637    pub(super) kind: AdmissionOperationKind,
638    pub(super) coordinator_authority_id: &'a AdmissionIdentifier,
639    pub(super) request_namespace_digest: &'a RequestNamespaceDigest,
640    pub(super) request_id: &'a AdmissionIdentifier,
641    pub(super) capability_id: &'a AdmissionIdentifier,
642    pub(super) authorization_capability_hash: &'a AdmissionDigest,
643    pub(super) request_binding_hash: &'a AdmissionDigest,
644    pub(super) policy_hash: &'a AdmissionDigest,
645    pub(super) effect_class: SideEffectClass,
646}
647
648pub(super) fn derive_operation_id(
649    input: OperationIdInput<'_>,
650) -> Result<AdmissionOperationId, AdmissionOperationError> {
651    #[derive(Serialize)]
652    struct OperationIdBody<'a> {
653        kind: AdmissionOperationKind,
654        coordinator_authority_id: &'a str,
655        request_namespace_digest: &'a str,
656        request_id: &'a str,
657        capability_id: &'a str,
658        authorization_capability_hash: &'a str,
659        request_binding_hash: &'a str,
660        policy_hash: &'a str,
661        effect_class: SideEffectClass,
662    }
663
664    let body = OperationIdBody {
665        kind: input.kind,
666        coordinator_authority_id: input.coordinator_authority_id.as_str(),
667        request_namespace_digest: input.request_namespace_digest.as_str(),
668        request_id: input.request_id.as_str(),
669        capability_id: input.capability_id.as_str(),
670        authorization_capability_hash: input.authorization_capability_hash.as_str(),
671        request_binding_hash: input.request_binding_hash.as_str(),
672        policy_hash: input.policy_hash.as_str(),
673        effect_class: input.effect_class,
674    };
675    domain_separated_digest(ADMISSION_OPERATION_DOMAIN, &body)
676        .map(AdmissionOperationId::from_derived)
677}
678
679fn derive_request_binding_hash(
680    immutable_request_hash: &AdmissionDigest,
681    action_parameter_hash: &AdmissionDigest,
682    participant_requirements: AdmissionParticipantRequirements,
683) -> Result<AdmissionDigest, AdmissionOperationError> {
684    #[derive(Serialize)]
685    struct RequestBindingBody<'a> {
686        immutable_request_hash: &'a str,
687        action_parameter_hash: &'a str,
688        participant_requirements: AdmissionParticipantRequirements,
689    }
690    domain_separated_digest(
691        ADMISSION_REQUEST_BINDING_DOMAIN,
692        &RequestBindingBody {
693            immutable_request_hash: immutable_request_hash.as_str(),
694            action_parameter_hash: action_parameter_hash.as_str(),
695            participant_requirements,
696        },
697    )
698    .map(AdmissionDigest::from_derived)
699}
700
701fn domain_separated_digest<T: Serialize>(
702    domain: &[u8],
703    body: &T,
704) -> Result<String, AdmissionOperationError> {
705    let canonical = canonical_json_bytes(body)
706        .map_err(|error| AdmissionOperationError::CanonicalJson(error.to_string()))?;
707    let mut bytes = Vec::with_capacity(domain.len() + canonical.len());
708    bytes.extend_from_slice(domain);
709    bytes.extend_from_slice(&canonical);
710    Ok(sha256_hex(&bytes))
711}
712
713fn is_sha256_hex(value: &str) -> bool {
714    value.len() == 64
715        && value
716            .as_bytes()
717            .iter()
718            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
719}