Skip to main content

fraiseql_core/runtime/executor/
subscription_plan.rs

1//! A subscription's read plan (ruling AA 4, settled in AC 2–5).
2//!
3//! A subscription is a read of its type delivered by push, so it meets the gates a read of
4//! that type through the same selection meets. The executor plans it at subscribe time, from
5//! the client's resolved selection and the subscriber's principal — the transport hands the
6//! document over and never classifies — and the plan is applied to every event's after-image
7//! (the document the write returned: the only source, nothing is fetched).
8//!
9//! At subscribe time ([`Executor::plan_subscription`]) the plan refuses what a read would
10//! refuse: a field the type does not declare, one outside the subscription's compile-time
11//! field list, the type's own `requires_role`, whatever the read plan refuses (a `Reject`
12//! field, a nested level's role, actor or #422 decision, a nested row policy that cannot be
13//! evaluated over the document, the #423 refusals that need no document), and a filter on a
14//! field the subscriber may not read (ruling AA 3).
15//!
16//! Per event ([`SubscriptionPlan::deliver`]): the root after-image must be a row the type's
17//! own row policy admits for this subscriber (ruling AC 4 — a subscription is a read of its
18//! type; a policy the document cannot answer refuses the subscription instead); nested
19//! levels row-filtered, projected through
20//! the selection, masked, then put to the #423 authorizer. Whatever refuses the event
21//! suppresses it for this subscriber: no frame, nothing the subscriber can tell from no event
22//! at all. Suppressions are counted in one aggregate figure
23//! ([`suppressed_subscription_events`]).
24
25use std::{
26    collections::HashMap,
27    sync::{
28        Arc,
29        atomic::{AtomicU64, Ordering},
30    },
31};
32
33use super::{
34    context::ExecutorContext,
35    core::Executor,
36    runners::{
37        query_nested::{RootRowFilter, own_read, type_read_refusal, whole_object},
38        read_plan::ReadPlan,
39    },
40    support::security::refuse_unreadable_where,
41};
42use crate::{
43    error::{FraiseQLError, Result},
44    graphql::{FieldSelection, ParsedQuery},
45    runtime::projection::effective_selections,
46    schema::{CompiledSchema, SubscriptionDefinition},
47    security::SecurityContext,
48};
49
50/// Events a subscription plan suppressed, since the process started. One figure for the
51/// whole process: a per-subscriber or per-type count would tell who is refused what.
52static SUPPRESSED: AtomicU64 = AtomicU64::new(0);
53
54/// How many events subscription plans have suppressed since the process started.
55#[must_use]
56pub fn suppressed_subscription_events() -> u64 {
57    SUPPRESSED.load(Ordering::Relaxed)
58}
59
60/// A subscription planned for one subscriber: what each event is served as.
61pub struct SubscriptionPlan {
62    ctx:          Arc<ExecutorContext>,
63    plan:         ReadPlan,
64    root_rows:    Option<RootRowFilter>,
65    principal:    Option<SecurityContext>,
66    subscription: String,
67    type_name:    String,
68    selections:   Vec<FieldSelection>,
69    variables:    HashMap<String, serde_json::Value>,
70}
71
72impl std::fmt::Debug for SubscriptionPlan {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.debug_struct("SubscriptionPlan")
75            .field("subscription", &self.subscription)
76            .field("type_name", &self.type_name)
77            .finish_non_exhaustive()
78    }
79}
80
81impl SubscriptionPlan {
82    /// The subscription this plan serves.
83    #[must_use]
84    pub fn subscription_name(&self) -> &str {
85        &self.subscription
86    }
87
88    /// The subscriber's own identity in the form a Change-Spine `acting_for` is stamped in
89    /// (a UUID `sub`, ruling AI): `None` for an anonymous plan or a subject that is not a
90    /// UUID, which no delegation can name.
91    #[must_use]
92    pub(crate) fn subscriber_uuid(&self) -> Option<uuid::Uuid> {
93        self.principal
94            .as_ref()
95            .and_then(|who| uuid::Uuid::parse_str(who.user_id.as_str()).ok())
96    }
97
98    /// Serve one event's after-image to this subscriber, or `None` when the plan suppresses
99    /// it.
100    #[must_use]
101    pub fn deliver(&self, after_image: &serde_json::Value) -> Option<serde_json::Value> {
102        if self.root_rows.as_ref().is_some_and(|rows| !rows.admits(after_image)) {
103            SUPPRESSED.fetch_add(1, Ordering::Relaxed);
104            return None;
105        }
106        if let Ok(served) = self.plan.serve(
107            &self.ctx,
108            self.principal.as_ref(),
109            &self.type_name,
110            &self.selections,
111            after_image,
112            &self.variables,
113        ) {
114            Some(served)
115        } else {
116            SUPPRESSED.fetch_add(1, Ordering::Relaxed);
117            None
118        }
119    }
120}
121
122impl Executor {
123    /// Plan a subscription for one subscriber: `document` is the subscription operation as
124    /// the client sent it, `variables` its variables, `principal` the subscriber.
125    ///
126    /// # Errors
127    ///
128    /// `Validation` for a document that selects no subscription or more than one, an unknown
129    /// subscription, a field the type does not declare or the subscription does not deliver;
130    /// `Authorization` for the type's role, a filter on a field the subscriber may not read,
131    /// and whatever the read plan refuses.
132    pub fn plan_subscription(
133        &self,
134        document: &ParsedQuery,
135        variables: Option<&serde_json::Value>,
136        principal: Option<&SecurityContext>,
137    ) -> Result<SubscriptionPlan> {
138        let ctx = &self.ctx;
139        let schema = &ctx.schema;
140        // § 6.4.1 (#1504): an omitted variable takes its declared default, so a
141        // defaulted argument binds its filter as a supplied one does.
142        let defaults = crate::graphql::value_json::variable_defaults(&document.variables)?;
143        let defaulted = crate::graphql::value_json::with_variable_defaults(&defaults, variables);
144        let variables_map =
145            crate::graphql::selection_set::variables_map(defaulted.as_ref().or(variables));
146        let resolved = crate::graphql::selection_set::resolve_and_filter(
147            &document.selections,
148            &document.fragments,
149            &variables_map,
150            self.max_query_depth(),
151        )?;
152        let mut roots = resolved.iter().filter(|s| s.name != "__typename");
153        let (Some(root), None) = (roots.next(), roots.next()) else {
154            return Err(FraiseQLError::Validation {
155                message: "a subscription operation selects exactly one root field".to_string(),
156                path:    None,
157            });
158        };
159        let definition =
160            schema.find_subscription(&root.name).ok_or_else(|| FraiseQLError::Validation {
161                message: format!("Subscription '{}' not found in schema", root.name),
162                path:    None,
163            })?;
164        let type_name = definition.return_type.as_str();
165
166        refuse_undeclared_selection(schema, type_name, &root.nested_fields)?;
167        refuse_undelivered_selection(definition, &root.nested_fields)?;
168        self.refuse_unless_readable(&definition.name, type_name, principal)?;
169        // The filter binds the arguments the document gave the root field, however spelled
170        // (#1158): reading the variables by argument name missed an inline value and a
171        // variable of another name, and with them this check.
172        let arguments: HashMap<String, serde_json::Value> =
173            crate::runtime::subscription::subscription_arguments(
174                schema,
175                document,
176                variables,
177                self.max_query_depth(),
178            )?
179            .into_iter()
180            .collect();
181        for path in active_filter_paths(definition, &arguments) {
182            let condition = crate::db::WhereClause::Field {
183                path,
184                operator: crate::db::WhereOperator::Eq,
185                value: serde_json::Value::Null,
186            };
187            refuse_unreadable_where(schema, type_name, &condition, principal)?;
188        }
189        self.plan_read(
190            &definition.name,
191            type_name,
192            root.nested_fields.clone(),
193            variables_map,
194            principal,
195        )
196    }
197
198    /// Plan a stream of `type_name`'s change events for one reader — the REST
199    /// `/{resource}/stream` (ruling AC 7): a read of the type through the selection a `GET`
200    /// of the resource with no `?select=` reads, every field the type declares, object
201    /// fields expanded as their type.
202    ///
203    /// # Errors
204    ///
205    /// `Validation` for a type nesting an object deeper than a whole-object read expands;
206    /// `Authorization` for the type's role and whatever the read plan refuses — a `Reject`
207    /// field the reader may not read refuses the stream, as it refuses the `GET`.
208    pub fn plan_type_stream(
209        &self,
210        type_name: &str,
211        principal: Option<&SecurityContext>,
212    ) -> Result<SubscriptionPlan> {
213        let selections = whole_object(&self.ctx.schema, type_name, 0)?;
214        // #422, as the resource's `GET` asks it: a root read of the type's own read.
215        if let Some(authorizer) = self.ctx.config.authorizer.as_ref() {
216            let name = own_read(&self.ctx.schema, type_name).map_or(type_name, |q| q.name.as_str());
217            let ops = [crate::security::AuthzOperation::root(
218                crate::security::OperationKind::Query,
219                name,
220                Some(type_name),
221            )];
222            crate::security::authorizer::enforce_authz(authorizer.as_ref(), principal, &ops, None)?;
223        }
224        self.refuse_unless_readable(type_name, type_name, principal)?;
225        self.plan_read(type_name, type_name, selections, HashMap::new(), principal)
226    }
227
228    /// Refuse `type_name` to a reader its own read refuses: that read's `requires_role` (else
229    /// the type's) and its `requires_actor` — the gates a nested level of the type applies.
230    fn refuse_unless_readable(
231        &self,
232        read: &str,
233        type_name: &str,
234        principal: Option<&SecurityContext>,
235    ) -> Result<()> {
236        match type_read_refusal(&self.ctx.schema, type_name, principal) {
237            None => Ok(()),
238            Some(why) => Err(FraiseQLError::Authorization {
239                message:  format!("'{read}' delivers '{type_name}', {why}"),
240                action:   Some("read".to_string()),
241                resource: Some(type_name.to_string()),
242            }),
243        }
244    }
245
246    /// The row predicate and the read plan of `selections` over `type_name`, for `read`.
247    fn plan_read(
248        &self,
249        read: &str,
250        type_name: &str,
251        selections: Vec<FieldSelection>,
252        variables: HashMap<String, serde_json::Value>,
253        principal: Option<&SecurityContext>,
254    ) -> Result<SubscriptionPlan> {
255        let ctx = &self.ctx;
256        let root_rows = RootRowFilter::plan(ctx, read, type_name, principal)?;
257        let bound = serde_json::Value::Object(variables.clone().into_iter().collect());
258        let plan = ReadPlan::classify(ctx, principal, Some(&bound), &[(type_name, &selections)])?;
259        Ok(SubscriptionPlan {
260            ctx: Arc::clone(ctx),
261            plan,
262            root_rows,
263            principal: principal.cloned(),
264            subscription: read.to_string(),
265            type_name: type_name.to_string(),
266            selections,
267            variables,
268        })
269    }
270}
271
272/// Refuse a selected field `type_name` does not declare, at every level, as `/graphql`
273/// validation does. A level whose type the schema does not describe as an object (a union or
274/// an interface, resolved per fragment) is left to its fragments.
275fn refuse_undeclared_selection(
276    schema: &CompiledSchema,
277    type_name: &str,
278    selections: &[FieldSelection],
279) -> Result<()> {
280    let Some(type_def) = schema.find_type(type_name) else {
281        return Ok(());
282    };
283    for sel in effective_selections(selections, type_name, schema) {
284        if sel.name == "__typename" {
285            continue;
286        }
287        let Some(field) = type_def.fields.iter().find(|f| f.name == sel.name) else {
288            return Err(FraiseQLError::Validation {
289                message: format!("Cannot query field '{}' on type '{type_name}'", sel.name),
290                path:    None,
291            });
292        };
293        if let Some(child) = field.field_type.inner_type().unwrap_or(&field.field_type).type_name()
294        {
295            refuse_undeclared_selection(schema, child, &sel.nested_fields)?;
296        }
297    }
298    Ok(())
299}
300
301/// Refuse a top-level field outside the subscription's compile-time field list, when it
302/// declares one: the list is an upper bound on what the subscription delivers.
303fn refuse_undelivered_selection(
304    definition: &SubscriptionDefinition,
305    selections: &[FieldSelection],
306) -> Result<()> {
307    if definition.fields.is_empty() {
308        return Ok(());
309    }
310    let delivered: Vec<&str> = definition
311        .fields
312        .iter()
313        .filter_map(|f| f.trim_start_matches('/').split(['/', '.']).next())
314        .collect();
315    match selections
316        .iter()
317        .find(|s| s.name != "__typename" && !delivered.contains(&s.name.as_str()))
318    {
319        Some(sel) => Err(FraiseQLError::Validation {
320            message: format!(
321                "Subscription '{}' does not deliver field '{}'",
322                definition.name, sel.name
323            ),
324            path:    None,
325        }),
326        None => Ok(()),
327    }
328}
329
330/// The stored-key paths the subscription's filters read for this subscriber: a
331/// `filter_fields` entry or an `argument_paths` entry whose argument the subscriber bound,
332/// and every static filter.
333fn active_filter_paths(
334    definition: &SubscriptionDefinition,
335    variables: &HashMap<String, serde_json::Value>,
336) -> Vec<Vec<String>> {
337    let bound = |argument: &str| variables.get(argument).is_some_and(|v| !v.is_null());
338    let segments = |path: &str| -> Vec<String> {
339        path.trim_start_matches('/')
340            .split(['/', '.'])
341            .filter(|s| !s.is_empty())
342            .map(crate::utils::to_snake_case)
343            .collect()
344    };
345    let mut paths: Vec<Vec<String>> = definition
346        .filter_fields
347        .iter()
348        .filter(|field| bound(field))
349        .map(|field| segments(field))
350        .collect();
351    if let Some(filter) = &definition.filter {
352        paths.extend(
353            filter
354                .argument_paths
355                .iter()
356                .filter(|(argument, _)| bound(argument))
357                .map(|(_, path)| segments(path)),
358        );
359        paths.extend(filter.static_filters.iter().map(|condition| segments(&condition.path)));
360    }
361    paths
362}
363
364#[cfg(test)]
365#[path = "subscription_plan_tests.rs"]
366mod tests;