From 46ff62b08dcaacfaf6d4dcd131e01067b98a8e39 Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Thu, 29 Jan 2026 17:47:57 +0100 Subject: [PATCH] feat: Introduce "service_account" user type `nonlocal_user` table already describes a "user" that does not have direct credentials to login to the platform. It is a task of the identity driver to implement appropriate authentication. Service account is a special user that does not have a way to login other than through exchange of the jwt or k8 (future). In order to implement this identity driver (user part) is restructured to be clearly identify different backend entity models. Introduce a `UserType` with the service_account including provider API to create a service account. --- src/api/v3/auth/token/common.rs | 47 ++- src/api/v3/auth/token/create.rs | 60 +-- src/api/v3/auth/token/show.rs | 30 +- src/api/v3/auth/token/token_impl.rs | 58 ++- .../project/user/role/check.rs | 65 +++- .../project/user/role/grant.rs | 52 ++- .../project/user/role/revoke.rs | 112 ++---- src/api/v3/user/mod.rs | 48 ++- src/api/v3/user/types.rs | 1 + src/api/v4/auth/token/token_impl.rs | 44 ++- src/api/v4/token/restriction.rs | 14 +- src/api/v4/user/mod.rs | 47 ++- src/auth/mod.rs | 18 +- src/config/identity.rs | 3 + src/db/entity/nonlocal_user.rs | 2 + src/db/entity/user.rs | 22 +- src/db/entity/user_option.rs | 2 + src/federation/api/identity_provider.rs | 16 +- src/identity/backend.rs | 14 + src/identity/backend/error.rs | 3 + src/identity/backend/sql.rs | 79 ++-- src/identity/backend/sql/authenticate.rs | 4 +- .../backend/sql/federated_user/create.rs | 1 + .../backend/sql/federated_user/find.rs | 1 + src/identity/backend/sql/group/create.rs | 1 + src/identity/backend/sql/group/delete.rs | 1 + src/identity/backend/sql/group/get.rs | 1 + src/identity/backend/sql/group/list.rs | 1 + src/identity/backend/sql/local_user.rs | 34 +- src/identity/backend/sql/local_user/create.rs | 33 +- src/identity/backend/sql/local_user/get.rs | 4 + src/identity/backend/sql/local_user/load.rs | 2 + src/identity/backend/sql/local_user/set.rs | 1 + src/identity/backend/sql/nonlocal_user.rs | 6 + .../backend/sql/nonlocal_user/create.rs | 83 ++++ src/identity/backend/sql/nonlocal_user/get.rs | 48 +++ src/identity/backend/sql/password.rs | 19 +- src/identity/backend/sql/password/create.rs | 1 + src/identity/backend/sql/service_account.rs | 19 + .../backend/sql/service_account/create.rs | 152 ++++++++ .../backend/sql/service_account/get.rs | 101 +++++ src/identity/backend/sql/user.rs | 127 +++++- src/identity/backend/sql/user/create.rs | 272 +++++++++---- src/identity/backend/sql/user/delete.rs | 1 + src/identity/backend/sql/user/get.rs | 12 +- src/identity/backend/sql/user/list.rs | 9 +- src/identity/backend/sql/user/set.rs | 3 +- src/identity/backend/sql/user_group/add.rs | 4 + src/identity/backend/sql/user_group/list.rs | 1 + src/identity/backend/sql/user_group/remove.rs | 4 + src/identity/backend/sql/user_group/set.rs | 2 + src/identity/backend/sql/user_option.rs | 364 +++++++++++++++--- .../backend/sql/user_option/create.rs | 89 +++++ src/identity/backend/sql/user_option/list.rs | 1 + src/identity/mock.rs | 20 +- src/identity/mod.rs | 148 ++++--- src/identity/types.rs | 2 + src/identity/types/provider_api.rs | 17 +- src/identity/types/service_account.rs | 95 +++++ src/identity/types/user.rs | 90 ++++- src/revoke/types/revocation_event.rs | 30 +- src/token/mod.rs | 16 +- tests/integration/identity.rs | 1 + tests/integration/identity/service_account.rs | 64 +++ .../identity/service_account/create.rs | 60 +++ .../identity/service_account/get.rs | 58 +++ 66 files changed, 2201 insertions(+), 539 deletions(-) create mode 100644 src/identity/backend/sql/nonlocal_user/create.rs create mode 100644 src/identity/backend/sql/nonlocal_user/get.rs create mode 100644 src/identity/backend/sql/service_account.rs create mode 100644 src/identity/backend/sql/service_account/create.rs create mode 100644 src/identity/backend/sql/service_account/get.rs create mode 100644 src/identity/backend/sql/user_option/create.rs create mode 100644 src/identity/types/service_account.rs create mode 100644 tests/integration/identity/service_account.rs create mode 100644 tests/integration/identity/service_account/create.rs create mode 100644 tests/integration/identity/service_account/get.rs 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(()) +}