Skip to main content

webgates_sessions/
context.rs

1//! Session context types shared across issuance, renewal, and revocation flows.
2//!
3//! This module defines framework-agnostic request context data used by the
4//! session layer to record who initiated a session change and under which
5//! runtime conditions it happened.
6//!
7//! Adapters can derive this metadata from HTTP requests, RPC metadata, CLI
8//! invocations, or background jobs before passing it into session services.
9
10use std::collections::BTreeMap;
11
12/// Immutable metadata describing the caller and environment for a session operation.
13///
14/// This type is transport-agnostic by design. Adapters may derive values from
15/// HTTP requests, RPC metadata, CLI invocations, or background jobs before
16/// passing them into session services.
17///
18/// # Examples
19///
20/// ```
21/// use webgates_sessions::context::{SessionActor, SessionClientContext, SessionContext};
22///
23/// let context = SessionContext::new()
24///     .with_actor(SessionActor::Subject {
25///         subject_id: "user-42".to_string(),
26///     })
27///     .with_correlation_id("req-abc-123")
28///     .with_client(
29///         SessionClientContext::new()
30///             .with_ip_address("203.0.113.5")
31///             .with_user_agent("MyApp/2.0"),
32///     )
33///     .with_attribute("entrypoint", "login");
34///
35/// assert!(!context.is_empty());
36/// ```
37#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct SessionContext {
39    /// Stable identifier for the logical actor that initiated the operation.
40    pub actor: Option<SessionActor>,
41    /// Correlation identifier used to trace a session workflow across layers.
42    pub correlation_id: Option<String>,
43    /// Client environment metadata collected at the boundary.
44    pub client: SessionClientContext,
45    /// Additional non-sensitive attributes associated with the operation.
46    pub attributes: BTreeMap<String, String>,
47}
48
49impl SessionContext {
50    /// Creates an empty context with no actor or client metadata.
51    #[must_use]
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    /// Sets the actor that initiated the session operation.
57    #[must_use]
58    pub fn with_actor(mut self, actor: SessionActor) -> Self {
59        self.actor = Some(actor);
60        self
61    }
62
63    /// Sets the correlation identifier for the current workflow.
64    #[must_use]
65    pub fn with_correlation_id(mut self, correlation_id: impl Into<String>) -> Self {
66        self.correlation_id = Some(correlation_id.into());
67        self
68    }
69
70    /// Replaces the client metadata associated with the current workflow.
71    #[must_use]
72    pub fn with_client(mut self, client: SessionClientContext) -> Self {
73        self.client = client;
74        self
75    }
76
77    /// Inserts a single non-sensitive attribute into the context.
78    #[must_use]
79    pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
80        self.attributes.insert(key.into(), value.into());
81        self
82    }
83
84    /// Returns `true` when the context contains no actor, correlation id,
85    /// client metadata, or attributes.
86    #[must_use]
87    pub fn is_empty(&self) -> bool {
88        self.actor.is_none()
89            && self.correlation_id.is_none()
90            && self.client.is_empty()
91            && self.attributes.is_empty()
92    }
93}
94
95/// Describes the principal that triggered a session change.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum SessionActor {
98    /// The authenticated account or subject that owns the session.
99    Subject {
100        /// Stable identifier of the authenticated subject.
101        subject_id: String,
102    },
103    /// An administrator or operator acting on behalf of another subject.
104    Delegate {
105        /// Stable identifier of the acting principal.
106        actor_id: String,
107        /// Free-form reason or source for the delegated operation.
108        reason: Option<String>,
109    },
110    /// A system-initiated background process.
111    System {
112        /// Stable name of the service or component.
113        service: String,
114    },
115}
116
117/// Client-origin metadata captured at a trust boundary.
118///
119/// Fields are optional because not every adapter or runtime can provide every
120/// value safely or reliably.
121///
122/// # Examples
123///
124/// ```
125/// use webgates_sessions::context::SessionClientContext;
126///
127/// let client = SessionClientContext::new()
128///     .with_ip_address("203.0.113.5")
129///     .with_user_agent("MyApp/2.0")
130///     .with_device_id("device-abc");
131///
132/// assert!(!client.is_empty());
133/// assert_eq!(client.ip_address.as_deref(), Some("203.0.113.5"));
134/// ```
135#[derive(Debug, Clone, Default, PartialEq, Eq)]
136pub struct SessionClientContext {
137    /// User agent or equivalent client identifier.
138    pub user_agent: Option<String>,
139    /// Source IP address recorded as text to avoid transport-specific types.
140    pub ip_address: Option<String>,
141    /// Stable device identifier when the outer system provides one.
142    pub device_id: Option<String>,
143}
144
145impl SessionClientContext {
146    /// Creates an empty client context.
147    #[must_use]
148    pub fn new() -> Self {
149        Self::default()
150    }
151
152    /// Sets the user agent value.
153    #[must_use]
154    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
155        self.user_agent = Some(user_agent.into());
156        self
157    }
158
159    /// Sets the source IP address.
160    #[must_use]
161    pub fn with_ip_address(mut self, ip_address: impl Into<String>) -> Self {
162        self.ip_address = Some(ip_address.into());
163        self
164    }
165
166    /// Sets the stable device identifier.
167    #[must_use]
168    pub fn with_device_id(mut self, device_id: impl Into<String>) -> Self {
169        self.device_id = Some(device_id.into());
170        self
171    }
172
173    /// Returns `true` when no client metadata is present.
174    #[must_use]
175    pub fn is_empty(&self) -> bool {
176        self.user_agent.is_none() && self.ip_address.is_none() && self.device_id.is_none()
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::{SessionActor, SessionClientContext, SessionContext};
183
184    #[test]
185    fn empty_context_reports_empty() {
186        let context = SessionContext::new();
187
188        assert!(context.is_empty());
189    }
190
191    #[test]
192    fn populated_context_reports_not_empty() {
193        let context = SessionContext::new()
194            .with_actor(SessionActor::Subject {
195                subject_id: "user-123".to_string(),
196            })
197            .with_correlation_id("req-456")
198            .with_client(
199                SessionClientContext::new()
200                    .with_user_agent("test-agent")
201                    .with_ip_address("127.0.0.1"),
202            )
203            .with_attribute("entrypoint", "login");
204
205        assert!(!context.is_empty());
206    }
207
208    #[test]
209    fn empty_client_context_reports_empty() {
210        let client = SessionClientContext::new();
211
212        assert!(client.is_empty());
213    }
214
215    #[test]
216    fn populated_client_context_reports_not_empty() {
217        let client = SessionClientContext::new().with_device_id("device-123");
218
219        assert!(!client.is_empty());
220    }
221}