Skip to main content

cloudillo_action/
filter.rs

1//! Visibility filtering for actions
2
3use std::collections::{HashMap, HashSet};
4use std::sync::Arc;
5
6use cloudillo_core::abac::can_view_item;
7use cloudillo_types::meta_adapter::{ActionView, ListActionOptions};
8
9use crate::{dsl::DslEngine, prelude::*};
10
11/// Filter actions by visibility based on the subject's access level
12///
13/// This function filters a list of actions to only include those the subject
14/// is allowed to see based on:
15/// - The action's visibility level
16/// - The subject's relationship with the issuer (following/connected)
17/// - Whether the subject is in the audience (for Direct visibility)
18/// - Whether the subject is a subscriber (for subscribable action types with Direct visibility)
19pub async fn filter_actions_by_visibility(
20	app: &App,
21	tn_id: TnId,
22	subject_id_tag: &str,
23	is_authenticated: bool,
24	tenant_id_tag: &str,
25	actions: Vec<ActionView>,
26) -> ClResult<Vec<ActionView>> {
27	// If no actions, return early
28	if actions.is_empty() {
29		return Ok(actions);
30	}
31
32	// Collect unique issuer id_tags
33	let issuer_tags: HashSet<&str> = actions.iter().map(|a| a.issuer.id_tag.as_ref()).collect();
34
35	// Batch load relationship status for all issuers
36	let relationships = load_relationships(app, tn_id, subject_id_tag, &issuer_tags).await?;
37
38	// Identify subscribable actions with Direct visibility that need subscriber lookup
39	let subscribable_direct: Vec<&str> = actions
40		.iter()
41		.filter(|a| a.visibility.is_none() && is_subscribable(app, &a.typ))
42		.map(|a| a.action_id.as_ref())
43		.collect();
44
45	// Batch load subscribers for subscribable Direct-visibility actions
46	let subscribers_map = load_subscribers(app, tn_id, &subscribable_direct).await;
47
48	// Filter actions based on visibility
49	info!(
50		"filter_actions_by_visibility: subject={}, is_auth={}, tenant={}, action_count={}",
51		subject_id_tag,
52		is_authenticated,
53		tenant_id_tag,
54		actions.len()
55	);
56	let filtered = actions
57		.into_iter()
58		.filter(|action| {
59			let issuer_tag = action.issuer.id_tag.as_ref();
60			let (following, connected) =
61				relationships.get(issuer_tag).copied().unwrap_or((false, false));
62
63			// Build audience list for Direct visibility check
64			let mut audience: Vec<&str> =
65				action.audience.as_ref().map(|a| vec![a.id_tag.as_ref()]).unwrap_or_default();
66
67			// For subscribable Direct-visibility actions, check if subject is a subscriber
68			if action.visibility.is_none() {
69				if let Some(subs) = subscribers_map.get(action.action_id.as_ref()) {
70					if subs.contains(subject_id_tag) {
71						audience.push(subject_id_tag);
72					}
73				}
74			}
75
76			let allowed = can_view_item(
77				subject_id_tag,
78				is_authenticated,
79				issuer_tag,
80				tenant_id_tag,
81				action.visibility,
82				following,
83				connected,
84				Some(&audience),
85			);
86			if !allowed {
87				info!(
88					"FILTERED OUT action={}: subject={}, issuer={}, tenant={}, visibility={:?}, audience={:?}",
89					action.action_id, subject_id_tag, issuer_tag, tenant_id_tag, action.visibility, audience
90				);
91			}
92			allowed
93		})
94		.collect();
95
96	Ok(filtered)
97}
98
99/// Check if action type is subscribable based on DSL definition
100fn is_subscribable(app: &App, action_type: &str) -> bool {
101	app.ext::<Arc<DslEngine>>()
102		.ok()
103		.and_then(|dsl| dsl.get_behavior(action_type))
104		.and_then(|b| b.subscribable)
105		.unwrap_or(false)
106}
107
108/// Load subscribers for a list of action IDs
109///
110/// Returns a map of action_id -> set of subscriber id_tags
111async fn load_subscribers(
112	app: &App,
113	tn_id: TnId,
114	action_ids: &[&str],
115) -> HashMap<String, HashSet<String>> {
116	let mut subscribers_map: HashMap<String, HashSet<String>> = HashMap::new();
117
118	for action_id in action_ids {
119		let subs_opts = ListActionOptions {
120			typ: Some(vec!["SUBS".into()]),
121			subject: Some((*action_id).to_string()),
122			status: Some(vec!["A".into()]),
123			..Default::default()
124		};
125
126		if let Ok(subs) = app.meta_adapter.list_actions(tn_id, &subs_opts).await {
127			let issuer_tags: HashSet<String> =
128				subs.into_iter().map(|a| a.issuer.id_tag.to_string()).collect();
129			subscribers_map.insert((*action_id).to_string(), issuer_tags);
130		}
131	}
132
133	subscribers_map
134}
135
136/// Load relationship status between subject and multiple targets
137///
138/// Returns a map of target_id_tag -> (following, connected)
139/// Uses batch query to avoid N+1 problem
140async fn load_relationships(
141	app: &App,
142	tn_id: TnId,
143	subject_id_tag: &str,
144	target_id_tags: &HashSet<&str>,
145) -> ClResult<HashMap<String, (bool, bool)>> {
146	// For anonymous users or empty target sets, return empty map
147	if subject_id_tag.is_empty() || target_id_tags.is_empty() {
148		return Ok(HashMap::new());
149	}
150
151	// Convert HashSet to Vec for batch query
152	let targets: Vec<&str> = target_id_tags.iter().copied().collect();
153
154	// Single batch query instead of N+1 queries
155	app.meta_adapter.get_relationships(tn_id, &targets).await
156}
157
158// vim: ts=4