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