Skip to main content

cloudillo_core/
create_perm.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Collection-level permission middleware for ABAC (CREATE operations)
5//!
6//! Validates CREATE permissions for resources that don't yet exist,
7//! based on subject attributes like quota, tier, and role.
8
9use axum::{
10	extract::{Request, State},
11	middleware::Next,
12	response::Response,
13};
14
15use crate::{abac::Environment, extract::Auth, middleware::PermissionCheckOutput, prelude::*};
16use cloudillo_types::types::SubjectAttrs;
17
18/// Middleware factory for collection permission checks
19///
20/// Returns a middleware function that validates CREATE permissions via ABAC.
21/// Evaluates collection-level policies based on subject attributes.
22///
23/// # Arguments
24/// * `resource_type` - The resource being created (e.g., "file", "action")
25/// * `action` - The permission action to check (e.g., "create")
26///
27/// # Returns
28/// A cloneable middleware function with return type `PermissionCheckOutput`
29pub fn check_perm_create(
30	resource_type: &'static str,
31	action: &'static str,
32) -> impl Fn(State<App>, Auth, Request, Next) -> PermissionCheckOutput + Clone {
33	move |state, auth, req, next| {
34		Box::pin(check_create_permission(state, auth, req, next, resource_type, action))
35	}
36}
37
38async fn check_create_permission(
39	State(app): State<App>,
40	Auth(auth_ctx): Auth,
41	req: Request,
42	next: Next,
43	resource_type: &str,
44	action: &str,
45) -> Result<Response, Error> {
46	use tracing::warn;
47
48	// A file-write scope (share link with editor access) is itself the grant for
49	// file *creation* only; `check_scope_allows_create_in` in the file handler
50	// then enforces the subtree boundary and the owner's quota applies. Such
51	// guests have no role, so they would fail the contributor check below. The
52	// shortcut must NOT extend to action/app creation or trash emptying.
53	if crate::file_access::scope_grants_collection_op(
54		auth_ctx.scope.as_deref(),
55		resource_type,
56		action,
57	) {
58		return Ok(next.run(req).await);
59	}
60
61	// Check if user has a role that allows content creation
62	// Minimum role for creating content: "contributor"
63	if !auth_ctx.roles.iter().any(|r| r.as_ref() == "contributor") {
64		warn!(
65			subject = %auth_ctx.id_tag,
66			resource_type = resource_type,
67			action = action,
68			roles = ?auth_ctx.roles,
69			"CREATE permission denied: requires at least 'contributor' role"
70		);
71		return Err(Error::PermissionDenied);
72	}
73
74	// Load subject attributes
75	let subject_attrs = load_subject_attrs(&app, &auth_ctx).await?;
76
77	// Create environment context
78	let environment = Environment::new();
79	let checker = app.permission_checker.read().await;
80
81	// Evaluate collection policy
82	if !checker.has_collection_permission(
83		&auth_ctx,
84		&subject_attrs,
85		resource_type,
86		action,
87		&environment,
88	) {
89		warn!(
90			subject = %auth_ctx.id_tag,
91			resource_type = resource_type,
92			action = action,
93			tier = %subject_attrs.tier,
94			quota_remaining_bytes = %subject_attrs.quota_remaining_bytes,
95			roles = ?subject_attrs.roles,
96			banned = subject_attrs.banned,
97			email_verified = subject_attrs.email_verified,
98			"CREATE permission denied"
99		);
100		return Err(Error::PermissionDenied);
101	}
102
103	Ok(next.run(req).await)
104}
105
106/// Load subject attributes for collection-level permission evaluation
107///
108/// Loads user's tier, quota, roles, and status from authentication/metadata.
109/// These attributes are used to evaluate CREATE operation permissions.
110async fn load_subject_attrs(
111	app: &App,
112	auth_ctx: &cloudillo_types::auth_adapter::AuthCtx,
113) -> ClResult<SubjectAttrs> {
114	// Check if user is banned by querying profile ban status
115	// Note: We use get_profile_info which returns ProfileData with status information
116	let banned = match app.meta_adapter.get_profile_info(auth_ctx.tn_id, &auth_ctx.id_tag).await {
117		Ok(_profile_data) => {
118			// Check if profile status indicates banned
119			// ProfileData.status is not available in current implementation,
120			// so we need to query more directly. For now, default to false.
121			// TODO: Extend ProfileData or add get_profile_ban_status method to adapter
122			false
123		}
124		Err(_) => {
125			// If profile doesn't exist locally, assume not banned
126			// (user might be from remote instance)
127			false
128		}
129	};
130
131	// Check if email is verified by checking tenant status
132	// If we can successfully read the tenant, they have been created and verified
133	let email_verified = match app.auth_adapter.read_tenant(&auth_ctx.id_tag).await {
134		Ok(_) => {
135			// If tenant exists and we can read it, assume verified
136			// In the current schema, tenant status 'A' means Active/verified
137			// TODO: Add explicit email_verified field to tenants table for better tracking
138			true
139		}
140		Err(_) => {
141			// If we can't read tenant, they may not be local or not verified
142			false
143		}
144	};
145
146	// Determine user tier based on roles
147	let tier: Box<str> = if auth_ctx.roles.iter().any(|r| r.as_ref() == "leader") {
148		"premium".into()
149	} else if auth_ctx.roles.iter().any(|r| r.as_ref() == "creator") {
150		"standard".into()
151	} else {
152		"free".into()
153	};
154
155	// Calculate quota remaining (in bytes)
156	// TODO: Query from meta_adapter to get user's actual used quota
157	let quota_bytes = match tier.as_ref() {
158		"premium" => 1024 * 1024 * 1024, // 1GB
159		"standard" => 100 * 1024 * 1024, // 100MB
160		_ => 10 * 1024 * 1024,           // 10MB (free tier)
161	};
162
163	// Get rate limit remaining (per hour)
164	// TODO: Query from meta_adapter or time-based tracker for actual rate limit tracking
165	let rate_limit_remaining_val = 100u32; // per hour
166
167	Ok(SubjectAttrs {
168		id_tag: auth_ctx.id_tag.clone(),
169		roles: auth_ctx.roles.to_vec(),
170		tier,
171		quota_remaining_bytes: quota_bytes.to_string().into(),
172		rate_limit_remaining: rate_limit_remaining_val.to_string().into(),
173		banned,
174		email_verified,
175	})
176}
177
178#[cfg(test)]
179mod tests {
180	use super::*;
181
182	#[test]
183	fn test_subject_attrs_creation() {
184		let attrs = SubjectAttrs {
185			id_tag: "alice".into(),
186			roles: vec!["creator".into()],
187			tier: "standard".into(),
188			quota_remaining_bytes: "100000000".into(),
189			rate_limit_remaining: "50".into(),
190			banned: false,
191			email_verified: true,
192		};
193
194		assert_eq!(attrs.id_tag.as_ref(), "alice");
195		assert_eq!(attrs.tier.as_ref(), "standard");
196		assert!(!attrs.banned);
197		assert!(attrs.email_verified);
198	}
199
200	#[test]
201	fn test_subject_attrs_implements_attr_set() {
202		use crate::abac::AttrSet;
203
204		let attrs = SubjectAttrs {
205			id_tag: "bob".into(),
206			roles: vec!["member".into(), "creator".into()],
207			tier: "premium".into(),
208			quota_remaining_bytes: "500000000".into(),
209			rate_limit_remaining: "95".into(),
210			banned: false,
211			email_verified: true,
212		};
213
214		// Test get()
215		assert_eq!(attrs.get("id_tag"), Some("bob"));
216		assert_eq!(attrs.get("tier"), Some("premium"));
217		assert_eq!(attrs.get("banned"), Some("false"));
218
219		// Test get_list()
220		let roles = attrs.get_list("roles");
221		assert!(roles.is_some());
222		assert_eq!(roles.unwrap().len(), 2);
223
224		// Test has()
225		assert!(attrs.has("tier", "premium"));
226		assert!(!attrs.has("tier", "free"));
227
228		// Test contains()
229		assert!(attrs.contains("roles", "creator"));
230		assert!(!attrs.contains("roles", "admin"));
231	}
232}
233
234// vim: ts=4