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
13/// Client forPharia **I**dentity **A**ccess **M**anagement. Authenticate and authorize users.
14#[derive(Clone, Debug)]
15pub struct IamClient {
16    /// Environment specific URL to Pharia IAM. E.g. <https://pharia-iam.product.pharia.com>
17    base_url: String,
18    /// Used for sending the http requests. We are using `ClientWithMiddleware` to allow for VCR
19    /// recording in tests.
20    http_client: ClientWithMiddleware,
21}
22
23impl IamClient {
24    /// Construct a new client using the respective IAM instance. E.g. [`IAM_PRODUCTION_URL`]
25    pub fn new(base_url: String) -> Self {
26        let client = Client::builder().use_rustls_tls().build().expect(
27            "Must be able to initialize TLS backend and resolver must be able to load system \
28            configuration.",
29        );
30
31        let http_client: ClientWithMiddleware = ClientBuilder::new(client).build();
32
33        Self {
34            base_url,
35            http_client,
36        }
37    }
38
39    #[cfg(test)]
40    pub fn with_vcr(base_url: String, path_to_cassette: std::path::PathBuf) -> Self {
41        let cassette_does_exist = path_to_cassette.is_file();
42        let vcr_mode = if cassette_does_exist {
43            reqwest_vcr::VCRMode::Replay
44        } else {
45            reqwest_vcr::VCRMode::Record
46        };
47
48        let middleware = reqwest_vcr::VCRMiddleware::try_from(path_to_cassette)
49            .unwrap()
50            .with_mode(vcr_mode)
51            .with_modify_request(|request| {
52                if let Some(header) = request.headers.get_mut("authorization") {
53                    *header = vec!["TOKEN_REMOVED".to_owned()];
54                }
55            });
56
57        IamClient::with_middleware(base_url, middleware)
58    }
59
60    #[cfg(test)]
61    fn with_middleware(base_url: String, middleware: impl reqwest_middleware::Middleware) -> Self {
62        let client = Client::builder().use_rustls_tls().build().expect(
63            "Must be able to initialize TLS backend and resolver must be able to load system \
64            configuration.",
65        );
66
67        let http_client: ClientWithMiddleware = ClientBuilder::new(client).with(middleware).build();
68
69        IamClient {
70            base_url,
71            http_client,
72        }
73    }
74
75    /// One stop shop for both authentication and authorization.
76    ///
77    /// # Parameters
78    ///
79    /// * `token`: Service or user token used for authentication.
80    /// * `permissions`: A list of all permissions you are interested in. The response will contain
81    ///   the subset of these permissions which are privileges the user has.
82    ///
83    /// Example Authorize Assistant Access against production instance
84    ///
85    /// ```
86    /// pub use pharia_common::{IamClient, Permission, IAM_PRODUCTION_URL, CheckUserError};
87    ///
88    /// pub async fn is_authorized(token: &str) -> Result<bool, CheckUserError> {
89    ///     let iam = IamClient::new(IAM_PRODUCTION_URL.to_owned());
90    ///     let permissions = [Permission::AccessAssistant];
91    ///     let user_info = iam.check_user(token, &permissions).await?;
92    ///     let is_authorized = user_info.permissions == permissions;
93    ///     Ok(is_authorized)
94    /// }
95    /// ```
96    pub async fn check_user<'a>(
97        &self,
98        token: impl Display,
99        permissions: &'a [Permission<'a>],
100    ) -> Result<UserInfoAndPermissions, CheckUserError> {
101        let request_body = CheckUserRequestBody { permissions };
102
103        let response = self
104            .http_client
105            .post(format!("{base_url}/check_user", base_url = self.base_url))
106            .bearer_auth(token)
107            .json(&request_body)
108            .send()
109            .await
110            .map_err(|e| CheckUserError::ConnectionError(e.into()))?;
111
112        // A long standing quirk of the HTTP standard: Unauthorized 401 actually means
113        // "unauthenticated". We consider this a domain specific logic error, rather than a runtime
114        // error, which should be fixed with retry. Therfore we categorize this error differently
115        // the other connection errors
116        if response.status() == StatusCode::UNAUTHORIZED {
117            return Err(CheckUserError::Unauthenticated);
118        }
119
120        if response.status() == StatusCode::UNPROCESSABLE_ENTITY {
121            use anyhow::anyhow;
122            eprintln!("{}", response.text().await.unwrap());
123            return Err(CheckUserError::ConnectionError(anyhow!(
124                "Unprocessable entity"
125            )));
126        }
127
128        // Map all other thing to ConnectionError
129        response
130            .error_for_status_ref()
131            .map_err(|e| CheckUserError::ConnectionError(e.into()))?;
132
133        let user_info = response
134            .json()
135            .await
136            .map_err(|e| CheckUserError::ConnectionError(e.into()))?;
137
138        Ok(user_info)
139    }
140}
141
142/// Body of the the IAM `/check_user` route. The token is not passed in the body but in the
143/// authorization header.
144#[derive(Serialize)]
145struct CheckUserRequestBody<'a> {
146    /// A list of permissions to query for the specific user.
147    permissions: &'a [Permission<'a>],
148}
149
150/// Returned by [`IamClient::check_user`]. Contains information describing the user as well as the
151/// union of the queried permissions and the privileges of the user.
152#[derive(Deserialize, PartialEq, Eq, Debug)]
153pub struct UserInfoAndPermissions {
154    /// Unique ID of the User
155    pub sub: String,
156    /// Email of the user. `None` for Service users
157    pub email: Option<String>,
158    /// May be `None` for Service Users
159    pub email_verified: Option<bool>,
160    /// List of requested permissions, which are privieleges of the User Service. They are in the
161    /// same order as in the query
162    pub permissions: Vec<Permission<'static>>,
163}
164
165/// An error returned by [`IamClient::check_user`]. Note that this does **not** include
166/// unauthorized. To check for authorization inspect the permissions of [`UserInfoAndPermissions`]
167#[derive(thiserror::Error, Debug)]
168pub enum CheckUserError {
169    #[error("User is Unauthenticated. Token is invalid")]
170    Unauthenticated,
171    #[error("User could not be authenticated due to connectivity issue:\n{0:#}")]
172    ConnectionError(#[source] anyhow::Error),
173}
174
175#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Hash)]
176#[serde(tag = "permission")]
177pub enum Permission<'a> {
178    AccessAssistant,
179    AccessNuminous,
180    /// The kernel uses this permission to authorize skill execution
181    KernelAccess,
182    /// Used by inference to decide wether a user is authorized to perform any kind of inference
183    /// requests.
184    ExecuteJob,
185    /// Is this user allowed to use this model? "*" Can be used as a model name in order to indicate
186    /// access to all models.
187    AccessModel {
188        model: Cow<'a, str>,
189    },
190    HasRelation {
191        relation: Cow<'a, str>,
192        object: Cow<'a, str>,
193    },
194}
195
196#[cfg(test)]
197mod tests {
198    use dotenvy::dotenv;
199    use std::{env, path::PathBuf};
200
201    use super::{
202        CheckUserError, IAM_PRODUCTION_URL, IamClient, Permission, UserInfoAndPermissions,
203    };
204
205    #[tokio::test]
206    async fn valid_user_token() {
207        // We are using cassets to record the request. This makes the test easy to execute even
208        // without a connection to Pharia. Additionally it allows us to execute the test even
209        // without the specific token of the user who recorded it at hand.
210        let mut cassette_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
211        cassette_path.push("tests/cassettes/valid_user_token.vcr.json");
212
213        // Given a client
214        let client = IamClient::with_vcr(IAM_PRODUCTION_URL.to_owned(), cassette_path);
215
216        // When sending a check user request with a valid token
217        let response = client.check_user(token(), &[]).await.unwrap();
218
219        // Then we recevie an answer, identifying the user
220        let expected = UserInfoAndPermissions {
221            sub: "295355180126307110".to_owned(),
222            email: Some("markus.klein@aleph-alpha.com".to_owned()),
223            email_verified: Some(true),
224            permissions: vec![],
225        };
226        assert_eq!(expected, response);
227    }
228
229    #[tokio::test]
230    async fn invalid_user_token() {
231        // We are using cassets to record the request. This makes the test easy to execute even
232        // without a connection to Pharia. Additionally it allows us to execute the test even
233        // without the specific token of the user who recorded it at hand.
234        let mut cassette_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
235        cassette_path.push("tests/cassettes/invalid_user_token.vcr.json");
236
237        // Given an invalid Pharia User Token
238        let token = "I-AM-AN-INVALID-TOKEN";
239        let client = IamClient::with_vcr(IAM_PRODUCTION_URL.to_owned(), cassette_path);
240
241        // When sending a check user request
242        let result = client.check_user(token, &[]).await;
243
244        // Then the user is unauthenticated
245        assert!(matches!(result, Err(CheckUserError::Unauthenticated)))
246    }
247
248    #[tokio::test]
249    async fn asking_for_permissions() {
250        // We are using cassets to record the request. This makes the test easy to execute even
251        // without a connection to Pharia. Additionally it allows us to execute the test even
252        // without the specific token of the user who recorded it at hand.
253        let mut cassette_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
254        cassette_path.push("tests/cassettes/asking_for_permissions.vcr.json");
255
256        // Given a client
257        let client = IamClient::with_vcr(IAM_PRODUCTION_URL.to_owned(), cassette_path);
258        let permissions = [
259            Permission::KernelAccess,
260            Permission::ExecuteJob,
261            Permission::AccessAssistant,
262            Permission::AccessNuminous,
263            Permission::AccessModel { model: "*".into() },
264        ];
265
266        // When sending a check user request with a token authorized for all permission it is
267        // asking for.
268        let response = client.check_user(token(), &permissions).await.unwrap();
269
270        // Then we recevie an answer, identifying the user and all the permissions are visible
271        // in the answer.
272        let expected = UserInfoAndPermissions {
273            sub: "295355180126307110".to_owned(),
274            email: Some("markus.klein@aleph-alpha.com".to_owned()),
275            email_verified: Some(true),
276            // It seems the IAM backend maintains order. So this assertion works.
277            permissions: permissions.to_vec(),
278        };
279        assert_eq!(expected, response);
280    }
281
282    #[tokio::test]
283    async fn asking_for_permissions_as_service() {
284        // We are using cassets to record the request. This makes the test easy to execute even
285        // without a connection to Pharia. Additionally it allows us to execute the test even
286        // without the specific token of the user who recorded it at hand.
287        let mut cassette_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
288        cassette_path.push("tests/cassettes/asking_for_permissions_as_service.vcr.json");
289
290        // Given a client
291        let client = IamClient::with_vcr(IAM_PRODUCTION_URL.to_owned(), cassette_path);
292        let permissions = [Permission::AccessAssistant, Permission::AccessNuminous];
293
294        // When sending a check user request with a token authorized for all permission it is
295        // asking for.
296        let response = client
297            .check_user(service_token(), &permissions)
298            .await
299            .unwrap();
300
301        // Then we recevie an answer, identifying the user and all the permissions are visible
302        // in the answer.
303        let expected = UserInfoAndPermissions {
304            sub: "336362361919115278".to_owned(),
305            email: None,
306            email_verified: None,
307            // It seems the IAM backend maintains order. So this assertion works.
308            permissions: [].to_vec(), // permissions.to_vec(),
309        };
310        assert_eq!(expected, response);
311    }
312
313    /// Service token used for recording cassettes
314    ///
315    /// Credentials: pharia-internal-rs-test
316    /// The user (developers) token from the environment
317    fn service_token() -> String {
318        _ = dotenv();
319        env::var("PHARIA_AI_SERVICE_TOKEN").unwrap_or_else(|_| "DUMMY".to_owned())
320    }
321
322    /// The user (developers) token from the environment
323    fn token() -> String {
324        _ = dotenv();
325        env::var("PHARIA_AI_TOKEN").unwrap_or_else(|_| "DUMMY".to_owned())
326    }
327}