cat_gateway/service/common/auth/rbac/
scheme.rs

1//! Catalyst RBAC Security Scheme
2use std::{env, error::Error, sync::LazyLock, time::Duration};
3
4use catalyst_types::catalyst_id::role_index::RoleId;
5use moka::future::Cache;
6use poem::{error::ResponseError, http::StatusCode, IntoResponse, Request};
7use poem_openapi::{auth::Bearer, SecurityScheme};
8use tracing::debug;
9
10use super::token::CatalystRBACTokenV1;
11use crate::{
12    db::index::session::CassandraSessionError,
13    service::common::{
14        auth::api_key::check_api_key,
15        responses::{ErrorResponses, WithErrorResponses},
16        types::headers::retry_after::{RetryAfterHeader, RetryAfterOption},
17    },
18};
19
20/// Auth token in the form of catv1.
21pub type EncodedAuthToken = String;
22
23/// The header name that holds the authorization RBAC token
24pub(crate) const AUTHORIZATION_HEADER: &str = "Authorization";
25
26/// Cached auth tokens
27// TODO: Caching is currently disabled because we want to measure the performance without it. See
28// https://github.com/input-output-hk/catalyst-voices/issues/1940 for more details.
29#[allow(dead_code)]
30static CACHE: LazyLock<Cache<EncodedAuthToken, CatalystRBACTokenV1>> = LazyLock::new(|| {
31    Cache::builder()
32        // Time to live (TTL): 30 minutes
33        .time_to_live(Duration::from_secs(30 * 60))
34        // Time to idle (TTI):  5 minutes
35        .time_to_idle(Duration::from_secs(5 * 60))
36        // Create the cache.
37        .build()
38});
39
40/// Catalyst RBAC Access Token
41#[derive(SecurityScheme)]
42#[oai(
43    ty = "bearer",
44    key_name = "Authorization", // MUST match the `AUTHORIZATION_HEADER` constant.
45    bearer_format = "catalyst-rbac-token",
46    checker = "checker_api_catalyst_auth"
47)]
48#[allow(clippy::module_name_repetitions)]
49pub(crate) struct CatalystRBACSecurityScheme(CatalystRBACTokenV1);
50
51impl From<CatalystRBACSecurityScheme> for CatalystRBACTokenV1 {
52    fn from(value: CatalystRBACSecurityScheme) -> Self {
53        value.0
54    }
55}
56
57/// Error with the service while processing a Catalyst RBAC Token
58///
59/// Can be related to database session failure.
60#[derive(Debug, thiserror::Error)]
61#[error("Service unavailable while processing a Catalyst RBAC Token")]
62pub struct ServiceUnavailableError(pub anyhow::Error);
63
64impl ResponseError for ServiceUnavailableError {
65    fn status(&self) -> StatusCode {
66        StatusCode::SERVICE_UNAVAILABLE
67    }
68
69    /// Convert this error to a HTTP response.
70    fn as_response(&self) -> poem::Response
71    where Self: Error + Send + Sync + 'static {
72        WithErrorResponses::<()>::service_unavailable(
73            &self.0,
74            RetryAfterOption::Some(RetryAfterHeader::default()),
75        )
76        .into_response()
77    }
78}
79
80/// Authentication token error.
81#[derive(Debug, thiserror::Error)]
82enum AuthTokenError {
83    /// Registration chain cannot be built.
84    #[error("Unable to build registration chain, err: {0}")]
85    BuildRegChain(String),
86    /// RBAC token cannot be parsed.
87    #[error("Fail to parse RBAC token string, err: {0}")]
88    ParseRbacToken(String),
89    /// Registration chain cannot be found.
90    #[error("Registration not found for the auth token.")]
91    RegistrationNotFound,
92    /// Latest signing key cannot be found.
93    #[error("Unable to get the latest signing key.")]
94    LatestSigningKey,
95}
96
97impl ResponseError for AuthTokenError {
98    fn status(&self) -> StatusCode {
99        StatusCode::UNAUTHORIZED
100    }
101
102    /// Convert this error to a HTTP response.
103    fn as_response(&self) -> poem::Response
104    where Self: Error + Send + Sync + 'static {
105        ErrorResponses::unauthorized(self.to_string()).into_response()
106    }
107}
108
109/// Token does not have required access rights
110///
111/// Not enough access rights, so its a 403 response.
112#[derive(Debug, thiserror::Error)]
113#[error("Insufficient Permission for Catalyst RBAC Token: {0:?}")]
114pub struct AuthTokenAccessViolation(Vec<String>);
115
116impl ResponseError for AuthTokenAccessViolation {
117    fn status(&self) -> StatusCode {
118        StatusCode::FORBIDDEN
119    }
120
121    /// Convert this error to a HTTP response.
122    fn as_response(&self) -> poem::Response
123    where Self: Error + Send + Sync + 'static {
124        // TODO: Actually check permissions needed for an endpoint.
125        ErrorResponses::forbidden(Some(self.0.clone())).into_response()
126    }
127}
128
129/// Time in the past the Token can be valid for.
130const MAX_TOKEN_AGE: Duration = Duration::from_secs(60 * 60); // 1 hour.
131
132/// Time in the future the Token can be valid for.
133const MAX_TOKEN_SKEW: Duration = Duration::from_secs(5 * 60); // 5 minutes
134
135/// When added to an endpoint, this hook is called per request to verify the bearer token
136/// is valid. The performed validation is described [here].
137///
138/// [here]: https://github.com/input-output-hk/catalyst-voices/blob/main/docs/src/catalyst-standards/permissionless-auth/auth-header.md#backend-processing-of-the-token
139async fn checker_api_catalyst_auth(
140    req: &Request, bearer: Bearer,
141) -> poem::Result<CatalystRBACTokenV1> {
142    /// Temporary: Conditional RBAC for testing
143    const RBAC_OFF: &str = "RBAC_OFF";
144
145    // Deserialize the token: this performs the 1-5 steps of the validation.
146    let mut token = CatalystRBACTokenV1::parse(&bearer.token).map_err(|e| {
147        debug!("Corrupt auth token: {e:?}");
148        AuthTokenError::ParseRbacToken(e.to_string())
149    })?;
150
151    // If env var explicitly set by SRE, switch off full verification
152    if env::var(RBAC_OFF).is_ok() {
153        return Ok(token);
154    };
155
156    // Step 6: get and build latest registration chain from the db.
157    let reg_chain = match token.reg_chain().await {
158        Ok(Some(reg_chain)) => reg_chain,
159        Ok(None) => {
160            debug!(
161                "Unable to find registrations for {} Catalyst ID",
162                token.catalyst_id()
163            );
164            return Err(AuthTokenError::RegistrationNotFound.into());
165        },
166        Err(err) if err.is::<CassandraSessionError>() => {
167            return Err(ServiceUnavailableError(err).into())
168        },
169        Err(err) => {
170            debug!("Unable to build a registration chain Catalyst ID: {err:?}");
171            return Err(AuthTokenError::BuildRegChain(err.to_string()).into());
172        },
173    };
174
175    // Step 7: Verify that the nonce is in the acceptable range.
176    // If `InternalApiKeyAuthorization` auth is provided, skip validation.
177    if check_api_key(req.headers()).is_err() && !token.is_young(MAX_TOKEN_AGE, MAX_TOKEN_SKEW) {
178        // Token is too old or too far in the future.
179        debug!("Auth token expired: {token}");
180        Err(AuthTokenAccessViolation(vec!["EXPIRED".to_string()]))?;
181    }
182
183    // TODO: Caching is currently disabled because we want to measure the performance without
184    // it.
185    // // Its valid and young enough, check if its in the auth cache.
186    // // This get() will extend the entry life for another 5 minutes.
187    // // Even though we keep calling get(), the entry will expire
188    // // after 30 minutes (TTL) from the origin insert().
189    // // This is an optimization which saves us constantly looking up registrations we have
190    // // already validated.
191    // if let Some(token) = CACHE.get(&bearer.token).await {
192    //     return Ok(token);
193    // }
194
195    // Step 8: Get the latest stable signing certificate registered for Role 0.
196    let (latest_pk, _) = reg_chain
197        .get_latest_signing_pk_for_role(&RoleId::Role0)
198        .ok_or_else(|| {
199            debug!(
200                "Unable to get last signing key for {} Catalyst ID",
201                token.catalyst_id()
202            );
203            AuthTokenError::LatestSigningKey
204        })?;
205
206    // Step 9: Verify the signature against the Role 0 pk.
207    if let Err(error) = token.verify(&latest_pk) {
208        debug!(error=%error, "Invalid signature for token: {token}");
209        Err(AuthTokenAccessViolation(vec![
210            "INVALID SIGNATURE".to_string()
211        ]))?;
212    }
213
214    // Step 10 is optional and isn't currently implemented.
215    //   - Get the latest unstable signing certificate registered for Role 0.
216    //   - Verify the signature against the Role 0 Public Key and Algorithm identified by the
217    //     certificate. If this fails, return 403.
218
219    // TODO: Caching is currently disabled because we want to measure the performance without
220    // it.
221    // // This entry will expire after 5 minutes (TTI) if there is no more ().
222    // CACHE.insert(bearer.token, token.clone()).await;
223
224    // Step 11: Token is valid
225    Ok(token)
226}