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