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|C|W} grants Read/Comment/Write access)
8//! - Ownership (owner has Admin access — write, plus share management)
9//! - FSHR action grants, but only from the file's owner (ADMIN subtype = Admin, WRITE = Write,
10//!   COMMENT = Comment, else Read)
11
12use std::sync::Arc;
13
14use crate::dir_cache::{DirCache, DirEntry};
15use crate::prelude::*;
16use cloudillo_types::meta_adapter;
17use cloudillo_types::meta_adapter::FileView;
18use cloudillo_types::types::{AccessLevel, TokenScope};
19
20/// Maximum parent-chain depth for bounded folder-tree traversals.
21pub const MAX_PARENT_DEPTH: usize = 64;
22
23/// Result of checking file access
24pub struct FileAccessResult {
25	pub file_view: FileView,
26	pub access_level: AccessLevel,
27	pub read_only: bool,
28}
29
30/// Error type for file access checks
31pub enum FileAccessError {
32	NotFound,
33	AccessDenied,
34	InternalError(String),
35}
36
37/// Context describing the subject requesting file access
38pub struct FileAccessCtx<'a> {
39	pub user_id_tag: &'a str,
40	pub tenant_id_tag: &'a str,
41	pub user_roles: &'a [Box<str>],
42}
43
44/// Resolve one `(tn, file_id)` → `DirEntry` through the folder cache, falling back
45/// to a single `read_file` on a miss. The row is cached **only when it is a folder**
46/// (`is_folder`), keeping the cache small and folder-only; non-folder rows (e.g. the
47/// leaf that starts a descendant walk) are returned but never inserted.
48///
49/// Propagates read errors as `Err` so request-path callers can surface a genuine
50/// fault as 5xx instead of mistaking it for "missing / not a descendant".
51pub async fn resolve_dir_entry(
52	meta: &Arc<dyn meta_adapter::MetaAdapter>,
53	cache: &DirCache,
54	tn_id: TnId,
55	file_id: &str,
56) -> ClResult<Option<DirEntry>> {
57	if let Some(entry) = cache.get(tn_id, file_id) {
58		return Ok(Some(entry)); // cached ⇒ folder
59	}
60	match meta.read_file(tn_id, file_id).await? {
61		Some(view) => {
62			let is_folder = view.file_tp.as_deref() == Some("FLDR");
63			let entry = DirEntry {
64				parent_id: view.parent_id.clone(),
65				name: view.file_name.clone(),
66				is_folder,
67			};
68			if is_folder {
69				cache.put(tn_id, file_id, entry.clone());
70			}
71			Ok(Some(entry))
72		}
73		None => Ok(None),
74	}
75}
76
77/// Walk the parent chain of a file to find an inherited share entry.
78///
79/// Checks each ancestor's share_access for the given user. Returns the first
80/// (closest ancestor) match's access level, or None if no ancestor is shared.
81/// Bounded to `MAX_PARENT_DEPTH` levels to prevent runaway traversal.
82///
83/// This is one of the single, cache-backed parent-chain walkers: every hop goes
84/// through `resolve_dir_entry`, memoizing folder rows in the shared `DirCache`.
85pub async fn walk_parent_chain_for_share(
86	app: &App,
87	tn_id: TnId,
88	file_id: &str,
89	user_id_tag: &str,
90) -> Option<AccessLevel> {
91	// DirCache is a required process-wide extension registered at app build (see
92	// crates/cloudillo/src/app.rs); a missing cache means misconfiguration, so log
93	// rather than silently dropping inherited-share access.
94	let Ok(cache) = app.ext::<DirCache>() else {
95		warn!("DirCache extension missing; skipping inherited-share parent walk");
96		return None;
97	};
98	let mut current_id = file_id.to_string();
99	for _ in 0..MAX_PARENT_DEPTH {
100		// Best-effort: a read error ends the walk (treated as no inherited share).
101		let Ok(Some(entry)) = resolve_dir_entry(&app.meta_adapter, cache, tn_id, &current_id).await
102		else {
103			break;
104		};
105		let Some(parent_id) = entry.parent_id else { break };
106		if let Ok(Some(perm)) = app
107			.meta_adapter
108			.check_share_access(tn_id, 'F', &parent_id, 'U', user_id_tag)
109			.await
110		{
111			return Some(AccessLevel::from_perm_char(perm));
112		}
113		current_id = parent_id.to_string();
114	}
115	None
116}
117
118/// Return true if `ancestor_id` is an ancestor folder of `file_id`.
119///
120/// Walks the `parent_id` chain upward from `file_id`, bounded to
121/// `MAX_PARENT_DEPTH` levels to prevent runaway traversal. Used to extend
122/// file-scope tokens (folder share links) to every descendant of a shared
123/// folder. The file itself is not considered its own descendant — callers
124/// handle the direct match separately.
125///
126/// Propagates read errors as `Err` so callers on request paths can surface a
127/// genuine fault as 5xx instead of silently treating it as "not a descendant".
128///
129/// This is one of the single, cache-backed parent-chain walkers: every hop goes
130/// through `resolve_dir_entry`, memoizing folder rows in the shared `DirCache`.
131pub async fn is_descendant_of(
132	meta: &Arc<dyn meta_adapter::MetaAdapter>,
133	cache: &DirCache,
134	tn_id: TnId,
135	file_id: &str,
136	ancestor_id: &str,
137) -> ClResult<bool> {
138	let mut current_id = file_id.to_string();
139	for _ in 0..MAX_PARENT_DEPTH {
140		let Some(entry) = resolve_dir_entry(meta, cache, tn_id, &current_id).await? else {
141			break;
142		};
143		let Some(parent_id) = entry.parent_id else { break };
144		if parent_id.as_ref() == ancestor_id {
145			return Ok(true);
146		}
147		current_id = parent_id.to_string();
148	}
149	Ok(false)
150}
151
152/// Return true if the scoped target file is a folder (`file_tp == "FLDR"`).
153///
154/// Used to gate the folder-subtree extension of a file-scope token: only a
155/// scope whose target is an actual folder grants access across its `parent_id`
156/// descendants. Answered straight from the folder cache via `resolve_dir_entry`:
157/// returns `Ok(true)` only for an existing `FLDR` row, `Ok(false)` for a missing
158/// or non-folder row, and `Err` for a genuine read fault so request-path callers
159/// that can return 5xx surface the fault instead of masking it as "not a folder".
160pub async fn scope_target_is_folder(
161	meta: &Arc<dyn meta_adapter::MetaAdapter>,
162	cache: &DirCache,
163	tn_id: TnId,
164	scope_file_id: &str,
165) -> ClResult<bool> {
166	Ok(resolve_dir_entry(meta, cache, tn_id, scope_file_id)
167		.await?
168		.is_some_and(|e| e.is_folder))
169}
170
171/// Check if a user has share access to a file — either a direct share entry
172/// on the file itself or an inherited share from an ancestor folder.
173pub async fn check_share_for_file(
174	app: &App,
175	tn_id: TnId,
176	file_id: &str,
177	user_id_tag: &str,
178) -> Option<AccessLevel> {
179	if let Ok(Some(perm)) =
180		app.meta_adapter.check_share_access(tn_id, 'F', file_id, 'U', user_id_tag).await
181	{
182		return Some(AccessLevel::from_perm_char(perm));
183	}
184	walk_parent_chain_for_share(app, tn_id, file_id, user_id_tag).await
185}
186
187/// Access level granted purely by community-role membership on a *tenant-owned*
188/// file. Only roles from `crate::roles::ROLE_HIERARCHY` count.
189///
190/// Membership is matched explicitly rather than by testing "the role slice is
191/// non-empty": a `[""]` slice reads as "has a role" and would hand every
192/// federated stranger Read access. Second defence behind `roles::parse_roles`,
193/// which drops empty segments.
194pub fn role_access_level(user_roles: &[Box<str>]) -> AccessLevel {
195	// A leader resolves to `Admin`, not `Write`: leadership over a tenant-owned file *is* the right
196	// to manage its share set, so `access_level` alone answers "may manage shares".
197	if user_roles.iter().any(|r| r.as_ref() == "leader") {
198		return AccessLevel::Admin;
199	}
200	if user_roles.iter().any(|r| matches!(r.as_ref(), "moderator" | "contributor")) {
201		return AccessLevel::Write;
202	}
203	if user_roles
204		.iter()
205		.any(|r| matches!(r.as_ref(), "public" | "follower" | "supporter"))
206	{
207		return AccessLevel::Read;
208	}
209	AccessLevel::None
210}
211
212/// Resolve the grant an `FSHR:{file_id}:{audience}` action row carries: `ADMIN` → Admin, `WRITE` →
213/// Write, `COMMENT` → Comment, `DEL` → None (a revocation is not a grant), anything else → Read.
214///
215/// An FSHR is a *claim by its issuer* that they granted access, so only the file's owner can make
216/// it credibly. Without the issuer test the row is a self-service grant: `POST /api/actions` is
217/// gated only by the `contributor` role, the action DSL's `key_pattern` builds the key straight
218/// from the client's `subject` and `aud`, and hooks run *after* the row is stored with no rollback,
219/// so `fshr::on_create` rejecting the write leaves the row behind. Federated peers can post such a
220/// token to the inbox just as easily.
221///
222/// Both live paths survive the test: on the recipient's node `fshr::on_accept` creates the file row
223/// with `owner_tag = issuer`, and on the owner's node the grantee's access resolves earlier, from
224/// the `share_entries` row.
225fn fshr_grant_level(
226	typ: &str,
227	sub_typ: Option<&str>,
228	issuer_tag: &str,
229	owner_id_tag: &str,
230	file_id: &str,
231) -> AccessLevel {
232	if typ != "FSHR" {
233		return AccessLevel::None;
234	}
235	if issuer_tag != owner_id_tag {
236		warn!(
237			file_id = %file_id,
238			issuer = %issuer_tag,
239			owner = %owner_id_tag,
240			sub_typ = ?sub_typ,
241			"Ignoring FSHR grant: issuer does not own the file"
242		);
243		return AccessLevel::None;
244	}
245	match sub_typ {
246		Some("ADMIN") => AccessLevel::Admin,
247		Some("WRITE") => AccessLevel::Write,
248		Some("COMMENT") => AccessLevel::Comment,
249		// A `DEL` shares the key `FSHR:{subject}:{audience}`, so it replaces the row it revokes —
250		// without this arm the catch-all reads it back as Read and revocation leaves read access.
251		Some("DEL") => AccessLevel::None,
252		_ => AccessLevel::Read,
253	}
254}
255
256/// Get access level for a user on a file
257///
258/// Determines access level based on:
259/// 1. Ownership — owner has Admin access
260/// 2. Direct `share_entries` grant on this file, then the caller-supplied `inherited_share`, then a
261///    parent-chain walk for a folder-inherited grant
262/// 3. Role-based access — tenant-owned files only: leader → Admin, moderator/contributor → Write,
263///    any role → Read
264/// 4. FSHR action issued by the file's owner — ADMIN → Admin, WRITE → Write, COMMENT → Comment,
265///    DEL → None (a revocation is not a grant), other sub-types → Read (see [`fshr_grant_level`])
266/// 5. No access — returns None
267pub async fn get_access_level(
268	app: &App,
269	tn_id: TnId,
270	file_id: &str,
271	owner_id_tag: &str,
272	ctx: &FileAccessCtx<'_>,
273	inherited_share: Option<AccessLevel>,
274) -> AccessLevel {
275	// The owner is the file's admin: write plus share management. Callers must test
276	// `can_write()`/`can_manage_shares()` rather than `== AccessLevel::Write`.
277	if ctx.user_id_tag == owner_id_tag {
278		return AccessLevel::Admin;
279	}
280
281	// Direct share on this specific file
282	if let Ok(Some(perm)) = app
283		.meta_adapter
284		.check_share_access(tn_id, 'F', file_id, 'U', ctx.user_id_tag)
285		.await
286	{
287		return AccessLevel::from_perm_char(perm);
288	}
289	// Inherited share from parent folder (already resolved by caller)
290	if let Some(level) = inherited_share {
291		return level;
292	}
293	// No known inheritance — walk the parent chain
294	if let Some(level) = walk_parent_chain_for_share(app, tn_id, file_id, ctx.user_id_tag).await {
295		return level;
296	}
297
298	// Role-based access for tenant-owned files only (owner_id_tag == tenant_id_tag)
299	// When a file has no explicit owner, it belongs to the tenant.
300	// Community members with roles get access based on their role level.
301	// Files owned by other users are NOT accessible via role-based access.
302	if owner_id_tag == ctx.tenant_id_tag {
303		let level = role_access_level(ctx.user_roles);
304		if level != AccessLevel::None {
305			return level;
306		}
307	}
308
309	// Look up FSHR action: key pattern is "FSHR:{file_id}:{audience}"
310	let action_key = format!("FSHR:{}:{}", file_id, ctx.user_id_tag);
311
312	// `get_action_by_key` does not filter on action status, so a pending ('C') or rejected FSHR
313	// resolves here too. Moot in practice: the local file row only exists once `on_accept` ran.
314	match app.meta_adapter.get_action_by_key(tn_id, &action_key).await {
315		Ok(Some(action)) => fshr_grant_level(
316			&action.typ,
317			action.sub_typ.as_ref().map(AsRef::as_ref),
318			&action.issuer_tag,
319			owner_id_tag,
320			file_id,
321		),
322		Ok(None) | Err(_) => AccessLevel::None,
323	}
324}
325
326/// Get access level for a user on a file, considering scoped tokens
327///
328/// Determines access level based on:
329/// 1. Scoped token — file:{file_id}:{R|C|W} grants Read/Comment/Write access
330///    (also checks document tree: a token for a root grants access to children)
331/// 2. Everything [`get_access_level`] resolves, in its order
332/// 3. No access — returns None
333pub async fn get_access_level_with_scope(
334	app: &App,
335	tn_id: TnId,
336	file_id: &str,
337	owner_id_tag: &str,
338	ctx: &FileAccessCtx<'_>,
339	scope: Option<&str>,
340	root_id: Option<&str>,
341) -> AccessLevel {
342	// Check scope-based access first (for share links)
343	if let Some(scope_str) = scope {
344		// Use typed TokenScope for safe parsing
345		if let Some(token_scope) = TokenScope::parse(scope_str) {
346			match &token_scope {
347				TokenScope::File { file_id: scope_file_id, access } => {
348					// Direct match: scope matches this file_id
349					if scope_file_id == file_id {
350						return *access;
351					}
352
353					// Document tree check: scope is for a root, this file is a child
354					// Depth-1 invariant: root_id always points directly to a top-level file
355					if let Some(root) = root_id
356						&& scope_file_id.as_str() == root
357					{
358						return *access;
359					}
360
361					// Cross-document link: file-type share entry ('F')
362					// If scope grants access to file A, check if there's a share entry
363					// linking file A → target file
364					// resource=container (scope_file_id), subject=target (file_id)
365					if let Ok(Some(perm)) = app
366						.meta_adapter
367						.check_share_access(tn_id, 'F', scope_file_id, 'F', file_id)
368						.await
369					{
370						// Cap at min(scope_access, share_permission)
371						return (*access).min(AccessLevel::from_perm_char(perm));
372					}
373
374					// Folder share: scope targets a folder; grant the scope's level
375					// to any file nested under it (linked via parent_id). Gated on
376					// the scoped target actually being a folder, so a document/file
377					// share link does not leak access across its parent_id siblings.
378					// Fails closed — a missing cache or read error yields no grant,
379					// since returning a bare AccessLevel here cannot signal a 5xx.
380					// DirCache is a required process-wide extension registered at app
381					// build (see crates/cloudillo/src/app.rs), so the else arm only
382					// fires on misconfiguration — log rather than fail silently.
383					if let Ok(cache) = app.ext::<DirCache>() {
384						let target_is_folder =
385							scope_target_is_folder(&app.meta_adapter, cache, tn_id, scope_file_id)
386								.await
387								.unwrap_or(false);
388						let nested_under_scope = target_is_folder
389							&& is_descendant_of(
390								&app.meta_adapter,
391								cache,
392								tn_id,
393								file_id,
394								scope_file_id,
395							)
396							.await
397							.unwrap_or(false);
398						if nested_under_scope {
399							return *access;
400						}
401					} else {
402						warn!("DirCache extension missing; folder-share scope grant skipped");
403					}
404
405					// Scope exists for a different file - deny access
406					return AccessLevel::None;
407				}
408				TokenScope::ApkgPublish => {
409					// APKG publish scope has no file access
410					return AccessLevel::None;
411				}
412			}
413		}
414		// Scope string present but unparseable — deny access (least privilege)
415		return AccessLevel::None;
416	}
417
418	// Fall back to existing logic (ownership, roles, FSHR actions)
419	get_access_level(app, tn_id, file_id, owner_id_tag, ctx, None).await
420}
421
422/// Check file access and return file view with access level
423///
424/// This is the main helper for WebSocket handlers. It:
425/// 1. Loads file metadata
426/// 2. Determines access level (considering scoped tokens for share links)
427/// 3. Returns combined result or error
428///
429/// The scope parameter should be auth_ctx.scope.as_deref().
430pub async fn check_file_access_with_scope(
431	app: &App,
432	tn_id: TnId,
433	file_id: &str,
434	ctx: &FileAccessCtx<'_>,
435	scope: Option<&str>,
436	via: Option<&str>,
437) -> Result<FileAccessResult, FileAccessError> {
438	use tracing::debug;
439
440	// Load file metadata
441	let file_view = match app.meta_adapter.read_file(tn_id, file_id).await {
442		Ok(Some(f)) => f,
443		Ok(None) => return Err(FileAccessError::NotFound),
444		Err(e) => return Err(FileAccessError::InternalError(e.to_string())),
445	};
446
447	// Get owner id_tag from file metadata
448	// If no owner, default to tenant (tenant owns all files without explicit owner)
449	let owner_id_tag = file_view
450		.owner
451		.as_ref()
452		.and_then(|p| if p.id_tag.is_empty() { None } else { Some(p.id_tag.as_ref()) })
453		.unwrap_or(ctx.tenant_id_tag);
454
455	debug!(file_id = file_id, user = ctx.user_id_tag, owner = owner_id_tag, scope = ?scope, "Checking file access");
456
457	// Get access level (considering scope for share links and document trees)
458	let mut access_level = get_access_level_with_scope(
459		app,
460		tn_id,
461		file_id,
462		owner_id_tag,
463		ctx,
464		scope,
465		file_view.root_id.as_deref(),
466	)
467	.await;
468
469	// Public files are readable by anyone (including unauthenticated guests)
470	if access_level == AccessLevel::None && file_view.visibility == Some('P') {
471		access_level = AccessLevel::Read;
472	}
473
474	// Cap access by file-to-file share entry when opened via embedding
475	if let Some(via_file_id) = via
476		&& scope.is_none()
477		&& access_level != AccessLevel::None
478	{
479		match app.meta_adapter.check_share_access(tn_id, 'F', via_file_id, 'F', file_id).await {
480			Ok(Some(perm)) => {
481				access_level = access_level.min(AccessLevel::from_perm_char(perm));
482			}
483			Ok(None) | Err(_) => {
484				// No file-to-file share entry — embedding doesn't exist, deny
485				access_level = AccessLevel::None;
486			}
487		}
488	}
489
490	if access_level == AccessLevel::None {
491		return Err(FileAccessError::AccessDenied);
492	}
493
494	let read_only = !access_level.can_write();
495
496	Ok(FileAccessResult { file_view, access_level, read_only })
497}
498
499/// Result of checking whether a file is allowed by scope
500pub enum ScopeCheck {
501	/// No scope restriction — fall through to normal access checks
502	NoScope,
503	/// File is within scope with this access level
504	Allowed(AccessLevel),
505	/// File is outside scope — deny access
506	Denied,
507}
508
509/// Check if a file operation is allowed by scope.
510///
511/// Returns `ScopeCheck::NoScope` when there is no scope restriction,
512/// `ScopeCheck::Allowed(level)` when the file is within scope,
513/// or `ScopeCheck::Denied` when the file is outside scope.
514pub fn check_scope_allows_file(
515	scope: Option<&str>,
516	file_id: &str,
517	root_id: Option<&str>,
518) -> ScopeCheck {
519	let Some(scope_str) = scope else { return ScopeCheck::NoScope };
520	// If a scope string is present but can't be parsed, deny access (least privilege)
521	let Some(token_scope) = TokenScope::parse(scope_str) else { return ScopeCheck::Denied };
522	match &token_scope {
523		TokenScope::File { file_id: scope_file_id, access } => {
524			// Direct match: scope matches this file_id
525			if scope_file_id == file_id {
526				return ScopeCheck::Allowed(*access);
527			}
528			// Document tree check: scope is for a root, this file is a child
529			if let Some(root) = root_id
530				&& scope_file_id.as_str() == root
531			{
532				return ScopeCheck::Allowed(*access);
533			}
534			ScopeCheck::Denied
535		}
536		TokenScope::ApkgPublish => ScopeCheck::Denied,
537	}
538}
539
540/// Check if a scoped token allows file creation, honoring folder subtrees.
541///
542/// Like the simple document-tree scope check (Write scope where
543/// `root_id == scope_file_id`), but also permits creation when the new file's
544/// parent is the scoped folder itself or a descendant of it. This is the path
545/// used by folder share links with editor (Write) access, letting guests upload
546/// directly into the shared folder (or any subfolder).
547///
548/// Allowed (with Write scope) when ANY of:
549/// - `root_id == scope_file_id` (document-tree rule, same as the sync variant)
550/// - `parent_id == scope_file_id` (direct child of the shared folder)
551/// - `parent_id` is a descendant of `scope_file_id` (nested subfolder)
552///
553/// Returns `Ok(())` if allowed, `Err(Error::PermissionDenied)` if denied.
554pub async fn check_scope_allows_create_in(
555	meta: &Arc<dyn meta_adapter::MetaAdapter>,
556	cache: &DirCache,
557	tn_id: TnId,
558	scope: Option<&str>,
559	parent_id: Option<&str>,
560	root_id: Option<&str>,
561) -> Result<(), Error> {
562	let Some(scope_str) = scope else { return Ok(()) };
563	// If a scope string is present but can't be parsed, deny access (least privilege)
564	let Some(token_scope) = TokenScope::parse(scope_str) else {
565		return Err(Error::PermissionDenied);
566	};
567	match &token_scope {
568		TokenScope::File { file_id: scope_file_id, access } => {
569			if !access.can_write() {
570				return Err(Error::PermissionDenied);
571			}
572			// Document-tree rule: new file is a child in the scoped document tree.
573			if root_id == Some(scope_file_id.as_str()) {
574				return Ok(());
575			}
576			// Folder-subtree rule: new file's parent is the scoped folder or nested
577			// under it. Only applies when the scoped target is actually a folder, so
578			// a document/file share link can't authorize creation across its
579			// parent_id siblings.
580			if let Some(parent) = parent_id
581				&& scope_target_is_folder(meta, cache, tn_id, scope_file_id).await?
582				&& (parent == scope_file_id.as_str()
583					|| is_descendant_of(meta, cache, tn_id, parent, scope_file_id).await?)
584			{
585				return Ok(());
586			}
587			Err(Error::PermissionDenied)
588		}
589		TokenScope::ApkgPublish => Ok(()), // Middleware already restricts to /api/files/apkg/
590	}
591}
592
593/// Returns true when a scoped token is itself sufficient authorization for a
594/// collection-level operation, letting the middleware skip the role/quota path.
595///
596/// A file share link with Write access authorizes file *creation* only; the
597/// file handlers (`check_scope_allows_create_in`) then enforce the scope's
598/// subtree boundary. It must NOT authorize action/app creation, trash emptying,
599/// or any other collection operation.
600pub fn scope_grants_collection_op(scope: Option<&str>, resource_type: &str, action: &str) -> bool {
601	let Some(scope) = scope else { return false };
602	// `Write` is the top of the scope vocabulary — `AccessLevel::to_scope_char` caps `Admin` at
603	// `'W'` and `TokenScope::parse` refuses any other char, so `Admin` is unreachable here.
604	matches!(TokenScope::parse(scope), Some(TokenScope::File { access: AccessLevel::Write, .. }))
605		&& resource_type == "file"
606		&& action == "create"
607}
608
609#[cfg(test)]
610mod tests {
611	use super::*;
612
613	#[test]
614	fn folder_write_scope_grants_file_create() {
615		assert!(scope_grants_collection_op(Some("file:f1~abc:W"), "file", "create"));
616	}
617
618	#[test]
619	fn write_scope_denies_non_file_create_ops() {
620		assert!(!scope_grants_collection_op(Some("file:f1~abc:W"), "action", "create"));
621		assert!(!scope_grants_collection_op(Some("file:f1~abc:W"), "file", "delete"));
622	}
623
624	#[test]
625	fn read_scope_denies_file_create() {
626		assert!(!scope_grants_collection_op(Some("file:f1~abc:R"), "file", "create"));
627	}
628
629	#[test]
630	fn no_scope_denies_file_create() {
631		assert!(!scope_grants_collection_op(None, "file", "create"));
632	}
633
634	#[test]
635	fn unparseable_scope_denies_file_create() {
636		assert!(!scope_grants_collection_op(Some("not-a-valid-scope"), "file", "create"));
637	}
638
639	#[test]
640	fn role_access_level_requires_a_known_role() {
641		// The federated-stranger case.
642		assert_eq!(role_access_level(&[]), AccessLevel::None);
643		// A single empty role string is not a role.
644		assert_eq!(role_access_level(&["".into()]), AccessLevel::None);
645		assert_eq!(role_access_level(&["SADM".into()]), AccessLevel::None);
646	}
647
648	#[test]
649	fn role_access_level_maps_community_roles() {
650		assert_eq!(role_access_level(&["public".into()]), AccessLevel::Read);
651		assert_eq!(role_access_level(&["follower".into()]), AccessLevel::Read);
652		assert_eq!(role_access_level(&["supporter".into()]), AccessLevel::Read);
653		assert_eq!(role_access_level(&["contributor".into()]), AccessLevel::Write);
654		assert_eq!(role_access_level(&["moderator".into()]), AccessLevel::Write);
655		// Leadership over a tenant-owned file carries share management, hence Admin not Write.
656		assert_eq!(role_access_level(&["leader".into()]), AccessLevel::Admin);
657		// The highest role in a mixed set wins.
658		assert_eq!(
659			role_access_level(&["public".into(), "follower".into(), "leader".into()]),
660			AccessLevel::Admin
661		);
662		assert_eq!(role_access_level(&["public".into(), "contributor".into()]), AccessLevel::Write);
663	}
664
665	const OWNER: &str = "alice.example.com";
666	const ATTACKER: &str = "mallory.example.com";
667
668	#[test]
669	fn fshr_from_a_non_owner_grants_nothing() {
670		// The action row is stored before `fshr::on_create` runs and a hook denial does not roll it
671		// back, so any contributor — or any followed peer posting to the inbox — can self-address
672		// an FSHR naming someone else's file. Every sub-type must be inert, `ADMIN` above all: it
673		// would otherwise read back as share-manager standing with an admin grant ceiling.
674		for sub_typ in [Some("ADMIN"), Some("WRITE"), Some("COMMENT"), Some("READ"), None] {
675			assert_eq!(
676				fshr_grant_level("FSHR", sub_typ, ATTACKER, OWNER, "f1~doc"),
677				AccessLevel::None,
678				"{sub_typ:?} from a non-owner must grant nothing"
679			);
680		}
681	}
682
683	#[test]
684	fn fshr_from_the_owner_grants_its_sub_type() {
685		// The live path: on the recipient's node `fshr::on_accept` writes `owner_tag = issuer`, so
686		// the grant resolves exactly as the sender sent it.
687		for (sub_typ, level) in [
688			(Some("ADMIN"), AccessLevel::Admin),
689			(Some("WRITE"), AccessLevel::Write),
690			(Some("COMMENT"), AccessLevel::Comment),
691			(Some("READ"), AccessLevel::Read),
692			(None, AccessLevel::Read),
693		] {
694			assert_eq!(fshr_grant_level("FSHR", sub_typ, OWNER, OWNER, "f1~doc"), level);
695		}
696	}
697
698	#[test]
699	fn a_del_from_the_owner_revokes_rather_than_granting_read() {
700		// `delete_share` drops the `share_entries` row and emits an FSHR `DEL`, which — same key —
701		// overwrites the grant. Falling through to the catch-all would hand the read back.
702		assert_eq!(
703			fshr_grant_level("FSHR", Some("DEL"), OWNER, OWNER, "f1~doc"),
704			AccessLevel::None
705		);
706	}
707
708	#[test]
709	fn only_fshr_rows_grant_anything() {
710		// The key is `FSHR:{file}:{audience}`, but `get_action_by_key` does not filter on type.
711		assert_eq!(
712			fshr_grant_level("CONN", Some("ADMIN"), OWNER, OWNER, "f1~doc"),
713			AccessLevel::None
714		);
715	}
716}
717
718// vim: ts=4