Skip to main content

rushdb_types/
security.rs

1//! Canonical tenant policy payload shared by admission, replay and readers.
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4
5/// How strongly a tenant's data-access policy is enforced.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7pub enum AuthorizationMode {
8    /// Enforce tenant isolation only; no per-query policy filtering.
9    BoundaryOnly,
10    /// Apply the tenant's [`DataAccessPolicy`] to every query.
11    PolicyEnforced,
12}
13
14/// Tenant-scoped data-access restrictions applied to query results.
15#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct DataAccessPolicy {
18    /// Label allow-list, compared as label names are (case-insensitive,
19    /// see [`crate::value::normalize_name`]). `["*"]`
20    /// ([`ALL_LABELS`]) allows every label, including labels created later;
21    /// an empty list allows none, so an enforced policy without labels locks
22    /// the tenant's data (reads and writes).
23    pub allowed_labels: Vec<String>,
24    /// Relationship types that readers must not traverse.
25    pub denied_rel_types: Vec<String>,
26    /// Per label, the only properties a reader sees (an allow-list); a label
27    /// without an entry shows all of its properties.
28    pub visible_properties: BTreeMap<String, Vec<String>>,
29    /// Maximum number of rows a query may return.
30    pub max_results: u64,
31}
32
33/// The `allowedLabels` entry that allows every label.
34pub const ALL_LABELS: &str = "*";
35
36impl DataAccessPolicy {
37    /// Whether the allow-list holds [`ALL_LABELS`].
38    #[must_use]
39    pub fn allows_every_label(&self) -> bool {
40        self.allowed_labels
41            .iter()
42            .any(|allowed| allowed == ALL_LABELS)
43    }
44
45    /// Whether `label` is allowed: [`ALL_LABELS`] is listed, or a name equal
46    /// to `label` once normalized ([`crate::value::normalize_name`]). Every
47    /// read and write check of the allow-list goes through here.
48    #[must_use]
49    pub fn allows_label(&self, label: &str) -> bool {
50        let label = crate::value::normalize_name(label);
51        self.allowed_labels
52            .iter()
53            .any(|allowed| allowed == ALL_LABELS || crate::value::normalize_name(allowed) == label)
54    }
55}
56
57/// A policy together with the commit that published it.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct ActivePolicy {
61    /// Applying commit index: monotonic, but not necessarily consecutive.
62    pub generation: u64,
63    /// The active policy payload.
64    pub policy: DataAccessPolicy,
65    /// Enforcement strength for this policy.
66    pub mode: AuthorizationMode,
67    /// Stable digest of the policy payload and mode.
68    pub fingerprint: u64,
69    /// Smallest group a catalog read may report under this policy (POL-2
70    /// small-count suppression): labels, properties, values, relationship
71    /// shapes and join candidates counted over fewer records are withheld,
72    /// as are sample and min/max values. 0 (the default) withholds nothing.
73    #[serde(default, skip_serializing_if = "is_zero")]
74    pub min_count: u64,
75}
76
77#[expect(
78    clippy::trivially_copy_pass_by_ref,
79    reason = "serde's skip_serializing_if passes a reference"
80)]
81fn is_zero(value: &u64) -> bool {
82    *value == 0
83}
84
85/// Stable digest of a policy's semantic content and enforcement mode.
86///
87/// # Panics
88///
89/// Panics if the policy cannot be serialized to JSON, which cannot happen for
90/// the plain data types held by [`DataAccessPolicy`].
91#[must_use]
92pub fn policy_fingerprint(policy: &DataAccessPolicy, mode: AuthorizationMode) -> u64 {
93    let json = serde_json::json!({
94        "labels": policy.allowed_labels, "denied": policy.denied_rel_types,
95        "visible": policy.visible_properties, "max": policy.max_results,
96        "mode": format!("{mode:?}"),
97    });
98    crate::hash::stable_hash64(&serde_json::to_vec(&json).expect("policy JSON is serializable"))
99}
100
101/// [`policy_fingerprint`] covering a small-count threshold too; equal to it
102/// when `min_count` is 0, so a policy without one keeps its digest.
103///
104/// # Panics
105///
106/// Panics if the policy cannot be serialized to JSON, which cannot happen for
107/// the plain data types held by [`DataAccessPolicy`].
108#[must_use]
109pub fn policy_fingerprint_with_min_count(
110    policy: &DataAccessPolicy,
111    mode: AuthorizationMode,
112    min_count: u64,
113) -> u64 {
114    if min_count == 0 {
115        return policy_fingerprint(policy, mode);
116    }
117    let json = serde_json::json!({
118        "labels": policy.allowed_labels, "denied": policy.denied_rel_types,
119        "visible": policy.visible_properties, "max": policy.max_results,
120        "mode": format!("{mode:?}"), "minCount": min_count,
121    });
122    crate::hash::stable_hash64(&serde_json::to_vec(&json).expect("policy JSON is serializable"))
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn a_threshold_changes_the_fingerprint_only_when_set() {
131        let policy = DataAccessPolicy {
132            allowed_labels: vec!["A".into()],
133            ..DataAccessPolicy::default()
134        };
135        let mode = AuthorizationMode::PolicyEnforced;
136        assert_eq!(
137            policy_fingerprint_with_min_count(&policy, mode, 0),
138            policy_fingerprint(&policy, mode)
139        );
140        assert_ne!(
141            policy_fingerprint_with_min_count(&policy, mode, 5),
142            policy_fingerprint(&policy, mode)
143        );
144        // A stored policy without a threshold reads back as 0 and writes no field.
145        let active = ActivePolicy {
146            generation: 1,
147            policy,
148            mode,
149            fingerprint: 7,
150            min_count: 0,
151        };
152        let json = serde_json::to_value(&active).unwrap();
153        assert!(json.get("min_count").is_none());
154        let back: ActivePolicy = serde_json::from_value(json).unwrap();
155        assert_eq!(back.min_count, 0);
156    }
157
158    #[test]
159    fn the_allow_list_is_empty_for_none_star_for_all_and_ignores_case() {
160        let policy = |labels: &[&str]| DataAccessPolicy {
161            allowed_labels: labels.iter().map(ToString::to_string).collect(),
162            ..DataAccessPolicy::default()
163        };
164        assert!(!policy(&[]).allows_label("Note"));
165        assert!(!policy(&[]).allows_every_label());
166        assert!(policy(&["*"]).allows_label("Note"));
167        assert!(policy(&["Note", "*"]).allows_every_label());
168        assert!(policy(&["note"]).allows_label("Note"));
169        assert!(policy(&["first-name"]).allows_label("First_Name"));
170        assert!(!policy(&["Note"]).allows_label("Notes"));
171        assert!(!policy(&["Note"]).allows_every_label());
172    }
173}