Skip to main content

solid_pod_rs/wac/
evaluator.rs

1//! Core WAC evaluation engine.
2//!
3//! Shared between WAC 1.x (`evaluate_access`) and WAC 2.0
4//! (`evaluate_access_ctx`). The 1.x entry point is a thin shim that
5//! constructs an empty request context and an empty dispatcher so any
6//! rule bearing conditions fails closed.
7
8use crate::wac::conditions::{
9    ConditionDispatcher, ConditionOutcome, ConditionRegistry, EmptyDispatcher, RequestContext,
10};
11use crate::wac::document::{get_ids, AclAuthorization, AclDocument};
12use crate::wac::origin;
13use crate::wac::{map_mode, AccessMode};
14
15/// Synchronous group membership lookup used by
16/// `evaluate_access_with_groups` and by condition evaluators
17/// (`acl:clientGroup`, `acl:issuerGroup`).
18///
19/// Implementors resolve a group IRI (typically a `vcard:Group`
20/// document) against a subject URI and return whether the subject is
21/// a member. The default no-op implementation returns `false` for
22/// every call.
23pub trait GroupMembership {
24    /// Return `true` if `agent_uri` is a member of the group identified by `group_iri`.
25    fn is_member(&self, group_iri: &str, agent_uri: &str) -> bool;
26}
27
28pub(crate) struct NoGroupMembership;
29impl GroupMembership for NoGroupMembership {
30    fn is_member(&self, _group_iri: &str, _agent_uri: &str) -> bool {
31        false
32    }
33}
34
35/// Static group-membership resolver used in tests and by pods that
36/// resolve group documents eagerly into an in-memory map.
37#[derive(Debug, Default, Clone)]
38pub struct StaticGroupMembership {
39    /// Map from group IRI to the list of member WebIDs.
40    pub groups: std::collections::HashMap<String, Vec<String>>,
41}
42
43impl StaticGroupMembership {
44    /// Create an empty group membership resolver.
45    pub fn new() -> Self {
46        Self::default()
47    }
48    /// Register `members` under `group_iri`, replacing any previous entry.
49    pub fn add(&mut self, group_iri: impl Into<String>, members: Vec<String>) {
50        self.groups.insert(group_iri.into(), members);
51    }
52}
53
54impl GroupMembership for StaticGroupMembership {
55    fn is_member(&self, group_iri: &str, agent_uri: &str) -> bool {
56        self.groups
57            .get(group_iri)
58            .map(|m| m.iter().any(|x| x == agent_uri))
59            .unwrap_or(false)
60    }
61}
62
63pub(crate) fn normalize_path(path: &str) -> String {
64    let stripped = path.strip_prefix("./").or_else(|| path.strip_prefix('.'));
65    let base = match stripped {
66        Some("") => "/".to_string(),
67        Some(s) if !s.starts_with('/') => format!("/{s}"),
68        Some(s) => s.to_string(),
69        None => path.to_string(),
70    };
71    let trimmed = base.trim_end_matches('/');
72    if trimmed.is_empty() {
73        "/".to_string()
74    } else {
75        trimmed.to_string()
76    }
77}
78
79pub(crate) fn path_matches(rule_path: &str, resource_path: &str, is_default: bool) -> bool {
80    let rule = normalize_path(rule_path);
81    let resource = normalize_path(resource_path);
82    if resource == rule {
83        return true;
84    }
85    // `acl:accessTo` covers exact match plus direct children of a
86    // container target — NOT deep descendants (WAC §4.2; cf. tests
87    // `access_to_does_not_inherit_by_itself` and
88    // `access_to_on_container_covers_direct_children`).
89    // `acl:default`, by contrast, applies recursively.
90    if !is_default {
91        let prefix = if rule == "/" {
92            String::from("/")
93        } else {
94            format!("{rule}/")
95        };
96        if let Some(rest) = resource.strip_prefix(&prefix) {
97            return !rest.is_empty() && !rest.contains('/');
98        }
99        return false;
100    }
101    if rule == "/" {
102        resource.starts_with('/')
103    } else {
104        resource.starts_with(&format!("{rule}/"))
105    }
106}
107
108pub(crate) fn get_modes(auth: &AclAuthorization) -> Vec<AccessMode> {
109    let mut modes = Vec::new();
110    for mode_ref in get_ids(&auth.mode) {
111        modes.extend_from_slice(map_mode(mode_ref));
112    }
113    modes
114}
115
116fn agent_matches_with_groups(
117    auth: &AclAuthorization,
118    agent_uri: Option<&str>,
119    groups: &dyn GroupMembership,
120) -> bool {
121    let agents = get_ids(&auth.agent);
122    if let Some(uri) = agent_uri {
123        if agents.contains(&uri) {
124            return true;
125        }
126    }
127    for cls in get_ids(&auth.agent_class) {
128        if cls == "foaf:Agent" || cls == "http://xmlns.com/foaf/0.1/Agent" {
129            return true;
130        }
131        if agent_uri.is_some()
132            && (cls == "acl:AuthenticatedAgent"
133                || cls == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent")
134        {
135            return true;
136        }
137    }
138    if let Some(uri) = agent_uri {
139        for group_iri in get_ids(&auth.agent_group) {
140            if groups.is_member(group_iri, uri) {
141                return true;
142            }
143        }
144    }
145    false
146}
147
148/// Evaluate whether access should be granted (WAC 1.x entry point).
149///
150/// The `request_origin` parameter carries the RFC 6454 origin from the
151/// HTTP `Origin:` header; pass `None` for request paths that have no
152/// origin context (e.g. server-to-server calls or tests). When the
153/// `acl-origin` feature is enabled, any ACL that declares `acl:origin`
154/// triples gates access on the request origin per WAC §4.3.
155///
156/// Note: WAC 2.0 documents with `acl:condition` triples will fail
157/// closed under this entry point because it wires an `EmptyDispatcher`.
158/// Use `evaluate_access_ctx` for WAC 2.0 evaluation.
159pub fn evaluate_access(
160    acl_doc: Option<&AclDocument>,
161    agent_uri: Option<&str>,
162    resource_path: &str,
163    required_mode: AccessMode,
164    request_origin: Option<&origin::Origin>,
165) -> bool {
166    evaluate_access_with_groups(
167        acl_doc,
168        agent_uri,
169        resource_path,
170        required_mode,
171        request_origin,
172        &NoGroupMembership,
173    )
174}
175
176/// WAC 1.x evaluation with a caller-supplied group resolver. Rules
177/// bearing `acl:condition` triples fail closed (empty dispatcher).
178pub fn evaluate_access_with_groups(
179    acl_doc: Option<&AclDocument>,
180    agent_uri: Option<&str>,
181    resource_path: &str,
182    required_mode: AccessMode,
183    request_origin: Option<&origin::Origin>,
184    groups: &dyn GroupMembership,
185) -> bool {
186    let ctx = RequestContext {
187        web_id: agent_uri,
188        client_id: None,
189        issuer: None,
190    };
191    evaluate_access_ctx_inner(
192        acl_doc,
193        &ctx,
194        resource_path,
195        required_mode,
196        request_origin,
197        groups,
198        &EmptyDispatcher,
199    )
200}
201
202/// WAC 2.0 evaluation entry point. Accepts a `RequestContext` carrying
203/// WebID / client / issuer, plus a `ConditionDispatcher` (typically a
204/// `ConditionRegistry`).
205///
206/// Conjunctive semantics: for every authorisation whose agent+mode+path
207/// predicates match, each attached `acl:condition` must dispatch to
208/// `Satisfied` for the rule to grant. Any `NotApplicable` or `Denied`
209/// outcome causes the rule to be skipped.
210#[allow(clippy::too_many_arguments)]
211pub fn evaluate_access_ctx(
212    acl_doc: Option<&AclDocument>,
213    ctx: &RequestContext<'_>,
214    resource_path: &str,
215    required_mode: AccessMode,
216    request_origin: Option<&origin::Origin>,
217    groups: &dyn GroupMembership,
218    dispatcher: &dyn ConditionDispatcher,
219) -> bool {
220    evaluate_access_ctx_inner(
221        acl_doc,
222        ctx,
223        resource_path,
224        required_mode,
225        request_origin,
226        groups,
227        dispatcher,
228    )
229}
230
231/// Convenience wrapper that takes a `ConditionRegistry` directly.
232#[allow(clippy::too_many_arguments)]
233pub fn evaluate_access_ctx_with_registry(
234    acl_doc: Option<&AclDocument>,
235    ctx: &RequestContext<'_>,
236    resource_path: &str,
237    required_mode: AccessMode,
238    request_origin: Option<&origin::Origin>,
239    groups: &dyn GroupMembership,
240    registry: &ConditionRegistry,
241) -> bool {
242    evaluate_access_ctx_inner(
243        acl_doc,
244        ctx,
245        resource_path,
246        required_mode,
247        request_origin,
248        groups,
249        registry,
250    )
251}
252
253#[allow(clippy::too_many_arguments)]
254fn evaluate_access_ctx_inner(
255    acl_doc: Option<&AclDocument>,
256    ctx: &RequestContext<'_>,
257    resource_path: &str,
258    required_mode: AccessMode,
259    request_origin: Option<&origin::Origin>,
260    groups: &dyn GroupMembership,
261    dispatcher: &dyn ConditionDispatcher,
262) -> bool {
263    let Some(doc) = acl_doc else {
264        return false;
265    };
266    let Some(graph) = doc.graph.as_ref() else {
267        return false;
268    };
269    let mut base_grant = false;
270    for auth in graph {
271        let granted = get_modes(auth);
272        if !granted.contains(&required_mode) {
273            continue;
274        }
275        if !agent_matches_with_groups(auth, ctx.web_id, groups) {
276            continue;
277        }
278        let mut path_ok = false;
279        for target in get_ids(&auth.access_to) {
280            if path_matches(target, resource_path, false) {
281                path_ok = true;
282                break;
283            }
284        }
285        if !path_ok {
286            for target in get_ids(&auth.default) {
287                if path_matches(target, resource_path, true) {
288                    path_ok = true;
289                    break;
290                }
291            }
292        }
293        if !path_ok {
294            continue;
295        }
296
297        // WAC 2.0 conjunctive condition gate. All conditions must
298        // return `Satisfied`. Any `NotApplicable` or `Denied` skips
299        // this authorisation (fail-closed).
300        let mut conditions_ok = true;
301        if let Some(conds) = &auth.condition {
302            for cond in conds {
303                match dispatcher.dispatch(cond, ctx, groups) {
304                    ConditionOutcome::Satisfied => continue,
305                    ConditionOutcome::NotApplicable | ConditionOutcome::Denied => {
306                        conditions_ok = false;
307                        break;
308                    }
309                }
310            }
311        }
312        if !conditions_ok {
313            continue;
314        }
315
316        base_grant = true;
317        break;
318    }
319    if !base_grant {
320        return false;
321    }
322
323    // WAC §4.3 invariant 4: Control mode bypasses the origin gate so
324    // that an owner can always fix a mis-configured ACL from any
325    // origin.
326    if matches!(required_mode, AccessMode::Control) {
327        return true;
328    }
329
330    // F4 — origin gate. Only active behind the `acl-origin` feature;
331    // otherwise behave exactly as pre-F4 to preserve backward compat.
332    #[cfg(feature = "acl-origin")]
333    {
334        match origin::check_origin(doc, request_origin) {
335            origin::OriginDecision::NoPolicySet | origin::OriginDecision::Permitted => true,
336            origin::OriginDecision::RejectedMismatch
337            | origin::OriginDecision::RejectedNoOrigin => {
338                crate::wac::metrics::ACL_ORIGIN_REJECTED_TOTAL
339                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
340                false
341            }
342        }
343    }
344    #[cfg(not(feature = "acl-origin"))]
345    {
346        let _ = request_origin;
347        true
348    }
349}