Skip to main content

tandem_memory/
decrypt_broker.rs

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    /// Classify the effective memory crypto mode for operator diagnostics and
156    /// guard checks. Hosted mode is selected whenever hosted encryption is
157    /// required or a non-local KMS provider is configured; otherwise the runtime
158    /// is local (plaintext, or a local file/passphrase-backed provider).
159    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    /// Human-readable startup diagnostic so operators know whether memory is
175    /// local plaintext, local encrypted, or hosted-KMS encrypted.
176    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    /// Like [`describe`](Self::describe), but surfaces a fail-closed
193    /// misconfiguration instead of claiming a protected mode when the config is
194    /// invalid (e.g. hosted encryption is required but no valid KMS provider /
195    /// principal is configured). Boot diagnostics should use this so operators
196    /// see the hosted misconfiguration rather than a false "hosted KMS" claim.
197    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
207/// Returns true when a provider name denotes a local (non-hosted) provider:
208/// blank, `disabled`, or any `local`-prefixed provider.
209fn 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/// Operator-facing classification of how memory secrets are protected at rest.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(rename_all = "snake_case", tag = "mode")]
219pub enum MemoryCryptoMode {
220    /// Local/single-user runtime; memory is stored as plaintext and relies on
221    /// host/file security. No KMS credentials are required.
222    LocalPlaintext,
223    /// Local/single-user runtime with an explicitly configured local
224    /// file/passphrase-backed provider (e.g. `local-passphrase`).
225    LocalEncrypted { provider: String },
226    /// Hosted/multi-tenant runtime; memory DEKs are governed by an external KMS.
227    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
240/// Resolve the memory crypto mode from the environment and render the startup
241/// diagnostic string operators should see at boot.
242pub fn memory_crypto_startup_diagnostic() -> String {
243    match MemoryDecryptBrokerConfig::from_env() {
244        // Validate before describing so a hosted-required-but-misconfigured
245        // runtime reports its fail-closed state instead of a false "hosted KMS".
246        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        // hosted_required but no valid KMS provider: the boot diagnostic must
721        // report the fail-closed misconfiguration, not claim "hosted KMS".
722        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        // A valid hosted config still describes hosted KMS.
739        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        // A local provider must never back a hosted/multi-tenant runtime.
748        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}