Skip to main content

rushdb_authz/
lib.rs

1//! Graph-aware data access policies layered above the hard tenant boundary
2//! (plan document 23). Policies are compiled once into a `SecurityScope` with
3//! a stable fingerprint; enforcement is default-deny, evaluated by callers
4//! over `MatchScope` candidates and mutation images, and can only narrow the
5//! domain — never widen it.
6
7use rushdb_lmpg::Lmpg;
8use serde_json::Value as Json;
9use thiserror::Error;
10
11use rushdb_types::security::{policy_fingerprint, policy_fingerprint_with_min_count};
12pub use rushdb_types::security::{ActivePolicy, AuthorizationMode, DataAccessPolicy};
13
14type PolicyReader<'a> = dyn Fn(&[u8]) -> Result<Option<Vec<u8>>, rushdb_storage::StorageError> + 'a;
15
16trait PolicyAccess {
17    fn label_allowed(&self, mode: AuthorizationMode, label: &str) -> bool;
18    fn rel_allowed(&self, rel_type: &str) -> bool;
19}
20
21impl PolicyAccess for DataAccessPolicy {
22    fn label_allowed(&self, mode: AuthorizationMode, label: &str) -> bool {
23        match mode {
24            AuthorizationMode::BoundaryOnly => true,
25            AuthorizationMode::PolicyEnforced => self.allows_label(label),
26        }
27    }
28
29    fn rel_allowed(&self, rel_type: &str) -> bool {
30        !self.denied_rel_types.iter().any(|t| t == rel_type)
31    }
32}
33
34/// Compiled security scope bound to one policy generation.
35#[derive(Clone)]
36pub struct SecurityScope<'a> {
37    /// Policy supplying the label grants, relationship denials, and masks.
38    pub policy: &'a DataAccessPolicy,
39    /// Mode controlling how strictly the policy is applied.
40    pub mode: AuthorizationMode,
41    fingerprint: u64,
42}
43
44impl<'a> SecurityScope<'a> {
45    /// Compiles `policy` under `mode` and records its stable fingerprint.
46    #[must_use]
47    pub fn compile(policy: &'a DataAccessPolicy, mode: AuthorizationMode) -> Self {
48        SecurityScope {
49            policy,
50            mode,
51            fingerprint: policy_fingerprint(policy, mode),
52        }
53    }
54
55    /// Returns the stable fingerprint identifying this policy generation.
56    #[must_use]
57    pub fn fingerprint(&self) -> u64 {
58        self.fingerprint
59    }
60}
61
62/// Errors produced while compiling or enforcing data access policies.
63#[derive(Debug, Error)]
64pub enum AuthzError {
65    /// The storage layer failed while reading policy state.
66    #[error("authorization storage error: {0}")]
67    Storage(#[from] rushdb_storage::StorageError),
68    /// Stored policy metadata is malformed or internally inconsistent.
69    #[error("invalid authorization policy: {0}")]
70    InvalidPolicy(String),
71    /// The authorized query failed while executing against the engine.
72    #[error("authorized query failed: {0}")]
73    Query(#[from] rushdb_query::QueryError),
74    /// The requested label lies outside the authorized domain.
75    #[error("label `{0}` is outside the authorized domain")]
76    LabelDenied(String),
77    /// The requested relationship type is explicitly denied.
78    #[error("relationship type `{0}` is denied")]
79    RelDenied(String),
80    /// No candidate matched the intersection of request and grants.
81    #[error("policy denied all candidates for this request")]
82    NoCandidates,
83}
84
85/// Mutation-side check over the new image (old-image revalidation happens
86/// at commit inside the engine's own preconditions).
87///
88/// # Errors
89///
90/// Returns [`AuthzError::LabelDenied`] when the label is not granted in
91/// [`AuthorizationMode::PolicyEnforced`] mode.
92pub fn authorize_mutation(scope: &SecurityScope<'_>, label: &str) -> Result<(), AuthzError> {
93    if matches!(scope.mode, AuthorizationMode::PolicyEnforced)
94        && !scope.policy.label_allowed(scope.mode, label)
95    {
96        return Err(AuthzError::LabelDenied(label.to_string()));
97    }
98    Ok(())
99}
100
101/// Rejects traversal through a relationship type the policy denies.
102///
103/// # Errors
104///
105/// Returns [`AuthzError::RelDenied`] when the relationship type is denied.
106pub fn authorize_traversal_type(
107    scope: &SecurityScope<'_>,
108    rel_type: &str,
109) -> Result<(), AuthzError> {
110    if scope.policy.rel_allowed(rel_type) {
111        Ok(())
112    } else {
113        Err(AuthzError::RelDenied(rel_type.to_string()))
114    }
115}
116
117fn mask_record_json(lmpg: &Lmpg, record: &mut Json, scope: &SecurityScope<'_>) {
118    let Some(obj) = record.as_object_mut() else {
119        return;
120    };
121    let raw_label = obj
122        .get("__label")
123        .and_then(|l| l.as_str())
124        .unwrap_or("")
125        .to_string();
126    let label_norm = normalize(&raw_label);
127    if matches!(scope.mode, AuthorizationMode::PolicyEnforced)
128        && !scope.policy.allows_label(&raw_label)
129    {
130        obj.clear();
131        return;
132    }
133    // enforce relationship-type grants through adjacency? traversal types
134    // are checked eagerly by callers via authorize_traversal_type.
135    let _ = lmpg;
136    // masks are keyed by normalized label name (case-insensitive grants)
137    let masked_fields = scope
138        .policy
139        .visible_properties
140        .iter()
141        .find(|(k, _)| normalize(k) == label_norm)
142        .map(|(_, v)| v.clone());
143    if let Some(masked) = masked_fields {
144        obj.retain(|k, _| masked.iter().any(|m| normalize(k) == normalize(m)));
145    }
146}
147
148fn normalize(name: &str) -> String {
149    rushdb_types::value::normalize_name(name)
150}
151
152/// Authorized query execution (Records perspective): scans, masks output,
153/// enforces result caps. Tenant scoping underneath is structural.
154///
155/// # Errors
156///
157/// Returns [`AuthzError::RelDenied`] or [`AuthzError::NoCandidates`] when the
158/// request falls outside the authorized domain, and propagates
159/// [`AuthzError::Query`] from the underlying query engine.
160pub fn find_records_authorized(
161    lmpg: &Lmpg,
162    req: &rushdb_query::SearchRequest,
163    scope: &SecurityScope<'_>,
164) -> Result<rushdb_query::Page, AuthzError> {
165    use rushdb_query::Page;
166
167    // default-deny: explicit labels required in enforced mode
168    let allowed_labels = match scope.mode {
169        AuthorizationMode::BoundaryOnly => req.labels.clone(),
170        AuthorizationMode::PolicyEnforced => {
171            let requested: Vec<String> = if req.labels.is_empty() {
172                // "all labels" request intersects the granted set: the labels
173                // present that it allows, as stored (under `*`, every label:
174                // left unnarrowed below).
175                rushdb_lmpg::reader::try_access::labels(lmpg.store.as_ref(), lmpg.tenant)?
176                    .into_iter()
177                    .filter(|label| scope.policy.allows_label(label))
178                    .collect()
179            } else {
180                req.labels
181                    .iter()
182                    .filter(|l| scope.policy.label_allowed(scope.mode, l))
183                    .cloned()
184                    .collect()
185            };
186            requested
187        }
188    };
189
190    if let AuthorizationMode::PolicyEnforced = scope.mode {
191        if let Some(spec) = req.related.first() {
192            if let Some(rt) = &spec.rel_type {
193                if !scope.policy.rel_allowed(rt) {
194                    return Err(AuthzError::RelDenied(rt.clone()));
195                }
196            }
197        }
198    }
199
200    // Under `*` an "all labels" request stays one.
201    let every = matches!(scope.mode, AuthorizationMode::PolicyEnforced)
202        && req.labels.is_empty()
203        && scope.policy.allows_every_label();
204    let allowed_labels = if every { Vec::new() } else { allowed_labels };
205    let effective = rushdb_query::SearchRequest {
206        labels: allowed_labels.clone(),
207        related: sanitize_related(req.related.as_slice(), scope),
208        ..req.clone()
209    };
210
211    if matches!(scope.mode, AuthorizationMode::PolicyEnforced)
212        && allowed_labels.is_empty()
213        && !every
214    {
215        return Err(AuthzError::NoCandidates);
216    }
217
218    let page = rushdb_query::find_records(lmpg, &effective)?;
219
220    let mut records = page.records;
221    for r in &mut records {
222        mask_record_json(lmpg, r, scope);
223    }
224    records.retain(|r| !r.as_object().is_some_and(serde_json::Map::is_empty));
225    if scope.policy.max_results > 0 {
226        records.truncate(usize::try_from(scope.policy.max_results).unwrap_or(usize::MAX));
227    }
228    let total = records.len() as u64;
229    Ok(Page {
230        total_matched: total,
231        records,
232    })
233}
234
235fn sanitize_related(
236    specs: &[rushdb_query::RelatedSpec],
237    scope: &SecurityScope<'_>,
238) -> Vec<rushdb_query::RelatedSpec> {
239    specs
240        .iter()
241        .filter(|s| s.rel_type.iter().all(|rt| scope.policy.rel_allowed(rt)))
242        .cloned()
243        .collect()
244}
245
246/// Exposes Direction / `CollectSpec` passthrough to keep engine facade tidy.
247pub type QueryDirection = DirectionAlias;
248
249type DirectionAlias = rushdb_query::Direction;
250
251// ---- replicated policy persistence + engine authorizer (document 23) ----
252
253use std::sync::Arc;
254
255/// Read one complete policy generation from one retained native view.
256///
257/// # Errors
258///
259/// Propagates storage failures and returns [`AuthzError::InvalidPolicy`] when
260/// the stored generation/fingerprint pair does not validate.
261pub fn load_active_policy_in_view(
262    view: &rushdb_storage::ReadView,
263    tenant: rushdb_types::ids::TenantId,
264) -> Result<Option<ActivePolicy>, AuthzError> {
265    load_policy_from(view, tenant, view.commit_index())
266}
267
268fn load_policy_from<R: rushdb_lmpg::reader::TryRead>(
269    view: &R,
270    tenant: rushdb_types::ids::TenantId,
271    cap: u64,
272) -> Result<Option<ActivePolicy>, AuthzError> {
273    load_policy_from_reader(&|key: &[u8]| view.get(key), tenant, cap)
274}
275
276/// Policy loader over an injected key-reader. Used by the coordinator's
277/// commit-time revalidation so it never opens a nested store view (which
278/// would deadlock under the store's publish lock).
279///
280/// # Errors
281///
282/// Propagates reader failures and returns [`AuthzError::InvalidPolicy`] when
283/// stored metadata is malformed or uses the removed global policy layout.
284pub fn load_policy_from_reader(
285    read: &PolicyReader<'_>,
286    tenant: rushdb_types::ids::TenantId,
287    cap: u64,
288) -> Result<Option<ActivePolicy>, AuthzError> {
289    use rushdb_types::keys::{
290        security_generation_key, security_policy_key, SECURITY_GENERATION_KEY,
291        SECURITY_POLICY_ACTIVE_KEY,
292    };
293    let raw_generation = read(&security_generation_key(tenant))?;
294    let raw_policy = read(&security_policy_key(tenant))?;
295    match (raw_generation, raw_policy) {
296        (None, None) => {
297            // Global policy metadata has no tenant ownership in the current
298            // format. Reject it rather than silently widening access.
299            if read(SECURITY_GENERATION_KEY.as_bytes())?.is_some()
300                || read(SECURITY_POLICY_ACTIVE_KEY.as_bytes())?.is_some()
301            {
302                return Err(AuthzError::InvalidPolicy(
303                    "unsupported global authorization policy metadata".into(),
304                ));
305            }
306            Ok(None)
307        }
308        (Some(generation), Some(raw)) => {
309            let generation = u64::from_be_bytes(
310                generation
311                    .as_slice()
312                    .try_into()
313                    .map_err(|_| AuthzError::InvalidPolicy("invalid generation width".into()))?,
314            );
315            let active: ActivePolicy = serde_json::from_slice(&raw)
316                .map_err(|e| AuthzError::InvalidPolicy(e.to_string()))?;
317            if generation == 0
318                || generation > cap
319                || active.generation != generation
320                || active.fingerprint
321                    != policy_fingerprint_with_min_count(
322                        &active.policy,
323                        active.mode,
324                        active.min_count,
325                    )
326            {
327                return Err(AuthzError::InvalidPolicy(
328                    "generation/fingerprint mismatch".into(),
329                ));
330            }
331            Ok(Some(active))
332        }
333        _ => Err(AuthzError::InvalidPolicy(
334            "incomplete policy/generation pair".into(),
335        )),
336    }
337}
338
339/// Loads the active policy for `lmpg`'s tenant from live committed state.
340///
341/// # Errors
342///
343/// Propagates storage failures and policy validation errors.
344pub fn load_active_policy(lmpg: &Lmpg) -> Result<Option<ActivePolicy>, AuthzError> {
345    load_active_policy_in_view(&lmpg.store.read_view()?, lmpg.tenant)
346}
347
348/// Returns the committed policy generation for `lmpg`'s tenant, or `0` when
349/// no policy is active.
350///
351/// # Errors
352///
353/// Propagates storage failures and policy validation errors.
354pub fn current_generation(lmpg: &Lmpg) -> Result<u64, AuthzError> {
355    Ok(load_active_policy(lmpg)?.map_or(0, |active| active.generation))
356}
357
358/// Explicit administrative policy replacement. New tenant metadata overrides
359/// legacy global metadata for this tenant only; no legacy bytes are changed.
360/// The applying CI is assigned under admission, not before entering it.
361///
362/// # Errors
363///
364/// Returns the storage error or commit-conflict reason as a string when the
365/// administrative commit is rejected.
366pub fn store_policy(
367    lmpg: &Lmpg,
368    policy: &DataAccessPolicy,
369    mode: AuthorizationMode,
370) -> Result<ActivePolicy, String> {
371    store_policy_with_min_count(lmpg, policy, mode, 0)
372}
373
374/// [`store_policy`] with a small-count threshold for catalog reads:
375/// groups counted over fewer than `min_count` records are withheld. 0 is no
376/// threshold and publishes exactly what [`store_policy`] does.
377///
378/// # Errors
379///
380/// Returns the storage error or commit-conflict reason as a string when the
381/// administrative commit is rejected.
382pub fn store_policy_with_min_count(
383    lmpg: &Lmpg,
384    policy: &DataAccessPolicy,
385    mode: AuthorizationMode,
386    min_count: u64,
387) -> Result<ActivePolicy, String> {
388    let tenant = lmpg.tenant;
389    let policy = policy.clone();
390    let outcome = lmpg
391        .store
392        .commit_for_tenant(&tenant, uuid::Uuid::now_v7(), move |ci, _txn| {
393            let active = ActivePolicy {
394                generation: ci,
395                fingerprint: policy_fingerprint_with_min_count(&policy, mode, min_count),
396                policy: policy.clone(),
397                mode,
398                min_count,
399            };
400            let mutation = if min_count == 0 {
401                rushdb_types::command::Mutation::SetSecurityPolicy { policy, mode }
402            } else {
403                rushdb_types::command::Mutation::SetSecurityPolicyThreshold {
404                    policy,
405                    mode,
406                    min_count,
407                }
408            };
409            let batch = rushdb_types::command::CommitBatch {
410                command_version: rushdb_types::command::COMMAND_VERSION,
411                tenant,
412                transaction_id: String::new(),
413                commit_key: uuid::Uuid::nil(),
414                start_index: 0,
415                isolation: rushdb_types::command::IsolationMode::Snapshot,
416                preconditions: vec![],
417                mutations: vec![mutation],
418            };
419            Ok((batch, active))
420        })
421        .map_err(|e| e.to_string())?;
422    match outcome {
423        rushdb_storage::CommitOutcome::Committed { payload, .. } => Ok(payload),
424        rushdb_storage::CommitOutcome::Conflicted(reason) => Err(reason),
425    }
426}
427
428/// Engine authorizer reading live committed policy state (default-deny in
429/// `PolicyEnforced` mode). Implemented against the replicated keyspace.
430pub struct ActiveAuthorizer {
431    store: Arc<rushdb_storage::Store>,
432    tenant: rushdb_types::ids::TenantId,
433}
434
435impl ActiveAuthorizer {
436    /// Creates an authorizer that reads policy for `tenant` from `store`.
437    pub fn new(store: Arc<rushdb_storage::Store>, tenant: rushdb_types::ids::TenantId) -> Self {
438        ActiveAuthorizer { store, tenant }
439    }
440
441    fn active(&self) -> Result<Option<ActivePolicy>, String> {
442        let view = self.store.read_view().map_err(|e| e.to_string())?;
443        load_active_policy_in_view(&view, self.tenant).map_err(|e| e.to_string())
444    }
445}
446
447impl rushdb_lmpg::Authorizer for ActiveAuthorizer {
448    fn authorize_label(&self, label: &str) -> Result<(), String> {
449        let Some(ap) = self.active()? else {
450            return Ok(());
451        };
452        if ap.mode != AuthorizationMode::PolicyEnforced {
453            return Ok(());
454        }
455        if !ap.policy.allows_label(label) {
456            return Err(format!("label `{label}` not authorized"));
457        }
458        Ok(())
459    }
460
461    fn authorize_rel_type(&self, rel_type: &str) -> Result<(), String> {
462        let Some(ap) = self.active()? else {
463            return Ok(());
464        };
465        if ap.policy.denied_rel_types.iter().any(|t| t == rel_type) {
466            return Err(format!("relationship type `{rel_type}` denied"));
467        }
468        Ok(())
469    }
470
471    fn current_generation(&self) -> Result<u64, String> {
472        Ok(self.active()?.map_or(0, |active| active.generation))
473    }
474
475    fn authorize_label_in_txn(
476        &self,
477        label: &str,
478        txn: &rushdb_storage::Txn<'_>,
479        cap: u64,
480    ) -> Result<(), String> {
481        let active = load_policy_from(txn, self.tenant, cap).map_err(|e| e.to_string())?;
482        if active.as_ref().is_some_and(|ap| {
483            ap.mode == AuthorizationMode::PolicyEnforced && !ap.policy.allows_label(label)
484        }) {
485            return Err(format!("label `{label}` not authorized"));
486        }
487        Ok(())
488    }
489
490    fn authorize_rel_type_in_txn(
491        &self,
492        rel_type: &str,
493        txn: &rushdb_storage::Txn<'_>,
494        cap: u64,
495    ) -> Result<(), String> {
496        let active = load_policy_from(txn, self.tenant, cap).map_err(|e| e.to_string())?;
497        if active.as_ref().is_some_and(|ap| {
498            ap.policy
499                .denied_rel_types
500                .iter()
501                .any(|denied| denied == rel_type)
502        }) {
503            return Err(format!("relationship type `{rel_type}` denied"));
504        }
505        Ok(())
506    }
507
508    fn current_generation_in_txn(
509        &self,
510        txn: &rushdb_storage::Txn<'_>,
511        cap: u64,
512    ) -> Result<u64, String> {
513        Ok(load_policy_from(txn, self.tenant, cap)
514            .map_err(|e| e.to_string())?
515            .map_or(0, |ap| ap.generation))
516    }
517}