1use serde::{Deserialize, Serialize};
12
13use crate::error::PodError;
14
15pub const MAX_ACL_BYTES: usize = 1_048_576;
29
30pub const MAX_ACL_JSON_DEPTH: usize = 32;
34
35fn 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
73pub 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
90pub 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
111pub 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
124pub 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#[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
198pub 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 if stripped.is_empty() {
225 return Some("/".to_string());
226 }
227 return Some(stripped.to_string());
228 }
229 }
230 None
231}
232
233pub 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
259pub 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
287pub 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#[cfg(feature = "acl-origin")]
334pub mod metrics {
335 use std::sync::atomic::AtomicU64;
336
337 pub static ACL_ORIGIN_REJECTED_TOTAL: AtomicU64 = AtomicU64::new(0);
339}
340
341#[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 #[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 let body = b"{}";
511 let doc = parse_jsonld_acl_with_limits(body, 1024, 32).unwrap();
512 assert!(doc.graph.is_none());
513 }
514
515 #[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 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 #[test]
552 fn effective_acl_target_golden_conformance_vectors() {
553 use AccessMode::{Append, Control, Read, Write};
554 let vectors: &[(&str, AccessMode, &str, AccessMode)] = &[
556 ("/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 ("/foo.acl", Read, "/foo", Control),
564 ("/foo.acl", Write, "/foo", Control),
565 ("/foo.acl", Append, "/foo", Control),
566 ("/dir/.acl", Read, "/dir/", Control),
568 ("/dir/.acl", Write, "/dir/", Control),
569 ("/.acl", Read, "/", Control),
571 ("/.acl", Write, "/", Control),
572 ("/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}