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;