diff --git a/src/api/v3/auth/token/common.rs b/src/api/v3/auth/token/common.rs index 5e0f05a9..a3dd7589 100644 --- a/src/api/v3/auth/token/common.rs +++ b/src/api/v3/auth/token/common.rs @@ -149,7 +149,7 @@ mod tests { use crate::config::Config; use crate::identity::{ MockIdentityProvider, - types::{UserPasswordAuthRequest, UserResponse}, + types::{UserPasswordAuthRequest, UserResponseBuilder}, }; use crate::keystone::Service; use crate::policy::MockPolicyFactory; @@ -165,12 +165,15 @@ mod tests { 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() - }) + .user( + UserResponseBuilder::default() + .id("uid") + .domain_id("udid") + .enabled(true) + .name("name") + .build() + .unwrap(), + ) .build() .unwrap(); let auth_clone = auth_info.clone(); @@ -247,12 +250,15 @@ mod tests { .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() - })) + Ok(Some( + UserResponseBuilder::default() + .id(id) + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let provider = Provider::mocked_builder() @@ -275,12 +281,15 @@ mod tests { assert_eq!( AuthenticatedInfo::builder() .user_id("uid") - .user(UserResponse { - id: "uid".to_string(), - domain_id: "user_domain_id".into(), - enabled: true, - ..Default::default() - }) + .user( + UserResponseBuilder::default() + .id("uid") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + ) .build() .unwrap(), authenticate_request( diff --git a/src/api/v3/auth/token/create.rs b/src/api/v3/auth/token/create.rs index 940625c6..918842e5 100644 --- a/src/api/v3/auth/token/create.rs +++ b/src/api/v3/auth/token/create.rs @@ -120,7 +120,7 @@ mod tests { use crate::config::Config; use crate::identity::{ MockIdentityProvider, - types::{UserPasswordAuthRequest, UserResponse}, + types::{UserPasswordAuthRequest, UserResponseBuilder}, }; use crate::keystone::Service; use crate::policy::MockPolicyFactory; @@ -170,12 +170,15 @@ mod tests { .returning(|_, _| { Ok(AuthenticatedInfo::builder() .user_id("uid") - .user(UserResponse { - id: "uid".to_string(), - domain_id: "udid".into(), - enabled: true, - ..Default::default() - }) + .user( + UserResponseBuilder::default() + .id("uid") + .domain_id("udid") + .enabled(true) + .name("name") + .build() + .unwrap(), + ) .build() .unwrap()) }); @@ -198,11 +201,15 @@ mod tests { 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() - }), + user: Some( + UserResponseBuilder::default() + .id("uid") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + ), project_id: "pid".into(), ..Default::default() })) @@ -216,11 +223,15 @@ mod tests { 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() - }), + user: Some( + UserResponseBuilder::default() + .id("uid") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + ), project_id: "pid".into(), project: Some(Project { id: "pid".into(), @@ -321,12 +332,15 @@ mod tests { .returning(|_, _| { Ok(AuthenticatedInfo::builder() .user_id("uid") - .user(UserResponse { - id: "uid".to_string(), - domain_id: "udid".into(), - enabled: true, - ..Default::default() - }) + .user( + UserResponseBuilder::default() + .id("uid") + .domain_id("udid") + .enabled(true) + .name("name") + .build() + .unwrap(), + ) .build() .unwrap()) }); diff --git a/src/api/v3/auth/token/show.rs b/src/api/v3/auth/token/show.rs index 491a76b4..7ca92ec1 100644 --- a/src/api/v3/auth/token/show.rs +++ b/src/api/v3/auth/token/show.rs @@ -140,7 +140,7 @@ mod tests { use crate::api::v3::auth::token::types::*; use crate::catalog::MockCatalogProvider; use crate::config::Config; - use crate::identity::{MockIdentityProvider, types::UserResponse}; + use crate::identity::{MockIdentityProvider, types::UserResponseBuilder}; use crate::keystone::Service; use crate::provider::Provider; use crate::resource::{MockResourceProvider, types::Domain}; @@ -155,11 +155,15 @@ mod tests { 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() - })) + Ok(Some( + UserResponseBuilder::default() + .id(id) + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let mut resource_mock = MockResourceProvider::default(); @@ -251,11 +255,15 @@ mod tests { 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() - })) + Ok(Some( + UserResponseBuilder::default() + .id(id) + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let mut resource_mock = MockResourceProvider::default(); diff --git a/src/api/v3/auth/token/token_impl.rs b/src/api/v3/auth/token/token_impl.rs index 51c6a140..dd3117b3 100644 --- a/src/api/v3/auth/token/token_impl.rs +++ b/src/api/v3/auth/token/token_impl.rs @@ -212,7 +212,7 @@ mod tests { use crate::assignment::types::Role as ProviderRole; use crate::config::Config; - use crate::identity::{MockIdentityProvider, types::UserResponse}; + use crate::identity::{MockIdentityProvider, types::UserResponseBuilder}; use crate::keystone::Service; use crate::policy::MockPolicyFactory; use crate::provider::Provider; @@ -233,11 +233,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "bar") .returning(|_, _| { - Ok(Some(UserResponse { - id: "bar".into(), - domain_id: "user_domain_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("bar") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let mut resource_mock = MockResourceProvider::default(); @@ -288,11 +292,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "bar") .returning(|_, _| { - Ok(Some(UserResponse { - id: "bar".into(), - domain_id: "user_domain_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("bar") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let mut resource_mock = MockResourceProvider::default(); @@ -347,11 +355,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "bar") .returning(|_, _| { - Ok(Some(UserResponse { - id: "bar".into(), - domain_id: "user_domain_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("bar") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let mut resource_mock = MockResourceProvider::default(); @@ -423,11 +435,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "bar") .returning(|_, _| { - Ok(Some(UserResponse { - id: "bar".into(), - domain_id: "user_domain_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("bar") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let mut resource_mock = MockResourceProvider::default(); diff --git a/src/api/v3/role_assignment/project/user/role/check.rs b/src/api/v3/role_assignment/project/user/role/check.rs index f546b89a..b9253eeb 100644 --- a/src/api/v3/role_assignment/project/user/role/check.rs +++ b/src/api/v3/role_assignment/project/user/role/check.rs @@ -160,10 +160,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "user_id") .returning(|_, _| { - Ok(Some(UserResponse { - id: "user_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let mut assignment_mock = MockAssignmentProvider::default(); assignment_mock @@ -243,10 +248,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "user_id") .returning(|_, _| { - Ok(Some(UserResponse { - id: "user_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let mut assignment_mock = MockAssignmentProvider::default(); assignment_mock @@ -326,10 +336,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "user_id") .returning(|_, _| { - Ok(Some(UserResponse { - id: "user_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let mut assignment_mock = MockAssignmentProvider::default(); assignment_mock @@ -477,10 +492,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "user_id") .returning(|_, _| { - Ok(Some(UserResponse { - id: "user_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let mut assignment_mock = MockAssignmentProvider::default(); assignment_mock @@ -544,10 +564,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "user_id") .returning(|_, _| { - Ok(Some(UserResponse { - id: "user_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let mut assignment_mock = MockAssignmentProvider::default(); assignment_mock diff --git a/src/api/v3/role_assignment/project/user/role/grant.rs b/src/api/v3/role_assignment/project/user/role/grant.rs index f04e4619..e02a71be 100644 --- a/src/api/v3/role_assignment/project/user/role/grant.rs +++ b/src/api/v3/role_assignment/project/user/role/grant.rs @@ -149,10 +149,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "user_id") .returning(|_, _| { - Ok(Some(UserResponse { - id: "user_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let mut assignment_mock = MockAssignmentProvider::default(); assignment_mock @@ -230,10 +235,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "user_id") .returning(|_, _| { - Ok(Some(UserResponse { - id: "user_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let mut assignment_mock = MockAssignmentProvider::default(); assignment_mock @@ -346,10 +356,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "user_id") .returning(|_, _| { - Ok(Some(UserResponse { - id: "user_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let mut assignment_mock = MockAssignmentProvider::default(); assignment_mock @@ -401,10 +416,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "user_id") .returning(|_, _| { - Ok(Some(UserResponse { - id: "user_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let mut assignment_mock = MockAssignmentProvider::default(); assignment_mock diff --git a/src/api/v3/role_assignment/project/user/role/revoke.rs b/src/api/v3/role_assignment/project/user/role/revoke.rs index 2fe675f8..a73c27c4 100644 --- a/src/api/v3/role_assignment/project/user/role/revoke.rs +++ b/src/api/v3/role_assignment/project/user/role/revoke.rs @@ -160,83 +160,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "user_id") .returning(|_, _| { - Ok(Some(UserResponse { - id: "user_id".into(), - ..Default::default() - })) - }); - - let mut assignment_mock = MockAssignmentProvider::default(); - assignment_mock - .expect_get_role() - .withf(|_, rid: &'_ str| rid == "role_id") - .returning(|_, _| { - Ok(Some(Role { - id: "role_id".into(), - name: "test_role".into(), - ..Default::default() - })) - }); - assignment_mock - .expect_revoke_grant() - .withf(|_, grant: &Assignment| { - grant.role_id == "role_id" - && grant.actor_id == "user_id" - && grant.target_id == "project_id" - && grant.r#type == AssignmentType::UserProject - && !grant.inherited - }) - .returning(|_, _| Ok(())); - - let mut resource_mock = MockResourceProvider::default(); - resource_mock - .expect_get_project() - .withf(|_, pid: &'_ str| pid == "project_id") - .returning(|_, id: &'_ str| { - Ok(Some(Project { - id: id.to_string(), - domain_id: "project_domain_id".into(), - ..Default::default() - })) - }); - - let provider_builder = Provider::mocked_builder() - .assignment(assignment_mock) - .identity(identity_mock) - .resource(resource_mock); - let state = get_mocked_state(provider_builder, true); - let mut api = openapi_router() - .layer(TraceLayer::new_for_http()) - .with_state(state); - - let response = api - .as_service() - .oneshot( - Request::builder() - .method("DELETE") - .uri("/projects/project_id/users/user_id/roles/role_id") - .header("x-auth-token", "foo") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::NO_CONTENT); - } - - #[tokio::test] - #[traced_test] - async fn test_revoke_forbidden() { - let mut identity_mock = MockIdentityProvider::default(); - identity_mock - .expect_get_user() - .withf(|_, id: &'_ str| id == "user_id") - .returning(|_, _| { - Ok(Some(UserResponse { - id: "user_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("did") + .enabled(true) + .name("uname") + .build() + .unwrap(), + )) }); let mut assignment_mock = MockAssignmentProvider::default(); @@ -354,10 +286,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "user_id") .returning(|_, _| { - Ok(Some(UserResponse { - id: "user_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("did") + .enabled(true) + .name("uname") + .build() + .unwrap(), + )) }); let mut assignment_mock = MockAssignmentProvider::default(); @@ -411,10 +348,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "user_id") .returning(|_, _| { - Ok(Some(UserResponse { - id: "user_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("did") + .enabled(true) + .name("uname") + .build() + .unwrap(), + )) }); let mut assignment_mock = MockAssignmentProvider::default(); diff --git a/src/api/v3/user/mod.rs b/src/api/v3/user/mod.rs index c1519518..d1a232a4 100644 --- a/src/api/v3/user/mod.rs +++ b/src/api/v3/user/mod.rs @@ -198,7 +198,7 @@ mod tests { use crate::identity::{ MockIdentityProvider, error::IdentityProviderError, - types::{Group, UserCreate, UserListParameters, UserResponse}, + types::{Group, UserCreate, UserListParameters, UserResponseBuilder}, }; use crate::tests::api::{get_mocked_state, get_mocked_state_unauthed}; @@ -210,11 +210,15 @@ mod tests { .expect_list_users() .withf(|_, _: &UserListParameters| true) .returning(|_, _| { - Ok(vec![UserResponse { - id: "1".into(), - name: "2".into(), - ..Default::default() - }]) + Ok(vec![ + UserResponseBuilder::default() + .id("1") + .domain_id("user_domain_id") + .enabled(true) + .name("2") + .build() + .unwrap(), + ]) }); let state = get_mocked_state(identity_mock); @@ -243,7 +247,8 @@ mod tests { vec![ApiUser { id: "1".into(), name: "2".into(), - // object + domain_id: "user_domain_id".into(), + enabled: true, extra: Some(json!({})), ..Default::default() }], @@ -313,12 +318,13 @@ mod tests { .expect_create_user() .withf(|_, 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() - }) + Ok(UserResponseBuilder::default() + .id("bar") + .domain_id(req.domain_id.clone()) + .enabled(true) + .name(req.name.clone()) + .build() + .unwrap()) }); let state = get_mocked_state(identity_mock); @@ -368,10 +374,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "bar") .returning(|_, _| { - Ok(Some(UserResponse { - id: "bar".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("bar") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let state = get_mocked_state(identity_mock); @@ -413,7 +424,10 @@ mod tests { assert_eq!( ApiUser { id: "bar".into(), + domain_id: "user_domain_id".into(), + enabled: true, extra: Some(json!({})), + name: "name".into(), ..Default::default() }, res.user, diff --git a/src/api/v3/user/types.rs b/src/api/v3/user/types.rs index 1a313f96..f27be967 100644 --- a/src/api/v3/user/types.rs +++ b/src/api/v3/user/types.rs @@ -194,6 +194,7 @@ impl From for identity_types::UserOptions { 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, + is_service_account: None, } } } diff --git a/src/api/v4/auth/token/token_impl.rs b/src/api/v4/auth/token/token_impl.rs index 2a2b36b9..a583a13c 100644 --- a/src/api/v4/auth/token/token_impl.rs +++ b/src/api/v4/auth/token/token_impl.rs @@ -202,7 +202,7 @@ mod tests { }; use crate::config::Config; - use crate::identity::{MockIdentityProvider, types::UserResponse}; + use crate::identity::{MockIdentityProvider, types::UserResponseBuilder}; use crate::keystone::Service; use crate::policy::MockPolicyFactory; use crate::provider::Provider; @@ -221,11 +221,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "bar") .returning(|_, _| { - Ok(Some(UserResponse { - id: "bar".into(), - domain_id: "user_domain_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("bar") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let mut resource_mock = MockResourceProvider::default(); @@ -276,11 +280,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "bar") .returning(|_, _| { - Ok(Some(UserResponse { - id: "bar".into(), - domain_id: "user_domain_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("bar") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let mut resource_mock = MockResourceProvider::default(); @@ -335,11 +343,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "bar") .returning(|_, _| { - Ok(Some(UserResponse { - id: "bar".into(), - domain_id: "user_domain_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("bar") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) }); let mut resource_mock = MockResourceProvider::default(); diff --git a/src/api/v4/token/restriction.rs b/src/api/v4/token/restriction.rs index 307c6fb3..f421c7bc 100644 --- a/src/api/v4/token/restriction.rs +++ b/src/api/v4/token/restriction.rs @@ -61,7 +61,7 @@ mod tests { use crate::config::Config; - use crate::identity::types::UserResponse; + use crate::identity::types::*; use crate::keystone::{Service, ServiceState}; use crate::policy::{MockPolicy, MockPolicyFactory, PolicyError, PolicyEvaluationResult}; use crate::provider::Provider; @@ -83,11 +83,13 @@ mod tests { .returning(|_, _| { Ok(Token::Unscoped(UnscopedPayload { user_id: "bar".into(), - user: Some(UserResponse { - id: "bar".into(), - domain_id: "udid".into(), - ..Default::default() - }), + user: Some( + UserResponseBuilder::default() + .id("bar") + .domain_id("udid") + .build() + .unwrap(), + ), ..Default::default() })) }); diff --git a/src/api/v4/user/mod.rs b/src/api/v4/user/mod.rs index bf09b2cf..0ae3959a 100644 --- a/src/api/v4/user/mod.rs +++ b/src/api/v4/user/mod.rs @@ -196,7 +196,7 @@ mod tests { use crate::identity::{ MockIdentityProvider, error::IdentityProviderError, - types::{Group, UserCreate, UserListParameters, UserResponse}, + types::{Group, UserCreate, UserListParameters, UserResponseBuilder}, }; use crate::tests::api::{get_mocked_state, get_mocked_state_unauthed}; @@ -208,11 +208,15 @@ mod tests { .expect_list_users() .withf(|_, _: &UserListParameters| true) .returning(|_, _| { - Ok(vec![UserResponse { - id: "1".into(), - name: "2".into(), - ..Default::default() - }]) + Ok(vec![ + UserResponseBuilder::default() + .id("1") + .domain_id("did") + .enabled(true) + .name("2") + .build() + .unwrap(), + ]) }); let state = get_mocked_state(identity_mock); @@ -240,7 +244,9 @@ mod tests { assert_eq!( vec![ApiUser { id: "1".into(), + domain_id: "did".into(), name: "2".into(), + enabled: true, // object extra: Some(json!({})), ..Default::default() @@ -311,12 +317,13 @@ mod tests { .expect_create_user() .withf(|_, 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() - }) + Ok(UserResponseBuilder::default() + .id("bar") + .domain_id(req.domain_id.clone()) + .enabled(true) + .name(req.name.clone()) + .build() + .unwrap()) }); let state = get_mocked_state(identity_mock); @@ -366,10 +373,15 @@ mod tests { .expect_get_user() .withf(|_, id: &'_ str| id == "bar") .returning(|_, _| { - Ok(Some(UserResponse { - id: "bar".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .id("bar") + .domain_id("did") + .enabled(true) + .name("foo") + .build() + .unwrap(), + )) }); let state = get_mocked_state(identity_mock); @@ -411,7 +423,10 @@ mod tests { assert_eq!( ApiUser { id: "bar".into(), + domain_id: "did".into(), + enabled: true, extra: Some(json!({})), + name: "foo".into(), ..Default::default() }, res.user, diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 8f8cfd37..7473e773 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -196,7 +196,7 @@ mod tests { use tracing_test::traced_test; - use crate::identity::types::UserResponse; + use crate::identity::types::{UserOptions, UserResponse}; #[test] fn test_authn_validate_no_user() { @@ -215,7 +215,13 @@ mod tests { .user(UserResponse { id: "uid".to_string(), enabled: false, - ..Default::default() + default_project_id: None, + domain_id: "did".into(), + extra: None, + name: "foo".into(), + options: UserOptions::default(), + federated: None, + password_expires_at: None, }) .build() .unwrap(); @@ -234,7 +240,13 @@ mod tests { .user(UserResponse { id: "uid2".to_string(), enabled: false, - ..Default::default() + default_project_id: None, + domain_id: "did".into(), + extra: None, + name: "foo".into(), + options: UserOptions::default(), + federated: None, + password_expires_at: None, }) .build() .unwrap(); diff --git a/src/config/identity.rs b/src/config/identity.rs index ca4efaec..28edcd4a 100644 --- a/src/config/identity.rs +++ b/src/config/identity.rs @@ -77,6 +77,9 @@ fn default_user_options_mapping() -> HashMap { ("1001".into(), "ignore_password_expiry".into()), ("1002".into(), "ignore_lockout_failure_attempts".into()), ("1003".into(), "lock_password".into()), + ("1004".into(), "ignore_user_inactivity".into()), + ("MFAR".into(), "multi_factor_auth_rules".into()), + ("MFAE".into(), "multi_factor_auth_rules".into()), ]) } diff --git a/src/db/entity/nonlocal_user.rs b/src/db/entity/nonlocal_user.rs index e5f1d6d6..fbe46c75 100644 --- a/src/db/entity/nonlocal_user.rs +++ b/src/db/entity/nonlocal_user.rs @@ -21,8 +21,10 @@ use sea_orm::entity::prelude::*; pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub domain_id: String, + #[sea_orm(primary_key, auto_increment = false)] pub name: String, + #[sea_orm(unique)] pub user_id: String, } diff --git a/src/db/entity/user.rs b/src/db/entity/user.rs index 10c89dee..88d513bf 100644 --- a/src/db/entity/user.rs +++ b/src/db/entity/user.rs @@ -26,20 +26,26 @@ use sea_orm::entity::prelude::*; #[cfg_attr(test, derive(Builder))] #[cfg_attr(test, builder(setter(strip_option, into)))] pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: String, - #[sea_orm(column_type = "Text", nullable)] #[cfg_attr(test, builder(default))] - pub extra: Option, - #[cfg_attr(test, builder(default))] - pub enabled: Option, + pub created_at: Option, + #[cfg_attr(test, builder(default))] pub default_project_id: Option, + + pub domain_id: String, + #[cfg_attr(test, builder(default))] - pub created_at: Option, + pub enabled: Option, + + #[sea_orm(column_type = "Text", nullable)] + #[cfg_attr(test, builder(default))] + pub extra: Option, + + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + #[cfg_attr(test, builder(default))] pub last_active_at: Option, - pub domain_id: String, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/db/entity/user_option.rs b/src/db/entity/user_option.rs index c81c749e..0e36b312 100644 --- a/src/db/entity/user_option.rs +++ b/src/db/entity/user_option.rs @@ -21,8 +21,10 @@ use sea_orm::entity::prelude::*; pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub user_id: String, + #[sea_orm(primary_key, auto_increment = false)] pub option_id: String, + #[sea_orm(column_type = "Text", nullable)] pub option_value: Option, } diff --git a/src/federation/api/identity_provider.rs b/src/federation/api/identity_provider.rs index e1232df1..f4d53920 100644 --- a/src/federation/api/identity_provider.rs +++ b/src/federation/api/identity_provider.rs @@ -36,7 +36,7 @@ mod tests { use crate::config::Config; use crate::federation::MockFederationProvider; - use crate::identity::types::UserResponse; + use crate::identity::types::UserResponseBuilder; use crate::keystone::{Service, ServiceState}; use crate::policy::{MockPolicy, MockPolicyFactory, PolicyError, PolicyEvaluationResult}; use crate::provider::Provider; @@ -51,11 +51,15 @@ mod tests { token_mock.expect_validate_token().returning(|_, _, _, _| { Ok(Token::Unscoped(UnscopedPayload { user_id: "bar".into(), - user: Some(UserResponse { - id: "bar".into(), - domain_id: "udid".into(), - ..Default::default() - }), + user: Some( + UserResponseBuilder::default() + .id("bar") + .domain_id("udid") + .enabled(true) + .name("name") + .build() + .unwrap(), + ), ..Default::default() })) }); diff --git a/src/identity/backend.rs b/src/identity/backend.rs index cc366f6e..d406b813 100644 --- a/src/identity/backend.rs +++ b/src/identity/backend.rs @@ -73,6 +73,13 @@ pub trait IdentityBackend: Send + Sync { group: GroupCreate, ) -> Result; + /// Create service account. + async fn create_service_account( + &self, + state: &ServiceState, + sa: ServiceAccountCreate, + ) -> Result; + /// Create user. async fn create_user( &self, @@ -101,6 +108,13 @@ pub trait IdentityBackend: Send + Sync { group_id: &'a str, ) -> Result, IdentityProviderError>; + /// Get single service account by ID. + async fn get_service_account<'a>( + &self, + state: &ServiceState, + user_id: &'a str, + ) -> Result, IdentityProviderError>; + /// Get single user by ID. async fn get_user<'a>( &self, diff --git a/src/identity/backend/error.rs b/src/identity/backend/error.rs index a53d0cc3..32387c60 100644 --- a/src/identity/backend/error.rs +++ b/src/identity/backend/error.rs @@ -82,6 +82,9 @@ pub enum IdentityDatabaseError { source: BuilderError, }, + #[error("user id must be given")] + UserIdMissing, + #[error("either user id or user name with user domain id or name must be given")] UserIdOrNameWithDomain, diff --git a/src/identity/backend/sql.rs b/src/identity/backend/sql.rs index 0dd69989..3d2cfc94 100644 --- a/src/identity/backend/sql.rs +++ b/src/identity/backend/sql.rs @@ -22,6 +22,7 @@ mod group; mod local_user; mod nonlocal_user; mod password; +mod service_account; mod user; mod user_group; mod user_option; @@ -39,7 +40,7 @@ pub struct SqlBackend {} #[async_trait] impl IdentityBackend for SqlBackend { /// Add the user into the group. - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn add_user_to_group<'a>( &self, state: &ServiceState, @@ -50,7 +51,7 @@ impl IdentityBackend for SqlBackend { } /// Add the user to the group with expiration. - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn add_user_to_group_expiring<'a>( &self, state: &ServiceState, @@ -65,7 +66,7 @@ impl IdentityBackend for SqlBackend { } /// Add user group membership relations. - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn add_users_to_groups<'a>( &self, state: &ServiceState, @@ -75,7 +76,7 @@ impl IdentityBackend for SqlBackend { } /// Add expiring user group membership relations. - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn add_users_to_groups_expiring<'a>( &self, state: &ServiceState, @@ -95,7 +96,7 @@ impl IdentityBackend for SqlBackend { } /// Create group. - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn create_group( &self, state: &ServiceState, @@ -104,8 +105,18 @@ impl IdentityBackend for SqlBackend { Ok(group::create(&state.db, group).await?) } + /// Create service account. + #[tracing::instrument(skip(self, state))] + async fn create_service_account( + &self, + state: &ServiceState, + sa: ServiceAccountCreate, + ) -> Result { + Ok(service_account::create(&state.config, &state.db, sa, None).await?) + } + /// Create user. - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn create_user( &self, state: &ServiceState, @@ -115,7 +126,7 @@ impl IdentityBackend for SqlBackend { } /// Delete group. - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn delete_group<'a>( &self, state: &ServiceState, @@ -125,7 +136,7 @@ impl IdentityBackend for SqlBackend { } /// Delete user. - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn delete_user<'a>( &self, state: &ServiceState, @@ -134,18 +145,8 @@ impl IdentityBackend for SqlBackend { Ok(user::delete(&state.db, user_id).await?) } - /// Fetch users from the database. - #[tracing::instrument(level = "debug", skip(self, state))] - async fn list_users( - &self, - state: &ServiceState, - params: &UserListParameters, - ) -> Result, IdentityProviderError> { - Ok(user::list(&state.config, &state.db, params).await?) - } - /// Get single group by ID. - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn get_group<'a>( &self, state: &ServiceState, @@ -154,8 +155,18 @@ impl IdentityBackend for SqlBackend { Ok(group::get(&state.db, group_id).await?) } + /// Get single service account by ID. + #[tracing::instrument(skip(self, state))] + async fn get_service_account<'a>( + &self, + state: &ServiceState, + user_id: &'a str, + ) -> Result, IdentityProviderError> { + Ok(service_account::get(&state.config, &state.db, user_id).await?) + } + /// Get single user by ID. - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn get_user<'a>( &self, state: &ServiceState, @@ -174,7 +185,7 @@ impl IdentityBackend for SqlBackend { } /// Find federated user by IDP and Unique ID - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn find_federated_user<'a>( &self, state: &ServiceState, @@ -190,7 +201,7 @@ impl IdentityBackend for SqlBackend { } /// List groups - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn list_groups( &self, state: &ServiceState, @@ -200,7 +211,7 @@ impl IdentityBackend for SqlBackend { } /// List groups a user is member of. - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn list_groups_of_user<'a>( &self, state: &ServiceState, @@ -217,8 +228,18 @@ impl IdentityBackend for SqlBackend { .await?) } + /// Fetch users from the database. + #[tracing::instrument(skip(self, state))] + async fn list_users( + &self, + state: &ServiceState, + params: &UserListParameters, + ) -> Result, IdentityProviderError> { + Ok(user::list(&state.config, &state.db, params).await?) + } + /// Remove the user from the group. - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn remove_user_from_group<'a>( &self, state: &ServiceState, @@ -229,7 +250,7 @@ impl IdentityBackend for SqlBackend { } /// Remove the user from the group with expiration. - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn remove_user_from_group_expiring<'a>( &self, state: &ServiceState, @@ -244,7 +265,7 @@ impl IdentityBackend for SqlBackend { } /// Remove the user from multiple groups. - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn remove_user_from_groups<'a>( &self, state: &ServiceState, @@ -255,7 +276,7 @@ impl IdentityBackend for SqlBackend { } /// Remove the user from multiple expiring groups. - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn remove_user_from_groups_expiring<'a>( &self, state: &ServiceState, @@ -270,7 +291,7 @@ impl IdentityBackend for SqlBackend { } /// Set group memberships of the user. - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn set_user_groups<'a>( &self, state: &ServiceState, @@ -281,7 +302,7 @@ impl IdentityBackend for SqlBackend { } /// Set expiring group memberships for the user. - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn set_user_groups_expiring<'a>( &self, state: &ServiceState, diff --git a/src/identity/backend/sql/authenticate.rs b/src/identity/backend/sql/authenticate.rs index 3565ba46..4a68e3a7 100644 --- a/src/identity/backend/sql/authenticate.rs +++ b/src/identity/backend/sql/authenticate.rs @@ -437,12 +437,12 @@ mod tests { ), Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "user"."id", "user"."extra", "user"."enabled", "user"."default_project_id", "user"."created_at", "user"."last_active_at", "user"."domain_id" FROM "user" WHERE "user"."id" = $1 LIMIT $2"#, + r#"SELECT "user"."created_at", "user"."default_project_id", "user"."domain_id", "user"."enabled", "user"."extra", "user"."id", "user"."last_active_at" FROM "user" WHERE "user"."id" = $1 LIMIT $2"#, ["user_id".into(), 1u64.into()] ), Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"UPDATE "user" SET "last_active_at" = $1 WHERE "user"."id" = $2 RETURNING "id", "extra", "enabled", "default_project_id", "created_at", "last_active_at", "domain_id""#, + r#"UPDATE "user" SET "last_active_at" = $1 WHERE "user"."id" = $2 RETURNING "created_at", "default_project_id", "domain_id", "enabled", "extra", "id", "last_active_at""#, [Utc::now().date_naive().into(), "user_id".into()] ), ] diff --git a/src/identity/backend/sql/federated_user/create.rs b/src/identity/backend/sql/federated_user/create.rs index fe7eec94..521e311e 100644 --- a/src/identity/backend/sql/federated_user/create.rs +++ b/src/identity/backend/sql/federated_user/create.rs @@ -19,6 +19,7 @@ use crate::db::entity::federated_user; use crate::error::DbContextExt; use crate::identity::backend::sql::IdentityDatabaseError; +#[tracing::instrument(skip_all)] pub async fn create( db: &C, federation: A, diff --git a/src/identity/backend/sql/federated_user/find.rs b/src/identity/backend/sql/federated_user/find.rs index e0b56906..d99a952c 100644 --- a/src/identity/backend/sql/federated_user/find.rs +++ b/src/identity/backend/sql/federated_user/find.rs @@ -21,6 +21,7 @@ use crate::error::DbContextExt; use crate::identity::backend::sql::IdentityDatabaseError; /// Get federated user entry by the idp_id and the unique_id. +#[tracing::instrument(skip_all)] pub async fn find_by_idp_and_unique_id, U: AsRef>( db: &DatabaseConnection, idp_id: I, diff --git a/src/identity/backend/sql/group/create.rs b/src/identity/backend/sql/group/create.rs index c2a5d805..4894c410 100644 --- a/src/identity/backend/sql/group/create.rs +++ b/src/identity/backend/sql/group/create.rs @@ -21,6 +21,7 @@ use crate::error::DbContextExt; use crate::identity::backend::sql::IdentityDatabaseError; use crate::identity::types::{Group, GroupCreate}; +#[tracing::instrument(skip_all)] pub async fn create( db: &DatabaseConnection, group: GroupCreate, diff --git a/src/identity/backend/sql/group/delete.rs b/src/identity/backend/sql/group/delete.rs index 20a1f252..db082ece 100644 --- a/src/identity/backend/sql/group/delete.rs +++ b/src/identity/backend/sql/group/delete.rs @@ -19,6 +19,7 @@ use crate::db::entity::prelude::Group as DbGroup; use crate::error::DbContextExt; use crate::identity::backend::sql::IdentityDatabaseError; +#[tracing::instrument(skip_all)] pub async fn delete>( db: &DatabaseConnection, group_id: S, diff --git a/src/identity/backend/sql/group/get.rs b/src/identity/backend/sql/group/get.rs index 308f4166..14a060fb 100644 --- a/src/identity/backend/sql/group/get.rs +++ b/src/identity/backend/sql/group/get.rs @@ -20,6 +20,7 @@ use crate::error::DbContextExt; use crate::identity::backend::sql::IdentityDatabaseError; use crate::identity::types::Group; +#[tracing::instrument(skip_all)] pub async fn get>( db: &DatabaseConnection, group_id: S, diff --git a/src/identity/backend/sql/group/list.rs b/src/identity/backend/sql/group/list.rs index 684caa65..1942f6ff 100644 --- a/src/identity/backend/sql/group/list.rs +++ b/src/identity/backend/sql/group/list.rs @@ -21,6 +21,7 @@ use crate::error::DbContextExt; use crate::identity::backend::sql::IdentityDatabaseError; use crate::identity::types::{Group, GroupListParameters}; +#[tracing::instrument(skip_all)] pub async fn list( db: &DatabaseConnection, params: &GroupListParameters, diff --git a/src/identity/backend/sql/local_user.rs b/src/identity/backend/sql/local_user.rs index a42bdff4..ebbf7e6a 100644 --- a/src/identity/backend/sql/local_user.rs +++ b/src/identity/backend/sql/local_user.rs @@ -12,14 +12,18 @@ // // SPDX-License-Identifier: Apache-2.0 -use crate::db::entity::local_user as db_local_user; +use sea_orm::entity::*; + +use crate::db::entity::{local_user as db_local_user, user as db_user}; use crate::identity::types::*; +use crate::{config::Config, identity::backend::error::IdentityDatabaseError}; mod create; mod get; mod load; mod set; +pub use create::create; pub use load::load_local_user_with_passwords; pub use load::load_local_users_passwords; pub use set::reset_failed_auth; @@ -31,6 +35,33 @@ impl UserResponseBuilder { } } +impl UserCreate { + /// Get `local_user::ActiveModel` from the `UserCreate` request. + pub(in super::super) fn to_local_user_active_model( + &self, + config: &Config, + main_record: &db_user::Model, + ) -> Result { + Ok(db_local_user::ActiveModel { + id: NotSet, + user_id: Set(main_record.id.clone()), + domain_id: Set(main_record.domain_id.clone()), + name: Set(self.name.clone()), + failed_auth_count: if main_record.enabled.is_some_and(|x| x) + && config + .security_compliance + .disable_user_account_days_inactive + .is_some() + { + Set(Some(0)) + } else { + NotSet + }, + failed_auth_at: NotSet, + }) + } +} + #[cfg(test)] pub(crate) mod tests { use chrono::Utc; @@ -78,4 +109,5 @@ pub(crate) mod tests { .map(|x| (lu.clone(), x.clone())) .collect() } + // TODO: implement test for `UserCreate::to_local_user_active_model` } diff --git a/src/identity/backend/sql/local_user/create.rs b/src/identity/backend/sql/local_user/create.rs index abd35266..537c2e0f 100644 --- a/src/identity/backend/sql/local_user/create.rs +++ b/src/identity/backend/sql/local_user/create.rs @@ -14,43 +14,26 @@ use sea_orm::ConnectionTrait; use sea_orm::entity::*; -use uuid::Uuid; use crate::config::Config; -use crate::db::entity::local_user; +use crate::db::entity::{local_user, user}; use crate::error::DbContextExt; use crate::identity::backend::sql::IdentityDatabaseError; use crate::identity::types::UserCreate; +#[tracing::instrument(skip_all)] pub async fn create( conf: &Config, db: &C, + main_record: &user::Model, user: &UserCreate, ) -> Result where C: ConnectionTrait, { - Ok(local_user::ActiveModel { - id: NotSet, - user_id: Set(user - .id - .clone() - .unwrap_or(Uuid::new_v4().simple().to_string())), - domain_id: Set(user.domain_id.clone()), - name: Set(user.name.clone()), - failed_auth_count: if user.enabled.is_some_and(|x| x) - && conf - .security_compliance - .disable_user_account_days_inactive - .is_some() - { - Set(Some(0)) - } else { - NotSet - }, - failed_auth_at: NotSet, - } - .insert(db) - .await - .context("inserting new user record")?) + Ok(user + .to_local_user_active_model(conf, main_record)? + .insert(db) + .await + .context("inserting new user record")?) } diff --git a/src/identity/backend/sql/local_user/get.rs b/src/identity/backend/sql/local_user/get.rs index 6499a55e..23176e1b 100644 --- a/src/identity/backend/sql/local_user/get.rs +++ b/src/identity/backend/sql/local_user/get.rs @@ -20,6 +20,8 @@ use crate::db::entity::{local_user, prelude::LocalUser}; use crate::error::DbContextExt; use crate::identity::backend::sql::IdentityDatabaseError; +#[allow(unused)] +#[tracing::instrument(skip_all)] pub async fn get_by_name_and_domain, D: AsRef>( db: &DatabaseConnection, name: N, @@ -33,6 +35,8 @@ pub async fn get_by_name_and_domain, D: AsRef>( .context("searching user by name and domain")?) } +#[allow(unused)] +#[tracing::instrument(skip_all)] pub async fn get_by_user_id>( db: &DatabaseConnection, user_id: U, diff --git a/src/identity/backend/sql/local_user/load.rs b/src/identity/backend/sql/local_user/load.rs index 15ef79a0..758850cd 100644 --- a/src/identity/backend/sql/local_user/load.rs +++ b/src/identity/backend/sql/local_user/load.rs @@ -25,6 +25,7 @@ use crate::error::DbContextExt; use crate::identity::backend::sql::IdentityDatabaseError; /// Load local user record with passwords from database. +#[tracing::instrument(skip_all)] pub async fn load_local_user_with_passwords, S2: AsRef, S3: AsRef>( db: &DatabaseConnection, user_id: Option, @@ -63,6 +64,7 @@ pub async fn load_local_user_with_passwords, S2: AsRef, S3: /// /// Returns vector of optional vectors with passwords in the same order as /// requested keeping None in place where local_user was empty. +#[tracing::instrument(skip_all)] pub async fn load_local_users_passwords> + std::fmt::Debug>( db: &DatabaseConnection, user_ids: L, diff --git a/src/identity/backend/sql/local_user/set.rs b/src/identity/backend/sql/local_user/set.rs index 4f6c1b62..920e6f2d 100644 --- a/src/identity/backend/sql/local_user/set.rs +++ b/src/identity/backend/sql/local_user/set.rs @@ -19,6 +19,7 @@ use crate::db::entity::local_user as db_local_user; use crate::error::DbContextExt; use crate::identity::backend::sql::IdentityDatabaseError; +#[tracing::instrument(skip_all)] pub async fn reset_failed_auth( db: &DatabaseConnection, user: &db_local_user::Model, diff --git a/src/identity/backend/sql/nonlocal_user.rs b/src/identity/backend/sql/nonlocal_user.rs index 0d442409..678dfb14 100644 --- a/src/identity/backend/sql/nonlocal_user.rs +++ b/src/identity/backend/sql/nonlocal_user.rs @@ -15,6 +15,12 @@ use crate::db::entity::nonlocal_user as db_nonlocal_user; use crate::identity::types::UserResponseBuilder; +mod create; +mod get; + +pub use create::create; +pub use get::*; + impl UserResponseBuilder { pub fn merge_nonlocal_user_data(&mut self, data: &db_nonlocal_user::Model) -> &mut Self { self.name(data.name.clone()); diff --git a/src/identity/backend/sql/nonlocal_user/create.rs b/src/identity/backend/sql/nonlocal_user/create.rs new file mode 100644 index 00000000..ecd8de3f --- /dev/null +++ b/src/identity/backend/sql/nonlocal_user/create.rs @@ -0,0 +1,83 @@ +// 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 sea_orm::ConnectionTrait; +use sea_orm::entity::*; + +use crate::db::entity::{nonlocal_user, user}; +use crate::error::DbContextExt; +use crate::identity::backend::sql::IdentityDatabaseError; + +/// Persist nonlocal user entry. +#[tracing::instrument(skip_all)] +pub async fn create( + db: &C, + main_record: &user::Model, + name: S, +) -> Result +where + C: ConnectionTrait, + S: Into, +{ + Ok(nonlocal_user::ActiveModel { + user_id: Set(main_record.id.clone()), + domain_id: Set(main_record.domain_id.clone()), + name: Set(name.into()), + } + .insert(db) + .await + .context("inserting new nonlocal user record")?) +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase, Transaction}; + + use super::*; + + #[tokio::test] + async fn test_create() { + // Create MockDatabase with mock query results + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![nonlocal_user::Model { + domain_id: "did".into(), + name: "uname".into(), + user_id: "1".into(), + }]]) + .into_connection(); + + let usr = user::Model { + id: "1".into(), + domain_id: "did".into(), + ..Default::default() + }; + assert_eq!( + create(&db, &usr, "uname").await.unwrap(), + nonlocal_user::Model { + domain_id: "did".into(), + name: "uname".into(), + user_id: "1".into(), + } + ); + // Checking transaction log + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"INSERT INTO "nonlocal_user" ("domain_id", "name", "user_id") VALUES ($1, $2, $3) RETURNING "domain_id", "name", "user_id""#, + ["did".into(), "uname".into(), "1".into(),] + ),] + ); + } +} diff --git a/src/identity/backend/sql/nonlocal_user/get.rs b/src/identity/backend/sql/nonlocal_user/get.rs new file mode 100644 index 00000000..7498f272 --- /dev/null +++ b/src/identity/backend/sql/nonlocal_user/get.rs @@ -0,0 +1,48 @@ +// 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 sea_orm::DatabaseConnection; +use sea_orm::entity::*; +use sea_orm::query::*; + +use crate::db::entity::{nonlocal_user, prelude::NonlocalUser}; +use crate::error::DbContextExt; +use crate::identity::backend::sql::IdentityDatabaseError; + +#[allow(unused)] +#[tracing::instrument(skip_all)] +pub async fn get_by_name_and_domain, D: AsRef>( + db: &DatabaseConnection, + name: N, + domain_id: D, +) -> Result, IdentityDatabaseError> { + Ok(NonlocalUser::find() + .filter(nonlocal_user::Column::Name.eq(name.as_ref())) + .filter(nonlocal_user::Column::DomainId.eq(domain_id.as_ref())) + .one(db) + .await + .context("searching nonlocal user by name and domain")?) +} + +#[tracing::instrument(skip_all)] +pub async fn get_by_user_id>( + db: &DatabaseConnection, + user_id: U, +) -> Result, IdentityDatabaseError> { + Ok(NonlocalUser::find() + .filter(nonlocal_user::Column::UserId.eq(user_id.as_ref())) + .one(db) + .await + .context("fetching the nonlocal user by ID")?) +} diff --git a/src/identity/backend/sql/password.rs b/src/identity/backend/sql/password.rs index 2a5efc05..b15781b8 100644 --- a/src/identity/backend/sql/password.rs +++ b/src/identity/backend/sql/password.rs @@ -21,11 +21,10 @@ mod create; pub use create::create; -/// Verify whether the password is expired or not. +/// Verify whether the password has expired or not. pub(super) fn is_password_expired( password_entry: &db_password::Model, ) -> Result { - //if let Some(expires_et) if let Some(expires) = password_entry .expires_at_int .and_then(DateTime::from_timestamp_secs) @@ -55,12 +54,26 @@ impl UserResponseBuilder { } #[cfg(test)] -pub(super) mod tests { +pub(crate) mod tests { use crate::db::entity::password as db_password; use chrono::{DateTime, TimeDelta, Utc}; use super::*; + pub fn get_password_mock(user_id: i32) -> db_password::Model { + let datetime = Utc::now(); + db_password::Model { + id: user_id.clone(), + local_user_id: user_id, + self_service: false, + expires_at: None, + password_hash: Some("fake_hash".into()), + created_at: datetime.naive_utc(), + created_at_int: datetime.naive_utc().and_utc().timestamp_micros(), + expires_at_int: None, + } + } + impl db_password::ModelBuilder { pub fn expires(&mut self, value: DateTime) -> &mut Self { self.expires_at_int(value.timestamp()) diff --git a/src/identity/backend/sql/password/create.rs b/src/identity/backend/sql/password/create.rs index 8c6d2a50..09e68c31 100644 --- a/src/identity/backend/sql/password/create.rs +++ b/src/identity/backend/sql/password/create.rs @@ -20,6 +20,7 @@ use crate::db::entity::password; use crate::error::DbContextExt; use crate::identity::backend::sql::IdentityDatabaseError; +#[tracing::instrument(skip_all)] pub async fn create>( db: &C, local_user_id: i32, diff --git a/src/identity/backend/sql/service_account.rs b/src/identity/backend/sql/service_account.rs new file mode 100644 index 00000000..bb50e164 --- /dev/null +++ b/src/identity/backend/sql/service_account.rs @@ -0,0 +1,19 @@ +// 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 + +mod create; +mod get; + +pub use create::create; +pub use get::get; diff --git a/src/identity/backend/sql/service_account/create.rs b/src/identity/backend/sql/service_account/create.rs new file mode 100644 index 00000000..9367aeb7 --- /dev/null +++ b/src/identity/backend/sql/service_account/create.rs @@ -0,0 +1,152 @@ +// 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 chrono::{DateTime, Utc}; +use sea_orm::DatabaseConnection; +use sea_orm::TransactionTrait; +use sea_orm::entity::*; + +use crate::config::Config; +use crate::error::DbContextExt; +use crate::identity::{ + backend::sql::{IdentityDatabaseError, nonlocal_user, user_option}, + types::*, +}; + +/// Create a service account. +/// +/// Create a structure of the Keystone user representing the service account. +/// Comprise of: +/// - `user` table entry with no options. +/// - `nonlocal_user` table entry. +#[tracing::instrument(skip_all)] +pub async fn create( + conf: &Config, + db: &DatabaseConnection, + sa: ServiceAccountCreate, + created_at: Option>, +) -> Result { + let txn = db + .begin() + .await + .context("starting transaction for persisting service account")?; + + let main_entry = sa + .to_user_active_model(conf, created_at)? + .insert(&txn) + .await + .context("inserting main user for the service account entry")?; + + let nlu_entry = nonlocal_user::create(&txn, &main_entry, sa.name.clone()).await?; + + user_option::create( + &txn, + main_entry.id.clone(), + &UserOptions { + is_service_account: Some(true), + ..Default::default() + }, + ) + .await?; + + txn.commit() + .await + .context("committing the user creation transaction")?; + + Ok(ServiceAccount { + domain_id: main_entry.domain_id, + enabled: sa.enabled.unwrap_or(true), + id: main_entry.id, + name: nlu_entry.name, + }) +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult, Statement, Transaction}; + + use super::*; + use crate::db::entity::{nonlocal_user, user}; + + #[tokio::test] + async fn test_create() { + // Create MockDatabase with mock query results + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![user::Model { + id: "1".into(), + domain_id: "did".into(), + enabled: Some(true), + ..Default::default() + }]]) + .append_query_results([vec![nonlocal_user::Model { + domain_id: "did".into(), + name: "sa_foo".into(), + user_id: "1".into(), + }]]) + .append_exec_results([MockExecResult { + rows_affected: 1, + ..Default::default() + }]) + .into_connection(); + + let now = Utc::now(); + let req = ServiceAccountCreate { + id: Some("1".into()), + domain_id: "did".into(), + name: "sa_foo".into(), + enabled: Some(true), + }; + assert_eq!( + create(&Config::default(), &db, req, Some(now)) + .await + .unwrap(), + ServiceAccount { + domain_id: "did".into(), + enabled: true, + id: "1".into(), + name: "sa_foo".into() + } + ); + + // Checking transaction log + assert_eq!( + db.into_transaction_log(), + [Transaction::many(vec![ + Statement::from_string(DatabaseBackend::Postgres, r#"BEGIN"#,), + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + r#"INSERT INTO "user" ("created_at", "domain_id", "enabled", "extra", "id") VALUES ($1, $2, $3, $4, $5) RETURNING "created_at", "default_project_id", "domain_id", "enabled", "extra", "id", "last_active_at""#, + [ + now.naive_utc().into(), + "did".into(), + true.into(), + "{}".into(), + "1".into(), + ] + ), + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + r#"INSERT INTO "nonlocal_user" ("domain_id", "name", "user_id") VALUES ($1, $2, $3) RETURNING "domain_id", "name", "user_id""#, + ["did".into(), "sa_foo".into(), "1".into()] + ), + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + r#"INSERT INTO "user_option" ("user_id", "option_id", "option_value") VALUES ($1, $2, $3) RETURNING "user_id", "option_id""#, + ["1".into(), "ISSA".into(), "true".into()] + ), + Statement::from_string(DatabaseBackend::Postgres, r#"COMMIT"#,) + ]),] + ); + } +} diff --git a/src/identity/backend/sql/service_account/get.rs b/src/identity/backend/sql/service_account/get.rs new file mode 100644 index 00000000..04f21e7d --- /dev/null +++ b/src/identity/backend/sql/service_account/get.rs @@ -0,0 +1,101 @@ +// 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 sea_orm::DatabaseConnection; + +use super::super::nonlocal_user; +use super::super::user; +use super::super::user_option; +use crate::config::Config; +use crate::identity::backend::sql::IdentityDatabaseError; +use crate::identity::types::*; + +#[tracing::instrument(skip_all)] +pub async fn get( + conf: &Config, + db: &DatabaseConnection, + user_id: U, +) -> Result, IdentityDatabaseError> +where + U: AsRef, +{ + let (main_row_handle, nl_user_handle, user_opts_handle) = tokio::join!( + user::get_main_entry(db, user_id.as_ref()), + nonlocal_user::get_by_user_id(db, user_id.as_ref()), + user_option::list_by_user_id(db, user_id.as_ref()), + ); + + let user_opts = user_opts_handle?; + if !user_opts.is_service_account.is_some_and(|x| x) { + return Ok(None); + } + + let mut sa_builder = ServiceAccountBuilder::default(); + if let (Some(main), Some(nl)) = (main_row_handle?, nl_user_handle?) { + sa_builder.domain_id(main.domain_id); + let last_activity_cutof_date = conf.security_compliance.get_user_last_activity_cutof_date(); + // TODO: This is the same logic as in the `UserResponseBuilder::merge_user_data` + // and must be reused. + sa_builder.enabled(if main.enabled.is_some_and(|val| val) { + if let (Some(last_active_at), Some(cutoff)) = + (&main.last_active_at, &last_activity_cutof_date) + { + user_opts.ignore_user_inactivity.is_some_and(|val| val) || last_active_at > cutoff + } else { + // Either last_active_at or cutoff date empty - user is active + true + } + } else { + false + }); + sa_builder.id(main.id); + sa_builder.name(nl.name); + return Ok(Some(sa_builder.build()?)); + } + + Ok(None) +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase}; + + use super::*; + use crate::config::Config; + use crate::identity::backend::sql::nonlocal_user::tests::get_nonlocal_user_mock; + use crate::identity::backend::sql::user::tests::get_user_mock; + use crate::identity::backend::sql::user_option::tests::get_user_options_mock; + + #[tokio::test] + async fn test_get() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_user_mock("1")]]) + .append_query_results([vec![get_nonlocal_user_mock("1")]]) + .append_query_results([get_user_options_mock( + "1", + &UserOptions { + is_service_account: Some(true), + ..Default::default() + }, + )]) + .into_connection(); + + let sot = get(&Config::default(), &db, "1") + .await + .unwrap() + .expect("must be something"); + + assert_eq!("1", sot.id); + } +} diff --git a/src/identity/backend/sql/user.rs b/src/identity/backend/sql/user.rs index 66766db3..487f7c80 100644 --- a/src/identity/backend/sql/user.rs +++ b/src/identity/backend/sql/user.rs @@ -12,12 +12,15 @@ // // SPDX-License-Identifier: Apache-2.0 -use chrono::NaiveDate; -use serde_json::Value; +use chrono::{DateTime, NaiveDate, Utc}; +use sea_orm::entity::*; +use serde_json::{Value, json}; use tracing::error; +use uuid::Uuid; use crate::db::entity::user as db_user; use crate::identity::types::*; +use crate::{config::Config, identity::backend::error::IdentityDatabaseError}; mod create; mod delete; @@ -87,6 +90,70 @@ impl UserResponseBuilder { } } +impl UserCreate { + /// Get `user::ActiveModel` from the `UserCreate` request. + pub(super) fn to_user_active_model( + &self, + config: &Config, + created_at: Option>, + ) -> Result { + let created_at = created_at.unwrap_or_else(Utc::now).naive_utc(); + + Ok(db_user::ActiveModel { + id: Set(self + .id + .clone() + .unwrap_or(Uuid::new_v4().simple().to_string())), + enabled: Set(Some(self.enabled.unwrap_or(true))), + extra: Set(Some(serde_json::to_string( + // For keystone it is important to have at least "{}" + &self.extra.as_ref().or(Some(&json!({}))), + )?)), + default_project_id: self + .default_project_id + .clone() + .map(Set) + .unwrap_or(NotSet) + .into(), + // Set last_active to now if compliance disabling is on + last_active_at: get_user_last_active_at(config, self.enabled, created_at) + .map(Set) + .unwrap_or(NotSet) + .into(), + created_at: Set(Some(created_at)), + domain_id: Set(self.domain_id.clone()), + }) + } +} + +impl ServiceAccountCreate { + /// Get a `db_user::ActiveModel` from the `ServiceAccountCreate` request. + pub(super) fn to_user_active_model( + &self, + conf: &Config, + created_at: Option>, + ) -> Result { + let created_at = created_at.unwrap_or_else(Utc::now).naive_utc(); + + Ok(db_user::ActiveModel { + id: Set(self + .id + .clone() + .unwrap_or(Uuid::new_v4().simple().to_string())), + enabled: Set(Some(self.enabled.unwrap_or(true))), + extra: Set(Some("{}".to_string())), + default_project_id: NotSet, + // Set last_active to now if compliance disabling is on + last_active_at: get_user_last_active_at(conf, self.enabled, created_at) + .map(Set) + .unwrap_or(NotSet) + .into(), + created_at: Set(Some(created_at)), + domain_id: Set(self.domain_id.clone()), + }) + } +} + #[cfg(test)] pub(super) mod tests { use chrono::{DateTime, Utc}; @@ -232,4 +299,60 @@ pub(super) mod tests { "last active in the past and cutof now with exempt is enabled" ); } + + #[test] + fn test_active_record_from_user_create() { + let now = Utc::now(); + let req = UserCreateBuilder::default() + .default_project_id("dpid") + .domain_id("did") + .id("1") + .name("foo") + .enabled(true) + .build() + .unwrap(); + let cfg = Config::default(); + let sot = req.to_user_active_model(&cfg, Some(now)).unwrap(); + assert_eq!(sot.default_project_id, Set(Some("dpid".into()))); + assert_eq!(sot.domain_id, Set("did".into())); + assert_eq!(sot.enabled, Set(Some(true))); + assert_eq!(sot.extra, Set(Some("{}".into()))); + assert_eq!(sot.id, Set("1".into())); + assert_eq!(sot.last_active_at, NotSet); + } + + #[test] + fn test_active_record_from_user_create_track_user_activity() { + let now = Utc::now(); + let req = UserCreateBuilder::default() + .domain_id("did") + .id("1") + .name("foo") + .enabled(true) + .build() + .unwrap(); + let mut cfg = Config::default(); + cfg.security_compliance.disable_user_account_days_inactive = Some(1); + let sot = req.to_user_active_model(&cfg, Some(now)).unwrap(); + assert_eq!(sot.last_active_at, Set(Some(now.naive_utc().date()))); + } + + #[test] + fn test_active_record_from_sa_create() { + let now = Utc::now(); + let req = ServiceAccountCreate { + domain_id: "did".into(), + enabled: Some(true), + id: Some("said".into()), + name: "sa_name".into(), + }; + let cfg = Config::default(); + let sot = req.to_user_active_model(&cfg, Some(now)).unwrap(); + assert_eq!(sot.default_project_id, NotSet); + assert_eq!(sot.domain_id, Set("did".into())); + assert_eq!(sot.enabled, Set(Some(true))); + assert_eq!(sot.extra, Set(Some("{}".into()))); + assert_eq!(sot.id, Set("said".into())); + assert_eq!(sot.last_active_at, NotSet); + } } diff --git a/src/identity/backend/sql/user/create.rs b/src/identity/backend/sql/user/create.rs index d18a718c..508d4e56 100644 --- a/src/identity/backend/sql/user/create.rs +++ b/src/identity/backend/sql/user/create.rs @@ -12,70 +12,43 @@ // // SPDX-License-Identifier: Apache-2.0 -use chrono::Local; +use chrono::{DateTime, Utc}; use sea_orm::DatabaseConnection; use sea_orm::entity::*; use sea_orm::{ConnectionTrait, TransactionTrait}; -use serde_json::json; -use uuid::Uuid; use crate::common::password_hashing; use crate::config::Config; use crate::db::entity::{ - federated_user as db_federated_user, local_user as db_local_user, password as db_password, - user as db_user, + federated_user as db_federated_user, password as db_password, user as db_user, }; use crate::error::DbContextExt; use crate::identity::backend::sql::IdentityDatabaseError; use crate::identity::types::*; use super::super::federated_user; +use super::super::local_user; use super::super::password; +use super::super::user_option; -async fn create_main( +#[tracing::instrument(skip_all)] +pub async fn create_main( conf: &Config, db: &C, user: &UserCreate, + created_at: Option>, ) -> Result where C: ConnectionTrait, { - let now = Local::now().naive_utc(); - // Set last_active to now if compliance disabling is on - let last_active_at = if let Some(true) = &user.enabled { - if conf - .security_compliance - .disable_user_account_days_inactive - .is_some() - { - Set(Some(now.date())) - } else { - NotSet - } - } else { - NotSet - }; - - Ok(db_user::ActiveModel { - id: Set(user - .id - .clone() - .unwrap_or(Uuid::new_v4().simple().to_string())), - enabled: Set(user.enabled), - extra: Set(Some(serde_json::to_string( - // For keystone it is important to have at least "{}" - &user.extra.as_ref().or(Some(&json!({}))), - )?)), - default_project_id: Set(user.default_project_id.clone()), - last_active_at, - created_at: Set(Some(now)), - domain_id: Set(user.domain_id.clone()), - } - .insert(db) - .await - .context("inserting user entry")?) + Ok(user + .to_user_active_model(conf, created_at)? + .insert(db) + .await + .context("inserting user entry")?) } +#[tracing::instrument(skip(conf, db))] pub async fn create( conf: &Config, db: &DatabaseConnection, @@ -87,9 +60,21 @@ pub async fn create( .begin() .await .context("starting transaction for persisting user")?; - let main_user = create_main(conf, &txn, &user).await?; + + let now = Utc::now(); + let main_user = create_main(conf, &txn, &user, Some(now)).await?; + if let Some(opts) = &user.options { + // Persist user options when passed + user_option::create(&txn, main_user.id.clone(), opts).await?; + } + let mut response_builder = UserResponseBuilder::default(); - response_builder.merge_user_data(&main_user, &UserOptions::default(), None); + response_builder.merge_user_data( + &main_user, + user.options.as_ref().unwrap_or(&UserOptions::default()), + None, + ); + if let Some(federation_data) = &user.federated { let mut federated_entities: Vec = Vec::new(); for federated_user in federation_data { @@ -110,6 +95,7 @@ pub async fn create( ); } else { for proto in &federated_user.protocols { + //for proto in &federated_user.protocol_ids { federated_entities.push( federated_user::create( &txn, @@ -118,7 +104,7 @@ pub async fn create( user_id: Set(main_user.id.clone()), idp_id: Set(federated_user.idp_id.clone()), protocol_id: Set(proto.protocol_id.clone()), - unique_id: Set(proto.unique_id.clone()), + unique_id: Set(federated_user.unique_id.clone()), display_name: Set(Some(user.name.clone())), }, ) @@ -127,33 +113,16 @@ pub async fn create( } } } - response_builder.merge_federated_user_data(federated_entities); } else { - // Local user - let local_user = db_local_user::ActiveModel { - id: NotSet, - user_id: Set(main_user.id.clone()), - domain_id: Set(user.domain_id.clone()), - name: Set(user.name.clone()), - failed_auth_count: if user.enabled.is_some_and(|x| x) - && conf - .security_compliance - .disable_user_account_days_inactive - .is_some() - { - Set(Some(0)) - } else { - NotSet - }, - failed_auth_at: NotSet, - } - .insert(&txn) - .await - .context("inserting new user record")?; + // When the user is not a federated one we can only assume it is a local user. + // For creating nonlocal user or service account dedicated API should be + // used. + let local_user = local_user::create(conf, &txn, &main_user, &user).await?; + response_builder.merge_local_user_data(&local_user); - let mut passwords: Vec = Vec::new(); if let Some(password) = &user.password { + let mut passwords: Vec = Vec::new(); let password_entry = password::create( &txn, local_user.id, @@ -163,11 +132,10 @@ pub async fn create( .await?; passwords.push(password_entry); + response_builder.merge_passwords_data(passwords); } - response_builder - .merge_local_user_data(&local_user) - .merge_passwords_data(passwords); } + txn.commit() .await .context("committing the user creation transaction")?; @@ -177,6 +145,168 @@ pub async fn create( #[cfg(test)] mod tests { - // use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult, - // Transaction}; + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult, Transaction}; + + use super::*; + use crate::config::Config; + use crate::identity::backend::sql::{ + federated_user::tests::get_federated_user_mock, local_user::tests::get_local_user_mock, + password::tests::get_password_mock, user::tests::get_user_mock, + }; + + #[tokio::test] + async fn test_create_main() { + let sot_db_res = db_user::Model { + id: "1".into(), + domain_id: "did".into(), + enabled: Some(true), + ..Default::default() + }; + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![sot_db_res.clone()]]) + .into_connection(); + + let now = Utc::now(); + let req = UserCreateBuilder::default() + .default_project_id("dpid") + .domain_id("did") + .id("1") + .name("foo") + .enabled(true) + .build() + .unwrap(); + assert_eq!( + create_main(&Config::default(), &db, &req, Some(now)) + .await + .unwrap(), + sot_db_res + ); + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"INSERT INTO "user" ("created_at", "default_project_id", "domain_id", "enabled", "extra", "id") VALUES ($1, $2, $3, $4, $5, $6) RETURNING "created_at", "default_project_id", "domain_id", "enabled", "extra", "id", "last_active_at""#, + [ + now.naive_utc().into(), + "dpid".into(), + "did".into(), + true.into(), + "{}".into(), + "1".into(), + ] + ),] + ); + } + + #[tokio::test] + async fn test_create_main_disable_inactivity_tracking() { + let sot_db_res = db_user::Model { + id: "1".into(), + domain_id: "did".into(), + enabled: Some(true), + ..Default::default() + }; + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![sot_db_res.clone()]]) + .into_connection(); + + let now = Utc::now(); + let req = UserCreateBuilder::default() + .default_project_id("dpid") + .domain_id("did") + .id("1") + .name("foo") + .enabled(true) + .build() + .unwrap(); + let mut cfg = Config::default(); + cfg.security_compliance.disable_user_account_days_inactive = Some(1); + assert_eq!( + create_main(&cfg, &db, &req, Some(now)).await.unwrap(), + sot_db_res + ); + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"INSERT INTO "user" ("created_at", "default_project_id", "domain_id", "enabled", "extra", "id", "last_active_at") VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING "created_at", "default_project_id", "domain_id", "enabled", "extra", "id", "last_active_at""#, + [ + now.naive_utc().into(), + "dpid".into(), + "did".into(), + true.into(), + "{}".into(), + "1".into(), + now.naive_utc().date().into(), + ] + ),] + ); + } + + #[tokio::test] + async fn test_create_federated() { + let user_opts = UserOptions { + ignore_password_expiry: Some(true), + ..Default::default() + }; + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_user_mock("1")]]) + .append_exec_results([MockExecResult { + rows_affected: 1, + ..Default::default() + }]) + .append_query_results([vec![get_federated_user_mock("1")]]) + .into_connection(); + let mut federation_data = FederationBuilder::default(); + federation_data + .idp_id("idp_id") + .unique_id("unique_id") + .protocols(vec![FederationProtocol { + protocol_id: "oidc".into(), + unique_id: "unique_id".into(), + }]); + let req = UserCreateBuilder::default() + .default_project_id("dpid") + .domain_id("did") + .id("1") + .name("foo") + .enabled(true) + .federated(vec![federation_data.build().unwrap()]) + .options(user_opts.clone()) + .build() + .unwrap(); + let sot = create(&Config::default(), &db, req).await.unwrap(); + assert_eq!(sot.name, "foo"); + assert_eq!(sot.options, user_opts); + } + + #[tokio::test] + async fn test_create_local() { + let user_opts = UserOptions { + ignore_password_expiry: Some(true), + ..Default::default() + }; + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_user_mock("1")]]) + .append_exec_results([MockExecResult { + rows_affected: 1, + ..Default::default() + }]) + .append_query_results([vec![get_local_user_mock("1")]]) + .append_query_results([vec![get_password_mock(1)]]) + .into_connection(); + let req = UserCreateBuilder::default() + .default_project_id("dpid") + .domain_id("did") + .id("1") + .name("foo") + .enabled(true) + .password("foobar") + .options(user_opts.clone()) + .build() + .unwrap(); + let sot = create(&Config::default(), &db, req).await.unwrap(); + assert_eq!(sot.name, "foo_domain"); + assert_eq!(sot.options, user_opts); + } } diff --git a/src/identity/backend/sql/user/delete.rs b/src/identity/backend/sql/user/delete.rs index 83fa1b16..af39b813 100644 --- a/src/identity/backend/sql/user/delete.rs +++ b/src/identity/backend/sql/user/delete.rs @@ -19,6 +19,7 @@ use crate::db::entity::prelude::User as DbUser; use crate::error::DbContextExt; use crate::identity::backend::sql::IdentityDatabaseError; +#[tracing::instrument(skip_all)] pub async fn delete>( db: &DatabaseConnection, user_id: U, diff --git a/src/identity/backend/sql/user/get.rs b/src/identity/backend/sql/user/get.rs index 7b18a5b6..16205d6e 100644 --- a/src/identity/backend/sql/user/get.rs +++ b/src/identity/backend/sql/user/get.rs @@ -28,6 +28,7 @@ use crate::identity::backend::sql::IdentityDatabaseError; use crate::identity::types::*; /// Get the `user` table entry by the `user_id`. +#[tracing::instrument(skip_all)] pub async fn get_main_entry>( db: &DatabaseConnection, user_id: U, @@ -38,6 +39,7 @@ pub async fn get_main_entry>( .context("fetching user by ID")?) } +#[tracing::instrument(skip_all)] pub async fn get( conf: &Config, db: &DatabaseConnection, @@ -101,6 +103,7 @@ pub async fn get( } /// Get the `domain_id` of the user specified by the `user_id`. +#[tracing::instrument(skip_all)] pub async fn get_user_domain_id>( db: &DatabaseConnection, user_id: U, @@ -145,7 +148,7 @@ mod tests { db.into_transaction_log(), [Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "user"."id", "user"."extra", "user"."enabled", "user"."default_project_id", "user"."created_at", "user"."last_active_at", "user"."domain_id" FROM "user" WHERE "user"."id" = $1 LIMIT $2"#, + r#"SELECT "user"."created_at", "user"."default_project_id", "user"."domain_id", "user"."enabled", "user"."extra", "user"."id", "user"."last_active_at" FROM "user" WHERE "user"."id" = $1 LIMIT $2"#, ["1".into(), 1u64.into()] ),] ); @@ -184,7 +187,10 @@ mod tests { ignore_change_password_upon_first_use: Some(true), ..Default::default() }, - ..Default::default() + default_project_id: None, + extra: None, + federated: None, + password_expires_at: None } ); @@ -194,7 +200,7 @@ mod tests { [ Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "user"."id", "user"."extra", "user"."enabled", "user"."default_project_id", "user"."created_at", "user"."last_active_at", "user"."domain_id" FROM "user" WHERE "user"."id" = $1 LIMIT $2"#, + r#"SELECT "user"."created_at", "user"."default_project_id", "user"."domain_id", "user"."enabled", "user"."extra", "user"."id", "user"."last_active_at" FROM "user" WHERE "user"."id" = $1 LIMIT $2"#, ["1".into(), 1u64.into()] ), Transaction::from_sql_and_values( diff --git a/src/identity/backend/sql/user/list.rs b/src/identity/backend/sql/user/list.rs index e4b75581..cc08d995 100644 --- a/src/identity/backend/sql/user/list.rs +++ b/src/identity/backend/sql/user/list.rs @@ -35,6 +35,7 @@ use crate::identity::types::*; /// `federated_user`, `user_option` entries merging results to the proper entry. /// For the local users additionally passwords are being retrieved to identify /// the password expiration date. +#[tracing::instrument(skip_all)] pub async fn list( conf: &Config, db: &DatabaseConnection, @@ -202,7 +203,7 @@ mod tests { for (l,r) in db.into_transaction_log().iter().zip([ Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "user"."id", "user"."extra", "user"."enabled", "user"."default_project_id", "user"."created_at", "user"."last_active_at", "user"."domain_id" FROM "user""#, + r#"SELECT "user"."created_at", "user"."default_project_id", "user"."domain_id", "user"."enabled", "user"."extra", "user"."id", "user"."last_active_at" FROM "user""#, [] ), Transaction::from_sql_and_values( @@ -273,7 +274,7 @@ mod tests { for (l,r) in db.into_transaction_log().iter().zip([ Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "user"."id", "user"."extra", "user"."enabled", "user"."default_project_id", "user"."created_at", "user"."last_active_at", "user"."domain_id" FROM "user""#, + r#"SELECT "user"."created_at", "user"."default_project_id", "user"."domain_id", "user"."enabled", "user"."extra", "user"."id", "user"."last_active_at" FROM "user""#, [] ), Transaction::from_sql_and_values( @@ -335,7 +336,7 @@ mod tests { for (l,r) in db.into_transaction_log().iter().zip([ Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "user"."id", "user"."extra", "user"."enabled", "user"."default_project_id", "user"."created_at", "user"."last_active_at", "user"."domain_id" FROM "user""#, + r#"SELECT "user"."created_at", "user"."default_project_id", "user"."domain_id", "user"."enabled", "user"."extra", "user"."id", "user"."last_active_at" FROM "user""#, [] ), Transaction::from_sql_and_values( @@ -391,7 +392,7 @@ mod tests { for (l,r) in db.into_transaction_log().iter().zip([ Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "user"."id", "user"."extra", "user"."enabled", "user"."default_project_id", "user"."created_at", "user"."last_active_at", "user"."domain_id" FROM "user""#, + r#"SELECT "user"."created_at", "user"."default_project_id", "user"."domain_id", "user"."enabled", "user"."extra", "user"."id", "user"."last_active_at" FROM "user""#, [] ), Transaction::from_sql_and_values( diff --git a/src/identity/backend/sql/user/set.rs b/src/identity/backend/sql/user/set.rs index 49fe34c4..e11adaf2 100644 --- a/src/identity/backend/sql/user/set.rs +++ b/src/identity/backend/sql/user/set.rs @@ -22,6 +22,7 @@ use crate::error::DbContextExt; use crate::identity::backend::sql::IdentityDatabaseError; /// Reset the `user.last_active_at` to the current date. +#[tracing::instrument(skip_all)] pub async fn reset_last_active( db: &DatabaseConnection, user: &db_user::Model, @@ -58,7 +59,7 @@ mod tests { db.into_transaction_log(), [Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"UPDATE "user" SET "last_active_at" = $1 WHERE "user"."id" = $2 RETURNING "id", "extra", "enabled", "default_project_id", "created_at", "last_active_at", "domain_id""#, + r#"UPDATE "user" SET "last_active_at" = $1 WHERE "user"."id" = $2 RETURNING "created_at", "default_project_id", "domain_id", "enabled", "extra", "id", "last_active_at""#, [Utc::now().date_naive().into(), "user_id".into()] ),] ); diff --git a/src/identity/backend/sql/user_group/add.rs b/src/identity/backend/sql/user_group/add.rs index f10e2d20..1a1513f1 100644 --- a/src/identity/backend/sql/user_group/add.rs +++ b/src/identity/backend/sql/user_group/add.rs @@ -25,6 +25,7 @@ use crate::error::DbContextExt; use crate::identity::backend::sql::IdentityDatabaseError; /// Add the user to the single group. +#[tracing::instrument(skip_all)] pub async fn add_user_to_group, G: AsRef>( db: &DatabaseConnection, user_id: U, @@ -43,6 +44,7 @@ pub async fn add_user_to_group, G: AsRef>( /// Add group user relations as specified by the tuples (user_id, group_id) /// iterator. +#[tracing::instrument(skip_all)] pub async fn add_users_to_groups( db: &DatabaseConnection, iter: I, @@ -67,6 +69,7 @@ where } /// Add the user to the single group with expiring membership. +#[tracing::instrument(skip_all)] pub async fn add_user_to_group_expiring, G: AsRef, IDP: AsRef>( db: &DatabaseConnection, user_id: U, @@ -89,6 +92,7 @@ pub async fn add_user_to_group_expiring, G: AsRef, IDP: AsRef /// Add expiring group user relations as specified by the tuples (user_id, /// group_id) iterator. +#[tracing::instrument(skip_all)] pub async fn add_users_to_groups_expiring( db: &DatabaseConnection, iter: I, diff --git a/src/identity/backend/sql/user_group/list.rs b/src/identity/backend/sql/user_group/list.rs index 59cad8d6..bfef79bb 100644 --- a/src/identity/backend/sql/user_group/list.rs +++ b/src/identity/backend/sql/user_group/list.rs @@ -29,6 +29,7 @@ use crate::identity::types::Group; /// /// Selects all groups with the ID in the list of user group memberships and /// expiring group memberships. +#[tracing::instrument(skip_all)] pub async fn list_user_groups>( db: &DatabaseConnection, user_id: S, diff --git a/src/identity/backend/sql/user_group/remove.rs b/src/identity/backend/sql/user_group/remove.rs index 6ae7f9e5..a7edd8eb 100644 --- a/src/identity/backend/sql/user_group/remove.rs +++ b/src/identity/backend/sql/user_group/remove.rs @@ -25,6 +25,7 @@ use crate::error::DbContextExt; use crate::identity::backend::sql::IdentityDatabaseError; /// Remove the user from the group. +#[tracing::instrument(skip_all)] pub async fn remove_user_from_group, G: AsRef>( db: &DatabaseConnection, user_id: U, @@ -39,6 +40,7 @@ pub async fn remove_user_from_group, G: AsRef>( } /// Remove the user from multiple groups. +#[tracing::instrument(skip_all)] pub async fn remove_user_from_groups( db: &DatabaseConnection, user_id: U, @@ -66,6 +68,7 @@ where } /// Remove the user from the group with expiration. +#[tracing::instrument(skip_all)] pub async fn remove_user_from_group_expiring, G: AsRef, IDP: AsRef>( db: &DatabaseConnection, user_id: U, @@ -85,6 +88,7 @@ pub async fn remove_user_from_group_expiring, G: AsRef, IDP: } /// Remove the user from multiple groups. +#[tracing::instrument(skip_all)] pub async fn remove_user_from_groups_expiring( db: &DatabaseConnection, user_id: U, diff --git a/src/identity/backend/sql/user_group/set.rs b/src/identity/backend/sql/user_group/set.rs index 5b5c5740..2ccbc31e 100644 --- a/src/identity/backend/sql/user_group/set.rs +++ b/src/identity/backend/sql/user_group/set.rs @@ -34,6 +34,7 @@ use super::*; /// Add user to the groups it should be in and remove from the groups where the /// user is currently member of, but should not be. This is only incremental /// operation and is not deleting group membership where the user should stay. +#[tracing::instrument(skip_all)] pub async fn set_user_groups( db: &DatabaseConnection, user_id: U, @@ -90,6 +91,7 @@ where /// Add user to the groups it should be in and remove from the groups where the /// user is currently member of, but should not be. This is only incremental /// operation and is not deleting group membership where the user should stay. +#[tracing::instrument(skip_all)] pub async fn set_user_groups_expiring( db: &DatabaseConnection, user_id: U, diff --git a/src/identity/backend/sql/user_option.rs b/src/identity/backend/sql/user_option.rs index 2aee18bf..88d88391 100644 --- a/src/identity/backend/sql/user_option.rs +++ b/src/identity/backend/sql/user_option.rs @@ -13,11 +13,13 @@ // SPDX-License-Identifier: Apache-2.0 use crate::db::entity::user_option; -use crate::identity::error::IdentityProviderError; +use crate::identity::backend::sql::IdentityDatabaseError; use crate::identity::types::*; +mod create; mod list; +pub use create::create; pub use list::list_by_user_id; impl FromIterator for UserOptions { @@ -37,12 +39,18 @@ impl FromIterator for UserOptions { ("1003", Some(val)) => { user_opts.lock_password = val.parse().ok(); } + ("1004", Some(val)) => { + user_opts.ignore_user_inactivity = val.parse().ok(); + } ("MFAR", Some(val)) => { user_opts.multi_factor_auth_rules = serde_json::from_str(val.as_ref()).ok(); } ("MFAE", Some(val)) => { user_opts.multi_factor_auth_enabled = val.parse().ok(); } + ("ISSA", Some(val)) => { + user_opts.is_service_account = val.parse().ok(); + } _ => {} } } @@ -50,56 +58,71 @@ impl FromIterator for UserOptions { } } -#[allow(unused)] -fn get_user_options_db_entries>( - user_id: U, - options: &UserOptions, -) -> Result, IdentityProviderError> { - let mut res: Vec = Vec::new(); - let uid = user_id.into(); - if let Some(val) = &options.ignore_change_password_upon_first_use { - res.push(user_option::Model { - user_id: uid.clone(), - option_id: "1000".into(), - option_value: Some(val.to_string()), - }); - } - if let Some(val) = &options.ignore_password_expiry { - res.push(user_option::Model { - user_id: uid.clone(), - option_id: "1001".into(), - option_value: Some(val.to_string()), - }); - } - if let Some(val) = &options.ignore_lockout_failure_attempts { - res.push(user_option::Model { - user_id: uid.clone(), - option_id: "1002".into(), - option_value: Some(val.to_string()), - }); - } - if let Some(val) = &options.lock_password { - res.push(user_option::Model { - user_id: uid.clone(), - option_id: "1003".into(), - option_value: Some(val.to_string()), - }); - } - if let Some(val) = &options.multi_factor_auth_rules { - res.push(user_option::Model { - user_id: uid.clone(), - option_id: "MFAR".into(), - option_value: Some(serde_json::to_string(val)?), - }); - } - if let Some(val) = &options.multi_factor_auth_enabled { - res.push(user_option::Model { - user_id: uid.clone(), - option_id: "MFAE".into(), - option_value: Some(val.to_string()), - }); +impl UserOptions { + pub(super) fn to_model_iter>( + &self, + user_id: U, + ) -> Result, IdentityDatabaseError> { + let mut res: Vec = Vec::new(); + let uid = user_id.into(); + if let Some(val) = &self.ignore_change_password_upon_first_use { + res.push(user_option::Model { + user_id: uid.clone(), + option_id: "1000".into(), + option_value: Some(val.to_string()), + }); + } + if let Some(val) = &self.ignore_password_expiry { + res.push(user_option::Model { + user_id: uid.clone(), + option_id: "1001".into(), + option_value: Some(val.to_string()), + }); + } + if let Some(val) = &self.ignore_lockout_failure_attempts { + res.push(user_option::Model { + user_id: uid.clone(), + option_id: "1002".into(), + option_value: Some(val.to_string()), + }); + } + if let Some(val) = &self.lock_password { + res.push(user_option::Model { + user_id: uid.clone(), + option_id: "1003".into(), + option_value: Some(val.to_string()), + }); + } + if let Some(val) = &self.ignore_user_inactivity { + res.push(user_option::Model { + user_id: uid.clone(), + option_id: "1004".into(), + option_value: Some(val.to_string()), + }); + } + if let Some(val) = &self.multi_factor_auth_rules { + res.push(user_option::Model { + user_id: uid.clone(), + option_id: "MFAR".into(), + option_value: Some(serde_json::to_string(val)?), + }); + } + if let Some(val) = &self.multi_factor_auth_enabled { + res.push(user_option::Model { + user_id: uid.clone(), + option_id: "MFAE".into(), + option_value: Some(val.to_string()), + }); + } + if let Some(val) = &self.is_service_account { + res.push(user_option::Model { + user_id: uid.clone(), + option_id: "ISSA".into(), + option_value: Some(val.to_string()), + }); + } + Ok(res) } - Ok(res) } #[cfg(test)] @@ -107,8 +130,6 @@ pub(crate) mod tests { use crate::db::entity::user_option; use crate::identity::types::UserOptions; - use super::*; - impl Default for user_option::Model { fn default() -> Self { Self { @@ -123,9 +144,246 @@ pub(crate) mod tests { user_id: U, options: &UserOptions, ) -> Vec { - get_user_options_db_entries(user_id, options) + options + .to_model_iter(user_id) .unwrap() .into_iter() .collect() } + + #[test] + fn test_from_rows_empty() { + assert_eq!( + UserOptions::from_iter(Vec::::new()), + UserOptions::default() + ); + } + + #[test] + fn test_to_model_iter() { + // Test conversion of multiple options to ensure we do not stop on first match. + // It is not necessary to cover all options in this test + let rows: Vec = UserOptions { + ignore_change_password_upon_first_use: Some(true), + ignore_password_expiry: Some(true), + ignore_lockout_failure_attempts: Some(true), + lock_password: Some(true), + ignore_user_inactivity: Some(true), + multi_factor_auth_rules: Some(vec![vec!["a".into(), "b".into()]]), + multi_factor_auth_enabled: Some(true), + is_service_account: Some(true), + } + .to_model_iter("uid") + .unwrap() + .into_iter() + .collect(); + assert!(rows.contains(&user_option::Model { + user_id: "uid".into(), + option_id: "1000".into(), + option_value: Some("true".into()) + })); + assert!(rows.contains(&user_option::Model { + user_id: "uid".into(), + option_id: "1001".into(), + option_value: Some("true".into()) + })); + assert!(rows.contains(&user_option::Model { + user_id: "uid".into(), + option_id: "1002".into(), + option_value: Some("true".into()) + })); + assert!(rows.contains(&user_option::Model { + user_id: "uid".into(), + option_id: "1003".into(), + option_value: Some("true".into()) + })); + assert!(rows.contains(&user_option::Model { + user_id: "uid".into(), + option_id: "1004".into(), + option_value: Some("true".into()) + })); + assert!(rows.contains(&user_option::Model { + user_id: "uid".into(), + option_id: "MFAR".into(), + option_value: Some("[[\"a\",\"b\"]]".into()) + }),); + assert!(rows.contains(&user_option::Model { + user_id: "uid".into(), + option_id: "MFAE".into(), + option_value: Some("true".into()) + }),); + assert!(rows.contains(&user_option::Model { + user_id: "uid".into(), + option_id: "ISSA".into(), + option_value: Some("true".into()) + })); + } + + #[test] + fn test_to_model_iter_1000() { + let sot = UserOptions { + ignore_change_password_upon_first_use: Some(true), + ..Default::default() + }; + let rows = vec![user_option::Model { + user_id: "uid".into(), + option_id: "1000".into(), + option_value: Some("true".into()), + }]; + assert_eq!( + sot.to_model_iter("uid") + .unwrap() + .into_iter() + .collect::>(), + rows + ); + assert_eq!(sot, UserOptions::from_iter(rows.into_iter())); + } + + #[test] + fn test_to_model_iter_1001() { + let sot = UserOptions { + ignore_password_expiry: Some(true), + ..Default::default() + }; + let rows = vec![user_option::Model { + user_id: "uid".into(), + option_id: "1001".into(), + option_value: Some("true".into()), + }]; + assert_eq!( + sot.to_model_iter("uid") + .unwrap() + .into_iter() + .collect::>(), + rows + ); + assert_eq!(sot, UserOptions::from_iter(rows.into_iter())); + } + + #[test] + fn test_to_model_iter_1002() { + let sot = UserOptions { + ignore_lockout_failure_attempts: Some(true), + ..Default::default() + }; + let rows = vec![user_option::Model { + user_id: "uid".into(), + option_id: "1002".into(), + option_value: Some("true".into()), + }]; + assert_eq!( + sot.to_model_iter("uid") + .unwrap() + .into_iter() + .collect::>(), + rows + ); + assert_eq!(sot, UserOptions::from_iter(rows.into_iter())); + } + + #[test] + fn test_to_model_iter_1003() { + let sot = UserOptions { + lock_password: Some(true), + ..Default::default() + }; + let rows = vec![user_option::Model { + user_id: "uid".into(), + option_id: "1003".into(), + option_value: Some("true".into()), + }]; + assert_eq!( + sot.to_model_iter("uid") + .unwrap() + .into_iter() + .collect::>(), + rows + ); + assert_eq!(sot, UserOptions::from_iter(rows.into_iter())); + } + + #[test] + fn test_1004() { + let sot = UserOptions { + ignore_user_inactivity: Some(true), + ..Default::default() + }; + let rows = vec![user_option::Model { + user_id: "uid".into(), + option_id: "1004".into(), + option_value: Some("true".into()), + }]; + assert_eq!( + sot.to_model_iter("uid") + .unwrap() + .into_iter() + .collect::>(), + rows + ); + assert_eq!(sot, UserOptions::from_iter(rows.into_iter())); + } + + #[test] + fn test_mfar() { + let sot = UserOptions { + multi_factor_auth_rules: Some(vec![vec!["a".into(), "b".into()]]), + ..Default::default() + }; + let rows = vec![user_option::Model { + user_id: "uid".into(), + option_id: "MFAR".into(), + option_value: Some("[[\"a\",\"b\"]]".into()), + }]; + assert_eq!( + sot.to_model_iter("uid") + .unwrap() + .into_iter() + .collect::>(), + rows + ); + assert_eq!(sot, UserOptions::from_iter(rows.into_iter())); + } + + #[test] + fn test_mfae() { + let sot = UserOptions { + multi_factor_auth_enabled: Some(true), + ..Default::default() + }; + let rows = vec![user_option::Model { + user_id: "uid".into(), + option_id: "MFAE".into(), + option_value: Some("true".into()), + }]; + assert_eq!( + sot.to_model_iter("uid") + .unwrap() + .into_iter() + .collect::>(), + rows + ); + assert_eq!(sot, UserOptions::from_iter(rows.into_iter())); + } + + #[test] + fn test_issa() { + let sot = UserOptions { + is_service_account: Some(true), + ..Default::default() + }; + let rows = vec![user_option::Model { + user_id: "uid".into(), + option_id: "ISSA".into(), + option_value: Some("true".into()), + }]; + assert_eq!( + sot.to_model_iter("uid") + .unwrap() + .into_iter() + .collect::>(), + rows + ); + assert_eq!(sot, UserOptions::from_iter(rows.into_iter())); + } } diff --git a/src/identity/backend/sql/user_option/create.rs b/src/identity/backend/sql/user_option/create.rs new file mode 100644 index 00000000..116a0af1 --- /dev/null +++ b/src/identity/backend/sql/user_option/create.rs @@ -0,0 +1,89 @@ +// 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 sea_orm::ConnectionTrait; +use sea_orm::entity::*; + +use crate::db::entity::prelude::UserOption as DbUserOption; +use crate::db::entity::user_option as db_user_option; +use crate::error::DbContextExt; +use crate::identity::backend::sql::IdentityDatabaseError; +use crate::identity::types::UserOptions; + +/// Persist user options. +#[tracing::instrument(skip_all)] +pub async fn create( + db: &C, + user_id: U, + opts: &UserOptions, +) -> Result<(), IdentityDatabaseError> +where + C: ConnectionTrait, + U: Into, +{ + let rows: Vec = opts + .to_model_iter(user_id)? + .into_iter() + .map(Into::::into) + .collect(); + if !rows.is_empty() { + DbUserOption::insert_many(rows) + .exec(db) + .await + .context("inserting new user options")?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult, Transaction}; + + use super::*; + + #[tokio::test] + async fn test_create() { + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); + create(&db, "1", &UserOptions::default()).await.unwrap(); + } + + #[tokio::test] + async fn test_create_issa() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_exec_results([MockExecResult { + rows_affected: 1, + ..Default::default() + }]) + .into_connection(); + create( + &db, + "1", + &UserOptions { + is_service_account: Some(true), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"INSERT INTO "user_option" ("user_id", "option_id", "option_value") VALUES ($1, $2, $3) RETURNING "user_id", "option_id""#, + ["1".into(), "ISSA".into(), "true".into(),] + ),] + ); + } +} diff --git a/src/identity/backend/sql/user_option/list.rs b/src/identity/backend/sql/user_option/list.rs index 0084455b..4318eda8 100644 --- a/src/identity/backend/sql/user_option/list.rs +++ b/src/identity/backend/sql/user_option/list.rs @@ -21,6 +21,7 @@ use crate::error::DbContextExt; use crate::identity::backend::sql::IdentityDatabaseError; use crate::identity::types::UserOptions; +#[tracing::instrument(skip_all)] pub async fn list_by_user_id>( db: &DatabaseConnection, user_id: S, diff --git a/src/identity/mock.rs b/src/identity/mock.rs index 5dc043a4..01cec71c 100644 --- a/src/identity/mock.rs +++ b/src/identity/mock.rs @@ -19,12 +19,7 @@ use std::collections::HashSet; use crate::auth::AuthenticatedInfo; use crate::config::Config; -use crate::identity::IdentityApi; -use crate::identity::error::IdentityProviderError; -use crate::identity::types::{ - Group, GroupCreate, GroupListParameters, UserCreate, UserListParameters, - UserPasswordAuthRequest, UserResponse, -}; +use crate::identity::{IdentityApi, error::IdentityProviderError, types::*}; use crate::keystone::ServiceState; use crate::plugin_manager::PluginManager; @@ -75,6 +70,12 @@ mock! { group: GroupCreate, ) -> Result; + async fn create_service_account( + &self, + state: &ServiceState, + sa: ServiceAccountCreate, + ) -> Result; + async fn create_user( &self, state: &ServiceState, @@ -99,6 +100,13 @@ mock! { group_id: &'a str, ) -> Result, IdentityProviderError>; + /// Get single service account by ID. + async fn get_service_account<'a>( + &self, + state: &ServiceState, + user_id: &'a str, + ) -> Result, IdentityProviderError>; + async fn get_user<'a>( &self, state: &ServiceState, diff --git a/src/identity/mod.rs b/src/identity/mod.rs index d2195f12..f1bf84f5 100644 --- a/src/identity/mod.rs +++ b/src/identity/mod.rs @@ -52,11 +52,7 @@ pub use mock::MockIdentityProvider; use crate::auth::AuthenticatedInfo; use crate::config::Config; use crate::identity::backend::{IdentityBackend, sql::SqlBackend}; -use crate::identity::error::IdentityProviderError; -use crate::identity::types::{ - Group, GroupCreate, GroupListParameters, UserCreate, UserListParameters, - UserPasswordAuthRequest, UserResponse, -}; +use crate::identity::{error::IdentityProviderError, types::*}; use crate::keystone::ServiceState; use crate::plugin_manager::PluginManager; use crate::resource::{ResourceApi, error::ResourceProviderError}; @@ -112,7 +108,7 @@ impl IdentityProvider { #[async_trait] impl IdentityApi for IdentityProvider { - #[tracing::instrument(level = "info", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn add_user_to_group<'a>( &self, state: &ServiceState, @@ -124,7 +120,7 @@ impl IdentityApi for IdentityProvider { .await } - #[tracing::instrument(level = "info", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn add_user_to_group_expiring<'a>( &self, state: &ServiceState, @@ -137,7 +133,7 @@ impl IdentityApi for IdentityProvider { .await } - #[tracing::instrument(level = "info", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn add_users_to_groups<'a>( &self, state: &ServiceState, @@ -148,7 +144,7 @@ impl IdentityApi for IdentityProvider { .await } - #[tracing::instrument(level = "info", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn add_users_to_groups_expiring<'a>( &self, state: &ServiceState, @@ -161,7 +157,7 @@ impl IdentityApi for IdentityProvider { } /// Authenticate user with the password auth method. - #[tracing::instrument(level = "info", skip(self, state, auth))] + #[tracing::instrument(skip(self, state, auth))] async fn authenticate_by_password( &self, state: &ServiceState, @@ -195,8 +191,42 @@ impl IdentityApi for IdentityProvider { .await } + /// Create group. + #[tracing::instrument(skip(self, state))] + async fn create_group( + &self, + state: &ServiceState, + group: GroupCreate, + ) -> Result { + let mut res = group; + if res.id.is_none() { + res.id = Some(Uuid::new_v4().simple().to_string()); + } + self.backend_driver.create_group(state, res).await + } + + /// Create service account. + #[tracing::instrument(skip(self, state))] + async fn create_service_account( + &self, + state: &ServiceState, + sa: ServiceAccountCreate, + ) -> Result { + let mut mod_sa = sa; + if mod_sa.id.is_none() { + mod_sa.id = Some(Uuid::new_v4().simple().to_string()); + } + if mod_sa.enabled.is_none() { + mod_sa.enabled = Some(true); + } + mod_sa.validate()?; + self.backend_driver + .create_service_account(state, mod_sa) + .await + } + /// Create user. - #[tracing::instrument(level = "info", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn create_user( &self, state: &ServiceState, @@ -214,7 +244,7 @@ impl IdentityApi for IdentityProvider { } /// Delete group. - #[tracing::instrument(level = "info", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn delete_group<'a>( &self, state: &ServiceState, @@ -224,7 +254,7 @@ impl IdentityApi for IdentityProvider { } /// Delete user. - #[tracing::instrument(level = "info", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn delete_user<'a>( &self, state: &ServiceState, @@ -237,8 +267,20 @@ impl IdentityApi for IdentityProvider { Ok(()) } + /// Get a service account by ID. + #[tracing::instrument(skip(self, state))] + async fn get_service_account<'a>( + &self, + state: &ServiceState, + user_id: &'a str, + ) -> Result, IdentityProviderError> { + self.backend_driver + .get_service_account(state, user_id) + .await + } + /// Get single user. - #[tracing::instrument(level = "info", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn get_user<'a>( &self, state: &ServiceState, @@ -291,7 +333,7 @@ impl IdentityApi for IdentityProvider { } /// Find federated user by `idp_id` and `unique_id`. - #[tracing::instrument(level = "info", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn find_federated_user<'a>( &self, state: &ServiceState, @@ -304,7 +346,7 @@ impl IdentityApi for IdentityProvider { } /// List users. - #[tracing::instrument(level = "info", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn list_users( &self, state: &ServiceState, @@ -314,7 +356,7 @@ impl IdentityApi for IdentityProvider { } /// List groups. - #[tracing::instrument(level = "info", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn list_groups( &self, state: &ServiceState, @@ -324,7 +366,7 @@ impl IdentityApi for IdentityProvider { } /// Get single group. - #[tracing::instrument(level = "info", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn get_group<'a>( &self, state: &ServiceState, @@ -333,22 +375,8 @@ impl IdentityApi for IdentityProvider { self.backend_driver.get_group(state, group_id).await } - /// Create group. - #[tracing::instrument(level = "info", skip(self, state))] - async fn create_group( - &self, - state: &ServiceState, - group: GroupCreate, - ) -> Result { - let mut res = group; - if res.id.is_none() { - res.id = Some(Uuid::new_v4().simple().to_string()); - } - self.backend_driver.create_group(state, res).await - } - /// List groups a user is a member of. - #[tracing::instrument(level = "info", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn list_groups_of_user<'a>( &self, state: &ServiceState, @@ -359,7 +387,7 @@ impl IdentityApi for IdentityProvider { .await } - #[tracing::instrument(level = "info", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn remove_user_from_group<'a>( &self, state: &ServiceState, @@ -371,7 +399,7 @@ impl IdentityApi for IdentityProvider { .await } - #[tracing::instrument(level = "info", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn remove_user_from_group_expiring<'a>( &self, state: &ServiceState, @@ -384,7 +412,7 @@ impl IdentityApi for IdentityProvider { .await } - #[tracing::instrument(level = "info", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn remove_user_from_groups<'a>( &self, state: &ServiceState, @@ -396,7 +424,7 @@ impl IdentityApi for IdentityProvider { .await } - #[tracing::instrument(level = "info", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn remove_user_from_groups_expiring<'a>( &self, state: &ServiceState, @@ -409,7 +437,7 @@ impl IdentityApi for IdentityProvider { .await } - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn set_user_groups<'a>( &self, state: &ServiceState, @@ -421,7 +449,7 @@ impl IdentityApi for IdentityProvider { .await } - #[tracing::instrument(level = "debug", skip(self, state))] + #[tracing::instrument(skip(self, state))] async fn set_user_groups_expiring<'a>( &self, state: &ServiceState, @@ -439,7 +467,7 @@ impl IdentityApi for IdentityProvider { #[cfg(test)] mod tests { use super::backend::MockIdentityBackend; - use super::types::user::UserCreateBuilder; + use super::types::user::{UserCreateBuilder, UserResponseBuilder}; use super::*; use crate::tests::get_state_mock; @@ -447,9 +475,15 @@ mod tests { async fn test_create_user() { let state = get_state_mock(); let mut backend = MockIdentityBackend::default(); - backend - .expect_create_user() - .returning(|_, _| Ok(UserResponse::default())); + backend.expect_create_user().returning(|_, _| { + Ok(UserResponseBuilder::default() + .id("id") + .domain_id("domain_id") + .enabled(true) + .name("name") + .build() + .unwrap()) + }); let provider = IdentityProvider::from_driver(backend); assert_eq!( @@ -464,7 +498,13 @@ mod tests { ) .await .unwrap(), - UserResponse::default() + UserResponseBuilder::default() + .domain_id("domain_id") + .enabled(true) + .id("id") + .name("name") + .build() + .unwrap() ); } @@ -475,7 +515,17 @@ mod tests { backend .expect_get_user() .withf(|_, uid: &'_ str| uid == "uid") - .returning(|_, _| Ok(Some(UserResponse::default()))); + .returning(|_, _| { + Ok(Some( + UserResponseBuilder::default() + .id("id") + .domain_id("domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) + }); let provider = IdentityProvider::from_driver(backend); assert_eq!( @@ -484,7 +534,13 @@ mod tests { .await .unwrap() .expect("user should be there"), - UserResponse::default() + UserResponseBuilder::default() + .domain_id("domain_id") + .enabled(true) + .id("id") + .name("name") + .build() + .unwrap(), ); } diff --git a/src/identity/types.rs b/src/identity/types.rs index ad5fe1f5..f30e5391 100644 --- a/src/identity/types.rs +++ b/src/identity/types.rs @@ -14,8 +14,10 @@ pub mod group; pub mod provider_api; +pub mod service_account; pub mod user; pub use group::*; pub use provider_api::IdentityApi; +pub use service_account::*; pub use user::*; diff --git a/src/identity/types/provider_api.rs b/src/identity/types/provider_api.rs index 1648578b..a6d84be2 100644 --- a/src/identity/types/provider_api.rs +++ b/src/identity/types/provider_api.rs @@ -17,8 +17,7 @@ use chrono::{DateTime, Utc}; use std::collections::HashSet; use crate::auth::AuthenticatedInfo; -use crate::identity::IdentityProviderError; -use crate::identity::types::{group::*, user::*}; +use crate::identity::{IdentityProviderError, types::*}; use crate::keystone::ServiceState; #[async_trait] @@ -67,6 +66,13 @@ pub trait IdentityApi: Send + Sync { group: GroupCreate, ) -> Result; + /// Create service account. + async fn create_service_account( + &self, + state: &ServiceState, + sa: ServiceAccountCreate, + ) -> Result; + async fn create_user( &self, state: &ServiceState, @@ -85,6 +91,13 @@ pub trait IdentityApi: Send + Sync { group_id: &'a str, ) -> Result, IdentityProviderError>; + /// Get single service account by ID. + async fn get_service_account<'a>( + &self, + state: &ServiceState, + user_id: &'a str, + ) -> Result, IdentityProviderError>; + async fn get_user<'a>( &self, state: &ServiceState, diff --git a/src/identity/types/service_account.rs b/src/identity/types/service_account.rs new file mode 100644 index 00000000..11a520a9 --- /dev/null +++ b/src/identity/types/service_account.rs @@ -0,0 +1,95 @@ +// 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 derive_builder::Builder; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use crate::error::BuilderError; + +/// Service account representation. +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize, Validate)] +#[builder(build_fn(error = "BuilderError"))] +#[builder(setter(strip_option, into))] +pub struct ServiceAccount { + /// The ID of the domain. + #[validate(length(max = 64))] + pub domain_id: String, + + /// If the service account is enabled, this value is true. Otherwise, + /// this value is false. + pub enabled: bool, + + /// The resource options for the user. + /// The user ID. + #[validate(length(max = 64))] + pub id: String, + + /// The user name. Must be unique within the owning domain. + #[validate(length(max = 255))] + pub name: String, +} + +/// Service account creation data. +#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, Validate)] +#[builder(build_fn(error = "BuilderError"))] +#[builder(setter(strip_option, into))] +pub struct ServiceAccountCreate { + /// The ID of the domain. + #[validate(length(min = 1, max = 64))] + pub domain_id: String, + + /// If the service account is enabled, this value is true. + #[builder(default)] + pub enabled: Option, + + /// The ID of the service account. When unset a new UUID would be assigned. + #[builder(default)] + #[validate(length(min = 1, max = 64))] + pub id: Option, + + /// The service account name. Must be unique within the owning domain. + #[validate(length(min = 1, max = 255))] + pub name: String, +} + +/// The service account update object. +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize, Validate)] +#[builder(build_fn(error = "BuilderError"))] +#[builder(setter(into))] +pub struct ServiceAccountUpdate { + /// Enable or disable the service account. + #[builder(default)] + pub enabled: Option, + + /// The user name. Must be unique within the owning domain. + #[validate(length(max = 255))] + #[builder(default)] + pub name: Option, +} + +/// Service account listing parameters. +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize, Validate)] +#[builder(build_fn(error = "BuilderError"))] +pub struct ServiceAccountListParameters { + /// Filter service accounts by the domain. + #[builder(default)] + #[validate(length(max = 64))] + pub domain_id: Option, + + /// Filter users by the name attribute. + #[builder(default)] + #[validate(length(max = 255))] + pub name: Option, +} diff --git a/src/identity/types/user.rs b/src/identity/types/user.rs index 4b14feba..008eec0b 100644 --- a/src/identity/types/user.rs +++ b/src/identity/types/user.rs @@ -12,15 +12,16 @@ // // SPDX-License-Identifier: Apache-2.0 -use chrono::{DateTime, Utc}; +use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc}; use derive_builder::Builder; use serde::{Deserialize, Serialize}; use serde_json::Value; use validator::Validate; +use crate::config::Config; use crate::error::BuilderError; -#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize, Validate)] +#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, Validate)] #[builder(build_fn(error = "BuilderError"))] #[builder(setter(strip_option, into))] pub struct UserResponse { @@ -35,15 +36,18 @@ pub struct UserResponse { #[builder(default)] #[validate(length(max = 64))] pub default_project_id: Option, + /// The ID of the domain. #[validate(length(max = 64))] pub domain_id: String, /// If the user is enabled, this value is true. If the user is disabled, /// this value is false. pub enabled: bool, + /// Additional user properties. #[builder(default)] pub extra: Option, + /// List of federated objects associated with a user. Each object in the /// list contains the `idp_id` and `protocols`. `protocols` is a list of /// objects, each of which contains `protocol_id` and `unique_id` of the @@ -51,17 +55,20 @@ pub struct UserResponse { #[builder(default)] #[validate(nested)] pub federated: Option>, - /// The resource options for the user. + /// The user ID. #[validate(length(max = 64))] pub id: String, + /// The user name. Must be unique within the owning domain. #[validate(length(max = 255))] pub name: String, #[builder(default)] + /// The options for the user. #[validate(nested)] pub options: UserOptions, + #[builder(default)] pub password_expires_at: Option>, } @@ -125,13 +132,16 @@ pub struct UserUpdate { #[builder(default)] #[validate(length(max = 64))] pub default_project_id: Option>, + /// If the user is enabled, this value is true. If the user is disabled, /// this value is false. #[builder(default)] pub enabled: Option, + /// Additional user properties. #[builder(default)] pub extra: Option, + /// List of federated objects associated with a user. Each object in the /// list contains the idp_id and protocols. protocols is a list of objects, /// each of which contains protocol_id and unique_id of the protocol and @@ -139,14 +149,17 @@ pub struct UserUpdate { #[builder(default)] #[validate(nested)] pub federated: Option>, + /// The user name. Must be unique within the owning domain. - #[validate(length(max = 64))] + #[validate(length(max = 255))] #[builder(default)] - pub name: Option>, + pub name: Option, + /// The resource options for the user. #[builder(default)] #[validate(nested)] pub options: Option, + /// New user password. #[builder(default)] #[validate(length(max = 72))] @@ -159,22 +172,32 @@ pub struct UserUpdate { #[builder(setter(strip_option, into))] pub struct UserOptions { pub ignore_change_password_upon_first_use: Option, + pub ignore_password_expiry: Option, + pub ignore_lockout_failure_attempts: Option, + pub lock_password: Option, + pub ignore_user_inactivity: Option, + pub multi_factor_auth_rules: Option>>, + pub multi_factor_auth_enabled: Option, + + /// Identifies whether the user is a service account. + pub is_service_account: Option, } /// User federation data. -#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize, Validate)] +#[derive(Builder, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize, Validate)] #[builder(build_fn(error = "BuilderError"))] #[builder(setter(strip_option, into))] pub struct Federation { /// Identity provider ID. #[validate(length(max = 64))] pub idp_id: String, + /// Protocols. #[builder(default)] #[validate(nested)] @@ -186,13 +209,14 @@ pub struct Federation { } /// Federation protocol data. -#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize, Validate)] +#[derive(Builder, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize, Validate)] #[builder(build_fn(error = "BuilderError"))] #[builder(setter(strip_option, into))] pub struct FederationProtocol { /// Federation protocol ID. #[validate(length(max = 64))] pub protocol_id: String, + // TODO: unique ID should potentially belong to the IDP and not to the protocol /// Unique ID of the associated user. #[validate(length(max = 64))] @@ -207,14 +231,17 @@ pub struct UserListParameters { #[builder(default)] #[validate(length(max = 64))] pub domain_id: Option, + /// Filter users by the name attribute. #[builder(default)] #[validate(length(max = 255))] pub name: Option, + /// Filter users by the federated unique ID. #[builder(default)] #[validate(length(max = 64))] pub unique_id: Option, + /// Filter users by User Type (local, federated, nonlocal, all). #[builder(default)] #[serde(default, rename = "type")] @@ -237,6 +264,9 @@ pub enum UserType { /// Non-local users (users without local authentication). NonLocal, + + /// Service Accounts (bots, etc). + ServiceAccount, } /// User password information. @@ -248,14 +278,17 @@ pub struct UserPasswordAuthRequest { #[builder(default)] #[validate(length(max = 64))] pub id: Option, + /// User Name. #[builder(default)] #[validate(length(max = 255))] pub name: Option, + /// User domain. #[builder(default)] #[validate(nested)] pub domain: Option, + /// User password expiry date. #[builder(default)] #[validate(length(max = 72))] @@ -271,8 +304,51 @@ pub struct Domain { #[builder(default)] #[validate(length(max = 64))] pub id: Option, + /// Domain Name. #[builder(default)] #[validate(length(max = 255))] pub name: Option, } + +/// Calculate the `last_active_at` for the user entry. +pub fn get_user_last_active_at( + conf: &Config, + enabled: Option, + activity_date: NaiveDateTime, +) -> Option { + if enabled.is_some_and(|x| x) { + if conf + .security_compliance + .disable_user_account_days_inactive + .is_some() + { + Some(activity_date.date()) + } else { + None + } + } else { + None + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + #[test] + fn test_get_user_last_active_at() { + let now = Utc::now().naive_utc(); + let mut cfg = Config::default(); + assert!(get_user_last_active_at(&cfg, Some(false), now).is_none()); + assert!(get_user_last_active_at(&cfg, Some(true), now).is_none()); + assert!(get_user_last_active_at(&cfg, None, now).is_none()); + + cfg.security_compliance.disable_user_account_days_inactive = Some(1); + assert_eq!( + get_user_last_active_at(&cfg, Some(true), now), + Some(now.date()) + ); + assert!(get_user_last_active_at(&cfg, Some(false), now).is_none()); + assert!(get_user_last_active_at(&cfg, None, now).is_none()); + } +} diff --git a/src/revoke/types/revocation_event.rs b/src/revoke/types/revocation_event.rs index d25dccaa..eab45407 100644 --- a/src/revoke/types/revocation_event.rs +++ b/src/revoke/types/revocation_event.rs @@ -194,7 +194,7 @@ impl TryFrom<&Token> for RevocationEventCreate { #[cfg(test)] mod tests { use super::*; - use crate::identity::types::UserResponse; + use crate::identity::types::UserResponseBuilder; use crate::token::{ProjectScopePayload, TrustPayload}; //use crate::resource::types::Domain; use crate::assignment::types::Role; @@ -204,11 +204,15 @@ mod tests { fn test_list_for_project_scope_token() { let token = Token::ProjectScope(ProjectScopePayload { user_id: "user_id".into(), - user: Some(UserResponse { - id: "user_id".to_string(), - domain_id: "user_domain_id".into(), - ..Default::default() - }), + user: Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + ), methods: Vec::from(["password".to_string()]), project_id: "project_id".into(), audit_ids: vec!["Zm9vCg".into()], @@ -298,11 +302,15 @@ mod tests { fn test_list_for_trust_token() { let token = Token::Trust(TrustPayload { user_id: "user_id".into(), - user: Some(UserResponse { - id: "user_id".to_string(), - domain_id: "user_domain_id".into(), - ..Default::default() - }), + user: Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + ), methods: Vec::from(["trust".to_string()]), project_id: "project_id".into(), audit_ids: vec!["Zm9vCg".into()], diff --git a/src/token/mod.rs b/src/token/mod.rs index bc541aed..24fa212a 100644 --- a/src/token/mod.rs +++ b/src/token/mod.rs @@ -994,7 +994,7 @@ mod tests { types::{Assignment, AssignmentType, Role, RoleAssignmentListParameters}, }; use crate::config::Config; - use crate::identity::{MockIdentityProvider, types::UserResponse}; + use crate::identity::{MockIdentityProvider, types::UserResponseBuilder}; use crate::keystone::Service; use crate::provider::Provider; use crate::resource::{MockResourceProvider, types::Project}; @@ -1167,11 +1167,15 @@ mod tests { .expect_get_user() .withf(move |_, id: &'_ str| id == token_clone.user_id()) .returning(|_, id: &'_ str| { - Ok(Some(UserResponse { - id: id.to_string(), - domain_id: "user_domain_id".into(), - ..Default::default() - })) + Ok(Some( + UserResponseBuilder::default() + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .id(id) + .build() + .unwrap(), + )) }); let mut resource_mock = MockResourceProvider::default(); let token_clone2 = token.clone(); diff --git a/tests/integration/identity.rs b/tests/integration/identity.rs index 1e691a4f..af8db54f 100644 --- a/tests/integration/identity.rs +++ b/tests/integration/identity.rs @@ -12,5 +12,6 @@ // // SPDX-License-Identifier: Apache-2.0 +mod service_account; mod user; mod user_group; diff --git a/tests/integration/identity/service_account.rs b/tests/integration/identity/service_account.rs new file mode 100644 index 00000000..7a47d704 --- /dev/null +++ b/tests/integration/identity/service_account.rs @@ -0,0 +1,64 @@ +// 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 eyre::Report; +use sea_orm::{DbConn, entity::*}; +use std::sync::Arc; + +use openstack_keystone::config::Config; +use openstack_keystone::db::entity::project; +use openstack_keystone::keystone::Service; +use openstack_keystone::plugin_manager::PluginManager; +use openstack_keystone::policy::PolicyFactory; +use openstack_keystone::provider::Provider; + +use crate::common::{bootstrap, get_isolated_database}; + +mod create; +mod get; + +async fn setup_data(db: &DbConn) -> Result<(), Report> { + bootstrap(db).await?; + // Domain/project data + let _domain_a = project::ActiveModel { + is_domain: Set(true), + id: Set("domain_a".into()), + name: Set("domain_a".into()), + extra: NotSet, + description: NotSet, + enabled: Set(Some(true)), + domain_id: Set("<>".into()), + parent_id: NotSet, + } + .insert(db) + .await?; + + Ok(()) +} + +async fn get_state() -> Result, Report> { + let db = get_isolated_database().await?; + setup_data(&db).await?; + + let cfg: Config = Config::default(); + + let plugin_manager = PluginManager::default(); + let provider = Provider::new(cfg.clone(), plugin_manager)?; + Ok(Arc::new(Service::new( + cfg, + db, + provider, + PolicyFactory::default(), + )?)) +} diff --git a/tests/integration/identity/service_account/create.rs b/tests/integration/identity/service_account/create.rs new file mode 100644 index 00000000..bdbb0c16 --- /dev/null +++ b/tests/integration/identity/service_account/create.rs @@ -0,0 +1,60 @@ +// 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 +//! Test add user group membership functionality. + +use eyre::Result; +use tracing_test::traced_test; +use uuid::Uuid; + +use openstack_keystone::identity::{IdentityApi, types::*}; + +use super::*; + +#[tokio::test] +#[traced_test] +async fn test_create() -> Result<()> { + let state = get_state().await?; + let uid = Uuid::new_v4().simple().to_string(); + + let sa = state + .provider + .get_identity_provider() + .create_service_account( + &state, + ServiceAccountCreate { + domain_id: "domain_a".into(), + enabled: Some(true), + id: Some(uid.clone()), + name: "sa_foo".into(), + }, + ) + .await?; + assert_eq!(sa.domain_id, "domain_a"); + assert!(sa.enabled); + assert_eq!(sa.id, uid); + assert_eq!(sa.name, "sa_foo"); + + let user = state + .provider + .get_identity_provider() + .get_user(&state, &sa.id) + .await? + .expect("user found"); + assert_eq!(user.domain_id, "domain_a"); + assert!(user.enabled); + assert_eq!(user.id, uid); + assert_eq!(user.name, "sa_foo"); + + Ok(()) +} diff --git a/tests/integration/identity/service_account/get.rs b/tests/integration/identity/service_account/get.rs new file mode 100644 index 00000000..a81d4b59 --- /dev/null +++ b/tests/integration/identity/service_account/get.rs @@ -0,0 +1,58 @@ +// 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 +//! Test add user group membership functionality. + +use eyre::Result; +use tracing_test::traced_test; +use uuid::Uuid; + +use openstack_keystone::identity::{IdentityApi, types::*}; + +use super::*; + +#[tokio::test] +#[traced_test] +async fn test_get() -> Result<()> { + let state = get_state().await?; + let uid = Uuid::new_v4().simple().to_string(); + + let sa = state + .provider + .get_identity_provider() + .create_service_account( + &state, + ServiceAccountCreate { + domain_id: "domain_a".into(), + enabled: Some(true), + id: Some(uid.clone()), + name: "sa_foo".into(), + }, + ) + .await?; + + let user = state + .provider + .get_identity_provider() + .get_user(&state, &sa.id) + .await? + .expect("user found"); + + let sa = state + .provider + .get_identity_provider() + .get_service_account(&state, &sa.id) + .await? + .expect("sa found"); + Ok(()) +}