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	// Check if user has a role that allows content creation
49	// Minimum role for creating content: "contributor"
50	if !auth_ctx.roles.iter().any(|r| r.as_ref() == "contributor") {
51		warn!(
52			subject = %auth_ctx.id_tag,
53			resource_type = resource_type,
54			action = action,
55			roles = ?auth_ctx.roles,
56			"CREATE permission denied: requires at least 'contributor' role"
57		);
58		return Err(Error::PermissionDenied);
59	}
60
61	// Load subject attributes
62	let subject_attrs = load_subject_attrs(&app, &auth_ctx).await?;
63
64	// Create environment context
65	let environment = Environment::new();
66	let checker = app.permission_checker.read().await;
67
68	// Evaluate collection policy
69	if !checker.has_collection_permission(
70		&auth_ctx,
71		&subject_attrs,
72		resource_type,
73		action,
74		&environment,
75	) {
76		warn!(
77			subject = %auth_ctx.id_tag,
78			resource_type = resource_type,
79			action = action,
80			tier = %subject_attrs.tier,
81			quota_remaining_bytes = %subject_attrs.quota_remaining_bytes,
82			roles = ?subject_attrs.roles,
83			banned = subject_attrs.banned,
84			email_verified = subject_attrs.email_verified,
85			"CREATE permission denied"
86		);
87		return Err(Error::PermissionDenied);
88	}
89
90	Ok(next.run(req).await)
91}
92
93/// Load subject attributes for collection-level permission evaluation
94///
95/// Loads user's tier, quota, roles, and status from authentication/metadata.
96/// These attributes are used to evaluate CREATE operation permissions.
97async fn load_subject_attrs(
98	app: &App,
99	auth_ctx: &cloudillo_types::auth_adapter::AuthCtx,
100) -> ClResult<SubjectAttrs> {
101	// Check if user is banned by querying profile ban status
102	// Note: We use get_profile_info which returns ProfileData with status information
103	let banned = match app.meta_adapter.get_profile_info(auth_ctx.tn_id, &auth_ctx.id_tag).await {
104		Ok(_profile_data) => {
105			// Check if profile status indicates banned
106			// ProfileData.status is not available in current implementation,
107			// so we need to query more directly. For now, default to false.
108			// TODO: Extend ProfileData or add get_profile_ban_status method to adapter
109			false
110		}
111		Err(_) => {
112			// If profile doesn't exist locally, assume not banned
113			// (user might be from remote instance)
114			false
115		}
116	};
117
118	// Check if email is verified by checking tenant status
119	// If we can successfully read the tenant, they have been created and verified
120	let email_verified = match app.auth_adapter.read_tenant(&auth_ctx.id_tag).await {
121		Ok(_) => {
122			// If tenant exists and we can read it, assume verified
123			// In the current schema, tenant status 'A' means Active/verified
124			// TODO: Add explicit email_verified field to tenants table for better tracking
125			true
126		}
127		Err(_) => {
128			// If we can't read tenant, they may not be local or not verified
129			false
130		}
131	};
132
133	// Determine user tier based on roles
134	let tier: Box<str> = if auth_ctx.roles.iter().any(|r| r.as_ref() == "leader") {
135		"premium".into()
136	} else if auth_ctx.roles.iter().any(|r| r.as_ref() == "creator") {
137		"standard".into()
138	} else {
139		"free".into()
140	};
141
142	// Calculate quota remaining (in bytes)
143	// TODO: Query from meta_adapter to get user's actual used quota
144	let quota_bytes = match tier.as_ref() {
145		"premium" => 1024 * 1024 * 1024, // 1GB
146		"standard" => 100 * 1024 * 1024, // 100MB
147		_ => 10 * 1024 * 1024,           // 10MB (free tier)
148	};
149
150	// Get rate limit remaining (per hour)
151	// TODO: Query from meta_adapter or time-based tracker for actual rate limit tracking
152	let rate_limit_remaining_val = 100u32; // per hour
153
154	Ok(SubjectAttrs {
155		id_tag: auth_ctx.id_tag.clone(),
156		roles: auth_ctx.roles.to_vec(),
157		tier,
158		quota_remaining_bytes: quota_bytes.to_string().into(),
159		rate_limit_remaining: rate_limit_remaining_val.to_string().into(),
160		banned,
161		email_verified,
162	})
163}
164
165#[cfg(test)]
166mod tests {
167	use super::*;
168
169	#[test]
170	fn test_subject_attrs_creation() {
171		let attrs = SubjectAttrs {
172			id_tag: "alice".into(),
173			roles: vec!["creator".into()],
174			tier: "standard".into(),
175			quota_remaining_bytes: "100000000".into(),
176			rate_limit_remaining: "50".into(),
177			banned: false,
178			email_verified: true,
179		};
180
181		assert_eq!(attrs.id_tag.as_ref(), "alice");
182		assert_eq!(attrs.tier.as_ref(), "standard");
183		assert!(!attrs.banned);
184		assert!(attrs.email_verified);
185	}
186
187	#[test]
188	fn test_subject_attrs_implements_attr_set() {
189		use crate::abac::AttrSet;
190
191		let attrs = SubjectAttrs {
192			id_tag: "bob".into(),
193			roles: vec!["member".into(), "creator".into()],
194			tier: "premium".into(),
195			quota_remaining_bytes: "500000000".into(),
196			rate_limit_remaining: "95".into(),
197			banned: false,
198			email_verified: true,
199		};
200
201		// Test get()
202		assert_eq!(attrs.get("id_tag"), Some("bob"));
203		assert_eq!(attrs.get("tier"), Some("premium"));
204		assert_eq!(attrs.get("banned"), Some("false"));
205
206		// Test get_list()
207		let roles = attrs.get_list("roles");
208		assert!(roles.is_some());
209		assert_eq!(roles.unwrap().len(), 2);
210
211		// Test has()
212		assert!(attrs.has("tier", "premium"));
213		assert!(!attrs.has("tier", "free"));
214
215		// Test contains()
216		assert!(attrs.contains("roles", "creator"));
217		assert!(!attrs.contains("roles", "admin"));
218	}
219}
220
221// vim: ts=4