Skip to main content

cloudillo_action/
perm.rs

1//! Action permission middleware for ABAC
2
3use axum::{
4	extract::{Path, Request, State},
5	middleware::Next,
6	response::Response,
7};
8
9use cloudillo_core::{
10	abac::Environment,
11	extract::{IdTag, OptionalAuth},
12	middleware::PermissionCheckOutput,
13};
14use cloudillo_types::auth_adapter::AuthCtx;
15use cloudillo_types::types::ActionAttrs;
16
17use crate::prelude::*;
18
19/// Middleware factory for action permission checks
20///
21/// Returns a middleware function that validates action permissions via ABAC
22///
23/// # Arguments
24/// * `action` - The permission action to check (e.g., "read", "write")
25///
26/// # Returns
27/// A cloneable middleware function with return type `PermissionCheckOutput`
28pub fn check_perm_action(
29	action: &'static str,
30) -> impl Fn(
31	State<App>,
32	TnId,
33	IdTag,
34	OptionalAuth,
35	Path<String>,
36	Request,
37	Next,
38) -> PermissionCheckOutput
39       + Clone {
40	move |state, tn_id, id_tag, auth, path, req, next| {
41		Box::pin(check_action_permission(state, tn_id, id_tag, auth, path, req, next, action))
42	}
43}
44
45#[allow(clippy::too_many_arguments)]
46async fn check_action_permission(
47	State(app): State<App>,
48	tn_id: TnId,
49	IdTag(tenant_id_tag): IdTag,
50	OptionalAuth(maybe_auth_ctx): OptionalAuth,
51	Path(action_id): Path<String>,
52	req: Request,
53	next: Next,
54	action: &str,
55) -> Result<Response, Error> {
56	use tracing::warn;
57
58	// Create auth context or guest context if not authenticated
59	let (auth_ctx, subject_id_tag) = if let Some(auth_ctx) = maybe_auth_ctx {
60		let id_tag = auth_ctx.id_tag.clone();
61		(auth_ctx, id_tag)
62	} else {
63		// For unauthenticated requests, create a guest context
64		let guest_ctx =
65			AuthCtx { tn_id, id_tag: "guest".into(), roles: vec![].into(), scope: None };
66		(guest_ctx, "guest".into())
67	};
68
69	// Load action attributes
70	let attrs = load_action_attrs(&app, tn_id, &action_id, &subject_id_tag, &tenant_id_tag).await?;
71
72	// Check permission
73	let environment = Environment::new();
74	let checker = app.permission_checker.read().await;
75
76	// Format action as "action:operation" for ABAC checker
77	let full_action = format!("action:{}", action);
78
79	if !checker.has_permission(&auth_ctx, &full_action, &attrs, &environment) {
80		warn!(
81			subject = %auth_ctx.id_tag,
82			action = action,
83			action_id = %action_id,
84			visibility = attrs.visibility,
85			issuer_id_tag = %attrs.issuer_id_tag,
86			action_type = attrs.typ,
87			"Action permission denied"
88		);
89		return Err(Error::PermissionDenied);
90	}
91
92	Ok(next.run(req).await)
93}
94
95// Load action attributes from MetaAdapter
96async fn load_action_attrs(
97	app: &App,
98	tn_id: TnId,
99	action_id: &str,
100	subject_id_tag: &str,
101	tenant_id_tag: &str,
102) -> ClResult<ActionAttrs> {
103	use cloudillo_core::abac::VisibilityLevel;
104	use tracing::debug;
105
106	// Get action view from MetaAdapter
107	let action_view = app.meta_adapter.get_action(tn_id, action_id).await?;
108
109	let action_view = action_view.ok_or(Error::NotFound)?;
110
111	// Extract audience as list of profile id_tags
112	let audience_tag = action_view
113		.audience
114		.as_ref()
115		.map(|p| vec![p.id_tag.clone()])
116		.unwrap_or_default();
117
118	// Get visibility from action metadata - convert char to string representation
119	let visibility: Box<str> = VisibilityLevel::from_char(action_view.visibility).as_str().into();
120
121	// Look up subject's relationship with the action issuer
122	let (following, connected) = if subject_id_tag != "guest" && !subject_id_tag.is_empty() {
123		// Get profile to check relationship status using list_profiles with id_tag filter
124		let opts = cloudillo_types::meta_adapter::ListProfileOptions {
125			id_tag: Some(subject_id_tag.to_string()),
126			..Default::default()
127		};
128		match app.meta_adapter.list_profiles(tn_id, &opts).await {
129			Ok(profiles) => {
130				if let Some(profile) = profiles.first() {
131					let following = profile.following;
132					let connected = profile.connected.is_connected();
133					debug!(
134						subject = subject_id_tag,
135						issuer = %action_view.issuer.id_tag,
136						following = following,
137						connected = connected,
138						"Loaded relationship status for action permission check"
139					);
140					(following, connected)
141				} else {
142					debug!(subject = subject_id_tag, "Profile not found, assuming no relationship");
143					(false, false)
144				}
145			}
146			Err(e) => {
147				debug!(
148					subject = subject_id_tag,
149					error = %e,
150					"Failed to load profile, assuming no relationship"
151				);
152				(false, false)
153			}
154		}
155	} else {
156		(false, false)
157	};
158
159	Ok(ActionAttrs {
160		typ: action_view.typ,
161		sub_typ: action_view.sub_typ,
162		tenant_id_tag: tenant_id_tag.into(),
163		issuer_id_tag: action_view.issuer.id_tag,
164		parent_id: action_view.parent_id,
165		root_id: action_view.root_id,
166		audience_tag,
167		tags: vec![], // TODO: Extract tags from action metadata when available
168		visibility,
169		following,
170		connected,
171	})
172}