diff --git a/crates/api-types/src/error_conv.rs b/crates/api-types/src/error_conv.rs index 5679d7b4e..03a523f1c 100644 --- a/crates/api-types/src/error_conv.rs +++ b/crates/api-types/src/error_conv.rs @@ -298,6 +298,7 @@ impl From for KeystoneApiError { impl From for KeystoneApiError { fn from(value: ApplicationCredentialProviderError) -> Self { match value { + ApplicationCredentialProviderError::AuthenticationFailed => Self::UnauthorizedNoContext, ApplicationCredentialProviderError::ApplicationCredentialNotFound(x) => { Self::NotFound { resource: "application_credential".into(), @@ -319,7 +320,7 @@ impl From for KeystoneApiError { Self::BadRequest(err.to_string()) } ApplicationCredentialProviderError::ApplicationCredentialExpired => { - Self::BadRequest("application credential has expired".into()) + Self::UnauthorizedNoContext } ApplicationCredentialProviderError::AccessRuleInUse(_) => { Self::Conflict("application credential access rule is in use".into()) diff --git a/crates/api-types/src/v3/auth/token.rs b/crates/api-types/src/v3/auth/token.rs index df49b3d0c..3c6584ce9 100644 --- a/crates/api-types/src/v3/auth/token.rs +++ b/crates/api-types/src/v3/auth/token.rs @@ -199,6 +199,13 @@ pub struct Identity { #[cfg_attr(feature = "validate", validate(nested))] pub totp: Option, + /// The application credential object, contains the authentication + /// information for application credential authentication. + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "builder", builder(default))] + #[cfg_attr(feature = "validate", validate(nested))] + pub application_credential: Option, + /// Catch-all for a method name not among the builtins above - e.g. /// `identity.` for a `mode = full_auth` dynamic auth /// plugin (ADR 0025 §4). Bounded by the same overall request @@ -395,6 +402,90 @@ fn validate_token_auth_secret(value: &TokenAuth) -> Result<(), validator::Valida crate::common::validate_secret_length(&value.id, 1024) } +/// The application credential object for authentication. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr( + feature = "builder", + derive(derive_builder::Builder), + builder( + build_fn(error = "crate::error::BuilderError"), + setter(strip_option, into) + ) +)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +#[cfg_attr( + feature = "validate", + validate(schema(function = "validate_application_credential_auth_secret")) +)] +pub struct ApplicationCredentialAuth { + /// Application credential ID. + #[cfg_attr(feature = "builder", builder(default))] + #[cfg_attr(feature = "validate", validate(length(max = 64)))] + pub id: Option, + /// Application credential name (alternative to ID, requires `user`). + #[cfg_attr(feature = "builder", builder(default))] + #[cfg_attr(feature = "validate", validate(length(max = 255)))] + pub name: Option, + /// Application credential secret. Required for direct authentication, + /// absent when the payload is relabeled by a route-mode plugin. + /// Application credential secret. + #[cfg_attr(feature = "openapi", schema(value_type = String))] + #[serde(serialize_with = "crate::common::serialize_secret_string")] + pub secret: SecretString, + /// User reference, required when authenticating by name. + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "builder", builder(default))] + #[cfg_attr(feature = "validate", validate(nested))] + pub user: Option, +} + +#[cfg(feature = "validate")] +fn validate_application_credential_auth_secret( + value: &ApplicationCredentialAuth, +) -> Result<(), validator::ValidationError> { + crate::common::validate_secret_length(&value.secret, 255) +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr( + feature = "builder", + derive(derive_builder::Builder), + builder( + build_fn(error = "crate::error::BuilderError", validate = "Self::validate"), + setter(strip_option, into) + ) +)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ApplicationCredentialUser { + /// User ID. + #[cfg_attr(feature = "builder", builder(default))] + #[cfg_attr(feature = "validate", validate(length(max = 64)))] + pub id: Option, + /// User name. + #[cfg_attr(feature = "builder", builder(default))] + #[cfg_attr(feature = "validate", validate(length(max = 255)))] + pub name: Option, + /// User domain (needed when resolving by name). + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "builder", builder(default))] + #[cfg_attr(feature = "validate", validate(nested))] + pub domain: Option, +} + +#[cfg(feature = "builder")] +impl ApplicationCredentialUserBuilder { + fn validate(&self) -> Result<(), String> { + let has_id = self.id.as_ref().is_some_and(|v| v.is_some()); + let has_name = self.name.as_ref().is_some_and(|v| v.is_some()); + if !has_id && !has_name { + return Err("application credential user requires at least id or name".into()); + } + Ok(()) + } +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))] #[cfg_attr(feature = "validate", derive(validator::Validate))] diff --git a/crates/appcred-driver-sql/src/application_credential.rs b/crates/appcred-driver-sql/src/application_credential.rs index fa1cb0dfd..c5db41798 100644 --- a/crates/appcred-driver-sql/src/application_credential.rs +++ b/crates/appcred-driver-sql/src/application_credential.rs @@ -29,10 +29,12 @@ mod create; mod delete; mod get; mod list; +mod verify; pub use create::create; pub use delete::delete; pub use get::get; pub use list::list; +pub use verify::verify_secret; impl TryFrom for ApplicationCredentialBuilder { type Error = ApplicationCredentialProviderError; diff --git a/crates/appcred-driver-sql/src/application_credential/verify.rs b/crates/appcred-driver-sql/src/application_credential/verify.rs new file mode 100644 index 000000000..8ce68d109 --- /dev/null +++ b/crates/appcred-driver-sql/src/application_credential/verify.rs @@ -0,0 +1,138 @@ +// 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 +//! # Verify application credential +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; +use sea_orm::query::*; +use secrecy::SecretString; + +use openstack_keystone_config::Config; +use openstack_keystone_core::application_credential::ApplicationCredentialProviderError; +use openstack_keystone_core::error::DbContextExt; +use openstack_keystone_password_hashing as password_hashing; + +use crate::entity::{ + application_credential as db_application_credential, + prelude::ApplicationCredential as DbApplicationCredential, +}; + +pub async fn verify_secret( + config: &Config, + db: &DatabaseConnection, + credential_id: &str, + secret: &SecretString, +) -> Result<(), ApplicationCredentialProviderError> { + let record = DbApplicationCredential::find() + .filter(db_application_credential::Column::Id.eq(credential_id)) + .one(db) + .await + .context("looking up application credential for authentication")? + .ok_or(ApplicationCredentialProviderError::AuthenticationFailed)?; + + // record is db_application_credential::Model, which has secret_hash + let matched = password_hashing::verify_password(config, secret, &record.secret_hash) + .await + .map_err(ApplicationCredentialProviderError::password_hash)?; + + if !matched { + return Err(ApplicationCredentialProviderError::AuthenticationFailed); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use chrono::{DateTime, Utc}; + use sea_orm::{DatabaseBackend, MockDatabase, Transaction}; + + use openstack_keystone_config::PasswordHashingAlgo; + + use super::*; + use crate::entity::application_credential as db_application_credential; + + fn make_app_cred_model(id: &str, secret_hash: &str) -> db_application_credential::Model { + db_application_credential::Model { + internal_id: 1, + id: id.to_string(), + name: "fake appcred".to_string(), + secret_hash: secret_hash.to_string(), + description: Some("description".to_string()), + user_id: "user_id".to_string(), + project_id: Some("project_id".to_string()), + expires_at: Some(DateTime::::MIN_UTC.timestamp_micros()), + system: None, + unrestricted: Some(true), + } + } + + #[tokio::test] + async fn test_verify_secret_success() { + let mut config = Config::default(); + config.identity.password_hashing_algorithm = PasswordHashingAlgo::None; + + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![make_app_cred_model("app_cred_id", "test_secret")]]) + .into_connection(); + + let result = verify_secret(&config, &db, "app_cred_id", &"test_secret".into()).await; + + assert!(result.is_ok()); + + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "application_credential"."internal_id", "application_credential"."id", "application_credential"."name", "application_credential"."secret_hash", "application_credential"."description", "application_credential"."user_id", "application_credential"."project_id", "application_credential"."expires_at", "application_credential"."system", "application_credential"."unrestricted" FROM "application_credential" WHERE "application_credential"."id" = $1 LIMIT $2"#, + ["app_cred_id".into(), 1u64.into()] + )] + ); + } + + #[tokio::test] + async fn test_verify_secret_wrong_secret() { + let mut config = Config::default(); + config.identity.password_hashing_algorithm = PasswordHashingAlgo::None; + + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![make_app_cred_model("app_cred_id", "test_secret")]]) + .into_connection(); + + let result = verify_secret(&config, &db, "app_cred_id", &"wrong_secret".into()).await; + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ApplicationCredentialProviderError::AuthenticationFailed + )); + } + + #[tokio::test] + async fn test_verify_secret_not_found() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([Vec::::new()]) + .into_connection(); + + let mut config = Config::default(); + config.identity.password_hashing_algorithm = PasswordHashingAlgo::None; + + let result = verify_secret(&config, &db, "nonexistent_id", &"test_secret".into()).await; + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ApplicationCredentialProviderError::AuthenticationFailed + )); + } +} diff --git a/crates/appcred-driver-sql/src/lib.rs b/crates/appcred-driver-sql/src/lib.rs index 13146bf1d..fb59f0d65 100644 --- a/crates/appcred-driver-sql/src/lib.rs +++ b/crates/appcred-driver-sql/src/lib.rs @@ -13,6 +13,7 @@ // SPDX-License-Identifier: Apache-2.0 //! # OpenStack Keystone Application Credential SQL driver +use secrecy::SecretString; use std::sync::Arc; use async_trait::async_trait; @@ -195,6 +196,33 @@ impl ApplicationCredentialBackend for SqlBackend { ) -> Result, ApplicationCredentialProviderError> { Ok(application_credential::list(&state.db.connection(), params).await?) } + + /// Verify an application credential's secret against the stored hash. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `credential_id`: The ID of the application credential. + /// - `secret`: The plaintext secret to verify. + /// + /// # Returns + /// - `Ok(())` if the secret matches the stored hash. + /// - `Err(ApplicationCredentialProviderError::AuthenticationFailed)` if + /// the credential does not exist or the secret does not match. + async fn verify_application_credential_secret( + &self, + state: &ServiceState, + credential_id: &str, + secret: &SecretString, + ) -> Result<(), ApplicationCredentialProviderError> { + let config = state.config_manager.config.read().await; + application_credential::verify_secret( + &config, + &state.db.connection(), + credential_id, + secret, + ) + .await + } } #[async_trait] diff --git a/crates/core-types/src/application_credential/application_credential.rs b/crates/core-types/src/application_credential/application_credential.rs index 7b908d9b1..14ecf547e 100644 --- a/crates/core-types/src/application_credential/application_credential.rs +++ b/crates/core-types/src/application_credential/application_credential.rs @@ -69,6 +69,53 @@ pub struct ApplicationCredential { #[validate(length(min = 1, max = 64))] pub user_id: String, } +/// Application credential authentication request. +/// +/// Carries the secret and the credential identifier (by ID or by name). +/// Used by [`ApplicationCredentialApi::authenticate_by_application_credential`]. +#[derive(Clone, Debug)] +pub struct ApplicationCredentialAuthRequest { + /// The secret to verify against the stored hash. + pub secret: SecretString, + /// How the credential is identified. + pub credential: ApplicationCredentialAuthData, +} + +/// How the application credential is identified for authentication. +#[derive(Clone, Debug)] +pub enum ApplicationCredentialAuthData { + /// By credential ID, with an optional user reference. + Id(ApplicationCredentialAuthById), + /// By credential name, with a required user reference. + Name(ApplicationCredentialAuthByName), +} + +/// Identify an application credential by its ID. +#[derive(Builder, Clone, Debug, Validate)] +#[builder(build_fn(error = "BuilderError"))] +#[builder(setter(strip_option, into))] +pub struct ApplicationCredentialAuthById { + /// The credential ID. + #[validate(length(min = 1, max = 64))] + pub id: String, + /// Optional user reference. + #[builder(default)] + #[validate(nested)] + pub user: Option, +} + +/// Identify an application credential by name (requires a user reference). +#[derive(Builder, Clone, Debug, Validate)] +#[builder(build_fn(error = "BuilderError"))] +#[builder(setter(into))] +pub struct ApplicationCredentialAuthByName { + /// The credential name. + #[validate(length(min = 1, max = 255))] + pub name: String, + /// The owning user (required — name is unique per user). + #[validate(nested)] + pub user: UserAuthRef, +} /// The created application credential object. #[derive(Builder, Clone, Debug, Validate)] @@ -203,6 +250,35 @@ pub struct ApplicationCredentialListParameters { pub user_id: String, } +/// Reference to a user for credential resolution. +#[derive(Builder, Clone, Debug, Validate)] +#[builder(build_fn(error = "BuilderError"))] +#[builder(setter(strip_option, into))] +#[validate(schema(function = "validate_user_auth_ref"))] +pub struct UserAuthRef { + /// User ID. + #[builder(default)] + #[validate(length(max = 64))] + pub id: Option, + /// User name. + #[builder(default)] + #[validate(length(max = 255))] + pub name: Option, + /// User domain (needed when resolving by name). + #[builder(default)] + pub domain: Option, +} + +/// Validates that at least `id` or `name` is present. +fn validate_user_auth_ref(value: &UserAuthRef) -> Result<(), validator::ValidationError> { + if value.id.is_none() && value.name.is_none() { + return Err(validator::ValidationError::new( + "user reference requires at least id or name", + )); + } + Ok(()) +} + impl From for ApplicationCredential { fn from(value: ApplicationCredentialCreateResponse) -> Self { Self { diff --git a/crates/core-types/src/application_credential/error.rs b/crates/core-types/src/application_credential/error.rs index 1e6e918d1..6ee6d8194 100644 --- a/crates/core-types/src/application_credential/error.rs +++ b/crates/core-types/src/application_credential/error.rs @@ -53,6 +53,10 @@ pub enum ApplicationCredentialProviderError { #[error("application credential has expired")] ApplicationCredentialExpired, + /// Authentication with application credential failed. + #[error("authentication failed")] + AuthenticationFailed, + /// Conflict. #[error("conflict: {0}")] Conflict(String), @@ -103,6 +107,11 @@ pub enum ApplicationCredentialProviderError { #[from] source: BuilderError, }, + + /// Per-user rate limit exceeded (ADR-0022). + #[error("rate limit exceeded, retry after {retry_after_secs}s")] + TooManyRequests { retry_after_secs: u64 }, + /// Unsupported driver. #[error("unsupported driver `{0}` for the application credential provider")] UnsupportedDriver(String), diff --git a/crates/core/src/application_credential/backend.rs b/crates/core/src/application_credential/backend.rs index 2604b7805..b3ededac7 100644 --- a/crates/core/src/application_credential/backend.rs +++ b/crates/core/src/application_credential/backend.rs @@ -14,6 +14,7 @@ //! # Application credential provider backend use async_trait::async_trait; +use secrecy::SecretString; use openstack_keystone_core_types::application_credential::*; @@ -151,4 +152,22 @@ pub trait ApplicationCredentialBackend: Send + Sync { state: &ServiceState, params: &ApplicationCredentialListParameters, ) -> Result, ApplicationCredentialProviderError>; + + /// Verify an application credential's secret against the stored hash. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `credential_id`: The ID of the application credential. + /// - `secret`: The plaintext secret to verify. + /// + /// # Returns + /// - `Ok(())` if the secret matches the stored hash. + /// - `Err(ApplicationCredentialProviderError::AuthenticationFailed)` if + /// the credential does not exist or the secret does not match. + async fn verify_application_credential_secret( + &self, + state: &ServiceState, + credential_id: &str, + secret: &SecretString, + ) -> Result<(), ApplicationCredentialProviderError>; } diff --git a/crates/core/src/application_credential/provider_api.rs b/crates/core/src/application_credential/provider_api.rs index 0372d792e..957e2fda2 100644 --- a/crates/core/src/application_credential/provider_api.rs +++ b/crates/core/src/application_credential/provider_api.rs @@ -15,6 +15,7 @@ use async_trait::async_trait; use crate::application_credential::error::ApplicationCredentialProviderError; +use crate::auth::AuthenticationResult; use crate::auth::ExecutionContext; use openstack_keystone_core_types::application_credential::*; @@ -148,4 +149,28 @@ pub trait ApplicationCredentialApi: Send + Sync { ctx: &ExecutionContext<'a>, params: &ApplicationCredentialListParameters, ) -> Result, ApplicationCredentialProviderError>; + + /// Authenticate using an application credential. + /// + /// Resolves the credential (by ID, or by name + owning user ID), + /// verifies the secret, checks that the credential has not expired + /// and that the owning user, bound project, and project domain are + /// all enabled, then returns an [`AuthenticationResult`] populated + /// with [`AuthenticationContext::ApplicationCredential`]. + /// + /// # Parameters + /// - `ctx`: The execution context. + /// - `id`: The application credential ID (optional if `name` is given). + /// - `name`: The application credential name (optional if `id` is given). + /// - `user_id`: The owning user ID (required when resolving by `name`). + /// - `secret`: The application credential secret. + /// + /// # Returns + /// - `Result` - + /// The authentication result or an error. + async fn authenticate_by_application_credential<'a>( + &self, + ctx: &ExecutionContext<'a>, + auth: &ApplicationCredentialAuthRequest, + ) -> Result; } diff --git a/crates/core/src/application_credential/service.rs b/crates/core/src/application_credential/service.rs index d451ddacd..c34042a50 100644 --- a/crates/core/src/application_credential/service.rs +++ b/crates/core/src/application_credential/service.rs @@ -17,6 +17,7 @@ use std::sync::Arc; use async_trait::async_trait; use base64::{Engine as _, engine::general_purpose}; +use chrono::Utc; use rand::{RngExt, rng}; use secrecy::SecretString; use tracing::warn; @@ -32,10 +33,13 @@ use crate::application_credential::{ ApplicationCredentialApi, ApplicationCredentialProviderError, backend::ApplicationCredentialBackend, }; -use crate::auth::ExecutionContext; +use crate::auth::{ + AuthenticationContext, AuthenticationResult, AuthenticationResultBuilder, ExecutionContext, + IdentityInfo, PrincipalInfo, UserIdentityInfoBuilder, +}; + use crate::events::AuditDispatchError; use crate::plugin_manager::PluginManagerApi; - /// Application Credential Provider. pub struct ApplicationCredentialService { backend_driver: Arc, @@ -64,6 +68,114 @@ impl ApplicationCredentialService { #[async_trait] impl ApplicationCredentialApi for ApplicationCredentialService { + /// Authenticate using an application credential. + /// + /// Resolves the credential (by ID, or by name + owning user ID), + /// verifies the secret against the stored hash, checks that the + /// credential has not expired, and validates that the owning user, + /// bound project, and project domain are all enabled. + /// + /// # Parameters + /// - `ctx`: The execution context. + /// - `id`: The application credential ID (optional if `name` is given). + /// - `name`: The application credential name (optional if `id` is given). + /// - `user_id`: The owning user ID (required when resolving by `name`). + /// - `secret`: The application credential secret. + /// + /// # Returns + /// - `Result` - + /// The authentication result populated with + /// [`AuthenticationContext::ApplicationCredential`] on success, or an + /// error. + async fn authenticate_by_application_credential<'a>( + &self, + ctx: &ExecutionContext<'a>, + auth: &ApplicationCredentialAuthRequest, + ) -> Result { + let state = ctx.state(); + + // --- 1. Resolve the credential --- + let app_cred = match &auth.credential { + ApplicationCredentialAuthData::Id(by_id) => self + .get_application_credential(ctx, &by_id.id) + .await? + .ok_or(ApplicationCredentialProviderError::AuthenticationFailed)?, + ApplicationCredentialAuthData::Name(by_name) => { + let uid = by_name + .user + .id + .as_deref() + .ok_or(ApplicationCredentialProviderError::AuthenticationFailed)?; + let params = ApplicationCredentialListParameters { + name: Some(by_name.name.clone()), + user_id: uid.to_string(), + ..Default::default() + }; + self.list_application_credentials(ctx, ¶ms) + .await? + .into_iter() + .next() + .ok_or(ApplicationCredentialProviderError::AuthenticationFailed)? + } + }; + + // --- 2. Rate limit (ADR-0022) --- + if state.rate_limiters.user_auth_enabled() { + if let Err(retry_after) = state.rate_limiters.check_user(&app_cred.user_id) { + return Err(ApplicationCredentialProviderError::TooManyRequests { + retry_after_secs: retry_after.as_secs(), + }); + } + } + + // --- 3. Verify the secret --- + self.backend_driver + .verify_application_credential_secret(state, &app_cred.id, &auth.secret) + .await?; + + // --- 4. Check credential expiration --- + if let Some(expires_at) = app_cred.expires_at { + if expires_at < Utc::now() { + return Err(ApplicationCredentialProviderError::ApplicationCredentialExpired); + } + } + + // --- 5. Fetch user for identity info --- + let user = state + .provider + .get_identity_provider() + .get_user(ctx, &app_cred.user_id) + .await + .map_err(|_| ApplicationCredentialProviderError::AuthenticationFailed)? + .ok_or(ApplicationCredentialProviderError::AuthenticationFailed)?; + + // --- 6. Fetch user domain for identity info --- + let user_domain = state + .provider + .get_resource_provider() + .get_domain(ctx, &user.domain_id) + .await + .map_err(|_| ApplicationCredentialProviderError::AuthenticationFailed)? + .ok_or(ApplicationCredentialProviderError::AuthenticationFailed)?; + + // --- 7. Build the authentication result --- + Ok(AuthenticationResultBuilder::default() + .context(AuthenticationContext::ApplicationCredential { + application_credential: app_cred, + token: None, + }) + .principal(PrincipalInfo { + identity: IdentityInfo::User( + UserIdentityInfoBuilder::default() + .user_id(user.id.clone()) + .user(user) + .user_domain(user_domain) + .build()?, + ), + }) + .build()?) + } + /// Create a standalone access rule owned by a user. /// /// # Parameters diff --git a/crates/core/src/mocks.rs b/crates/core/src/mocks.rs index 3b922e894..80aeaf0f3 100644 --- a/crates/core/src/mocks.rs +++ b/crates/core/src/mocks.rs @@ -473,61 +473,67 @@ mod application_credential { }; mock! { - pub ApplicationCredentialProvider {} - - #[async_trait] - impl ApplicationCredentialApi for ApplicationCredentialProvider { - async fn create_access_rule<'a>( - &self, - ctx: &ExecutionContext<'a>, - rule: AccessRuleCreate, - ) -> Result; - - async fn create_application_credential<'a>( - &self, - ctx: &ExecutionContext<'a>, - rec: ApplicationCredentialCreate, - ) -> Result; - - async fn delete_access_rule<'a>( - &self, - ctx: &ExecutionContext<'a>, - user_id: &'a str, - id: &'a str, - ) -> Result<(), ApplicationCredentialProviderError>; - - async fn delete_application_credential<'a>( - &self, - ctx: &ExecutionContext<'a>, - rec: ApplicationCredential, - ) -> Result<(), ApplicationCredentialProviderError>; - - async fn get_access_rule<'a>( - &self, - ctx: &ExecutionContext<'a>, - user_id: &'a str, - id: &'a str, - ) -> Result, ApplicationCredentialProviderError>; - - async fn get_application_credential<'a>( - &self, - ctx: &ExecutionContext<'a>, - id: &'a str, - ) -> Result, ApplicationCredentialProviderError>; - - async fn list_access_rules<'a>( - &self, - ctx: &ExecutionContext<'a>, - user_id: &'a str, - ) -> Result, ApplicationCredentialProviderError>; - - async fn list_application_credentials<'a>( - &self, - ctx: &ExecutionContext<'a>, - params: &ApplicationCredentialListParameters, - ) -> Result, ApplicationCredentialProviderError>; + pub ApplicationCredentialProvider {} + + #[async_trait] + impl ApplicationCredentialApi for ApplicationCredentialProvider { + async fn create_access_rule<'a>( + &self, + ctx: &ExecutionContext<'a>, + rule: AccessRuleCreate, + ) -> Result; + + async fn create_application_credential<'a>( + &self, + ctx: &ExecutionContext<'a>, + rec: ApplicationCredentialCreate, + ) -> Result; + + async fn delete_access_rule<'a>( + &self, + ctx: &ExecutionContext<'a>, + user_id: &'a str, + id: &'a str, + ) -> Result<(), ApplicationCredentialProviderError>; + + async fn delete_application_credential<'a>( + &self, + ctx: &ExecutionContext<'a>, + rec: ApplicationCredential, + ) -> Result<(), ApplicationCredentialProviderError>; + + async fn get_access_rule<'a>( + &self, + ctx: &ExecutionContext<'a>, + user_id: &'a str, + id: &'a str, + ) -> Result, ApplicationCredentialProviderError>; + + async fn get_application_credential<'a>( + &self, + ctx: &ExecutionContext<'a>, + id: &'a str, + ) -> Result, ApplicationCredentialProviderError>; + + async fn list_access_rules<'a>( + &self, + ctx: &ExecutionContext<'a>, + user_id: &'a str, + ) -> Result, ApplicationCredentialProviderError>; + + async fn list_application_credentials<'a>( + &self, + ctx: &ExecutionContext<'a>, + params: &ApplicationCredentialListParameters, + ) -> Result, ApplicationCredentialProviderError>; + + async fn authenticate_by_application_credential<'a>( + &self, + ctx: &ExecutionContext<'a>, + auth: &ApplicationCredentialAuthRequest, + ) -> Result; + } } - } } pub use application_credential::MockApplicationCredentialProvider; diff --git a/crates/core/src/token/service.rs b/crates/core/src/token/service.rs index fd73d252b..5cf32ffb1 100644 --- a/crates/core/src/token/service.rs +++ b/crates/core/src/token/service.rs @@ -504,8 +504,44 @@ impl TokenApi for TokenService { scope: &ScopeInfo, ) -> Result { let mut sc = ctx.clone(); - sc.set_authorization_scope(scope.clone())?; + // Application credential auth: auto-derive the project scope from + // the credential's bound project when no explicit scope is provided. + // The credential is always bound to exactly one project. + let effective_scope = if let ( + AuthenticationContext::ApplicationCredential { + application_credential, + .. + }, + ScopeInfo::Unscoped, + ) = (ctx.authentication_context(), scope) + { + let exec_ctx = ExecutionContext::internal(state); + let project = state + .provider + .get_resource_provider() + .get_project(&exec_ctx, &application_credential.project_id) + .await? + .ok_or(ResourceProviderError::ProjectNotFound( + application_credential.project_id.clone(), + ))?; + let project_domain = state + .provider + .get_resource_provider() + .get_domain(&exec_ctx, &project.domain_id) + .await? + .ok_or(ResourceProviderError::DomainNotFound( + project.domain_id.clone(), + ))?; + ScopeInfo::Project { + project, + project_domain, + } + } else { + scope.clone() + }; + + sc.set_authorization_scope(effective_scope.clone())?; // `issued_at` is the token's creation timestamp at whole-second // precision (parity with python keystone's fernet formatter, and with // the fernet envelope timestamp this same token decodes back to). @@ -515,7 +551,7 @@ impl TokenApi for TokenService { Utc::now().trunc_subsecs(0), )?; sc.set_token(token); - let vsc = ValidatedSecurityContext::new_for_scope(sc, scope.clone(), state).await?; + let vsc = ValidatedSecurityContext::new_for_scope(sc, effective_scope, state).await?; // ADR 0031 "Tokens": `keystone_token_issued_total{driver,method}`. // Only recorded when the authentication context maps to one of the @@ -532,7 +568,6 @@ impl TokenApi for TokenService { Ok(vsc) } - /// Encode the token into a `String` representation. /// /// # Parameters diff --git a/crates/keystone/src/api/v3/auth/token/common.rs b/crates/keystone/src/api/v3/auth/token/common.rs index 24c34d452..1193dd96c 100644 --- a/crates/keystone/src/api/v3/auth/token/common.rs +++ b/crates/keystone/src/api/v3/auth/token/common.rs @@ -25,6 +25,10 @@ use openstack_keystone_core::auth_plugin_auth::{ WasmPluginAuthError, WasmPluginAuthRequest, authenticate_via_wasm_mapping_plugin, authenticate_via_wasm_plugin, route_via_wasm_plugin, }; +use openstack_keystone_core_types::application_credential::{ + ApplicationCredentialAuthById, ApplicationCredentialAuthByName, ApplicationCredentialAuthData, + ApplicationCredentialAuthRequest, UserAuthRef, +}; /// Authenticate the user ignoring any scope information. It is important not to /// expose any hints that user, project, domain, etc might exist before we have @@ -172,6 +176,51 @@ pub(super) async fn authenticate_request( token_restriction: vsc.inner().token_restriction().cloned(), }; res.push(auth_res); + } else if method == "application_credential" { + if let Some(app_cred) = &req.auth.identity.application_credential { + let credential = if let Some(id) = &app_cred.id { + ApplicationCredentialAuthData::Id(ApplicationCredentialAuthById { + id: id.clone(), + user: app_cred.user.as_ref().map(|u| UserAuthRef { + id: u.id.clone(), + name: u.name.clone(), + domain: u.domain.clone().map(Into::into), + }), + }) + } else if let Some(name) = &app_cred.name { + let user = app_cred.user.as_ref().ok_or(KeystoneApiError::BadRequest( + "application_credential.user is required when using name".into(), + ))?; + ApplicationCredentialAuthData::Name(ApplicationCredentialAuthByName { + name: name.clone(), + user: UserAuthRef { + id: user.id.clone(), + name: user.name.clone(), + domain: user.domain.clone().map(Into::into), + }, + }) + } else { + return Err(KeystoneApiError::BadRequest( + "application_credential.id or .name is required".into(), + )); + }; + + let auth_req = ApplicationCredentialAuthRequest { + secret: app_cred.secret.clone(), + credential, + }; + + res.push( + state + .provider + .get_application_credential_provider() + .authenticate_by_application_credential( + &ExecutionContext::internal(state), + &auth_req, + ) + .await?, + ); + } } else if let Some(payload) = effective_extra.get(method) { // Unrecognized method name with a matching request body block - // dispatch to a loaded `mode = full_auth` dynamic auth plugin @@ -284,6 +333,7 @@ mod tests { }), token: None, totp: None, + application_credential: None, extra: Default::default(), }, scope: None, @@ -336,6 +386,7 @@ mod tests { methods: vec!["totp".to_string()], password: None, token: None, + application_credential: None, totp: Some(TotpAuth { user: TotpUserBuilder::default() .id("uid") @@ -429,6 +480,7 @@ mod tests { id: "fake_token".into() }), totp: None, + application_credential: None, extra: Default::default(), }, scope: None, @@ -502,6 +554,7 @@ mod tests { }), totp: None, extra: Default::default(), + application_credential: None, }, scope: None, }, @@ -514,6 +567,199 @@ mod tests { assert!(matches!(result, Err(KeystoneApiError::Unauthorized { .. }))); } + #[tokio::test] + async fn test_authenticate_request_application_credential_by_id() { + let auth = AuthenticationResultBuilder::default() + .context(AuthenticationContext::ApplicationCredential { + application_credential: openstack_keystone_core_types::application_credential::ApplicationCredentialBuilder::default() + .id("app_cred_id") + .name("my_app_cred") + .project_id("pid") + .user_id("uid") + .unrestricted(false) + .roles(vec![]) + .build() + .unwrap(), + token: None, + }) + .principal(PrincipalInfo { + identity: IdentityInfo::User( + UserIdentityInfoBuilder::default() + .user_id("uid") + .build() + .unwrap(), + ), + }) + .build() + .unwrap(); + let auth_clone = auth.clone(); + + let mut app_cred_mock = + crate::application_credential::MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_authenticate_by_application_credential() + .withf(|_, auth: &ApplicationCredentialAuthRequest| { + matches!(&auth.credential, ApplicationCredentialAuthData::Id(by_id) if by_id.id == "app_cred_id") + && auth.secret.expose_secret() == "app_cred_secret" +}) + .returning(move |_, _| Ok(auth_clone.clone())); + + let provider = Provider::mocked_builder().mock_application_credential(app_cred_mock); + + let state = get_mocked_state(provider, true, None).await; + + assert_eq!( + vec![auth], + authenticate_request( + &state, + &AuthRequest { + auth: AuthRequestInner { + identity: Identity { + methods: vec!["application_credential".to_string()], + password: None, + token: None, + totp: None, + application_credential: Some(ApplicationCredentialAuth { + id: Some("app_cred_id".into()), + name: None, + secret: "app_cred_secret".into(), + user: None, + }), + extra: Default::default(), + }, + scope: None, + }, + }, + &axum::http::HeaderMap::new(), + None, + ) + .await + .unwrap() + ); + } + + #[tokio::test] + async fn test_authenticate_request_application_credential_by_name() { + let auth = AuthenticationResultBuilder::default() + .context(AuthenticationContext::ApplicationCredential { + application_credential: openstack_keystone_core_types::application_credential::ApplicationCredentialBuilder::default() + .id("app_cred_id") + .name("my_app_cred") + .project_id("pid") + .user_id("uid") + .unrestricted(false) + .roles(vec![]) + .build() + .unwrap(), + token: None, + }) + .principal(PrincipalInfo { + identity: IdentityInfo::User( + UserIdentityInfoBuilder::default() + .user_id("uid") + .build() + .unwrap(), + ), + }) + .build() + .unwrap(); + let auth_clone = auth.clone(); + + let mut app_cred_mock = + crate::application_credential::MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_authenticate_by_application_credential() + .withf(|_, auth: &ApplicationCredentialAuthRequest| { + if let ApplicationCredentialAuthData::Name(by_name) = &auth.credential { + by_name.name == "my_app_cred" + && auth.secret.expose_secret() == "app_cred_secret" + } else { + false + } + }) + .returning(move |_, _| Ok(auth_clone.clone())); + + let provider = Provider::mocked_builder().mock_application_credential(app_cred_mock); + + let state = get_mocked_state(provider, true, None).await; + + assert_eq!( + vec![auth], + authenticate_request( + &state, + &AuthRequest { + auth: AuthRequestInner { + identity: Identity { + methods: vec!["application_credential".to_string()], + password: None, + token: None, + totp: None, + application_credential: Some(ApplicationCredentialAuth { + id: None, + name: Some("my_app_cred".into()), + secret: "app_cred_secret".into(), + user: Some(ApplicationCredentialUser { + id: Some("uid".into()), + name: None, + domain: None, + }), + }), + extra: Default::default(), + }, + scope: None, + }, + }, + &axum::http::HeaderMap::new(), + None, + ) + .await + .unwrap() + ); + } + + #[tokio::test] + async fn test_authenticate_request_application_credential_failed() { + let mut app_cred_mock = + crate::application_credential::MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_authenticate_by_application_credential() + .returning(|_, _| { + Err( + openstack_keystone_core_types::application_credential::ApplicationCredentialProviderError::AuthenticationFailed, + ) + }); + + let provider = Provider::mocked_builder().mock_application_credential(app_cred_mock); + + let state = get_mocked_state(provider, true, None).await; + + let rsp = authenticate_request( + &state, + &AuthRequest { + auth: AuthRequestInner { + identity: Identity { + methods: vec!["application_credential".to_string()], + password: None, + token: None, + totp: None, + application_credential: Some(ApplicationCredentialAuth { + id: Some("app_cred_id".into()), + name: None, + secret: "wrong_secret".into(), + user: None, + }), + extra: Default::default(), + }, + scope: None, + }, + }, + &axum::http::HeaderMap::new(), + None, + ) + .await; + + assert!(rsp.is_err()); + } #[tokio::test] async fn test_authenticate_request_unsupported() { let state = get_mocked_state(Provider::mocked_builder(), true, None).await; @@ -527,6 +773,7 @@ mod tests { password: None, token: None, totp: None, + application_credential: None, extra: Default::default(), }, scope: None, @@ -765,6 +1012,7 @@ mod route_dispatch_tests { password: None, token: None, totp: None, + application_credential: None, extra, }, scope: None, @@ -812,6 +1060,7 @@ mod route_dispatch_tests { password: None, token: None, totp: None, + application_credential: None, extra: Default::default(), }, scope: None, diff --git a/crates/keystone/src/api/v3/auth/token/create.rs b/crates/keystone/src/api/v3/auth/token/create.rs index 374528fc6..2fd74a3d4 100644 --- a/crates/keystone/src/api/v3/auth/token/create.rs +++ b/crates/keystone/src/api/v3/auth/token/create.rs @@ -199,6 +199,9 @@ mod tests { use openstack_keystone_config::{ Config, ConfigManager, Interface, ProxyHeader, RateLimitSection, }; + use openstack_keystone_core_types::application_credential::{ + ApplicationCredentialAuthData, ApplicationCredentialAuthRequest, + }; use openstack_keystone_core_types::auth::*; use openstack_keystone_core_types::identity::{IdentityProviderError, UserPasswordAuthRequest}; use openstack_keystone_core_types::resource::{Domain, DomainBuilder, Project}; @@ -206,6 +209,7 @@ mod tests { use secrecy::ExposeSecret; use crate::api::v3::auth::token::types::*; + use crate::application_credential::MockApplicationCredentialProvider; use crate::assignment::MockAssignmentProvider; use crate::catalog::MockCatalogProvider; use crate::identity::MockIdentityProvider; @@ -1240,6 +1244,562 @@ mod tests { "429 response must carry a Retry-After header" ); } + + fn app_cred_auth_body() -> Vec { + serde_json::to_vec(&json!({ + "auth": { + "identity": { + "methods": ["application_credential"], + "application_credential": { + "id": "app_cred_id", + "secret": "app_cred_secret" + } + } + } + })) + .unwrap() + } + + fn app_cred_auth_body_by_name() -> Vec { + serde_json::to_vec(&json!({ + "auth": { + "identity": { + "methods": ["application_credential"], + "application_credential": { + "name": "my_app_cred", + "secret": "app_cred_secret", + "user": { + "id": "uid" + } + } + } + } + })) + .unwrap() + } + + #[tokio::test] + #[traced_test] + async fn test_post_application_credential() { + let config = Config::default(); + let project = Project { + id: "pid".into(), + domain_id: "pdid".into(), + enabled: true, + ..Default::default() + }; + let user_domain = Domain { + id: "user_domain_id".into(), + enabled: true, + ..Default::default() + }; + let project_domain = Domain { + id: "pdid".into(), + enabled: true, + ..Default::default() + }; + + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock + .expect_list_role_assignments() + .returning(|_, _| Ok(Vec::new())); + + let mut catalog_mock = MockCatalogProvider::default(); + catalog_mock + .expect_get_catalog() + .returning(|_, _| Ok(Vec::new())); + + let auth = AuthenticationResultBuilder::default() + .context(AuthenticationContext::ApplicationCredential { + application_credential: openstack_keystone_core_types::application_credential::ApplicationCredentialBuilder::default() + .id("app_cred_id") + .name("my_app_cred") + .project_id("pid") + .user_id("uid") + .unrestricted(false) + .roles(vec![]) + .build() + .unwrap(), + token: None, + }) + .principal(PrincipalInfo { + identity: IdentityInfo::User( + UserIdentityInfoBuilder::default() + .user_id("uid") + .build() + .unwrap(), + ), + }) + .build() + .unwrap(); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_authenticate_by_application_credential() + .returning(move |_, _| Ok(auth.clone())); + + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, _| { + use openstack_keystone_core_types::identity::UserResponse; + Ok(Some(UserResponse { + id: "uid".into(), + name: "uname".into(), + domain_id: "user_domain_id".into(), + enabled: true, + default_project_id: None, + extra: std::collections::HashMap::new(), + federated: None, + options: openstack_keystone_core_types::identity::UserOptions::default(), + password_expires_at: None, + })) + }); + + let mut resource_mock = MockResourceProvider::default(); + resource_mock + .expect_get_project() + .withf(|_, id: &'_ str| id == "pid") + .returning(move |_, _| Ok(Some(project.clone()))); + resource_mock + .expect_get_domain() + .withf(|_, id: &'_ str| id == "user_domain_id") + .returning(move |_, _| Ok(Some(user_domain.clone()))); + resource_mock + .expect_get_domain() + .withf(|_, id: &'_ str| id == "pdid") + .returning(move |_, _| Ok(Some(project_domain.clone()))); + + let mut token_mock = MockTokenProvider::default(); + let vsc_for_mock = { + use openstack_keystone_core_types::auth::AuthzInfoBuilder; + use openstack_keystone_core_types::resource::ProjectBuilder; + use openstack_keystone_core_types::token::FernetToken; + let user_resp = openstack_keystone_core_types::identity::UserResponseBuilder::default() + .id("uid") + .name("uname".to_string()) + .domain_id("user_domain_id".to_string()) + .enabled(true) + .build() + .unwrap(); + let fernet_payload = openstack_keystone_core_types::token::ProjectScopePayload { + user_id: "uid".into(), + methods: Vec::from(["application_credential".to_string()]), + project_id: "pid".into(), + ..Default::default() + }; + let authz = AuthzInfoBuilder::default() + .roles(vec![]) + .scope(ScopeInfo::Project { + project: ProjectBuilder::default() + .id("pid") + .domain_id("pdid") + .enabled(true) + .name("pname") + .build() + .unwrap(), + project_domain: DomainBuilder::default() + .id("pdid") + .name("pdname") + .enabled(true) + .build() + .unwrap(), + }) + .build() + .unwrap(); + let sc = SecurityContext::test_build() + .authentication_context(AuthenticationContext::ApplicationCredential { + application_credential: openstack_keystone_core_types::application_credential::ApplicationCredentialBuilder::default() + .id("app_cred_id") + .name("my_app_cred") + .project_id("pid") + .user_id("uid") + .unrestricted(false) + .roles(vec![]) + .build() + .unwrap(), + token: None, + }) + .principal(PrincipalInfo { + identity: IdentityInfo::User( + UserIdentityInfoBuilder::default() + .user_id("uid") + .user(user_resp) + .user_domain( + DomainBuilder::default() + .id("user_domain_id") + .name("user_domain_name") + .enabled(true) + .build() + .unwrap(), + ) + .build() + .unwrap(), + ), + }) + .token(FernetToken::ProjectScope(fernet_payload)) + .authorization(authz) + .build(); + openstack_keystone_core::auth::ValidatedSecurityContext::test_new(sc) + }; + let vsc_clone = vsc_for_mock.clone(); + token_mock + .expect_issue_token_context() + .returning(move |_, _, _| Ok(vsc_clone.clone())); + token_mock + .expect_encode_token() + .returning(|_| Ok("token".to_string())); + + let provider = Provider::mocked_builder() + .mock_application_credential(app_cred_mock) + .mock_assignment(assignment_mock) + .mock_catalog(catalog_mock) + .mock_identity(identity_mock) + .mock_resource(resource_mock) + .mock_token(token_mock) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + ConfigManager::not_watched(config), + DatabaseConnection::default(), + provider, + Arc::new(MockPolicy::default()), + AuditDispatcher::noop(), + None, + ) + .await + .unwrap(), + ); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .method("POST") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(app_cred_auth_body())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: TokenResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(vec!["application_credential"], res.token.methods); + } + + #[tokio::test] + #[traced_test] + async fn test_post_application_credential_auth_failed() { + let config = Config::default(); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_authenticate_by_application_credential() + .returning(|_, _| { + Err( + openstack_keystone_core_types::application_credential::ApplicationCredentialProviderError::AuthenticationFailed, + ) + }); + + let provider = Provider::mocked_builder() + .mock_application_credential(app_cred_mock) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + ConfigManager::not_watched(config), + DatabaseConnection::default(), + provider, + Arc::new(MockPolicy::default()), + AuditDispatcher::noop(), + None, + ) + .await + .unwrap(), + ); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .method("POST") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(app_cred_auth_body())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + #[traced_test] + async fn test_post_application_credential_by_name() { + let config = Config::default(); + + let auth = AuthenticationResultBuilder::default() + .context(AuthenticationContext::ApplicationCredential { + application_credential: openstack_keystone_core_types::application_credential::ApplicationCredentialBuilder::default() + .id("app_cred_id") + .name("my_app_cred") + .project_id("pid") + .user_id("uid") + .unrestricted(false) + .roles(vec![]) + .build() + .unwrap(), + token: None, + }) + .principal(PrincipalInfo { + identity: IdentityInfo::User( + UserIdentityInfoBuilder::default() + .user_id("uid") + .build() + .unwrap(), + ), + }) + .build() + .unwrap(); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_authenticate_by_application_credential() + .withf(|_, auth: &ApplicationCredentialAuthRequest| { + matches!(&auth.credential, ApplicationCredentialAuthData::Name(by_name) if by_name.name == "my_app_cred") +}) +.returning(move |_, _| Ok(auth.clone())); + + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, _| { + use openstack_keystone_core_types::identity::UserResponse; + Ok(Some(UserResponse { + id: "uid".into(), + name: "uname".into(), + domain_id: "user_domain_id".into(), + enabled: true, + default_project_id: None, + extra: std::collections::HashMap::new(), + federated: None, + options: openstack_keystone_core_types::identity::UserOptions::default(), + password_expires_at: None, + })) + }); + + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock + .expect_list_role_assignments() + .returning(|_, _| Ok(Vec::new())); + + let mut catalog_mock = MockCatalogProvider::default(); + catalog_mock + .expect_get_catalog() + .returning(|_, _| Ok(Vec::new())); + + let mut resource_mock = MockResourceProvider::default(); + resource_mock.expect_get_project().returning(move |_, _| { + Ok(Some(Project { + id: "pid".into(), + domain_id: "pdid".into(), + enabled: true, + ..Default::default() + })) + }); + resource_mock.expect_get_domain().returning(move |_, _| { + Ok(Some(Domain { + id: "pdid".into(), + enabled: true, + ..Default::default() + })) + }); + + let mut token_mock = MockTokenProvider::default(); + let vsc_for_mock = { + use openstack_keystone_core_types::auth::AuthzInfoBuilder; + use openstack_keystone_core_types::resource::ProjectBuilder; + use openstack_keystone_core_types::token::FernetToken; + let user_resp = openstack_keystone_core_types::identity::UserResponseBuilder::default() + .id("uid") + .name("uname".to_string()) + .domain_id("user_domain_id".to_string()) + .enabled(true) + .build() + .unwrap(); + let fernet_payload = openstack_keystone_core_types::token::ProjectScopePayload { + user_id: "uid".into(), + methods: Vec::from(["application_credential".to_string()]), + project_id: "pid".into(), + ..Default::default() + }; + let authz = AuthzInfoBuilder::default() + .roles(vec![]) + .scope(ScopeInfo::Project { + project: ProjectBuilder::default() + .id("pid") + .domain_id("pdid") + .enabled(true) + .name("pname") + .build() + .unwrap(), + project_domain: DomainBuilder::default() + .id("pdid") + .name("pdname") + .enabled(true) + .build() + .unwrap(), + }) + .build() + .unwrap(); + let sc = SecurityContext::test_build() + .authentication_context(AuthenticationContext::ApplicationCredential { + application_credential: openstack_keystone_core_types::application_credential::ApplicationCredentialBuilder::default() + .id("app_cred_id") + .name("my_app_cred") + .project_id("pid") + .user_id("uid") + .unrestricted(false) + .roles(vec![]) + .build() + .unwrap(), + token: None, + }) + .principal(PrincipalInfo { + identity: IdentityInfo::User( + UserIdentityInfoBuilder::default() + .user_id("uid") + .user(user_resp) + .user_domain( + DomainBuilder::default() + .id("user_domain_id") + .name("user_domain_name") + .enabled(true) + .build() + .unwrap(), + ) + .build() + .unwrap(), + ), + }) + .token(FernetToken::ProjectScope(fernet_payload)) + .authorization(authz) + .build(); + openstack_keystone_core::auth::ValidatedSecurityContext::test_new(sc) + }; + let vsc_clone = vsc_for_mock.clone(); + token_mock + .expect_issue_token_context() + .returning(move |_, _, _| Ok(vsc_clone.clone())); + token_mock + .expect_encode_token() + .returning(|_| Ok("token".to_string())); + + let provider = Provider::mocked_builder() + .mock_application_credential(app_cred_mock) + .mock_assignment(assignment_mock) + .mock_catalog(catalog_mock) + .mock_identity(identity_mock) + .mock_resource(resource_mock) + .mock_token(token_mock) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + ConfigManager::not_watched(config), + DatabaseConnection::default(), + provider, + Arc::new(MockPolicy::default()), + AuditDispatcher::noop(), + None, + ) + .await + .unwrap(), + ); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .method("POST") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(app_cred_auth_body_by_name())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + } + + #[tokio::test] + #[traced_test] + async fn test_post_application_credential_expired() { + let config = Config::default(); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_authenticate_by_application_credential() +.returning(|_, _| { + Err( + openstack_keystone_core_types::application_credential::ApplicationCredentialProviderError::ApplicationCredentialExpired, + ) + }); + + let provider = Provider::mocked_builder() + .mock_application_credential(app_cred_mock) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + ConfigManager::not_watched(config), + DatabaseConnection::default(), + provider, + Arc::new(MockPolicy::default()), + AuditDispatcher::noop(), + None, + ) + .await + .unwrap(), + ); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .method("POST") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(app_cred_auth_body())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } } /// End-to-end HTTP-level integration tests for `mapping`/`route` dynamic @@ -1649,7 +2209,7 @@ mod auth_plugin_http_tests { "auth": { "identity": { "methods": ["application_credential"], - "application_credential": {"application_credential_id": "tf-alice"} + "application_credential": {"id": "tf-alice", "secret": "tf-alice-secret"} } } }), diff --git a/docker-compose.yaml b/docker-compose.yaml index 8a8e72855..5fada7422 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -30,9 +30,10 @@ services: keystone_rust: image: keystone-rust:dev build: . - command: ["bash", "-c", "keystone-db up && keystone -c /etc/keystone/keystone.conf -vv"] + command: ["bash", "-c", "keystone-manage db up && keystone -c /etc/keystone/keystone.conf -vv"] environment: DATABASE_URL: postgresql://keystone:password@db/keystone + RUST_LOG: debug ports: - "18080:8080" depends_on: diff --git a/tests/api/tests/api_v3/auth/token.rs b/tests/api/tests/api_v3/auth/token.rs index 6e567ec8b..26f47701b 100644 --- a/tests/api/tests/api_v3/auth/token.rs +++ b/tests/api/tests/api_v3/auth/token.rs @@ -11,7 +11,7 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 - +mod application_credential; mod auth_plugin; mod authorization; mod password; diff --git a/tests/api/tests/api_v3/auth/token/application_credential.rs b/tests/api/tests/api_v3/auth/token/application_credential.rs new file mode 100644 index 000000000..e644ca669 --- /dev/null +++ b/tests/api/tests/api_v3/auth/token/application_credential.rs @@ -0,0 +1,639 @@ +// 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 +//! Functional tests for authentication by application credential. + +use std::collections::HashMap; +use std::sync::Arc; + +use eyre::Result; +use secrecy::ExposeSecret; +use uuid::Uuid; + +use openstack_keystone_api_types::scope::{DomainBuilder, Scope, ScopeProjectBuilder}; +use openstack_keystone_api_types::v3::auth::token::*; +use openstack_keystone_api_types::v3::project::ProjectCreateBuilder; +use openstack_keystone_api_types::v3::user::UserCreateBuilder; +use openstack_sdk::AsyncOpenStack; +use openstack_sdk::config::CloudConfig; + +use test_api::assignment::grant::add_project_grant; +use test_api::auth::token::auth_token; +use test_api::common; +use test_api::guard::ResourceGuard; +use test_api::identity::user::create_user; +use test_api::resource::project::create_project; +use test_api::role::list_roles; + +fn app_cred_identity_by_id(id: &str, secret: &str) -> Identity { + IdentityBuilder::default() + .methods(vec!["application_credential".into()]) + .application_credential(ApplicationCredentialAuth { + id: Some(id.into()), + name: None, + secret: secret.into(), + user: None, + }) + .build() + .unwrap() +} + +fn app_cred_identity_by_name(name: &str, secret: &str, user_id: &str) -> Identity { + IdentityBuilder::default() + .methods(vec!["application_credential".into()]) + .application_credential(ApplicationCredentialAuth { + id: None, + name: Some(name.into()), + secret: secret.into(), + user: Some(ApplicationCredentialUser { + id: Some(user_id.into()), + name: None, + domain: None, + }), + }) + .build() + .unwrap() +} + +/// Authenticate the user with password, then create an application credential +/// via raw HTTP. Returns the credential ID. +async fn create_app_cred_for_user( + user_id: &str, + user_name: &str, + password: &str, + user_domain_id: &str, + project_id: &str, + project_domain_id: &str, + app_cred_secret: &str, + roles: &[serde_json::Value], +) -> Result<(String, String)> { + let mut tc = common::TestClient::default()?; + tc.auth_password( + common::get_password_auth(user_name, password, user_domain_id)?, + Some(Scope::Project( + ScopeProjectBuilder::default() + .id(project_id) + .domain(DomainBuilder::default().id(project_domain_id).build()?) + .build()?, + )), + ) + .await?; + + let app_cred_name = format!("ac_{}", Uuid::new_v4().simple()); + let create_body = serde_json::json!({ + "application_credential": { + "name": &app_cred_name, + "secret": app_cred_secret, + "roles": roles, + } + }); + let rsp = common::raw_request( + http::Method::POST, + &format!("v3/users/{}/application_credentials", user_id), + Some(tc.token.as_ref().unwrap().expose_secret()), + Some(create_body), + ) + .await?; + assert_eq!(rsp.status(), reqwest::StatusCode::CREATED); + let cred_resp: serde_json::Value = rsp.json().await?; + let cred_id = cred_resp["application_credential"]["id"] + .as_str() + .expect("app cred id must be present") + .to_string(); + Ok((cred_id, app_cred_name)) +} +#[tokio::test] +async fn test_auth_by_application_credential_id() -> Result<()> { + let admin = Arc::new(AsyncOpenStack::new(&CloudConfig::from_env()?).await?); + let password = "TestPassword123!"; + + let user = create_user( + &admin, + UserCreateBuilder::default() + .name(format!("usr_{}", Uuid::new_v4().simple())) + .domain_id("default") + .enabled(true) + .password(password) + .build()?, + ) + .await?; + + let project = create_project( + &admin, + ProjectCreateBuilder::default() + .domain_id("default") + .parent_id("default") + .name(format!("prj_{}", Uuid::new_v4().simple())) + .is_domain(false) + .enabled(true) + .build()?, + ) + .await?; + + let roles: HashMap = list_roles(&admin) + .await? + .into_iter() + .map(|r| (r.name, r.id)) + .collect(); + let member = roles.get("member").expect("member role must exist"); + add_project_grant(&admin, &project.id, &user.id, member).await?; + + let app_cred_secret = "my_app_cred_secret_value"; + let (cred_id, _) = create_app_cred_for_user( + &user.id, + &user.name, + password, + &user.domain_id, + &project.id, + "default", + app_cred_secret, + &[serde_json::json!({"id": member, "name": "member"})], + ) + .await?; + + let body = serde_json::json!({ + "auth": { + "identity": { + "methods": ["application_credential"], + "application_credential": { + "id": &cred_id, + "secret": app_cred_secret + } + } + } + }); + let rsp = common::raw_request(http::Method::POST, "v3/auth/tokens", None, Some(body)).await?; + + assert_eq!(rsp.status(), reqwest::StatusCode::CREATED); + let token_resp: serde_json::Value = rsp.json().await?; + assert!( + token_resp["token"]["methods"] + .as_array() + .unwrap() + .iter() + .any(|m| m == "application_credential"), + "methods must contain application_credential" + ); + assert_eq!(token_resp["token"]["user"]["id"].as_str().unwrap(), user.id); + + user.delete().await?; + project.delete().await?; + Ok(()) +} + +#[tokio::test] +async fn test_auth_by_application_credential_name() -> Result<()> { + let admin = Arc::new(AsyncOpenStack::new(&CloudConfig::from_env()?).await?); + let password = "TestPassword123!"; + + let user = create_user( + &admin, + UserCreateBuilder::default() + .name(format!("usr_{}", Uuid::new_v4().simple())) + .domain_id("default") + .enabled(true) + .password(password) + .build()?, + ) + .await?; + + let project = create_project( + &admin, + ProjectCreateBuilder::default() + .domain_id("default") + .parent_id("default") + .name(format!("prj_{}", Uuid::new_v4().simple())) + .is_domain(false) + .enabled(true) + .build()?, + ) + .await?; + + let roles: HashMap = list_roles(&admin) + .await? + .into_iter() + .map(|r| (r.name, r.id)) + .collect(); + let member = roles.get("member").expect("member role must exist"); + add_project_grant(&admin, &project.id, &user.id, member).await?; + + let mut tc = common::TestClient::default()?; + tc.auth_password( + common::get_password_auth(&user.name, password, &user.domain_id)?, + Some(Scope::Project( + ScopeProjectBuilder::default() + .id(&project.id) + .domain(DomainBuilder::default().id("default").build()?) + .build()?, + )), + ) + .await?; + + let app_cred_secret = "by_name_secret"; + let app_cred_name = format!("ac_{}", Uuid::new_v4().simple()); + let create_body = serde_json::json!({ + "application_credential": { + "name": &app_cred_name, + "secret": app_cred_secret, + "roles": [{"id": member, "name": "member"}], + } + }); + let rsp = common::raw_request( + http::Method::POST, + &format!("v3/users/{}/application_credentials", user.id), + Some(tc.token.as_ref().unwrap().expose_secret()), + Some(create_body), + ) + .await?; + assert_eq!(rsp.status(), reqwest::StatusCode::CREATED); + + let (token, _) = auth_token( + &admin, + app_cred_identity_by_name(&app_cred_name, app_cred_secret, &user.id), + None, + ) + .await?; + + assert!( + token + .methods + .contains(&"application_credential".to_string()), + "methods must contain application_credential" + ); + assert_eq!(token.user.id, user.id); + + user.delete().await?; + project.delete().await?; + Ok(()) +} + +#[tokio::test] +async fn test_auth_by_application_credential_wrong_secret() -> Result<()> { + let admin = Arc::new(AsyncOpenStack::new(&CloudConfig::from_env()?).await?); + let password = "TestPassword123!"; + + let user = create_user( + &admin, + UserCreateBuilder::default() + .name(format!("usr_{}", Uuid::new_v4().simple())) + .domain_id("default") + .enabled(true) + .password(password) + .build()?, + ) + .await?; + + let project = create_project( + &admin, + ProjectCreateBuilder::default() + .domain_id("default") + .parent_id("default") + .name(format!("prj_{}", Uuid::new_v4().simple())) + .is_domain(false) + .enabled(true) + .build()?, + ) + .await?; + + let roles: HashMap = list_roles(&admin) + .await? + .into_iter() + .map(|r| (r.name, r.id)) + .collect(); + let member = roles.get("member").expect("member role must exist"); + add_project_grant(&admin, &project.id, &user.id, member).await?; + + let (cred_id, _) = create_app_cred_for_user( + &user.id, + &user.name, + password, + &user.domain_id, + &project.id, + "default", + "correct_secret", + &[serde_json::json!({"id": member, "name": "member"})], + ) + .await?; + + let result = auth_token( + &admin, + app_cred_identity_by_id(&cred_id, "wrong_secret"), + None, + ) + .await; + + assert!(result.is_err(), "wrong secret must be rejected"); + + user.delete().await?; + project.delete().await?; + Ok(()) +} + +#[tokio::test] +async fn test_auth_by_application_credential_nonexistent() -> Result<()> { + let admin = Arc::new(AsyncOpenStack::new(&CloudConfig::from_env()?).await?); + + let result = auth_token( + &admin, + app_cred_identity_by_id("totally_nonexistent_id", "any_secret"), + None, + ) + .await; + + assert!(result.is_err(), "nonexistent credential must be rejected"); + Ok(()) +} + +#[tokio::test] +async fn test_auth_by_application_credential_disabled_user() -> Result<()> { + let admin = Arc::new(AsyncOpenStack::new(&CloudConfig::from_env()?).await?); + let password = "TestPassword123!"; + + let user = create_user( + &admin, + UserCreateBuilder::default() + .name(format!("usr_{}", Uuid::new_v4().simple())) + .domain_id("default") + .enabled(true) + .password(password) + .build()?, + ) + .await?; + + let project = create_project( + &admin, + ProjectCreateBuilder::default() + .domain_id("default") + .parent_id("default") + .name(format!("prj_{}", Uuid::new_v4().simple())) + .is_domain(false) + .enabled(true) + .build()?, + ) + .await?; + + let roles: HashMap = list_roles(&admin) + .await? + .into_iter() + .map(|r| (r.name, r.id)) + .collect(); + let member = roles.get("member").expect("member role must exist"); + add_project_grant(&admin, &project.id, &user.id, member).await?; + + let app_cred_secret = "disabled_user_secret"; + let (cred_id, _) = create_app_cred_for_user( + &user.id, + &user.name, + password, + &user.domain_id, + &project.id, + "default", + app_cred_secret, + &[serde_json::json!({"id": member, "name": "member"})], + ) + .await?; + + // Disable the user + test_api::identity::user::update_user( + &admin, + &user.id, + openstack_keystone_api_types::v3::user::UserUpdateBuilder::default() + .enabled(false) + .build()?, + ) + .await?; + + let result = auth_token( + &admin, + app_cred_identity_by_id(&cred_id, app_cred_secret), + None, + ) + .await; + + assert!(result.is_err(), "disabled user must be rejected"); + + // Re-enable for cleanup + test_api::identity::user::update_user( + &admin, + &user.id, + openstack_keystone_api_types::v3::user::UserUpdateBuilder::default() + .enabled(true) + .build()?, + ) + .await?; + + user.delete().await?; + project.delete().await?; + Ok(()) +} + +#[tokio::test] +async fn test_auth_by_application_credential_disabled_project() -> Result<()> { + let admin = Arc::new(AsyncOpenStack::new(&CloudConfig::from_env()?).await?); + let password = "TestPassword123!"; + + let user = create_user( + &admin, + UserCreateBuilder::default() + .name(format!("usr_{}", Uuid::new_v4().simple())) + .domain_id("default") + .enabled(true) + .password(password) + .build()?, + ) + .await?; + + let project = create_project( + &admin, + ProjectCreateBuilder::default() + .domain_id("default") + .parent_id("default") + .name(format!("prj_{}", Uuid::new_v4().simple())) + .is_domain(false) + .enabled(true) + .build()?, + ) + .await?; + + let roles: HashMap = list_roles(&admin) + .await? + .into_iter() + .map(|r| (r.name, r.id)) + .collect(); + let member = roles.get("member").expect("member role must exist"); + add_project_grant(&admin, &project.id, &user.id, member).await?; + + let app_cred_secret = "disabled_project_secret"; + let (cred_id, _) = create_app_cred_for_user( + &user.id, + &user.name, + password, + &user.domain_id, + &project.id, + "default", + app_cred_secret, + &[serde_json::json!({"id": member, "name": "member"})], + ) + .await?; + + // Disable the project + test_api::resource::project::update_project( + &admin, + &project.id, + openstack_keystone_api_types::v3::project::ProjectUpdateBuilder::default() + .enabled(false) + .build()?, + ) + .await?; + + let result = auth_token( + &admin, + app_cred_identity_by_id(&cred_id, app_cred_secret), + None, + ) + .await; + + assert!(result.is_err(), "disabled project must be rejected"); + + // Re-enable for cleanup + test_api::resource::project::update_project( + &admin, + &project.id, + openstack_keystone_api_types::v3::project::ProjectUpdateBuilder::default() + .enabled(true) + .build()?, + ) + .await?; + + user.delete().await?; + project.delete().await?; + Ok(()) +} + +#[tokio::test] +async fn test_auth_by_application_credential_disabled_domain() -> Result<()> { + let admin = Arc::new(AsyncOpenStack::new(&CloudConfig::from_env()?).await?); + let password = "TestPassword123!"; + + let domain = test_api::resource::domain::create_domain( + &admin, + openstack_keystone_api_types::v3::domain::DomainCreateBuilder::default() + .name(format!("dom_{}", Uuid::new_v4().simple())) + .enabled(true) + .build()?, + ) + .await?; + + let user = create_user( + &admin, + UserCreateBuilder::default() + .name(format!("usr_{}", Uuid::new_v4().simple())) + .domain_id(&domain.id) + .enabled(true) + .password(password) + .build()?, + ) + .await?; + + let project = create_project( + &admin, + ProjectCreateBuilder::default() + .domain_id(&domain.id) + .parent_id(&domain.id) + .name(format!("prj_{}", Uuid::new_v4().simple())) + .is_domain(false) + .enabled(true) + .build()?, + ) + .await?; + + let roles: HashMap = list_roles(&admin) + .await? + .into_iter() + .map(|r| (r.name, r.id)) + .collect(); + let member = roles.get("member").expect("member role must exist"); + add_project_grant(&admin, &project.id, &user.id, member).await?; + + let app_cred_secret = "disabled_domain_secret"; + let (cred_id, _) = create_app_cred_for_user( + &user.id, + &user.name, + password, + &domain.id, + &project.id, + &domain.id, + app_cred_secret, + &[serde_json::json!({"id": member, "name": "member"})], + ) + .await?; + + // Disable the domain + test_api::resource::domain::update_domain( + &admin, + &domain.id, + openstack_keystone_api_types::v3::domain::DomainUpdateBuilder::default() + .enabled(false) + .build()?, + ) + .await?; + + let result = auth_token( + &admin, + app_cred_identity_by_id(&cred_id, app_cred_secret), + None, + ) + .await; + + assert!(result.is_err(), "disabled domain must be rejected"); + + // Re-enable for cleanup + test_api::resource::domain::update_domain( + &admin, + &domain.id, + openstack_keystone_api_types::v3::domain::DomainUpdateBuilder::default() + .enabled(true) + .build()?, + ) + .await?; + + user.delete().await?; + project.delete().await?; + domain.delete().await?; + Ok(()) +} + +#[tokio::test] +async fn test_auth_by_application_credential_raw_401() -> Result<()> { + let body = serde_json::json!({ + "auth": { + "identity": { + "methods": ["application_credential"], + "application_credential": { + "id": "nonexistent", + "secret": "bad_secret" + } + } + } + }); + + let rsp = common::raw_request(http::Method::POST, "v3/auth/tokens", None, Some(body)).await?; + + assert_eq!( + rsp.status(), + reqwest::StatusCode::UNAUTHORIZED, + "must return 401 for invalid application credential" + ); + + Ok(()) +} diff --git a/tests/api/tests/api_v3/auth/token/auth_plugin.rs b/tests/api/tests/api_v3/auth/token/auth_plugin.rs index 1b1acbacc..43493b6ff 100644 --- a/tests/api/tests/api_v3/auth/token/auth_plugin.rs +++ b/tests/api/tests/api_v3/auth/token/auth_plugin.rs @@ -98,7 +98,7 @@ async fn test_application_credential_route_issues_token() -> Result<()> { .methods(vec!["application_credential".to_string()]) .extra(HashMap::from([( "application_credential".to_string(), - serde_json::json!({"application_credential_id": cred_id}), + serde_json::json!({"id": cred_id,"secret": "dummy"}), )])) .build()?; let auth_result = auth_token(&test_client, identity, None).await; @@ -136,7 +136,7 @@ async fn test_route_deny_is_rejected() -> Result<()> { .methods(vec!["application_credential".to_string()]) .extra(HashMap::from([( "application_credential".to_string(), - serde_json::json!({"application_credential_id": "deny-me"}), + serde_json::json!({"id": "deny-me","secret": "dummy"}), )])) .build()?; let response = authenticate_identity(&test_client, identity, None).await?; @@ -158,7 +158,7 @@ async fn test_application_credential_passthrough_fails_closed() -> Result<()> { .methods(vec!["application_credential".to_string()]) .extra(HashMap::from([( "application_credential".to_string(), - serde_json::json!({"application_credential_id": "native-not-implemented"}), + serde_json::json!({"id": "native-not-implemented","secret": "dummy"}), )])) .build()?; let response = authenticate_identity(&test_client, identity, None).await?; diff --git a/tests/integration/src/application_credential.rs b/tests/integration/src/application_credential.rs index 7c998c8c8..483671ec6 100644 --- a/tests/integration/src/application_credential.rs +++ b/tests/integration/src/application_credential.rs @@ -21,6 +21,7 @@ use openstack_keystone_core::keystone::Service; use openstack_keystone_core_types::application_credential as types; mod access_rule; +mod authenticate; mod create; mod get; mod list; diff --git a/tests/integration/src/application_credential/authenticate.rs b/tests/integration/src/application_credential/authenticate.rs new file mode 100644 index 000000000..423b18100 --- /dev/null +++ b/tests/integration/src/application_credential/authenticate.rs @@ -0,0 +1,543 @@ +// 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 +//! Integration tests for authentication by application credential. + +use std::ops::Deref; + +use eyre::Report; +use tracing_test::traced_test; +use uuid::Uuid; + +use openstack_keystone::application_credential::ApplicationCredentialProviderError; +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::application_credential::*; +use openstack_keystone_core_types::auth::*; +use openstack_keystone_core_types::role::*; + +use crate::common::get_state; +use crate::{create_domain, create_project, create_role, create_user}; + +/// Helper to build an auth request by credential ID. +fn auth_request_by_id(cred_id: &str, secret: &str) -> ApplicationCredentialAuthRequest { + ApplicationCredentialAuthRequest { + secret: secret.into(), + credential: ApplicationCredentialAuthData::Id(ApplicationCredentialAuthById { + id: cred_id.to_string(), + user: None, + }), + } +} + +#[tokio::test] +#[traced_test] +async fn test_authenticate_by_app_cred_success() -> Result<(), Report> { + let (state, _) = get_state().await?; + let domain = create_domain!(state)?; + let project = create_project!(state, domain.id.clone())?; + let user = create_user!(state, domain.id.clone())?; + let role = create_role!(state)?; + + let secret = "test_app_cred_secret".to_string(); + let cred = state + .provider + .get_application_credential_provider() + .create_application_credential( + &ExecutionContext::internal(&state), + ApplicationCredentialCreate { + name: Uuid::new_v4().to_string(), + project_id: project.id.clone(), + roles: vec![RoleRef::from(role.clone())], + secret: Some(secret.clone().into()), + user_id: user.id.clone(), + ..Default::default() + }, + ) + .await?; + + let result = state + .provider + .get_application_credential_provider() + .authenticate_by_application_credential( + &ExecutionContext::internal(&state), + &auth_request_by_id(&cred.id, &secret), + ) + .await?; + + assert!( + matches!( + result.context, + AuthenticationContext::ApplicationCredential { .. } + ), + "context must be ApplicationCredential" + ); + + if let AuthenticationContext::ApplicationCredential { + application_credential, + token, + } = &result.context + { + assert_eq!(application_credential.id, cred.id); + assert_eq!(application_credential.project_id, project.id); + assert!(token.is_none()); + } + + Ok(()) +} + +#[tokio::test] +#[traced_test] +async fn test_authenticate_by_app_cred_wrong_secret() -> Result<(), Report> { + let (state, _) = get_state().await?; + let domain = create_domain!(state)?; + let project = create_project!(state, domain.id.clone())?; + let user = create_user!(state, domain.id.clone())?; + + let cred = state + .provider + .get_application_credential_provider() + .create_application_credential( + &ExecutionContext::internal(&state), + ApplicationCredentialCreate { + name: Uuid::new_v4().to_string(), + project_id: project.id.clone(), + roles: vec![], + secret: Some("correct_secret".into()), + user_id: user.id.clone(), + ..Default::default() + }, + ) + .await?; + + let result = state + .provider + .get_application_credential_provider() + .authenticate_by_application_credential( + &ExecutionContext::internal(&state), + &auth_request_by_id(&cred.id, "wrong_secret"), + ) + .await; + + assert!( + matches!( + result, + Err(ApplicationCredentialProviderError::AuthenticationFailed) + ), + "wrong secret must fail" + ); + + Ok(()) +} + +#[tokio::test] +#[traced_test] +async fn test_authenticate_by_app_cred_not_found() -> Result<(), Report> { + let (state, _) = get_state().await?; + + let result = state + .provider + .get_application_credential_provider() + .authenticate_by_application_credential( + &ExecutionContext::internal(&state), + &auth_request_by_id("nonexistent_id", "any_secret"), + ) + .await; + + assert!( + matches!( + result, + Err(ApplicationCredentialProviderError::AuthenticationFailed) + ), + "nonexistent credential must fail" + ); + + Ok(()) +} + +#[tokio::test] +#[traced_test] +async fn test_authenticate_by_app_cred_expired() -> Result<(), Report> { + let (state, _) = get_state().await?; + let domain = create_domain!(state)?; + let project = create_project!(state, domain.id.clone())?; + let user = create_user!(state, domain.id.clone())?; + + let secret = "expired_cred_secret".to_string(); + let cred = state + .provider + .get_application_credential_provider() + .create_application_credential( + &ExecutionContext::internal(&state), + ApplicationCredentialCreate { + name: Uuid::new_v4().to_string(), + project_id: project.id.clone(), + roles: vec![], + secret: Some(secret.clone().into()), + user_id: user.id.clone(), + expires_at: Some(chrono::Utc::now() - chrono::Duration::hours(1)), + ..Default::default() + }, + ) + .await?; + + let result = state + .provider + .get_application_credential_provider() + .authenticate_by_application_credential( + &ExecutionContext::internal(&state), + &auth_request_by_id(&cred.id, &secret), + ) + .await; + + assert!( + matches!( + result, + Err(ApplicationCredentialProviderError::ApplicationCredentialExpired) + ), + "expired credential must fail" + ); + + Ok(()) +} + +#[tokio::test] +#[traced_test] +async fn test_authenticate_by_app_cred_disabled_user_rejected_by_central_validation() +-> Result<(), Report> { + // The provider does NOT check user.enabled — that is handled centrally + // by UserIdentityInfo::validate() during token issuance + // (ValidatedSecurityContext::new_for_scope). + let (state, _) = get_state().await?; + let domain = create_domain!(state)?; + let project = create_project!(state, domain.id.clone())?; + let user = create_user!(state, domain.id.clone())?; + let role = create_role!(state)?; + + state + .provider + .get_assignment_provider() + .create_grant( + &ExecutionContext::internal(&state), + openstack_keystone_core_types::assignment::AssignmentCreate::user_project( + &user.id, + &project.id, + &role.id, + false, + ), + ) + .await?; + + let secret = "disabled_user_secret".to_string(); + let cred = state + .provider + .get_application_credential_provider() + .create_application_credential( + &ExecutionContext::internal(&state), + ApplicationCredentialCreate { + name: Uuid::new_v4().to_string(), + project_id: project.id.clone(), + roles: vec![RoleRef::from(role.deref().clone())], + secret: Some(secret.clone().into()), + user_id: user.id.clone(), + ..Default::default() + }, + ) + .await?; + + state + .provider + .get_identity_provider() + .update_user( + &ExecutionContext::internal(&state), + &user.id, + openstack_keystone_core_types::identity::UserUpdate { + enabled: Some(false), + ..Default::default() + }, + ) + .await?; + + // Provider returns Ok — user.enabled is NOT checked here + let auth_result = state + .provider + .get_application_credential_provider() + .authenticate_by_application_credential( + &ExecutionContext::internal(&state), + &auth_request_by_id(&cred.id, &secret), + ) + .await; + + assert!( + auth_result.is_ok(), + "provider must return Ok — user.enabled is checked centrally" + ); + + // Token issuance fails via UserIdentityInfo::validate() + let ctx = SecurityContext::try_from(auth_result.unwrap())?; + let authz_info = openstack_keystone_core::api::common::get_authz_info( + &state, + Some(&openstack_keystone_core_types::scope::Scope::Project( + openstack_keystone_core_types::scope::ProjectBuilder::default() + .id(Some(project.id.clone())) + .build()?, + )), + ) + .await?; + + let result = state + .provider + .get_token_provider() + .issue_token_context(&state, &ctx, &authz_info) + .await; + + assert!( + result.is_err(), + "token issuance must fail for a disabled user" + ); + + Ok(()) +} + +#[tokio::test] +#[traced_test] +async fn test_authenticate_by_app_cred_disabled_project_rejected_by_scope_validation() +-> Result<(), Report> { + // The provider does NOT check project.enabled — that is handled by + // ScopeInfo::validate() via get_authz_info. + let (state, _) = get_state().await?; + let domain = create_domain!(state)?; + let project = create_project!(state, domain.id.clone())?; + let user = create_user!(state, domain.id.clone())?; + let role = create_role!(state)?; + + state + .provider + .get_assignment_provider() + .create_grant( + &ExecutionContext::internal(&state), + openstack_keystone_core_types::assignment::AssignmentCreate::user_project( + &user.id, + &project.id, + &role.id, + false, + ), + ) + .await?; + + let secret = "disabled_project_secret".to_string(); + let _cred = state + .provider + .get_application_credential_provider() + .create_application_credential( + &ExecutionContext::internal(&state), + ApplicationCredentialCreate { + name: Uuid::new_v4().to_string(), + project_id: project.id.clone(), + roles: vec![RoleRef::from(role.deref().clone())], + secret: Some(secret.clone().into()), + user_id: user.id.clone(), + ..Default::default() + }, + ) + .await?; + + // Disable the project + state + .provider + .get_resource_provider() + .update_project( + &ExecutionContext::internal(&state), + &project.id, + openstack_keystone_core_types::resource::ProjectUpdate { + enabled: Some(false), + ..Default::default() + }, + ) + .await?; + + // Scope resolution rejects the disabled project + let result = openstack_keystone_core::api::common::get_authz_info( + &state, + Some(&openstack_keystone_core_types::scope::Scope::Project( + openstack_keystone_core_types::scope::ProjectBuilder::default() + .id(Some(project.id.clone())) + .build()?, + )), + ) + .await; + + assert!( + result.is_err(), + "scope resolution must fail for a disabled project" + ); + + Ok(()) +} + +#[tokio::test] +#[traced_test] +async fn test_authenticate_by_app_cred_disabled_domain_rejected_by_scope_validation() +-> Result<(), Report> { + // The provider does NOT check domain.enabled — that is handled by + // ScopeInfo::validate() via get_authz_info. + let (state, _) = get_state().await?; + let domain = create_domain!(state)?; + let project = create_project!(state, domain.id.clone())?; + let user = create_user!(state, domain.id.clone())?; + let role = create_role!(state)?; + + state + .provider + .get_assignment_provider() + .create_grant( + &ExecutionContext::internal(&state), + openstack_keystone_core_types::assignment::AssignmentCreate::user_project( + &user.id, + &project.id, + &role.id, + false, + ), + ) + .await?; + + let secret = "disabled_domain_secret".to_string(); + let _cred = state + .provider + .get_application_credential_provider() + .create_application_credential( + &ExecutionContext::internal(&state), + ApplicationCredentialCreate { + name: Uuid::new_v4().to_string(), + project_id: project.id.clone(), + roles: vec![RoleRef::from(role.deref().clone())], + secret: Some(secret.clone().into()), + user_id: user.id.clone(), + ..Default::default() + }, + ) + .await?; + + // Disable the domain + state + .provider + .get_resource_provider() + .update_domain( + &ExecutionContext::internal(&state), + &domain.id, + openstack_keystone_core_types::resource::DomainUpdate { + enabled: Some(false), + ..Default::default() + }, + ) + .await?; + + // Scope resolution rejects the disabled domain + let result = openstack_keystone_core::api::common::get_authz_info( + &state, + Some(&openstack_keystone_core_types::scope::Scope::Project( + openstack_keystone_core_types::scope::ProjectBuilder::default() + .id(Some(project.id.clone())) + .build()?, + )), + ) + .await; + + assert!( + result.is_err(), + "scope resolution must fail for a disabled domain" + ); + + Ok(()) +} + +#[tokio::test] +#[traced_test] +async fn test_authenticate_by_app_cred_scope_to_different_project_rejected() -> Result<(), Report> { + // validate_scope_boundaries rejects scoping to a different project. + let (state, _) = get_state().await?; + let domain = create_domain!(state)?; + let project = create_project!(state, domain.id.clone())?; + let other_project = create_project!(state, domain.id.clone())?; + let user = create_user!(state, domain.id.clone())?; + let role = create_role!(state)?; + + let secret = "scope_test_secret".to_string(); + let cred = state + .provider + .get_application_credential_provider() + .create_application_credential( + &ExecutionContext::internal(&state), + ApplicationCredentialCreate { + name: Uuid::new_v4().to_string(), + project_id: project.id.clone(), + roles: vec![RoleRef::from(role.deref().clone())], + secret: Some(secret.clone().into()), + user_id: user.id.clone(), + ..Default::default() + }, + ) + .await?; + + let auth_result = state + .provider + .get_application_credential_provider() + .authenticate_by_application_credential( + &ExecutionContext::internal(&state), + &auth_request_by_id(&cred.id, &secret), + ) + .await?; + + let ctx = SecurityContext::try_from(auth_result)?; + + // Scoping to a different project must be rejected + let wrong_scope = ScopeInfo::Project { + project: openstack_keystone_core_types::resource::ProjectBuilder::default() + .id(&other_project.id) + .name("other") + .domain_id(&domain.id) + .enabled(true) + .build()?, + project_domain: openstack_keystone_core_types::resource::DomainBuilder::default() + .id(&domain.id) + .name("test") + .enabled(true) + .build()?, + }; + + assert!( + ctx.validate_scope_boundaries(&wrong_scope).is_err(), + "app cred must not be scoped to a different project" + ); + + // Domain scope must be rejected + let domain_scope = ScopeInfo::Domain( + openstack_keystone_core_types::resource::DomainBuilder::default() + .id(&domain.id) + .name("test") + .enabled(true) + .build()?, + ); + assert!( + ctx.validate_scope_boundaries(&domain_scope).is_err(), + "app cred must not be scoped to a domain" + ); + + // System scope must be rejected + let system_scope = ScopeInfo::System("all".into()); + assert!( + ctx.validate_scope_boundaries(&system_scope).is_err(), + "app cred must not be scoped to system" + ); + + Ok(()) +}