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}