1use std::sync::Arc;
13
14use crate::dir_cache::{DirCache, DirEntry};
15use crate::prelude::*;
16use cloudillo_types::meta_adapter;
17use cloudillo_types::meta_adapter::FileView;
18use cloudillo_types::types::{AccessLevel, TokenScope};
19
20pub const MAX_PARENT_DEPTH: usize = 64;
22
23pub struct FileAccessResult {
25 pub file_view: FileView,
26 pub access_level: AccessLevel,
27 pub read_only: bool,
28}
29
30pub enum FileAccessError {
32 NotFound,
33 AccessDenied,
34 InternalError(String),
35}
36
37pub struct FileAccessCtx<'a> {
39 pub user_id_tag: &'a str,
40 pub tenant_id_tag: &'a str,
41 pub user_roles: &'a [Box<str>],
42}
43
44pub async fn resolve_dir_entry(
52 meta: &Arc<dyn meta_adapter::MetaAdapter>,
53 cache: &DirCache,
54 tn_id: TnId,
55 file_id: &str,
56) -> ClResult<Option<DirEntry>> {
57 if let Some(entry) = cache.get(tn_id, file_id) {
58 return Ok(Some(entry)); }
60 match meta.read_file(tn_id, file_id).await? {
61 Some(view) => {
62 let is_folder = view.file_tp.as_deref() == Some("FLDR");
63 let entry = DirEntry {
64 parent_id: view.parent_id.clone(),
65 name: view.file_name.clone(),
66 is_folder,
67 };
68 if is_folder {
69 cache.put(tn_id, file_id, entry.clone());
70 }
71 Ok(Some(entry))
72 }
73 None => Ok(None),
74 }
75}
76
77pub async fn walk_parent_chain_for_share(
86 app: &App,
87 tn_id: TnId,
88 file_id: &str,
89 user_id_tag: &str,
90) -> Option<AccessLevel> {
91 let Ok(cache) = app.ext::<DirCache>() else {
95 warn!("DirCache extension missing; skipping inherited-share parent walk");
96 return None;
97 };
98 let mut current_id = file_id.to_string();
99 for _ in 0..MAX_PARENT_DEPTH {
100 let Ok(Some(entry)) = resolve_dir_entry(&app.meta_adapter, cache, tn_id, ¤t_id).await
102 else {
103 break;
104 };
105 let Some(parent_id) = entry.parent_id else { break };
106 if let Ok(Some(perm)) = app
107 .meta_adapter
108 .check_share_access(tn_id, 'F', &parent_id, 'U', user_id_tag)
109 .await
110 {
111 return Some(AccessLevel::from_perm_char(perm));
112 }
113 current_id = parent_id.to_string();
114 }
115 None
116}
117
118pub async fn is_descendant_of(
132 meta: &Arc<dyn meta_adapter::MetaAdapter>,
133 cache: &DirCache,
134 tn_id: TnId,
135 file_id: &str,
136 ancestor_id: &str,
137) -> ClResult<bool> {
138 let mut current_id = file_id.to_string();
139 for _ in 0..MAX_PARENT_DEPTH {
140 let Some(entry) = resolve_dir_entry(meta, cache, tn_id, ¤t_id).await? else {
141 break;
142 };
143 let Some(parent_id) = entry.parent_id else { break };
144 if parent_id.as_ref() == ancestor_id {
145 return Ok(true);
146 }
147 current_id = parent_id.to_string();
148 }
149 Ok(false)
150}
151
152pub async fn scope_target_is_folder(
161 meta: &Arc<dyn meta_adapter::MetaAdapter>,
162 cache: &DirCache,
163 tn_id: TnId,
164 scope_file_id: &str,
165) -> ClResult<bool> {
166 Ok(resolve_dir_entry(meta, cache, tn_id, scope_file_id)
167 .await?
168 .is_some_and(|e| e.is_folder))
169}
170
171pub async fn check_share_for_file(
174 app: &App,
175 tn_id: TnId,
176 file_id: &str,
177 user_id_tag: &str,
178) -> Option<AccessLevel> {
179 if let Ok(Some(perm)) =
180 app.meta_adapter.check_share_access(tn_id, 'F', file_id, 'U', user_id_tag).await
181 {
182 return Some(AccessLevel::from_perm_char(perm));
183 }
184 walk_parent_chain_for_share(app, tn_id, file_id, user_id_tag).await
185}
186
187pub fn role_access_level(user_roles: &[Box<str>]) -> AccessLevel {
195 if user_roles.iter().any(|r| r.as_ref() == "leader") {
198 return AccessLevel::Admin;
199 }
200 if user_roles.iter().any(|r| matches!(r.as_ref(), "moderator" | "contributor")) {
201 return AccessLevel::Write;
202 }
203 if user_roles
204 .iter()
205 .any(|r| matches!(r.as_ref(), "public" | "follower" | "supporter"))
206 {
207 return AccessLevel::Read;
208 }
209 AccessLevel::None
210}
211
212fn fshr_grant_level(
226 typ: &str,
227 sub_typ: Option<&str>,
228 issuer_tag: &str,
229 owner_id_tag: &str,
230 file_id: &str,
231) -> AccessLevel {
232 if typ != "FSHR" {
233 return AccessLevel::None;
234 }
235 if issuer_tag != owner_id_tag {
236 warn!(
237 file_id = %file_id,
238 issuer = %issuer_tag,
239 owner = %owner_id_tag,
240 sub_typ = ?sub_typ,
241 "Ignoring FSHR grant: issuer does not own the file"
242 );
243 return AccessLevel::None;
244 }
245 match sub_typ {
246 Some("ADMIN") => AccessLevel::Admin,
247 Some("WRITE") => AccessLevel::Write,
248 Some("COMMENT") => AccessLevel::Comment,
249 Some("DEL") => AccessLevel::None,
252 _ => AccessLevel::Read,
253 }
254}
255
256pub async fn get_access_level(
268 app: &App,
269 tn_id: TnId,
270 file_id: &str,
271 owner_id_tag: &str,
272 ctx: &FileAccessCtx<'_>,
273 inherited_share: Option<AccessLevel>,
274) -> AccessLevel {
275 if ctx.user_id_tag == owner_id_tag {
278 return AccessLevel::Admin;
279 }
280
281 if let Ok(Some(perm)) = app
283 .meta_adapter
284 .check_share_access(tn_id, 'F', file_id, 'U', ctx.user_id_tag)
285 .await
286 {
287 return AccessLevel::from_perm_char(perm);
288 }
289 if let Some(level) = inherited_share {
291 return level;
292 }
293 if let Some(level) = walk_parent_chain_for_share(app, tn_id, file_id, ctx.user_id_tag).await {
295 return level;
296 }
297
298 if owner_id_tag == ctx.tenant_id_tag {
303 let level = role_access_level(ctx.user_roles);
304 if level != AccessLevel::None {
305 return level;
306 }
307 }
308
309 let action_key = format!("FSHR:{}:{}", file_id, ctx.user_id_tag);
311
312 match app.meta_adapter.get_action_by_key(tn_id, &action_key).await {
315 Ok(Some(action)) => fshr_grant_level(
316 &action.typ,
317 action.sub_typ.as_ref().map(AsRef::as_ref),
318 &action.issuer_tag,
319 owner_id_tag,
320 file_id,
321 ),
322 Ok(None) | Err(_) => AccessLevel::None,
323 }
324}
325
326pub async fn get_access_level_with_scope(
334 app: &App,
335 tn_id: TnId,
336 file_id: &str,
337 owner_id_tag: &str,
338 ctx: &FileAccessCtx<'_>,
339 scope: Option<&str>,
340 root_id: Option<&str>,
341) -> AccessLevel {
342 if let Some(scope_str) = scope {
344 if let Some(token_scope) = TokenScope::parse(scope_str) {
346 match &token_scope {
347 TokenScope::File { file_id: scope_file_id, access } => {
348 if scope_file_id == file_id {
350 return *access;
351 }
352
353 if let Some(root) = root_id
356 && scope_file_id.as_str() == root
357 {
358 return *access;
359 }
360
361 if let Ok(Some(perm)) = app
366 .meta_adapter
367 .check_share_access(tn_id, 'F', scope_file_id, 'F', file_id)
368 .await
369 {
370 return (*access).min(AccessLevel::from_perm_char(perm));
372 }
373
374 if let Ok(cache) = app.ext::<DirCache>() {
384 let target_is_folder =
385 scope_target_is_folder(&app.meta_adapter, cache, tn_id, scope_file_id)
386 .await
387 .unwrap_or(false);
388 let nested_under_scope = target_is_folder
389 && is_descendant_of(
390 &app.meta_adapter,
391 cache,
392 tn_id,
393 file_id,
394 scope_file_id,
395 )
396 .await
397 .unwrap_or(false);
398 if nested_under_scope {
399 return *access;
400 }
401 } else {
402 warn!("DirCache extension missing; folder-share scope grant skipped");
403 }
404
405 return AccessLevel::None;
407 }
408 TokenScope::ApkgPublish => {
409 return AccessLevel::None;
411 }
412 }
413 }
414 return AccessLevel::None;
416 }
417
418 get_access_level(app, tn_id, file_id, owner_id_tag, ctx, None).await
420}
421
422pub async fn check_file_access_with_scope(
431 app: &App,
432 tn_id: TnId,
433 file_id: &str,
434 ctx: &FileAccessCtx<'_>,
435 scope: Option<&str>,
436 via: Option<&str>,
437) -> Result<FileAccessResult, FileAccessError> {
438 use tracing::debug;
439
440 let file_view = match app.meta_adapter.read_file(tn_id, file_id).await {
442 Ok(Some(f)) => f,
443 Ok(None) => return Err(FileAccessError::NotFound),
444 Err(e) => return Err(FileAccessError::InternalError(e.to_string())),
445 };
446
447 let owner_id_tag = file_view
450 .owner
451 .as_ref()
452 .and_then(|p| if p.id_tag.is_empty() { None } else { Some(p.id_tag.as_ref()) })
453 .unwrap_or(ctx.tenant_id_tag);
454
455 debug!(file_id = file_id, user = ctx.user_id_tag, owner = owner_id_tag, scope = ?scope, "Checking file access");
456
457 let mut access_level = get_access_level_with_scope(
459 app,
460 tn_id,
461 file_id,
462 owner_id_tag,
463 ctx,
464 scope,
465 file_view.root_id.as_deref(),
466 )
467 .await;
468
469 if access_level == AccessLevel::None && file_view.visibility == Some('P') {
471 access_level = AccessLevel::Read;
472 }
473
474 if let Some(via_file_id) = via
476 && scope.is_none()
477 && access_level != AccessLevel::None
478 {
479 match app.meta_adapter.check_share_access(tn_id, 'F', via_file_id, 'F', file_id).await {
480 Ok(Some(perm)) => {
481 access_level = access_level.min(AccessLevel::from_perm_char(perm));
482 }
483 Ok(None) | Err(_) => {
484 access_level = AccessLevel::None;
486 }
487 }
488 }
489
490 if access_level == AccessLevel::None {
491 return Err(FileAccessError::AccessDenied);
492 }
493
494 let read_only = !access_level.can_write();
495
496 Ok(FileAccessResult { file_view, access_level, read_only })
497}
498
499pub enum ScopeCheck {
501 NoScope,
503 Allowed(AccessLevel),
505 Denied,
507}
508
509pub fn check_scope_allows_file(
515 scope: Option<&str>,
516 file_id: &str,
517 root_id: Option<&str>,
518) -> ScopeCheck {
519 let Some(scope_str) = scope else { return ScopeCheck::NoScope };
520 let Some(token_scope) = TokenScope::parse(scope_str) else { return ScopeCheck::Denied };
522 match &token_scope {
523 TokenScope::File { file_id: scope_file_id, access } => {
524 if scope_file_id == file_id {
526 return ScopeCheck::Allowed(*access);
527 }
528 if let Some(root) = root_id
530 && scope_file_id.as_str() == root
531 {
532 return ScopeCheck::Allowed(*access);
533 }
534 ScopeCheck::Denied
535 }
536 TokenScope::ApkgPublish => ScopeCheck::Denied,
537 }
538}
539
540pub async fn check_scope_allows_create_in(
555 meta: &Arc<dyn meta_adapter::MetaAdapter>,
556 cache: &DirCache,
557 tn_id: TnId,
558 scope: Option<&str>,
559 parent_id: Option<&str>,
560 root_id: Option<&str>,
561) -> Result<(), Error> {
562 let Some(scope_str) = scope else { return Ok(()) };
563 let Some(token_scope) = TokenScope::parse(scope_str) else {
565 return Err(Error::PermissionDenied);
566 };
567 match &token_scope {
568 TokenScope::File { file_id: scope_file_id, access } => {
569 if !access.can_write() {
570 return Err(Error::PermissionDenied);
571 }
572 if root_id == Some(scope_file_id.as_str()) {
574 return Ok(());
575 }
576 if let Some(parent) = parent_id
581 && scope_target_is_folder(meta, cache, tn_id, scope_file_id).await?
582 && (parent == scope_file_id.as_str()
583 || is_descendant_of(meta, cache, tn_id, parent, scope_file_id).await?)
584 {
585 return Ok(());
586 }
587 Err(Error::PermissionDenied)
588 }
589 TokenScope::ApkgPublish => Ok(()), }
591}
592
593pub fn scope_grants_collection_op(scope: Option<&str>, resource_type: &str, action: &str) -> bool {
601 let Some(scope) = scope else { return false };
602 matches!(TokenScope::parse(scope), Some(TokenScope::File { access: AccessLevel::Write, .. }))
605 && resource_type == "file"
606 && action == "create"
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 #[test]
614 fn folder_write_scope_grants_file_create() {
615 assert!(scope_grants_collection_op(Some("file:f1~abc:W"), "file", "create"));
616 }
617
618 #[test]
619 fn write_scope_denies_non_file_create_ops() {
620 assert!(!scope_grants_collection_op(Some("file:f1~abc:W"), "action", "create"));
621 assert!(!scope_grants_collection_op(Some("file:f1~abc:W"), "file", "delete"));
622 }
623
624 #[test]
625 fn read_scope_denies_file_create() {
626 assert!(!scope_grants_collection_op(Some("file:f1~abc:R"), "file", "create"));
627 }
628
629 #[test]
630 fn no_scope_denies_file_create() {
631 assert!(!scope_grants_collection_op(None, "file", "create"));
632 }
633
634 #[test]
635 fn unparseable_scope_denies_file_create() {
636 assert!(!scope_grants_collection_op(Some("not-a-valid-scope"), "file", "create"));
637 }
638
639 #[test]
640 fn role_access_level_requires_a_known_role() {
641 assert_eq!(role_access_level(&[]), AccessLevel::None);
643 assert_eq!(role_access_level(&["".into()]), AccessLevel::None);
645 assert_eq!(role_access_level(&["SADM".into()]), AccessLevel::None);
646 }
647
648 #[test]
649 fn role_access_level_maps_community_roles() {
650 assert_eq!(role_access_level(&["public".into()]), AccessLevel::Read);
651 assert_eq!(role_access_level(&["follower".into()]), AccessLevel::Read);
652 assert_eq!(role_access_level(&["supporter".into()]), AccessLevel::Read);
653 assert_eq!(role_access_level(&["contributor".into()]), AccessLevel::Write);
654 assert_eq!(role_access_level(&["moderator".into()]), AccessLevel::Write);
655 assert_eq!(role_access_level(&["leader".into()]), AccessLevel::Admin);
657 assert_eq!(
659 role_access_level(&["public".into(), "follower".into(), "leader".into()]),
660 AccessLevel::Admin
661 );
662 assert_eq!(role_access_level(&["public".into(), "contributor".into()]), AccessLevel::Write);
663 }
664
665 const OWNER: &str = "alice.example.com";
666 const ATTACKER: &str = "mallory.example.com";
667
668 #[test]
669 fn fshr_from_a_non_owner_grants_nothing() {
670 for sub_typ in [Some("ADMIN"), Some("WRITE"), Some("COMMENT"), Some("READ"), None] {
675 assert_eq!(
676 fshr_grant_level("FSHR", sub_typ, ATTACKER, OWNER, "f1~doc"),
677 AccessLevel::None,
678 "{sub_typ:?} from a non-owner must grant nothing"
679 );
680 }
681 }
682
683 #[test]
684 fn fshr_from_the_owner_grants_its_sub_type() {
685 for (sub_typ, level) in [
688 (Some("ADMIN"), AccessLevel::Admin),
689 (Some("WRITE"), AccessLevel::Write),
690 (Some("COMMENT"), AccessLevel::Comment),
691 (Some("READ"), AccessLevel::Read),
692 (None, AccessLevel::Read),
693 ] {
694 assert_eq!(fshr_grant_level("FSHR", sub_typ, OWNER, OWNER, "f1~doc"), level);
695 }
696 }
697
698 #[test]
699 fn a_del_from_the_owner_revokes_rather_than_granting_read() {
700 assert_eq!(
703 fshr_grant_level("FSHR", Some("DEL"), OWNER, OWNER, "f1~doc"),
704 AccessLevel::None
705 );
706 }
707
708 #[test]
709 fn only_fshr_rows_grant_anything() {
710 assert_eq!(
712 fshr_grant_level("CONN", Some("ADMIN"), OWNER, OWNER, "f1~doc"),
713 AccessLevel::None
714 );
715 }
716}
717
718