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;