1use std::sync::Arc;
12
13use crate::dir_cache::{DirCache, DirEntry};
14use crate::prelude::*;
15use cloudillo_types::meta_adapter;
16use cloudillo_types::meta_adapter::FileView;
17use cloudillo_types::types::{AccessLevel, TokenScope};
18
19pub const MAX_PARENT_DEPTH: usize = 64;
21
22pub struct FileAccessResult {
24 pub file_view: FileView,
25 pub access_level: AccessLevel,
26 pub read_only: bool,
27}
28
29pub enum FileAccessError {
31 NotFound,
32 AccessDenied,
33 InternalError(String),
34}
35
36pub struct FileAccessCtx<'a> {
38 pub user_id_tag: &'a str,
39 pub tenant_id_tag: &'a str,
40 pub user_roles: &'a [Box<str>],
41}
42
43pub async fn resolve_dir_entry(
51 meta: &Arc<dyn meta_adapter::MetaAdapter>,
52 cache: &DirCache,
53 tn_id: TnId,
54 file_id: &str,
55) -> ClResult<Option<DirEntry>> {
56 if let Some(entry) = cache.get(tn_id, file_id) {
57 return Ok(Some(entry)); }
59 match meta.read_file(tn_id, file_id).await? {
60 Some(view) => {
61 let is_folder = view.file_tp.as_deref() == Some("FLDR");
62 let entry = DirEntry {
63 parent_id: view.parent_id.clone(),
64 name: view.file_name.clone(),
65 is_folder,
66 };
67 if is_folder {
68 cache.put(tn_id, file_id, entry.clone());
69 }
70 Ok(Some(entry))
71 }
72 None => Ok(None),
73 }
74}
75
76pub async fn walk_parent_chain_for_share(
85 app: &App,
86 tn_id: TnId,
87 file_id: &str,
88 user_id_tag: &str,
89) -> Option<AccessLevel> {
90 let Ok(cache) = app.ext::<DirCache>() else {
94 warn!("DirCache extension missing; skipping inherited-share parent walk");
95 return None;
96 };
97 let mut current_id = file_id.to_string();
98 for _ in 0..MAX_PARENT_DEPTH {
99 let Ok(Some(entry)) = resolve_dir_entry(&app.meta_adapter, cache, tn_id, ¤t_id).await
101 else {
102 break;
103 };
104 let Some(parent_id) = entry.parent_id else { break };
105 if let Ok(Some(perm)) = app
106 .meta_adapter
107 .check_share_access(tn_id, 'F', &parent_id, 'U', user_id_tag)
108 .await
109 {
110 return Some(AccessLevel::from_perm_char(perm));
111 }
112 current_id = parent_id.to_string();
113 }
114 None
115}
116
117pub async fn is_descendant_of(
131 meta: &Arc<dyn meta_adapter::MetaAdapter>,
132 cache: &DirCache,
133 tn_id: TnId,
134 file_id: &str,
135 ancestor_id: &str,
136) -> ClResult<bool> {
137 let mut current_id = file_id.to_string();
138 for _ in 0..MAX_PARENT_DEPTH {
139 let Some(entry) = resolve_dir_entry(meta, cache, tn_id, ¤t_id).await? else {
140 break;
141 };
142 let Some(parent_id) = entry.parent_id else { break };
143 if parent_id.as_ref() == ancestor_id {
144 return Ok(true);
145 }
146 current_id = parent_id.to_string();
147 }
148 Ok(false)
149}
150
151pub async fn scope_target_is_folder(
160 meta: &Arc<dyn meta_adapter::MetaAdapter>,
161 cache: &DirCache,
162 tn_id: TnId,
163 scope_file_id: &str,
164) -> ClResult<bool> {
165 Ok(resolve_dir_entry(meta, cache, tn_id, scope_file_id)
166 .await?
167 .is_some_and(|e| e.is_folder))
168}
169
170pub async fn check_share_for_file(
173 app: &App,
174 tn_id: TnId,
175 file_id: &str,
176 user_id_tag: &str,
177) -> Option<AccessLevel> {
178 if let Ok(Some(perm)) =
179 app.meta_adapter.check_share_access(tn_id, 'F', file_id, 'U', user_id_tag).await
180 {
181 return Some(AccessLevel::from_perm_char(perm));
182 }
183 walk_parent_chain_for_share(app, tn_id, file_id, user_id_tag).await
184}
185
186pub async fn get_access_level(
195 app: &App,
196 tn_id: TnId,
197 file_id: &str,
198 owner_id_tag: &str,
199 ctx: &FileAccessCtx<'_>,
200 inherited_share: Option<AccessLevel>,
201) -> AccessLevel {
202 if ctx.user_id_tag == owner_id_tag {
204 return AccessLevel::Write;
205 }
206
207 if let Ok(Some(perm)) = app
209 .meta_adapter
210 .check_share_access(tn_id, 'F', file_id, 'U', ctx.user_id_tag)
211 .await
212 {
213 return AccessLevel::from_perm_char(perm);
214 }
215 if let Some(level) = inherited_share {
217 return level;
218 }
219 if let Some(level) = walk_parent_chain_for_share(app, tn_id, file_id, ctx.user_id_tag).await {
221 return level;
222 }
223
224 if owner_id_tag == ctx.tenant_id_tag && !ctx.user_roles.is_empty() {
229 if ctx
230 .user_roles
231 .iter()
232 .any(|r| matches!(r.as_ref(), "leader" | "moderator" | "contributor"))
233 {
234 return AccessLevel::Write;
235 }
236 return AccessLevel::Read;
238 }
239
240 let action_key = format!("FSHR:{}:{}", file_id, ctx.user_id_tag);
242
243 match app.meta_adapter.get_action_by_key(tn_id, &action_key).await {
244 Ok(Some(action)) => {
245 if action.typ.as_ref() == "FSHR" {
247 match action.sub_typ.as_ref().map(AsRef::as_ref) {
249 Some("WRITE") => AccessLevel::Write,
250 Some("COMMENT") => AccessLevel::Comment,
251 _ => AccessLevel::Read,
252 }
253 } else {
254 AccessLevel::None
255 }
256 }
257 Ok(None) | Err(_) => AccessLevel::None,
258 }
259}
260
261pub async fn get_access_level_with_scope(
270 app: &App,
271 tn_id: TnId,
272 file_id: &str,
273 owner_id_tag: &str,
274 ctx: &FileAccessCtx<'_>,
275 scope: Option<&str>,
276 root_id: Option<&str>,
277) -> AccessLevel {
278 if let Some(scope_str) = scope {
280 if let Some(token_scope) = TokenScope::parse(scope_str) {
282 match &token_scope {
283 TokenScope::File { file_id: scope_file_id, access } => {
284 if scope_file_id == file_id {
286 return *access;
287 }
288
289 if let Some(root) = root_id
292 && scope_file_id.as_str() == root
293 {
294 return *access;
295 }
296
297 if let Ok(Some(perm)) = app
302 .meta_adapter
303 .check_share_access(tn_id, 'F', scope_file_id, 'F', file_id)
304 .await
305 {
306 return (*access).min(AccessLevel::from_perm_char(perm));
308 }
309
310 if let Ok(cache) = app.ext::<DirCache>() {
320 let target_is_folder =
321 scope_target_is_folder(&app.meta_adapter, cache, tn_id, scope_file_id)
322 .await
323 .unwrap_or(false);
324 let nested_under_scope = target_is_folder
325 && is_descendant_of(
326 &app.meta_adapter,
327 cache,
328 tn_id,
329 file_id,
330 scope_file_id,
331 )
332 .await
333 .unwrap_or(false);
334 if nested_under_scope {
335 return *access;
336 }
337 } else {
338 warn!("DirCache extension missing; folder-share scope grant skipped");
339 }
340
341 return AccessLevel::None;
343 }
344 TokenScope::ApkgPublish => {
345 return AccessLevel::None;
347 }
348 }
349 }
350 return AccessLevel::None;
352 }
353
354 get_access_level(app, tn_id, file_id, owner_id_tag, ctx, None).await
356}
357
358pub async fn check_file_access_with_scope(
367 app: &App,
368 tn_id: TnId,
369 file_id: &str,
370 ctx: &FileAccessCtx<'_>,
371 scope: Option<&str>,
372 via: Option<&str>,
373) -> Result<FileAccessResult, FileAccessError> {
374 use tracing::debug;
375
376 let file_view = match app.meta_adapter.read_file(tn_id, file_id).await {
378 Ok(Some(f)) => f,
379 Ok(None) => return Err(FileAccessError::NotFound),
380 Err(e) => return Err(FileAccessError::InternalError(e.to_string())),
381 };
382
383 let owner_id_tag = file_view
386 .owner
387 .as_ref()
388 .and_then(|p| if p.id_tag.is_empty() { None } else { Some(p.id_tag.as_ref()) })
389 .unwrap_or(ctx.tenant_id_tag);
390
391 debug!(file_id = file_id, user = ctx.user_id_tag, owner = owner_id_tag, scope = ?scope, "Checking file access");
392
393 let mut access_level = get_access_level_with_scope(
395 app,
396 tn_id,
397 file_id,
398 owner_id_tag,
399 ctx,
400 scope,
401 file_view.root_id.as_deref(),
402 )
403 .await;
404
405 if access_level == AccessLevel::None && file_view.visibility == Some('P') {
407 access_level = AccessLevel::Read;
408 }
409
410 if let Some(via_file_id) = via
412 && scope.is_none()
413 && access_level != AccessLevel::None
414 {
415 match app.meta_adapter.check_share_access(tn_id, 'F', via_file_id, 'F', file_id).await {
416 Ok(Some(perm)) => {
417 access_level = access_level.min(AccessLevel::from_perm_char(perm));
418 }
419 Ok(None) | Err(_) => {
420 access_level = AccessLevel::None;
422 }
423 }
424 }
425
426 if access_level == AccessLevel::None {
427 return Err(FileAccessError::AccessDenied);
428 }
429
430 let read_only = access_level != AccessLevel::Write && access_level != AccessLevel::Admin;
431
432 Ok(FileAccessResult { file_view, access_level, read_only })
433}
434
435pub enum ScopeCheck {
437 NoScope,
439 Allowed(AccessLevel),
441 Denied,
443}
444
445pub fn check_scope_allows_file(
451 scope: Option<&str>,
452 file_id: &str,
453 root_id: Option<&str>,
454) -> ScopeCheck {
455 let Some(scope_str) = scope else { return ScopeCheck::NoScope };
456 let Some(token_scope) = TokenScope::parse(scope_str) else { return ScopeCheck::Denied };
458 match &token_scope {
459 TokenScope::File { file_id: scope_file_id, access } => {
460 if scope_file_id == file_id {
462 return ScopeCheck::Allowed(*access);
463 }
464 if let Some(root) = root_id
466 && scope_file_id.as_str() == root
467 {
468 return ScopeCheck::Allowed(*access);
469 }
470 ScopeCheck::Denied
471 }
472 TokenScope::ApkgPublish => ScopeCheck::Denied,
473 }
474}
475
476pub async fn check_scope_allows_create_in(
491 meta: &Arc<dyn meta_adapter::MetaAdapter>,
492 cache: &DirCache,
493 tn_id: TnId,
494 scope: Option<&str>,
495 parent_id: Option<&str>,
496 root_id: Option<&str>,
497) -> Result<(), Error> {
498 let Some(scope_str) = scope else { return Ok(()) };
499 let Some(token_scope) = TokenScope::parse(scope_str) else {
501 return Err(Error::PermissionDenied);
502 };
503 match &token_scope {
504 TokenScope::File { file_id: scope_file_id, access } => {
505 if *access != AccessLevel::Write {
506 return Err(Error::PermissionDenied);
507 }
508 if root_id == Some(scope_file_id.as_str()) {
510 return Ok(());
511 }
512 if let Some(parent) = parent_id
517 && scope_target_is_folder(meta, cache, tn_id, scope_file_id).await?
518 && (parent == scope_file_id.as_str()
519 || is_descendant_of(meta, cache, tn_id, parent, scope_file_id).await?)
520 {
521 return Ok(());
522 }
523 Err(Error::PermissionDenied)
524 }
525 TokenScope::ApkgPublish => Ok(()), }
527}
528
529pub fn scope_grants_collection_op(scope: Option<&str>, resource_type: &str, action: &str) -> bool {
537 let Some(scope) = scope else { return false };
538 matches!(TokenScope::parse(scope), Some(TokenScope::File { access: AccessLevel::Write, .. }))
539 && resource_type == "file"
540 && action == "create"
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 #[test]
548 fn folder_write_scope_grants_file_create() {
549 assert!(scope_grants_collection_op(Some("file:f1~abc:W"), "file", "create"));
550 }
551
552 #[test]
553 fn write_scope_denies_non_file_create_ops() {
554 assert!(!scope_grants_collection_op(Some("file:f1~abc:W"), "action", "create"));
555 assert!(!scope_grants_collection_op(Some("file:f1~abc:W"), "file", "delete"));
556 }
557
558 #[test]
559 fn read_scope_denies_file_create() {
560 assert!(!scope_grants_collection_op(Some("file:f1~abc:R"), "file", "create"));
561 }
562
563 #[test]
564 fn no_scope_denies_file_create() {
565 assert!(!scope_grants_collection_op(None, "file", "create"));
566 }
567
568 #[test]
569 fn unparseable_scope_denies_file_create() {
570 assert!(!scope_grants_collection_op(Some("not-a-valid-scope"), "file", "create"));
571 }
572}
573
574