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}