Skip to main content

solid_pod_rs/wac/
conditions.rs

1//! WAC 2.0 conditions framework.
2//!
3//! Reference: <https://webacl.org/secure-access-conditions/>
4//!
5//! The framework models authorisation predicates beyond the classic
6//! `acl:agent`/`acl:agentGroup`/`acl:agentClass` triad. Each condition
7//! evaluates to one of three outcomes:
8//!
9//! * `Satisfied` — the predicate holds; continue evaluating other rules.
10//! * `Denied` — the predicate explicitly does not hold.
11//! * `NotApplicable` — the server does not recognise the condition type,
12//!   so it cannot make a ruling. Per the WAC 2.0 fail-closed rule, a
13//!   `NotApplicable` outcome causes the host authorisation to be
14//!   skipped (i.e. it must not grant).
15//!
16//! Conjunctive semantics: for a rule bearing `N` conditions, every
17//! condition must return `Satisfied` for the rule to grant.
18
19use serde::{Deserialize, Serialize};
20
21use crate::wac::client::{ClientConditionBody, ClientConditionEvaluator};
22use crate::wac::document::AclDocument;
23use crate::wac::evaluator::GroupMembership;
24use crate::wac::issuer::{IssuerConditionBody, IssuerConditionEvaluator};
25
26/// Outcome of evaluating a single `acl:condition` predicate.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum ConditionOutcome {
29    /// Predicate holds for the current request.
30    Satisfied,
31    /// Server cannot dispatch (unknown condition type / no evaluator).
32    /// Fail-closed: the host authorisation does NOT grant.
33    NotApplicable,
34    /// Predicate explicitly does not hold.
35    Denied,
36}
37
38/// Discriminated union of recognised condition types.
39///
40/// Parsed from the `@type` discriminator in JSON-LD. Unknown types
41/// land in the [`Condition::Unknown`] arm with the offending IRI
42/// preserved so the document still parses, dispatch returns
43/// `NotApplicable`, and the write-time validator can echo the
44/// rejected type verbatim in a 422 response.
45#[derive(Debug, Clone)]
46pub enum Condition {
47    /// `acl:ClientCondition` — gate on the requesting client identity.
48    Client(ClientConditionBody),
49
50    /// `acl:IssuerCondition` — gate on the token issuer.
51    Issuer(IssuerConditionBody),
52
53    /// Any condition type the server does not recognise. The `type_iri`
54    /// is preserved verbatim from the `@type` (or Turtle `rdf:type`)
55    /// discriminator.
56    Unknown {
57        /// IRI of the rejected condition type (`@type` value).
58        type_iri: String,
59    },
60}
61
62impl Condition {
63    /// Canonical IRI of the `@type` discriminator for this condition.
64    /// Used by the serialiser and by `validate_acl_document` when
65    /// reporting 422 rejections.
66    pub fn type_iri(&self) -> &str {
67        match self {
68            Condition::Client(_) => "acl:ClientCondition",
69            Condition::Issuer(_) => "acl:IssuerCondition",
70            Condition::Unknown { type_iri } => type_iri.as_str(),
71        }
72    }
73}
74
75// ---------------------------------------------------------------------------
76// Manual (de)serialisation.
77//
78// Rationale: the previous derive-based `#[serde(other)] Unknown` variant
79// cannot carry the rejected IRI because `serde(other)` is restricted to
80// unit variants. Sprint-9 row 56 requires a 422 response that echoes
81// the exact unsupported IRI, so we route JSON through an intermediate
82// `serde_json::Value` and inspect the discriminator ourselves.
83// ---------------------------------------------------------------------------
84
85impl Serialize for Condition {
86    fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
87        use serde::ser::SerializeMap;
88        match self {
89            Condition::Client(body) => {
90                let mut m = ser.serialize_map(None)?;
91                m.serialize_entry("@type", "acl:ClientCondition")?;
92                if let Some(v) = &body.client {
93                    m.serialize_entry("acl:client", v)?;
94                }
95                if let Some(v) = &body.client_group {
96                    m.serialize_entry("acl:clientGroup", v)?;
97                }
98                if let Some(v) = &body.client_class {
99                    m.serialize_entry("acl:clientClass", v)?;
100                }
101                m.end()
102            }
103            Condition::Issuer(body) => {
104                let mut m = ser.serialize_map(None)?;
105                m.serialize_entry("@type", "acl:IssuerCondition")?;
106                if let Some(v) = &body.issuer {
107                    m.serialize_entry("acl:issuer", v)?;
108                }
109                if let Some(v) = &body.issuer_group {
110                    m.serialize_entry("acl:issuerGroup", v)?;
111                }
112                if let Some(v) = &body.issuer_class {
113                    m.serialize_entry("acl:issuerClass", v)?;
114                }
115                m.end()
116            }
117            Condition::Unknown { type_iri } => {
118                let mut m = ser.serialize_map(Some(1))?;
119                m.serialize_entry("@type", type_iri)?;
120                m.end()
121            }
122        }
123    }
124}
125
126impl<'de> Deserialize<'de> for Condition {
127    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
128        let raw: serde_json::Value = Deserialize::deserialize(de)?;
129        let obj = raw.as_object().ok_or_else(|| {
130            serde::de::Error::custom("acl:condition entry must be a JSON object")
131        })?;
132        let type_iri_value = obj
133            .get("@type")
134            .ok_or_else(|| serde::de::Error::custom("acl:condition missing @type"))?;
135        let type_iri_str = type_iri_value.as_str().ok_or_else(|| {
136            serde::de::Error::custom("acl:condition @type must be a string")
137        })?;
138        let matches_client = matches!(
139            type_iri_str,
140            "acl:ClientCondition"
141                | "http://www.w3.org/ns/auth/acl#ClientCondition"
142                | "https://www.w3.org/ns/auth/acl#ClientCondition"
143        );
144        let matches_issuer = matches!(
145            type_iri_str,
146            "acl:IssuerCondition"
147                | "http://www.w3.org/ns/auth/acl#IssuerCondition"
148                | "https://www.w3.org/ns/auth/acl#IssuerCondition"
149        );
150        if matches_client {
151            let body =
152                ClientConditionBody::deserialize(raw).map_err(serde::de::Error::custom)?;
153            Ok(Condition::Client(body))
154        } else if matches_issuer {
155            let body =
156                IssuerConditionBody::deserialize(raw).map_err(serde::de::Error::custom)?;
157            Ok(Condition::Issuer(body))
158        } else {
159            Ok(Condition::Unknown {
160                type_iri: type_iri_str.to_string(),
161            })
162        }
163    }
164}
165
166/// Minimal request context passed to every condition evaluator.
167///
168/// Borrowed so the caller does not have to allocate on the hot path.
169#[derive(Debug, Clone, Copy)]
170pub struct RequestContext<'a> {
171    /// Authenticated WebID, if any (`Some` for logged-in requests,
172    /// `None` for anonymous).
173    pub web_id: Option<&'a str>,
174    /// OAuth/OIDC client identifier from the access token's `azp` /
175    /// `client_id` claim (or DPoP key thumbprint bound WebID).
176    pub client_id: Option<&'a str>,
177    /// Token issuer — the `iss` claim.
178    pub issuer: Option<&'a str>,
179}
180
181/// Registry-facing dispatcher trait. Separate from the concrete
182/// registry so tests can substitute a mock dispatcher without the full
183/// evaluator wiring.
184pub trait ConditionDispatcher: Send + Sync {
185    fn dispatch(
186        &self,
187        cond: &Condition,
188        ctx: &RequestContext<'_>,
189        groups: &dyn GroupMembership,
190    ) -> ConditionOutcome;
191}
192
193/// Registry mapping condition types to their evaluators.
194///
195/// Construct via `ConditionRegistry::new()` and chain
196/// `with_client()`/`with_issuer()` to register the built-in evaluators.
197/// A registry with no evaluators registered returns `NotApplicable`
198/// for every condition — which means any rule bearing conditions
199/// fails closed. That is intentional: it is the safe default for
200/// servers that have not yet opted into WAC 2.0.
201#[derive(Default)]
202pub struct ConditionRegistry {
203    client_eval: Option<ClientConditionEvaluator>,
204    issuer_eval: Option<IssuerConditionEvaluator>,
205}
206
207impl ConditionRegistry {
208    pub fn new() -> Self {
209        Self::default()
210    }
211
212    /// Register the default built-in client-condition evaluator.
213    pub fn with_client(mut self, e: ClientConditionEvaluator) -> Self {
214        self.client_eval = Some(e);
215        self
216    }
217
218    /// Register the default built-in issuer-condition evaluator.
219    pub fn with_issuer(mut self, e: IssuerConditionEvaluator) -> Self {
220        self.issuer_eval = Some(e);
221        self
222    }
223
224    /// Convenience constructor enabling both built-ins. Used by most
225    /// call sites and tests.
226    pub fn default_with_client_and_issuer() -> Self {
227        Self::new()
228            .with_client(ClientConditionEvaluator)
229            .with_issuer(IssuerConditionEvaluator)
230    }
231
232    /// List of condition-type IRIs the registry can dispatch. Used by
233    /// `validate_for_write` to tell callers which types a 422 response
234    /// is rejecting.
235    pub fn supported_iris(&self) -> Vec<&'static str> {
236        let mut s: Vec<&'static str> = Vec::new();
237        if self.client_eval.is_some() {
238            s.push("acl:ClientCondition");
239        }
240        if self.issuer_eval.is_some() {
241            s.push("acl:IssuerCondition");
242        }
243        s
244    }
245}
246
247impl ConditionDispatcher for ConditionRegistry {
248    fn dispatch(
249        &self,
250        cond: &Condition,
251        ctx: &RequestContext<'_>,
252        groups: &dyn GroupMembership,
253    ) -> ConditionOutcome {
254        match cond {
255            Condition::Client(body) => match &self.client_eval {
256                Some(e) => e.evaluate(body, ctx, groups),
257                None => ConditionOutcome::NotApplicable,
258            },
259            Condition::Issuer(body) => match &self.issuer_eval {
260                Some(e) => e.evaluate(body, ctx, groups),
261                None => ConditionOutcome::NotApplicable,
262            },
263            Condition::Unknown { .. } => ConditionOutcome::NotApplicable,
264        }
265    }
266}
267
268/// Empty dispatcher — returns `NotApplicable` for every call. Used by
269/// the legacy `evaluate_access` entry point so that pre-WAC-2.0 callers
270/// keep behaving identically (no conditions registered → any rule with
271/// conditions fails closed, which for WAC 1.x documents is a no-op).
272pub struct EmptyDispatcher;
273impl ConditionDispatcher for EmptyDispatcher {
274    fn dispatch(
275        &self,
276        _cond: &Condition,
277        _ctx: &RequestContext<'_>,
278        _groups: &dyn GroupMembership,
279    ) -> ConditionOutcome {
280        ConditionOutcome::NotApplicable
281    }
282}
283
284/// Raised by `validate_for_write` when a document carries a condition
285/// type the registry cannot dispatch. Handlers surface this as 422
286/// Unprocessable Entity with the offending IRI in the body.
287#[derive(Debug, thiserror::Error)]
288#[error("unsupported acl:condition type: {iri}")]
289pub struct UnsupportedCondition {
290    pub iri: String,
291}
292
293/// Write-time gatekeeper. Walks every authorisation in the document
294/// and ensures every attached condition parses to a known variant.
295///
296/// Returns the first [`Condition::Unknown`] encountered, with the
297/// exact rejected `@type` IRI preserved, so handlers can echo it in a
298/// 422 Unprocessable Entity response.
299pub fn validate_for_write(
300    doc: &AclDocument,
301    _registry: &ConditionRegistry,
302) -> Result<(), UnsupportedCondition> {
303    let Some(graph) = &doc.graph else {
304        return Ok(());
305    };
306    for auth in graph {
307        if let Some(conds) = &auth.condition {
308            for c in conds {
309                if let Condition::Unknown { type_iri } = c {
310                    return Err(UnsupportedCondition {
311                        iri: type_iri.clone(),
312                    });
313                }
314            }
315        }
316    }
317    Ok(())
318}
319
320/// Validate an ACL document in the shape a handler receives on PUT
321/// `.acl`. Returns [`UnsupportedCondition`] for the first unknown
322/// `acl:condition` type; handlers map this to 422 Unprocessable Entity
323/// with the offending IRI in the response body.
324///
325/// Uses the default registry (client + issuer condition evaluators
326/// enabled). Consumers with a customised registry should call
327/// [`validate_for_write`] directly.
328pub fn validate_acl_document(doc: &AclDocument) -> Result<(), UnsupportedCondition> {
329    validate_for_write(doc, &ConditionRegistry::default_with_client_and_issuer())
330}