Skip to main content

cloudillo_core/
file_access.rs

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