solid-pod-rs 0.4.0-alpha.16

Rust-native Solid Pod server library — LDP, WAC, WebID, Solid-OIDC, Solid Notifications, NIP-98. Framework-agnostic.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
//! Core WAC evaluation engine.
//!
//! Shared between WAC 1.x (`evaluate_access`) and WAC 2.0
//! (`evaluate_access_ctx`). The 1.x entry point is a thin shim that
//! constructs an empty request context and an empty dispatcher so any
//! rule bearing conditions fails closed.

use crate::wac::conditions::{
    ConditionDispatcher, ConditionOutcome, ConditionRegistry, EmptyDispatcher, RequestContext,
};
use crate::wac::document::{get_ids, AclAuthorization, AclDocument};
use crate::wac::origin;
use crate::wac::{map_mode, AccessMode};

/// Synchronous group membership lookup used by
/// `evaluate_access_with_groups` and by condition evaluators
/// (`acl:clientGroup`, `acl:issuerGroup`).
///
/// Implementors resolve a group IRI (typically a `vcard:Group`
/// document) against a subject URI and return whether the subject is
/// a member. The default no-op implementation returns `false` for
/// every call.
pub trait GroupMembership {
    /// Return `true` if `agent_uri` is a member of the group identified by `group_iri`.
    fn is_member(&self, group_iri: &str, agent_uri: &str) -> bool;
}

pub(crate) struct NoGroupMembership;
impl GroupMembership for NoGroupMembership {
    fn is_member(&self, _group_iri: &str, _agent_uri: &str) -> bool {
        false
    }
}

/// Static group-membership resolver used in tests and by pods that
/// resolve group documents eagerly into an in-memory map.
#[derive(Debug, Default, Clone)]
pub struct StaticGroupMembership {
    /// Map from group IRI to the list of member WebIDs.
    pub groups: std::collections::HashMap<String, Vec<String>>,
}

impl StaticGroupMembership {
    /// Create an empty group membership resolver.
    pub fn new() -> Self {
        Self::default()
    }
    /// Register `members` under `group_iri`, replacing any previous entry.
    pub fn add(&mut self, group_iri: impl Into<String>, members: Vec<String>) {
        self.groups.insert(group_iri.into(), members);
    }
}

impl GroupMembership for StaticGroupMembership {
    fn is_member(&self, group_iri: &str, agent_uri: &str) -> bool {
        self.groups
            .get(group_iri)
            .map(|m| m.iter().any(|x| x == agent_uri))
            .unwrap_or(false)
    }
}

pub(crate) fn normalize_path(path: &str) -> String {
    let stripped = path.strip_prefix("./").or_else(|| path.strip_prefix('.'));
    let base = match stripped {
        Some("") => "/".to_string(),
        Some(s) if !s.starts_with('/') => format!("/{s}"),
        Some(s) => s.to_string(),
        None => path.to_string(),
    };
    let trimmed = base.trim_end_matches('/');
    if trimmed.is_empty() {
        "/".to_string()
    } else {
        trimmed.to_string()
    }
}

pub(crate) fn path_matches(rule_path: &str, resource_path: &str, is_default: bool) -> bool {
    let rule = normalize_path(rule_path);
    let resource = normalize_path(resource_path);
    if resource == rule {
        return true;
    }
    // `acl:accessTo` covers exact match plus direct children of a
    // container target — NOT deep descendants (WAC §4.2; cf. tests
    // `access_to_does_not_inherit_by_itself` and
    // `access_to_on_container_covers_direct_children`).
    // `acl:default`, by contrast, applies recursively.
    if !is_default {
        let prefix = if rule == "/" {
            String::from("/")
        } else {
            format!("{rule}/")
        };
        if let Some(rest) = resource.strip_prefix(&prefix) {
            return !rest.is_empty() && !rest.contains('/');
        }
        return false;
    }
    if rule == "/" {
        resource.starts_with('/')
    } else {
        resource.starts_with(&format!("{rule}/"))
    }
}

pub(crate) fn get_modes(auth: &AclAuthorization) -> Vec<AccessMode> {
    let mut modes = Vec::new();
    for mode_ref in get_ids(&auth.mode) {
        modes.extend_from_slice(map_mode(mode_ref));
    }
    modes
}

fn agent_matches_with_groups(
    auth: &AclAuthorization,
    agent_uri: Option<&str>,
    groups: &dyn GroupMembership,
) -> bool {
    let agents = get_ids(&auth.agent);
    if let Some(uri) = agent_uri {
        if agents.contains(&uri) {
            return true;
        }
    }
    for cls in get_ids(&auth.agent_class) {
        if cls == "foaf:Agent" || cls == "http://xmlns.com/foaf/0.1/Agent" {
            return true;
        }
        if agent_uri.is_some()
            && (cls == "acl:AuthenticatedAgent"
                || cls == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent")
        {
            return true;
        }
    }
    if let Some(uri) = agent_uri {
        for group_iri in get_ids(&auth.agent_group) {
            if groups.is_member(group_iri, uri) {
                return true;
            }
        }
    }
    false
}

/// Evaluate whether access should be granted (WAC 1.x entry point).
///
/// The `request_origin` parameter carries the RFC 6454 origin from the
/// HTTP `Origin:` header; pass `None` for request paths that have no
/// origin context (e.g. server-to-server calls or tests). When the
/// `acl-origin` feature is enabled, any ACL that declares `acl:origin`
/// triples gates access on the request origin per WAC §4.3.
///
/// Note: WAC 2.0 documents with `acl:condition` triples will fail
/// closed under this entry point because it wires an `EmptyDispatcher`.
/// Use `evaluate_access_ctx` for WAC 2.0 evaluation.
pub fn evaluate_access(
    acl_doc: Option<&AclDocument>,
    agent_uri: Option<&str>,
    resource_path: &str,
    required_mode: AccessMode,
    request_origin: Option<&origin::Origin>,
) -> bool {
    evaluate_access_with_groups(
        acl_doc,
        agent_uri,
        resource_path,
        required_mode,
        request_origin,
        &NoGroupMembership,
    )
}

/// WAC 1.x evaluation with a caller-supplied group resolver. Rules
/// bearing `acl:condition` triples fail closed (empty dispatcher).
pub fn evaluate_access_with_groups(
    acl_doc: Option<&AclDocument>,
    agent_uri: Option<&str>,
    resource_path: &str,
    required_mode: AccessMode,
    request_origin: Option<&origin::Origin>,
    groups: &dyn GroupMembership,
) -> bool {
    let ctx = RequestContext {
        web_id: agent_uri,
        client_id: None,
        issuer: None,
        payment_balance_sats: None,
    };
    evaluate_access_ctx_inner(
        acl_doc,
        &ctx,
        resource_path,
        required_mode,
        request_origin,
        groups,
        &EmptyDispatcher,
    )
}

/// WAC 2.0 evaluation entry point. Accepts a `RequestContext` carrying
/// WebID / client / issuer, plus a `ConditionDispatcher` (typically a
/// `ConditionRegistry`).
///
/// Conjunctive semantics: for every authorisation whose agent+mode+path
/// predicates match, each attached `acl:condition` must dispatch to
/// `Satisfied` for the rule to grant. Any `NotApplicable` or `Denied`
/// outcome causes the rule to be skipped.
#[allow(clippy::too_many_arguments)]
pub fn evaluate_access_ctx(
    acl_doc: Option<&AclDocument>,
    ctx: &RequestContext<'_>,
    resource_path: &str,
    required_mode: AccessMode,
    request_origin: Option<&origin::Origin>,
    groups: &dyn GroupMembership,
    dispatcher: &dyn ConditionDispatcher,
) -> bool {
    evaluate_access_ctx_inner(
        acl_doc,
        ctx,
        resource_path,
        required_mode,
        request_origin,
        groups,
        dispatcher,
    )
}

/// Convenience wrapper that takes a `ConditionRegistry` directly.
#[allow(clippy::too_many_arguments)]
pub fn evaluate_access_ctx_with_registry(
    acl_doc: Option<&AclDocument>,
    ctx: &RequestContext<'_>,
    resource_path: &str,
    required_mode: AccessMode,
    request_origin: Option<&origin::Origin>,
    groups: &dyn GroupMembership,
    registry: &ConditionRegistry,
) -> bool {
    evaluate_access_ctx_inner(
        acl_doc,
        ctx,
        resource_path,
        required_mode,
        request_origin,
        groups,
        registry,
    )
}

#[allow(clippy::too_many_arguments)]
fn evaluate_access_ctx_inner(
    acl_doc: Option<&AclDocument>,
    ctx: &RequestContext<'_>,
    resource_path: &str,
    required_mode: AccessMode,
    request_origin: Option<&origin::Origin>,
    groups: &dyn GroupMembership,
    dispatcher: &dyn ConditionDispatcher,
) -> bool {
    let Some(doc) = acl_doc else {
        return false;
    };
    let Some(graph) = doc.graph.as_ref() else {
        return false;
    };
    // P2: an ACL resolved from an ANCESTOR container's `.acl` (the WAC
    // walk-up found it one or more levels up) must honour ONLY
    // `acl:default` rules. `acl:accessTo` names an EXACT resource and
    // MUST NOT inherit to descendants (WAC §4.2). Without this gate an
    // ancestor `accessTo`-only grant leaks to arbitrary children.
    let honour_access_to = !doc.inherited;
    let mut base_grant = false;
    for auth in graph {
        let granted = get_modes(auth);
        if !granted.contains(&required_mode) {
            continue;
        }
        if !agent_matches_with_groups(auth, ctx.web_id, groups) {
            continue;
        }
        let mut path_ok = false;
        if honour_access_to {
            for target in get_ids(&auth.access_to) {
                if path_matches(target, resource_path, false) {
                    path_ok = true;
                    break;
                }
            }
        }
        if !path_ok {
            for target in get_ids(&auth.default) {
                if path_matches(target, resource_path, true) {
                    path_ok = true;
                    break;
                }
            }
        }
        if !path_ok {
            continue;
        }

        // WAC 2.0 conjunctive condition gate. All conditions must
        // return `Satisfied`. Any `NotApplicable` or `Denied` skips
        // this authorisation (fail-closed).
        let mut conditions_ok = true;
        if let Some(conds) = &auth.condition {
            for cond in conds {
                match dispatcher.dispatch(cond, ctx, groups) {
                    ConditionOutcome::Satisfied => continue,
                    ConditionOutcome::NotApplicable | ConditionOutcome::Denied => {
                        conditions_ok = false;
                        break;
                    }
                }
            }
        }
        if !conditions_ok {
            continue;
        }

        base_grant = true;
        break;
    }
    if !base_grant {
        return false;
    }

    // WAC §4.3 invariant 4: Control mode bypasses the origin gate so
    // that an owner can always fix a mis-configured ACL from any
    // origin.
    if matches!(required_mode, AccessMode::Control) {
        return true;
    }

    // F4 — origin gate. Only active behind the `acl-origin` feature;
    // otherwise behave exactly as pre-F4 to preserve backward compat.
    #[cfg(feature = "acl-origin")]
    {
        match origin::check_origin(doc, request_origin) {
            origin::OriginDecision::NoPolicySet | origin::OriginDecision::Permitted => true,
            origin::OriginDecision::RejectedMismatch | origin::OriginDecision::RejectedNoOrigin => {
                crate::wac::metrics::ACL_ORIGIN_REJECTED_TOTAL
                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
                false
            }
        }
    }
    #[cfg(not(feature = "acl-origin"))]
    {
        let _ = request_origin;
        true
    }
}

/// Total `acl:PaymentCondition` cost (in satoshis) carried by the single
/// authorisation that grants `(ctx.web_id, required_mode)` on
/// `resource_path`.
///
/// This re-runs the exact predicate-matching performed by
/// [`evaluate_access_ctx`] — agent/mode/path match, ancestor-inheritance
/// `accessTo` gating, and the conjunctive condition gate — and, for the
/// first authorisation that grants, sums the `cost_sats` of every
/// `acl:PaymentCondition` attached to that one rule. Returns `0` when no
/// rule grants, or when the granting rule carries no PaymentCondition.
///
/// The handler layer calls this after a successful grant to learn how
/// many sats to debit from the caller's Web Ledger. Debiting the cost of
/// only the matched rule (rather than every PaymentCondition in the
/// document) means a caller is charged once, for the rule they actually
/// used.
pub fn granted_payment_cost(
    acl_doc: Option<&AclDocument>,
    ctx: &RequestContext<'_>,
    resource_path: &str,
    required_mode: AccessMode,
    groups: &dyn GroupMembership,
    dispatcher: &dyn ConditionDispatcher,
) -> u64 {
    let Some(doc) = acl_doc else {
        return 0;
    };
    let Some(graph) = doc.graph.as_ref() else {
        return 0;
    };
    let honour_access_to = !doc.inherited;
    for auth in graph {
        let granted = get_modes(auth);
        if !granted.contains(&required_mode) {
            continue;
        }
        if !agent_matches_with_groups(auth, ctx.web_id, groups) {
            continue;
        }
        let mut path_ok = false;
        if honour_access_to {
            for target in get_ids(&auth.access_to) {
                if path_matches(target, resource_path, false) {
                    path_ok = true;
                    break;
                }
            }
        }
        if !path_ok {
            for target in get_ids(&auth.default) {
                if path_matches(target, resource_path, true) {
                    path_ok = true;
                    break;
                }
            }
        }
        if !path_ok {
            continue;
        }

        // Conjunctive condition gate — same fail-closed semantics as the
        // evaluator. A rule only grants (and only incurs its cost) when
        // every attached condition is `Satisfied`.
        let mut conditions_ok = true;
        if let Some(conds) = &auth.condition {
            for cond in conds {
                match dispatcher.dispatch(cond, ctx, groups) {
                    ConditionOutcome::Satisfied => continue,
                    ConditionOutcome::NotApplicable | ConditionOutcome::Denied => {
                        conditions_ok = false;
                        break;
                    }
                }
            }
        }
        if !conditions_ok {
            continue;
        }

        // First granting authorisation wins — mirror the evaluator's
        // `break`. Charge only the PaymentConditions on this one rule.
        return auth
            .condition
            .as_deref()
            .map(crate::wac::payment::total_payment_cost)
            .unwrap_or(0);
    }
    0
}