1use 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#[derive(Clone)]
36pub struct SecurityScope<'a> {
37 pub policy: &'a DataAccessPolicy,
39 pub mode: AuthorizationMode,
41 fingerprint: u64,
42}
43
44impl<'a> SecurityScope<'a> {
45 #[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 #[must_use]
57 pub fn fingerprint(&self) -> u64 {
58 self.fingerprint
59 }
60}
61
62#[derive(Debug, Error)]
64pub enum AuthzError {
65 #[error("authorization storage error: {0}")]
67 Storage(#[from] rushdb_storage::StorageError),
68 #[error("invalid authorization policy: {0}")]
70 InvalidPolicy(String),
71 #[error("authorized query failed: {0}")]
73 Query(#[from] rushdb_query::QueryError),
74 #[error("label `{0}` is outside the authorized domain")]
76 LabelDenied(String),
77 #[error("relationship type `{0}` is denied")]
79 RelDenied(String),
80 #[error("policy denied all candidates for this request")]
82 NoCandidates,
83}
84
85pub 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
101pub 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 let _ = lmpg;
136 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
152pub 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 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 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 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
246pub type QueryDirection = DirectionAlias;
248
249type DirectionAlias = rushdb_query::Direction;
250
251use std::sync::Arc;
254
255pub 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
276pub 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 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
339pub fn load_active_policy(lmpg: &Lmpg) -> Result<Option<ActivePolicy>, AuthzError> {
345 load_active_policy_in_view(&lmpg.store.read_view()?, lmpg.tenant)
346}
347
348pub fn current_generation(lmpg: &Lmpg) -> Result<u64, AuthzError> {
355 Ok(load_active_policy(lmpg)?.map_or(0, |active| active.generation))
356}
357
358pub 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
374pub 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
428pub struct ActiveAuthorizer {
431 store: Arc<rushdb_storage::Store>,
432 tenant: rushdb_types::ids::TenantId,
433}
434
435impl ActiveAuthorizer {
436 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}