Skip to main content

headless_lms_authorization/
error.rs

1/*!
2Contains error and result types for the authorization checks.
3*/
4
5use std::fmt::Display;
6use std::panic::Location;
7
8use backtrace::Backtrace;
9use headless_lms_base::error::backend_error::BackendError;
10use headless_lms_models::ModelError;
11use headless_lms_utils::error::util_error::UtilError;
12use tracing_error::SpanTrace;
13
14/**
15Used as the result type for all authorization checks.
16*/
17pub type AuthorizationResult<T> = Result<T, AuthorizationError>;
18
19/// The type of [AuthorizationError] that occured.
20#[derive(Debug, PartialEq, Eq)]
21pub enum AuthorizationErrorType {
22    /// There is no authenticated user, but the check needs one.
23    Unauthorized,
24
25    /// The user is known but is not allowed to perform the action.
26    Forbidden,
27
28    /// The check could not be completed, e.g. the user's roles could not be fetched.
29    InternalServerError,
30
31    /// The models layer failed while the check was loading the data it needs. The original
32    /// [ModelError] is the source; recover it with [AuthorizationError::into_model_error]
33    /// and map it the same way a directly returned `ModelError` is mapped, so that e.g. a
34    /// check against a nonexistent page still answers "not found" rather than "server error".
35    Model,
36}
37
38/**
39Error type used by the authorization checks.
40
41The message is meant to be seen by the user; the source carries the role and action detail
42that is only useful to whoever is diagnosing the denial.
43
44Build one with [`authorization_err!`]:
45
46```ignore
47authorization_err!(Unauthorized, "This course requires authentication to access".to_string());
48authorization_err!(InternalServerError, "Failed to fetch user roles".to_string(), original_error);
49```
50*/
51pub struct AuthorizationError {
52    error_type: <AuthorizationError as BackendError>::ErrorType,
53    message: String,
54    /// Original error that caused this error.
55    source: Option<anyhow::Error>,
56    /// A trace of tokio tracing spans, generated automatically when the error is generated.
57    span_trace: Box<SpanTrace>,
58    /// Stack trace, generated automatically when the error is created.
59    backtrace: Box<Backtrace>,
60    /// Source location where the error was raised.
61    location: Option<&'static Location<'static>>,
62}
63
64// Generate the clean developer `Debug`/`clean_string` and a cause resolver.
65headless_lms_base::impl_clean_debug!(
66    AuthorizationError,
67    [AuthorizationError, ModelError, UtilError]
68);
69
70impl std::error::Error for AuthorizationError {
71    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
72        self.source
73            .as_deref()
74            .map(|e| e as &(dyn std::error::Error + 'static))
75    }
76
77    fn cause(&self) -> Option<&dyn std::error::Error> {
78        self.source()
79    }
80}
81
82impl Display for AuthorizationError {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        write!(
85            f,
86            "AuthorizationError {:?} {:?}",
87            self.error_type, self.message
88        )
89    }
90}
91
92impl BackendError for AuthorizationError {
93    type ErrorType = AuthorizationErrorType;
94
95    fn backtrace(&self) -> Option<&Backtrace> {
96        Some(&self.backtrace)
97    }
98
99    fn error_type(&self) -> &Self::ErrorType {
100        &self.error_type
101    }
102
103    fn message(&self) -> &str {
104        &self.message
105    }
106
107    fn span_trace(&self) -> &SpanTrace {
108        &self.span_trace
109    }
110
111    fn location(&self) -> Option<&'static Location<'static>> {
112        self.location
113    }
114
115    fn new_with_traces_and_location<M: Into<String>, S: Into<Option<anyhow::Error>>>(
116        error_type: Self::ErrorType,
117        message: M,
118        source_error: S,
119        backtrace: Backtrace,
120        span_trace: SpanTrace,
121        location: Option<&'static Location<'static>>,
122    ) -> Self {
123        Self {
124            error_type,
125            message: message.into(),
126            source: source_error.into(),
127            span_trace: Box::new(span_trace),
128            backtrace: Box::new(backtrace),
129            location,
130        }
131    }
132}
133
134impl AuthorizationError {
135    /// Whether the check ran and refused, rather than failing to run at all. Consumers that
136    /// answer a permission question with a boolean read a denial as "no" and everything else
137    /// as a failure.
138    pub fn is_denial(&self) -> bool {
139        matches!(
140            self.error_type,
141            AuthorizationErrorType::Unauthorized | AuthorizationErrorType::Forbidden
142        )
143    }
144
145    /// Unwraps an [AuthorizationErrorType::Model] error into the [ModelError] the check failed
146    /// on, so that the caller can apply its own `ModelError` mapping instead of collapsing
147    /// every data-loading failure into a single status code. Any other error is handed back
148    /// unchanged.
149    pub fn into_model_error(mut self) -> Result<ModelError, Self> {
150        if !matches!(self.error_type, AuthorizationErrorType::Model) {
151            return Err(self);
152        }
153        match self.source.take().map(|source| source.downcast()) {
154            Some(Ok(model_error)) => Ok(model_error),
155            Some(Err(source)) => {
156                self.source = Some(source);
157                Err(self)
158            }
159            None => Err(self),
160        }
161    }
162}
163
164impl From<ModelError> for AuthorizationError {
165    fn from(err: ModelError) -> Self {
166        let message = err.message().to_string();
167        Self::new(AuthorizationErrorType::Model, message, Some(err.into()))
168    }
169}
170
171headless_lms_utils::define_err_macro!(
172    authorization_err,
173    AuthorizationError,
174    AuthorizationErrorType,
175    AuthorizationErrorType,
176    "Create an AuthorizationError with less boilerplate."
177);
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use headless_lms_models::ModelErrorType;
183
184    #[test]
185    fn into_model_error_recovers_the_wrapped_model_error() {
186        let err = AuthorizationError::from(ModelError::new(
187            ModelErrorType::RecordNotFound,
188            "row missing".to_string(),
189            None,
190        ));
191
192        let model_error = err.into_model_error().expect("model source");
193        assert!(matches!(
194            model_error.error_type(),
195            ModelErrorType::RecordNotFound
196        ));
197    }
198
199    #[test]
200    fn into_model_error_hands_other_error_types_back() {
201        let source = ModelError::new(ModelErrorType::Generic, "boom".to_string(), None);
202        let err = authorization_err!(InternalServerError, "Denied".to_string(), source);
203
204        let err = err.into_model_error().expect_err("not a model error");
205        assert!(std::error::Error::source(&err).is_some());
206    }
207
208    #[test]
209    fn only_refusals_are_denials() {
210        assert!(authorization_err!(Forbidden, "no".to_string()).is_denial());
211        assert!(authorization_err!(Unauthorized, "no".to_string()).is_denial());
212        assert!(!authorization_err!(InternalServerError, "no".to_string()).is_denial());
213    }
214}