1use serde::{Deserialize, Serialize};
2
3use crate::{
4 AccessClass, ActorRef, AuthorizationId, CorrelationId, IdempotencyKey, OperationDefinition,
5 OperationId, OperationName, OperationSpec, PlanFingerprint, ProducerRef, SpecError, TargetKind,
6 TargetRef, TargetRefError, Timestamp, TraceContext,
7};
8
9const MAX_CONFIRMATION_REF_CHARS: usize = 256;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
14pub struct OperationContext {
15 operation_id: OperationId,
16 correlation_id: CorrelationId,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
18 causation_id: Option<OperationId>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 actor: Option<ActorRef>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
22 idempotency_key: Option<IdempotencyKey>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 deadline: Option<Timestamp>,
25 #[serde(default)]
26 trace: TraceContext,
27}
28
29impl OperationContext {
30 #[must_use]
32 pub fn new() -> Self {
33 Self {
34 operation_id: OperationId::new(),
35 correlation_id: CorrelationId::new(),
36 causation_id: None,
37 actor: None,
38 idempotency_key: None,
39 deadline: None,
40 trace: TraceContext::default(),
41 }
42 }
43
44 #[must_use]
46 pub fn with_correlation_id(mut self, correlation_id: CorrelationId) -> Self {
47 self.correlation_id = correlation_id;
48 self
49 }
50
51 #[must_use]
53 pub fn with_causation_id(mut self, causation_id: OperationId) -> Self {
54 self.causation_id = Some(causation_id);
55 self
56 }
57
58 #[must_use]
60 pub fn with_actor(mut self, actor: ActorRef) -> Self {
61 self.actor = Some(actor);
62 self
63 }
64
65 #[must_use]
67 pub fn with_idempotency_key(mut self, idempotency_key: IdempotencyKey) -> Self {
68 self.idempotency_key = Some(idempotency_key);
69 self
70 }
71
72 #[must_use]
74 pub fn with_deadline(mut self, deadline: Timestamp) -> Self {
75 self.deadline = Some(deadline);
76 self
77 }
78
79 #[must_use]
81 pub fn with_trace(mut self, trace: TraceContext) -> Self {
82 self.trace = trace;
83 self
84 }
85
86 #[must_use]
88 pub fn operation_id(&self) -> &OperationId {
89 &self.operation_id
90 }
91
92 #[must_use]
94 pub fn correlation_id(&self) -> &CorrelationId {
95 &self.correlation_id
96 }
97
98 #[must_use]
100 pub fn causation_id(&self) -> Option<&OperationId> {
101 self.causation_id.as_ref()
102 }
103
104 #[must_use]
106 pub fn actor(&self) -> Option<&ActorRef> {
107 self.actor.as_ref()
108 }
109
110 #[must_use]
112 pub fn idempotency_key(&self) -> Option<&IdempotencyKey> {
113 self.idempotency_key.as_ref()
114 }
115
116 #[must_use]
118 pub const fn deadline(&self) -> Option<Timestamp> {
119 self.deadline
120 }
121
122 #[must_use]
124 pub fn trace(&self) -> &TraceContext {
125 &self.trace
126 }
127}
128
129impl Default for OperationContext {
130 fn default() -> Self {
131 Self::new()
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
138pub struct AuthorizationScope {
139 operation: OperationName,
140 target: TargetRef,
141}
142
143impl AuthorizationScope {
144 #[must_use]
146 pub const fn new(operation: OperationName, target: TargetRef) -> Self {
147 Self { operation, target }
148 }
149
150 #[must_use]
152 pub fn operation(&self) -> &OperationName {
153 &self.operation
154 }
155
156 #[must_use]
158 pub fn target(&self) -> &TargetRef {
159 &self.target
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
166pub struct AuthorizationEvidence {
167 id: AuthorizationId,
168 issuer: ProducerRef,
169 scope: AuthorizationScope,
170 issued_at: Timestamp,
171 expires_at: Timestamp,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 plan_fingerprint: Option<PlanFingerprint>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 confirmation_ref: Option<String>,
176}
177
178impl AuthorizationEvidence {
179 pub fn new(
181 issuer: ProducerRef,
182 scope: AuthorizationScope,
183 issued_at: Timestamp,
184 expires_at: Timestamp,
185 ) -> Result<Self, AuthorizationError> {
186 if expires_at <= issued_at {
187 return Err(AuthorizationError::InvalidLifetime);
188 }
189 Ok(Self {
190 id: AuthorizationId::new(),
191 issuer,
192 scope,
193 issued_at,
194 expires_at,
195 plan_fingerprint: None,
196 confirmation_ref: None,
197 })
198 }
199
200 #[must_use]
202 pub fn with_plan_fingerprint(mut self, fingerprint: PlanFingerprint) -> Self {
203 self.plan_fingerprint = Some(fingerprint);
204 self
205 }
206
207 pub fn with_confirmation_ref(
209 mut self,
210 confirmation_ref: impl Into<String>,
211 ) -> Result<Self, AuthorizationError> {
212 let confirmation_ref = confirmation_ref.into();
213 let chars = confirmation_ref.chars().count();
214 if chars == 0
215 || chars > MAX_CONFIRMATION_REF_CHARS
216 || confirmation_ref.chars().any(char::is_control)
217 {
218 return Err(AuthorizationError::InvalidConfirmationRef);
219 }
220 self.confirmation_ref = Some(confirmation_ref);
221 Ok(self)
222 }
223
224 pub fn validate_binding(
226 &self,
227 operation: &OperationName,
228 target: &TargetRef,
229 now: Timestamp,
230 expected_plan: Option<&PlanFingerprint>,
231 ) -> Result<(), AuthorizationError> {
232 if now < self.issued_at {
233 return Err(AuthorizationError::NotYetValid);
234 }
235 if now >= self.expires_at {
236 return Err(AuthorizationError::Expired);
237 }
238 if self.scope.operation() != operation {
239 return Err(AuthorizationError::OperationMismatch);
240 }
241 if self.scope.target() != target {
242 return Err(AuthorizationError::TargetMismatch);
243 }
244 if self.plan_fingerprint.as_ref() != expected_plan {
245 return Err(AuthorizationError::PlanMismatch);
246 }
247 Ok(())
248 }
249
250 #[must_use]
252 pub fn id(&self) -> &AuthorizationId {
253 &self.id
254 }
255
256 #[must_use]
258 pub fn issuer(&self) -> &ProducerRef {
259 &self.issuer
260 }
261
262 #[must_use]
264 pub fn scope(&self) -> &AuthorizationScope {
265 &self.scope
266 }
267
268 #[must_use]
270 pub const fn expires_at(&self) -> Timestamp {
271 self.expires_at
272 }
273
274 #[must_use]
276 pub fn plan_fingerprint(&self) -> Option<&PlanFingerprint> {
277 self.plan_fingerprint.as_ref()
278 }
279
280 #[must_use]
282 pub fn confirmation_ref(&self) -> Option<&str> {
283 self.confirmation_ref.as_deref()
284 }
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
290pub struct OperationRequest<P> {
291 context: OperationContext,
292 operation: OperationName,
293 target: TargetRef,
294 parameters: P,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 authorization: Option<AuthorizationEvidence>,
297}
298
299impl<P> OperationRequest<P> {
300 pub fn new<O>(context: OperationContext, parameters: P) -> Result<Self, TargetRefError>
302 where
303 O: OperationDefinition<Parameters = P>,
304 {
305 let spec = O::spec();
306 let target = O::target(¶meters)?;
307 Ok(Self {
308 context,
309 operation: spec.name().clone(),
310 target,
311 parameters,
312 authorization: None,
313 })
314 }
315
316 #[must_use]
318 pub fn with_authorization(mut self, authorization: AuthorizationEvidence) -> Self {
319 self.authorization = Some(authorization);
320 self
321 }
322
323 pub fn validate_against(
325 &self,
326 spec: &OperationSpec,
327 now: Timestamp,
328 expected_plan: Option<&PlanFingerprint>,
329 ) -> Result<(), RequestError> {
330 spec.validate()?;
331 if &self.operation != spec.name() {
332 return Err(RequestError::OperationMismatch);
333 }
334 if self.target.kind() != spec.target_kind() {
335 return Err(RequestError::TargetKindMismatch {
336 expected: spec.target_kind().clone(),
337 actual: self.target.kind().clone(),
338 });
339 }
340 if self
341 .context
342 .deadline()
343 .is_some_and(|deadline| deadline <= now)
344 {
345 return Err(RequestError::DeadlineExceeded);
346 }
347 if spec.access() == AccessClass::Mutation
348 && spec.idempotent()
349 && self.context.idempotency_key().is_none()
350 {
351 return Err(RequestError::MissingIdempotencyKey);
352 }
353 match (&self.authorization, spec.access()) {
354 (None, AccessClass::Mutation) => Err(RequestError::MissingAuthorization),
355 (Some(authorization), _) => authorization
356 .validate_binding(&self.operation, &self.target, now, expected_plan)
357 .map_err(RequestError::Authorization),
358 (None, AccessClass::Read) => Ok(()),
359 }
360 }
361
362 #[must_use]
364 pub fn context(&self) -> &OperationContext {
365 &self.context
366 }
367
368 #[must_use]
370 pub fn operation(&self) -> &OperationName {
371 &self.operation
372 }
373
374 #[must_use]
376 pub fn target(&self) -> &TargetRef {
377 &self.target
378 }
379
380 #[must_use]
382 pub fn parameters(&self) -> &P {
383 &self.parameters
384 }
385
386 #[must_use]
388 pub fn authorization(&self) -> Option<&AuthorizationEvidence> {
389 self.authorization.as_ref()
390 }
391}
392
393#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
395#[non_exhaustive]
396pub enum AuthorizationError {
397 #[error("authorization expiry must be later than issue time")]
399 InvalidLifetime,
400 #[error("authorization is not yet valid")]
402 NotYetValid,
403 #[error("authorization has expired")]
405 Expired,
406 #[error("authorization operation does not match request")]
408 OperationMismatch,
409 #[error("authorization target does not match request")]
411 TargetMismatch,
412 #[error("authorization plan fingerprint does not match request")]
414 PlanMismatch,
415 #[error("invalid authorization confirmation reference")]
417 InvalidConfirmationRef,
418}
419
420#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
422#[non_exhaustive]
423pub enum RequestError {
424 #[error(transparent)]
426 Spec(#[from] SpecError),
427 #[error("request operation does not match specification")]
429 OperationMismatch,
430 #[error("request target kind mismatch: expected {expected:?}, found {actual:?}")]
432 TargetKindMismatch {
433 expected: TargetKind,
435 actual: TargetKind,
437 },
438 #[error("operation deadline has passed")]
440 DeadlineExceeded,
441 #[error("idempotent mutation requires an idempotency key")]
443 MissingIdempotencyKey,
444 #[error("mutation requires authorization evidence")]
446 MissingAuthorization,
447 #[error(transparent)]
449 Authorization(#[from] AuthorizationError),
450}
451
452#[cfg(test)]
453#[path = "request_tests.rs"]
454mod tests;