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 anchor;
113pub mod client;
114pub mod conditions;
115pub mod document;
116pub mod evaluator;
117pub mod issuer;
118pub mod origin;
119pub mod parser;
120pub mod payment;
121pub mod resolver;
122pub mod serializer;
123
124// ---------------------------------------------------------------------------
125// Re-exports (preserve the pre-split public surface verbatim so no
126// consumer import breaks).
127// ---------------------------------------------------------------------------
128
129pub use anchor::{anchor_mode_of, AnchorMode, ProvenanceAnchorBody, ProvenanceAnchorEvaluator};
130pub use client::{ClientConditionBody, ClientConditionEvaluator};
131pub use conditions::{
132    validate_acl_document, validate_for_write, Condition, ConditionDispatcher, ConditionOutcome,
133    ConditionRegistry, EmptyDispatcher, RequestContext, UnsupportedCondition,
134};
135pub use document::{AclAuthorization, AclDocument, IdOrIds, IdRef};
136pub use evaluator::{
137    evaluate_access, evaluate_access_ctx, evaluate_access_ctx_with_registry,
138    evaluate_access_with_groups, granted_payment_cost, GroupMembership, StaticGroupMembership,
139};
140pub use issuer::{IssuerConditionBody, IssuerConditionEvaluator};
141pub use origin::{check_origin, extract_origin_patterns, Origin, OriginDecision, OriginPattern};
142pub use parser::{parse_turtle_acl, parse_turtle_acl_with_limit};
143pub use payment::{total_payment_cost, PaymentConditionBody, PaymentConditionEvaluator};
144pub use resolver::{
145    acl_sidecar_key, classify_policy_read, parent_container, AclResolver, InvalidPolicyReason,
146    PolicyOutcome, PolicyRead, PolicyStep,
147};
148#[cfg(feature = "tokio-runtime")]
149pub use resolver::{resolve_policy_from_storage, StorageAclResolver};
150pub use serializer::serialize_turtle_acl;
151
152/// Access modes defined by WAC.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
154pub enum AccessMode {
155    Read,
156    Write,
157    Append,
158    Control,
159}
160
161pub const ALL_MODES: &[AccessMode] = &[
162    AccessMode::Read,
163    AccessMode::Write,
164    AccessMode::Append,
165    AccessMode::Control,
166];
167
168pub(crate) fn map_mode(mode_ref: &str) -> &'static [AccessMode] {
169    match mode_ref {
170        "acl:Read" | "http://www.w3.org/ns/auth/acl#Read" => &[AccessMode::Read],
171        "acl:Write" | "http://www.w3.org/ns/auth/acl#Write" => {
172            &[AccessMode::Write, AccessMode::Append]
173        }
174        "acl:Append" | "http://www.w3.org/ns/auth/acl#Append" => &[AccessMode::Append],
175        "acl:Control" | "http://www.w3.org/ns/auth/acl#Control" => &[AccessMode::Control],
176        _ => &[],
177    }
178}
179
180pub fn method_to_mode(method: &str) -> AccessMode {
181    match method.to_uppercase().as_str() {
182        "GET" | "HEAD" => AccessMode::Read,
183        "PUT" | "DELETE" | "PATCH" => AccessMode::Write,
184        "POST" => AccessMode::Append,
185        _ => AccessMode::Read,
186    }
187}
188
189pub fn mode_name(mode: AccessMode) -> &'static str {
190    match mode {
191        AccessMode::Read => "read",
192        AccessMode::Write => "write",
193        AccessMode::Append => "append",
194        AccessMode::Control => "control",
195    }
196}
197
198// ---------------------------------------------------------------------------
199// Sidecar-enforcement policy — SINGLE SOURCE OF TRUTH.
200//
201// An `.acl` / `.meta` sidecar governs *another* resource's permissions, so
202// WAC §4.3.5 requires `acl:Control` on the PROTECTED resource for ANY
203// access to the sidecar — read AND write. Encoding that rule once, in
204// wasm-safe core, keeps every runtime (native `solid-pod-rs-server`,
205// CF-Workers pods, downstream forums) on the same decision so the READ
206// and WRITE elevation cannot drift apart per-consumer.
207// ---------------------------------------------------------------------------
208
209/// Strip a `.acl` / `.meta` suffix from `path`, returning the protected
210/// resource the sidecar governs. `/victim/.acl` → `/victim/`,
211/// `/a/b.acl` → `/a/b`, `/.acl` → `/`, `.acl` → `/`. Returns `None` when
212/// `path` is not an ACL/meta sidecar.
213///
214/// Pure and I/O-free (wasm-safe). This is THE canonical
215/// "is-this-a-sidecar / what-does-it-govern" predicate; do not re-derive
216/// it per runtime.
217pub fn protected_resource_for_acl(path: &str) -> Option<String> {
218    for suffix in [".acl", ".meta"] {
219        if let Some(stripped) = path.strip_suffix(suffix) {
220            // `/.acl` and `/dir/.acl` strip to `/` and `/dir/`
221            // respectively (container ACLs); `/a/b.acl` strips to the
222            // resource `/a/b`. A bare `.acl` (no leading slash) strips to
223            // the empty string and maps to the pod root `/`.
224            if stripped.is_empty() {
225                return Some("/".to_string());
226            }
227            return Some(stripped.to_string());
228        }
229    }
230    None
231}
232
233/// Canonical sidecar-elevation decision for BOTH reads and writes.
234///
235/// Maps a `(path, base_mode)` access request to the `(resource, mode)` the
236/// WAC check must actually run against:
237///
238/// * When `path` is an `.acl`/`.meta` sidecar
239///   ([`protected_resource_for_acl`] is `Some(p)`), the check elevates to
240///   `(p, AccessMode::Control)` — because reading or writing the sidecar
241///   discloses or rewrites the full authorization graph of `p`, which WAC
242///   §4.3.5 gates on `acl:Control` of `p`, never the base mode on the
243///   sidecar path.
244/// * Otherwise the request passes through unchanged as `(path, base_mode)`.
245///
246/// Callers pass `base_mode = AccessMode::Read` for a read and the request's
247/// write mode (`Write`/`Append`/`Control`) for a write; the elevation
248/// collapses either to `Control` on the governed resource. This is the ONE
249/// function the native server's read/write enforcement and any wasm runtime
250/// share, so the sidecar rule has a single source of truth. Pure and
251/// I/O-free (wasm-safe).
252pub fn effective_acl_target(path: &str, base_mode: AccessMode) -> (String, AccessMode) {
253    match protected_resource_for_acl(path) {
254        Some(protected) => (protected, AccessMode::Control),
255        None => (path.to_string(), base_mode),
256    }
257}
258
259/// Build a `WAC-Allow` header value (WAC 1.x — no condition dispatcher).
260///
261/// Advertises static capabilities for the authenticated agent and for
262/// anonymous (public) access. The origin gate is a per-request concern,
263/// so we evaluate without an origin and leave any origin-gated rules to
264/// reject at request time.
265pub fn wac_allow_header(
266    acl_doc: Option<&AclDocument>,
267    agent_uri: Option<&str>,
268    resource_path: &str,
269) -> String {
270    let mut user_modes = Vec::new();
271    let mut public_modes = Vec::new();
272    for mode in ALL_MODES {
273        if evaluate_access(acl_doc, agent_uri, resource_path, *mode, None) {
274            user_modes.push(mode_name(*mode));
275        }
276        if evaluate_access(acl_doc, None, resource_path, *mode, None) {
277            public_modes.push(mode_name(*mode));
278        }
279    }
280    format!(
281        "user=\"{}\", public=\"{}\"",
282        user_modes.join(" "),
283        public_modes.join(" ")
284    )
285}
286
287/// WAC 2.0 — build a `WAC-Allow` header omitting modes whose conditions
288/// are unsatisfied in the current request context.
289pub fn wac_allow_header_with_dispatcher(
290    acl_doc: Option<&AclDocument>,
291    ctx: &RequestContext<'_>,
292    resource_path: &str,
293    groups: &dyn GroupMembership,
294    dispatcher: &dyn ConditionDispatcher,
295) -> String {
296    let mut user_modes = Vec::new();
297    let mut public_modes = Vec::new();
298    let public_ctx = RequestContext {
299        web_id: None,
300        client_id: ctx.client_id,
301        issuer: ctx.issuer,
302        payment_balance_sats: ctx.payment_balance_sats,
303    };
304    for mode in ALL_MODES {
305        if evaluate_access_ctx(acl_doc, ctx, resource_path, *mode, None, groups, dispatcher) {
306            user_modes.push(mode_name(*mode));
307        }
308        if evaluate_access_ctx(
309            acl_doc,
310            &public_ctx,
311            resource_path,
312            *mode,
313            None,
314            groups,
315            dispatcher,
316        ) {
317            public_modes.push(mode_name(*mode));
318        }
319    }
320    format!(
321        "user=\"{}\", public=\"{}\"",
322        user_modes.join(" "),
323        public_modes.join(" ")
324    )
325}
326
327// ---------------------------------------------------------------------------
328// Lightweight metric counter for the acl-origin gate. When a proper
329// metrics facade lands (F1/F2) this module will be swapped for its
330// `Counter` type; for now we expose a minimal atomic compatible with
331// whichever facade arrives.
332// ---------------------------------------------------------------------------
333#[cfg(feature = "acl-origin")]
334pub mod metrics {
335    use std::sync::atomic::AtomicU64;
336
337    /// Total number of WAC evaluations denied by the `acl:origin` gate.
338    pub static ACL_ORIGIN_REJECTED_TOTAL: AtomicU64 = AtomicU64::new(0);
339}
340
341// ---------------------------------------------------------------------------
342// Tests — retained from pre-split wac.rs. Exercise JSON-LD round-trip,
343// Turtle parse/serialise, and the WAC-Allow header shape.
344// ---------------------------------------------------------------------------
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    fn make_doc(graph: Vec<AclAuthorization>) -> AclDocument {
351        AclDocument {
352            context: None,
353            graph: Some(graph),
354            inherited: false,
355        }
356    }
357
358    fn public_read(path: &str) -> AclAuthorization {
359        AclAuthorization {
360            id: None,
361            r#type: None,
362            agent: None,
363            agent_class: Some(IdOrIds::Single(IdRef {
364                id: "foaf:Agent".into(),
365            })),
366            agent_group: None,
367            origin: None,
368            access_to: Some(IdOrIds::Single(IdRef { id: path.into() })),
369            default: None,
370            mode: Some(IdOrIds::Single(IdRef {
371                id: "acl:Read".into(),
372            })),
373            condition: None,
374        }
375    }
376
377    #[test]
378    fn no_acl_denies_all() {
379        assert!(!evaluate_access(None, None, "/foo", AccessMode::Read, None));
380    }
381
382    #[test]
383    fn public_read_grants_anonymous() {
384        let doc = make_doc(vec![public_read("/")]);
385        assert!(evaluate_access(
386            Some(&doc),
387            None,
388            "/",
389            AccessMode::Read,
390            None
391        ));
392    }
393
394    #[test]
395    fn write_implies_append() {
396        let auth = AclAuthorization {
397            id: None,
398            r#type: None,
399            agent: Some(IdOrIds::Single(IdRef {
400                id: "did:nostr:owner".into(),
401            })),
402            agent_class: None,
403            agent_group: None,
404            origin: None,
405            access_to: Some(IdOrIds::Single(IdRef { id: "/".into() })),
406            default: None,
407            mode: Some(IdOrIds::Single(IdRef {
408                id: "acl:Write".into(),
409            })),
410            condition: None,
411        };
412        let doc = make_doc(vec![auth]);
413        assert!(evaluate_access(
414            Some(&doc),
415            Some("did:nostr:owner"),
416            "/",
417            AccessMode::Append,
418            None,
419        ));
420    }
421
422    #[test]
423    fn method_mapping() {
424        assert_eq!(method_to_mode("GET"), AccessMode::Read);
425        assert_eq!(method_to_mode("PUT"), AccessMode::Write);
426        assert_eq!(method_to_mode("POST"), AccessMode::Append);
427    }
428
429    #[test]
430    fn wac_allow_shape() {
431        let doc = make_doc(vec![public_read("/")]);
432        let hdr = wac_allow_header(Some(&doc), None, "/");
433        assert_eq!(hdr, "user=\"read\", public=\"read\"");
434    }
435
436    #[test]
437    fn turtle_acl_round_trip_parses_basic_rules() {
438        let ttl = r#"
439            @prefix acl: <http://www.w3.org/ns/auth/acl#> .
440            @prefix foaf: <http://xmlns.com/foaf/0.1/> .
441
442            <#public> a acl:Authorization ;
443                acl:agentClass foaf:Agent ;
444                acl:accessTo </> ;
445                acl:mode acl:Read .
446        "#;
447        let doc = parse_turtle_acl(ttl).unwrap();
448        assert!(evaluate_access(
449            Some(&doc),
450            None,
451            "/",
452            AccessMode::Read,
453            None
454        ));
455        assert!(!evaluate_access(
456            Some(&doc),
457            None,
458            "/",
459            AccessMode::Write,
460            None
461        ));
462    }
463
464    #[test]
465    fn turtle_acl_with_owner_grants_write() {
466        let ttl = r#"
467            @prefix acl: <http://www.w3.org/ns/auth/acl#> .
468
469            <#owner> a acl:Authorization ;
470                acl:agent <did:nostr:owner> ;
471                acl:accessTo </> ;
472                acl:default </> ;
473                acl:mode acl:Write, acl:Control .
474        "#;
475        let doc = parse_turtle_acl(ttl).unwrap();
476        assert!(evaluate_access(
477            Some(&doc),
478            Some("did:nostr:owner"),
479            "/foo",
480            AccessMode::Write,
481            None,
482        ));
483    }
484
485    #[test]
486    fn serialize_turtle_acl_emits_prefixes_and_rules() {
487        let doc = make_doc(vec![public_read("/")]);
488        let out = serialize_turtle_acl(&doc);
489        assert!(out.contains("@prefix acl:"));
490        assert!(out.contains("acl:Authorization"));
491        assert!(out.contains("acl:mode"));
492    }
493
494    // ----- Sprint 12: parameterised JSON-LD size cap ----------------------
495
496    #[test]
497    fn jsonld_acl_with_limits_rejects_oversized() {
498        let body = b"{\"@context\": \"https://www.w3.org/ns/auth/acl\"}";
499        let err = parse_jsonld_acl_with_limits(body, 10, 32).unwrap_err();
500        let msg = err.to_string();
501        assert!(
502            msg.contains("payload too large") || msg.contains("exceeds"),
503            "oversized JSON-LD should be rejected: {msg}"
504        );
505    }
506
507    #[test]
508    fn jsonld_acl_with_limits_accepts_within_bounds() {
509        // Minimal valid JSON-LD ACL (empty graph).
510        let body = b"{}";
511        let doc = parse_jsonld_acl_with_limits(body, 1024, 32).unwrap();
512        assert!(doc.graph.is_none());
513    }
514
515    // ----- Sidecar-enforcement policy (single source of truth) ------------
516
517    #[test]
518    fn protected_resource_for_acl_strips_suffixes() {
519        assert_eq!(
520            protected_resource_for_acl("/victim/.acl").as_deref(),
521            Some("/victim/")
522        );
523        assert_eq!(
524            protected_resource_for_acl("/a/b.acl").as_deref(),
525            Some("/a/b")
526        );
527        assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
528        assert_eq!(
529            protected_resource_for_acl("/a/b.meta").as_deref(),
530            Some("/a/b")
531        );
532        assert_eq!(
533            protected_resource_for_acl("/dir/.meta").as_deref(),
534            Some("/dir/")
535        );
536        assert_eq!(protected_resource_for_acl("/.meta").as_deref(), Some("/"));
537        assert_eq!(protected_resource_for_acl(".acl").as_deref(), Some("/"));
538        // Not a sidecar.
539        assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
540        assert_eq!(protected_resource_for_acl("/foo.aclx").as_deref(), None);
541    }
542
543    /// GOLDEN CONFORMANCE VECTORS for [`effective_acl_target`].
544    ///
545    /// Every runtime that enforces WAC (native server, CF-Workers pod,
546    /// downstream forum) MUST reproduce this table exactly: `(path,
547    /// base_mode)` → `(resource, mode)`. The sidecar rows cover both READ
548    /// and WRITE elevating to `Control` on the governed resource — the
549    /// invariant a downstream consumer previously got wrong on the READ
550    /// side.
551    #[test]
552    fn effective_acl_target_golden_conformance_vectors() {
553        use AccessMode::{Append, Control, Read, Write};
554        // (input path, base_mode) -> (expected resource, expected mode)
555        let vectors: &[(&str, AccessMode, &str, AccessMode)] = &[
556            // Ordinary resource: read / write / append pass through unchanged.
557            ("/foo", Read, "/foo", Read),
558            ("/foo", Write, "/foo", Write),
559            ("/foo", Append, "/foo", Append),
560            ("/dir/", Read, "/dir/", Read),
561            ("/dir/", Write, "/dir/", Write),
562            // `.acl` sidecar: READ *and* WRITE both elevate to Control on /foo.
563            ("/foo.acl", Read, "/foo", Control),
564            ("/foo.acl", Write, "/foo", Control),
565            ("/foo.acl", Append, "/foo", Control),
566            // Container `.acl`: /dir/.acl -> (/dir/, Control).
567            ("/dir/.acl", Read, "/dir/", Control),
568            ("/dir/.acl", Write, "/dir/", Control),
569            // Root `.acl`: /.acl -> (/, Control).
570            ("/.acl", Read, "/", Control),
571            ("/.acl", Write, "/", Control),
572            // `.meta` behaves identically to `.acl`.
573            ("/foo.meta", Read, "/foo", Control),
574            ("/foo.meta", Write, "/foo", Control),
575            ("/dir/.meta", Read, "/dir/", Control),
576            ("/.meta", Write, "/", Control),
577        ];
578        for (path, base, exp_res, exp_mode) in vectors {
579            let (res, mode) = effective_acl_target(path, *base);
580            assert_eq!(&res, exp_res, "resource for ({path:?}, {base:?})");
581            assert_eq!(mode, *exp_mode, "mode for ({path:?}, {base:?})");
582        }
583    }
584
585    #[test]
586    fn effective_acl_target_non_sidecar_is_identity() {
587        for mode in ALL_MODES {
588            let (res, out) = effective_acl_target("/data/note.ttl", *mode);
589            assert_eq!(res, "/data/note.ttl");
590            assert_eq!(out, *mode);
591        }
592    }
593}