Skip to main content

conversation_api/
principal.rs

1//! Authenticated caller authority shared by Client ingress and Agent execution.
2//! Decoding a principal does not authenticate it; only trusted ingress supplies it.
3
4use crate::{ConversationSurface, SurfaceParseError};
5use serde::{Deserialize, Serialize};
6
7/// The authority under which a Client acts. A Space principal is a restricted
8/// Client enrolled in one Space, not an owner or the Space's Core service.
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
11pub enum ClientPrincipal {
12    User {
13        #[serde(rename = "userId")]
14        user_id: String,
15    },
16    Space {
17        #[serde(rename = "tenantId")]
18        tenant_id: String,
19        #[serde(rename = "scopeId")]
20        scope_id: String,
21    },
22}
23
24impl ClientPrincipal {
25    /// No initializer, billing owner or service identity fallback.
26    pub fn user_id(&self) -> Option<&str> {
27        match self {
28            Self::User { user_id } => Some(user_id),
29            Self::Space { .. } => None,
30        }
31    }
32
33    pub fn validate_for(&self, tenant_id: &str, scope_id: &str) -> bool {
34        if !valid_id(tenant_id) || !valid_id(scope_id) {
35            return false;
36        }
37        match self {
38            Self::User { user_id } => valid_id(user_id),
39            Self::Space {
40                tenant_id: tenant,
41                scope_id: scope,
42            } => tenant == tenant_id && scope == scope_id,
43        }
44    }
45
46    /// Conversation selection is owned by authenticated ingress, never request payloads.
47    /// The caller must retain tenant and scope alongside the returned surface.
48    pub fn client_surface(
49        &self,
50        tenant_id: &str,
51        scope_id: &str,
52    ) -> Result<ConversationSurface, SurfaceParseError> {
53        if !self.validate_for(tenant_id, scope_id) {
54            return Err(SurfaceParseError);
55        }
56        match self {
57            Self::User { user_id } => ConversationSurface::client_personal(user_id),
58            Self::Space { .. } => Ok(ConversationSurface::space_clients()),
59        }
60    }
61}
62
63fn valid_id(value: &str) -> bool {
64    !value.is_empty()
65        && value.len() <= 256
66        && value.trim() == value
67        && !value.chars().any(char::is_control)
68}