1use std::collections::HashMap;
27use std::path::Path;
28use std::sync::Arc;
29
30use parking_lot::RwLock;
31use serde::{Deserialize, Serialize};
32
33#[cfg(feature = "cedar")]
34use cedar_policy::{
35 Authorizer, Context, Decision as CedarDecision, Entities, Entity, EntityId, EntityTypeName,
36 EntityUid, PolicySet, Request, Schema, ValidationMode,
37};
38
39use crate::error::PolicyError;
40
41pub const DEFAULT_SCHEMA: &str = include_str!("cedar/hirn.cedarschema");
43
44pub const DEFAULT_OPEN_POLICY: &str = include_str!("cedar/default.cedar");
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum Action {
51 Remember,
52 Correct,
53 Supersede,
54 Merge,
55 Retract,
56 Purge,
57 Recall,
58 Think,
59 Forget,
60 Consolidate,
61 Watch,
62 Connect,
63 Execute,
64 Admin,
65 RecallRawText,
66 Read,
67 Write,
68 Delete,
69}
70
71impl Action {
72 #[must_use]
74 pub const fn as_str(self) -> &'static str {
75 match self {
76 Self::Remember => "remember",
77 Self::Correct => "correct",
78 Self::Supersede => "supersede",
79 Self::Merge => "merge",
80 Self::Retract => "retract",
81 Self::Purge => "purge",
82 Self::Recall => "recall",
83 Self::Think => "think",
84 Self::Forget => "forget",
85 Self::Consolidate => "consolidate",
86 Self::Watch => "watch",
87 Self::Connect => "connect",
88 Self::Execute => "execute",
89 Self::Admin => "admin",
90 Self::RecallRawText => "recall_raw_text",
91 Self::Read => "read",
92 Self::Write => "write",
93 Self::Delete => "delete",
94 }
95 }
96}
97
98impl std::fmt::Display for Action {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 f.write_str(self.as_str())
101 }
102}
103
104impl std::str::FromStr for Action {
105 type Err = PolicyError;
106
107 fn from_str(s: &str) -> Result<Self, Self::Err> {
108 match s.to_ascii_lowercase().as_str() {
109 "remember" => Ok(Self::Remember),
110 "correct" => Ok(Self::Correct),
111 "supersede" => Ok(Self::Supersede),
112 "merge" => Ok(Self::Merge),
113 "retract" => Ok(Self::Retract),
114 "purge" => Ok(Self::Purge),
115 "recall" => Ok(Self::Recall),
116 "think" => Ok(Self::Think),
117 "forget" => Ok(Self::Forget),
118 "consolidate" => Ok(Self::Consolidate),
119 "watch" => Ok(Self::Watch),
120 "connect" => Ok(Self::Connect),
121 "execute" => Ok(Self::Execute),
122 "admin" => Ok(Self::Admin),
123 "recall_raw_text" => Ok(Self::RecallRawText),
124 "read" => Ok(Self::Read),
125 "write" => Ok(Self::Write),
126 "delete" => Ok(Self::Delete),
127 _ => Err(PolicyError::InvalidAction(s.to_string())),
128 }
129 }
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct AuthzDecision {
135 pub allowed: bool,
137 pub policy_ids: Vec<String>,
139 pub reasons: Vec<String>,
141 pub errors: Vec<String>,
143}
144
145impl AuthzDecision {
146 pub fn allow() -> Self {
148 Self {
149 allowed: true,
150 policy_ids: Vec::new(),
151 reasons: Vec::new(),
152 errors: Vec::new(),
153 }
154 }
155
156 pub fn deny(reason: impl Into<String>) -> Self {
158 Self {
159 allowed: false,
160 policy_ids: Vec::new(),
161 reasons: vec![reason.into()],
162 errors: Vec::new(),
163 }
164 }
165}
166
167#[derive(Debug, Clone)]
169pub struct AuthzRequest {
170 pub agent_id: String,
172 pub action: Action,
174 pub realm: String,
176 pub namespace: String,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
182pub enum EntityKind {
183 Agent {
184 reputation: i64,
185 created_at: String,
186 teams: Vec<String>,
187 },
188 Team {
189 description: String,
190 organization: Option<String>,
191 },
192 Organization {
193 description: String,
194 },
195 Realm {
196 description: String,
197 },
198 Namespace {
199 classification: String,
200 realm: String,
201 },
202 MemoryLayer {
203 description: String,
204 },
205 Operation {
206 description: String,
207 },
208 Tool {
209 description: String,
210 },
211}
212
213pub struct PolicyEngine {
219 inner: Arc<RwLock<PolicyEngineInner>>,
220}
221
222struct PolicyEngineInner {
223 enabled: bool,
225 entities: HashMap<String, EntityKind>,
227 policy_sources: HashMap<String, String>,
229 schema_text: String,
231 #[cfg(feature = "cedar")]
233 cedar: Option<CedarState>,
234}
235
236#[derive(Clone)]
237struct PolicyEngineDraft {
238 enabled: bool,
239 entities: HashMap<String, EntityKind>,
240 policy_sources: HashMap<String, String>,
241 schema_text: String,
242}
243
244impl From<&PolicyEngineInner> for PolicyEngineDraft {
245 fn from(inner: &PolicyEngineInner) -> Self {
246 Self {
247 enabled: inner.enabled,
248 entities: inner.entities.clone(),
249 policy_sources: inner.policy_sources.clone(),
250 schema_text: inner.schema_text.clone(),
251 }
252 }
253}
254
255#[cfg(feature = "cedar")]
256struct CedarState {
257 schema: Schema,
258 policy_set: PolicySet,
259 entities: Entities,
260}
261
262impl PolicyEngine {
263 pub fn new(schema_text: &str, policies: &[(&str, &str)]) -> Result<Self, PolicyError> {
268 let mut policy_sources = HashMap::new();
269 for &(name, text) in policies {
270 policy_sources.insert(name.to_string(), text.to_string());
271 }
272
273 let inner = PolicyEngineInner {
274 enabled: true,
275 entities: HashMap::new(),
276 policy_sources,
277 schema_text: schema_text.to_string(),
278 #[cfg(feature = "cedar")]
279 cedar: None,
280 };
281
282 let engine = Self {
283 inner: Arc::new(RwLock::new(inner)),
284 };
285
286 #[cfg(feature = "cedar")]
287 engine.rebuild_cedar()?;
288
289 Ok(engine)
290 }
291
292 #[must_use]
303 pub fn open_mode() -> Self {
304 tracing::error!(
305 "PolicyEngine::open_mode — ALL authorization requests PERMITTED without evaluation. \
306 This is NOT safe for production. Set a `policies_dir` to enable Cedar policy enforcement."
307 );
308 let inner = PolicyEngineInner {
309 enabled: false,
310 entities: HashMap::new(),
311 policy_sources: HashMap::new(),
312 schema_text: String::new(),
313 #[cfg(feature = "cedar")]
314 cedar: None,
315 };
316
317 Self {
318 inner: Arc::new(RwLock::new(inner)),
319 }
320 }
321
322 pub fn load_from_brain(brain_dir: &Path) -> Result<Self, PolicyError> {
331 Self::load_from_brain_inner(brain_dir, false)
332 }
333
334 pub fn load_from_brain_insecure_dev_mode(brain_dir: &Path) -> Result<Self, PolicyError> {
340 tracing::error!(
341 brain_dir = %brain_dir.display(),
342 "PolicyEngine::load_from_brain_insecure_dev_mode — falling back to OPEN mode when \
343 no Cedar policies are found. This is NOT safe for production."
344 );
345 Self::load_from_brain_inner(brain_dir, true)
346 }
347
348 fn load_from_brain_inner(
349 brain_dir: &Path,
350 allow_default_open_policy: bool,
351 ) -> Result<Self, PolicyError> {
352 let policies_dir = brain_dir.join("policies");
353
354 let schema_path = policies_dir.join("hirn.cedarschema");
355 let schema_text = if schema_path.exists() {
356 std::fs::read_to_string(&schema_path).map_err(|e| PolicyError::Io {
357 path: schema_path.display().to_string(),
358 reason: e.to_string(),
359 })?
360 } else {
361 DEFAULT_SCHEMA.to_string()
362 };
363
364 let mut policy_files: Vec<(String, String)> = Vec::new();
365 if policies_dir.exists() {
366 let entries = std::fs::read_dir(&policies_dir).map_err(|e| PolicyError::Io {
367 path: policies_dir.display().to_string(),
368 reason: e.to_string(),
369 })?;
370 for entry in entries {
371 let entry = entry.map_err(|e| PolicyError::Io {
372 path: policies_dir.display().to_string(),
373 reason: e.to_string(),
374 })?;
375 let path = entry.path();
376 if path.extension().is_some_and(|ext| ext == "cedar") {
377 let name = path
378 .file_name()
379 .unwrap_or_default()
380 .to_string_lossy()
381 .to_string();
382 let text = std::fs::read_to_string(&path).map_err(|e| PolicyError::Io {
383 path: path.display().to_string(),
384 reason: e.to_string(),
385 })?;
386 policy_files.push((name, text));
387 }
388 }
389 }
390
391 if policy_files.is_empty() {
392 if !allow_default_open_policy {
393 return Err(PolicyError::MissingPolicies {
394 path: policies_dir.display().to_string(),
395 });
396 }
397 policy_files.push(("default.cedar".to_string(), DEFAULT_OPEN_POLICY.to_string()));
398 }
399
400 let refs: Vec<(&str, &str)> = policy_files
401 .iter()
402 .map(|(n, t)| (n.as_str(), t.as_str()))
403 .collect();
404
405 Self::new(&schema_text, &refs)
406 }
407
408 #[must_use]
410 pub fn is_open_mode(&self) -> bool {
411 !self.inner.read().enabled
412 }
413
414 #[must_use]
416 pub fn is_enabled(&self) -> bool {
417 self.inner.read().enabled
418 }
419
420 #[must_use]
422 pub fn policy_count(&self) -> usize {
423 #[cfg(feature = "cedar")]
424 {
425 let guard = self.inner.read();
426 guard
427 .cedar
428 .as_ref()
429 .map_or(0, |c| c.policy_set.policies().count())
430 }
431 #[cfg(not(feature = "cedar"))]
432 {
433 0
434 }
435 }
436
437 #[must_use]
439 pub fn entity_count(&self) -> usize {
440 self.inner.read().entities.len()
441 }
442
443 #[must_use]
445 pub fn registered_namespaces(&self) -> Vec<(String, String)> {
446 let guard = self.inner.read();
447 guard
448 .entities
449 .iter()
450 .filter_map(|(key, kind)| {
451 if let EntityKind::Namespace { realm, .. } = kind {
452 let id = key
453 .strip_prefix("Hirn::Namespace::\"")
454 .and_then(|s| s.strip_suffix('"'))
455 .unwrap_or(key);
456 Some((id.to_string(), realm.clone()))
457 } else {
458 None
459 }
460 })
461 .collect()
462 }
463
464 fn update_state<R>(
465 &self,
466 mutate: impl FnOnce(&mut PolicyEngineDraft) -> R,
467 ) -> Result<R, PolicyError> {
468 let mut guard = self.inner.write();
469 let mut draft = PolicyEngineDraft::from(&*guard);
470 let result = mutate(&mut draft);
471
472 #[cfg(feature = "cedar")]
473 let cedar = if draft.enabled {
474 Some(Self::build_cedar_state(
475 &draft.schema_text,
476 &draft.policy_sources,
477 &draft.entities,
478 )?)
479 } else {
480 None
481 };
482
483 guard.enabled = draft.enabled;
484 guard.entities = draft.entities;
485 guard.policy_sources = draft.policy_sources;
486 guard.schema_text = draft.schema_text;
487
488 #[cfg(feature = "cedar")]
489 {
490 guard.cedar = cedar;
491 }
492
493 Ok(result)
494 }
495
496 #[must_use]
498 pub fn list_policies(&self) -> Vec<(String, String)> {
499 let guard = self.inner.read();
500 let mut policies: Vec<(String, String)> = guard
501 .policy_sources
502 .iter()
503 .map(|(k, v)| (k.clone(), v.clone()))
504 .collect();
505 policies.sort_by(|a, b| a.0.cmp(&b.0));
506 policies
507 }
508
509 pub fn authorize(&self, request: &AuthzRequest) -> AuthzDecision {
514 let guard = self.inner.read();
515
516 if !guard.enabled {
517 return AuthzDecision::allow();
518 }
519
520 #[cfg(feature = "cedar")]
521 {
522 self.authorize_cedar(&guard, request)
523 }
524 #[cfg(not(feature = "cedar"))]
525 {
526 let _ = &guard;
527 let _ = request;
528 AuthzDecision::allow()
529 }
530 }
531
532 pub fn allowed_namespaces_for(&self, agent_id: &str, action: Action) -> Option<Vec<String>> {
537 if !self.is_enabled() {
538 return None;
539 }
540
541 let namespaces = self.registered_namespaces();
542 let mut allowed = Vec::new();
543 for (ns_id, realm) in &namespaces {
544 let decision = self.authorize(&AuthzRequest {
545 agent_id: agent_id.to_string(),
546 action,
547 realm: realm.clone(),
548 namespace: ns_id.clone(),
549 });
550 if decision.allowed {
551 allowed.push(ns_id.clone());
552 }
553 }
554 Some(allowed)
555 }
556
557 pub fn register_agent(
561 &self,
562 agent_id: &str,
563 reputation: i64,
564 created_at: &str,
565 teams: &[&str],
566 ) -> Result<(), PolicyError> {
567 let key = format!("Hirn::Agent::\"{}\"", agent_id);
568 let entity = EntityKind::Agent {
569 reputation,
570 created_at: created_at.to_string(),
571 teams: teams.iter().map(|s| (*s).to_string()).collect(),
572 };
573 self.update_state(move |draft| {
574 draft.entities.insert(key, entity);
575 })
576 }
577
578 pub fn register_team(
580 &self,
581 team_id: &str,
582 description: &str,
583 organization: Option<&str>,
584 ) -> Result<(), PolicyError> {
585 let key = format!("Hirn::Team::\"{}\"", team_id);
586 let entity = EntityKind::Team {
587 description: description.to_string(),
588 organization: organization.map(String::from),
589 };
590 self.update_state(move |draft| {
591 draft.entities.insert(key, entity);
592 })
593 }
594
595 pub fn register_organization(
597 &self,
598 org_id: &str,
599 description: &str,
600 ) -> Result<(), PolicyError> {
601 let key = format!("Hirn::Organization::\"{}\"", org_id);
602 let entity = EntityKind::Organization {
603 description: description.to_string(),
604 };
605 self.update_state(move |draft| {
606 draft.entities.insert(key, entity);
607 })
608 }
609
610 pub fn register_realm(&self, realm_id: &str, description: &str) -> Result<(), PolicyError> {
612 let key = format!("Hirn::Realm::\"{}\"", realm_id);
613 let entity = EntityKind::Realm {
614 description: description.to_string(),
615 };
616 self.update_state(move |draft| {
617 draft.entities.insert(key, entity);
618 })
619 }
620
621 pub fn register_namespace(
623 &self,
624 namespace_id: &str,
625 classification: &str,
626 realm: &str,
627 ) -> Result<(), PolicyError> {
628 let key = format!("Hirn::Namespace::\"{}\"", namespace_id);
629 let entity = EntityKind::Namespace {
630 classification: classification.to_string(),
631 realm: realm.to_string(),
632 };
633 self.update_state(move |draft| {
634 draft.entities.insert(key, entity);
635 })
636 }
637
638 pub fn register_memory_layer(
640 &self,
641 layer_id: &str,
642 description: &str,
643 ) -> Result<(), PolicyError> {
644 let key = format!("Hirn::MemoryLayer::\"{}\"", layer_id);
645 let entity = EntityKind::MemoryLayer {
646 description: description.to_string(),
647 };
648 self.update_state(move |draft| {
649 draft.entities.insert(key, entity);
650 })
651 }
652
653 pub fn register_operation(
655 &self,
656 operation_id: &str,
657 description: &str,
658 ) -> Result<(), PolicyError> {
659 let key = format!("Hirn::Operation::\"{}\"", operation_id);
660 let entity = EntityKind::Operation {
661 description: description.to_string(),
662 };
663 self.update_state(move |draft| {
664 draft.entities.insert(key, entity);
665 })
666 }
667
668 pub fn register_tool(&self, tool_id: &str, description: &str) -> Result<(), PolicyError> {
670 let key = format!("Hirn::Tool::\"{}\"", tool_id);
671 let entity = EntityKind::Tool {
672 description: description.to_string(),
673 };
674 self.update_state(move |draft| {
675 draft.entities.insert(key, entity);
676 })
677 }
678
679 pub fn remove_entity(&self, key: &str) -> Result<bool, PolicyError> {
681 self.update_state(|draft| draft.entities.remove(key).is_some())
682 }
683
684 pub fn add_policy(&self, name: &str, policy_text: &str) -> Result<(), PolicyError> {
688 self.add_policies(&[(name, policy_text)])
689 }
690
691 pub fn add_policies(&self, policies: &[(&str, &str)]) -> Result<(), PolicyError> {
693 self.update_state(|draft| {
694 for &(name, text) in policies {
695 draft
696 .policy_sources
697 .insert(name.to_string(), text.to_string());
698 }
699 })
700 }
701
702 pub fn remove_policy(&self, name: &str) -> Result<bool, PolicyError> {
704 self.update_state(|draft| draft.policy_sources.remove(name).is_some())
705 }
706
707 pub fn validate(&self) -> Vec<String> {
709 #[cfg(feature = "cedar")]
710 {
711 let guard = self.inner.read();
712 if let Some(cedar) = &guard.cedar {
713 let validator = cedar_policy::Validator::new(cedar.schema.clone());
714 let result = validator.validate(&cedar.policy_set, ValidationMode::default());
715 let mut messages = Vec::new();
716 for note in result.validation_errors() {
717 messages.push(format!("error: {note}"));
718 }
719 for note in result.validation_warnings() {
720 messages.push(format!("warning: {note}"));
721 }
722 messages
723 } else {
724 Vec::new()
725 }
726 }
727 #[cfg(not(feature = "cedar"))]
728 {
729 Vec::new()
730 }
731 }
732
733 pub fn save_to_brain(&self, brain_dir: &Path) -> Result<(), PolicyError> {
735 let policies_dir = brain_dir.join("policies");
736 std::fs::create_dir_all(&policies_dir).map_err(|e| PolicyError::Io {
737 path: policies_dir.display().to_string(),
738 reason: e.to_string(),
739 })?;
740
741 let guard = self.inner.read();
742
743 let schema_path = policies_dir.join("hirn.cedarschema");
744 std::fs::write(&schema_path, &guard.schema_text).map_err(|e| PolicyError::Io {
745 path: schema_path.display().to_string(),
746 reason: e.to_string(),
747 })?;
748
749 for (name, text) in &guard.policy_sources {
750 let policy_path = policies_dir.join(name);
751 std::fs::write(&policy_path, text).map_err(|e| PolicyError::Io {
752 path: policy_path.display().to_string(),
753 reason: e.to_string(),
754 })?;
755 }
756
757 Ok(())
758 }
759
760 #[cfg(feature = "cedar")]
763 fn rebuild_cedar(&self) -> Result<(), PolicyError> {
764 let mut guard = self.inner.write();
765
766 guard.cedar = if guard.enabled {
767 Some(Self::build_cedar_state(
768 &guard.schema_text,
769 &guard.policy_sources,
770 &guard.entities,
771 )?)
772 } else {
773 None
774 };
775
776 Ok(())
777 }
778
779 #[cfg(feature = "cedar")]
780 fn build_cedar_state(
781 schema_text: &str,
782 policy_sources: &HashMap<String, String>,
783 entities: &HashMap<String, EntityKind>,
784 ) -> Result<CedarState, PolicyError> {
785 let schema = schema_text
786 .parse::<Schema>()
787 .map_err(|e| PolicyError::SchemaInvalid(format!("{e}")))?;
788
789 let combined_text: String = policy_sources
790 .iter()
791 .map(|(name, text)| format!("// source: {name}\n{text}\n"))
792 .collect();
793
794 let policy_set =
795 combined_text
796 .parse::<PolicySet>()
797 .map_err(|e| PolicyError::PolicyInvalid {
798 name: "combined".to_string(),
799 detail: format!("{e}"),
800 })?;
801
802 let entities = Self::build_entities(entities, &schema)?;
803
804 Ok(CedarState {
805 schema,
806 policy_set,
807 entities,
808 })
809 }
810
811 #[cfg(feature = "cedar")]
812 fn authorize_cedar(&self, guard: &PolicyEngineInner, request: &AuthzRequest) -> AuthzDecision {
813 let cedar = match &guard.cedar {
814 Some(c) => c,
815 None => return AuthzDecision::deny("policy engine not initialized"),
816 };
817
818 let principal = EntityUid::from_type_name_and_id(
819 Self::parse_type_name("Hirn::Agent"),
820 EntityId::new(request.agent_id.clone()),
821 );
822
823 let action = EntityUid::from_type_name_and_id(
824 Self::parse_type_name("Hirn::Action"),
825 EntityId::new(request.action.as_str()),
826 );
827
828 let resource = if request.namespace.is_empty() {
829 EntityUid::from_type_name_and_id(
830 Self::parse_type_name("Hirn::Realm"),
831 EntityId::new(request.realm.clone()),
832 )
833 } else {
834 EntityUid::from_type_name_and_id(
835 Self::parse_type_name("Hirn::Namespace"),
836 EntityId::new(request.namespace.clone()),
837 )
838 };
839
840 let context = Context::empty();
841
842 let cedar_request =
843 match Request::new(principal, action, resource, context, Some(&cedar.schema)) {
844 Ok(r) => r,
845 Err(e) => return AuthzDecision::deny(format!("invalid request: {e}")),
846 };
847
848 let authorizer = Authorizer::new();
849 let response = authorizer.is_authorized(&cedar_request, &cedar.policy_set, &cedar.entities);
850
851 let mut decision = AuthzDecision {
852 allowed: response.decision() == CedarDecision::Allow,
853 policy_ids: response
854 .diagnostics()
855 .reason()
856 .map(|id| id.to_string())
857 .collect(),
858 reasons: Vec::new(),
859 errors: response
860 .diagnostics()
861 .errors()
862 .map(|e| e.to_string())
863 .collect(),
864 };
865
866 if !decision.allowed {
867 decision.reasons.push(format!(
868 "denied: {} cannot {} on {}",
869 request.agent_id,
870 request.action,
871 if request.namespace.is_empty() {
872 &request.realm
873 } else {
874 &request.namespace
875 }
876 ));
877 }
878
879 decision
880 }
881
882 #[cfg(feature = "cedar")]
883 fn parse_type_name(name: &str) -> EntityTypeName {
884 name.parse().expect("valid Cedar entity type name")
885 }
886
887 #[cfg(feature = "cedar")]
888 fn build_entities(
889 entity_map: &HashMap<String, EntityKind>,
890 schema: &Schema,
891 ) -> Result<Entities, PolicyError> {
892 let mut entities_vec: Vec<Entity> = Vec::new();
893
894 for (key, kind) in entity_map {
895 let entity = Self::build_entity(key, kind)?;
896 entities_vec.push(entity);
897 }
898
899 Entities::from_entities(entities_vec, Some(schema))
900 .map_err(|e| PolicyError::EntityInvalid(format!("{e}")))
901 }
902
903 #[cfg(feature = "cedar")]
904 fn build_entity(key: &str, kind: &EntityKind) -> Result<Entity, PolicyError> {
905 use cedar_policy::RestrictedExpression;
906
907 let (type_str, id_str) = Self::parse_entity_key(key)?;
908 let uid = EntityUid::from_type_name_and_id(
909 Self::parse_type_name(type_str),
910 EntityId::new(id_str),
911 );
912
913 match kind {
914 EntityKind::Agent {
915 reputation,
916 created_at,
917 teams,
918 } => {
919 let parents: Vec<EntityUid> = teams
920 .iter()
921 .map(|t| {
922 EntityUid::from_type_name_and_id(
923 Self::parse_type_name("Hirn::Team"),
924 EntityId::new(t.as_str()),
925 )
926 })
927 .collect();
928
929 let attrs = HashMap::from([
930 (
931 "reputation".to_string(),
932 RestrictedExpression::new_long(*reputation),
933 ),
934 (
935 "created_at".to_string(),
936 RestrictedExpression::new_string(created_at.clone()),
937 ),
938 ]);
939
940 Ok(Entity::new(uid, attrs, parents.into_iter().collect())
941 .map_err(|e| PolicyError::EntityInvalid(format!("{e}")))?)
942 }
943 EntityKind::Team {
944 description,
945 organization,
946 } => {
947 let parents: Vec<EntityUid> = organization
948 .iter()
949 .map(|o| {
950 EntityUid::from_type_name_and_id(
951 Self::parse_type_name("Hirn::Organization"),
952 EntityId::new(o.as_str()),
953 )
954 })
955 .collect();
956
957 let attrs = HashMap::from([(
958 "description".to_string(),
959 RestrictedExpression::new_string(description.clone()),
960 )]);
961
962 Ok(Entity::new(uid, attrs, parents.into_iter().collect())
963 .map_err(|e| PolicyError::EntityInvalid(format!("{e}")))?)
964 }
965 EntityKind::Organization { description } => {
966 let attrs = HashMap::from([(
967 "description".to_string(),
968 RestrictedExpression::new_string(description.clone()),
969 )]);
970
971 Ok(Entity::new(uid, attrs, [].into_iter().collect())
972 .map_err(|e| PolicyError::EntityInvalid(format!("{e}")))?)
973 }
974 EntityKind::Realm { description } => {
975 let attrs = HashMap::from([(
976 "description".to_string(),
977 RestrictedExpression::new_string(description.clone()),
978 )]);
979
980 Ok(Entity::new(uid, attrs, [].into_iter().collect())
981 .map_err(|e| PolicyError::EntityInvalid(format!("{e}")))?)
982 }
983 EntityKind::Namespace {
984 classification,
985 realm,
986 } => {
987 let parents = vec![EntityUid::from_type_name_and_id(
988 Self::parse_type_name("Hirn::Realm"),
989 EntityId::new(realm.as_str()),
990 )];
991
992 let attrs = HashMap::from([(
993 "classification".to_string(),
994 RestrictedExpression::new_string(classification.clone()),
995 )]);
996
997 Ok(Entity::new(uid, attrs, parents.into_iter().collect())
998 .map_err(|e| PolicyError::EntityInvalid(format!("{e}")))?)
999 }
1000 EntityKind::MemoryLayer { description }
1001 | EntityKind::Operation { description }
1002 | EntityKind::Tool { description } => {
1003 let attrs = HashMap::from([(
1004 "description".to_string(),
1005 RestrictedExpression::new_string(description.clone()),
1006 )]);
1007
1008 Ok(Entity::new(uid, attrs, [].into_iter().collect())
1009 .map_err(|e| PolicyError::EntityInvalid(format!("{e}")))?)
1010 }
1011 }
1012 }
1013
1014 #[cfg(feature = "cedar")]
1016 fn parse_entity_key(key: &str) -> Result<(&str, &str), PolicyError> {
1017 if let Some(idx) = key.rfind("::\"") {
1018 let type_str = &key[..idx];
1019 let id_raw = &key[idx + 2..];
1020 let id_str = id_raw.trim_matches('"');
1021 Ok((type_str, id_str))
1022 } else {
1023 Err(PolicyError::EntityInvalid(format!(
1024 "invalid entity key format: {key}"
1025 )))
1026 }
1027 }
1028}
1029
1030impl Clone for PolicyEngine {
1031 fn clone(&self) -> Self {
1032 Self {
1033 inner: Arc::clone(&self.inner),
1034 }
1035 }
1036}
1037
1038impl std::fmt::Debug for PolicyEngine {
1039 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1040 let guard = self.inner.read();
1041 f.debug_struct("PolicyEngine")
1042 .field("enabled", &guard.enabled)
1043 .field("entities", &guard.entities.len())
1044 .field("policy_sources", &guard.policy_sources.len())
1045 .finish()
1046 }
1047}
1048
1049#[cfg(test)]
1052mod tests {
1053 use super::*;
1054
1055 #[test]
1056 fn valid_schema_parses() {
1057 let engine = PolicyEngine::new(DEFAULT_SCHEMA, &[("default.cedar", DEFAULT_OPEN_POLICY)]);
1058 assert!(engine.is_ok(), "default schema should parse: {engine:?}");
1059 }
1060
1061 #[test]
1062 fn invalid_schema_returns_error() {
1063 let bad_schema = "this is not a valid cedar schema!!!";
1064 let result = PolicyEngine::new(bad_schema, &[("default.cedar", DEFAULT_OPEN_POLICY)]);
1065 assert!(result.is_err());
1066 match result.unwrap_err() {
1067 PolicyError::SchemaInvalid(msg) => {
1068 assert!(!msg.is_empty());
1069 }
1070 other => panic!("expected SchemaInvalid, got: {other:?}"),
1071 }
1072 }
1073
1074 #[test]
1075 fn schema_covers_all_actions() {
1076 for action in [
1077 "remember",
1078 "correct",
1079 "supersede",
1080 "merge",
1081 "retract",
1082 "purge",
1083 "recall",
1084 "think",
1085 "forget",
1086 "consolidate",
1087 "watch",
1088 "connect",
1089 "execute",
1090 "admin",
1091 "recall_raw_text",
1092 "read",
1093 "write",
1094 "delete",
1095 ] {
1096 assert!(
1097 DEFAULT_SCHEMA.contains(action),
1098 "schema should include action '{action}'"
1099 );
1100 }
1101 }
1102
1103 #[test]
1104 fn action_strings_round_trip() {
1105 for action in [
1106 "remember",
1107 "correct",
1108 "supersede",
1109 "merge",
1110 "retract",
1111 "purge",
1112 "recall",
1113 "think",
1114 "forget",
1115 "consolidate",
1116 "watch",
1117 "connect",
1118 "execute",
1119 "admin",
1120 "recall_raw_text",
1121 "read",
1122 "write",
1123 "delete",
1124 ] {
1125 let parsed: Action = action.parse().unwrap();
1126 assert_eq!(parsed.as_str(), action);
1127 }
1128 }
1129
1130 #[test]
1131 fn open_mode_allows_everything() {
1132 let engine = PolicyEngine::open_mode();
1133 assert!(engine.is_open_mode());
1134
1135 let decision = engine.authorize(&AuthzRequest {
1136 agent_id: "any-agent".to_string(),
1137 action: Action::Remember,
1138 realm: "any-realm".to_string(),
1139 namespace: String::new(),
1140 });
1141 assert!(decision.allowed);
1142 }
1143
1144 #[test]
1145 fn default_open_policy_allows_everything() {
1146 let engine =
1147 PolicyEngine::new(DEFAULT_SCHEMA, &[("default.cedar", DEFAULT_OPEN_POLICY)]).unwrap();
1148
1149 engine
1150 .register_agent("test-agent", 100, "2025-01-01T00:00:00Z", &[])
1151 .unwrap();
1152 engine.register_realm("test-realm", "Test").unwrap();
1153
1154 let decision = engine.authorize(&AuthzRequest {
1155 agent_id: "test-agent".to_string(),
1156 action: Action::Remember,
1157 realm: "test-realm".to_string(),
1158 namespace: String::new(),
1159 });
1160 assert!(decision.allowed, "open policy should allow: {decision:?}");
1161 }
1162
1163 #[test]
1164 fn team_policy_allows_members_denies_others() {
1165 let policy = r#"
1166 permit(
1167 principal in Hirn::Team::"writers",
1168 action == Hirn::Action::"remember",
1169 resource == Hirn::Realm::"production"
1170 );
1171 "#;
1172
1173 let engine = PolicyEngine::new(DEFAULT_SCHEMA, &[("acl.cedar", policy)]).unwrap();
1174
1175 engine
1176 .register_team("writers", "Writer team", None)
1177 .unwrap();
1178 engine.register_realm("production", "Prod").unwrap();
1179
1180 engine
1181 .register_agent("alice", 100, "2025-01-01T00:00:00Z", &["writers"])
1182 .unwrap();
1183 let decision = engine.authorize(&AuthzRequest {
1184 agent_id: "alice".to_string(),
1185 action: Action::Remember,
1186 realm: "production".to_string(),
1187 namespace: String::new(),
1188 });
1189 assert!(decision.allowed, "alice should be allowed: {decision:?}");
1190
1191 engine
1192 .register_agent("bob", 100, "2025-01-01T00:00:00Z", &[])
1193 .unwrap();
1194 let decision = engine.authorize(&AuthzRequest {
1195 agent_id: "bob".to_string(),
1196 action: Action::Remember,
1197 realm: "production".to_string(),
1198 namespace: String::new(),
1199 });
1200 assert!(!decision.allowed, "bob should be denied: {decision:?}");
1201 }
1202
1203 #[test]
1204 fn abac_reputation_constraint() {
1205 let policy = r#"
1206 permit(
1207 principal,
1208 action == Hirn::Action::"remember",
1209 resource
1210 ) when { principal.reputation >= 50 };
1211 "#;
1212
1213 let engine = PolicyEngine::new(DEFAULT_SCHEMA, &[("acl.cedar", policy)]).unwrap();
1214 engine.register_realm("test", "Test").unwrap();
1215
1216 engine
1217 .register_agent("good-agent", 100, "2025-01-01T00:00:00Z", &[])
1218 .unwrap();
1219 let decision = engine.authorize(&AuthzRequest {
1220 agent_id: "good-agent".to_string(),
1221 action: Action::Remember,
1222 realm: "test".to_string(),
1223 namespace: String::new(),
1224 });
1225 assert!(decision.allowed, "high rep allowed: {decision:?}");
1226
1227 engine
1228 .register_agent("bad-agent", 10, "2025-01-01T00:00:00Z", &[])
1229 .unwrap();
1230 let decision = engine.authorize(&AuthzRequest {
1231 agent_id: "bad-agent".to_string(),
1232 action: Action::Remember,
1233 realm: "test".to_string(),
1234 namespace: String::new(),
1235 });
1236 assert!(!decision.allowed, "low rep denied: {decision:?}");
1237 }
1238
1239 #[test]
1240 fn save_and_load_from_brain() {
1241 let temp = tempfile::tempdir().unwrap();
1242 let brain_dir = temp.path();
1243
1244 let custom_policy = r#"
1245 permit(
1246 principal in Hirn::Team::"writers",
1247 action == Hirn::Action::"remember",
1248 resource
1249 );
1250 "#;
1251 let engine = PolicyEngine::new(DEFAULT_SCHEMA, &[("custom.cedar", custom_policy)]).unwrap();
1252 engine.save_to_brain(brain_dir).unwrap();
1253
1254 assert!(brain_dir.join("policies/hirn.cedarschema").exists());
1255 assert!(brain_dir.join("policies/custom.cedar").exists());
1256
1257 let loaded = PolicyEngine::load_from_brain(brain_dir).unwrap();
1258 assert!(loaded.policy_count() >= 1);
1259 }
1260
1261 #[test]
1262 fn invalid_policy_add_rolls_back_policy_sources() {
1263 let engine =
1264 PolicyEngine::new(DEFAULT_SCHEMA, &[("default.cedar", DEFAULT_OPEN_POLICY)]).unwrap();
1265 let before = engine.list_policies();
1266
1267 let err = engine
1268 .add_policy("broken.cedar", "this is not valid cedar")
1269 .unwrap_err();
1270 assert!(matches!(err, PolicyError::PolicyInvalid { .. }));
1271 assert_eq!(engine.list_policies(), before);
1272 }
1273
1274 #[test]
1275 fn invalid_policy_remove_rolls_back_policy_sources() {
1276 let engine = PolicyEngine::new(
1277 DEFAULT_SCHEMA,
1278 &[
1279 ("default.cedar", DEFAULT_OPEN_POLICY),
1280 ("extra.cedar", DEFAULT_OPEN_POLICY),
1281 ],
1282 )
1283 .unwrap();
1284
1285 {
1286 let mut guard = engine.inner.write();
1287 guard.schema_text = "this is not a valid cedar schema!!!".to_string();
1288 }
1289
1290 let err = engine.remove_policy("extra.cedar").unwrap_err();
1291 assert!(matches!(err, PolicyError::SchemaInvalid(_)));
1292 assert!(
1293 engine
1294 .list_policies()
1295 .iter()
1296 .any(|(name, _)| name == "extra.cedar")
1297 );
1298 }
1299
1300 #[test]
1301 fn invalid_entity_registration_rolls_back_entities() {
1302 let engine =
1303 PolicyEngine::new(DEFAULT_SCHEMA, &[("default.cedar", DEFAULT_OPEN_POLICY)]).unwrap();
1304 let before = engine.entity_count();
1305
1306 {
1307 let mut guard = engine.inner.write();
1308 guard.schema_text = "this is not a valid cedar schema!!!".to_string();
1309 }
1310
1311 let err = engine
1312 .register_namespace("candidate-ns", "public", "candidate-realm")
1313 .unwrap_err();
1314 assert!(matches!(err, PolicyError::SchemaInvalid(_)));
1315 assert_eq!(engine.entity_count(), before);
1316 assert!(engine.registered_namespaces().is_empty());
1317 }
1318
1319 #[test]
1320 fn load_from_brain_without_policies_fails_closed() {
1321 let temp = tempfile::tempdir().unwrap();
1322 let err = PolicyEngine::load_from_brain(temp.path()).unwrap_err();
1323 assert!(matches!(err, PolicyError::MissingPolicies { .. }));
1324 }
1325
1326 #[test]
1327 fn load_from_brain_insecure_dev_mode_uses_default_open_policy() {
1328 let temp = tempfile::tempdir().unwrap();
1329 let loaded = PolicyEngine::load_from_brain_insecure_dev_mode(temp.path()).unwrap();
1330 assert!(loaded.policy_count() >= 1);
1331 assert!(!loaded.is_open_mode());
1332 }
1333
1334 #[test]
1335 fn allowed_namespaces_open_mode() {
1336 let engine = PolicyEngine::open_mode();
1337 let result = engine.allowed_namespaces_for("anyone", Action::Recall);
1338 assert!(result.is_none());
1339 }
1340
1341 #[test]
1342 fn allowed_namespaces_filters() {
1343 let engine =
1344 PolicyEngine::new(DEFAULT_SCHEMA, &[("default.cedar", DEFAULT_OPEN_POLICY)]).unwrap();
1345 engine.register_realm("production", "prod").unwrap();
1346 engine
1347 .register_namespace("ns_a", "public", "production")
1348 .unwrap();
1349 engine
1350 .register_namespace("ns_b", "public", "production")
1351 .unwrap();
1352 engine
1353 .register_agent("agent-1", 50, "2024-01-01", &[])
1354 .unwrap();
1355
1356 let result = engine.allowed_namespaces_for("agent-1", Action::Recall);
1357 assert!(result.is_some());
1358 let mut allowed = result.unwrap();
1359 allowed.sort();
1360 assert_eq!(allowed, vec!["ns_a", "ns_b"]);
1361 }
1362
1363 #[test]
1364 fn concurrent_authorization() {
1365 use std::sync::Arc;
1366 use std::thread;
1367
1368 let engine = Arc::new(
1369 PolicyEngine::new(DEFAULT_SCHEMA, &[("default.cedar", DEFAULT_OPEN_POLICY)]).unwrap(),
1370 );
1371
1372 engine
1373 .register_agent("concurrent-agent", 100, "2025-01-01T00:00:00Z", &[])
1374 .unwrap();
1375 engine.register_realm("test", "Test").unwrap();
1376
1377 let handles: Vec<_> = (0..100)
1378 .map(|_| {
1379 let eng = Arc::clone(&engine);
1380 thread::spawn(move || {
1381 let d = eng.authorize(&AuthzRequest {
1382 agent_id: "concurrent-agent".to_string(),
1383 action: Action::Recall,
1384 realm: "test".to_string(),
1385 namespace: String::new(),
1386 });
1387 assert!(d.allowed);
1388 })
1389 })
1390 .collect();
1391
1392 for h in handles {
1393 h.join().unwrap();
1394 }
1395 }
1396
1397 #[test]
1398 fn schema_includes_entity_types() {
1399 for entity in ["MemoryLayer", "Operation", "Tool"] {
1400 assert!(
1401 DEFAULT_SCHEMA.contains(entity),
1402 "schema should include entity '{entity}'"
1403 );
1404 }
1405 }
1406
1407 #[test]
1408 fn register_memory_layer_entity() {
1409 let engine =
1410 PolicyEngine::new(DEFAULT_SCHEMA, &[("default.cedar", DEFAULT_OPEN_POLICY)]).unwrap();
1411 engine
1412 .register_memory_layer("Episodic", "Episodic memory layer")
1413 .unwrap();
1414
1415 let entities = engine.entity_count();
1416 assert!(entities >= 1, "should have at least 1 entity");
1417 }
1418
1419 #[test]
1420 fn register_operation_entity() {
1421 let engine =
1422 PolicyEngine::new(DEFAULT_SCHEMA, &[("default.cedar", DEFAULT_OPEN_POLICY)]).unwrap();
1423 engine
1424 .register_operation("Recall", "Recall operation")
1425 .unwrap();
1426
1427 let entities = engine.entity_count();
1428 assert!(entities >= 1, "should have at least 1 entity");
1429 }
1430
1431 #[test]
1432 fn register_tool_entity() {
1433 let engine =
1434 PolicyEngine::new(DEFAULT_SCHEMA, &[("default.cedar", DEFAULT_OPEN_POLICY)]).unwrap();
1435 engine
1436 .register_tool("remember_tool", "Memory toolkit: remember")
1437 .unwrap();
1438
1439 let entities = engine.entity_count();
1440 assert!(entities >= 1, "should have at least 1 entity");
1441 }
1442
1443 #[test]
1444 fn namespace_scoped_permit_policy() {
1445 let policy = r#"
1446 permit(
1447 principal == Hirn::Agent::"agent-a",
1448 action == Hirn::Action::"recall",
1449 resource == Hirn::Namespace::"team_x"
1450 );
1451 "#;
1452
1453 let engine = PolicyEngine::new(DEFAULT_SCHEMA, &[("ns.cedar", policy)]).unwrap();
1454 engine.register_realm("prod", "Production").unwrap();
1455 engine
1456 .register_namespace("team_x", "public", "prod")
1457 .unwrap();
1458 engine
1459 .register_namespace("team_y", "classified", "prod")
1460 .unwrap();
1461 engine
1462 .register_agent("agent-a", 50, "2025-01-01", &[])
1463 .unwrap();
1464 engine
1465 .register_agent("agent-b", 50, "2025-01-01", &[])
1466 .unwrap();
1467
1468 let d = engine.authorize(&AuthzRequest {
1470 agent_id: "agent-a".to_string(),
1471 action: Action::Recall,
1472 realm: "prod".to_string(),
1473 namespace: "team_x".to_string(),
1474 });
1475 assert!(d.allowed, "agent-a should access team_x: {d:?}");
1476
1477 let d = engine.authorize(&AuthzRequest {
1479 agent_id: "agent-a".to_string(),
1480 action: Action::Recall,
1481 realm: "prod".to_string(),
1482 namespace: "team_y".to_string(),
1483 });
1484 assert!(!d.allowed, "agent-a should be denied team_y: {d:?}");
1485
1486 let d = engine.authorize(&AuthzRequest {
1488 agent_id: "agent-b".to_string(),
1489 action: Action::Recall,
1490 realm: "prod".to_string(),
1491 namespace: "team_x".to_string(),
1492 });
1493 assert!(!d.allowed, "agent-b should be denied team_x: {d:?}");
1494 }
1495}