diff --git a/.gitignore b/.gitignore index 0776d79e..37a91871 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,9 @@ **/target # Ignore rust files in the root folder -*.rs +/*.rs # no OpenPolicyAgent data bundle.tar.gz -./*.rego +/*.rego policy.wasm .manifest diff --git a/src/api/error.rs b/src/api/error.rs index e7ad498a..f8caff5e 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -253,12 +253,24 @@ pub enum TokenError { source: crate::api::v3::auth::token::types::TokenBuilderError, }, + #[error("error building token data: {}", source)] + Builder4 { + #[from] + source: crate::api::v4::auth::token::types::TokenBuilderError, + }, + #[error("error building token user data: {}", source)] UserBuilder { #[from] source: crate::api::v3::auth::token::types::UserBuilderError, }, + #[error("error building token user data: {}", source)] + UserBuilder4 { + #[from] + source: crate::api::v4::auth::token::types::UserBuilderError, + }, + #[error("error building token user data: {}", source)] ProjectBuilder { #[from] diff --git a/src/api/mod.rs b/src/api/mod.rs index ac06f492..838f8b55 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -31,16 +31,17 @@ pub(crate) mod common; pub mod error; pub mod types; pub mod v3; +pub mod v4; use crate::api::types::*; #[derive(OpenApi)] #[openapi( - info(version = "3.14.0"), + info(version = "4.0.1"), modifiers(&SecurityAddon), tags( - (name="identity_providers", description=v3::federation::identity_provider::DESCRIPTION), - (name="mappings", description=v3::federation::mapping::DESCRIPTION) + (name="identity_providers", description=v4::federation::identity_provider::DESCRIPTION), + (name="mappings", description=v4::federation::mapping::DESCRIPTION) ) )] pub struct ApiDoc; @@ -61,6 +62,7 @@ impl Modify for SecurityAddon { pub fn openapi_router() -> OpenApiRouter { OpenApiRouter::new() .nest("/v3", v3::openapi_router()) + .nest("/v4", v4::openapi_router()) .routes(routes!(version)) } @@ -80,20 +82,24 @@ async fn version(headers: HeaderMap) -> Result Self { + Self { + rel: "self".into(), + href, + } + } +} + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] pub struct MediaType { pub base: String, diff --git a/src/api/v3/auth/token/mod.rs b/src/api/v3/auth/token/mod.rs index e27310a6..644bed7d 100644 --- a/src/api/v3/auth/token/mod.rs +++ b/src/api/v3/auth/token/mod.rs @@ -22,6 +22,10 @@ use axum::{ use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::types::Scope; +use crate::api::v3::auth::token::types::{ + AuthRequest, CreateTokenParameters, Token as ApiResponseToken, TokenResponse, + ValidateTokenParameters, +}; use crate::api::{ Catalog, auth::Auth, @@ -33,10 +37,6 @@ use crate::catalog::CatalogApi; use crate::identity::IdentityApi; use crate::keystone::ServiceState; use crate::token::TokenApi; -use types::{ - AuthRequest, CreateTokenParameters, Token as ApiResponseToken, TokenResponse, - ValidateTokenParameters, -}; mod common; pub mod types; diff --git a/src/api/v3/group/mod.rs b/src/api/v3/group/mod.rs index 12f12a34..44dc338a 100644 --- a/src/api/v3/group/mod.rs +++ b/src/api/v3/group/mod.rs @@ -28,7 +28,7 @@ use types::{Group, GroupCreateRequest, GroupList, GroupListParameters, GroupResp pub mod types; -pub(super) fn openapi_router() -> OpenApiRouter { +pub(crate) fn openapi_router() -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(list, create)) .routes(routes!(show, remove)) diff --git a/src/api/v3/mod.rs b/src/api/v3/mod.rs index 7a4ba25f..1272bf83 100644 --- a/src/api/v3/mod.rs +++ b/src/api/v3/mod.rs @@ -25,7 +25,6 @@ use crate::api::error::KeystoneApiError; use crate::keystone::ServiceState; pub mod auth; -pub mod federation; pub mod group; pub mod role; pub mod role_assignment; @@ -37,7 +36,6 @@ pub(super) fn openapi_router() -> OpenApiRouter { OpenApiRouter::new() .nest("/auth", auth::openapi_router()) .nest("/groups", group::openapi_router()) - .nest("/federation", federation::openapi_router()) .nest("/role_assignments", role_assignment::openapi_router()) .nest("/roles", role::openapi_router()) .nest("/users", user::openapi_router()) @@ -57,9 +55,8 @@ pub(super) fn openapi_router() -> OpenApiRouter { async fn version( headers: HeaderMap, OriginalUri(uri): OriginalUri, - req: Request, + _req: Request, ) -> Result { - println!("Request: {req:?}, uri: {uri:?}"); let host = headers .get(header::HOST) .and_then(|header| header.to_str().ok()) diff --git a/src/api/v3/role/mod.rs b/src/api/v3/role/mod.rs index babd36cd..88f984a3 100644 --- a/src/api/v3/role/mod.rs +++ b/src/api/v3/role/mod.rs @@ -26,7 +26,7 @@ use types::{Role, RoleList, RoleListParameters, RoleResponse}; pub mod types; -pub(super) fn openapi_router() -> OpenApiRouter { +pub(crate) fn openapi_router() -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(list)) .routes(routes!(show)) diff --git a/src/api/v3/role_assignment/mod.rs b/src/api/v3/role_assignment/mod.rs index b2992177..882492b1 100644 --- a/src/api/v3/role_assignment/mod.rs +++ b/src/api/v3/role_assignment/mod.rs @@ -24,9 +24,9 @@ use crate::assignment::AssignmentApi; use crate::keystone::ServiceState; use types::{Assignment, AssignmentList, RoleAssignmentListParameters}; -mod types; +pub mod types; -pub(super) fn openapi_router() -> OpenApiRouter { +pub(crate) fn openapi_router() -> OpenApiRouter { OpenApiRouter::new().routes(routes!(list)) } diff --git a/src/api/v3/user/mod.rs b/src/api/v3/user/mod.rs index bc5540ab..40b1bf9b 100644 --- a/src/api/v3/user/mod.rs +++ b/src/api/v3/user/mod.rs @@ -27,7 +27,6 @@ use crate::identity::IdentityApi; use crate::keystone::ServiceState; use types::{User, UserCreateRequest, UserList, UserListParameters, UserResponse}; -pub mod passkey; pub mod types; pub(super) fn openapi_router() -> OpenApiRouter { @@ -35,7 +34,6 @@ pub(super) fn openapi_router() -> OpenApiRouter { .routes(routes!(list, create)) .routes(routes!(show, remove)) .routes(routes!(groups)) - .nest("/{user_id}/passkeys", passkey::openapi_router()) } /// List users diff --git a/src/api/v4/auth/mod.rs b/src/api/v4/auth/mod.rs new file mode 100644 index 00000000..a802a737 --- /dev/null +++ b/src/api/v4/auth/mod.rs @@ -0,0 +1,23 @@ +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +use utoipa_axum::router::OpenApiRouter; + +use crate::keystone::ServiceState; + +pub mod token; + +pub(super) fn openapi_router() -> OpenApiRouter { + OpenApiRouter::new().nest("/tokens", token::openapi_router()) +} diff --git a/src/api/v4/auth/token/common.rs b/src/api/v4/auth/token/common.rs new file mode 100644 index 00000000..f31d07c6 --- /dev/null +++ b/src/api/v4/auth/token/common.rs @@ -0,0 +1,402 @@ +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +use crate::api::common; +use crate::api::error::{KeystoneApiError, TokenError}; +use crate::api::types::ProjectBuilder; +use crate::api::v3::role::types::Role; +use crate::api::v4::auth::token::types::{Token, TokenBuilder, UserBuilder}; +use crate::identity::IdentityApi; +use crate::keystone::ServiceState; +use crate::resource::{ + ResourceApi, + types::{Domain, Project}, +}; +use crate::token::Token as ProviderToken; + +impl Token { + pub async fn from_provider_token( + state: &ServiceState, + token: &ProviderToken, + ) -> Result { + let mut response = TokenBuilder::default(); + let mut project: Option = token.project().cloned(); + let mut domain: Option = token.domain().cloned(); + response.audit_ids(token.audit_ids().clone()); + response.methods(token.methods().clone()); + response.expires_at(*token.expires_at()); + + let user = if let Some(user) = token.user() { + user + } else { + &state + .provider + .get_identity_provider() + .get_user(&state.db, token.user_id()) + .await + .map_err(KeystoneApiError::identity)? + .ok_or_else(|| KeystoneApiError::NotFound { + resource: "user".into(), + identifier: token.user_id().clone(), + })? + }; + + let user_domain = common::get_domain(state, Some(&user.domain_id), None::<&str>).await?; + + let mut user_response: UserBuilder = UserBuilder::default(); + user_response.id(user.id.clone()); + user_response.name(user.name.clone()); + user_response.password_expires_at(user.password_expires_at); + user_response.domain(user_domain.clone()); + response.user(user_response.build().map_err(TokenError::from)?); + + if let Some(roles) = token.roles() { + response.roles( + roles + .clone() + .into_iter() + .map(Into::into) + .collect::>(), + ); + } + + match token { + ProviderToken::Unscoped(_token) => {} + ProviderToken::DomainScope(token) => { + if domain.is_none() { + domain = Some( + common::get_domain(state, Some(&token.domain_id), None::<&str>).await?, + ); + } + } + ProviderToken::ProjectScope(token) => { + if project.is_none() { + project = Some( + state + .provider + .get_resource_provider() + .get_project(&state.db, &token.project_id) + .await + .map_err(KeystoneApiError::resource)? + .ok_or_else(|| KeystoneApiError::NotFound { + resource: "project".into(), + identifier: token.project_id.clone(), + })?, + ); + } + } + ProviderToken::ApplicationCredential(token) => { + if project.is_none() { + project = Some( + state + .provider + .get_resource_provider() + .get_project(&state.db, &token.project_id) + .await + .map_err(KeystoneApiError::resource)? + .ok_or_else(|| KeystoneApiError::NotFound { + resource: "project".into(), + identifier: token.project_id.clone(), + })?, + ); + } + } + ProviderToken::FederationUnscoped(_token) => {} + ProviderToken::FederationDomainScope(token) => { + if domain.is_none() { + domain = Some( + common::get_domain(state, Some(&token.domain_id), None::<&str>).await?, + ); + } + } + ProviderToken::FederationProjectScope(token) => { + if project.is_none() { + project = Some( + state + .provider + .get_resource_provider() + .get_project(&state.db, &token.project_id) + .await + .map_err(KeystoneApiError::resource)? + .ok_or_else(|| KeystoneApiError::NotFound { + resource: "project".into(), + identifier: token.project_id.clone(), + })?, + ); + } + } + } + + if let Some(domain) = domain { + response.domain(domain.clone()); + } + if let Some(project) = project { + response.project( + get_project_info_builder(state, &project, &user_domain) + .await? + .build() + .map_err(TokenError::from)?, + ); + } + Ok(response.build().map_err(TokenError::from)?) + } +} + +async fn get_project_info_builder( + state: &ServiceState, + project: &Project, + user_domain: &Domain, +) -> Result { + let mut project_response = ProjectBuilder::default(); + project_response.id(project.id.clone()); + project_response.name(project.name.clone()); + if project.domain_id == user_domain.id { + project_response.domain(user_domain.clone().into()); + } else { + let project_domain = + common::get_domain(state, Some(&project.domain_id), None::<&str>).await?; + project_response.domain(project_domain.clone().into()); + } + Ok(project_response) +} + +#[cfg(test)] +mod tests { + use sea_orm::DatabaseConnection; + use std::sync::Arc; + + use crate::api::v3::auth::token::types::Token; + use crate::api::v3::role::types::Role; + use crate::assignment::{ + MockAssignmentProvider, + types::{Assignment, AssignmentType, Role as ProviderRole, RoleAssignmentListParameters}, + }; + + use crate::config::Config; + use crate::identity::{MockIdentityProvider, types::UserResponse}; + use crate::keystone::Service; + use crate::policy::MockPolicyFactory; + use crate::provider::Provider; + use crate::resource::{ + MockResourceProvider, + types::{Domain, Project}, + }; + use crate::token::{ + DomainScopePayload, ProjectScopePayload, Token as ProviderToken, UnscopedPayload, + }; + + #[tokio::test] + async fn test_from_unscoped() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_get_user() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "bar") + .returning(|_, _| { + Ok(Some(UserResponse { + id: "bar".into(), + domain_id: "user_domain_id".into(), + ..Default::default() + })) + }); + + let mut resource_mock = MockResourceProvider::default(); + resource_mock + .expect_get_domain() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "user_domain_id") + .returning(|_, _| { + Ok(Some(Domain { + id: "user_domain_id".into(), + ..Default::default() + })) + }); + let provider = Provider::mocked_builder() + .identity(identity_mock) + .resource(resource_mock) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + MockPolicyFactory::new(), + ) + .unwrap(), + ); + + let api_token = Token::from_provider_token( + &state, + &ProviderToken::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + }), + ) + .await + .unwrap(); + assert_eq!("bar", api_token.user.id); + assert_eq!(Some("user_domain_id"), api_token.user.domain.id.as_deref()); + assert!(api_token.project.is_none()); + assert!(api_token.domain.is_none()); + } + + #[tokio::test] + async fn test_from_domain_scoped() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_get_user() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "bar") + .returning(|_, _| { + Ok(Some(UserResponse { + id: "bar".into(), + domain_id: "user_domain_id".into(), + ..Default::default() + })) + }); + + let mut resource_mock = MockResourceProvider::default(); + resource_mock + .expect_get_domain() + .returning(|_, id: &'_ str| { + Ok(Some(Domain { + id: id.to_string(), + ..Default::default() + })) + }); + let provider = Provider::mocked_builder() + .identity(identity_mock) + .resource(resource_mock) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + MockPolicyFactory::new(), + ) + .unwrap(), + ); + + let api_token = Token::from_provider_token( + &state, + &ProviderToken::DomainScope(DomainScopePayload { + user_id: "bar".into(), + domain_id: "domain_id".into(), + ..Default::default() + }), + ) + .await + .unwrap(); + + assert_eq!("bar", api_token.user.id); + assert_eq!(Some("user_domain_id"), api_token.user.domain.id.as_deref()); + assert_eq!( + Some("domain_id"), + api_token.domain.expect("domain scope").id.as_deref() + ); + assert!(api_token.project.is_none()); + } + + #[tokio::test] + async fn test_from_project_scoped() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_get_user() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "bar") + .returning(|_, _| { + Ok(Some(UserResponse { + id: "bar".into(), + domain_id: "user_domain_id".into(), + ..Default::default() + })) + }); + + let mut resource_mock = MockResourceProvider::default(); + resource_mock + .expect_get_domain() + .returning(|_, id: &'_ str| { + Ok(Some(Domain { + id: id.to_string(), + ..Default::default() + })) + }); + resource_mock + .expect_get_project() + .returning(|_, id: &'_ str| { + Ok(Some(Project { + id: id.to_string(), + domain_id: "project_domain_id".into(), + ..Default::default() + })) + }); + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock.expect_list_role_assignments().returning( + |_, _, q: &RoleAssignmentListParameters| { + Ok(vec![Assignment { + role_id: "rid".into(), + role_name: Some("role_name".into()), + actor_id: q.user_id.clone().unwrap(), + target_id: q.project_id.clone().unwrap(), + r#type: AssignmentType::UserProject, + inherited: false, + }]) + }, + ); + let provider = Provider::mocked_builder() + .assignment(assignment_mock) + .identity(identity_mock) + .resource(resource_mock) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + MockPolicyFactory::new(), + ) + .unwrap(), + ); + let token = ProviderToken::ProjectScope(ProjectScopePayload { + user_id: "bar".into(), + project_id: "project_id".into(), + roles: Some(vec![ProviderRole { + id: "rid".into(), + name: "role_name".into(), + ..Default::default() + }]), + ..Default::default() + }); + + let api_token = Token::from_provider_token(&state, &token).await.unwrap(); + + assert_eq!("bar", api_token.user.id); + assert_eq!(Some("user_domain_id"), api_token.user.domain.id.as_deref()); + let project = api_token.project.expect("project_scope"); + assert_eq!(Some("project_domain_id"), project.domain.id.as_deref()); + assert_eq!("project_id", project.id); + assert!(api_token.domain.is_none()); + assert_eq!( + api_token.roles, + Some(vec![Role { + id: "rid".into(), + name: "role_name".into(), + ..Default::default() + }]) + ); + } +} diff --git a/src/api/v4/auth/token/mod.rs b/src/api/v4/auth/token/mod.rs new file mode 100644 index 00000000..2c3effa3 --- /dev/null +++ b/src/api/v4/auth/token/mod.rs @@ -0,0 +1,1028 @@ +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +use axum::{ + Json, + extract::{Query, State}, + http::HeaderMap, + http::StatusCode, + response::IntoResponse, +}; +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::api::types::Scope; +use crate::api::v4::auth::token::types::{ + AuthRequest, CreateTokenParameters, Token as ApiResponseToken, TokenResponse, + ValidateTokenParameters, +}; +use crate::api::{ + Catalog, + auth::Auth, + common::{find_project_from_scope, get_domain}, + error::KeystoneApiError, +}; +use crate::auth::{AuthenticatedInfo, AuthzInfo}; +use crate::catalog::CatalogApi; +use crate::identity::IdentityApi; +use crate::keystone::ServiceState; +use crate::token::TokenApi; + +mod common; +pub mod types; + +pub(super) fn openapi_router() -> OpenApiRouter { + OpenApiRouter::new().routes(routes!(show, post)) +} + +/// Authenticate the user ignoring any scope information. It is important not to expose any +/// hints that user, project, domain, etc might exist before we have authenticated them by +/// taking different amount of time in case of certain validations. +async fn authenticate_request( + state: &ServiceState, + req: &AuthRequest, +) -> Result { + let mut authenticated_info: Option = None; + for method in req.auth.identity.methods.iter() { + if method == "password" { + if let Some(password_auth) = &req.auth.identity.password { + let req = password_auth.user.clone().try_into()?; + authenticated_info = Some( + state + .provider + .get_identity_provider() + .authenticate_by_password(&state.db, &state.provider, req) + .await?, + ); + } + } else if method == "token" { + if let Some(token) = &req.auth.identity.token { + let mut authz = state + .provider + .get_token_provider() + .authenticate_by_token(&token.id, Some(false), None) + .await?; + // Resolve the user + authz.user = Some( + state + .provider + .get_identity_provider() + .get_user(&state.db, &authz.user_id) + .await + .map(|x| { + x.ok_or_else(|| KeystoneApiError::NotFound { + resource: "user".into(), + identifier: authz.user_id.clone(), + }) + })??, + ); + authenticated_info = Some(authz); + + {} + } + } + } + authenticated_info + .ok_or(KeystoneApiError::Unauthorized) + .and_then(|authn| { + authn.validate()?; + Ok(authn) + }) +} + +/// Build the AuthZ information from the request +/// +/// # Arguments +/// +/// * `state` - The service state +/// * `req` - The Request +/// +/// # Result +/// +/// * `Ok(AuthzInfo)` - The AuthZ information +/// * `Err(KeystoneApiError)` - The error +async fn get_authz_info( + state: &ServiceState, + req: &AuthRequest, +) -> Result { + let authz_info = match &req.auth.scope { + Some(Scope::Project(scope)) => { + if let Some(project) = find_project_from_scope(state, scope).await? { + AuthzInfo::Project(project) + } else { + return Err(KeystoneApiError::Unauthorized); + } + } + Some(Scope::Domain(scope)) => { + if let Ok(domain) = get_domain(state, scope.id.as_ref(), scope.name.as_ref()).await { + AuthzInfo::Domain(domain) + } else { + return Err(KeystoneApiError::Unauthorized); + } + } + Some(Scope::System(_scope)) => { + todo!() + } + None => AuthzInfo::Unscoped, + }; + authz_info.validate()?; + Ok(authz_info) +} + +/// Authenticate user issuing a new token +#[utoipa::path( + post, + path = "/", + description = "Issue token", + params(CreateTokenParameters), + responses( + (status = OK, description = "Token object", body = TokenResponse), + ), + tag="auth" +)] +#[tracing::instrument(name = "api::token_post", level = "debug", skip(state, req))] +async fn post( + Query(query): Query, + State(state): State, + Json(req): Json, +) -> Result { + let authed_info = authenticate_request(&state, &req).await?; + let authz_info = get_authz_info(&state, &req).await?; + + let mut token = state + .provider + .get_token_provider() + .issue_token(authed_info, authz_info)?; + + token = state + .provider + .get_token_provider() + .expand_token_information(&token, &state.db, &state.provider) + .await?; + + let mut api_token = TokenResponse { + token: ApiResponseToken::from_provider_token(&state, &token).await?, + }; + if !query.nocatalog.is_some_and(|x| x) { + let catalog: Catalog = state + .provider + .get_catalog_provider() + .get_catalog(&state.db, true) + .await? + .into(); + api_token.token.catalog = Some(catalog); + } + return Ok(( + StatusCode::OK, + [( + "X-Subject-Token", + state.provider.get_token_provider().encode_token(&token)?, + )], + Json(api_token), + ) + .into_response()); +} + +/// Validate token +#[utoipa::path( + get, + path = "/", + description = "Validate token", + params(ValidateTokenParameters), + responses( + (status = OK, description = "Token object", body = TokenResponse), + ), + tag="auth" +)] +#[tracing::instrument( + name = "api::token_get", + level = "debug", + skip(state, headers, _user_auth) +)] +async fn show( + Auth(_user_auth): Auth, + Query(query): Query, + headers: HeaderMap, + State(state): State, +) -> Result { + let subject_token: String = headers + .get("X-Subject-Token") + .ok_or(KeystoneApiError::SubjectTokenMissing)? + .to_str() + .map_err(|_| KeystoneApiError::InvalidHeader)? + .to_string(); + + let mut token = state + .provider + .get_token_provider() + .validate_token(&subject_token, query.allow_expired, None) + .await + .map_err(|_| KeystoneApiError::NotFound { + resource: "token".into(), + identifier: String::new(), + })?; + + token = state + .provider + .get_token_provider() + .expand_token_information(&token, &state.db, &state.provider) + .await + .map_err(|_| KeystoneApiError::Forbidden)?; + + let mut response_token = ApiResponseToken::from_provider_token(&state, &token).await?; + + if !query.nocatalog.is_some_and(|x| x) { + let catalog: Catalog = state + .provider + .get_catalog_provider() + .get_catalog(&state.db, true) + .await? + .into(); + response_token.catalog = Some(catalog); + } + + Ok(TokenResponse { + token: response_token, + }) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode, header}, + }; + use http_body_util::BodyExt; // for `collect` + use sea_orm::DatabaseConnection; + use serde_json::json; + use std::sync::Arc; + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + use tower_http::trace::TraceLayer; + use tracing_test::traced_test; + + use super::openapi_router; + use crate::api::v4::auth::token::types::*; + use crate::assignment::MockAssignmentProvider; + use crate::auth::AuthenticatedInfo; + use crate::catalog::MockCatalogProvider; + use crate::config::Config; + use crate::identity::{ + MockIdentityProvider, + types::{UserPasswordAuthRequest, UserResponse}, + }; + use crate::keystone::Service; + use crate::policy::MockPolicyFactory; + use crate::provider::Provider; + use crate::resource::{ + MockResourceProvider, + types::{Domain, Project}, + }; + use crate::tests::api::get_mocked_state_unauthed; + use crate::token::{ + MockTokenProvider, ProjectScopePayload, Token as ProviderToken, TokenProviderError, + UnscopedPayload, + }; + + use super::*; + + #[tokio::test] + async fn test_authenticate_request_password() { + let config = Config::default(); + let auth_info = AuthenticatedInfo::builder() + .user_id("uid") + .user(UserResponse { + id: "uid".to_string(), + domain_id: "udid".into(), + enabled: true, + ..Default::default() + }) + .build() + .unwrap(); + let auth_clone = auth_info.clone(); + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_authenticate_by_password() + .withf(|_, _, req: &UserPasswordAuthRequest| { + req.id == Some("uid".to_string()) + && req.password == "pwd" + && req.name == Some("uname".to_string()) + }) + .returning(move |_, _, _| Ok(auth_clone.clone())); + + let provider = Provider::mocked_builder() + .config(config.clone()) + .identity(identity_mock) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + config, + DatabaseConnection::Disconnected, + provider, + MockPolicyFactory::new(), + ) + .unwrap(), + ); + + assert_eq!( + auth_info, + authenticate_request( + &state, + &AuthRequest { + auth: AuthRequestInner { + identity: Identity { + methods: vec!["password".to_string()], + password: Some(PasswordAuth { + user: UserPassword { + id: Some("uid".to_string()), + password: "pwd".to_string(), + name: Some("uname".to_string()), + ..Default::default() + }, + }), + token: None, + }, + scope: None, + }, + } + ) + .await + .unwrap() + ); + } + + #[tokio::test] + async fn test_authenticate_request_token() { + let config = Config::default(); + + let mut token_mock = MockTokenProvider::default(); + token_mock + .expect_authenticate_by_token() + .withf( + |id: &'_ str, allow_expired: &Option, window: &Option| { + id == "fake_token" && *allow_expired == Some(false) && window.is_none() + }, + ) + .returning(|_, _, _| Ok(AuthenticatedInfo::builder().user_id("uid").build().unwrap())); + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_get_user() + .withf(|_, id: &'_ str| id == "uid") + .returning(|_, id: &'_ str| { + Ok(Some(UserResponse { + id: id.to_string(), + domain_id: "user_domain_id".into(), + enabled: true, + ..Default::default() + })) + }); + + let provider = Provider::mocked_builder() + .config(config.clone()) + .identity(identity_mock) + .token(token_mock) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + config, + DatabaseConnection::Disconnected, + provider, + MockPolicyFactory::new(), + ) + .unwrap(), + ); + + assert_eq!( + AuthenticatedInfo::builder() + .user_id("uid") + .user(UserResponse { + id: "uid".to_string(), + domain_id: "user_domain_id".into(), + enabled: true, + ..Default::default() + }) + .build() + .unwrap(), + authenticate_request( + &state, + &AuthRequest { + auth: AuthRequestInner { + identity: Identity { + methods: vec!["token".to_string()], + password: None, + token: Some(TokenAuth { + id: "fake_token".to_string() + }), + }, + scope: None, + }, + } + ) + .await + .unwrap() + ); + } + + #[tokio::test] + async fn test_authenticate_request_unsupported() { + let config = Config::default(); + + let provider = Provider::mocked_builder() + .config(config.clone()) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + config, + DatabaseConnection::Disconnected, + provider, + MockPolicyFactory::new(), + ) + .unwrap(), + ); + + let rsp = authenticate_request( + &state, + &AuthRequest { + auth: AuthRequestInner { + identity: Identity { + methods: vec!["fake".to_string()], + password: None, + token: None, + }, + scope: None, + }, + }, + ) + .await; + if let KeystoneApiError::Unauthorized = rsp.unwrap_err() { + } else { + panic!("Should receive Unauthorized"); + } + } + + #[tokio::test] + async fn test_get() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, id: &'_ str| { + Ok(Some(UserResponse { + id: id.to_string(), + domain_id: "user_domain_id".into(), + ..Default::default() + })) + }); + + let mut resource_mock = MockResourceProvider::default(); + resource_mock + .expect_get_domain() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "user_domain_id") + .returning(|_, _| { + Ok(Some(Domain { + id: "user_domain_id".into(), + ..Default::default() + })) + }); + let mut token_mock = MockTokenProvider::default(); + token_mock.expect_validate_token().returning(|_, _, _| { + Ok(ProviderToken::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); + token_mock + .expect_populate_role_assignments() + .returning(|_, _, _| Ok(())); + token_mock + .expect_expand_token_information() + .returning(|_, _, _| { + Ok(ProviderToken::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); + let mut catalog_mock = MockCatalogProvider::default(); + catalog_mock + .expect_get_catalog() + .returning(|_, _| Ok(Vec::new())); + + let provider = Provider::mocked_builder() + .identity(identity_mock) + .resource(resource_mock) + .token(token_mock) + .catalog(catalog_mock) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + MockPolicyFactory::new(), + ) + .unwrap(), + ); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .header("x-auth-token", "foo") + .header("x-subject-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let _res: TokenResponse = serde_json::from_slice(&body).unwrap(); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn test_get_allow_expired() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, id: &'_ str| { + Ok(Some(UserResponse { + id: id.to_string(), + domain_id: "user_domain_id".into(), + ..Default::default() + })) + }); + + let mut resource_mock = MockResourceProvider::default(); + resource_mock + .expect_get_domain() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "user_domain_id") + .returning(|_, _| { + Ok(Some(Domain { + id: "user_domain_id".into(), + ..Default::default() + })) + }); + let mut token_mock = MockTokenProvider::default(); + token_mock + .expect_validate_token() + .withf(|token: &'_ str, _, _| token == "foo") + .returning(|_, _, _| { + Ok(ProviderToken::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); + token_mock + .expect_validate_token() + .withf(|token: &'_ str, allow_expired: &Option, _| { + token == "bar" && *allow_expired == Some(true) + }) + .returning(|_, _, _| { + Ok(ProviderToken::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); + token_mock + .expect_populate_role_assignments() + .returning(|_, _, _| Ok(())); + token_mock + .expect_expand_token_information() + .returning(|_, _, _| { + Ok(ProviderToken::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); + let mut catalog_mock = MockCatalogProvider::default(); + catalog_mock + .expect_get_catalog() + .returning(|_, _| Ok(Vec::new())); + + let provider = Provider::mocked_builder() + .identity(identity_mock) + .resource(resource_mock) + .token(token_mock) + .catalog(catalog_mock) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + MockPolicyFactory::new(), + ) + .unwrap(), + ); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/?allow_expired=true") + .header("x-auth-token", "foo") + .header("x-subject-token", "bar") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_get_expired() { + let mut token_mock = MockTokenProvider::default(); + token_mock + .expect_validate_token() + .withf(|token: &'_ str, _, _| token == "foo") + .returning(|_, _, _| { + Ok(ProviderToken::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); + token_mock + .expect_expand_token_information() + .withf(|token: &ProviderToken, &_, &_| token.user_id() == "bar") + .returning(|_, _, _| { + Ok(ProviderToken::Unscoped(UnscopedPayload { + user_id: "foo".into(), + ..Default::default() + })) + }); + token_mock + .expect_validate_token() + .withf(|token: &'_ str, _, _| token == "baz") + .returning(|_, _, _| Err(TokenProviderError::Expired)); + + let provider = Provider::mocked_builder() + .token(token_mock) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + MockPolicyFactory::new(), + ) + .unwrap(), + ); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .header("x-auth-token", "foo") + .header("x-subject-token", "baz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_get_unauth() { + let state = get_mocked_state_unauthed(); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + #[traced_test] + async fn test_post() { + let config = Config::default(); + let project = Project { + id: "pid".into(), + domain_id: "pdid".into(), + enabled: true, + ..Default::default() + }; + let user_domain = Domain { + id: "user_domain_id".into(), + enabled: true, + ..Default::default() + }; + let project_domain = Domain { + id: "pdid".into(), + enabled: true, + ..Default::default() + }; + let mut assignment_mock = MockAssignmentProvider::default(); + let mut catalog_mock = MockCatalogProvider::default(); + assignment_mock + .expect_list_role_assignments() + .returning(|_, _, _| Ok(Vec::new())); + + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_authenticate_by_password() + .withf(|_, _, req: &UserPasswordAuthRequest| { + req.id == Some("uid".to_string()) + && req.password == "pass" + && req.name == Some("uname".to_string()) + }) + .returning(|_, _, _| { + Ok(AuthenticatedInfo::builder() + .user_id("uid") + .user(UserResponse { + id: "uid".to_string(), + domain_id: "udid".into(), + enabled: true, + ..Default::default() + }) + .build() + .unwrap()) + }); + + let mut resource_mock = MockResourceProvider::default(); + resource_mock + .expect_get_project() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "pid") + .returning(move |_, _| Ok(Some(project.clone()))); + resource_mock + .expect_get_domain() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "user_domain_id") + .returning(move |_, _| Ok(Some(user_domain.clone()))); + resource_mock + .expect_get_domain() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "pdid") + .returning(move |_, _| Ok(Some(project_domain.clone()))); + let mut token_mock = MockTokenProvider::default(); + token_mock.expect_issue_token().returning(|_, _| { + Ok(ProviderToken::ProjectScope(ProjectScopePayload { + user_id: "bar".into(), + methods: Vec::from(["password".to_string()]), + user: Some(UserResponse { + id: "uid".to_string(), + domain_id: "user_domain_id".into(), + ..Default::default() + }), + project_id: "pid".into(), + ..Default::default() + })) + }); + token_mock + .expect_populate_role_assignments() + .returning(|_, _, _| Ok(())); + token_mock + .expect_expand_token_information() + .returning(|_, _, _| { + Ok(ProviderToken::ProjectScope(ProjectScopePayload { + user_id: "bar".into(), + methods: Vec::from(["password".to_string()]), + user: Some(UserResponse { + id: "uid".to_string(), + domain_id: "user_domain_id".into(), + ..Default::default() + }), + project_id: "pid".into(), + project: Some(Project { + id: "pid".into(), + domain_id: "pdid".into(), + enabled: true, + ..Default::default() + }), + ..Default::default() + })) + }); + token_mock + .expect_encode_token() + .returning(|_| Ok("token".to_string())); + catalog_mock + .expect_get_catalog() + .returning(|_, _| Ok(Vec::new())); + + let provider = Provider::mocked_builder() + .config(config.clone()) + .assignment(assignment_mock) + .catalog(catalog_mock) + .identity(identity_mock) + .resource(resource_mock) + .token(token_mock) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + config, + DatabaseConnection::Disconnected, + provider, + MockPolicyFactory::new(), + ) + .unwrap(), + ); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .method("POST") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "auth": { + "identity": { + "methods": ["password"], + "password": { + "user": { + "id": "uid", + "name": "uname", + "domain": { + "id": "udid", + "name": "udname" + }, + "password": "pass", + }, + }, + }, + "scope": { + "project": { + "id": "pid", + "name": "pname", + "domain": { + "id": "pdid", + "name": "pdname" + } + } + } + } + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: TokenResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(vec!["password"], res.token.methods); + } + + #[tokio::test] + #[traced_test] + async fn test_post_project_disabled() { + let config = Config::default(); + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_authenticate_by_password() + .returning(|_, _, _| { + Ok(AuthenticatedInfo::builder() + .user_id("uid") + .user(UserResponse { + id: "uid".to_string(), + domain_id: "udid".into(), + enabled: true, + ..Default::default() + }) + .build() + .unwrap()) + }); + + let mut resource_mock = MockResourceProvider::default(); + resource_mock + .expect_get_project() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "pid") + .returning(move |_, _| { + Ok(Some(Project { + id: "pid".into(), + domain_id: "pdid".into(), + enabled: false, + ..Default::default() + })) + }); + + let provider = Provider::mocked_builder() + .config(config.clone()) + .identity(identity_mock) + .resource(resource_mock) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + config, + DatabaseConnection::Disconnected, + provider, + MockPolicyFactory::new(), + ) + .unwrap(), + ); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .method("POST") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "auth": { + "identity": { + "methods": ["password"], + "password": { + "user": { + "id": "uid", + "name": "uname", + "domain": { + "id": "udid", + "name": "udname" + }, + "password": "pass", + }, + }, + }, + "scope": { + "project": { + "id": "pid", + "name": "pname", + "domain": { + "id": "pdid", + "name": "pdname" + } + } + } + } + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/src/api/v4/auth/token/types.rs b/src/api/v4/auth/token/types.rs new file mode 100644 index 00000000..5023285d --- /dev/null +++ b/src/api/v4/auth/token/types.rs @@ -0,0 +1,232 @@ +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use chrono::{DateTime, Utc}; +use derive_builder::Builder; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use crate::api::error::TokenError; +use crate::api::types::*; +use crate::api::v3::role::types::Role; +use crate::identity::types as identity_types; +use crate::token::Token as BackendToken; + +/// Authorization token +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +#[builder(setter(strip_option, into))] +pub struct Token { + /// A list of one or two audit IDs. An audit ID is a unique, randomly generated, URL-safe + /// string that you can use to track a token. The first audit ID is the current audit ID for + /// the token. The second audit ID is present for only re-scoped tokens and is the audit ID + /// from the token before it was re-scoped. A re- scoped token is one that was exchanged for + /// another token of the same or different scope. You can use these audit IDs to track the use + /// of a token or chain of tokens across multiple requests and endpoints without exposing the + /// token ID to non-privileged users. + pub audit_ids: Vec, + + /// The authentication methods, which are commonly password, token, or other methods. Indicates + /// the accumulated set of authentication methods that were used to obtain the token. For + /// example, if the token was obtained by password authentication, it contains password. Later, + /// if the token is exchanged by using the token authentication method one or more times, the + /// subsequently created tokens contain both password and token in their methods attribute. + /// Unlike multi-factor authentication, the methods attribute merely indicates the methods that + /// were used to authenticate the user in exchange for a token. The client is responsible for + /// determining the total number of authentication factors. + pub methods: Vec, + + /// The date and time when the token expires. + pub expires_at: DateTime, + + /// A user object. + //#[builder(default)] + pub user: User, + + /// A project object including the id, name and domain object representing the project the + /// token is scoped to. This is only included in tokens that are scoped to a project. + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub project: Option, + + /// A domain object including the id and name representing the domain the token is scoped to. + /// This is only included in tokens that are scoped to a domain. + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub domain: Option, + + /// A list of role objects + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub roles: Option>, + + /// A catalog object. + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub catalog: Option, +} + +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +#[builder(setter(strip_option, into))] +pub struct TokenResponse { + /// Token + pub token: Token, +} + +impl IntoResponse for TokenResponse { + fn into_response(self) -> Response { + (StatusCode::OK, Json(self)).into_response() + } +} + +/// An authentication request. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct AuthRequest { + /// An identity object. + pub auth: AuthRequestInner, +} + +/// An authentication request. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct AuthRequestInner { + /// An identity object. + pub identity: Identity, + + /// The authorization scope, including the system (Since v3.10), a project, or a domain (Since + /// v3.4). If multiple scopes are specified in the same request (e.g. project and domain or + /// domain and system) an HTTP 400 Bad Request will be returned, as a token cannot be + /// simultaneously scoped to multiple authorization targets. An ID is sufficient to uniquely + /// identify a project but if a project is specified by name, then the domain of the project + /// must also be specified in order to uniquely identify the project by name. A domain scope + /// may be specified by either the domain’s ID or name with equivalent results. + pub scope: Option, +} + +/// An identity object. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct Identity { + /// The authentication method. For password authentication, specify password. + pub methods: Vec, + + /// The password object, contains the authentication information. + pub password: Option, + + /// The token object, contains the authentication information. + pub token: Option, +} + +/// The password object, contains the authentication information. +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +#[builder(setter(strip_option, into))] +pub struct PasswordAuth { + /// A user object. + #[builder(default)] + pub user: UserPassword, +} + +/// User password information +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct UserPassword { + /// User ID + pub id: Option, + /// User Name + pub name: Option, + /// User domain + pub domain: Option, + /// User password expiry date + pub password: String, +} + +impl TryFrom for identity_types::UserPasswordAuthRequest { + type Error = TokenError; + + fn try_from(value: UserPassword) -> Result { + let mut upa = identity_types::UserPasswordAuthRequestBuilder::default(); + if let Some(id) = &value.id { + upa.id(id); + } + if let Some(name) = &value.name { + upa.name(name); + } + if let Some(domain) = &value.domain { + let mut domain_builder = identity_types::DomainBuilder::default(); + if let Some(id) = &domain.id { + domain_builder.id(id); + } + if let Some(name) = &domain.name { + domain_builder.name(name); + } + upa.domain(domain_builder.build()?); + } + upa.password(value.password.clone()); + Ok(upa.build()?) + } +} + +/// User information +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +#[builder(setter(into))] +pub struct User { + /// User ID + pub id: String, + /// User Name + #[builder(default)] + pub name: Option, + /// User domain + pub domain: Domain, + /// User password expiry date + #[builder(default)] + pub password_expires_at: Option>, +} + +impl TryFrom<&BackendToken> for Token { + type Error = TokenError; + + fn try_from(value: &BackendToken) -> Result { + let mut token = TokenBuilder::default(); + token.user(UserBuilder::default().id(value.user_id()).build()?); + token.methods(value.methods().clone()); + token.audit_ids(value.audit_ids().clone()); + token.expires_at(*value.expires_at()); + Ok(token.build()?) + } +} + +/// The token object, contains the authentication information. +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +#[builder(setter(strip_option, into))] +pub struct TokenAuth { + /// An authentication token. + pub id: String, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, IntoParams)] +pub struct CreateTokenParameters { + /// The authentication response excludes the service catalog. By default, the response includes + /// the service catalog. + pub nocatalog: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, IntoParams)] +pub struct ValidateTokenParameters { + /// The authentication response excludes the service catalog. By default, the response includes + /// the service catalog. + pub nocatalog: Option, + /// Allow fetching a token that has expired. By default expired tokens return a 404 exception. + pub allow_expired: Option, +} diff --git a/src/api/v3/federation/auth.rs b/src/api/v4/federation/auth.rs similarity index 98% rename from src/api/v3/federation/auth.rs rename to src/api/v4/federation/auth.rs index 444557dc..c61f31b1 100644 --- a/src/api/v3/federation/auth.rs +++ b/src/api/v4/federation/auth.rs @@ -29,8 +29,8 @@ use openidconnect::{ }; use crate::api::error::KeystoneApiError; -use crate::api::v3::federation::error::OidcError; -use crate::api::v3::federation::types::*; +use crate::api::v4::federation::error::OidcError; +use crate::api::v4::federation::types::*; use crate::federation::FederationApi; use crate::federation::types::{AuthState, MappingListParameters as ProviderMappingListParameters}; use crate::keystone::ServiceState; diff --git a/src/api/v3/federation/error.rs b/src/api/v4/federation/error.rs similarity index 99% rename from src/api/v3/federation/error.rs rename to src/api/v4/federation/error.rs index bbf368da..79f3f604 100644 --- a/src/api/v3/federation/error.rs +++ b/src/api/v4/federation/error.rs @@ -16,7 +16,7 @@ use thiserror::Error; use tracing::{Level, error, instrument}; use crate::api::error::KeystoneApiError; -use crate::api::v3::federation::types::*; +use crate::api::v4::federation::types::*; #[derive(Error, Debug)] pub enum OidcError { diff --git a/src/api/v3/federation/identity_provider.rs b/src/api/v4/federation/identity_provider.rs similarity index 99% rename from src/api/v3/federation/identity_provider.rs rename to src/api/v4/federation/identity_provider.rs index 7be8fde9..8df87e43 100644 --- a/src/api/v3/federation/identity_provider.rs +++ b/src/api/v4/federation/identity_provider.rs @@ -25,7 +25,7 @@ use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::auth::Auth; use crate::api::error::KeystoneApiError; -use crate::api::v3::federation::types::*; +use crate::api::v4::federation::types::*; use crate::federation::FederationApi; use crate::keystone::ServiceState; #[double] diff --git a/src/api/v3/federation/mapping.rs b/src/api/v4/federation/mapping.rs similarity index 99% rename from src/api/v3/federation/mapping.rs rename to src/api/v4/federation/mapping.rs index 23af280a..e4d1a50e 100644 --- a/src/api/v3/federation/mapping.rs +++ b/src/api/v4/federation/mapping.rs @@ -25,7 +25,7 @@ use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::auth::Auth; use crate::api::error::KeystoneApiError; -use crate::api::v3::federation::types::*; +use crate::api::v4::federation::types::*; use crate::federation::FederationApi; use crate::keystone::ServiceState; #[double] diff --git a/src/api/v3/federation/mod.rs b/src/api/v4/federation/mod.rs similarity index 100% rename from src/api/v3/federation/mod.rs rename to src/api/v4/federation/mod.rs diff --git a/src/api/v3/federation/oidc.rs b/src/api/v4/federation/oidc.rs similarity index 99% rename from src/api/v3/federation/oidc.rs rename to src/api/v4/federation/oidc.rs index 0bb2e566..1cf6960a 100644 --- a/src/api/v3/federation/oidc.rs +++ b/src/api/v4/federation/oidc.rs @@ -28,11 +28,11 @@ use openidconnect::{ }; use crate::api::common::{find_project_from_scope, get_domain}; -use crate::api::v3::auth::token::types::{ +use crate::api::v4::auth::token::types::{ Token as ApiResponseToken, TokenResponse as KeystoneTokenResponse, }; -use crate::api::v3::federation::error::OidcError; -use crate::api::v3::federation::types::*; +use crate::api::v4::federation::error::OidcError; +use crate::api::v4::federation::types::*; use crate::api::{Catalog, error::KeystoneApiError}; use crate::auth::{AuthenticatedInfo, AuthenticationError, AuthzInfo}; use crate::catalog::CatalogApi; diff --git a/src/api/v3/federation/types.rs b/src/api/v4/federation/types.rs similarity index 100% rename from src/api/v3/federation/types.rs rename to src/api/v4/federation/types.rs diff --git a/src/api/v3/federation/types/auth.rs b/src/api/v4/federation/types/auth.rs similarity index 100% rename from src/api/v3/federation/types/auth.rs rename to src/api/v4/federation/types/auth.rs diff --git a/src/api/v3/federation/types/identity_provider.rs b/src/api/v4/federation/types/identity_provider.rs similarity index 100% rename from src/api/v3/federation/types/identity_provider.rs rename to src/api/v4/federation/types/identity_provider.rs diff --git a/src/api/v3/federation/types/mapping.rs b/src/api/v4/federation/types/mapping.rs similarity index 100% rename from src/api/v3/federation/types/mapping.rs rename to src/api/v4/federation/types/mapping.rs diff --git a/src/api/v4/group/mod.rs b/src/api/v4/group/mod.rs new file mode 100644 index 00000000..eb406b44 --- /dev/null +++ b/src/api/v4/group/mod.rs @@ -0,0 +1,320 @@ +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +use utoipa_axum::router::OpenApiRouter; + +use crate::keystone::ServiceState; + +use crate::api::v3::group::openapi_router as v3_openapi_router; + +pub(super) fn openapi_router() -> OpenApiRouter { + v3_openapi_router() +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode, header}, + }; + use http_body_util::BodyExt; // for `collect` + use sea_orm::DatabaseConnection; + use serde_json::json; + + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + use tower_http::trace::TraceLayer; + + use super::openapi_router; + use crate::api::v3::group::types::{ + Group as ApiGroup, GroupCreate as ApiGroupCreate, GroupCreateRequest, GroupList, + GroupResponse, + }; + use crate::identity::{ + MockIdentityProvider, + error::IdentityProviderError, + types::{Group, GroupCreate, GroupListParameters}, + }; + + use crate::tests::api::{get_mocked_state, get_mocked_state_unauthed}; + + #[tokio::test] + async fn test_list() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_list_groups() + .withf(|_: &DatabaseConnection, _: &GroupListParameters| true) + .returning(|_, _| { + Ok(vec![Group { + id: "1".into(), + name: "2".into(), + ..Default::default() + }]) + }); + + let state = get_mocked_state(identity_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: GroupList = serde_json::from_slice(&body).unwrap(); + assert_eq!( + vec![ApiGroup { + id: "1".into(), + name: "2".into(), + // for some reason when deserializing missing value appears still as an empty + // object + extra: Some(json!({})), + ..Default::default() + }], + res.groups + ); + } + + #[tokio::test] + async fn test_list_qp() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_list_groups() + .withf(|_: &DatabaseConnection, qp: &GroupListParameters| { + GroupListParameters { + domain_id: Some("domain".into()), + name: Some("name".into()), + } == *qp + }) + .returning(|_, _| Ok(Vec::new())); + + let state = get_mocked_state(identity_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/?domain_id=domain&name=name") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let _res: GroupList = serde_json::from_slice(&body).unwrap(); + } + + #[tokio::test] + async fn test_list_unauth() { + let state = get_mocked_state_unauthed(); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_get() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_get_group() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "foo") + .returning(|_, _| Ok(None)); + + identity_mock + .expect_get_group() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "bar") + .returning(|_, _| { + Ok(Some(Group { + id: "bar".into(), + ..Default::default() + })) + }); + + let state = get_mocked_state(identity_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/foo") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/bar") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: GroupResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!( + ApiGroup { + id: "bar".into(), + extra: Some(json!({})), + ..Default::default() + }, + res.group, + ); + } + + #[tokio::test] + async fn test_create() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_create_group() + .withf(|_: &DatabaseConnection, req: &GroupCreate| { + req.domain_id == "domain" && req.name == "name" + }) + .returning(|_, req| { + Ok(Group { + id: "bar".into(), + domain_id: req.domain_id, + name: req.name, + ..Default::default() + }) + }); + + let state = get_mocked_state(identity_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let req = GroupCreateRequest { + group: ApiGroupCreate { + domain_id: "domain".into(), + name: "name".into(), + ..Default::default() + }, + }; + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("POST") + .header(header::CONTENT_TYPE, "application/json") + .uri("/") + .header("x-auth-token", "foo") + .body(Body::from(serde_json::to_string(&req).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: GroupResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(res.group.name, req.group.name); + assert_eq!(res.group.domain_id, req.group.domain_id); + } + + #[tokio::test] + async fn test_delete() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_delete_group() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "foo") + .returning(|_, _| Err(IdentityProviderError::GroupNotFound("foo".into()))); + + identity_mock + .expect_delete_group() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "bar") + .returning(|_, _| Ok(())); + + let state = get_mocked_state(identity_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/foo") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/bar") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } +} diff --git a/src/api/v4/mod.rs b/src/api/v4/mod.rs new file mode 100644 index 00000000..d5f76fe9 --- /dev/null +++ b/src/api/v4/mod.rs @@ -0,0 +1,79 @@ +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +//! v4 API + +use axum::{ + extract::{OriginalUri, Request}, + http::{HeaderMap, header}, + response::IntoResponse, +}; +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; + +pub mod auth; +pub mod federation; +pub mod group; +pub mod role; +pub mod role_assignment; +pub mod user; + +use crate::api::types::*; + +pub(super) fn openapi_router() -> OpenApiRouter { + OpenApiRouter::new() + .nest("/auth", auth::openapi_router()) + .nest("/groups", group::openapi_router()) + .nest("/federation", federation::openapi_router()) + .nest("/role_assignments", role_assignment::openapi_router()) + .nest("/roles", role::openapi_router()) + .nest("/users", user::openapi_router()) + .routes(routes!(version)) +} + +/// Version discovery endpoint +#[utoipa::path( + get, + path = "/", + description = "Version discovery", + responses( + (status = OK, description = "Versions", body = SingleVersion), + ), + tag = "version" +)] +async fn version( + headers: HeaderMap, + OriginalUri(uri): OriginalUri, + _req: Request, +) -> Result { + let host = headers + .get(header::HOST) + .and_then(|header| header.to_str().ok()) + .unwrap_or("localhost"); + let link = Link { + rel: "self".into(), + href: format!("http://{}{}", host, uri.path()), + }; + let version = Version { + id: "v4.0".into(), + status: VersionStatus::Stable, + links: Some(vec![link]), + media_types: Some(vec![MediaType::default()]), + ..Default::default() + }; + let res = SingleVersion { version }; + Ok(res) +} diff --git a/src/api/v4/role/mod.rs b/src/api/v4/role/mod.rs new file mode 100644 index 00000000..60a197ce --- /dev/null +++ b/src/api/v4/role/mod.rs @@ -0,0 +1,259 @@ +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +use utoipa_axum::router::OpenApiRouter; + +use crate::keystone::ServiceState; + +use crate::api::v3::role::openapi_router as v3_openapi_router; + +pub(super) fn openapi_router() -> OpenApiRouter { + v3_openapi_router() +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; // for `collect` + use sea_orm::DatabaseConnection; + use serde_json::json; + use std::sync::Arc; + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + use tower_http::trace::TraceLayer; + + use super::openapi_router; + use crate::api::v3::role::types::{ + Role as ApiRole, //GroupCreate as ApiGroupCreate, GroupCreateRequest, + RoleList, + RoleResponse, + }; + use crate::assignment::{ + MockAssignmentProvider, + types::{Role, RoleListParameters}, + }; + + use crate::config::Config; + + use crate::keystone::{Service, ServiceState}; + use crate::policy::MockPolicyFactory; + use crate::provider::Provider; + + use crate::token::{MockTokenProvider, Token, UnscopedPayload}; + + use crate::tests::api::get_mocked_state_unauthed; + + fn get_mocked_state(assignment_mock: MockAssignmentProvider) -> ServiceState { + let mut token_mock = MockTokenProvider::default(); + token_mock.expect_validate_token().returning(|_, _, _| { + Ok(Token::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); + token_mock + .expect_expand_token_information() + .returning(|_, _, _| { + Ok(Token::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); + + let provider = Provider::mocked_builder() + .assignment(assignment_mock) + .token(token_mock) + .build() + .unwrap(); + + Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + MockPolicyFactory::new(), + ) + .unwrap(), + ) + } + + #[tokio::test] + async fn test_list() { + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock + .expect_list_roles() + .withf(|_: &DatabaseConnection, _: &RoleListParameters| true) + .returning(|_, _| { + Ok(vec![Role { + id: "1".into(), + name: "2".into(), + ..Default::default() + }]) + }); + + let state = get_mocked_state(assignment_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: RoleList = serde_json::from_slice(&body).unwrap(); + assert_eq!( + vec![ApiRole { + id: "1".into(), + name: "2".into(), + // for some reason when deserializing missing value appears still as an empty + // object + extra: Some(json!({})), + ..Default::default() + }], + res.roles + ); + } + + #[tokio::test] + async fn test_list_qp() { + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock + .expect_list_roles() + .withf(|_: &DatabaseConnection, qp: &RoleListParameters| { + RoleListParameters { + domain_id: Some("domain".into()), + name: Some("name".into()), + } == *qp + }) + .returning(|_, _| Ok(Vec::new())); + + let state = get_mocked_state(assignment_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/?domain_id=domain&name=name") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let _res: RoleList = serde_json::from_slice(&body).unwrap(); + } + + #[tokio::test] + async fn test_list_unauth() { + let state = get_mocked_state_unauthed(); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_get() { + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock + .expect_get_role() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "foo") + .returning(|_, _| Ok(None)); + + assignment_mock + .expect_get_role() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "bar") + .returning(|_, _| { + Ok(Some(Role { + id: "bar".into(), + ..Default::default() + })) + }); + + let state = get_mocked_state(assignment_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/foo") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/bar") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: RoleResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!( + ApiRole { + id: "bar".into(), + extra: Some(json!({})), + ..Default::default() + }, + res.role, + ); + } +} diff --git a/src/api/v4/role_assignment/mod.rs b/src/api/v4/role_assignment/mod.rs new file mode 100644 index 00000000..74defe5d --- /dev/null +++ b/src/api/v4/role_assignment/mod.rs @@ -0,0 +1,266 @@ +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +use utoipa_axum::router::OpenApiRouter; + +use crate::api::v3::role_assignment::openapi_router as v3_openapi_router; +use crate::keystone::ServiceState; + +pub(crate) fn openapi_router() -> OpenApiRouter { + v3_openapi_router() +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; // for `collect` + use sea_orm::DatabaseConnection; + + use std::sync::Arc; + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + use tower_http::trace::TraceLayer; + + use super::openapi_router; + use crate::api::v3::role_assignment::types::{ + Assignment as ApiAssignment, AssignmentList as ApiAssignmentList, Project, Role, Scope, + User, + }; + use crate::assignment::{ + MockAssignmentProvider, + types::{Assignment, AssignmentType, RoleAssignmentListParameters}, + }; + + use crate::config::Config; + + use crate::keystone::{Service, ServiceState}; + use crate::policy::MockPolicyFactory; + use crate::provider::Provider; + + use crate::token::{MockTokenProvider, Token, UnscopedPayload}; + + fn get_mocked_state(assignment_mock: MockAssignmentProvider) -> ServiceState { + let mut token_mock = MockTokenProvider::default(); + token_mock.expect_validate_token().returning(|_, _, _| { + Ok(Token::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); + token_mock + .expect_expand_token_information() + .returning(|_, _, _| { + Ok(Token::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); + + let provider = Provider::mocked_builder() + .assignment(assignment_mock) + .token(token_mock) + .build() + .unwrap(); + + Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + MockPolicyFactory::new(), + ) + .unwrap(), + ) + } + + #[tokio::test] + async fn test_list() { + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock + .expect_list_role_assignments() + .withf(|_: &DatabaseConnection, _: &Provider, _: &RoleAssignmentListParameters| true) + .returning(|_, _, _| { + Ok(vec![Assignment { + role_id: "role".into(), + role_name: Some("rn".into()), + actor_id: "actor".into(), + target_id: "target".into(), + r#type: AssignmentType::UserProject, + inherited: false, + }]) + }); + + let state = get_mocked_state(assignment_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: ApiAssignmentList = serde_json::from_slice(&body).unwrap(); + assert_eq!( + vec![ApiAssignment { + role: Role { + id: "role".into(), + name: Some("rn".into()) + }, + user: Some(User { id: "actor".into() }), + scope: Scope::Project(Project { + id: "target".into() + }), + group: None, + }], + res.role_assignments + ); + } + + #[tokio::test] + async fn test_list_qp() { + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock + .expect_list_role_assignments() + .withf( + |_: &DatabaseConnection, _: &Provider, qp: &RoleAssignmentListParameters| { + RoleAssignmentListParameters { + role_id: Some("role".into()), + user_id: Some("user1".into()), + project_id: Some("project1".into()), + ..Default::default() + } == *qp + }, + ) + .returning(|_, _, _| { + Ok(vec![Assignment { + role_id: "role".into(), + role_name: None, + actor_id: "actor".into(), + target_id: "target".into(), + r#type: AssignmentType::UserProject, + inherited: false, + }]) + }); + + assignment_mock + .expect_list_role_assignments() + .withf( + |_: &DatabaseConnection, _: &Provider, qp: &RoleAssignmentListParameters| { + RoleAssignmentListParameters { + role_id: Some("role".into()), + user_id: Some("user2".into()), + domain_id: Some("domain2".into()), + ..Default::default() + } == *qp + }, + ) + .returning(|_, _, _| { + Ok(vec![Assignment { + role_id: "role".into(), + role_name: None, + actor_id: "actor".into(), + target_id: "target".into(), + r#type: AssignmentType::UserProject, + inherited: false, + }]) + }); + + assignment_mock + .expect_list_role_assignments() + .withf( + |_: &DatabaseConnection, _: &Provider, qp: &RoleAssignmentListParameters| { + RoleAssignmentListParameters { + group_id: Some("group3".into()), + project_id: Some("project3".into()), + ..Default::default() + } == *qp + }, + ) + .returning(|_, _, _| { + Ok(vec![Assignment { + role_id: "role".into(), + role_name: None, + actor_id: "actor".into(), + target_id: "target".into(), + r#type: AssignmentType::UserProject, + inherited: false, + }]) + }); + + let state = get_mocked_state(assignment_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/?role.id=role&user.id=user1&scope.project.id=project1") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let _res: ApiAssignmentList = serde_json::from_slice(&body).unwrap(); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/?role.id=role&user.id=user2&scope.domain.id=domain2") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/?group.id=group3&scope.project.id=project3") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } +} diff --git a/src/api/v4/user/mod.rs b/src/api/v4/user/mod.rs new file mode 100644 index 00000000..bc5540ab --- /dev/null +++ b/src/api/v4/user/mod.rs @@ -0,0 +1,524 @@ +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::api::v3::group::types::{Group, GroupList}; +use crate::identity::IdentityApi; +use crate::keystone::ServiceState; +use types::{User, UserCreateRequest, UserList, UserListParameters, UserResponse}; + +pub mod passkey; +pub mod types; + +pub(super) fn openapi_router() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(list, create)) + .routes(routes!(show, remove)) + .routes(routes!(groups)) + .nest("/{user_id}/passkeys", passkey::openapi_router()) +} + +/// List users +#[utoipa::path( + get, + path = "/", + params(UserListParameters), + description = "List users", + responses( + (status = OK, description = "List of users", body = UserList), + (status = 500, description = "Internal error", example = json!(KeystoneApiError::InternalError(String::from("id = 1")))) + ), + tag="users" +)] +#[tracing::instrument(name = "api::user_list", level = "debug", skip(state))] +async fn list( + Auth(user_auth): Auth, + Query(query): Query, + State(state): State, +) -> Result { + let users: Vec = state + .provider + .get_identity_provider() + .list_users(&state.db, &query.into()) + .await + .map_err(KeystoneApiError::identity)? + .into_iter() + .map(Into::into) + .collect(); + Ok(UserList { users }) +} + +/// Get single user +#[utoipa::path( + get, + path = "/{user_id}", + params(), + responses( + (status = OK, description = "Single user", body = UserResponse), + (status = 404, description = "User not found", example = json!(KeystoneApiError::NotFound(String::from("id = 1")))) + ), + tag="users" +)] +#[tracing::instrument(name = "api::user_get", level = "debug", skip(state))] +async fn show( + Auth(user_auth): Auth, + Path(user_id): Path, + State(state): State, +) -> Result { + state + .provider + .get_identity_provider() + .get_user(&state.db, &user_id) + .await + .map(|x| { + x.ok_or_else(|| KeystoneApiError::NotFound { + resource: "user".into(), + identifier: user_id, + }) + })? +} + +/// Create user +#[utoipa::path( + post, + path = "/", + description = "Create new user", + responses( + (status = CREATED, description = "New user", body = UserResponse), + ), + tag="users" +)] +#[tracing::instrument(name = "api::create_user", level = "debug", skip(state))] +async fn create( + Auth(user_auth): Auth, + Query(query): Query, + State(state): State, + Json(req): Json, +) -> Result { + let user = state + .provider + .get_identity_provider() + .create_user(&state.db, req.into()) + .await + .map_err(KeystoneApiError::identity)?; + Ok((StatusCode::CREATED, user).into_response()) +} + +/// Delete user +#[utoipa::path( + delete, + path = "/{user_id}", + description = "Delete user by ID", + params(), + responses( + (status = 204, description = "Deleted"), + (status = 404, description = "User not found", example = json!(KeystoneApiError::NotFound(String::from("id = 1")))) + ), + tag="users" +)] +#[tracing::instrument(name = "api::user_delete", level = "debug", skip(state))] +async fn remove( + Auth(user_auth): Auth, + Path(user_id): Path, + State(state): State, +) -> Result { + state + .provider + .get_identity_provider() + .delete_user(&state.db, &user_id) + .await + .map_err(KeystoneApiError::identity)?; + Ok((StatusCode::NO_CONTENT).into_response()) +} + +/// List groups a user is member of +#[utoipa::path( + get, + path = "/{user_id}/groups", + description = "List groups a user is member of", + responses( + (status = OK, description = "List of user groups", body = GroupList), + (status = 500, description = "Internal error", example = json!(KeystoneApiError::InternalError(String::from("id = 1")))) + ), + tag="users" +)] +#[tracing::instrument(name = "api::user_list", level = "debug", skip(state))] +async fn groups( + Auth(user_auth): Auth, + Path(user_id): Path, + State(state): State, +) -> Result { + let groups: Vec = state + .provider + .get_identity_provider() + .list_groups_for_user(&state.db, &user_id) + .await + .map_err(KeystoneApiError::identity)? + .into_iter() + .map(Into::into) + .collect(); + Ok(GroupList { groups }) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{self, Request, StatusCode}, + }; + use http_body_util::BodyExt; // for `collect` + use sea_orm::DatabaseConnection; + use serde_json::json; + + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + use tower_http::trace::TraceLayer; + + use super::openapi_router; + use crate::api::v3::group::types::{Group as ApiGroup, GroupList}; + use crate::api::v3::user::types::{ + User as ApiUser, UserCreate as ApiUserCreate, UserCreateRequest, UserList, + UserResponse as ApiUserResponse, + }; + use crate::identity::{ + MockIdentityProvider, + error::IdentityProviderError, + types::{Group, UserCreate, UserListParameters, UserResponse}, + }; + + use crate::tests::api::{get_mocked_state, get_mocked_state_unauthed}; + + #[tokio::test] + async fn test_list() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_list_users() + .withf(|_: &DatabaseConnection, _: &UserListParameters| true) + .returning(|_, _| { + Ok(vec![UserResponse { + id: "1".into(), + name: "2".into(), + ..Default::default() + }]) + }); + + let state = get_mocked_state(identity_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: UserList = serde_json::from_slice(&body).unwrap(); + assert_eq!( + vec![ApiUser { + id: "1".into(), + name: "2".into(), + // object + extra: Some(json!({})), + ..Default::default() + }], + res.users + ); + } + + #[tokio::test] + async fn test_list_qp() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_list_users() + .withf(|_: &DatabaseConnection, qp: &UserListParameters| { + UserListParameters { + domain_id: Some("domain".into()), + name: Some("name".into()), + } == *qp + }) + .returning(|_, _| Ok(Vec::new())); + + let state = get_mocked_state(identity_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/?domain_id=domain&name=name") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let _res: UserList = serde_json::from_slice(&body).unwrap(); + } + + #[tokio::test] + async fn test_list_unauth() { + let state = get_mocked_state_unauthed(); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_create() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_create_user() + .withf(|_: &DatabaseConnection, req: &UserCreate| { + req.domain_id == "domain" && req.name == "name" + }) + .returning(|_, req| { + Ok(UserResponse { + id: "bar".into(), + domain_id: req.domain_id, + name: req.name, + ..Default::default() + }) + }); + + let state = get_mocked_state(identity_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let user = UserCreateRequest { + user: ApiUserCreate { + domain_id: "domain".into(), + name: "name".into(), + ..Default::default() + }, + }; + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("POST") + .header(http::header::CONTENT_TYPE, "application/json") + .uri("/") + .header("x-auth-token", "foo") + .body(Body::from(serde_json::to_string(&user).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let created_user: ApiUserResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(created_user.user.name, user.user.name); + } + + #[tokio::test] + async fn test_get() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_get_user() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "foo") + .returning(|_, _| Ok(None)); + + identity_mock + .expect_get_user() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "bar") + .returning(|_, _| { + Ok(Some(UserResponse { + id: "bar".into(), + ..Default::default() + })) + }); + + let state = get_mocked_state(identity_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/foo") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/bar") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: ApiUserResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!( + ApiUser { + id: "bar".into(), + extra: Some(json!({})), + ..Default::default() + }, + res.user, + ); + } + + #[tokio::test] + async fn test_delete() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_delete_user() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "foo") + .returning(|_, _| Err(IdentityProviderError::UserNotFound("foo".into()))); + + identity_mock + .expect_delete_user() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "bar") + .returning(|_, _| Ok(())); + + let state = get_mocked_state(identity_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/foo") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/bar") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn test_groups() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_list_groups_for_user() + .withf(|_: &DatabaseConnection, uid: &str| uid == "foo") + .returning(|_, _| { + Ok(vec![Group { + id: "1".into(), + name: "2".into(), + ..Default::default() + }]) + }); + + let state = get_mocked_state(identity_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/foo/groups") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: GroupList = serde_json::from_slice(&body).unwrap(); + assert_eq!( + vec![ApiGroup { + id: "1".into(), + name: "2".into(), + extra: Some(json!({})), + ..Default::default() + }], + res.groups + ); + } +} diff --git a/src/api/v3/user/passkey/mod.rs b/src/api/v4/user/passkey/mod.rs similarity index 100% rename from src/api/v3/user/passkey/mod.rs rename to src/api/v4/user/passkey/mod.rs diff --git a/src/api/v4/user/types.rs b/src/api/v4/user/types.rs new file mode 100644 index 00000000..2351a653 --- /dev/null +++ b/src/api/v4/user/types.rs @@ -0,0 +1,259 @@ +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use utoipa::{IntoParams, ToSchema}; + +use crate::identity::types as identity_types; + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct User { + /// User ID + pub id: String, + /// User domain ID + pub domain_id: String, + /// User name + pub name: String, + /// If the user is enabled, this value is true. If the user is disabled, this value is false. + pub enabled: bool, + /// The ID of the default project for the user. A user’s default project must not be a domain. + /// Setting this attribute does not grant any actual authorization on the project, and is + /// merely provided for convenience. Therefore, the referenced project does not need to exist + /// within the user domain. (Since v3.1) If the user does not have authorization to their + /// default project, the default project is ignored at token creation. (Since v3.1) + /// Additionally, if your default project is not valid, a token is issued without an explicit + /// scope of authorization. + #[serde(skip_serializing_if = "Option::is_none")] + pub default_project_id: Option, + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub extra: Option, + /// The date and time when the password expires. The time zone is UTC. + #[serde(skip_serializing_if = "Option::is_none")] + pub password_expires_at: Option>, + /// The resource options for the user. Available resource options are + /// ignore_change_password_upon_first_use, ignore_password_expiry, + /// ignore_lockout_failure_attempts, lock_password, multi_factor_auth_enabled, and + /// multi_factor_auth_rules ignore_user_inactivity. + #[serde(skip_serializing_if = "Option::is_none")] + pub options: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct UserResponse { + /// User object + pub user: User, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct UserCreate { + /// User domain ID + pub domain_id: String, + /// The user name. Must be unique within the owning domain. + pub name: String, + /// If the user is enabled, this value is true. If the user is disabled, this value is false. + pub enabled: Option, + /// The ID of the default project for the user. A user’s default project must not be a domain. + /// Setting this attribute does not grant any actual authorization on the project, and is + /// merely provided for convenience. Therefore, the referenced project does not need to exist + /// within the user domain. (Since v3.1) If the user does not have authorization to their + /// default project, the default project is ignored at token creation. (Since v3.1) + /// Additionally, if your default project is not valid, a token is issued without an explicit + /// scope of authorization. + pub default_project_id: Option, + /// The password for the user. + pub password: Option, + /// The resource options for the user. Available resource options are + /// ignore_change_password_upon_first_use, ignore_password_expiry, + /// ignore_lockout_failure_attempts, lock_password, multi_factor_auth_enabled, and + /// multi_factor_auth_rules ignore_user_inactivity. + pub options: Option, + /// Additional user properties + #[serde(flatten)] + pub extra: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct UserUpdateRequest { + /// The user name. Must be unique within the owning domain. + pub name: Option, + /// If the user is enabled, this value is true. If the user is disabled, this value is false. + pub enabled: Option, + /// The ID of the default project for the user. A user’s default project must not be a domain. + /// Setting this attribute does not grant any actual authorization on the project, and is + /// merely provided for convenience. Therefore, the referenced project does not need to exist + /// within the user domain. (Since v3.1) If the user does not have authorization to their + /// default project, the default project is ignored at token creation. (Since v3.1) + /// Additionally, if your default project is not valid, a token is issued without an explicit + /// scope of authorization. + pub default_project_id: Option, + /// The password for the user. + pub password: Option, + /// The resource options for the user. Available resource options are + /// ignore_change_password_upon_first_use, ignore_password_expiry, + /// ignore_lockout_failure_attempts, lock_password, multi_factor_auth_enabled, and + /// multi_factor_auth_rules ignore_user_inactivity. + pub options: Option, + /// Additional user properties + #[serde(flatten)] + pub extra: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct UserOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub ignore_change_password_upon_first_use: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ignore_password_expiry: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ignore_lockout_failure_attempts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lock_password: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ignore_user_inactivity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub multi_factor_auth_rules: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + pub multi_factor_auth_enabled: Option, +} + +impl From for UserOptions { + fn from(value: identity_types::UserOptions) -> Self { + Self { + ignore_change_password_upon_first_use: value.ignore_change_password_upon_first_use, + ignore_password_expiry: value.ignore_password_expiry, + ignore_lockout_failure_attempts: value.ignore_lockout_failure_attempts, + lock_password: value.lock_password, + ignore_user_inactivity: value.ignore_user_inactivity, + multi_factor_auth_rules: value.multi_factor_auth_rules, + multi_factor_auth_enabled: value.multi_factor_auth_enabled, + } + } +} + +impl From for identity_types::UserOptions { + fn from(value: UserOptions) -> Self { + Self { + ignore_change_password_upon_first_use: value.ignore_change_password_upon_first_use, + ignore_password_expiry: value.ignore_password_expiry, + ignore_lockout_failure_attempts: value.ignore_lockout_failure_attempts, + lock_password: value.lock_password, + ignore_user_inactivity: value.ignore_user_inactivity, + multi_factor_auth_rules: value.multi_factor_auth_rules, + multi_factor_auth_enabled: value.multi_factor_auth_enabled, + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct UserCreateRequest { + /// User object + pub user: UserCreate, +} + +impl From for User { + fn from(value: identity_types::UserResponse) -> Self { + let opts: UserOptions = value.options.clone().into(); + // We only want to see user options if there is at least 1 option set + let opts = if opts.ignore_change_password_upon_first_use.is_some() + || opts.ignore_password_expiry.is_some() + || opts.ignore_lockout_failure_attempts.is_some() + || opts.lock_password.is_some() + || opts.ignore_user_inactivity.is_some() + || opts.multi_factor_auth_rules.is_some() + || opts.multi_factor_auth_enabled.is_some() + { + Some(opts) + } else { + None + }; + Self { + id: value.id, + domain_id: value.domain_id, + name: value.name, + enabled: value.enabled, + default_project_id: value.default_project_id, + extra: value.extra, + password_expires_at: value.password_expires_at, + options: opts, + } + } +} + +impl From for identity_types::UserCreate { + fn from(value: UserCreateRequest) -> Self { + let user = value.user; + Self { + id: String::new(), + name: user.name, + domain_id: user.domain_id, + enabled: user.enabled, + password: user.password, + extra: user.extra, + default_project_id: user.default_project_id, + options: user.options.map(Into::into), + federated: None, + } + } +} + +impl IntoResponse for UserResponse { + fn into_response(self) -> Response { + (StatusCode::OK, Json(self)).into_response() + } +} + +/// Users +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct UserList { + /// Collection of user objects + pub users: Vec, +} + +impl From> for UserList { + fn from(value: Vec) -> Self { + let objects: Vec = value.into_iter().map(User::from).collect(); + Self { users: objects } + } +} + +impl IntoResponse for UserList { + fn into_response(self) -> Response { + (StatusCode::OK, Json(self)).into_response() + } +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, IntoParams)] +pub struct UserListParameters { + /// Filter users by Domain ID + pub domain_id: Option, + /// Filter users by Name + pub name: Option, +} + +impl From for identity_types::UserListParameters { + fn from(value: UserListParameters) -> Self { + Self { + domain_id: value.domain_id, + name: value.name, + // limit: value.limit, + } + } +} diff --git a/tests/keycloak/keystone_utils.rs b/tests/keycloak/keystone_utils.rs index c0783703..18b51d2f 100644 --- a/tests/keycloak/keystone_utils.rs +++ b/tests/keycloak/keystone_utils.rs @@ -30,7 +30,7 @@ use std::time::Duration; use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; -use openstack_keystone::api::v3::federation::types::*; +use openstack_keystone::api::v4::federation::types::*; pub async fn auth() -> String { let keystone_url = env::var("KEYSTONE_URL").expect("KEYSTONE_URL is set"); @@ -78,7 +78,7 @@ pub async fn setup_kecloak_idp, K: AsRef, S: AsRef>( let client = Client::new(); let idp: IdentityProviderResponse = client - .post(format!("{}/v3/federation/identity_providers", keystone_url)) + .post(format!("{}/v4/federation/identity_providers", keystone_url)) .header("x-auth-token", token.as_ref()) .json(&json!({ "identity_provider": { @@ -96,7 +96,7 @@ pub async fn setup_kecloak_idp, K: AsRef, S: AsRef>( let mapping: MappingResponse = client .post(format!( - "{}/v3/federation/mappings", + "{}/v4/federation/mappings", keystone_url, )) .header("x-auth-token", token.as_ref()) @@ -105,7 +105,7 @@ pub async fn setup_kecloak_idp, K: AsRef, S: AsRef>( "id": "kc", "name": "keycloak", "idp_id": idp.identity_provider.id.clone(), - "allowed_redirect_uris": ["http://localhost:8080/v3/identity_providers/kc/callback"], + "allowed_redirect_uris": ["http://localhost:8080/v4/identity_providers/kc/callback"], "user_id_claim": "sub", "user_name_claim": "preferred_username", "domain_id_claim": "domain_id" diff --git a/tests/keycloak/main.rs b/tests/keycloak/main.rs index 54e56637..76a83344 100644 --- a/tests/keycloak/main.rs +++ b/tests/keycloak/main.rs @@ -26,8 +26,8 @@ mod keystone_utils; use keycloak_utils::*; use keystone_utils::*; -use openstack_keystone::api::v3::auth::token::types::TokenResponse; -use openstack_keystone::api::v3::federation::types::*; +use openstack_keystone::api::v4::auth::token::types::TokenResponse; +use openstack_keystone::api::v4::federation::types::*; #[tokio::test] async fn test_login_keycloak() { @@ -54,7 +54,7 @@ async fn test_login_keycloak() { let auth_req: IdentityProviderAuthResponse = client .post(format!( - "{}/v3/federation/identity_providers/{}/auth", + "{}/v4/federation/identity_providers/{}/auth", keystone_url, idp.identity_provider.id )) .json(&json!({ @@ -124,7 +124,7 @@ async fn test_login_keycloak() { let res: FederationAuthCodeCallbackResponse = guard.clone().unwrap(); let _auth_rsp: TokenResponse = client - .post(format!("{}/v3/federation/oidc/callback", keystone_url)) + .post(format!("{}/v4/federation/oidc/callback", keystone_url)) .json(&json!({ "state": res.state, "code": res.code