Skip to main content

cloudillo_core/
file_access.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! File access level helpers
5//!
6//! Provides functions to determine user access levels to files based on:
7//! - Scoped tokens (file:{file_id}:{R|W} grants Read/Write access)
8//! - Ownership (owner always has Write access)
9//! - FSHR action grants (WRITE subtype = Write, otherwise Read)
10
11use 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
19/// Maximum parent-chain depth for bounded folder-tree traversals.
20pub const MAX_PARENT_DEPTH: usize = 64;
21
22/// Result of checking file access
23pub struct FileAccessResult {
24	pub file_view: FileView,
25	pub access_level: AccessLevel,
26	pub read_only: bool,
27}
28
29/// Error type for file access checks
30pub enum FileAccessError {
31	NotFound,
32	AccessDenied,
33	InternalError(String),
34}
35
36/// Context describing the subject requesting file access
37pub 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
43/// Resolve one `(tn, file_id)` → `DirEntry` through the folder cache, falling back
44/// to a single `read_file` on a miss. The row is cached **only when it is a folder**
45/// (`is_folder`), keeping the cache small and folder-only; non-folder rows (e.g. the
46/// leaf that starts a descendant walk) are returned but never inserted.
47///
48/// Propagates read errors as `Err` so request-path callers can surface a genuine
49/// fault as 5xx instead of mistaking it for "missing / not a descendant".
50pub 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)); // cached ⇒ folder
58	}
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
76/// Walk the parent chain of a file to find an inherited share entry.
77///
78/// Checks each ancestor's share_access for the given user. Returns the first
79/// (closest ancestor) match's access level, or None if no ancestor is shared.
80/// Bounded to `MAX_PARENT_DEPTH` levels to prevent runaway traversal.
81///
82/// This is one of the single, cache-backed parent-chain walkers: every hop goes
83/// through `resolve_dir_entry`, memoizing folder rows in the shared `DirCache`.
84pub 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	// DirCache is a required process-wide extension registered at app build (see
91	// crates/cloudillo/src/app.rs); a missing cache means misconfiguration, so log
92	// rather than silently dropping inherited-share access.
93	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		// Best-effort: a read error ends the walk (treated as no inherited share).
100		let Ok(Some(entry)) = resolve_dir_entry(&app.meta_adapter, cache, tn_id, &current_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
117/// Return true if `ancestor_id` is an ancestor folder of `file_id`.
118///
119/// Walks the `parent_id` chain upward from `file_id`, bounded to
120/// `MAX_PARENT_DEPTH` levels to prevent runaway traversal. Used to extend
121/// file-scope tokens (folder share links) to every descendant of a shared
122/// folder. The file itself is not considered its own descendant — callers
123/// handle the direct match separately.
124///
125/// Propagates read errors as `Err` so callers on request paths can surface a
126/// genuine fault as 5xx instead of silently treating it as "not a descendant".
127///
128/// This is one of the single, cache-backed parent-chain walkers: every hop goes
129/// through `resolve_dir_entry`, memoizing folder rows in the shared `DirCache`.
130pub 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, &current_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
151/// Return true if the scoped target file is a folder (`file_tp == "FLDR"`).
152///
153/// Used to gate the folder-subtree extension of a file-scope token: only a
154/// scope whose target is an actual folder grants access across its `parent_id`
155/// descendants. Answered straight from the folder cache via `resolve_dir_entry`:
156/// returns `Ok(true)` only for an existing `FLDR` row, `Ok(false)` for a missing
157/// or non-folder row, and `Err` for a genuine read fault so request-path callers
158/// that can return 5xx surface the fault instead of masking it as "not a folder".
159pub 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
170/// Check if a user has share access to a file — either a direct share entry
171/// on the file itself or an inherited share from an ancestor folder.
172pub 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
186/// Get access level for a user on a file
187///
188/// Determines access level based on:
189/// 1. Ownership - owner has Write access
190/// 2. Role-based access - for tenant-owned files (no explicit owner), community
191///    roles determine access: leader/moderator/contributor → Write, any role → Read
192/// 3. FSHR action - WRITE subtype grants Write, other subtypes grant Read
193/// 4. No access - returns None
194pub 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	// Owner always has write access
203	if ctx.user_id_tag == owner_id_tag {
204		return AccessLevel::Write;
205	}
206
207	// Direct share on this specific file
208	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	// Inherited share from parent folder (already resolved by caller)
216	if let Some(level) = inherited_share {
217		return level;
218	}
219	// No known inheritance — walk the parent chain
220	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	// Role-based access for tenant-owned files only (owner_id_tag == tenant_id_tag)
225	// When a file has no explicit owner, it belongs to the tenant.
226	// Community members with roles get access based on their role level.
227	// Files owned by other users are NOT accessible via role-based access.
228	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		// Any authenticated role on this tenant → at least Read
237		return AccessLevel::Read;
238	}
239
240	// Look up FSHR action: key pattern is "FSHR:{file_id}:{audience}"
241	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			// Check if action is FSHR type and active
246			if action.typ.as_ref() == "FSHR" {
247				// WRITE subtype grants write, COMMENT grants comment, others grant read
248				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
261/// Get access level for a user on a file, considering scoped tokens
262///
263/// Determines access level based on:
264/// 1. Scoped token - file:{file_id}:{R|W} grants Read/Write access
265///    (also checks document tree: a token for a root grants access to children)
266/// 2. Ownership - owner has Write access
267/// 3. FSHR action - WRITE subtype grants Write, other subtypes grant Read
268/// 4. No access - returns None
269pub 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	// Check scope-based access first (for share links)
279	if let Some(scope_str) = scope {
280		// Use typed TokenScope for safe parsing
281		if let Some(token_scope) = TokenScope::parse(scope_str) {
282			match &token_scope {
283				TokenScope::File { file_id: scope_file_id, access } => {
284					// Direct match: scope matches this file_id
285					if scope_file_id == file_id {
286						return *access;
287					}
288
289					// Document tree check: scope is for a root, this file is a child
290					// Depth-1 invariant: root_id always points directly to a top-level file
291					if let Some(root) = root_id
292						&& scope_file_id.as_str() == root
293					{
294						return *access;
295					}
296
297					// Cross-document link: file-type share entry ('F')
298					// If scope grants access to file A, check if there's a share entry
299					// linking file A → target file
300					// resource=container (scope_file_id), subject=target (file_id)
301					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						// Cap at min(scope_access, share_permission)
307						return (*access).min(AccessLevel::from_perm_char(perm));
308					}
309
310					// Folder share: scope targets a folder; grant the scope's level
311					// to any file nested under it (linked via parent_id). Gated on
312					// the scoped target actually being a folder, so a document/file
313					// share link does not leak access across its parent_id siblings.
314					// Fails closed — a missing cache or read error yields no grant,
315					// since returning a bare AccessLevel here cannot signal a 5xx.
316					// DirCache is a required process-wide extension registered at app
317					// build (see crates/cloudillo/src/app.rs), so the else arm only
318					// fires on misconfiguration — log rather than fail silently.
319					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					// Scope exists for a different file - deny access
342					return AccessLevel::None;
343				}
344				TokenScope::ApkgPublish => {
345					// APKG publish scope has no file access
346					return AccessLevel::None;
347				}
348			}
349		}
350		// Scope string present but unparseable — deny access (least privilege)
351		return AccessLevel::None;
352	}
353
354	// Fall back to existing logic (ownership, roles, FSHR actions)
355	get_access_level(app, tn_id, file_id, owner_id_tag, ctx, None).await
356}
357
358/// Check file access and return file view with access level
359///
360/// This is the main helper for WebSocket handlers. It:
361/// 1. Loads file metadata
362/// 2. Determines access level (considering scoped tokens for share links)
363/// 3. Returns combined result or error
364///
365/// The scope parameter should be auth_ctx.scope.as_deref().
366pub 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	// Load file metadata
377	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	// Get owner id_tag from file metadata
384	// If no owner, default to tenant (tenant owns all files without explicit owner)
385	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	// Get access level (considering scope for share links and document trees)
394	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	// Public files are readable by anyone (including unauthenticated guests)
406	if access_level == AccessLevel::None && file_view.visibility == Some('P') {
407		access_level = AccessLevel::Read;
408	}
409
410	// Cap access by file-to-file share entry when opened via embedding
411	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				// No file-to-file share entry — embedding doesn't exist, deny
421				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
435/// Result of checking whether a file is allowed by scope
436pub enum ScopeCheck {
437	/// No scope restriction — fall through to normal access checks
438	NoScope,
439	/// File is within scope with this access level
440	Allowed(AccessLevel),
441	/// File is outside scope — deny access
442	Denied,
443}
444
445/// Check if a file operation is allowed by scope.
446///
447/// Returns `ScopeCheck::NoScope` when there is no scope restriction,
448/// `ScopeCheck::Allowed(level)` when the file is within scope,
449/// or `ScopeCheck::Denied` when the file is outside scope.
450pub 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	// If a scope string is present but can't be parsed, deny access (least privilege)
457	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			// Direct match: scope matches this file_id
461			if scope_file_id == file_id {
462				return ScopeCheck::Allowed(*access);
463			}
464			// Document tree check: scope is for a root, this file is a child
465			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
476/// Check if a scoped token allows file creation, honoring folder subtrees.
477///
478/// Like the simple document-tree scope check (Write scope where
479/// `root_id == scope_file_id`), but also permits creation when the new file's
480/// parent is the scoped folder itself or a descendant of it. This is the path
481/// used by folder share links with editor (Write) access, letting guests upload
482/// directly into the shared folder (or any subfolder).
483///
484/// Allowed (with Write scope) when ANY of:
485/// - `root_id == scope_file_id` (document-tree rule, same as the sync variant)
486/// - `parent_id == scope_file_id` (direct child of the shared folder)
487/// - `parent_id` is a descendant of `scope_file_id` (nested subfolder)
488///
489/// Returns `Ok(())` if allowed, `Err(Error::PermissionDenied)` if denied.
490pub 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	// If a scope string is present but can't be parsed, deny access (least privilege)
500	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			// Document-tree rule: new file is a child in the scoped document tree.
509			if root_id == Some(scope_file_id.as_str()) {
510				return Ok(());
511			}
512			// Folder-subtree rule: new file's parent is the scoped folder or nested
513			// under it. Only applies when the scoped target is actually a folder, so
514			// a document/file share link can't authorize creation across its
515			// parent_id siblings.
516			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(()), // Middleware already restricts to /api/files/apkg/
526	}
527}
528
529/// Returns true when a scoped token is itself sufficient authorization for a
530/// collection-level operation, letting the middleware skip the role/quota path.
531///
532/// A file share link with Write access authorizes file *creation* only; the
533/// file handlers (`check_scope_allows_create_in`) then enforce the scope's
534/// subtree boundary. It must NOT authorize action/app creation, trash emptying,
535/// or any other collection operation.
536pub 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// vim: ts=4