Skip to main content

tandem_memory/
key_lifecycle.rs

1use crate::envelope::{MemoryEnvelopeMetadata, MemoryKeyScope};
2use crate::types::{MemoryError, MemoryResult};
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum MemoryKeyVersionState {
8    Primary,
9    Active,
10    Disabled,
11    Revoked,
12    Destroyed,
13}
14
15impl MemoryKeyVersionState {
16    pub fn allows_normal_decrypt(self) -> bool {
17        matches!(self, Self::Primary | Self::Active)
18    }
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct MemoryKeyVersionEvidence {
23    pub kek_id: String,
24    pub kek_version: String,
25    pub state: MemoryKeyVersionState,
26    pub rotation_epoch: u64,
27    pub evidence_id: String,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct MemoryKeyScopeRevocation {
32    pub key_scope: MemoryKeyScope,
33    pub reason: String,
34    pub revoked_by: String,
35    pub revoked_at_ms: u64,
36    pub evidence_id: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct MemoryBreakGlassGrant {
41    pub actor_id: String,
42    pub approval_id: String,
43    pub reason: String,
44    pub key_scope: MemoryKeyScope,
45    pub expires_at_ms: u64,
46    pub max_export_items: u32,
47    pub evidence_id: String,
48}
49
50impl MemoryBreakGlassGrant {
51    pub fn is_active_for(&self, now_ms: u64, actor_id: &str, key_scope: &MemoryKeyScope) -> bool {
52        self.expires_at_ms > now_ms
53            && self.actor_id == actor_id
54            && key_scope_matches(&self.key_scope, key_scope)
55            && !self.approval_id.trim().is_empty()
56            && !self.reason.trim().is_empty()
57            && self.max_export_items > 0
58    }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
62pub struct MemoryKeyLifecyclePolicy {
63    #[serde(default)]
64    pub key_versions: Vec<MemoryKeyVersionEvidence>,
65    #[serde(default)]
66    pub revoked_scopes: Vec<MemoryKeyScopeRevocation>,
67    #[serde(default)]
68    pub break_glass_grants: Vec<MemoryBreakGlassGrant>,
69    pub minimum_rotation_epoch: u64,
70    pub now_ms: u64,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum MemoryKeyLifecycleOutcome {
76    Allowed,
77    BreakGlassAllowed,
78    Denied,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct MemoryKeyLifecycleDecision {
83    pub outcome: MemoryKeyLifecycleOutcome,
84    pub reason: String,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub evidence_id: Option<String>,
87}
88
89impl MemoryKeyLifecycleDecision {
90    pub fn allowed(reason: impl Into<String>, evidence_id: Option<String>) -> Self {
91        Self {
92            outcome: MemoryKeyLifecycleOutcome::Allowed,
93            reason: reason.into(),
94            evidence_id,
95        }
96    }
97
98    pub fn break_glass(reason: impl Into<String>, evidence_id: Option<String>) -> Self {
99        Self {
100            outcome: MemoryKeyLifecycleOutcome::BreakGlassAllowed,
101            reason: reason.into(),
102            evidence_id,
103        }
104    }
105
106    pub fn denied(reason: impl Into<String>, evidence_id: Option<String>) -> Self {
107        Self {
108            outcome: MemoryKeyLifecycleOutcome::Denied,
109            reason: reason.into(),
110            evidence_id,
111        }
112    }
113
114    pub fn into_result(self) -> MemoryResult<Self> {
115        if self.outcome == MemoryKeyLifecycleOutcome::Denied {
116            return Err(MemoryError::InvalidConfig(self.reason));
117        }
118        Ok(self)
119    }
120}
121
122pub fn evaluate_memory_key_lifecycle(
123    envelope: &MemoryEnvelopeMetadata,
124    actor_id: &str,
125    break_glass_requested: bool,
126    policy: &MemoryKeyLifecyclePolicy,
127) -> MemoryKeyLifecycleDecision {
128    if envelope.rotation_epoch < policy.minimum_rotation_epoch {
129        return MemoryKeyLifecycleDecision::denied("memory key rotation epoch is stale", None);
130    }
131
132    if let Some(revocation) = policy
133        .revoked_scopes
134        .iter()
135        .find(|revocation| key_scope_matches(&revocation.key_scope, &envelope.key_scope))
136    {
137        if break_glass_requested {
138            if let Some(grant) = policy
139                .break_glass_grants
140                .iter()
141                .find(|grant| grant.is_active_for(policy.now_ms, actor_id, &envelope.key_scope))
142            {
143                return MemoryKeyLifecycleDecision::break_glass(
144                    "memory key scope revocation bypassed by scoped break-glass grant",
145                    Some(grant.evidence_id.clone()),
146                );
147            }
148        }
149        return MemoryKeyLifecycleDecision::denied(
150            "memory key scope is revoked",
151            Some(revocation.evidence_id.clone()),
152        );
153    }
154
155    let Some(version) = policy.key_versions.iter().find(|version| {
156        version.kek_id == envelope.kek_id && version.kek_version == envelope.kek_version
157    }) else {
158        return MemoryKeyLifecycleDecision::denied("memory key version evidence is missing", None);
159    };
160
161    if version.rotation_epoch < envelope.rotation_epoch {
162        return MemoryKeyLifecycleDecision::denied(
163            "memory key version evidence is older than envelope rotation epoch",
164            Some(version.evidence_id.clone()),
165        );
166    }
167
168    if version.state.allows_normal_decrypt() {
169        return MemoryKeyLifecycleDecision::allowed(
170            "memory key version is active",
171            Some(version.evidence_id.clone()),
172        );
173    }
174
175    if break_glass_requested {
176        if let Some(grant) = policy
177            .break_glass_grants
178            .iter()
179            .find(|grant| grant.is_active_for(policy.now_ms, actor_id, &envelope.key_scope))
180        {
181            return MemoryKeyLifecycleDecision::break_glass(
182                "memory key version bypassed by scoped break-glass grant",
183                Some(grant.evidence_id.clone()),
184            );
185        }
186    }
187
188    MemoryKeyLifecycleDecision::denied(
189        "memory key version is not active for normal decrypt",
190        Some(version.evidence_id.clone()),
191    )
192}
193
194pub fn key_scope_matches(expected: &MemoryKeyScope, actual: &MemoryKeyScope) -> bool {
195    expected.org_id == actual.org_id
196        && expected.workspace_id == actual.workspace_id
197        && expected.deployment_id.as_deref().unwrap_or("")
198            == actual.deployment_id.as_deref().unwrap_or("")
199        && expected.data_class == actual.data_class
200        && expected.source_binding_id.as_deref().unwrap_or("")
201            == actual.source_binding_id.as_deref().unwrap_or("")
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::envelope::{MemoryEnvelopeMetadata, MemoryKeyScope};
208    use crate::types::MemoryTenantScope;
209    use tandem_enterprise_contract::DataClass;
210
211    fn tenant_scope() -> MemoryTenantScope {
212        MemoryTenantScope {
213            org_id: "acme".to_string(),
214            workspace_id: "finance".to_string(),
215            deployment_id: Some("prod".to_string()),
216        }
217    }
218
219    fn envelope() -> MemoryEnvelopeMetadata {
220        MemoryEnvelopeMetadata {
221            key_scope: MemoryKeyScope::new(
222                &tenant_scope(),
223                DataClass::FinancialRecord,
224                Some("drive-finance".to_string()),
225            ),
226            kek_id: "kek-finance".to_string(),
227            kek_version: "7".to_string(),
228            wrapped_dek: "wrapped".to_string(),
229            algorithm: "AES-256-GCM".to_string(),
230            encryption_context_hash: "ctx-hash".to_string(),
231            rotation_epoch: 4,
232            policy_decision_id: "decision-1".to_string(),
233            audit_id: "audit-1".to_string(),
234        }
235    }
236
237    fn active_policy() -> MemoryKeyLifecyclePolicy {
238        MemoryKeyLifecyclePolicy {
239            key_versions: vec![MemoryKeyVersionEvidence {
240                kek_id: "kek-finance".to_string(),
241                kek_version: "7".to_string(),
242                state: MemoryKeyVersionState::Primary,
243                rotation_epoch: 4,
244                evidence_id: "key-evidence-1".to_string(),
245            }],
246            revoked_scopes: vec![],
247            break_glass_grants: vec![],
248            minimum_rotation_epoch: 0,
249            now_ms: 1_000,
250        }
251    }
252
253    #[test]
254    fn active_key_version_allows_normal_decrypt() {
255        let decision =
256            evaluate_memory_key_lifecycle(&envelope(), "actor-1", false, &active_policy());
257        assert_eq!(decision.outcome, MemoryKeyLifecycleOutcome::Allowed);
258        assert_eq!(decision.evidence_id.as_deref(), Some("key-evidence-1"));
259    }
260
261    #[test]
262    fn disabled_key_version_denies_normal_decrypt() {
263        let mut policy = active_policy();
264        policy.key_versions[0].state = MemoryKeyVersionState::Disabled;
265        let decision = evaluate_memory_key_lifecycle(&envelope(), "actor-1", false, &policy);
266        assert_eq!(decision.outcome, MemoryKeyLifecycleOutcome::Denied);
267        assert!(decision.reason.contains("not active"));
268    }
269
270    #[test]
271    fn revoked_scope_denies_normal_decrypt() {
272        let mut policy = active_policy();
273        policy.revoked_scopes.push(MemoryKeyScopeRevocation {
274            key_scope: envelope().key_scope,
275            reason: "connector revoked".to_string(),
276            revoked_by: "security-admin".to_string(),
277            revoked_at_ms: 900,
278            evidence_id: "revocation-1".to_string(),
279        });
280        let decision = evaluate_memory_key_lifecycle(&envelope(), "actor-1", false, &policy);
281        assert_eq!(decision.outcome, MemoryKeyLifecycleOutcome::Denied);
282        assert_eq!(decision.evidence_id.as_deref(), Some("revocation-1"));
283    }
284
285    #[test]
286    fn break_glass_requires_matching_active_grant() {
287        let mut policy = active_policy();
288        policy.key_versions[0].state = MemoryKeyVersionState::Disabled;
289        policy.break_glass_grants.push(MemoryBreakGlassGrant {
290            actor_id: "support-1".to_string(),
291            approval_id: "approval-1".to_string(),
292            reason: "customer incident".to_string(),
293            key_scope: envelope().key_scope,
294            expires_at_ms: 2_000,
295            max_export_items: 5,
296            evidence_id: "break-glass-1".to_string(),
297        });
298
299        let denied = evaluate_memory_key_lifecycle(&envelope(), "support-1", false, &policy);
300        assert_eq!(denied.outcome, MemoryKeyLifecycleOutcome::Denied);
301
302        let allowed = evaluate_memory_key_lifecycle(&envelope(), "support-1", true, &policy);
303        assert_eq!(
304            allowed.outcome,
305            MemoryKeyLifecycleOutcome::BreakGlassAllowed
306        );
307        assert_eq!(allowed.evidence_id.as_deref(), Some("break-glass-1"));
308    }
309}