1use crate::wac::conditions::{
9 ConditionDispatcher, ConditionOutcome, ConditionRegistry, EmptyDispatcher, RequestContext,
10};
11use crate::wac::document::{get_ids, AclAuthorization, AclDocument};
12use crate::wac::origin;
13use crate::wac::{map_mode, AccessMode};
14
15pub trait GroupMembership {
24 fn is_member(&self, group_iri: &str, agent_uri: &str) -> bool;
26}
27
28pub(crate) struct NoGroupMembership;
29impl GroupMembership for NoGroupMembership {
30 fn is_member(&self, _group_iri: &str, _agent_uri: &str) -> bool {
31 false
32 }
33}
34
35#[derive(Debug, Default, Clone)]
38pub struct StaticGroupMembership {
39 pub groups: std::collections::HashMap<String, Vec<String>>,
41}
42
43impl StaticGroupMembership {
44 pub fn new() -> Self {
46 Self::default()
47 }
48 pub fn add(&mut self, group_iri: impl Into<String>, members: Vec<String>) {
50 self.groups.insert(group_iri.into(), members);
51 }
52}
53
54impl GroupMembership for StaticGroupMembership {
55 fn is_member(&self, group_iri: &str, agent_uri: &str) -> bool {
56 self.groups
57 .get(group_iri)
58 .map(|m| m.iter().any(|x| x == agent_uri))
59 .unwrap_or(false)
60 }
61}
62
63pub(crate) fn normalize_path(path: &str) -> String {
64 let stripped = path.strip_prefix("./").or_else(|| path.strip_prefix('.'));
65 let base = match stripped {
66 Some("") => "/".to_string(),
67 Some(s) if !s.starts_with('/') => format!("/{s}"),
68 Some(s) => s.to_string(),
69 None => path.to_string(),
70 };
71 let trimmed = base.trim_end_matches('/');
72 if trimmed.is_empty() {
73 "/".to_string()
74 } else {
75 trimmed.to_string()
76 }
77}
78
79pub(crate) fn path_matches(rule_path: &str, resource_path: &str, is_default: bool) -> bool {
80 let rule = normalize_path(rule_path);
81 let resource = normalize_path(resource_path);
82 if resource == rule {
83 return true;
84 }
85 if !is_default {
91 let prefix = if rule == "/" {
92 String::from("/")
93 } else {
94 format!("{rule}/")
95 };
96 if let Some(rest) = resource.strip_prefix(&prefix) {
97 return !rest.is_empty() && !rest.contains('/');
98 }
99 return false;
100 }
101 if rule == "/" {
102 resource.starts_with('/')
103 } else {
104 resource.starts_with(&format!("{rule}/"))
105 }
106}
107
108pub(crate) fn get_modes(auth: &AclAuthorization) -> Vec<AccessMode> {
109 let mut modes = Vec::new();
110 for mode_ref in get_ids(&auth.mode) {
111 modes.extend_from_slice(map_mode(mode_ref));
112 }
113 modes
114}
115
116fn agent_matches_with_groups(
117 auth: &AclAuthorization,
118 agent_uri: Option<&str>,
119 groups: &dyn GroupMembership,
120) -> bool {
121 let agents = get_ids(&auth.agent);
122 if let Some(uri) = agent_uri {
123 if agents.contains(&uri) {
124 return true;
125 }
126 }
127 for cls in get_ids(&auth.agent_class) {
128 if cls == "foaf:Agent" || cls == "http://xmlns.com/foaf/0.1/Agent" {
129 return true;
130 }
131 if agent_uri.is_some()
132 && (cls == "acl:AuthenticatedAgent"
133 || cls == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent")
134 {
135 return true;
136 }
137 }
138 if let Some(uri) = agent_uri {
139 for group_iri in get_ids(&auth.agent_group) {
140 if groups.is_member(group_iri, uri) {
141 return true;
142 }
143 }
144 }
145 false
146}
147
148pub fn evaluate_access(
160 acl_doc: Option<&AclDocument>,
161 agent_uri: Option<&str>,
162 resource_path: &str,
163 required_mode: AccessMode,
164 request_origin: Option<&origin::Origin>,
165) -> bool {
166 evaluate_access_with_groups(
167 acl_doc,
168 agent_uri,
169 resource_path,
170 required_mode,
171 request_origin,
172 &NoGroupMembership,
173 )
174}
175
176pub fn evaluate_access_with_groups(
179 acl_doc: Option<&AclDocument>,
180 agent_uri: Option<&str>,
181 resource_path: &str,
182 required_mode: AccessMode,
183 request_origin: Option<&origin::Origin>,
184 groups: &dyn GroupMembership,
185) -> bool {
186 let ctx = RequestContext {
187 web_id: agent_uri,
188 client_id: None,
189 issuer: None,
190 };
191 evaluate_access_ctx_inner(
192 acl_doc,
193 &ctx,
194 resource_path,
195 required_mode,
196 request_origin,
197 groups,
198 &EmptyDispatcher,
199 )
200}
201
202#[allow(clippy::too_many_arguments)]
211pub fn evaluate_access_ctx(
212 acl_doc: Option<&AclDocument>,
213 ctx: &RequestContext<'_>,
214 resource_path: &str,
215 required_mode: AccessMode,
216 request_origin: Option<&origin::Origin>,
217 groups: &dyn GroupMembership,
218 dispatcher: &dyn ConditionDispatcher,
219) -> bool {
220 evaluate_access_ctx_inner(
221 acl_doc,
222 ctx,
223 resource_path,
224 required_mode,
225 request_origin,
226 groups,
227 dispatcher,
228 )
229}
230
231#[allow(clippy::too_many_arguments)]
233pub fn evaluate_access_ctx_with_registry(
234 acl_doc: Option<&AclDocument>,
235 ctx: &RequestContext<'_>,
236 resource_path: &str,
237 required_mode: AccessMode,
238 request_origin: Option<&origin::Origin>,
239 groups: &dyn GroupMembership,
240 registry: &ConditionRegistry,
241) -> bool {
242 evaluate_access_ctx_inner(
243 acl_doc,
244 ctx,
245 resource_path,
246 required_mode,
247 request_origin,
248 groups,
249 registry,
250 )
251}
252
253#[allow(clippy::too_many_arguments)]
254fn evaluate_access_ctx_inner(
255 acl_doc: Option<&AclDocument>,
256 ctx: &RequestContext<'_>,
257 resource_path: &str,
258 required_mode: AccessMode,
259 request_origin: Option<&origin::Origin>,
260 groups: &dyn GroupMembership,
261 dispatcher: &dyn ConditionDispatcher,
262) -> bool {
263 let Some(doc) = acl_doc else {
264 return false;
265 };
266 let Some(graph) = doc.graph.as_ref() else {
267 return false;
268 };
269 let mut base_grant = false;
270 for auth in graph {
271 let granted = get_modes(auth);
272 if !granted.contains(&required_mode) {
273 continue;
274 }
275 if !agent_matches_with_groups(auth, ctx.web_id, groups) {
276 continue;
277 }
278 let mut path_ok = false;
279 for target in get_ids(&auth.access_to) {
280 if path_matches(target, resource_path, false) {
281 path_ok = true;
282 break;
283 }
284 }
285 if !path_ok {
286 for target in get_ids(&auth.default) {
287 if path_matches(target, resource_path, true) {
288 path_ok = true;
289 break;
290 }
291 }
292 }
293 if !path_ok {
294 continue;
295 }
296
297 let mut conditions_ok = true;
301 if let Some(conds) = &auth.condition {
302 for cond in conds {
303 match dispatcher.dispatch(cond, ctx, groups) {
304 ConditionOutcome::Satisfied => continue,
305 ConditionOutcome::NotApplicable | ConditionOutcome::Denied => {
306 conditions_ok = false;
307 break;
308 }
309 }
310 }
311 }
312 if !conditions_ok {
313 continue;
314 }
315
316 base_grant = true;
317 break;
318 }
319 if !base_grant {
320 return false;
321 }
322
323 if matches!(required_mode, AccessMode::Control) {
327 return true;
328 }
329
330 #[cfg(feature = "acl-origin")]
333 {
334 match origin::check_origin(doc, request_origin) {
335 origin::OriginDecision::NoPolicySet | origin::OriginDecision::Permitted => true,
336 origin::OriginDecision::RejectedMismatch
337 | origin::OriginDecision::RejectedNoOrigin => {
338 crate::wac::metrics::ACL_ORIGIN_REJECTED_TOTAL
339 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
340 false
341 }
342 }
343 }
344 #[cfg(not(feature = "acl-origin"))]
345 {
346 let _ = request_origin;
347 true
348 }
349}