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