lean_ctx/core/policy/org/
model.rs1use ed25519_dalek::{Signer, SigningKey};
18use serde::{Deserialize, Serialize};
19
20use crate::core::policy::{self, PolicyError, PolicyPack, ResolvedPolicy};
21
22pub const SCHEMA_VERSION: u32 = 1;
23pub const KIND: &str = "lean-ctx.org-policy";
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct OrgVerifyResult {
28 pub signature_valid: bool,
29 pub signer_public_key: Option<String>,
30 pub error: Option<String>,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct OrgPolicyV1 {
40 pub schema_version: u32,
41 pub kind: String,
43 pub org: String,
45 pub policy_version: String,
48 pub issued_at: String,
50 pub enforced: bool,
54 pub pack_toml: String,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub signer_public_key: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub signature: Option<String>,
63}
64
65impl OrgPolicyV1 {
66 pub fn build(
70 org: &str,
71 policy_version: &str,
72 enforced: bool,
73 pack_toml: &str,
74 ) -> Result<Self, PolicyError> {
75 let pack = policy::parse(pack_toml)?;
77 policy::resolve(&pack)?;
78 Ok(Self {
79 schema_version: SCHEMA_VERSION,
80 kind: KIND.to_string(),
81 org: org.to_string(),
82 policy_version: policy_version.to_string(),
83 issued_at: chrono::Utc::now().to_rfc3339(),
84 enforced,
85 pack_toml: pack_toml.to_string(),
86 signer_public_key: None,
87 signature: None,
88 })
89 }
90
91 pub fn pack(&self) -> Result<PolicyPack, PolicyError> {
93 policy::parse(&self.pack_toml)
94 }
95
96 pub fn resolved(&self) -> Result<ResolvedPolicy, PolicyError> {
98 policy::resolve(&self.pack()?)
99 }
100
101 pub fn canonical_bytes(&self) -> Result<Vec<u8>, String> {
104 let mut clone = self.clone();
105 clone.signature = None;
106 clone.signer_public_key = None;
107 serde_json::to_vec(&clone).map_err(|e| format!("serialize for signing: {e}"))
108 }
109
110 pub fn sign(&mut self) -> Result<(), String> {
112 let key =
113 crate::core::agent_identity::get_or_create_keypair(&super::org_key_id(&self.org))?;
114 self.sign_with_key(&key);
115 Ok(())
116 }
117
118 pub fn sign_with_key(&mut self, key: &SigningKey) {
121 self.signature = None;
122 self.signer_public_key = None;
123 let canonical = self.canonical_bytes().unwrap_or_default();
127 let sig = key.sign(&canonical);
128 self.signer_public_key = Some(crate::core::agent_identity::hex_encode(
129 &key.verifying_key().to_bytes(),
130 ));
131 self.signature = Some(crate::core::agent_identity::hex_encode(&sig.to_bytes()));
132 }
133
134 #[must_use]
139 pub fn verify(&self) -> OrgVerifyResult {
140 let fail = |msg: &str| OrgVerifyResult {
141 signature_valid: false,
142 signer_public_key: self.signer_public_key.clone(),
143 error: Some(msg.to_string()),
144 };
145 if self.kind != KIND {
146 return fail("not an org-policy artifact");
147 }
148 let (Some(sig_hex), Some(pk_hex)) = (&self.signature, &self.signer_public_key) else {
149 return fail("artifact is not signed");
150 };
151 let (Ok(sig_bytes), Ok(pk_bytes)) = (
152 crate::core::agent_identity::hex_decode(sig_hex),
153 crate::core::agent_identity::hex_decode(pk_hex),
154 ) else {
155 return fail("malformed signature or public key hex");
156 };
157 let canonical = match self.canonical_bytes() {
158 Ok(c) => c,
159 Err(e) => return fail(&e),
160 };
161 if crate::core::agent_identity::verify_signature(&pk_bytes, &canonical, &sig_bytes) {
162 OrgVerifyResult {
163 signature_valid: true,
164 signer_public_key: Some(pk_hex.clone()),
165 error: None,
166 }
167 } else {
168 fail("signature does not match payload (tampered or wrong key)")
169 }
170 }
171
172 pub fn to_json(&self) -> Result<String, String> {
174 serde_json::to_string_pretty(self).map_err(|e| format!("serialize org policy: {e}"))
175 }
176
177 pub fn from_json(text: &str) -> Result<Self, String> {
179 let parsed: Self = serde_json::from_str(text)
180 .map_err(|e| format!("not a valid org-policy artifact: {e}"))?;
181 if parsed.kind != KIND {
182 return Err(format!(
183 "wrong artifact kind '{}' (expected '{KIND}')",
184 parsed.kind
185 ));
186 }
187 Ok(parsed)
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 const PACK: &str = r#"
196name = "acme-floor"
197version = "1.0.0"
198description = "ACME org floor"
199extends = "strict-redaction"
200
201[context]
202deny_tools = ["ctx_url_read"]
203"#;
204
205 fn key() -> SigningKey {
206 let mut seed = [0u8; 32];
207 getrandom::fill(&mut seed).unwrap();
208 SigningKey::from_bytes(&seed)
209 }
210
211 #[test]
212 fn build_rejects_invalid_pack() {
213 let err = OrgPolicyV1::build("acme", "1", true, "not = valid = toml");
214 assert!(err.is_err());
215 }
216
217 #[test]
218 fn sign_then_verify_roundtrips() {
219 let mut a = OrgPolicyV1::build("acme", "2026.06.1", true, PACK).unwrap();
220 a.sign_with_key(&key());
221 assert!(a.verify().signature_valid);
222 }
223
224 #[test]
225 fn verify_detects_tampered_pack_body() {
226 let mut a = OrgPolicyV1::build("acme", "1", true, PACK).unwrap();
227 a.sign_with_key(&key());
228 a.pack_toml = a.pack_toml.replace("ctx_url_read", "ctx_read");
229 assert!(
230 !a.verify().signature_valid,
231 "editing the pack body must break the signature"
232 );
233 }
234
235 #[test]
236 fn verify_detects_flipped_enforced_flag() {
237 let mut a = OrgPolicyV1::build("acme", "1", true, PACK).unwrap();
238 a.sign_with_key(&key());
239 a.enforced = false;
240 assert!(!a.verify().signature_valid);
241 }
242
243 #[test]
244 fn json_roundtrip_preserves_and_verifies() {
245 let mut a = OrgPolicyV1::build("acme", "1", true, PACK).unwrap();
246 a.sign_with_key(&key());
247 let json = a.to_json().unwrap();
248 let loaded = OrgPolicyV1::from_json(&json).unwrap();
249 assert_eq!(loaded, a);
250 assert!(loaded.verify().signature_valid);
251 }
252
253 #[test]
254 fn from_json_rejects_foreign_kind() {
255 let json = r#"{"schema_version":1,"kind":"something-else","org":"x","policy_version":"1","issued_at":"t","enforced":false,"pack_toml":""}"#;
256 assert!(OrgPolicyV1::from_json(json).is_err());
257 }
258
259 #[test]
260 fn resolved_folds_extends_chain() {
261 let a = OrgPolicyV1::build("acme", "1", true, PACK).unwrap();
262 let r = a.resolved().unwrap();
263 assert!(r.deny_tools.contains(&"ctx_url_read".to_string()));
265 assert!(!r.redaction.is_empty());
266 }
267}