Skip to main content

lean_ctx/core/policy/org/
model.rs

1//! The signed org-policy artifact (GL #674).
2//!
3//! [`OrgPolicyV1`] is how an organisation distributes one **central, signed**
4//! policy pack to every endpoint. The admin authors a normal pack
5//! ([`crate::core::policy::PolicyPack`]), wraps its TOML source in this artifact
6//! and **Ed25519-signs** it; clients that have pinned the org's public key
7//! ([`super::trust`]) verify the signature **offline** before the runtime folds
8//! the pack in as an un-bypassable *floor* ([`crate::core::policy::floor`]).
9//!
10//! Signing mirrors [`crate::core::savings_ledger::signed_batch`] and the
11//! compliance report: the two signature fields are cleared while computing the
12//! canonical bytes, so a verifier reproduces the exact signed payload from the
13//! artifact alone. The authoritative content is `pack_toml` — the verbatim pack
14//! source — which every client re-parses and re-validates itself, so a tampered
15//! pack body fails both validation *and* the signature.
16
17use 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/// Outcome of verifying an [`OrgPolicyV1`] signature — offline, no network.
26#[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/// A signed, centrally distributed org policy.
34///
35/// `signature` / `signer_public_key` are excluded from the signed payload (set
36/// to `None` while computing the canonical bytes), exactly like the other
37/// signed artifacts in the engine.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct OrgPolicyV1 {
40    pub schema_version: u32,
41    /// Discriminator so a verifier can refuse unrelated signed JSON.
42    pub kind: String,
43    /// Organisation identifier (`acme`) — also selects the signing key.
44    pub org: String,
45    /// Admin-set distribution version (`2026.06.1`) — lets a client see which
46    /// rollout it currently holds (independent of the pack's own `version`).
47    pub policy_version: String,
48    /// When the admin signed this rollout (RFC 3339).
49    pub issued_at: String,
50    /// When `true`, a client that has pinned this org's key MUST apply the pack
51    /// as a floor (the runtime does; this flag is the admin's declared intent
52    /// and is surfaced by `policy org status`).
53    pub enforced: bool,
54    /// The authoritative pack source (verbatim TOML). Re-parsed + re-validated
55    /// client-side, so the body cannot be swapped without breaking validation.
56    pub pack_toml: String,
57    /// Ed25519 public key of the signing org key (hex). `None` until signed.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub signer_public_key: Option<String>,
60    /// Ed25519 signature over the canonical bytes (hex). `None` until signed.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub signature: Option<String>,
63}
64
65impl OrgPolicyV1 {
66    /// Build an unsigned artifact from an authored pack source. The TOML is
67    /// parsed + validated + resolved up front so an admin never distributes a
68    /// pack that would be rejected on the endpoint.
69    pub fn build(
70        org: &str,
71        policy_version: &str,
72        enforced: bool,
73        pack_toml: &str,
74    ) -> Result<Self, PolicyError> {
75        // Validate the body is a resolvable pack before we wrap/sign it.
76        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    /// The wrapped pack, re-parsed and validated from `pack_toml`.
92    pub fn pack(&self) -> Result<PolicyPack, PolicyError> {
93        policy::parse(&self.pack_toml)
94    }
95
96    /// The wrapped pack, fully resolved (its `extends` chain folded in).
97    pub fn resolved(&self) -> Result<ResolvedPolicy, PolicyError> {
98        policy::resolve(&self.pack()?)
99    }
100
101    /// Deterministic bytes that get signed/verified: the whole struct with the
102    /// two signature fields cleared. Identical on sign and verify.
103    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    /// Sign with the org signing key from the keystore (created on first use).
111    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    /// Sign with an explicit key (used by `sign` and by hermetic tests). The
119    /// public key is embedded so the artifact is self-verifying.
120    pub fn sign_with_key(&mut self, key: &SigningKey) {
121        self.signature = None;
122        self.signer_public_key = None;
123        // `canonical_bytes` cannot fail here: the struct is a plain value with
124        // both signature fields cleared. Fall back to an empty payload on the
125        // impossible serialize error rather than panicking in the hot CLI path.
126        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    /// Verify the embedded signature against the embedded public key — offline,
135    /// no audit trail, no network. A failure means the artifact was altered or
136    /// was never validly signed. Trust (is this key *ours*?) is a separate
137    /// check in [`super::trust`].
138    #[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    /// Serialize to the pretty JSON artifact written to disk / distributed.
173    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    /// Parse an artifact, rejecting unrelated JSON by `kind`.
178    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        // strict-redaction lineage + the pack's own deny accumulate.
264        assert!(r.deny_tools.contains(&"ctx_url_read".to_string()));
265        assert!(!r.redaction.is_empty());
266    }
267}