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	// Role-based access for tenant-owned files only (owner_id_tag == tenant_id_tag)
54	// When a file has no explicit owner, it belongs to the tenant.
55	// Community members with roles get access based on their role level.
56	// Files owned by other users are NOT accessible via role-based access.
57	if owner_id_tag == ctx.tenant_id_tag && !ctx.user_roles.is_empty() {
58		if ctx
59			.user_roles
60			.iter()
61			.any(|r| matches!(r.as_ref(), "leader" | "moderator" | "contributor"))
62		{
63			return AccessLevel::Write;
64		}
65		// Any authenticated role on this tenant → at least Read
66		return AccessLevel::Read;
67	}
68
69	// Look up FSHR action: key pattern is "FSHR:{file_id}:{audience}"
70	let action_key = format!("FSHR:{}:{}", file_id, ctx.user_id_tag);
71
72	match app.meta_adapter.get_action_by_key(tn_id, &action_key).await {
73		Ok(Some(action)) => {
74			// Check if action is FSHR type and active
75			if action.typ.as_ref() == "FSHR" {
76				// WRITE subtype grants write access, others grant read
77				if action.sub_typ.as_ref().map(|s| s.as_ref()) == Some("WRITE") {
78					AccessLevel::Write
79				} else {
80					AccessLevel::Read
81				}
82			} else {
83				AccessLevel::None
84			}
85		}
86		Ok(None) => AccessLevel::None,
87		Err(_) => AccessLevel::None,
88	}
89}
90
91/// Get access level for a user on a file, considering scoped tokens
92///
93/// Determines access level based on:
94/// 1. Scoped token - file:{file_id}:{R|W} grants Read/Write access
95/// 2. Ownership - owner has Write access
96/// 3. FSHR action - WRITE subtype grants Write, other subtypes grant Read
97/// 4. No access - returns None
98pub async fn get_access_level_with_scope(
99	app: &App,
100	tn_id: TnId,
101	file_id: &str,
102	owner_id_tag: &str,
103	ctx: &FileAccessCtx<'_>,
104	scope: Option<&str>,
105) -> AccessLevel {
106	// Check scope-based access first (for share links)
107	if let Some(scope_str) = scope {
108		// Use typed TokenScope for safe parsing
109		if let Some(token_scope) = TokenScope::parse(scope_str) {
110			match &token_scope {
111				TokenScope::File { file_id: scope_file_id, access } => {
112					// Check if scope matches this file_id
113					if scope_file_id == file_id {
114						return *access;
115					}
116					// Scope exists for a different file - deny access to this file
117					// This prevents using a token scoped to file A to access file B
118					return AccessLevel::None;
119				}
120			}
121		}
122		// Non-file scope or parse failure, fall through to normal access check
123	}
124
125	// Fall back to existing logic (ownership, roles, FSHR actions)
126	get_access_level(app, tn_id, file_id, owner_id_tag, ctx).await
127}
128
129/// Check file access and return file view with access level
130///
131/// This is the main helper for WebSocket handlers. It:
132/// 1. Loads file metadata
133/// 2. Determines access level (considering scoped tokens for share links)
134/// 3. Returns combined result or error
135///
136/// The scope parameter should be auth_ctx.scope.as_deref().
137pub async fn check_file_access_with_scope(
138	app: &App,
139	tn_id: TnId,
140	file_id: &str,
141	ctx: &FileAccessCtx<'_>,
142	scope: Option<&str>,
143) -> Result<FileAccessResult, FileAccessError> {
144	use tracing::debug;
145
146	// Load file metadata
147	let file_view = match app.meta_adapter.read_file(tn_id, file_id).await {
148		Ok(Some(f)) => f,
149		Ok(None) => return Err(FileAccessError::NotFound),
150		Err(e) => return Err(FileAccessError::InternalError(e.to_string())),
151	};
152
153	// Get owner id_tag from file metadata
154	// If no owner, default to tenant (tenant owns all files without explicit owner)
155	let owner_id_tag = file_view
156		.owner
157		.as_ref()
158		.and_then(|p| if p.id_tag.is_empty() { None } else { Some(p.id_tag.as_ref()) })
159		.unwrap_or(ctx.tenant_id_tag);
160
161	debug!(file_id = file_id, user = ctx.user_id_tag, owner = owner_id_tag, scope = ?scope, "Checking file access");
162
163	// Get access level (considering scope for share links)
164	let access_level =
165		get_access_level_with_scope(app, tn_id, file_id, owner_id_tag, ctx, scope).await;
166
167	if access_level == AccessLevel::None {
168		return Err(FileAccessError::AccessDenied);
169	}
170
171	let read_only = access_level == AccessLevel::Read;
172
173	Ok(FileAccessResult { file_view, access_level, read_only })
174}
175
176// vim: ts=4