1use crate::envelope::{hosted_memory_encryption_required, MemoryEnvelopeMetadata};
2use crate::key_lifecycle::{
3 evaluate_memory_key_lifecycle, MemoryKeyLifecycleDecision, MemoryKeyLifecycleOutcome,
4 MemoryKeyLifecyclePolicy,
5};
6use crate::types::{MemoryError, MemoryResult, MemoryTenantScope};
7use serde::{Deserialize, Serialize};
8use tandem_enterprise_contract::DataClass;
9
10const MEMORY_DECRYPT_PROVIDER_ENV: &str = "TANDEM_MEMORY_DECRYPT_PROVIDER";
11const MEMORY_DECRYPT_PRINCIPAL_ID_ENV: &str = "TANDEM_MEMORY_DECRYPT_PRINCIPAL_ID";
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum MemoryDecryptPurpose {
16 RetrievalGateway,
17 IngestionWorker,
18 RuntimeWorker,
19 Migration,
20 BreakGlass,
21 KeyAdministration,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum MemorySecretFamily {
27 MemoryEnvelope,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct MemoryDecryptPrincipal {
32 pub principal_id: String,
33 pub purpose: MemoryDecryptPurpose,
34 pub tenant_scope: MemoryTenantScope,
35 pub allowed_data_classes: Vec<DataClass>,
36 #[serde(default)]
37 pub allowed_source_binding_ids: Vec<String>,
38}
39
40impl MemoryDecryptPrincipal {
41 pub fn retrieval_gateway(
42 principal_id: impl Into<String>,
43 tenant_scope: MemoryTenantScope,
44 allowed_data_classes: Vec<DataClass>,
45 allowed_source_binding_ids: Vec<String>,
46 ) -> Self {
47 Self {
48 principal_id: principal_id.into(),
49 purpose: MemoryDecryptPurpose::RetrievalGateway,
50 tenant_scope,
51 allowed_data_classes,
52 allowed_source_binding_ids,
53 }
54 }
55
56 fn validate(&self) -> MemoryResult<()> {
57 if is_wildcard_or_blank(&self.principal_id) {
58 return Err(MemoryError::InvalidConfig(
59 "memory decrypt principal id must be explicit".to_string(),
60 ));
61 }
62 validate_tenant_scope(&self.tenant_scope)?;
63 if self.allowed_data_classes.is_empty() {
64 return Err(MemoryError::InvalidConfig(
65 "memory decrypt principal requires explicit data classes".to_string(),
66 ));
67 }
68 if self.purpose == MemoryDecryptPurpose::KeyAdministration {
69 return Err(MemoryError::InvalidConfig(
70 "key administration principal cannot unwrap memory DEKs".to_string(),
71 ));
72 }
73 if self
74 .allowed_source_binding_ids
75 .iter()
76 .any(|value| is_wildcard_or_blank(value))
77 {
78 return Err(MemoryError::InvalidConfig(
79 "memory decrypt source grants must not use wildcard values".to_string(),
80 ));
81 }
82 Ok(())
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct MemoryDecryptBrokerConfig {
88 pub provider: String,
89 pub runtime_principal_id: String,
90 pub secret_family: MemorySecretFamily,
91 pub hosted_required: bool,
92}
93
94impl MemoryDecryptBrokerConfig {
95 pub fn local_disabled() -> Self {
96 Self {
97 provider: "disabled".to_string(),
98 runtime_principal_id: "local".to_string(),
99 secret_family: MemorySecretFamily::MemoryEnvelope,
100 hosted_required: false,
101 }
102 }
103
104 pub fn hosted(
105 provider: impl Into<String>,
106 runtime_principal_id: impl Into<String>,
107 ) -> MemoryResult<Self> {
108 let config = Self {
109 provider: provider.into(),
110 runtime_principal_id: runtime_principal_id.into(),
111 secret_family: MemorySecretFamily::MemoryEnvelope,
112 hosted_required: true,
113 };
114 config.validate()?;
115 Ok(config)
116 }
117
118 pub fn from_env() -> MemoryResult<Self> {
119 let hosted_required = hosted_memory_encryption_required();
120 let provider = std::env::var(MEMORY_DECRYPT_PROVIDER_ENV).unwrap_or_default();
121 let principal_id = std::env::var(MEMORY_DECRYPT_PRINCIPAL_ID_ENV).unwrap_or_default();
122 if !hosted_required && provider.trim().is_empty() && principal_id.trim().is_empty() {
123 return Ok(Self::local_disabled());
124 }
125 let config = Self {
126 provider,
127 runtime_principal_id: principal_id,
128 secret_family: MemorySecretFamily::MemoryEnvelope,
129 hosted_required,
130 };
131 Ok(config)
132 }
133
134 pub fn validate(&self) -> MemoryResult<()> {
135 if self.secret_family != MemorySecretFamily::MemoryEnvelope {
136 return Err(MemoryError::InvalidConfig(
137 "memory decrypt broker must use the memory envelope secret family".to_string(),
138 ));
139 }
140 if self.hosted_required {
141 if is_wildcard_or_blank(&self.provider) || provider_is_local(&self.provider) {
142 return Err(MemoryError::InvalidConfig(
143 "hosted memory decrypt requires an explicit KMS provider".to_string(),
144 ));
145 }
146 if is_wildcard_or_blank(&self.runtime_principal_id) {
147 return Err(MemoryError::InvalidConfig(
148 "hosted memory decrypt requires a scoped runtime principal".to_string(),
149 ));
150 }
151 }
152 Ok(())
153 }
154
155 pub fn crypto_mode(&self) -> MemoryCryptoMode {
160 if self.hosted_required || !provider_is_local(&self.provider) {
161 return MemoryCryptoMode::HostedKms {
162 provider: self.provider.clone(),
163 };
164 }
165 let trimmed = self.provider.trim();
166 if trimmed.to_ascii_lowercase().starts_with("local-") {
167 return MemoryCryptoMode::LocalEncrypted {
168 provider: trimmed.to_string(),
169 };
170 }
171 MemoryCryptoMode::LocalPlaintext
172 }
173
174 pub fn describe(&self) -> String {
177 match self.crypto_mode() {
178 MemoryCryptoMode::LocalPlaintext => {
179 "memory crypto: local plaintext (single-user; relies on host/file security, no KMS)"
180 .to_string()
181 }
182 MemoryCryptoMode::LocalEncrypted { provider } => format!(
183 "memory crypto: local encrypted (provider `{provider}`; single-user, no hosted KMS)"
184 ),
185 MemoryCryptoMode::HostedKms { provider } => format!(
186 "memory crypto: hosted KMS (provider `{provider}`, principal `{}`)",
187 self.runtime_principal_id
188 ),
189 }
190 }
191
192 pub fn describe_validated(&self) -> String {
198 match self.validate() {
199 Ok(()) => self.describe(),
200 Err(err) => format!(
201 "memory crypto: misconfigured ({err}); fail-closed — memory decrypt requests will be rejected"
202 ),
203 }
204 }
205}
206
207fn provider_is_local(provider: &str) -> bool {
210 let trimmed = provider.trim();
211 trimmed.is_empty()
212 || trimmed.eq_ignore_ascii_case("disabled")
213 || trimmed.to_ascii_lowercase().starts_with("local")
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(rename_all = "snake_case", tag = "mode")]
219pub enum MemoryCryptoMode {
220 LocalPlaintext,
223 LocalEncrypted { provider: String },
226 HostedKms { provider: String },
228}
229
230impl MemoryCryptoMode {
231 pub fn is_hosted(&self) -> bool {
232 matches!(self, MemoryCryptoMode::HostedKms { .. })
233 }
234
235 pub fn is_local(&self) -> bool {
236 !self.is_hosted()
237 }
238}
239
240pub fn memory_crypto_startup_diagnostic() -> String {
243 match MemoryDecryptBrokerConfig::from_env() {
244 Ok(config) => config.describe_validated(),
247 Err(err) => format!("memory crypto: configuration error ({err})"),
248 }
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct MemoryDecryptRequest {
253 pub envelope: MemoryEnvelopeMetadata,
254 pub tenant_scope: MemoryTenantScope,
255 pub principal: MemoryDecryptPrincipal,
256 pub policy_decision_id: String,
257 pub audit_id: String,
258 #[serde(default)]
259 pub break_glass_requested: bool,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub key_lifecycle_policy: Option<MemoryKeyLifecyclePolicy>,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265pub struct MemoryDekUnwrapTicket {
266 pub provider: String,
267 pub runtime_principal_id: String,
268 pub principal_id: String,
269 pub purpose: MemoryDecryptPurpose,
270 pub key_scope_id: String,
271 pub kek_id: String,
272 pub kek_version: String,
273 pub wrapped_dek: String,
274 pub algorithm: String,
275 pub encryption_context_hash: String,
276 pub policy_decision_id: String,
277 pub audit_id: String,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub key_lifecycle_decision: Option<MemoryKeyLifecycleDecision>,
280}
281
282pub trait MemoryDekUnwrapProvider {
283 fn provider_id(&self) -> &str;
284 fn secret_family(&self) -> MemorySecretFamily;
285 fn unwrap_dek(&self, ticket: &MemoryDekUnwrapTicket) -> MemoryResult<Vec<u8>>;
286}
287
288pub type MemoryDekUnwrapProviderBox = Box<dyn MemoryDekUnwrapProvider + Send + Sync>;
289
290impl MemoryDecryptBrokerConfig {
291 pub fn build_dek_unwrap_provider(&self) -> MemoryResult<Option<MemoryDekUnwrapProviderBox>> {
292 crate::kms_providers::memory_dek_unwrap_provider_from_config(self)
293 }
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
297#[serde(rename_all = "snake_case")]
298pub enum MemoryDecryptAuditOutcome {
299 Allowed,
300 Denied,
301 Noop,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305pub struct MemoryDecryptAuditEvent {
306 pub outcome: MemoryDecryptAuditOutcome,
307 pub reason: String,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub provider: Option<String>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub runtime_principal_id: Option<String>,
312 pub principal_id: String,
313 pub purpose: MemoryDecryptPurpose,
314 pub org_id: String,
315 pub workspace_id: String,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub deployment_id: Option<String>,
318 pub data_class: DataClass,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub source_binding_id: Option<String>,
321 pub policy_decision_id: String,
322 pub audit_id: String,
323}
324
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
326pub struct MemoryDecryptAuthorization {
327 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub ticket: Option<MemoryDekUnwrapTicket>,
329 pub audit_event: MemoryDecryptAuditEvent,
330}
331
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct MemoryDecryptBroker {
334 config: MemoryDecryptBrokerConfig,
335}
336
337impl MemoryDecryptBroker {
338 pub fn new(config: MemoryDecryptBrokerConfig) -> MemoryResult<Self> {
339 Ok(Self { config })
340 }
341
342 pub fn from_env() -> MemoryResult<Self> {
343 Self::new(MemoryDecryptBrokerConfig::from_env()?)
344 }
345
346 pub fn authorize_unwrap(
347 &self,
348 request: MemoryDecryptRequest,
349 ) -> MemoryResult<Option<MemoryDekUnwrapTicket>> {
350 let authorization = self.authorize_unwrap_with_audit(request)?;
351 match authorization.audit_event.outcome {
352 MemoryDecryptAuditOutcome::Allowed | MemoryDecryptAuditOutcome::Noop => {
353 Ok(authorization.ticket)
354 }
355 MemoryDecryptAuditOutcome::Denied => {
356 Err(MemoryError::InvalidConfig(authorization.audit_event.reason))
357 }
358 }
359 }
360
361 pub fn authorize_unwrap_with_audit(
362 &self,
363 request: MemoryDecryptRequest,
364 ) -> MemoryResult<MemoryDecryptAuthorization> {
365 if !self.config.hosted_required && self.config.provider == "disabled" {
366 return Ok(MemoryDecryptAuthorization {
367 ticket: None,
368 audit_event: self.audit_event(
369 &request,
370 MemoryDecryptAuditOutcome::Noop,
371 "local memory decrypt broker disabled",
372 ),
373 });
374 }
375
376 if let Err(error) = self.config.validate() {
377 return Ok(MemoryDecryptAuthorization {
378 ticket: None,
379 audit_event: self.audit_event(
380 &request,
381 MemoryDecryptAuditOutcome::Denied,
382 &error.to_string(),
383 ),
384 });
385 }
386 if let Err(error) = request.principal.validate() {
387 return Ok(MemoryDecryptAuthorization {
388 ticket: None,
389 audit_event: self.audit_event(
390 &request,
391 MemoryDecryptAuditOutcome::Denied,
392 &error.to_string(),
393 ),
394 });
395 }
396 if let Err(error) = validate_decrypt_request(&request) {
397 return Ok(MemoryDecryptAuthorization {
398 ticket: None,
399 audit_event: self.audit_event(
400 &request,
401 MemoryDecryptAuditOutcome::Denied,
402 &error.to_string(),
403 ),
404 });
405 }
406
407 let lifecycle_decision = match request.key_lifecycle_policy.as_ref() {
408 Some(policy) => {
409 let decision = evaluate_memory_key_lifecycle(
410 &request.envelope,
411 &request.principal.principal_id,
412 request.break_glass_requested,
413 policy,
414 );
415 if decision.outcome == MemoryKeyLifecycleOutcome::Denied {
416 return Ok(MemoryDecryptAuthorization {
417 ticket: None,
418 audit_event: self.audit_event(
419 &request,
420 MemoryDecryptAuditOutcome::Denied,
421 &decision.reason,
422 ),
423 });
424 }
425 Some(decision)
426 }
427 None => None,
428 };
429
430 let audit_event = self.audit_event(
431 &request,
432 MemoryDecryptAuditOutcome::Allowed,
433 "memory decrypt unwrap authorized",
434 );
435 Ok(MemoryDecryptAuthorization {
436 ticket: Some(MemoryDekUnwrapTicket {
437 provider: self.config.provider.clone(),
438 runtime_principal_id: self.config.runtime_principal_id.clone(),
439 principal_id: request.principal.principal_id,
440 purpose: request.principal.purpose,
441 key_scope_id: request.envelope.key_scope.canonical_id(),
442 kek_id: request.envelope.kek_id,
443 kek_version: request.envelope.kek_version,
444 wrapped_dek: request.envelope.wrapped_dek,
445 algorithm: request.envelope.algorithm,
446 encryption_context_hash: request.envelope.encryption_context_hash,
447 policy_decision_id: request.policy_decision_id,
448 audit_id: request.audit_id,
449 key_lifecycle_decision: lifecycle_decision,
450 }),
451 audit_event,
452 })
453 }
454
455 fn audit_event(
456 &self,
457 request: &MemoryDecryptRequest,
458 outcome: MemoryDecryptAuditOutcome,
459 reason: &str,
460 ) -> MemoryDecryptAuditEvent {
461 MemoryDecryptAuditEvent {
462 outcome,
463 reason: reason.to_string(),
464 provider: non_empty_string(&self.config.provider),
465 runtime_principal_id: non_empty_string(&self.config.runtime_principal_id),
466 principal_id: request.principal.principal_id.clone(),
467 purpose: request.principal.purpose,
468 org_id: request.tenant_scope.org_id.clone(),
469 workspace_id: request.tenant_scope.workspace_id.clone(),
470 deployment_id: request.tenant_scope.deployment_id.clone(),
471 data_class: request.envelope.key_scope.data_class,
472 source_binding_id: request.envelope.key_scope.source_binding_id.clone(),
473 policy_decision_id: request.policy_decision_id.clone(),
474 audit_id: request.audit_id.clone(),
475 }
476 }
477}
478
479fn validate_decrypt_request(request: &MemoryDecryptRequest) -> MemoryResult<()> {
480 if is_wildcard_or_blank(&request.policy_decision_id) {
481 return Err(MemoryError::InvalidConfig(
482 "memory decrypt requires a policy decision id".to_string(),
483 ));
484 }
485 if is_wildcard_or_blank(&request.audit_id) {
486 return Err(MemoryError::InvalidConfig(
487 "memory decrypt requires an audit id".to_string(),
488 ));
489 }
490 if request.policy_decision_id != request.envelope.policy_decision_id {
491 return Err(MemoryError::InvalidConfig(
492 "memory decrypt policy decision does not match envelope".to_string(),
493 ));
494 }
495 if request.audit_id != request.envelope.audit_id {
496 return Err(MemoryError::InvalidConfig(
497 "memory decrypt audit id does not match envelope".to_string(),
498 ));
499 }
500 if !tenant_scopes_match(&request.tenant_scope, &request.envelope.key_scope) {
501 return Err(MemoryError::InvalidConfig(
502 "memory decrypt tenant scope does not match envelope".to_string(),
503 ));
504 }
505 if request.principal.tenant_scope != request.tenant_scope {
506 return Err(MemoryError::InvalidConfig(
507 "memory decrypt principal tenant scope does not match request".to_string(),
508 ));
509 }
510 if !request
511 .principal
512 .allowed_data_classes
513 .contains(&request.envelope.key_scope.data_class)
514 {
515 return Err(MemoryError::InvalidConfig(
516 "memory decrypt principal lacks data-class grant".to_string(),
517 ));
518 }
519 if let Some(source_binding_id) = request.envelope.key_scope.source_binding_id.as_deref() {
520 if !request
521 .principal
522 .allowed_source_binding_ids
523 .iter()
524 .any(|allowed| allowed == source_binding_id)
525 {
526 return Err(MemoryError::InvalidConfig(
527 "memory decrypt principal lacks source-binding grant".to_string(),
528 ));
529 }
530 }
531 Ok(())
532}
533
534fn tenant_scopes_match(
535 tenant_scope: &MemoryTenantScope,
536 key_scope: &crate::envelope::MemoryKeyScope,
537) -> bool {
538 tenant_scope.org_id == key_scope.org_id
539 && tenant_scope.workspace_id == key_scope.workspace_id
540 && tenant_scope.deployment_id.as_deref().unwrap_or("")
541 == key_scope.deployment_id.as_deref().unwrap_or("")
542}
543
544fn validate_tenant_scope(tenant_scope: &MemoryTenantScope) -> MemoryResult<()> {
545 for (field, value) in [
546 ("org_id", tenant_scope.org_id.as_str()),
547 ("workspace_id", tenant_scope.workspace_id.as_str()),
548 ] {
549 if is_wildcard_or_blank(value) {
550 return Err(MemoryError::InvalidConfig(format!(
551 "memory decrypt principal must not use wildcard `{field}`"
552 )));
553 }
554 }
555 if tenant_scope
556 .deployment_id
557 .as_deref()
558 .map(is_wildcard_or_blank)
559 .unwrap_or(false)
560 {
561 return Err(MemoryError::InvalidConfig(
562 "memory decrypt principal must not use wildcard `deployment_id`".to_string(),
563 ));
564 }
565 Ok(())
566}
567
568fn is_wildcard_or_blank(value: &str) -> bool {
569 matches!(
570 value.trim().to_ascii_lowercase().as_str(),
571 "" | "*" | "all" | "global" | "default"
572 )
573}
574
575fn non_empty_string(value: &str) -> Option<String> {
576 let trimmed = value.trim();
577 if trimmed.is_empty() {
578 None
579 } else {
580 Some(trimmed.to_string())
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587 use crate::envelope::{MemoryEnvelopeMetadata, MemoryKeyScope};
588 use crate::key_lifecycle::{
589 MemoryBreakGlassGrant, MemoryKeyLifecycleOutcome, MemoryKeyLifecyclePolicy,
590 MemoryKeyScopeRevocation, MemoryKeyVersionEvidence, MemoryKeyVersionState,
591 };
592
593 fn tenant_scope() -> MemoryTenantScope {
594 MemoryTenantScope {
595 org_id: "acme".to_string(),
596 workspace_id: "finance".to_string(),
597 deployment_id: Some("prod".to_string()),
598 }
599 }
600
601 fn envelope(data_class: DataClass, source_binding_id: Option<&str>) -> MemoryEnvelopeMetadata {
602 MemoryEnvelopeMetadata {
603 key_scope: MemoryKeyScope::new(
604 &tenant_scope(),
605 data_class,
606 source_binding_id.map(ToOwned::to_owned),
607 ),
608 kek_id: "projects/acme/locations/global/keyRings/memory/cryptoKeys/finance".to_string(),
609 kek_version: "1".to_string(),
610 wrapped_dek: "wrapped".to_string(),
611 algorithm: "AES-256-GCM".to_string(),
612 encryption_context_hash: "ctx-hash".to_string(),
613 rotation_epoch: 0,
614 policy_decision_id: "decision-1".to_string(),
615 audit_id: "audit-1".to_string(),
616 }
617 }
618
619 fn broker() -> MemoryDecryptBroker {
620 MemoryDecryptBroker::new(
621 MemoryDecryptBrokerConfig::hosted("google_cloud_kms", "runtime-memory-decryptor")
622 .expect("hosted config"),
623 )
624 .expect("broker")
625 }
626
627 fn principal(data_classes: Vec<DataClass>, sources: Vec<&str>) -> MemoryDecryptPrincipal {
628 MemoryDecryptPrincipal::retrieval_gateway(
629 "kb-mcp-retrieval-gateway",
630 tenant_scope(),
631 data_classes,
632 sources.into_iter().map(ToOwned::to_owned).collect(),
633 )
634 }
635
636 fn request(
637 envelope: MemoryEnvelopeMetadata,
638 principal: MemoryDecryptPrincipal,
639 ) -> MemoryDecryptRequest {
640 MemoryDecryptRequest {
641 envelope,
642 tenant_scope: tenant_scope(),
643 principal,
644 policy_decision_id: "decision-1".to_string(),
645 audit_id: "audit-1".to_string(),
646 break_glass_requested: false,
647 key_lifecycle_policy: None,
648 }
649 }
650
651 fn active_lifecycle_policy() -> MemoryKeyLifecyclePolicy {
652 MemoryKeyLifecyclePolicy {
653 key_versions: vec![MemoryKeyVersionEvidence {
654 kek_id: "projects/acme/locations/global/keyRings/memory/cryptoKeys/finance"
655 .to_string(),
656 kek_version: "1".to_string(),
657 state: MemoryKeyVersionState::Primary,
658 rotation_epoch: 0,
659 evidence_id: "key-evidence-1".to_string(),
660 }],
661 revoked_scopes: vec![],
662 break_glass_grants: vec![],
663 minimum_rotation_epoch: 0,
664 now_ms: 1_000,
665 }
666 }
667
668 #[test]
669 fn local_disabled_broker_is_noop() {
670 let broker = MemoryDecryptBroker::new(MemoryDecryptBrokerConfig::local_disabled())
671 .expect("local broker");
672 let ticket = broker
673 .authorize_unwrap(request(
674 envelope(DataClass::Internal, None),
675 principal(vec![DataClass::Internal], vec![]),
676 ))
677 .expect("local noop");
678 assert!(ticket.is_none());
679 }
680
681 #[test]
682 fn local_default_config_reports_plaintext_mode() {
683 let config = MemoryDecryptBrokerConfig::local_disabled();
684 assert_eq!(config.crypto_mode(), MemoryCryptoMode::LocalPlaintext);
685 assert!(config.crypto_mode().is_local());
686 assert!(config.describe().contains("local plaintext"));
687 config.validate().expect("local plaintext config is valid");
688 }
689
690 #[test]
691 fn hosted_config_reports_hosted_kms_mode() {
692 let config =
693 MemoryDecryptBrokerConfig::hosted("google_cloud_kms", "runtime-memory-decryptor")
694 .expect("hosted config");
695 match config.crypto_mode() {
696 MemoryCryptoMode::HostedKms { provider } => assert_eq!(provider, "google_cloud_kms"),
697 other => panic!("expected hosted KMS mode, got {other:?}"),
698 }
699 assert!(config.crypto_mode().is_hosted());
700 assert!(config.describe().contains("hosted KMS"));
701 }
702
703 #[test]
704 fn local_encryption_provider_reports_local_encrypted_mode() {
705 let config = MemoryDecryptBrokerConfig {
706 provider: "local-passphrase".to_string(),
707 runtime_principal_id: "local".to_string(),
708 secret_family: MemorySecretFamily::MemoryEnvelope,
709 hosted_required: false,
710 };
711 assert!(matches!(
712 config.crypto_mode(),
713 MemoryCryptoMode::LocalEncrypted { .. }
714 ));
715 assert!(config.describe().contains("local encrypted"));
716 }
717
718 #[test]
719 fn describe_validated_surfaces_hosted_misconfiguration() {
720 let misconfigured = MemoryDecryptBrokerConfig {
723 provider: String::new(),
724 runtime_principal_id: "runtime-memory-decryptor".to_string(),
725 secret_family: MemorySecretFamily::MemoryEnvelope,
726 hosted_required: true,
727 };
728 let diagnostic = misconfigured.describe_validated();
729 assert!(
730 diagnostic.contains("misconfigured") && diagnostic.contains("fail-closed"),
731 "diagnostic={diagnostic}"
732 );
733 assert!(
734 !diagnostic.contains("hosted KMS"),
735 "must not claim hosted KMS when fail-closed: {diagnostic}"
736 );
737
738 let valid =
740 MemoryDecryptBrokerConfig::hosted("google_cloud_kms", "runtime-memory-decryptor")
741 .expect("hosted config");
742 assert!(valid.describe_validated().contains("hosted KMS"));
743 }
744
745 #[test]
746 fn hosted_mode_rejects_local_provider() {
747 let config = MemoryDecryptBrokerConfig {
749 provider: "local-passphrase".to_string(),
750 runtime_principal_id: "runtime-memory-decryptor".to_string(),
751 secret_family: MemorySecretFamily::MemoryEnvelope,
752 hosted_required: true,
753 };
754 let err = config
755 .validate()
756 .expect_err("local provider must not back hosted tenants");
757 assert!(err.to_string().contains("explicit KMS provider"));
758 }
759
760 #[test]
761 fn hosted_config_fails_closed_without_provider_or_principal() {
762 let missing_provider = MemoryDecryptBrokerConfig {
763 provider: String::new(),
764 runtime_principal_id: "runtime-memory-decryptor".to_string(),
765 secret_family: MemorySecretFamily::MemoryEnvelope,
766 hosted_required: true,
767 };
768 assert!(missing_provider.validate().is_err());
769
770 let missing_principal = MemoryDecryptBrokerConfig {
771 provider: "google_cloud_kms".to_string(),
772 runtime_principal_id: String::new(),
773 secret_family: MemorySecretFamily::MemoryEnvelope,
774 hosted_required: true,
775 };
776 assert!(missing_principal.validate().is_err());
777 }
778
779 #[test]
780 fn hosted_missing_provider_returns_denied_audit_event() {
781 let broker = MemoryDecryptBroker::new(MemoryDecryptBrokerConfig {
782 provider: String::new(),
783 runtime_principal_id: "runtime-memory-decryptor".to_string(),
784 secret_family: MemorySecretFamily::MemoryEnvelope,
785 hosted_required: true,
786 })
787 .expect("broker");
788 let authorization = broker
789 .authorize_unwrap_with_audit(request(
790 envelope(DataClass::Internal, None),
791 principal(vec![DataClass::Internal], vec![]),
792 ))
793 .expect("authorization");
794 assert!(authorization.ticket.is_none());
795 assert_eq!(
796 authorization.audit_event.outcome,
797 MemoryDecryptAuditOutcome::Denied
798 );
799 assert_eq!(
800 authorization.audit_event.principal_id,
801 "kb-mcp-retrieval-gateway"
802 );
803 assert_eq!(authorization.audit_event.audit_id, "audit-1");
804 assert!(authorization
805 .audit_event
806 .reason
807 .contains("explicit KMS provider"));
808 }
809
810 #[test]
811 fn hosted_unwrap_requires_matching_tenant_scope() {
812 let mut other_tenant = tenant_scope();
813 other_tenant.workspace_id = "hr".to_string();
814 let mut principal = principal(vec![DataClass::Internal], vec![]);
815 principal.tenant_scope = other_tenant;
816 let err = broker()
817 .authorize_unwrap(request(envelope(DataClass::Internal, None), principal))
818 .expect_err("tenant mismatch rejected");
819 assert!(err
820 .to_string()
821 .contains("principal tenant scope does not match request"));
822 }
823
824 #[test]
825 fn hosted_unwrap_requires_data_class_grant() {
826 let err = broker()
827 .authorize_unwrap(request(
828 envelope(DataClass::FinancialRecord, None),
829 principal(vec![DataClass::Internal], vec![]),
830 ))
831 .expect_err("data-class mismatch rejected");
832 assert!(err.to_string().contains("lacks data-class grant"));
833 }
834
835 #[test]
836 fn low_risk_principal_cannot_decrypt_sensitive_classes() {
837 for sensitive_class in [
838 DataClass::Restricted,
839 DataClass::Credential,
840 DataClass::FinancialRecord,
841 DataClass::Executive,
842 ] {
843 let err = broker()
844 .authorize_unwrap(request(
845 envelope(sensitive_class, None),
846 principal(vec![DataClass::Public, DataClass::Internal], vec![]),
847 ))
848 .expect_err("sensitive data class rejected");
849 assert!(err.to_string().contains("lacks data-class grant"));
850 }
851 }
852
853 #[test]
854 fn key_administration_principal_cannot_unwrap_data_keys() {
855 let mut admin = principal(vec![DataClass::Internal], vec![]);
856 admin.purpose = MemoryDecryptPurpose::KeyAdministration;
857 let err = broker()
858 .authorize_unwrap(request(envelope(DataClass::Internal, None), admin))
859 .expect_err("key admin decrypt rejected");
860 assert!(err.to_string().contains("cannot unwrap memory DEKs"));
861 }
862
863 #[test]
864 fn hosted_retrieval_requires_source_binding_grant() {
865 let err = broker()
866 .authorize_unwrap(request(
867 envelope(DataClass::Confidential, Some("drive-finance")),
868 principal(vec![DataClass::Confidential], vec!["drive-hr"]),
869 ))
870 .expect_err("source-binding mismatch rejected");
871 assert!(err.to_string().contains("lacks source-binding grant"));
872 }
873
874 #[test]
875 fn hosted_unwrap_ticket_is_bound_to_scope_policy_and_audit() {
876 let ticket = broker()
877 .authorize_unwrap(request(
878 envelope(DataClass::Confidential, Some("drive-finance")),
879 principal(vec![DataClass::Confidential], vec!["drive-finance"]),
880 ))
881 .expect("authorized")
882 .expect("ticket");
883 assert_eq!(ticket.provider, "google_cloud_kms");
884 assert_eq!(ticket.runtime_principal_id, "runtime-memory-decryptor");
885 assert_eq!(ticket.principal_id, "kb-mcp-retrieval-gateway");
886 assert_eq!(ticket.policy_decision_id, "decision-1");
887 assert_eq!(ticket.audit_id, "audit-1");
888 assert_eq!(ticket.wrapped_dek, "wrapped");
889 assert_eq!(ticket.algorithm, "AES-256-GCM");
890 assert!(ticket
891 .key_scope_id
892 .contains("/confidential/source/drive-finance"));
893 }
894
895 #[test]
896 fn hosted_unwrap_denies_revoked_key_scope() {
897 let envelope = envelope(DataClass::Confidential, Some("drive-finance"));
898 let mut lifecycle = active_lifecycle_policy();
899 lifecycle.revoked_scopes.push(MemoryKeyScopeRevocation {
900 key_scope: envelope.key_scope.clone(),
901 reason: "source revoked".to_string(),
902 revoked_by: "security-admin".to_string(),
903 revoked_at_ms: 900,
904 evidence_id: "revocation-1".to_string(),
905 });
906 let mut request = request(
907 envelope,
908 principal(vec![DataClass::Confidential], vec!["drive-finance"]),
909 );
910 request.key_lifecycle_policy = Some(lifecycle);
911
912 let authorization = broker()
913 .authorize_unwrap_with_audit(request)
914 .expect("authorization");
915 assert!(authorization.ticket.is_none());
916 assert_eq!(
917 authorization.audit_event.outcome,
918 MemoryDecryptAuditOutcome::Denied
919 );
920 assert!(authorization
921 .audit_event
922 .reason
923 .contains("scope is revoked"));
924 }
925
926 #[test]
927 fn hosted_unwrap_denies_disabled_key_without_break_glass() {
928 let mut lifecycle = active_lifecycle_policy();
929 lifecycle.key_versions[0].state = MemoryKeyVersionState::Disabled;
930 let mut request = request(
931 envelope(DataClass::Confidential, Some("drive-finance")),
932 principal(vec![DataClass::Confidential], vec!["drive-finance"]),
933 );
934 request.key_lifecycle_policy = Some(lifecycle);
935
936 let err = broker()
937 .authorize_unwrap(request)
938 .expect_err("disabled key denied");
939 assert!(err.to_string().contains("not active"));
940 }
941
942 #[test]
943 fn hosted_unwrap_allows_scoped_break_glass_for_disabled_key() {
944 let envelope = envelope(DataClass::Confidential, Some("drive-finance"));
945 let mut lifecycle = active_lifecycle_policy();
946 lifecycle.key_versions[0].state = MemoryKeyVersionState::Disabled;
947 lifecycle.break_glass_grants.push(MemoryBreakGlassGrant {
948 actor_id: "kb-mcp-retrieval-gateway".to_string(),
949 approval_id: "approval-1".to_string(),
950 reason: "customer incident".to_string(),
951 key_scope: envelope.key_scope.clone(),
952 expires_at_ms: 2_000,
953 max_export_items: 5,
954 evidence_id: "break-glass-1".to_string(),
955 });
956 let mut request = request(
957 envelope,
958 principal(vec![DataClass::Confidential], vec!["drive-finance"]),
959 );
960 request.break_glass_requested = true;
961 request.key_lifecycle_policy = Some(lifecycle);
962
963 let ticket = broker()
964 .authorize_unwrap(request)
965 .expect("break-glass authorized")
966 .expect("ticket");
967 assert_eq!(
968 ticket
969 .key_lifecycle_decision
970 .as_ref()
971 .map(|decision| decision.outcome),
972 Some(MemoryKeyLifecycleOutcome::BreakGlassAllowed)
973 );
974 }
975
976 #[test]
977 fn hosted_unwrap_rejects_policy_or_audit_substitution() {
978 let mut request = request(
979 envelope(DataClass::Internal, None),
980 principal(vec![DataClass::Internal], vec![]),
981 );
982 request.policy_decision_id = "decision-2".to_string();
983 let err = broker()
984 .authorize_unwrap(request)
985 .expect_err("policy substitution rejected");
986 assert!(err
987 .to_string()
988 .contains("policy decision does not match envelope"));
989 }
990}