diff --git a/crates/api-types/src/error_conv.rs b/crates/api-types/src/error_conv.rs index 3bc40dc04..d3f5938d4 100644 --- a/crates/api-types/src/error_conv.rs +++ b/crates/api-types/src/error_conv.rs @@ -218,19 +218,33 @@ impl From for KeystoneApiError { } impl From for KeystoneApiError { - fn from(source: ApplicationCredentialProviderError) -> Self { - match source { + fn from(value: ApplicationCredentialProviderError) -> Self { + match value { ApplicationCredentialProviderError::ApplicationCredentialNotFound(x) => { Self::NotFound { resource: "application_credential".into(), identifier: x, } } + ApplicationCredentialProviderError::AccessRuleNotFound(x) => Self::NotFound { + resource: "access_rule".into(), + identifier: x, + }, + ApplicationCredentialProviderError::RoleNotFound(x) => Self::NotFound { + resource: "role".into(), + identifier: x, + }, ref err @ ApplicationCredentialProviderError::Conflict(..) => { Self::Conflict(err.to_string()) } - err @ ApplicationCredentialProviderError::ApplicationCredentialExpired => { - Self::unauthorized(err, None::) + ref err @ ApplicationCredentialProviderError::Validation { .. } => { + Self::BadRequest(err.to_string()) + } + ApplicationCredentialProviderError::ApplicationCredentialExpired => { + Self::BadRequest("application credential has expired".into()) + } + ApplicationCredentialProviderError::AccessRuleInUse(x) => { + Self::BadRequest(format!("access rule {x} is still in use")) } other => Self::InternalError(other.to_string()), } diff --git a/crates/api-types/src/v3.rs b/crates/api-types/src/v3.rs index 05ce4ace2..b2534e221 100644 --- a/crates/api-types/src/v3.rs +++ b/crates/api-types/src/v3.rs @@ -12,6 +12,7 @@ // // SPDX-License-Identifier: Apache-2.0 //! # V3 API types +pub mod application_credential; pub mod auth; pub mod credential; pub mod domain; @@ -23,6 +24,8 @@ pub mod role; pub mod role_assignment; pub mod user; +#[cfg(feature = "conv")] +mod application_credential_conv; #[cfg(feature = "conv")] mod auth_conv; #[cfg(feature = "conv")] diff --git a/crates/api-types/src/v3/application_credential.rs b/crates/api-types/src/v3/application_credential.rs new file mode 100644 index 000000000..38d75ddb6 --- /dev/null +++ b/crates/api-types/src/v3/application_credential.rs @@ -0,0 +1,16 @@ +// 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 + +pub mod access_rule; +pub mod application_credential; diff --git a/crates/api-types/src/v3/application_credential/access_rule.rs b/crates/api-types/src/v3/application_credential/access_rule.rs new file mode 100644 index 000000000..8bbd389ab --- /dev/null +++ b/crates/api-types/src/v3/application_credential/access_rule.rs @@ -0,0 +1,84 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; +/// Short access rule representation. +#[derive(Clone, Debug, Deserialize, PartialEq, 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))] +pub struct AccessRule { + /// The ID of the access rule. + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub id: String, + + /// The HTTP method permitted. + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 16)))] + pub method: Option, + + /// The API path permitted. + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 128)))] + pub path: Option, + + /// The service type permitted. + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub service: Option, +} + +/// Access rule for creation (id is optional). +#[derive(Clone, Debug, Deserialize, PartialEq, 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))] +pub struct AccessRuleCreate { + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub id: Option, + + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 16)))] + pub method: Option, + + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 128)))] + pub path: Option, + + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub service: Option, +} diff --git a/crates/api-types/src/v3/application_credential/application_credential.rs b/crates/api-types/src/v3/application_credential/application_credential.rs new file mode 100644 index 000000000..ec683ecd3 --- /dev/null +++ b/crates/api-types/src/v3/application_credential/application_credential.rs @@ -0,0 +1,179 @@ +// 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 +//! # Application credential API types + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "validate")] +use validator::Validate; + +use crate::v3::application_credential::access_rule::{AccessRule, AccessRuleCreate}; +use crate::v3::role::RoleRef; + +/// Full application credential representation. +#[derive(Clone, Debug, Deserialize, PartialEq, 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))] +pub struct ApplicationCredential { + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(nested))] + pub access_rules: Option>, + + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 255)))] + pub description: Option, + + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, + + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub id: String, + + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 255)))] + pub name: String, + + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub project_id: String, + + #[cfg_attr(feature = "validate", validate(nested))] + pub roles: Vec, + + pub unrestricted: bool, + + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub user_id: String, +} + +/// Data for creating an application credential. +#[derive(Clone, Debug, Deserialize, PartialEq, 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))] +pub struct ApplicationCredentialCreate { + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(nested))] + pub access_rules: Option>, + + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, + + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub id: Option, + + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 255)))] + pub name: String, + + #[cfg_attr(feature = "builder", builder(default))] + pub roles: Vec, + + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + pub unrestricted: Option, +} + +/// Wrapper for a single application credential response. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ApplicationCredentialResponse { + #[cfg_attr(feature = "validate", validate(nested))] + pub application_credential: ApplicationCredential, +} + +/// Wrapper for a create request body. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ApplicationCredentialCreateRequest { + #[cfg_attr(feature = "validate", validate(nested))] + pub application_credential: ApplicationCredentialCreate, +} + +/// Application credential as returned by create — includes secret (shown once only). +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ApplicationCredentialCreated { + #[serde(skip_serializing_if = "Option::is_none")] + pub access_rules: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, + + pub id: String, + pub name: String, + pub project_id: String, + pub roles: Vec, + + /// Only present in create response. Never returned again. + pub secret: String, + + pub unrestricted: bool, + pub user_id: String, +} + +/// Wrapper for create response body. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ApplicationCredentialCreateResponse { + pub application_credential: ApplicationCredentialCreated, +} + +/// List of application credentials. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ApplicationCredentialList { + #[cfg_attr(feature = "validate", validate(nested))] + pub application_credentials: Vec, +} + +/// List parameters for filtering application credentials. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ApplicationCredentialListParameters { + #[cfg_attr(feature = "validate", validate(length(max = 255)))] + pub name: Option, +} diff --git a/crates/api-types/src/v3/application_credential_conv.rs b/crates/api-types/src/v3/application_credential_conv.rs new file mode 100644 index 000000000..4399c4b47 --- /dev/null +++ b/crates/api-types/src/v3/application_credential_conv.rs @@ -0,0 +1,117 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +use secrecy::ExposeSecret; + +use openstack_keystone_core_types::application_credential as provider_types; + +use crate::v3::application_credential::access_rule as api_types_access_rule; +use crate::v3::application_credential::application_credential as api_types_application_credential; + +impl From for api_types_access_rule::AccessRule { + fn from(value: provider_types::AccessRule) -> Self { + Self { + id: value.id, + method: value.method, + path: value.path, + service: value.service, + } + } +} + +impl From for provider_types::AccessRuleCreate { + fn from(value: api_types_access_rule::AccessRuleCreate) -> Self { + Self { + id: value.id, + method: value.method, + path: value.path, + service: value.service, + user_id: String::new(), // assigned server-side + } + } +} + +impl From + for api_types_application_credential::ApplicationCredential +{ + fn from(value: provider_types::ApplicationCredential) -> Self { + Self { + access_rules: value + .access_rules + .map(|rules| rules.into_iter().map(Into::into).collect()), + description: value.description, + expires_at: value.expires_at, + id: value.id, + name: value.name, + project_id: value.project_id, + roles: value.roles.into_iter().map(Into::into).collect(), + unrestricted: value.unrestricted, + user_id: value.user_id, + } + } +} + +impl From + for provider_types::ApplicationCredentialCreate +{ + fn from(value: api_types_application_credential::ApplicationCredentialCreate) -> Self { + Self { + access_rules: value + .access_rules + .map(|rules| rules.into_iter().map(Into::into).collect()), + description: value.description, + expires_at: value.expires_at, + id: value.id, + name: value.name, + project_id: String::new(), // assigned server-side from token + roles: value.roles.into_iter().map(Into::into).collect(), + secret: None, // generated server-side + unrestricted: value.unrestricted, + user_id: String::new(), // assigned server-side from token + } + } +} + +impl From + for provider_types::ApplicationCredentialListParameters +{ + fn from(value: api_types_application_credential::ApplicationCredentialListParameters) -> Self { + Self { + limit: Default::default(), + marker: Default::default(), + name: value.name, + user_id: String::new(), // injected from auth context, not from request body + } + } +} + +impl From + for api_types_application_credential::ApplicationCredentialCreated +{ + fn from(value: provider_types::ApplicationCredentialCreateResponse) -> Self { + Self { + access_rules: value + .access_rules + .map(|rules| rules.into_iter().map(Into::into).collect()), + description: value.description, + expires_at: value.expires_at, + id: value.id, + name: value.name, + project_id: value.project_id, + roles: value.roles.into_iter().map(Into::into).collect(), + secret: value.secret.expose_secret().to_string(), + unrestricted: value.unrestricted, + user_id: value.user_id, + } + } +} diff --git a/crates/appcred-driver-sql/src/application_credential.rs b/crates/appcred-driver-sql/src/application_credential.rs index c555c42d9..fa1cb0dfd 100644 --- a/crates/appcred-driver-sql/src/application_credential.rs +++ b/crates/appcred-driver-sql/src/application_credential.rs @@ -26,13 +26,13 @@ use crate::entity::{ pub mod access_rule; mod create; +mod delete; mod get; mod list; - pub use create::create; +pub use delete::delete; pub use get::get; pub use list::list; - impl TryFrom for ApplicationCredentialBuilder { type Error = ApplicationCredentialProviderError; diff --git a/crates/appcred-driver-sql/src/application_credential/delete.rs b/crates/appcred-driver-sql/src/application_credential/delete.rs new file mode 100644 index 000000000..151fccab4 --- /dev/null +++ b/crates/appcred-driver-sql/src/application_credential/delete.rs @@ -0,0 +1,99 @@ +// 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 +//! # Delete application credential +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; +use sea_orm::query::*; + +use openstack_keystone_core::application_credential::ApplicationCredentialProviderError; +use openstack_keystone_core::error::DbContextExt; + +use crate::entity::{ + application_credential as db_application_credential, + prelude::ApplicationCredential as DbApplicationCredential, +}; + +pub async fn delete( + db: &DatabaseConnection, + id: &str, +) -> Result<(), ApplicationCredentialProviderError> { + let app_cred = DbApplicationCredential::find() + .filter(db_application_credential::Column::Id.eq(id)) + .one(db) + .await + .context("fetching application credential for delete")? + .ok_or_else(|| { + ApplicationCredentialProviderError::ApplicationCredentialNotFound(id.to_string()) + })?; + + DbApplicationCredential::delete_by_id(app_cred.internal_id) + .exec(db) + .await + .context("deleting application credential")?; + + Ok(()) +} +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult, Transaction}; + + use super::super::tests::*; + use super::*; + + #[tokio::test] + async fn test_delete() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_application_credential_mock( + "app_cred_id", + Some(12345), + )]]) + .append_exec_results([MockExecResult { + last_insert_id: 0, + rows_affected: 1, + }]) + .into_connection(); + + delete(&db, "app_cred_id").await.unwrap(); + + 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()] + ), + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"DELETE FROM "application_credential" WHERE "application_credential"."internal_id" = $1"#, + [12345i32.into()] + ), + ] + ); + } + + #[tokio::test] + async fn test_delete_not_found() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([Vec::::new()]) + .into_connection(); + + let result = delete(&db, "non-existing-id").await; + + assert!(matches!( + result, + Err(ApplicationCredentialProviderError::ApplicationCredentialNotFound(_)) + )); + } +} diff --git a/crates/appcred-driver-sql/src/lib.rs b/crates/appcred-driver-sql/src/lib.rs index 8557e2b89..fb3989a8c 100644 --- a/crates/appcred-driver-sql/src/lib.rs +++ b/crates/appcred-driver-sql/src/lib.rs @@ -100,6 +100,22 @@ impl ApplicationCredentialBackend for SqlBackend { application_credential::access_rule::delete(&state.db, user_id, id).await } + /// Delete an application credential by ID. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `id`: The ID of the application credential to delete. + /// + /// # Returns + /// - `Result<(), ApplicationCredentialProviderError>` - Unit on success, or + /// an error. + async fn delete_application_credential<'a>( + &self, + state: &ServiceState, + id: &'a str, + ) -> Result<(), ApplicationCredentialProviderError> { + application_credential::delete(&state.db, id).await + } /// Get a user's access rule by its ID. /// /// # Parameters diff --git a/crates/core/src/application_credential/backend.rs b/crates/core/src/application_credential/backend.rs index ba720fbf0..2604b7805 100644 --- a/crates/core/src/application_credential/backend.rs +++ b/crates/core/src/application_credential/backend.rs @@ -73,6 +73,21 @@ pub trait ApplicationCredentialBackend: Send + Sync { id: &'a str, ) -> Result<(), ApplicationCredentialProviderError>; + /// Delete an application credential by ID. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `id`: The ID of the application credential to delete. + /// + /// # Returns + /// - `Result<(), ApplicationCredentialProviderError>` - Unit on success, or + /// an error. + async fn delete_application_credential<'a>( + &self, + state: &ServiceState, + id: &'a str, + ) -> Result<(), ApplicationCredentialProviderError>; + /// Get a user's access rule by its ID. /// /// # Parameters diff --git a/crates/core/src/application_credential/provider_api.rs b/crates/core/src/application_credential/provider_api.rs index c0772546b..d23731b7d 100644 --- a/crates/core/src/application_credential/provider_api.rs +++ b/crates/core/src/application_credential/provider_api.rs @@ -70,6 +70,21 @@ pub trait ApplicationCredentialApi: Send + Sync { id: &'a str, ) -> Result<(), ApplicationCredentialProviderError>; + /// Delete an application credential by ID. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `id`: The ID of the application credential to delete. + /// + /// # Returns + /// - `Result<(), ApplicationCredentialProviderError>` - Unit on success, or + /// an error. + async fn delete_application_credential<'a>( + &self, + ctx: &ExecutionContext<'a>, + id: &'a str, + ) -> Result<(), ApplicationCredentialProviderError>; + /// Get a user's access rule by its ID. /// /// # Parameters diff --git a/crates/core/src/application_credential/service.rs b/crates/core/src/application_credential/service.rs index b2a299f77..04d178a32 100644 --- a/crates/core/src/application_credential/service.rs +++ b/crates/core/src/application_credential/service.rs @@ -268,6 +268,48 @@ impl ApplicationCredentialApi for ApplicationCredentialService { Ok(()) } + /// Delete an application credential by ID. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `id`: The ID of the application credential to delete. + /// + /// # Returns + /// - `Result<(), ApplicationCredentialProviderError>` - Unit on success, or + /// an error. + async fn delete_application_credential<'a>( + &self, + ctx: &ExecutionContext<'a>, + id: &'a str, + ) -> Result<(), ApplicationCredentialProviderError> { + // Fetch first to get project_id for the event — mirrors Python fetching before delete + let app_cred = self + .backend_driver + .get_application_credential(ctx.state(), id) + .await? + .ok_or_else(|| { + ApplicationCredentialProviderError::ApplicationCredentialNotFound(id.to_string()) + })?; + + let project_id = app_cred.project_id.clone(); + + self.backend_driver + .delete_application_credential(ctx.state(), id) + .await?; + + ctx.state() + .event_dispatcher + .emit(Event::new( + Operation::Delete, + EventPayload::ApplicationCredential { + id: id.to_string(), + project_id, + }, + )) + .await; + + Ok(()) + } /// Get a user's access rule by its ID. /// /// # Parameters diff --git a/crates/core/src/mocks.rs b/crates/core/src/mocks.rs index 27536ca36..3af3a0a13 100644 --- a/crates/core/src/mocks.rs +++ b/crates/core/src/mocks.rs @@ -145,6 +145,12 @@ mod application_credential { id: &'a str, ) -> Result<(), ApplicationCredentialProviderError>; + async fn delete_application_credential<'a>( + &self, + ctx: &ExecutionContext<'a>, + id: &'a str, + ) -> Result<(), ApplicationCredentialProviderError>; + async fn get_access_rule<'a>( &self, ctx: &ExecutionContext<'a>, diff --git a/crates/keystone/src/api/v3/domain/mod.rs b/crates/keystone/src/api/v3/domain/mod.rs index 114819bbf..ca8e588ce 100644 --- a/crates/keystone/src/api/v3/domain/mod.rs +++ b/crates/keystone/src/api/v3/domain/mod.rs @@ -27,7 +27,8 @@ pub mod types; #[derive(OpenApi)] #[openapi( tags( - (name="domains", description=r#"Domains are a collection of projects and users that define administrative boundaries for managing Identity entities. Domains can represent an individual, company, or operator-owned space. They expose administrative activities directly to system users. Users can be granted the administrator role for a domain. A domain administrator can create projects, users, and groups in a domain and assign roles to users and groups in a domain. + (name="domains", + description=r#"Domains are a collection of projects and users that define administrative boundaries for managing Identity entities. Domains can represent an individual, company, or operator-owned space. They expose administrative activities directly to system users. Users can be granted the administrator role for a domain. A domain administrator can create projects, users, and groups in a domain and assign roles to users and groups in a domain. "#), ) )] diff --git a/crates/keystone/src/api/v3/user/application_credential/create.rs b/crates/keystone/src/api/v3/user/application_credential/create.rs new file mode 100644 index 000000000..a0fe34415 --- /dev/null +++ b/crates/keystone/src/api/v3/user/application_credential/create.rs @@ -0,0 +1,410 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::json; +use validator::Validate; + +use super::types::application_credential::{ + ApplicationCredentialCreateRequest, ApplicationCredentialCreateResponse, +}; +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::auth::ScopeInfo; +/// Create application credential. +/// +/// POST /v3/users/{user_id}/application_credentials +#[utoipa::path( + post, + path = "/", + request_body = ApplicationCredentialCreateRequest, + responses( + (status = CREATED, description = "Application credential created", body = ApplicationCredentialCreateResponse), + (status = 400, description = "Bad request — validation error or role not found"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "User not found"), + (status = 409, description = "Conflict — application credential already exists"), + ), + tag = "application_credentials" +)] + +pub(super) async fn create( + Auth(user_auth): Auth, + Path(user_id): Path, + State(state): State, + Json(payload): Json, +) -> Result { + payload.validate()?; + + // Verify user exists — 404 if not found + state + .provider + .get_identity_provider() + .get_user(&ExecutionContext::from_auth(&state, &user_auth), &user_id) + .await? + .ok_or_else(|| KeystoneApiError::not_found("user", &user_id))?; + + // Security check — cannot create credentials for another user + let ctx_user_id = user_auth.principal().get_user_id(); + if ctx_user_id != user_id { + return Err(KeystoneApiError::forbidden(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "Cannot create an application credential for another user.", + ))); + } + + // project_id must come from the token scope, not the request body + let project_id = match user_auth.authorization().map(|a| &a.scope) { + Some(ScopeInfo::Project { project, .. }) => project.id.clone(), + _ => { + return Err(KeystoneApiError::BadRequest( + "application credentials require a project-scoped token".into(), + )); + } + }; + + state + .policy_enforcer + .enforce( + "identity/user/application_credential/create", + &user_auth, + json!({"user_id": user_id}), + None, + ) + .await?; + + let mut app_cred: openstack_keystone_core_types::application_credential::ApplicationCredentialCreate + = payload.application_credential.into(); + + // Inject server-side fields — never trust the request body for these + app_cred.user_id = user_id.clone(); + app_cred.project_id = project_id; + + let created = state + .provider + .get_application_credential_provider() + .create_application_credential(&ExecutionContext::from_auth(&state, &user_auth), app_cred) + .await + .map_err(KeystoneApiError::from)?; + + Ok(( + StatusCode::CREATED, + Json(ApplicationCredentialCreateResponse { + application_credential: created.into(), + }), + )) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode, header}, + }; + use http_body_util::BodyExt; + use tower::ServiceExt; + use tower_http::trace::TraceLayer; + use tracing_test::traced_test; + + use openstack_keystone_core_types::application_credential::ApplicationCredentialCreateResponseBuilder; + use openstack_keystone_core_types::identity::*; + use secrecy::SecretString; + + use crate::api::tests::{get_mocked_state, test_fixture_scoped}; + use crate::api::v3::openapi_router; + use crate::api::v3::user::application_credential::types::application_credential::{ + ApplicationCredentialCreateBuilder, ApplicationCredentialCreateRequest, + ApplicationCredentialCreateResponse, + }; + use crate::application_credential::MockApplicationCredentialProvider; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + + fn mock_user(mock: &mut MockIdentityProvider) { + mock.expect_get_user().returning(|_, _| { + Ok(Some( + UserResponseBuilder::default() + .id("uid") + .domain_id("did") + .enabled(true) + .name("test_user") + .build() + .unwrap(), + )) + }); + } + + fn mock_create_response() + -> openstack_keystone_core_types::application_credential::ApplicationCredentialCreateResponse + { + ApplicationCredentialCreateResponseBuilder::default() + .id("new-cred-id") + .name("my-cred") + .user_id("uid") + .project_id("pid") + .unrestricted(false) + .roles(vec![]) + .secret(SecretString::new("generated-secret".into())) + .build() + .unwrap() + } + + fn request_body() -> String { + let req = ApplicationCredentialCreateRequest { + application_credential: ApplicationCredentialCreateBuilder::default() + .name("my-cred") + .roles(vec![]) + .build() + .unwrap(), + }; + serde_json::to_string(&req).unwrap() + } + + #[traced_test] + #[tokio::test] + async fn test_create() { + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_create_application_credential() + .returning(|_, _| Ok(mock_create_response())); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("POST") + .uri("/users/uid/application_credentials") + .extension(vsc) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(request_body())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: ApplicationCredentialCreateResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(res.application_credential.id, "new-cred-id"); + assert_eq!(res.application_credential.name, "my-cred"); + // secret must be present in create response + assert!(!res.application_credential.secret.is_empty()); + } + + #[traced_test] + #[tokio::test] + async fn test_create_user_not_found() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, _| Ok(None)); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder().mock_identity(identity_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("POST") + .uri("/users/uid/application_credentials") + .extension(vsc) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(request_body())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[traced_test] + #[tokio::test] + async fn test_create_role_not_found() { + use openstack_keystone_core_types::application_credential::ApplicationCredentialProviderError; + + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_create_application_credential() + .returning(|_, _| { + Err(ApplicationCredentialProviderError::RoleNotFound( + "role-id".to_string(), + )) + }); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("POST") + .uri("/users/uid/application_credentials") + .extension(vsc) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(request_body())) + .unwrap(), + ) + .await + .unwrap(); + + // Role not found → 404 per OpenStack spec + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[traced_test] + #[tokio::test] + async fn test_create_conflict() { + use openstack_keystone_core_types::application_credential::ApplicationCredentialProviderError; + + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_create_application_credential() + .returning(|_, _| { + Err(ApplicationCredentialProviderError::Conflict( + "already exists".to_string(), + )) + }); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("POST") + .uri("/users/uid/application_credentials") + .extension(vsc) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(request_body())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CONFLICT); + } + + #[traced_test] + #[tokio::test] + async fn test_create_not_allowed() { + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder().mock_identity(identity_mock), + false, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("POST") + .uri("/users/uid/application_credentials") + .extension(vsc) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(request_body())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[traced_test] + #[tokio::test] + async fn test_create_unauthorized() { + let state = get_mocked_state(Provider::mocked_builder(), true, None).await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("POST") + .uri("/users/uid/application_credentials") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(request_body())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/keystone/src/api/v3/user/application_credential/delete.rs b/crates/keystone/src/api/v3/user/application_credential/delete.rs new file mode 100644 index 000000000..64f140f34 --- /dev/null +++ b/crates/keystone/src/api/v3/user/application_credential/delete.rs @@ -0,0 +1,302 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::json; + +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use openstack_keystone_core::auth::ExecutionContext; + +#[utoipa::path( + delete, + path = "/{application_credential_id}", + responses( + (status = NO_CONTENT, description = "Application credential deleted"), + (status = 404, description = "Application credential or user not found"), + (status = 403, description = "Forbidden"), + (status = 401, description = "Unauthorized"), + ), + tag = "application_credentials" +)] +pub(super) async fn delete( + Auth(user_auth): Auth, + Path((user_id, application_credential_id)): Path<(String, String)>, + State(state): State, +) -> Result { + state + .provider + .get_identity_provider() + .get_user(&ExecutionContext::from_auth(&state, &user_auth), &user_id) + .await? + .ok_or_else(|| KeystoneApiError::not_found("user", &user_id))?; + + // Fetch credential to get real user_id for policy enforcement + // Mirrors Python _update_request_user_id_attribute() security fix + let current = state + .provider + .get_application_credential_provider() + .get_application_credential( + &ExecutionContext::from_auth(&state, &user_auth), + &application_credential_id, + ) + .await + .map_err(KeystoneApiError::from)? + .ok_or_else(|| { + KeystoneApiError::not_found("application_credential", &application_credential_id) + })?; + + // Use credential's real user_id — not the URL parameter + state + .policy_enforcer + .enforce( + "identity/user/application_credential/delete", + &user_auth, + json!({"user_id": current.user_id}), + None, + ) + .await?; + + state + .provider + .get_application_credential_provider() + .delete_application_credential( + &ExecutionContext::from_auth(&state, &user_auth), + &application_credential_id, + ) + .await + .map_err(KeystoneApiError::from)?; + + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use tower::ServiceExt; + use tower_http::trace::TraceLayer; + use tracing_test::traced_test; + + use openstack_keystone_core_types::identity::*; + + use crate::api::tests::{get_mocked_state, test_fixture_scoped}; + use crate::api::v3::openapi_router; + use crate::application_credential::MockApplicationCredentialProvider; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + + fn mock_user(mock: &mut MockIdentityProvider) { + mock.expect_get_user().returning(|_, _| { + Ok(Some( + UserResponseBuilder::default() + .id("uid") + .domain_id("did") + .enabled(true) + .name("test_user") + .build() + .unwrap(), + )) + }); + } + + fn mock_credential() + -> openstack_keystone_core_types::application_credential::ApplicationCredential { + openstack_keystone_core_types::application_credential::ApplicationCredentialBuilder::default() + .id("cred-id").name("test-cred").user_id("uid") + .project_id("pid").unrestricted(false).roles(vec![]) + .build().unwrap() + } + + #[traced_test] + #[tokio::test] + async fn test_delete() { + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_get_application_credential() + .returning(|_, _| Ok(Some(mock_credential()))); + app_cred_mock + .expect_delete_application_credential() + .returning(|_, _| Ok(())); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/users/uid/application_credentials/cred-id") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } + + #[traced_test] + #[tokio::test] + async fn test_delete_user_not_found() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, _| Ok(None)); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder().mock_identity(identity_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/users/uid/application_credentials/cred-id") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[traced_test] + #[tokio::test] + async fn test_delete_credential_not_found() { + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_get_application_credential() + .returning(|_, _| Ok(None)); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/users/uid/application_credentials/non-existing") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[traced_test] + #[tokio::test] + async fn test_delete_not_allowed() { + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_get_application_credential() + .returning(|_, _| Ok(Some(mock_credential()))); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + false, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/users/uid/application_credentials/cred-id") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[traced_test] + #[tokio::test] + async fn test_delete_unauthorized() { + let state = get_mocked_state(Provider::mocked_builder(), true, None).await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/users/uid/application_credentials/cred-id") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/keystone/src/api/v3/user/application_credential/list.rs b/crates/keystone/src/api/v3/user/application_credential/list.rs new file mode 100644 index 000000000..7fdb13736 --- /dev/null +++ b/crates/keystone/src/api/v3/user/application_credential/list.rs @@ -0,0 +1,324 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::json; +use validator::Validate; + +use super::types::application_credential::{ + ApplicationCredentialList, ApplicationCredentialListParameters, +}; +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use openstack_keystone_core::auth::ExecutionContext; + +#[utoipa::path( + get, + path = "/", + params(ApplicationCredentialListParameters), + responses( + (status = OK, description = "List of application credentials", body = ApplicationCredentialList), + (status = 404, description = "User not found"), + (status = 403, description = "Forbidden"), + (status = 401, description = "Unauthorized"), + ), + tag = "application_credentials" +)] +pub(super) async fn list( + Auth(user_auth): Auth, + Path(user_id): Path, + Query(payload): Query, + State(state): State, +) -> Result { + payload.validate()?; + + // Verify user exists — returns 404 if not found per OpenStack API spec + state + .provider + .get_identity_provider() + .get_user(&ExecutionContext::from_auth(&state, &user_auth), &user_id) + .await? + .ok_or_else(|| KeystoneApiError::not_found("user", &user_id))?; + + state + .policy_enforcer + .enforce( + "identity/user/application_credential/list", + &user_auth, + json!({"user_id": user_id}), + None, + ) + .await?; + + // Set the user_id in the payload to ensure the list is scoped to the correct user + let mut filter: openstack_keystone_core_types::application_credential::ApplicationCredentialListParameters = payload.into(); + filter.user_id = user_id.clone(); + let application_credentials = state + .provider + .get_application_credential_provider() + .list_application_credentials(&ExecutionContext::from_auth(&state, &user_auth), &filter) + .await + .map_err(KeystoneApiError::from)?; + + Ok(( + StatusCode::OK, + Json(ApplicationCredentialList { + application_credentials: application_credentials + .into_iter() + .map(Into::into) + .collect(), + }), + )) +} +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use tower::ServiceExt; + use tower_http::trace::TraceLayer; + use tracing_test::traced_test; + + use openstack_keystone_core_types::application_credential::ApplicationCredentialBuilder as CoreApplicationCredentialBuilder; + use openstack_keystone_core_types::identity::*; + + use crate::api::tests::{get_mocked_state, test_fixture_scoped}; + use crate::api::v3::openapi_router; + use crate::api::v3::user::application_credential::types::application_credential::ApplicationCredentialList; + use crate::application_credential::MockApplicationCredentialProvider; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + + fn mock_user(mock: &mut MockIdentityProvider) { + mock.expect_get_user().returning(|_, _| { + Ok(Some( + UserResponseBuilder::default() + .id("uid") + .domain_id("did") + .enabled(true) + .name("test_user") + .build() + .unwrap(), + )) + }); + } + + fn mock_credentials() + -> Vec { + vec![ + CoreApplicationCredentialBuilder::default() + .id("cred-1") + .name("first") + .user_id("uid") + .project_id("pid") + .unrestricted(false) + .roles(vec![]) + .build() + .unwrap(), + CoreApplicationCredentialBuilder::default() + .id("cred-2") + .name("second") + .user_id("uid") + .project_id("pid") + .unrestricted(false) + .roles(vec![]) + .build() + .unwrap(), + ] + } + + #[traced_test] + #[tokio::test] + async fn test_list() { + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_list_application_credentials() + .returning(|_, _| Ok(mock_credentials())); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: ApplicationCredentialList = serde_json::from_slice(&body).unwrap(); + assert_eq!(res.application_credentials.len(), 2); + assert_eq!(res.application_credentials[0].id, "cred-1"); + assert_eq!(res.application_credentials[1].id, "cred-2"); + } + + #[traced_test] + #[tokio::test] + async fn test_list_empty() { + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_list_application_credentials() + .returning(|_, _| Ok(vec![])); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: ApplicationCredentialList = serde_json::from_slice(&body).unwrap(); + assert_eq!(res.application_credentials.len(), 0); + } + + #[traced_test] + #[tokio::test] + async fn test_list_user_not_found() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, _| Ok(None)); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder().mock_identity(identity_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[traced_test] + #[tokio::test] + async fn test_list_not_allowed() { + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_list_application_credentials() + .returning(|_, _| Ok(vec![])); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + false, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[traced_test] + #[tokio::test] + async fn test_list_unauthorized() { + let state = get_mocked_state(Provider::mocked_builder(), true, None).await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/keystone/src/api/v3/user/application_credential/mod.rs b/crates/keystone/src/api/v3/user/application_credential/mod.rs new file mode 100644 index 000000000..33697a680 --- /dev/null +++ b/crates/keystone/src/api/v3/user/application_credential/mod.rs @@ -0,0 +1,42 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +use utoipa::OpenApi; +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::keystone::ServiceState; + +mod create; +mod delete; +mod list; +mod show; +pub mod types; + +/// OpenApi specification for the application-credential API. +#[derive(OpenApi)] +#[openapi( + tags( + (name="application_credentials", + description=r#"Application Credentials are a way to authenticate to the OpenStack Identity service without using a user's password. They are useful for applications that need to interact with OpenStack services. +"#), + ) +)] +pub struct ApiDoc; + +pub(crate) fn openapi_router() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(create::create)) + .routes(routes!(delete::delete)) + .routes(routes!(list::list)) + .routes(routes!(show::show)) +} diff --git a/crates/keystone/src/api/v3/user/application_credential/show.rs b/crates/keystone/src/api/v3/user/application_credential/show.rs new file mode 100644 index 000000000..5690e0bfd --- /dev/null +++ b/crates/keystone/src/api/v3/user/application_credential/show.rs @@ -0,0 +1,316 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::json; + +use super::types::application_credential::ApplicationCredentialResponse; +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use openstack_keystone_core::auth::ExecutionContext; + +#[utoipa::path( + get, + path = "/{application_credential_id}", + params(), + responses( + (status = OK, description = "Single application credential", body = ApplicationCredentialResponse), + (status = 404, description = "Application credential or user not found"), + (status = 403, description = "Forbidden"), + (status = 401, description = "Unauthorized"), + ), + tag = "application_credentials" +)] +pub(super) async fn show( + Auth(user_auth): Auth, + Path((user_id, application_credential_id)): Path<(String, String)>, + State(state): State, +) -> Result { + // Verify user exists first — per OpenStack API spec + state + .provider + .get_identity_provider() + .get_user(&ExecutionContext::from_auth(&state, &user_auth), &user_id) + .await? + .ok_or_else(|| KeystoneApiError::not_found("user", &user_id))?; + + let current = state + .provider + .get_application_credential_provider() + .get_application_credential( + &ExecutionContext::from_auth(&state, &user_auth), + &application_credential_id, + ) + .await + .map_err(KeystoneApiError::from)? + .ok_or_else(|| { + KeystoneApiError::not_found("application_credential", &application_credential_id) + })?; + + state + .policy_enforcer + .enforce( + "identity/user/application_credential/show", + &user_auth, + json!({"user_id": current.user_id}), + None, + ) + .await?; + + Ok(( + StatusCode::OK, + Json(ApplicationCredentialResponse { + application_credential: current.into(), + }), + )) +} +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use tower::ServiceExt; + use tower_http::trace::TraceLayer; + use tracing_test::traced_test; + + use openstack_keystone_core_types::application_credential::ApplicationCredentialBuilder as CoreApplicationCredentialBuilder; + use openstack_keystone_core_types::identity::*; + + use crate::api::tests::{get_mocked_state, test_fixture_scoped}; + use crate::api::v3::openapi_router; + use crate::api::v3::user::application_credential::types::application_credential::{ + ApplicationCredentialBuilder as ApiApplicationCredentialBuilder, + ApplicationCredentialResponse, + }; + use crate::application_credential::MockApplicationCredentialProvider; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + + fn mock_user(mock: &mut MockIdentityProvider) { + mock.expect_get_user().returning(|_, _| { + Ok(Some( + UserResponseBuilder::default() + .id("uid") + .domain_id("did") + .enabled(true) + .name("test_user") + .build() + .unwrap(), + )) + }); + } + + fn mock_credential() + -> openstack_keystone_core_types::application_credential::ApplicationCredential { + CoreApplicationCredentialBuilder::default() + .id("existing-id") + .name("test-cred") + .user_id("uid") + .project_id("pid") + .unrestricted(false) + .roles(vec![]) + .build() + .unwrap() + } + + #[traced_test] + #[tokio::test] + async fn test_show() { + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_get_application_credential() + .withf(|_, id: &'_ str| id == "existing-id") + .returning(|_, _| Ok(Some(mock_credential()))); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials/existing-id") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: ApplicationCredentialResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!( + ApiApplicationCredentialBuilder::default() + .id("existing-id") + .name("test-cred") + .user_id("uid") + .project_id("pid") + .unrestricted(false) + .roles(vec![]) + .build() + .unwrap(), + res.application_credential, + ); + } + + #[traced_test] + #[tokio::test] + async fn test_show_user_not_found() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, _| Ok(None)); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder().mock_identity(identity_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials/existing-id") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[traced_test] + #[tokio::test] + async fn test_show_credential_not_found() { + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_get_application_credential() + .returning(|_, _| Ok(None)); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials/non-existing-id") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[traced_test] + #[tokio::test] + async fn test_show_not_allowed() { + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_get_application_credential() + .returning(|_, _| Ok(Some(mock_credential()))); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + false, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials/existing-id") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[traced_test] + #[tokio::test] + async fn test_show_unauthorized() { + let state = get_mocked_state(Provider::mocked_builder(), true, None).await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials/existing-id") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/keystone/src/api/v3/user/application_credential/types.rs b/crates/keystone/src/api/v3/user/application_credential/types.rs new file mode 100644 index 000000000..4d76c0572 --- /dev/null +++ b/crates/keystone/src/api/v3/user/application_credential/types.rs @@ -0,0 +1,15 @@ +// 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 + +pub use openstack_keystone_api_types::v3::application_credential::*; diff --git a/crates/keystone/src/api/v3/user/mod.rs b/crates/keystone/src/api/v3/user/mod.rs index 3f90d316a..1ab7f1ca7 100644 --- a/crates/keystone/src/api/v3/user/mod.rs +++ b/crates/keystone/src/api/v3/user/mod.rs @@ -16,6 +16,7 @@ use utoipa_axum::{router::OpenApiRouter, routes}; use crate::keystone::ServiceState; +pub mod application_credential; mod create; mod delete; mod groups; @@ -32,6 +33,10 @@ pub(super) fn openapi_router() -> OpenApiRouter { .routes(routes!(update::update)) .routes(routes!(groups::groups)) .merge(os_ec2::openapi_router()) + .nest( + "/{user_id}/application_credentials", + application_credential::openapi_router(), + ) } #[cfg(test)] diff --git a/policy/user/application_credential/create.rego b/policy/user/application_credential/create.rego new file mode 100644 index 000000000..085b58d8f --- /dev/null +++ b/policy/user/application_credential/create.rego @@ -0,0 +1,9 @@ +# METADATA +# description: Policy for creating application credentials +package identity.user.application_credential.create + +default allow := false + +allow if { + input.credentials.user_id == input.target.user_id +} \ No newline at end of file diff --git a/policy/user/application_credential/create_test.rego b/policy/user/application_credential/create_test.rego new file mode 100644 index 000000000..5525ed45f --- /dev/null +++ b/policy/user/application_credential/create_test.rego @@ -0,0 +1,11 @@ +package test_application_credential_create + +import data.identity.user.application_credential.create + +test_owner_allowed if { + create.allow with input as {"credentials": {"user_id": "uid"}, "target": {"user_id": "uid"}} +} + +test_non_owner_forbidden if { + not create.allow with input as {"credentials": {"user_id": "other"}, "target": {"user_id": "uid"}} +} \ No newline at end of file diff --git a/policy/user/application_credential/delete.rego b/policy/user/application_credential/delete.rego new file mode 100644 index 000000000..d244fc7a1 --- /dev/null +++ b/policy/user/application_credential/delete.rego @@ -0,0 +1,9 @@ +# METADATA +# description: Policy for deleting application credentials +package identity.user.application_credential.delete + +default allow := false + +allow if { input.credentials.is_admin } + +allow if { input.credentials.user_id == input.target.user_id } \ No newline at end of file diff --git a/policy/user/application_credential/delete_test.rego b/policy/user/application_credential/delete_test.rego new file mode 100644 index 000000000..71ffef6cf --- /dev/null +++ b/policy/user/application_credential/delete_test.rego @@ -0,0 +1,15 @@ +package test_application_credential_delete + +import data.identity.user.application_credential.delete + +test_admin_allowed if { + delete.allow with input as {"credentials": {"is_admin": true}, "target": {"user_id": "uid"}} +} + +test_owner_allowed if { + delete.allow with input as {"credentials": {"user_id": "uid"}, "target": {"user_id": "uid"}} +} + +test_non_owner_forbidden if { + not delete.allow with input as {"credentials": {"user_id": "other", "roles": []}, "target": {"user_id": "uid"}} +} \ No newline at end of file diff --git a/policy/user/application_credential/list.rego b/policy/user/application_credential/list.rego new file mode 100644 index 000000000..4bf712453 --- /dev/null +++ b/policy/user/application_credential/list.rego @@ -0,0 +1,14 @@ +# METADATA +# description: Policy for listing application credentials +package identity.user.application_credential.list + +default allow := false + +allow if { input.credentials.is_admin } + +allow if { + "reader" in input.credentials.roles + input.credentials.system == "all" +} + +allow if { input.credentials.user_id == input.target.user_id } \ No newline at end of file diff --git a/policy/user/application_credential/list_test.rego b/policy/user/application_credential/list_test.rego new file mode 100644 index 000000000..758dfc523 --- /dev/null +++ b/policy/user/application_credential/list_test.rego @@ -0,0 +1,19 @@ +package test_application_credential_list + +import data.identity.user.application_credential.list + +test_admin_allowed if { + list.allow with input as {"credentials": {"is_admin": true}, "target": {"user_id": "uid"}} +} + +test_system_reader_allowed if { + list.allow with input as {"credentials": {"roles": ["reader"], "system": "all"}, "target": {"user_id": "uid"}} +} + +test_owner_allowed if { + list.allow with input as {"credentials": {"user_id": "uid"}, "target": {"user_id": "uid"}} +} + +test_non_owner_forbidden if { + not list.allow with input as {"credentials": {"user_id": "other", "roles": []}, "target": {"user_id": "uid"}} +} \ No newline at end of file diff --git a/policy/user/application_credential/show.rego b/policy/user/application_credential/show.rego new file mode 100644 index 000000000..da7747a28 --- /dev/null +++ b/policy/user/application_credential/show.rego @@ -0,0 +1,14 @@ +# METADATA +# description: Policy for showing application credential details +package identity.user.application_credential.show + +default allow := false + +allow if { input.credentials.is_admin } + +allow if { + "reader" in input.credentials.roles + input.credentials.system == "all" +} + +allow if { input.credentials.user_id == input.target.user_id } \ No newline at end of file diff --git a/policy/user/application_credential/show_test.rego b/policy/user/application_credential/show_test.rego new file mode 100644 index 000000000..af903d3f5 --- /dev/null +++ b/policy/user/application_credential/show_test.rego @@ -0,0 +1,19 @@ +package test_application_credential_show + +import data.identity.user.application_credential.show + +test_admin_allowed if { + show.allow with input as {"credentials": {"is_admin": true}, "target": {"user_id": "uid"}} +} + +test_system_reader_allowed if { + show.allow with input as {"credentials": {"roles": ["reader"], "system": "all"}, "target": {"user_id": "uid"}} +} + +test_owner_allowed if { + show.allow with input as {"credentials": {"user_id": "uid"}, "target": {"user_id": "uid"}} +} + +test_non_owner_forbidden if { + not show.allow with input as {"credentials": {"user_id": "other", "roles": []}, "target": {"user_id": "uid"}} +} \ No newline at end of file diff --git a/tests/api/src/identity.rs b/tests/api/src/identity.rs index 4a03c26c2..cd5b79dd1 100644 --- a/tests/api/src/identity.rs +++ b/tests/api/src/identity.rs @@ -11,4 +11,5 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 +pub mod application_credential; pub mod user; diff --git a/tests/api/src/identity/application_credential.rs b/tests/api/src/identity/application_credential.rs new file mode 100644 index 000000000..30bea1db5 --- /dev/null +++ b/tests/api/src/identity/application_credential.rs @@ -0,0 +1,170 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +use crate::guard::*; +use eyre::Result; +use openstack_keystone_api_types::v3::application_credential::application_credential::*; +use openstack_sdk::api::rest_endpoint_prelude::*; +use openstack_sdk::{AsyncOpenStack, api::QueryAsync}; +use std::borrow::Cow; +use std::sync::Arc; + +struct AppCredCreateRequest { + user_id: String, + app_cred: ApplicationCredentialCreate, +} + +impl RestEndpoint for AppCredCreateRequest { + fn method(&self) -> http::Method { + http::Method::POST + } + fn endpoint(&self) -> Cow<'static, str> { + format!("users/{}/application_credentials", self.user_id).into() + } + fn body(&self) -> Result)>, BodyError> { + let mut params = JsonBodyParams::default(); + params.push( + "application_credential", + serde_json::to_value(&self.app_cred)?, + ); + params.into_body() + } + fn service_type(&self) -> ServiceType { + ServiceType::Identity + } + fn response_key(&self) -> Option> { + Some("application_credential".into()) + } + fn api_version(&self) -> Option { + Some(ApiVersion::new(3, 0)) + } +} + +struct AppCredDeleteRequest { + user_id: String, + id: String, +} + +impl RestEndpoint for AppCredDeleteRequest { + fn method(&self) -> http::Method { + http::Method::DELETE + } + fn endpoint(&self) -> Cow<'static, str> { + format!("users/{}/application_credentials/{}", self.user_id, self.id).into() + } + fn service_type(&self) -> ServiceType { + ServiceType::Identity + } + fn api_version(&self) -> Option { + Some(ApiVersion::new(3, 0)) + } +} + +#[async_trait::async_trait] +impl DeletableResource for ApplicationCredentialCreated { + async fn delete(&self, state: &Arc) -> Result<()> { + Ok(openstack_sdk::api::ignore(AppCredDeleteRequest { + user_id: self.user_id.clone(), + id: self.id.clone(), + }) + .query_async(state.as_ref()) + .await?) + } +} + +pub async fn create_application_credential( + tc: &Arc, + user_id: &str, + app_cred: ApplicationCredentialCreate, +) -> Result> { + let obj: ApplicationCredentialCreated = AppCredCreateRequest { + user_id: user_id.to_string(), + app_cred, + } + .query_async(tc.as_ref()) + .await?; + Ok(AsyncResourceGuard::new(obj, tc.clone())) +} + +struct AppCredGetRequest { + user_id: String, + id: String, +} + +impl RestEndpoint for AppCredGetRequest { + fn method(&self) -> http::Method { + http::Method::GET + } + fn endpoint(&self) -> Cow<'static, str> { + format!("users/{}/application_credentials/{}", self.user_id, self.id).into() + } + fn service_type(&self) -> ServiceType { + ServiceType::Identity + } + fn response_key(&self) -> Option> { + Some("application_credential".into()) + } + fn api_version(&self) -> Option { + Some(ApiVersion::new(3, 0)) + } +} + +struct AppCredListRequest { + user_id: String, +} + +impl RestEndpoint for AppCredListRequest { + fn method(&self) -> http::Method { + http::Method::GET + } + fn endpoint(&self) -> Cow<'static, str> { + format!("users/{}/application_credentials", self.user_id).into() + } + fn service_type(&self) -> ServiceType { + ServiceType::Identity + } + fn response_key(&self) -> Option> { + Some("application_credentials".into()) + } + fn api_version(&self) -> Option { + Some(ApiVersion::new(3, 0)) + } +} + +pub async fn get_application_credential( + tc: &Arc, + user_id: &str, + id: &str, +) -> Result { + use openstack_keystone_api_types::v3::application_credential::application_credential::ApplicationCredential; + let obj: ApplicationCredential = AppCredGetRequest { + user_id: user_id.to_string(), + id: id.to_string(), + } + .query_async(tc.as_ref()) + .await?; + Ok(obj) +} + +pub async fn list_application_credentials( + tc: &Arc, + user_id: &str, +) -> Result> { + use openstack_keystone_api_types::v3::application_credential::application_credential::ApplicationCredential; + let objs: Vec = AppCredListRequest { + user_id: user_id.to_string(), + } + .query_async(tc.as_ref()) + .await?; + Ok(objs) +} diff --git a/tests/api/tests/api_v3/identity.rs b/tests/api/tests/api_v3/identity.rs index 019f7e50e..99721bcc7 100644 --- a/tests/api/tests/api_v3/identity.rs +++ b/tests/api/tests/api_v3/identity.rs @@ -11,4 +11,5 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 +mod application_credential; mod user; diff --git a/tests/api/tests/api_v3/identity/application_credential.rs b/tests/api/tests/api_v3/identity/application_credential.rs new file mode 100644 index 000000000..b24baf55f --- /dev/null +++ b/tests/api/tests/api_v3/identity/application_credential.rs @@ -0,0 +1,18 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +mod create; +mod delete; +mod list; +mod show; diff --git a/tests/api/tests/api_v3/identity/application_credential/create.rs b/tests/api/tests/api_v3/identity/application_credential/create.rs new file mode 100644 index 000000000..3705b2126 --- /dev/null +++ b/tests/api/tests/api_v3/identity/application_credential/create.rs @@ -0,0 +1,76 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +use crate::api_v3::identity::application_credential::list::get_project_scoped_client; +use eyre::Result; +use openstack_keystone_api_types::v3::application_credential::application_credential::*; +use test_api::guard::ResourceGuard; +use test_api::identity::application_credential::create_application_credential; +use tracing_test::traced_test; + +#[tokio::test] +#[traced_test] +async fn test_create() -> Result<()> { + let tc = get_project_scoped_client().await?; + let user_id = tc + .get_auth_info() + .ok_or_else(|| eyre::eyre!("no auth info available"))? + .token + .user + .id; + + let cred = create_application_credential( + &tc, + &user_id, + ApplicationCredentialCreateBuilder::default() + .name("test-cred") + .roles(vec![]) + .build()?, + ) + .await?; + + assert_eq!(cred.name, "test-cred"); + assert!(!cred.secret.is_empty()); + assert_eq!(cred.user_id, user_id); + + cred.delete().await?; + Ok(()) +} + +#[tokio::test] +#[traced_test] +async fn test_create_with_description() -> Result<()> { + let tc = get_project_scoped_client().await?; + let user_id = tc + .get_auth_info() + .ok_or_else(|| eyre::eyre!("no auth info available"))? + .token + .user + .id; + + let cred = create_application_credential( + &tc, + &user_id, + ApplicationCredentialCreateBuilder::default() + .name("test-cred") + .description("my description") + .roles(vec![]) + .build()?, + ) + .await?; + + assert_eq!(cred.description, Some("my description".to_string())); + + cred.delete().await?; + Ok(()) +} diff --git a/tests/api/tests/api_v3/identity/application_credential/delete.rs b/tests/api/tests/api_v3/identity/application_credential/delete.rs new file mode 100644 index 000000000..a4789821d --- /dev/null +++ b/tests/api/tests/api_v3/identity/application_credential/delete.rs @@ -0,0 +1,52 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +use crate::api_v3::identity::application_credential::list::get_project_scoped_client; +use eyre::Result; +use openstack_keystone_api_types::v3::application_credential::application_credential::*; +use test_api::guard::ResourceGuard; +use test_api::identity::application_credential::{ + create_application_credential, get_application_credential, +}; +use tracing_test::traced_test; + +#[tokio::test] +#[traced_test] +async fn test_delete() -> Result<()> { + let tc = get_project_scoped_client().await?; + let user_id = tc + .get_auth_info() + .ok_or_else(|| eyre::eyre!("no auth info available"))? + .token + .user + .id; + + let cred = create_application_credential( + &tc, + &user_id, + ApplicationCredentialCreateBuilder::default() + .name("test-cred") + .roles(vec![]) + .build()?, + ) + .await?; + + let cred_id = cred.id.clone(); + cred.delete().await?; + + // Verify it no longer exists + let result = get_application_credential(&tc, &user_id, &cred_id).await; + assert!(result.is_err()); + + Ok(()) +} diff --git a/tests/api/tests/api_v3/identity/application_credential/list.rs b/tests/api/tests/api_v3/identity/application_credential/list.rs new file mode 100644 index 000000000..a08e86d83 --- /dev/null +++ b/tests/api/tests/api_v3/identity/application_credential/list.rs @@ -0,0 +1,88 @@ +use eyre::Result; +use openstack_keystone_api_types::v3::application_credential::application_credential::*; +use openstack_sdk::{AsyncOpenStack, config::CloudConfig}; +use std::sync::Arc; +use test_api::guard::ResourceGuard; +use test_api::identity::application_credential::{ + create_application_credential, list_application_credentials, +}; +use tracing_test::traced_test; + +pub async fn get_project_scoped_client() -> Result> { + let mut tc = AsyncOpenStack::new(&CloudConfig::from_env()?).await?; + + tc.authorize( + Some(openstack_sdk::auth::authtoken::AuthTokenScope::Project( + openstack_sdk::types::identity::v3::Project { + id: None, + name: Some("admin".to_string()), + domain: Some(openstack_sdk::types::identity::v3::Domain { + id: Some("default".to_string()), + name: None, + }), + }, + )), + false, + false, + ) + .await?; + + Ok(Arc::new(tc)) +} + +#[tokio::test] +#[traced_test] +async fn test_list() -> Result<()> { + let tc = get_project_scoped_client().await?; + let user_id = tc + .get_auth_info() + .ok_or_else(|| eyre::eyre!("no auth info available"))? + .token + .user + .id; + + let cred1 = create_application_credential( + &tc, + &user_id, + ApplicationCredentialCreateBuilder::default() + .name(format!("cred-1")) + .roles(vec![]) + .build()?, + ) + .await?; + + let cred2 = create_application_credential( + &tc, + &user_id, + ApplicationCredentialCreateBuilder::default() + .name(format!("cred-2")) + .roles(vec![]) + .build()?, + ) + .await?; + + let list = list_application_credentials(&tc, &user_id).await?; + assert!(list.iter().any(|c| c.id == cred1.id)); + assert!(list.iter().any(|c| c.id == cred2.id)); + + cred1.delete().await?; + cred2.delete().await?; + Ok(()) +} + +#[tokio::test] +#[traced_test] +async fn test_list_empty() -> Result<()> { + let tc = get_project_scoped_client().await?; + let user_id = tc + .get_auth_info() + .ok_or_else(|| eyre::eyre!("no auth info available"))? + .token + .user + .id; + + let list = list_application_credentials(&tc, &user_id).await?; + assert!(list.iter().all(|c| c.user_id == user_id)); + + Ok(()) +} diff --git a/tests/api/tests/api_v3/identity/application_credential/show.rs b/tests/api/tests/api_v3/identity/application_credential/show.rs new file mode 100644 index 000000000..8cbb7766b --- /dev/null +++ b/tests/api/tests/api_v3/identity/application_credential/show.rs @@ -0,0 +1,52 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +use crate::api_v3::identity::application_credential::list::get_project_scoped_client; +use eyre::Result; +use openstack_keystone_api_types::v3::application_credential::application_credential::*; +use test_api::guard::ResourceGuard; +use test_api::identity::application_credential::{ + create_application_credential, get_application_credential, +}; +use tracing_test::traced_test; + +#[tokio::test] +#[traced_test] +async fn test_show() -> Result<()> { + let tc = get_project_scoped_client().await?; + let user_id = tc + .get_auth_info() + .ok_or_else(|| eyre::eyre!("no auth info available"))? + .token + .user + .id; + + let cred = create_application_credential( + &tc, + &user_id, + ApplicationCredentialCreateBuilder::default() + .name("test-cred") + .roles(vec![]) + .build()?, + ) + .await?; + + let fetched = get_application_credential(&tc, &user_id, &cred.id).await?; + + assert_eq!(fetched.id, cred.id); + assert_eq!(fetched.name, "test-cred"); + assert_eq!(fetched.user_id, user_id); + + cred.delete().await?; + Ok(()) +}