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}