Skip to main content

wire/
org_policy.rs

1//! RFC-001 Phase 3 (minimal) — per-org pairing policy persistence.
2//!
3//! The receiver's trusted-org set + inbound mode, stored at
4//! `config/wire/org_policies.json`. Implements the [`OrgPolicy`] trait
5//! (`pair_decision`) that `decide()` consumes, so the live pairing wiring
6//! (P1b) can look up "do I auto/notify-pair members of this org?".
7//!
8//! **Fail-closed.** A missing or malformed policy file loads as the empty
9//! policy → every org is untrusted (`None`) → `decide()` returns `Manual`
10//! (today's default-deny bilateral flow). A broken policy must never grant
11//! eased pairing, so loading never errors.
12//!
13//! This is the minimal subset the wiring needs (org_did → inbound mode). The
14//! full filtering surface from amendment #83 (first-match-wins table, the
15//! `org_attestation`/`project` columns, the consent-gated `wire_org_set_policy`
16//! MCP tool, AC-FILT) layers on top of this store.
17
18use crate::pair_decision::{InboundMode, OrgPolicy};
19use anyhow::Result;
20use serde_json::json;
21use std::collections::HashMap;
22use std::path::Path;
23
24const FILE: &str = "org_policies.json";
25
26/// File-backed per-org policy. Maps `org_did` → inbound mode for the orgs the
27/// receiver trusts; absence means untrusted (default-deny).
28#[derive(Debug, Clone, Default)]
29pub struct FileOrgPolicy {
30    orgs: HashMap<String, InboundMode>,
31}
32
33impl FileOrgPolicy {
34    /// Load from `config/wire/org_policies.json`. Missing or malformed → empty
35    /// (default-deny). Never errors — a broken policy must not grant easing.
36    pub fn load() -> Self {
37        match crate::config::config_dir() {
38            Ok(dir) => Self::load_path(&dir.join(FILE)),
39            Err(_) => Self::default(),
40        }
41    }
42
43    /// Load from an explicit path (testable). Fail-closed on any error.
44    pub fn load_path(path: &Path) -> Self {
45        let Ok(bytes) = std::fs::read(path) else {
46            return Self::default();
47        };
48        let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
49            return Self::default();
50        };
51        let mut orgs = HashMap::new();
52        if let Some(map) = json.get("orgs").and_then(|v| v.as_object()) {
53            for (org_did, entry) in map {
54                if let Some(mode) = entry
55                    .get("inbound")
56                    .and_then(|v| v.as_str())
57                    .and_then(parse_mode)
58                {
59                    orgs.insert(org_did.clone(), mode);
60                }
61            }
62        }
63        Self { orgs }
64    }
65
66    /// Set/replace one org's inbound mode (in memory; call `save*` to persist).
67    pub fn set(&mut self, org_did: &str, mode: InboundMode) {
68        self.orgs.insert(org_did.to_string(), mode);
69    }
70
71    /// Drop an org from the trusted set.
72    pub fn remove(&mut self, org_did: &str) {
73        self.orgs.remove(org_did);
74    }
75
76    /// Number of trusted orgs (for `wire org policy list`).
77    pub fn len(&self) -> usize {
78        self.orgs.len()
79    }
80
81    pub fn is_empty(&self) -> bool {
82        self.orgs.is_empty()
83    }
84
85    /// Persist to `config/wire/org_policies.json`.
86    pub fn save(&self) -> Result<()> {
87        let dir = crate::config::config_dir()?;
88        std::fs::create_dir_all(&dir)?;
89        self.save_path(&dir.join(FILE))?;
90        Ok(())
91    }
92
93    /// Persist to an explicit path (testable).
94    pub fn save_path(&self, path: &Path) -> std::io::Result<()> {
95        std::fs::write(path, self.to_json())
96    }
97
98    fn to_json(&self) -> String {
99        let orgs: serde_json::Map<String, serde_json::Value> = self
100            .orgs
101            .iter()
102            .map(|(k, v)| (k.clone(), json!({ "inbound": mode_str(*v) })))
103            .collect();
104        serde_json::to_string_pretty(&json!({ "version": 1, "orgs": orgs }))
105            .unwrap_or_else(|_| "{}".into())
106    }
107}
108
109impl OrgPolicy for FileOrgPolicy {
110    fn inbound_mode(&self, org_did: &str) -> Option<InboundMode> {
111        self.orgs.get(org_did).copied()
112    }
113}
114
115fn parse_mode(s: &str) -> Option<InboundMode> {
116    match s {
117        "auto" => Some(InboundMode::Auto),
118        "notify" => Some(InboundMode::Notify),
119        _ => None,
120    }
121}
122
123fn mode_str(m: InboundMode) -> &'static str {
124    match m {
125        InboundMode::Auto => "auto",
126        InboundMode::Notify => "notify",
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    fn tmp(name: &str) -> std::path::PathBuf {
135        std::env::temp_dir().join(format!("wire-orgpol-{}-{name}.json", std::process::id()))
136    }
137
138    #[test]
139    fn missing_file_is_default_deny() {
140        let p = tmp("missing");
141        let _ = std::fs::remove_file(&p);
142        let pol = FileOrgPolicy::load_path(&p);
143        assert!(pol.is_empty());
144        assert_eq!(pol.inbound_mode("did:wire:org:slanchaai-1"), None);
145    }
146
147    #[test]
148    fn malformed_file_is_default_deny() {
149        let p = tmp("malformed");
150        std::fs::write(&p, b"not json {{{").unwrap();
151        let pol = FileOrgPolicy::load_path(&p);
152        assert!(pol.is_empty(), "malformed policy must fail closed to empty");
153        let _ = std::fs::remove_file(&p);
154    }
155
156    #[test]
157    fn set_save_load_roundtrip() {
158        let p = tmp("roundtrip");
159        let mut pol = FileOrgPolicy::default();
160        pol.set("did:wire:org:slanchaai-1", InboundMode::Auto);
161        pol.set("did:wire:org:contractor-2", InboundMode::Notify);
162        pol.save_path(&p).unwrap();
163
164        let loaded = FileOrgPolicy::load_path(&p);
165        assert_eq!(
166            loaded.inbound_mode("did:wire:org:slanchaai-1"),
167            Some(InboundMode::Auto)
168        );
169        assert_eq!(
170            loaded.inbound_mode("did:wire:org:contractor-2"),
171            Some(InboundMode::Notify)
172        );
173        assert_eq!(loaded.inbound_mode("did:wire:org:unknown-9"), None);
174        let _ = std::fs::remove_file(&p);
175    }
176
177    #[test]
178    fn unknown_mode_string_is_skipped() {
179        let p = tmp("badmode");
180        std::fs::write(
181            &p,
182            br#"{"version":1,"orgs":{"did:wire:org:x-1":{"inbound":"superuser"}}}"#,
183        )
184        .unwrap();
185        let pol = FileOrgPolicy::load_path(&p);
186        assert_eq!(pol.inbound_mode("did:wire:org:x-1"), None);
187        let _ = std::fs::remove_file(&p);
188    }
189
190    #[test]
191    fn remove_drops_org() {
192        let mut pol = FileOrgPolicy::default();
193        pol.set("did:wire:org:x-1", InboundMode::Auto);
194        pol.remove("did:wire:org:x-1");
195        assert_eq!(pol.inbound_mode("did:wire:org:x-1"), None);
196    }
197}