diff --git a/src/config.rs b/src/config.rs index 1750dce2..7a521868 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,7 +12,7 @@ // // SPDX-License-Identifier: Apache-2.0 -use chrono::TimeDelta; +use chrono::{NaiveDate, TimeDelta, Utc}; use config::{File, FileFormat}; use eyre::{Report, WrapErr}; use regex::Regex; @@ -22,74 +22,73 @@ use std::collections::HashMap; use std::path::PathBuf; use url::Url; +/// Keystone configuration. +/// #[derive(Debug, Default, Deserialize, Clone)] pub struct Config { - /// Global configuration options + /// Global configuration options. #[serde(rename = "DEFAULT")] pub default: Option, /// - /// Assignments (roles) related configuration + /// Assignments (roles) provider configuration. #[serde(default)] - pub assignment: AssignmentSection, + pub assignment: AssignmentProvider, /// Authentication configuration. - pub auth: AuthSection, + pub auth: AuthProvider, - /// Catalog + /// Catalog provider configuration. #[serde(default)] - pub catalog: CatalogSection, + pub catalog: CatalogProvider, + /// Federation provider configuration. #[serde(default)] - pub federation: FederationSection, + pub federation: FederationProvider, - /// Fernet tokens + /// Fernet tokens provider configuration. #[serde(default)] - pub fernet_tokens: FernetTokenSection, + pub fernet_tokens: FernetTokenProvider, - /// Database configuration + /// Database configuration. //#[serde(default)] pub database: DatabaseSection, - /// Identity provider related configuration + /// Identity provider configuration configuration. #[serde(default)] - pub identity: IdentitySection, + pub identity: IdentityProvider, - /// API policy enforcement + /// API policy enforcement. #[serde(default)] - pub api_policy: PolicySection, + pub api_policy: PolicyProvider, - /// Resource provider related configuration. + /// Resource provider configuration. #[serde(default)] - pub resource: ResourceSection, + pub resource: ResourceProvider, /// Revoke provider configuration. #[serde(default)] - pub revoke: RevokeSection, + pub revoke: RevokeProvider, - /// Security compliance + /// Security compliance configuration. #[serde(default)] - pub security_compliance: SecurityComplianceSection, + pub security_compliance: SecurityComplianceProvider, - /// Token + /// Token provider configuration. #[serde(default)] - pub token: TokenSection, - - /// User options id to name mapping - #[serde(default = "default_user_options_mapping")] - pub user_options_id_name_mapping: HashMap, + pub token: TokenProvider, } #[derive(Debug, Default, Deserialize, Clone)] pub struct DefaultSection { - /// Debug logging + /// Debug logging. pub debug: Option, - /// Public endpoint + /// Public endpoint. pub public_endpoint: Option, } /// Authentication configuration. #[derive(Debug, Default, Deserialize, Clone)] -pub struct AuthSection { +pub struct AuthProvider { /// Authentication methods to be enabled and used for token validation. #[serde(deserialize_with = "csv")] pub methods: Vec, @@ -105,12 +104,16 @@ where .collect()) } +/// Fernet token provider. #[derive(Debug, Default, Deserialize, Clone)] -pub struct FernetTokenSection { +pub struct FernetTokenProvider { + /// Path to the fernet keys. pub key_repository: PathBuf, + /// Maximal number of fernet keys to keep as active. pub max_active_keys: usize, } +/// Database configuration. #[derive(Debug, Default, Deserialize, Clone)] pub struct DatabaseSection { /// Database URL. @@ -131,7 +134,7 @@ impl DatabaseSection { /// The configuration options for the API policy enforcement. #[derive(Clone, Debug, Default, Deserialize)] -pub struct PolicySection { +pub struct PolicyProvider { /// Whether the policy enforcement should be enforced or not. pub enable: bool, @@ -139,13 +142,15 @@ pub struct PolicySection { pub opa_base_url: Option, } +/// Assignment Provider. #[derive(Debug, Deserialize, Clone)] -pub struct AssignmentSection { +pub struct AssignmentProvider { + /// Assignment provider driver. #[serde(default = "default_sql_driver")] pub driver: String, } -impl Default for AssignmentSection { +impl Default for AssignmentProvider { fn default() -> Self { Self { driver: default_sql_driver(), @@ -153,13 +158,15 @@ impl Default for AssignmentSection { } } +/// Catalog provider. #[derive(Debug, Deserialize, Clone)] -pub struct CatalogSection { +pub struct CatalogProvider { + /// Catalog provider driver. #[serde(default = "default_sql_driver")] pub driver: String, } -impl Default for CatalogSection { +impl Default for CatalogProvider { fn default() -> Self { Self { driver: default_sql_driver(), @@ -167,13 +174,15 @@ impl Default for CatalogSection { } } +/// Federation provider. #[derive(Debug, Deserialize, Clone)] -pub struct FederationSection { +pub struct FederationProvider { + /// Federation provider backend. #[serde(default = "default_sql_driver")] pub driver: String, } -impl Default for FederationSection { +impl Default for FederationProvider { fn default() -> Self { Self { driver: default_sql_driver(), @@ -181,35 +190,49 @@ impl Default for FederationSection { } } +/// Identity provider. #[derive(Debug, Deserialize, Clone)] -pub struct IdentitySection { +pub struct IdentityProvider { + /// Identity provider driver. #[serde(default = "default_sql_driver")] pub driver: String, + /// Default password hashing algorithm. #[serde(default)] pub password_hashing_algorithm: PasswordHashingAlgo, + + /// Maximal password length. pub max_password_length: usize, + + /// Default number of password hashing rounds. pub password_hash_rounds: Option, + + /// User options id to name mapping. + #[serde(default = "default_user_options_mapping")] + pub user_options_id_name_mapping: HashMap, } -impl Default for IdentitySection { +impl Default for IdentityProvider { fn default() -> Self { Self { driver: default_sql_driver(), password_hashing_algorithm: PasswordHashingAlgo::Bcrypt, max_password_length: 4096, password_hash_rounds: None, + user_options_id_name_mapping: default_user_options_mapping(), } } } +/// Resource provider (domain, project). #[derive(Debug, Deserialize, Clone)] -pub struct ResourceSection { +pub struct ResourceProvider { + /// Resource provider backend. #[serde(default = "default_sql_driver")] pub driver: String, } -impl Default for ResourceSection { +impl Default for ResourceProvider { fn default() -> Self { Self { driver: default_sql_driver(), @@ -219,7 +242,7 @@ impl Default for ResourceSection { /// Revoke provider configuration. #[derive(Debug, Deserialize, Clone)] -pub struct RevokeSection { +pub struct RevokeProvider { /// Entry point for the token revocation backend driver in the /// `keystone.revoke` namespace. Keystone only provides a `sql` driver. #[serde(default = "default_sql_driver")] @@ -229,7 +252,7 @@ pub struct RevokeSection { pub expiration_buffer: usize, } -impl Default for RevokeSection { +impl Default for RevokeProvider { fn default() -> Self { Self { driver: default_sql_driver(), @@ -238,19 +261,21 @@ impl Default for RevokeSection { } } +/// Password hashing algorithm. #[derive(Debug, Default, Deserialize, Clone)] pub enum PasswordHashingAlgo { + /// Bcrypt. #[default] Bcrypt, } /// Security compliance configuration. #[derive(Debug, Deserialize, Clone)] -pub struct SecurityComplianceSection { +pub struct SecurityComplianceProvider { /// The maximum number of days a user can go without authenticating before /// being considered "inactive" and automatically disabled (locked). /// This feature is disabled by default; set any value to enable - /// it. This feature depends on the sql backend for the [identity] driver. + /// it. This feature depends on the sql backend for the `[identity] driver`. /// When a user exceeds this threshold and is considered "inactive", the /// user's enabled attribute in the HTTP API may not match the value of /// the user’s enabled column in the user table. @@ -263,12 +288,12 @@ pub struct SecurityComplianceSection { /// options attribute ignore_change_password_upon_first_use to True for the /// desired user via the update user API. This feature is disabled by /// default. This feature is only applicable with the sql backend for the - /// [identity] driver. + /// `[identity] driver`. #[serde(default)] pub change_password_upon_first_use: bool, /// If report_invalid_password_hash is configured, defines the hash function - /// to be used by HMAC. Possible values are names suitable to hashlib.new() - /// [https://docs.python.org/3/library/hashlib.html#hashlib.new]. + /// to be used b`y HMAC. Possible values are names suitable to hashlib.new() + /// . #[serde(default)] pub invalid_password_hash_function: InvalidPasswordHashMethod, /// If report_invalid_password_hash is configured, uses provided secret key @@ -294,20 +319,19 @@ pub struct SecurityComplianceSection { /// The maximum number of times that a user can fail to authenticate before /// the user account is locked for the number of seconds specified by - /// [security_compliance] lockout_duration. This feature is disabled by - /// default. If this feature is enabled and [security_compliance] - /// lockout_duration is not set, then users may be locked out indefinitely + /// `[security_compliance] lockout_duration`. This feature is disabled by + /// default. If this feature is enabled and `[security_compliance] + /// lockout_duration` is not set, then users may be locked out indefinitely /// until the user is explicitly enabled via the API. This feature depends - /// on the sql backend for the [identity] driver. + /// on the sql backend for the `[identity] driver`. #[serde(default)] pub lockout_failure_attempts: Option, /// The number of seconds a user account will be locked when the maximum /// number of failed authentication attempts (as specified by - /// [security_compliance] lockout_failure_attempts) is exceeded. Setting + /// `[security_compliance] lockout_failure_attempts`) is exceeded. Setting /// this option will have no effect unless you also set - /// [security_compliance] lockout_failure_attempts to a non-zero value. This - /// feature depends on the sql backend for the [identity] driver. - //#[serde(default = "AccountLockoutDuration::default")] + /// `[security_compliance] lockout_failure_attempts` to a non-zero value. This + /// feature depends on the sql backend for the `[identity]` driver. #[serde( deserialize_with = "optional_timedelta_from_seconds", default = "AccountLockoutDuration::default" @@ -318,17 +342,17 @@ pub struct SecurityComplianceSection { /// in order to wipe out their password history and reuse an old password. /// This feature does not prevent administrators from manually resetting /// passwords. It is disabled by default and allows for immediate password - /// changes. This feature depends on the sql backend for the [identity] - /// driver. Note: If [security_compliance] password_expires_days is set, - /// then the value for this option should be less than the - /// password_expires_days. + /// changes. This feature depends on the sql backend for the `[identity] + /// driver` driver. Note: If `[security_compliance] password_expires_days` + /// is set, then the value for this option should be less than the + /// `password_expires_days`. #[serde(default)] pub minimum_password_age: u32, /// The number of days for which a password will be considered valid before /// requiring it to be changed. This feature is disabled by default. If /// enabled, new password changes will have an expiration date, /// however existing passwords would not be impacted. This feature depends - /// on the sql backend for the [identity] driver. + /// on the sql backend for the `[identity] driver`. #[serde(default)] pub password_expires_days: Option, /// The regular expression used to validate password strength requirements. @@ -336,7 +360,7 @@ pub struct SecurityComplianceSection { /// following is an example of a pattern which requires at least 1 letter, 1 /// digit, and have a minimum length of 7 characters: /// ^(?=.*\d)(?=.*[a-zA-Z]).{7,}$ This feature depends on the sql backend - /// for the [identity] driver. + /// for the `[identity] driver`. #[serde(default)] pub password_regex: Option, /// Describe your password regular expression here in language for humans. @@ -362,7 +386,7 @@ pub struct SecurityComplianceSection { /// The total number which includes the new password should not be greater /// or equal to this value. Setting the value to zero (the default) disables /// this feature. Thus, to enable this feature, values must be greater than - /// 0. This feature depends on the sql backend for the [identity] driver. + /// 0. This feature depends on the sql backend for the `[identity]` driver. #[serde(default)] pub unique_last_password_count: Option, } @@ -381,7 +405,7 @@ pub struct SecurityComplianceSection { // .ok_or_else(|| serde::de::Error::custom("TimeDelta overflow for seconds")) // } -/// Deserializes an Option and interprets Some(i64) as total SECONDS for +/// Deserializes an `Option` and interprets `Some(i64)` as total SECONDS for /// TimeDelta. fn optional_timedelta_from_seconds<'de, D>(deserializer: D) -> Result, D::Error> where @@ -402,7 +426,7 @@ where } } -impl Default for SecurityComplianceSection { +impl Default for SecurityComplianceProvider { fn default() -> Self { Self { disable_user_account_days_inactive: None, @@ -463,10 +487,12 @@ fn default_user_options_mapping() -> HashMap { ]) } +/// Token provider. #[derive(Debug, Default, Deserialize, Clone)] -pub struct TokenSection { +pub struct TokenProvider { + /// Token provider driver. #[serde(default)] - pub provider: TokenProvider, + pub provider: TokenProviderDriver, /// The amount of time that a token should remain valid (in seconds). /// Drastically reducing this value may break "long-running" operations /// that involve multiple services to coordinate together, and will @@ -479,8 +505,10 @@ pub struct TokenSection { pub expiration: usize, } +/// Token provider driver. #[derive(Debug, Default, Deserialize, Clone)] -pub enum TokenProvider { +pub enum TokenProviderDriver { + /// Fernet. #[default] #[serde(rename = "fernet")] Fernet, @@ -496,6 +524,21 @@ impl Config { builder.try_into() } + + /// Return oldest last_active_at date for the user to be considered active. + /// + /// When [`disable_user_account_days_inactive`](field@SecurityComplianceProvider::disable_user_account_days_inactive) + /// is set return the corresponding oldest user activity date for it to be considered as + /// disabled. When the option is not set returns `None`. + pub(crate) fn get_user_last_activity_cutof_date(&self) -> Option { + self.security_compliance + .disable_user_account_days_inactive + .and_then(|inactive_after_days| { + Utc::now() + .checked_sub_signed(TimeDelta::days(inactive_after_days.into())) + .map(|val| val.date_naive()) + }) + } } impl TryFrom> for Config { diff --git a/src/db/entity.rs b/src/db/entity.rs index 59fb4a94..27d7db04 100644 --- a/src/db/entity.rs +++ b/src/db/entity.rs @@ -86,20 +86,6 @@ impl Default for role::Model { } } -impl Default for user::Model { - fn default() -> Self { - Self { - id: String::new(), - extra: None, - enabled: None, - default_project_id: None, - created_at: None, - last_active_at: None, - domain_id: String::new(), - } - } -} - impl Default for service::Model { fn default() -> Self { Self { diff --git a/src/db/entity/password.rs b/src/db/entity/password.rs index ba7d5209..817c7860 100644 --- a/src/db/entity/password.rs +++ b/src/db/entity/password.rs @@ -19,6 +19,7 @@ use derive_builder::Builder; use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[cfg_attr(test, derive(Default))] #[cfg_attr(test, derive(Builder))] #[cfg_attr(test, builder(setter(strip_option, into)))] #[sea_orm(table_name = "password")] diff --git a/src/db/entity/user.rs b/src/db/entity/user.rs index 41292233..10c89dee 100644 --- a/src/db/entity/user.rs +++ b/src/db/entity/user.rs @@ -14,20 +14,30 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.4 +#[cfg(test)] +use derive_builder::Builder; use serde::Deserialize; use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Deserialize)] #[sea_orm(table_name = "user")] +#[cfg_attr(test, derive(Default))] +#[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, + #[cfg_attr(test, builder(default))] pub default_project_id: Option, + #[cfg_attr(test, builder(default))] pub created_at: Option, + #[cfg_attr(test, builder(default))] pub last_active_at: Option, pub domain_id: String, } diff --git a/src/identity/backends/error.rs b/src/identity/backends/error.rs index baaffb51..a6ab5d7f 100644 --- a/src/identity/backends/error.rs +++ b/src/identity/backends/error.rs @@ -29,6 +29,9 @@ pub enum IdentityDatabaseError { #[error("{0}")] GroupNotFound(String), + #[error("Date calculation error")] + DateError, + #[error(transparent)] Serde { #[from] diff --git a/src/identity/backends/sql/authenticate.rs b/src/identity/backends/sql/authenticate.rs index 222a6b62..6aa56363 100644 --- a/src/identity/backends/sql/authenticate.rs +++ b/src/identity/backends/sql/authenticate.rs @@ -11,7 +11,7 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 -//! Authentication implementation. +//! User account authentication implementation. use chrono::Utc; use sea_orm::DatabaseConnection; use tracing::info; @@ -62,7 +62,7 @@ pub async fn authenticate_by_password( if !user_opts .ignore_lockout_failure_attempts .is_some_and(|val| val) - && is_account_locked(config, db, &local_user).await? + && should_lock(config, db, &local_user).await? { return Err(AuthenticationError::UserLocked(local_user.user_id.clone()))?; } @@ -95,15 +95,26 @@ pub async fn authenticate_by_password( } } - let user = user::get_main_entry(db, &local_user.user_id).await?.ok_or( + let user_entry = user::get_main_entry(db, &local_user.user_id).await?.ok_or( IdentityDatabaseError::NoMainUserEntry(local_user.user_id.clone()), )?; - let user = - local_user::get_local_user_builder(config, &user, local_user, Some(passwords), user_opts) - .build()?; + + // Reset the last_active_at for the user that successfully authenticated. + user::reset_last_active(db, &user_entry).await?; + + let user_entry = UserResponseBuilder::default() + .merge_user_data( + &user_entry, + &user_opts, + config.get_user_last_activity_cutof_date().as_ref(), + ) + .merge_local_user_data(&local_user) + .merge_passwords_data(passwords) + .build()?; + Ok(AuthenticatedInfo::builder() - .user_id(user.id.clone()) - .user(user) + .user_id(user_entry.id.clone()) + .user(user_entry) .methods(vec!["password".into()]) .build() .map_err(AuthenticationError::from)?) @@ -116,7 +127,7 @@ pub async fn authenticate_by_password( /// attempts as described by /// [ADR-10](https://openstack-experimental.github.io/keystone/adr/0010-pci-dss-failed-auth-protection.html) #[tracing::instrument(level = "debug", skip(config, db))] -async fn is_account_locked( +async fn should_lock( config: &Config, db: &DatabaseConnection, local_user: &db_local_user::Model, @@ -164,11 +175,11 @@ mod tests { use super::*; #[tokio::test] - async fn test_is_account_locked_default_config() { + async fn test_should_lock_default_config() { let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); let config = Config::default(); assert!( - !is_account_locked(&config, &db, &db_local_user::Model::default(),) + !should_lock(&config, &db, &db_local_user::Model::default(),) .await .unwrap(), "Default config does not request any validation and user is not considered locked" @@ -176,12 +187,12 @@ mod tests { } #[tokio::test] - async fn test_is_account_locked_no_failed_auth_count() { + async fn test_should_lock_no_failed_auth_count() { let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); let mut config = Config::default(); config.security_compliance.lockout_failure_attempts = Some(5); assert!( - !is_account_locked( + !should_lock( &config, &db, &db_local_user::Model { @@ -195,7 +206,7 @@ mod tests { "User with unset failed_auth props is not considered locked" ); assert!( - !is_account_locked( + !should_lock( &config, &db, &db_local_user::Model { @@ -211,14 +222,14 @@ mod tests { } #[tokio::test] - async fn test_is_account_locked_no_failed_auth_at() { + async fn test_should_lock_no_failed_auth_at() { let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![db_local_user::Model::default()]]) .into_connection(); let mut config = Config::default(); config.security_compliance.lockout_failure_attempts = Some(5); assert!( - !is_account_locked( + !should_lock( &config, &db, &db_local_user::Model { @@ -248,7 +259,7 @@ mod tests { } #[tokio::test] - async fn test_is_account_locked_expired() { + async fn test_should_lock_expired() { let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![db_local_user::Model::default()]]) .into_connection(); @@ -256,7 +267,7 @@ mod tests { config.security_compliance.lockout_failure_attempts = Some(5); config.security_compliance.lockout_duration = Some(TimeDelta::seconds(100)); assert!( - !is_account_locked( + !should_lock( &config, &db, &db_local_user::Model { @@ -291,12 +302,12 @@ mod tests { } #[tokio::test] - async fn test_is_account_locked_lock() { + async fn test_should_lock_lock() { let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); let mut config = Config::default(); config.security_compliance.lockout_failure_attempts = Some(5); assert!( - is_account_locked( + should_lock( &config, &db, &db_local_user::Model { @@ -309,7 +320,7 @@ mod tests { "User with failed_auth_count > lockout_failure_attempts is locked for lockout_duration", ); assert!( - is_account_locked( + should_lock( &config, &db, &db_local_user::Model { @@ -322,7 +333,7 @@ mod tests { "User with failed_auth_count = lockout_failure_attempts is locked for lockout_duration", ); assert!( - !is_account_locked( + !should_lock( &config, &db, &db_local_user::Model { @@ -361,7 +372,8 @@ mod tests { .append_query_results([user_option::tests::get_user_options_mock( &UserOptions::default(), )]) - .append_query_results([vec![user::tests::get_user_mock("1")]]) + .append_query_results([vec![user::tests::get_user_mock("user_id")]]) + .append_query_results([vec![user::tests::get_user_mock("user_id")]]) .into_connection(); assert!( authenticate_by_password( @@ -377,6 +389,33 @@ mod tests { .is_ok(), "unlocked user with correct password should be allowed to login" ); + + // Checking transaction log + assert_eq!( + db.into_transaction_log(), + [ + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "local_user"."id" AS "A_id", "local_user"."user_id" AS "A_user_id", "local_user"."domain_id" AS "A_domain_id", "local_user"."name" AS "A_name", "local_user"."failed_auth_count" AS "A_failed_auth_count", "local_user"."failed_auth_at" AS "A_failed_auth_at", "password"."id" AS "B_id", "password"."local_user_id" AS "B_local_user_id", "password"."self_service" AS "B_self_service", "password"."created_at" AS "B_created_at", "password"."expires_at" AS "B_expires_at", "password"."password_hash" AS "B_password_hash", "password"."created_at_int" AS "B_created_at_int", "password"."expires_at_int" AS "B_expires_at_int" FROM "local_user" LEFT JOIN "password" ON "local_user"."id" = "password"."local_user_id" WHERE "local_user"."user_id" = $1 ORDER BY "local_user"."id" ASC, "password"."created_at_int" DESC"#, + ["user_id".into()] + ), + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "user_option"."user_id", "user_option"."option_id", "user_option"."option_value" FROM "user_option" WHERE "user_option"."user_id" = $1"#, + ["user_id".into()] + ), + 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"#, + ["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""#, + [Utc::now().date_naive().into(), "user_id".into()] + ), + ] + ); } #[tokio::test] @@ -453,7 +492,8 @@ mod tests { ignore_lockout_failure_attempts: Some(true), ..Default::default() })]) - .append_query_results([vec![user::tests::get_user_mock("1")]]) + .append_query_results([vec![user::tests::get_user_mock("user_id")]]) + .append_query_results([vec![user::tests::get_user_mock("user_id")]]) .into_connection(); assert!( authenticate_by_password( @@ -573,7 +613,8 @@ mod tests { ignore_password_expiry: Some(true), ..Default::default() })]) - .append_query_results([vec![user::tests::get_user_mock("1")]]) + .append_query_results([vec![user::tests::get_user_mock("user_id")]]) + .append_query_results([vec![user::tests::get_user_mock("user_id")]]) .into_connection(); assert!( authenticate_by_password( diff --git a/src/identity/backends/sql/federated_user.rs b/src/identity/backends/sql/federated_user.rs index 240d2192..8b1e377c 100644 --- a/src/identity/backends/sql/federated_user.rs +++ b/src/identity/backends/sql/federated_user.rs @@ -12,8 +12,7 @@ // // SPDX-License-Identifier: Apache-2.0 -use super::user; -use crate::db::entity::{federated_user as db_federated_user, user as db_user}; +use crate::db::entity::federated_user as db_federated_user; use crate::identity::types::*; mod create; @@ -22,30 +21,30 @@ mod find; pub use create::create; pub use find::find_by_idp_and_unique_id; -pub fn get_federated_user_builder>( - user: &db_user::Model, - data: F, - opts: UserOptions, -) -> UserResponseBuilder { - let mut user_builder: UserResponseBuilder = user::get_user_builder(user, opts); - let mut feds: Vec = Vec::new(); - if let Some(first) = data.into_iter().next() { - if let Some(name) = first.display_name { - user_builder.name(name.clone()); - } +impl UserResponseBuilder { + pub fn merge_federated_user_data(&mut self, data: I) -> &mut Self + where + I: IntoIterator, + { + let mut feds: Vec = Vec::new(); + if let Some(first) = data.into_iter().next() { + if let Some(name) = first.display_name { + self.name(name.clone()); + } - let mut fed = FederationBuilder::default(); - fed.idp_id(first.idp_id.clone()); - fed.unique_id(first.unique_id.clone()); - let protocol = FederationProtocol { - protocol_id: first.protocol_id.clone(), - unique_id: first.unique_id.clone(), - }; - fed.protocols(vec![protocol]); - if let Ok(fed_obj) = fed.build() { - feds.push(fed_obj); + let mut fed = FederationBuilder::default(); + fed.idp_id(first.idp_id.clone()); + fed.unique_id(first.unique_id.clone()); + let protocol = FederationProtocol { + protocol_id: first.protocol_id.clone(), + unique_id: first.unique_id.clone(), + }; + fed.protocols(vec![protocol]); + if let Ok(fed_obj) = fed.build() { + feds.push(fed_obj); + } } + self.federated(feds); + self } - user_builder.federated(feds); - user_builder } diff --git a/src/identity/backends/sql/local_user.rs b/src/identity/backends/sql/local_user.rs index ac865c12..690d1e7d 100644 --- a/src/identity/backends/sql/local_user.rs +++ b/src/identity/backends/sql/local_user.rs @@ -12,11 +12,7 @@ // // SPDX-License-Identifier: Apache-2.0 -use chrono::{DateTime, Days, Utc}; - -use super::user; -use crate::config::Config; -use crate::db::entity::{local_user as db_local_user, password as db_password, user as db_user}; +use crate::db::entity::local_user as db_local_user; use crate::identity::types::*; mod create; @@ -29,30 +25,11 @@ pub use load::load_local_user_with_passwords; pub use load::load_local_users_passwords; pub use set::reset_failed_auth; -pub fn get_local_user_builder>( - conf: &Config, - user: &db_user::Model, - data: db_local_user::Model, - passwords: Option

, - opts: UserOptions, -) -> UserResponseBuilder { - let mut user_builder: UserResponseBuilder = user::get_user_builder(user, opts); - user_builder.name(data.name.clone()); - if let Some(password_expires_days) = conf.security_compliance.password_expires_days - && let Some(pass) = passwords - && let (Some(current_password), Some(options)) = - (pass.into_iter().next(), user_builder.get_options()) - && let Some(false) = options.ignore_password_expiry.or(Some(false)) - && let Some(dt) = DateTime::from_timestamp_micros(current_password.created_at_int) - .unwrap_or(DateTime::from_naive_utc_and_offset( - current_password.created_at, - Utc, - )) - .checked_add_days(Days::new(password_expires_days)) - { - user_builder.password_expires_at(dt); +impl UserResponseBuilder { + pub fn merge_local_user_data(&mut self, data: &db_local_user::Model) -> &mut Self { + self.name(data.name.clone()); + self } - user_builder } #[cfg(test)] diff --git a/src/identity/backends/sql/nonlocal_user.rs b/src/identity/backends/sql/nonlocal_user.rs index 9c382ec9..eb600042 100644 --- a/src/identity/backends/sql/nonlocal_user.rs +++ b/src/identity/backends/sql/nonlocal_user.rs @@ -12,16 +12,12 @@ // // SPDX-License-Identifier: Apache-2.0 -use super::user; -use crate::db::entity::{nonlocal_user as db_nonlocal_user, user as db_user}; -use crate::identity::types::*; +use crate::db::entity::nonlocal_user as db_nonlocal_user; +use crate::identity::types::UserResponseBuilder; -pub fn get_nonlocal_user_builder( - user: &db_user::Model, - data: db_nonlocal_user::Model, - opts: UserOptions, -) -> UserResponseBuilder { - let mut user_builder: UserResponseBuilder = user::get_user_builder(user, opts); - user_builder.name(data.name.clone()); - user_builder +impl UserResponseBuilder { + pub fn merge_nonlocal_user_data(&mut self, data: &db_nonlocal_user::Model) -> &mut Self { + self.name(data.name.clone()); + self + } } diff --git a/src/identity/backends/sql/password.rs b/src/identity/backends/sql/password.rs index 8a0f979c..2c6b166e 100644 --- a/src/identity/backends/sql/password.rs +++ b/src/identity/backends/sql/password.rs @@ -11,9 +11,11 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 +use chrono::{DateTime, Utc}; + use crate::db::entity::password as db_password; use crate::identity::backends::error::IdentityDatabaseError; -use chrono::{DateTime, Utc}; +use crate::identity::types::UserResponseBuilder; mod create; @@ -34,6 +36,24 @@ pub(super) fn is_password_expired( Ok(false) } +impl UserResponseBuilder { + pub fn merge_passwords_data(&mut self, passwords: I) -> &mut Self + where + I: IntoIterator, + { + if let Some(latest_password) = passwords.into_iter().next() { + if let Some(microseconds) = latest_password.expires_at_int + && let Some(ts) = DateTime::from_timestamp_micros(microseconds) + { + self.password_expires_at(ts); + } else if let Some(expires_at) = latest_password.expires_at { + self.password_expires_at(expires_at.and_utc()); + } + } + self + } +} + #[cfg(test)] pub(super) mod tests { use crate::db::entity::password as db_password; diff --git a/src/identity/backends/sql/user.rs b/src/identity/backends/sql/user.rs index ce62d560..4c24f3d3 100644 --- a/src/identity/backends/sql/user.rs +++ b/src/identity/backends/sql/user.rs @@ -12,6 +12,7 @@ // // SPDX-License-Identifier: Apache-2.0 +use chrono::NaiveDate; use serde_json::Value; use tracing::error; @@ -22,35 +23,68 @@ mod create; mod delete; mod get; mod list; +mod set; pub use create::create; pub use delete::delete; pub use get::get; pub(super) use get::get_main_entry; pub use list::list; +pub use set::reset_last_active; -pub fn get_user_builder(user: &db_user::Model, opts: UserOptions) -> UserResponseBuilder { - let mut user_builder: UserResponseBuilder = UserResponseBuilder::default(); - user_builder.id(user.id.clone()); - user_builder.domain_id(user.domain_id.clone()); - // TODO: default enabled logic - user_builder.enabled(user.enabled.unwrap_or(false)); - if let Some(extra) = &user.extra { - user_builder.extra( - serde_json::from_str::(extra) - .inspect_err(|e| error!("failed to deserialize user extra: {e}")) - .unwrap_or_default(), - ); +impl UserResponseBuilder { + /// Merge the `user` table entry with corresponding user options into the [`UserResponseBuilder`]. + /// + /// Update the [`UserResponseBuilder`] with the details from the main `user` table row and the + /// corresponding user options. + /// + /// Calculates the [`UserResponse.enabled`](field@UserResponse::enabled) property according to + /// the following logic: + /// - When [`user.enabled`](field@db_user::Model::enabled) is `false` => `false` + /// - When [`ignore_user_inactivity`](field@UserOptions::ignore_user_inactivity) is true => `true` + /// - Otherwise when both set returns [`user.last_active_at`](field@db_user::Model::last_active_at) + /// `> last_activity_cutof_date`. Returns `true` when one or both are unset. + /// - Defaults to `false` + pub(super) fn merge_user_data( + &mut self, + user: &db_user::Model, + options: &UserOptions, + last_activity_cutof_date: Option<&NaiveDate>, + ) -> &mut Self { + self.id(user.id.clone()); + self.domain_id(user.domain_id.clone()); + self.enabled(if user.enabled.is_some_and(|val| val) { + // Only look at the last_activity when the user is enabled. + if let (Some(last_active_at), Some(cutoff)) = + (&user.last_active_at, &last_activity_cutof_date) + { + options.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 + }); + if let Some(extra) = &user.extra { + self.extra( + serde_json::from_str::(extra) + .inspect_err(|e| error!("failed to deserialize user extra: {e}")) + .unwrap_or_default(), + ); + } + self.options(options.clone()); + self } - - user_builder.options(opts); - - user_builder } #[cfg(test)] pub(super) mod tests { - use crate::db::entity::user as db_user; + use chrono::{DateTime, Utc}; + use serde_json::json; + + use super::*; + use crate::{db::entity::user as db_user, identity::types::UserResponseBuilder}; pub fn get_user_mock>(user_id: U) -> db_user::Model { db_user::Model { @@ -60,4 +94,133 @@ pub(super) mod tests { ..Default::default() } } + + fn get_user_builder() -> db_user::ModelBuilder { + let mut builder = db_user::ModelBuilder::default(); + builder.id("user_id"); + builder.domain_id("domain_id"); + builder + } + + #[test] + fn get_merge_user_data() { + let mut builder = UserResponseBuilder::default(); + + let opts = UserOptions { + ignore_password_expiry: Some(true), + ..Default::default() + }; + builder.name("user_name").merge_user_data( + &get_user_builder() + .enabled(true) + .last_active_at(Utc::now().date_naive()) + .extra("{\"foo\": \"bar\"}".to_string()) + .build() + .unwrap(), + &opts, + None, + ); + let user = builder.build().unwrap(); + assert_eq!(user.id, "user_id"); + assert_eq!(user.domain_id, "domain_id"); + assert_eq!(user.options, opts); + assert_eq!(user.extra.unwrap(), json!({"foo": "bar"})); + } + + #[test] + fn get_merge_user_data_enabled() { + assert!( + !UserResponseBuilder::default() + .name("user_name") + .merge_user_data( + &get_user_builder() + .enabled(false) + .last_active_at(Utc::now().date_naive()) + .build() + .unwrap(), + &UserOptions::default(), + Some(&Utc::now().date_naive()), + ) + .build() + .map(|u| u.enabled) + .unwrap(), + "disabled user with last active now and cutof in the past is disabled" + ); + assert!( + UserResponseBuilder::default() + .name("user_name") + .merge_user_data( + &get_user_builder() + .enabled(true) + .last_active_at(Utc::now().date_naive()) + .build() + .unwrap(), + &UserOptions::default(), + Some(&DateTime::::MIN_UTC.date_naive()), + ) + .build() + .map(|u| u.enabled) + .unwrap(), + "last active now and cutof in the past is enabled" + ); + + assert!( + !UserResponseBuilder::default() + .name("user_name") + .merge_user_data( + &get_user_builder() + .enabled(true) + .last_active_at(DateTime::::MIN_UTC.date_naive()) + .build() + .unwrap(), + &UserOptions::default(), + Some(&Utc::now().date_naive()), + ) + .build() + .map(|u| u.enabled) + .unwrap(), + "last active in the past and cutof now with unset exempt is disabled" + ); + + assert!( + !UserResponseBuilder::default() + .name("user_name") + .merge_user_data( + &get_user_builder() + .enabled(true) + .last_active_at(DateTime::::MIN_UTC.date_naive()) + .build() + .unwrap(), + &UserOptions { + ignore_user_inactivity: Some(false), + ..Default::default() + }, + Some(&Utc::now().date_naive()), + ) + .build() + .map(|u| u.enabled) + .unwrap(), + "last active in the past and cutof now with no exempt is disabled" + ); + assert!( + UserResponseBuilder::default() + .name("user_name") + .merge_user_data( + &get_user_builder() + .enabled(true) + .last_active_at(DateTime::::MIN_UTC.date_naive()) + .build() + .unwrap(), + &UserOptions { + ignore_user_inactivity: Some(true), + ..Default::default() + }, + Some(&Utc::now().date_naive()), + ) + .build() + .map(|u| u.enabled) + .unwrap(), + "last active in the past and cutof now with exempt is enabled" + ); + } } diff --git a/src/identity/backends/sql/user/create.rs b/src/identity/backends/sql/user/create.rs index 8907d7f5..529e7e1f 100644 --- a/src/identity/backends/sql/user/create.rs +++ b/src/identity/backends/sql/user/create.rs @@ -75,6 +75,8 @@ pub async fn create( user: UserCreate, ) -> Result { let main_user = create_main(conf, db, &user).await?; + let mut response_builder = UserResponseBuilder::default(); + response_builder.merge_user_data(&main_user, &UserOptions::default(), None); if let Some(federation_data) = &user.federated { let mut federated_entities: Vec = Vec::new(); for federated_user in federation_data { @@ -115,13 +117,7 @@ pub async fn create( } } - let builder = federated_user::get_federated_user_builder( - &main_user, - federated_entities, - UserOptions::default(), - ); - - Ok(builder.build()?) + response_builder.merge_federated_user_data(federated_entities); } else { // Local user let local_user = local_user::create(conf, db, &user).await?; @@ -137,18 +133,12 @@ pub async fn create( passwords.push(password_entry); } - Ok(local_user::get_local_user_builder( - conf, - &main_user, - local_user, - Some(passwords), - UserOptions::default(), - ) - .build()?) + response_builder + .merge_local_user_data(&local_user) + .merge_passwords_data(passwords); } - // let ub = common::get_user_builder(&main_user, Vec::new()).build()?; - // Ok(ub) + Ok(response_builder.build()?) } #[cfg(test)] diff --git a/src/identity/backends/sql/user/get.rs b/src/identity/backends/sql/user/get.rs index e362877a..f6d15841 100644 --- a/src/identity/backends/sql/user/get.rs +++ b/src/identity/backends/sql/user/get.rs @@ -14,18 +14,19 @@ use sea_orm::DatabaseConnection; use sea_orm::entity::*; +use sea_orm::query::*; -use super::super::federated_user; use super::super::local_user; -use super::super::nonlocal_user; use crate::config::Config; use crate::db::entity::{ + nonlocal_user as db_nonlocal_user, prelude::{FederatedUser, NonlocalUser, User as DbUser, UserOption}, user as db_user, }; use crate::identity::backends::sql::{IdentityDatabaseError, db_err}; use crate::identity::types::*; +/// Get the `user` table entry by the `user_id`. pub async fn get_main_entry>( db: &DatabaseConnection, user_id: U, @@ -41,12 +42,7 @@ pub async fn get( db: &DatabaseConnection, user_id: &str, ) -> Result, IdentityDatabaseError> { - let user_select = DbUser::find_by_id(user_id); - - let user_entry: Option = user_select - .one(db) - .await - .map_err(|err| db_err(err, "fetching the user data"))?; + let user_entry: Option = get_main_entry(db, user_id).await?; if let Some(user) = user_entry { let (user_opts, local_user_with_passwords) = tokio::join!( @@ -59,29 +55,27 @@ pub async fn get( ) ); - let user_builder: UserResponseBuilder = match local_user_with_passwords? { - Some(local_user_with_passwords) => local_user::get_local_user_builder( - conf, - &user, - local_user_with_passwords.0, - Some(local_user_with_passwords.1), - UserOptions::from_iter( - user_opts.map_err(|err| db_err(err, "fetching user options"))?, - ), - ), - _ => match user - .find_related(NonlocalUser) + let mut user_builder = UserResponseBuilder::default(); + user_builder.merge_user_data( + &user, + &UserOptions::from_iter(user_opts.map_err(|err| db_err(err, "fetching user options"))?), + conf.get_user_last_activity_cutof_date().as_ref(), + ); + + match local_user_with_passwords? { + Some(local_user_with_passwords) => { + user_builder.merge_local_user_data(&local_user_with_passwords.0); + user_builder.merge_passwords_data(local_user_with_passwords.1); + } + _ => match NonlocalUser::find() + .filter(db_nonlocal_user::Column::UserId.eq(&user.id)) .one(db) .await .map_err(|err| db_err(err, "fetching nonlocal user data"))? { - Some(nonlocal_user) => nonlocal_user::get_nonlocal_user_builder( - &user, - nonlocal_user, - UserOptions::from_iter( - user_opts.map_err(|err| db_err(err, "fetching user options"))?, - ), - ), + Some(nonlocal_user) => { + user_builder.merge_nonlocal_user_data(&nonlocal_user); + } _ => { let federated_user = user .find_related(FederatedUser) @@ -89,13 +83,7 @@ pub async fn get( .await .map_err(|err| db_err(err, "fetching federated user data"))?; if !federated_user.is_empty() { - federated_user::get_federated_user_builder( - &user, - federated_user, - UserOptions::from_iter( - user_opts.map_err(|err| db_err(err, "fetching user options"))?, - ), - ) + user_builder.merge_federated_user_data(federated_user); } else { return Err(IdentityDatabaseError::MalformedUser(user_id.to_string()))?; } diff --git a/src/identity/backends/sql/user/list.rs b/src/identity/backends/sql/user/list.rs index 0bd46f68..f6ebf1b1 100644 --- a/src/identity/backends/sql/user/list.rs +++ b/src/identity/backends/sql/user/list.rs @@ -16,9 +16,7 @@ use sea_orm::DatabaseConnection; use sea_orm::entity::*; use sea_orm::query::*; -use super::super::federated_user; use super::super::local_user; -use super::super::nonlocal_user; use crate::config::Config; use crate::db::entity::{ federated_user as db_federated_user, local_user as db_local_user, @@ -68,6 +66,8 @@ pub async fn list( local_user::load_local_users_passwords(db, locals.iter().cloned().map(|u| u.map(|x| x.id))) .await?; + let last_activity_cutof_date = conf.get_user_last_activity_cutof_date(); + let mut results: Vec = Vec::new(); for (u, (o, (l, (p, (n, f))))) in db_users.into_iter().zip( user_opts @@ -91,18 +91,23 @@ pub async fn list( if l.is_none() && n.is_none() && f.is_empty() { continue; } - let user_builder: UserResponseBuilder = if let Some(local) = l { - local_user::get_local_user_builder( - conf, - &u, - local, - p.map(|x| x.into_iter()), - UserOptions::from_iter(o), - ) + + let mut user_builder = UserResponseBuilder::default(); + user_builder.merge_user_data( + &u, + &UserOptions::from_iter(o), + last_activity_cutof_date.as_ref(), + ); + //user_builder.merge_options(&UserOptions::from_iter(o)); + if let Some(local) = l { + user_builder.merge_local_user_data(&local); + if let Some(pass) = p { + user_builder.merge_passwords_data(pass.into_iter()); + } } else if let Some(nonlocal) = n { - nonlocal_user::get_nonlocal_user_builder(&u, nonlocal, UserOptions::from_iter(o)) + user_builder.merge_nonlocal_user_data(&nonlocal); } else if !f.is_empty() { - federated_user::get_federated_user_builder(&u, f, UserOptions::from_iter(o)) + user_builder.merge_federated_user_data(f); } else { return Err(IdentityDatabaseError::MalformedUser(u.id))?; }; diff --git a/src/identity/backends/sql/user/set.rs b/src/identity/backends/sql/user/set.rs new file mode 100644 index 00000000..cc4905a4 --- /dev/null +++ b/src/identity/backends/sql/user/set.rs @@ -0,0 +1,65 @@ +// 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 +//! Set user properties. + +use chrono::Utc; +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; + +use crate::db::entity::user as db_user; +use crate::identity::backends::sql::{IdentityDatabaseError, db_err}; + +/// Reset the `user.last_active_at` to the current date. +pub async fn reset_last_active( + db: &DatabaseConnection, + user: &db_user::Model, +) -> Result { + let mut update: db_user::ActiveModel = user.clone().into(); + update.last_active_at = Set(Some(Utc::now().date_naive())); + update + .update(db) + .await + .map_err(|err| db_err(err, "resetting user's last_active_at")) +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + use sea_orm::{DatabaseBackend, MockDatabase, Transaction}; + + use super::super::tests::get_user_mock; + use super::*; + + #[tokio::test] + async fn test_reset_last_active() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_user_mock("user_id")]]) + .into_connection(); + assert!( + reset_last_active(&db, &get_user_mock("user_id")) + .await + .is_ok() + ); + + // Checking transaction log + assert_eq!( + 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""#, + [Utc::now().date_naive().into(), "user_id".into()] + ),] + ); + } +} diff --git a/src/identity/types/user.rs b/src/identity/types/user.rs index 5af23a28..4091e9a2 100644 --- a/src/identity/types/user.rs +++ b/src/identity/types/user.rs @@ -120,12 +120,6 @@ pub struct UserUpdate { pub federated: Option>, } -impl UserResponseBuilder { - pub fn get_options(&self) -> Option<&UserOptions> { - self.options.as_ref() - } -} - #[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] #[builder(setter(strip_option, into))] pub struct UserOptions { diff --git a/src/token/mod.rs b/src/token/mod.rs index 6324d418..7474f4bf 100644 --- a/src/token/mod.rs +++ b/src/token/mod.rs @@ -31,7 +31,7 @@ use crate::assignment::{ types::{Role, RoleAssignmentListParametersBuilder}, }; use crate::auth::{AuthenticatedInfo, AuthenticationError, AuthzInfo}; -use crate::config::{Config, TokenProvider as TokenProviderType}; +use crate::config::{Config, TokenProviderDriver}; use crate::identity::IdentityApi; use crate::keystone::ServiceState; use crate::resource::{ @@ -55,7 +55,7 @@ pub struct TokenProvider { impl TokenProvider { pub fn new(config: &Config) -> Result { let backend_driver = match config.token.provider { - TokenProviderType::Fernet => FernetTokenProvider::new(config.clone()), + TokenProviderDriver::Fernet => FernetTokenProvider::new(config.clone()), }; Ok(Self { config: config.clone(),