Skip to main content

solid_pod_rs/wac/
mod.rs

1//! Web Access Control evaluator.
2//!
3//! Parses JSON-LD / Turtle ACL documents and evaluates whether a given
4//! agent URI is granted a specific access mode on a resource path.
5//! WAC 2.0 conditions (client / issuer gates) are supported via the
6//! `conditions` submodule.
7//!
8//! Reference: <https://solid.github.io/web-access-control-spec/> +
9//! <https://webacl.org/secure-access-conditions/>
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::PodError;
14
15// ---------------------------------------------------------------------------
16// Parser DoS bounds.
17//
18// The ACL parsers run on untrusted bodies uploaded by external clients.
19// Without bounds, a pathological document can either exhaust memory
20// (oversize Turtle) or blow the parser stack (deeply nested JSON-LD).
21// JSS's `n3`-based parser is similarly bounded; we match for parity and
22// defence-in-depth.
23// ---------------------------------------------------------------------------
24
25/// Maximum byte length of an ACL document body. WAC 2.0 ACLs are flat
26/// declarative documents; 1 MiB is generous and prevents O(n²) parser
27/// blowup. Configurable at parse time via `JSS_MAX_ACL_BYTES`.
28pub const MAX_ACL_BYTES: usize = 1_048_576;
29
30/// Maximum JSON-LD nesting depth. Solid ACLs are ≤4 levels deep in
31/// practice; 32 is a generous fail-closed cap against depth bombs.
32/// Configurable via `JSS_MAX_ACL_JSON_DEPTH`.
33pub const MAX_ACL_JSON_DEPTH: usize = 32;
34
35/// Count the structural nesting depth of a JSON byte slice without
36/// parsing it. Ignores braces/brackets inside string literals. Fails
37/// fast as soon as `max` is exceeded so pathological documents never
38/// reach `serde_json`, which allocates stack proportional to depth.
39fn check_json_depth(body: &[u8], max: usize) -> Result<(), PodError> {
40    let mut depth: usize = 0;
41    let mut in_str = false;
42    let mut esc = false;
43    for &b in body {
44        if in_str {
45            if esc {
46                esc = false;
47            } else if b == b'\\' {
48                esc = true;
49            } else if b == b'"' {
50                in_str = false;
51            }
52            continue;
53        }
54        match b {
55            b'"' => in_str = true,
56            b'{' | b'[' => {
57                depth = depth.saturating_add(1);
58                if depth > max {
59                    return Err(PodError::BadRequest(format!(
60                        "ACL JSON depth exceeds {max}"
61                    )));
62                }
63            }
64            b'}' | b']' => {
65                depth = depth.saturating_sub(1);
66            }
67            _ => {}
68        }
69    }
70    Ok(())
71}
72
73/// Parse a JSON-LD ACL body with byte and depth bounds enforced.
74///
75/// The resolver in [`StorageAclResolver`] routes through this helper so
76/// fuzzed or malicious ACLs are rejected before `serde_json` is invoked.
77/// To supply explicit limits, use [`parse_jsonld_acl_with_limits`].
78pub fn parse_jsonld_acl(body: &[u8]) -> Result<AclDocument, PodError> {
79    let limit = std::env::var("JSS_MAX_ACL_BYTES")
80        .ok()
81        .and_then(|v| v.parse().ok())
82        .unwrap_or(MAX_ACL_BYTES);
83    let depth_limit = std::env::var("JSS_MAX_ACL_JSON_DEPTH")
84        .ok()
85        .and_then(|v| v.parse().ok())
86        .unwrap_or(MAX_ACL_JSON_DEPTH);
87    parse_jsonld_acl_with_limits(body, limit, depth_limit)
88}
89
90/// Parse a JSON-LD ACL body with caller-supplied byte and depth limits.
91///
92/// Equivalent to [`parse_jsonld_acl`] but accepts limits as parameters
93/// instead of reading from environment variables. Returns
94/// `PodError::PayloadTooLarge` (HTTP 413 equivalent) when
95/// `body.len() > max_bytes`.
96pub fn parse_jsonld_acl_with_limits(
97    body: &[u8],
98    max_bytes: usize,
99    max_depth: usize,
100) -> Result<AclDocument, PodError> {
101    if body.len() > max_bytes {
102        return Err(PodError::PayloadTooLarge(format!(
103            "ACL body exceeds {max_bytes} bytes"
104        )));
105    }
106    check_json_depth(body, max_depth)?;
107    serde_json::from_slice::<AclDocument>(body)
108        .map_err(|e| PodError::AclParse(format!("JSON-LD ACL parse: {e}")))
109}
110
111// Sub-modules — each kept under 500 LOC.
112pub mod client;
113pub mod conditions;
114pub mod document;
115pub mod evaluator;
116pub mod issuer;
117pub mod origin;
118pub mod parser;
119pub mod payment;
120pub mod resolver;
121pub mod serializer;
122
123// ---------------------------------------------------------------------------
124// Re-exports (preserve the pre-split public surface verbatim so no
125// consumer import breaks).
126// ---------------------------------------------------------------------------
127
128pub use client::{ClientConditionBody, ClientConditionEvaluator};
129pub use conditions::{
130    validate_acl_document, validate_for_write, Condition, ConditionDispatcher, ConditionOutcome,
131    ConditionRegistry, EmptyDispatcher, RequestContext, UnsupportedCondition,
132};
133pub use document::{AclAuthorization, AclDocument, IdOrIds, IdRef};
134pub use evaluator::{
135    evaluate_access, evaluate_access_ctx, evaluate_access_ctx_with_registry,
136    evaluate_access_with_groups, GroupMembership, StaticGroupMembership,
137};
138pub use issuer::{IssuerConditionBody, IssuerConditionEvaluator};
139pub use origin::{check_origin, extract_origin_patterns, Origin, OriginDecision, OriginPattern};
140pub use parser::{parse_turtle_acl, parse_turtle_acl_with_limit};
141pub use payment::{total_payment_cost, PaymentConditionBody, PaymentConditionEvaluator};
142pub use resolver::AclResolver;
143#[cfg(feature = "tokio-runtime")]
144pub use resolver::StorageAclResolver;
145pub use serializer::serialize_turtle_acl;
146
147/// Access modes defined by WAC.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
149pub enum AccessMode {
150    Read,
151    Write,
152    Append,
153    Control,
154}
155
156pub const ALL_MODES: &[AccessMode] = &[
157    AccessMode::Read,
158    AccessMode::Write,
159    AccessMode::Append,
160    AccessMode::Control,
161];
162
163pub(crate) fn map_mode(mode_ref: &str) -> &'static [AccessMode] {
164    match mode_ref {
165        "acl:Read" | "http://www.w3.org/ns/auth/acl#Read" => &[AccessMode::Read],
166        "acl:Write" | "http://www.w3.org/ns/auth/acl#Write" => {
167            &[AccessMode::Write, AccessMode::Append]
168        }
169        "acl:Append" | "http://www.w3.org/ns/auth/acl#Append" => &[AccessMode::Append],
170        "acl:Control" | "http://www.w3.org/ns/auth/acl#Control" => &[AccessMode::Control],
171        _ => &[],
172    }
173}
174
175pub fn method_to_mode(method: &str) -> AccessMode {
176    match method.to_uppercase().as_str() {
177        "GET" | "HEAD" => AccessMode::Read,
178        "PUT" | "DELETE" | "PATCH" => AccessMode::Write,
179        "POST" => AccessMode::Append,
180        _ => AccessMode::Read,
181    }
182}
183
184pub fn mode_name(mode: AccessMode) -> &'static str {
185    match mode {
186        AccessMode::Read => "read",
187        AccessMode::Write => "write",
188        AccessMode::Append => "append",
189        AccessMode::Control => "control",
190    }
191}
192
193/// Build a `WAC-Allow` header value (WAC 1.x — no condition dispatcher).
194///
195/// Advertises static capabilities for the authenticated agent and for
196/// anonymous (public) access. The origin gate is a per-request concern,
197/// so we evaluate without an origin and leave any origin-gated rules to
198/// reject at request time.
199pub fn wac_allow_header(
200    acl_doc: Option<&AclDocument>,
201    agent_uri: Option<&str>,
202    resource_path: &str,
203) -> String {
204    let mut user_modes = Vec::new();
205    let mut public_modes = Vec::new();
206    for mode in ALL_MODES {
207        if evaluate_access(acl_doc, agent_uri, resource_path, *mode, None) {
208            user_modes.push(mode_name(*mode));
209        }
210        if evaluate_access(acl_doc, None, resource_path, *mode, None) {
211            public_modes.push(mode_name(*mode));
212        }
213    }
214    format!(
215        "user=\"{}\", public=\"{}\"",
216        user_modes.join(" "),
217        public_modes.join(" ")
218    )
219}
220
221/// WAC 2.0 — build a `WAC-Allow` header omitting modes whose conditions
222/// are unsatisfied in the current request context.
223pub fn wac_allow_header_with_dispatcher(
224    acl_doc: Option<&AclDocument>,
225    ctx: &RequestContext<'_>,
226    resource_path: &str,
227    groups: &dyn GroupMembership,
228    dispatcher: &dyn ConditionDispatcher,
229) -> String {
230    let mut user_modes = Vec::new();
231    let mut public_modes = Vec::new();
232    let public_ctx = RequestContext {
233        web_id: None,
234        client_id: ctx.client_id,
235        issuer: ctx.issuer,
236        payment_balance_sats: ctx.payment_balance_sats,
237    };
238    for mode in ALL_MODES {
239        if evaluate_access_ctx(acl_doc, ctx, resource_path, *mode, None, groups, dispatcher) {
240            user_modes.push(mode_name(*mode));
241        }
242        if evaluate_access_ctx(
243            acl_doc,
244            &public_ctx,
245            resource_path,
246            *mode,
247            None,
248            groups,
249            dispatcher,
250        ) {
251            public_modes.push(mode_name(*mode));
252        }
253    }
254    format!(
255        "user=\"{}\", public=\"{}\"",
256        user_modes.join(" "),
257        public_modes.join(" ")
258    )
259}
260
261// ---------------------------------------------------------------------------
262// Lightweight metric counter for the acl-origin gate. When a proper
263// metrics facade lands (F1/F2) this module will be swapped for its
264// `Counter` type; for now we expose a minimal atomic compatible with
265// whichever facade arrives.
266// ---------------------------------------------------------------------------
267#[cfg(feature = "acl-origin")]
268pub mod metrics {
269    use std::sync::atomic::AtomicU64;
270
271    /// Total number of WAC evaluations denied by the `acl:origin` gate.
272    pub static ACL_ORIGIN_REJECTED_TOTAL: AtomicU64 = AtomicU64::new(0);
273}
274
275// ---------------------------------------------------------------------------
276// Tests — retained from pre-split wac.rs. Exercise JSON-LD round-trip,
277// Turtle parse/serialise, and the WAC-Allow header shape.
278// ---------------------------------------------------------------------------
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    fn make_doc(graph: Vec<AclAuthorization>) -> AclDocument {
285        AclDocument {
286            context: None,
287            graph: Some(graph),
288        }
289    }
290
291    fn public_read(path: &str) -> AclAuthorization {
292        AclAuthorization {
293            id: None,
294            r#type: None,
295            agent: None,
296            agent_class: Some(IdOrIds::Single(IdRef {
297                id: "foaf:Agent".into(),
298            })),
299            agent_group: None,
300            origin: None,
301            access_to: Some(IdOrIds::Single(IdRef { id: path.into() })),
302            default: None,
303            mode: Some(IdOrIds::Single(IdRef {
304                id: "acl:Read".into(),
305            })),
306            condition: None,
307        }
308    }
309
310    #[test]
311    fn no_acl_denies_all() {
312        assert!(!evaluate_access(None, None, "/foo", AccessMode::Read, None));
313    }
314
315    #[test]
316    fn public_read_grants_anonymous() {
317        let doc = make_doc(vec![public_read("/")]);
318        assert!(evaluate_access(
319            Some(&doc),
320            None,
321            "/",
322            AccessMode::Read,
323            None
324        ));
325    }
326
327    #[test]
328    fn write_implies_append() {
329        let auth = AclAuthorization {
330            id: None,
331            r#type: None,
332            agent: Some(IdOrIds::Single(IdRef {
333                id: "did:nostr:owner".into(),
334            })),
335            agent_class: None,
336            agent_group: None,
337            origin: None,
338            access_to: Some(IdOrIds::Single(IdRef { id: "/".into() })),
339            default: None,
340            mode: Some(IdOrIds::Single(IdRef {
341                id: "acl:Write".into(),
342            })),
343            condition: None,
344        };
345        let doc = make_doc(vec![auth]);
346        assert!(evaluate_access(
347            Some(&doc),
348            Some("did:nostr:owner"),
349            "/",
350            AccessMode::Append,
351            None,
352        ));
353    }
354
355    #[test]
356    fn method_mapping() {
357        assert_eq!(method_to_mode("GET"), AccessMode::Read);
358        assert_eq!(method_to_mode("PUT"), AccessMode::Write);
359        assert_eq!(method_to_mode("POST"), AccessMode::Append);
360    }
361
362    #[test]
363    fn wac_allow_shape() {
364        let doc = make_doc(vec![public_read("/")]);
365        let hdr = wac_allow_header(Some(&doc), None, "/");
366        assert_eq!(hdr, "user=\"read\", public=\"read\"");
367    }
368
369    #[test]
370    fn turtle_acl_round_trip_parses_basic_rules() {
371        let ttl = r#"
372            @prefix acl: <http://www.w3.org/ns/auth/acl#> .
373            @prefix foaf: <http://xmlns.com/foaf/0.1/> .
374
375            <#public> a acl:Authorization ;
376                acl:agentClass foaf:Agent ;
377                acl:accessTo </> ;
378                acl:mode acl:Read .
379        "#;
380        let doc = parse_turtle_acl(ttl).unwrap();
381        assert!(evaluate_access(
382            Some(&doc),
383            None,
384            "/",
385            AccessMode::Read,
386            None
387        ));
388        assert!(!evaluate_access(
389            Some(&doc),
390            None,
391            "/",
392            AccessMode::Write,
393            None
394        ));
395    }
396
397    #[test]
398    fn turtle_acl_with_owner_grants_write() {
399        let ttl = r#"
400            @prefix acl: <http://www.w3.org/ns/auth/acl#> .
401
402            <#owner> a acl:Authorization ;
403                acl:agent <did:nostr:owner> ;
404                acl:accessTo </> ;
405                acl:default </> ;
406                acl:mode acl:Write, acl:Control .
407        "#;
408        let doc = parse_turtle_acl(ttl).unwrap();
409        assert!(evaluate_access(
410            Some(&doc),
411            Some("did:nostr:owner"),
412            "/foo",
413            AccessMode::Write,
414            None,
415        ));
416    }
417
418    #[test]
419    fn serialize_turtle_acl_emits_prefixes_and_rules() {
420        let doc = make_doc(vec![public_read("/")]);
421        let out = serialize_turtle_acl(&doc);
422        assert!(out.contains("@prefix acl:"));
423        assert!(out.contains("acl:Authorization"));
424        assert!(out.contains("acl:mode"));
425    }
426
427    // ----- Sprint 12: parameterised JSON-LD size cap ----------------------
428
429    #[test]
430    fn jsonld_acl_with_limits_rejects_oversized() {
431        let body = b"{\"@context\": \"https://www.w3.org/ns/auth/acl\"}";
432        let err = parse_jsonld_acl_with_limits(body, 10, 32).unwrap_err();
433        let msg = err.to_string();
434        assert!(
435            msg.contains("payload too large") || msg.contains("exceeds"),
436            "oversized JSON-LD should be rejected: {msg}"
437        );
438    }
439
440    #[test]
441    fn jsonld_acl_with_limits_accepts_within_bounds() {
442        // Minimal valid JSON-LD ACL (empty graph).
443        let body = b"{}";
444        let doc = parse_jsonld_acl_with_limits(body, 1024, 32).unwrap();
445        assert!(doc.graph.is_none());
446    }
447}