Skip to main content

termivar_scanner/web_runtime/
assessment_api_visibility.rs

1//! Root-scoped authorization-context review for the bounded assessment runtime.
2//!
3//! This module composes the existing API visibility comparator under the
4//! assessment's shared authority. It never creates a second broker or budget,
5//! never serializes credential material, and projects only the comparator's
6//! single canonical paired-comparison evidence object.
7
8use std::fmt;
9
10use termivar_core::{ApiVisibilityDimension, EntityId};
11use thiserror::Error;
12use url::Url;
13
14use crate::{
15    ApiAuthorizationContextPairStrategy, ApiComparisonProfile, ApiObservationCommitReceipt,
16    ApiVisibilityContextProbe, ApiVisibilityDifferentialDisposition,
17    ApiVisibilityDifferentialRequest, ApiVisibilityReview, ApiVisibilityReviewDisposition,
18    HttpProbe, HttpProbeMethod, PayloadSeed, PayloadStrategy, PayloadStrategyLimits,
19    PayloadVariantRole, RuntimeApiVisibilityExecutionError, StandardWebDecisionRuntime,
20    API_AUTHORIZATION_CONTEXT_PAIR_HEADER_NAME,
21};
22
23use super::{
24    assessment_item::{
25        AssessmentCapabilityDescriptor, AssessmentItemProjectionError, AssessmentProjectionContext,
26    },
27    SharedWebRuntimeAuthority,
28};
29
30const ROOT_COMPARISON_ID: &str = "web-assessment-root-authorization-context@1";
31const ANONYMOUS_CONTEXT_ID: &str = "context:web-assessment:anonymous";
32const AUTHORIZED_CONTEXT_ID: &str = "context:web-assessment:host-authorized";
33
34const API_AUTHORIZATION_VISIBILITY_REVIEW: AssessmentCapabilityDescriptor =
35    AssessmentCapabilityDescriptor::differential_review(
36        "api.review.authorization-context.visibility-difference@1",
37        "Authorization contexts returned different JSON visibility",
38        "api-authorization-context",
39        "One atomic anonymous/authorized JSON comparison indicates a visibility difference that may be intentional and requires policy review.",
40        None,
41        1_000_000,
42        None,
43        "api.remediation.authorization-context-policy@1",
44        "Review the intended authorization policy for this resource and verify that each context receives only its permitted fields.",
45    );
46
47/// Host-supplied complete `Authorization` header value for the authorized root.
48///
49/// The value is accepted only as bounded, visible ASCII through the existing
50/// `api.authorization.context-pair@1` strategy. This type intentionally
51/// implements neither `Clone` nor `Serialize`; its debug representation is
52/// fully redacted.
53pub struct WebAssessmentRootAuthorizationContext {
54    candidate_header_value: String,
55}
56
57impl WebAssessmentRootAuthorizationContext {
58    /// Validates one complete header value without performing network I/O.
59    pub fn new(value: impl Into<Vec<u8>>) -> Result<Self, WebAssessmentAuthorizationContextError> {
60        let limits = PayloadStrategyLimits::default();
61        let seed = PayloadSeed::new(value, limits)
62            .map_err(|_| WebAssessmentAuthorizationContextError::InvalidValue)?;
63        let strategy = ApiAuthorizationContextPairStrategy::new();
64        let control = strategy
65            .derive_one(PayloadVariantRole::Control, &seed, limits)
66            .map_err(|_| WebAssessmentAuthorizationContextError::InvalidValue)?;
67        let candidate = strategy
68            .derive_one(PayloadVariantRole::Candidate, &seed, limits)
69            .map_err(|_| WebAssessmentAuthorizationContextError::InvalidValue)?;
70        if !control.as_bytes().is_empty()
71            || control.role() != PayloadVariantRole::Control
72            || candidate.role() != PayloadVariantRole::Candidate
73        {
74            return Err(WebAssessmentAuthorizationContextError::InvalidValue);
75        }
76        let candidate_header_value = String::from_utf8(candidate.as_bytes().to_vec())
77            .map_err(|_| WebAssessmentAuthorizationContextError::InvalidValue)?;
78        Ok(Self {
79            candidate_header_value,
80        })
81    }
82
83    fn into_candidate_header_value(self) -> String {
84        self.candidate_header_value
85    }
86}
87
88impl fmt::Debug for WebAssessmentRootAuthorizationContext {
89    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90        formatter.write_str("WebAssessmentRootAuthorizationContext(<redacted>)")
91    }
92}
93
94/// Static, value-free authorization-context validation failure.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
96#[non_exhaustive]
97pub enum WebAssessmentAuthorizationContextError {
98    #[error("authorization context is not a bounded safe HTTP header value")]
99    InvalidValue,
100}
101
102pub(super) struct RootApiVisibilityRuntime {
103    runtime: StandardWebDecisionRuntime,
104    request: Option<ApiVisibilityDifferentialRequest>,
105}
106
107impl fmt::Debug for RootApiVisibilityRuntime {
108    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
109        formatter
110            .debug_struct("RootApiVisibilityRuntime")
111            .field("runtime", &"<shared-authority>")
112            .field("request", &"<redacted>")
113            .finish()
114    }
115}
116
117impl RootApiVisibilityRuntime {
118    pub(super) async fn execute(
119        &mut self,
120    ) -> Result<RootApiVisibilityOutcome, RuntimeApiVisibilityExecutionError> {
121        let request = self
122            .request
123            .take()
124            .ok_or(RuntimeApiVisibilityExecutionError::AlreadyStarted)?;
125        let report = self.runtime.run_api_visibility_pair(request).await?;
126        Ok(RootApiVisibilityOutcome::from_report(&report))
127    }
128}
129
130pub(super) fn build_root_api_visibility_runtime(
131    target: &Url,
132    resource_scope: EntityId,
133    authority: SharedWebRuntimeAuthority,
134    context: WebAssessmentRootAuthorizationContext,
135) -> Result<RootApiVisibilityRuntime, RootApiVisibilityCompositionError> {
136    let control = HttpProbe::new(target.clone(), HttpProbeMethod::Get)
137        .and_then(|probe| probe.with_header("accept", "application/json"))
138        .map_err(|_| RootApiVisibilityCompositionError)?;
139    let candidate = HttpProbe::new(target.clone(), HttpProbeMethod::Get)
140        .and_then(|probe| probe.with_header("accept", "application/json"))
141        .and_then(|probe| {
142            probe.with_header(
143                API_AUTHORIZATION_CONTEXT_PAIR_HEADER_NAME,
144                context.into_candidate_header_value(),
145            )
146        })
147        .map_err(|_| RootApiVisibilityCompositionError)?;
148    let request = ApiVisibilityDifferentialRequest::new(
149        ROOT_COMPARISON_ID,
150        resource_scope,
151        ApiVisibilityContextProbe::new(ANONYMOUS_CONTEXT_ID, control)
152            .map_err(|_| RootApiVisibilityCompositionError)?,
153        ApiVisibilityContextProbe::new(AUTHORIZED_CONTEXT_ID, candidate)
154            .map_err(|_| RootApiVisibilityCompositionError)?,
155        [API_AUTHORIZATION_CONTEXT_PAIR_HEADER_NAME],
156        ApiComparisonProfile::default(),
157        ApiVisibilityDimension::Fields,
158        // CLI hosts have no trusted wall-clock observation authority. Runtime
159        // classification and fingerprints deliberately do not depend on this.
160        0,
161    )
162    .map_err(|_| RootApiVisibilityCompositionError)?;
163    let runtime = StandardWebDecisionRuntime::builder(target.clone())
164        .enable_api_reasoning()
165        .build_with_shared_authority(authority)
166        .map_err(|_| RootApiVisibilityCompositionError)?;
167    Ok(RootApiVisibilityRuntime {
168        runtime,
169        request: Some(request),
170    })
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
174#[error("root API authorization-context review could not be composed safely")]
175pub(super) struct RootApiVisibilityCompositionError;
176
177#[derive(Clone)]
178pub(super) struct CommittedAssessmentApiVisibility {
179    commit: ApiObservationCommitReceipt,
180    review: ApiVisibilityReview,
181}
182
183impl fmt::Debug for CommittedAssessmentApiVisibility {
184    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185        formatter
186            .debug_struct("CommittedAssessmentApiVisibility")
187            .field("commit", &"<redacted>")
188            .field("review", &"<redacted>")
189            .finish()
190    }
191}
192
193impl CommittedAssessmentApiVisibility {
194    pub(super) fn commit(&self) -> &ApiObservationCommitReceipt {
195        &self.commit
196    }
197
198    pub(super) fn review(&self) -> &ApiVisibilityReview {
199        &self.review
200    }
201}
202
203#[derive(Debug)]
204pub(super) enum RootApiVisibilityOutcome {
205    NoDifference,
206    NeedsReview(Box<CommittedAssessmentApiVisibility>),
207    Incomplete,
208    Cancelled,
209    RuntimeLimit(crate::RuntimeBudgetDimension),
210    ContractMismatch,
211}
212
213impl RootApiVisibilityOutcome {
214    fn from_report(report: &crate::RuntimeApiVisibilityRunReport) -> Self {
215        match report.disposition() {
216            ApiVisibilityDifferentialDisposition::NoDifferenceObserved => {
217                if exact_committed_review(
218                    report,
219                    ApiVisibilityReviewDisposition::NoDifferenceObserved,
220                )
221                .is_some()
222                {
223                    Self::NoDifference
224                } else {
225                    Self::ContractMismatch
226                }
227            },
228            ApiVisibilityDifferentialDisposition::AwaitHumanReview => {
229                exact_committed_review(report, ApiVisibilityReviewDisposition::AwaitHumanReview)
230                    .map(|committed| Self::NeedsReview(Box::new(committed)))
231                    .unwrap_or(Self::ContractMismatch)
232            },
233            ApiVisibilityDifferentialDisposition::UnresolvedDifference => {
234                if exact_committed_review(
235                    report,
236                    ApiVisibilityReviewDisposition::UnresolvedDifference,
237                )
238                .is_some()
239                {
240                    Self::Incomplete
241                } else {
242                    Self::ContractMismatch
243                }
244            },
245            ApiVisibilityDifferentialDisposition::Inconclusive => Self::Incomplete,
246            ApiVisibilityDifferentialDisposition::CancelledByHost => Self::Cancelled,
247            ApiVisibilityDifferentialDisposition::RuntimeBudgetLimit => report
248                .limit_exceeded()
249                .map(|limit| Self::RuntimeLimit(limit.dimension()))
250                .unwrap_or(Self::ContractMismatch),
251        }
252    }
253}
254
255fn exact_committed_review(
256    report: &crate::RuntimeApiVisibilityRunReport,
257    expected: ApiVisibilityReviewDisposition,
258) -> Option<CommittedAssessmentApiVisibility> {
259    let observation = report.observation()?;
260    let review = report.review()?;
261    (review.disposition() == expected
262        && review.resource_scope() == observation.commit().resource_scope()
263        && review.comparison_subject() == observation.commit().comparison_subject()
264        && review.relation_id() == observation.commit().relation_id()
265        && review.evidence().id() == observation.commit().evidence_id())
266    .then(|| CommittedAssessmentApiVisibility {
267        commit: observation.commit().clone(),
268        review: review.clone(),
269    })
270}
271
272pub(super) fn project_api_visibility_item(
273    context: &mut AssessmentProjectionContext,
274    knowledge: &crate::KnowledgeBase,
275    authorized_root_subject: &EntityId,
276    committed: &CommittedAssessmentApiVisibility,
277) -> Result<(), AssessmentItemProjectionError> {
278    context.project_api_visibility_paired_comparison(
279        &API_AUTHORIZATION_VISIBILITY_REVIEW,
280        knowledge,
281        authorized_root_subject,
282        committed.commit(),
283        committed.review(),
284    )
285}