pharia_common/
iam.rs

1//! **IAM** is short for **I**dentity **A**ccess **M**anagement. This module contains opinionated
2//! adapters to connect to the internal Pharia IAM solution.
3
4use std::{borrow::Cow, fmt::Display};
5
6use reqwest::{Client, StatusCode};
7use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
8use serde::{Deserialize, Serialize};
9
10/// URL of IAM in our production environment
11pub const IAM_PRODUCTION_URL: &str = "https://pharia-iam.product.pharia.com";
12
13pub const IAM_STAGE_URL: &str = "https://pharia-iam.stage.product.pharia.com";
14
15/// Client forPharia **I**dentity **A**ccess **M**anagement. Authenticate and authorize users.
16#[derive(Clone, Debug)]
17pub struct IamClient {
18    /// Environment specific URL to Pharia IAM. E.g. <https://pharia-iam.product.pharia.com>
19    base_url: String,
20    /// Used for sending the http requests. We are using `ClientWithMiddleware` to allow for VCR
21    /// recording in tests.
22    http_client: ClientWithMiddleware,
23}
24
25impl IamClient {
26    /// Construct a new client using the respective IAM instance. E.g. [`IAM_PRODUCTION_URL`]
27    pub fn new(base_url: String) -> Self {
28        let client = Client::builder().use_rustls_tls().build().expect(
29            "Must be able to initialize TLS backend and resolver must be able to load system \
30            configuration.",
31        );
32
33        let http_client: ClientWithMiddleware = ClientBuilder::new(client).build();
34
35        Self {
36            base_url,
37            http_client,
38        }
39    }
40
41    #[cfg(test)]
42    pub fn with_vcr(base_url: String, path_to_cassette: std::path::PathBuf) -> Self {
43        let cassette_does_exist = path_to_cassette.is_file();
44        let vcr_mode = if cassette_does_exist {
45            reqwest_vcr::VCRMode::Replay
46        } else {
47            reqwest_vcr::VCRMode::Record
48        };
49
50        let middleware = reqwest_vcr::VCRMiddleware::try_from(path_to_cassette)
51            .unwrap()
52            .with_mode(vcr_mode)
53            .with_modify_request(|request| {
54                if let Some(header) = request.headers.get_mut("authorization") {
55                    *header = vec!["TOKEN_REMOVED".to_owned()];
56                }
57            });
58
59        IamClient::with_middleware(base_url, middleware)
60    }
61
62    #[cfg(test)]
63    fn with_middleware(base_url: String, middleware: impl reqwest_middleware::Middleware) -> Self {
64        let client = Client::builder().use_rustls_tls().build().expect(
65            "Must be able to initialize TLS backend and resolver must be able to load system \
66            configuration.",
67        );
68
69        let http_client: ClientWithMiddleware = ClientBuilder::new(client).with(middleware).build();
70
71        IamClient {
72            base_url,
73            http_client,
74        }
75    }
76
77    /// One stop shop for both authentication and asking a set of permissions. While this method
78    /// returns a subset of permissions to which matches the privileges of the user it does not
79    /// perform the authorization check. Call `authorize`
80    ///
81    /// # Parameters
82    ///
83    /// * `token`: Service or user token used for authentication.
84    /// * `permissions`: A list of all permissions you are interested in. The response will contain
85    ///   the subset of these permissions which are privileges the user has.
86    pub async fn check_user<'a>(
87        &self,
88        token: impl Display,
89        permissions: &'a [Permission<'a>],
90    ) -> Result<UserInfoAndPermissions, CheckUserError> {
91        let request_body = CheckUserRequestBody { permissions };
92
93        let response = self
94            .http_client
95            .post(format!("{base_url}/check_user", base_url = self.base_url))
96            .bearer_auth(token)
97            .json(&request_body)
98            .send()
99            .await
100            .map_err(|e| CheckUserError::ConnectionError(e.into()))?;
101
102        // A long standing quirk of the HTTP standard: Unauthorized 401 actually means
103        // "unauthenticated". We consider this a domain specific logic error, rather than a runtime
104        // error, which should be fixed with retry. Therfore we categorize this error differently
105        // the other connection errors
106        if response.status() == StatusCode::UNAUTHORIZED {
107            return Err(CheckUserError::Unauthenticated);
108        }
109
110        if response.status() == StatusCode::UNPROCESSABLE_ENTITY {
111            use anyhow::anyhow;
112            eprintln!("{}", response.text().await.unwrap());
113            return Err(CheckUserError::ConnectionError(anyhow!(
114                "Unprocessable entity"
115            )));
116        }
117
118        // Map all other thing to ConnectionError
119        response
120            .error_for_status_ref()
121            .map_err(|e| CheckUserError::ConnectionError(e.into()))?;
122
123        let user_info = response
124            .json()
125            .await
126            .map_err(|e| CheckUserError::ConnectionError(e.into()))?;
127
128        Ok(user_info)
129    }
130
131    /// Same as `check_user` but also performs the authorization check and fails if the user is not
132    /// authorized.
133    ///
134    /// # Parameters
135    ///
136    /// * `token`: Service or user token used for authentication.
137    /// * `permissions`: A list of all permissions you are interested in. The response will contain
138    ///   the subset of these permissions which are privileges the user has.
139    ///
140    /// Example: Check if the user has the `AccessAssistant` permission.
141    ///
142    /// ```
143    /// use pharia_common::{Permission, IamClient, AuthorizationError, IAM_PRODUCTION_URL};
144    ///
145    /// pub async fn authorize(token: &str) -> Result<(), AuthorizationError> {
146    ///     let iam = IamClient::new(IAM_PRODUCTION_URL.to_owned());
147    ///     let permissions = [Permission::AccessAssistant];
148    ///     let user_info = iam.authorize(token, &permissions).await?;
149    ///     Ok(())
150    /// }
151    /// ```
152    pub async fn authorize<'a>(
153        &self,
154        token: impl Display,
155        permissions: &'a [Permission<'a>],
156    ) -> Result<UserInfoAndPermissions, AuthorizationError> {
157        let user_info = self.check_user(token, permissions).await?;
158        if user_info.permissions == permissions {
159            Ok(user_info)
160        } else {
161            Err(AuthorizationError::Unauthorized)
162        }
163    }
164}
165
166/// Body of the the IAM `/check_user` route. The token is not passed in the body but in the
167/// authorization header.
168#[derive(Serialize)]
169struct CheckUserRequestBody<'a> {
170    /// A list of permissions to query for the specific user.
171    permissions: &'a [Permission<'a>],
172}
173
174/// Returned by [`IamClient::check_user`]. Contains information describing the user as well as the
175/// union of the queried permissions and the privileges of the user.
176#[derive(Deserialize, PartialEq, Eq, Debug)]
177pub struct UserInfoAndPermissions {
178    /// Unique ID of the User
179    pub sub: String,
180    /// Email of the user. `None` for Service users
181    pub email: Option<String>,
182    /// May be `None` for Service Users
183    pub email_verified: Option<bool>,
184    /// List of requested permissions, which are privieleges of the User Service. They are in the
185    /// same order as in the query
186    pub permissions: Vec<Permission<'static>>,
187}
188
189/// An error returned by [`IamClient::check_user`]. Note that this does **not** include
190/// unauthorized. To check for authorization inspect the permissions of [`UserInfoAndPermissions`]
191#[derive(thiserror::Error, Debug)]
192pub enum CheckUserError {
193    #[error("User is Unauthenticated. Token is invalid")]
194    Unauthenticated,
195    #[error("User could not be authenticated due to connectivity issue:\n{0:#}")]
196    ConnectionError(#[source] anyhow::Error),
197}
198
199#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Hash)]
200#[serde(tag = "permission")]
201pub enum Permission<'a> {
202    AccessAssistant,
203    NuminousAccess,
204    /// The kernel uses this permission to authorize skill execution
205    KernelAccess,
206    /// Used by inference to decide wether a user is authorized to perform any kind of inference
207    /// requests.
208    ExecuteJobs,
209    /// Is this user allowed to use this model? "*" Can be used as a model name in order to indicate
210    /// access to all models.
211    AccessModel {
212        model: Cow<'a, str>,
213    },
214    HasRelation {
215        relation: Cow<'a, str>,
216        object: Cow<'a, str>,
217    },
218}
219
220#[derive(thiserror::Error, Debug)]
221pub enum AuthorizationError {
222    #[error("User is Unauthenticated. Token is invalid")]
223    Unauthenticated,
224    #[error("Unauthorized")]
225    Unauthorized,
226    #[error("User could not be authenticated due to connectivity issue:\n{0:#}")]
227    ConnectionError(#[source] anyhow::Error),
228}
229
230impl From<CheckUserError> for AuthorizationError {
231    fn from(err: CheckUserError) -> Self {
232        match err {
233            CheckUserError::Unauthenticated => AuthorizationError::Unauthenticated,
234            CheckUserError::ConnectionError(err) => AuthorizationError::ConnectionError(err),
235        }
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use dotenvy::dotenv;
242    use std::{borrow::Cow, env, path::PathBuf};
243
244    use crate::iam::IAM_STAGE_URL;
245
246    use super::{
247        CheckUserError, IAM_PRODUCTION_URL, IamClient, Permission, UserInfoAndPermissions,
248    };
249
250    #[tokio::test]
251    async fn valid_user_token() {
252        // We are using cassets to record the request. This makes the test easy to execute even
253        // without a connection to Pharia. Additionally it allows us to execute the test even
254        // without the specific token of the user who recorded it at hand.
255        let mut cassette_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
256        cassette_path.push("tests/cassettes/valid_user_token.vcr.json");
257
258        // Given a client
259        let client = IamClient::with_vcr(IAM_PRODUCTION_URL.to_owned(), cassette_path);
260
261        // When sending a check user request with a valid token
262        let response = client.check_user(token(), &[]).await.unwrap();
263
264        // Then we recevie an answer, identifying the user
265        let expected = UserInfoAndPermissions {
266            sub: "295355180126307110".to_owned(),
267            email: Some("markus.klein@aleph-alpha.com".to_owned()),
268            email_verified: Some(true),
269            permissions: vec![],
270        };
271        assert_eq!(expected, response);
272    }
273
274    #[tokio::test]
275    async fn invalid_user_token() {
276        // We are using cassets to record the request. This makes the test easy to execute even
277        // without a connection to Pharia. Additionally it allows us to execute the test even
278        // without the specific token of the user who recorded it at hand.
279        let mut cassette_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
280        cassette_path.push("tests/cassettes/invalid_user_token.vcr.json");
281
282        // Given an invalid Pharia User Token
283        let token = "I-AM-AN-INVALID-TOKEN";
284        let client = IamClient::with_vcr(IAM_PRODUCTION_URL.to_owned(), cassette_path);
285
286        // When sending a check user request
287        let result = client.check_user(token, &[]).await;
288
289        // Then the user is unauthenticated
290        assert!(matches!(result, Err(CheckUserError::Unauthenticated)))
291    }
292
293    #[tokio::test]
294    async fn asking_for_permissions() {
295        // We are using cassets to record the request. This makes the test easy to execute even
296        // without a connection to Pharia. Additionally it allows us to execute the test even
297        // without the specific token of the user who recorded it at hand.
298        let mut cassette_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
299        cassette_path.push("tests/cassettes/asking_for_permissions.vcr.json");
300
301        // Given a client
302        let client = IamClient::with_vcr(IAM_PRODUCTION_URL.to_owned(), cassette_path);
303        let permissions = [
304            Permission::KernelAccess,
305            Permission::ExecuteJobs,
306            Permission::AccessAssistant,
307            Permission::NuminousAccess,
308            Permission::AccessModel { model: "*".into() },
309        ];
310
311        // When sending a check user request with a token authorized for all permission it is
312        // asking for.
313        let response = client.check_user(token(), &permissions).await.unwrap();
314
315        // Then we recevie an answer, identifying the user and all the permissions are visible
316        // in the answer.
317        let expected = UserInfoAndPermissions {
318            sub: "295355180126307110".to_owned(),
319            email: Some("markus.klein@aleph-alpha.com".to_owned()),
320            email_verified: Some(true),
321            // It seems the IAM backend maintains order. So this assertion works.
322            permissions: permissions.to_vec(),
323        };
324        assert_eq!(expected, response);
325    }
326
327    #[tokio::test]
328    async fn authorize() {
329        // We are using cassets to record the request. This makes the test easy to execute even
330        // without a connection to Pharia. Additionally it allows us to execute the test even
331        // without the specific token of the user who recorded it at hand.
332        let mut cassette_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
333        cassette_path.push("tests/cassettes/authorize.vcr.json");
334
335        // Given a client
336        let client = IamClient::with_vcr(IAM_PRODUCTION_URL.to_owned(), cassette_path);
337        let permissions = [
338            Permission::KernelAccess,
339            Permission::ExecuteJobs,
340            Permission::AccessAssistant,
341            Permission::NuminousAccess,
342            Permission::AccessModel { model: "*".into() },
343        ];
344
345        // When sending a check user request with a token authorized for all permission it is
346        // asking for.
347        let response = client.authorize(token(), &permissions).await.unwrap();
348
349        // Then we recevie an answer, identifying the user and all the permissions are visible
350        // in the answer.
351        let expected = UserInfoAndPermissions {
352            sub: "295355180126307110".to_owned(),
353            email: Some("markus.klein@aleph-alpha.com".to_owned()),
354            email_verified: Some(true),
355            // It seems the IAM backend maintains order. So this assertion works.
356            permissions: permissions.to_vec(),
357        };
358        assert_eq!(expected, response);
359    }
360
361    #[tokio::test]
362    async fn asking_for_permissions_as_service() {
363        // We are using cassets to record the request. This makes the test easy to execute even
364        // without a connection to Pharia. Additionally it allows us to execute the test even
365        // without the specific token of the user who recorded it at hand.
366        let mut cassette_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
367        cassette_path.push("tests/cassettes/asking_for_permissions_as_service.vcr.json");
368
369        // Given a client
370        let client = IamClient::with_vcr(IAM_PRODUCTION_URL.to_owned(), cassette_path);
371        let permissions = [Permission::AccessAssistant, Permission::NuminousAccess];
372
373        // When sending a check user request with a token authorized for all permission it is
374        // asking for.
375        let response = client
376            .check_user(service_token(), &permissions)
377            .await
378            .unwrap();
379
380        // Then we recevie an answer, identifying the user and all the permissions are visible
381        // in the answer.
382        let expected = UserInfoAndPermissions {
383            sub: "336362361919115278".to_owned(),
384            email: None,
385            email_verified: None,
386            // It seems the IAM backend maintains order. So this assertion works.
387            permissions: [].to_vec(), // permissions.to_vec(),
388        };
389        assert_eq!(expected, response);
390    }
391
392    /// The [`Permission`]s enum is not exhaustive. If only testing as admin you get every, even
393    /// made up ones, mirrored. So we want to have a test to verify that permissions do exist, by
394    /// authorizing for them, with a
395    #[tokio::test]
396    async fn verify_predefined_permissions() {
397        let mut cassette_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
398        cassette_path.push("tests/cassettes/verify_predefined_permissions.vcr.json");
399
400        // Given a client
401        let client = IamClient::with_vcr(IAM_STAGE_URL.to_owned(), cassette_path);
402        let permissions = [
403            Permission::AccessAssistant,
404            Permission::ExecuteJobs,
405            Permission::KernelAccess,
406            Permission::NuminousAccess,
407            Permission::AccessModel {
408                model: Cow::Borrowed("*"),
409            },
410        ];
411
412        // When sending a check user request with a token authorized for all permission it is
413        // asking for.
414        let result = client
415            .authorize(stage_non_admin_token(), &permissions)
416            .await;
417
418        // Then we recevie an answer, identifying the user and all the permissions are visible
419        // in the answer.
420        eprintln!("{:?}", result);
421        assert!(result.is_ok());
422    }
423
424    /// Service token used for recording cassettes
425    ///
426    /// Credentials: pharia-internal-rs-test
427    /// The user (developers) token from the environment
428    fn service_token() -> String {
429        _ = dotenv();
430        env::var("PHARIA_AI_SERVICE_TOKEN").unwrap_or_else(|_| "DUMMY".to_owned())
431    }
432
433    /// The user (developers) token from the environment
434    fn token() -> String {
435        _ = dotenv();
436        env::var("PHARIA_AI_TOKEN").unwrap_or_else(|_| "DUMMY".to_owned())
437    }
438
439    /// The user (developers) token from the environment
440    fn stage_non_admin_token() -> String {
441        _ = dotenv();
442        env::var("PHARIA_STAGE_NON_ADMIN").unwrap_or_else(|_| "DUMMY".to_owned())
443    }
444}