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    /// Iterate `(org_did, mode)` for `wire org list`. Order is unspecified
82    /// (HashMap); callers that need stable output should sort.
83    pub fn entries(&self) -> impl Iterator<Item = (&String, &InboundMode)> {
84        self.orgs.iter()
85    }
86
87    pub fn is_empty(&self) -> bool {
88        self.orgs.is_empty()
89    }
90
91    /// Persist to `config/wire/org_policies.json`.
92    pub fn save(&self) -> Result<()> {
93        let dir = crate::config::config_dir()?;
94        std::fs::create_dir_all(&dir)?;
95        self.save_path(&dir.join(FILE))?;
96        Ok(())
97    }
98
99    /// Persist to an explicit path (testable).
100    pub fn save_path(&self, path: &Path) -> std::io::Result<()> {
101        std::fs::write(path, self.to_json())
102    }
103
104    fn to_json(&self) -> String {
105        let orgs: serde_json::Map<String, serde_json::Value> = self
106            .orgs
107            .iter()
108            .map(|(k, v)| (k.clone(), json!({ "inbound": mode_str(*v) })))
109            .collect();
110        serde_json::to_string_pretty(&json!({ "version": 1, "orgs": orgs }))
111            .unwrap_or_else(|_| "{}".into())
112    }
113}
114
115impl OrgPolicy for FileOrgPolicy {
116    fn inbound_mode(&self, org_did: &str) -> Option<InboundMode> {
117        self.orgs.get(org_did).copied()
118    }
119}
120
121fn parse_mode(s: &str) -> Option<InboundMode> {
122    match s {
123        "auto" => Some(InboundMode::Auto),
124        "notify" => Some(InboundMode::Notify),
125        _ => None,
126    }
127}
128
129fn mode_str(m: InboundMode) -> &'static str {
130    match m {
131        InboundMode::Auto => "auto",
132        InboundMode::Notify => "notify",
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    fn tmp(name: &str) -> std::path::PathBuf {
141        std::env::temp_dir().join(format!("wire-orgpol-{}-{name}.json", std::process::id()))
142    }
143
144    #[test]
145    fn missing_file_is_default_deny() {
146        let p = tmp("missing");
147        let _ = std::fs::remove_file(&p);
148        let pol = FileOrgPolicy::load_path(&p);
149        assert!(pol.is_empty());
150        assert_eq!(pol.inbound_mode("did:wire:org:slanchaai-1"), None);
151    }
152
153    #[test]
154    fn malformed_file_is_default_deny() {
155        let p = tmp("malformed");
156        std::fs::write(&p, b"not json {{{").unwrap();
157        let pol = FileOrgPolicy::load_path(&p);
158        assert!(pol.is_empty(), "malformed policy must fail closed to empty");
159        let _ = std::fs::remove_file(&p);
160    }
161
162    #[test]
163    fn set_save_load_roundtrip() {
164        let p = tmp("roundtrip");
165        let mut pol = FileOrgPolicy::default();
166        pol.set("did:wire:org:slanchaai-1", InboundMode::Auto);
167        pol.set("did:wire:org:contractor-2", InboundMode::Notify);
168        pol.save_path(&p).unwrap();
169
170        let loaded = FileOrgPolicy::load_path(&p);
171        assert_eq!(
172            loaded.inbound_mode("did:wire:org:slanchaai-1"),
173            Some(InboundMode::Auto)
174        );
175        assert_eq!(
176            loaded.inbound_mode("did:wire:org:contractor-2"),
177            Some(InboundMode::Notify)
178        );
179        assert_eq!(loaded.inbound_mode("did:wire:org:unknown-9"), None);
180        let _ = std::fs::remove_file(&p);
181    }
182
183    #[test]
184    fn unknown_mode_string_is_skipped() {
185        let p = tmp("badmode");
186        std::fs::write(
187            &p,
188            br#"{"version":1,"orgs":{"did:wire:org:x-1":{"inbound":"superuser"}}}"#,
189        )
190        .unwrap();
191        let pol = FileOrgPolicy::load_path(&p);
192        assert_eq!(pol.inbound_mode("did:wire:org:x-1"), None);
193        let _ = std::fs::remove_file(&p);
194    }
195
196    #[test]
197    fn remove_drops_org() {
198        let mut pol = FileOrgPolicy::default();
199        pol.set("did:wire:org:x-1", InboundMode::Auto);
200        pol.remove("did:wire:org:x-1");
201        assert_eq!(pol.inbound_mode("did:wire:org:x-1"), None);
202    }
203}