Skip to main content

fraiseql_core/schema/
subscription_policy.rs

1//! Row-level visibility policy for subscription delivery (#596).
2//!
3//! The pull path (GraphQL queries) enforces per-row RLS; historically the push paths
4//! (subscriptions) did not — any principal authorized to subscribe to an entity
5//! received **every** row's after-images. A [`SubscriptionPolicy`] closes that: at
6//! subscribe time it derives a **server-owned** owner condition from the connection's
7//! enriched identity (#539, the forge-proof `fraiseql.enriched.*` namespace), so a
8//! scoped subscriber only receives rows it owns.
9//!
10//! # One derivation, two seams
11//!
12//! [`SubscriptionPolicy::derive`] returns a seam-neutral [`OwnerCondition`]. Both push
13//! seams are thin adapters over this single function:
14//! - the graphql `/ws` path maps [`OwnerCondition::Eq`] to a `(field, value)` RLS condition on
15//!   `subscribe_with_rls`;
16//! - the realtime entity-stream maps it to a server-owned equality `FieldFilter`.
17//!
18//! Keeping the semantics (owner-path resolution, `bypass_roles`, unresolvable-identity
19//! refusal) in *one* place is a security property: if the two seams grew their own
20//! interpretations they would drift, and a divergence — e.g. `bypass_roles` honored on
21//! one path but not the other — is itself a visibility bypass.
22//!
23//! It is **fail-closed for declaring entities**: a policy present but the identity field
24//! unresolvable (no enrichment configured, NULL/absent field) yields
25//! [`OwnerCondition::Refuse`], which the seam turns into a refused subscription rather
26//! than delivering everything.
27
28use std::collections::HashMap;
29
30use serde::{Deserialize, Serialize};
31use serde_json::Value;
32
33use crate::security::ENRICHED_NAMESPACE_PREFIX;
34
35/// An entity's row-visibility policy for subscription delivery (#596).
36///
37/// Declared per entity in the compiled schema
38/// ([`TypeDefinition::subscription_policy`](super::TypeDefinition)):
39///
40/// ```jsonc
41/// "subscription_policy": {
42///   "owner_path": "$.owner_id",      // single-level path into the after-image
43///   "identity_field": "user_id",     // the fraiseql.enriched.* field (#539)
44///   "bypass_roles": ["admin"]        // roles that get full visibility
45/// }
46/// ```
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct SubscriptionPolicy {
50    /// The single-level JSON path into the after-image holding the owner id
51    /// (e.g. `"$.owner_id"`). Only `$.<field>` is supported — the delivery matchers
52    /// key on a flat field name.
53    pub owner_path:     String,
54    /// The enriched-identity field (in the `fraiseql.enriched.*` namespace, #539)
55    /// whose value the owner must equal (e.g. `"user_id"`).
56    pub identity_field: String,
57    /// Roles that bypass the policy entirely (full visibility, e.g. `["admin"]`).
58    #[serde(default, skip_serializing_if = "Vec::is_empty")]
59    pub bypass_roles:   Vec<String>,
60}
61
62/// The server-owned owner condition derived for one subscribe request — seam-neutral.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum OwnerCondition {
65    /// The principal holds a bypass role — full visibility, no added condition.
66    Bypass,
67    /// A server-owned equality condition: the principal sees only rows where
68    /// `field == value`.
69    Eq {
70        /// The flat owner field name (the delivery matcher keys on it).
71        field: String,
72        /// The principal's server-resolved identity value.
73        value: Value,
74    },
75    /// **Fail-closed**: the policy applies but the enriched identity is unresolvable
76    /// (no enrichment configured, or a NULL/absent field) — refuse the subscription.
77    Refuse(String),
78}
79
80impl SubscriptionPolicy {
81    /// The flat owner field name the delivery matchers key on — `owner_path` with a
82    /// leading `$.` stripped.
83    #[must_use]
84    pub fn owner_field(&self) -> &str {
85        self.owner_path.trim_start_matches("$.")
86    }
87
88    /// Validate the policy at load time.
89    ///
90    /// # Errors
91    ///
92    /// - `owner_path` is empty or a nested path (only single-level `$.<field>`).
93    /// - `identity_field` is empty.
94    pub fn validate(&self) -> Result<(), String> {
95        if self.identity_field.is_empty() {
96            return Err("subscription_policy.identity_field must not be empty".to_string());
97        }
98        let field = self.owner_field();
99        if field.is_empty() {
100            return Err("subscription_policy.owner_path must name a field (e.g. \"$.owner_id\")"
101                .to_string());
102        }
103        if field.contains('.') || field.contains('[') {
104            return Err(format!(
105                "subscription_policy.owner_path `{}` must be a single-level `$.<field>` — nested \
106                 paths are not supported by the delivery matchers",
107                self.owner_path
108            ));
109        }
110        Ok(())
111    }
112
113    /// Derive the owner condition for a connection whose enriched identity is in
114    /// `attributes` and whose roles are `roles` (#596). Bypass role →
115    /// [`Bypass`](OwnerCondition::Bypass); a resolvable identity →
116    /// [`Eq`](OwnerCondition::Eq); otherwise **fail-closed**
117    /// [`Refuse`](OwnerCondition::Refuse).
118    ///
119    /// The identity value is read **only** from the server-resolved
120    /// `fraiseql.enriched.*` namespace — the #539 resolver strips any inbound
121    /// `fraiseql.*` claims, so a client-supplied plain attribute cannot widen
122    /// visibility.
123    #[must_use]
124    pub fn derive(&self, attributes: &HashMap<String, Value>, roles: &[String]) -> OwnerCondition {
125        if self.bypass_roles.iter().any(|bypass| roles.iter().any(|role| role == bypass)) {
126            return OwnerCondition::Bypass;
127        }
128        let key = format!("{ENRICHED_NAMESPACE_PREFIX}{}", self.identity_field);
129        match attributes.get(&key) {
130            Some(value) if !value.is_null() => OwnerCondition::Eq {
131                field: self.owner_field().to_string(),
132                value: value.clone(),
133            },
134            _ => OwnerCondition::Refuse(format!(
135                "subscription refused (fail-closed): enriched identity field `{}` is unresolvable \
136                 — configure `[identity.enrichment]` so the owner boundary can be derived",
137                self.identity_field
138            )),
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests;