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}