Skip to main content

cloudillo_file/
perm.rs

1//! File permission middleware for ABAC
2
3use axum::{
4	extract::{Path, Request, State},
5	middleware::Next,
6	response::Response,
7};
8
9use crate::prelude::*;
10use cloudillo_core::abac::Environment;
11use cloudillo_core::extract::{IdTag, OptionalAuth};
12use cloudillo_core::file_access;
13use cloudillo_core::middleware::PermissionCheckOutput;
14use cloudillo_types::auth_adapter::AuthCtx;
15use cloudillo_types::types::FileAttrs;
16
17/// Middleware factory for file permission checks
18///
19/// Returns a middleware function that validates file permissions via ABAC
20///
21/// # Arguments
22/// * `action` - The permission action to check (e.g., "read", "write")
23///
24/// # Returns
25/// A cloneable middleware function with return type `PermissionCheckOutput`
26pub fn check_perm_file(
27	action: &'static str,
28) -> impl Fn(
29	State<App>,
30	IdTag,
31	TnId,
32	OptionalAuth,
33	Path<String>,
34	Request,
35	Next,
36) -> PermissionCheckOutput
37       + Clone {
38	move |state, id_tag, tn_id, auth, path, req, next| {
39		Box::pin(check_file_permission(state, id_tag, tn_id, auth, path, req, next, action))
40	}
41}
42
43#[allow(clippy::too_many_arguments)]
44async fn check_file_permission(
45	State(app): State<App>,
46	IdTag(tenant_id_tag): IdTag,
47	tn_id: TnId,
48	OptionalAuth(maybe_auth_ctx): OptionalAuth,
49	Path(file_id): Path<String>,
50	req: Request,
51	next: Next,
52	action: &str,
53) -> Result<Response, Error> {
54	use tracing::warn;
55
56	// Create auth context or guest context if not authenticated
57	let (auth_ctx, subject_id_tag) = if let Some(auth_ctx) = maybe_auth_ctx {
58		let id_tag = auth_ctx.id_tag.clone();
59		(auth_ctx, id_tag)
60	} else {
61		// For unauthenticated requests, create a guest context
62		let guest_ctx =
63			AuthCtx { tn_id, id_tag: "guest".into(), roles: vec![].into(), scope: None };
64		(guest_ctx, "guest".into())
65	};
66
67	// Load file attributes (pass scope from auth context for scoped token access)
68	let attrs = load_file_attrs(
69		&app,
70		tn_id,
71		&file_id,
72		&subject_id_tag,
73		&tenant_id_tag,
74		&auth_ctx.roles,
75		auth_ctx.scope.as_deref(),
76	)
77	.await?;
78
79	// Check permission
80	let environment = Environment::new();
81	let checker = app.permission_checker.read().await;
82
83	// Format action as "file:operation" for ABAC checker
84	let full_action = format!("file:{}", action);
85
86	if !checker.has_permission(&auth_ctx, &full_action, &attrs, &environment) {
87		warn!(
88			subject = %auth_ctx.id_tag,
89			action = %full_action,
90			file_id = %file_id,
91			visibility = attrs.visibility,
92			owner_id_tag = %attrs.owner_id_tag,
93			access_level = ?attrs.access_level,
94			"File permission denied"
95		);
96		return Err(Error::PermissionDenied);
97	}
98
99	Ok(next.run(req).await)
100}
101
102// Load file attributes from MetaAdapter
103async fn load_file_attrs(
104	app: &App,
105	tn_id: TnId,
106	file_or_variant_id: &str,
107	subject_id_tag: &str,
108	tenant_id_tag: &str,
109	subject_roles: &[Box<str>],
110	scope: Option<&str>,
111) -> ClResult<FileAttrs> {
112	use cloudillo_core::abac::VisibilityLevel;
113	use std::borrow::Cow;
114	use tracing::debug;
115
116	// Detect if this is a variant_id (starts with 'b') and look up the file_id
117	let file_id: Cow<str> = if file_or_variant_id.starts_with('b') {
118		// This is a variant_id, look up the file_id
119		debug!("Looking up file_id for variant_id: {}", file_or_variant_id);
120		let fid = app.meta_adapter.read_file_id_by_variant(tn_id, file_or_variant_id).await?;
121		debug!("Found file_id: {} for variant_id: {}", fid, file_or_variant_id);
122		Cow::Owned(fid.to_string())
123	} else {
124		Cow::Borrowed(file_or_variant_id)
125	};
126
127	// Get file view from MetaAdapter
128	let file_view = app.meta_adapter.read_file(tn_id, &file_id).await?;
129
130	let file_view = file_view.ok_or(Error::NotFound)?;
131
132	// Extract owner from nested ProfileInfo
133	// If no owner or owner has empty id_tag, file is owned by the tenant itself
134	let owner_id_tag = file_view
135		.owner
136		.as_ref()
137		.and_then(|p| if p.id_tag.is_empty() { None } else { Some(p.id_tag.clone()) })
138		.unwrap_or_else(|| {
139			debug!("File has no owner, using tenant_id_tag: {}", tenant_id_tag);
140			tenant_id_tag.into()
141		});
142
143	// Determine access level by looking up scoped tokens, FSHR action grants
144	let ctx = file_access::FileAccessCtx {
145		user_id_tag: subject_id_tag,
146		tenant_id_tag,
147		user_roles: subject_roles,
148	};
149	let access_level =
150		file_access::get_access_level_with_scope(app, tn_id, &file_id, &owner_id_tag, &ctx, scope)
151			.await;
152
153	// Get visibility from file metadata - convert char to string representation
154	let visibility: Box<str> = VisibilityLevel::from_char(file_view.visibility).as_str().into();
155
156	// Look up subject's relationship with the file owner
157	let (following, connected) = if subject_id_tag != "guest" && !subject_id_tag.is_empty() {
158		// Get profile to check relationship status using list_profiles with id_tag filter
159		let opts = cloudillo_types::meta_adapter::ListProfileOptions {
160			id_tag: Some(subject_id_tag.to_string()),
161			..Default::default()
162		};
163		match app.meta_adapter.list_profiles(tn_id, &opts).await {
164			Ok(profiles) => {
165				if let Some(profile) = profiles.first() {
166					let following = profile.following;
167					let connected = profile.connected.is_connected();
168					debug!(
169						subject = subject_id_tag,
170						owner = %owner_id_tag,
171						following = following,
172						connected = connected,
173						"Loaded relationship status for file permission check"
174					);
175					(following, connected)
176				} else {
177					debug!(subject = subject_id_tag, "Profile not found, assuming no relationship");
178					(false, false)
179				}
180			}
181			Err(e) => {
182				debug!(
183					subject = subject_id_tag,
184					error = %e,
185					"Failed to load profile, assuming no relationship"
186				);
187				(false, false)
188			}
189		}
190	} else {
191		(false, false)
192	};
193
194	Ok(FileAttrs {
195		file_id: file_view.file_id,
196		owner_id_tag,
197		mime_type: file_view.content_type.unwrap_or_else(|| "application/octet-stream".into()),
198		tags: file_view.tags.unwrap_or_default(),
199		visibility,
200		access_level,
201		following,
202		connected,
203	})
204}
205
206// vim: ts=4