Skip to main content

rocketmq_auth/
error.rs

1// Copyright 2026 The RocketMQ Rust Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Stable operational error boundary for authentication and authorization services.
16
17use std::error::Error as StdError;
18use std::fmt;
19
20use rocketmq_error::fields;
21use rocketmq_error::Error as CanonicalError;
22use rocketmq_error::ErrorContext;
23use rocketmq_error::ErrorDescriptor;
24use rocketmq_error::AUTH_CONFIGURATION_INVALID;
25use rocketmq_error::AUTH_OPERATION_FAILED;
26use rocketmq_error::CORE_ARGUMENT_INVALID;
27use rocketmq_error::CORE_SERIALIZATION_FAILED;
28use rocketmq_error::STORAGE_BACKEND_UNAVAILABLE;
29use rocketmq_error::STORAGE_READ_FAILED;
30use rocketmq_error::STORAGE_WRITE_FAILED;
31use rocketmq_security_api::SecurityProviderError;
32
33/// Closed, low-cardinality classification of an authentication service failure.
34///
35/// Policy denials are decisions and must not be represented by this type.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum AuthFailureKind {
38    InvalidInput,
39    InvalidConfiguration,
40    NotFound,
41    Conflict,
42    Expired,
43    Unauthenticated,
44    PermissionDenied,
45    Timeout,
46    Unavailable,
47    InvalidData,
48    Unsupported,
49    Internal,
50}
51
52impl AuthFailureKind {
53    const fn public_message(self) -> &'static str {
54        match self {
55            Self::InvalidInput => "Authentication service input is invalid",
56            Self::InvalidConfiguration => "Authentication service configuration is invalid",
57            Self::NotFound => "Authentication service resource was not found",
58            Self::Conflict => "Authentication service state conflicts with the request",
59            Self::Expired => "Authentication service request has expired",
60            Self::Unauthenticated => "Authentication could not be verified",
61            Self::PermissionDenied => "Authorization was denied",
62            Self::Timeout => "Authentication service operation timed out",
63            Self::Unavailable => "Authentication service is unavailable",
64            Self::InvalidData => "Authentication service data is invalid",
65            Self::Unsupported => "Authentication service operation is unsupported",
66            Self::Internal => "Authentication service operation failed",
67        }
68    }
69}
70
71/// Closed, low-cardinality operation associated with an authentication service failure.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum AuthOperation {
74    Initialize,
75    InitializeProvider,
76    BuildContext,
77    Authenticate,
78    Authorize,
79    ReadMetadata,
80    WriteMetadata,
81    LockMetadata,
82    EncodeMetadata,
83    DecodeMetadata,
84    ManageMetadata,
85    Bootstrap,
86    RotateCredential,
87    MaintainService,
88    LoadSecret,
89    Audit,
90}
91
92impl AuthOperation {
93    /// Returns the fixed low-cardinality operation identifier used by diagnostics.
94    #[must_use]
95    pub const fn as_str(self) -> &'static str {
96        match self {
97            Self::Initialize => "initialize",
98            Self::InitializeProvider => "initialize-provider",
99            Self::BuildContext => "build-context",
100            Self::Authenticate => "authenticate",
101            Self::Authorize => "authorize",
102            Self::ReadMetadata => "read-metadata",
103            Self::WriteMetadata => "write-metadata",
104            Self::LockMetadata => "lock-metadata",
105            Self::EncodeMetadata => "encode-metadata",
106            Self::DecodeMetadata => "decode-metadata",
107            Self::ManageMetadata => "manage-metadata",
108            Self::Bootstrap => "bootstrap",
109            Self::RotateCredential => "rotate-credential",
110            Self::MaintainService => "maintain-service",
111            Self::LoadSecret => "load-secret",
112            Self::Audit => "audit",
113        }
114    }
115}
116
117/// Typed operational failure returned by authentication and authorization services.
118///
119/// Public formatting deliberately excludes the operation and source. The typed source
120/// remains available to trusted diagnostics through [`std::error::Error::source`].
121pub struct AuthServiceError {
122    operation: AuthOperation,
123    kind: AuthFailureKind,
124    source: Option<Box<dyn StdError + Send + Sync + 'static>>,
125}
126
127impl AuthServiceError {
128    #[must_use]
129    pub const fn new(operation: AuthOperation, kind: AuthFailureKind) -> Self {
130        Self {
131            operation,
132            kind,
133            source: None,
134        }
135    }
136
137    pub fn with_source<E>(operation: AuthOperation, kind: AuthFailureKind, source: E) -> Self
138    where
139        E: StdError + Send + Sync + 'static,
140    {
141        Self {
142            operation,
143            kind,
144            source: Some(Box::new(source)),
145        }
146    }
147
148    /// Adapts a catalog-backed security-provider failure at an Auth operation boundary.
149    ///
150    /// Provider classification is derived from its canonical condition so every
151    /// Auth and composition-root consumer uses the same mapping.
152    #[must_use]
153    pub fn provider(operation: AuthOperation, source: SecurityProviderError) -> Self {
154        let kind = match source.condition() {
155            rocketmq_error::CanonicalCondition::InvalidArgument
156            | rocketmq_error::CanonicalCondition::FailedPrecondition => AuthFailureKind::InvalidConfiguration,
157            rocketmq_error::CanonicalCondition::NotFound => AuthFailureKind::NotFound,
158            rocketmq_error::CanonicalCondition::AlreadyExists | rocketmq_error::CanonicalCondition::Aborted => {
159                AuthFailureKind::Conflict
160            }
161            rocketmq_error::CanonicalCondition::Unauthenticated => AuthFailureKind::Unauthenticated,
162            rocketmq_error::CanonicalCondition::PermissionDenied => AuthFailureKind::PermissionDenied,
163            rocketmq_error::CanonicalCondition::ResourceExhausted
164            | rocketmq_error::CanonicalCondition::Unavailable
165            | rocketmq_error::CanonicalCondition::Cancelled => AuthFailureKind::Unavailable,
166            rocketmq_error::CanonicalCondition::DeadlineExceeded => AuthFailureKind::Timeout,
167            rocketmq_error::CanonicalCondition::DataLoss => AuthFailureKind::InvalidData,
168            rocketmq_error::CanonicalCondition::Unimplemented => AuthFailureKind::Unsupported,
169            rocketmq_error::CanonicalCondition::Internal => AuthFailureKind::Internal,
170        };
171        Self::with_source(operation, kind, source)
172    }
173
174    #[must_use]
175    pub const fn operation(&self) -> AuthOperation {
176        self.operation
177    }
178
179    #[must_use]
180    pub const fn kind(&self) -> AuthFailureKind {
181        self.kind
182    }
183
184    #[must_use]
185    pub const fn source_present(&self) -> bool {
186        self.source.is_some()
187    }
188
189    pub(crate) fn invalid_context<T>(_diagnostic: T) -> Self {
190        Self::new(AuthOperation::BuildContext, AuthFailureKind::InvalidInput)
191    }
192
193    pub(crate) fn invalid_context_source<E>(source: E) -> Self
194    where
195        E: StdError + Send + Sync + 'static,
196    {
197        Self::with_source(AuthOperation::BuildContext, AuthFailureKind::InvalidInput, source)
198    }
199
200    pub(crate) fn configuration_error<T>(_diagnostic: T) -> Self {
201        Self::new(AuthOperation::Initialize, AuthFailureKind::InvalidConfiguration)
202    }
203
204    pub(crate) fn subject_not_found<T>(_diagnostic: T) -> Self {
205        Self::new(AuthOperation::ManageMetadata, AuthFailureKind::NotFound)
206    }
207
208    pub(crate) fn not_initialized<T>(_diagnostic: T) -> Self {
209        Self::new(AuthOperation::Initialize, AuthFailureKind::Unavailable)
210    }
211
212    pub(crate) fn storage_lock_failed<T>(_diagnostic: T) -> Self {
213        Self::new(AuthOperation::LockMetadata, AuthFailureKind::Unavailable)
214    }
215
216    pub(crate) fn metadata_io(source: rocketmq_runtime::RuntimeError) -> Self {
217        let kind = match source.condition() {
218            rocketmq_error::CanonicalCondition::DeadlineExceeded => AuthFailureKind::Timeout,
219            rocketmq_error::CanonicalCondition::Unavailable => AuthFailureKind::Unavailable,
220            _ => AuthFailureKind::Internal,
221        };
222        Self::with_source(AuthOperation::WriteMetadata, kind, source)
223    }
224
225    pub(crate) fn provider_failed<E>(operation: AuthOperation, source: E) -> Self
226    where
227        E: StdError + Send + Sync + 'static,
228    {
229        Self::with_source(operation, AuthFailureKind::Unavailable, source)
230    }
231
232    pub(crate) fn storage_read_failed<E>(source: E) -> Self
233    where
234        E: StdError + Send + Sync + 'static,
235    {
236        Self::with_source(AuthOperation::ReadMetadata, AuthFailureKind::Unavailable, source)
237    }
238
239    pub(crate) fn storage_write_failed<E>(source: E) -> Self
240    where
241        E: StdError + Send + Sync + 'static,
242    {
243        Self::with_source(AuthOperation::WriteMetadata, AuthFailureKind::Unavailable, source)
244    }
245
246    pub(crate) const fn storage_conflict() -> Self {
247        Self::new(AuthOperation::WriteMetadata, AuthFailureKind::Conflict)
248    }
249
250    pub(crate) const fn serialization_failed(operation: AuthOperation) -> Self {
251        Self::new(operation, AuthFailureKind::InvalidData)
252    }
253}
254
255impl fmt::Display for AuthServiceError {
256    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
257        formatter.write_str(self.kind.public_message())
258    }
259}
260
261impl fmt::Debug for AuthServiceError {
262    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
263        formatter
264            .debug_struct("AuthServiceError")
265            .field("operation", &self.operation)
266            .field("kind", &self.kind)
267            .field("source_present", &self.source_present())
268            .finish()
269    }
270}
271
272impl StdError for AuthServiceError {
273    fn source(&self) -> Option<&(dyn StdError + 'static)> {
274        self.source.as_deref().map(|source| source as &(dyn StdError + 'static))
275    }
276}
277
278impl From<AuthServiceError> for CanonicalError {
279    fn from(error: AuthServiceError) -> Self {
280        let operation = error.operation();
281        let kind = error.kind();
282        match (operation, kind) {
283            (_, AuthFailureKind::PermissionDenied) => project_canonical(
284                error,
285                &rocketmq_error::AUTH_PERMISSION_DENIED,
286                ErrorContext::new().with_text(fields::OPERATION_DIAGNOSTIC, operation.as_str()),
287            ),
288            (AuthOperation::Authenticate, AuthFailureKind::NotFound) => project_canonical(
289                error,
290                &rocketmq_error::AUTH_CREDENTIALS_INVALID,
291                ErrorContext::new().with_secret_presence(fields::CREDENTIALS_PRESENT),
292            ),
293            (AuthOperation::ManageMetadata, AuthFailureKind::NotFound) => project_canonical(
294                error,
295                &rocketmq_error::AUTH_USER_NOT_FOUND,
296                ErrorContext::new().with_text(fields::OPERATION_DIAGNOSTIC, operation.as_str()),
297            ),
298            (_, AuthFailureKind::InvalidInput | AuthFailureKind::NotFound) => project_canonical(
299                error,
300                &CORE_ARGUMENT_INVALID,
301                ErrorContext::new().with_secret_presence(fields::MESSAGE_PRESENT),
302            ),
303            (_, AuthFailureKind::InvalidConfiguration | AuthFailureKind::Unsupported) => project_canonical(
304                error,
305                &AUTH_CONFIGURATION_INVALID,
306                ErrorContext::new()
307                    .with_text(fields::KEY, "auth.authorization")
308                    .with_secret_presence(fields::REASON_PRESENT),
309            ),
310            (AuthOperation::Initialize, _) => project_canonical(
311                error,
312                &AUTH_CONFIGURATION_INVALID,
313                ErrorContext::new()
314                    .with_text(fields::KEY, "auth.authorization")
315                    .with_secret_presence(fields::REASON_PRESENT),
316            ),
317            (AuthOperation::ReadMetadata, _) => project_storage(error, &STORAGE_READ_FAILED, "read"),
318            (AuthOperation::WriteMetadata, _) => project_storage(error, &STORAGE_WRITE_FAILED, "write"),
319            (AuthOperation::LockMetadata, _) => project_storage(error, &STORAGE_BACKEND_UNAVAILABLE, "lock"),
320            (AuthOperation::EncodeMetadata, _) => project_serialization(error, "encode"),
321            (AuthOperation::DecodeMetadata, _) => project_serialization(error, "decode"),
322            (_, AuthFailureKind::Unauthenticated | AuthFailureKind::Expired) => project_canonical(
323                error,
324                &rocketmq_error::AUTH_CREDENTIALS_INVALID,
325                ErrorContext::new().with_secret_presence(fields::CREDENTIALS_PRESENT),
326            ),
327            _ => project_canonical(
328                error,
329                &AUTH_OPERATION_FAILED,
330                ErrorContext::new().with_text(fields::OPERATION_DIAGNOSTIC, operation.as_str()),
331            ),
332        }
333    }
334}
335
336fn project_storage(
337    error: AuthServiceError,
338    descriptor: &'static ErrorDescriptor,
339    operation: &'static str,
340) -> CanonicalError {
341    project_canonical(
342        error,
343        descriptor,
344        ErrorContext::new()
345            .with_text(fields::STORE_OPERATION, operation)
346            .with_text(fields::STORE_COMPONENT, "auth-metadata"),
347    )
348}
349
350fn project_serialization(error: AuthServiceError, operation: &'static str) -> CanonicalError {
351    project_canonical(
352        error,
353        &CORE_SERIALIZATION_FAILED,
354        ErrorContext::new()
355            .with_text(fields::OPERATION_DIAGNOSTIC, operation)
356            .with_text(fields::FORMAT, "auth-metadata"),
357    )
358}
359
360fn project_canonical(
361    error: AuthServiceError,
362    descriptor: &'static ErrorDescriptor,
363    context: ErrorContext,
364) -> CanonicalError {
365    let has_source = error.source_present();
366    let context = if has_source
367        && descriptor
368            .fields()
369            .iter()
370            .any(|schema| *schema == fields::SOURCE_PRESENT.schema())
371    {
372        context.with_secret_presence(fields::SOURCE_PRESENT)
373    } else {
374        context
375    };
376    let canonical = if has_source {
377        CanonicalError::caused_by(descriptor, error)
378    } else {
379        CanonicalError::new(descriptor)
380    };
381    canonical.with_context(context)
382}
383
384/// Result returned by authentication and authorization service operations.
385pub type AuthServiceResult<T> = Result<T, AuthServiceError>;
386
387#[cfg(test)]
388mod tests {
389    use std::io;
390    use std::mem::size_of;
391
392    use super::*;
393
394    #[test]
395    fn public_formatting_never_exposes_source_text() {
396        let error = AuthServiceError::with_source(
397            AuthOperation::ReadMetadata,
398            AuthFailureKind::Unavailable,
399            io::Error::other("secret\r\n/injected/private/path"),
400        );
401
402        assert_eq!(error.to_string(), "Authentication service is unavailable");
403        let debug = format!("{error:?}");
404        assert!(!debug.contains("secret"));
405        assert!(!debug.contains("private/path"));
406        assert!(debug.contains("source_present: true"));
407    }
408
409    #[test]
410    fn typed_source_is_retained_for_trusted_diagnostics() {
411        let error = AuthServiceError::with_source(
412            AuthOperation::ReadMetadata,
413            AuthFailureKind::Unavailable,
414            io::Error::other("disk unavailable"),
415        );
416
417        assert!(error
418            .source()
419            .and_then(|source| source.downcast_ref::<io::Error>())
420            .is_some());
421    }
422
423    #[test]
424    fn service_error_remains_small_enough_for_result_boundaries() {
425        assert!(size_of::<AuthServiceError>() <= 4 * size_of::<usize>());
426    }
427
428    #[test]
429    fn rocketmq_projection_uses_fixed_messages_and_retains_operational_sources() {
430        let invalid: CanonicalError = AuthServiceError::with_source(
431            AuthOperation::BuildContext,
432            AuthFailureKind::InvalidInput,
433            io::Error::other("secret\r\npath"),
434        )
435        .into();
436        assert_eq!(invalid.descriptor(), &CORE_ARGUMENT_INVALID);
437        assert!(!invalid.to_string().contains("secret"));
438
439        let operational: CanonicalError = AuthServiceError::with_source(
440            AuthOperation::ReadMetadata,
441            AuthFailureKind::Unavailable,
442            io::Error::other("diagnostic cause"),
443        )
444        .into();
445        assert_eq!(operational.descriptor(), &STORAGE_READ_FAILED);
446        let auth = operational.source().expect("auth facade source must be retained");
447        assert!(auth.downcast_ref::<AuthServiceError>().is_some());
448        let io = auth.source().expect("I/O source must be retained");
449        assert!(io.downcast_ref::<io::Error>().is_some());
450    }
451
452    #[test]
453    fn rocketmq_projection_preserves_frozen_error_categories() {
454        let cases = [
455            (
456                AuthServiceError::new(AuthOperation::ManageMetadata, AuthFailureKind::NotFound),
457                &rocketmq_error::AUTH_USER_NOT_FOUND,
458            ),
459            (
460                AuthServiceError::new(AuthOperation::Initialize, AuthFailureKind::InvalidConfiguration),
461                &AUTH_CONFIGURATION_INVALID,
462            ),
463            (
464                AuthServiceError::new(AuthOperation::ReadMetadata, AuthFailureKind::Unavailable),
465                &STORAGE_READ_FAILED,
466            ),
467            (
468                AuthServiceError::new(AuthOperation::WriteMetadata, AuthFailureKind::Conflict),
469                &STORAGE_WRITE_FAILED,
470            ),
471            (
472                AuthServiceError::new(AuthOperation::LockMetadata, AuthFailureKind::Unavailable),
473                &STORAGE_BACKEND_UNAVAILABLE,
474            ),
475            (
476                AuthServiceError::new(AuthOperation::EncodeMetadata, AuthFailureKind::InvalidData),
477                &CORE_SERIALIZATION_FAILED,
478            ),
479            (
480                AuthServiceError::new(AuthOperation::Authorize, AuthFailureKind::Internal),
481                &AUTH_OPERATION_FAILED,
482            ),
483            (
484                AuthServiceError::with_source(
485                    AuthOperation::Authorize,
486                    AuthFailureKind::Unavailable,
487                    io::Error::other("provider unavailable"),
488                ),
489                &AUTH_OPERATION_FAILED,
490            ),
491        ];
492
493        for (error, descriptor) in cases {
494            assert_eq!(CanonicalError::from(error).descriptor(), descriptor);
495        }
496    }
497
498    #[test]
499    fn provider_mapping_uses_canonical_conditions_and_preserves_source() {
500        let cases = [
501            (
502                rocketmq_security_api::SecurityProviderFailure::NotFound,
503                AuthFailureKind::NotFound,
504            ),
505            (
506                rocketmq_security_api::SecurityProviderFailure::Conflict,
507                AuthFailureKind::Conflict,
508            ),
509            (
510                rocketmq_security_api::SecurityProviderFailure::Unsupported,
511                AuthFailureKind::Unsupported,
512            ),
513            (
514                rocketmq_security_api::SecurityProviderFailure::InvalidData,
515                AuthFailureKind::InvalidData,
516            ),
517            (
518                rocketmq_security_api::SecurityProviderFailure::ContractViolation,
519                AuthFailureKind::InvalidConfiguration,
520            ),
521            (
522                rocketmq_security_api::SecurityProviderFailure::Unavailable,
523                AuthFailureKind::Unavailable,
524            ),
525            (
526                rocketmq_security_api::SecurityProviderFailure::OperationFailed,
527                AuthFailureKind::Internal,
528            ),
529        ];
530
531        for (provider_kind, auth_kind) in cases {
532            let provider =
533                SecurityProviderError::new(provider_kind, rocketmq_security_api::SecurityOperation::ReadSecret);
534            let error = AuthServiceError::provider(AuthOperation::LoadSecret, provider);
535            assert_eq!(error.kind(), auth_kind);
536            assert!(error
537                .source()
538                .and_then(|source| source.downcast_ref::<SecurityProviderError>())
539                .is_some());
540        }
541    }
542}