From 67b6718dafc07f17b3dbf2a27d4348835ca475b8 Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Mon, 23 Jun 2025 21:55:45 +0200 Subject: [PATCH] feat: migrate identity from osauth --- Cargo.toml | 6 +- src/identity/application_credential.rs | 161 +++++++++++ src/identity/catalog.rs | 234 ++++++++++++++++ src/identity/internal.rs | 241 ++++++++++++++++ src/identity/mod.rs | 51 ++++ src/identity/password.rs | 347 +++++++++++++++++++++++ src/identity/protocol.rs | 372 +++++++++++++++++++++++++ src/identity/token.rs | 265 ++++++++++++++++++ src/lib.rs | 5 +- 9 files changed, 1679 insertions(+), 3 deletions(-) create mode 100644 src/identity/application_credential.rs create mode 100644 src/identity/catalog.rs create mode 100644 src/identity/internal.rs create mode 100644 src/identity/mod.rs create mode 100644 src/identity/password.rs create mode 100644 src/identity/protocol.rs create mode 100644 src/identity/token.rs diff --git a/Cargo.toml b/Cargo.toml index eec25354fc..ca297ab20f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,11 +14,11 @@ edition = "2021" rust-version = "1.82" [features] -default = ["baremetal", "block-storage", "compute", "image", "network", "native-tls", "object-storage"] +default = ["baremetal", "block-storage", "compute", "identity", "image", "network", "native-tls", "object-storage"] baremetal = ["json-patch"] block-storage = [] compute = [] -identity = [] # reserved for future use +identity = ["static_assertions", "tokio"] image = [] network = ["macaddr", "ipnet"] native-tls = ["reqwest/default-tls", "osauth/native-tls"] @@ -40,6 +40,8 @@ serde = "^1.0" serde_derive = "^1.0" serde_json = "^1.0" serde_yaml = "^0.9" +static_assertions = { version = "^1.1", optional = true } +tokio = { version = "^1.0", features = ["sync"], optional = true } tokio-util = { version = "^0.7", features = ["codec", "compat"], optional = true } waiter = { version = "^0.2" } diff --git a/src/identity/application_credential.rs b/src/identity/application_credential.rs new file mode 100644 index 0000000000..5a89f917af --- /dev/null +++ b/src/identity/application_credential.rs @@ -0,0 +1,161 @@ +// Copyright 2023 Matt Williams +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Application Credential authentication. + +use async_trait::async_trait; +use osauth::{AuthType, EndpointFilters, Error}; +use reqwest::{Client, RequestBuilder, Url}; +use static_assertions::assert_impl_all; + +use super::internal::Internal; +use super::protocol; +use super::IdOrName; + +/// Application Credential authentication using Identity API V3. +/// +/// For any Identity authentication you need to know `auth_url`, which is an authentication endpoint +/// of the Identity service. For the Application Credential authentication you also need: +/// 1. Application Credential ID +/// 2. Application Credential secret +/// +/// Start with creating a `ApplicationCredential` object using [new](#method.new): +/// +/// ```rust,no_run +/// use osauth::common::IdOrName; +/// let auth = osauth::identity::ApplicationCredential::new( +/// "https://cloud.local/identity", +/// "", +/// "", +/// ) +/// .expect("Invalid auth_url"); +/// +/// let session = osauth::Session::new(auth); +/// ``` +/// +/// The authentication token is cached while it's still valid or until +/// [refresh](../trait.AuthType.html#tymethod.refresh) is called. +/// Clones of an `ApplicationCredential` also start with an empty cache. +#[derive(Debug, Clone)] +pub struct ApplicationCredential { + inner: Internal, +} + +assert_impl_all!(ApplicationCredential: Send, Sync); + +impl ApplicationCredential { + /// Create an application credential authentication. + pub fn new(auth_url: U, id: S1, secret: S2) -> Result + where + U: AsRef, + S1: Into, + S2: Into, + { + let app_cred = protocol::ApplicationCredential { + id: IdOrName::Id(id.into()), + secret: secret.into(), + user: None, + }; + let body = protocol::AuthRoot { + auth: protocol::Auth { + identity: protocol::Identity::ApplicationCredential(app_cred), + scope: None, + }, + }; + Ok(Self { + inner: Internal::new(auth_url.as_ref(), body)?, + }) + } + + /// Create an application credential authentication from a credential name. + pub fn with_user_id( + auth_url: U, + name: S1, + secret: S2, + user_id: S3, + ) -> Result + where + U: AsRef, + S1: Into, + S2: Into, + S3: Into, + { + let app_cred = protocol::ApplicationCredential { + id: IdOrName::Name(name.into()), + secret: secret.into(), + user: Some(IdOrName::Id(user_id.into())), + }; + let body = protocol::AuthRoot { + auth: protocol::Auth { + identity: protocol::Identity::ApplicationCredential(app_cred), + scope: None, + }, + }; + Ok(Self { + inner: Internal::new(auth_url.as_ref(), body)?, + }) + } + + /// Project name or ID (if project scoped). + #[inline] + pub fn project(&self) -> Option<&IdOrName> { + self.inner.project() + } +} + +#[async_trait] +impl AuthType for ApplicationCredential { + /// Authenticate a request. + async fn authenticate( + &self, + client: &Client, + request: RequestBuilder, + ) -> Result { + self.inner.authenticate(client, request).await + } + + /// Get a URL for the requested service. + async fn get_endpoint( + &self, + client: &Client, + service_type: &str, + filters: &EndpointFilters, + ) -> Result { + self.inner.get_endpoint(client, service_type, filters).await + } + + /// Refresh the cached token and service catalog. + async fn refresh(&self, client: &Client) -> Result<(), Error> { + self.inner.refresh(client, true).await + } +} + +#[cfg(test)] +pub mod test { + #![allow(unused_results)] + + use reqwest::Url; + + use super::ApplicationCredential; + + #[test] + fn test_identity_new() { + let id = ApplicationCredential::new("http://127.0.0.1:8080/", "abcdef", "shhhh").unwrap(); + let e = Url::parse(id.inner.token_endpoint()).unwrap(); + assert_eq!(e.scheme(), "http"); + assert_eq!(e.host_str().unwrap(), "127.0.0.1"); + assert_eq!(e.port().unwrap(), 8080u16); + assert_eq!(e.path(), "/v3/auth/tokens"); + } +} diff --git a/src/identity/catalog.rs b/src/identity/catalog.rs new file mode 100644 index 0000000000..1c6a3e3ec0 --- /dev/null +++ b/src/identity/catalog.rs @@ -0,0 +1,234 @@ +// Copyright 2021 Dmitry Tantsur +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Abstraction over a service catalog. + +use std::fmt; + +use log::{debug, error}; +use osauth::{Error, ErrorKind}; +use reqwest::Url; + +use super::protocol; +use crate::EndpointFilters; + +/// Abstraction over a service catalog. +/// +/// In standalone case only one URL is returned for any service. +#[derive(Debug, Clone)] +pub struct ServiceCatalog { + inner: Vec, +} + +fn new_endpoint_not_found(service_type: D) -> Error { + Error::new( + ErrorKind::EndpointNotFound, + format!("Endpoint for service {} was not found", service_type), + ) +} + +impl ServiceCatalog { + pub(crate) fn new(catalog: Vec) -> ServiceCatalog { + ServiceCatalog { inner: catalog } + } + + /// Find an endpoint in the catalog. + pub fn find_endpoint( + &self, + service_type: &str, + filters: &EndpointFilters, + ) -> Result { + let svc = match self.inner.iter().find(|x| x.service_type == *service_type) { + Some(s) => s, + None => return Err(new_endpoint_not_found(service_type)), + }; + + let mut endpoints: Vec<_> = svc + .endpoints + .iter() + .filter(|x| { + filters + .interfaces + // FIXME(dtantsur): return to using check() when migrated + .iter() + .position(|item| item == &x.interface) + .is_some() + }) + .collect(); + endpoints + // NOTE(dtantsur): because of the filter above unwrap never fails + .sort_unstable_by_key(|x| { + filters + .interfaces + // FIXME(dtantsur): return to using find() when migrated + .iter() + .position(|item| item == &x.interface) + .unwrap() + }); + endpoints + .into_iter() + .next() + .ok_or_else(|| new_endpoint_not_found(service_type)) + .and_then(|endp| { + debug!("Received {:?} for {}", endp, service_type); + Url::parse(&endp.url).map_err(|e| { + error!( + "Invalid URL {} received from service catalog for service \ + '{}', filters {:?}: {}", + endp.url, service_type, filters, e + ); + Error::new( + ErrorKind::InvalidResponse, + format!("Invalid URL {} for {} - {}", endp.url, service_type, e), + ) + }) + }) + } +} + +#[cfg(test)] +pub mod test { + use reqwest::Url; + + use crate::identity::protocol::{CatalogRecord, Endpoint}; + use crate::{EndpointFilters, Error, ErrorKind, InterfaceType, ValidInterfaces}; + use InterfaceType::*; + + use super::ServiceCatalog; + + fn demo_service1() -> CatalogRecord { + CatalogRecord { + service_type: String::from("identity"), + endpoints: vec![ + Endpoint { + interface: String::from("public"), + region: String::from("RegionOne"), + url: String::from("https://host.one/identity"), + }, + Endpoint { + interface: String::from("internal"), + region: String::from("RegionOne"), + url: String::from("http://192.168.22.1/identity"), + }, + Endpoint { + interface: String::from("public"), + region: String::from("RegionTwo"), + url: String::from("https://host.two:5000"), + }, + ], + } + } + + fn demo_service2() -> CatalogRecord { + CatalogRecord { + service_type: String::from("baremetal"), + endpoints: vec![ + Endpoint { + interface: String::from("public"), + region: String::from("RegionOne"), + url: String::from("https://host.one/baremetal"), + }, + Endpoint { + interface: String::from("public"), + region: String::from("RegionTwo"), + url: String::from("https://host.two:6385"), + }, + ], + } + } + + pub fn demo_catalog() -> ServiceCatalog { + ServiceCatalog::new(vec![demo_service1(), demo_service2()]) + } + + fn find_endpoint( + cat: &ServiceCatalog, + service_type: &str, + interface_type: InterfaceType, + region: Option<&str>, + ) -> Result { + let filters = EndpointFilters { + interfaces: ValidInterfaces::one(interface_type), + region: region.map(|x| x.to_string()), + }; + cat.find_endpoint(service_type, &filters) + } + + #[test] + fn test_find_endpoint() { + let cat = demo_catalog(); + + let e1 = find_endpoint(&cat, "identity", Public, None).unwrap(); + assert_eq!(e1.as_str(), "https://host.one/identity"); + + let e2 = find_endpoint(&cat, "identity", Internal, None).unwrap(); + assert_eq!(e2.as_str(), "http://192.168.22.1/identity"); + + let e3 = find_endpoint(&cat, "baremetal", Public, None).unwrap(); + assert_eq!(e3.as_str(), "https://host.one/baremetal"); + } + + #[test] + fn test_find_endpoint_from_many() { + let cat = demo_catalog(); + let service_type = "identity"; + + let f1 = EndpointFilters::default().with_interfaces(vec![Public, Internal]); + let e1 = cat.find_endpoint(service_type, &f1).unwrap(); + assert_eq!(e1.as_str(), "https://host.one/identity"); + + let f2 = EndpointFilters::default().with_interfaces(vec![Admin, Internal, Public]); + let e2 = cat.find_endpoint(service_type, &f2).unwrap(); + assert_eq!(e2.as_str(), "http://192.168.22.1/identity"); + + let f3 = EndpointFilters::default().with_interfaces(vec![Admin, Public]); + let e3 = cat.find_endpoint(service_type, &f3).unwrap(); + assert_eq!(e3.as_str(), "https://host.one/identity"); + } + + #[test] + fn test_find_endpoint_with_region() { + let cat = demo_catalog(); + + let e1 = find_endpoint(&cat, "identity", Public, Some("RegionTwo")).unwrap(); + assert_eq!(e1.as_str(), "https://host.two:5000/"); + + let e2 = find_endpoint(&cat, "identity", Internal, Some("RegionOne")).unwrap(); + assert_eq!(e2.as_str(), "http://192.168.22.1/identity"); + + let e3 = find_endpoint(&cat, "baremetal", Public, Some("RegionTwo")).unwrap(); + assert_eq!(e3.as_str(), "https://host.two:6385/"); + } + + fn assert_not_found(result: Result) { + let err = result.err().unwrap(); + if err.kind() != ErrorKind::EndpointNotFound { + panic!("Unexpected error {}", err); + } + } + + #[test] + fn test_find_endpoint_not_found() { + let cat = demo_catalog(); + + assert_not_found(find_endpoint(&cat, "foobar", Public, None)); + assert_not_found(find_endpoint(&cat, "identity", Public, Some("RegionFoo"))); + assert_not_found(find_endpoint(&cat, "baremetal", Internal, None)); + assert_not_found(find_endpoint(&cat, "identity", Internal, Some("RegionTwo"))); + + let f1 = EndpointFilters::default().with_interfaces(vec![Admin, Internal]); + let e1 = cat.find_endpoint("baremetal", &f1); + assert_not_found(e1); + } +} diff --git a/src/identity/internal.rs b/src/identity/internal.rs new file mode 100644 index 0000000000..c5296e8806 --- /dev/null +++ b/src/identity/internal.rs @@ -0,0 +1,241 @@ +// Copyright 2020 Dmitry Tantsur +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Internal implementation of the identity authentication. + +use std::collections::hash_map::DefaultHasher; +use std::fmt; +use std::hash::{Hash, Hasher}; +use std::ops::Deref; + +use chrono::{DateTime, Duration, FixedOffset, Local}; +use log::{debug, error, trace}; +use osauth::client; +use reqwest::{Client, RequestBuilder, Response, Url}; +use tokio::sync::{RwLock, RwLockReadGuard}; + +use super::catalog::ServiceCatalog; +use super::protocol::{self, AuthRoot}; +use super::{IdOrName, Scope, INVALID_SUBJECT_HEADER, MISSING_SUBJECT_HEADER, TOKEN_MIN_VALIDITY}; +use crate::{EndpointFilters, Error, ErrorKind}; + +/// Plain authentication token without additional details. +#[derive(Clone)] +pub(crate) struct Token { + value: String, + expires_at: DateTime, + catalog: ServiceCatalog, +} + +static_assertions::assert_eq_size!(Option, Token); + +impl fmt::Debug for Token { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let mut hasher = DefaultHasher::new(); + self.value.hash(&mut hasher); + write!( + f, + "Token {{ value: hash({}), catalog: {:?} }}", + hasher.finish(), + self.catalog + ) + } +} + +/// Internal identity authentication object. +#[derive(Debug)] +pub(crate) struct Internal { + body: AuthRoot, + token_endpoint: String, + cached_token: RwLock>, +} + +impl Internal { + /// Create a new implementation. + pub fn new(auth_url: &str, body: AuthRoot) -> Result { + let mut auth_url = Url::parse(auth_url) + .map_err(|e| Error::new(ErrorKind::InvalidInput, format!("Invalid auth_url: {}", e)))?; + + let _ = auth_url + .path_segments_mut() + .map_err(|_| Error::new(ErrorKind::InvalidConfig, "Invalid auth_url: wrong schema?"))? + .pop_if_empty() + .push(""); + + let token_endpoint = if auth_url.as_str().ends_with("/v3/") { + format!("{}auth/tokens", auth_url) + } else { + format!("{}v3/auth/tokens", auth_url) + }; + + Ok(Internal { + body, + token_endpoint, + cached_token: RwLock::new(None), + }) + } + + /// Access to the cached token. + pub async fn cached_token(&self, client: &Client) -> Result, Error> { + self.refresh(client, false).await?; + let guard = self.cached_token.read().await; + // unwrap is safe because do_refresh unconditionally populates the token + Ok(RwLockReadGuard::try_map(guard, |opt| opt.as_ref()).unwrap()) + } + + /// Get a URL for the requested service. + pub async fn get_endpoint( + &self, + client: &Client, + service_type: &str, + filters: &EndpointFilters, + ) -> Result { + debug!( + "Requesting a catalog endpoint for service '{}', filters {:?}", + service_type, filters + ); + let token = self.cached_token(client).await?; + token.catalog.find_endpoint(service_type, filters) + } + + /// Get the authentication token string. + #[inline] + pub async fn get_token(&self, client: &Client) -> Result { + let token = self.cached_token(client).await?; + Ok(token.value.clone()) + } + + /// Add a scope to the authentication. + pub fn set_scope(&mut self, scope: Scope) { + self.body.auth.scope = Some(match scope { + Scope::Project { project, domain } => { + protocol::Scope::Project(protocol::Project { project, domain }) + } + }); + } + + /// User name or ID. + #[inline] + pub fn user(&self) -> Option<&IdOrName> { + match self.body.auth.identity { + protocol::Identity::Password(ref pw) => Some(&pw.user), + _ => None, + } + } + + /// Project name or ID (if project scoped). + #[inline] + pub fn project(&self) -> Option<&IdOrName> { + match self.body.auth.scope { + Some(protocol::Scope::Project(ref prj)) => Some(&prj.project), + _ => None, + } + } + + /// Refresh the token (if needed or forced). + pub async fn refresh(&self, client: &Client, force: bool) -> Result<(), Error> { + // This is executed every request at least once, so it's important to start with a read + // lock. We expect to hit this branch most of the time. + if !force && token_alive(&self.cached_token.read().await) { + return Ok(()); + } + + let mut lock = self.cached_token.write().await; + // Additonal check in case another thread has updated the token while we were waiting for + // the write lock. + if token_alive(&lock) { + return Ok(()); + } + + let resp = client + .post(&self.token_endpoint) + .json(&self.body) + .send() + .await?; + *lock = Some(token_from_response(client::check(resp).await?).await?); + Ok(()) + } + + /// Create an authenticated request. + pub async fn authenticate( + &self, + client: &Client, + request: RequestBuilder, + ) -> Result { + let token = self.get_token(client).await?; + Ok(request.header("x-auth-token", token)) + } + + #[cfg(test)] + pub fn token_endpoint(&self) -> &str { + &self.token_endpoint + } +} + +impl Clone for Internal { + fn clone(&self) -> Internal { + Internal { + body: self.body.clone(), + token_endpoint: self.token_endpoint.clone(), + cached_token: RwLock::new(None), + } + } +} + +#[inline] +fn token_alive(token: &impl Deref>) -> bool { + if let Some(value) = token.deref() { + let validity_time_left = value.expires_at.signed_duration_since(Local::now()); + trace!("Token is valid for {:?}", validity_time_left); + validity_time_left > Duration::minutes(TOKEN_MIN_VALIDITY) + } else { + false + } +} + +async fn token_from_response(resp: Response) -> Result { + let value = match resp.headers().get("x-subject-token") { + Some(hdr) => match hdr.to_str() { + Ok(s) => Ok(s.to_string()), + Err(e) => { + error!( + "Invalid X-Subject-Token {:?} received from {}: {}", + hdr, + resp.url(), + e + ); + Err(Error::new( + ErrorKind::InvalidResponse, + INVALID_SUBJECT_HEADER, + )) + } + }, + None => { + error!("No X-Subject-Token header received from {}", resp.url()); + Err(Error::new( + ErrorKind::InvalidResponse, + MISSING_SUBJECT_HEADER, + )) + } + }?; + + let root = resp.json::().await?; + debug!("Received a token expiring at {}", root.token.expires_at); + trace!("Received catalog: {:?}", root.token.catalog); + Ok(Token { + value, + expires_at: root.token.expires_at, + catalog: ServiceCatalog::new(root.token.catalog), + }) +} diff --git a/src/identity/mod.rs b/src/identity/mod.rs new file mode 100644 index 0000000000..acb4cd6999 --- /dev/null +++ b/src/identity/mod.rs @@ -0,0 +1,51 @@ +// Copyright 2019-2020 Dmitry Tantsur +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Authentication using Identity API v3. +//! +//! Currently supports [Password](struct.Password.html) and [ApplicationCredential] authentication. +//! Identity API v2 is not and will not be supported. + +mod application_credential; +mod catalog; +mod internal; +mod password; +pub(crate) mod protocol; +mod token; + +use super::IdOrName; + +pub use self::application_credential::ApplicationCredential; +pub use self::password::Password; +pub use self::token::Token; + +const MISSING_SUBJECT_HEADER: &str = "Missing X-Subject-Token header"; +const INVALID_SUBJECT_HEADER: &str = "Invalid X-Subject-Token header"; +// Required validity time in minutes. Here we refresh the token if it expires +// in 10 minutes or less. +const TOKEN_MIN_VALIDITY: i64 = 10; + +/// A scope of a token. +/// +/// Only project scopes are currently supported. +#[derive(Debug)] +pub enum Scope { + /// A token scoped to a project. + Project { + /// Project ID or name. + project: IdOrName, + /// ID or name of the project domain. + domain: Option, + }, +} diff --git a/src/identity/password.rs b/src/identity/password.rs new file mode 100644 index 0000000000..1e4944a26a --- /dev/null +++ b/src/identity/password.rs @@ -0,0 +1,347 @@ +// Copyright 2019-2020 Dmitry Tantsur +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Password authentication. + +use async_trait::async_trait; +use osauth::{AuthType, Error}; +use reqwest::{Client, RequestBuilder, Url}; +use static_assertions::assert_impl_all; + +use super::internal::Internal; +use super::protocol; +use super::Scope; +use crate::{EndpointFilters, IdOrName}; + +/// Password authentication using Identity API V3. +/// +/// For any Identity authentication you need to know `auth_url`, which is an authentication endpoint +/// of the Identity service. For the Password authentication you also need: +/// 1. User name and password. +/// 2. Domain of the user. +/// 3. Name of the project to use. +/// 4. Domain of the project. +/// +/// Note: currently only names are supported for user, user domain and project domain. ID support is +/// coming later. +/// +/// Start with creating a `Password` object using [new](#method.new), then add a project scope +/// with [with_project_scope](#method.with_project_scope): +/// +/// ```rust,no_run +/// # async fn example() -> Result<(), osauth::Error> { +/// use osauth::common::IdOrName; +/// let auth = osauth::identity::Password::new( +/// "https://cloud.local/identity", +/// "admin", +/// "pa$$w0rd", +/// "Default" +/// )? +/// .with_project_scope(IdOrName::from_name("project1"), IdOrName::from_id("default")); +/// +/// let session = osauth::Session::new(auth).await?; +/// # Ok(()) } +/// # #[tokio::main] +/// # async fn main() { example().await.unwrap(); } +/// ``` +/// +/// By default, the `public` endpoint interface is used. If you would prefer to default to another +/// one, you can set it on the `Session`. Region can also be set there: +/// +/// ```rust,no_run +/// # async fn example() -> Result<(), osauth::Error> { +/// use osauth::common::IdOrName; +/// +/// let scope = osauth::identity::Scope::Project { +/// project: IdOrName::from_name("project1"), +/// domain: Some(IdOrName::from_id("default")), +/// }; +/// let auth = osauth::identity::Password::new( +/// "https://cloud.local/identity", +/// "admin", +/// "pa$$w0rd", +/// "Default" +/// )? +/// .with_scope(scope); +/// +/// let session = osauth::Session::new(auth) +/// .await? +/// .with_endpoint_interface(osauth::InterfaceType::Internal) +/// .with_region("US-East"); +/// # Ok(()) } +/// # #[tokio::main] +/// # async fn main() { example().await.unwrap(); } +/// ``` +/// +/// The authentication token is cached while it's still valid or until +/// [refresh](../trait.AuthType.html#tymethod.refresh) is called. +/// Clones of a `Password` also start with an empty cache. +#[derive(Debug, Clone)] +pub struct Password { + inner: Internal, +} + +assert_impl_all!(Password: Send, Sync); + +impl Password { + /// Create a password authentication. + pub fn new( + auth_url: U, + user_name: S1, + password: S2, + user_domain_name: S3, + ) -> Result + where + U: AsRef, + S1: Into, + S2: Into, + S3: Into, + { + let pw = protocol::UserAndPassword { + user: IdOrName::Name(user_name.into()), + password: password.into(), + domain: Some(IdOrName::Name(user_domain_name.into())), + }; + let body = protocol::AuthRoot { + auth: protocol::Auth { + identity: protocol::Identity::Password(pw), + scope: None, + }, + }; + Ok(Password { + inner: Internal::new(auth_url.as_ref(), body)?, + }) + } + + /// Scope authentication to the given project. + /// + /// A convenience wrapper around `set_scope`. + #[inline] + pub fn set_project_scope(&mut self, project: IdOrName, domain: impl Into>) { + self.set_scope(Scope::Project { + project, + domain: domain.into(), + }); + } + + /// Add a scope to the authentication. + /// + /// This is required in the most cases. + #[inline] + pub fn set_scope(&mut self, scope: Scope) { + self.inner.set_scope(scope); + } + + /// Scope authentication to the given project. + /// + /// A convenience wrapper around `with_scope`. + #[inline] + pub fn with_project_scope( + mut self, + project: IdOrName, + domain: impl Into>, + ) -> Password { + self.set_project_scope(project, domain); + self + } + + /// Add a scope to the authentication. + #[inline] + pub fn with_scope(mut self, scope: Scope) -> Self { + self.set_scope(scope); + self + } + + /// User name or ID. + #[inline] + pub fn user(&self) -> &IdOrName { + self.inner.user().expect("Password auth without a user") + } + + /// Project name or ID (if project scoped). + #[inline] + pub fn project(&self) -> Option<&IdOrName> { + self.inner.project() + } +} + +#[async_trait] +impl AuthType for Password { + /// Authenticate a request. + async fn authenticate( + &self, + client: &Client, + request: RequestBuilder, + ) -> Result { + self.inner.authenticate(client, request).await + } + + /// Get a URL for the requested service. + async fn get_endpoint( + &self, + client: &Client, + service_type: &str, + filters: &EndpointFilters, + ) -> Result { + self.inner.get_endpoint(client, service_type, filters).await + } + + /// Refresh the cached token and service catalog. + async fn refresh(&self, client: &Client) -> Result<(), Error> { + self.inner.refresh(client, true).await + } +} + +#[cfg(test)] +pub mod test { + #![allow(unused_results)] + + use reqwest::Url; + + use super::Password; + use crate::identity::IdOrName; + + #[test] + fn test_identity_new() { + let id = Password::new("http://127.0.0.1:8080/", "admin", "pa$$w0rd", "Default").unwrap(); + let e = Url::parse(id.inner.token_endpoint()).unwrap(); + assert_eq!(e.scheme(), "http"); + assert_eq!(e.host_str().unwrap(), "127.0.0.1"); + assert_eq!(e.port().unwrap(), 8080u16); + assert_eq!(e.path(), "/v3/auth/tokens"); + assert_eq!(id.user(), &IdOrName::Name("admin".to_string())); + } + + #[test] + fn test_identity_new_invalid() { + Password::new("http://127.0.0.1 8080/", "admin", "pa$$w0rd", "Default") + .err() + .unwrap(); + } + + #[test] + fn test_identity_create() { + let id = Password::new( + "http://127.0.0.1:8080/identity", + "user", + "pa$$w0rd", + "example.com", + ) + .unwrap() + .with_project_scope( + IdOrName::Name("cool project".to_string()), + IdOrName::Name("example.com".to_string()), + ); + assert_eq!(id.user(), &IdOrName::Name("user".to_string())); + assert_eq!( + id.project(), + Some(&IdOrName::Name("cool project".to_string())) + ); + assert_eq!( + id.inner.token_endpoint(), + "http://127.0.0.1:8080/identity/v3/auth/tokens" + ); + } + + #[test] + fn test_token_endpoint_with_trailing_slash() { + let id = Password::new( + "http://127.0.0.1:8080/identity/", + "user", + "pa$$w0rd", + "example.com", + ) + .unwrap() + .with_project_scope( + IdOrName::Name("cool project".to_string()), + IdOrName::Name("example.com".to_string()), + ); + assert_eq!(id.user(), &IdOrName::Name("user".to_string())); + assert_eq!( + id.project(), + Some(&IdOrName::Name("cool project".to_string())) + ); + assert_eq!( + id.inner.token_endpoint(), + "http://127.0.0.1:8080/identity/v3/auth/tokens" + ); + } + + #[test] + fn test_token_endpoint_with_v3() { + let id = Password::new( + "http://127.0.0.1:8080/identity/v3", + "user", + "pa$$w0rd", + "example.com", + ) + .unwrap() + .with_project_scope( + IdOrName::Name("cool project".to_string()), + IdOrName::Name("example.com".to_string()), + ); + assert_eq!(id.user(), &IdOrName::Name("user".to_string())); + assert_eq!( + id.project(), + Some(&IdOrName::Name("cool project".to_string())) + ); + assert_eq!( + id.inner.token_endpoint(), + "http://127.0.0.1:8080/identity/v3/auth/tokens" + ); + } + + #[test] + fn test_token_endpoint_with_trailing_slash_v3() { + let id = Password::new( + "http://127.0.0.1:8080/identity/v3/", + "user", + "pa$$w0rd", + "example.com", + ) + .unwrap() + .with_project_scope( + IdOrName::Name("cool project".to_string()), + IdOrName::Name("example.com".to_string()), + ); + assert_eq!(id.user(), &IdOrName::Name("user".to_string())); + assert_eq!( + id.project(), + Some(&IdOrName::Name("cool project".to_string())) + ); + assert_eq!( + id.inner.token_endpoint(), + "http://127.0.0.1:8080/identity/v3/auth/tokens" + ); + } + + #[test] + fn test_token_endpoint_root() { + let id = Password::new("http://127.0.0.1:8080", "user", "pa$$w0rd", "example.com") + .unwrap() + .with_project_scope( + IdOrName::Name("cool project".to_string()), + IdOrName::Name("example.com".to_string()), + ); + assert_eq!(id.user(), &IdOrName::Name("user".to_string())); + assert_eq!( + id.project(), + Some(&IdOrName::Name("cool project".to_string())) + ); + assert_eq!( + id.inner.token_endpoint(), + "http://127.0.0.1:8080/v3/auth/tokens" + ); + } +} diff --git a/src/identity/protocol.rs b/src/identity/protocol.rs new file mode 100644 index 0000000000..703ff52207 --- /dev/null +++ b/src/identity/protocol.rs @@ -0,0 +1,372 @@ +// Copyright 2019 Dmitry Tantsur +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Identity V3 JSON structures and protocol bits. + +use chrono::{DateTime, FixedOffset}; +use serde::ser::SerializeStruct; +use serde::{Deserialize, Serialize, Serializer}; + +use crate::IdOrName; + +/// User and password. +#[derive(Clone, Debug, Serialize)] +pub struct UserAndPassword { + #[serde(flatten)] + pub user: IdOrName, + pub password: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub domain: Option, +} + +/// Application credential. +#[derive(Clone, Debug, Serialize)] +pub struct ApplicationCredential { + #[serde(flatten)] + pub id: IdOrName, + pub secret: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub user: Option, +} + +/// Authentication identity. +#[derive(Clone, Debug)] +pub enum Identity { + /// Authentication with a user and a password. + Password(UserAndPassword), + /// Authentication with a token. + Token(String), + /// Authentication with an application credential. + ApplicationCredential(ApplicationCredential), +} + +/// A reference to a project in a domain. +#[derive(Clone, Debug, Serialize)] +pub struct Project { + #[serde(flatten)] + pub project: IdOrName, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub domain: Option, +} + +/// A scope. +#[allow(unused)] +#[derive(Clone, Debug, Serialize)] +pub enum Scope { + /// Project scope. + #[serde(rename = "project")] + Project(Project), + /// Domain scope. + #[serde(rename = "domain")] + Domain(IdOrName), + #[serde(rename = "system", serialize_with = "ser_system_scope")] + System, +} + +/// An authentication object. +#[derive(Clone, Debug, Serialize)] +pub struct Auth { + /// Authentication identity. + pub identity: Identity, + /// Authentication scope (if needed). + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, +} + +/// An authentication request root. +#[derive(Clone, Debug, Serialize)] +pub struct AuthRoot { + pub auth: Auth, +} + +/// An endpoint in the catalog. +#[derive(Clone, Debug, Deserialize)] +pub struct Endpoint { + pub interface: String, + #[allow(dead_code)] // FIXME(dtantsur): remove when migrated endpoint filters + pub region: String, + pub url: String, +} + +/// A service catalog record. +#[derive(Clone, Debug, Deserialize)] +pub struct CatalogRecord { + #[serde(rename = "type")] + pub service_type: String, + pub endpoints: Vec, +} + +/// An authentication token with embedded catalog. +#[derive(Clone, Debug, Deserialize)] +pub struct Token { + pub expires_at: DateTime, + pub catalog: Vec, +} + +/// A token response root. +#[derive(Clone, Debug, Deserialize)] +pub struct TokenRoot { + pub token: Token, +} + +#[derive(Debug, Serialize)] +struct PasswordAuth<'a> { + user: &'a UserAndPassword, +} + +#[derive(Debug, Serialize)] +struct TokenAuth<'a> { + id: &'a str, +} + +impl Serialize for Identity { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut inner = serializer.serialize_struct("Identity", 2)?; + match self { + Identity::Password(ref user) => { + inner.serialize_field("methods", &["password"])?; + inner.serialize_field("password", &PasswordAuth { user })?; + } + Identity::Token(ref token) => { + inner.serialize_field("methods", &["token"])?; + inner.serialize_field("token", &TokenAuth { id: token })?; + } + Identity::ApplicationCredential(ref cred) => { + inner.serialize_field("methods", &["application_credential"])?; + inner.serialize_field("application_credential", &cred)?; + } + } + inner.end() + } +} + +fn ser_system_scope(serializer: S) -> Result +where + S: Serializer, +{ + let mut inner = serializer.serialize_struct("System", 1)?; + inner.serialize_field("all", &true)?; + inner.end() +} + +#[cfg(test)] +mod test { + use super::*; + use crate::common::test; + + const PASSWORD_NAME_UNSCOPED: &str = r#" +{ + "auth": { + "identity": { + "methods": [ + "password" + ], + "password": { + "user": { + "name": "admin", + "domain": { + "name": "Default" + }, + "password": "devstacker" + } + } + } + } +}"#; + + const PASSWORD_ID_SCOPED_WITH_ID: &str = r#" +{ + "auth": { + "identity": { + "methods": [ + "password" + ], + "password": { + "user": { + "id": "ee4dfb6e5540447cb3741905149d9b6e", + "password": "devstacker" + } + } + }, + "scope": { + "domain": { + "id": "default" + } + } + } +}"#; + + const PASSWORD_ID_SYSTEM_SCOPE: &str = r#" +{ + "auth": { + "identity": { + "methods": [ + "password" + ], + "password": { + "user": { + "id": "ee4dfb6e5540447cb3741905149d9b6e", + "password": "devstacker" + } + } + }, + "scope": { + "system": { + "all": true + } + } + } +}"#; + + const TOKEN_SCOPED_WITH_NAME: &str = r#" +{ + "auth": { + "identity": { + "methods": [ + "token" + ], + "token": { + "id": "abcdef" + } + }, + "scope": { + "domain": { + "name": "Default" + } + } + } +}"#; + + const APPLICATION_CREDENTIAL_ID: &str = r#" +{ + "auth": { + "identity": { + "methods": [ + "application_credential" + ], + "application_credential": { + "id": "abcdef", + "secret": "shhhh" + } + } + } +}"#; + + const APPLICATION_CREDENTIAL_NAME: &str = r#" +{ + "auth": { + "identity": { + "methods": [ + "application_credential" + ], + "application_credential": { + "name": "abcdef", + "secret": "shhhh", + "user": { + "id": "a6b3c6e7a6d" + } + } + } + } +}"#; + + #[test] + fn test_password_name_unscoped() { + let value = AuthRoot { + auth: Auth { + identity: Identity::Password(UserAndPassword { + user: IdOrName::Name("admin".to_string()), + password: "devstacker".to_string(), + domain: Some(IdOrName::from_name("Default")), + }), + scope: None, + }, + }; + test::compare(PASSWORD_NAME_UNSCOPED, value); + } + + #[test] + fn test_password_id_scoped_with_id() { + let value = AuthRoot { + auth: Auth { + identity: Identity::Password(UserAndPassword { + user: IdOrName::Id("ee4dfb6e5540447cb3741905149d9b6e".to_string()), + password: "devstacker".to_string(), + domain: None, + }), + scope: Some(Scope::Domain(IdOrName::from_id("default"))), + }, + }; + test::compare(PASSWORD_ID_SCOPED_WITH_ID, value); + } + + #[test] + fn test_password_id_system_scope() { + let value = AuthRoot { + auth: Auth { + identity: Identity::Password(UserAndPassword { + user: IdOrName::Id("ee4dfb6e5540447cb3741905149d9b6e".to_string()), + password: "devstacker".to_string(), + domain: None, + }), + scope: Some(Scope::System), + }, + }; + test::compare(PASSWORD_ID_SYSTEM_SCOPE, value); + } + + #[test] + fn test_token_scoped_with_name() { + let value = AuthRoot { + auth: Auth { + identity: Identity::Token("abcdef".to_string()), + scope: Some(Scope::Domain(IdOrName::Name("Default".to_string()))), + }, + }; + test::compare(TOKEN_SCOPED_WITH_NAME, value); + } + + #[test] + fn test_application_credential_id() { + let value = AuthRoot { + auth: Auth { + identity: Identity::ApplicationCredential(ApplicationCredential { + id: IdOrName::Id("abcdef".to_string()), + secret: "shhhh".to_string(), + user: None, + }), + scope: None, + }, + }; + test::compare(APPLICATION_CREDENTIAL_ID, value); + } + + #[test] + fn test_application_credential_name() { + let value = AuthRoot { + auth: Auth { + identity: Identity::ApplicationCredential(ApplicationCredential { + id: IdOrName::Name("abcdef".to_string()), + secret: "shhhh".to_string(), + user: Some(IdOrName::Id("a6b3c6e7a6d".into())), + }), + scope: None, + }, + }; + test::compare(APPLICATION_CREDENTIAL_NAME, value); + } +} diff --git a/src/identity/token.rs b/src/identity/token.rs new file mode 100644 index 0000000000..9afbc747d3 --- /dev/null +++ b/src/identity/token.rs @@ -0,0 +1,265 @@ +// Copyright 2019-2020 Dmitry Tantsur +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Token authentication. + +use async_trait::async_trait; +use osauth::{AuthType, EndpointFilters, Error}; +use reqwest::{Client, RequestBuilder, Url}; +use static_assertions::assert_impl_all; + +use super::internal::Internal; +use super::protocol; +use super::{IdOrName, Scope}; + +/// Token authentication using Identity API V3. +/// +/// For any Identity authentication you need to know `auth_url`, which is an authentication endpoint +/// of the Identity service. For the Token authentication you also need: +/// 1. Existing authentication token. +/// 2. Name of the project to use. +/// 3. Domain of the project. +/// +/// Start with creating a `Token` object using [new](#method.new), then add a project scope +/// with [with_project_scope](#method.with_project_scope): +/// +/// ```rust,no_run +/// use osauth::common::IdOrName; +/// let auth = osauth::identity::Token::new( +/// "https://cloud.local/identity", +/// "", +/// ) +/// .expect("Invalid auth_url") +/// .with_project_scope(IdOrName::from_name("project1"), IdOrName::from_id("default")); +/// +/// let session = osauth::Session::new(auth); +/// ``` +/// +/// The authentication token is cached while it's still valid or until +/// [refresh](../trait.AuthType.html#tymethod.refresh) is called. +/// Clones of a `Token` also start with an empty cache. +#[derive(Debug, Clone)] +pub struct Token { + inner: Internal, +} + +assert_impl_all!(Token: Send, Sync); + +impl Token { + /// Create a token authentication. + pub fn new(auth_url: U, token: S) -> Result + where + U: AsRef, + S: Into, + { + let body = protocol::AuthRoot { + auth: protocol::Auth { + identity: protocol::Identity::Token(token.into()), + scope: None, + }, + }; + Ok(Self { + inner: Internal::new(auth_url.as_ref(), body)?, + }) + } + + /// Scope authentication to the given project. + /// + /// A convenience wrapper around `set_scope`. + #[inline] + pub fn set_project_scope(&mut self, project: IdOrName, domain: impl Into>) { + self.set_scope(Scope::Project { + project, + domain: domain.into(), + }); + } + + /// Add a scope to the authentication. + /// + /// This is required in the most cases. + #[inline] + pub fn set_scope(&mut self, scope: Scope) { + self.inner.set_scope(scope); + } + + /// Scope authentication to the given project. + /// + /// A convenience wrapper around `with_scope`. + #[inline] + pub fn with_project_scope( + mut self, + project: IdOrName, + domain: impl Into>, + ) -> Token { + self.set_project_scope(project, domain); + self + } + + /// Add a scope to the authentication. + #[inline] + pub fn with_scope(mut self, scope: Scope) -> Self { + self.set_scope(scope); + self + } + + /// Project name or ID (if project scoped). + #[inline] + pub fn project(&self) -> Option<&IdOrName> { + self.inner.project() + } +} + +#[async_trait] +impl AuthType for Token { + /// Authenticate a request. + async fn authenticate( + &self, + client: &Client, + request: RequestBuilder, + ) -> Result { + self.inner.authenticate(client, request).await + } + + /// Get a URL for the requested service. + async fn get_endpoint( + &self, + client: &Client, + service_type: &str, + filters: &EndpointFilters, + ) -> Result { + self.inner.get_endpoint(client, service_type, filters).await + } + + /// Refresh the cached token and service catalog. + async fn refresh(&self, client: &Client) -> Result<(), Error> { + self.inner.refresh(client, true).await + } +} + +#[cfg(test)] +pub mod test { + #![allow(unused_results)] + + use reqwest::Url; + + use super::Token; + use crate::identity::IdOrName; + + #[test] + fn test_identity_new() { + let id = Token::new("http://127.0.0.1:8080/", "abcdef").unwrap(); + let e = Url::parse(id.inner.token_endpoint()).unwrap(); + assert_eq!(e.scheme(), "http"); + assert_eq!(e.host_str().unwrap(), "127.0.0.1"); + assert_eq!(e.port().unwrap(), 8080u16); + assert_eq!(e.path(), "/v3/auth/tokens"); + } + + #[test] + fn test_identity_new_invalid() { + Token::new("http://127.0.0.1 8080/", "abcdef") + .err() + .unwrap(); + } + + #[test] + fn test_identity_create() { + let id = Token::new("http://127.0.0.1:8080/identity", "abcdef") + .unwrap() + .with_project_scope( + IdOrName::Name("cool project".to_string()), + IdOrName::Name("example.com".to_string()), + ); + assert_eq!( + id.project(), + Some(&IdOrName::Name("cool project".to_string())) + ); + assert_eq!( + id.inner.token_endpoint(), + "http://127.0.0.1:8080/identity/v3/auth/tokens" + ); + } + + #[test] + fn test_token_endpoint_with_trailing_slash() { + let id = Token::new("http://127.0.0.1:8080/identity/", "abcdef") + .unwrap() + .with_project_scope( + IdOrName::Name("cool project".to_string()), + IdOrName::Name("example.com".to_string()), + ); + assert_eq!( + id.project(), + Some(&IdOrName::Name("cool project".to_string())) + ); + assert_eq!( + id.inner.token_endpoint(), + "http://127.0.0.1:8080/identity/v3/auth/tokens" + ); + } + + #[test] + fn test_token_endpoint_with_v3() { + let id = Token::new("http://127.0.0.1:8080/identity/v3", "abcdef") + .unwrap() + .with_project_scope( + IdOrName::Name("cool project".to_string()), + IdOrName::Name("example.com".to_string()), + ); + assert_eq!( + id.project(), + Some(&IdOrName::Name("cool project".to_string())) + ); + assert_eq!( + id.inner.token_endpoint(), + "http://127.0.0.1:8080/identity/v3/auth/tokens" + ); + } + + #[test] + fn test_token_endpoint_with_trailing_slash_v3() { + let id = Token::new("http://127.0.0.1:8080/identity/v3/", "abcdef") + .unwrap() + .with_project_scope( + IdOrName::Name("cool project".to_string()), + IdOrName::Name("example.com".to_string()), + ); + assert_eq!( + id.project(), + Some(&IdOrName::Name("cool project".to_string())) + ); + assert_eq!( + id.inner.token_endpoint(), + "http://127.0.0.1:8080/identity/v3/auth/tokens" + ); + } + + #[test] + fn test_token_endpoint_root() { + let id = Token::new("http://127.0.0.1:8080", "abcdef") + .unwrap() + .with_project_scope( + IdOrName::Name("cool project".to_string()), + IdOrName::Name("example.com".to_string()), + ); + assert_eq!( + id.project(), + Some(&IdOrName::Name("cool project".to_string())) + ); + assert_eq!( + id.inner.token_endpoint(), + "http://127.0.0.1:8080/v3/auth/tokens" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index b6ca4b8c13..c69cd20813 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -752,6 +752,8 @@ mod cloud; pub mod common; #[cfg(feature = "compute")] pub mod compute; +#[cfg(feature = "identity")] +pub mod identity; #[cfg(feature = "image")] pub mod image; #[cfg(feature = "network")] @@ -767,7 +769,8 @@ mod utils; pub mod waiter; pub use osauth::common::IdOrName; -pub use osauth::{EndpointFilters, Error, ErrorKind, InterfaceType, ValidInterfaces}; +pub use osauth::{EndpointFilters, InterfaceType, ValidInterfaces}; +pub use osauth::{Error, ErrorKind}; /// A result of an OpenStack operation. pub type Result = std::result::Result;