Skip to main content

solid_pod_rs/wac/
resolver.rs

1//! ACL resolver — locates the effective ACL document for a given path.
2//!
3//! `find_effective_acl` walks the storage tree from the resource path
4//! up to the root, returning the first `*.acl` sibling that parses as
5//! JSON-LD or Turtle.
6//!
7//! # Typed policy outcomes (ADR-2005)
8//!
9//! The walk-up algorithm has three materially different terminal states, and
10//! collapsing them is an authorisation bug. Before the typed outcomes below
11//! existed, this resolver reported *every* non-success as "no ACL here" and
12//! carried on walking upward. A malformed `/secret.acl` therefore fell
13//! through to a permissive `/.acl` and the caller was **granted** the
14//! ancestor's broader rights — the restrictive policy the operator wrote was
15//! silently replaced by the inherited one it was meant to override. A storage
16//! read failure behaved identically.
17//!
18//! [`PolicyOutcome`] names the three states so a caller can no longer confuse
19//! them:
20//!
21//! | Outcome | Meaning | Required handling |
22//! |---------|---------|-------------------|
23//! | [`Missing`](PolicyOutcome::Missing) | No `.acl` exists at this level | **Inherit** — continue the walk to the ancestor. This is the legitimate WAC §4.2 inheritance path. |
24//! | [`Found`](PolicyOutcome::Found) | A `.acl` exists and parsed | Evaluate it. |
25//! | [`Invalid`](PolicyOutcome::Invalid) | A `.acl` exists but is unparseable or exceeds the parser bounds | **Deny. Never inherit.** A policy the operator authored is present but unreadable; inheriting past it would grant more than the author intended. |
26//! | [`Unavailable`](PolicyOutcome::Unavailable) | The backend could not be read | **Deny / error. Never inherit.** The policy's content is unknown, so no grant can be justified. |
27//!
28//! The distinction that matters is between *absence* (a fact: nothing governs
29//! this level, so the ancestor does) and *failure* (an unknown: something may
30//! govern this level and we cannot read it). Only absence may inherit.
31//!
32//! # Reuse across tiers
33//!
34//! [`classify_policy_read`] is the pure, runtime-free, single-level decision
35//! function. It compiles on the `core` surface, so an edge/WASM tier backed by
36//! KV or R2 rather than [`Storage`] implements the *same* semantics by feeding
37//! it each read instead of re-deriving the rules — which is how the two tiers
38//! drifted apart in the first place. [`resolve_policy_from_storage`] is the
39//! native walk built on top of it.
40
41use async_trait::async_trait;
42
43use crate::error::PodError;
44// `Storage` lives behind `tokio-runtime`; the storage-backed resolver
45// impl below is gated to match. The `AclResolver` trait is pure and
46// remains available under `core` so wasm32 consumers can implement
47// their own KV-backed resolver against the same contract.
48#[cfg(feature = "tokio-runtime")]
49use crate::storage::Storage;
50use crate::wac::document::AclDocument;
51use crate::wac::parse_jsonld_acl;
52use crate::wac::parser::parse_turtle_acl;
53
54// ---------------------------------------------------------------------------
55// Typed policy outcomes (ADR-2005)
56// ---------------------------------------------------------------------------
57
58/// Why a located policy document could not be turned into an [`AclDocument`].
59///
60/// Every variant is terminal: the document exists, so the walk must **not**
61/// continue to an ancestor. Carrying the reason lets a caller distinguish an
62/// operator authoring mistake (`Malformed`) from a resource-exhaustion guard
63/// firing (`TooLarge` / `TooDeep`), which have different operational fixes.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum InvalidPolicyReason {
66    /// The body parsed as neither JSON-LD nor Turtle.
67    Malformed(String),
68    /// The body exceeded the parser's byte cap ([`crate::wac::MAX_ACL_BYTES`]).
69    TooLarge(String),
70    /// The body exceeded the JSON nesting cap
71    /// ([`crate::wac::MAX_ACL_JSON_DEPTH`]).
72    TooDeep(String),
73    /// The body is not valid UTF-8, so it can be neither JSON-LD nor Turtle.
74    NotUtf8,
75}
76
77impl std::fmt::Display for InvalidPolicyReason {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            InvalidPolicyReason::Malformed(m) => write!(f, "malformed ACL document: {m}"),
81            InvalidPolicyReason::TooLarge(m) => write!(f, "ACL document too large: {m}"),
82            InvalidPolicyReason::TooDeep(m) => write!(f, "ACL document too deeply nested: {m}"),
83            InvalidPolicyReason::NotUtf8 => write!(f, "ACL document is not valid UTF-8"),
84        }
85    }
86}
87
88/// The terminal result of resolving the effective policy for a resource.
89///
90/// See the [module docs](self) for the handling contract. The key invariant:
91/// **only [`Missing`](Self::Missing) may inherit.**
92#[derive(Debug, Clone)]
93pub enum PolicyOutcome {
94    /// A policy document was located and parsed. `inherited` on the document
95    /// records whether it came from an ancestor container.
96    Found(AclDocument),
97    /// No policy exists at the resource or at any ancestor up to the root.
98    ///
99    /// This is *absence*, not failure: the walk completed and found nothing.
100    /// The evaluator still denies by default (no ACL grants nothing), but the
101    /// resolution itself succeeded.
102    Missing,
103    /// A policy document exists at `policy_path` but could not be parsed.
104    ///
105    /// Deny. Never inherit past it.
106    Invalid {
107        /// Storage key of the offending `.acl` sidecar.
108        policy_path: String,
109        /// Why the parse failed.
110        reason: InvalidPolicyReason,
111    },
112    /// The backend could not be read at `policy_path`.
113    ///
114    /// Deny or surface an error. Never inherit past it — the policy's content
115    /// is unknown, so no grant can be justified from an ancestor.
116    Unavailable {
117        /// Storage key whose read failed.
118        policy_path: String,
119        /// Backend error detail.
120        detail: String,
121    },
122}
123
124impl PolicyOutcome {
125    /// The parsed document, if one was found.
126    ///
127    /// Returns `None` for every non-`Found` outcome — including the failure
128    /// outcomes. Callers that feed this straight into the evaluator get
129    /// deny-by-default, but they lose the distinction between "nothing governs
130    /// this" and "we could not read what governs this"; prefer matching on the
131    /// outcome directly, or gate on [`Self::is_failure`] first.
132    #[must_use]
133    pub fn document(&self) -> Option<&AclDocument> {
134        match self {
135            PolicyOutcome::Found(doc) => Some(doc),
136            _ => None,
137        }
138    }
139
140    /// `true` for [`Invalid`](Self::Invalid) and [`Unavailable`](Self::Unavailable)
141    /// — the outcomes that must deny rather than inherit.
142    #[must_use]
143    pub fn is_failure(&self) -> bool {
144        matches!(
145            self,
146            PolicyOutcome::Invalid { .. } | PolicyOutcome::Unavailable { .. }
147        )
148    }
149
150    /// `true` when the walk may legitimately fall back to an ancestor or legacy
151    /// policy — i.e. only for [`Missing`](Self::Missing).
152    #[must_use]
153    pub fn may_inherit(&self) -> bool {
154        matches!(self, PolicyOutcome::Missing)
155    }
156
157    /// Collapse to the historical `Result<Option<AclDocument>, PodError>` shape.
158    ///
159    /// `Found` → `Ok(Some)`, `Missing` → `Ok(None)`, and both failure outcomes
160    /// → `Err`. The error mapping keeps the HTTP status the bounded parsers
161    /// already produced (413 for `TooLarge`, 400 for `TooDeep`) so existing
162    /// callers see no status change; `Malformed` / `NotUtf8` become
163    /// [`PodError::AclParse`] and `Unavailable` becomes [`PodError::Backend`].
164    ///
165    /// This is a **lossy but fail-closed** adapter: an `Err` can never be
166    /// mistaken for a grant. New code should match on the outcome instead.
167    pub fn into_result(self) -> Result<Option<AclDocument>, PodError> {
168        match self {
169            PolicyOutcome::Found(doc) => Ok(Some(doc)),
170            PolicyOutcome::Missing => Ok(None),
171            PolicyOutcome::Invalid {
172                policy_path,
173                reason,
174            } => Err(match reason {
175                InvalidPolicyReason::TooLarge(m) => PodError::PayloadTooLarge(m),
176                InvalidPolicyReason::TooDeep(m) => PodError::BadRequest(m),
177                other => PodError::AclParse(format!("{policy_path}: {other}")),
178            }),
179            PolicyOutcome::Unavailable {
180                policy_path,
181                detail,
182            } => Err(PodError::Backend(format!(
183                "ACL read failed at {policy_path}: {detail}"
184            ))),
185        }
186    }
187
188    /// Adapt a legacy `Result<Option<AclDocument>, PodError>` into a typed
189    /// outcome.
190    ///
191    /// Used as the default [`AclResolver::resolve_policy`] body so an existing
192    /// out-of-repo implementor that only supplies `find_effective_acl` keeps
193    /// compiling and still reports fail-closed outcomes. The mapping is the
194    /// inverse of [`Self::into_result`]; it cannot recover *which* ancestor a
195    /// legacy resolver skipped, so such an implementor should override
196    /// `resolve_policy` to get true per-level fidelity.
197    pub fn from_legacy(result: Result<Option<AclDocument>, PodError>, resource_path: &str) -> Self {
198        match result {
199            Ok(Some(doc)) => PolicyOutcome::Found(doc),
200            Ok(None) => PolicyOutcome::Missing,
201            Err(PodError::PayloadTooLarge(m)) => PolicyOutcome::Invalid {
202                policy_path: resource_path.to_string(),
203                reason: InvalidPolicyReason::TooLarge(m),
204            },
205            Err(PodError::BadRequest(m)) => PolicyOutcome::Invalid {
206                policy_path: resource_path.to_string(),
207                reason: InvalidPolicyReason::TooDeep(m),
208            },
209            Err(PodError::AclParse(m)) => PolicyOutcome::Invalid {
210                policy_path: resource_path.to_string(),
211                reason: InvalidPolicyReason::Malformed(m),
212            },
213            Err(e) => PolicyOutcome::Unavailable {
214                policy_path: resource_path.to_string(),
215                detail: e.to_string(),
216            },
217        }
218    }
219}
220
221/// The result of a single-level policy read, as observed by whichever backend
222/// the tier uses.
223///
224/// Constructed by the caller from its own storage API and handed to
225/// [`classify_policy_read`]. Deliberately carries no runtime types so the edge
226/// tier can build it from a KV/R2 response.
227#[derive(Debug)]
228pub enum PolicyRead<'a> {
229    /// The backend confirmed no `.acl` exists at this level. This is the ONLY
230    /// input that permits the walk to continue upward.
231    Absent,
232    /// A `.acl` body was read.
233    Present {
234        /// Raw body bytes.
235        body: &'a [u8],
236        /// The stored `Content-Type`, used to decide whether to try Turtle.
237        content_type: &'a str,
238    },
239    /// The read itself failed (I/O error, timeout, permission denied, a KV
240    /// error that is not "key absent"). Never treat this as [`PolicyRead::Absent`].
241    Failed(String),
242}
243
244/// What the walk should do after classifying one level.
245#[derive(Debug)]
246pub enum PolicyStep {
247    /// Nothing governs this level; continue to the ancestor. Emitted only for
248    /// [`PolicyRead::Absent`].
249    Ascend,
250    /// A terminal outcome was reached; stop the walk and return it.
251    Settled(PolicyOutcome),
252}
253
254/// Classify one level of the WAC walk-up. Pure and runtime-free.
255///
256/// `policy_path` is the storage key that was read (used only for diagnostics).
257/// `inherited` records whether this level is an ancestor container rather than
258/// the resource's own sidecar; it is stamped onto a successfully parsed
259/// document so the evaluator honours only `acl:default` rules (WAC §4.2).
260///
261/// A present body is tried as JSON-LD first, then — only when the stored
262/// content type or the body itself looks like Turtle — as Turtle. A body that
263/// satisfies neither is [`InvalidPolicyReason::Malformed`], **not** a miss:
264/// this is precisely the case that used to inherit a broader ancestor grant.
265pub fn classify_policy_read(
266    policy_path: &str,
267    read: PolicyRead<'_>,
268    inherited: bool,
269) -> PolicyStep {
270    let (body, content_type) = match read {
271        // Absence is a fact, and the only input that may inherit.
272        PolicyRead::Absent => return PolicyStep::Ascend,
273        // A failed read is an unknown, never a miss.
274        PolicyRead::Failed(detail) => {
275            return PolicyStep::Settled(PolicyOutcome::Unavailable {
276                policy_path: policy_path.to_string(),
277                detail,
278            })
279        }
280        PolicyRead::Present { body, content_type } => (body, content_type),
281    };
282
283    let invalid = |reason: InvalidPolicyReason| {
284        PolicyStep::Settled(PolicyOutcome::Invalid {
285            policy_path: policy_path.to_string(),
286            reason,
287        })
288    };
289
290    // JSON-LD first, through the bounded parser. Bound violations are
291    // terminal in their own right — an oversized or depth-bombed ACL must
292    // never fall through to the ancestor.
293    match parse_jsonld_acl(body) {
294        Ok(mut doc) => {
295            doc.inherited = inherited;
296            return PolicyStep::Settled(PolicyOutcome::Found(doc));
297        }
298        Err(PodError::PayloadTooLarge(m)) => return invalid(InvalidPolicyReason::TooLarge(m)),
299        Err(PodError::BadRequest(m)) => return invalid(InvalidPolicyReason::TooDeep(m)),
300        // Not JSON-LD — fall through to the Turtle attempt below.
301        Err(_) => {}
302    }
303
304    let Ok(text) = std::str::from_utf8(body) else {
305        return invalid(InvalidPolicyReason::NotUtf8);
306    };
307
308    let ct = content_type.to_ascii_lowercase();
309    let looks_turtle = ct.starts_with("text/turtle")
310        || ct.starts_with("application/turtle")
311        || ct.starts_with("application/x-turtle");
312    if !looks_turtle && !text.contains("@prefix") && !text.contains("acl:Authorization") {
313        return invalid(InvalidPolicyReason::Malformed(
314            "body is neither JSON-LD nor Turtle".into(),
315        ));
316    }
317
318    match parse_turtle_acl(text) {
319        Ok(mut doc) => {
320            doc.inherited = inherited;
321            PolicyStep::Settled(PolicyOutcome::Found(doc))
322        }
323        Err(PodError::PayloadTooLarge(m)) => invalid(InvalidPolicyReason::TooLarge(m)),
324        Err(e) => invalid(InvalidPolicyReason::Malformed(e.to_string())),
325    }
326}
327
328/// The storage key of the `.acl` sidecar governing `path`.
329///
330/// `/` maps to `/.acl`; any other path drops a trailing slash and appends
331/// `.acl`, so `/a/b` → `/a/b.acl` and `/a/b/` → `/a/b.acl`.
332#[must_use]
333pub fn acl_sidecar_key(path: &str) -> String {
334    if path == "/" {
335        "/.acl".to_string()
336    } else {
337        format!("{}.acl", path.trim_end_matches('/'))
338    }
339}
340
341/// The next ancestor to probe after `path`, or `None` once the root has been
342/// probed.
343#[must_use]
344pub fn parent_container(path: &str) -> Option<String> {
345    if path == "/" || path.is_empty() {
346        return None;
347    }
348    let trimmed = path.trim_end_matches('/');
349    Some(match trimmed.rfind('/') {
350        Some(0) => "/".to_string(),
351        Some(pos) => trimmed[..pos].to_string(),
352        None => "/".to_string(),
353    })
354}
355
356/// Resolve the effective policy for `resource_path` against a [`Storage`]
357/// backend, honouring the typed outcomes.
358///
359/// Object-safe in its storage argument (`&dyn Storage`), so both the generic
360/// [`StorageAclResolver`] and a server holding an `Arc<dyn Storage>` share this
361/// one implementation rather than maintaining two copies of the walk that can
362/// drift apart.
363///
364/// The walk ascends **only** while each level reports [`PolicyRead::Absent`]
365/// ([`PodError::NotFound`] from the backend). Any other backend error, or any
366/// `.acl` that fails to parse, terminates the walk with a failure outcome.
367#[cfg(feature = "tokio-runtime")]
368pub async fn resolve_policy_from_storage(
369    storage: &dyn Storage,
370    resource_path: &str,
371) -> PolicyOutcome {
372    let mut path = resource_path.to_string();
373    // The first iteration probes the resource's OWN `.acl` sidecar (a direct
374    // ACL); every later iteration walks up to an ANCESTOR container, whose ACL
375    // is INHERITED. The evaluator must treat the two differently — inherited
376    // ACLs honour only `acl:default` (WAC §4.2) — so tag the document.
377    let mut inherited = false;
378    loop {
379        let acl_key = acl_sidecar_key(&path);
380        // Keep the read alive for the whole iteration so `PolicyRead::Present`
381        // can borrow the body without an extra copy.
382        let got = storage.get(&acl_key).await;
383        let read = match &got {
384            Ok((body, meta)) => PolicyRead::Present {
385                body,
386                content_type: &meta.content_type,
387            },
388            Err(PodError::NotFound(_)) => PolicyRead::Absent,
389            Err(e) => PolicyRead::Failed(e.to_string()),
390        };
391        match classify_policy_read(&acl_key, read, inherited) {
392            PolicyStep::Settled(outcome) => return outcome,
393            PolicyStep::Ascend => {}
394        }
395        match parent_container(&path) {
396            Some(parent) => {
397                // Every subsequent ACL is resolved from an ancestor.
398                inherited = true;
399                path = parent;
400            }
401            None => return PolicyOutcome::Missing,
402        }
403    }
404}
405
406/// Resolves the effective ACL document for a resource using the WAC walk-up-the-tree algorithm.
407///
408/// Starting at the resource path, the resolver looks for an `.acl` sidecar at each ancestor
409/// container up to the root. The first parseable ACL document found (JSON-LD or Turtle) is
410/// returned. If no ACL is found at any level, returns `Ok(None)` -- callers must deny access
411/// when no ACL exists (deny-by-default).
412///
413/// Prefer [`resolve_policy`](AclResolver::resolve_policy) in new code: it
414/// distinguishes a genuine miss from an unparseable or unreadable policy, which
415/// `find_effective_acl` cannot express (see the [module docs](self)).
416#[async_trait]
417pub trait AclResolver: Send + Sync {
418    /// Locate the nearest ACL document that governs `resource_path`.
419    async fn find_effective_acl(
420        &self,
421        resource_path: &str,
422    ) -> Result<Option<AclDocument>, PodError>;
423
424    /// Resolve the effective policy with typed outcomes (ADR-2005).
425    ///
426    /// The default implementation adapts [`Self::find_effective_acl`] via
427    /// [`PolicyOutcome::from_legacy`], so an existing implementor keeps
428    /// compiling and stays fail-closed. Implementors whose backend can tell a
429    /// missing key from a failed read should override this to report the
430    /// distinction faithfully.
431    async fn resolve_policy(&self, resource_path: &str) -> PolicyOutcome {
432        PolicyOutcome::from_legacy(self.find_effective_acl(resource_path).await, resource_path)
433    }
434}
435
436/// `AclResolver` backed by a [`Storage`] implementation.
437#[cfg(feature = "tokio-runtime")]
438pub struct StorageAclResolver<S: Storage> {
439    storage: std::sync::Arc<S>,
440}
441
442#[cfg(feature = "tokio-runtime")]
443impl<S: Storage> StorageAclResolver<S> {
444    /// Wrap a shared storage handle in a resolver.
445    pub fn new(storage: std::sync::Arc<S>) -> Self {
446        Self { storage }
447    }
448}
449
450#[cfg(feature = "tokio-runtime")]
451#[async_trait]
452impl<S: Storage> AclResolver for StorageAclResolver<S> {
453    /// Walk from `resource_path` toward `/`, returning the first valid `.acl` sidecar found.
454    ///
455    /// A malformed or unreadable `.acl` now yields `Err` rather than silently
456    /// inheriting the next ancestor's grant — see [`PolicyOutcome::into_result`].
457    async fn find_effective_acl(
458        &self,
459        resource_path: &str,
460    ) -> Result<Option<AclDocument>, PodError> {
461        self.resolve_policy(resource_path).await.into_result()
462    }
463
464    async fn resolve_policy(&self, resource_path: &str) -> PolicyOutcome {
465        resolve_policy_from_storage(&*self.storage, resource_path).await
466    }
467}