Skip to main content

aep_service/
service.rs

1use std::{collections::BTreeMap, sync::Arc};
2
3use aep_core::{
4    AgentStatus, AssertionOperation, Authentication, AuthenticationMethod, Binding, Bindings,
5    Command, Commands, CoreConfiguration, EnrollRequest, EnrollResponse, EnrollmentDecisionStatus,
6    ErrorCode, Extensions, GrantType, HttpConfiguration, Identity, InspectClaims, InspectDocument,
7    RevokeResponse, ServiceIdentity, StatusResponse, StringBoolean, VERSION,
8    missing_required_claim_names, parse_enroll_request, parse_grant_request, parse_revoke_request,
9    validate_inspect_document,
10};
11use futures::FutureExt as _;
12use http::{HeaderMap, HeaderValue, header};
13use serde::Serialize;
14use serde_json::Value;
15use sha2::{Digest as _, Sha256};
16use time::format_description::well_known::Rfc3339;
17
18use crate::{
19    AuthenticatedCommandOptions, AuthenticatedPrincipal, AuthenticationKind,
20    ClientAssertionReplayStore, Clock, CommandIdempotencyInput, CommandIdempotencyResult,
21    CommandIdempotencyStore, EnrollmentPolicy, EnrollmentRecord, EnrollmentStore, GrantContext,
22    IdempotentCommand, IdempotentCommandOptions, MemoryClientAssertionReplayStore,
23    MemoryCommandIdempotencyStore, MemoryEnrollmentStore, ProtectedResourceAuthentication,
24    ProtectedResourceRequest, ResponseBody, ServiceConfiguration, ServiceError, ServiceOptions,
25    ServiceResponse, StaticEnrollmentPolicy, SystemClock,
26    authentication::{
27        AssertionAuthentication, SelectedAuthorization, authenticate_assertion,
28        select_authorization,
29    },
30    problem,
31};
32
33pub struct Service {
34    clock: Arc<dyn Clock>,
35    configuration: ServiceConfiguration,
36    enrollment_policy: Arc<dyn EnrollmentPolicy>,
37    enrollment_store: Arc<dyn EnrollmentStore>,
38    idempotency_store: Arc<dyn CommandIdempotencyStore>,
39    replay_store: Arc<dyn ClientAssertionReplayStore>,
40    verifier: Arc<dyn crate::ClientAssertionVerifier>,
41    allow_insecure_loopback: bool,
42}
43
44impl Service {
45    pub fn new(options: ServiceOptions) -> Result<Arc<Self>, ServiceError> {
46        let configuration = build_configuration(&options)?;
47        let clock = options.clock.unwrap_or_else(|| Arc::new(SystemClock));
48        Ok(Arc::new(Self {
49            allow_insecure_loopback: options.allow_insecure_loopback,
50            configuration,
51            enrollment_policy: options
52                .enrollment_policy
53                .unwrap_or_else(|| Arc::new(StaticEnrollmentPolicy::default())),
54            enrollment_store: options
55                .enrollment_store
56                .unwrap_or_else(|| Arc::new(MemoryEnrollmentStore::default())),
57            idempotency_store: options.idempotency_store.unwrap_or_else(|| {
58                Arc::new(MemoryCommandIdempotencyStore::with_clock(clock.clone()))
59            }),
60            replay_store: options
61                .replay_store
62                .unwrap_or_else(|| Arc::new(MemoryClientAssertionReplayStore::default())),
63            verifier: options.verifier,
64            clock,
65        }))
66    }
67
68    pub fn inspect_document(&self) -> InspectDocument {
69        self.configuration.inspect_document.clone()
70    }
71
72    pub async fn enroll(
73        self: &Arc<Self>,
74        request: &[u8],
75        options: IdempotentCommandOptions,
76    ) -> Result<ServiceResponse, ServiceError> {
77        let Some(claims) = self
78            .authenticate_command(
79                &options.client_assertion,
80                AssertionOperation::Enroll,
81                Some(&options.idempotency_key),
82            )
83            .await?
84        else {
85            return Ok(problem(ErrorCode::NotRecognized, 401));
86        };
87        let Ok(request) = parse_enroll_request(request) else {
88            return Ok(problem(ErrorCode::InvalidRequest, 400));
89        };
90        if request.agent_did != claims.sub || options.idempotency_key.is_empty() {
91            return Ok(if options.idempotency_key.is_empty() {
92                problem(ErrorCode::InvalidRequest, 400)
93            } else {
94                problem(ErrorCode::NotRecognized, 401)
95            });
96        }
97        if request
98            .idempotency_key
99            .as_ref()
100            .is_some_and(|key| key != &options.idempotency_key)
101        {
102            return Ok(problem(ErrorCode::InvalidRequest, 400));
103        }
104        // CLM-SEC-006: the claim document is bounded before it is stored or acted on. CLM-SEC-003
105        // permits `invalid_request` for a document the Service will not parse.
106        if !within_claim_value_limits(
107            request.claims.as_ref(),
108            self.configuration.claim_value_limits,
109        ) {
110            return Ok(problem(ErrorCode::InvalidRequest, 400));
111        }
112        let input = idempotency_input(
113            &claims.sub,
114            IdempotentCommand::Enroll,
115            &options.idempotency_key,
116            &request,
117        )?;
118        let service = self.clone();
119        let result = self
120            .idempotency_store
121            .execute(
122                input,
123                Box::new(move || async move { service.execute_enroll(request).await }.boxed()),
124            )
125            .await?;
126        Ok(idempotency_response(result))
127    }
128
129    pub async fn status(
130        &self,
131        options: AuthenticatedCommandOptions,
132    ) -> Result<ServiceResponse, ServiceError> {
133        let Some(claims) = self
134            .authenticate_command(&options.client_assertion, AssertionOperation::Status, None)
135            .await?
136        else {
137            return Ok(problem(ErrorCode::NotRecognized, 401));
138        };
139        let Some(enrollment) = self.enrollment_store.find(&claims.sub).await? else {
140            return Ok(problem(ErrorCode::NotRecognized, 401));
141        };
142        Ok(success(ResponseBody::Status(status_response(&enrollment))))
143    }
144
145    pub async fn grant(
146        self: &Arc<Self>,
147        request: &[u8],
148        options: IdempotentCommandOptions,
149    ) -> Result<ServiceResponse, ServiceError> {
150        let Some(claims) = self
151            .authenticate_command(
152                &options.client_assertion,
153                AssertionOperation::Grant,
154                Some(&options.idempotency_key),
155            )
156            .await?
157        else {
158            return Ok(problem(ErrorCode::NotRecognized, 401));
159        };
160        let Ok(request) = parse_grant_request(request) else {
161            return Ok(problem(ErrorCode::InvalidRequest, 400));
162        };
163        if options.idempotency_key.is_empty() {
164            return Ok(problem(ErrorCode::InvalidRequest, 400));
165        }
166        let Some(enrollment) = self.enrollment_store.find(&claims.sub).await? else {
167            return Ok(problem(ErrorCode::NotRecognized, 401));
168        };
169        if let Some(response) = blocked_enrollment(&enrollment) {
170            return Ok(response);
171        }
172        let Some(handler) = self
173            .configuration
174            .grant_handlers
175            .get(request.grant_type.as_str())
176            .cloned()
177        else {
178            return Ok(problem(ErrorCode::UnsupportedGrantType, 400));
179        };
180        let input = idempotency_input(
181            &claims.sub,
182            IdempotentCommand::Grant,
183            &options.idempotency_key,
184            &request,
185        )?;
186        let agent_did = claims.sub;
187        let now = self.clock.now();
188        let result = self
189            .idempotency_store
190            .execute(
191                input,
192                Box::new(move || {
193                    async move {
194                        let context = GrantContext {
195                            agent_did,
196                            enrollment,
197                            grant_type: request.grant_type.clone(),
198                            now,
199                        };
200                        let response = handler.grant(&request, &context).await?;
201                        if !response.as_object().is_some_and(|response| {
202                            response
203                                .get("credential_id")
204                                .and_then(Value::as_str)
205                                .is_some_and(|credential_id| !credential_id.is_empty())
206                        }) {
207                            return Err(ServiceError::Handler(
208                                "Grant handler response requires a non-empty credential_id"
209                                    .to_owned(),
210                            ));
211                        }
212                        Ok(success(ResponseBody::Grant(response)))
213                    }
214                    .boxed()
215                }),
216            )
217            .await?;
218        Ok(idempotency_response(result))
219    }
220
221    pub async fn revoke(
222        self: &Arc<Self>,
223        request: &[u8],
224        options: IdempotentCommandOptions,
225    ) -> Result<ServiceResponse, ServiceError> {
226        let Some(claims) = self
227            .authenticate_command(
228                &options.client_assertion,
229                AssertionOperation::Revoke,
230                Some(&options.idempotency_key),
231            )
232            .await?
233        else {
234            return Ok(problem(ErrorCode::NotRecognized, 401));
235        };
236        let Ok(request) = parse_revoke_request(request) else {
237            return Ok(problem(ErrorCode::InvalidRequest, 400));
238        };
239        if options.idempotency_key.is_empty() {
240            return Ok(problem(ErrorCode::InvalidRequest, 400));
241        }
242        let Some(enrollment) = self.enrollment_store.find(&claims.sub).await? else {
243            return Ok(problem(ErrorCode::NotRecognized, 401));
244        };
245        let handlers = match &request.grant_type {
246            Some(grant_type) => {
247                let Some(handler) = self
248                    .configuration
249                    .grant_handlers
250                    .get(grant_type.as_str())
251                    .cloned()
252                else {
253                    return Ok(problem(ErrorCode::UnsupportedGrantType, 400));
254                };
255                vec![(grant_type.clone(), handler)]
256            }
257            None => self
258                .configuration
259                .grant_handlers
260                .iter()
261                .map(|(grant_type, handler)| (GrantType::from(grant_type.clone()), handler.clone()))
262                .collect(),
263        };
264        let input = idempotency_input(
265            &claims.sub,
266            IdempotentCommand::Revoke,
267            &options.idempotency_key,
268            &request,
269        )?;
270        let agent_did = claims.sub;
271        let now = self.clock.now();
272        let result = self
273            .idempotency_store
274            .execute(
275                input,
276                Box::new(move || {
277                    async move {
278                        for (grant_type, handler) in handlers {
279                            let context = GrantContext {
280                                agent_did: agent_did.clone(),
281                                enrollment: enrollment.clone(),
282                                grant_type,
283                                now,
284                            };
285                            handler.revoke(&request, &context).await?;
286                        }
287                        Ok(success(ResponseBody::Revoke(RevokeResponse::default())))
288                    }
289                    .boxed()
290                }),
291            )
292            .await?;
293        Ok(idempotency_response(result))
294    }
295
296    pub async fn authenticate_protected_resource(
297        &self,
298        request: ProtectedResourceRequest,
299    ) -> Result<ProtectedResourceAuthentication, ServiceError> {
300        let selected = match select_authorization(&request.headers) {
301            Ok(selected) => selected,
302            Err(()) => return Ok(self.authentication_failure(ErrorCode::NotRecognized, &request)),
303        };
304        if let SelectedAuthorization::Aep(assertion) = selected {
305            if !self
306                .configuration
307                .authentication_methods
308                .contains(&AuthenticationMethod::AepJwt)
309            {
310                return Ok(self
311                    .authentication_failure(ErrorCode::UnsupportedAuthenticationMethod, &request));
312            }
313            let Some(claims) = self
314                .authenticate_assertion(
315                    &assertion,
316                    AssertionOperation::Authenticate,
317                    None,
318                    Some(&request.url),
319                )
320                .await?
321            else {
322                return Ok(self.authentication_failure(ErrorCode::NotRecognized, &request));
323            };
324            let Some(enrollment) = self.enrollment_store.find(&claims.sub).await? else {
325                return Ok(self.authentication_failure(ErrorCode::NotRecognized, &request));
326            };
327            if enrollment.status != AgentStatus::Active {
328                return Ok(self.authentication_failure(ErrorCode::NotRecognized, &request));
329            }
330            return Ok(ProtectedResourceAuthentication::Authenticated(
331                AuthenticatedPrincipal {
332                    agent_did: claims.sub,
333                    authentication_kind: AuthenticationKind::ClientAssertion,
334                    authentication_method: AuthenticationMethod::AepJwt,
335                    credential_id: None,
336                    grant_type: None,
337                    scopes: Vec::new(),
338                },
339            ));
340        }
341        if let SelectedAuthorization::Session(method) = &selected
342            && !self.configuration.authentication_methods.contains(method)
343        {
344            return Ok(
345                self.authentication_failure(ErrorCode::UnsupportedAuthenticationMethod, &request)
346            );
347        }
348        let input = crate::CredentialAuthenticationInput {
349            headers: request.headers.clone(),
350            now: self.clock.now(),
351        };
352        let mut presented = matches!(selected, SelectedAuthorization::Session(_));
353        for method in &self.configuration.authentication_methods {
354            if method == &AuthenticationMethod::AepJwt {
355                continue;
356            }
357            let Some(handler) = self.configuration.grant_handlers.get(method.as_str()) else {
358                continue;
359            };
360            presented |= handler.has_presentation(&input).await?;
361            if let Some(principal) = handler.authenticate(&input).await? {
362                if principal.agent_did.is_empty()
363                    || principal.authentication_kind != AuthenticationKind::SessionCredential
364                    || principal.authentication_method != *method
365                    || principal.grant_type.as_ref().map(GrantType::as_str) != Some(method.as_str())
366                {
367                    return Ok(self.authentication_failure(ErrorCode::NotRecognized, &request));
368                }
369                let Some(enrollment) = self.enrollment_store.find(&principal.agent_did).await?
370                else {
371                    return Ok(self.authentication_failure(ErrorCode::NotRecognized, &request));
372                };
373                if enrollment.status != AgentStatus::Active {
374                    return Ok(self.authentication_failure(ErrorCode::NotRecognized, &request));
375                }
376                return Ok(ProtectedResourceAuthentication::Authenticated(principal));
377            }
378        }
379        let code = if presented {
380            ErrorCode::NotRecognized
381        } else {
382            ErrorCode::AuthenticationRequired
383        };
384        Ok(self.authentication_failure(code, &request))
385    }
386
387    async fn execute_enroll(
388        &self,
389        request: EnrollRequest,
390    ) -> Result<ServiceResponse, ServiceError> {
391        if let Some(existing) = self.enrollment_store.find(&request.agent_did).await? {
392            return Ok(success(ResponseBody::Enroll(enroll_response(&existing))));
393        }
394        let missing = missing_required_claim_names(
395            &self.configuration.claims.required,
396            request.claims.as_ref(),
397        );
398        if !missing.is_empty() {
399            // CORE-ERR-009 pairs `requirements_unmet` with 422.
400            let mut response = problem(ErrorCode::RequirementsUnmet, 422);
401            let ResponseBody::Problem(details) = &mut response.body else {
402                unreachable!();
403            };
404            details.requirements_pending = Some(
405                missing
406                    .into_iter()
407                    .map(|claim| claim.as_str().to_owned())
408                    .collect(),
409            );
410            return Ok(response);
411        }
412        let now = self.clock.now();
413        let decision = self.enrollment_policy.decide(&request, now).await?;
414        let record = self
415            .enrollment_store
416            .save(EnrollmentRecord {
417                agent_did: request.agent_did,
418                claims: request.claims.unwrap_or_default(),
419                created_at: now,
420                owner_action_required: decision.owner_action_required,
421                requirements_pending: decision.requirements_pending,
422                since: now,
423                status: decision_status(decision.status),
424                updated_at: now,
425                verification_pending: decision.verification_pending,
426            })
427            .await?;
428        Ok(success(ResponseBody::Enroll(enroll_response(&record))))
429    }
430
431    async fn authenticate_command(
432        &self,
433        assertion: &str,
434        operation: AssertionOperation,
435        idempotency_key: Option<&str>,
436    ) -> Result<Option<aep_core::ClientAssertionClaims>, ServiceError> {
437        self.authenticate_assertion(assertion, operation, idempotency_key, None)
438            .await
439    }
440
441    async fn authenticate_assertion(
442        &self,
443        assertion: &str,
444        operation: AssertionOperation,
445        idempotency_key: Option<&str>,
446        resource: Option<&url::Url>,
447    ) -> Result<Option<aep_core::ClientAssertionClaims>, ServiceError> {
448        authenticate_assertion(AssertionAuthentication {
449            allow_insecure_loopback: self.allow_insecure_loopback,
450            assertion,
451            clock: self.clock.as_ref(),
452            idempotency_key,
453            identity_methods: &self.configuration.inspect_document.identity.methods,
454            maximum_clock_skew: self.configuration.maximum_clock_skew,
455            operation,
456            replay_store: &self.replay_store,
457            resource,
458            service_did: &self.configuration.service_did,
459            signing_algorithms: &self.configuration.signing_algorithms,
460            verifier: &self.verifier,
461        })
462        .await
463    }
464
465    fn authentication_failure(
466        &self,
467        code: ErrorCode,
468        request: &ProtectedResourceRequest,
469    ) -> ProtectedResourceAuthentication {
470        let mut response = problem(code.clone(), 401);
471        let inspect = self.configuration.inspect_url.clone().unwrap_or_else(|| {
472            let mut inspect = request.url.clone();
473            inspect.set_path(aep_core::WELL_KNOWN_PATH);
474            inspect.set_query(None);
475            inspect.set_fragment(None);
476            inspect
477        });
478        let challenge = format!(
479            "{} service_did=\"{}\", inspect=\"{}\", reason=\"{}\"",
480            aep_core::AUTHORIZATION_SCHEME,
481            self.configuration.service_did,
482            inspect,
483            code.as_str()
484        );
485        if let Ok(value) = HeaderValue::from_str(&challenge) {
486            response.headers.insert(header::WWW_AUTHENTICATE, value);
487        }
488        ProtectedResourceAuthentication::Rejected(response)
489    }
490}
491
492fn build_configuration(options: &ServiceOptions) -> Result<ServiceConfiguration, ServiceError> {
493    if !options.service_did.starts_with("did:") {
494        return Err(ServiceError::InvalidConfiguration(
495            "Service DID must be a DID".to_owned(),
496        ));
497    }
498    require_unique(
499        options.identity_methods.iter().map(|value| value.as_str()),
500        "identity methods",
501    )?;
502    require_unique(
503        options
504            .grant_types
505            .iter()
506            .map(|value| value.grant_type.as_str()),
507        "grant types",
508    )?;
509    require_unique(
510        options
511            .authentication_methods
512            .iter()
513            .map(|value| value.as_str()),
514        "authentication methods",
515    )?;
516    require_unique(
517        options
518            .signing_algorithms
519            .iter()
520            .map(|value| value.as_str()),
521        "signing algorithms",
522    )?;
523    if options.identity_methods.is_empty() || options.signing_algorithms.is_empty() {
524        return Err(ServiceError::InvalidConfiguration(
525            "identity methods and signing algorithms must not be empty".to_owned(),
526        ));
527    }
528    let mut grant_handlers = BTreeMap::new();
529    let mut grant_types_config = BTreeMap::new();
530    for definition in &options.grant_types {
531        let Some(handler) = &definition.handler else {
532            return Err(ServiceError::InvalidConfiguration(format!(
533                "grant type {} requires a handler",
534                definition.grant_type.as_str()
535            )));
536        };
537        grant_handlers.insert(definition.grant_type.as_str().to_owned(), handler.clone());
538        if let Some(config) = &definition.config {
539            grant_types_config.insert(definition.grant_type.as_str().to_owned(), config.clone());
540        }
541    }
542    for method in &options.authentication_methods {
543        if method != &AuthenticationMethod::AepJwt && !grant_handlers.contains_key(method.as_str())
544        {
545            return Err(ServiceError::InvalidConfiguration(format!(
546                "authentication method {} requires a matching grant type handler",
547                method.as_str()
548            )));
549        }
550    }
551    let commands = if options.grant_types.is_empty() {
552        vec![Command::Enroll, Command::Inspect, Command::Status]
553    } else {
554        vec![
555            Command::Enroll,
556            Command::Grant,
557            Command::Inspect,
558            Command::Revoke,
559            Command::Status,
560        ]
561    };
562    let inspect_document = InspectDocument {
563        aep_version: VERSION.to_owned(),
564        authentication: (!options.authentication_methods.is_empty()).then(|| Authentication {
565            methods: options.authentication_methods.clone(),
566        }),
567        bindings: Bindings {
568            supported: vec![Binding::Http],
569            additional: BTreeMap::new(),
570        },
571        claims: Some(InspectClaims {
572            optional: options.claims.optional.clone(),
573            preferred: options.claims.preferred.clone(),
574            required: options.claims.required.clone(),
575            additional: BTreeMap::new(),
576        }),
577        commands: Commands {
578            grant_types: options
579                .grant_types
580                .iter()
581                .map(|definition| definition.grant_type.clone())
582                .collect(),
583            grant_types_config,
584            supported: commands,
585            additional: BTreeMap::new(),
586        },
587        core: CoreConfiguration {
588            signing_algorithms: options.signing_algorithms.clone(),
589            additional: BTreeMap::new(),
590        },
591        extensions: Some(Extensions {
592            supported: options.extensions.clone(),
593            additional: BTreeMap::new(),
594        }),
595        http: HttpConfiguration {
596            endpoint_base: options.endpoint_base.clone(),
597            openapi: options
598                .openapi
599                .as_ref()
600                .map(|openapi| aep_core::OpenApiReference {
601                    path_matching: aep_core::OpenApiPathMatching {
602                        trailing_slash: openapi.trailing_slash,
603                    },
604                    url: openapi.url.clone(),
605                }),
606            additional: BTreeMap::new(),
607        },
608        identity: Identity {
609            methods: options.identity_methods.clone(),
610            additional: BTreeMap::new(),
611        },
612        service: ServiceIdentity {
613            did: options.service_did.clone(),
614            additional: BTreeMap::new(),
615        },
616        additional: BTreeMap::new(),
617    };
618    validate_inspect_document(&inspect_document).map_err(aep_core::CoreError::from)?;
619    Ok(ServiceConfiguration {
620        authentication_methods: options.authentication_methods.clone(),
621        claim_value_limits: options.claim_value_limits.validate()?,
622        claims: options.claims.clone(),
623        grant_handlers,
624        inspect_document,
625        inspect_url: options.inspect_url.clone(),
626        maximum_clock_skew: options.maximum_clock_skew,
627        service_did: options.service_did.clone(),
628        signing_algorithms: options.signing_algorithms.clone(),
629    })
630}
631
632/// Reports whether a claim document stays inside the bounds CLM-SEC-006 requires a Service to
633/// place on it.
634fn within_claim_value_limits(
635    claims: Option<&aep_core::ClaimValues>,
636    limits: crate::ClaimValueLimits,
637) -> bool {
638    let Some(claims) = claims else {
639        return true;
640    };
641    let Ok(encoded) = serde_json::to_vec(claims) else {
642        return false;
643    };
644    if encoded.len() > limits.maximum_encoded_bytes {
645        return false;
646    }
647    let Ok(value) = serde_json::from_slice::<Value>(&encoded) else {
648        return false;
649    };
650    let mut members = 0;
651    within_value_limits(&value, 1, limits, &mut members)
652}
653
654fn within_value_limits(
655    value: &Value,
656    depth: usize,
657    limits: crate::ClaimValueLimits,
658    members: &mut usize,
659) -> bool {
660    match value {
661        Value::String(value) => value.chars().count() <= limits.maximum_string_length,
662        Value::Null | Value::Bool(_) | Value::Number(_) => true,
663        Value::Array(values) => {
664            depth <= limits.maximum_object_depth
665                && values
666                    .iter()
667                    .all(|value| within_value_limits(value, depth + 1, limits, members))
668        }
669        Value::Object(object) => {
670            if depth > limits.maximum_object_depth {
671                return false;
672            }
673            object.iter().all(|(name, value)| {
674                *members += 1;
675                *members <= limits.maximum_member_count
676                    && name.chars().count() <= limits.maximum_string_length
677                    && within_value_limits(value, depth + 1, limits, members)
678            })
679        }
680    }
681}
682
683fn enroll_response(record: &EnrollmentRecord) -> EnrollResponse {
684    EnrollResponse {
685        additional: BTreeMap::new(),
686        owner_action_required: record.owner_action_required.then_some(StringBoolean::True),
687        requirements_pending: non_empty(&record.requirements_pending),
688        status: record.status,
689        verification_pending: non_empty(&record.verification_pending),
690    }
691}
692
693fn status_response(record: &EnrollmentRecord) -> StatusResponse {
694    StatusResponse {
695        additional: BTreeMap::new(),
696        owner_action_required: record.owner_action_required.then_some(StringBoolean::True),
697        requirements_pending: non_empty(&record.requirements_pending),
698        since: record.since.format(&Rfc3339).ok(),
699        status: record.status,
700        verification_pending: non_empty(&record.verification_pending),
701    }
702}
703
704fn non_empty(values: &[String]) -> Option<Vec<String>> {
705    (!values.is_empty()).then(|| values.to_vec())
706}
707
708fn decision_status(status: EnrollmentDecisionStatus) -> AgentStatus {
709    match status {
710        EnrollmentDecisionStatus::Active => AgentStatus::Active,
711        EnrollmentDecisionStatus::Pending => AgentStatus::Pending,
712        EnrollmentDecisionStatus::Rejected => AgentStatus::Rejected,
713    }
714}
715
716fn blocked_enrollment(enrollment: &EnrollmentRecord) -> Option<ServiceResponse> {
717    let code = match enrollment.status {
718        AgentStatus::Active => return None,
719        AgentStatus::Pending => ErrorCode::VerificationPending,
720        AgentStatus::Rejected => ErrorCode::NotRecognized,
721        AgentStatus::Suspended => ErrorCode::IdentitySuspended,
722        AgentStatus::Terminated => ErrorCode::IdentityTerminated,
723        AgentStatus::Unavailable => ErrorCode::IdentityUnavailable,
724    };
725    let status = if code == ErrorCode::NotRecognized {
726        401
727    } else {
728        403
729    };
730    let mut response = problem(code, status);
731    if enrollment.status == AgentStatus::Pending {
732        let ResponseBody::Problem(details) = &mut response.body else {
733            unreachable!();
734        };
735        details.owner_action_required = enrollment
736            .owner_action_required
737            .then_some(StringBoolean::True);
738        details.requirements_pending = non_empty(&enrollment.requirements_pending);
739        details.verification_pending = non_empty(&enrollment.verification_pending);
740    }
741    Some(response)
742}
743
744fn idempotency_input(
745    agent_did: &str,
746    command: IdempotentCommand,
747    idempotency_key: &str,
748    request: &impl Serialize,
749) -> Result<CommandIdempotencyInput, ServiceError> {
750    let canonical = serde_json::to_vec(request)?;
751    let digest: String = Sha256::digest(canonical)
752        .iter()
753        .map(|byte| format!("{byte:02x}"))
754        .collect();
755    Ok(CommandIdempotencyInput {
756        agent_did: agent_did.to_owned(),
757        command,
758        idempotency_key: idempotency_key.to_owned(),
759        request_hash: format!("sha256:{digest}"),
760    })
761}
762
763fn idempotency_response(result: CommandIdempotencyResult) -> ServiceResponse {
764    match result {
765        CommandIdempotencyResult::Created(response)
766        | CommandIdempotencyResult::Replayed(response) => response,
767        CommandIdempotencyResult::Conflict => problem(ErrorCode::IdempotencyConflict, 409),
768    }
769}
770
771fn success(body: ResponseBody) -> ServiceResponse {
772    let mut headers = HeaderMap::new();
773    headers.insert(
774        header::CONTENT_TYPE,
775        HeaderValue::from_static(body.content_type()),
776    );
777    ServiceResponse {
778        body,
779        headers,
780        status: 200,
781    }
782}
783
784fn require_unique<'a>(
785    values: impl IntoIterator<Item = &'a str>,
786    name: &str,
787) -> Result<(), ServiceError> {
788    let mut unique = std::collections::BTreeSet::new();
789    if values.into_iter().any(|value| !unique.insert(value)) {
790        return Err(ServiceError::InvalidConfiguration(format!(
791            "{name} must not contain duplicates"
792        )));
793    }
794    Ok(())
795}