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