Skip to main content

khive_runtime/
mailbox_view.rs

1use std::sync::Arc;
2
3use khive_gate::{check_with_mailbox_policy, mailbox_read_owner};
4use khive_storage::note::{Note, NoteMailboxScope};
5use serde_json::Value;
6use sha2::{Digest, Sha256};
7
8use crate::{
9    engine_config::ActorConfig, ActorRef, Gate, GateDecision, GateError, GateRef, GateRequest,
10    KhiveRuntime, MailboxPolicyError, MailboxReadGate, NamespaceToken, RuntimeError, RuntimeResult,
11};
12
13/// An authorized mailbox selection. This never replaces the real caller token.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct MailboxView {
16    pub actor_id: String,
17    pub delegated: bool,
18}
19
20impl MailboxView {
21    fn legacy_local(&self, token: &NamespaceToken) -> bool {
22        !self.delegated && token.actor().is_anonymous() && token.actor().id == "local"
23    }
24
25    /// The same partition as [`Self::permits_message_note`], in the form a
26    /// note store applies inside its query so a scan window never holds rows
27    /// this view hides.
28    pub fn note_scope(&self, token: &NamespaceToken) -> NoteMailboxScope {
29        NoteMailboxScope {
30            actor_id: self.actor_id.clone(),
31            legacy_local: self.legacy_local(token),
32        }
33    }
34
35    /// The same actor partitions as Comm's inbox and sent views. Generic note
36    /// reads apply this before returning message rows, including broad
37    /// kind=note reads that encounter a message row.
38    pub fn permits_message_note(&self, token: &NamespaceToken, note: &Note) -> bool {
39        permits_message_note(&self.actor_id, self.legacy_local(token), note)
40    }
41
42    /// [`Self::permits_message_note`] for a view already reduced to its
43    /// store-side [`NoteMailboxScope`], so a caller-supplied cursor or key
44    /// anchor is judged by the same rule as the rows the scope admits.
45    pub fn scope_permits_message_note(scope: &NoteMailboxScope, note: &Note) -> bool {
46        permits_message_note(&scope.actor_id, scope.legacy_local, note)
47    }
48}
49
50fn permits_message_note(actor_id: &str, legacy_local: bool, note: &Note) -> bool {
51    if note.kind != "message" {
52        return true;
53    }
54    let properties = note.properties.as_ref();
55    let text = |key: &str| -> Option<&str> {
56        properties
57            .and_then(|value| value.get(key))
58            .and_then(Value::as_str)
59    };
60    match text("direction") {
61        Some("inbound") => {
62            text("to_actor") == Some(actor_id)
63                || (legacy_local
64                    && properties
65                        .and_then(|p| p.get("to_actor"))
66                        .is_none_or(Value::is_null))
67        }
68        Some("outbound") => {
69            text("from_actor") == Some(actor_id)
70                || (legacy_local
71                    && properties
72                        .and_then(|p| p.get("from_actor"))
73                        .is_none_or(Value::is_null))
74        }
75        // Pre-v1 local rows can lack all routing fields. Named callers
76        // never inherit that unattributed pool.
77        None => {
78            legacy_local
79                && properties
80                    .and_then(|p| p.get("direction"))
81                    .is_none_or(Value::is_null)
82                && text("from_actor").is_none()
83                && text("to_actor").is_none()
84        }
85        _ => false,
86    }
87}
88
89/// The same strict selector check is used before ordinary/intercepted dispatch
90/// and before direct pack-handler reads. JSON null is not an omitted selector.
91pub(crate) fn validate_mailbox_request(req: &GateRequest) -> RuntimeResult<()> {
92    mailbox_read_owner(req)
93        .map(|_| ())
94        .map_err(|error| RuntimeError::InvalidInput(error.to_string()))
95}
96
97impl KhiveRuntime {
98    /// Authorize a read-only mailbox view without changing token identity.
99    ///
100    /// The original arguments are retained as gate input. Direct handler calls
101    /// consult both the existing policy and the separate mailbox capability;
102    /// a permissive or unconfigured gate never grants cross-actor reads.
103    pub fn authorize_mailbox_view(
104        &self,
105        token: &NamespaceToken,
106        verb: &str,
107        selector: Option<&str>,
108        args: &Value,
109    ) -> RuntimeResult<MailboxView> {
110        let selector_field = match verb {
111            "comm.inbox" | "comm.thread" => Some("mailbox_actor"),
112            "comm.probe" => Some("actor"),
113            // Generic message reads have no cross-actor selector. The caller
114            // still needs the ordinary verb gate decision and a row-level
115            // mailbox filter before any message can be returned.
116            "list" | "search" | "get" | "context" | "neighbors" => None,
117            _ => {
118                return Err(RuntimeError::InvalidInput(
119                    "mailbox views are supported only by comm.inbox, comm.thread, comm.probe, list, search, get, context and neighbors"
120                        .into(),
121                ));
122            }
123        };
124        let req = GateRequest::new(
125            token.actor().clone(),
126            token.gate_namespace().clone(),
127            verb,
128            args.clone(),
129        );
130        validate_mailbox_request(&req)?;
131        if let Some(selector_field) = selector_field {
132            if args.get(selector_field).and_then(Value::as_str) != selector {
133                return Err(RuntimeError::InvalidInput(format!(
134                    "mailbox selector must match the original {selector_field} argument"
135                )));
136            }
137        } else if selector.is_some() {
138            return Err(RuntimeError::InvalidInput(format!(
139                "{verb} has no cross-actor mailbox selector"
140            )));
141        }
142        match check_with_mailbox_policy(self.config().gate.as_ref(), &req) {
143            Ok(GateDecision::Allow { .. }) => {
144                let owner = mailbox_read_owner(&req)
145                    .map_err(|error| RuntimeError::InvalidInput(error.to_string()))?;
146                Ok(MailboxView {
147                    delegated: owner.is_some(),
148                    actor_id: owner.map_or_else(|| token.actor().id.clone(), |owner| owner.id),
149                })
150            }
151            Ok(GateDecision::Deny { reason }) => Err(RuntimeError::permission_denied(verb, reason)),
152            Err(error) => Err(RuntimeError::GateUnavailable {
153                verb: verb.to_string(),
154                reason: error.wire_reason().to_string(),
155            }),
156        }
157    }
158}
159
160impl ActorConfig {
161    pub(crate) fn mailbox_gate(
162        &self,
163        inner: GateRef,
164    ) -> Result<Option<MailboxReadGate>, MailboxPolicyError> {
165        if self.mailbox_readers.is_empty() {
166            return Ok(None);
167        }
168        if self.mailbox_readers.len() > 256 {
169            return Err(MailboxPolicyError::TooManyReaders);
170        }
171        if self
172            .mailbox_readers
173            .iter()
174            .any(|id| !crate::is_valid_mailbox_actor_label(id))
175        {
176            return Err(MailboxPolicyError::InvalidReader);
177        }
178        // Only the explicit serving-file owner can grant access; never infer
179        // this from the effective runtime actor (which may come from env/CLI).
180        let owner = self.id.as_deref().ok_or(MailboxPolicyError::InvalidOwner)?;
181        crate::Namespace::parse(owner).map_err(|_| MailboxPolicyError::InvalidOwner)?;
182        let readers = self
183            .mailbox_readers
184            .iter()
185            .map(|id| ActorRef::new("actor", id.clone()))
186            .collect();
187        MailboxReadGate::new(inner, ActorRef::new("actor", owner), readers).map(Some)
188    }
189}
190
191/// Boot conversion is intentionally infallible for existing embedding callers.
192/// Invalid programmatic mailbox configuration therefore installs an unavailable
193/// gate rather than falling back to the base policy. File loading rejects it.
194pub(crate) fn configured_mailbox_gate(actor: &ActorConfig, inner: GateRef) -> GateRef {
195    match actor.mailbox_gate(inner.clone()) {
196        Ok(Some(gate)) => Arc::new(gate),
197        Ok(None) => inner,
198        Err(_) => {
199            let mut hash = Sha256::new();
200            hash.update(b"khive.invalid-mailbox-read-policy.v1\0");
201            for field in [inner.configuration_fingerprint(), actor.id.as_deref()] {
202                match field {
203                    Some(value) => {
204                        hash.update([1]);
205                        hash.update((value.len() as u64).to_be_bytes());
206                        hash.update(value.as_bytes());
207                    }
208                    None => hash.update([0]),
209                }
210            }
211            hash.update((actor.mailbox_readers.len() as u64).to_be_bytes());
212            for reader in &actor.mailbox_readers {
213                hash.update((reader.len() as u64).to_be_bytes());
214                hash.update(reader.as_bytes());
215            }
216            Arc::new(InvalidMailboxConfigGate {
217                fingerprint: format!("sha256:{:x}", hash.finalize()),
218            })
219        }
220    }
221}
222
223#[derive(Debug)]
224struct InvalidMailboxConfigGate {
225    fingerprint: String,
226}
227
228impl Gate for InvalidMailboxConfigGate {
229    fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
230        Err(GateError::Policy(
231            "invalid mailbox reader configuration".into(),
232        ))
233    }
234
235    fn configuration_fingerprint(&self) -> Option<&str> {
236        Some(&self.fingerprint)
237    }
238}
239
240#[cfg(test)]
241mod tests;