From b88cd312edf7c128c751318909861e811aae7ba2 Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Sat, 27 Jun 2026 23:54:00 +0000 Subject: [PATCH 1/4] feat: Add api types of App Cred Signed-off-by: Hamza Konac --- crates/api-types/src/v3.rs | 3 + .../src/v3/application_credential.rs | 16 ++ .../v3/application_credential/access_rule.rs | 88 +++++++++++ .../application_credential.rs | 148 ++++++++++++++++++ .../src/v3/application_credential_conv.rs | 90 +++++++++++ crates/keystone/src/api/v3/domain/mod.rs | 3 +- .../v3/user/application_credential/create.rs | 13 ++ .../v3/user/application_credential/delete.rs | 13 ++ .../v3/user/application_credential/list.rs | 13 ++ .../api/v3/user/application_credential/mod.rs | 42 +++++ .../v3/user/application_credential/show.rs | 72 +++++++++ .../v3/user/application_credential/types.rs | 15 ++ crates/keystone/src/api/v3/user/mod.rs | 5 + 13 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 crates/api-types/src/v3/application_credential.rs create mode 100644 crates/api-types/src/v3/application_credential/access_rule.rs create mode 100644 crates/api-types/src/v3/application_credential/application_credential.rs create mode 100644 crates/api-types/src/v3/application_credential_conv.rs create mode 100644 crates/keystone/src/api/v3/user/application_credential/create.rs create mode 100644 crates/keystone/src/api/v3/user/application_credential/delete.rs create mode 100644 crates/keystone/src/api/v3/user/application_credential/list.rs create mode 100644 crates/keystone/src/api/v3/user/application_credential/mod.rs create mode 100644 crates/keystone/src/api/v3/user/application_credential/show.rs create mode 100644 crates/keystone/src/api/v3/user/application_credential/types.rs 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..edc717a1a --- /dev/null +++ b/crates/api-types/src/v3/application_credential/access_rule.rs @@ -0,0 +1,88 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "validate")] +use validator::Validate; + +/// 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..68978ec51 --- /dev/null +++ b/crates/api-types/src/v3/application_credential/application_credential.rs @@ -0,0 +1,148 @@ +// 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::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, +} + +/// 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, + + pub limit: Option, + + pub marker: 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..42bb1beb6 --- /dev/null +++ b/crates/api-types/src/v3/application_credential_conv.rs @@ -0,0 +1,90 @@ +// 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 openstack_keystone_core_types::application_credential as provider_types; + +use crate::v3::application_credential as api_types; + +impl From for api_types::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::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::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, + unrestricted: value.unrestricted, + user_id: value.user_id, + } + } +} + +impl From for provider_types::ApplicationCredentialCreate { + fn from(value: api_types::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, + 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::ApplicationCredentialListParameters) -> Self { + Self { + limit: value.limit, + marker: value.marker, + name: value.name, + user_id: String::new(), // injected from auth context, not from request body + } + } +} 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..0abb76f01 --- /dev/null +++ b/crates/keystone/src/api/v3/user/application_credential/create.rs @@ -0,0 +1,13 @@ +// 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 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..0abb76f01 --- /dev/null +++ b/crates/keystone/src/api/v3/user/application_credential/delete.rs @@ -0,0 +1,13 @@ +// 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 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..0abb76f01 --- /dev/null +++ b/crates/keystone/src/api/v3/user/application_credential/list.rs @@ -0,0 +1,13 @@ +// 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 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..dc40d91c3 --- /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::remove)) + .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..dbde91350 --- /dev/null +++ b/crates/keystone/src/api/v3/user/application_credential/show.rs @@ -0,0 +1,72 @@ +// 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::{Json, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::json; +use validator::Validate; + +use super::types::application_credential::{ApplicationCredential, ApplicationCredentialResponse}; +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use openstack_keystone_core::auth::ExecutionContext; + +/// Show application credential details. +#[utoipa::path( + get, + path = "/{application_credential_id}", + params(), + responses( + (status = OK, description = "Single application credential", body = ApplicationCredentialResponse), + (status = 404, description = "Application credential not found", example = json!(KeystoneApiError::NotFound(String::from("id = 1")))), + (status = 403, description = "Forbidden", example = json!(KeystoneApiError::Forbidden)), + (status = 401, description = "Unauthorized", example = json!(KeystoneApiError::Unauthorized)) + ), + tag="application_credentials" +)] +pub(super) async fn show( + Auth(user_auth): Auth, + Path(application_credential_id): Path, + State(state): State, +) -> Result { + let current = state + .provider + .get_application_credential_provider() + .get_application_credential( + &ExecutionContext::from_auth(&state, &user_auth), + &application_credential_id, + ) + .await? + .ok_or(KeystoneApiError::NotFound)?; + + state + .policy_enforcer + .enforce( + "identity/application_credential/show", + &user_auth, + json!({"user_id": current.user_id}), + None, + ) + .await?; + + Ok(( + StatusCode::OK, + Json(ApplicationCredentialResponse { + application_credential: current.into(), + }), + )) +} 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)] From 2a3771482d0d5673503ac3348c64e827091a4dc6 Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Sun, 28 Jun 2026 23:46:57 +0000 Subject: [PATCH 2/4] feat: Complete api layer implementtaions Signed-off-by: Hamza Konac --- crates/api-types/src/error_conv.rs | 34 ++ .../application_credential.rs | 41 +- .../src/v3/application_credential_conv.rs | 54 ++- .../v3/user/application_credential/create.rs | 399 ++++++++++++++++++ .../v3/user/application_credential/list.rs | 312 ++++++++++++++ .../api/v3/user/application_credential/mod.rs | 2 +- .../v3/user/application_credential/show.rs | 267 +++++++++++- 7 files changed, 1079 insertions(+), 30 deletions(-) diff --git a/crates/api-types/src/error_conv.rs b/crates/api-types/src/error_conv.rs index 3bc40dc04..d566bdbee 100644 --- a/crates/api-types/src/error_conv.rs +++ b/crates/api-types/src/error_conv.rs @@ -488,6 +488,40 @@ impl From for KeystoneApiError { } } +impl From for KeystoneApiError { + 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()) + } + 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()), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/api-types/src/v3/application_credential/application_credential.rs b/crates/api-types/src/v3/application_credential/application_credential.rs index 68978ec51..ec683ecd3 100644 --- a/crates/api-types/src/v3/application_credential/application_credential.rs +++ b/crates/api-types/src/v3/application_credential/application_credential.rs @@ -18,7 +18,8 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "validate")] use validator::Validate; -use crate::role::RoleRef; +use crate::v3::application_credential::access_rule::{AccessRule, AccessRuleCreate}; +use crate::v3::role::RoleRef; /// Full application credential representation. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -125,6 +126,40 @@ pub struct ApplicationCredentialCreateRequest { 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))] @@ -141,8 +176,4 @@ pub struct ApplicationCredentialList { pub struct ApplicationCredentialListParameters { #[cfg_attr(feature = "validate", validate(length(max = 255)))] pub name: Option, - - pub limit: Option, - - pub marker: Option, } diff --git a/crates/api-types/src/v3/application_credential_conv.rs b/crates/api-types/src/v3/application_credential_conv.rs index 42bb1beb6..5325d1d8d 100644 --- a/crates/api-types/src/v3/application_credential_conv.rs +++ b/crates/api-types/src/v3/application_credential_conv.rs @@ -11,12 +11,15 @@ // 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 as api_types; +use crate::v3 as api_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::AccessRule { +impl From for api_types_access_rule::AccessRule { fn from(value: provider_types::AccessRule) -> Self { Self { id: value.id, @@ -27,8 +30,8 @@ impl From for api_types::AccessRule { } } -impl From for provider_types::AccessRuleCreate { - fn from(value: api_types::AccessRuleCreate) -> Self { +impl From for provider_types::AccessRuleCreate { + fn from(value: api_types_access_rule::AccessRuleCreate) -> Self { Self { id: value.id, method: value.method, @@ -39,7 +42,9 @@ impl From for provider_types::AccessRuleCreate { } } -impl From for api_types::ApplicationCredential { +impl From + for api_types_application_credential::ApplicationCredential +{ fn from(value: provider_types::ApplicationCredential) -> Self { Self { access_rules: value @@ -50,15 +55,17 @@ impl From for api_types::ApplicationCrede id: value.id, name: value.name, project_id: value.project_id, - roles: value.roles, + 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::ApplicationCredentialCreate) -> Self { +impl From + for provider_types::ApplicationCredentialCreate +{ + fn from(value: api_types_application_credential::ApplicationCredentialCreate) -> Self { Self { access_rules: value .access_rules @@ -68,7 +75,7 @@ impl From for provider_types::Applicatio id: value.id, name: value.name, project_id: String::new(), // assigned server-side from token - roles: value.roles, + 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 @@ -76,15 +83,36 @@ impl From for provider_types::Applicatio } } -impl From +impl From for provider_types::ApplicationCredentialListParameters { - fn from(value: api_types::ApplicationCredentialListParameters) -> Self { + fn from(value: api_types_application_credential::ApplicationCredentialListParameters) -> Self { Self { - limit: value.limit, - marker: value.marker, + 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/keystone/src/api/v3/user/application_credential/create.rs b/crates/keystone/src/api/v3/user/application_credential/create.rs index 0abb76f01..d4df4acdb 100644 --- a/crates/keystone/src/api/v3/user/application_credential/create.rs +++ b/crates/keystone/src/api/v3/user/application_credential/create.rs @@ -11,3 +11,402 @@ // 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::application_credential::ApplicationCredentialApi; +use crate::identity::IdentityApi; +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/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/list.rs b/crates/keystone/src/api/v3/user/application_credential/list.rs index 0abb76f01..63c3ee7ee 100644 --- a/crates/keystone/src/api/v3/user/application_credential/list.rs +++ b/crates/keystone/src/api/v3/user/application_credential/list.rs @@ -11,3 +11,315 @@ // 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::identity::IdentityApi; +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/application_credential/list", + &user_auth, + json!({"user_id": user_id}), + None, + ) + .await?; + + let application_credentials = state + .provider + .get_application_credential_provider() + .list_application_credentials( + &ExecutionContext::from_auth(&state, &user_auth), + &payload.into(), + ) + .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 index dc40d91c3..a134f6f41 100644 --- a/crates/keystone/src/api/v3/user/application_credential/mod.rs +++ b/crates/keystone/src/api/v3/user/application_credential/mod.rs @@ -36,7 +36,7 @@ pub struct ApiDoc; pub(crate) fn openapi_router() -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(create::create)) - .routes(routes!(delete::remove)) + // .routes(routes!(delete::remove)) .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 index dbde91350..aa363f449 100644 --- a/crates/keystone/src/api/v3/user/application_credential/show.rs +++ b/crates/keystone/src/api/v3/user/application_credential/show.rs @@ -11,38 +11,47 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 + use axum::{ - extract::{Json, State}, + Json, + extract::{Path, State}, http::StatusCode, response::IntoResponse, }; use serde_json::json; -use validator::Validate; -use super::types::application_credential::{ApplicationCredential, ApplicationCredentialResponse}; +use super::types::application_credential::ApplicationCredentialResponse; use crate::api::auth::Auth; use crate::api::error::KeystoneApiError; +use crate::identity::IdentityApi; use crate::keystone::ServiceState; use openstack_keystone_core::auth::ExecutionContext; -/// Show application credential details. #[utoipa::path( get, path = "/{application_credential_id}", params(), responses( (status = OK, description = "Single application credential", body = ApplicationCredentialResponse), - (status = 404, description = "Application credential not found", example = json!(KeystoneApiError::NotFound(String::from("id = 1")))), - (status = 403, description = "Forbidden", example = json!(KeystoneApiError::Forbidden)), - (status = 401, description = "Unauthorized", example = json!(KeystoneApiError::Unauthorized)) + (status = 404, description = "Application credential or user not found"), + (status = 403, description = "Forbidden"), + (status = 401, description = "Unauthorized"), ), - tag="application_credentials" + tag = "application_credentials" )] pub(super) async fn show( Auth(user_auth): Auth, - Path(application_credential_id): Path, + 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() @@ -50,8 +59,11 @@ pub(super) async fn show( &ExecutionContext::from_auth(&state, &user_auth), &application_credential_id, ) - .await? - .ok_or(KeystoneApiError::NotFound)?; + .await + .map_err(KeystoneApiError::from)? + .ok_or_else(|| { + KeystoneApiError::not_found("application_credential", &application_credential_id) + })?; state .policy_enforcer @@ -70,3 +82,236 @@ pub(super) async fn show( }), )) } +#[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); + } +} From b72efcc4185b22a3f63259cd5b22e7934dc8d737 Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Tue, 7 Jul 2026 22:37:25 +0000 Subject: [PATCH 3/4] feat: Implement app cred APIs Signed-off-by: Hamza Konac --- crates/api-types/src/error_conv.rs | 56 ++-- .../v3/application_credential/access_rule.rs | 4 - .../src/v3/application_credential_conv.rs | 1 - .../src/application_credential.rs | 4 +- .../src/application_credential/delete.rs | 99 ++++++ crates/appcred-driver-sql/src/lib.rs | 16 + .../src/application_credential/backend.rs | 15 + .../application_credential/provider_api.rs | 15 + .../src/application_credential/service.rs | 42 +++ crates/core/src/mocks.rs | 6 + .../v3/user/application_credential/create.rs | 4 +- .../v3/user/application_credential/delete.rs | 282 ++++++++++++++++++ .../v3/user/application_credential/list.rs | 11 +- .../api/v3/user/application_credential/mod.rs | 2 +- .../v3/user/application_credential/show.rs | 3 +- .../user/application_credential/create.rego | 9 + .../application_credential/create_test.rego | 11 + .../user/application_credential/delete.rego | 9 + .../application_credential/delete_test.rego | 15 + policy/user/application_credential/list.rego | 14 + .../application_credential/list_test.rego | 19 ++ policy/user/application_credential/show.rego | 14 + .../application_credential/show_test.rego | 19 ++ tests/api/src/identity.rs | 1 + .../src/identity/application_credential.rs | 170 +++++++++++ tests/api/tests/api_v3/identity.rs | 1 + .../api_v3/identity/application_credential.rs | 18 ++ .../identity/application_credential/create.rs | 102 +++++++ .../identity/application_credential/delete.rs | 78 +++++ .../identity/application_credential/list.rs | 90 ++++++ .../identity/application_credential/show.rs | 78 +++++ 31 files changed, 1151 insertions(+), 57 deletions(-) create mode 100644 crates/appcred-driver-sql/src/application_credential/delete.rs create mode 100644 policy/user/application_credential/create.rego create mode 100644 policy/user/application_credential/create_test.rego create mode 100644 policy/user/application_credential/delete.rego create mode 100644 policy/user/application_credential/delete_test.rego create mode 100644 policy/user/application_credential/list.rego create mode 100644 policy/user/application_credential/list_test.rego create mode 100644 policy/user/application_credential/show.rego create mode 100644 policy/user/application_credential/show_test.rego create mode 100644 tests/api/src/identity/application_credential.rs create mode 100644 tests/api/tests/api_v3/identity/application_credential.rs create mode 100644 tests/api/tests/api_v3/identity/application_credential/create.rs create mode 100644 tests/api/tests/api_v3/identity/application_credential/delete.rs create mode 100644 tests/api/tests/api_v3/identity/application_credential/list.rs create mode 100644 tests/api/tests/api_v3/identity/application_credential/show.rs diff --git a/crates/api-types/src/error_conv.rs b/crates/api-types/src/error_conv.rs index d566bdbee..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()), } @@ -488,40 +502,6 @@ impl From for KeystoneApiError { } } -impl From for KeystoneApiError { - 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()) - } - 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()), - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/api-types/src/v3/application_credential/access_rule.rs b/crates/api-types/src/v3/application_credential/access_rule.rs index edc717a1a..8bbd389ab 100644 --- a/crates/api-types/src/v3/application_credential/access_rule.rs +++ b/crates/api-types/src/v3/application_credential/access_rule.rs @@ -12,11 +12,7 @@ // // SPDX-License-Identifier: Apache-2.0 -use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -#[cfg(feature = "validate")] -use validator::Validate; - /// Short access rule representation. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[cfg_attr( diff --git a/crates/api-types/src/v3/application_credential_conv.rs b/crates/api-types/src/v3/application_credential_conv.rs index 5325d1d8d..4399c4b47 100644 --- a/crates/api-types/src/v3/application_credential_conv.rs +++ b/crates/api-types/src/v3/application_credential_conv.rs @@ -15,7 +15,6 @@ use secrecy::ExposeSecret; use openstack_keystone_core_types::application_credential as provider_types; -use crate::v3 as api_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; 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/user/application_credential/create.rs b/crates/keystone/src/api/v3/user/application_credential/create.rs index d4df4acdb..a0fe34415 100644 --- a/crates/keystone/src/api/v3/user/application_credential/create.rs +++ b/crates/keystone/src/api/v3/user/application_credential/create.rs @@ -26,8 +26,6 @@ use super::types::application_credential::{ }; use crate::api::auth::Auth; use crate::api::error::KeystoneApiError; -use crate::application_credential::ApplicationCredentialApi; -use crate::identity::IdentityApi; use crate::keystone::ServiceState; use openstack_keystone_core::auth::ExecutionContext; use openstack_keystone_core_types::auth::ScopeInfo; @@ -87,7 +85,7 @@ pub(super) async fn create( state .policy_enforcer .enforce( - "identity/application_credential/create", + "identity/user/application_credential/create", &user_auth, json!({"user_id": user_id}), None, diff --git a/crates/keystone/src/api/v3/user/application_credential/delete.rs b/crates/keystone/src/api/v3/user/application_credential/delete.rs index 0abb76f01..4fecde55e 100644 --- a/crates/keystone/src/api/v3/user/application_credential/delete.rs +++ b/crates/keystone/src/api/v3/user/application_credential/delete.rs @@ -11,3 +11,285 @@ // 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 { + // 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 index 63c3ee7ee..7fdb13736 100644 --- a/crates/keystone/src/api/v3/user/application_credential/list.rs +++ b/crates/keystone/src/api/v3/user/application_credential/list.rs @@ -26,7 +26,6 @@ use super::types::application_credential::{ }; use crate::api::auth::Auth; use crate::api::error::KeystoneApiError; -use crate::identity::IdentityApi; use crate::keystone::ServiceState; use openstack_keystone_core::auth::ExecutionContext; @@ -61,20 +60,20 @@ pub(super) async fn list( state .policy_enforcer .enforce( - "identity/application_credential/list", + "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), - &payload.into(), - ) + .list_application_credentials(&ExecutionContext::from_auth(&state, &user_auth), &filter) .await .map_err(KeystoneApiError::from)?; diff --git a/crates/keystone/src/api/v3/user/application_credential/mod.rs b/crates/keystone/src/api/v3/user/application_credential/mod.rs index a134f6f41..33697a680 100644 --- a/crates/keystone/src/api/v3/user/application_credential/mod.rs +++ b/crates/keystone/src/api/v3/user/application_credential/mod.rs @@ -36,7 +36,7 @@ pub struct ApiDoc; pub(crate) fn openapi_router() -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(create::create)) - // .routes(routes!(delete::remove)) + .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 index aa363f449..5690e0bfd 100644 --- a/crates/keystone/src/api/v3/user/application_credential/show.rs +++ b/crates/keystone/src/api/v3/user/application_credential/show.rs @@ -23,7 +23,6 @@ use serde_json::json; use super::types::application_credential::ApplicationCredentialResponse; use crate::api::auth::Auth; use crate::api::error::KeystoneApiError; -use crate::identity::IdentityApi; use crate::keystone::ServiceState; use openstack_keystone_core::auth::ExecutionContext; @@ -68,7 +67,7 @@ pub(super) async fn show( state .policy_enforcer .enforce( - "identity/application_credential/show", + "identity/user/application_credential/show", &user_auth, json!({"user_id": current.user_id}), None, 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..ec356bac7 --- /dev/null +++ b/tests/api/tests/api_v3/identity/application_credential/create.rs @@ -0,0 +1,102 @@ +// 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 openstack_keystone_api_types::v3::user::*; +use openstack_sdk::{AsyncOpenStack, config::CloudConfig}; +use std::sync::Arc; +use test_api::guard::ResourceGuard; +use test_api::identity::application_credential::create_application_credential; +use test_api::identity::user::create_user; +use tracing_test::traced_test; +use uuid::Uuid; + +// 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_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..20a834cf4 --- /dev/null +++ b/tests/api/tests/api_v3/identity/application_credential/delete.rs @@ -0,0 +1,78 @@ +// 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 openstack_keystone_api_types::v3::user::*; +use openstack_sdk::{AsyncOpenStack, config::CloudConfig}; +use std::sync::Arc; +use test_api::guard::ResourceGuard; +use test_api::identity::application_credential::{ + create_application_credential, get_application_credential, +}; +use test_api::identity::user::create_user; +use tracing_test::traced_test; +use uuid::Uuid; + +// 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_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..62dffaeb6 --- /dev/null +++ b/tests/api/tests/api_v3/identity/application_credential/list.rs @@ -0,0 +1,90 @@ +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::auth::project::list_auth_projects; +use test_api::guard::ResourceGuard; +use test_api::identity::application_credential::{ + create_application_credential, list_application_credentials, +}; +use tracing_test::traced_test; +use uuid::Uuid; + +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-{}", Uuid::new_v4().simple())) + .roles(vec![]) + .build()?, + ) + .await?; + + let cred2 = create_application_credential( + &tc, + &user_id, + ApplicationCredentialCreateBuilder::default() + .name(format!("cred-2-{}", Uuid::new_v4().simple())) + .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..4e732f9b9 --- /dev/null +++ b/tests/api/tests/api_v3/identity/application_credential/show.rs @@ -0,0 +1,78 @@ +// 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 openstack_keystone_api_types::v3::user::*; +use openstack_sdk::{AsyncOpenStack, config::CloudConfig}; +use std::sync::Arc; +use test_api::guard::ResourceGuard; +use test_api::identity::application_credential::{ + create_application_credential, get_application_credential, +}; +use test_api::identity::user::create_user; +use tracing_test::traced_test; +use uuid::Uuid; + +// 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_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(()) +} From 2cb70cc739574d46925dc24c7477c4461039b6f1 Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Tue, 7 Jul 2026 23:07:14 +0000 Subject: [PATCH 4/4] fix: Fix the unit test Signed-off-by: Hamza Konac --- .../v3/user/application_credential/delete.rs | 7 +++++ .../identity/application_credential/create.rs | 26 ------------------- .../identity/application_credential/delete.rs | 26 ------------------- .../identity/application_credential/list.rs | 6 ++--- .../identity/application_credential/show.rs | 26 ------------------- 5 files changed, 9 insertions(+), 82 deletions(-) diff --git a/crates/keystone/src/api/v3/user/application_credential/delete.rs b/crates/keystone/src/api/v3/user/application_credential/delete.rs index 4fecde55e..64f140f34 100644 --- a/crates/keystone/src/api/v3/user/application_credential/delete.rs +++ b/crates/keystone/src/api/v3/user/application_credential/delete.rs @@ -40,6 +40,13 @@ pub(super) async fn delete( 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 diff --git a/tests/api/tests/api_v3/identity/application_credential/create.rs b/tests/api/tests/api_v3/identity/application_credential/create.rs index ec356bac7..3705b2126 100644 --- a/tests/api/tests/api_v3/identity/application_credential/create.rs +++ b/tests/api/tests/api_v3/identity/application_credential/create.rs @@ -14,35 +14,9 @@ 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 openstack_keystone_api_types::v3::user::*; -use openstack_sdk::{AsyncOpenStack, config::CloudConfig}; -use std::sync::Arc; use test_api::guard::ResourceGuard; use test_api::identity::application_credential::create_application_credential; -use test_api::identity::user::create_user; use tracing_test::traced_test; -use uuid::Uuid; - -// 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] diff --git a/tests/api/tests/api_v3/identity/application_credential/delete.rs b/tests/api/tests/api_v3/identity/application_credential/delete.rs index 20a834cf4..a4789821d 100644 --- a/tests/api/tests/api_v3/identity/application_credential/delete.rs +++ b/tests/api/tests/api_v3/identity/application_credential/delete.rs @@ -14,37 +14,11 @@ 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 openstack_keystone_api_types::v3::user::*; -use openstack_sdk::{AsyncOpenStack, config::CloudConfig}; -use std::sync::Arc; use test_api::guard::ResourceGuard; use test_api::identity::application_credential::{ create_application_credential, get_application_credential, }; -use test_api::identity::user::create_user; use tracing_test::traced_test; -use uuid::Uuid; - -// 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] diff --git a/tests/api/tests/api_v3/identity/application_credential/list.rs b/tests/api/tests/api_v3/identity/application_credential/list.rs index 62dffaeb6..a08e86d83 100644 --- a/tests/api/tests/api_v3/identity/application_credential/list.rs +++ b/tests/api/tests/api_v3/identity/application_credential/list.rs @@ -2,13 +2,11 @@ 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::auth::project::list_auth_projects; use test_api::guard::ResourceGuard; use test_api::identity::application_credential::{ create_application_credential, list_application_credentials, }; use tracing_test::traced_test; -use uuid::Uuid; pub async fn get_project_scoped_client() -> Result> { let mut tc = AsyncOpenStack::new(&CloudConfig::from_env()?).await?; @@ -47,7 +45,7 @@ async fn test_list() -> Result<()> { &tc, &user_id, ApplicationCredentialCreateBuilder::default() - .name(format!("cred-1-{}", Uuid::new_v4().simple())) + .name(format!("cred-1")) .roles(vec![]) .build()?, ) @@ -57,7 +55,7 @@ async fn test_list() -> Result<()> { &tc, &user_id, ApplicationCredentialCreateBuilder::default() - .name(format!("cred-2-{}", Uuid::new_v4().simple())) + .name(format!("cred-2")) .roles(vec![]) .build()?, ) diff --git a/tests/api/tests/api_v3/identity/application_credential/show.rs b/tests/api/tests/api_v3/identity/application_credential/show.rs index 4e732f9b9..8cbb7766b 100644 --- a/tests/api/tests/api_v3/identity/application_credential/show.rs +++ b/tests/api/tests/api_v3/identity/application_credential/show.rs @@ -14,37 +14,11 @@ 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 openstack_keystone_api_types::v3::user::*; -use openstack_sdk::{AsyncOpenStack, config::CloudConfig}; -use std::sync::Arc; use test_api::guard::ResourceGuard; use test_api::identity::application_credential::{ create_application_credential, get_application_credential, }; -use test_api::identity::user::create_user; use tracing_test::traced_test; -use uuid::Uuid; - -// 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]