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 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
123pub 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, granted_payment_cost, 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#[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
193pub 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
221pub 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#[cfg(feature = "acl-origin")]
268pub mod metrics {
269 use std::sync::atomic::AtomicU64;
270
271 pub static ACL_ORIGIN_REJECTED_TOTAL: AtomicU64 = AtomicU64::new(0);
273}
274
275#[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 inherited: false,
289 }
290 }
291
292 fn public_read(path: &str) -> AclAuthorization {
293 AclAuthorization {
294 id: None,
295 r#type: None,
296 agent: None,
297 agent_class: Some(IdOrIds::Single(IdRef {
298 id: "foaf:Agent".into(),
299 })),
300 agent_group: None,
301 origin: None,
302 access_to: Some(IdOrIds::Single(IdRef { id: path.into() })),
303 default: None,
304 mode: Some(IdOrIds::Single(IdRef {
305 id: "acl:Read".into(),
306 })),
307 condition: None,
308 }
309 }
310
311 #[test]
312 fn no_acl_denies_all() {
313 assert!(!evaluate_access(None, None, "/foo", AccessMode::Read, None));
314 }
315
316 #[test]
317 fn public_read_grants_anonymous() {
318 let doc = make_doc(vec![public_read("/")]);
319 assert!(evaluate_access(
320 Some(&doc),
321 None,
322 "/",
323 AccessMode::Read,
324 None
325 ));
326 }
327
328 #[test]
329 fn write_implies_append() {
330 let auth = AclAuthorization {
331 id: None,
332 r#type: None,
333 agent: Some(IdOrIds::Single(IdRef {
334 id: "did:nostr:owner".into(),
335 })),
336 agent_class: None,
337 agent_group: None,
338 origin: None,
339 access_to: Some(IdOrIds::Single(IdRef { id: "/".into() })),
340 default: None,
341 mode: Some(IdOrIds::Single(IdRef {
342 id: "acl:Write".into(),
343 })),
344 condition: None,
345 };
346 let doc = make_doc(vec![auth]);
347 assert!(evaluate_access(
348 Some(&doc),
349 Some("did:nostr:owner"),
350 "/",
351 AccessMode::Append,
352 None,
353 ));
354 }
355
356 #[test]
357 fn method_mapping() {
358 assert_eq!(method_to_mode("GET"), AccessMode::Read);
359 assert_eq!(method_to_mode("PUT"), AccessMode::Write);
360 assert_eq!(method_to_mode("POST"), AccessMode::Append);
361 }
362
363 #[test]
364 fn wac_allow_shape() {
365 let doc = make_doc(vec![public_read("/")]);
366 let hdr = wac_allow_header(Some(&doc), None, "/");
367 assert_eq!(hdr, "user=\"read\", public=\"read\"");
368 }
369
370 #[test]
371 fn turtle_acl_round_trip_parses_basic_rules() {
372 let ttl = r#"
373 @prefix acl: <http://www.w3.org/ns/auth/acl#> .
374 @prefix foaf: <http://xmlns.com/foaf/0.1/> .
375
376 <#public> a acl:Authorization ;
377 acl:agentClass foaf:Agent ;
378 acl:accessTo </> ;
379 acl:mode acl:Read .
380 "#;
381 let doc = parse_turtle_acl(ttl).unwrap();
382 assert!(evaluate_access(
383 Some(&doc),
384 None,
385 "/",
386 AccessMode::Read,
387 None
388 ));
389 assert!(!evaluate_access(
390 Some(&doc),
391 None,
392 "/",
393 AccessMode::Write,
394 None
395 ));
396 }
397
398 #[test]
399 fn turtle_acl_with_owner_grants_write() {
400 let ttl = r#"
401 @prefix acl: <http://www.w3.org/ns/auth/acl#> .
402
403 <#owner> a acl:Authorization ;
404 acl:agent <did:nostr:owner> ;
405 acl:accessTo </> ;
406 acl:default </> ;
407 acl:mode acl:Write, acl:Control .
408 "#;
409 let doc = parse_turtle_acl(ttl).unwrap();
410 assert!(evaluate_access(
411 Some(&doc),
412 Some("did:nostr:owner"),
413 "/foo",
414 AccessMode::Write,
415 None,
416 ));
417 }
418
419 #[test]
420 fn serialize_turtle_acl_emits_prefixes_and_rules() {
421 let doc = make_doc(vec![public_read("/")]);
422 let out = serialize_turtle_acl(&doc);
423 assert!(out.contains("@prefix acl:"));
424 assert!(out.contains("acl:Authorization"));
425 assert!(out.contains("acl:mode"));
426 }
427
428 #[test]
431 fn jsonld_acl_with_limits_rejects_oversized() {
432 let body = b"{\"@context\": \"https://www.w3.org/ns/auth/acl\"}";
433 let err = parse_jsonld_acl_with_limits(body, 10, 32).unwrap_err();
434 let msg = err.to_string();
435 assert!(
436 msg.contains("payload too large") || msg.contains("exceeds"),
437 "oversized JSON-LD should be rejected: {msg}"
438 );
439 }
440
441 #[test]
442 fn jsonld_acl_with_limits_accepts_within_bounds() {
443 let body = b"{}";
445 let doc = parse_jsonld_acl_with_limits(body, 1024, 32).unwrap();
446 assert!(doc.graph.is_none());
447 }
448}