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::AclResolver;
145#[cfg(feature = "tokio-runtime")]
146pub use resolver::StorageAclResolver;
147pub use serializer::serialize_turtle_acl;
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
151pub enum AccessMode {
152 Read,
153 Write,
154 Append,
155 Control,
156}
157
158pub const ALL_MODES: &[AccessMode] = &[
159 AccessMode::Read,
160 AccessMode::Write,
161 AccessMode::Append,
162 AccessMode::Control,
163];
164
165pub(crate) fn map_mode(mode_ref: &str) -> &'static [AccessMode] {
166 match mode_ref {
167 "acl:Read" | "http://www.w3.org/ns/auth/acl#Read" => &[AccessMode::Read],
168 "acl:Write" | "http://www.w3.org/ns/auth/acl#Write" => {
169 &[AccessMode::Write, AccessMode::Append]
170 }
171 "acl:Append" | "http://www.w3.org/ns/auth/acl#Append" => &[AccessMode::Append],
172 "acl:Control" | "http://www.w3.org/ns/auth/acl#Control" => &[AccessMode::Control],
173 _ => &[],
174 }
175}
176
177pub fn method_to_mode(method: &str) -> AccessMode {
178 match method.to_uppercase().as_str() {
179 "GET" | "HEAD" => AccessMode::Read,
180 "PUT" | "DELETE" | "PATCH" => AccessMode::Write,
181 "POST" => AccessMode::Append,
182 _ => AccessMode::Read,
183 }
184}
185
186pub fn mode_name(mode: AccessMode) -> &'static str {
187 match mode {
188 AccessMode::Read => "read",
189 AccessMode::Write => "write",
190 AccessMode::Append => "append",
191 AccessMode::Control => "control",
192 }
193}
194
195pub fn wac_allow_header(
202 acl_doc: Option<&AclDocument>,
203 agent_uri: Option<&str>,
204 resource_path: &str,
205) -> String {
206 let mut user_modes = Vec::new();
207 let mut public_modes = Vec::new();
208 for mode in ALL_MODES {
209 if evaluate_access(acl_doc, agent_uri, resource_path, *mode, None) {
210 user_modes.push(mode_name(*mode));
211 }
212 if evaluate_access(acl_doc, None, resource_path, *mode, None) {
213 public_modes.push(mode_name(*mode));
214 }
215 }
216 format!(
217 "user=\"{}\", public=\"{}\"",
218 user_modes.join(" "),
219 public_modes.join(" ")
220 )
221}
222
223pub fn wac_allow_header_with_dispatcher(
226 acl_doc: Option<&AclDocument>,
227 ctx: &RequestContext<'_>,
228 resource_path: &str,
229 groups: &dyn GroupMembership,
230 dispatcher: &dyn ConditionDispatcher,
231) -> String {
232 let mut user_modes = Vec::new();
233 let mut public_modes = Vec::new();
234 let public_ctx = RequestContext {
235 web_id: None,
236 client_id: ctx.client_id,
237 issuer: ctx.issuer,
238 payment_balance_sats: ctx.payment_balance_sats,
239 };
240 for mode in ALL_MODES {
241 if evaluate_access_ctx(acl_doc, ctx, resource_path, *mode, None, groups, dispatcher) {
242 user_modes.push(mode_name(*mode));
243 }
244 if evaluate_access_ctx(
245 acl_doc,
246 &public_ctx,
247 resource_path,
248 *mode,
249 None,
250 groups,
251 dispatcher,
252 ) {
253 public_modes.push(mode_name(*mode));
254 }
255 }
256 format!(
257 "user=\"{}\", public=\"{}\"",
258 user_modes.join(" "),
259 public_modes.join(" ")
260 )
261}
262
263#[cfg(feature = "acl-origin")]
270pub mod metrics {
271 use std::sync::atomic::AtomicU64;
272
273 pub static ACL_ORIGIN_REJECTED_TOTAL: AtomicU64 = AtomicU64::new(0);
275}
276
277#[cfg(test)]
283mod tests {
284 use super::*;
285
286 fn make_doc(graph: Vec<AclAuthorization>) -> AclDocument {
287 AclDocument {
288 context: None,
289 graph: Some(graph),
290 inherited: false,
291 }
292 }
293
294 fn public_read(path: &str) -> AclAuthorization {
295 AclAuthorization {
296 id: None,
297 r#type: None,
298 agent: None,
299 agent_class: Some(IdOrIds::Single(IdRef {
300 id: "foaf:Agent".into(),
301 })),
302 agent_group: None,
303 origin: None,
304 access_to: Some(IdOrIds::Single(IdRef { id: path.into() })),
305 default: None,
306 mode: Some(IdOrIds::Single(IdRef {
307 id: "acl:Read".into(),
308 })),
309 condition: None,
310 }
311 }
312
313 #[test]
314 fn no_acl_denies_all() {
315 assert!(!evaluate_access(None, None, "/foo", AccessMode::Read, None));
316 }
317
318 #[test]
319 fn public_read_grants_anonymous() {
320 let doc = make_doc(vec![public_read("/")]);
321 assert!(evaluate_access(
322 Some(&doc),
323 None,
324 "/",
325 AccessMode::Read,
326 None
327 ));
328 }
329
330 #[test]
331 fn write_implies_append() {
332 let auth = AclAuthorization {
333 id: None,
334 r#type: None,
335 agent: Some(IdOrIds::Single(IdRef {
336 id: "did:nostr:owner".into(),
337 })),
338 agent_class: None,
339 agent_group: None,
340 origin: None,
341 access_to: Some(IdOrIds::Single(IdRef { id: "/".into() })),
342 default: None,
343 mode: Some(IdOrIds::Single(IdRef {
344 id: "acl:Write".into(),
345 })),
346 condition: None,
347 };
348 let doc = make_doc(vec![auth]);
349 assert!(evaluate_access(
350 Some(&doc),
351 Some("did:nostr:owner"),
352 "/",
353 AccessMode::Append,
354 None,
355 ));
356 }
357
358 #[test]
359 fn method_mapping() {
360 assert_eq!(method_to_mode("GET"), AccessMode::Read);
361 assert_eq!(method_to_mode("PUT"), AccessMode::Write);
362 assert_eq!(method_to_mode("POST"), AccessMode::Append);
363 }
364
365 #[test]
366 fn wac_allow_shape() {
367 let doc = make_doc(vec![public_read("/")]);
368 let hdr = wac_allow_header(Some(&doc), None, "/");
369 assert_eq!(hdr, "user=\"read\", public=\"read\"");
370 }
371
372 #[test]
373 fn turtle_acl_round_trip_parses_basic_rules() {
374 let ttl = r#"
375 @prefix acl: <http://www.w3.org/ns/auth/acl#> .
376 @prefix foaf: <http://xmlns.com/foaf/0.1/> .
377
378 <#public> a acl:Authorization ;
379 acl:agentClass foaf:Agent ;
380 acl:accessTo </> ;
381 acl:mode acl:Read .
382 "#;
383 let doc = parse_turtle_acl(ttl).unwrap();
384 assert!(evaluate_access(
385 Some(&doc),
386 None,
387 "/",
388 AccessMode::Read,
389 None
390 ));
391 assert!(!evaluate_access(
392 Some(&doc),
393 None,
394 "/",
395 AccessMode::Write,
396 None
397 ));
398 }
399
400 #[test]
401 fn turtle_acl_with_owner_grants_write() {
402 let ttl = r#"
403 @prefix acl: <http://www.w3.org/ns/auth/acl#> .
404
405 <#owner> a acl:Authorization ;
406 acl:agent <did:nostr:owner> ;
407 acl:accessTo </> ;
408 acl:default </> ;
409 acl:mode acl:Write, acl:Control .
410 "#;
411 let doc = parse_turtle_acl(ttl).unwrap();
412 assert!(evaluate_access(
413 Some(&doc),
414 Some("did:nostr:owner"),
415 "/foo",
416 AccessMode::Write,
417 None,
418 ));
419 }
420
421 #[test]
422 fn serialize_turtle_acl_emits_prefixes_and_rules() {
423 let doc = make_doc(vec![public_read("/")]);
424 let out = serialize_turtle_acl(&doc);
425 assert!(out.contains("@prefix acl:"));
426 assert!(out.contains("acl:Authorization"));
427 assert!(out.contains("acl:mode"));
428 }
429
430 #[test]
433 fn jsonld_acl_with_limits_rejects_oversized() {
434 let body = b"{\"@context\": \"https://www.w3.org/ns/auth/acl\"}";
435 let err = parse_jsonld_acl_with_limits(body, 10, 32).unwrap_err();
436 let msg = err.to_string();
437 assert!(
438 msg.contains("payload too large") || msg.contains("exceeds"),
439 "oversized JSON-LD should be rejected: {msg}"
440 );
441 }
442
443 #[test]
444 fn jsonld_acl_with_limits_accepts_within_bounds() {
445 let body = b"{}";
447 let doc = parse_jsonld_acl_with_limits(body, 1024, 32).unwrap();
448 assert!(doc.graph.is_none());
449 }
450}