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 crate::prelude::*;
12use cloudillo_types::meta_adapter::FileView;
13use cloudillo_types::types::{AccessLevel, TokenScope};
14
15/// Result of checking file access
16pub struct FileAccessResult {
17	pub file_view: FileView,
18	pub access_level: AccessLevel,
19	pub read_only: bool,
20}
21
22/// Error type for file access checks
23pub enum FileAccessError {
24	NotFound,
25	AccessDenied,
26	InternalError(String),
27}
28
29/// Context describing the subject requesting file access
30pub struct FileAccessCtx<'a> {
31	pub user_id_tag: &'a str,
32	pub tenant_id_tag: &'a str,
33	pub user_roles: &'a [Box<str>],
34}
35
36/// Walk the parent chain of a file to find an inherited share entry.
37///
38/// Checks each ancestor's share_access for the given user. Returns the first
39/// (closest ancestor) match's access level, or None if no ancestor is shared.
40/// Bounded to 20 levels to prevent runaway traversal.
41pub async fn walk_parent_chain_for_share(
42	app: &App,
43	tn_id: TnId,
44	file_id: &str,
45	user_id_tag: &str,
46) -> Option<AccessLevel> {
47	let mut current_id = file_id.to_string();
48	let mut depth = 0;
49	while depth < 20 {
50		match app.meta_adapter.read_file(tn_id, &current_id).await {
51			Ok(Some(file)) => {
52				if let Some(ref parent_id) = file.parent_id {
53					if let Ok(Some(perm)) = app
54						.meta_adapter
55						.check_share_access(tn_id, 'F', parent_id, 'U', user_id_tag)
56						.await
57					{
58						return Some(AccessLevel::from_perm_char(perm));
59					}
60					current_id = parent_id.to_string();
61					depth += 1;
62				} else {
63					break;
64				}
65			}
66			_ => break,
67		}
68	}
69	None
70}
71
72/// Check if a user has share access to a file — either a direct share entry
73/// on the file itself or an inherited share from an ancestor folder.
74pub async fn check_share_for_file(
75	app: &App,
76	tn_id: TnId,
77	file_id: &str,
78	user_id_tag: &str,
79) -> Option<AccessLevel> {
80	if let Ok(Some(perm)) =
81		app.meta_adapter.check_share_access(tn_id, 'F', file_id, 'U', user_id_tag).await
82	{
83		return Some(AccessLevel::from_perm_char(perm));
84	}
85	walk_parent_chain_for_share(app, tn_id, file_id, user_id_tag).await
86}
87
88/// Get access level for a user on a file
89///
90/// Determines access level based on:
91/// 1. Ownership - owner has Write access
92/// 2. Role-based access - for tenant-owned files (no explicit owner), community
93///    roles determine access: leader/moderator/contributor → Write, any role → Read
94/// 3. FSHR action - WRITE subtype grants Write, other subtypes grant Read
95/// 4. No access - returns None
96pub async fn get_access_level(
97	app: &App,
98	tn_id: TnId,
99	file_id: &str,
100	owner_id_tag: &str,
101	ctx: &FileAccessCtx<'_>,
102	inherited_share: Option<AccessLevel>,
103) -> AccessLevel {
104	// Owner always has write access
105	if ctx.user_id_tag == owner_id_tag {
106		return AccessLevel::Write;
107	}
108
109	// Direct share on this specific file
110	if let Ok(Some(perm)) = app
111		.meta_adapter
112		.check_share_access(tn_id, 'F', file_id, 'U', ctx.user_id_tag)
113		.await
114	{
115		return AccessLevel::from_perm_char(perm);
116	}
117	// Inherited share from parent folder (already resolved by caller)
118	if let Some(level) = inherited_share {
119		return level;
120	}
121	// No known inheritance — walk the parent chain
122	if let Some(level) = walk_parent_chain_for_share(app, tn_id, file_id, ctx.user_id_tag).await {
123		return level;
124	}
125
126	// Role-based access for tenant-owned files only (owner_id_tag == tenant_id_tag)
127	// When a file has no explicit owner, it belongs to the tenant.
128	// Community members with roles get access based on their role level.
129	// Files owned by other users are NOT accessible via role-based access.
130	if owner_id_tag == ctx.tenant_id_tag && !ctx.user_roles.is_empty() {
131		if ctx
132			.user_roles
133			.iter()
134			.any(|r| matches!(r.as_ref(), "leader" | "moderator" | "contributor"))
135		{
136			return AccessLevel::Write;
137		}
138		// Any authenticated role on this tenant → at least Read
139		return AccessLevel::Read;
140	}
141
142	// Look up FSHR action: key pattern is "FSHR:{file_id}:{audience}"
143	let action_key = format!("FSHR:{}:{}", file_id, ctx.user_id_tag);
144
145	match app.meta_adapter.get_action_by_key(tn_id, &action_key).await {
146		Ok(Some(action)) => {
147			// Check if action is FSHR type and active
148			if action.typ.as_ref() == "FSHR" {
149				// WRITE subtype grants write, COMMENT grants comment, others grant read
150				match action.sub_typ.as_ref().map(AsRef::as_ref) {
151					Some("WRITE") => AccessLevel::Write,
152					Some("COMMENT") => AccessLevel::Comment,
153					_ => AccessLevel::Read,
154				}
155			} else {
156				AccessLevel::None
157			}
158		}
159		Ok(None) | Err(_) => AccessLevel::None,
160	}
161}
162
163/// Get access level for a user on a file, considering scoped tokens
164///
165/// Determines access level based on:
166/// 1. Scoped token - file:{file_id}:{R|W} grants Read/Write access
167///    (also checks document tree: a token for a root grants access to children)
168/// 2. Ownership - owner has Write access
169/// 3. FSHR action - WRITE subtype grants Write, other subtypes grant Read
170/// 4. No access - returns None
171pub async fn get_access_level_with_scope(
172	app: &App,
173	tn_id: TnId,
174	file_id: &str,
175	owner_id_tag: &str,
176	ctx: &FileAccessCtx<'_>,
177	scope: Option<&str>,
178	root_id: Option<&str>,
179) -> AccessLevel {
180	// Check scope-based access first (for share links)
181	if let Some(scope_str) = scope {
182		// Use typed TokenScope for safe parsing
183		if let Some(token_scope) = TokenScope::parse(scope_str) {
184			match &token_scope {
185				TokenScope::File { file_id: scope_file_id, access } => {
186					// Direct match: scope matches this file_id
187					if scope_file_id == file_id {
188						return *access;
189					}
190
191					// Document tree check: scope is for a root, this file is a child
192					// Depth-1 invariant: root_id always points directly to a top-level file
193					if let Some(root) = root_id
194						&& scope_file_id.as_str() == root
195					{
196						return *access;
197					}
198
199					// Cross-document link: file-type share entry ('F')
200					// If scope grants access to file A, check if there's a share entry
201					// linking file A → target file
202					// resource=container (scope_file_id), subject=target (file_id)
203					if let Ok(Some(perm)) = app
204						.meta_adapter
205						.check_share_access(tn_id, 'F', scope_file_id, 'F', file_id)
206						.await
207					{
208						// Cap at min(scope_access, share_permission)
209						return (*access).min(AccessLevel::from_perm_char(perm));
210					}
211
212					// Scope exists for a different file - deny access
213					return AccessLevel::None;
214				}
215				TokenScope::ApkgPublish => {
216					// APKG publish scope has no file access
217					return AccessLevel::None;
218				}
219			}
220		}
221		// Scope string present but unparseable — deny access (least privilege)
222		return AccessLevel::None;
223	}
224
225	// Fall back to existing logic (ownership, roles, FSHR actions)
226	get_access_level(app, tn_id, file_id, owner_id_tag, ctx, None).await
227}
228
229/// Check file access and return file view with access level
230///
231/// This is the main helper for WebSocket handlers. It:
232/// 1. Loads file metadata
233/// 2. Determines access level (considering scoped tokens for share links)
234/// 3. Returns combined result or error
235///
236/// The scope parameter should be auth_ctx.scope.as_deref().
237pub async fn check_file_access_with_scope(
238	app: &App,
239	tn_id: TnId,
240	file_id: &str,
241	ctx: &FileAccessCtx<'_>,
242	scope: Option<&str>,
243	via: Option<&str>,
244) -> Result<FileAccessResult, FileAccessError> {
245	use tracing::debug;
246
247	// Load file metadata
248	let file_view = match app.meta_adapter.read_file(tn_id, file_id).await {
249		Ok(Some(f)) => f,
250		Ok(None) => return Err(FileAccessError::NotFound),
251		Err(e) => return Err(FileAccessError::InternalError(e.to_string())),
252	};
253
254	// Get owner id_tag from file metadata
255	// If no owner, default to tenant (tenant owns all files without explicit owner)
256	let owner_id_tag = file_view
257		.owner
258		.as_ref()
259		.and_then(|p| if p.id_tag.is_empty() { None } else { Some(p.id_tag.as_ref()) })
260		.unwrap_or(ctx.tenant_id_tag);
261
262	debug!(file_id = file_id, user = ctx.user_id_tag, owner = owner_id_tag, scope = ?scope, "Checking file access");
263
264	// Get access level (considering scope for share links and document trees)
265	let mut access_level = get_access_level_with_scope(
266		app,
267		tn_id,
268		file_id,
269		owner_id_tag,
270		ctx,
271		scope,
272		file_view.root_id.as_deref(),
273	)
274	.await;
275
276	// Public files are readable by anyone (including unauthenticated guests)
277	if access_level == AccessLevel::None && file_view.visibility == Some('P') {
278		access_level = AccessLevel::Read;
279	}
280
281	// Cap access by file-to-file share entry when opened via embedding
282	if let Some(via_file_id) = via
283		&& scope.is_none()
284		&& access_level != AccessLevel::None
285	{
286		match app.meta_adapter.check_share_access(tn_id, 'F', via_file_id, 'F', file_id).await {
287			Ok(Some(perm)) => {
288				access_level = access_level.min(AccessLevel::from_perm_char(perm));
289			}
290			Ok(None) | Err(_) => {
291				// No file-to-file share entry — embedding doesn't exist, deny
292				access_level = AccessLevel::None;
293			}
294		}
295	}
296
297	if access_level == AccessLevel::None {
298		return Err(FileAccessError::AccessDenied);
299	}
300
301	let read_only = access_level != AccessLevel::Write && access_level != AccessLevel::Admin;
302
303	Ok(FileAccessResult { file_view, access_level, read_only })
304}
305
306/// Result of checking whether a file is allowed by scope
307pub enum ScopeCheck {
308	/// No scope restriction — fall through to normal access checks
309	NoScope,
310	/// File is within scope with this access level
311	Allowed(AccessLevel),
312	/// File is outside scope — deny access
313	Denied,
314}
315
316/// Check if a file operation is allowed by scope.
317///
318/// Returns `ScopeCheck::NoScope` when there is no scope restriction,
319/// `ScopeCheck::Allowed(level)` when the file is within scope,
320/// or `ScopeCheck::Denied` when the file is outside scope.
321pub fn check_scope_allows_file(
322	scope: Option<&str>,
323	file_id: &str,
324	root_id: Option<&str>,
325) -> ScopeCheck {
326	let Some(scope_str) = scope else { return ScopeCheck::NoScope };
327	// If a scope string is present but can't be parsed, deny access (least privilege)
328	let Some(token_scope) = TokenScope::parse(scope_str) else { return ScopeCheck::Denied };
329	match &token_scope {
330		TokenScope::File { file_id: scope_file_id, access } => {
331			// Direct match: scope matches this file_id
332			if scope_file_id == file_id {
333				return ScopeCheck::Allowed(*access);
334			}
335			// Document tree check: scope is for a root, this file is a child
336			if let Some(root) = root_id
337				&& scope_file_id.as_str() == root
338			{
339				return ScopeCheck::Allowed(*access);
340			}
341			ScopeCheck::Denied
342		}
343		TokenScope::ApkgPublish => ScopeCheck::Denied,
344	}
345}
346
347/// Check if a scoped token allows write access for file creation.
348///
349/// For scoped tokens, file creation is only allowed when:
350/// - The scope grants Write access
351/// - The file has a root_id that matches the scope's file_id
352///   (i.e., the new file is a child in the scoped document tree)
353///
354/// Returns Ok(()) if allowed, Err if denied.
355pub fn check_scope_allows_create(scope: Option<&str>, root_id: Option<&str>) -> Result<(), Error> {
356	let Some(scope_str) = scope else { return Ok(()) };
357	// If a scope string is present but can't be parsed, deny access (least privilege)
358	let Some(token_scope) = TokenScope::parse(scope_str) else {
359		return Err(Error::PermissionDenied);
360	};
361	match &token_scope {
362		TokenScope::File { file_id: scope_file_id, access } => {
363			if *access != AccessLevel::Write {
364				return Err(Error::PermissionDenied);
365			}
366			match root_id {
367				Some(root) if root == scope_file_id => Ok(()),
368				_ => Err(Error::PermissionDenied),
369			}
370		}
371		TokenScope::ApkgPublish => Ok(()), // Middleware already restricts to /api/files/apkg/
372	}
373}
374
375// vim: ts=4